@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.
@@ -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, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
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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"}
@@ -1,20 +1,35 @@
1
- import type { CeemsPage, CeemsSite, StructuredDataDefaults, StructuredDataType } from './interfaces';
2
- /** Every type the `{% structureddata %}` tag can output, in the order it outputs them. */
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
- /** Used when the site has published no defaults (sites published before structured data). */
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. A type missing a
11
- * required property (an event with no start date, a product with no price) is left out rather than output
12
- * invalid. `only` limits the graph to those types.
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?: StructuredDataType[]): {
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 {};
@@ -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
- /** Every type the `{% structureddata %}` tag can output, in the order it outputs them. */
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
- const BASE = ['WEBSITE', 'WEBPAGE', 'BREADCRUMB', 'ORGANISATION'];
20
- /** Used when the site has published no defaults (sites published before structured data). */
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
- page: BASE,
23
- blog: BASE,
24
- article: [...BASE, 'ARTICLE'],
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 defaults = site.site?.structuredData?.defaults ?? exports.DEFAULT_STRUCTURED_DATA;
32
- const kind = page.type === 'article' ? 'article' : page.type === 'blog' ? 'blog' : 'page';
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. A type missing a
37
- * required property (an event with no start date, a product with no price) is left out rather than output
38
- * invalid. `only` limits the graph to those types.
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 enabled = new Set(enabledTypes(site, page));
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](new Builder(site, page));
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
- constructor(site, page) {
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.hasOrganisation ? { '@id': b.id('', 'organization') } : undefined,
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
- '@type': 'WebPage',
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: { '@id': b.id('', 'website') },
137
- breadcrumb: crumbs(b).length > 1 ? { '@id': b.id(b.page.fullpath, 'breadcrumb') } : undefined,
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.type !== 'article' || !b.pageName)
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.hasOrganisation ? { '@id': b.id('', 'organization') } : undefined,
171
- publisher: b.hasOrganisation ? { '@id': b.id('', 'organization') } : undefined,
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: online
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: event.offers ? offer(event.offers, b.pageUrl) : undefined,
232
- organizer: b.hasOrganisation ? { '@id': b.id('', 'organization') } : undefined,
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 (!product || !name || !(typeof product.price === 'number') || !product.currency)
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.description) ?? text(b.page.description),
245
- image: imageUrl(product.image) ?? imageUrl(b.page.social),
246
- sku: text(product.sku),
247
- brand: product.brand ? { '@type': 'Brand', name: product.brand } : undefined,
248
- offers: offer(product, b.pageUrl),
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.hasOrganisation ? { '@id': b.id('', 'organization') } : undefined,
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',