@nakedcreativity/cee-ms-node 2.0.0-beta.4 → 2.0.0-beta.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +106 -18
- package/lib/draft.js +8 -3
- package/lib/draft.js.map +1 -1
- package/lib/index.d.ts +23 -4
- package/lib/index.js +72 -15
- package/lib/index.js.map +1 -1
- package/lib/interfaces.d.ts +141 -14
- package/lib/interfaces.js +1 -0
- package/lib/interfaces.js.map +1 -1
- package/lib/links.d.ts +67 -2
- package/lib/links.js +201 -7
- package/lib/links.js.map +1 -1
- package/lib/social-meta.d.ts +15 -0
- package/lib/social-meta.js +73 -0
- package/lib/social-meta.js.map +1 -0
- package/lib/structured-data.d.ts +22 -7
- package/lib/structured-data.js +283 -53
- package/lib/structured-data.js.map +1 -1
- package/lib/tags/blog.d.ts +3 -2
- package/lib/tags/blog.js +14 -5
- package/lib/tags/blog.js.map +1 -1
- package/lib/tags/index.d.ts +1 -0
- package/lib/tags/index.js +1 -0
- package/lib/tags/index.js.map +1 -1
- package/lib/tags/social-meta.d.ts +6 -0
- package/lib/tags/social-meta.js +19 -0
- package/lib/tags/social-meta.js.map +1 -0
- package/lib/tags/structured-data.d.ts +4 -3
- package/lib/tags/structured-data.js +6 -5
- package/lib/tags/structured-data.js.map +1 -1
- package/package.json +8 -1
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.socialMeta = socialMeta;
|
|
4
|
+
exports.renderSocialMeta = renderSocialMeta;
|
|
5
|
+
const structured_data_1 = require("./structured-data");
|
|
6
|
+
/**
|
|
7
|
+
* The page's link-preview tags: Open Graph, read by Facebook, LinkedIn, WhatsApp, Slack and others, and X's
|
|
8
|
+
* card. Built from what the page already publishes for search (title, description, social image, dates), so
|
|
9
|
+
* a template needs no more than `{% socialmeta %}`. X reads the title, description and image from Open Graph.
|
|
10
|
+
*/
|
|
11
|
+
function socialMeta(site, page) {
|
|
12
|
+
const root = String(site.site?.url ?? '').replace(/\/+$/, '');
|
|
13
|
+
const path = page.fullpath && page.fullpath !== 'home' ? `/${page.fullpath}` : '/';
|
|
14
|
+
const absolute = (href) => /^https?:\/\//i.test(href) ? href : `${root}${href.startsWith('/') ? '' : '/'}${href}`;
|
|
15
|
+
const entity = (0, structured_data_1.entityOf)(page);
|
|
16
|
+
const image = (0, structured_data_1.imageUrl)(page.social);
|
|
17
|
+
const tags = [];
|
|
18
|
+
const og = (key, value) => {
|
|
19
|
+
const content = (0, structured_data_1.text)(value);
|
|
20
|
+
if (content)
|
|
21
|
+
tags.push({ attribute: 'property', key, content });
|
|
22
|
+
};
|
|
23
|
+
const x = (key, value) => {
|
|
24
|
+
const content = (0, structured_data_1.text)(value);
|
|
25
|
+
if (content)
|
|
26
|
+
tags.push({ attribute: 'name', key, content });
|
|
27
|
+
};
|
|
28
|
+
og('og:type', entity === 'ARTICLE' ? 'article' : entity === 'PERSON' ? 'profile' : 'website');
|
|
29
|
+
og('og:title', (0, structured_data_1.text)(page.title) ?? (0, structured_data_1.text)(page.name) ?? site.site?.name);
|
|
30
|
+
og('og:description', (0, structured_data_1.plain)(page.description));
|
|
31
|
+
if (root)
|
|
32
|
+
og('og:url', root + path);
|
|
33
|
+
og('og:site_name', site.site?.name);
|
|
34
|
+
if (image) {
|
|
35
|
+
og('og:image', absolute(image));
|
|
36
|
+
if (page.social?.width && page.social?.height) {
|
|
37
|
+
og('og:image:width', page.social.width);
|
|
38
|
+
og('og:image:height', page.social.height);
|
|
39
|
+
}
|
|
40
|
+
og('og:image:alt', page.social?.alt);
|
|
41
|
+
}
|
|
42
|
+
if (entity === 'ARTICLE') {
|
|
43
|
+
const published = (0, structured_data_1.isoDate)(page.date) ?? (0, structured_data_1.isoDate)(page.publishedAt);
|
|
44
|
+
og('article:published_time', published);
|
|
45
|
+
og('article:modified_time', (0, structured_data_1.isoDate)(page.updatedAt) ?? published);
|
|
46
|
+
for (const author of page.authors ?? []) {
|
|
47
|
+
const profile = (0, structured_data_1.text)(author.profile);
|
|
48
|
+
if (profile)
|
|
49
|
+
og('article:author', absolute(profile));
|
|
50
|
+
}
|
|
51
|
+
for (const tag of page.tags ?? [])
|
|
52
|
+
og('article:tag', tag);
|
|
53
|
+
}
|
|
54
|
+
x('twitter:card', image ? 'summary_large_image' : 'summary');
|
|
55
|
+
x('twitter:site', xHandle(site.site?.organisation?.sameAs));
|
|
56
|
+
return tags;
|
|
57
|
+
}
|
|
58
|
+
/** The organisation's X account, `@name`, from its profile links. */
|
|
59
|
+
function xHandle(sameAs) {
|
|
60
|
+
for (const url of sameAs ?? []) {
|
|
61
|
+
const match = /^https?:\/\/(?:www\.)?(?:x|twitter)\.com\/@?([A-Za-z0-9_]{1,15})\/?$/i.exec(url.trim());
|
|
62
|
+
if (match)
|
|
63
|
+
return `@${match[1]}`;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
/** The tags as HTML, their content escaped for an attribute. */
|
|
67
|
+
function renderSocialMeta(tags) {
|
|
68
|
+
return tags.map((t) => `<meta ${t.attribute}="${t.key}" content="${escapeAttribute(t.content)}">`).join('');
|
|
69
|
+
}
|
|
70
|
+
function escapeAttribute(value) {
|
|
71
|
+
return value.replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>');
|
|
72
|
+
}
|
|
73
|
+
//# sourceMappingURL=social-meta.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"social-meta.js","sourceRoot":"","sources":["../src/social-meta.ts"],"names":[],"mappings":"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"}
|
package/lib/structured-data.d.ts
CHANGED
|
@@ -1,20 +1,35 @@
|
|
|
1
|
-
import type { CeemsPage, CeemsSite, StructuredDataDefaults, StructuredDataType } from './interfaces';
|
|
2
|
-
/**
|
|
1
|
+
import type { CeemsImage, CeemsPage, CeemsSite, PageEntity, StructuredDataDefaults, StructuredDataType } from './interfaces';
|
|
2
|
+
/** The types a page or the site's defaults can switch on, in the order the tag outputs them. */
|
|
3
3
|
export declare const STRUCTURED_DATA_TYPES: readonly StructuredDataType[];
|
|
4
|
-
/**
|
|
4
|
+
/** What `{% structureddata %}` can be limited to: the types, and the page's entity (its product, event…). */
|
|
5
|
+
export type StructuredDataPart = StructuredDataType | 'ENTITY';
|
|
6
|
+
export declare const STRUCTURED_DATA_PARTS: readonly StructuredDataPart[];
|
|
7
|
+
/**
|
|
8
|
+
* Used when the site has published no defaults. The site and organisation are described once, on the home
|
|
9
|
+
* page; other pages refer to them by `@id` with a short copy.
|
|
10
|
+
*/
|
|
5
11
|
export declare const DEFAULT_STRUCTURED_DATA: StructuredDataDefaults;
|
|
6
12
|
type Node = Record<string, unknown>;
|
|
13
|
+
/** The page's entity, defaulting as Cee.ms publishes it, for pages published before entities. */
|
|
14
|
+
export declare function entityOf(page: CeemsPage): PageEntity;
|
|
7
15
|
/** The types a page outputs: its own choice, else the site's defaults for its kind. */
|
|
8
16
|
export declare function enabledTypes(site: CeemsSite, page: CeemsPage): StructuredDataType[];
|
|
9
17
|
/**
|
|
10
|
-
* The page's schema.org graph: one node for each enabled type that has the data it needs
|
|
11
|
-
* required property (an event with no start date, a product with
|
|
12
|
-
* invalid. `only` limits the graph to those
|
|
18
|
+
* The page's schema.org graph: one node for each enabled type that has the data it needs, and one for its
|
|
19
|
+
* entity. A node missing a required property (an event with no start date, a product with neither price nor
|
|
20
|
+
* reviews) is left out rather than output invalid. `only` limits the graph to those parts.
|
|
13
21
|
*/
|
|
14
|
-
export declare function structuredDataGraph(site: CeemsSite, page: CeemsPage, only?:
|
|
22
|
+
export declare function structuredDataGraph(site: CeemsSite, page: CeemsPage, only?: StructuredDataPart[]): {
|
|
15
23
|
'@context': string;
|
|
16
24
|
'@graph': Node[];
|
|
17
25
|
};
|
|
18
26
|
/** JSON for a `<script>`: `<`, `>` and `&` are escaped so content can't end the script. */
|
|
19
27
|
export declare function serialiseJsonLd(value: unknown): string;
|
|
28
|
+
export declare function imageUrl(image?: CeemsImage | null): string | undefined;
|
|
29
|
+
/** A date published as an ISO string (or a `Date`, for custom pages) as an ISO string. */
|
|
30
|
+
export declare function isoDate(value: unknown): string | undefined;
|
|
31
|
+
/** A non-empty trimmed string, else `undefined`. Stored links (`LinkDrop`) read as their address. */
|
|
32
|
+
export declare function text(value: unknown): string | undefined;
|
|
33
|
+
/** Rich text as plain text, for properties that take no markup. */
|
|
34
|
+
export declare function plain(value: unknown): unknown;
|
|
20
35
|
export {};
|
package/lib/structured-data.js
CHANGED
|
@@ -1,10 +1,15 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.DEFAULT_STRUCTURED_DATA = exports.STRUCTURED_DATA_TYPES = void 0;
|
|
3
|
+
exports.DEFAULT_STRUCTURED_DATA = exports.STRUCTURED_DATA_PARTS = exports.STRUCTURED_DATA_TYPES = void 0;
|
|
4
|
+
exports.entityOf = entityOf;
|
|
4
5
|
exports.enabledTypes = enabledTypes;
|
|
5
6
|
exports.structuredDataGraph = structuredDataGraph;
|
|
6
7
|
exports.serialiseJsonLd = serialiseJsonLd;
|
|
7
|
-
|
|
8
|
+
exports.imageUrl = imageUrl;
|
|
9
|
+
exports.isoDate = isoDate;
|
|
10
|
+
exports.text = text;
|
|
11
|
+
exports.plain = plain;
|
|
12
|
+
/** The types a page or the site's defaults can switch on, in the order the tag outputs them. */
|
|
8
13
|
exports.STRUCTURED_DATA_TYPES = [
|
|
9
14
|
'WEBSITE',
|
|
10
15
|
'ORGANISATION',
|
|
@@ -12,59 +17,94 @@ exports.STRUCTURED_DATA_TYPES = [
|
|
|
12
17
|
'BREADCRUMB',
|
|
13
18
|
'ARTICLE',
|
|
14
19
|
'FAQ',
|
|
15
|
-
'EVENT',
|
|
16
|
-
'PRODUCT',
|
|
17
|
-
'PERSON',
|
|
18
20
|
];
|
|
19
|
-
|
|
20
|
-
/**
|
|
21
|
+
exports.STRUCTURED_DATA_PARTS = [...exports.STRUCTURED_DATA_TYPES, 'ENTITY'];
|
|
22
|
+
/**
|
|
23
|
+
* Used when the site has published no defaults. The site and organisation are described once, on the home
|
|
24
|
+
* page; other pages refer to them by `@id` with a short copy.
|
|
25
|
+
*/
|
|
21
26
|
exports.DEFAULT_STRUCTURED_DATA = {
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
27
|
+
home: ['WEBSITE', 'WEBPAGE', 'ORGANISATION', 'FAQ'],
|
|
28
|
+
page: ['WEBPAGE', 'BREADCRUMB', 'FAQ'],
|
|
29
|
+
blog: ['WEBPAGE', 'BREADCRUMB', 'FAQ'],
|
|
30
|
+
article: ['WEBPAGE', 'BREADCRUMB', 'ARTICLE', 'FAQ'],
|
|
25
31
|
};
|
|
32
|
+
/** Entities whose node takes reviews. Google ignores reviews a business publishes about itself. */
|
|
33
|
+
const REVIEWED = ['PRODUCT', 'EVENT'];
|
|
34
|
+
/** The page's entity, defaulting as Cee.ms publishes it, for pages published before entities. */
|
|
35
|
+
function entityOf(page) {
|
|
36
|
+
if (page.entity)
|
|
37
|
+
return page.entity;
|
|
38
|
+
if (page.type === 'article')
|
|
39
|
+
return 'ARTICLE';
|
|
40
|
+
return page.fullpath ? 'PAGE' : 'HOME';
|
|
41
|
+
}
|
|
42
|
+
/** Which of the site's defaults a page uses: articles and home by their entity, blogs by their type. */
|
|
43
|
+
function kindOf(page) {
|
|
44
|
+
const entity = entityOf(page);
|
|
45
|
+
if (entity === 'ARTICLE')
|
|
46
|
+
return 'article';
|
|
47
|
+
if (page.type === 'blog')
|
|
48
|
+
return 'blog';
|
|
49
|
+
return entity === 'HOME' ? 'home' : 'page';
|
|
50
|
+
}
|
|
26
51
|
/** The types a page outputs: its own choice, else the site's defaults for its kind. */
|
|
27
52
|
function enabledTypes(site, page) {
|
|
28
53
|
const own = page.structuredData?.types;
|
|
29
54
|
if (Array.isArray(own))
|
|
30
55
|
return own;
|
|
31
|
-
const
|
|
32
|
-
|
|
33
|
-
return defaults[kind] ?? exports.DEFAULT_STRUCTURED_DATA[kind];
|
|
56
|
+
const kind = kindOf(page);
|
|
57
|
+
return site.site?.structuredData?.defaults?.[kind] ?? exports.DEFAULT_STRUCTURED_DATA[kind];
|
|
34
58
|
}
|
|
35
59
|
/**
|
|
36
|
-
* The page's schema.org graph: one node for each enabled type that has the data it needs
|
|
37
|
-
* required property (an event with no start date, a product with
|
|
38
|
-
* invalid. `only` limits the graph to those
|
|
60
|
+
* The page's schema.org graph: one node for each enabled type that has the data it needs, and one for its
|
|
61
|
+
* entity. A node missing a required property (an event with no start date, a product with neither price nor
|
|
62
|
+
* reviews) is left out rather than output invalid. `only` limits the graph to those parts.
|
|
39
63
|
*/
|
|
40
64
|
function structuredDataGraph(site, page, only) {
|
|
41
|
-
const
|
|
65
|
+
const b = new Builder(site, page, enabledTypes(site, page));
|
|
66
|
+
const entity = ENTITIES[entityOf(page)]?.(b);
|
|
67
|
+
if (entity)
|
|
68
|
+
b.entityId = entity['@id'];
|
|
42
69
|
const graph = [];
|
|
43
70
|
for (const type of exports.STRUCTURED_DATA_TYPES) {
|
|
44
|
-
if (!enabled.has(type) || (only && !only.includes(type)))
|
|
71
|
+
if (!b.enabled.has(type) || (only && !only.includes(type)))
|
|
45
72
|
continue;
|
|
46
|
-
const node = BUILDERS[type](
|
|
73
|
+
const node = BUILDERS[type](b);
|
|
47
74
|
if (node)
|
|
48
75
|
graph.push(node);
|
|
49
76
|
}
|
|
77
|
+
if (!only || only.includes('ENTITY')) {
|
|
78
|
+
if (entity)
|
|
79
|
+
graph.push(entity);
|
|
80
|
+
// people the page's modules feature, each once
|
|
81
|
+
const ids = new Set(graph.map((n) => n['@id']));
|
|
82
|
+
for (const person of featuredPeople(b)) {
|
|
83
|
+
if (ids.has(person['@id']))
|
|
84
|
+
continue;
|
|
85
|
+
ids.add(person['@id']);
|
|
86
|
+
graph.push(person);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
50
89
|
return { '@context': 'https://schema.org', '@graph': graph };
|
|
51
90
|
}
|
|
52
91
|
/** JSON for a `<script>`: `<`, `>` and `&` are escaped so content can't end the script. */
|
|
53
92
|
function serialiseJsonLd(value) {
|
|
54
|
-
return JSON.stringify(value)
|
|
55
|
-
.replace(/</g, '\\u003c')
|
|
56
|
-
.replace(/>/g, '\\u003e')
|
|
57
|
-
.replace(/&/g, '\\u0026');
|
|
93
|
+
return JSON.stringify(value).replace(/</g, '\\u003c').replace(/>/g, '\\u003e').replace(/&/g, '\\u0026');
|
|
58
94
|
}
|
|
59
95
|
/** The URLs and shared properties every builder needs. */
|
|
60
96
|
class Builder {
|
|
61
97
|
site;
|
|
62
98
|
page;
|
|
63
99
|
root;
|
|
64
|
-
|
|
100
|
+
enabled;
|
|
101
|
+
/** The `@id` of the page's entity node, when it has one. */
|
|
102
|
+
entityId;
|
|
103
|
+
constructor(site, page, enabled) {
|
|
65
104
|
this.site = site;
|
|
66
105
|
this.page = page;
|
|
67
106
|
this.root = String(site.site?.url ?? '').replace(/\/+$/, '');
|
|
107
|
+
this.enabled = new Set(enabled);
|
|
68
108
|
}
|
|
69
109
|
/** The absolute URL of a site path, `''` being the home page. */
|
|
70
110
|
url(path = '') {
|
|
@@ -76,12 +116,90 @@ class Builder {
|
|
|
76
116
|
id(path, fragment) {
|
|
77
117
|
return `${this.url(path)}#${fragment}`;
|
|
78
118
|
}
|
|
79
|
-
get hasOrganisation() {
|
|
80
|
-
return !!this.site.site?.organisation?.name;
|
|
81
|
-
}
|
|
82
119
|
get pageName() {
|
|
83
120
|
return text(this.page.title) ?? text(this.page.name);
|
|
84
121
|
}
|
|
122
|
+
/**
|
|
123
|
+
* The organisation, for a publisher, organiser or provider: its `@id` when this page carries the full
|
|
124
|
+
* node, else a short copy, so the property still has a name where the full node isn't output.
|
|
125
|
+
*/
|
|
126
|
+
get organisation() {
|
|
127
|
+
const org = this.site.site?.organisation;
|
|
128
|
+
if (!org?.name)
|
|
129
|
+
return;
|
|
130
|
+
const ref = { '@id': this.id('', 'organization') };
|
|
131
|
+
if (this.enabled.has('ORGANISATION'))
|
|
132
|
+
return ref;
|
|
133
|
+
return compact({
|
|
134
|
+
'@type': org.type || 'Organization',
|
|
135
|
+
...ref,
|
|
136
|
+
name: org.name,
|
|
137
|
+
url: text(org.url) ?? (this.root ? this.url() : undefined),
|
|
138
|
+
logo: imageUrl(org.logo),
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
/** An address on the site as an absolute URL; one that is already absolute is left alone. */
|
|
142
|
+
absolute(href) {
|
|
143
|
+
return /^https?:\/\//i.test(href) ? href : `${this.root}${href.startsWith('/') ? '' : '/'}${href}`;
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* A person from People. Every page uses the same `@id` for them: their profile page's when it is published,
|
|
147
|
+
* else one of the site's. The short copy names them; `full` describes them, as their profile page and the
|
|
148
|
+
* pages featuring them do.
|
|
149
|
+
*/
|
|
150
|
+
person(person, full = false) {
|
|
151
|
+
const name = text(person.name);
|
|
152
|
+
if (!name)
|
|
153
|
+
return;
|
|
154
|
+
const profile = text(person.profile);
|
|
155
|
+
const sameAs = list(sameAsOf(person));
|
|
156
|
+
const short = compact({
|
|
157
|
+
'@type': 'Person',
|
|
158
|
+
'@id': profile ? `${this.absolute(profile)}#person` : this.id('', `person-${person._id}`),
|
|
159
|
+
name,
|
|
160
|
+
jobTitle: text(person.jobTitle),
|
|
161
|
+
url: profile ? this.absolute(profile) : sameAs?.[0],
|
|
162
|
+
});
|
|
163
|
+
if (!full)
|
|
164
|
+
return short;
|
|
165
|
+
return compact({
|
|
166
|
+
...short,
|
|
167
|
+
image: imageUrl(person.photo),
|
|
168
|
+
description: text(plain(person.bio)),
|
|
169
|
+
sameAs,
|
|
170
|
+
worksFor: this.organisation,
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
/** An article's authors (`page.authors`), else the organisation. One author is a node; several, a list. */
|
|
174
|
+
get authors() {
|
|
175
|
+
const nodes = (this.page.authors ?? []).flatMap((author) => {
|
|
176
|
+
if (author.kind === 'person')
|
|
177
|
+
return list([this.person(author)]) ?? [];
|
|
178
|
+
if (author.kind === 'organisation')
|
|
179
|
+
return list([this.organisation]) ?? [];
|
|
180
|
+
const name = text(author.name);
|
|
181
|
+
return name
|
|
182
|
+
? [compact({ '@type': 'Person', name, jobTitle: text(author.jobTitle), url: text(author.url) })]
|
|
183
|
+
: [];
|
|
184
|
+
});
|
|
185
|
+
if (!nodes.length)
|
|
186
|
+
return this.organisation;
|
|
187
|
+
return nodes.length === 1 ? nodes[0] : nodes;
|
|
188
|
+
}
|
|
189
|
+
/** The person from People whose profile page this is, once loaded (see `wrapLinks`). */
|
|
190
|
+
get profilePerson() {
|
|
191
|
+
const person = this.page.person;
|
|
192
|
+
return entityOf(this.page) === 'PERSON' && person && typeof person === 'object' && text(person.name)
|
|
193
|
+
? person
|
|
194
|
+
: undefined;
|
|
195
|
+
}
|
|
196
|
+
/** The website, by `@id` when this page carries it, else a short copy. */
|
|
197
|
+
get website() {
|
|
198
|
+
const ref = { '@id': this.id('', 'website') };
|
|
199
|
+
if (this.enabled.has('WEBSITE'))
|
|
200
|
+
return ref;
|
|
201
|
+
return compact({ '@type': 'WebSite', ...ref, url: this.url(), name: text(this.site.site?.name) });
|
|
202
|
+
}
|
|
85
203
|
}
|
|
86
204
|
const BUILDERS = {
|
|
87
205
|
WEBSITE(b) {
|
|
@@ -92,7 +210,7 @@ const BUILDERS = {
|
|
|
92
210
|
'@id': b.id('', 'website'),
|
|
93
211
|
url: b.url(),
|
|
94
212
|
name: text(b.site.site?.name),
|
|
95
|
-
publisher: b.
|
|
213
|
+
publisher: b.organisation,
|
|
96
214
|
});
|
|
97
215
|
},
|
|
98
216
|
ORGANISATION(b) {
|
|
@@ -126,16 +244,24 @@ const BUILDERS = {
|
|
|
126
244
|
WEBPAGE(b) {
|
|
127
245
|
if (!b.root)
|
|
128
246
|
return;
|
|
247
|
+
const profile = !!b.profilePerson;
|
|
248
|
+
const reviewer = b.page.byline?.reviewedBy;
|
|
129
249
|
return compact({
|
|
130
|
-
|
|
250
|
+
// a page about one person from People is their profile page
|
|
251
|
+
'@type': profile ? 'ProfilePage' : 'WebPage',
|
|
131
252
|
'@id': b.id(b.page.fullpath, 'webpage'),
|
|
132
253
|
url: b.pageUrl,
|
|
133
254
|
name: b.pageName,
|
|
134
255
|
description: text(b.page.description),
|
|
135
256
|
primaryImageOfPage: imageUrl(b.page.social),
|
|
136
|
-
isPartOf:
|
|
137
|
-
breadcrumb: crumbs(b).length > 1
|
|
257
|
+
isPartOf: b.website,
|
|
258
|
+
breadcrumb: b.enabled.has('BREADCRUMB') && crumbs(b).length > 1
|
|
259
|
+
? { '@id': b.id(b.page.fullpath, 'breadcrumb') }
|
|
260
|
+
: undefined,
|
|
261
|
+
mainEntity: b.entityId ? { '@id': b.entityId } : undefined,
|
|
262
|
+
dateCreated: profile ? isoDate(b.page.createdAt) : undefined,
|
|
138
263
|
dateModified: isoDate(b.page.updatedAt),
|
|
264
|
+
reviewedBy: reviewer && typeof reviewer === 'object' ? b.person(reviewer) : undefined,
|
|
139
265
|
});
|
|
140
266
|
},
|
|
141
267
|
BREADCRUMB(b) {
|
|
@@ -155,7 +281,7 @@ const BUILDERS = {
|
|
|
155
281
|
};
|
|
156
282
|
},
|
|
157
283
|
ARTICLE(b) {
|
|
158
|
-
if (b.page
|
|
284
|
+
if (entityOf(b.page) !== 'ARTICLE' || !b.pageName)
|
|
159
285
|
return;
|
|
160
286
|
const published = isoDate(b.page.date) ?? isoDate(b.page.publishedAt);
|
|
161
287
|
return compact({
|
|
@@ -167,8 +293,8 @@ const BUILDERS = {
|
|
|
167
293
|
datePublished: published,
|
|
168
294
|
dateModified: isoDate(b.page.updatedAt) ?? published,
|
|
169
295
|
mainEntityOfPage: b.root ? { '@id': b.id(b.page.fullpath, 'webpage') } : undefined,
|
|
170
|
-
author: b.
|
|
171
|
-
publisher: b.
|
|
296
|
+
author: b.authors,
|
|
297
|
+
publisher: b.organisation,
|
|
172
298
|
keywords: list(b.page.tags)?.join(', '),
|
|
173
299
|
});
|
|
174
300
|
},
|
|
@@ -196,18 +322,21 @@ const BUILDERS = {
|
|
|
196
322
|
return;
|
|
197
323
|
return { '@type': 'FAQPage', '@id': b.id(b.page.fullpath, 'faq'), mainEntity: questions };
|
|
198
324
|
},
|
|
325
|
+
};
|
|
326
|
+
/** The node for what the page is about. Home and plain pages have none of their own. */
|
|
327
|
+
const ENTITIES = {
|
|
199
328
|
EVENT(b) {
|
|
200
329
|
const event = b.page.structuredData?.event;
|
|
201
330
|
const name = text(event?.name) ?? b.pageName;
|
|
202
331
|
const startDate = isoDate(event?.startDate);
|
|
203
332
|
if (!event || !name || !startDate)
|
|
204
333
|
return;
|
|
205
|
-
const online = event.attendance === 'ONLINE';
|
|
206
334
|
const venue = compact({
|
|
207
335
|
'@type': 'Place',
|
|
208
336
|
name: text(event.location?.name),
|
|
209
337
|
address: postalAddress(event.location?.address),
|
|
210
338
|
});
|
|
339
|
+
const place = Object.keys(venue).length > 1 ? venue : undefined;
|
|
211
340
|
const virtual = event.location?.url ? { '@type': 'VirtualLocation', url: event.location.url } : undefined;
|
|
212
341
|
return compact({
|
|
213
342
|
'@type': 'Event',
|
|
@@ -220,35 +349,41 @@ const BUILDERS = {
|
|
|
220
349
|
eventAttendanceMode: event.attendance
|
|
221
350
|
? `https://schema.org/${pascal(event.attendance)}EventAttendanceMode`
|
|
222
351
|
: undefined,
|
|
223
|
-
location:
|
|
224
|
-
? virtual
|
|
225
|
-
: event.attendance === 'MIXED'
|
|
226
|
-
? list([Object.keys(venue).length > 1 ? venue : undefined, virtual])
|
|
227
|
-
: Object.keys(venue).length > 1
|
|
228
|
-
? venue
|
|
229
|
-
: undefined,
|
|
352
|
+
location: event.attendance === 'ONLINE' ? virtual : event.attendance === 'MIXED' ? list([place, virtual]) : place,
|
|
230
353
|
image: imageUrl(event.image) ?? imageUrl(b.page.social),
|
|
231
|
-
offers:
|
|
232
|
-
organizer: b.
|
|
354
|
+
offers: offer(event.offers, b.pageUrl),
|
|
355
|
+
organizer: b.organisation,
|
|
356
|
+
...reviews(b),
|
|
233
357
|
});
|
|
234
358
|
},
|
|
235
359
|
PRODUCT(b) {
|
|
236
360
|
const product = b.page.structuredData?.product;
|
|
237
361
|
const name = text(product?.name) ?? b.pageName;
|
|
238
|
-
if (!
|
|
362
|
+
if (!name)
|
|
363
|
+
return;
|
|
364
|
+
const offers = product && offer(product, b.pageUrl);
|
|
365
|
+
const rated = reviews(b);
|
|
366
|
+
// Google needs an offer, a review or a rating
|
|
367
|
+
if (!offers && !rated.review)
|
|
239
368
|
return;
|
|
240
369
|
return compact({
|
|
241
370
|
'@type': 'Product',
|
|
242
371
|
'@id': b.id(b.page.fullpath, 'product'),
|
|
243
372
|
name,
|
|
244
|
-
description: text(product
|
|
245
|
-
image: imageUrl(product
|
|
246
|
-
sku: text(product
|
|
247
|
-
brand: product
|
|
248
|
-
offers
|
|
373
|
+
description: text(product?.description) ?? text(b.page.description),
|
|
374
|
+
image: imageUrl(product?.image) ?? imageUrl(b.page.social),
|
|
375
|
+
sku: text(product?.sku),
|
|
376
|
+
brand: product?.brand ? { '@type': 'Brand', name: product.brand } : undefined,
|
|
377
|
+
offers,
|
|
378
|
+
...rated,
|
|
249
379
|
});
|
|
250
380
|
},
|
|
251
381
|
PERSON(b) {
|
|
382
|
+
// their full description, on their profile page
|
|
383
|
+
const chosen = b.profilePerson;
|
|
384
|
+
if (chosen)
|
|
385
|
+
return compact({ ...b.person(chosen, true), '@id': b.id(b.page.fullpath, 'person'), url: b.pageUrl });
|
|
386
|
+
// the details typed on the page, before People
|
|
252
387
|
const person = b.page.structuredData?.person;
|
|
253
388
|
if (!person?.name)
|
|
254
389
|
return;
|
|
@@ -258,12 +393,107 @@ const BUILDERS = {
|
|
|
258
393
|
name: person.name,
|
|
259
394
|
jobTitle: text(person.jobTitle),
|
|
260
395
|
url: text(person.url) ?? (b.root ? b.pageUrl : undefined),
|
|
261
|
-
image: imageUrl(person.image),
|
|
396
|
+
image: imageUrl(person.image) ?? imageUrl(b.page.social),
|
|
262
397
|
sameAs: list(person.sameAs),
|
|
263
|
-
worksFor: b.
|
|
398
|
+
worksFor: b.organisation,
|
|
399
|
+
});
|
|
400
|
+
},
|
|
401
|
+
SERVICE(b) {
|
|
402
|
+
const service = b.page.structuredData?.service;
|
|
403
|
+
const name = text(service?.name) ?? b.pageName;
|
|
404
|
+
if (!name)
|
|
405
|
+
return;
|
|
406
|
+
return compact({
|
|
407
|
+
'@type': 'Service',
|
|
408
|
+
'@id': b.id(b.page.fullpath, 'service'),
|
|
409
|
+
name,
|
|
410
|
+
description: text(service?.description) ?? text(b.page.description),
|
|
411
|
+
serviceType: text(service?.serviceType),
|
|
412
|
+
areaServed: text(service?.areaServed),
|
|
413
|
+
image: imageUrl(service?.image) ?? imageUrl(b.page.social),
|
|
414
|
+
provider: b.organisation,
|
|
415
|
+
offers: offer(service?.offers, b.pageUrl),
|
|
264
416
|
});
|
|
265
417
|
},
|
|
266
418
|
};
|
|
419
|
+
/**
|
|
420
|
+
* `review` and `aggregateRating` from the page's modules whose type maps reviews, for a product or event.
|
|
421
|
+
* The rating is worked out from the reviews, so it always matches what the page shows. Ratings on other
|
|
422
|
+
* scales are converted to the first module's.
|
|
423
|
+
*/
|
|
424
|
+
function reviews(b) {
|
|
425
|
+
if (!REVIEWED.includes(entityOf(b.page)))
|
|
426
|
+
return {};
|
|
427
|
+
const mappings = b.site.site?.structuredData?.reviewModules ?? {};
|
|
428
|
+
const review = [];
|
|
429
|
+
let scale;
|
|
430
|
+
let total = 0;
|
|
431
|
+
for (const module of pageModules(b.page)) {
|
|
432
|
+
const map = mappings[module.type];
|
|
433
|
+
const items = map && module.content?.[map.itemsField];
|
|
434
|
+
if (!Array.isArray(items))
|
|
435
|
+
continue;
|
|
436
|
+
const best = map.bestRating ?? 5;
|
|
437
|
+
const worst = map.worstRating ?? 1;
|
|
438
|
+
for (const item of items) {
|
|
439
|
+
const author = text(plain(item?.[map.authorField]));
|
|
440
|
+
const rating = Number(text(item?.[map.ratingField]));
|
|
441
|
+
if (!author || !isFinite(rating) || rating < worst || rating > best || best <= worst)
|
|
442
|
+
continue;
|
|
443
|
+
scale ??= { best, worst };
|
|
444
|
+
total += scale.worst + ((rating - worst) / (best - worst)) * (scale.best - scale.worst);
|
|
445
|
+
review.push(compact({
|
|
446
|
+
'@type': 'Review',
|
|
447
|
+
author: { '@type': 'Person', name: author },
|
|
448
|
+
reviewRating: { '@type': 'Rating', ratingValue: rating, bestRating: best, worstRating: worst },
|
|
449
|
+
reviewBody: map.bodyField ? text(plain(item?.[map.bodyField])) : undefined,
|
|
450
|
+
datePublished: map.dateField ? isoDate(item?.[map.dateField])?.slice(0, 10) : undefined,
|
|
451
|
+
}));
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
if (!review.length || !scale)
|
|
455
|
+
return {};
|
|
456
|
+
return {
|
|
457
|
+
review,
|
|
458
|
+
aggregateRating: {
|
|
459
|
+
'@type': 'AggregateRating',
|
|
460
|
+
ratingValue: Math.round((total / review.length) * 10) / 10,
|
|
461
|
+
reviewCount: review.length,
|
|
462
|
+
bestRating: scale.best,
|
|
463
|
+
worstRating: scale.worst,
|
|
464
|
+
},
|
|
465
|
+
};
|
|
466
|
+
}
|
|
467
|
+
/** A person's profile addresses: `sameAs` from `site.json`, else their `links` column. */
|
|
468
|
+
function sameAsOf(person) {
|
|
469
|
+
if (Array.isArray(person.sameAs))
|
|
470
|
+
return person.sameAs.map(String);
|
|
471
|
+
const links = Array.isArray(person.links) ? person.links : [];
|
|
472
|
+
return links.flatMap((link) => {
|
|
473
|
+
const url = text(link?.url);
|
|
474
|
+
return url ? [url] : [];
|
|
475
|
+
});
|
|
476
|
+
}
|
|
477
|
+
/**
|
|
478
|
+
* The people the page's modules feature, from module types that map a row field to People: each described,
|
|
479
|
+
* and working for the organisation. A person merely mentioned in a module without the mapping isn't output.
|
|
480
|
+
*/
|
|
481
|
+
function featuredPeople(b) {
|
|
482
|
+
const mappings = b.site.site?.structuredData?.peopleModules ?? {};
|
|
483
|
+
const nodes = [];
|
|
484
|
+
for (const module of pageModules(b.page)) {
|
|
485
|
+
const map = mappings[module.type];
|
|
486
|
+
const value = map && module.content?.[map.field];
|
|
487
|
+
for (const person of Array.isArray(value) ? value : value ? [value] : []) {
|
|
488
|
+
if (!person || typeof person !== 'object')
|
|
489
|
+
continue;
|
|
490
|
+
const node = b.person(person, true);
|
|
491
|
+
if (node)
|
|
492
|
+
nodes.push(node);
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
return nodes;
|
|
496
|
+
}
|
|
267
497
|
/** Home, then the page's published ancestors, as `tags/breadcrumb.ts` walks them, ending at the page. */
|
|
268
498
|
function crumbs(b) {
|
|
269
499
|
const { site, page } = b;
|
|
@@ -293,7 +523,7 @@ function pageModules(page) {
|
|
|
293
523
|
return modules;
|
|
294
524
|
}
|
|
295
525
|
function offer(o, fallbackUrl) {
|
|
296
|
-
if (typeof o.price !== 'number' || !o.currency)
|
|
526
|
+
if (!o || typeof o.price !== 'number' || !o.currency)
|
|
297
527
|
return;
|
|
298
528
|
return compact({
|
|
299
529
|
'@type': 'Offer',
|