@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,182 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
// GH-44: pre-renders every `[mermaid]`/`[plantuml]`/etc. block the raw
|
|
4
|
+
// content aggregate contains, via a self-hosted Kroki service, asynchronously,
|
|
5
|
+
// before any page is converted. Also starts that Kroki service itself, on
|
|
6
|
+
// demand, if nothing answers at its fixed URL yet — see kroki-docker.js's
|
|
7
|
+
// own header for that half.
|
|
8
|
+
//
|
|
9
|
+
// This is an ANTORA pipeline extension (`antora.extensions` in the
|
|
10
|
+
// playbook) — see index.js's own header for why this package's exports are
|
|
11
|
+
// that kind, and shiki-prewarm.js's own header for the identical shape this
|
|
12
|
+
// follows: `@asciidoctor/core ~2.2` converts every page through a fully
|
|
13
|
+
// synchronous (Opal) loop, but rendering a diagram means an HTTP round trip
|
|
14
|
+
// to Kroki, which is asynchronous. So that work happens ONCE, up front,
|
|
15
|
+
// outside the conversion loop entirely, and its result handed to the
|
|
16
|
+
// synchronous side (kroki.js, in the SIBLING
|
|
17
|
+
// @inditextech/docouture-asciidoc-extensions package) through a plain shared
|
|
18
|
+
// module, kroki-instance.js.
|
|
19
|
+
//
|
|
20
|
+
// Unlike Shiki, there is no single instance to build — every distinct
|
|
21
|
+
// diagram in the corpus needs its own render, and this listener does not yet
|
|
22
|
+
// know which pages actually have `[mermaid]` blocks (that's Asciidoctor's
|
|
23
|
+
// job, which hasn't run yet). So it works off the RAW content instead:
|
|
24
|
+
// `contentAggregated` fires with every source file's contents still a plain
|
|
25
|
+
// Buffer, before Antora classifies anything into pages — the same point
|
|
26
|
+
// nav-modules.js reads a component descriptor's own unknown keys before
|
|
27
|
+
// classification discards them (see that file's header for the citation on
|
|
28
|
+
// event ordering).
|
|
29
|
+
//
|
|
30
|
+
// `kroki-enabled` and `kroki-diagram-types` are read off the PLAYBOOK here
|
|
31
|
+
// (`playbook.asciidoc.attributes`), not off any one document — there is no
|
|
32
|
+
// AsciiDoc document yet at this point in the pipeline. kroki-config.js's
|
|
33
|
+
// `resolveEnabledTypes` is shared with kroki.js specifically so the two
|
|
34
|
+
// never disagree about what "enabled" means from two different attribute
|
|
35
|
+
// shapes (a raw playbook value here, `Document#getAttribute` there).
|
|
36
|
+
const {
|
|
37
|
+
SUPPORTED_TYPES,
|
|
38
|
+
ENABLED_ATTR,
|
|
39
|
+
TYPES_ATTR,
|
|
40
|
+
KROKI_URL,
|
|
41
|
+
resolveEnabledTypes,
|
|
42
|
+
resolveFormat,
|
|
43
|
+
} = require('@inditextech/docouture-asciidoc-extensions/lib/kroki-config')
|
|
44
|
+
const { applyDefaultMermaidTheme } = require('@inditextech/docouture-asciidoc-extensions/lib/kroki-mermaid-theme')
|
|
45
|
+
const kroki = require('@inditextech/docouture-asciidoc-extensions/lib/kroki-instance')
|
|
46
|
+
const { ensureKrokiRunning } = require('./kroki-docker')
|
|
47
|
+
|
|
48
|
+
// Matches the exact shape kroki.js intercepts: a `[type]` (optionally
|
|
49
|
+
// `,format=<anything>` — deliberately not restricted to `svg|png` here;
|
|
50
|
+
// `resolveFormat` is the one place both this file and kroki.js validate the
|
|
51
|
+
// value, so a typo (`format=jpeg`) is caught and warned about the same way
|
|
52
|
+
// on both sides, rather than this regex silently failing to match the
|
|
53
|
+
// whole block and this file treating it as not a diagram at all while
|
|
54
|
+
// kroki.js's own Asciidoctor-parsed `attrs.format` still sees it) style
|
|
55
|
+
// line immediately followed by a four-dot-delimited LITERAL block. Built
|
|
56
|
+
// from `SUPPORTED_TYPES` rather than a bare `\w+`, so a block styled with
|
|
57
|
+
// some other, unsupported name (or a coincidental four-dot block that
|
|
58
|
+
// isn't a diagram at all) is never sent to Kroki on a guess. Tolerant of
|
|
59
|
+
// `\r\n` line endings and of trailing whitespace on the delimiter lines —
|
|
60
|
+
// real-world authoring and git checkout settings both produce those — but
|
|
61
|
+
// otherwise matches `tools/fumadocs-migrate/lib/emit.mjs`'s own emitted
|
|
62
|
+
// shape verbatim, since that's the one real caller of this today (which
|
|
63
|
+
// never emits `format=`).
|
|
64
|
+
function buildBlockPattern() {
|
|
65
|
+
const typeAlternation = SUPPORTED_TYPES.map((type) => type.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|')
|
|
66
|
+
return new RegExp(
|
|
67
|
+
'^\\[(' +
|
|
68
|
+
typeAlternation +
|
|
69
|
+
')(?:,\\s*format\\s*=\\s*([a-zA-Z0-9_-]*)\\s*)?\\]\\r?\\n\\.\\.\\.\\.[ \\t]*\\r?\\n([\\s\\S]*?)\\r?\\n\\.\\.\\.\\.[ \\t]*$',
|
|
70
|
+
'gm'
|
|
71
|
+
)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Every `(type, source, format)` triple found in one file's raw contents. */
|
|
75
|
+
function extractDiagrams(text, pattern) {
|
|
76
|
+
const found = []
|
|
77
|
+
pattern.lastIndex = 0
|
|
78
|
+
let match
|
|
79
|
+
while ((match = pattern.exec(text))) {
|
|
80
|
+
found.push({ type: match[1], format: match[2], source: match[3] })
|
|
81
|
+
}
|
|
82
|
+
return found
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function fetchDiagram(type, source, format, logger) {
|
|
86
|
+
try {
|
|
87
|
+
const response = await fetch(KROKI_URL + '/' + type + '/' + format, {
|
|
88
|
+
method: 'POST',
|
|
89
|
+
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
|
|
90
|
+
body: source,
|
|
91
|
+
})
|
|
92
|
+
if (!response.ok) {
|
|
93
|
+
logger.warn(
|
|
94
|
+
'Kroki render failed for a %s diagram (%s): %s %s — falling back to raw source for it',
|
|
95
|
+
type,
|
|
96
|
+
format,
|
|
97
|
+
response.status,
|
|
98
|
+
response.statusText
|
|
99
|
+
)
|
|
100
|
+
return
|
|
101
|
+
}
|
|
102
|
+
if (format === 'png') {
|
|
103
|
+
return Buffer.from(await response.arrayBuffer()).toString('base64')
|
|
104
|
+
}
|
|
105
|
+
return await response.text()
|
|
106
|
+
} catch (err) {
|
|
107
|
+
logger.warn(
|
|
108
|
+
'Could not reach the Kroki service at %s to render a %s diagram (%s) — falling back to raw source for it',
|
|
109
|
+
KROKI_URL,
|
|
110
|
+
type,
|
|
111
|
+
err.message
|
|
112
|
+
)
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
module.exports = function registerKrokiPrewarm(context, deps = {}) {
|
|
117
|
+
const doEnsureKrokiRunning = deps.ensureKrokiRunning || ensureKrokiRunning
|
|
118
|
+
context.on('contentAggregated', async ({ contentAggregate, playbook }) => {
|
|
119
|
+
const attributes = (playbook && playbook.asciidoc && playbook.asciidoc.attributes) || {}
|
|
120
|
+
const logger = context.getLogger('docouture-kroki-prewarm')
|
|
121
|
+
const enabledTypes = resolveEnabledTypes(attributes[ENABLED_ATTR], attributes[TYPES_ATTR], (unknown) =>
|
|
122
|
+
logger.warn('Ignoring unknown %s entry "%s"; expected one of %s', TYPES_ATTR, unknown, SUPPORTED_TYPES.join(', '))
|
|
123
|
+
)
|
|
124
|
+
if (!enabledTypes.size) return
|
|
125
|
+
|
|
126
|
+
// GH-44: no manual `docker compose up` step required — the first build
|
|
127
|
+
// that actually needs Kroki starts it, on any of this feature's
|
|
128
|
+
// invocation paths (CLI, justfile/nx, raw `antora`, a consumer's own
|
|
129
|
+
// CI) equally, because this listener runs on all of them. See
|
|
130
|
+
// kroki-docker.js's own header for why there's no matching teardown.
|
|
131
|
+
await doEnsureKrokiRunning(KROKI_URL, playbook && playbook.dir, logger)
|
|
132
|
+
|
|
133
|
+
const pattern = buildBlockPattern()
|
|
134
|
+
// Deduplicated by kroki-instance.js's own key: the same diagram source
|
|
135
|
+
// (and requested format) repeated across pages (or byte-identical after
|
|
136
|
+
// a migration re-run) should cost one Kroki call, not one per
|
|
137
|
+
// occurrence.
|
|
138
|
+
const jobs = new Map()
|
|
139
|
+
for (const bucket of contentAggregate) {
|
|
140
|
+
for (const file of bucket.files || []) {
|
|
141
|
+
if (!file.path || !file.path.endsWith('.adoc')) continue
|
|
142
|
+
const text = file.contents.toString('utf8')
|
|
143
|
+
for (const { type, source, format: requestedFormat } of extractDiagrams(text, pattern)) {
|
|
144
|
+
if (!enabledTypes.has(type)) continue
|
|
145
|
+
const format = resolveFormat(type, requestedFormat, (t, requested) =>
|
|
146
|
+
logger.warn('Ignoring unsupported format "%s" for a %s diagram; falling back to svg', requested, t)
|
|
147
|
+
)
|
|
148
|
+
// See kroki-mermaid-theme.js's own header: this has to be the
|
|
149
|
+
// SAME transform kroki.js applies before computing its own
|
|
150
|
+
// lookup key, or a prewarmed entry becomes unreachable from the
|
|
151
|
+
// synchronous side.
|
|
152
|
+
const effectiveSource = type === 'mermaid' ? applyDefaultMermaidTheme(source) : source
|
|
153
|
+
const key = kroki.keyFor(type, effectiveSource, format)
|
|
154
|
+
if (!jobs.has(key)) jobs.set(key, { type, source: effectiveSource, format })
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
if (!jobs.size) return
|
|
159
|
+
|
|
160
|
+
logger.info('Rendering %d diagram(s) via Kroki at %s', jobs.size, KROKI_URL)
|
|
161
|
+
let rendered = 0
|
|
162
|
+
await Promise.all(
|
|
163
|
+
Array.from(jobs, async ([key, { type, source, format }]) => {
|
|
164
|
+
const data = await fetchDiagram(type, source, format, logger)
|
|
165
|
+
if (data) {
|
|
166
|
+
kroki.set(key, { format, data })
|
|
167
|
+
rendered++
|
|
168
|
+
}
|
|
169
|
+
})
|
|
170
|
+
)
|
|
171
|
+
if (rendered === jobs.size) {
|
|
172
|
+
logger.info('Kroki rendered all %d diagram(s) successfully', jobs.size)
|
|
173
|
+
} else {
|
|
174
|
+
logger.warn(
|
|
175
|
+
'Kroki rendered %d/%d diagram(s); %d fell back to raw source — see warnings above for why',
|
|
176
|
+
rendered,
|
|
177
|
+
jobs.size,
|
|
178
|
+
jobs.size - rendered
|
|
179
|
+
)
|
|
180
|
+
}
|
|
181
|
+
})
|
|
182
|
+
}
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
4
|
+
|
|
5
|
+
const registerKrokiPrewarm = require('./kroki-prewarm')
|
|
6
|
+
const kroki = require('@inditextech/docouture-asciidoc-extensions/lib/kroki-instance')
|
|
7
|
+
const { applyDefaultMermaidTheme } = require('@inditextech/docouture-asciidoc-extensions/lib/kroki-mermaid-theme')
|
|
8
|
+
|
|
9
|
+
function createContext() {
|
|
10
|
+
const listeners = {}
|
|
11
|
+
const warnings = []
|
|
12
|
+
const infos = []
|
|
13
|
+
return {
|
|
14
|
+
getLogger: () => ({ warn: (...args) => warnings.push(args), info: (...args) => infos.push(args) }),
|
|
15
|
+
on(event, fn) {
|
|
16
|
+
;(listeners[event] ||= []).push(fn)
|
|
17
|
+
},
|
|
18
|
+
async emit(event, payload) {
|
|
19
|
+
for (const fn of listeners[event] || []) await fn(payload)
|
|
20
|
+
},
|
|
21
|
+
warnings,
|
|
22
|
+
infos,
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function file(path, contents) {
|
|
27
|
+
return { path, contents: Buffer.from(contents, 'utf8') }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function block(type, source, format) {
|
|
31
|
+
const style = format ? `${type},format=${format}` : type
|
|
32
|
+
return `[${style}]\n....\n${source}\n....\n`
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function run({ attributes, files }) {
|
|
36
|
+
const context = createContext()
|
|
37
|
+
// GH-44: `ensureKrokiRunning` (auto-start) is exercised on its own, in
|
|
38
|
+
// kroki-docker.spec.js — stubbed out here via the same dependency-
|
|
39
|
+
// injection seam publish.ts's own `loadDriver` deps param uses, so these
|
|
40
|
+
// tests are only about the raw-content scanning and per-diagram fetch/
|
|
41
|
+
// cache logic. Without this, its own reachability probe would consume the
|
|
42
|
+
// `fetch` mock these tests set up for the DIAGRAM fetch, throwing off
|
|
43
|
+
// every call-count assertion below.
|
|
44
|
+
registerKrokiPrewarm(context, { ensureKrokiRunning: async () => undefined })
|
|
45
|
+
await context.emit('contentAggregated', {
|
|
46
|
+
playbook: { asciidoc: { attributes } },
|
|
47
|
+
contentAggregate: [{ files }],
|
|
48
|
+
})
|
|
49
|
+
return context
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
afterEach(() => {
|
|
53
|
+
vi.unstubAllGlobals()
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
describe('registerKrokiPrewarm', () => {
|
|
57
|
+
it('does nothing when kroki-enabled is not set', async () => {
|
|
58
|
+
const fetchMock = vi.fn()
|
|
59
|
+
vi.stubGlobal('fetch', fetchMock)
|
|
60
|
+
|
|
61
|
+
const source = 'stateDiagram-v2\nA --> B (disabled case)'
|
|
62
|
+
await run({ attributes: {}, files: [file('modules/main/pages/a.adoc', block('mermaid', source))] })
|
|
63
|
+
|
|
64
|
+
expect(fetchMock).not.toHaveBeenCalled()
|
|
65
|
+
expect(kroki.get(kroki.keyFor('mermaid', applyDefaultMermaidTheme(source), 'svg'))).toBeUndefined()
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('fetches and caches every requested diagram type found in the raw content', async () => {
|
|
69
|
+
const svg = '<svg>rendered</svg>'
|
|
70
|
+
const fetchMock = vi.fn().mockResolvedValue({ ok: true, text: async () => svg })
|
|
71
|
+
vi.stubGlobal('fetch', fetchMock)
|
|
72
|
+
|
|
73
|
+
const source = 'stateDiagram-v2\nA --> B (cached case)'
|
|
74
|
+
await run({
|
|
75
|
+
attributes: { 'kroki-enabled': true, 'kroki-diagram-types': 'mermaid' },
|
|
76
|
+
files: [file('modules/main/pages/a.adoc', block('mermaid', source))],
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
expect(fetchMock).toHaveBeenCalledTimes(1)
|
|
80
|
+
expect(fetchMock.mock.calls[0][0]).toBe('http://localhost:8500/mermaid/svg')
|
|
81
|
+
// The body actually POSTed is the theme-injected source (kroki-mermaid-
|
|
82
|
+
// theme.js), not the author's raw one — this is the one place that's
|
|
83
|
+
// observable from outside kroki-prewarm.js itself.
|
|
84
|
+
expect(fetchMock.mock.calls[0][1].body).toBe(applyDefaultMermaidTheme(source))
|
|
85
|
+
expect(kroki.get(kroki.keyFor('mermaid', applyDefaultMermaidTheme(source), 'svg'))).toEqual({
|
|
86
|
+
format: 'svg',
|
|
87
|
+
data: svg,
|
|
88
|
+
})
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('logs a success summary when every requested diagram renders', async () => {
|
|
92
|
+
const svg = '<svg>rendered</svg>'
|
|
93
|
+
const fetchMock = vi.fn().mockResolvedValue({ ok: true, text: async () => svg })
|
|
94
|
+
vi.stubGlobal('fetch', fetchMock)
|
|
95
|
+
|
|
96
|
+
const source = 'stateDiagram-v2\nA --> B (summary case)'
|
|
97
|
+
const context = await run({
|
|
98
|
+
attributes: { 'kroki-enabled': true, 'kroki-diagram-types': 'mermaid' },
|
|
99
|
+
files: [file('modules/main/pages/a.adoc', block('mermaid', source))],
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
expect(context.infos.some(([msg]) => msg.includes('rendered all'))).toBe(true)
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it('warns with a fallback summary when some diagrams fail to render', async () => {
|
|
106
|
+
const fetchMock = vi.fn().mockRejectedValue(new Error('connect ECONNREFUSED'))
|
|
107
|
+
vi.stubGlobal('fetch', fetchMock)
|
|
108
|
+
|
|
109
|
+
const source = 'stateDiagram-v2\nA --> B (summary failure case)'
|
|
110
|
+
const context = await run({
|
|
111
|
+
attributes: { 'kroki-enabled': true, 'kroki-diagram-types': 'mermaid' },
|
|
112
|
+
files: [file('modules/main/pages/a.adoc', block('mermaid', source))],
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
expect(context.warnings.some(([msg]) => msg.includes('fell back to raw source'))).toBe(true)
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
it('ignores a block whose type is not in kroki-diagram-types', async () => {
|
|
119
|
+
const fetchMock = vi.fn()
|
|
120
|
+
vi.stubGlobal('fetch', fetchMock)
|
|
121
|
+
|
|
122
|
+
const source = 'A -> B (not requested case)'
|
|
123
|
+
await run({
|
|
124
|
+
attributes: { 'kroki-enabled': true, 'kroki-diagram-types': 'plantuml' },
|
|
125
|
+
files: [file('modules/main/pages/a.adoc', block('graphviz', source))],
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
expect(fetchMock).not.toHaveBeenCalled()
|
|
129
|
+
expect(kroki.get(kroki.keyFor('graphviz', source))).toBeUndefined()
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
it('warns and leaves the cache empty when Kroki is unreachable', async () => {
|
|
133
|
+
const fetchMock = vi.fn().mockRejectedValue(new Error('connect ECONNREFUSED'))
|
|
134
|
+
vi.stubGlobal('fetch', fetchMock)
|
|
135
|
+
|
|
136
|
+
const source = 'stateDiagram-v2\nA --> B (unreachable case)'
|
|
137
|
+
const context = await run({
|
|
138
|
+
attributes: { 'kroki-enabled': true },
|
|
139
|
+
files: [file('modules/main/pages/a.adoc', block('mermaid', source))],
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
expect(kroki.get(kroki.keyFor('mermaid', applyDefaultMermaidTheme(source), 'svg'))).toBeUndefined()
|
|
143
|
+
expect(context.warnings.some(([msg]) => msg.includes('Could not reach the Kroki service'))).toBe(true)
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
it('warns once per unknown kroki-diagram-types entry and still processes the known ones', async () => {
|
|
147
|
+
const svg = '<svg>known</svg>'
|
|
148
|
+
const fetchMock = vi.fn().mockResolvedValue({ ok: true, text: async () => svg })
|
|
149
|
+
vi.stubGlobal('fetch', fetchMock)
|
|
150
|
+
|
|
151
|
+
const source = 'stateDiagram-v2\nA --> B (unknown-type case)'
|
|
152
|
+
const context = await run({
|
|
153
|
+
attributes: { 'kroki-enabled': true, 'kroki-diagram-types': 'mermaid,not-a-real-type' },
|
|
154
|
+
files: [file('modules/main/pages/a.adoc', block('mermaid', source))],
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
expect(kroki.get(kroki.keyFor('mermaid', applyDefaultMermaidTheme(source), 'svg'))).toEqual({
|
|
158
|
+
format: 'svg',
|
|
159
|
+
data: svg,
|
|
160
|
+
})
|
|
161
|
+
expect(context.warnings.some(([msg]) => msg.includes('unknown %s entry'))).toBe(true)
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
it('ignores non-.adoc files', async () => {
|
|
165
|
+
const fetchMock = vi.fn()
|
|
166
|
+
vi.stubGlobal('fetch', fetchMock)
|
|
167
|
+
|
|
168
|
+
await run({
|
|
169
|
+
attributes: { 'kroki-enabled': true },
|
|
170
|
+
files: [file('modules/main/pages/a.png', block('mermaid', 'A --> B (non-adoc case)'))],
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
expect(fetchMock).not.toHaveBeenCalled()
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
it('requests and base64-caches a png diagram for a type that supports it', async () => {
|
|
177
|
+
const bytes = new Uint8Array([137, 80, 78, 71]) // a PNG magic-number prefix is enough for this test
|
|
178
|
+
const fetchMock = vi.fn().mockResolvedValue({ ok: true, arrayBuffer: async () => bytes.buffer })
|
|
179
|
+
vi.stubGlobal('fetch', fetchMock)
|
|
180
|
+
|
|
181
|
+
const source = 'stateDiagram-v2\nA --> B (png case)'
|
|
182
|
+
await run({
|
|
183
|
+
attributes: { 'kroki-enabled': true, 'kroki-diagram-types': 'mermaid' },
|
|
184
|
+
files: [file('modules/main/pages/a.adoc', block('mermaid', source, 'png'))],
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
expect(fetchMock.mock.calls[0][0]).toBe('http://localhost:8500/mermaid/png')
|
|
188
|
+
expect(kroki.get(kroki.keyFor('mermaid', applyDefaultMermaidTheme(source), 'png'))).toEqual({
|
|
189
|
+
format: 'png',
|
|
190
|
+
data: Buffer.from(bytes).toString('base64'),
|
|
191
|
+
})
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
it('falls back to svg and warns when png is requested for a type Kroki does not support', async () => {
|
|
195
|
+
const svg = '<svg>bpmn fallback</svg>'
|
|
196
|
+
const fetchMock = vi.fn().mockResolvedValue({ ok: true, text: async () => svg })
|
|
197
|
+
vi.stubGlobal('fetch', fetchMock)
|
|
198
|
+
|
|
199
|
+
const source = '<definitions>(bpmn png-unsupported case)</definitions>'
|
|
200
|
+
const context = await run({
|
|
201
|
+
attributes: { 'kroki-enabled': true, 'kroki-diagram-types': 'bpmn' },
|
|
202
|
+
files: [file('modules/main/pages/a.adoc', block('bpmn', source, 'png'))],
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
expect(fetchMock.mock.calls[0][0]).toBe('http://localhost:8500/bpmn/svg')
|
|
206
|
+
expect(kroki.get(kroki.keyFor('bpmn', source, 'svg'))).toEqual({ format: 'svg', data: svg })
|
|
207
|
+
expect(context.warnings.some(([msg]) => msg.includes('unsupported format'))).toBe(true)
|
|
208
|
+
})
|
|
209
|
+
})
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
// Traces Antora's OWN generator pipeline — when it starts, and how long
|
|
4
|
+
// each documented phase takes — under a single `docouture-lifecycle` logger, at
|
|
5
|
+
// `info`. @antora/site-generator's generate-site.js notifies every one of
|
|
6
|
+
// the events below (`context.notify(eventName)`, see that file's own
|
|
7
|
+
// source for the exact call sites this list is transcribed from) but never
|
|
8
|
+
// logs any of them at any level itself — they exist purely as extension
|
|
9
|
+
// hook points. Nothing else, in Antora or this package, narrates "starting
|
|
10
|
+
// content aggregation" or "site generation took Ns" without this; asking
|
|
11
|
+
// Antora for a higher `--log-level` gets you MORE of its per-file/per-page
|
|
12
|
+
// diagnostic noise, not a trace of its own pipeline.
|
|
13
|
+
//
|
|
14
|
+
// Unconditional, always registered, unlike kroki-prewarm.js/
|
|
15
|
+
// shiki-prewarm.js — this touches no external state (no Docker, no
|
|
16
|
+
// network) and has no failure mode worth degrading from: it is `Date.now()`
|
|
17
|
+
// and a log line, nothing else, so there is nothing here to disable.
|
|
18
|
+
//
|
|
19
|
+
// Its log lines flow through the exact same observability plumbing already
|
|
20
|
+
// wired for every other `docouture-*` extension — `--log-level=info` (Antora's
|
|
21
|
+
// own real default is `warn`) plus a `"name":"docouture-` filter — in this
|
|
22
|
+
// monorepo's own `just dev`/`just build-site` recipes and in
|
|
23
|
+
// `docouture dev`/`docouture build` (the CLI package's `antora-log.ts`). No extra
|
|
24
|
+
// flag or config specific to this file is needed to see it.
|
|
25
|
+
//
|
|
26
|
+
// EVENT ORDER below is generate-site.js's actual pipeline order (plus
|
|
27
|
+
// GeneratorContext's own always-fired contextStarted/contextClosed), not
|
|
28
|
+
// alphabetical.
|
|
29
|
+
const EVENTS = [
|
|
30
|
+
'contextStarted', // GeneratorContext.start — before the playbook is even built
|
|
31
|
+
'playbookBuilt',
|
|
32
|
+
'beforeProcess',
|
|
33
|
+
'contentAggregated', // kroki-prewarm.js's/shiki-prewarm.js's own hook point
|
|
34
|
+
'uiLoaded',
|
|
35
|
+
'contentClassified', // version-report.js's own hook point
|
|
36
|
+
'documentsConverted',
|
|
37
|
+
'navigationBuilt', // nav-modules.js's/footer.js's/search-index.js's/llms-txt.js's/not-found-page.js's own hook point
|
|
38
|
+
'pagesComposed',
|
|
39
|
+
'redirectsProduced',
|
|
40
|
+
'siteMapped', // only fires when playbook.site.url is set
|
|
41
|
+
'beforePublish',
|
|
42
|
+
'sitePublished',
|
|
43
|
+
'contextClosed', // GeneratorContext.close, in generate-site.js's own `finally` — always fires, success or failure
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
module.exports = function registerLifecycleLog(context, deps = {}) {
|
|
47
|
+
const now = deps.now || Date.now
|
|
48
|
+
const logger = context.getLogger('docouture-lifecycle')
|
|
49
|
+
const startedAt = now()
|
|
50
|
+
let lastAt = startedAt
|
|
51
|
+
|
|
52
|
+
for (const event of EVENTS) {
|
|
53
|
+
context.on(event, () => {
|
|
54
|
+
const at = now()
|
|
55
|
+
logger.info('Antora: %s (+%dms, %dms total)', event, at - lastAt, at - startedAt)
|
|
56
|
+
lastAt = at
|
|
57
|
+
})
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
module.exports.EVENTS = EVENTS
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
import { describe, expect, it } from 'vitest'
|
|
4
|
+
|
|
5
|
+
const registerLifecycleLog = require('./lifecycle-log')
|
|
6
|
+
|
|
7
|
+
function createContext() {
|
|
8
|
+
const listeners = {}
|
|
9
|
+
const logs = []
|
|
10
|
+
return {
|
|
11
|
+
getLogger: () => ({ info: (...args) => logs.push(args) }),
|
|
12
|
+
on(event, fn) {
|
|
13
|
+
;(listeners[event] ||= []).push(fn)
|
|
14
|
+
},
|
|
15
|
+
async emit(event) {
|
|
16
|
+
for (const fn of listeners[event] || []) await fn()
|
|
17
|
+
},
|
|
18
|
+
logs,
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
describe('registerLifecycleLog', () => {
|
|
23
|
+
it('registers a listener for every documented generate-site.js event', async () => {
|
|
24
|
+
const context = createContext()
|
|
25
|
+
let tick = 0
|
|
26
|
+
registerLifecycleLog(context, { now: () => tick })
|
|
27
|
+
|
|
28
|
+
for (const event of registerLifecycleLog.EVENTS) {
|
|
29
|
+
tick += 1
|
|
30
|
+
await context.emit(event)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
expect(context.logs).toHaveLength(registerLifecycleLog.EVENTS.length)
|
|
34
|
+
expect(context.logs.map(([, event]) => event)).toEqual(registerLifecycleLog.EVENTS)
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('logs elapsed time since the previous event and since the start', async () => {
|
|
38
|
+
const context = createContext()
|
|
39
|
+
let tick = 1000
|
|
40
|
+
registerLifecycleLog(context, { now: () => tick })
|
|
41
|
+
|
|
42
|
+
tick = 1050 // +50ms since registration
|
|
43
|
+
await context.emit('contentAggregated')
|
|
44
|
+
tick = 1230 // +180ms since the previous event, +230ms total
|
|
45
|
+
await context.emit('contentClassified')
|
|
46
|
+
|
|
47
|
+
expect(context.logs[0]).toEqual(['Antora: %s (+%dms, %dms total)', 'contentAggregated', 50, 50])
|
|
48
|
+
expect(context.logs[1]).toEqual(['Antora: %s (+%dms, %dms total)', 'contentClassified', 180, 230])
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('does not throw when an event Antora never fires for this build (e.g. siteMapped without site.url) is simply never emitted', async () => {
|
|
52
|
+
const context = createContext()
|
|
53
|
+
registerLifecycleLog(context, { now: () => 0 })
|
|
54
|
+
|
|
55
|
+
await context.emit('contentAggregated')
|
|
56
|
+
|
|
57
|
+
expect(context.logs).toHaveLength(1)
|
|
58
|
+
})
|
|
59
|
+
})
|