Commit 7b8fd9ef authored by fjsanpedro's avatar fjsanpedro

Add structured data for groups

This commit adds structured markup data for groups. It also
adds structured data for the group's subgroups and projects.
parent 05ce22b8
...@@ -74,6 +74,9 @@ export default { ...@@ -74,6 +74,9 @@ export default {
visibilityTooltip() { visibilityTooltip() {
return GROUP_VISIBILITY_TYPE[this.group.visibility]; return GROUP_VISIBILITY_TYPE[this.group.visibility];
}, },
microdata() {
return this.group.microdata || {};
},
}, },
mounted() { mounted() {
if (this.group.name === 'Learn GitLab') { if (this.group.name === 'Learn GitLab') {
...@@ -99,7 +102,15 @@ export default { ...@@ -99,7 +102,15 @@ export default {
</script> </script>
<template> <template>
<li :id="groupDomId" :class="rowClass" class="group-row" @click.stop="onClickRowGroup"> <li
:id="groupDomId"
:class="rowClass"
class="group-row"
:itemprop="microdata.itemprop"
:itemtype="microdata.itemtype"
:itemscope="microdata.itemscope"
@click.stop="onClickRowGroup"
>
<div <div
:class="{ 'project-row-contents': !isGroup }" :class="{ 'project-row-contents': !isGroup }"
class="group-row-contents d-flex align-items-center py-2 pr-3" class="group-row-contents d-flex align-items-center py-2 pr-3"
...@@ -118,7 +129,13 @@ export default { ...@@ -118,7 +129,13 @@ export default {
class="avatar-container rect-avatar s32 d-none flex-grow-0 flex-shrink-0 " class="avatar-container rect-avatar s32 d-none flex-grow-0 flex-shrink-0 "
> >
<a :href="group.relativePath" class="no-expand"> <a :href="group.relativePath" class="no-expand">
<img v-if="hasAvatar" :src="group.avatarUrl" class="avatar s40" /> <img
v-if="hasAvatar"
:src="group.avatarUrl"
data-testid="group-avatar"
class="avatar s40"
:itemprop="microdata.imageItemprop"
/>
<identicon v-else :entity-id="group.id" :entity-name="group.name" size-class="s40" /> <identicon v-else :entity-id="group.id" :entity-name="group.name" size-class="s40" />
</a> </a>
</div> </div>
...@@ -127,9 +144,11 @@ export default { ...@@ -127,9 +144,11 @@ export default {
<div class="d-flex align-items-center flex-wrap title namespace-title gl-mr-3"> <div class="d-flex align-items-center flex-wrap title namespace-title gl-mr-3">
<a <a
v-gl-tooltip.bottom v-gl-tooltip.bottom
data-testid="group-name"
:href="group.relativePath" :href="group.relativePath"
:title="group.fullName" :title="group.fullName"
class="no-expand gl-mt-3 gl-mr-3 gl-text-gray-900!" class="no-expand gl-mt-3 gl-mr-3 gl-text-gray-900!"
:itemprop="microdata.nameItemprop"
>{{ >{{
// ending bracket must be by closing tag to prevent // ending bracket must be by closing tag to prevent
// link hover text-decoration from over-extending // link hover text-decoration from over-extending
...@@ -146,7 +165,12 @@ export default { ...@@ -146,7 +165,12 @@ export default {
</span> </span>
</div> </div>
<div v-if="group.description" class="description"> <div v-if="group.description" class="description">
<span v-html="group.description"> </span> <span
:itemprop="microdata.descriptionItemprop"
data-testid="group-description"
v-html="group.description"
>
</span>
</div> </div>
</div> </div>
<div v-if="isGroupPendingRemoval"> <div v-if="isGroupPendingRemoval">
......
...@@ -47,8 +47,9 @@ export default (containerId = 'js-groups-tree', endpoint, action = '') => { ...@@ -47,8 +47,9 @@ export default (containerId = 'js-groups-tree', endpoint, action = '') => {
data() { data() {
const { dataset } = dataEl || this.$options.el; const { dataset } = dataEl || this.$options.el;
const hideProjects = parseBoolean(dataset.hideProjects); const hideProjects = parseBoolean(dataset.hideProjects);
const showSchemaMarkup = parseBoolean(dataset.showSchemaMarkup);
const service = new GroupsService(endpoint || dataset.endpoint); const service = new GroupsService(endpoint || dataset.endpoint);
const store = new GroupsStore(hideProjects); const store = new GroupsStore({ hideProjects, showSchemaMarkup });
return { return {
action, action,
......
import { normalizeHeaders, parseIntPagination } from '../../lib/utils/common_utils'; import { normalizeHeaders, parseIntPagination } from '../../lib/utils/common_utils';
import { getGroupItemMicrodata } from './utils';
export default class GroupsStore { export default class GroupsStore {
constructor(hideProjects) { constructor({ hideProjects = false, showSchemaMarkup = false } = {}) {
this.state = {}; this.state = {};
this.state.groups = []; this.state.groups = [];
this.state.pageInfo = {}; this.state.pageInfo = {};
this.hideProjects = hideProjects; this.hideProjects = hideProjects;
this.showSchemaMarkup = showSchemaMarkup;
} }
setGroups(rawGroups) { setGroups(rawGroups) {
...@@ -94,6 +96,7 @@ export default class GroupsStore { ...@@ -94,6 +96,7 @@ export default class GroupsStore {
starCount: rawGroupItem.star_count, starCount: rawGroupItem.star_count,
updatedAt: rawGroupItem.updated_at, updatedAt: rawGroupItem.updated_at,
pendingRemoval: rawGroupItem.marked_for_deletion, pendingRemoval: rawGroupItem.marked_for_deletion,
microdata: this.showSchemaMarkup ? getGroupItemMicrodata(rawGroupItem) : {},
}; };
} }
......
export const getGroupItemMicrodata = ({ type }) => {
const defaultMicrodata = {
itemscope: true,
itemtype: 'https://schema.org/Thing',
itemprop: 'owns',
imageItemprop: 'image',
nameItemprop: 'name',
descriptionItemprop: 'description',
};
switch (type) {
case 'group':
return {
...defaultMicrodata,
itemtype: 'https://schema.org/Organization',
itemprop: 'subOrganization',
imageItemprop: 'logo',
};
case 'project':
return {
...defaultMicrodata,
itemtype: 'https://schema.org/SoftwareSourceCode',
};
default:
return defaultMicrodata;
}
};
...@@ -6,10 +6,10 @@ ...@@ -6,10 +6,10 @@
.row.mb-3 .row.mb-3
.home-panel-title-row.col-md-12.col-lg-6.d-flex .home-panel-title-row.col-md-12.col-lg-6.d-flex
.avatar-container.rect-avatar.s64.home-panel-avatar.gl-mr-3.float-none .avatar-container.rect-avatar.s64.home-panel-avatar.gl-mr-3.float-none
= group_icon(@group, class: 'avatar avatar-tile s64', width: 64, height: 64) = group_icon(@group, class: 'avatar avatar-tile s64', width: 64, height: 64, itemprop: 'logo')
.d-flex.flex-column.flex-wrap.align-items-baseline .d-flex.flex-column.flex-wrap.align-items-baseline
.d-inline-flex.align-items-baseline .d-inline-flex.align-items-baseline
%h1.home-panel-title.gl-mt-3.gl-mb-2 %h1.home-panel-title.gl-mt-3.gl-mb-2{ itemprop: 'name' }
= @group.name = @group.name
%span.visibility-icon.text-secondary.gl-ml-2.has-tooltip{ data: { container: 'body' }, title: visibility_icon_description(@group) } %span.visibility-icon.text-secondary.gl-ml-2.has-tooltip{ data: { container: 'body' }, title: visibility_icon_description(@group) }
= visibility_level_icon(@group.visibility_level, options: {class: 'icon'}) = visibility_level_icon(@group.visibility_level, options: {class: 'icon'})
...@@ -34,7 +34,7 @@ ...@@ -34,7 +34,7 @@
- if @group.description.present? - if @group.description.present?
.group-home-desc.mt-1 .group-home-desc.mt-1
.home-panel-description .home-panel-description
.home-panel-description-markdown.read-more-container .home-panel-description-markdown.read-more-container{ itemprop: 'description' }
= markdown_field(@group, :description) = markdown_field(@group, :description)
%button.btn.btn-blank.btn-link.js-read-more-trigger.d-lg-none{ type: "button" } %button.btn.btn-blank.btn-link.js-read-more-trigger.d-lg-none{ type: "button" }
= _("Read more") = _("Read more")
...@@ -3,6 +3,6 @@ ...@@ -3,6 +3,6 @@
= render "shared/groups/empty_state" = render "shared/groups/empty_state"
%section{ data: { hide_projects: 'false', group_id: group.id, path: group_path(group) } } %section{ data: { hide_projects: 'false', group_id: group.id, path: group_path(group) } }
.js-groups-list-holder .js-groups-list-holder{ data: { show_schema_markup: 'true'} }
.loading-container.text-center.prepend-top-20 .loading-container.text-center.prepend-top-20
.spinner.spinner-md .spinner.spinner-md
- breadcrumb_title _("Details") - breadcrumb_title _("Details")
- @content_class = "limit-container-width" unless fluid_layout - @content_class = "limit-container-width" unless fluid_layout
- page_itemtype 'https://schema.org/Organization'
- if show_thanks_for_purchase_banner? - if show_thanks_for_purchase_banner?
= render_if_exists 'shared/thanks_for_purchase_banner', plan_title: plan_title, quantity: params[:purchased_quantity].to_i = render_if_exists 'shared/thanks_for_purchase_banner', plan_title: plan_title, quantity: params[:purchased_quantity].to_i
......
---
title: Add SEO structured markup for groups
merge_request: 47374
author:
type: added
...@@ -193,4 +193,69 @@ RSpec.describe 'Group show page' do ...@@ -193,4 +193,69 @@ RSpec.describe 'Group show page' do
it_behaves_like 'page meta description', 'Lorem ipsum dolor sit amet' it_behaves_like 'page meta description', 'Lorem ipsum dolor sit amet'
end end
context 'structured schema markup' do
let_it_be(:group) { create(:group, :public, :with_avatar, description: 'foo') }
let_it_be(:subgroup) { create(:group, :public, :with_avatar, parent: group, description: 'bar') }
let_it_be_with_reload(:project) { create(:project, :public, :with_avatar, namespace: group, description: 'foo') }
let_it_be(:subproject) { create(:project, :public, :with_avatar, namespace: subgroup, description: 'bar') }
it 'shows Organization structured markup', :js do
visit path
wait_for_all_requests
aggregate_failures do
expect(page).to have_selector('.content[itemscope][itemtype="https://schema.org/Organization"]')
page.within('.group-home-panel') do
expect(page).to have_selector('img.avatar[itemprop="logo"]')
expect(page).to have_selector('[itemprop="name"]', text: group.name)
expect(page).to have_selector('[itemprop="description"]', text: group.description)
end
page.within('[itemprop="owns"][itemtype="https://schema.org/SoftwareSourceCode"]') do
expect(page).to have_selector('img.avatar[itemprop="image"]')
expect(page).to have_selector('[itemprop="name"]', text: project.name)
expect(page).to have_selector('[itemprop="description"]', text: project.description)
end
# Finding the subgroup row and expanding it
el = find('[itemprop="subOrganization"][itemtype="https://schema.org/Organization"]')
el.click
wait_for_all_requests
page.within(el) do
expect(page).to have_selector('img.avatar[itemprop="logo"]')
expect(page).to have_selector('[itemprop="name"]', text: subgroup.name)
expect(page).to have_selector('[itemprop="description"]', text: subgroup.description)
page.within('[itemprop="owns"][itemtype="https://schema.org/SoftwareSourceCode"]') do
expect(page).to have_selector('img.avatar[itemprop="image"]')
expect(page).to have_selector('[itemprop="name"]', text: subproject.name)
expect(page).to have_selector('[itemprop="description"]', text: subproject.description)
end
end
end
end
it 'does not include structured markup in shared projects tab', :js do
other_project = create(:project, :public)
other_project.project_group_links.create!(group: group)
visit group_shared_path(group)
wait_for_all_requests
expect(page).to have_selector('li.group-row')
expect(page).not_to have_selector('[itemprop="owns"][itemtype="https://schema.org/SoftwareSourceCode"]')
end
it 'does not include structured markup in archived projects tab', :js do
project.update!(archived: true)
visit group_archived_path(group)
wait_for_all_requests
expect(page).to have_selector('li.group-row')
expect(page).not_to have_selector('[itemprop="owns"][itemtype="https://schema.org/SoftwareSourceCode"]')
end
end
end end
...@@ -35,7 +35,7 @@ describe('AppComponent', () => { ...@@ -35,7 +35,7 @@ describe('AppComponent', () => {
let mock; let mock;
let getGroupsSpy; let getGroupsSpy;
const store = new GroupsStore(false); const store = new GroupsStore({ hideProjects: false });
const service = new GroupsService(mockEndpoint); const service = new GroupsService(mockEndpoint);
const createShallowComponent = (hideProjects = false) => { const createShallowComponent = (hideProjects = false) => {
......
...@@ -2,6 +2,7 @@ import Vue from 'vue'; ...@@ -2,6 +2,7 @@ import Vue from 'vue';
import mountComponent from 'helpers/vue_mount_component_helper'; import mountComponent from 'helpers/vue_mount_component_helper';
import groupItemComponent from '~/groups/components/group_item.vue'; import groupItemComponent from '~/groups/components/group_item.vue';
import groupFolderComponent from '~/groups/components/group_folder.vue'; import groupFolderComponent from '~/groups/components/group_folder.vue';
import { getGroupItemMicrodata } from '~/groups/store/utils';
import eventHub from '~/groups/event_hub'; import eventHub from '~/groups/event_hub';
import * as urlUtilities from '~/lib/utils/url_utility'; import * as urlUtilities from '~/lib/utils/url_utility';
import { mockParentGroupItem, mockChildren } from '../mock_data'; import { mockParentGroupItem, mockChildren } from '../mock_data';
...@@ -30,6 +31,11 @@ describe('GroupItemComponent', () => { ...@@ -30,6 +31,11 @@ describe('GroupItemComponent', () => {
vm.$destroy(); vm.$destroy();
}); });
const withMicrodata = group => ({
...group,
microdata: getGroupItemMicrodata(group),
});
describe('computed', () => { describe('computed', () => {
describe('groupDomId', () => { describe('groupDomId', () => {
it('should return ID string suffixed with group ID', () => { it('should return ID string suffixed with group ID', () => {
...@@ -212,4 +218,47 @@ describe('GroupItemComponent', () => { ...@@ -212,4 +218,47 @@ describe('GroupItemComponent', () => {
expect(vm.$el.querySelector('.group-list-tree')).toBeDefined(); expect(vm.$el.querySelector('.group-list-tree')).toBeDefined();
}); });
}); });
describe('schema.org props', () => {
describe('when showSchemaMarkup is disabled on the group', () => {
it.each(['itemprop', 'itemtype', 'itemscope'], 'it does not set %s', attr => {
expect(vm.$el.getAttribute(attr)).toBeNull();
});
it.each(
['.js-group-avatar', '.js-group-name', '.js-group-description'],
'it does not set `itemprop` on sub-nodes',
selector => {
expect(vm.$el.querySelector(selector).getAttribute('itemprop')).toBeNull();
},
);
});
describe('when group has microdata', () => {
beforeEach(() => {
const group = withMicrodata({
...mockParentGroupItem,
avatarUrl: 'http://foo.bar',
description: 'Foo Bar',
});
vm = createComponent(group);
});
it.each`
attr | value
${'itemscope'} | ${'itemscope'}
${'itemtype'} | ${'https://schema.org/Organization'}
${'itemprop'} | ${'subOrganization'}
`('it does set correct $attr', ({ attr, value } = {}) => {
expect(vm.$el.getAttribute(attr)).toBe(value);
});
it.each`
selector | propValue
${'[data-testid="group-avatar"]'} | ${'logo'}
${'[data-testid="group-name"]'} | ${'name'}
${'[data-testid="group-description"]'} | ${'description'}
`('it does set correct $selector', ({ selector, propValue } = {}) => {
expect(vm.$el.querySelector(selector).getAttribute('itemprop')).toBe(propValue);
});
});
});
}); });
import GroupsStore from '~/groups/store/groups_store'; import GroupsStore from '~/groups/store/groups_store';
import { getGroupItemMicrodata } from '~/groups/store/utils';
import { import {
mockGroups, mockGroups,
mockSearchedGroups, mockSearchedGroups,
...@@ -17,9 +18,9 @@ describe('ProjectsStore', () => { ...@@ -17,9 +18,9 @@ describe('ProjectsStore', () => {
expect(Object.keys(store.state).length).toBe(2); expect(Object.keys(store.state).length).toBe(2);
expect(Array.isArray(store.state.groups)).toBeTruthy(); expect(Array.isArray(store.state.groups)).toBeTruthy();
expect(Object.keys(store.state.pageInfo).length).toBe(0); expect(Object.keys(store.state.pageInfo).length).toBe(0);
expect(store.hideProjects).not.toBeDefined(); expect(store.hideProjects).toBeFalsy();
store = new GroupsStore(true); store = new GroupsStore({ hideProjects: true });
expect(store.hideProjects).toBeTruthy(); expect(store.hideProjects).toBeTruthy();
}); });
...@@ -86,22 +87,30 @@ describe('ProjectsStore', () => { ...@@ -86,22 +87,30 @@ describe('ProjectsStore', () => {
describe('formatGroupItem', () => { describe('formatGroupItem', () => {
it('should parse group item object and return updated object', () => { it('should parse group item object and return updated object', () => {
let store; const store = new GroupsStore();
let updatedGroupItem; const updatedGroupItem = store.formatGroupItem(mockRawChildren[0]);
store = new GroupsStore();
updatedGroupItem = store.formatGroupItem(mockRawChildren[0]);
expect(Object.keys(updatedGroupItem).indexOf('fullName')).toBeGreaterThan(-1); expect(Object.keys(updatedGroupItem).indexOf('fullName')).toBeGreaterThan(-1);
expect(updatedGroupItem.childrenCount).toBe(mockRawChildren[0].children_count); expect(updatedGroupItem.childrenCount).toBe(mockRawChildren[0].children_count);
expect(updatedGroupItem.isChildrenLoading).toBe(false); expect(updatedGroupItem.isChildrenLoading).toBe(false);
expect(updatedGroupItem.isBeingRemoved).toBe(false); expect(updatedGroupItem.isBeingRemoved).toBe(false);
expect(updatedGroupItem.microdata).toEqual({});
});
store = new GroupsStore(true); it('with hideProjects', () => {
updatedGroupItem = store.formatGroupItem(mockRawChildren[0]); const store = new GroupsStore({ hideProjects: true });
const updatedGroupItem = store.formatGroupItem(mockRawChildren[0]);
expect(Object.keys(updatedGroupItem).indexOf('fullName')).toBeGreaterThan(-1); expect(Object.keys(updatedGroupItem).indexOf('fullName')).toBeGreaterThan(-1);
expect(updatedGroupItem.childrenCount).toBe(mockRawChildren[0].subgroup_count); expect(updatedGroupItem.childrenCount).toBe(mockRawChildren[0].subgroup_count);
expect(updatedGroupItem.microdata).toEqual({});
});
it('with showSchemaMarkup', () => {
const store = new GroupsStore({ showSchemaMarkup: true });
const updatedGroupItem = store.formatGroupItem(mockRawChildren[0]);
expect(updatedGroupItem.microdata).toEqual(getGroupItemMicrodata(mockRawChildren[0]));
}); });
}); });
......
import { getGroupItemMicrodata } from '~/groups/store/utils';
describe('~/groups/store/utils', () => {
describe('getGroupItemMetadata', () => {
it('has default type', () => {
expect(getGroupItemMicrodata({ type: 'silly' })).toMatchInlineSnapshot(`
Object {
"descriptionItemprop": "description",
"imageItemprop": "image",
"itemprop": "owns",
"itemscope": true,
"itemtype": "https://schema.org/Thing",
"nameItemprop": "name",
}
`);
});
it('has group props', () => {
expect(getGroupItemMicrodata({ type: 'group' })).toMatchInlineSnapshot(`
Object {
"descriptionItemprop": "description",
"imageItemprop": "logo",
"itemprop": "subOrganization",
"itemscope": true,
"itemtype": "https://schema.org/Organization",
"nameItemprop": "name",
}
`);
});
it('has project props', () => {
expect(getGroupItemMicrodata({ type: 'project' })).toMatchInlineSnapshot(`
Object {
"descriptionItemprop": "description",
"imageItemprop": "image",
"itemprop": "owns",
"itemscope": true,
"itemtype": "https://schema.org/SoftwareSourceCode",
"nameItemprop": "name",
}
`);
});
});
});
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment