@redpanda-data/docs-extensions-and-macros 5.53.0 → 5.55.0

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.
Files changed (51) hide show
  1. package/bin/doc-tools-mcp.js +4 -5
  2. package/bin/doc-tools.js +166 -2
  3. package/bin/mcp-tools/generated-docs-review.js +26 -9
  4. package/bin/mcp-tools/overrides-audit.js +1 -1
  5. package/bin/mcp-tools/rpcn-docs.js +4 -14
  6. package/cli-utils/format-helm-spec.js +4 -3
  7. package/cli-utils/helm-commented-values.js +88 -18
  8. package/cli-utils/self-managed-docs-branch.js +36 -30
  9. package/extension-utils/html-utils.js +27 -0
  10. package/extensions/REFERENCE.adoc +64 -12
  11. package/extensions/add-git-dates.js +13 -1
  12. package/extensions/aggregate-terms.js +34 -20
  13. package/extensions/generate-rp-connect-info.js +79 -24
  14. package/extensions/modify-connect-tag-playbook.js +498 -75
  15. package/extensions/resolve-xrefs-in-attachments.js +1 -12
  16. package/extensions/url-to-xref.js +3 -0
  17. package/extensions/util/connect-catalog.js +10 -5
  18. package/extensions/util/connect-docs-asset.js +188 -0
  19. package/extensions/util/connect-github.js +16 -0
  20. package/extensions/version-fetcher/fetch-latest-docker-tag.js +35 -13
  21. package/extensions/version-fetcher/get-latest-redpanda-helm-version-from-operator.js +64 -40
  22. package/extensions/version-fetcher/list-connect-releases.js +10 -0
  23. package/extensions/version-fetcher/release-lines.js +64 -0
  24. package/extensions/version-fetcher/set-latest-version.js +73 -3
  25. package/macros/README.adoc +1 -0
  26. package/macros/REFERENCE.adoc +99 -0
  27. package/macros/badge.js +5 -8
  28. package/macros/enterprise.js +2 -5
  29. package/macros/glossary.js +4 -14
  30. package/macros/iceberg-explorer.js +242 -0
  31. package/macros/prop.js +3 -13
  32. package/mcp/USER_GUIDE.adoc +25 -135
  33. package/package.json +4 -2
  34. package/tools/connect-docs/check-build-log.js +311 -0
  35. package/tools/connect-docs/check-rendered-html.js +469 -0
  36. package/tools/connect-docs/ci-playbook.yml +77 -0
  37. package/tools/connect-docs/diff-generated.js +382 -0
  38. package/tools/connect-docs/pr-comment.js +104 -0
  39. package/tools/doc-impact-recording/index.js +190 -0
  40. package/tools/overrides-audit/README.adoc +1 -2
  41. package/tools/overrides-audit/adapters/properties.js +1 -1
  42. package/tools/overrides-audit/index.js +3 -4
  43. package/tools/redpanda-connect/AUTOMATION.md +3 -36
  44. package/tools/redpanda-connect/README.adoc +5 -33
  45. package/tools/redpanda-connect/generate-rpcn-connector-docs.js +3 -3
  46. package/tools/redpanda-connect/pr-summary-formatter.js +6 -4
  47. package/tools/redpanda-connect/report-delta.js +6 -3
  48. package/tools/redpanda-connect/rpcn-connector-docs-handler.js +38 -16
  49. package/tools/redpanda-release-notes/generate-release-notes.js +116 -1
  50. package/tools/rpk-docs/templates/command.hbs +1 -1
  51. package/tools/overrides-audit/adapters/connect.js +0 -144
@@ -200,24 +200,6 @@ The MCP server provides *writer-friendly tools* designed for natural language in
200
200
  * Requires knowledge of doc-tools CLI syntax
201
201
  * Only works in repos that have doc-tools installed
202
202
 
203
- === Contextual guides (prompts)
204
-
205
- *property-docs-guide*:: Comprehensive guide for updating Redpanda property documentation
206
- +
207
- * Explains the auto-generation system and override workflow
208
- * Provides rules for property descriptions (no cloud conditionals, no enterprise includes)
209
- * Lists common scenarios and validation steps
210
- * Claude automatically retrieves this when working with property documentation
211
- * Ensures correct workflow: update property-overrides.json, not generated .adoc files
212
-
213
- *rpcn-connector-docs-guide*:: Comprehensive guide for updating Redpanda Connect connector reference documentation
214
- +
215
- * Explains the override system with `$ref` syntax for DRY (Don't Repeat Yourself) principles
216
- * Describes repository structure and how autogenerated content works with overrides
217
- * Provides workflow for improving connector documentation
218
- * Claude automatically retrieves this when working with connector documentation
219
- * Ensures correct workflow: update docs-data/overrides.json, use $ref for deduplication
220
-
221
203
  === Background job management
222
204
 
223
205
  Long-running documentation generation tasks support background execution with progress updates:
@@ -265,130 +247,38 @@ Background jobs send real-time progress notifications via MCP protocol, so Claud
265
247
 
266
248
  All commands have a 10-minute timeout (increased from 5 minutes) to handle large documentation generation tasks.
267
249
 
268
- == Available prompts
269
-
270
- The MCP server provides *AI consistency prompts* that help Claude follow your team's documentation standards automatically. These prompts are pre-configured instructions that guide Claude's responses for common documentation tasks.
271
-
272
- === Documentation review prompts
273
-
274
- *review-for-style*:: Review documentation content for style guide compliance
275
- +
276
- * Checks: Style guide compliance, terminology consistency, voice/tone, AsciiDoc formatting
277
- * Input: Content to review (can be file path or raw text)
278
- * Output: Detailed feedback with critical issues, suggestions, and actionable recommendations
279
- * Use when: Reviewing any documentation before publishing
280
-
281
- *check-terminology*:: Validate terminology usage against approved team terminology
282
- +
283
- * Checks: Incorrect terms, deprecated terms (whitelist/blacklist, master/slave), inconsistent usage
284
- * Input: Content to check
285
- * Output: Critical issues with corrections, inconsistencies found, missing glossary links
286
- * Use when: Ensuring consistent terminology across documentation
287
-
288
- *improve-clarity*:: Refactor existing documentation for clarity and readability
289
- +
290
- * Applies: Style guide principles, simplifies complex explanations, improves structure
291
- * Input: Content to improve
292
- * Output: Refactored content with explanations of changes made
293
- * Use when: Content is technically correct but difficult to understand
294
-
295
- === Content creation prompts
296
-
297
- *write-new-guide*:: Generate a new tutorial or guide following team standards
298
- +
299
- * Applies: Team templates, approved terminology, voice/tone guidelines, AsciiDoc formatting
300
- * Input: Topic (what to write about), optional audience (beginners, experienced devs, operators)
301
- * Output: Complete guide following team standards
302
- * Use when: Starting a new tutorial, how-to, or getting started guide
303
-
304
- === Workflow guide prompts
305
-
306
- *property-docs-guide*:: Comprehensive guide for updating Redpanda property documentation
307
- +
308
- * Explains: Auto-generation system, override workflow, validation steps
309
- * Rules: Never edit generated `.adoc` files, always update `property-overrides.json`
310
- * Use when: Claude is helping with property documentation
311
- * Claude automatically retrieves this prompt when working with properties
312
-
313
- *rpcn-connector-docs-guide*:: Guide for updating Redpanda Connect connector documentation
314
- +
315
- * Explains: Override system with `$ref` syntax for DRY principles, repository structure
316
- * Rules: Update `docs-data/overrides.json`, use `$ref` for deduplication
317
- * Use when: Claude is helping with connector documentation
318
- * Claude automatically retrieves this prompt when working with connectors
319
-
320
- === How to use prompts
250
+ == Guidance from docs-team-standards
321
251
 
322
- Prompts work through natural conversation with Claude Code:
252
+ This server provides tools and one resource. It has no MCP prompts. Writing, review, and workflow guidance lives in the https://github.com/redpanda-data/docs-team-standards[docs-team-standards^] Claude Code plugin:
323
253
 
324
- *Explicit prompt usage:*
254
+ [cols="1,2"]
255
+ |===
256
+ |Task |docs-team-standards
325
257
 
326
- ----
327
- You: "Use the review-for-style prompt with this content: [paste content]"
328
- Claude: *retrieves and applies the review-for-style prompt*
329
- *provides structured feedback following the prompt instructions*
330
- ----
331
-
332
- *Implicit prompt usage:*
333
-
334
- ----
335
- You: "Review this tutorial for our style guide"
336
- Claude: *automatically uses review-for-style prompt*
337
- *provides feedback based on your team standards*
338
- ----
339
-
340
- *Listing available prompts:*
341
-
342
- ----
343
- You: "List available prompts"
344
- Claude: *shows all prompts with descriptions*
345
- ----
346
-
347
- You can also preview prompts using the CLI:
258
+ |Review content for style, terminology, structure, and clarity
259
+ |The `review` skill, the `/review` command, or the `content-reviewer` agent
348
260
 
349
- [,bash]
350
- ----
351
- # Preview a review prompt
352
- npx doc-tools preview-prompt review-for-style --content "Test content"
353
-
354
- # Preview a write prompt
355
- npx doc-tools preview-prompt write-new-guide --topic "Deploy cluster"
261
+ |Write a new page or guide
262
+ |The `page-templates` skill and the `/plan` command
356
263
 
357
- # List all prompts with versions
358
- npx doc-tools mcp-version
359
- ----
264
+ |Update property documentation
265
+ |The `property-overrides` skill
266
+ |===
360
267
 
361
268
  == Available resources
362
269
 
363
- Resources are reference materials that Claude can read to understand your team's standards.
364
-
365
- === Style guide
366
-
367
- *redpanda://style-guide*:: Redpanda Documentation Style Guide (v1.0.0)
270
+ *redpanda://personas*:: Target audience personas for the current repository
368
271
  +
369
- * Complete style guide based on Google Developer Documentation Style Guide
370
- * Includes: Redpanda-specific guidelines, voice and tone, AsciiDoc formatting rules
371
- * Contains: Terminology quick reference, formatting examples, common patterns
372
- * Claude automatically references this when reviewing or writing documentation
272
+ * Loaded from `docs-data/personas.yaml` in your current working directory
273
+ * Defines reader types, goals, pain points, and content preferences
373
274
 
374
- === How to use resources
375
-
376
- Resources are automatically loaded by prompts, but you can also reference them explicitly:
377
-
378
- *Explicit reference:*
275
+ To read it, ask Claude directly:
379
276
 
380
277
  ----
381
- You: "Read the redpanda://style-guide resource and tell me the rules for code blocks"
382
- Claude: *reads the style guide resource*
383
- *explains the code block formatting rules*
278
+ You: "Read the redpanda://personas resource and summarize the target readers"
384
279
  ----
385
280
 
386
- *Listing available resources:*
387
-
388
- ----
389
- You: "List available resources"
390
- Claude: *shows all resources with descriptions and versions*
391
- ----
281
+ The style guide lives in docs-team-standards, not in this server.
392
282
 
393
283
  === Design philosophy
394
284
 
@@ -972,12 +862,12 @@ Suggestion: Check that the version exists in the Redpanda repository. You can fi
972
862
  https://github.com/redpanda-data/streaming-enterprise/releases
973
863
  ----
974
864
 
975
- === Example 7: Property documentation updates (with prompt)
865
+ === Example 7: Property documentation updates
976
866
 
977
867
  ----
978
868
  You: Improve the description for cleanup.policy property to explain what each value does
979
869
 
980
- Claude: *retrieves property-docs-guide prompt*
870
+ Claude: *uses the property-overrides skill from docs-team-standards*
981
871
  *reads property-overrides.json*
982
872
  *updates the description field with enhanced explanation*
983
873
  *uses generate_property_docs MCP tool with generate_partials: true*
@@ -995,7 +885,7 @@ The description now explains:
995
885
  - `compact,delete` - Compaction + time-based deletion
996
886
  ----
997
887
 
998
- This example shows how the prompt automatically provides context about the property documentation workflow, ensuring Claude follows the correct process (update overrides, not generated files).
888
+ The skill provides the property documentation workflow, so Claude updates the overrides file, not the generated files.
999
889
 
1000
890
  === Example 8: Review documentation for style compliance
1001
891
 
@@ -1004,7 +894,7 @@ You: Review this tutorial for style guide compliance:
1004
894
 
1005
895
  [Content of tutorial pasted here]
1006
896
 
1007
- Claude: *uses review-for-style prompt*
897
+ Claude: *uses the review skill from docs-team-standards*
1008
898
  *analyzes content against team style guide*
1009
899
 
1010
900
  Style Review for Tutorial
@@ -1042,7 +932,7 @@ Overall: Good structure, clear explanations. Fix critical issues before publishi
1042
932
  ----
1043
933
  You: Check the terminology in this doc for any deprecated or incorrect terms
1044
934
 
1045
- Claude: *uses check-terminology prompt*
935
+ Claude: *uses the review skill from docs-team-standards*
1046
936
 
1047
937
  Terminology Review
1048
938
  ==========================================
@@ -1070,7 +960,7 @@ Summary:
1070
960
  ----
1071
961
  You: Write a getting started guide for deploying a 3-node Redpanda cluster
1072
962
 
1073
- Claude: *uses write-new-guide prompt*
963
+ Claude: *uses the page-templates skill from docs-team-standards*
1074
964
  *applies team templates and standards*
1075
965
 
1076
966
  = Deploy a Three-Node Redpanda Cluster
@@ -1103,7 +993,7 @@ You: This explanation of compaction is too complex. Can you simplify it?
1103
993
 
1104
994
  [Paste complex explanation]
1105
995
 
1106
- Claude: *uses improve-clarity prompt*
996
+ Claude: *uses the review skill from docs-team-standards*
1107
997
 
1108
998
  Here's a clearer version:
1109
999
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@redpanda-data/docs-extensions-and-macros",
3
- "version": "5.53.0",
3
+ "version": "5.55.0",
4
4
  "description": "Antora extensions and macros developed for Redpanda documentation.",
5
5
  "keywords": [
6
6
  "antora",
@@ -94,7 +94,9 @@
94
94
  "./extensions/property-page-index": "./extensions/property-page-index.js",
95
95
  "./extensions/render-property-descriptions": "./extensions/render-property-descriptions.js",
96
96
  "./extensions/kapa-source-groups": "./extensions/kapa-source-groups.js",
97
- "./docs-data/kapa-source-groups.json": "./docs-data/kapa-source-groups.json"
97
+ "./docs-data/kapa-source-groups.json": "./docs-data/kapa-source-groups.json",
98
+ "./macros/iceberg-explorer": "./macros/iceberg-explorer.js",
99
+ "./macros/iceberg-explorer.js": "./macros/iceberg-explorer.js"
98
100
  },
99
101
  "files": [
100
102
  "extensions",
@@ -0,0 +1,311 @@
1
+ 'use strict'
2
+
3
+ const fs = require('fs')
4
+ const path = require('path')
5
+
6
+ /**
7
+ * doc-tools check-build-log: the blocking gate of the Connect docs PR check.
8
+ *
9
+ * Antora's own --log-failure-level makes Antora exit non-zero, but it says
10
+ * nothing about WHAT failed in a form a job summary can show, and it cannot
11
+ * catch the build that exits 0 having written no pages at all (Antora on an
12
+ * unsupported Node version does exactly that). So the workflow runs Antora
13
+ * with --log-failure-level=fatal and --log-format json, and this command
14
+ * decides:
15
+ *
16
+ * - any record at level error or fatal fails the check. With
17
+ * --blocking-sources, an error fails it only when it comes from one of
18
+ * those repositories (its own source or any page in its include chain),
19
+ * or has no source at all (an extension error); errors from the other
20
+ * sources are listed but do not block. Fatal records always block,
21
+ * - with --min-pages and --site-dir, fewer HTML pages than expected fails it,
22
+ * - warnings never fail it; they are listed in the summary.
23
+ *
24
+ * The summary is Markdown, written to stdout, for $GITHUB_STEP_SUMMARY.
25
+ */
26
+
27
+ const LEVELS = ['fatal', 'error', 'warn', 'info', 'debug', 'trace']
28
+ const BLOCKING_LEVELS = new Set(['fatal', 'error'])
29
+ // pino's numeric levels, in case a log was written without level labels.
30
+ const NUMERIC_LEVELS = { 10: 'trace', 20: 'debug', 30: 'info', 40: 'warn', 50: 'error', 60: 'fatal' }
31
+
32
+ const DEFAULTS = Object.freeze({
33
+ maxGroups: 50,
34
+ maxLocations: 5
35
+ })
36
+
37
+ function normalizeLevel (level) {
38
+ if (typeof level === 'number') return NUMERIC_LEVELS[level] || String(level)
39
+ return String(level || 'info').toLowerCase()
40
+ }
41
+
42
+ /**
43
+ * A human-readable location for one log record: the source repository and
44
+ * ref, then the file and line. Local worktree paths are made relative to the
45
+ * worktree so the same file reads the same on every runner.
46
+ */
47
+ function relativePath (file, source) {
48
+ let filePath = (file && file.path) || ''
49
+ if (filePath && source && source.worktree && path.isAbsolute(filePath)) {
50
+ const rel = path.relative(source.worktree, filePath)
51
+ if (!rel.startsWith('..')) filePath = rel
52
+ }
53
+ return filePath
54
+ }
55
+
56
+ /** owner/name of a source URL, without the host or .git. */
57
+ function repoSlug (url) {
58
+ return String(url || '').replace(/\.git$/, '').replace(/^[a-z]+:\/\/[^/]+\//i, '').replace(/^git@[^:]+:/, '')
59
+ }
60
+
61
+ function locationOf (record) {
62
+ const file = record.file || {}
63
+ const source = record.source || {}
64
+ if (!file.path && !source.url) return null
65
+ const filePath = relativePath(file, source)
66
+ let where = ''
67
+ if (source.url) {
68
+ const repo = repoSlug(source.url)
69
+ where = source.refname ? `${repo}@${source.refname}` : repo
70
+ if (source.startPath) where += `/${source.startPath}`
71
+ }
72
+ let loc = filePath
73
+ if (loc && file.line) loc += `:${file.line}`
74
+ // The include chain: the page that pulled the file in, which is the useful
75
+ // location when the problem is in a shared partial.
76
+ const via = Array.isArray(record.stack) && record.stack.length
77
+ ? record.stack.map((s) => {
78
+ const p = relativePath(s.file, s.source)
79
+ return p ? `${p}${s.file.line ? `:${s.file.line}` : ''}` : null
80
+ }).filter(Boolean)
81
+ : []
82
+ let out = [where, loc].filter(Boolean).join(': ')
83
+ if (via.length) out += ` (included from ${via.join(' < ')})`
84
+ return out || null
85
+ }
86
+
87
+ function messageOf (record) {
88
+ if (record.msg) return String(record.msg)
89
+ if (record.err && record.err.message) return String(record.err.message)
90
+ if (record.message) return String(record.message)
91
+ return '(no message)'
92
+ }
93
+
94
+ /**
95
+ * Parse an Antora JSON (NDJSON) log. Lines that are not JSON objects (stray
96
+ * stderr, a progress bar) are counted, not fatal: a log is still checkable
97
+ * when something else wrote to the same file.
98
+ */
99
+ function parseLog (text) {
100
+ const records = []
101
+ let unparsed = 0
102
+ for (const line of String(text).split('\n')) {
103
+ const trimmed = line.trim()
104
+ if (!trimmed) continue
105
+ let obj
106
+ try {
107
+ obj = JSON.parse(trimmed)
108
+ } catch {
109
+ unparsed++
110
+ continue
111
+ }
112
+ if (!obj || typeof obj !== 'object' || Array.isArray(obj)) {
113
+ unparsed++
114
+ continue
115
+ }
116
+ records.push(obj)
117
+ }
118
+ return { records, unparsed }
119
+ }
120
+
121
+ function countHtmlPages (dir) {
122
+ let count = 0
123
+ const stack = [dir]
124
+ while (stack.length) {
125
+ const current = stack.pop()
126
+ let entries
127
+ try {
128
+ entries = fs.readdirSync(current, { withFileTypes: true })
129
+ } catch {
130
+ continue
131
+ }
132
+ for (const entry of entries) {
133
+ const full = path.join(current, entry.name)
134
+ if (entry.isDirectory()) stack.push(full)
135
+ else if (entry.isFile() && entry.name.endsWith('.html')) count++
136
+ }
137
+ }
138
+ return count
139
+ }
140
+
141
+ /**
142
+ * Check a parsed log. Returns the counts, the grouped messages, and whether
143
+ * the check passed.
144
+ */
145
+ /**
146
+ * Whether an error-level record blocks. Without a source list, every one
147
+ * does. With one, a record blocks when it has no source at all (an
148
+ * extension's own error), or when its source or any source in its include
149
+ * chain is one of the listed repositories.
150
+ */
151
+ function isBlocking (record, level, blockingSources) {
152
+ if (level === 'fatal') return true
153
+ if (!blockingSources || !blockingSources.length) return true
154
+ const sources = [record.source, ...(Array.isArray(record.stack) ? record.stack.map((s) => s && s.source) : [])]
155
+ .filter((s) => s && s.url)
156
+ if (!sources.length) return true
157
+ const wanted = new Set(blockingSources.map((s) => repoSlug(s).toLowerCase()))
158
+ return sources.some((s) => wanted.has(repoSlug(s.url).toLowerCase()))
159
+ }
160
+
161
+ function checkBuildLog ({ text, siteDir, minPages, blockingSources } = {}) {
162
+ const { records, unparsed } = parseLog(text)
163
+ const counts = Object.fromEntries(LEVELS.map((l) => [l, 0]))
164
+ const groups = new Map()
165
+ let blocking = 0
166
+ for (const record of records) {
167
+ let level = normalizeLevel(record.level)
168
+ counts[level] = (counts[level] || 0) + 1
169
+ if (!BLOCKING_LEVELS.has(level) && level !== 'warn') continue
170
+ if (BLOCKING_LEVELS.has(level)) {
171
+ if (isBlocking(record, level, blockingSources)) blocking++
172
+ else level = 'error-elsewhere'
173
+ }
174
+ const msg = messageOf(record)
175
+ const key = `${level}\u0000${msg}`
176
+ let group = groups.get(key)
177
+ if (!group) {
178
+ group = { level, msg, count: 0, locations: [] }
179
+ groups.set(key, group)
180
+ }
181
+ group.count++
182
+ const loc = locationOf(record)
183
+ if (loc && !group.locations.includes(loc)) group.locations.push(loc)
184
+ }
185
+
186
+ const failures = []
187
+ if (blocking > 0) {
188
+ const scope = blockingSources && blockingSources.length ? ` from ${blockingSources.join(', ')}` : ''
189
+ failures.push(`${blocking} log ${blocking === 1 ? 'record' : 'records'} at level error or fatal${scope}`)
190
+ }
191
+
192
+ let pages = null
193
+ if (minPages != null) {
194
+ if (!siteDir) throw new Error('--min-pages needs --site-dir')
195
+ pages = fs.existsSync(siteDir) ? countHtmlPages(siteDir) : 0
196
+ if (pages < minPages) {
197
+ failures.push(`${pages} HTML ${pages === 1 ? 'page' : 'pages'} written under ${siteDir}, fewer than the required ${minPages}`)
198
+ }
199
+ }
200
+
201
+ const byLevel = (level) => [...groups.values()]
202
+ .filter((g) => g.level === level)
203
+ .sort((a, b) => b.count - a.count || a.msg.localeCompare(b.msg))
204
+
205
+ return {
206
+ ok: failures.length === 0,
207
+ failures,
208
+ counts,
209
+ total: records.length,
210
+ unparsed,
211
+ pages,
212
+ minPages: minPages == null ? null : minPages,
213
+ blocking,
214
+ blockingSources: blockingSources && blockingSources.length ? blockingSources : null,
215
+ groups: {
216
+ fatal: byLevel('fatal'),
217
+ error: byLevel('error'),
218
+ errorElsewhere: byLevel('error-elsewhere'),
219
+ warn: byLevel('warn')
220
+ }
221
+ }
222
+ }
223
+
224
+ // Markdown table cells and inline code cannot hold a raw pipe or backtick
225
+ // run, and a message can contain both.
226
+ function mdInline (text) {
227
+ return String(text).replace(/\r?\n/g, ' ').replace(/\|/g, '\\|').replace(/</g, '&lt;')
228
+ }
229
+
230
+ function formatGroups (title, groups, { maxGroups, maxLocations }) {
231
+ if (!groups.length) return []
232
+ const lines = [`### ${title}`, '']
233
+ for (const g of groups.slice(0, maxGroups)) {
234
+ lines.push(`- ${g.count > 1 ? `(${g.count}x) ` : ''}${mdInline(g.msg)}`)
235
+ for (const loc of g.locations.slice(0, maxLocations)) lines.push(` - ${mdInline(loc)}`)
236
+ if (g.locations.length > maxLocations) lines.push(` - and ${g.locations.length - maxLocations} more locations`)
237
+ }
238
+ if (groups.length > maxGroups) lines.push(`- and ${groups.length - maxGroups} more distinct messages`)
239
+ lines.push('')
240
+ return lines
241
+ }
242
+
243
+ function formatMarkdown (result, opts = {}) {
244
+ const { maxGroups, maxLocations } = { ...DEFAULTS, ...opts }
245
+ const lines = []
246
+ lines.push(`## Antora build log: ${result.ok ? 'passed' : 'failed'}`, '')
247
+ if (!result.ok) {
248
+ for (const f of result.failures) lines.push(`- **Blocking:** ${f}`)
249
+ lines.push('')
250
+ }
251
+ lines.push('| Level | Count |', '|---|---|')
252
+ for (const level of LEVELS) {
253
+ if (level === 'debug' || level === 'trace') {
254
+ if (!result.counts[level]) continue
255
+ }
256
+ lines.push(`| ${level} | ${result.counts[level] || 0} |`)
257
+ }
258
+ if (result.pages != null) lines.push(`| HTML pages | ${result.pages} (minimum ${result.minPages}) |`)
259
+ if (result.unparsed) lines.push(`| non-JSON lines | ${result.unparsed} |`)
260
+ lines.push('')
261
+ lines.push(...formatGroups('Fatal', result.groups.fatal, { maxGroups, maxLocations }))
262
+ lines.push(...formatGroups('Errors', result.groups.error, { maxGroups, maxLocations }))
263
+ if (result.groups.errorElsewhere.length) {
264
+ lines.push(...formatGroups(`Errors from other sources (do not block; only ${result.blockingSources.join(', ')} block)`, result.groups.errorElsewhere, { maxGroups, maxLocations }))
265
+ }
266
+ lines.push(...formatGroups('Warnings (do not block)', result.groups.warn, { maxGroups, maxLocations }))
267
+ return lines.join('\n')
268
+ }
269
+
270
+ function intOption (value, flag) {
271
+ if (value == null) return undefined
272
+ const n = Number(value)
273
+ if (!Number.isInteger(n) || n < 0) throw new Error(`${flag} must be a non-negative integer, got ${value}`)
274
+ return n
275
+ }
276
+
277
+ function runCli (logFile, options = {}) {
278
+ let result
279
+ try {
280
+ if (!logFile || !fs.existsSync(logFile)) throw new Error(`log file not found: ${logFile}`)
281
+ result = checkBuildLog({
282
+ text: fs.readFileSync(logFile, 'utf8'),
283
+ siteDir: options.siteDir,
284
+ minPages: intOption(options.minPages, '--min-pages'),
285
+ blockingSources: options.blockingSources
286
+ ? String(options.blockingSources).split(',').map((x) => x.trim()).filter(Boolean)
287
+ : undefined
288
+ })
289
+ } catch (err) {
290
+ console.error(`Error: ${err.message}`)
291
+ process.exit(2)
292
+ }
293
+ const out = options.format === 'json' ? JSON.stringify(result, null, 2) : formatMarkdown(result)
294
+ console.log(out)
295
+ if (options.output) {
296
+ fs.mkdirSync(path.dirname(path.resolve(options.output)), { recursive: true })
297
+ fs.writeFileSync(path.resolve(options.output), out + '\n')
298
+ }
299
+ process.exit(result.ok ? 0 : 1)
300
+ }
301
+
302
+ module.exports = {
303
+ DEFAULTS,
304
+ parseLog,
305
+ checkBuildLog,
306
+ countHtmlPages,
307
+ formatMarkdown,
308
+ locationOf,
309
+ isBlocking,
310
+ runCli
311
+ }