@docpensieve/core 0.4.0-beta.2 → 0.5.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/client/search.js +22 -4
- package/package.json +2 -2
- package/src/authors.js +15 -10
- package/src/config.js +178 -0
- package/src/generator.js +401 -262
- package/src/search-index.js +19 -5
- package/templates/header-menu.hbs +20 -2
- package/templates/layout.hbs +30 -18
- package/types/authors.d.ts +4 -2
- package/types/config.d.ts +30 -0
- package/types/search-index.d.ts +3 -1
package/src/generator.js
CHANGED
|
@@ -10,6 +10,7 @@ import { fileURLToPath } from 'node:url';
|
|
|
10
10
|
|
|
11
11
|
import {
|
|
12
12
|
ConfigError,
|
|
13
|
+
DEFAULT_LANGUAGE,
|
|
13
14
|
DOC_EXTENSIONS,
|
|
14
15
|
assetPathToSlug,
|
|
15
16
|
dirPathToSlug,
|
|
@@ -17,6 +18,8 @@ import {
|
|
|
17
18
|
PAGE_LAYOUTS,
|
|
18
19
|
ThemeError,
|
|
19
20
|
VERSIONS_MANIFEST,
|
|
21
|
+
textDirection,
|
|
22
|
+
uiStrings,
|
|
20
23
|
} from '@docpensieve/shared';
|
|
21
24
|
import Handlebars from 'handlebars';
|
|
22
25
|
|
|
@@ -225,33 +228,68 @@ export class SiteGenerator {
|
|
|
225
228
|
});
|
|
226
229
|
}
|
|
227
230
|
|
|
228
|
-
|
|
229
|
-
|
|
231
|
+
// The version is served in the language of the site; each translation is
|
|
232
|
+
// served under its own code. Nothing already published moves that way, and
|
|
233
|
+
// one orphan branch still holds the whole version.
|
|
234
|
+
const languages = [
|
|
235
|
+
{ lang: this.config.lang ?? DEFAULT_LANGUAGE, folder: version.folder, suffix: '', target },
|
|
236
|
+
...Object.entries(version.translations ?? {}).map(([lang, folder]) => ({
|
|
237
|
+
lang,
|
|
238
|
+
folder,
|
|
239
|
+
suffix: lang,
|
|
240
|
+
target: path.join(target, lang),
|
|
241
|
+
})),
|
|
242
|
+
];
|
|
243
|
+
|
|
244
|
+
// Every language is read before any page is written: a page knows its
|
|
245
|
+
// twins only once the others are known, and the language switcher must
|
|
246
|
+
// never offer an address that leads nowhere.
|
|
247
|
+
const reading = [];
|
|
248
|
+
for (const language of languages) {
|
|
249
|
+
const dir = path.resolve(rootDir, language.folder);
|
|
250
|
+
let pages;
|
|
251
|
+
try {
|
|
252
|
+
pages = await this.loader.load(dir);
|
|
253
|
+
} catch (cause) {
|
|
254
|
+
// The folder of the version says what it is by itself; a translation
|
|
255
|
+
// folder does not, and "folder not found" left the reader looking for
|
|
256
|
+
// which of the two was missing.
|
|
257
|
+
if (language.suffix === '') throw cause;
|
|
258
|
+
throw new GeneratorError(
|
|
259
|
+
`The "${language.lang}" translation of version "${version.slug}" cannot be read.`,
|
|
260
|
+
{
|
|
261
|
+
cause,
|
|
262
|
+
hint: `Expected its pages here: ${language.folder}. Remove the entry from "translations" to stop publishing that language.`,
|
|
263
|
+
},
|
|
264
|
+
);
|
|
265
|
+
}
|
|
230
266
|
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
267
|
+
// A version without pages still published a redirect to itself: the site
|
|
268
|
+
// root led to a page that did not exist.
|
|
269
|
+
if (pages.length === 0) {
|
|
270
|
+
throw new GeneratorError(
|
|
271
|
+
language.suffix === ''
|
|
272
|
+
? `Version "${version.slug}" has no page to publish.`
|
|
273
|
+
: `The "${language.lang}" translation of version "${version.slug}" has no page to publish.`,
|
|
274
|
+
{
|
|
275
|
+
hint: `Add a page to ${language.folder}, or remove "draft: true" from the existing ones.`,
|
|
276
|
+
},
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
reading.push({
|
|
280
|
+
...language,
|
|
281
|
+
sourceDir: dir,
|
|
282
|
+
docs: pages,
|
|
283
|
+
slugs: new Set(pages.map((doc) => doc.slug)),
|
|
236
284
|
});
|
|
237
285
|
}
|
|
238
286
|
|
|
239
|
-
const
|
|
240
|
-
/** @param {import('./loader.js').Doc} doc */
|
|
241
|
-
const pageUrl = (doc) => joinUrl(versionBase, doc.slug);
|
|
287
|
+
const versionRoot = joinUrl(this.config.baseUrl, 'versions', version.slug);
|
|
242
288
|
|
|
243
289
|
const current = this.config.versions.find((candidate) => candidate.current);
|
|
244
290
|
const notice = versionNotice(version, current, this.config.baseUrl);
|
|
245
291
|
|
|
246
|
-
// 'auto' follows the file tree; otherwise each version describes its menu
|
|
247
|
-
// in a file of its own, since each has its own pages.
|
|
248
|
-
const described =
|
|
249
|
-
this.config.sidebar && this.config.sidebar !== 'auto'
|
|
250
|
-
? await this.#describedSidebar(sourceDir, docs, pageUrl, version.folder)
|
|
251
|
-
: null;
|
|
252
|
-
const sidebar = described ?? buildSidebar(docs, pageUrl, { brand: this.config.projectName });
|
|
253
292
|
const folded = this.config.foldedSidebar === true;
|
|
254
|
-
const breadcrumbTitles = collectSectionTitles(docs);
|
|
255
293
|
const layout = await this.#loadLayout();
|
|
256
294
|
const classes = this.#classes();
|
|
257
295
|
|
|
@@ -271,269 +309,370 @@ export class SiteGenerator {
|
|
|
271
309
|
|
|
272
310
|
// The project's images go into every version: each one stands on its
|
|
273
311
|
// own, down to the orphan branch it is published on.
|
|
274
|
-
const images = await this.#copyImages(target,
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
const
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
const
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
312
|
+
const images = await this.#copyImages(target, versionRoot, written, version);
|
|
313
|
+
|
|
314
|
+
/** @type {import('./discovery.js').PublishedPage[]} */
|
|
315
|
+
const published = [];
|
|
316
|
+
let pageTotal = 0;
|
|
317
|
+
|
|
318
|
+
for (const language of reading) {
|
|
319
|
+
const { sourceDir, docs } = language;
|
|
320
|
+
const versionBase = joinUrl(this.config.baseUrl, 'versions', version.slug, language.suffix);
|
|
321
|
+
/** @param {import('./loader.js').Doc} doc */
|
|
322
|
+
const pageUrl = (doc) => joinUrl(versionBase, doc.slug);
|
|
323
|
+
|
|
324
|
+
// 'auto' follows the file tree; otherwise each version describes its menu
|
|
325
|
+
// in a file of its own, since each has its own pages.
|
|
326
|
+
const described =
|
|
327
|
+
this.config.sidebar && this.config.sidebar !== 'auto'
|
|
328
|
+
? await this.#describedSidebar(sourceDir, docs, pageUrl, language.folder)
|
|
329
|
+
: null;
|
|
330
|
+
const sidebar = described ?? buildSidebar(docs, pageUrl, { brand: this.config.projectName });
|
|
331
|
+
const breadcrumbTitles = collectSectionTitles(docs);
|
|
332
|
+
|
|
333
|
+
// The languages this version offers, and where the page being read has a
|
|
334
|
+
// twin. A language the page was never translated into is announced and
|
|
335
|
+
// disabled rather than hidden: the reader learns the site has it.
|
|
336
|
+
const languageLinks =
|
|
337
|
+
reading.length > 1
|
|
338
|
+
? reading.map((other) => ({
|
|
339
|
+
lang: other.lang,
|
|
340
|
+
label: other.lang.toUpperCase(),
|
|
341
|
+
base: joinUrl(this.config.baseUrl, 'versions', version.slug, other.suffix),
|
|
342
|
+
slugs: other.slugs,
|
|
343
|
+
current: other.lang === language.lang,
|
|
344
|
+
// The language the pages are written in answers for the readers
|
|
345
|
+
// whose own language the site does not have.
|
|
346
|
+
default: other.suffix === '',
|
|
347
|
+
}))
|
|
348
|
+
: [];
|
|
349
|
+
|
|
350
|
+
// What every page of the version shares, the search page included.
|
|
351
|
+
const searchUrl = this.config.search !== false ? joinUrl(versionBase, SEARCH_SLUG) : '';
|
|
352
|
+
// Described once per version, like the menu: the same authors serve every
|
|
353
|
+
// page, and a biography corrected in one version leaves the others alone.
|
|
354
|
+
const authors = await this.#readAuthors(sourceDir, language.folder, versionBase);
|
|
355
|
+
|
|
356
|
+
// Links of the header, resolved once per version. A link naming a version
|
|
357
|
+
// leads there from every version: a section written in one version only
|
|
358
|
+
// stays reachable from the others.
|
|
359
|
+
/** @param {{ href: string, version?: string }} link */
|
|
360
|
+
const headerTarget = (link) =>
|
|
361
|
+
EXTERNAL_PREVIEW.test(link.href)
|
|
362
|
+
? link.href
|
|
363
|
+
: joinUrl(this.config.baseUrl, 'versions', link.version ?? version.slug) +
|
|
364
|
+
link.href.replace(/^\/+/, '');
|
|
365
|
+
|
|
366
|
+
const headerLinks = (this.config.headerLinks ?? []).map((link) =>
|
|
367
|
+
link.columns
|
|
368
|
+
? {
|
|
369
|
+
label: link.label,
|
|
370
|
+
columns: link.columns.map((column) => ({
|
|
371
|
+
title: column.title,
|
|
372
|
+
items: column.items.map((item) => ({
|
|
373
|
+
label: item.label,
|
|
374
|
+
href: headerTarget(item),
|
|
375
|
+
})),
|
|
376
|
+
})),
|
|
377
|
+
}
|
|
378
|
+
: // Without columns the configuration has checked the target: an entry
|
|
379
|
+
// that leads nowhere and opens nothing never gets here.
|
|
380
|
+
{
|
|
381
|
+
label: link.label,
|
|
382
|
+
href: headerTarget({ href: String(link.href), version: link.version }),
|
|
383
|
+
},
|
|
384
|
+
);
|
|
308
385
|
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
:
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
:
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
description: doc.frontmatter.description ? String(doc.frontmatter.description) : undefined,
|
|
362
|
-
preview:
|
|
363
|
-
target === '' || EXTERNAL_PREVIEW.test(target)
|
|
364
|
-
? target || undefined
|
|
365
|
-
: new URL(
|
|
366
|
-
absolute ? target.slice(1) : target,
|
|
367
|
-
`https://docpensieve.invalid${absolute ? versionBase : dirUrl}`,
|
|
368
|
-
).pathname,
|
|
369
|
-
// The same date the byline and the sitemap read, formatted once.
|
|
370
|
-
modified:
|
|
371
|
-
readDate(
|
|
372
|
-
doc.frontmatter.modified ?? doc.frontmatter.date,
|
|
373
|
-
'modified',
|
|
374
|
-
doc.slug || 'the home page',
|
|
375
|
-
) ?? undefined,
|
|
386
|
+
const shell = {
|
|
387
|
+
lang: language.lang,
|
|
388
|
+
// Right-to-left languages need the attribute to be readable at all;
|
|
389
|
+
// it comes from the language itself rather than from a list of ours.
|
|
390
|
+
dir: textDirection(language.lang),
|
|
391
|
+
// The words the shell adds around the pages. In the language of the
|
|
392
|
+
// pages: left in English, they would announce the language of the tool
|
|
393
|
+
// rather than the language of the documentation.
|
|
394
|
+
ui: uiStrings(language.lang, this.config.ui),
|
|
395
|
+
// A fixed scheme is a class on <html>, which the skins and the dark
|
|
396
|
+
// variant of the utilities both obey.
|
|
397
|
+
darkModeClass: ['dark', 'light'].includes(this.config.theme?.darkMode ?? '')
|
|
398
|
+
? this.config.theme.darkMode
|
|
399
|
+
: null,
|
|
400
|
+
projectName: this.config.projectName,
|
|
401
|
+
versionName: version.name,
|
|
402
|
+
homeUrl: versionBase,
|
|
403
|
+
// One stylesheet for the whole version, translations included: it is
|
|
404
|
+
// compiled from the classes of every language, and written once at the
|
|
405
|
+
// root of the version.
|
|
406
|
+
cssHref: joinUrl(versionRoot, path.dirname(STYLESHEET)) + path.basename(STYLESHEET),
|
|
407
|
+
feedUrl: this.#feedUrl(),
|
|
408
|
+
logoUrl: images.logo ?? '',
|
|
409
|
+
favicon: images.favicon
|
|
410
|
+
? {
|
|
411
|
+
href: images.favicon,
|
|
412
|
+
type: FAVICON_TYPES[path.extname(images.favicon).toLowerCase()],
|
|
413
|
+
}
|
|
414
|
+
: null,
|
|
415
|
+
// Social networks only read an absolute address: normalisation
|
|
416
|
+
// refuses a preview image without siteUrl.
|
|
417
|
+
socialImage:
|
|
418
|
+
images.socialImage && this.config.siteUrl
|
|
419
|
+
? new URL(images.socialImage, this.config.siteUrl).href
|
|
420
|
+
: '',
|
|
421
|
+
searchUrl,
|
|
422
|
+
// Held at the top of the screen unless the project gives that height
|
|
423
|
+
// back to the text.
|
|
424
|
+
stickyHeader: this.config.stickyHeader !== false,
|
|
425
|
+
headerLinks,
|
|
426
|
+
// A menu with nothing in it would be a button that opens onto nothing.
|
|
427
|
+
headerMenu: this.config.versions.length > 1 || headerLinks.length > 0 || searchUrl !== '',
|
|
428
|
+
// The light / dark switch: a button, and the few lines of script it needs.
|
|
429
|
+
schemeToggle: this.config.theme?.toggle !== false,
|
|
430
|
+
cls: classes,
|
|
431
|
+
versions: this.#versionLinks(version.slug),
|
|
432
|
+
// A switcher offering a single choice is not a switcher.
|
|
433
|
+
showVersions: this.config.versions.length > 1,
|
|
434
|
+
// The back-to-top button is page furniture, not content: writing it in
|
|
435
|
+
// every file would repeat it everywhere, and forget it somewhere.
|
|
436
|
+
scrollToTop: this.config.scrollToTop !== false,
|
|
437
|
+
notice,
|
|
376
438
|
};
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
439
|
+
// Every page of the version, for the components that list pages: one of
|
|
440
|
+
// them renders a single page at a time and could never gather this by
|
|
441
|
+
// itself. Targets are resolved here, each against the folder of the page
|
|
442
|
+
// that declares it — a preview written in one page is not relative to the
|
|
443
|
+
// page that shows it in a card.
|
|
444
|
+
const pages = docs.map((doc) => {
|
|
445
|
+
const folder = dirPathToSlug(path.relative(sourceDir, path.dirname(doc.path)));
|
|
446
|
+
const dirUrl = joinUrl(versionBase, folder);
|
|
447
|
+
const target = String(doc.frontmatter.preview ?? '');
|
|
448
|
+
const absolute = target.startsWith('/');
|
|
449
|
+
|
|
450
|
+
return {
|
|
451
|
+
url: pageUrl(doc),
|
|
452
|
+
slug: doc.slug,
|
|
453
|
+
title: String(doc.frontmatter.title ?? this.config.projectName),
|
|
454
|
+
description: doc.frontmatter.description
|
|
455
|
+
? String(doc.frontmatter.description)
|
|
456
|
+
: undefined,
|
|
457
|
+
preview:
|
|
458
|
+
target === '' || EXTERNAL_PREVIEW.test(target)
|
|
459
|
+
? target || undefined
|
|
460
|
+
: new URL(
|
|
461
|
+
absolute ? target.slice(1) : target,
|
|
462
|
+
`https://docpensieve.invalid${absolute ? versionBase : dirUrl}`,
|
|
463
|
+
).pathname,
|
|
464
|
+
// The same date the byline and the sitemap read, formatted once.
|
|
465
|
+
modified:
|
|
466
|
+
readDate(
|
|
467
|
+
doc.frontmatter.modified ?? doc.frontmatter.date,
|
|
468
|
+
'modified',
|
|
469
|
+
doc.slug || 'the home page',
|
|
470
|
+
shell.ui.dateLocale,
|
|
471
|
+
) ?? undefined,
|
|
472
|
+
};
|
|
409
473
|
});
|
|
410
474
|
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
text: htmlToText(html),
|
|
435
|
-
});
|
|
475
|
+
/** @type {{ title: string, url: string, description: string, text: string }[]} */
|
|
476
|
+
const entries = [];
|
|
477
|
+
|
|
478
|
+
for (const doc of docs) {
|
|
479
|
+
const url = pageUrl(doc);
|
|
480
|
+
|
|
481
|
+
// Base of relative targets: the source file's folder, mapped into URL
|
|
482
|
+
// space. Not the page URL, which has one more level — `./diagram.png`
|
|
483
|
+
// written in `guide/install.md` would otherwise point to
|
|
484
|
+
// `/guide/install/diagram.png`, whereas the file is output under `/guide/`.
|
|
485
|
+
const folder = dirPathToSlug(path.relative(sourceDir, path.dirname(doc.path)));
|
|
486
|
+
const dirUrl = joinUrl(versionBase, folder);
|
|
487
|
+
// Components need to know which page they render: a link they produce
|
|
488
|
+
// escapes the compiler plugins (ADR-006).
|
|
489
|
+
this.deps.onPage?.({
|
|
490
|
+
url,
|
|
491
|
+
dirUrl,
|
|
492
|
+
basePath: versionBase,
|
|
493
|
+
filepath: doc.path,
|
|
494
|
+
sourceDir,
|
|
495
|
+
slug: doc.slug,
|
|
496
|
+
pages,
|
|
497
|
+
});
|
|
436
498
|
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
credits.updated && { prefix: 'Updated', ...credits.updated },
|
|
445
|
-
].filter(Boolean),
|
|
446
|
-
};
|
|
447
|
-
|
|
448
|
-
const page = layout({
|
|
449
|
-
...shell,
|
|
450
|
-
title: documentTitle(doc.frontmatter.title, this.config.projectName),
|
|
451
|
-
description: doc.frontmatter.description ?? '',
|
|
452
|
-
canonical: this.config.siteUrl ? new URL(url, this.config.siteUrl).href : '',
|
|
453
|
-
currentUrl: url,
|
|
454
|
-
wide,
|
|
455
|
-
// A version in preparation must not compete with the current one:
|
|
456
|
-
// same content, two addresses, and the wrong one comes up. "follow"
|
|
457
|
-
// still lets its links be followed.
|
|
458
|
-
noindex: version.prerelease === true,
|
|
459
|
-
byline,
|
|
460
|
-
tags: wide ? [] : pageTags(doc.frontmatter.tags),
|
|
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,
|
|
465
|
-
toc: wide ? [] : toc,
|
|
466
|
-
preloads,
|
|
467
|
-
content: html,
|
|
468
|
-
jsonld,
|
|
469
|
-
});
|
|
499
|
+
const { html, toc, preloads } = await this.compiler.compile(doc.content, {
|
|
500
|
+
filepath: doc.path,
|
|
501
|
+
url,
|
|
502
|
+
dirUrl,
|
|
503
|
+
basePath: versionBase,
|
|
504
|
+
sourceDir,
|
|
505
|
+
});
|
|
470
506
|
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
507
|
+
// Who wrote the page, and when. Read before the structured data, which
|
|
508
|
+
// describes the same people: the page and its metadata must not
|
|
509
|
+
// disagree about an author.
|
|
510
|
+
const credits = buildByline(
|
|
511
|
+
doc.frontmatter,
|
|
512
|
+
authors,
|
|
513
|
+
doc.slug || 'the home page',
|
|
514
|
+
shell.ui.dateLocale,
|
|
515
|
+
);
|
|
474
516
|
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
517
|
+
const jsonld = new StructuredDataBuilder(doc.frontmatter, url, this.config, {
|
|
518
|
+
breadcrumbTitles,
|
|
519
|
+
basePath: versionBase,
|
|
520
|
+
dirUrl,
|
|
521
|
+
logo: images.logo,
|
|
522
|
+
// A described author carries a biography and a link, which a bare
|
|
523
|
+
// name in the frontmatter cannot.
|
|
524
|
+
authors: credits?.authors ?? [],
|
|
525
|
+
}).toScriptTag();
|
|
526
|
+
|
|
527
|
+
// A home page has neither menu nor table of contents: those are reading
|
|
528
|
+
// landmarks within a document, not in an entrance hall.
|
|
529
|
+
const wide = pageLayout(doc) === 'home';
|
|
530
|
+
|
|
531
|
+
entries.push({
|
|
532
|
+
title: String(doc.frontmatter.title ?? this.config.projectName),
|
|
533
|
+
url,
|
|
534
|
+
description: String(doc.frontmatter.description ?? ''),
|
|
535
|
+
text: htmlToText(html),
|
|
536
|
+
});
|
|
479
537
|
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
538
|
+
const byline =
|
|
539
|
+
wide || !credits
|
|
540
|
+
? null
|
|
541
|
+
: {
|
|
542
|
+
authors: credits.authors,
|
|
543
|
+
dates: [
|
|
544
|
+
credits.created && { prefix: shell.ui.written, ...credits.created },
|
|
545
|
+
credits.updated && { prefix: shell.ui.updated, ...credits.updated },
|
|
546
|
+
].filter(Boolean),
|
|
547
|
+
};
|
|
548
|
+
|
|
549
|
+
const page = layout({
|
|
550
|
+
...shell,
|
|
551
|
+
title: documentTitle(doc.frontmatter.title, this.config.projectName),
|
|
552
|
+
description: doc.frontmatter.description ?? '',
|
|
553
|
+
canonical: this.config.siteUrl ? new URL(url, this.config.siteUrl).href : '',
|
|
554
|
+
currentUrl: url,
|
|
555
|
+
wide,
|
|
556
|
+
// A version in preparation must not compete with the current one:
|
|
557
|
+
// same content, two addresses, and the wrong one comes up. "follow"
|
|
558
|
+
// still lets its links be followed.
|
|
559
|
+
noindex: version.prerelease === true,
|
|
560
|
+
// Where this page exists in the other languages. An entry without a
|
|
561
|
+
// url is a language the page was never translated into: announced,
|
|
562
|
+
// never offered, because a link leading nowhere is worse than none.
|
|
563
|
+
languages: languageLinks.map((other) => ({
|
|
564
|
+
lang: other.lang,
|
|
565
|
+
label: other.label,
|
|
566
|
+
current: other.current,
|
|
567
|
+
default: other.default,
|
|
568
|
+
url: other.slugs.has(doc.slug) ? this.#absolute(joinUrl(other.base, doc.slug)) : '',
|
|
569
|
+
})),
|
|
570
|
+
byline,
|
|
571
|
+
tags: wide ? [] : pageTags(doc.frontmatter.tags),
|
|
572
|
+
// Folded, the menu opens on the branch of the page being rendered, so
|
|
573
|
+
// it is prepared per page rather than once per version.
|
|
574
|
+
sidebar: wide ? [] : folded ? foldSidebar(sidebar, url) : sidebar,
|
|
575
|
+
foldedSidebar: folded,
|
|
576
|
+
toc: wide ? [] : toc,
|
|
577
|
+
preloads,
|
|
578
|
+
content: html,
|
|
579
|
+
jsonld,
|
|
488
580
|
});
|
|
581
|
+
|
|
582
|
+
for (const [, value] of page.matchAll(CLASS_ATTRIBUTE)) {
|
|
583
|
+
for (const token of value.split(/\s+/)) if (token) candidates.add(token);
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
const destination = path.join(
|
|
587
|
+
language.target,
|
|
588
|
+
...doc.slug.split('/').filter(Boolean),
|
|
589
|
+
'index.html',
|
|
590
|
+
);
|
|
591
|
+
written.set(destination, path.relative(sourceDir, doc.path).split(path.sep).join('/'));
|
|
592
|
+
await this.#write(destination, page);
|
|
489
593
|
}
|
|
490
594
|
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
595
|
+
// The search page and the index it reads, built with the site: content
|
|
596
|
+
// pages load no script, and this page is useful before its own runs.
|
|
597
|
+
if (searchUrl) {
|
|
598
|
+
const destination = path.join(language.target, SEARCH_SLUG, 'index.html');
|
|
599
|
+
const taken = written.get(destination);
|
|
600
|
+
if (taken !== undefined) {
|
|
601
|
+
throw new GeneratorError(`"${taken}" takes the place of the search page, ${searchUrl}.`, {
|
|
602
|
+
hint: 'Rename that page, or set search: false in the configuration.',
|
|
603
|
+
});
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
/** @param {string} file */
|
|
607
|
+
const assetUrl = (file) =>
|
|
608
|
+
joinUrl(versionBase, path.posix.dirname(file)) + path.posix.basename(file);
|
|
609
|
+
const indexFile = path.join(language.target, ...SEARCH_INDEX.split('/'));
|
|
610
|
+
const scriptFile = path.join(language.target, ...SEARCH_SCRIPT.split('/'));
|
|
611
|
+
await this.#write(indexFile, JSON.stringify(entries));
|
|
612
|
+
await mkdir(path.dirname(scriptFile), { recursive: true });
|
|
613
|
+
await copyFile(CLIENT_SEARCH, scriptFile);
|
|
614
|
+
|
|
615
|
+
const page = layout({
|
|
616
|
+
...shell,
|
|
617
|
+
title: documentTitle(shell.ui.searchTitle, this.config.projectName),
|
|
618
|
+
description: `Search the pages of ${this.config.projectName} ${version.name}.`,
|
|
619
|
+
canonical: '',
|
|
620
|
+
currentUrl: searchUrl,
|
|
621
|
+
wide: false,
|
|
622
|
+
// A list of every page, and a script: nothing a search engine should
|
|
623
|
+
// offer as a result.
|
|
624
|
+
noindex: true,
|
|
625
|
+
// The search page is written in every language, so every one of them
|
|
626
|
+
// has this twin.
|
|
627
|
+
languages: languageLinks.map((other) => ({
|
|
628
|
+
lang: other.lang,
|
|
629
|
+
label: other.label,
|
|
630
|
+
current: other.current,
|
|
631
|
+
default: other.default,
|
|
632
|
+
url: this.#absolute(joinUrl(other.base, SEARCH_SLUG)),
|
|
633
|
+
})),
|
|
634
|
+
sidebar,
|
|
635
|
+
toc: [],
|
|
636
|
+
preloads: [],
|
|
637
|
+
scripts: [assetUrl(SEARCH_SCRIPT)],
|
|
638
|
+
content: searchPageContent(entries, assetUrl(SEARCH_INDEX), shell.ui, language.lang),
|
|
639
|
+
jsonld: '',
|
|
640
|
+
});
|
|
641
|
+
for (const [, value] of page.matchAll(CLASS_ATTRIBUTE)) {
|
|
642
|
+
for (const token of value.split(/\s+/)) if (token) candidates.add(token);
|
|
643
|
+
}
|
|
644
|
+
await this.#write(destination, page);
|
|
645
|
+
for (const file of [destination, indexFile, scriptFile])
|
|
646
|
+
written.set(file, 'the search page');
|
|
519
647
|
}
|
|
520
|
-
await this.#write(destination, page);
|
|
521
|
-
for (const file of [destination, indexFile, scriptFile]) written.set(file, 'the search page');
|
|
522
|
-
}
|
|
523
648
|
|
|
524
|
-
|
|
649
|
+
await this.#copyAssets(sourceDir, language.target, '', written);
|
|
650
|
+
|
|
651
|
+
published.push(...docs.map((doc) => ({ url: pageUrl(doc), frontmatter: doc.frontmatter })));
|
|
652
|
+
pageTotal += docs.length;
|
|
653
|
+
}
|
|
525
654
|
|
|
526
|
-
// The stylesheet is compiled last: it needs the classes
|
|
655
|
+
// The stylesheet is compiled last: it needs the classes of every language.
|
|
527
656
|
const { css } = await this.deps.theme.compile({ candidates: [...candidates] });
|
|
528
657
|
// Comments and indentation make the stylesheet readable, and heavier on
|
|
529
658
|
// every page: the reader receives it minified.
|
|
530
659
|
await this.#write(path.join(target, ...STYLESHEET.split('/')), minifyCss(css));
|
|
531
660
|
|
|
532
|
-
return {
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
661
|
+
return { pages: pageTotal, outDir: target, published };
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
/**
|
|
665
|
+
* The address of a page as another machine reads it.
|
|
666
|
+
*
|
|
667
|
+
* `hreflang` is followed from outside the site, so a path alone would only
|
|
668
|
+
* work by chance. Without `siteUrl` the path is all there is, and it still
|
|
669
|
+
* serves the language switcher inside the page.
|
|
670
|
+
*
|
|
671
|
+
* @param {string} urlPath Path within the site.
|
|
672
|
+
* @returns {string}
|
|
673
|
+
*/
|
|
674
|
+
#absolute(urlPath) {
|
|
675
|
+
return this.config.siteUrl ? new URL(urlPath, this.config.siteUrl).href : urlPath;
|
|
537
676
|
}
|
|
538
677
|
|
|
539
678
|
/**
|