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