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