@docpensieve/core 0.3.0-beta.1 → 0.3.0

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@docpensieve/core",
3
- "version": "0.3.0-beta.1",
3
+ "version": "0.3.0",
4
4
  "description": "DocPensieve engine: loading, MDX compilation, structured data, site generation",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -21,7 +21,7 @@
21
21
  "types"
22
22
  ],
23
23
  "dependencies": {
24
- "@docpensieve/shared": "0.3.0-beta.1",
24
+ "@docpensieve/shared": "0.3.0",
25
25
  "@mdx-js/mdx": "^3.1.1",
26
26
  "@shikijs/rehype": "^4.4.3",
27
27
  "gray-matter": "^4.0.3",
package/src/authors.js ADDED
@@ -0,0 +1,175 @@
1
+ /**
2
+ * The byline of a page: who wrote it, and when it was written or last changed.
3
+ *
4
+ * The frontmatter names the authors; a JSON file of the version describes
5
+ * them. The split matters: a name repeated on forty pages would carry its
6
+ * biography forty times, and correcting it would mean forty edits.
7
+ *
8
+ * This module reads no file. The generator hands it the parsed description,
9
+ * as it does for the menu: without that, the engine could not be tested
10
+ * without a disk.
11
+ *
12
+ * @module @docpensieve/core/authors
13
+ */
14
+
15
+ import { ConfigError } from '@docpensieve/shared';
16
+
17
+ /**
18
+ * @typedef {object} Author
19
+ * @property {string} key Key used by the frontmatter.
20
+ * @property {string} name Name shown to the reader.
21
+ * @property {string} [bio] One or two sentences, shown under the name.
22
+ * @property {string} [avatar] Image path, relative to the version's folder.
23
+ * @property {string} [url] Personal site or profile.
24
+ */
25
+
26
+ /**
27
+ * @typedef {object} Byline
28
+ * @property {Author[]} authors
29
+ * @property {{ iso: string, label: string } | null} created
30
+ * @property {{ iso: string, label: string } | null} updated
31
+ */
32
+
33
+ /** Fields an author may declare, so that a typo is caught rather than ignored. */
34
+ const AUTHOR_FIELDS = new Set(['name', 'bio', 'avatar', 'url']);
35
+
36
+ /**
37
+ * Reads a date of the frontmatter, and gives it in both forms: the machine one
38
+ * for `<time datetime>`, the readable one for the reader.
39
+ *
40
+ * A date is often written unquoted in YAML, which parses it as a Date; quoted,
41
+ * it arrives as text. Both are accepted, anything unreadable is refused rather
42
+ * than shown as `Invalid Date`.
43
+ *
44
+ * @param {unknown} value
45
+ * @param {string} field Name of the field, for the error message.
46
+ * @param {string} where Page the date comes from.
47
+ * @returns {{ iso: string, label: string } | null} `null` when absent.
48
+ * @throws {ConfigError} When the value is not a date.
49
+ */
50
+ export function readDate(value, field, where) {
51
+ if (value === undefined || value === null || value === '') return null;
52
+
53
+ const date = value instanceof Date ? value : new Date(String(value));
54
+ if (Number.isNaN(date.getTime())) {
55
+ throw new ConfigError(`Invalid ${field} in ${where}: "${String(value)}".`, {
56
+ hint: `Write a date as ${field}: 2026-09-16, which is read the same way everywhere.`,
57
+ });
58
+ }
59
+
60
+ return {
61
+ iso: date.toISOString().slice(0, 10),
62
+ // Fixed locale: the page is built once and read everywhere, so the label
63
+ // must not depend on the machine that produced it. Day first, month
64
+ // spelled out — "16 September 2026" is read the same way everywhere,
65
+ // where 09/16 and 16/09 are the same page read two ways.
66
+ label: new Intl.DateTimeFormat('en-GB', {
67
+ day: 'numeric',
68
+ month: 'long',
69
+ year: 'numeric',
70
+ timeZone: 'UTC',
71
+ }).format(date),
72
+ };
73
+ }
74
+
75
+ /**
76
+ * Turns the JSON description of a version into a table of authors.
77
+ *
78
+ * @param {unknown} description Parsed content of the file.
79
+ * @param {{ source: string }} options `source` names the file in errors.
80
+ * @returns {Map<string, Author>}
81
+ * @throws {ConfigError} When the shape is wrong, naming the offending entry.
82
+ */
83
+ export function buildAuthorTable(description, { source }) {
84
+ if (description === null || typeof description !== 'object' || Array.isArray(description)) {
85
+ throw new ConfigError(`${source} must describe authors as an object.`, {
86
+ hint: 'Write { "ada": { "name": "Ada Lovelace", "bio": "…" } }, one entry per author.',
87
+ });
88
+ }
89
+
90
+ /** @type {Map<string, Author>} */
91
+ const table = new Map();
92
+
93
+ for (const [key, value] of Object.entries(/** @type {Record<string, unknown>} */ (description))) {
94
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) {
95
+ throw new ConfigError(`Author "${key}" of ${source} must be an object.`, {
96
+ hint: `Write "${key}": { "name": "…" } — the name is the only required field.`,
97
+ });
98
+ }
99
+
100
+ const entry = /** @type {Record<string, unknown>} */ (value);
101
+
102
+ for (const field of Object.keys(entry)) {
103
+ if (!AUTHOR_FIELDS.has(field)) {
104
+ throw new ConfigError(`Author "${key}" of ${source} has an unknown field "${field}".`, {
105
+ hint: `Known fields: ${[...AUTHOR_FIELDS].join(', ')}. A typo here would be silently ignored.`,
106
+ });
107
+ }
108
+ }
109
+
110
+ if (typeof entry.name !== 'string' || entry.name.trim() === '') {
111
+ throw new ConfigError(`Author "${key}" of ${source} has no name.`, {
112
+ hint: `Add "name": "…" — it is what the reader sees, the key is only how a page refers to it.`,
113
+ });
114
+ }
115
+
116
+ for (const field of ['bio', 'avatar', 'url']) {
117
+ const given = entry[field];
118
+ if (given !== undefined && (typeof given !== 'string' || given.trim() === '')) {
119
+ throw new ConfigError(`The ${field} of author "${key}" of ${source} must be text.`, {
120
+ hint: `Either write "${field}": "…", or leave the field out.`,
121
+ });
122
+ }
123
+ }
124
+
125
+ /** @type {Author} */
126
+ const author = { key, name: entry.name.trim() };
127
+ if (typeof entry.bio === 'string') author.bio = entry.bio.trim();
128
+ if (typeof entry.avatar === 'string') author.avatar = entry.avatar.trim();
129
+ if (typeof entry.url === 'string') author.url = entry.url.trim();
130
+ table.set(key, author);
131
+ }
132
+
133
+ return table;
134
+ }
135
+
136
+ /**
137
+ * The authors of a page, in the order the frontmatter names them.
138
+ *
139
+ * A key the table does not describe is not an error: the name is shown as
140
+ * written. It is what lets a project name its authors before describing them,
141
+ * and what keeps pages written before the file working.
142
+ *
143
+ * @param {unknown} value `authors` from the frontmatter: one name or a list.
144
+ * @param {Map<string, Author>} [table]
145
+ * @returns {Author[]}
146
+ */
147
+ export function resolvePageAuthors(value, table = new Map()) {
148
+ const list = Array.isArray(value) ? value : value === undefined || value === null ? [] : [value];
149
+
150
+ return list
151
+ .map((entry) => String(entry).trim())
152
+ .filter(Boolean)
153
+ .map((key) => table.get(key) ?? { key, name: key });
154
+ }
155
+
156
+ /**
157
+ * Assembles what the head of a page shows, or nothing when it has none of it.
158
+ *
159
+ * @param {Record<string, any>} frontmatter
160
+ * @param {Map<string, Author>} [table]
161
+ * @param {string} [where] Page named in a date error.
162
+ * @returns {Byline | null}
163
+ * @throws {ConfigError} When a date cannot be read.
164
+ */
165
+ export function buildByline(frontmatter, table = new Map(), where = 'this page') {
166
+ const authors = resolvePageAuthors(frontmatter?.authors, table);
167
+ const created = readDate(frontmatter?.date, 'date', where);
168
+ // The same field the sitemap reads for lastmod: one date, one meaning.
169
+ const updated = readDate(frontmatter?.modified, 'modified', where);
170
+
171
+ if (authors.length === 0 && !created && !updated) return null;
172
+
173
+ // An update on the day of writing says nothing: it is the same event.
174
+ return { authors, created, updated: updated && updated.iso !== created?.iso ? updated : null };
175
+ }
package/src/config.js CHANGED
@@ -38,6 +38,9 @@ import {
38
38
  * @property {Version[]} versions At least one.
39
39
  * @property {{ framework: string, darkMode?: string, toggle?: boolean, tokens?: Record<string, string>, css?: string, source?: string }} theme
40
40
  * @property {string} sidebar `'auto'`, or the path of a description.
41
+ * @property {string} [authors] Path of a JSON describing the authors, read in each version folder.
42
+ * @property {{ label: string, href: string, version?: string }[]} [headerLinks]
43
+ * Links of the header, beside the version switcher.
41
44
  * @property {boolean} globalComponents
42
45
  * @property {boolean} scrollToTop Back-to-top button on every page.
43
46
  * @property {{ enabled: boolean }} jsonld
@@ -70,6 +73,13 @@ export const DEFAULT_CONFIG = Object.freeze({
70
73
  // The light / dark switch is on unless the project turns it off (ADR-014).
71
74
  theme: { framework: 'tailwind', darkMode: 'class', toggle: true },
72
75
  sidebar: 'auto',
76
+ // No author file by default: a page still shows the names its frontmatter
77
+ // gives. The file only adds what a name cannot carry — a biography, an
78
+ // avatar, a link.
79
+ authors: '',
80
+ // No link in the header by default: the version switcher and the search
81
+ // field are there already.
82
+ headerLinks: [],
73
83
  globalComponents: true,
74
84
  scrollToTop: true,
75
85
  jsonld: { enabled: true },
@@ -240,6 +250,61 @@ export function normalizeConfig(userConfig) {
240
250
  }
241
251
  }
242
252
 
253
+ // Like the menu, the authors are described in each version's folder: a
254
+ // biography corrected in the beta must not rewrite a published version.
255
+ if (config.authors) {
256
+ const file = typeof config.authors === 'string' ? config.authors : '';
257
+ if (
258
+ !file.toLowerCase().endsWith('.json') ||
259
+ path.isAbsolute(file) ||
260
+ file.split('/').includes('..')
261
+ ) {
262
+ throw new ConfigError(
263
+ `authors must be a .json file within each version folder: "${String(config.authors)}".`,
264
+ {
265
+ hint: "For instance authors: 'authors.json', read as docs/v1.0/authors.json for that version.",
266
+ },
267
+ );
268
+ }
269
+ }
270
+
271
+ // Links of the header: site navigation, not page content. A target starts
272
+ // from the root of a version, or names another site; a relative one would
273
+ // change meaning from page to page. A link may name the version it lives in,
274
+ // so that a section written in one version is reachable from all of them.
275
+ if (config.headerLinks !== undefined) {
276
+ if (!Array.isArray(config.headerLinks)) {
277
+ throw new ConfigError('headerLinks must be a list of links.', {
278
+ hint: "For instance headerLinks: [{ label: 'Examples', href: '/examples/' }].",
279
+ });
280
+ }
281
+ const slugs = new Set(config.versions.map((version) => version.slug));
282
+ for (const link of config.headerLinks) {
283
+ if (!link || typeof link.label !== 'string' || link.label.trim() === '') {
284
+ throw new ConfigError(`A header link has no label: ${JSON.stringify(link)}.`, {
285
+ hint: "Write { label: 'Examples', href: '/examples/' }.",
286
+ });
287
+ }
288
+ if (
289
+ typeof link.href !== 'string' ||
290
+ !(link.href.startsWith('/') || /^[a-z][a-z0-9+.-]*:/i.test(link.href))
291
+ ) {
292
+ throw new ConfigError(
293
+ `The header link "${link.label}" needs an absolute target: "${String(link.href)}".`,
294
+ {
295
+ hint: "Start from the root of the version — '/examples/' — or give a full address.",
296
+ },
297
+ );
298
+ }
299
+ if (link.version !== undefined && !slugs.has(link.version)) {
300
+ throw new ConfigError(
301
+ `The header link "${link.label}" names an unknown version: "${String(link.version)}".`,
302
+ { hint: `Declared versions: ${[...slugs].join(', ')}.` },
303
+ );
304
+ }
305
+ }
306
+ }
307
+
243
308
  // siteUrl feeds everything that must be absolute: canonical, JSON-LD.
244
309
  // Invalid, it went through here and blew up further on as a raw TypeError,
245
310
  // stack included; with an exotic scheme, it built a nonsensical prefix.
package/src/generator.js CHANGED
@@ -20,10 +20,12 @@ import {
20
20
  } from '@docpensieve/shared';
21
21
  import Handlebars from 'handlebars';
22
22
 
23
+ import { buildAuthorTable, buildByline, readDate } from './authors.js';
23
24
  import { Compiler } from './compiler.js';
24
25
  import { resolveVersion } from './config.js';
25
26
  import { DocLoader } from './loader.js';
26
27
  import { buildFeed, buildRobots, buildSitemap } from './discovery.js';
28
+ import { imageSize } from './image-size.js';
27
29
  import { minifyCss } from './minify-css.js';
28
30
  import { SEARCH_SLUG, htmlToText, searchPageContent } from './search-index.js';
29
31
  import { buildSidebar, buildSidebarFromDescription, collectSectionTitles } from './sidebar.js';
@@ -44,6 +46,25 @@ const SEARCH_SCRIPT = 'assets/search.js';
44
46
  /** Source of that script, shipped with this package. */
45
47
  const CLIENT_SEARCH = fileURLToPath(new URL('../client/search.js', import.meta.url));
46
48
 
49
+ /**
50
+ * Targets a preview keeps as they are: another site, an anchor, a data URI.
51
+ * Same rule as the compiler and the components apply to a link.
52
+ */
53
+ const EXTERNAL_PREVIEW = /^(?:[a-z][a-z0-9+.-]*:|\/\/|#)/i;
54
+
55
+ /**
56
+ * Tags of a page, as the reader sees them: in the order written, blanks and
57
+ * repeats dropped. A single tag may be written without brackets, as a single
58
+ * author may — accepting only a list lost the value without a word.
59
+ *
60
+ * @param {unknown} value `tags` from the frontmatter.
61
+ * @returns {string[]}
62
+ */
63
+ function pageTags(value) {
64
+ const list = Array.isArray(value) ? value : value === undefined || value === null ? [] : [value];
65
+ return [...new Set(list.map((tag) => String(tag).trim()).filter(Boolean))];
66
+ }
67
+
47
68
  /**
48
69
  * Where each project image is written in a version, before its extension.
49
70
  * @type {Record<'logo' | 'favicon' | 'socialImage', string>}
@@ -153,7 +174,9 @@ export class SiteGenerator {
153
174
  * compiler?: Compiler,
154
175
  * onPage?: (page: {
155
176
  * url: string, dirUrl?: string, basePath: string,
156
- * filepath?: string, sourceDir?: string,
177
+ * filepath?: string, sourceDir?: string, slug?: string,
178
+ * pages?: { url: string, slug: string, title: string, description?: string,
179
+ * preview?: string, modified?: { iso: string, label: string } }[],
157
180
  * }) => void,
158
181
  * }} [deps]
159
182
  * Global components and the theme are injected rather than imported:
@@ -217,10 +240,11 @@ export class SiteGenerator {
217
240
 
218
241
  // 'auto' follows the file tree; otherwise each version describes its menu
219
242
  // in a file of its own, since each has its own pages.
220
- const sidebar =
243
+ const described =
221
244
  this.config.sidebar && this.config.sidebar !== 'auto'
222
245
  ? await this.#describedSidebar(sourceDir, docs, pageUrl, version.folder)
223
- : buildSidebar(docs, pageUrl, { brand: this.config.projectName });
246
+ : null;
247
+ const sidebar = described ?? buildSidebar(docs, pageUrl, { brand: this.config.projectName });
224
248
  const breadcrumbTitles = collectSectionTitles(docs);
225
249
  const layout = await this.#loadLayout();
226
250
  const classes = this.#classes();
@@ -245,6 +269,21 @@ export class SiteGenerator {
245
269
 
246
270
  // What every page of the version shares, the search page included.
247
271
  const searchUrl = this.config.search !== false ? joinUrl(versionBase, SEARCH_SLUG) : '';
272
+ // Described once per version, like the menu: the same authors serve every
273
+ // page, and a biography corrected in one version leaves the others alone.
274
+ const authors = await this.#readAuthors(sourceDir, version.folder, versionBase);
275
+
276
+ // Links of the header, resolved once per version. A link naming a version
277
+ // leads there from every version: a section written in one version only
278
+ // stays reachable from the others.
279
+ const headerLinks = (this.config.headerLinks ?? []).map((link) => ({
280
+ label: link.label,
281
+ href: EXTERNAL_PREVIEW.test(link.href)
282
+ ? link.href
283
+ : joinUrl(this.config.baseUrl, 'versions', link.version ?? version.slug) +
284
+ link.href.replace(/^\/+/, ''),
285
+ }));
286
+
248
287
  const shell = {
249
288
  lang: this.config.lang ?? 'en',
250
289
  // A fixed scheme is a class on <html>, which the skins and the dark
@@ -268,6 +307,9 @@ export class SiteGenerator {
268
307
  ? new URL(images.socialImage, this.config.siteUrl).href
269
308
  : '',
270
309
  searchUrl,
310
+ headerLinks,
311
+ // A menu with nothing in it would be a button that opens onto nothing.
312
+ headerMenu: this.config.versions.length > 1 || headerLinks.length > 0 || searchUrl !== '',
271
313
  // The light / dark switch: a button, and the few lines of script it needs.
272
314
  schemeToggle: this.config.theme?.toggle !== false,
273
315
  cls: classes,
@@ -279,6 +321,39 @@ export class SiteGenerator {
279
321
  scrollToTop: this.config.scrollToTop !== false,
280
322
  notice,
281
323
  };
324
+ // Every page of the version, for the components that list pages: one of
325
+ // them renders a single page at a time and could never gather this by
326
+ // itself. Targets are resolved here, each against the folder of the page
327
+ // that declares it — a preview written in one page is not relative to the
328
+ // page that shows it in a card.
329
+ const pages = docs.map((doc) => {
330
+ const folder = dirPathToSlug(path.relative(sourceDir, path.dirname(doc.path)));
331
+ const dirUrl = joinUrl(versionBase, folder);
332
+ const target = String(doc.frontmatter.preview ?? '');
333
+ const absolute = target.startsWith('/');
334
+
335
+ return {
336
+ url: pageUrl(doc),
337
+ slug: doc.slug,
338
+ title: String(doc.frontmatter.title ?? this.config.projectName),
339
+ description: doc.frontmatter.description ? String(doc.frontmatter.description) : undefined,
340
+ preview:
341
+ target === '' || EXTERNAL_PREVIEW.test(target)
342
+ ? target || undefined
343
+ : new URL(
344
+ absolute ? target.slice(1) : target,
345
+ `https://docpensieve.invalid${absolute ? versionBase : dirUrl}`,
346
+ ).pathname,
347
+ // The same date the byline and the sitemap read, formatted once.
348
+ modified:
349
+ readDate(
350
+ doc.frontmatter.modified ?? doc.frontmatter.date,
351
+ 'modified',
352
+ doc.slug || 'the home page',
353
+ ) ?? undefined,
354
+ };
355
+ });
356
+
282
357
  /** @type {{ title: string, url: string, description: string, text: string }[]} */
283
358
  const entries = [];
284
359
 
@@ -293,7 +368,15 @@ export class SiteGenerator {
293
368
  const dirUrl = joinUrl(versionBase, folder);
294
369
  // Components need to know which page they render: a link they produce
295
370
  // escapes the compiler plugins (ADR-006).
296
- this.deps.onPage?.({ url, dirUrl, basePath: versionBase, filepath: doc.path, sourceDir });
371
+ this.deps.onPage?.({
372
+ url,
373
+ dirUrl,
374
+ basePath: versionBase,
375
+ filepath: doc.path,
376
+ sourceDir,
377
+ slug: doc.slug,
378
+ pages,
379
+ });
297
380
 
298
381
  const { html, toc, preloads } = await this.compiler.compile(doc.content, {
299
382
  filepath: doc.path,
@@ -303,11 +386,19 @@ export class SiteGenerator {
303
386
  sourceDir,
304
387
  });
305
388
 
389
+ // Who wrote the page, and when. Read before the structured data, which
390
+ // describes the same people: the page and its metadata must not
391
+ // disagree about an author.
392
+ const credits = buildByline(doc.frontmatter, authors, doc.slug || 'the home page');
393
+
306
394
  const jsonld = new StructuredDataBuilder(doc.frontmatter, url, this.config, {
307
395
  breadcrumbTitles,
308
396
  basePath: versionBase,
309
397
  dirUrl,
310
398
  logo: images.logo,
399
+ // A described author carries a biography and a link, which a bare
400
+ // name in the frontmatter cannot.
401
+ authors: credits?.authors ?? [],
311
402
  }).toScriptTag();
312
403
 
313
404
  // A home page has neither menu nor table of contents: those are reading
@@ -321,6 +412,17 @@ export class SiteGenerator {
321
412
  text: htmlToText(html),
322
413
  });
323
414
 
415
+ const byline =
416
+ wide || !credits
417
+ ? null
418
+ : {
419
+ authors: credits.authors,
420
+ dates: [
421
+ credits.created && { prefix: 'Written', ...credits.created },
422
+ credits.updated && { prefix: 'Updated', ...credits.updated },
423
+ ].filter(Boolean),
424
+ };
425
+
324
426
  const page = layout({
325
427
  ...shell,
326
428
  title: documentTitle(doc.frontmatter.title, this.config.projectName),
@@ -332,6 +434,8 @@ export class SiteGenerator {
332
434
  // same content, two addresses, and the wrong one comes up. "follow"
333
435
  // still lets its links be followed.
334
436
  noindex: version.prerelease === true,
437
+ byline,
438
+ tags: wide ? [] : pageTags(doc.frontmatter.tags),
335
439
  sidebar: wide ? [] : sidebar,
336
440
  toc: wide ? [] : toc,
337
441
  preloads,
@@ -407,6 +511,93 @@ export class SiteGenerator {
407
511
  };
408
512
  }
409
513
 
514
+ /**
515
+ * Reads the authors a version describes, their avatars resolved.
516
+ *
517
+ * The file is optional: without it, a page still shows the names its
518
+ * frontmatter gives. Named in the configuration but missing, it is an
519
+ * error — leaving every biography out without a word would be worse.
520
+ *
521
+ * @param {string} sourceDir Source folder of the version.
522
+ * @param {string} folder The version's folder, as the configuration names it.
523
+ * @param {string} versionBase URL of the version.
524
+ * @returns {Promise<Map<string, import('./authors.js').Author>>} Authors by
525
+ * key, their avatars resolved to a URL and measured.
526
+ * @throws {ConfigError} Unreadable file, invalid JSON, wrong author, missing avatar.
527
+ */
528
+ async #readAuthors(sourceDir, folder, versionBase) {
529
+ const name = this.config.authors;
530
+ if (!name) return new Map();
531
+
532
+ const source = `${folder}/${name}`;
533
+
534
+ let text;
535
+ try {
536
+ text = await readFile(path.join(sourceDir, ...name.split('/')), 'utf8');
537
+ } catch (cause) {
538
+ // Absent, the file has nothing to add: this version shows the names its
539
+ // pages give (ADR-017). Present but unreadable, it is a fault.
540
+ if (/** @type {{ code?: string }} */ (cause).code === 'ENOENT') return new Map();
541
+ throw new ConfigError(`Could not read the author description at ${source}.`, {
542
+ cause,
543
+ hint: 'Check that it is a file, and that nothing holds it open.',
544
+ });
545
+ }
546
+
547
+ let description;
548
+ try {
549
+ description = JSON.parse(text);
550
+ } catch (cause) {
551
+ throw new ConfigError(
552
+ `${source} is not valid JSON: ${/** @type {Error} */ (cause).message}`,
553
+ {
554
+ cause,
555
+ hint: 'A trailing comma or a missing quote is enough: open it in an editor that checks JSON.',
556
+ },
557
+ );
558
+ }
559
+
560
+ const table = buildAuthorTable(description, { source });
561
+
562
+ // Resolved once per version, not once per page: the same handful of
563
+ // images would otherwise be read and measured on every page.
564
+ for (const author of table.values()) {
565
+ if (!author.avatar) continue;
566
+ const segments = author.avatar.split('/').filter(Boolean);
567
+
568
+ let bytes;
569
+ try {
570
+ bytes = await readFile(path.join(sourceDir, ...segments));
571
+ } catch (cause) {
572
+ throw new ConfigError(
573
+ `No avatar at ${folder}/${author.avatar}, declared by author "${author.key}".`,
574
+ {
575
+ cause,
576
+ hint: 'The path starts at the version folder, so that the image travels with the version.',
577
+ },
578
+ );
579
+ }
580
+
581
+ // Published where the asset copy puts it: folders lose their ordering
582
+ // prefix, as pages do. The raw source path pointed at a file that is
583
+ // never written — `06-examples/` is served as `examples/`.
584
+ const published = assetPathToSlug(author.avatar).split('/');
585
+ const entry = /** @type {Record<string, any>} */ (author);
586
+ entry.avatarUrl =
587
+ joinUrl(versionBase, published.slice(0, -1).join('/')) + published[published.length - 1];
588
+
589
+ // Dimensions spare the reader a jump when the image arrives, as for
590
+ // every other image of a page.
591
+ const size = imageSize(bytes, path.extname(author.avatar));
592
+ if (size) {
593
+ entry.avatarWidth = size.width;
594
+ entry.avatarHeight = size.height;
595
+ }
596
+ }
597
+
598
+ return table;
599
+ }
600
+
410
601
  /**
411
602
  * Reads the sidebar description of a version.
412
603
  *
@@ -414,9 +605,10 @@ export class SiteGenerator {
414
605
  * @param {import('./loader.js').Doc[]} docs Documents of the version.
415
606
  * @param {(doc: import('./loader.js').Doc) => string} pageUrl
416
607
  * @param {string} folder The version's folder, as the configuration names it.
417
- * @returns {Promise<import('./sidebar.js').SidebarNode[]>}
418
- * @throws {ConfigError} When the file is missing, is not JSON, or describes
419
- * the menu wrongly.
608
+ * @returns {Promise<import('./sidebar.js').SidebarNode[] | null>} `null`
609
+ * when this version has no description, which then keeps the automatic menu.
610
+ * @throws {ConfigError} When the file cannot be read, is not JSON, or
611
+ * describes the menu wrongly.
420
612
  */
421
613
  async #describedSidebar(sourceDir, docs, pageUrl, folder) {
422
614
  const name = this.config.sidebar;
@@ -426,9 +618,12 @@ export class SiteGenerator {
426
618
  try {
427
619
  text = await readFile(path.join(sourceDir, ...name.split('/')), 'utf8');
428
620
  } catch (cause) {
429
- throw new ConfigError(`No sidebar description at ${source}.`, {
621
+ // Absent, the version keeps the menu of its folders (ADR-017): a project
622
+ // can describe the menu of its new version without touching the others.
623
+ if (/** @type {{ code?: string }} */ (cause).code === 'ENOENT') return null;
624
+ throw new ConfigError(`Could not read the sidebar description at ${source}.`, {
430
625
  cause,
431
- hint: `Each version describes its own menu, since each has its own pages: create ${source}, or set sidebar: 'auto'.`,
626
+ hint: 'Check that it is a file, and that nothing holds it open.',
432
627
  });
433
628
  }
434
629
 
@@ -668,8 +863,8 @@ export class SiteGenerator {
668
863
  async #loadLayout() {
669
864
  if (this.#layout) return this.#layout;
670
865
 
671
- const [layout, navItems, tocItems] = await Promise.all(
672
- ['layout.hbs', 'nav-items.hbs', 'toc-items.hbs'].map((file) =>
866
+ const [layout, navItems, tocItems, headerMenu] = await Promise.all(
867
+ ['layout.hbs', 'nav-items.hbs', 'toc-items.hbs', 'header-menu.hbs'].map((file) =>
673
868
  readFile(path.join(TEMPLATE_DIR, file), 'utf8'),
674
869
  ),
675
870
  );
@@ -678,6 +873,7 @@ export class SiteGenerator {
678
873
  handlebars.registerHelper('eq', (a, b) => a === b);
679
874
  handlebars.registerPartial('navItems', navItems);
680
875
  handlebars.registerPartial('tocItems', tocItems);
876
+ handlebars.registerPartial('headerMenu', headerMenu);
681
877
 
682
878
  this.#layout = handlebars.compile(layout);
683
879
  return this.#layout;
@@ -762,6 +958,8 @@ export class SiteGenerator {
762
958
 
763
959
  // The sidebar description is read by the build, not published.
764
960
  if (this.config.sidebar !== 'auto' && readable === this.config.sidebar) continue;
961
+ // The author descriptions too: they feed the pages, they are not pages.
962
+ if (this.config.authors && readable === this.config.authors) continue;
765
963
  if (DOC_EXTENSIONS.includes(path.extname(entry.name).toLowerCase())) continue;
766
964
 
767
965
  const destination = path.join(target, ...assetPathToSlug(next).split('/'));
package/src/index.js CHANGED
@@ -19,6 +19,8 @@
19
19
  * @typedef {import('./compiler.js').TocEntry} TocEntry
20
20
  * @typedef {import('./compiler.js').Preload} Preload
21
21
  * @typedef {import('./sidebar.js').SidebarNode} SidebarNode
22
+ * @typedef {import('./authors.js').Author} Author
23
+ * @typedef {import('./authors.js').Byline} Byline
22
24
  */
23
25
 
24
26
  export {
@@ -34,3 +36,4 @@ export { StructuredDataBuilder } from './structured-data.js';
34
36
  export { SiteGenerator } from './generator.js';
35
37
  export { buildSidebar, buildSidebarFromDescription, collectSectionTitles } from './sidebar.js';
36
38
  export { buildFeed, buildRobots, buildSitemap } from './discovery.js';
39
+ export { buildAuthorTable, buildByline, readDate, resolvePageAuthors } from './authors.js';
@@ -70,15 +70,29 @@ function toList(value) {
70
70
  /**
71
71
  * Lists the authors as `Person` nodes.
72
72
  *
73
- * @param {unknown} authors Single string or array of names.
74
- * @returns {{ '@type': string, name: string }[]}
73
+ * @param {unknown} authors Single string or array of names, or of keys.
74
+ * @param {{ key: string, name: string, bio?: string, url?: string }[]} [described]
75
+ * Authors the version describes, which carry what a bare name cannot.
76
+ * @returns {Record<string, any>[]}
75
77
  */
76
- function toPersons(authors) {
78
+ function toPersons(authors, described = []) {
79
+ const byKey = new Map(described.map((author) => [author.key, author]));
77
80
  const list = Array.isArray(authors) ? authors : authors ? [authors] : [];
78
81
  return list
79
82
  .map((name) => String(name).trim())
80
83
  .filter(Boolean)
81
- .map((name) => ({ '@type': 'Person', name }));
84
+ .map((name) => {
85
+ const found = byKey.get(name);
86
+ // Undescribed, the frontmatter entry is the name itself: that is what
87
+ // keeps a page written before the description file working.
88
+ if (!found) return { '@type': 'Person', name };
89
+
90
+ /** @type {Record<string, any>} */
91
+ const person = { '@type': 'Person', name: found.name };
92
+ if (found.bio) person.description = found.bio;
93
+ if (found.url) person.url = found.url;
94
+ return person;
95
+ });
82
96
  }
83
97
 
84
98
  /** Assembles a schema.org graph for a page. */
@@ -87,7 +101,7 @@ export class StructuredDataBuilder {
87
101
  * @param {Record<string, any>} frontmatter Page frontmatter.
88
102
  * @param {string} url Page URL on the site (`'/guide/install/'`).
89
103
  * @param {Record<string, any>} config Normalised project config.
90
- * @param {{ breadcrumbTitles?: Record<string, string>, basePath?: string, dirUrl?: string, logo?: string }} [options]
104
+ * @param {{ breadcrumbTitles?: Record<string, string>, basePath?: string, dirUrl?: string, logo?: string, authors?: { key: string, name: string, bio?: string, url?: string }[] }} [options]
91
105
  * `breadcrumbTitles` maps a folder slug to its real title, so that the
92
106
  * breadcrumb shows “Café Guide” rather than “Cafe guide”. `basePath` is
93
107
  * the site root from which crumbs are counted: the generator sets
@@ -242,7 +256,7 @@ export class StructuredDataBuilder {
242
256
  }
243
257
  if (modified) node.dateModified = modified;
244
258
 
245
- const authors = toPersons(this.frontmatter.authors);
259
+ const authors = toPersons(this.frontmatter.authors, this.options?.authors ?? []);
246
260
  if (authors.length > 0) node.author = authors;
247
261
 
248
262
  // A single tag is written without brackets, like a single author.
@@ -0,0 +1,26 @@
1
+ {{!-- What the header offers beyond the brand: the version switcher, the links of
2
+ the site and the search field. Written once here, shown twice by the layout — in
3
+ a row on a wide screen, behind a menu button on a narrow one. --}}
4
+ {{#if showVersions}}
5
+ <details class="{{{cls.versions}}}">
6
+ {{!-- The accessible name contains the visible label: when dictated by voice,
7
+ what is read on screen must find this button. --}}
8
+ <summary aria-label="Version {{versionName}}, switch version">{{versionName}}</summary>
9
+ <ul class="{{{cls.versionsList}}}">
10
+ {{#each versions}}
11
+ <li><a href="{{url}}"{{#if current}} aria-current="true"{{/if}}>{{name}}</a></li>
12
+ {{/each}}
13
+ </ul>
14
+ </details>
15
+ {{/if}}
16
+ {{#if headerLinks.length}}
17
+ <ul class="{{{cls.headerLinks}}}">
18
+ {{#each headerLinks}}<li><a href="{{href}}">{{label}}</a></li>{{/each}}
19
+ </ul>
20
+ {{/if}}
21
+ {{!-- A plain form: it leads to the search page, and needs no script. --}}
22
+ {{#if searchUrl}}
23
+ <form class="{{{cls.search}}}" role="search" action="{{searchUrl}}">
24
+ <input type="search" name="q" placeholder="Search" aria-label="Search the documentation" />
25
+ </form>
26
+ {{/if}}
@@ -59,24 +59,22 @@
59
59
  <header class="{{{cls.header}}}">
60
60
  {{!-- The logo's alt stays empty: the name that follows already says it. --}}
61
61
  <a class="{{{cls.brand}}}" href="{{homeUrl}}">{{#if logoUrl}}<img class="{{{cls.brandLogo}}}" src="{{logoUrl}}" alt="" />{{/if}}{{projectName}}</a>
62
- {{#if showVersions}}
63
- <details class="{{{cls.versions}}}">
64
- {{!-- The accessible name contains the visible label: when dictated by
65
- voice, what is read on screen must find this button. --}}
66
- <summary aria-label="Version {{versionName}}, switch version">{{versionName}}</summary>
67
- <ul class="{{{cls.versionsList}}}">
68
- {{#each versions}}
69
- <li><a href="{{url}}"{{#if current}} aria-current="true"{{/if}}>{{name}}</a></li>
70
- {{/each}}
71
- </ul>
62
+ {{!-- Shown twice, one at a time: in a row on a wide screen, behind a button
63
+ on a narrow one. The button is a details element, so that the menu opens
64
+ without a script as the version switcher does. --}}
65
+ {{#if headerMenu}}
66
+ <nav class="{{{cls.headerNav}}}" aria-label="Site">
67
+ {{> headerMenu}}
68
+ </nav>
69
+ <details class="{{{cls.menu}}}">
70
+ <summary aria-label="Menu">
71
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true" focusable="false"><path d="M4 7h16M4 12h16M4 17h16" /></svg>
72
+ </summary>
73
+ <nav class="{{{cls.menuPanel}}}" aria-label="Site">
74
+ {{> headerMenu}}
75
+ </nav>
72
76
  </details>
73
77
  {{/if}}
74
- {{!-- A plain form: it leads to the search page, and needs no script. --}}
75
- {{#if searchUrl}}
76
- <form class="{{{cls.search}}}" role="search" action="{{searchUrl}}">
77
- <input type="search" name="q" placeholder="Search" aria-label="Search the documentation" />
78
- </form>
79
- {{/if}}
80
78
  {{!-- Hidden until its script runs: without JavaScript, it would do nothing. --}}
81
79
  {{#if schemeToggle}}
82
80
  <button class="{{{cls.schemeToggle}}}" type="button" data-scheme-toggle hidden aria-label="Switch the colour scheme">
@@ -104,7 +102,38 @@
104
102
  The current version is <a href="{{notice.url}}">{{notice.name}}</a>.
105
103
  </aside>
106
104
  {{/if}}
105
+ {{!-- Who wrote the page, and when. Absent when the page says neither,
106
+ and on a home page, where a byline under an entrance hall means
107
+ nothing. --}}
108
+ {{#if byline}}
109
+ <div class="{{{cls.byline}}}">
110
+ {{#if byline.authors.length}}
111
+ <ul class="{{{cls.bylineAuthors}}}">
112
+ {{#each byline.authors}}
113
+ <li class="{{{@root.cls.bylineAuthor}}}">
114
+ {{#if avatarUrl}}<img class="{{{@root.cls.bylineAvatar}}}" src="{{avatarUrl}}" alt="" {{#if avatarWidth}}width="{{avatarWidth}}" height="{{avatarHeight}}" {{/if}}loading="lazy" decoding="async" />{{/if}}
115
+ <span class="{{{@root.cls.bylineName}}}">{{#if url}}<a href="{{url}}">{{name}}</a>{{else}}{{name}}{{/if}}</span>
116
+ {{#if bio}}<span class="{{{@root.cls.bylineBio}}}">{{bio}}</span>{{/if}}
117
+ </li>
118
+ {{/each}}
119
+ </ul>
120
+ {{/if}}
121
+ {{#if byline.dates.length}}
122
+ <p class="{{{cls.bylineDates}}}">
123
+ {{#each byline.dates}}<span>{{prefix}} <time datetime="{{iso}}">{{label}}</time></span>{{/each}}
124
+ </p>
125
+ {{/if}}
126
+ </div>
127
+ {{/if}}
107
128
  <article class="{{{cls.article}}}">{{{content}}}</article>
129
+ {{!-- The tags of the page, below what they describe: read after the
130
+ text, they say what it was about. Labels, not links — there is no page
131
+ per tag. None on a home page. --}}
132
+ {{#if tags.length}}
133
+ <ul class="{{{cls.tags}}}" aria-label="Tags">
134
+ {{#each tags}}<li class="{{{@root.cls.tag}}}">{{this}}</li>{{/each}}
135
+ </ul>
136
+ {{/if}}
108
137
  </main>
109
138
 
110
139
  {{#if toc.length}}
@@ -0,0 +1,97 @@
1
+ /**
2
+ * The byline of a page: who wrote it, and when it was written or last changed.
3
+ *
4
+ * The frontmatter names the authors; a JSON file of the version describes
5
+ * them. The split matters: a name repeated on forty pages would carry its
6
+ * biography forty times, and correcting it would mean forty edits.
7
+ *
8
+ * This module reads no file. The generator hands it the parsed description,
9
+ * as it does for the menu: without that, the engine could not be tested
10
+ * without a disk.
11
+ *
12
+ * @module @docpensieve/core/authors
13
+ */
14
+ export type Author = {
15
+ /**
16
+ * Key used by the frontmatter.
17
+ */
18
+ key: string;
19
+ /**
20
+ * Name shown to the reader.
21
+ */
22
+ name: string;
23
+ /**
24
+ * One or two sentences, shown under the name.
25
+ */
26
+ bio?: string;
27
+ /**
28
+ * Image path, relative to the version's folder.
29
+ */
30
+ avatar?: string;
31
+ /**
32
+ * Personal site or profile.
33
+ */
34
+ url?: string;
35
+ };
36
+ export type Byline = {
37
+ authors: Author[];
38
+ created: {
39
+ iso: string;
40
+ label: string;
41
+ } | null;
42
+ updated: {
43
+ iso: string;
44
+ label: string;
45
+ } | null;
46
+ };
47
+ /**
48
+ * Reads a date of the frontmatter, and gives it in both forms: the machine one
49
+ * for `<time datetime>`, the readable one for the reader.
50
+ *
51
+ * A date is often written unquoted in YAML, which parses it as a Date; quoted,
52
+ * it arrives as text. Both are accepted, anything unreadable is refused rather
53
+ * than shown as `Invalid Date`.
54
+ *
55
+ * @param {unknown} value
56
+ * @param {string} field Name of the field, for the error message.
57
+ * @param {string} where Page the date comes from.
58
+ * @returns {{ iso: string, label: string } | null} `null` when absent.
59
+ * @throws {ConfigError} When the value is not a date.
60
+ */
61
+ export declare function readDate(value: unknown, field: string, where: string): {
62
+ iso: string;
63
+ label: string;
64
+ } | null;
65
+ /**
66
+ * Turns the JSON description of a version into a table of authors.
67
+ *
68
+ * @param {unknown} description Parsed content of the file.
69
+ * @param {{ source: string }} options `source` names the file in errors.
70
+ * @returns {Map<string, Author>}
71
+ * @throws {ConfigError} When the shape is wrong, naming the offending entry.
72
+ */
73
+ export declare function buildAuthorTable(description: unknown, { source }: {
74
+ source: string;
75
+ }): Map<string, Author>;
76
+ /**
77
+ * The authors of a page, in the order the frontmatter names them.
78
+ *
79
+ * A key the table does not describe is not an error: the name is shown as
80
+ * written. It is what lets a project name its authors before describing them,
81
+ * and what keeps pages written before the file working.
82
+ *
83
+ * @param {unknown} value `authors` from the frontmatter: one name or a list.
84
+ * @param {Map<string, Author>} [table]
85
+ * @returns {Author[]}
86
+ */
87
+ export declare function resolvePageAuthors(value: unknown, table?: Map<string, Author>): Author[];
88
+ /**
89
+ * Assembles what the head of a page shows, or nothing when it has none of it.
90
+ *
91
+ * @param {Record<string, any>} frontmatter
92
+ * @param {Map<string, Author>} [table]
93
+ * @param {string} [where] Page named in a date error.
94
+ * @returns {Byline | null}
95
+ * @throws {ConfigError} When a date cannot be read.
96
+ */
97
+ export declare function buildByline(frontmatter: Record<string, any>, table?: Map<string, Author>, where?: string): Byline | null;
package/types/config.d.ts CHANGED
@@ -71,6 +71,18 @@ export type DocPensieveConfig = {
71
71
  * `'auto'`, or the path of a description.
72
72
  */
73
73
  sidebar: string;
74
+ /**
75
+ * Path of a JSON describing the authors, read in each version folder.
76
+ */
77
+ authors?: string;
78
+ /**
79
+ * Links of the header, beside the version switcher.
80
+ */
81
+ headerLinks?: {
82
+ label: string;
83
+ href: string;
84
+ version?: string;
85
+ }[];
74
86
  globalComponents: boolean;
75
87
  /**
76
88
  * Back-to-top button on every page.
@@ -137,6 +149,9 @@ export type DocPensieveConfig = {
137
149
  * @property {Version[]} versions At least one.
138
150
  * @property {{ framework: string, darkMode?: string, toggle?: boolean, tokens?: Record<string, string>, css?: string, source?: string }} theme
139
151
  * @property {string} sidebar `'auto'`, or the path of a description.
152
+ * @property {string} [authors] Path of a JSON describing the authors, read in each version folder.
153
+ * @property {{ label: string, href: string, version?: string }[]} [headerLinks]
154
+ * Links of the header, beside the version switcher.
140
155
  * @property {boolean} globalComponents
141
156
  * @property {boolean} scrollToTop Back-to-top button on every page.
142
157
  * @property {{ enabled: boolean }} jsonld
@@ -20,6 +20,18 @@ export declare class SiteGenerator {
20
20
  basePath: string;
21
21
  filepath?: string;
22
22
  sourceDir?: string;
23
+ slug?: string;
24
+ pages?: {
25
+ url: string;
26
+ slug: string;
27
+ title: string;
28
+ description?: string;
29
+ preview?: string;
30
+ modified?: {
31
+ iso: string;
32
+ label: string;
33
+ };
34
+ }[];
23
35
  }) => void;
24
36
  };
25
37
  loader: DocLoader;
@@ -33,7 +45,9 @@ export declare class SiteGenerator {
33
45
  * compiler?: Compiler,
34
46
  * onPage?: (page: {
35
47
  * url: string, dirUrl?: string, basePath: string,
36
- * filepath?: string, sourceDir?: string,
48
+ * filepath?: string, sourceDir?: string, slug?: string,
49
+ * pages?: { url: string, slug: string, title: string, description?: string,
50
+ * preview?: string, modified?: { iso: string, label: string } }[],
37
51
  * }) => void,
38
52
  * }} [deps]
39
53
  * Global components and the theme are injected rather than imported:
@@ -54,6 +68,18 @@ export declare class SiteGenerator {
54
68
  basePath: string;
55
69
  filepath?: string;
56
70
  sourceDir?: string;
71
+ slug?: string;
72
+ pages?: {
73
+ url: string;
74
+ slug: string;
75
+ title: string;
76
+ description?: string;
77
+ preview?: string;
78
+ modified?: {
79
+ iso: string;
80
+ label: string;
81
+ };
82
+ }[];
57
83
  }) => void;
58
84
  });
59
85
  /**
package/types/index.d.ts CHANGED
@@ -14,6 +14,8 @@ export type CompileResult = import('./compiler.js').CompileResult;
14
14
  export type TocEntry = import('./compiler.js').TocEntry;
15
15
  export type Preload = import('./compiler.js').Preload;
16
16
  export type SidebarNode = import('./sidebar.js').SidebarNode;
17
+ export type Author = import('./authors.js').Author;
18
+ export type Byline = import('./authors.js').Byline;
17
19
  /**
18
20
  * Engine types, re-exported for consumers of the published package: without
19
21
  * this they would only be reachable through an internal path.
@@ -25,6 +27,8 @@ export type SidebarNode = import('./sidebar.js').SidebarNode;
25
27
  * @typedef {import('./compiler.js').TocEntry} TocEntry
26
28
  * @typedef {import('./compiler.js').Preload} Preload
27
29
  * @typedef {import('./sidebar.js').SidebarNode} SidebarNode
30
+ * @typedef {import('./authors.js').Author} Author
31
+ * @typedef {import('./authors.js').Byline} Byline
28
32
  */
29
33
  export { DEFAULT_CONFIG, defineConfig, loadConfig, normalizeConfig, resolveVersion, } from './config.js';
30
34
  export { DocLoader } from './loader.js';
@@ -33,3 +37,4 @@ export { StructuredDataBuilder } from './structured-data.js';
33
37
  export { SiteGenerator } from './generator.js';
34
38
  export { buildSidebar, buildSidebarFromDescription, collectSectionTitles } from './sidebar.js';
35
39
  export { buildFeed, buildRobots, buildSitemap } from './discovery.js';
40
+ export { buildAuthorTable, buildByline, readDate, resolvePageAuthors } from './authors.js';
@@ -24,6 +24,12 @@ export declare class StructuredDataBuilder {
24
24
  basePath?: string;
25
25
  dirUrl?: string;
26
26
  logo?: string;
27
+ authors?: {
28
+ key: string;
29
+ name: string;
30
+ bio?: string;
31
+ url?: string;
32
+ }[];
27
33
  };
28
34
  breadcrumbTitles: Record<string, string>;
29
35
  basePath: string;
@@ -33,7 +39,7 @@ export declare class StructuredDataBuilder {
33
39
  * @param {Record<string, any>} frontmatter Page frontmatter.
34
40
  * @param {string} url Page URL on the site (`'/guide/install/'`).
35
41
  * @param {Record<string, any>} config Normalised project config.
36
- * @param {{ breadcrumbTitles?: Record<string, string>, basePath?: string, dirUrl?: string, logo?: string }} [options]
42
+ * @param {{ breadcrumbTitles?: Record<string, string>, basePath?: string, dirUrl?: string, logo?: string, authors?: { key: string, name: string, bio?: string, url?: string }[] }} [options]
37
43
  * `breadcrumbTitles` maps a folder slug to its real title, so that the
38
44
  * breadcrumb shows “Café Guide” rather than “Cafe guide”. `basePath` is
39
45
  * the site root from which crumbs are counted: the generator sets
@@ -46,6 +52,12 @@ export declare class StructuredDataBuilder {
46
52
  basePath?: string;
47
53
  dirUrl?: string;
48
54
  logo?: string;
55
+ authors?: {
56
+ key: string;
57
+ name: string;
58
+ bio?: string;
59
+ url?: string;
60
+ }[];
49
61
  });
50
62
  /**
51
63
  * Builds the page's schema.org graph.