@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 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
+ }