@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.
- package/lib/changelog-pages.js +1 -1
- package/lib/footer.js +2 -2
- package/lib/footer.spec.js +179 -0
- package/lib/html-to-markdown.js +9 -6
- package/lib/html-to-markdown.spec.js +9 -0
- package/lib/kroki-docker.js +3 -3
- package/lib/kroki-include-line-filters.js +14 -9
- package/lib/kroki-include-line-filters.spec.js +19 -0
- package/lib/kroki-prewarm.js +7 -7
- package/lib/nav-modules.js +1 -1
- package/lib/nav-modules.spec.js +223 -0
- package/package.json +2 -2
package/lib/changelog-pages.js
CHANGED
|
@@ -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
|
|
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 (
|
|
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 (
|
|
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
|
+
})
|
package/lib/html-to-markdown.js
CHANGED
|
@@ -234,12 +234,15 @@ function splitListItem(li, depth) {
|
|
|
234
234
|
|
|
235
235
|
function tableToMarkdown(tableNode) {
|
|
236
236
|
const rows = []
|
|
237
|
-
// Backslash
|
|
238
|
-
//
|
|
239
|
-
//
|
|
240
|
-
//
|
|
241
|
-
//
|
|
242
|
-
|
|
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')
|
package/lib/kroki-docker.js
CHANGED
|
@@ -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
|
|
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
|
|
162
|
-
if (stderr
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
244
|
-
|
|
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
|
-
|
|
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
|
})
|
package/lib/kroki-prewarm.js
CHANGED
|
@@ -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.
|
|
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 =
|
|
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
|
|
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
|
|
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
|
package/lib/nav-modules.js
CHANGED
|
@@ -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 (
|
|
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.
|
|
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.
|
|
30
|
+
"@inditextech/docouture-asciidoc-extensions": "1.1.0"
|
|
31
31
|
},
|
|
32
32
|
"devDependencies": {
|
|
33
33
|
"@antora/content-classifier": "~3.2.0"
|