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

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.4.0-beta.1",
3
+ "version": "0.4.0-beta.2",
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.4.0-beta.1",
24
+ "@docpensieve/shared": "0.4.0-beta.2",
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,8 +39,11 @@ 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
+ * @property {{ label: string, href?: string, version?: string, columns?: { title?: string, items: { label: string, href: string, version?: string }[] }[] }[]} [headerLinks]
43
+ * Links of the header, beside the version switcher. An entry carrying
44
+ * `columns` opens a panel of links instead of leading anywhere itself.
45
+ * @property {boolean} [foldedSidebar] Categories of the menu fold, opened on
46
+ * the branch of the page being read.
44
47
  * @property {boolean} globalComponents
45
48
  * @property {boolean} scrollToTop Back-to-top button on every page.
46
49
  * @property {{ enabled: boolean }} jsonld
@@ -80,6 +83,9 @@ export const DEFAULT_CONFIG = Object.freeze({
80
83
  // No link in the header by default: the version switcher and the search
81
84
  // field are there already.
82
85
  headerLinks: [],
86
+ // The menu shows whole by default: a documentation of a few dozen pages
87
+ // reads better open than behind folds. Long ones turn this on.
88
+ foldedSidebar: false,
83
89
  globalComponents: true,
84
90
  scrollToTop: true,
85
91
  jsonld: { enabled: true },
@@ -279,29 +285,69 @@ export function normalizeConfig(userConfig) {
279
285
  });
280
286
  }
281
287
  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
+
289
+ /** @param {any} link @param {string} where */
290
+ const checkTarget = (link, where) => {
288
291
  if (
289
292
  typeof link.href !== 'string' ||
290
293
  !(link.href.startsWith('/') || /^[a-z][a-z0-9+.-]*:/i.test(link.href))
291
294
  ) {
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
- );
295
+ throw new ConfigError(`${where} needs an absolute target: "${String(link.href)}".`, {
296
+ hint: "Start from the root of the version — '/examples/' — or give a full address.",
297
+ });
298
298
  }
299
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
- );
300
+ throw new ConfigError(`${where} names an unknown version: "${String(link.version)}".`, {
301
+ hint: `Declared versions: ${[...slugs].join(', ')}.`,
302
+ });
304
303
  }
304
+ };
305
+
306
+ for (const link of config.headerLinks) {
307
+ if (!link || typeof link.label !== 'string' || link.label.trim() === '') {
308
+ throw new ConfigError(`A header link has no label: ${JSON.stringify(link)}.`, {
309
+ hint: "Write { label: 'Examples', href: '/examples/' }.",
310
+ });
311
+ }
312
+
313
+ // An entry either leads somewhere, or opens a panel of links: both at
314
+ // once would leave a click meaning two things.
315
+ if (link.columns !== undefined) {
316
+ if (!Array.isArray(link.columns) || link.columns.length === 0) {
317
+ throw new ConfigError(`The columns of "${link.label}" must be a list of columns.`, {
318
+ hint: "Write columns: [{ title: 'Guide', items: [{ label: 'Install', href: '/guide/install/' }] }].",
319
+ });
320
+ }
321
+ if (link.href !== undefined) {
322
+ throw new ConfigError(`The header entry "${link.label}" has both href and columns.`, {
323
+ hint: 'An entry either leads somewhere, or opens a panel: drop one of the two.',
324
+ });
325
+ }
326
+ for (const column of link.columns) {
327
+ if (!column || !Array.isArray(column.items) || column.items.length === 0) {
328
+ throw new ConfigError(`A column of "${link.label}" holds no link.`, {
329
+ hint: "Every column needs items: [{ label: 'Install', href: '/guide/install/' }].",
330
+ });
331
+ }
332
+ if (column.title !== undefined && typeof column.title !== 'string') {
333
+ throw new ConfigError(`A column title of "${link.label}" must be text.`, {
334
+ hint: 'Either write a title, or leave the field out.',
335
+ });
336
+ }
337
+ for (const item of column.items) {
338
+ if (!item || typeof item.label !== 'string' || item.label.trim() === '') {
339
+ throw new ConfigError(
340
+ `A link of "${link.label}" has no label: ${JSON.stringify(item)}.`,
341
+ { hint: "Write { label: 'Install', href: '/guide/install/' }." },
342
+ );
343
+ }
344
+ checkTarget(item, `The link "${item.label}" of "${link.label}"`);
345
+ }
346
+ }
347
+ continue;
348
+ }
349
+
350
+ checkTarget(link, `The header link "${link.label}"`);
305
351
  }
306
352
  }
307
353
 
package/src/generator.js CHANGED
@@ -28,7 +28,12 @@ import { buildFeed, buildRobots, buildSitemap } from './discovery.js';
28
28
  import { imageSize } from './image-size.js';
29
29
  import { minifyCss } from './minify-css.js';
30
30
  import { SEARCH_SLUG, htmlToText, searchPageContent } from './search-index.js';
31
- import { buildSidebar, buildSidebarFromDescription, collectSectionTitles } from './sidebar.js';
31
+ import {
32
+ buildSidebar,
33
+ buildSidebarFromDescription,
34
+ collectSectionTitles,
35
+ foldSidebar,
36
+ } from './sidebar.js';
32
37
  import { StructuredDataBuilder } from './structured-data.js';
33
38
 
34
39
  /** Template folder, resolved from this module rather than from the cwd. */
@@ -245,6 +250,7 @@ export class SiteGenerator {
245
250
  ? await this.#describedSidebar(sourceDir, docs, pageUrl, version.folder)
246
251
  : null;
247
252
  const sidebar = described ?? buildSidebar(docs, pageUrl, { brand: this.config.projectName });
253
+ const folded = this.config.foldedSidebar === true;
248
254
  const breadcrumbTitles = collectSectionTitles(docs);
249
255
  const layout = await this.#loadLayout();
250
256
  const classes = this.#classes();
@@ -276,13 +282,29 @@ export class SiteGenerator {
276
282
  // Links of the header, resolved once per version. A link naming a version
277
283
  // leads there from every version: a section written in one version only
278
284
  // stays reachable from the others.
279
- const headerLinks = (this.config.headerLinks ?? []).map((link) => ({
280
- label: link.label,
281
- href: EXTERNAL_PREVIEW.test(link.href)
285
+ /** @param {{ href: string, version?: string }} link */
286
+ const headerTarget = (link) =>
287
+ EXTERNAL_PREVIEW.test(link.href)
282
288
  ? link.href
283
289
  : joinUrl(this.config.baseUrl, 'versions', link.version ?? version.slug) +
284
- link.href.replace(/^\/+/, ''),
285
- }));
290
+ link.href.replace(/^\/+/, '');
291
+
292
+ const headerLinks = (this.config.headerLinks ?? []).map((link) =>
293
+ link.columns
294
+ ? {
295
+ label: link.label,
296
+ columns: link.columns.map((column) => ({
297
+ title: column.title,
298
+ items: column.items.map((item) => ({ label: item.label, href: headerTarget(item) })),
299
+ })),
300
+ }
301
+ : // Without columns the configuration has checked the target: an entry
302
+ // that leads nowhere and opens nothing never gets here.
303
+ {
304
+ label: link.label,
305
+ href: headerTarget({ href: String(link.href), version: link.version }),
306
+ },
307
+ );
286
308
 
287
309
  const shell = {
288
310
  lang: this.config.lang ?? 'en',
@@ -436,7 +458,10 @@ export class SiteGenerator {
436
458
  noindex: version.prerelease === true,
437
459
  byline,
438
460
  tags: wide ? [] : pageTags(doc.frontmatter.tags),
439
- sidebar: wide ? [] : sidebar,
461
+ // Folded, the menu opens on the branch of the page being rendered, so
462
+ // it is prepared per page rather than once per version.
463
+ sidebar: wide ? [] : folded ? foldSidebar(sidebar, url) : sidebar,
464
+ foldedSidebar: folded,
440
465
  toc: wide ? [] : toc,
441
466
  preloads,
442
467
  content: html,
package/src/index.js CHANGED
@@ -19,6 +19,7 @@
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('./sidebar.js').FoldedNode} FoldedNode
22
23
  * @typedef {import('./authors.js').Author} Author
23
24
  * @typedef {import('./authors.js').Byline} Byline
24
25
  */
@@ -34,6 +35,11 @@ export { DocLoader } from './loader.js';
34
35
  export { Compiler } from './compiler.js';
35
36
  export { StructuredDataBuilder } from './structured-data.js';
36
37
  export { SiteGenerator } from './generator.js';
37
- export { buildSidebar, buildSidebarFromDescription, collectSectionTitles } from './sidebar.js';
38
+ export {
39
+ buildSidebar,
40
+ buildSidebarFromDescription,
41
+ collectSectionTitles,
42
+ foldSidebar,
43
+ } from './sidebar.js';
38
44
  export { buildFeed, buildRobots, buildSitemap } from './discovery.js';
39
45
  export { buildAuthorTable, buildByline, readDate, resolvePageAuthors } from './authors.js';
package/src/sidebar.js CHANGED
@@ -104,6 +104,58 @@ export function collectSectionTitles(docs) {
104
104
  return titles;
105
105
  }
106
106
 
107
+ /**
108
+ * Prepares a menu whose categories fold, for the page being rendered.
109
+ *
110
+ * Two things change. Every category carries `open`, true along the branch
111
+ * holding the current page: a long menu opens where the reader stands, and
112
+ * stays closed everywhere else. And a category that is itself a page gains
113
+ * that page as its first entry — folded, its title becomes the handle of the
114
+ * fold, which cannot be a link as well without a click meaning two things.
115
+ *
116
+ * The tree is rebuilt rather than marked in place: it is shared by every page
117
+ * of the version, and marking it would leave one page's branch open on all the
118
+ * others.
119
+ *
120
+ * @typedef {object} FoldedNode
121
+ * @property {string} label
122
+ * @property {string | null} url
123
+ * @property {FoldedNode[]} items
124
+ * @property {boolean} [open] Whether the category starts open. Absent on a
125
+ * plain entry, which has nothing to fold.
126
+ */
127
+
128
+ /**
129
+ * @param {SidebarNode[]} nodes
130
+ * @param {string} [currentUrl] URL of the page being rendered.
131
+ * @returns {FoldedNode[]}
132
+ */
133
+ export function foldSidebar(nodes, currentUrl = '') {
134
+ return nodes.map((node) => {
135
+ if (node.items.length === 0) return { ...node };
136
+
137
+ const items = foldSidebar(
138
+ node.url ? [{ label: node.label, url: node.url, items: [] }, ...node.items] : node.items,
139
+ currentUrl,
140
+ );
141
+
142
+ // Open when the reader is inside: on the category's own page, or on any
143
+ // page it holds, however deep.
144
+ return { ...node, items, open: node.url === currentUrl || holds(items, currentUrl) };
145
+ });
146
+ }
147
+
148
+ /**
149
+ * Whether a branch holds the current page.
150
+ *
151
+ * @param {FoldedNode[]} nodes
152
+ * @param {string} currentUrl
153
+ * @returns {boolean}
154
+ */
155
+ function holds(nodes, currentUrl) {
156
+ return nodes.some((node) => node.url === currentUrl || holds(node.items, currentUrl));
157
+ }
158
+
107
159
  /**
108
160
  * An entry of a sidebar description: a page path, or an object — see
109
161
  * `buildSidebarFromDescription`.
@@ -15,7 +15,29 @@ a row on a wide screen, behind a menu button on a narrow one. --}}
15
15
  {{/if}}
16
16
  {{#if headerLinks.length}}
17
17
  <ul class="{{{cls.headerLinks}}}">
18
- {{#each headerLinks}}<li><a href="{{href}}">{{label}}</a></li>{{/each}}
18
+ {{#each headerLinks}}
19
+ <li>
20
+ {{#if columns.length}}
21
+ {{!-- A panel of links rather than a link: a details again, so that it
22
+ opens without a script, and folds into the menu of a narrow screen. --}}
23
+ <details class="{{{@root.cls.mega}}}">
24
+ <summary>{{label}}</summary>
25
+ <div class="{{{@root.cls.megaPanel}}}">
26
+ {{#each columns}}
27
+ <div class="{{{@root.cls.megaColumn}}}">
28
+ {{#if title}}<p class="{{{@root.cls.megaTitle}}}">{{title}}</p>{{/if}}
29
+ <ul>
30
+ {{#each items}}<li><a href="{{href}}">{{label}}</a></li>{{/each}}
31
+ </ul>
32
+ </div>
33
+ {{/each}}
34
+ </div>
35
+ </details>
36
+ {{else}}
37
+ <a href="{{href}}">{{label}}</a>
38
+ {{/if}}
39
+ </li>
40
+ {{/each}}
19
41
  </ul>
20
42
  {{/if}}
21
43
  {{!-- A plain form: it leads to the search page, and needs no script. --}}
@@ -85,10 +85,19 @@
85
85
  </header>
86
86
 
87
87
  <div class="{{#if wide}}{{{cls.shellWide}}}{{else}}{{{cls.shell}}}{{/if}}">
88
+ {{!-- Written once, shown twice: the column on a wide screen, and a folded
89
+ menu above the content on a narrow one, where a full menu would eat the
90
+ first screen. Only one of the two is displayed at a time. --}}
88
91
  {{#if sidebar.length}}
89
92
  <nav class="{{{cls.sidebar}}}" aria-label="Documentation navigation">
90
93
  {{> navItems sidebar}}
91
94
  </nav>
95
+ <details class="{{{cls.sidebarMenu}}}">
96
+ <summary>Documentation menu</summary>
97
+ <nav aria-label="Documentation navigation">
98
+ {{> navItems sidebar}}
99
+ </nav>
100
+ </details>
92
101
  {{/if}}
93
102
 
94
103
  <main class="{{{cls.main}}}" id="content" tabindex="-1">
@@ -1,14 +1,30 @@
1
1
  <ul class="{{{@root.cls.nav}}}">
2
2
  {{#each this}}
3
3
  <li class="{{{@root.cls.navItem}}}{{#if items.length}} {{{@root.cls.navItemParent}}}{{/if}}">
4
+ {{#if items.length}}
5
+ {{#if @root.foldedSidebar}}
6
+ {{!-- Folded, a category is a native details: it opens without a script,
7
+ and starts open on the branch the reader is in. Its own page sits as the
8
+ first entry, since the handle of the fold cannot be a link as well. --}}
9
+ <details class="{{{@root.cls.navGroup}}}"{{#if open}} open{{/if}}>
10
+ <summary class="{{{@root.cls.navSummary}}}">{{label}}</summary>
11
+ {{> navItems items}}
12
+ </details>
13
+ {{else}}
4
14
  {{#if url}}
5
15
  <a class="{{{@root.cls.navLink}}}" href="{{url}}"{{#if (eq url @root.currentUrl)}} aria-current="page"{{/if}}>{{label}}</a>
6
16
  {{else}}
7
17
  <span class="{{{@root.cls.navLabel}}}">{{label}}</span>
8
18
  {{/if}}
9
- {{#if items.length}}
10
19
  {{> navItems items}}
11
20
  {{/if}}
21
+ {{else}}
22
+ {{#if url}}
23
+ <a class="{{{@root.cls.navLink}}}" href="{{url}}"{{#if (eq url @root.currentUrl)}} aria-current="page"{{/if}}>{{label}}</a>
24
+ {{else}}
25
+ <span class="{{{@root.cls.navLabel}}}">{{label}}</span>
26
+ {{/if}}
27
+ {{/if}}
12
28
  </li>
13
29
  {{/each}}
14
30
  </ul>
package/types/config.d.ts CHANGED
@@ -76,13 +76,27 @@ export type DocPensieveConfig = {
76
76
  */
77
77
  authors?: string;
78
78
  /**
79
- * Links of the header, beside the version switcher.
79
+ * Links of the header, beside the version switcher. An entry carrying
80
+ * `columns` opens a panel of links instead of leading anywhere itself.
80
81
  */
81
82
  headerLinks?: {
82
83
  label: string;
83
- href: string;
84
+ href?: string;
84
85
  version?: string;
86
+ columns?: {
87
+ title?: string;
88
+ items: {
89
+ label: string;
90
+ href: string;
91
+ version?: string;
92
+ }[];
93
+ }[];
85
94
  }[];
95
+ /**
96
+ * Categories of the menu fold, opened on
97
+ * the branch of the page being read.
98
+ */
99
+ foldedSidebar?: boolean;
86
100
  globalComponents: boolean;
87
101
  /**
88
102
  * Back-to-top button on every page.
@@ -150,8 +164,11 @@ export type DocPensieveConfig = {
150
164
  * @property {{ framework: string, darkMode?: string, toggle?: boolean, tokens?: Record<string, string>, css?: string, source?: string }} theme
151
165
  * @property {string} sidebar `'auto'`, or the path of a description.
152
166
  * @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.
167
+ * @property {{ label: string, href?: string, version?: string, columns?: { title?: string, items: { label: string, href: string, version?: string }[] }[] }[]} [headerLinks]
168
+ * Links of the header, beside the version switcher. An entry carrying
169
+ * `columns` opens a panel of links instead of leading anywhere itself.
170
+ * @property {boolean} [foldedSidebar] Categories of the menu fold, opened on
171
+ * the branch of the page being read.
155
172
  * @property {boolean} globalComponents
156
173
  * @property {boolean} scrollToTop Back-to-top button on every page.
157
174
  * @property {{ enabled: boolean }} jsonld
package/types/index.d.ts CHANGED
@@ -14,6 +14,7 @@ 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 FoldedNode = import('./sidebar.js').FoldedNode;
17
18
  export type Author = import('./authors.js').Author;
18
19
  export type Byline = import('./authors.js').Byline;
19
20
  /**
@@ -27,6 +28,7 @@ export type Byline = import('./authors.js').Byline;
27
28
  * @typedef {import('./compiler.js').TocEntry} TocEntry
28
29
  * @typedef {import('./compiler.js').Preload} Preload
29
30
  * @typedef {import('./sidebar.js').SidebarNode} SidebarNode
31
+ * @typedef {import('./sidebar.js').FoldedNode} FoldedNode
30
32
  * @typedef {import('./authors.js').Author} Author
31
33
  * @typedef {import('./authors.js').Byline} Byline
32
34
  */
@@ -35,6 +37,6 @@ export { DocLoader } from './loader.js';
35
37
  export { Compiler } from './compiler.js';
36
38
  export { StructuredDataBuilder } from './structured-data.js';
37
39
  export { SiteGenerator } from './generator.js';
38
- export { buildSidebar, buildSidebarFromDescription, collectSectionTitles } from './sidebar.js';
40
+ export { buildSidebar, buildSidebarFromDescription, collectSectionTitles, foldSidebar, } from './sidebar.js';
39
41
  export { buildFeed, buildRobots, buildSitemap } from './discovery.js';
40
42
  export { buildAuthorTable, buildByline, readDate, resolvePageAuthors } from './authors.js';
@@ -58,6 +58,42 @@ export declare function buildSidebar(docs: import('./loader.js').Doc[], toUrl?:
58
58
  * @returns {Record<string, string>} Full folder slug to title.
59
59
  */
60
60
  export declare function collectSectionTitles(docs: import('./loader.js').Doc[]): Record<string, string>;
61
+ export type FoldedNode = {
62
+ label: string;
63
+ url: string | null;
64
+ items: FoldedNode[];
65
+ /**
66
+ * Whether the category starts open. Absent on a
67
+ * plain entry, which has nothing to fold.
68
+ */
69
+ open?: boolean;
70
+ };
71
+ /**
72
+ * Prepares a menu whose categories fold, for the page being rendered.
73
+ *
74
+ * Two things change. Every category carries `open`, true along the branch
75
+ * holding the current page: a long menu opens where the reader stands, and
76
+ * stays closed everywhere else. And a category that is itself a page gains
77
+ * that page as its first entry — folded, its title becomes the handle of the
78
+ * fold, which cannot be a link as well without a click meaning two things.
79
+ *
80
+ * The tree is rebuilt rather than marked in place: it is shared by every page
81
+ * of the version, and marking it would leave one page's branch open on all the
82
+ * others.
83
+ *
84
+ * @typedef {object} FoldedNode
85
+ * @property {string} label
86
+ * @property {string | null} url
87
+ * @property {FoldedNode[]} items
88
+ * @property {boolean} [open] Whether the category starts open. Absent on a
89
+ * plain entry, which has nothing to fold.
90
+ */
91
+ /**
92
+ * @param {SidebarNode[]} nodes
93
+ * @param {string} [currentUrl] URL of the page being rendered.
94
+ * @returns {FoldedNode[]}
95
+ */
96
+ export declare function foldSidebar(nodes: SidebarNode[], currentUrl?: string): FoldedNode[];
61
97
  export type SidebarEntry = string | {
62
98
  page?: string;
63
99
  label?: string;