@inditextech/docouture-antora-extensions 1.0.0 → 1.1.0

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.
@@ -81,7 +81,7 @@ module.exports = function registerChangelogPages(context, changelogPath) {
81
81
 
82
82
  const indexFiles = contentCatalog
83
83
  .getFiles()
84
- .filter((file) => file.src && file.src.family === 'page' && file.src.relative === 'changelog/index.adoc')
84
+ .filter((file) => file.src?.family === 'page' && file.src?.relative === 'changelog/index.adoc')
85
85
 
86
86
  if (!indexFiles.length) {
87
87
  logger.info("No 'changelog/index.adoc' page found on this site — nothing to append to")
package/lib/footer.js CHANGED
@@ -97,13 +97,13 @@ function resolveFooter(footer, componentVersion, contentCatalog, logger) {
97
97
 
98
98
  const groups = []
99
99
  for (const group of footer.groups) {
100
- if (!group || group.constructor !== Object || !Array.isArray(group.links)) {
100
+ if (group?.constructor !== Object || !Array.isArray(group?.links)) {
101
101
  logger.warn('Ignoring footer group in %s: every group needs a links list', where)
102
102
  continue
103
103
  }
104
104
  const links = []
105
105
  for (const link of group.links) {
106
- if (!link || link.constructor !== Object || !link.text || !link.url) {
106
+ if (link?.constructor !== Object || !link?.text || !link?.url) {
107
107
  logger.warn('Ignoring footer link in %s: every link needs a text and a url', where)
108
108
  continue
109
109
  }
@@ -0,0 +1,179 @@
1
+ // SPDX-FileCopyrightText: 2026 INDUSTRIA DE DISEÑO TEXTIL S.A. (INDITEX S.A.)
2
+ //
3
+ // SPDX-License-Identifier: Apache-2.0
4
+
5
+ 'use strict'
6
+
7
+ import { describe, expect, it, vi } from 'vitest'
8
+
9
+ const registerFooter = require('./footer')
10
+
11
+ function createContext(logger = { warn: vi.fn(), info: () => {} }) {
12
+ const listeners = {}
13
+ return {
14
+ logger,
15
+ getLogger: () => logger,
16
+ on(event, fn) {
17
+ ;(listeners[event] ||= []).push(fn)
18
+ },
19
+ async emit(event, payload) {
20
+ for (const fn of listeners[event] || []) await fn(payload)
21
+ },
22
+ }
23
+ }
24
+
25
+ function componentVersion(name, version, overrides = {}) {
26
+ return { name, version, ...overrides }
27
+ }
28
+
29
+ async function run(buckets, { pages = {}, logger } = {}) {
30
+ const context = createContext(logger)
31
+ registerFooter(context)
32
+
33
+ const contentCatalog = {
34
+ resolvePage: (target) => {
35
+ const url = pages[target]
36
+ return url ? { pub: { url } } : undefined
37
+ },
38
+ }
39
+
40
+ const versions = buckets.map((bucket) => componentVersion(bucket.name, bucket.version))
41
+ const contentCatalog2 = contentCatalog
42
+
43
+ await context.emit('contentAggregated', { contentAggregate: buckets })
44
+ await context.emit('navigationBuilt', {
45
+ contentCatalog: {
46
+ ...contentCatalog2,
47
+ getComponents: () => [{ versions }],
48
+ },
49
+ })
50
+
51
+ return { versions, logger: context.logger }
52
+ }
53
+
54
+ function warnedAbout(logger, text) {
55
+ return logger.warn.mock.calls.some((call) => typeof call[0] === 'string' && call[0].includes(text))
56
+ }
57
+
58
+ describe('registerFooter', () => {
59
+ it('attaches resolved footer groups to the matching component version', async () => {
60
+ const { versions } = await run(
61
+ [
62
+ {
63
+ name: 'weavejs',
64
+ version: 'latest',
65
+ footer: {
66
+ groups: [
67
+ {
68
+ title: 'Resources',
69
+ links: [
70
+ { text: 'Home', url: 'ROOT:index.adoc' },
71
+ { text: 'Repository', url: 'https://github.com/example/example' },
72
+ ],
73
+ },
74
+ ],
75
+ },
76
+ },
77
+ ],
78
+ { pages: { 'ROOT:index.adoc': '/weavejs/latest/index.html' } }
79
+ )
80
+
81
+ expect(versions[0].footer).toEqual({
82
+ groups: [
83
+ {
84
+ title: 'Resources',
85
+ links: [
86
+ { text: 'Home', url: '/weavejs/latest/index.html' },
87
+ { text: 'Repository', url: 'https://github.com/example/example' },
88
+ ],
89
+ },
90
+ ],
91
+ })
92
+ })
93
+
94
+ it('does nothing to a component version whose bucket has no footer', async () => {
95
+ const { versions } = await run([{ name: 'weavejs', version: 'latest' }])
96
+ expect(versions[0].footer).toBeUndefined()
97
+ })
98
+
99
+ it('warns and drops a footer that is not a { groups: [...] } shape', async () => {
100
+ const logger = { warn: vi.fn(), info: () => {} }
101
+ const { versions } = await run([{ name: 'weavejs', version: 'latest', footer: { groups: 'not-a-list' } }], {
102
+ logger,
103
+ })
104
+ expect(versions[0].footer).toBeUndefined()
105
+ expect(warnedAbout(logger, 'expected a groups list')).toBe(true)
106
+ })
107
+
108
+ it('warns and skips a group with no links list, but keeps other groups', async () => {
109
+ const logger = { warn: vi.fn(), info: () => {} }
110
+ const { versions } = await run(
111
+ [
112
+ {
113
+ name: 'weavejs',
114
+ version: 'latest',
115
+ footer: {
116
+ groups: [{ title: 'Broken' }, { title: 'Fine', links: [{ text: 'Home', url: '/plain.html' }] }],
117
+ },
118
+ },
119
+ ],
120
+ { logger }
121
+ )
122
+ expect(versions[0].footer.groups).toEqual([{ title: 'Fine', links: [{ text: 'Home', url: '/plain.html' }] }])
123
+ expect(warnedAbout(logger, 'every group needs a links list')).toBe(true)
124
+ })
125
+
126
+ it('warns and skips a link missing text or url', async () => {
127
+ const logger = { warn: vi.fn(), info: () => {} }
128
+ const { versions } = await run(
129
+ [
130
+ {
131
+ name: 'weavejs',
132
+ version: 'latest',
133
+ footer: { groups: [{ links: [{ text: 'No url' }, { url: '/no-text.html' }] }] },
134
+ },
135
+ ],
136
+ { logger }
137
+ )
138
+ expect(versions[0].footer).toBeUndefined()
139
+ expect(warnedAbout(logger, 'every link needs a text and a url')).toBe(true)
140
+ })
141
+
142
+ it('drops a link whose page ID resolves to nothing, with a warning', async () => {
143
+ const logger = { warn: vi.fn(), info: () => {} }
144
+ const { versions } = await run(
145
+ [
146
+ {
147
+ name: 'weavejs',
148
+ version: 'latest',
149
+ footer: { groups: [{ links: [{ text: 'Ghost', url: 'ROOT:missing.adoc' }] }] },
150
+ },
151
+ ],
152
+ { logger }
153
+ )
154
+ expect(versions[0].footer).toBeUndefined()
155
+ expect(warnedAbout(logger, 'resolves to no page')).toBe(true)
156
+ })
157
+
158
+ it('drops a group left with no links after filtering, without rendering an empty column', async () => {
159
+ const { versions } = await run([
160
+ {
161
+ name: 'weavejs',
162
+ version: 'latest',
163
+ footer: { groups: [{ title: 'Empty', links: [{ text: 'Ghost', url: 'ROOT:missing.adoc' }] }] },
164
+ },
165
+ ])
166
+ expect(versions[0].footer).toBeUndefined()
167
+ })
168
+
169
+ it('renders a group with no title (GH-77)', async () => {
170
+ const { versions } = await run([
171
+ {
172
+ name: 'weavejs',
173
+ version: 'latest',
174
+ footer: { groups: [{ links: [{ text: 'Home', url: '/plain.html' }] }] },
175
+ },
176
+ ])
177
+ expect(versions[0].footer.groups[0].title).toBeUndefined()
178
+ })
179
+ })
@@ -234,12 +234,15 @@ function splitListItem(li, depth) {
234
234
 
235
235
  function tableToMarkdown(tableNode) {
236
236
  const rows = []
237
- // Backslash is escaped first: escaping `|` alone would let a source
238
- // backslash immediately before one combine with the `\` this line just
239
- // inserted, producing `\\|` an escaped backslash followed by a live,
240
- // unescaped column separator, the exact thing this escape exists to
241
- // prevent.
242
- const escapeCell = (text) => text.replace(/\\/g, '\\\\').replace(/\|/g, '\\|')
237
+ // Backslash and pipe are escaped in a single pass not two chained
238
+ // .replace() calls so neither escape can interfere with the other:
239
+ // escaping `|` first would let a source backslash immediately before one
240
+ // combine with the new backslash to produce `\\|`, an escaped backslash
241
+ // followed by a live, unescaped column separator. A single regex with a
242
+ // replacer callback visits each original character exactly once, so this
243
+ // ordering hazard can't occur no matter which character comes first in
244
+ // the input.
245
+ const escapeCell = (text) => text.replace(/[\\|]/g, (ch) => (ch === '\\' ? String.raw`\\` : String.raw`\|`))
243
246
  for (const rowNode of tableNode.querySelectorAll('tr')) {
244
247
  const cells = rowNode.querySelectorAll('th,td').map((cell) => escapeCell(inlineText(cell)) || ' ')
245
248
  if (cells.length) rows.push(cells)
@@ -39,6 +39,15 @@ describe('htmlToMarkdown', () => {
39
39
  expect(htmlToMarkdown(html)).toBe('| A | B |\n| --- | --- |\n| 1 | 2 |')
40
40
  })
41
41
 
42
+ // Regression test for the escaping order: a cell containing a literal
43
+ // backslash must come out doubled, and a cell containing a literal pipe
44
+ // must come out backslash-escaped, without either interfering with the
45
+ // other — see escapeCell's own comment in html-to-markdown.js.
46
+ it('escapes backslashes and pipes in table cells', () => {
47
+ const html = '<table><tr><th>A</th><th>B</th></tr><tr><td>back\\slash</td><td>pipe|char</td></tr></table>'
48
+ expect(htmlToMarkdown(html)).toBe('| A | B |\n| --- | --- |\n| back\\\\slash | pipe\\|char |')
49
+ })
50
+
42
51
  it('drops table-of-contents and icon elements', () => {
43
52
  const html = '<div class="toc"><a href="#a">A</a></div><p>Body</p><svg><path/></svg>'
44
53
  expect(htmlToMarkdown(html)).toBe('Body')
@@ -122,7 +122,7 @@ async function ensureKrokiRunning(url, playbookDir, logger, deps = {}) {
122
122
  logger.info(
123
123
  'docker compose up -d succeeded — waiting for %s to become reachable...%s',
124
124
  url,
125
- formatProcessOutput(composeResult && composeResult.stdout, composeResult && composeResult.stderr)
125
+ formatProcessOutput(composeResult?.stdout, composeResult?.stderr)
126
126
  )
127
127
 
128
128
  const deadline = Date.now() + STARTUP_TIMEOUT_MS
@@ -158,8 +158,8 @@ async function ensureKrokiRunning(url, playbookDir, logger, deps = {}) {
158
158
  */
159
159
  function formatProcessOutput(stdout, stderr) {
160
160
  const parts = []
161
- if (stdout && stdout.trim()) parts.push('stdout:\n' + stdout.trim())
162
- if (stderr && stderr.trim()) parts.push('stderr:\n' + stderr.trim())
161
+ if (stdout?.trim()) parts.push('stdout:\n' + stdout.trim())
162
+ if (stderr?.trim()) parts.push('stderr:\n' + stderr.trim())
163
163
  return parts.length ? '\n' + parts.join('\n') : ''
164
164
  }
165
165
 
@@ -76,17 +76,18 @@ function getLines(attrs) {
76
76
  filtered = true
77
77
  let delim
78
78
  let from
79
- if (~(delim = linedef.indexOf('..'))) {
79
+ delim = linedef.indexOf('..')
80
+ if (~delim) {
80
81
  from = linedef.substring(0, delim)
81
82
  let to = linedef.substring(delim + 2)
82
- if ((to = parseInt(to, 10) || -1) > 0) {
83
- if ((from = parseInt(from, 10) || -1) > 0) {
83
+ if ((to = Number.parseInt(to, 10) || -1) > 0) {
84
+ if ((from = Number.parseInt(from, 10) || -1) > 0) {
84
85
  for (let i = from; i <= to; i++) linenums.push(i)
85
86
  }
86
- } else if (to === -1 && (from = parseInt(from, 10) || -1) > 0) {
87
+ } else if (to === -1 && (from = Number.parseInt(from, 10) || -1) > 0) {
87
88
  linenums.push(from, Infinity)
88
89
  }
89
- } else if ((from = parseInt(linedef, 10) || -1) > 0) {
90
+ } else if ((from = Number.parseInt(linedef, 10) || -1) > 0) {
90
91
  linenums.push(from)
91
92
  }
92
93
  })
@@ -197,7 +198,8 @@ function filterLinesByTags(content, tags, opts = {}) {
197
198
  if (star === undefined) {
198
199
  selectingDefault = selecting = !mapContainsValue(tags, true)
199
200
  } else {
200
- if ((wildcard = star) || tags.keys().next().value !== '*') {
201
+ wildcard = star
202
+ if (wildcard || tags.keys().next().value !== '*') {
201
203
  selectingDefault = selecting = false
202
204
  } else {
203
205
  selectingDefault = selecting = !wildcard
@@ -240,11 +242,14 @@ function filterLinesByTags(content, tags, opts = {}) {
240
242
  }
241
243
  }
242
244
  } else if (tags.has(thisTag)) {
243
- if ((selecting = tags.get(thisTag))) tagsSelected.push(thisTag)
244
- tagStack.unshift([(activeTag = thisTag), selecting, lineNum])
245
+ selecting = tags.get(thisTag)
246
+ if (selecting) tagsSelected.push(thisTag)
247
+ activeTag = thisTag
248
+ tagStack.unshift([activeTag, selecting, lineNum])
245
249
  } else if (wildcard !== undefined) {
246
250
  selecting = activeTag && !selecting ? false : wildcard
247
- tagStack.unshift([(activeTag = thisTag), selecting, lineNum])
251
+ activeTag = thisTag
252
+ tagStack.unshift([activeTag, selecting, lineNum])
248
253
  }
249
254
  } else if (selecting) {
250
255
  if (!startLineNum) startLineNum = lineNum
@@ -144,4 +144,23 @@ describe('filterLinesByTags', () => {
144
144
  filterLinesByTags(content, getTags({ tags: 'a;b' }), { onWarn: (msg) => warnings.push(msg) })
145
145
  expect(warnings.some((msg) => msg.includes('mismatched end tag'))).toBe(true)
146
146
  })
147
+
148
+ it('defaults to excluding everything when a wildcard (*) is not the sole/first requested tag', () => {
149
+ // globstar (**) is absent, star (*) is present, and the first key
150
+ // inserted into the tags map is not '*' itself ('a' was named first) —
151
+ // both are independently sufficient to force selectingDefault = false.
152
+ const content = ['tag::a[]', 'kept', 'end::a[]', 'tag::c[]', 'unlisted-tag line', 'end::c[]', 'outro'].join('\n')
153
+ const [lines] = filterLinesByTags(content, getTags({ tags: 'a,*' }))
154
+ // 'outro' (outside every tag) is excluded — the false selectingDefault —
155
+ // while 'c', an unlisted tag, still falls through to the wildcard (true).
156
+ expect(lines).toEqual(['kept', 'unlisted-tag line'])
157
+ })
158
+
159
+ it('applies the wildcard to a tag directive that names neither a listed tag nor */**', () => {
160
+ const content = ['tag::unlisted[]', 'wildcard-selected line', 'end::unlisted[]'].join('\n')
161
+ const [selected] = filterLinesByTags(content, getTags({ tags: 'keep,*' }))
162
+ expect(selected).toEqual(['wildcard-selected line'])
163
+ const [excluded] = filterLinesByTags(content, getTags({ tags: 'keep,!*' }))
164
+ expect(excluded).toEqual([])
165
+ })
147
166
  })
@@ -137,11 +137,11 @@ const { getLines, getTags, filterLinesByLineNumbers, filterLinesByTags } = requi
137
137
  // `format=` value from wherever it appears, quote-aware, the same way
138
138
  // `parseIncludeAttrlist` already does for `include::` directives.
139
139
  function buildBlockPattern() {
140
- const typeAlternation = SUPPORTED_TYPES.map((type) => type.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|')
140
+ const typeAlternation = SUPPORTED_TYPES.map((type) => type.replaceAll(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`)).join(
141
+ '|'
142
+ )
141
143
  return new RegExp(
142
- '^\\[(' +
143
- typeAlternation +
144
- ')(?:,([^\\]]*))?\\]\\r?\\n\\.\\.\\.\\.[ \\t]*\\r?\\n([\\s\\S]*?)\\r?\\n\\.\\.\\.\\.[ \\t]*$',
144
+ String.raw`^\[(${typeAlternation})(?:,([^\]]*))?\]\r?\n\.\.\.\.[ \t]*\r?\n([\s\S]*?)\r?\n\.\.\.\.[ \t]*$`,
145
145
  'gm'
146
146
  )
147
147
  }
@@ -481,7 +481,7 @@ async function fetchDiagram(type, source, format, options, logger) {
481
481
  module.exports = function registerKrokiPrewarm(context, deps = {}) {
482
482
  const doEnsureKrokiRunning = deps.ensureKrokiRunning || ensureKrokiRunning
483
483
  context.on('contentClassified', async ({ contentCatalog, playbook }) => {
484
- const attributes = (playbook && playbook.asciidoc && playbook.asciidoc.attributes) || {}
484
+ const attributes = playbook?.asciidoc?.attributes || {}
485
485
  const logger = context.getLogger('docouture-kroki-prewarm')
486
486
  const enabledTypes = resolveEnabledTypes(attributes[ENABLED_ATTR], attributes[TYPES_ATTR], (unknown) =>
487
487
  logger.warn('Ignoring unknown %s entry "%s"; expected one of %s', TYPES_ATTR, unknown, SUPPORTED_TYPES.join(', '))
@@ -493,7 +493,7 @@ module.exports = function registerKrokiPrewarm(context, deps = {}) {
493
493
  // invocation paths (CLI, justfile/nx, raw `antora`, a consumer's own
494
494
  // CI) equally, because this listener runs on all of them. See
495
495
  // kroki-docker.js's own header for why there's no matching teardown.
496
- await doEnsureKrokiRunning(KROKI_URL, playbook && playbook.dir, logger)
496
+ await doEnsureKrokiRunning(KROKI_URL, playbook?.dir, logger)
497
497
 
498
498
  const pattern = buildBlockPattern()
499
499
  // Deduplicated by kroki-instance.js's own key: the same diagram source
@@ -505,7 +505,7 @@ module.exports = function registerKrokiPrewarm(context, deps = {}) {
505
505
  // registered by `registerPageAlias`/`addSplatAlias`) which have no
506
506
  // `.path` of their own at all — guard against those rather than just
507
507
  // the `.adoc` extension check.
508
- for (const file of contentCatalog.getFiles((candidate) => candidate.path && candidate.path.endsWith('.adoc'))) {
508
+ for (const file of contentCatalog.getFiles((candidate) => candidate.path?.endsWith('.adoc'))) {
509
509
  const text = file.contents.toString('utf8')
510
510
  for (const { type, source: rawSource, format: requestedFormat, options } of extractDiagrams(text, pattern)) {
511
511
  if (!enabledTypes.has(type)) continue
@@ -140,7 +140,7 @@ function annotate(componentVersion, { nav, navModules }, contentCatalog, logger)
140
140
 
141
141
  const declared = new Map()
142
142
  for (const entry of navModules) {
143
- if (!entry || entry.constructor !== Object || !entry.module) {
143
+ if (entry?.constructor !== Object || !entry?.module) {
144
144
  logger.warn('Ignoring nav_modules entry in %s: every entry needs a module key', where)
145
145
  continue
146
146
  }
@@ -0,0 +1,223 @@
1
+ // SPDX-FileCopyrightText: 2026 INDUSTRIA DE DISEÑO TEXTIL S.A. (INDITEX S.A.)
2
+ //
3
+ // SPDX-License-Identifier: Apache-2.0
4
+
5
+ 'use strict'
6
+
7
+ import { describe, expect, it, vi } from 'vitest'
8
+
9
+ const registerNavModules = require('./nav-modules')
10
+
11
+ function createContext(logger = { warn: vi.fn(), info: () => {} }) {
12
+ const listeners = {}
13
+ return {
14
+ logger,
15
+ getLogger: () => logger,
16
+ on(event, fn) {
17
+ ;(listeners[event] ||= []).push(fn)
18
+ },
19
+ async emit(event, payload) {
20
+ for (const fn of listeners[event] || []) await fn(payload)
21
+ },
22
+ }
23
+ }
24
+
25
+ function tree(order, items = []) {
26
+ return { order, items }
27
+ }
28
+
29
+ async function run(bucket, trees, { pages = {}, logger } = {}) {
30
+ const context = createContext(logger)
31
+ registerNavModules(context)
32
+
33
+ const componentVersion = { name: 'weavejs', version: 'latest', navigation: trees }
34
+ const contentCatalog = {
35
+ resolvePage: (target) => {
36
+ const url = pages[target]
37
+ return url ? { pub: { url } } : undefined
38
+ },
39
+ getComponents: () => [{ versions: [componentVersion] }],
40
+ }
41
+
42
+ await context.emit('contentAggregated', { contentAggregate: [bucket] })
43
+ await context.emit('navigationBuilt', { contentCatalog })
44
+
45
+ return { componentVersion, logger: context.logger }
46
+ }
47
+
48
+ function warnedAbout(logger, text) {
49
+ return logger.warn.mock.calls.some((call) => typeof call[0] === 'string' && call[0].includes(text))
50
+ }
51
+
52
+ describe('registerNavModules', () => {
53
+ it('stamps a matched tree with module/title and finds its first internal page as startUrl', async () => {
54
+ const item = { urlType: 'internal', url: '/weavejs/latest/main/index.html', items: [] }
55
+ const { componentVersion } = await run(
56
+ {
57
+ name: 'weavejs',
58
+ version: 'latest',
59
+ nav: ['modules/main/nav.adoc'],
60
+ navModules: [{ module: 'main', title: 'Framework', description: 'Desc', icon: 'menu' }],
61
+ },
62
+ [tree(0, [item])]
63
+ )
64
+
65
+ const t = componentVersion.navigation[0]
66
+ expect(t.module).toBe('main')
67
+ expect(t.title).toBe('Framework')
68
+ expect(t.description).toBe('Desc')
69
+ expect(t.icon).toBe('menu')
70
+ expect(t.startUrl).toBe('/weavejs/latest/main/index.html')
71
+ expect(item.module).toBe('main')
72
+ })
73
+
74
+ it('stamps items depth-first, all the way down the tree', async () => {
75
+ const grandchild = { urlType: 'internal', url: '/weavejs/latest/main/deep.html', items: [] }
76
+ const child = { items: [grandchild] } // an unlinked category heading, no url of its own
77
+ const { componentVersion } = await run(
78
+ { name: 'weavejs', version: 'latest', nav: ['modules/main/nav.adoc'], navModules: [{ module: 'main' }] },
79
+ [tree(0, [child])]
80
+ )
81
+ expect(child.module).toBe('main')
82
+ expect(grandchild.module).toBe('main')
83
+ expect(componentVersion.navigation[0].startUrl).toBe('/weavejs/latest/main/deep.html')
84
+ })
85
+
86
+ it('falls back to the module slug as the title when none is declared', async () => {
87
+ const { componentVersion } = await run(
88
+ { name: 'weavejs', version: 'latest', nav: ['modules/main/nav.adoc'], navModules: [{ module: 'main' }] },
89
+ [tree(0, [{ urlType: 'internal', url: '/x.html', items: [] }])]
90
+ )
91
+ expect(componentVersion.navigation[0].title).toBe('main')
92
+ })
93
+
94
+ it('resolves an authored start_page in preference to the first internal item', async () => {
95
+ const { componentVersion } = await run(
96
+ {
97
+ name: 'weavejs',
98
+ version: 'latest',
99
+ nav: ['modules/main/nav.adoc'],
100
+ navModules: [{ module: 'main', startPage: 'main:quickstart.adoc' }],
101
+ },
102
+ [tree(0, [{ urlType: 'internal', url: '/x.html', items: [] }])],
103
+ { pages: { 'main:quickstart.adoc': '/weavejs/latest/main/quickstart.html' } }
104
+ )
105
+ expect(componentVersion.navigation[0].startUrl).toBe('/weavejs/latest/main/quickstart.html')
106
+ })
107
+
108
+ it('warns and falls back to the navigation when start_page resolves to nothing', async () => {
109
+ const logger = { warn: vi.fn(), info: () => {} }
110
+ const { componentVersion } = await run(
111
+ {
112
+ name: 'weavejs',
113
+ version: 'latest',
114
+ nav: ['modules/main/nav.adoc'],
115
+ navModules: [{ module: 'main', startPage: 'main:missing.adoc' }],
116
+ },
117
+ [tree(0, [{ urlType: 'internal', url: '/x.html', items: [] }])],
118
+ { logger }
119
+ )
120
+ expect(componentVersion.navigation[0].startUrl).toBe('/x.html')
121
+ expect(warnedAbout(logger, 'resolves to no page; falling back')).toBe(true)
122
+ })
123
+
124
+ it('warns when a module has no internal page to link to at all', async () => {
125
+ const logger = { warn: vi.fn(), info: () => {} }
126
+ const { componentVersion } = await run(
127
+ { name: 'weavejs', version: 'latest', nav: ['modules/main/nav.adoc'], navModules: [{ module: 'main' }] },
128
+ [tree(0, [])],
129
+ { logger }
130
+ )
131
+ expect(componentVersion.navigation[0].startUrl).toBeUndefined()
132
+ expect(warnedAbout(logger, 'has no internal page to link to')).toBe(true)
133
+ })
134
+
135
+ it('marks switcher:false without warning about a missing start page', async () => {
136
+ const logger = { warn: vi.fn(), info: () => {} }
137
+ const { componentVersion } = await run(
138
+ {
139
+ name: 'weavejs',
140
+ version: 'latest',
141
+ nav: ['modules/ROOT/nav.adoc'],
142
+ navModules: [{ module: 'ROOT', switcher: false }],
143
+ },
144
+ [tree(0, [])],
145
+ { logger }
146
+ )
147
+ expect(componentVersion.navigation[0].switcher).toBe(false)
148
+ expect(componentVersion.navigation[0].startUrl).toBeUndefined()
149
+ expect(warnedAbout(logger, 'has no internal page to link to')).toBe(false)
150
+ })
151
+
152
+ it('floors a fractional tree.order to find the owning nav file (one file, multiple lists)', async () => {
153
+ const { componentVersion } = await run(
154
+ { name: 'weavejs', version: 'latest', nav: ['modules/main/nav.adoc'], navModules: [{ module: 'main' }] },
155
+ [tree(0.5, [{ urlType: 'internal', url: '/x.html', items: [] }])]
156
+ )
157
+ expect(componentVersion.navigation[0].module).toBe('main')
158
+ })
159
+
160
+ it('leaves a tree untouched when its nav path is outside modules/', async () => {
161
+ const { componentVersion } = await run(
162
+ { name: 'weavejs', version: 'latest', nav: ['some-other-file.adoc'], navModules: [{ module: 'main' }] },
163
+ [tree(0, [])]
164
+ )
165
+ expect(componentVersion.navigation[0].module).toBeUndefined()
166
+ })
167
+
168
+ it('warns when navModules is not a list', async () => {
169
+ const logger = { warn: vi.fn(), info: () => {} }
170
+ await run({ name: 'weavejs', version: 'latest', nav: [], navModules: 'not-a-list' }, [], { logger })
171
+ expect(warnedAbout(logger, 'expected a list of entries')).toBe(true)
172
+ })
173
+
174
+ it('warns and skips an entry missing a module key', async () => {
175
+ const logger = { warn: vi.fn(), info: () => {} }
176
+ await run({ name: 'weavejs', version: 'latest', nav: [], navModules: [{ title: 'No module key' }] }, [], {
177
+ logger,
178
+ })
179
+ expect(warnedAbout(logger, 'every entry needs a module key')).toBe(true)
180
+ })
181
+
182
+ it('warns and ignores a duplicate nav_modules entry for the same module', async () => {
183
+ const logger = { warn: vi.fn(), info: () => {} }
184
+ await run(
185
+ {
186
+ name: 'weavejs',
187
+ version: 'latest',
188
+ nav: ['modules/main/nav.adoc'],
189
+ navModules: [
190
+ { module: 'main', title: 'First' },
191
+ { module: 'main', title: 'Second' },
192
+ ],
193
+ },
194
+ [tree(0, [{ urlType: 'internal', url: '/x.html', items: [] }])],
195
+ { logger }
196
+ )
197
+ expect(warnedAbout(logger, 'Ignoring duplicate nav_modules entry')).toBe(true)
198
+ })
199
+
200
+ it('warns about a tree matching no nav_modules entry, without failing', async () => {
201
+ const logger = { warn: vi.fn(), info: () => {} }
202
+ const { componentVersion } = await run(
203
+ { name: 'weavejs', version: 'latest', nav: ['modules/other/nav.adoc'], navModules: [{ module: 'main' }] },
204
+ [tree(0, [{ urlType: 'internal', url: '/x.html', items: [] }])],
205
+ { logger }
206
+ )
207
+ expect(componentVersion.navigation[0].module).toBe('other')
208
+ expect(warnedAbout(logger, 'No nav_modules entry for module')).toBe(true)
209
+ })
210
+
211
+ it('warns about a declared nav_modules entry that matches no navigation file', async () => {
212
+ const logger = { warn: vi.fn(), info: () => {} }
213
+ await run({ name: 'weavejs', version: 'latest', nav: [], navModules: [{ module: 'ghost' }] }, [], { logger })
214
+ expect(warnedAbout(logger, 'matches no navigation file')).toBe(true)
215
+ })
216
+
217
+ it('does nothing for a bucket with no navModules declared', async () => {
218
+ const { componentVersion } = await run({ name: 'weavejs', version: 'latest', nav: [] }, [
219
+ tree(0, [{ urlType: 'internal', url: '/x.html', items: [] }]),
220
+ ])
221
+ expect(componentVersion.navigation[0].module).toBeUndefined()
222
+ })
223
+ })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inditextech/docouture-antora-extensions",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "Antora pipeline extensions shared by docouture documentation sites — per-module navigation metadata (nav_modules)",
5
5
  "repository": {
6
6
  "type": "git",
@@ -27,7 +27,7 @@
27
27
  "@shikijs/themes": "~4.4.0",
28
28
  "node-html-parser": "^9.0.1",
29
29
  "shiki": "~4.4.0",
30
- "@inditextech/docouture-asciidoc-extensions": "1.0.0"
30
+ "@inditextech/docouture-asciidoc-extensions": "1.1.0"
31
31
  },
32
32
  "devDependencies": {
33
33
  "@antora/content-classifier": "~3.2.0"