@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,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
+ })