@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,279 @@
1
+ 'use strict'
2
+
3
+ const { parse, NodeType } = require('node-html-parser')
4
+
5
+ // h1-h4 are destinations worth their own record. h5/h6 are a label inside a
6
+ // section rather than a destination — see this file's header — so they are
7
+ // deliberately absent here and fall through to the generic recursion branch,
8
+ // which folds their text into whatever chunk is currently open.
9
+ const SECTION_HEADING_TAGS = new Set(['h1', 'h2', 'h3', 'h4'])
10
+
11
+ // Elements whose entire subtree is dropped before any text is collected —
12
+ // see the module doc comment for why each one is here.
13
+ const STRIPPED_TAGS = new Set(['pre', 'svg'])
14
+ const STRIPPED_CLASSES = new Set(['toc', 'icon'])
15
+
16
+ const ENVELOPE_VERSION = 1
17
+ // Multilingual search is out of scope for this epic (see #64); every record
18
+ // is authored English prose today, so this is a constant, not a lookup.
19
+ const LANGUAGE = 'english'
20
+
21
+ // @antora/page-composer's own default when neither a page nor the playbook
22
+ // names one — see build-ui-model.js's buildPageUiModel and constants.js.
23
+ const DEFAULT_LAYOUT_NAME = 'default'
24
+ // The layout a landing page opts into with `:page-layout: home`
25
+ // (ui-bundle/src/layouts/home.hbs). It is a shell of card grids, feature
26
+ // tables and a CTA — marketing surface assembled from block extensions, not
27
+ // documentation prose — so it is excluded rather than indexed as if it were
28
+ // a regular doc page.
29
+ const HOME_LAYOUT_NAME = 'home'
30
+
31
+ /**
32
+ * Builds the site's full-text search index at Antora build time and
33
+ * publishes it as one static JSON asset per component version — no UI reads
34
+ * it yet (that starts at #66/#67, S2/S3 of the search epic, #64).
35
+ *
36
+ * Why the HTML and not the AsciiDoc source, why `navigationBuilt`
37
+ * specifically, the record shape, the section-splitting rule and the
38
+ * empty-page fallback are all argued at length in #65 — repeating that
39
+ * reasoning inline would drift out of sync with the issue, so only the
40
+ * mechanics are documented here.
41
+ *
42
+ * ORDERING DEPENDENCY: this module reads `tree.module` / `tree.title` off
43
+ * `componentVersion.navigation`, which `nav-modules.js` stamps on the SAME
44
+ * `navigationBuilt` event. `GeneratorContext#notify` awaits listeners in
45
+ * registration order (see nav-modules.js's own header for the citation), so
46
+ * this only produces correct categories because `index.js` registers
47
+ * nav-modules BEFORE this module. Reorder those calls and every category
48
+ * silently falls back to the component title — no error, just wrong
49
+ * grouping.
50
+ */
51
+ module.exports = function registerSearchIndex(context) {
52
+ const logger = context.getLogger('docouture-search-index')
53
+
54
+ context.on('navigationBuilt', ({ contentCatalog, siteCatalog, playbook }) => {
55
+ const outputDir = playbook.ui?.outputDir ?? '_'
56
+ const defaultLayout = playbook.ui?.defaultLayout || DEFAULT_LAYOUT_NAME
57
+
58
+ // Site-wide feedback (GH-103-adjacent): a component version producing
59
+ // no search records used to fail silently — the index file for it
60
+ // simply never got written, with nothing in the log to explain why.
61
+ // These totals, plus the per-component-version summary below, make what
62
+ // actually got indexed (or didn't) visible on every build without
63
+ // reading the produced JSON files.
64
+ let totalPages = 0
65
+ let totalRecords = 0
66
+ let totalFiles = 0
67
+
68
+ for (const component of contentCatalog.getComponents()) {
69
+ for (const componentVersion of component.versions) {
70
+ const where = `${componentVersion.name}@${componentVersion.version || 'default'}`
71
+ const pages = contentCatalog.getPages(
72
+ (page) =>
73
+ page.out &&
74
+ page.src.component === componentVersion.name &&
75
+ page.src.version === componentVersion.version &&
76
+ resolveLayout(page, defaultLayout) !== HOME_LAYOUT_NAME
77
+ )
78
+ const records = buildComponentVersionRecords(componentVersion, contentCatalog, defaultLayout, logger)
79
+
80
+ if (!records.length) {
81
+ logger.warn('%s produced no search records; no search index written', where)
82
+ continue
83
+ }
84
+
85
+ const basename = componentVersion.version
86
+ ? `${componentVersion.name}-${componentVersion.version}.json`
87
+ : `${componentVersion.name}.json`
88
+ const outPath = `${outputDir}/search/${basename}`
89
+
90
+ const envelope = {
91
+ version: ENVELOPE_VERSION,
92
+ language: LANGUAGE,
93
+ component: componentVersion.name,
94
+ componentVersion: componentVersion.version || '',
95
+ records,
96
+ }
97
+
98
+ siteCatalog.addFile({
99
+ contents: Buffer.from(JSON.stringify(envelope)),
100
+ out: { path: outPath },
101
+ pub: { url: '/' + outPath },
102
+ })
103
+
104
+ // Basename only — see footer.js's own note on componentVersion.footer
105
+ // for why the UI resolves this against uiRootPath rather than being
106
+ // handed an absolute URL.
107
+ componentVersion.searchIndex = basename
108
+
109
+ logger.info('%s: %s pages, %s search records -> %s', where, pages.length, records.length, outPath)
110
+
111
+ totalPages += pages.length
112
+ totalRecords += records.length
113
+ totalFiles += 1
114
+ }
115
+ }
116
+
117
+ logger.info(
118
+ 'search index totals: %s pages, %s records, %s index file(s) written',
119
+ totalPages,
120
+ totalRecords,
121
+ totalFiles
122
+ )
123
+ })
124
+ }
125
+
126
+ function buildComponentVersionRecords(componentVersion, contentCatalog, defaultLayout, logger) {
127
+ // Module -> category title. Empty when the component declares no
128
+ // nav_modules at all, which is what makes a single-module site fall back
129
+ // to one category (its own title) for every page.
130
+ const categoryByModule = new Map()
131
+ for (const tree of componentVersion.navigation || []) {
132
+ if (tree.module) categoryByModule.set(tree.module, tree.title || tree.module)
133
+ }
134
+
135
+ const pages = contentCatalog.getPages(
136
+ (page) =>
137
+ page.out &&
138
+ page.src.component === componentVersion.name &&
139
+ page.src.version === componentVersion.version &&
140
+ resolveLayout(page, defaultLayout) !== HOME_LAYOUT_NAME
141
+ )
142
+
143
+ const records = []
144
+ for (const page of pages) {
145
+ const category = categoryByModule.get(page.src.module) || componentVersion.title
146
+ records.push(...buildPageRecords(page, componentVersion.title, category, logger))
147
+ }
148
+ return records
149
+ }
150
+
151
+ function buildPageRecords(page, componentTitle, category, logger) {
152
+ const title = page.asciidoc?.doctitle
153
+ if (!title) {
154
+ // No AsciiDoc header at all (rare, but a page-partial fragment or the
155
+ // synthetic 404 page are both real cases) — nothing to title a record
156
+ // with, so there is nothing useful to index.
157
+ return []
158
+ }
159
+
160
+ const url = page.pub.url
161
+ const baseHierarchy = dedupeAdjacent([componentTitle, category])
162
+
163
+ // Pass 1: flatten the article into chunks in document order — one for the
164
+ // prose before the first heading (level 0, no heading text), then one per
165
+ // h1-h4 that carries an id. Deliberately not a walk of Asciidoctor's own
166
+ // `.sect1 > h2 + .sectionbody` nesting; see #65 for why that shape is the
167
+ // wrong one to reproduce.
168
+ const root = parse(page.contents.toString())
169
+ const chunks = [{ level: 0, headingText: undefined, id: undefined, textParts: [] }]
170
+ walk(root, chunks)
171
+
172
+ // Pass 2: turn chunks into records, tracking an explicit stack of open
173
+ // ancestor headings by level (headings are siblings in the flattened HTML,
174
+ // not DOM ancestors of the sections they open, so this can't be read off
175
+ // the tree itself).
176
+ const stack = []
177
+ const records = []
178
+ for (const chunk of chunks) {
179
+ const content = collapseWhitespace(chunk.textParts.join(''))
180
+ if (chunk.level === 0) {
181
+ if (content) {
182
+ records.push({ title, hierarchy: baseHierarchy, content, url, category })
183
+ }
184
+ continue
185
+ }
186
+
187
+ // Ancestors are whatever is still open at a shallower level than this
188
+ // heading; this heading's own text is not one of its own ancestors.
189
+ const ancestors = stack.slice(0, chunk.level - 1).filter(Boolean)
190
+ records.push({
191
+ title,
192
+ section: chunk.headingText,
193
+ hierarchy: [...baseHierarchy, ...ancestors],
194
+ content,
195
+ url: `${url}#${chunk.id}`,
196
+ category,
197
+ })
198
+
199
+ stack[chunk.level - 1] = chunk.headingText
200
+ stack.length = chunk.level
201
+ }
202
+
203
+ if (records.some((record) => record.content)) return records
204
+
205
+ // The empty-page case (#65): a page whose entire body was `pre` blocks
206
+ // produces only content-less chunks above (no prose at all, whether or
207
+ // not a heading happened to wrap the code) and would otherwise be
208
+ // unfindable by its own title. One fallback record carries what
209
+ // survives: title, hierarchy, and the page's own :description: attribute
210
+ // where it has one.
211
+ logger.info('Page %s produced no search content; emitting a fallback record', url)
212
+ return [
213
+ {
214
+ title,
215
+ hierarchy: baseHierarchy,
216
+ content: page.asciidoc?.attributes?.description || '',
217
+ url,
218
+ category,
219
+ },
220
+ ]
221
+ }
222
+
223
+ // Depth-first, document-order walk. Pushes a new chunk onto `chunks`
224
+ // whenever a heading with an id is found; every other text node is appended
225
+ // to whichever chunk is currently last.
226
+ function walk(node, chunks) {
227
+ for (const child of node.childNodes) {
228
+ if (child.nodeType === NodeType.TEXT_NODE) {
229
+ chunks[chunks.length - 1].textParts.push(child.text)
230
+ continue
231
+ }
232
+ if (child.nodeType !== NodeType.ELEMENT_NODE) continue
233
+
234
+ const tag = child.rawTagName?.toLowerCase()
235
+ if (STRIPPED_TAGS.has(tag)) continue
236
+ if ((child.classList?.value || []).some((cls) => STRIPPED_CLASSES.has(cls))) continue
237
+
238
+ const id = child.getAttribute('id')
239
+ if (SECTION_HEADING_TAGS.has(tag) && id) {
240
+ chunks.push({
241
+ level: Number(tag[1]),
242
+ headingText: collapseWhitespace(child.text),
243
+ id,
244
+ textParts: [],
245
+ })
246
+ continue
247
+ }
248
+
249
+ // A wrapper containing a heading — this project's own block extensions
250
+ // (cards, steps, feature tabs, accordion) among them — has to be opened
251
+ // up, or its prose gets filed under whichever chunk was current when the
252
+ // wrapper opened. Recursing here is what "opens it up". Inline `<code>`
253
+ // falls through this same branch and its text lands in the current
254
+ // chunk, which is why it needs no special case to be kept.
255
+ walk(child, chunks)
256
+ }
257
+ }
258
+
259
+ function collapseWhitespace(text) {
260
+ return text.replace(/\s+/g, ' ').trim()
261
+ }
262
+
263
+ // Mirrors @antora/page-composer's own resolution (build-ui-model.js:86):
264
+ // a page's `:page-layout:` attribute, else the playbook's ui.default_layout,
265
+ // else Antora's own 'default'. Read directly off `file.asciidoc.attributes`
266
+ // rather than `page.attributes`/`page.layout` because neither of those UI
267
+ // model fields exists yet at `navigationBuilt` — page-composer builds them
268
+ // at the next step, `pagesComposed`.
269
+ function resolveLayout(page, defaultLayout) {
270
+ return page.asciidoc?.attributes?.['page-layout'] || defaultLayout
271
+ }
272
+
273
+ function dedupeAdjacent(parts) {
274
+ const out = []
275
+ for (const part of parts) {
276
+ if (part && part !== out[out.length - 1]) out.push(part)
277
+ }
278
+ return out
279
+ }
@@ -0,0 +1,104 @@
1
+ 'use strict'
2
+
3
+ import { describe, expect, it } from 'vitest'
4
+
5
+ const registerSearchIndex = require('./search-index')
6
+
7
+ function createContext() {
8
+ const listeners = {}
9
+ const logs = { info: [], warn: [] }
10
+ return {
11
+ logs,
12
+ getLogger: () => ({
13
+ info: (...args) => logs.info.push(args),
14
+ warn: (...args) => logs.warn.push(args),
15
+ }),
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 createPage({ component = 'test-docs', version = '', module: mod = 'ROOT', url, title, html }) {
26
+ return {
27
+ out: true,
28
+ src: { component, version, module: mod },
29
+ pub: { url },
30
+ asciidoc: { doctitle: title, attributes: {} },
31
+ contents: Buffer.from(html || `<h2 id="x">Section</h2><p>Body text.</p>`),
32
+ }
33
+ }
34
+
35
+ function createContentCatalog(components, pages) {
36
+ return {
37
+ getComponents: () => components,
38
+ getPages: (filterFn) => pages.filter(filterFn),
39
+ }
40
+ }
41
+
42
+ async function run({ components, pages }) {
43
+ const context = createContext()
44
+ registerSearchIndex(context)
45
+
46
+ const contentCatalog = createContentCatalog(components, pages)
47
+ const files = []
48
+ const siteCatalog = { addFile: (f) => files.push(f) }
49
+ const playbook = { ui: {} }
50
+
51
+ await context.emit('navigationBuilt', { contentCatalog, siteCatalog, playbook })
52
+
53
+ return { files, logs: context.logs }
54
+ }
55
+
56
+ describe('registerSearchIndex feedback', () => {
57
+ it('logs a per-component-version summary and a site-wide total', async () => {
58
+ const componentVersion = { name: 'test-docs', version: '', title: 'Test Docs', navigation: [] }
59
+ const component = { versions: [componentVersion] }
60
+ const pages = [createPage({ url: '/test-docs/index.html', title: 'Home' })]
61
+
62
+ const { files, logs } = await run({ components: [component], pages })
63
+
64
+ expect(files).toHaveLength(1)
65
+ expect(logs.info).toContainEqual([
66
+ '%s: %s pages, %s search records -> %s',
67
+ 'test-docs@default',
68
+ 1,
69
+ 1,
70
+ '_/search/test-docs.json',
71
+ ])
72
+ expect(logs.info).toContainEqual(['search index totals: %s pages, %s records, %s index file(s) written', 1, 1, 1])
73
+ expect(logs.warn).toHaveLength(0)
74
+ })
75
+
76
+ it('warns, instead of silently skipping, when a component version produces no search records', async () => {
77
+ const componentVersion = { name: 'empty-docs', version: '', title: 'Empty Docs', navigation: [] }
78
+ const component = { versions: [componentVersion] }
79
+
80
+ const { files, logs } = await run({ components: [component], pages: [] })
81
+
82
+ expect(files).toHaveLength(0)
83
+ expect(logs.warn).toHaveLength(1)
84
+ expect(logs.warn[0].join(' ')).toContain('empty-docs@default')
85
+ expect(logs.warn[0].join(' ')).toContain('no search index written')
86
+ })
87
+
88
+ it('accumulates totals across multiple component versions', async () => {
89
+ const versionA = { name: 'a', version: '', title: 'A', navigation: [] }
90
+ const versionB = { name: 'b', version: '', title: 'B', navigation: [] }
91
+ const components = [{ versions: [versionA] }, { versions: [versionB] }]
92
+ const pages = [
93
+ createPage({ component: 'a', url: '/a/index.html', title: 'A Home' }),
94
+ createPage({ component: 'b', url: '/b/index.html', title: 'B Home' }),
95
+ ]
96
+
97
+ const { files, logs } = await run({ components, pages })
98
+
99
+ expect(files).toHaveLength(2)
100
+ const totalsLine = logs.info.find((args) => args[0].startsWith('search index totals'))
101
+ expect(totalsLine).toEqual(['search index totals: %s pages, %s records, %s index file(s) written', 2, 2, 2])
102
+ expect(logs.warn).toHaveLength(0)
103
+ })
104
+ })
@@ -0,0 +1,76 @@
1
+ 'use strict'
2
+
3
+ // GH-89: pre-warms the one Shiki highlighter instance the whole build's
4
+ // source blocks share, asynchronously, before any page is converted.
5
+ //
6
+ // This is an ANTORA pipeline extension (`antora.extensions` in the
7
+ // playbook) — see index.js's own header for why this package's exports are
8
+ // that kind and not the `asciidoc.extensions` kind. It exists only to solve
9
+ // GH-89's own "Key Risk / Spike Required" section: `@asciidoctor/core ~2.2`
10
+ // converts every page through a fully-synchronous (Opal) loop, but building
11
+ // a Shiki highlighter — instantiating its WASM oniguruma engine, loading
12
+ // every bundled language/theme grammar — is asynchronous. So that async
13
+ // work has to happen ONCE, up front, outside the conversion loop entirely,
14
+ // and its result handed to the synchronous side through a plain shared
15
+ // module (`shiki-instance.js`, in the SIBLING
16
+ // @inditextech/docouture-asciidoc-extensions package — that's where the
17
+ // synchronous consumer, `shiki-syntax-highlighter.js`'s `highlight()`,
18
+ // lives too).
19
+ //
20
+ // `contentAggregated` is the event to hook: @antora/site-generator awaits it
21
+ // (`generate-site.js`, `await context.notify('contentAggregated', …)`)
22
+ // before `contentClassified`, itself before `documentsConverted` — the
23
+ // phase that actually calls into Asciidoctor. Same ordering guarantee
24
+ // nav-modules.js already relies on for its own `contentAggregated` listener
25
+ // (see that file's header) — this one just doesn't touch `contentAggregate`
26
+ // itself, so it declares zero parameters.
27
+ const { createOnigurumaEngine } = require('shiki/engine/oniguruma')
28
+ const { createHighlighterCoreSync } = require('shiki/core')
29
+ const shikiInstance = require('@inditextech/docouture-asciidoc-extensions/lib/shiki-instance')
30
+ const { LANGS, LIGHT_THEME, DARK_THEME } = require('@inditextech/docouture-asciidoc-extensions/lib/shiki-config')
31
+
32
+ async function loadLangs() {
33
+ return Promise.all(LANGS.map((id) => import('@shikijs/langs/' + id).then((mod) => mod.default)))
34
+ }
35
+
36
+ async function loadThemes() {
37
+ return Promise.all([LIGHT_THEME, DARK_THEME].map((id) => import('@shikijs/themes/' + id).then((mod) => mod.default)))
38
+ }
39
+
40
+ module.exports = function registerShikiPrewarm(context) {
41
+ context.on('contentAggregated', async function docoutureShikiPrewarm() {
42
+ const [engine, langs, themes] = await Promise.all([
43
+ // `shiki/wasm` ships the pre-built oniguruma binary — no network fetch,
44
+ // no separate build step. Instantiating it is the one truly
45
+ // asynchronous piece; everything downstream of this `await` in
46
+ // shiki-syntax-highlighter.js's `highlight()` is synchronous.
47
+ createOnigurumaEngine(import('shiki/wasm')),
48
+ loadLangs(),
49
+ loadThemes(),
50
+ ])
51
+
52
+ // Sync from here: `createHighlighterCoreSync` (note: NOT
53
+ // `createHighlighterCore`) requires its engine and grammars already
54
+ // resolved, in exchange for a highlighter whose own `codeToHtml` is a
55
+ // plain synchronous function — the whole point of doing all of the
56
+ // above up front instead of on first use.
57
+ const highlighter = createHighlighterCoreSync({ engine, langs, themes })
58
+
59
+ // Shiki's own default-colour custom properties normally live on the
60
+ // `<pre>` it generates itself; shiki-syntax-highlighter.js discards that
61
+ // wrapper (see its own header) and needs them separately. Probing a
62
+ // trivial block through the real highlighter — rather than reading
63
+ // theme JSON fields directly — guarantees this matches whatever Shiki
64
+ // itself would have put there, without this file needing to know how
65
+ // Shiki derives a theme's default foreground/background.
66
+ const probe = highlighter.codeToHtml('', {
67
+ lang: 'text',
68
+ themes: { light: LIGHT_THEME, dark: DARK_THEME },
69
+ defaultColor: false,
70
+ })
71
+ const match = /<pre[^>]*\sstyle="([^"]*)"/.exec(probe)
72
+ const rootStyle = match ? match[1] : ''
73
+
74
+ shikiInstance.set(highlighter, rootStyle)
75
+ })
76
+ }
@@ -0,0 +1,40 @@
1
+ 'use strict'
2
+
3
+ /**
4
+ * Reports, on every build, exactly what Antora computed for each
5
+ * component's versions — which ones exist, and which one it picked as
6
+ * `latest`. Purely diagnostic: nothing here changes any build output.
7
+ *
8
+ * Exists because that computation (@antora/content-classifier's
9
+ * `registerComponentVersion`, `component.versions.find(v => !v.prerelease)`)
10
+ * is otherwise invisible until someone notices a version tag/toggle
11
+ * rendering the wrong colour on the built site — by which point it could
12
+ * just as easily be a stale/never-actually-published deploy as a real
13
+ * version-data problem (see the docouture-publish.yml / gh-pages fixes this
14
+ * shipped alongside). Logging it plainly at build time answers "did Antora
15
+ * pick the version I expected as latest" without needing to inspect a live
16
+ * site at all.
17
+ *
18
+ * Hooked on `contentClassified` — the earliest point after
19
+ * `classifyContent` has populated the content catalog (every component's
20
+ * `versions[]` and `.latest` fully resolved) and before navigation/pages are
21
+ * built. Always `logger.info`, never `logger.warn`: this is a plain report
22
+ * of what Antora decided, not a claim that anything is wrong — a component
23
+ * legitimately has only one (prerelease) version before its first release,
24
+ * and that is not an error.
25
+ */
26
+ module.exports = function registerVersionReport(context) {
27
+ const logger = context.getLogger('docouture-version-report')
28
+
29
+ context.on('contentClassified', ({ contentCatalog }) => {
30
+ for (const component of contentCatalog.getComponents()) {
31
+ const rows = component.versions.map((componentVersion) => {
32
+ const flags = []
33
+ if (componentVersion === component.latest) flags.push('latest')
34
+ if (componentVersion.prerelease) flags.push('prerelease')
35
+ return flags.length ? `${componentVersion.version} (${flags.join(', ')})` : componentVersion.version
36
+ })
37
+ logger.info('%s versions: %s', component.name, rows.join(', '))
38
+ }
39
+ })
40
+ }
@@ -0,0 +1,83 @@
1
+ 'use strict'
2
+
3
+ import { describe, expect, it } from 'vitest'
4
+
5
+ const registerVersionReport = require('./version-report')
6
+
7
+ function createContext(logs) {
8
+ const listeners = {}
9
+ return {
10
+ getLogger: () => ({
11
+ warn: () => {},
12
+ info: (...args) => logs.push(args),
13
+ }),
14
+ on(event, fn) {
15
+ ;(listeners[event] ||= []).push(fn)
16
+ },
17
+ async emit(event, payload) {
18
+ for (const fn of listeners[event] || []) await fn(payload)
19
+ },
20
+ }
21
+ }
22
+
23
+ function componentVersion({ version, prerelease }) {
24
+ return { version, prerelease }
25
+ }
26
+
27
+ async function run(components) {
28
+ const logs = []
29
+ const context = createContext(logs)
30
+ registerVersionReport(context)
31
+
32
+ const contentCatalog = {
33
+ getComponents: () => components,
34
+ }
35
+
36
+ await context.emit('contentClassified', { contentCatalog })
37
+ return logs.map(([, ...args]) => args.join(': '))
38
+ }
39
+
40
+ describe('registerVersionReport', () => {
41
+ it('flags the version Antora picked as latest', async () => {
42
+ const stable = componentVersion({ version: 'stable' })
43
+ const prerelease = componentVersion({ version: 'prerelease', prerelease: true })
44
+ const component = { name: 'test-docs', versions: [stable, prerelease], latest: stable }
45
+
46
+ const rows = await run([component])
47
+
48
+ expect(rows).toEqual(['test-docs: stable (latest), prerelease (prerelease)'])
49
+ })
50
+
51
+ it('reports a single-version component with no latest/prerelease flags implied incorrectly', async () => {
52
+ const onlyVersion = componentVersion({ version: 'prerelease', prerelease: true })
53
+ const component = { name: 'test-docs', versions: [onlyVersion], latest: onlyVersion }
54
+
55
+ const rows = await run([component])
56
+
57
+ // A component with only a prerelease version still has SOME latest
58
+ // (Antora's own fallback — there's no "no latest" state) — both flags
59
+ // legitimately apply to the same single entry.
60
+ expect(rows).toEqual(['test-docs: prerelease (latest, prerelease)'])
61
+ })
62
+
63
+ it('reports every component in the catalog', async () => {
64
+ const v1 = componentVersion({ version: '1.0' })
65
+ const v2 = componentVersion({ version: '2.0' })
66
+ const componentA = { name: 'a', versions: [v1], latest: v1 }
67
+ const componentB = { name: 'b', versions: [v2], latest: v2 }
68
+
69
+ const rows = await run([componentA, componentB])
70
+
71
+ expect(rows).toEqual(['a: 1.0 (latest)', 'b: 2.0 (latest)'])
72
+ })
73
+
74
+ it('reports a non-latest, non-prerelease version plainly', async () => {
75
+ const older = componentVersion({ version: '1.0' })
76
+ const newer = componentVersion({ version: '2.0' })
77
+ const component = { name: 'test-docs', versions: [older, newer], latest: newer }
78
+
79
+ const rows = await run([component])
80
+
81
+ expect(rows).toEqual(['test-docs: 1.0, 2.0 (latest)'])
82
+ })
83
+ })
package/package.json ADDED
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "@inditextech/docouture-antora-extensions",
3
+ "version": "0.1.0-SNAPSHOT.40.1",
4
+ "description": "Antora pipeline extensions shared by docouture documentation sites — per-module navigation metadata (nav_modules)",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "https://github.com/InditexTech/test-antoradocs.git",
8
+ "directory": "code/packages/antora-extensions"
9
+ },
10
+ "license": "MPL-2.0",
11
+ "main": "index.js",
12
+ "files": [
13
+ "index.js",
14
+ "lib",
15
+ "resources"
16
+ ],
17
+ "engines": {
18
+ "node": ">=24.0.0"
19
+ },
20
+ "dependencies": {
21
+ "@antora/page-composer": "~3.1.15",
22
+ "@antora/redirect-producer": "~3.1.15",
23
+ "@shikijs/langs": "~4.4.0",
24
+ "@shikijs/themes": "~4.4.0",
25
+ "node-html-parser": "^9.0.1",
26
+ "shiki": "~4.4.0",
27
+ "@inditextech/docouture-asciidoc-extensions": "0.1.0-SNAPSHOT.40.1"
28
+ },
29
+ "scripts": {
30
+ "lint": "eslint .",
31
+ "test": "vitest run"
32
+ }
33
+ }