@inditextech/docouture-antora-extensions 0.1.0-SNAPSHOT.40.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/index.js +113 -0
- package/lib/duplicate-latest-version.js +148 -0
- package/lib/duplicate-latest-version.spec.js +204 -0
- package/lib/footer.js +122 -0
- package/lib/html-to-markdown.js +259 -0
- package/lib/html-to-markdown.spec.js +73 -0
- package/lib/kroki-docker.js +162 -0
- package/lib/kroki-docker.spec.js +92 -0
- package/lib/kroki-prewarm.js +182 -0
- package/lib/kroki-prewarm.spec.js +209 -0
- package/lib/lifecycle-log.js +61 -0
- package/lib/lifecycle-log.spec.js +59 -0
- package/lib/llms-txt.js +185 -0
- package/lib/llms-txt.spec.js +172 -0
- package/lib/nav-modules.js +233 -0
- package/lib/not-found-page.js +170 -0
- package/lib/redirects.js +249 -0
- package/lib/redirects.spec.js +129 -0
- package/lib/resolve-url.js +45 -0
- package/lib/search-index.js +279 -0
- package/lib/search-index.spec.js +104 -0
- package/lib/shiki-prewarm.js +76 -0
- package/lib/version-report.js +40 -0
- package/lib/version-report.spec.js +83 -0
- package/package.json +33 -0
- package/resources/kroki-compose.yml +72 -0
package/index.js
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const registerDuplicateLatestVersion = require('./lib/duplicate-latest-version')
|
|
4
|
+
const registerFooter = require('./lib/footer')
|
|
5
|
+
const registerKrokiPrewarm = require('./lib/kroki-prewarm')
|
|
6
|
+
const registerLifecycleLog = require('./lib/lifecycle-log')
|
|
7
|
+
const registerLlmsTxt = require('./lib/llms-txt')
|
|
8
|
+
const registerNavModules = require('./lib/nav-modules')
|
|
9
|
+
const registerNotFoundPage = require('./lib/not-found-page')
|
|
10
|
+
const registerRedirects = require('./lib/redirects')
|
|
11
|
+
const registerSearchIndex = require('./lib/search-index')
|
|
12
|
+
const registerShikiPrewarm = require('./lib/shiki-prewarm')
|
|
13
|
+
const registerVersionReport = require('./lib/version-report')
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Registers docouture' Antora pipeline extensions.
|
|
17
|
+
*
|
|
18
|
+
* These are ANTORA extensions, not Asciidoctor ones — a different contract
|
|
19
|
+
* and a different playbook key. They hook the site generator's own lifecycle
|
|
20
|
+
* (`antora.extensions` in the playbook), where the sibling
|
|
21
|
+
* @inditextech/docouture-asciidoc-extensions package hooks the AsciiDoc processor
|
|
22
|
+
* per page (`asciidoc.extensions`). Antora tells the two apart by inspecting
|
|
23
|
+
* `register.toString()` and warns when one is listed under the other's key.
|
|
24
|
+
*
|
|
25
|
+
* Declared with two named parameters, `(context, { config })` — GH redirects
|
|
26
|
+
* feature. @antora/site-generator's GeneratorContext._registerExtensions
|
|
27
|
+
* branches on `register.length`: zero parameters gets `register.call(context)`
|
|
28
|
+
* (no way to receive this extension entry's own playbook config at all);
|
|
29
|
+
* exactly one NAMED parameter gets `register(context)`, still no config;
|
|
30
|
+
* anything else — including this file's two — gets a plain
|
|
31
|
+
* `register(context, Object.assign({ config }, vars))` call, `config` being
|
|
32
|
+
* everything on this package's own `antora.extensions` entry in the playbook
|
|
33
|
+
* besides `enabled`/`id`/`require`. redirects.js's rules (see its own header)
|
|
34
|
+
* are authored there, not in `docs/antora.yml`, which is the one reason this
|
|
35
|
+
* package needs that config at all — every other sub-extension here still
|
|
36
|
+
* ignores it entirely. The one thing that still has to be avoided is the
|
|
37
|
+
* OTHER branch of that same detection: a parameter literally named `registry`
|
|
38
|
+
* is what trips the "Asciidoctor extension registered as an Antora extension"
|
|
39
|
+
* warning, so this stays `context`, never `registry`, same as before.
|
|
40
|
+
*
|
|
41
|
+
* REGISTRATION ORDER IS LOAD-BEARING. footer, search-index and llms-txt all
|
|
42
|
+
* listen on `navigationBuilt`, and GeneratorContext#notify awaits listeners
|
|
43
|
+
* in registration order (see nav-modules.js's own header for the citation),
|
|
44
|
+
* not declaration order within a single file — the order these calls are
|
|
45
|
+
* made IS that order. nav-modules MUST run first: it stamps `tree.module` /
|
|
46
|
+
* `tree.title` onto `componentVersion.navigation`, and both search-index and
|
|
47
|
+
* llms-txt (GH-95) read those same fields off the same event to build each
|
|
48
|
+
* record's/section's category. footer and llms-txt have no such dependency
|
|
49
|
+
* on each other and could run in either order after nav-modules; swap either
|
|
50
|
+
* of them with search-index and nothing throws — records/sections just fall
|
|
51
|
+
* back to filing themselves under the component title, as if no site
|
|
52
|
+
* declared `nav_modules` at all.
|
|
53
|
+
*
|
|
54
|
+
* shiki-prewarm (GH-89) listens on a DIFFERENT event (`contentAggregated`,
|
|
55
|
+
* not `navigationBuilt`) and touches none of the state the other four
|
|
56
|
+
* share, so its position in this list is not load-bearing the way theirs is
|
|
57
|
+
* — it is simply registered here too because this file is the one place
|
|
58
|
+
* `@antora/site-generator` is told about every docouture Antora extension.
|
|
59
|
+
*
|
|
60
|
+
* kroki-prewarm (GH-44) listens on the same `contentAggregated` event as
|
|
61
|
+
* shiki-prewarm, for the same reason (async work that must finish before
|
|
62
|
+
* Asciidoctor's synchronous conversion starts) but touches entirely
|
|
63
|
+
* different state (kroki-instance.js, not shiki-instance.js) — the two
|
|
64
|
+
* listeners are independent and their relative order is not load-bearing
|
|
65
|
+
* either.
|
|
66
|
+
*
|
|
67
|
+
* not-found-page also listens on `navigationBuilt`, but only to stash the
|
|
68
|
+
* `navigationCatalog` reference for later — it reads `tree.module` (which
|
|
69
|
+
* nav-modules.js stamps during THAT SAME event) only once `pagesComposed`
|
|
70
|
+
* fires, by which point every `navigationBuilt` listener, regardless of
|
|
71
|
+
* order, has already run to completion. Its position here is therefore not
|
|
72
|
+
* load-bearing either.
|
|
73
|
+
*
|
|
74
|
+
* version-report listens on yet another event (`contentClassified`, fired
|
|
75
|
+
* before `navigationBuilt`) and only reads `contentCatalog`, which none of
|
|
76
|
+
* the above write to — its position is not load-bearing either; it is
|
|
77
|
+
* simply a plain diagnostic report of what Antora already decided.
|
|
78
|
+
*
|
|
79
|
+
* lifecycle-log listens on EVERY documented generate-site.js event (see its
|
|
80
|
+
* own header for the full list and citation) purely to log when Antora
|
|
81
|
+
* itself enters each one and how long the previous phase took — it reads
|
|
82
|
+
* and writes no shared state at all, so its position is not load-bearing
|
|
83
|
+
* either. Registered FIRST anyway: for an event several extensions share
|
|
84
|
+
* (`contentAggregated`, `contentClassified`, `navigationBuilt`), that makes
|
|
85
|
+
* its "entering phase" trace line print before that event's own
|
|
86
|
+
* extension-specific work (and its logs) run, which reads chronologically
|
|
87
|
+
* rather than the other way round.
|
|
88
|
+
*
|
|
89
|
+
* redirects also listens on `navigationBuilt`, reading only real pages'
|
|
90
|
+
* already-computed `pub.url` — nothing nav-modules/footer/search-index/
|
|
91
|
+
* llms-txt/not-found-page write, and nothing that reads from it either — so
|
|
92
|
+
* its position is not load-bearing.
|
|
93
|
+
*
|
|
94
|
+
* duplicate-latest-version (GH #137) listens on `pagesComposed`, reading
|
|
95
|
+
* only `contentCatalog`'s already-rendered pages (`file.contents`,
|
|
96
|
+
* `file.out`, `file.pub`) and writing only new files into `siteCatalog` —
|
|
97
|
+
* nothing any other extension here reads or writes overlaps with it, so its
|
|
98
|
+
* position is not load-bearing either. Listed last simply because it's the
|
|
99
|
+
* newest addition.
|
|
100
|
+
*/
|
|
101
|
+
module.exports.register = function (context, { config }) {
|
|
102
|
+
registerLifecycleLog(context)
|
|
103
|
+
registerNavModules(context)
|
|
104
|
+
registerFooter(context)
|
|
105
|
+
registerSearchIndex(context)
|
|
106
|
+
registerLlmsTxt(context)
|
|
107
|
+
registerShikiPrewarm(context)
|
|
108
|
+
registerKrokiPrewarm(context)
|
|
109
|
+
registerNotFoundPage(context)
|
|
110
|
+
registerVersionReport(context)
|
|
111
|
+
registerRedirects(context, config?.redirects)
|
|
112
|
+
registerDuplicateLatestVersion(context, config)
|
|
113
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Duplicates a component's LATEST version's already-rendered pages, images
|
|
5
|
+
* and attachments as a second, fully independent copy published under a
|
|
6
|
+
* fixed `latest` URL segment — so `/…/<real-version>/…` (`stable` in
|
|
7
|
+
* standalone mode, whichever release tag Antora computed as latest in
|
|
8
|
+
* versioned mode) AND `/…/latest/…` both serve real, non-redirect content.
|
|
9
|
+
*
|
|
10
|
+
* Why this exists instead of `urls.latest_version_segment`: every one of
|
|
11
|
+
* Antora's own `latest_version_segment_strategy` values (`replace` — the
|
|
12
|
+
* default — `redirect:to`, `redirect:from`) treats one of the two segments
|
|
13
|
+
* as a symbolic alias and reduces the OTHER one to a single root-page
|
|
14
|
+
* redirect stub; every other page under the aliased-away segment stops
|
|
15
|
+
* being real at all. For a standalone (stable/prerelease) site that meant
|
|
16
|
+
* `/…/stable/…` — the URL its own feature name promises — silently stopped
|
|
17
|
+
* resolving to real content the moment a release existed. See GH #137 and
|
|
18
|
+
* this repo's `docs-site-package` skill (`reference/versioning-modes.md`,
|
|
19
|
+
* "URL routing") for the full story.
|
|
20
|
+
*
|
|
21
|
+
* Hooked on `pagesComposed` — after Antora has rendered every page's final
|
|
22
|
+
* HTML (`file.contents`) but before redirects/sitemap/publish — so what
|
|
23
|
+
* this does is add plain, already-rendered files straight to `siteCatalog`
|
|
24
|
+
* (the same catalog redirects.js and not-found-page.js add their own
|
|
25
|
+
* site-level files to), not `contentCatalog`: nothing here needs
|
|
26
|
+
* `contentCatalog`'s key-based duplicate bookkeeping or Vinyl wrapping,
|
|
27
|
+
* since Antora's own file-publisher only ever looks at two fields on a
|
|
28
|
+
* published file — `out.path` and `contents` — regardless of which catalog
|
|
29
|
+
* it came from.
|
|
30
|
+
*
|
|
31
|
+
* No HTML rewriting is needed to make this correct. This UI bundle's own
|
|
32
|
+
* `relativize` helper (see `packages/ui-bundle/src/helpers/relativize.ts`)
|
|
33
|
+
* computes every internal href as a PATH-RELATIVE offset from the current
|
|
34
|
+
* page's own directory — a pure function of directory DEPTH, never of the
|
|
35
|
+
* literal version string. Swapping one version segment for another
|
|
36
|
+
* (`stable` → `latest`) never changes how many segments a path has, so
|
|
37
|
+
* every href already baked into a real page's rendered `contents` — nav,
|
|
38
|
+
* breadcrumbs, pagination, even the version switcher's links to sibling
|
|
39
|
+
* versions — resolves exactly as correctly from the `/latest/…` clone as it
|
|
40
|
+
* did from the source version's own tree. This also means
|
|
41
|
+
* `<link rel="canonical">` (computed off the SOURCE page's own `pub.url`
|
|
42
|
+
* and already baked into `contents` before this extension ever sees it)
|
|
43
|
+
* keeps pointing at the source version by construction — the desired
|
|
44
|
+
* default, since it avoids duplicate-content SEO issues while both URLs
|
|
45
|
+
* still return full, real, 200-status content.
|
|
46
|
+
*
|
|
47
|
+
* Mode-agnostic on purpose: this always duplicates `component.latest` (the
|
|
48
|
+
* ComponentVersion Antora itself computes as latest, excluding
|
|
49
|
+
* prereleases by default) to the `latest` segment, for every component.
|
|
50
|
+
* That happens to be `stable` in standalone mode and whichever release tag
|
|
51
|
+
* is newest in versioned mode — no mode branching needed here at all. A
|
|
52
|
+
* versioned-mode site's `/latest/…` duplicate therefore moves to a new tag
|
|
53
|
+
* automatically the next time a newer one ships; nothing here has to know
|
|
54
|
+
* that happened.
|
|
55
|
+
*
|
|
56
|
+
* Deliberately does nothing when:
|
|
57
|
+
* - `config.duplicateLatestVersion` is not truthy (opt-in, like
|
|
58
|
+
* `redirects.js`'s own `rules` config).
|
|
59
|
+
* - a component has no non-prerelease version yet (`component.latest` is
|
|
60
|
+
* itself the prerelease — no real release exists to duplicate; a fresh
|
|
61
|
+
* standalone site with no `stable` tag yet, or a fresh versioned site
|
|
62
|
+
* with no release tag yet, should show only `/…/prerelease/…`, not a
|
|
63
|
+
* `/latest/…` alias for content that was never actually released).
|
|
64
|
+
* - `component.latest.version` already equals the `latest` segment
|
|
65
|
+
* itself (nothing to duplicate onto itself).
|
|
66
|
+
*
|
|
67
|
+
* Configured on the extension's own registration entry in the playbook —
|
|
68
|
+
* same place `redirects.js`'s rules live, for the same reason (this is
|
|
69
|
+
* build behaviour, not per-ref content). MUST be authored snake_case, like
|
|
70
|
+
* every other playbook key (`html_extension_style`, `latest_version_segment`,
|
|
71
|
+
* ...): `@antora/playbook-builder` lowercases every playbook key and only
|
|
72
|
+
* re-cases `_`/`-` boundaries back to camelCase (`build-playbook.js`'s own
|
|
73
|
+
* `camelCaseKeys`) — a camelCase-authored `duplicateLatestVersion: true`
|
|
74
|
+
* survives as `duplicatelatestversion`, which `config?.duplicateLatestVersion`
|
|
75
|
+
* below then reads as `undefined` and silently no-ops (GH #137 follow-up;
|
|
76
|
+
* this is exactly what happened the first time this shipped):
|
|
77
|
+
*
|
|
78
|
+
* antora:
|
|
79
|
+
* extensions:
|
|
80
|
+
* - require: '@inditextech/docouture-antora-extensions'
|
|
81
|
+
* duplicate_latest_version: true
|
|
82
|
+
*/
|
|
83
|
+
module.exports = function registerDuplicateLatestVersion(context, config) {
|
|
84
|
+
const logger = context.getLogger('docouture-duplicate-latest-version')
|
|
85
|
+
if (!config?.duplicateLatestVersion) return
|
|
86
|
+
|
|
87
|
+
const ALIAS_SEGMENT = 'latest'
|
|
88
|
+
|
|
89
|
+
context.on('pagesComposed', ({ contentCatalog, siteCatalog }) => {
|
|
90
|
+
for (const component of contentCatalog.getComponents()) {
|
|
91
|
+
const latest = component.latest
|
|
92
|
+
if (!latest || latest.prerelease) continue // no real release yet — nothing to duplicate
|
|
93
|
+
const sourceVersion = latest.version
|
|
94
|
+
if (sourceVersion === ALIAS_SEGMENT) continue // already the alias segment itself
|
|
95
|
+
|
|
96
|
+
const componentSegment = component.name === 'ROOT' ? '' : component.name
|
|
97
|
+
const sourceFiles = contentCatalog
|
|
98
|
+
.getFiles()
|
|
99
|
+
.filter((file) => file.out && file.src.component === component.name && file.src.version === sourceVersion)
|
|
100
|
+
|
|
101
|
+
let cloned = 0
|
|
102
|
+
for (const file of sourceFiles) {
|
|
103
|
+
const outPath = withReplacedVersionSegment(file.out.path, componentSegment, sourceVersion, ALIAS_SEGMENT)
|
|
104
|
+
if (outPath === undefined) {
|
|
105
|
+
logger.warn(
|
|
106
|
+
"Could not compute a '%s' alias path for %s (component '%s', version '%s') — skipping",
|
|
107
|
+
ALIAS_SEGMENT,
|
|
108
|
+
file.out.path,
|
|
109
|
+
component.name,
|
|
110
|
+
sourceVersion
|
|
111
|
+
)
|
|
112
|
+
continue
|
|
113
|
+
}
|
|
114
|
+
const clone = { out: { path: outPath }, contents: file.contents }
|
|
115
|
+
if (file.pub?.url) {
|
|
116
|
+
const pubUrl = withReplacedVersionSegment(file.pub.url, componentSegment, sourceVersion, ALIAS_SEGMENT, true)
|
|
117
|
+
if (pubUrl !== undefined) clone.pub = { url: pubUrl }
|
|
118
|
+
}
|
|
119
|
+
siteCatalog.addFile(clone)
|
|
120
|
+
cloned++
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (cloned) {
|
|
124
|
+
logger.info(
|
|
125
|
+
"Duplicated %s file(s) of %s's '%s' version under '/%s/' as a second, independent copy",
|
|
126
|
+
cloned,
|
|
127
|
+
component.name,
|
|
128
|
+
sourceVersion,
|
|
129
|
+
ALIAS_SEGMENT
|
|
130
|
+
)
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
})
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Replaces the `<version>` path segment in a real file's already-computed
|
|
137
|
+
// `out.path` (no leading slash) or `pub.url` (leading slash) with the alias
|
|
138
|
+
// segment — anchored to the exact `<component>/<version>/` (or `<version>/`
|
|
139
|
+
// for the ROOT component) prefix Antora's own computeOut/computePub always
|
|
140
|
+
// produce, so this never touches anything past that prefix even if the
|
|
141
|
+
// version string happens to reappear later in the path (e.g. inside a page
|
|
142
|
+
// name).
|
|
143
|
+
function withReplacedVersionSegment(str, componentSegment, oldVersion, newVersion, leadingSlash = false) {
|
|
144
|
+
const prefix = (leadingSlash ? '/' : '') + (componentSegment ? componentSegment + '/' : '')
|
|
145
|
+
const oldSegment = prefix + oldVersion + '/'
|
|
146
|
+
if (!str.startsWith(oldSegment)) return undefined
|
|
147
|
+
return prefix + newVersion + '/' + str.slice(oldSegment.length)
|
|
148
|
+
}
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
4
|
+
|
|
5
|
+
const registerDuplicateLatestVersion = require('./duplicate-latest-version')
|
|
6
|
+
|
|
7
|
+
function createContext(logger = { warn: vi.fn(), info: vi.fn() }) {
|
|
8
|
+
const listeners = {}
|
|
9
|
+
return {
|
|
10
|
+
logger,
|
|
11
|
+
getLogger: () => logger,
|
|
12
|
+
on(event, fn) {
|
|
13
|
+
;(listeners[event] ||= []).push(fn)
|
|
14
|
+
},
|
|
15
|
+
async emit(event, payload) {
|
|
16
|
+
for (const fn of listeners[event] || []) await fn(payload)
|
|
17
|
+
},
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function createFile(component, version, module_, relative, outPath, pubUrl, contents = 'CONTENTS') {
|
|
22
|
+
return {
|
|
23
|
+
src: { component, version, module: module_, family: 'page', relative },
|
|
24
|
+
out: { path: outPath },
|
|
25
|
+
pub: { url: pubUrl },
|
|
26
|
+
contents,
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function run(config, { components, files, logger } = {}) {
|
|
31
|
+
const context = createContext(logger)
|
|
32
|
+
registerDuplicateLatestVersion(context, config)
|
|
33
|
+
|
|
34
|
+
const contentCatalog = {
|
|
35
|
+
getComponents: () => components,
|
|
36
|
+
getFiles: () => files,
|
|
37
|
+
}
|
|
38
|
+
const added = []
|
|
39
|
+
const siteCatalog = { addFile: (f) => added.push(f) }
|
|
40
|
+
|
|
41
|
+
await context.emit('pagesComposed', { contentCatalog, siteCatalog })
|
|
42
|
+
return { added, logger: context.logger }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
describe('registerDuplicateLatestVersion', () => {
|
|
46
|
+
it('does nothing when duplicateLatestVersion is not set', async () => {
|
|
47
|
+
const { added } = await run(undefined, {
|
|
48
|
+
components: [{ name: 'weavejs', latest: { version: 'stable', prerelease: false } }],
|
|
49
|
+
files: [
|
|
50
|
+
createFile(
|
|
51
|
+
'weavejs',
|
|
52
|
+
'stable',
|
|
53
|
+
'main',
|
|
54
|
+
'quickstart.adoc',
|
|
55
|
+
'weavejs/stable/main/quickstart/index.html',
|
|
56
|
+
'/weavejs/stable/main/quickstart/'
|
|
57
|
+
),
|
|
58
|
+
],
|
|
59
|
+
})
|
|
60
|
+
expect(added).toHaveLength(0)
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('duplicates every real file of the latest (non-prerelease) version under /latest/…', async () => {
|
|
64
|
+
const { added } = await run(
|
|
65
|
+
{ duplicateLatestVersion: true },
|
|
66
|
+
{
|
|
67
|
+
components: [{ name: 'weavejs', latest: { version: 'stable', prerelease: false } }],
|
|
68
|
+
files: [
|
|
69
|
+
createFile(
|
|
70
|
+
'weavejs',
|
|
71
|
+
'stable',
|
|
72
|
+
'main',
|
|
73
|
+
'quickstart.adoc',
|
|
74
|
+
'weavejs/stable/main/quickstart/index.html',
|
|
75
|
+
'/weavejs/stable/main/quickstart/'
|
|
76
|
+
),
|
|
77
|
+
createFile(
|
|
78
|
+
'weavejs',
|
|
79
|
+
'stable',
|
|
80
|
+
'main',
|
|
81
|
+
'index.adoc',
|
|
82
|
+
'weavejs/stable/main/index.html',
|
|
83
|
+
'/weavejs/stable/main/'
|
|
84
|
+
),
|
|
85
|
+
// a different version's own file must never be touched
|
|
86
|
+
createFile(
|
|
87
|
+
'weavejs',
|
|
88
|
+
'prerelease',
|
|
89
|
+
'main',
|
|
90
|
+
'quickstart.adoc',
|
|
91
|
+
'weavejs/prerelease/main/quickstart/index.html',
|
|
92
|
+
'/weavejs/prerelease/main/quickstart/'
|
|
93
|
+
),
|
|
94
|
+
],
|
|
95
|
+
}
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
expect(added).toHaveLength(2)
|
|
99
|
+
expect(added.map((f) => f.out.path)).toEqual(
|
|
100
|
+
expect.arrayContaining(['weavejs/latest/main/quickstart/index.html', 'weavejs/latest/main/index.html'])
|
|
101
|
+
)
|
|
102
|
+
expect(added.map((f) => f.pub.url)).toEqual(
|
|
103
|
+
expect.arrayContaining(['/weavejs/latest/main/quickstart/', '/weavejs/latest/main/'])
|
|
104
|
+
)
|
|
105
|
+
// contents are carried over verbatim — no HTML rewriting needed, since
|
|
106
|
+
// the UI's relativize helper computes hrefs from directory depth alone.
|
|
107
|
+
expect(added.every((f) => f.contents === 'CONTENTS')).toBe(true)
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it('handles the ROOT component (no component URL segment)', async () => {
|
|
111
|
+
const { added } = await run(
|
|
112
|
+
{ duplicateLatestVersion: true },
|
|
113
|
+
{
|
|
114
|
+
components: [{ name: 'ROOT', latest: { version: 'stable', prerelease: false } }],
|
|
115
|
+
files: [createFile('ROOT', 'stable', 'ROOT', 'index.adoc', 'stable/index.html', '/stable/')],
|
|
116
|
+
}
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
expect(added).toHaveLength(1)
|
|
120
|
+
expect(added[0].out.path).toBe('latest/index.html')
|
|
121
|
+
expect(added[0].pub.url).toBe('/latest/')
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
it('does nothing for a component with no non-prerelease version yet', async () => {
|
|
125
|
+
const { added, logger } = await run(
|
|
126
|
+
{ duplicateLatestVersion: true },
|
|
127
|
+
{
|
|
128
|
+
components: [{ name: 'weavejs', latest: { version: 'prerelease', prerelease: true } }],
|
|
129
|
+
files: [
|
|
130
|
+
createFile(
|
|
131
|
+
'weavejs',
|
|
132
|
+
'prerelease',
|
|
133
|
+
'main',
|
|
134
|
+
'quickstart.adoc',
|
|
135
|
+
'weavejs/prerelease/main/quickstart/index.html',
|
|
136
|
+
'/weavejs/prerelease/main/quickstart/'
|
|
137
|
+
),
|
|
138
|
+
],
|
|
139
|
+
}
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
expect(added).toHaveLength(0)
|
|
143
|
+
expect(logger.warn).not.toHaveBeenCalled()
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
it('does nothing when the latest version already IS the alias segment', async () => {
|
|
147
|
+
const { added } = await run(
|
|
148
|
+
{ duplicateLatestVersion: true },
|
|
149
|
+
{
|
|
150
|
+
components: [{ name: 'weavejs', latest: { version: 'latest', prerelease: false } }],
|
|
151
|
+
files: [
|
|
152
|
+
createFile(
|
|
153
|
+
'weavejs',
|
|
154
|
+
'latest',
|
|
155
|
+
'main',
|
|
156
|
+
'quickstart.adoc',
|
|
157
|
+
'weavejs/latest/main/quickstart/index.html',
|
|
158
|
+
'/weavejs/latest/main/quickstart/'
|
|
159
|
+
),
|
|
160
|
+
],
|
|
161
|
+
}
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
expect(added).toHaveLength(0)
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
it('duplicates each component independently across a multi-component catalog', async () => {
|
|
168
|
+
const { added } = await run(
|
|
169
|
+
{ duplicateLatestVersion: true },
|
|
170
|
+
{
|
|
171
|
+
components: [
|
|
172
|
+
{ name: 'weavejs', latest: { version: 'stable', prerelease: false } },
|
|
173
|
+
{ name: 'other-lib', latest: { version: '2.0.0', prerelease: false } },
|
|
174
|
+
],
|
|
175
|
+
files: [
|
|
176
|
+
createFile(
|
|
177
|
+
'weavejs',
|
|
178
|
+
'stable',
|
|
179
|
+
'main',
|
|
180
|
+
'quickstart.adoc',
|
|
181
|
+
'weavejs/stable/main/quickstart/index.html',
|
|
182
|
+
'/weavejs/stable/main/quickstart/'
|
|
183
|
+
),
|
|
184
|
+
createFile(
|
|
185
|
+
'other-lib',
|
|
186
|
+
'2.0.0',
|
|
187
|
+
'main',
|
|
188
|
+
'quickstart.adoc',
|
|
189
|
+
'other-lib/2.0.0/main/quickstart/index.html',
|
|
190
|
+
'/other-lib/2.0.0/main/quickstart/'
|
|
191
|
+
),
|
|
192
|
+
],
|
|
193
|
+
}
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
expect(added).toHaveLength(2)
|
|
197
|
+
expect(added.map((f) => f.out.path)).toEqual(
|
|
198
|
+
expect.arrayContaining([
|
|
199
|
+
'weavejs/latest/main/quickstart/index.html',
|
|
200
|
+
'other-lib/latest/main/quickstart/index.html',
|
|
201
|
+
])
|
|
202
|
+
)
|
|
203
|
+
})
|
|
204
|
+
})
|
package/lib/footer.js
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const resolveUrl = require('./resolve-url')
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Reads the site footer's authored link groups out of the component
|
|
7
|
+
* descriptor and attaches them, resolved, to the component version — where
|
|
8
|
+
* the UI bundle's `site-footer` partial finds them as
|
|
9
|
+
* `page.componentVersion.footer`.
|
|
10
|
+
*
|
|
11
|
+
* Why here and not in the playbook: `site.keys` is declared
|
|
12
|
+
* `format: 'primitive-map'` in @antora/playbook-builder's schema, so it takes
|
|
13
|
+
* flat primitives and nothing else — no list of groups, no list of links —
|
|
14
|
+
* and convict rejects any playbook key that isn't in the schema at all. The
|
|
15
|
+
* component descriptor is the one place a site can author a nested structure,
|
|
16
|
+
* which is the same conclusion nav-modules.js reached; see its header for the
|
|
17
|
+
* full list of dead ends, all of which apply here unchanged.
|
|
18
|
+
*
|
|
19
|
+
* Authored shape — lists all the way down, never a map keyed by an author's
|
|
20
|
+
* own string:
|
|
21
|
+
*
|
|
22
|
+
* footer:
|
|
23
|
+
* groups:
|
|
24
|
+
* - title: Resources # optional — GH-77, an unlabelled group renders no heading
|
|
25
|
+
* links:
|
|
26
|
+
* - text: Home
|
|
27
|
+
* url: ROOT:index.adoc
|
|
28
|
+
* - text: Repository
|
|
29
|
+
* url: https://github.com/example/example
|
|
30
|
+
*
|
|
31
|
+
* @antora/content-aggregator runs the descriptor through `camelCaseKeys`,
|
|
32
|
+
* which recurses into nested objects and rewrites their KEYS. Fixed key names
|
|
33
|
+
* (`groups`, `links`, `text`, `url`) survive that intact; a map keyed by, say,
|
|
34
|
+
* a group's title would not. `footer` itself is untouched, and `start_page`
|
|
35
|
+
* inside nav_modules arrives as `startPage`, which is what this file and
|
|
36
|
+
* nav-modules.js read.
|
|
37
|
+
*
|
|
38
|
+
* `url` is either a page ID — the same string you would write inside
|
|
39
|
+
* `xref:...[]`, resolved against this component — or a literal URL. A page ID
|
|
40
|
+
* that resolves to nothing is dropped with a warning, so the footer never
|
|
41
|
+
* renders a dead link; that is the same rule repo-link.hbs and nav-brand.hbs
|
|
42
|
+
* follow for their own optional targets.
|
|
43
|
+
*
|
|
44
|
+
* The UI decides what to DO with the groups (the first fills the links
|
|
45
|
+
* column; the second is used for the modules column only when the component
|
|
46
|
+
* has fewer than two switchable modules). Nothing about that policy lives
|
|
47
|
+
* here: this extension resolves what was authored and stops.
|
|
48
|
+
*/
|
|
49
|
+
module.exports = function registerFooter(context) {
|
|
50
|
+
const logger = context.getLogger('docouture-footer')
|
|
51
|
+
// Keyed the same way @antora/navigation-builder keys its own accumulator,
|
|
52
|
+
// and the same way nav-modules.js keys its copy of the descriptors.
|
|
53
|
+
const descriptors = new Map()
|
|
54
|
+
|
|
55
|
+
// The aggregate is the last point at which the descriptor's unknown keys
|
|
56
|
+
// still exist — see nav-modules.js's own note on why this is a two-phase
|
|
57
|
+
// extension rather than a one-liner.
|
|
58
|
+
context.on('contentAggregated', ({ contentAggregate }) => {
|
|
59
|
+
for (const bucket of contentAggregate) {
|
|
60
|
+
if (!bucket.footer) continue
|
|
61
|
+
descriptors.set(bucket.version + '@' + bucket.name, bucket.footer)
|
|
62
|
+
}
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
// Deliberately the same event nav-modules.js uses: the content catalog is
|
|
66
|
+
// complete (so page IDs resolve) and page-composer has not yet built any UI
|
|
67
|
+
// model, so attaching to the componentVersion object is enough to reach
|
|
68
|
+
// every page of it.
|
|
69
|
+
context.on('navigationBuilt', ({ contentCatalog }) => {
|
|
70
|
+
for (const component of contentCatalog.getComponents()) {
|
|
71
|
+
for (const componentVersion of component.versions) {
|
|
72
|
+
const footer = descriptors.get(componentVersion.version + '@' + componentVersion.name)
|
|
73
|
+
if (!footer) continue
|
|
74
|
+
const resolved = resolveFooter(footer, componentVersion, contentCatalog, logger)
|
|
75
|
+
if (resolved) componentVersion.footer = resolved
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
})
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function resolveFooter(footer, componentVersion, contentCatalog, logger) {
|
|
82
|
+
const where = `${componentVersion.name}@${componentVersion.version || 'default'}`
|
|
83
|
+
|
|
84
|
+
if (footer.constructor !== Object || !Array.isArray(footer.groups)) {
|
|
85
|
+
logger.warn('Ignoring footer in %s: expected a groups list', where)
|
|
86
|
+
return undefined
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Page IDs are resolved as if from the component's ROOT module, so a bare
|
|
90
|
+
// `index.adoc` means what an author would expect it to mean and a
|
|
91
|
+
// module-qualified `sdk:index.adoc` works without naming the component.
|
|
92
|
+
const context = { component: componentVersion.name, version: componentVersion.version, module: 'ROOT' }
|
|
93
|
+
|
|
94
|
+
const groups = []
|
|
95
|
+
for (const group of footer.groups) {
|
|
96
|
+
if (!group || group.constructor !== Object || !Array.isArray(group.links)) {
|
|
97
|
+
logger.warn('Ignoring footer group in %s: every group needs a links list', where)
|
|
98
|
+
continue
|
|
99
|
+
}
|
|
100
|
+
const links = []
|
|
101
|
+
for (const link of group.links) {
|
|
102
|
+
if (!link || link.constructor !== Object || !link.text || !link.url) {
|
|
103
|
+
logger.warn('Ignoring footer link in %s: every link needs a text and a url', where)
|
|
104
|
+
continue
|
|
105
|
+
}
|
|
106
|
+
const url = resolveUrl(link.url, contentCatalog, context)
|
|
107
|
+
if (!url) {
|
|
108
|
+
logger.warn('Dropping footer link %s in %s: %s resolves to no page', link.text, where, link.url)
|
|
109
|
+
continue
|
|
110
|
+
}
|
|
111
|
+
links.push({ text: link.text, url })
|
|
112
|
+
}
|
|
113
|
+
// An empty group would render as a blank column; drop it instead, the
|
|
114
|
+
// same way the partial itself omits a column it has no data for.
|
|
115
|
+
// `title` is optional (GH-77) — a group with links but no title still
|
|
116
|
+
// renders, just without a heading, same "omit what's unauthored" rule
|
|
117
|
+
// every other bit of this descriptor follows.
|
|
118
|
+
if (links.length) groups.push({ title: group.title, links })
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return groups.length ? { groups } : undefined
|
|
122
|
+
}
|