@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/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
- const sourceDir = path.resolve(rootDir, version.folder);
229
- const docs = await this.loader.load(sourceDir);
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
- // A version without pages still published a redirect to itself: the site
232
- // root led to a page that did not exist.
233
- if (docs.length === 0) {
234
- throw new GeneratorError(`Version "${version.slug}" has no page to publish.`, {
235
- hint: `Add a page to ${version.folder}, or remove "draft: true" from the existing ones.`,
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 versionBase = joinUrl(this.config.baseUrl, 'versions', version.slug);
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, versionBase, written, version);
275
-
276
- // What every page of the version shares, the search page included.
277
- const searchUrl = this.config.search !== false ? joinUrl(versionBase, SEARCH_SLUG) : '';
278
- // Described once per version, like the menu: the same authors serve every
279
- // page, and a biography corrected in one version leaves the others alone.
280
- const authors = await this.#readAuthors(sourceDir, version.folder, versionBase);
281
-
282
- // Links of the header, resolved once per version. A link naming a version
283
- // leads there from every version: a section written in one version only
284
- // stays reachable from the others.
285
- /** @param {{ href: string, version?: string }} link */
286
- const headerTarget = (link) =>
287
- EXTERNAL_PREVIEW.test(link.href)
288
- ? link.href
289
- : joinUrl(this.config.baseUrl, 'versions', link.version ?? version.slug) +
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
- );
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
- const shell = {
310
- lang: this.config.lang ?? 'en',
311
- // A fixed scheme is a class on <html>, which the skins and the dark
312
- // variant of the utilities both obey.
313
- darkModeClass: ['dark', 'light'].includes(this.config.theme?.darkMode ?? '')
314
- ? this.config.theme.darkMode
315
- : null,
316
- projectName: this.config.projectName,
317
- versionName: version.name,
318
- homeUrl: versionBase,
319
- cssHref: joinUrl(versionBase, path.dirname(STYLESHEET)) + path.basename(STYLESHEET),
320
- feedUrl: this.#feedUrl(),
321
- logoUrl: images.logo ?? '',
322
- favicon: images.favicon
323
- ? { href: images.favicon, type: FAVICON_TYPES[path.extname(images.favicon).toLowerCase()] }
324
- : null,
325
- // Social networks only read an absolute address: normalisation
326
- // refuses a preview image without siteUrl.
327
- socialImage:
328
- images.socialImage && this.config.siteUrl
329
- ? new URL(images.socialImage, this.config.siteUrl).href
330
- : '',
331
- searchUrl,
332
- headerLinks,
333
- // A menu with nothing in it would be a button that opens onto nothing.
334
- headerMenu: this.config.versions.length > 1 || headerLinks.length > 0 || searchUrl !== '',
335
- // The light / dark switch: a button, and the few lines of script it needs.
336
- schemeToggle: this.config.theme?.toggle !== false,
337
- cls: classes,
338
- versions: this.#versionLinks(version.slug),
339
- // A switcher offering a single choice is not a switcher.
340
- showVersions: this.config.versions.length > 1,
341
- // The back-to-top button is page furniture, not content: writing it in
342
- // every file would repeat it everywhere, and forget it somewhere.
343
- scrollToTop: this.config.scrollToTop !== false,
344
- notice,
345
- };
346
- // Every page of the version, for the components that list pages: one of
347
- // them renders a single page at a time and could never gather this by
348
- // itself. Targets are resolved here, each against the folder of the page
349
- // that declares it a preview written in one page is not relative to the
350
- // page that shows it in a card.
351
- const pages = docs.map((doc) => {
352
- const folder = dirPathToSlug(path.relative(sourceDir, path.dirname(doc.path)));
353
- const dirUrl = joinUrl(versionBase, folder);
354
- const target = String(doc.frontmatter.preview ?? '');
355
- const absolute = target.startsWith('/');
356
-
357
- return {
358
- url: pageUrl(doc),
359
- slug: doc.slug,
360
- title: String(doc.frontmatter.title ?? this.config.projectName),
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
- /** @type {{ title: string, url: string, description: string, text: string }[]} */
380
- const entries = [];
381
-
382
- for (const doc of docs) {
383
- const url = pageUrl(doc);
384
-
385
- // Base of relative targets: the source file's folder, mapped into URL
386
- // space. Not the page URL, which has one more level — `./diagram.png`
387
- // written in `guide/install.md` would otherwise point to
388
- // `/guide/install/diagram.png`, whereas the file is output under `/guide/`.
389
- const folder = dirPathToSlug(path.relative(sourceDir, path.dirname(doc.path)));
390
- const dirUrl = joinUrl(versionBase, folder);
391
- // Components need to know which page they render: a link they produce
392
- // escapes the compiler plugins (ADR-006).
393
- this.deps.onPage?.({
394
- url,
395
- dirUrl,
396
- basePath: versionBase,
397
- filepath: doc.path,
398
- sourceDir,
399
- slug: doc.slug,
400
- pages,
401
- });
402
-
403
- const { html, toc, preloads } = await this.compiler.compile(doc.content, {
404
- filepath: doc.path,
405
- url,
406
- dirUrl,
407
- basePath: versionBase,
408
- sourceDir,
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
- // Who wrote the page, and when. Read before the structured data, which
412
- // describes the same people: the page and its metadata must not
413
- // disagree about an author.
414
- const credits = buildByline(doc.frontmatter, authors, doc.slug || 'the home page');
415
-
416
- const jsonld = new StructuredDataBuilder(doc.frontmatter, url, this.config, {
417
- breadcrumbTitles,
418
- basePath: versionBase,
419
- dirUrl,
420
- logo: images.logo,
421
- // A described author carries a biography and a link, which a bare
422
- // name in the frontmatter cannot.
423
- authors: credits?.authors ?? [],
424
- }).toScriptTag();
425
-
426
- // A home page has neither menu nor table of contents: those are reading
427
- // landmarks within a document, not in an entrance hall.
428
- const wide = pageLayout(doc) === 'home';
429
-
430
- entries.push({
431
- title: String(doc.frontmatter.title ?? this.config.projectName),
432
- url,
433
- description: String(doc.frontmatter.description ?? ''),
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
- const byline =
438
- wide || !credits
439
- ? null
440
- : {
441
- authors: credits.authors,
442
- dates: [
443
- credits.created && { prefix: 'Written', ...credits.created },
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
- for (const [, value] of page.matchAll(CLASS_ATTRIBUTE)) {
472
- for (const token of value.split(/\s+/)) if (token) candidates.add(token);
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
- const destination = path.join(target, ...doc.slug.split('/').filter(Boolean), 'index.html');
476
- written.set(destination, path.relative(sourceDir, doc.path).split(path.sep).join('/'));
477
- await this.#write(destination, page);
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
- // The search page and the index it reads, built with the site: content
481
- // pages load no script, and this page is useful before its own runs.
482
- if (searchUrl) {
483
- const destination = path.join(target, SEARCH_SLUG, 'index.html');
484
- const taken = written.get(destination);
485
- if (taken !== undefined) {
486
- throw new GeneratorError(`"${taken}" takes the place of the search page, ${searchUrl}.`, {
487
- hint: 'Rename that page, or set search: false in the configuration.',
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
- /** @param {string} file */
492
- const assetUrl = (file) =>
493
- joinUrl(versionBase, path.posix.dirname(file)) + path.posix.basename(file);
494
- const indexFile = path.join(target, ...SEARCH_INDEX.split('/'));
495
- const scriptFile = path.join(target, ...SEARCH_SCRIPT.split('/'));
496
- await this.#write(indexFile, JSON.stringify(entries));
497
- await mkdir(path.dirname(scriptFile), { recursive: true });
498
- await copyFile(CLIENT_SEARCH, scriptFile);
499
-
500
- const page = layout({
501
- ...shell,
502
- title: documentTitle('Search', this.config.projectName),
503
- description: `Search the pages of ${this.config.projectName} ${version.name}.`,
504
- canonical: '',
505
- currentUrl: searchUrl,
506
- wide: false,
507
- // A list of every page, and a script: nothing a search engine should
508
- // offer as a result.
509
- noindex: true,
510
- sidebar,
511
- toc: [],
512
- preloads: [],
513
- scripts: [assetUrl(SEARCH_SCRIPT)],
514
- content: searchPageContent(entries, assetUrl(SEARCH_INDEX)),
515
- jsonld: '',
516
- });
517
- for (const [, value] of page.matchAll(CLASS_ATTRIBUTE)) {
518
- for (const token of value.split(/\s+/)) if (token) candidates.add(token);
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
- await this.#copyAssets(sourceDir, target, '', written);
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 above.
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
- pages: docs.length,
534
- outDir: target,
535
- published: docs.map((doc) => ({ url: pageUrl(doc), frontmatter: doc.frontmatter })),
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
  /**