@docpensieve/core 0.3.0-beta.2 → 0.4.0-beta.1

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.2",
3
+ "version": "0.4.0-beta.1",
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.2",
24
+ "@docpensieve/shared": "0.4.0-beta.1",
25
25
  "@mdx-js/mdx": "^3.1.1",
26
26
  "@shikijs/rehype": "^4.4.3",
27
27
  "gray-matter": "^4.0.3",
package/src/config.js CHANGED
@@ -39,6 +39,8 @@ import {
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
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.
42
44
  * @property {boolean} globalComponents
43
45
  * @property {boolean} scrollToTop Back-to-top button on every page.
44
46
  * @property {{ enabled: boolean }} jsonld
@@ -75,6 +77,9 @@ export const DEFAULT_CONFIG = Object.freeze({
75
77
  // gives. The file only adds what a name cannot carry — a biography, an
76
78
  // avatar, a link.
77
79
  authors: '',
80
+ // No link in the header by default: the version switcher and the search
81
+ // field are there already.
82
+ headerLinks: [],
78
83
  globalComponents: true,
79
84
  scrollToTop: true,
80
85
  jsonld: { enabled: true },
@@ -263,6 +268,43 @@ export function normalizeConfig(userConfig) {
263
268
  }
264
269
  }
265
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
+
266
308
  // siteUrl feeds everything that must be absolute: canonical, JSON-LD.
267
309
  // Invalid, it went through here and blew up further on as a raw TypeError,
268
310
  // stack included; with an exotic scheme, it built a nonsensical prefix.
package/src/generator.js CHANGED
@@ -52,6 +52,19 @@ const CLIENT_SEARCH = fileURLToPath(new URL('../client/search.js', import.meta.u
52
52
  */
53
53
  const EXTERNAL_PREVIEW = /^(?:[a-z][a-z0-9+.-]*:|\/\/|#)/i;
54
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
+
55
68
  /**
56
69
  * Where each project image is written in a version, before its extension.
57
70
  * @type {Record<'logo' | 'favicon' | 'socialImage', string>}
@@ -227,10 +240,11 @@ export class SiteGenerator {
227
240
 
228
241
  // 'auto' follows the file tree; otherwise each version describes its menu
229
242
  // in a file of its own, since each has its own pages.
230
- const sidebar =
243
+ const described =
231
244
  this.config.sidebar && this.config.sidebar !== 'auto'
232
245
  ? await this.#describedSidebar(sourceDir, docs, pageUrl, version.folder)
233
- : buildSidebar(docs, pageUrl, { brand: this.config.projectName });
246
+ : null;
247
+ const sidebar = described ?? buildSidebar(docs, pageUrl, { brand: this.config.projectName });
234
248
  const breadcrumbTitles = collectSectionTitles(docs);
235
249
  const layout = await this.#loadLayout();
236
250
  const classes = this.#classes();
@@ -259,6 +273,17 @@ export class SiteGenerator {
259
273
  // page, and a biography corrected in one version leaves the others alone.
260
274
  const authors = await this.#readAuthors(sourceDir, version.folder, versionBase);
261
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
+
262
287
  const shell = {
263
288
  lang: this.config.lang ?? 'en',
264
289
  // A fixed scheme is a class on <html>, which the skins and the dark
@@ -282,6 +307,9 @@ export class SiteGenerator {
282
307
  ? new URL(images.socialImage, this.config.siteUrl).href
283
308
  : '',
284
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 !== '',
285
313
  // The light / dark switch: a button, and the few lines of script it needs.
286
314
  schemeToggle: this.config.theme?.toggle !== false,
287
315
  cls: classes,
@@ -407,6 +435,7 @@ export class SiteGenerator {
407
435
  // still lets its links be followed.
408
436
  noindex: version.prerelease === true,
409
437
  byline,
438
+ tags: wide ? [] : pageTags(doc.frontmatter.tags),
410
439
  sidebar: wide ? [] : sidebar,
411
440
  toc: wide ? [] : toc,
412
441
  preloads,
@@ -494,7 +523,7 @@ export class SiteGenerator {
494
523
  * @param {string} versionBase URL of the version.
495
524
  * @returns {Promise<Map<string, import('./authors.js').Author>>} Authors by
496
525
  * key, their avatars resolved to a URL and measured.
497
- * @throws {ConfigError} Missing file, invalid JSON, wrong author, missing avatar.
526
+ * @throws {ConfigError} Unreadable file, invalid JSON, wrong author, missing avatar.
498
527
  */
499
528
  async #readAuthors(sourceDir, folder, versionBase) {
500
529
  const name = this.config.authors;
@@ -506,9 +535,12 @@ export class SiteGenerator {
506
535
  try {
507
536
  text = await readFile(path.join(sourceDir, ...name.split('/')), 'utf8');
508
537
  } catch (cause) {
509
- throw new ConfigError(`No author description at ${source}.`, {
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}.`, {
510
542
  cause,
511
- hint: `Each version describes its own authors: create ${source}, or drop the authors field.`,
543
+ hint: 'Check that it is a file, and that nothing holds it open.',
512
544
  });
513
545
  }
514
546
 
@@ -546,9 +578,13 @@ export class SiteGenerator {
546
578
  );
547
579
  }
548
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('/');
549
585
  const entry = /** @type {Record<string, any>} */ (author);
550
586
  entry.avatarUrl =
551
- joinUrl(versionBase, segments.slice(0, -1).join('/')) + segments[segments.length - 1];
587
+ joinUrl(versionBase, published.slice(0, -1).join('/')) + published[published.length - 1];
552
588
 
553
589
  // Dimensions spare the reader a jump when the image arrives, as for
554
590
  // every other image of a page.
@@ -569,9 +605,10 @@ export class SiteGenerator {
569
605
  * @param {import('./loader.js').Doc[]} docs Documents of the version.
570
606
  * @param {(doc: import('./loader.js').Doc) => string} pageUrl
571
607
  * @param {string} folder The version's folder, as the configuration names it.
572
- * @returns {Promise<import('./sidebar.js').SidebarNode[]>}
573
- * @throws {ConfigError} When the file is missing, is not JSON, or describes
574
- * 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.
575
612
  */
576
613
  async #describedSidebar(sourceDir, docs, pageUrl, folder) {
577
614
  const name = this.config.sidebar;
@@ -581,9 +618,12 @@ export class SiteGenerator {
581
618
  try {
582
619
  text = await readFile(path.join(sourceDir, ...name.split('/')), 'utf8');
583
620
  } catch (cause) {
584
- 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}.`, {
585
625
  cause,
586
- 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.',
587
627
  });
588
628
  }
589
629
 
@@ -823,8 +863,8 @@ export class SiteGenerator {
823
863
  async #loadLayout() {
824
864
  if (this.#layout) return this.#layout;
825
865
 
826
- const [layout, navItems, tocItems] = await Promise.all(
827
- ['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) =>
828
868
  readFile(path.join(TEMPLATE_DIR, file), 'utf8'),
829
869
  ),
830
870
  );
@@ -833,6 +873,7 @@ export class SiteGenerator {
833
873
  handlebars.registerHelper('eq', (a, b) => a === b);
834
874
  handlebars.registerPartial('navItems', navItems);
835
875
  handlebars.registerPartial('tocItems', tocItems);
876
+ handlebars.registerPartial('headerMenu', headerMenu);
836
877
 
837
878
  this.#layout = handlebars.compile(layout);
838
879
  return this.#layout;
@@ -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">
@@ -128,6 +126,14 @@
128
126
  </div>
129
127
  {{/if}}
130
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}}
131
137
  </main>
132
138
 
133
139
  {{#if toc.length}}
package/types/config.d.ts CHANGED
@@ -75,6 +75,14 @@ export type DocPensieveConfig = {
75
75
  * Path of a JSON describing the authors, read in each version folder.
76
76
  */
77
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
+ }[];
78
86
  globalComponents: boolean;
79
87
  /**
80
88
  * Back-to-top button on every page.
@@ -142,6 +150,8 @@ export type DocPensieveConfig = {
142
150
  * @property {{ framework: string, darkMode?: string, toggle?: boolean, tokens?: Record<string, string>, css?: string, source?: string }} theme
143
151
  * @property {string} sidebar `'auto'`, or the path of a description.
144
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.
145
155
  * @property {boolean} globalComponents
146
156
  * @property {boolean} scrollToTop Back-to-top button on every page.
147
157
  * @property {{ enabled: boolean }} jsonld