@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/lib/llms-txt.js
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const htmlToMarkdown = require('./html-to-markdown')
|
|
4
|
+
const resolveUrl = require('./resolve-url')
|
|
5
|
+
|
|
6
|
+
// Mirrors search-index.js's own constants and reasoning for both: a page
|
|
7
|
+
// opts out of `:page-layout: home` because the landing is marketing surface
|
|
8
|
+
// assembled from block extensions, not documentation prose, and everything
|
|
9
|
+
// else defaults to Antora/page-composer's own 'default' layout.
|
|
10
|
+
const DEFAULT_LAYOUT_NAME = 'default'
|
|
11
|
+
const HOME_LAYOUT_NAME = 'home'
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Generates `llms.txt` and `llms-full.txt` at Antora build time (GH-95) and
|
|
15
|
+
* publishes both at the site root, alongside `sitemap.xml` — a Markdown
|
|
16
|
+
* index and a full aggregated Markdown dump of the site, meant for LLM
|
|
17
|
+
* ingestion (https://llmstxt.org) without scraping rendered HTML chrome.
|
|
18
|
+
*
|
|
19
|
+
* Site-wide, not per-component: unlike search-index.js, which publishes one
|
|
20
|
+
* JSON file per component version, both files here are a SINGLE aggregate
|
|
21
|
+
* across every component/version the aggregate has, because the issue asks
|
|
22
|
+
* for files "alongside index.html and sitemap.xml" at the output root, and
|
|
23
|
+
* llmstxt.org's own convention is one pair of files per site. A component's
|
|
24
|
+
* pages are grouped under a heading named after their nav module (the title
|
|
25
|
+
* nav-modules.js stamps onto `componentVersion.navigation`), falling back to
|
|
26
|
+
* the component's own title for a site with no `nav_modules`; the component
|
|
27
|
+
* title is prefixed too, but ONLY when the aggregate has more than one
|
|
28
|
+
* component, so the common single-component site doesn't get a redundant
|
|
29
|
+
* "Weave.js — Framework" when "Framework" alone is unambiguous.
|
|
30
|
+
*
|
|
31
|
+
* ORDERING DEPENDENCY, same one search-index.js documents at length: this
|
|
32
|
+
* reads `tree.module` / `tree.title` off `componentVersion.navigation`,
|
|
33
|
+
* stamped by nav-modules.js on the SAME `navigationBuilt` event, so index.js
|
|
34
|
+
* must register nav-modules before this module or every section silently
|
|
35
|
+
* falls back to the component title.
|
|
36
|
+
*
|
|
37
|
+
* Configuration is authored per component, under a new `llms` key in
|
|
38
|
+
* `docs/antora.yml` — the same place `nav_modules` and `footer` live, and for
|
|
39
|
+
* the same reason: `site.keys` in the playbook is a flat primitive map and
|
|
40
|
+
* cannot hold a list. See nav-modules.js's own header for the full argument.
|
|
41
|
+
*
|
|
42
|
+
* llms:
|
|
43
|
+
* summary: The visual collaborative apps framework, headless and store-agnostic.
|
|
44
|
+
* exclude:
|
|
45
|
+
* - main:some-internal-page.adoc
|
|
46
|
+
*
|
|
47
|
+
* `summary` becomes the blockquote under the site title in `llms.txt`; the
|
|
48
|
+
* first component to declare one wins when several do (there is only one
|
|
49
|
+
* site-wide summary line, not one per component). `exclude` is a list of
|
|
50
|
+
* page IDs — the same strings `xref:...[]` accepts — resolved against the
|
|
51
|
+
* component the way footer.js resolves its own link targets; a page ID that
|
|
52
|
+
* resolves to nothing is dropped with a warning rather than silently
|
|
53
|
+
* matching everything or nothing.
|
|
54
|
+
*/
|
|
55
|
+
module.exports = function registerLlmsTxt(context) {
|
|
56
|
+
const logger = context.getLogger('docouture-llms-txt')
|
|
57
|
+
// Keyed the same way nav-modules.js and footer.js key their own copies of
|
|
58
|
+
// the descriptor — see nav-modules.js's header for why this has to be a
|
|
59
|
+
// two-phase (contentAggregated, then navigationBuilt) extension at all.
|
|
60
|
+
const descriptors = new Map()
|
|
61
|
+
|
|
62
|
+
context.on('contentAggregated', ({ contentAggregate }) => {
|
|
63
|
+
for (const bucket of contentAggregate) {
|
|
64
|
+
if (!bucket.llms) continue
|
|
65
|
+
descriptors.set(bucket.version + '@' + bucket.name, bucket.llms)
|
|
66
|
+
}
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
context.on('navigationBuilt', ({ contentCatalog, siteCatalog, playbook }) => {
|
|
70
|
+
const defaultLayout = playbook.ui?.defaultLayout || DEFAULT_LAYOUT_NAME
|
|
71
|
+
const components = contentCatalog.getComponents()
|
|
72
|
+
const multiComponent = components.length > 1
|
|
73
|
+
|
|
74
|
+
const sectionsByHeading = new Map()
|
|
75
|
+
const fullParts = []
|
|
76
|
+
let summary
|
|
77
|
+
|
|
78
|
+
for (const component of components) {
|
|
79
|
+
for (const componentVersion of component.versions) {
|
|
80
|
+
const where = `${componentVersion.name}@${componentVersion.version || 'default'}`
|
|
81
|
+
const descriptor = descriptors.get(componentVersion.version + '@' + componentVersion.name) || {}
|
|
82
|
+
if (descriptor.summary && !summary) summary = descriptor.summary
|
|
83
|
+
|
|
84
|
+
const excluded = buildExcludedSet(descriptor.exclude, contentCatalog, componentVersion, logger, where)
|
|
85
|
+
|
|
86
|
+
const moduleTitleByModule = new Map()
|
|
87
|
+
for (const tree of componentVersion.navigation || []) {
|
|
88
|
+
if (tree.module) moduleTitleByModule.set(tree.module, tree.title || tree.module)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const pages = contentCatalog.getPages(
|
|
92
|
+
(page) =>
|
|
93
|
+
page.out &&
|
|
94
|
+
page.src.component === componentVersion.name &&
|
|
95
|
+
page.src.version === componentVersion.version &&
|
|
96
|
+
resolveLayout(page, defaultLayout) !== HOME_LAYOUT_NAME &&
|
|
97
|
+
!excluded.has(page.pub.url)
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
for (const page of pages) {
|
|
101
|
+
const title = page.asciidoc?.doctitle
|
|
102
|
+
if (!title) continue // no AsciiDoc header at all — nothing to list or dump, same rule search-index.js applies
|
|
103
|
+
|
|
104
|
+
const moduleTitle = moduleTitleByModule.get(page.src.module) || componentVersion.title
|
|
105
|
+
const heading = multiComponent ? `${componentVersion.title} — ${moduleTitle}` : moduleTitle
|
|
106
|
+
|
|
107
|
+
if (!sectionsByHeading.has(heading)) sectionsByHeading.set(heading, [])
|
|
108
|
+
sectionsByHeading.get(heading).push({
|
|
109
|
+
title,
|
|
110
|
+
url: page.pub.url,
|
|
111
|
+
description: page.asciidoc?.attributes?.description || '',
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
fullParts.push(buildFullEntry(title, page))
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const llmsTxt = buildLlmsTxt(playbook.site?.title, summary, sectionsByHeading)
|
|
120
|
+
const llmsFullTxt = fullParts.join('\n\n---\n\n') + '\n'
|
|
121
|
+
|
|
122
|
+
siteCatalog.addFile({
|
|
123
|
+
contents: Buffer.from(llmsTxt),
|
|
124
|
+
out: { path: 'llms.txt' },
|
|
125
|
+
pub: { url: '/llms.txt' },
|
|
126
|
+
})
|
|
127
|
+
siteCatalog.addFile({
|
|
128
|
+
contents: Buffer.from(llmsFullTxt),
|
|
129
|
+
out: { path: 'llms-full.txt' },
|
|
130
|
+
pub: { url: '/llms-full.txt' },
|
|
131
|
+
})
|
|
132
|
+
})
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Mirrors @antora/page-composer's own resolution (see search-index.js's own
|
|
136
|
+
// note on why this reads `file.asciidoc.attributes` rather than the UI
|
|
137
|
+
// model's `page.layout`, which does not exist yet at `navigationBuilt`).
|
|
138
|
+
function resolveLayout(page, defaultLayout) {
|
|
139
|
+
return page.asciidoc?.attributes?.['page-layout'] || defaultLayout
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function buildExcludedSet(exclude, contentCatalog, componentVersion, logger, where) {
|
|
143
|
+
const excluded = new Set()
|
|
144
|
+
if (exclude === undefined) return excluded
|
|
145
|
+
if (!Array.isArray(exclude)) {
|
|
146
|
+
logger.warn('Ignoring llms.exclude in %s: expected a list of page IDs, got %s', where, typeof exclude)
|
|
147
|
+
return excluded
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Page IDs are resolved as if from the component's ROOT module, the same
|
|
151
|
+
// default context footer.js uses for its own link targets, so a bare
|
|
152
|
+
// `index.adoc` means what an author would expect and a module-qualified
|
|
153
|
+
// `sdk:index.adoc` works without naming the component.
|
|
154
|
+
const resolveContext = { component: componentVersion.name, version: componentVersion.version, module: 'ROOT' }
|
|
155
|
+
for (const spec of exclude) {
|
|
156
|
+
const url = resolveUrl(spec, contentCatalog, resolveContext)
|
|
157
|
+
if (url) {
|
|
158
|
+
excluded.add(url)
|
|
159
|
+
} else {
|
|
160
|
+
logger.warn('llms.exclude entry %s in %s resolves to no page', spec, where)
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
return excluded
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function buildFullEntry(title, page) {
|
|
167
|
+
const markdown = htmlToMarkdown(page.contents.toString())
|
|
168
|
+
return `# ${title}\n\nSource: ${page.pub.url}\n\n${markdown}`
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function buildLlmsTxt(siteTitle, summary, sectionsByHeading) {
|
|
172
|
+
let out = `# ${siteTitle || 'Documentation'}\n`
|
|
173
|
+
if (summary) out += `\n> ${summary}\n`
|
|
174
|
+
|
|
175
|
+
for (const [heading, entries] of sectionsByHeading) {
|
|
176
|
+
if (!entries.length) continue
|
|
177
|
+
out += `\n## ${heading}\n\n`
|
|
178
|
+
for (const entry of entries) {
|
|
179
|
+
const description = entry.description ? `: ${entry.description}` : ''
|
|
180
|
+
out += `- [${entry.title}](${entry.url})${description}\n`
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return out.trim() + '\n'
|
|
185
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
import { describe, expect, it } from 'vitest'
|
|
4
|
+
|
|
5
|
+
const registerLlmsTxt = require('./llms-txt')
|
|
6
|
+
|
|
7
|
+
function createContext() {
|
|
8
|
+
const listeners = {}
|
|
9
|
+
return {
|
|
10
|
+
getLogger: () => ({ warn: () => {}, info: () => {} }),
|
|
11
|
+
on(event, fn) {
|
|
12
|
+
;(listeners[event] ||= []).push(fn)
|
|
13
|
+
},
|
|
14
|
+
async emit(event, payload) {
|
|
15
|
+
for (const fn of listeners[event] || []) await fn(payload)
|
|
16
|
+
},
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function createPage({ module: mod, relative, url, title, description, html, layout }) {
|
|
21
|
+
return {
|
|
22
|
+
out: true,
|
|
23
|
+
src: { component: 'weavejs', version: '', module: mod, relative },
|
|
24
|
+
pub: { url },
|
|
25
|
+
asciidoc: {
|
|
26
|
+
doctitle: title,
|
|
27
|
+
attributes: { description, ...(layout ? { 'page-layout': layout } : {}) },
|
|
28
|
+
},
|
|
29
|
+
contents: Buffer.from(html || '<p>Body</p>'),
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function createContentCatalog(componentVersion, pages) {
|
|
34
|
+
return {
|
|
35
|
+
getComponents: () => [{ versions: [componentVersion] }],
|
|
36
|
+
getPages: (filterFn) => pages.filter(filterFn),
|
|
37
|
+
resolvePage(spec, context) {
|
|
38
|
+
const parts = spec.split(':')
|
|
39
|
+
const relative = parts.pop()
|
|
40
|
+
const mod = parts.pop() || context.module
|
|
41
|
+
const page = pages.find(
|
|
42
|
+
(p) =>
|
|
43
|
+
p.src.component === context.component &&
|
|
44
|
+
p.src.version === context.version &&
|
|
45
|
+
p.src.module === mod &&
|
|
46
|
+
p.src.relative === relative
|
|
47
|
+
)
|
|
48
|
+
return page ? { pub: page.pub } : undefined
|
|
49
|
+
},
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function run({ llms, navigation, pages }) {
|
|
54
|
+
const context = createContext()
|
|
55
|
+
registerLlmsTxt(context)
|
|
56
|
+
|
|
57
|
+
const componentVersion = {
|
|
58
|
+
name: 'weavejs',
|
|
59
|
+
version: '',
|
|
60
|
+
title: 'Weave.js',
|
|
61
|
+
navigation: navigation || [],
|
|
62
|
+
}
|
|
63
|
+
const contentCatalog = createContentCatalog(componentVersion, pages)
|
|
64
|
+
const files = []
|
|
65
|
+
const siteCatalog = { addFile: (f) => files.push(f) }
|
|
66
|
+
const playbook = { site: { title: 'Weave.js' }, ui: {} }
|
|
67
|
+
|
|
68
|
+
await context.emit('contentAggregated', {
|
|
69
|
+
contentAggregate: llms ? [{ name: 'weavejs', version: '', llms }] : [],
|
|
70
|
+
})
|
|
71
|
+
await context.emit('navigationBuilt', { contentCatalog, siteCatalog, playbook })
|
|
72
|
+
|
|
73
|
+
const llmsTxt = files.find((f) => f.out.path === 'llms.txt').contents.toString()
|
|
74
|
+
const llmsFullTxt = files.find((f) => f.out.path === 'llms-full.txt').contents.toString()
|
|
75
|
+
return { llmsTxt, llmsFullTxt, files }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
describe('registerLlmsTxt', () => {
|
|
79
|
+
it('builds an index grouped by nav module, with the summary as a blockquote', async () => {
|
|
80
|
+
const pages = [
|
|
81
|
+
createPage({
|
|
82
|
+
module: 'main',
|
|
83
|
+
relative: 'index.adoc',
|
|
84
|
+
url: '/weavejs/main/index.html',
|
|
85
|
+
title: 'Getting Started',
|
|
86
|
+
description: 'How to start',
|
|
87
|
+
html: '<h2 id="x">Sub</h2><p>Hello world</p>',
|
|
88
|
+
}),
|
|
89
|
+
]
|
|
90
|
+
const { llmsTxt, llmsFullTxt } = await run({
|
|
91
|
+
llms: { summary: 'The visual collaborative apps framework.' },
|
|
92
|
+
navigation: [{ module: 'main', title: 'Framework' }],
|
|
93
|
+
pages,
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
expect(llmsTxt).toContain('# Weave.js')
|
|
97
|
+
expect(llmsTxt).toContain('> The visual collaborative apps framework.')
|
|
98
|
+
expect(llmsTxt).toContain('## Framework')
|
|
99
|
+
expect(llmsTxt).toContain('- [Getting Started](/weavejs/main/index.html): How to start')
|
|
100
|
+
|
|
101
|
+
expect(llmsFullTxt).toContain('# Getting Started')
|
|
102
|
+
expect(llmsFullTxt).toContain('Source: /weavejs/main/index.html')
|
|
103
|
+
expect(llmsFullTxt).toContain('## Sub')
|
|
104
|
+
expect(llmsFullTxt).toContain('Hello world')
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it('falls back to the component title when no nav_modules metadata is present', async () => {
|
|
108
|
+
const pages = [createPage({ module: 'ROOT', relative: 'index.adoc', url: '/weavejs/index.html', title: 'Home' })]
|
|
109
|
+
const { llmsTxt } = await run({ pages })
|
|
110
|
+
|
|
111
|
+
expect(llmsTxt).toContain('## Weave.js')
|
|
112
|
+
expect(llmsTxt).not.toContain('>') // no summary declared, no blockquote line
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
it('drops excluded pages from both files', async () => {
|
|
116
|
+
const pages = [
|
|
117
|
+
createPage({
|
|
118
|
+
module: 'main',
|
|
119
|
+
relative: 'index.adoc',
|
|
120
|
+
url: '/weavejs/main/index.html',
|
|
121
|
+
title: 'Getting Started',
|
|
122
|
+
}),
|
|
123
|
+
createPage({
|
|
124
|
+
module: 'main',
|
|
125
|
+
relative: 'secret.adoc',
|
|
126
|
+
url: '/weavejs/main/secret.html',
|
|
127
|
+
title: 'Secret',
|
|
128
|
+
}),
|
|
129
|
+
]
|
|
130
|
+
const { llmsTxt, llmsFullTxt } = await run({
|
|
131
|
+
llms: { exclude: ['main:secret.adoc'] },
|
|
132
|
+
navigation: [{ module: 'main', title: 'Framework' }],
|
|
133
|
+
pages,
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
expect(llmsTxt).toContain('Getting Started')
|
|
137
|
+
expect(llmsTxt).not.toContain('Secret')
|
|
138
|
+
expect(llmsFullTxt).not.toContain('# Secret')
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
it('excludes pages using the home layout', async () => {
|
|
142
|
+
const pages = [
|
|
143
|
+
createPage({
|
|
144
|
+
module: 'ROOT',
|
|
145
|
+
relative: 'index.adoc',
|
|
146
|
+
url: '/weavejs/index.html',
|
|
147
|
+
title: 'Landing',
|
|
148
|
+
layout: 'home',
|
|
149
|
+
}),
|
|
150
|
+
createPage({
|
|
151
|
+
module: 'main',
|
|
152
|
+
relative: 'index.adoc',
|
|
153
|
+
url: '/weavejs/main/index.html',
|
|
154
|
+
title: 'Getting Started',
|
|
155
|
+
}),
|
|
156
|
+
]
|
|
157
|
+
const { llmsTxt } = await run({ navigation: [{ module: 'main', title: 'Framework' }], pages })
|
|
158
|
+
|
|
159
|
+
expect(llmsTxt).not.toContain('Landing')
|
|
160
|
+
expect(llmsTxt).toContain('Getting Started')
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
it('skips pages with no AsciiDoc title', async () => {
|
|
164
|
+
const pages = [
|
|
165
|
+
createPage({ module: 'main', relative: 'index.adoc', url: '/weavejs/main/index.html', title: undefined }),
|
|
166
|
+
]
|
|
167
|
+
const { llmsTxt, llmsFullTxt } = await run({ pages })
|
|
168
|
+
|
|
169
|
+
expect(llmsTxt.trim()).toBe('# Weave.js')
|
|
170
|
+
expect(llmsFullTxt.trim()).toBe('')
|
|
171
|
+
})
|
|
172
|
+
})
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const resolveUrl = require('./resolve-url')
|
|
4
|
+
|
|
5
|
+
// `modules/<module>/nav.adoc` — the shape of every entry in a component
|
|
6
|
+
// descriptor's own `nav` list. Anything else (a nav file outside modules/,
|
|
7
|
+
// which Antora does allow) simply has no module to attach metadata to.
|
|
8
|
+
const MODULE_NAV_PATH_RX = /^modules\/([^/]+)\//
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Attaches per-module metadata to Antora's navigation trees, so the UI can
|
|
12
|
+
* show ONE module's navigation at a time with a switcher between them.
|
|
13
|
+
*
|
|
14
|
+
* Background: @antora/navigation-builder already produces one root tree per
|
|
15
|
+
* unordered list per nav file, in the order the descriptor's `nav` key lists
|
|
16
|
+
* them (build-navigation.js: `tree.order = navFile.nav.index`). A component
|
|
17
|
+
* with seven nav files therefore already has seven root trees in
|
|
18
|
+
* `page.navigation` — Antora's own equivalent of a Fumadocs `root: true`
|
|
19
|
+
* folder. What it has no notion of is which MODULE a tree belongs to, or what
|
|
20
|
+
* that module should be called in a switcher.
|
|
21
|
+
*
|
|
22
|
+
* There is no built-in place to put that, which is why this extension exists.
|
|
23
|
+
* Every alternative was checked and is a dead end:
|
|
24
|
+
*
|
|
25
|
+
* - custom keys in antora.yml are dropped: ContentCatalog#registerComponentVersion
|
|
26
|
+
* destructures only asciidoc/displayVersion/prerelease/startPage/title
|
|
27
|
+
* - playbook `site.keys` is declared `format: 'primitive-map'`, so flat
|
|
28
|
+
* primitives only — no nested map, no list
|
|
29
|
+
* - a nav file's own AsciiDoc attributes are unreachable: buildNavigation
|
|
30
|
+
* hardcodes `extensions: []` and keeps only the ulists
|
|
31
|
+
* - a list TITLE on the nav file would give a title and nothing else, and
|
|
32
|
+
* it would also push a module entry into every page's `page.breadcrumbs`
|
|
33
|
+
* (page-composer's attachNavProperties collects ancestors that have
|
|
34
|
+
* `content`), which is a visible change to every page in the site
|
|
35
|
+
*
|
|
36
|
+
* So the metadata is authored in antora.yml under `nav_modules` and read HERE,
|
|
37
|
+
* from the raw aggregate, before classification throws the unknown key away.
|
|
38
|
+
*
|
|
39
|
+
* Authored shape — a LIST, not a map keyed by module:
|
|
40
|
+
*
|
|
41
|
+
* nav_modules:
|
|
42
|
+
* - module: main
|
|
43
|
+
* title: Framework
|
|
44
|
+
* description: The visual collaborative apps framework
|
|
45
|
+
* icon: design/menu-tab-outlined
|
|
46
|
+
*
|
|
47
|
+
* Two optional keys beyond the ones the switcher draws:
|
|
48
|
+
*
|
|
49
|
+
* - `switcher: false` annotates a tree without offering it as a module to
|
|
50
|
+
* switch to. That is what a LANDING page's own navigation is: a menu that
|
|
51
|
+
* belongs to no module (`modules/ROOT/nav.adoc`, rendered only on the
|
|
52
|
+
* landing — see the UI's `page-nav-module` attribute), which should not
|
|
53
|
+
* appear beside the real modules in the switcher. Declaring it is also
|
|
54
|
+
* what stops the "no nav_modules entry" warning below from failing a build
|
|
55
|
+
* whose playbook sets `failure_level: warn`.
|
|
56
|
+
* - `start_page` overrides where the switcher and the site footer send
|
|
57
|
+
* someone who picks this module. It is a page ID — the same string you
|
|
58
|
+
* would write inside `xref:...[]` — resolved against this component.
|
|
59
|
+
* Without it, the target is the first internal page in the module's own
|
|
60
|
+
* navigation, which for a conventionally laid out module is its index.
|
|
61
|
+
*
|
|
62
|
+
* The list is load-bearing. @antora/content-aggregator runs the whole
|
|
63
|
+
* descriptor through `camelCaseKeys`, which recurses into nested objects and
|
|
64
|
+
* rewrites their KEYS (only `asciidoc` is exempted). A map keyed by module
|
|
65
|
+
* would silently rename every kebab-case module — `store-azure-web-pubsub`
|
|
66
|
+
* would arrive here as `storeAzureWebPubsub` and match nothing. Values are
|
|
67
|
+
* left alone, so carrying the slug as a value rather than a key is what makes
|
|
68
|
+
* it survive. `nav_modules` itself still camelCases to `navModules`, which is
|
|
69
|
+
* why that is what this file reads.
|
|
70
|
+
*
|
|
71
|
+
* Result: each tree in `page.navigation` gains `module`, `title`, and
|
|
72
|
+
* optionally `description` and `icon`. Sites that don't declare `nav_modules`
|
|
73
|
+
* are untouched, and the UI falls back to rendering every tree — which is the
|
|
74
|
+
* behaviour every single-nav-file site already had.
|
|
75
|
+
*/
|
|
76
|
+
module.exports = function registerNavModules(context) {
|
|
77
|
+
const logger = context.getLogger('docouture-nav-modules')
|
|
78
|
+
// Keyed the same way @antora/navigation-builder keys its own accumulator.
|
|
79
|
+
const descriptors = new Map()
|
|
80
|
+
|
|
81
|
+
// The aggregate is the LAST point at which the descriptor's unknown keys
|
|
82
|
+
// still exist: generate-site.js calls `vars.remove('contentAggregate')` in
|
|
83
|
+
// the very next step, and classifyContent would have dropped `navModules`
|
|
84
|
+
// anyway. Copy out what's needed now; annotate later.
|
|
85
|
+
context.on('contentAggregated', ({ contentAggregate }) => {
|
|
86
|
+
for (const bucket of contentAggregate) {
|
|
87
|
+
if (!bucket.navModules) continue
|
|
88
|
+
descriptors.set(bucket.version + '@' + bucket.name, {
|
|
89
|
+
nav: bucket.nav || [],
|
|
90
|
+
navModules: bucket.navModules,
|
|
91
|
+
})
|
|
92
|
+
}
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
// navigationBuilt fires after buildNavigation has assigned the trees to
|
|
96
|
+
// `componentVersion.navigation` and before pagesComposed, which is when
|
|
97
|
+
// page-composer hands that same array to the UI model as `page.navigation`.
|
|
98
|
+
// These are the same objects throughout, so annotating in place is enough —
|
|
99
|
+
// nothing needs to be threaded through the page composer.
|
|
100
|
+
context.on('navigationBuilt', ({ contentCatalog }) => {
|
|
101
|
+
for (const component of contentCatalog.getComponents()) {
|
|
102
|
+
for (const componentVersion of component.versions) {
|
|
103
|
+
const descriptor = descriptors.get(componentVersion.version + '@' + componentVersion.name)
|
|
104
|
+
if (descriptor) annotate(componentVersion, descriptor, contentCatalog, logger)
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
})
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function stampItems(items, module_) {
|
|
111
|
+
for (const item of items || []) {
|
|
112
|
+
item.module = module_
|
|
113
|
+
stampItems(item.items, module_)
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Depth first, because a module's first entry is often an unlinked category
|
|
118
|
+
// heading (Fumadocs' `"---Overview---"` separators survived the migration as
|
|
119
|
+
// nav entries with `content` and neither `url` nor children) and the first
|
|
120
|
+
// real page hangs off a later sibling.
|
|
121
|
+
function findFirstInternalUrl(items) {
|
|
122
|
+
for (const item of items || []) {
|
|
123
|
+
if (item.urlType === 'internal' && item.url) return item.url
|
|
124
|
+
const nested = findFirstInternalUrl(item.items)
|
|
125
|
+
if (nested) return nested
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function annotate(componentVersion, { nav, navModules }, contentCatalog, logger) {
|
|
130
|
+
const where = `${componentVersion.name}@${componentVersion.version || 'default'}`
|
|
131
|
+
|
|
132
|
+
if (!Array.isArray(navModules)) {
|
|
133
|
+
logger.warn('Ignoring nav_modules in %s: expected a list of entries, got %s', where, typeof navModules)
|
|
134
|
+
return
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const declared = new Map()
|
|
138
|
+
for (const entry of navModules) {
|
|
139
|
+
if (!entry || entry.constructor !== Object || !entry.module) {
|
|
140
|
+
logger.warn('Ignoring nav_modules entry in %s: every entry needs a module key', where)
|
|
141
|
+
continue
|
|
142
|
+
}
|
|
143
|
+
if (declared.has(entry.module)) {
|
|
144
|
+
logger.warn('Ignoring duplicate nav_modules entry for module %s in %s', entry.module, where)
|
|
145
|
+
continue
|
|
146
|
+
}
|
|
147
|
+
declared.set(entry.module, entry)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const matched = new Set()
|
|
151
|
+
for (const tree of componentVersion.navigation || []) {
|
|
152
|
+
// `tree.order` is the index of the nav file in the descriptor's own `nav`
|
|
153
|
+
// list — an integer, except when one nav file holds more than one list, in
|
|
154
|
+
// which case the extra lists get a fraction on top of that same index
|
|
155
|
+
// (build-navigation.js). Flooring therefore gets back to the file either
|
|
156
|
+
// way, and every list in a file belongs to that file's module.
|
|
157
|
+
const navPath = nav[Math.floor(tree.order)]
|
|
158
|
+
const module_ = navPath && MODULE_NAV_PATH_RX.exec(navPath)?.[1]
|
|
159
|
+
if (!module_) continue
|
|
160
|
+
|
|
161
|
+
tree.module = module_
|
|
162
|
+
// Stamped all the way down, not just on the root. `page.previous` and
|
|
163
|
+
// `page.next` are these very item objects (page-composer's findNavItem
|
|
164
|
+
// returns matches out of this same tree), so carrying the module on each
|
|
165
|
+
// one is what lets the footer pagination tell "the next page" from "the
|
|
166
|
+
// next page IN THIS MODULE" without re-deriving anything from URLs.
|
|
167
|
+
stampItems(tree.items, module_)
|
|
168
|
+
|
|
169
|
+
const meta = declared.get(module_)
|
|
170
|
+
if (!meta) {
|
|
171
|
+
// Not fatal: the tree still renders, and the UI keys segmentation off
|
|
172
|
+
// `tree.module`, which is set. Only the switcher entry is missing.
|
|
173
|
+
logger.warn('No nav_modules entry for module %s in %s', module_, where)
|
|
174
|
+
continue
|
|
175
|
+
}
|
|
176
|
+
matched.add(module_)
|
|
177
|
+
|
|
178
|
+
// Fall back to the slug rather than leaving the switcher entry blank.
|
|
179
|
+
tree.title = meta.title || module_
|
|
180
|
+
if (meta.description) tree.description = meta.description
|
|
181
|
+
if (meta.icon) tree.icon = meta.icon
|
|
182
|
+
|
|
183
|
+
// `switcher: false` — annotated, but not a module anyone can switch TO.
|
|
184
|
+
// A landing page's own navigation is the case this exists for; see this
|
|
185
|
+
// file's header. The UI keys "offer this in the switcher" off `startUrl`,
|
|
186
|
+
// so withholding it is the whole implementation, and it is also why the
|
|
187
|
+
// flag has to skip the "no page to link to" warning below rather than
|
|
188
|
+
// fall through to it.
|
|
189
|
+
if (meta.switcher === false) {
|
|
190
|
+
tree.switcher = false
|
|
191
|
+
continue
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Where the switcher and the site footer should send someone who picks
|
|
195
|
+
// this module. `start_page` is the authored override, a page ID resolved
|
|
196
|
+
// against this component; absent one, it is the first internal page in
|
|
197
|
+
// the module's own navigation, found here rather than in the template
|
|
198
|
+
// because finding it means a depth-first walk, which Handlebars has no
|
|
199
|
+
// way to express. Named `startUrl`, not `url`, on purpose: a nav node
|
|
200
|
+
// WITH a `url` is a link as far as nav-tree.hbs is concerned, and a root
|
|
201
|
+
// menu is not one.
|
|
202
|
+
let startUrl
|
|
203
|
+
if (meta.startPage) {
|
|
204
|
+
startUrl = resolveUrl(meta.startPage, contentCatalog, {
|
|
205
|
+
component: componentVersion.name,
|
|
206
|
+
version: componentVersion.version,
|
|
207
|
+
module: module_,
|
|
208
|
+
})
|
|
209
|
+
if (!startUrl) {
|
|
210
|
+
logger.warn(
|
|
211
|
+
'nav_modules start_page %s for module %s in %s resolves to no page; falling back to the navigation',
|
|
212
|
+
meta.startPage,
|
|
213
|
+
module_,
|
|
214
|
+
where
|
|
215
|
+
)
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
if (!startUrl) startUrl = findFirstInternalUrl(tree.items)
|
|
219
|
+
if (startUrl) {
|
|
220
|
+
tree.startUrl = startUrl
|
|
221
|
+
} else {
|
|
222
|
+
logger.warn('Module %s in %s has no internal page to link to from the navigation switcher', module_, where)
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
for (const module_ of declared.keys()) {
|
|
227
|
+
if (matched.has(module_)) continue
|
|
228
|
+
// Almost always a typo in the slug, or a module whose nav file is missing
|
|
229
|
+
// from the descriptor's `nav` list — both of which silently produce a
|
|
230
|
+
// module the switcher can never reach.
|
|
231
|
+
logger.warn('nav_modules entry for module %s in %s matches no navigation file', module_, where)
|
|
232
|
+
}
|
|
233
|
+
}
|