@inditextech/docouture-antora-extensions 0.1.0-SNAPSHOT.40.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/index.js +113 -0
- package/lib/duplicate-latest-version.js +148 -0
- package/lib/duplicate-latest-version.spec.js +204 -0
- package/lib/footer.js +122 -0
- package/lib/html-to-markdown.js +259 -0
- package/lib/html-to-markdown.spec.js +73 -0
- package/lib/kroki-docker.js +162 -0
- package/lib/kroki-docker.spec.js +92 -0
- package/lib/kroki-prewarm.js +182 -0
- package/lib/kroki-prewarm.spec.js +209 -0
- package/lib/lifecycle-log.js +61 -0
- package/lib/lifecycle-log.spec.js +59 -0
- package/lib/llms-txt.js +185 -0
- package/lib/llms-txt.spec.js +172 -0
- package/lib/nav-modules.js +233 -0
- package/lib/not-found-page.js +170 -0
- package/lib/redirects.js +249 -0
- package/lib/redirects.spec.js +129 -0
- package/lib/resolve-url.js +45 -0
- package/lib/search-index.js +279 -0
- package/lib/search-index.spec.js +104 -0
- package/lib/shiki-prewarm.js +76 -0
- package/lib/version-report.js +40 -0
- package/lib/version-report.spec.js +83 -0
- package/package.json +33 -0
- package/resources/kroki-compose.yml +72 -0
|
@@ -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
|
+
}
|