@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,259 @@
1
+ 'use strict'
2
+
3
+ const { parse, NodeType } = require('node-html-parser')
4
+
5
+ // Same rationale as search-index.js's own STRIPPED_TAGS/STRIPPED_CLASSES:
6
+ // the table of contents Asciidoctor emits inline in some layouts and any
7
+ // inline icon SVGs are page furniture, not article content, and neither
8
+ // survives translation to Markdown meaningfully.
9
+ const STRIPPED_TAGS = new Set(['svg'])
10
+ const STRIPPED_CLASSES = new Set(['toc', 'icon'])
11
+
12
+ // Asciidoctor's five admonition kinds (NOTE/TIP/IMPORTANT/CAUTION/WARNING),
13
+ // each rendered as `<div class="admonitionblock <kind>">`. See
14
+ // admonitionToMarkdown for why this needs its own case rather than falling
15
+ // through to the generic table handler.
16
+ const ADMONITION_LABELS = {
17
+ note: 'Note',
18
+ tip: 'Tip',
19
+ important: 'Important',
20
+ caution: 'Caution',
21
+ warning: 'Warning',
22
+ }
23
+
24
+ const BLOCK_TAGS = new Set(['p', 'div', 'section', 'article', 'header', 'footer', 'aside', 'figure', 'figcaption'])
25
+
26
+ /**
27
+ * Converts one page's converted-HTML article body (`page.contents` at the
28
+ * `navigationBuilt` event — chrome-free, since page-composer has not yet
29
+ * wrapped it in the UI layout; see llms-txt.js's own header) into Markdown
30
+ * suitable for `llms-full.txt`.
31
+ *
32
+ * Deliberately narrow: this repo's pages are built from a known, small set
33
+ * of block shapes (Asciidoctor's own admonitions/tables/code blocks, plus
34
+ * this project's card/step/tabs/accordion block extensions — see
35
+ * search-index.js's own note on those). Anything not explicitly handled
36
+ * below is opened up and its text recursed into, the same fallback
37
+ * search-index.js's walk() uses for the same reason: an unrecognised
38
+ * wrapper's prose should still surface somewhere rather than vanish.
39
+ *
40
+ * @param {String} html
41
+ * @returns {String} Markdown
42
+ */
43
+ module.exports = function htmlToMarkdown(html) {
44
+ // node-html-parser treats `<pre>` as a "block text" element by default —
45
+ // the same bucket as `<script>`/`<style>` — and keeps its content as raw,
46
+ // unparsed text rather than a `<code>` child element. That is exactly
47
+ // wrong for a fenced code block: it needs the language class off the
48
+ // nested `<code>`, so parsing has to be told to open `<pre>` up too. Note
49
+ // this has to be done by OMITTING `pre` from the map, not setting it to
50
+ // `false`: the parser's `is_block_text_element` check tests key presence
51
+ // only, ignoring the boolean value — `{ pre: false }` still block-texts it.
52
+ const root = parse(html, { blockTextElements: { script: true, style: true } })
53
+ const md = blockToMarkdown(root, 0)
54
+ // Collapse the accumulation of blank lines block joins tend to produce
55
+ // down to at most one, and trim the trailing whitespace every block
56
+ // emitter leaves behind it.
57
+ return md.replace(/\n{3,}/g, '\n\n').trim()
58
+ }
59
+
60
+ function blockToMarkdown(node, listDepth) {
61
+ let out = ''
62
+ for (const child of node.childNodes) {
63
+ out += nodeToMarkdown(child, listDepth)
64
+ }
65
+ return out
66
+ }
67
+
68
+ function nodeToMarkdown(node, listDepth) {
69
+ if (node.nodeType === NodeType.TEXT_NODE) return node.text
70
+ if (node.nodeType !== NodeType.ELEMENT_NODE) return ''
71
+
72
+ const tag = node.rawTagName?.toLowerCase()
73
+ if (STRIPPED_TAGS.has(tag)) return ''
74
+ const classes = node.classList?.value || []
75
+ if (classes.some((cls) => STRIPPED_CLASSES.has(cls))) return ''
76
+ if (tag === 'div' && classes.includes('admonitionblock')) return admonitionToMarkdown(node, classes)
77
+
78
+ switch (tag) {
79
+ case 'h1':
80
+ case 'h2':
81
+ case 'h3':
82
+ case 'h4':
83
+ case 'h5':
84
+ case 'h6':
85
+ return `\n\n${'#'.repeat(Number(tag[1]))} ${inlineText(node)}\n\n`
86
+
87
+ case 'pre': {
88
+ const code = node.querySelector('code')
89
+ const lang = (code?.classList?.value || []).map((c) => c.replace(/^language-/, '')).find(Boolean) || ''
90
+ const text = (code || node).text.replace(/\n+$/, '')
91
+ return `\n\n\`\`\`${lang}\n${text}\n\`\`\`\n\n`
92
+ }
93
+
94
+ case 'code':
95
+ return `\`${node.text}\``
96
+
97
+ case 'strong':
98
+ case 'b': {
99
+ const text = inlineText(node)
100
+ // An icon font glyph (`<i class="fa icon-warning">`, no text content —
101
+ // the glyph is painted by CSS `content`) falls through here empty.
102
+ // Emphasising nothing would render as a bare `**`/`*`, so it is
103
+ // dropped rather than kept.
104
+ return text ? `**${text}**` : ''
105
+ }
106
+
107
+ case 'em':
108
+ case 'i': {
109
+ const text = inlineText(node)
110
+ return text ? `*${text}*` : ''
111
+ }
112
+
113
+ case 'a': {
114
+ const href = node.getAttribute('href')
115
+ const text = inlineText(node)
116
+ // Antora's `sectanchors` emits an empty `<a class="anchor">` right
117
+ // before every heading's own text, purely for a CSS hover affordance —
118
+ // see the asciidoc skill's own note on the attribute. No text, no link
119
+ // worth keeping.
120
+ if (!text) return ''
121
+ return href ? `[${text}](${href})` : text
122
+ }
123
+
124
+ case 'img': {
125
+ const alt = node.getAttribute('alt') || ''
126
+ const src = node.getAttribute('src') || ''
127
+ return `![${alt}](${src})`
128
+ }
129
+
130
+ case 'br':
131
+ return '\n'
132
+
133
+ case 'hr':
134
+ return '\n\n---\n\n'
135
+
136
+ case 'blockquote':
137
+ return `\n\n${blockToMarkdown(node, listDepth)
138
+ .trim()
139
+ .split('\n')
140
+ .map((line) => `> ${line}`)
141
+ .join('\n')}\n\n`
142
+
143
+ case 'ul':
144
+ case 'ol':
145
+ return `\n\n${listToMarkdown(node, listDepth, tag === 'ol')}\n\n`
146
+
147
+ case 'li':
148
+ // Handled by listToMarkdown per item; a stray <li> outside ul/ol
149
+ // (shouldn't happen in Asciidoctor output) just falls through to its
150
+ // own content below.
151
+ return blockToMarkdown(node, listDepth)
152
+
153
+ case 'table':
154
+ return `\n\n${tableToMarkdown(node)}\n\n`
155
+
156
+ case 'p':
157
+ return `\n\n${inlineText(node)}\n\n`
158
+
159
+ default:
160
+ // Generic wrapper (div/section/this project's own block extensions/
161
+ // etc.) — open it up rather than drop its text. Block-level tags get
162
+ // paragraph breaks around them so prose from adjacent wrappers doesn't
163
+ // run together; the smaller set of pure-inline unknown tags does not.
164
+ if (BLOCK_TAGS.has(tag)) return `\n\n${blockToMarkdown(node, listDepth).trim()}\n\n`
165
+ return blockToMarkdown(node, listDepth)
166
+ }
167
+ }
168
+
169
+ function inlineText(node) {
170
+ return collapseWhitespace(blockToMarkdown(node, 0))
171
+ }
172
+
173
+ function collapseWhitespace(text) {
174
+ return text.replace(/\s+/g, ' ').trim()
175
+ }
176
+
177
+ // Asciidoctor renders an admonition as a two-cell `<table>`: `td.icon`
178
+ // (an empty `<i>` painted by CSS/an icon font, no real content) and
179
+ // `td.content` (the admonition's own body, sometimes with its own
180
+ // `div.title` — the "Example"/custom caption text — ahead of the prose).
181
+ // Falling through to the generic table handler would render the icon cell
182
+ // as a bare, empty `**` (see the strong/em cases' own note) and produce a
183
+ // one-row "table" that isn't really tabular data at all, so this is
184
+ // rendered as a labelled blockquote instead — content only, icon dropped.
185
+ function admonitionToMarkdown(node, classes) {
186
+ const kind = classes.find((cls) => cls in ADMONITION_LABELS)
187
+ const label = ADMONITION_LABELS[kind] || 'Note'
188
+ const contentCell = node.querySelector('td.content')
189
+ const body = (contentCell ? blockToMarkdown(contentCell, 0) : inlineText(node)).replace(/\n{3,}/g, '\n\n').trim()
190
+ const quoted = body
191
+ .split('\n')
192
+ .map((line) => (line ? `> ${line}` : '>'))
193
+ .join('\n')
194
+ return `\n\n> **${label}**\n${quoted}\n\n`
195
+ }
196
+
197
+ function listToMarkdown(listNode, depth, ordered) {
198
+ const indent = ' '.repeat(depth)
199
+ const lines = []
200
+ let i = 0
201
+ for (const child of listNode.childNodes) {
202
+ if (child.nodeType !== NodeType.ELEMENT_NODE || child.rawTagName?.toLowerCase() !== 'li') continue
203
+ i += 1
204
+ const marker = ordered ? `${i}.` : '-'
205
+ const { inline, nested } = splitListItem(child, depth)
206
+ lines.push(`${indent}${marker} ${inline}`)
207
+ if (nested) lines.push(nested)
208
+ }
209
+ return lines.join('\n')
210
+ }
211
+
212
+ // A list item's own text and a nested sub-list are pulled apart rather than
213
+ // walked as one blob: the sub-list already renders itself fully indented (it
214
+ // recurses with `depth + 1`), so folding it back through the generic inline
215
+ // text path would double-indent it and, worse, collapse the blank line its
216
+ // own block wrapping leaves behind into the middle of the item's text.
217
+ function splitListItem(li, depth) {
218
+ let inline = ''
219
+ let nested = ''
220
+ for (const child of li.childNodes) {
221
+ const tag = child.nodeType === NodeType.ELEMENT_NODE ? child.rawTagName?.toLowerCase() : null
222
+ if (tag === 'ul' || tag === 'ol') {
223
+ nested += (nested ? '\n' : '') + listToMarkdown(child, depth + 1, tag === 'ol')
224
+ } else {
225
+ inline += nodeToMarkdown(child, depth)
226
+ }
227
+ }
228
+ return { inline: collapseWhitespace(inline), nested }
229
+ }
230
+
231
+ function tableToMarkdown(tableNode) {
232
+ const rows = []
233
+ // Backslash is escaped first: escaping `|` alone would let a source
234
+ // backslash immediately before one combine with the `\` this line just
235
+ // inserted, producing `\\|` — an escaped backslash followed by a live,
236
+ // unescaped column separator, the exact thing this escape exists to
237
+ // prevent.
238
+ const escapeCell = (text) => text.replace(/\\/g, '\\\\').replace(/\|/g, '\\|')
239
+ for (const rowNode of tableNode.querySelectorAll('tr')) {
240
+ const cells = rowNode.querySelectorAll('th,td').map((cell) => escapeCell(inlineText(cell)) || ' ')
241
+ if (cells.length) rows.push(cells)
242
+ }
243
+ if (!rows.length) return ''
244
+
245
+ const colCount = Math.max(...rows.map((r) => r.length))
246
+ const pad = (row) => {
247
+ const padded = row.slice()
248
+ while (padded.length < colCount) padded.push(' ')
249
+ return padded
250
+ }
251
+
252
+ const [header, ...body] = rows
253
+ const lines = [
254
+ `| ${pad(header).join(' | ')} |`,
255
+ `| ${Array(colCount).fill('---').join(' | ')} |`,
256
+ ...body.map((row) => `| ${pad(row).join(' | ')} |`),
257
+ ]
258
+ return lines.join('\n')
259
+ }
@@ -0,0 +1,73 @@
1
+ 'use strict'
2
+
3
+ import { describe, expect, it } from 'vitest'
4
+
5
+ const htmlToMarkdown = require('./html-to-markdown')
6
+
7
+ describe('htmlToMarkdown', () => {
8
+ it('converts headings', () => {
9
+ expect(htmlToMarkdown('<h1 id="a">Title</h1><h2 id="b">Sub</h2>')).toBe('# Title\n\n## Sub')
10
+ })
11
+
12
+ it('converts paragraphs and inline emphasis', () => {
13
+ const html = '<p>Some <strong>bold</strong> and <em>italic</em> text.</p>'
14
+ expect(htmlToMarkdown(html)).toBe('Some **bold** and *italic* text.')
15
+ })
16
+
17
+ it('converts links', () => {
18
+ expect(htmlToMarkdown('<p><a href="https://example.com">Example</a></p>')).toBe('[Example](https://example.com)')
19
+ })
20
+
21
+ it('converts inline code and fenced code blocks with a language', () => {
22
+ const html = '<p>Run <code>npm install</code></p><pre><code class="language-js">const x = 1;</code></pre>'
23
+ expect(htmlToMarkdown(html)).toBe('Run `npm install`\n\n```js\nconst x = 1;\n```')
24
+ })
25
+
26
+ it('converts unordered and ordered lists, including nesting', () => {
27
+ const html = '<ul><li>One</li><li>Two<ul><li>Nested</li></ul></li></ul>'
28
+ expect(htmlToMarkdown(html)).toBe('- One\n- Two\n - Nested')
29
+
30
+ expect(htmlToMarkdown('<ol><li>First</li><li>Second</li></ol>')).toBe('1. First\n2. Second')
31
+ })
32
+
33
+ it('converts tables to GitHub-flavoured Markdown tables', () => {
34
+ const html = '<table><tr><th>A</th><th>B</th></tr><tr><td>1</td><td>2</td></tr></table>'
35
+ expect(htmlToMarkdown(html)).toBe('| A | B |\n| --- | --- |\n| 1 | 2 |')
36
+ })
37
+
38
+ it('drops table-of-contents and icon elements', () => {
39
+ const html = '<div class="toc"><a href="#a">A</a></div><p>Body</p><svg><path/></svg>'
40
+ expect(htmlToMarkdown(html)).toBe('Body')
41
+ })
42
+
43
+ it('opens up unrecognised wrapper elements rather than dropping their text', () => {
44
+ const html = '<div class="card"><h3 id="c">Card title</h3><p>Card body</p></div>'
45
+ expect(htmlToMarkdown(html)).toBe('### Card title\n\nCard body')
46
+ })
47
+
48
+ it("drops sectanchors' empty anchor links ahead of headings", () => {
49
+ const html = '<h2 id="x"><a class="anchor" href="#x"></a>Section</h2>'
50
+ expect(htmlToMarkdown(html)).toBe('## Section')
51
+ })
52
+
53
+ it('renders an admonition block as a labelled blockquote, dropping the icon cell', () => {
54
+ const html =
55
+ '<div class="admonitionblock warning"><table><tr>' +
56
+ '<td class="icon"><i class="fa icon-warning" title="Warning"></i></td>' +
57
+ '<td class="content"><div class="paragraph"><p>Careful here.</p></div></td>' +
58
+ '</tr></table></div>'
59
+ expect(htmlToMarkdown(html)).toBe('> **Warning**\n> Careful here.')
60
+ })
61
+
62
+ it('collapses the blank-line gap between an admonition title and its body', () => {
63
+ const html =
64
+ '<div class="admonitionblock note"><table><tr>' +
65
+ '<td class="icon"><i class="fa icon-note" title="Note"></i></td>' +
66
+ '<td class="content">' +
67
+ '<div class="title">Example</div>' +
68
+ '<div class="paragraph"><p>See the docs.</p></div>' +
69
+ '</td>' +
70
+ '</tr></table></div>'
71
+ expect(htmlToMarkdown(html)).toBe('> **Note**\n> Example\n>\n> See the docs.')
72
+ })
73
+ })
@@ -0,0 +1,162 @@
1
+ 'use strict'
2
+
3
+ const { execFile } = require('node:child_process')
4
+ const { promisify } = require('node:util')
5
+ const { access } = require('node:fs/promises')
6
+ const path = require('node:path')
7
+
8
+ // GH-44: starts the local Kroki service on demand, the first time a
9
+ // `kroki-enabled: true` build actually needs it and finds nothing already
10
+ // listening. No teardown of any kind — see kroki-prewarm.js's own header for
11
+ // why: a container left running is reused by the next build (the
12
+ // reachability check below short-circuits straight past a start attempt),
13
+ // and simply discarded whenever the machine or CI runner it ran on goes
14
+ // away. This is deliberately the ONE place across every invocation path —
15
+ // `docouture dev`/`docouture build`, this monorepo's own `just dev`/`just
16
+ // build-site`, a raw `antora` invocation, any consumer's own CI — that
17
+ // starts Kroki, because it is the one piece of code every single one of
18
+ // those paths runs through: the Antora pipeline extension itself.
19
+ const execFileAsync = promisify(execFile)
20
+
21
+ const BUNDLED_COMPOSE_FILE = path.join(__dirname, '..', 'resources', 'kroki-compose.yml')
22
+
23
+ // The file `docouture eject kroki` copies the bundled compose definition to —
24
+ // see that command's own header. Looked up relative to the PLAYBOOK's own
25
+ // directory (`playbook.dir`, set by @antora/playbook-builder to wherever
26
+ // `antora-playbook.yml` actually is), i.e. the site root, the same
27
+ // directory `docouture eject kroki` writes it into.
28
+ const OVERRIDE_FILENAME = 'kroki-compose.yml'
29
+
30
+ const REACHABLE_TIMEOUT_MS = 1000
31
+ const STARTUP_TIMEOUT_MS = 60000
32
+ const POLL_INTERVAL_MS = 2000
33
+
34
+ async function isReachable(url) {
35
+ const controller = new AbortController()
36
+ const timer = setTimeout(() => controller.abort(), REACHABLE_TIMEOUT_MS)
37
+ try {
38
+ await fetch(url, { signal: controller.signal })
39
+ return true
40
+ } catch {
41
+ return false
42
+ } finally {
43
+ clearTimeout(timer)
44
+ }
45
+ }
46
+
47
+ async function resolveComposeFile(playbookDir) {
48
+ if (playbookDir) {
49
+ const override = path.join(playbookDir, OVERRIDE_FILENAME)
50
+ try {
51
+ await access(override)
52
+ return override
53
+ } catch {
54
+ // No ejected override — fall through to the bundled default.
55
+ }
56
+ }
57
+ return BUNDLED_COMPOSE_FILE
58
+ }
59
+
60
+ function sleep(ms) {
61
+ return new Promise((resolvePromise) => setTimeout(resolvePromise, ms))
62
+ }
63
+
64
+ /**
65
+ * Ensures a Kroki service answers at `url`, starting one via `docker
66
+ * compose` if nothing does yet. Never throws: a missing `docker` binary, a
67
+ * daemon that isn't running, or a service that never comes up within the
68
+ * timeout all degrade to a logged warning — kroki.js's own cache-miss
69
+ * fallback (raw diagram source, exactly like a disabled site) already
70
+ * handles the rest, so this never needs to fail a build itself.
71
+ *
72
+ * @param {string} url - the fixed Kroki URL (`kroki-config.js`'s `KROKI_URL`).
73
+ * @param {string | undefined} playbookDir - `playbook.dir`; used to look up
74
+ * an ejected override compose file before falling back to the bundled one.
75
+ * @param {{ info: (fmt: string, ...args: unknown[]) => void, warn: (fmt: string, ...args: unknown[]) => void }} logger -
76
+ * `info` gives every run — cache hit or cold start — a visible trail of
77
+ * what actually happened, since the only prior signal was a `warn` on
78
+ * failure; a healthy run was otherwise silent and indistinguishable from
79
+ * Nx quietly replaying a stale cached build (see kroki-prewarm.js's own
80
+ * header on cache interaction).
81
+ * @param {{ isReachable?: typeof isReachable, execFileAsync?: typeof execFileAsync, sleep?: typeof sleep }} [deps] -
82
+ * swappable seams for tests, rather than mocking `node:child_process`/
83
+ * `fetch` at the module level.
84
+ * @returns {Promise<void>}
85
+ */
86
+ async function ensureKrokiRunning(url, playbookDir, logger, deps = {}) {
87
+ const checkReachable = deps.isReachable || isReachable
88
+ const runDockerCompose = deps.execFileAsync || execFileAsync
89
+ const wait = deps.sleep || sleep
90
+ const startedAt = Date.now()
91
+
92
+ if (await checkReachable(url)) {
93
+ logger.info('Kroki service already reachable at %s — reusing it', url)
94
+ return
95
+ }
96
+
97
+ const composeFile = await resolveComposeFile(playbookDir)
98
+ logger.info('Kroki service not reachable at %s yet — starting it via %s', url, composeFile)
99
+ let composeResult
100
+ try {
101
+ composeResult = await runDockerCompose('docker', ['compose', '-f', composeFile, 'up', '-d'])
102
+ } catch (err) {
103
+ // Node attaches `stdout`/`stderr` to the rejection itself (not just
104
+ // `message`) for a failed execFile — surfacing them is the difference
105
+ // between "is Docker installed?" and actually seeing docker's own error
106
+ // (a missing image, a port already bound, a daemon that refused the
107
+ // connection, ...).
108
+ const stdout = /** @type {{ stdout?: string }} */ (err).stdout
109
+ const stderr = /** @type {{ stderr?: string }} */ (err).stderr
110
+ logger.warn(
111
+ 'Could not start the local Kroki service (%s) — is Docker installed and running? Diagrams will render as raw source until %s is reachable.%s',
112
+ /** @type {Error} */ (err).message,
113
+ url,
114
+ formatProcessOutput(stdout, stderr)
115
+ )
116
+ return
117
+ }
118
+ logger.info(
119
+ 'docker compose up -d succeeded — waiting for %s to become reachable...%s',
120
+ url,
121
+ formatProcessOutput(composeResult && composeResult.stdout, composeResult && composeResult.stderr)
122
+ )
123
+
124
+ const deadline = Date.now() + STARTUP_TIMEOUT_MS
125
+ while (Date.now() < deadline) {
126
+ if (await checkReachable(url)) {
127
+ logger.info('Kroki service reachable at %s after %ds', url, Math.round((Date.now() - startedAt) / 1000))
128
+ return
129
+ }
130
+ logger.info(
131
+ 'Still waiting for Kroki at %s to become reachable (%ds elapsed, giving up after %ds — a cold `docker compose up` pulling images for the first time is the usual reason this takes a while)...',
132
+ url,
133
+ Math.round((Date.now() - startedAt) / 1000),
134
+ STARTUP_TIMEOUT_MS / 1000
135
+ )
136
+ await wait(POLL_INTERVAL_MS)
137
+ }
138
+ logger.warn(
139
+ 'Started the local Kroki service but it did not become reachable at %s within %ds — diagrams will render as raw source for this build.',
140
+ url,
141
+ STARTUP_TIMEOUT_MS / 1000
142
+ )
143
+ }
144
+
145
+ /**
146
+ * Renders captured process output for a log message — empty/whitespace-only
147
+ * streams (the common case: `docker compose up -d` is quiet on a warm image
148
+ * cache) contribute nothing, so a healthy run's log line doesn't grow a
149
+ * trailing blank appendix.
150
+ *
151
+ * @param {string | undefined} stdout
152
+ * @param {string | undefined} stderr
153
+ * @returns {string}
154
+ */
155
+ function formatProcessOutput(stdout, stderr) {
156
+ const parts = []
157
+ if (stdout && stdout.trim()) parts.push('stdout:\n' + stdout.trim())
158
+ if (stderr && stderr.trim()) parts.push('stderr:\n' + stderr.trim())
159
+ return parts.length ? '\n' + parts.join('\n') : ''
160
+ }
161
+
162
+ module.exports = { ensureKrokiRunning, BUNDLED_COMPOSE_FILE, OVERRIDE_FILENAME }
@@ -0,0 +1,92 @@
1
+ 'use strict'
2
+
3
+ import { describe, expect, it, vi } from 'vitest'
4
+
5
+ const { ensureKrokiRunning, BUNDLED_COMPOSE_FILE } = require('./kroki-docker')
6
+
7
+ function logger() {
8
+ const warnings = []
9
+ const infos = []
10
+ return { warn: (...args) => warnings.push(args), info: (...args) => infos.push(args), warnings, infos }
11
+ }
12
+
13
+ describe('ensureKrokiRunning', () => {
14
+ it('does nothing when Kroki already answers', async () => {
15
+ const isReachable = vi.fn().mockResolvedValue(true)
16
+ const execFileAsync = vi.fn()
17
+ const log = logger()
18
+
19
+ await ensureKrokiRunning('http://localhost:8500', undefined, log, { isReachable, execFileAsync })
20
+
21
+ expect(execFileAsync).not.toHaveBeenCalled()
22
+ expect(log.warnings).toEqual([])
23
+ expect(log.infos.some(([msg]) => msg.includes('already reachable'))).toBe(true)
24
+ })
25
+
26
+ it('starts the bundled compose file when nothing answers, then succeeds on the next probe', async () => {
27
+ const isReachable = vi.fn().mockResolvedValueOnce(false).mockResolvedValueOnce(true)
28
+ const execFileAsync = vi.fn().mockResolvedValue({ stdout: '', stderr: '' })
29
+ const log = logger()
30
+
31
+ await ensureKrokiRunning('http://localhost:8500', undefined, log, { isReachable, execFileAsync })
32
+
33
+ expect(execFileAsync).toHaveBeenCalledTimes(1)
34
+ expect(execFileAsync).toHaveBeenCalledWith('docker', ['compose', '-f', BUNDLED_COMPOSE_FILE, 'up', '-d'])
35
+ expect(log.warnings).toEqual([])
36
+ expect(log.infos.some(([msg]) => msg.includes('starting it via'))).toBe(true)
37
+ expect(log.infos.some(([msg]) => msg.includes('up -d succeeded'))).toBe(true)
38
+ expect(log.infos.some(([msg]) => msg.includes('reachable at %s after'))).toBe(true)
39
+ })
40
+
41
+ it('warns and gives up quietly when docker compose itself fails (e.g. Docker not installed)', async () => {
42
+ const isReachable = vi.fn().mockResolvedValue(false)
43
+ const execFileAsync = vi.fn().mockRejectedValue(new Error('spawn docker ENOENT'))
44
+ const log = logger()
45
+
46
+ await ensureKrokiRunning('http://localhost:8500', undefined, log, { isReachable, execFileAsync })
47
+
48
+ expect(log.warnings.some(([msg]) => msg.includes('Could not start the local Kroki service'))).toBe(true)
49
+ })
50
+
51
+ it('warns when the service never becomes reachable within the timeout', async () => {
52
+ const isReachable = vi.fn().mockResolvedValue(false)
53
+ const execFileAsync = vi.fn().mockResolvedValue({ stdout: '', stderr: '' })
54
+ const sleep = vi.fn().mockResolvedValue(undefined)
55
+ const log = logger()
56
+
57
+ const realNow = Date.now
58
+ let now = 0
59
+ vi.spyOn(Date, 'now').mockImplementation(() => now)
60
+ // Advance the fake clock a bit further than the timeout on every sleep()
61
+ // call, so the polling loop's own `Date.now() < deadline` check exits
62
+ // after a couple of iterations instead of looping until a real 60s pass.
63
+ sleep.mockImplementation(async () => {
64
+ now += 30000
65
+ })
66
+
67
+ try {
68
+ await ensureKrokiRunning('http://localhost:8500', undefined, log, { isReachable, execFileAsync, sleep })
69
+ } finally {
70
+ Date.now = realNow
71
+ }
72
+
73
+ expect(log.warnings.some(([msg]) => msg.includes('did not become reachable'))).toBe(true)
74
+ })
75
+
76
+ it('prefers an ejected override compose file over the bundled default', async () => {
77
+ const isReachable = vi.fn().mockResolvedValueOnce(false).mockResolvedValueOnce(true)
78
+ const execFileAsync = vi.fn().mockResolvedValue({ stdout: '', stderr: '' })
79
+ const log = logger()
80
+
81
+ // No override file actually exists at this made-up path, so this also
82
+ // exercises the "falls back to the bundled file" branch — a real
83
+ // override's presence is covered by copy-template-style file-existence
84
+ // logic already tested at the CLI layer (eject.spec.ts).
85
+ await ensureKrokiRunning('http://localhost:8500', '/tmp/does-not-exist-kroki-docker-spec', log, {
86
+ isReachable,
87
+ execFileAsync,
88
+ })
89
+
90
+ expect(execFileAsync).toHaveBeenCalledWith('docker', ['compose', '-f', BUNDLED_COMPOSE_FILE, 'up', '-d'])
91
+ })
92
+ })