@redpanda-data/docs-extensions-and-macros 5.54.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 (42) hide show
  1. package/bin/doc-tools-mcp.js +4 -5
  2. package/bin/doc-tools.js +39 -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 +20 -4
  11. package/extensions/add-git-dates.js +13 -1
  12. package/extensions/aggregate-terms.js +34 -20
  13. package/extensions/modify-connect-tag-playbook.js +181 -3
  14. package/extensions/resolve-xrefs-in-attachments.js +1 -12
  15. package/extensions/util/connect-docs-asset.js +6 -5
  16. package/extensions/version-fetcher/fetch-latest-docker-tag.js +35 -13
  17. package/extensions/version-fetcher/get-latest-redpanda-helm-version-from-operator.js +64 -40
  18. package/extensions/version-fetcher/list-connect-releases.js +3 -3
  19. package/extensions/version-fetcher/release-lines.js +64 -0
  20. package/extensions/version-fetcher/set-latest-version.js +73 -3
  21. package/macros/README.adoc +1 -0
  22. package/macros/REFERENCE.adoc +99 -0
  23. package/macros/badge.js +5 -8
  24. package/macros/enterprise.js +2 -5
  25. package/macros/glossary.js +4 -14
  26. package/macros/iceberg-explorer.js +242 -0
  27. package/macros/prop.js +3 -13
  28. package/mcp/USER_GUIDE.adoc +25 -135
  29. package/package.json +4 -2
  30. package/tools/doc-impact-recording/index.js +190 -0
  31. package/tools/overrides-audit/README.adoc +1 -2
  32. package/tools/overrides-audit/adapters/properties.js +1 -1
  33. package/tools/overrides-audit/index.js +3 -4
  34. package/tools/redpanda-connect/AUTOMATION.md +3 -36
  35. package/tools/redpanda-connect/README.adoc +5 -33
  36. package/tools/redpanda-connect/generate-rpcn-connector-docs.js +3 -3
  37. package/tools/redpanda-connect/pr-summary-formatter.js +6 -4
  38. package/tools/redpanda-connect/report-delta.js +6 -3
  39. package/tools/redpanda-connect/rpcn-connector-docs-handler.js +38 -16
  40. package/tools/redpanda-release-notes/generate-release-notes.js +116 -1
  41. package/tools/rpk-docs/templates/command.hbs +1 -1
  42. package/tools/overrides-audit/adapters/connect.js +0 -144
@@ -8,6 +8,7 @@ module.exports.register = function ({ config }) {
8
8
  const GetLatestDockerTag = require('./fetch-latest-docker-tag');
9
9
  const GetLatestHelmChartVersionFromOperator = require('./get-latest-redpanda-helm-version-from-operator');
10
10
  const GetLatestConnectVersion = require('./get-latest-connect');
11
+ const { latestStablePerLine, releaseLineOf, isOlderReleaseLine } = require('./release-lines');
11
12
  const logger = this.getLogger('set-latest-version-extension');
12
13
 
13
14
  const { getGitHubApiToken } = require('../../cli-utils/github-token');
@@ -55,7 +56,14 @@ module.exports.register = function ({ config }) {
55
56
  // pre-freeze version forever.
56
57
  GetLatestRedpandaVersion(github, owner, 'streaming-enterprise', logger),
57
58
  GetLatestDockerTag(dockerNamespace, 'console', logger),
58
- GetLatestDockerTag(dockerNamespace, 'redpanda-operator', logger),
59
+ // Three pages (300 tags) so older docs versions can still find the
60
+ // newest patch of their own operator line. Docker Hub lists recent
61
+ // pushes first, so the first page holds every line that still gets
62
+ // patches. Today the second and third pages add only legacy lines
63
+ // (2.x, 22.3, 23.x) that no built docs version pins to. They are
64
+ // headroom for a line that stops getting patches and drops off the
65
+ // first page as newer tags are pushed.
66
+ GetLatestDockerTag(dockerNamespace, 'redpanda-operator', logger, { maxPages: 3 }),
59
67
  GetLatestConnectVersion(github, owner, 'connect', logger),
60
68
  ]);
61
69
 
@@ -97,6 +105,33 @@ module.exports.register = function ({ config }) {
97
105
  };
98
106
 
99
107
  const components = await contentCatalog.getComponents();
108
+
109
+ // An operator or Helm chart release supports only its own Redpanda line
110
+ // and the lines next to it, so a docs version for an older line must
111
+ // install that line's release, not the newest one. Resolve the newest
112
+ // operator tag of each line from the fetched tags, and that line's chart
113
+ // version from its release branch, once per line.
114
+ const operatorLines = latestStablePerLine(latestVersions.operator?.stableReleases);
115
+ const lineVersions = new Map();
116
+ for (const component of components) {
117
+ for (const { version } of component.versions) {
118
+ if (!isOlderReleaseLine(version, component.latest?.version)) continue;
119
+ const line = releaseLineOf(version);
120
+ if (lineVersions.has(line)) continue;
121
+ const operatorTag = operatorLines.get(line) || null;
122
+ lineVersions.set(line, { operator: operatorTag, helmChart: null });
123
+ }
124
+ }
125
+ await Promise.all([...lineVersions].map(async ([line, resolved]) => {
126
+ if (!resolved.operator) return;
127
+ try {
128
+ const chart = await GetLatestHelmChartVersionFromOperator(github, owner, 'redpanda-operator', resolved.operator, null, logger);
129
+ resolved.helmChart = chart?.latestStableRelease || null;
130
+ } catch (error) {
131
+ logger.warn(`Helm chart lookup for the ${line} line failed: ${error.message || error}`);
132
+ }
133
+ }));
134
+
100
135
  components.forEach(component => {
101
136
  const prerelease = component.latestPrerelease;
102
137
 
@@ -105,13 +140,45 @@ module.exports.register = function ({ config }) {
105
140
  asciidoc.attributes['page-component-version-is-prerelease'] = 'true';
106
141
  }
107
142
 
143
+ // Older docs versions get the newest operator and chart release of
144
+ // their own line. The latest version, prereleases, and unversioned
145
+ // components get the newest release overall. When an older line has
146
+ // no release to pin to, the newest release would not support it, so
147
+ // the version keeps the value its antora.yml sets, and gets the
148
+ // newest release only when antora.yml sets nothing.
149
+ let operatorVersion = latestVersions.operator?.latestStableRelease;
150
+ let helmChartVersion = latestVersions.helmChart?.latestStableRelease;
151
+ if (isOlderReleaseLine(version, component.latest?.version)) {
152
+ const line = releaseLineOf(version);
153
+ const resolved = lineVersions.get(line) || {};
154
+ const keepOrNewest = (key, newest, what) => {
155
+ const own = asciidoc.attributes[key];
156
+ if (own) {
157
+ logger.warn(`No ${what} release found for the ${line} line, so ${name} ${version} keeps ${key} from its antora.yml (${own}).`);
158
+ return own;
159
+ }
160
+ logger.warn(`No ${what} release found for the ${line} line and its antora.yml sets no ${key}, so ${name} ${version} uses the newest release (${newest || 'unknown'}).`);
161
+ return newest;
162
+ };
163
+ if (resolved.operator) {
164
+ operatorVersion = resolved.operator;
165
+ } else {
166
+ operatorVersion = keepOrNewest('latest-operator-version', operatorVersion, 'stable Redpanda Operator');
167
+ }
168
+ if (resolved.helmChart) {
169
+ helmChartVersion = resolved.helmChart;
170
+ } else {
171
+ helmChartVersion = keepOrNewest('latest-redpanda-helm-chart-version', helmChartVersion, 'Redpanda Helm chart');
172
+ }
173
+ }
174
+
108
175
  // Set operator and helm chart attributes via helper function. These keep
109
176
  // their raw fetched form, including the "v" prefix on
110
177
  // latest-operator-version, because docs pages pass that value straight
111
178
  // to `helm --version`. Only the short sibling is derived.
112
179
  updateAttributes(asciidoc, [
113
- { condition: latestVersions.operator, key: 'latest-operator-version', value: latestVersions.operator?.latestStableRelease },
114
- { condition: latestVersions.helmChart, key: 'latest-redpanda-helm-chart-version', value: latestVersions.helmChart?.latestStableRelease }
180
+ { condition: latestVersions.operator, key: 'latest-operator-version', value: operatorVersion },
181
+ { condition: latestVersions.helmChart, key: 'latest-redpanda-helm-chart-version', value: helmChartVersion }
115
182
  ]);
116
183
 
117
184
  // Set attributes for console and connect versions
@@ -190,6 +257,9 @@ module.exports.register = function ({ config }) {
190
257
  summary.forEach(([label, stableVersion, betaVersion]) => {
191
258
  logger.info(`- ${label}: ${stableVersion || 'unknown'}${betaVersion ? ', beta: ' + betaVersion : ''}`);
192
259
  });
260
+ [...lineVersions].sort(([a], [b]) => semver.rcompare(`${a}.0`, `${b}.0`)).forEach(([line, resolved]) => {
261
+ logger.info(`- ${line} docs: operator ${resolved.operator || 'antora.yml value (no release in line)'}, Helm chart ${resolved.helmChart || 'antora.yml value (no release in line)'}`);
262
+ });
193
263
  } catch (error) {
194
264
  logger.error(`Error updating versions: ${error}`);
195
265
  }
@@ -64,6 +64,7 @@ IMPORTANT: Macros must be registered under the `asciidoc.extensions` key in your
64
64
 
65
65
  === Block macros
66
66
 
67
+ * **iceberg-explorer** - Emit a mount point that docs-ui hydrates into the interactive Iceberg Mode Explorer
67
68
  * **components_by_category** - Display Redpanda Connect components grouped by category
68
69
  * **component_table** - Generate searchable component tables
69
70
  * **component_type_dropdown** - Create dropdown for component types
@@ -430,6 +430,105 @@ asciidoc:
430
430
  - '@redpanda-data/docs-extensions-and-macros/macros/helm-ref'
431
431
  ----
432
432
 
433
+ == iceberg-explorer
434
+
435
+ This macro registers an `[iceberg-explorer]` block that emits a version-aware mount point for the interactive Iceberg Mode Explorer:
436
+
437
+ [,html]
438
+ ----
439
+ <div class="iceberg-explorer-mount" data-iceberg-explorer="1" data-version="26.2">
440
+ <p class="iceberg-explorer-fallback">Interactive Iceberg Mode Explorer. If this text is still here...</p>
441
+ </div>
442
+ ----
443
+
444
+ The block renders the container only. The explorer UI and its translation engine live in the docs-ui bundle, which hydrates the mount point, so the block is inert unless the site uses a docs-ui build that includes the explorer. This is the same "macro emits container, docs-ui hydrates it" handshake used by the Redpanda Connect component metadata block.
445
+
446
+ Until the explorer module replaces the container's contents, the fallback paragraph is visible. It is ordinary markup rather than `<noscript>` on purpose: the failure readers are most likely to hit is JavaScript running normally on a site whose UI bundle has no explorer module, and a browser hides `<noscript>` in that case, leaving a zero-height blank space with no explanation.
447
+
448
+ The page's component version (`page-version`, falling back to `page-component-version`) is stamped onto the mount point as `data-version` so the UI can load the engine build that matches the doc version. If neither attribute is set, `data-version` is omitted.
449
+
450
+ Antora derives both attributes from the component version and locks them, so a page header cannot set them: an unversioned component (`version: ~`, which is what this repo's preview site uses) emits no `data-version`, and docs-ui falls back to its own version detection.
451
+
452
+ === Mount contract
453
+
454
+ docs-ui must hydrate on the `data-iceberg-explorer` attribute (`[data-iceberg-explorer]`), never on a class name that matches the block name.
455
+
456
+ Asciidoctor turns an unrecognized block style into a CSS class. A site that uses `[iceberg-explorer]` but whose playbook does not register this macro still publishes `<div class="openblock iceberg-explorer">`, with the author's JSON body dumped onto the page as a code listing. If the explorer module hydrated on `.iceberg-explorer`, that accident would render as a working-looking explorer filled with built-in sample data, the author's defaults silently discarded, and no error anywhere to trace it back to the missing playbook entry.
457
+
458
+ Only this macro can emit a data attribute, so:
459
+
460
+ * `data-iceberg-explorer` is the mount contract, and its value is the contract version (`1` today). Bump it only if the emitted attributes change shape.
461
+ * `iceberg-explorer-mount` is a styling hook, not a mount signal.
462
+
463
+ The constants are exported (`MOUNT_ATTRIBUTE`, `MOUNT_CONTRACT_VERSION`, `MOUNT_CLASS`, `MOUNT_SELECTOR`) so docs-ui and the tests can assert against one definition.
464
+
465
+ === Usage
466
+
467
+ ```asciidoc
468
+ [iceberg-explorer]
469
+ --
470
+ --
471
+ ```
472
+
473
+ Set the initial configuration with the `config` attribute:
474
+
475
+ ```asciidoc
476
+ [iceberg-explorer,config="key:mode=binary;value:mode=binary"]
477
+ --
478
+ --
479
+ ```
480
+
481
+ Or pass a JSON object as the block body to supply author-defined defaults:
482
+
483
+ ```asciidoc
484
+ [iceberg-explorer]
485
+ ----
486
+ {
487
+ "config": "value:mode=schema_latest"
488
+ }
489
+ ----
490
+ ```
491
+
492
+ The explorer module reads only the `config` key and renders its own sample schema and record. Other keys are forwarded verbatim in `data-defaults` and ignored, so do not document author-supplied schemas or records as supported until docs-ui reads them.
493
+
494
+ The JSON is attribute-escaped and emitted as `data-defaults`. The body must be a JSON *object*: `JSON.parse()` also accepts scalars and arrays, and docs-ui reads keys off the parsed value, so a bare `123` is as unusable as a syntax error. If the body is not a JSON object, the macro logs a warning that names the page, omits `data-defaults`, and still renders the mount point.
495
+
496
+ The block accepts an open (`--`), listing (`----`), literal, or pass body. It is not registered for the paragraph context, because the processor replaces the block with the mount point: registering paragraphs would delete the prose under a stray `[iceberg-explorer]` line from the published page.
497
+
498
+ |===
499
+ | Attribute | Description
500
+
501
+ | `config`
502
+ | Initial configuration string. Emitted as `data-config`. Falls back to the `config` key of the JSON body.
503
+
504
+ | `height`
505
+ | Minimum height for the container, emitted as an inline `min-height` style. A bare number is treated as pixels (`height=400` becomes `min-height:400px`). Otherwise the value must be a CSS length in `px`, `rem`, `em`, `vh`, `vw`, or `%`; any other value is rejected with a warning rather than interpolated into the style attribute.
506
+
507
+ | `engine-base`
508
+ | Optional base location for the translation engine build, emitted as `data-engine-base`. It must be a root-relative path on the docs site, such as `/_/engine/26.2`, because docs-ui loads a script from it. Any other value (an absolute or protocol-relative URL, a relative path, backslashes, or control characters) is ignored with a build warning. docs-ui falls back to its own default when this is absent, so set it only to pin a specific engine build.
509
+ |===
510
+
511
+ === Registration
512
+
513
+ [,yaml]
514
+ ----
515
+ asciidoc:
516
+ extensions:
517
+ - '@redpanda-data/docs-extensions-and-macros/macros/iceberg-explorer'
518
+ ----
519
+
520
+ === docs-ui dependency
521
+
522
+ This block is half of a two-repo handshake and does nothing on its own. The explorer UI and its translation engine ship in docs-ui, so the block requires a docs-ui release that includes the explorer module. On a site built with a bundle that lacks the module, the block renders the container and its fallback paragraph, nothing more.
523
+
524
+ Order of operations before an author uses the block on a published page:
525
+
526
+ . Merge and publish this package, so consumers can resolve the macro.
527
+ . Release a docs-ui bundle that includes the explorer module, with hydration keyed on `[data-iceberg-explorer]` (see <<Mount contract>>).
528
+ . Add the macro to each consuming playbook's `asciidoc.extensions` (`redpanda-data/docs`, `cloud-docs`). Registering it in this repo's `local-antora-playbook.yml` covers only this repo's preview site.
529
+
530
+ Until step 2 lands, a page that uses the block shows the fallback paragraph rather than an explorer. That is deliberate: it is visible, so a missing or outdated UI bundle looks broken rather than looking like a page with nothing on it.
531
+
433
532
  == components_by_category
434
533
 
435
534
  This macro generates a tabbed interface to display Redpanda Connect components by category.
package/macros/badge.js CHANGED
@@ -1,5 +1,7 @@
1
1
  'use strict';
2
2
 
3
+ const { escapeHtml } = require('../extension-utils/html-utils');
4
+
3
5
  /**
4
6
  * Build the HTML for a badge.
5
7
  *
@@ -34,12 +36,7 @@ function buildBadgeHtml ({ label, size, tooltip } = {}) {
34
36
  // handlers). The label needs it as much as the tooltip -- it lands in the
35
37
  // class attribute and in the text content, and escaping only the tooltip left
36
38
  // `badge:x[label=x" onmouseover="...]` free to break out of the class.
37
- const escapeAttr = (value) => String(value)
38
- .replace(/&/g, '&amp;')
39
- .replace(/"/g, '&quot;')
40
- .replace(/</g, '&lt;')
41
- .replace(/>/g, '&gt;');
42
- const className = `badge--${escapeAttr(text.toLowerCase().replace(/\s+/g, '-'))}`;
39
+ const className = `badge--${escapeHtml(text.toLowerCase().replace(/\s+/g, '-'))}`;
43
40
  // hasOwnProperty, not a bare index: `label=constructor` and `label=__proto__`
44
41
  // are already lowercase, so a bare lookup shipped
45
42
  // `function Object() { [native code] }` to readers as hover text. prop.js
@@ -49,12 +46,12 @@ function buildBadgeHtml ({ label, size, tooltip } = {}) {
49
46
  ? DEFAULT_TOOLTIPS[labelKey]
50
47
  : undefined;
51
48
  const hoverText = tooltip || defaultTooltip;
52
- const tooltipAttr = hoverText ? ` data-tooltip="${escapeAttr(hoverText)}"` : '';
49
+ const tooltipAttr = hoverText ? ` data-tooltip="${escapeHtml(hoverText)}"` : '';
53
50
 
54
51
  // Add brackets if not large. The brackets matter beyond styling: they are
55
52
  // real text content, so the label survives an HTML-to-Markdown conversion as
56
53
  // "(beta)" rather than vanishing with the CSS class.
57
- const renderedLabel = isLarge ? escapeAttr(text) : `(${escapeAttr(text)})`;
54
+ const renderedLabel = isLarge ? escapeHtml(text) : `(${escapeHtml(text)})`;
58
55
 
59
56
  return `<span class="badge ${className} ${sizeClass}"${tooltipAttr}>${renderedLabel}</span>`;
60
57
  }
@@ -69,6 +69,7 @@
69
69
  */
70
70
 
71
71
  const yaml = require('js-yaml')
72
+ const { escapeHtml } = require('../extension-utils/html-utils')
72
73
  const semver = require('semver')
73
74
  const { buildBadgeHtml, DEFAULT_TOOLTIPS } = require('./badge')
74
75
  const { isAttributeSet } = require('./prop')
@@ -545,11 +546,7 @@ function buildEnterpriseContent ({ feature, text, xref, url, tooltip, licensingP
545
546
  // consumer that re-parses the page then reads as markup -- the docs UI
546
547
  // promotes this attribute into a tooltip, the Markdown converter and the
547
548
  // search indexer both re-read it.
548
- const escapedTooltip = tooltipText
549
- .replace(/&/g, '&amp;')
550
- .replace(/"/g, '&quot;')
551
- .replace(/</g, '&lt;')
552
- .replace(/>/g, '&gt;')
549
+ const escapedTooltip = escapeHtml(tooltipText)
553
550
  const tooltipHtml = tooltipAttr ? ` ${tooltipAttr}="${escapedTooltip}"` : ''
554
551
  let inner = display
555
552
  if (links) {
@@ -3,22 +3,12 @@
3
3
  const $glossaryContexts = Symbol('$glossaryContexts')
4
4
  const { posix: path } = require('path')
5
5
  const logger = require('@antora/logger')('glossary-macro')
6
+ const { escapeHtml } = require('../extension-utils/html-utils')
6
7
 
7
8
  // Backtick-monospace in hover-text. The unconstrained (double-backtick) form is
8
9
  // matched first, otherwise it leaves a stray delimiter on each side.
9
10
  const MONOSPACE_RX = /``([^`]+)``|`([^`]+)`/g
10
11
 
11
- // Escape for interpolation into a double-quoted HTML attribute. Same four
12
- // replacements, in the same order, as badge.js and enterprise.js; `&` has to go
13
- // first or it re-escapes the entities the later passes introduce.
14
- function escapeAttr (value) {
15
- return String(value)
16
- .replace(/&/g, '&amp;')
17
- .replace(/"/g, '&quot;')
18
- .replace(/</g, '&lt;')
19
- .replace(/>/g, '&gt;')
20
- }
21
-
22
12
  // Only a data-* tooltip is read by a JS tooltip library that renders it as HTML;
23
13
  // the native title attribute is always plain text. Shared with the
24
14
  // glossary-tooltip validation below so the two cannot drift apart.
@@ -45,14 +35,14 @@ function tooltipRendersHtml (tooltipAttr) {
45
35
  // file's raw :hover-text: value -- so it is escaped once rather than twice and
46
36
  // its markup survives to the reader instead of being shown as literal tags.
47
37
  function formatTooltipDefinition (text, tooltipAttr, definitionIsHtml) {
48
- if (!tooltipRendersHtml(tooltipAttr)) return escapeAttr(text)
38
+ if (!tooltipRendersHtml(tooltipAttr)) return escapeHtml(text)
49
39
  if (definitionIsHtml) {
50
40
  // Already HTML, so one pass is right: the attribute decode hands the markup
51
41
  // back intact for innerHTML to render, and a quote in the text still cannot
52
42
  // terminate the attribute early.
53
- return escapeAttr(text)
43
+ return escapeHtml(text)
54
44
  }
55
- return escapeAttr(escapeAttr(text)).replace(
45
+ return escapeHtml(escapeHtml(text)).replace(
56
46
  MONOSPACE_RX,
57
47
  (match, unconstrained, constrained) =>
58
48
  `<code>${unconstrained !== undefined ? unconstrained : constrained}</code>`
@@ -0,0 +1,242 @@
1
+ 'use strict';
2
+
3
+ const { escapeHtml } = require('../extension-utils/html-utils');
4
+ // Same logger as the other macros (#283): warnings land in Antora's log with
5
+ // a level Antora can fail on, instead of bypassing it through console.warn.
6
+ const logger = require('@antora/logger')('iceberg-explorer-macro');
7
+
8
+ /**
9
+ * Iceberg Mode Explorer block.
10
+ *
11
+ * Registers an `[iceberg-explorer]` AsciiDoc block that emits a lightweight,
12
+ * version-aware mount point. The interactive tool itself (Ace editors,
13
+ * controls, rendering) and its translation engine live in the docs-ui bundle;
14
+ * a docs-ui JS module hydrates every element carrying the mount attribute
15
+ * (`MOUNT_SELECTOR` below).
16
+ *
17
+ * This mirrors the "macro emits container -> docs-ui JS hydrates" handshake
18
+ * used by the component metadata block in `rp-connect-components.js` +
19
+ * `docs-ui/src/js/24-move-connector-metadata.js`.
20
+ *
21
+ * MOUNT CONTRACT. docs-ui must hydrate on the `data-iceberg-explorer`
22
+ * attribute, never on a class name that matches the block name. Asciidoctor
23
+ * turns an unrecognized block style into a CSS class, so a page that uses
24
+ * `[iceberg-explorer]` in a site whose playbook forgot to register this macro
25
+ * still renders `<div class="openblock iceberg-explorer">`. Hydrating on
26
+ * `.iceberg-explorer` would dress that accident up as a working explorer
27
+ * showing built-in sample data while the author's JSON body was discarded, with
28
+ * no error anywhere. Only this macro can emit a data attribute, so the
29
+ * attribute is the contract and the class is for styling only.
30
+ *
31
+ * Authoring:
32
+ *
33
+ * [iceberg-explorer]
34
+ * --
35
+ * --
36
+ *
37
+ * or with author-supplied defaults (JSON) as the block body:
38
+ *
39
+ * [iceberg-explorer]
40
+ * ----
41
+ * {
42
+ * "config": "key:mode=binary;value:mode=schema_id_prefix,layout=flat;headers:value_type=binary"
43
+ * }
44
+ * ----
45
+ *
46
+ * The explorer module reads only the "config" key (docs-ui
47
+ * src/js/27-iceberg-explorer.js), rendering its own sample schema and record.
48
+ * Any other key is forwarded verbatim in `data-defaults` and ignored today, so
49
+ * do not advertise author-supplied schemas or records as supported.
50
+ *
51
+ * The block reads the page's component version and stamps it onto the mount
52
+ * point so the UI can fetch the engine build that matches the doc version.
53
+ *
54
+ * @param {Registry} registry - The Antora/Asciidoctor extension registry.
55
+ * @param {Object} context - The Antora context (unused today; kept for parity
56
+ * with the other macros and for future content-catalog resolution).
57
+ */
58
+
59
+ // Mount contract shared with docs-ui. Exported so both sides and the tests
60
+ // reference one definition instead of a hard-coded string in three places.
61
+ const MOUNT_ATTRIBUTE = 'data-iceberg-explorer';
62
+ // Bumped only if the emitted attributes change shape, so a docs-ui build can
63
+ // refuse a mount point it does not understand.
64
+ const MOUNT_CONTRACT_VERSION = '1';
65
+ // Styling hook only. Deliberately NOT the block name: see MOUNT CONTRACT above.
66
+ const MOUNT_CLASS = 'iceberg-explorer-mount';
67
+ const MOUNT_SELECTOR = `[${MOUNT_ATTRIBUTE}]`;
68
+
69
+ module.exports.register = function (registry, context) {
70
+ // Support both calling conventions:
71
+ // - Antora passes a registry as the first argument; it exposes `.block()`
72
+ // directly, so register on it.
73
+ // - Some preview harnesses (e.g. docs-ui's build-preview-pages) call
74
+ // `register.call(Asciidoctor.Extensions)` with the Extensions module as
75
+ // `this` and no arguments. That module exposes `.register(fn)` (not
76
+ // `.block()`); inside `fn`, `this` is a registry with `.block()`.
77
+ const target = registry || this;
78
+ if (typeof target.block === 'function') {
79
+ defineBlock(target);
80
+ } else if (typeof target.register === 'function') {
81
+ target.register(function () { defineBlock(this); });
82
+ } else {
83
+ throw new Error('iceberg-explorer: no usable Asciidoctor registry provided');
84
+ }
85
+ };
86
+
87
+ function defineBlock (registry) {
88
+ registry.block(function () {
89
+ const self = this;
90
+ self.named('iceberg-explorer');
91
+ // Accept an empty open block (`--`) or a listing/literal/pass block whose
92
+ // body carries author-supplied JSON defaults. `paragraph` is deliberately
93
+ // absent: the processor discards the body, so registering the paragraph
94
+ // context made `[iceberg-explorer]` above ordinary prose delete that
95
+ // paragraph from the published page.
96
+ self.onContext(['open', 'listing', 'literal', 'pass']);
97
+ self.process((parent, reader, attrs) => {
98
+ const attributes = parent.getDocument().getAttributes();
99
+ // Name the page in every warning. Antora sets page-relative-src-path
100
+ // (the convention rp-connect-components.js follows); plain Asciidoctor
101
+ // runs only have docfile.
102
+ const page =
103
+ attributes['page-relative-src-path'] ||
104
+ attributes['docfile'] ||
105
+ 'unknown page';
106
+ const warn = (message) => {
107
+ logger.warn(`${page}: ${message}`);
108
+ };
109
+
110
+ // Author-supplied defaults (optional). Validate that the body is a JSON
111
+ // object so a typo surfaces at build time rather than silently shipping
112
+ // bad data. docs-ui reads keys off the parsed value, so a scalar or an
113
+ // array is as unusable as a syntax error, and JSON.parse accepts both.
114
+ let defaults = null;
115
+ const body = reader.getLines().join('\n').trim();
116
+ if (body) {
117
+ let parsed;
118
+ let valid = false;
119
+ try {
120
+ parsed = JSON.parse(body);
121
+ valid = true;
122
+ } catch (err) {
123
+ warn(
124
+ `block body is not valid JSON (${err.message}). ` +
125
+ 'Rendering the explorer with built-in defaults instead.'
126
+ );
127
+ }
128
+ if (valid) {
129
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
130
+ warn(
131
+ `block body must be a JSON object with a "config" key, not ${describeJson(parsed)}. ` +
132
+ 'Rendering the explorer with built-in defaults instead.'
133
+ );
134
+ } else {
135
+ defaults = parsed;
136
+ }
137
+ }
138
+ }
139
+
140
+ // Doc version drives which engine build the UI loads. `page-version` is
141
+ // the short component version (for example, "26.2"); fall back to the
142
+ // component version attribute if present.
143
+ const version =
144
+ attributes['page-version'] ||
145
+ attributes['page-component-version'] ||
146
+ '';
147
+
148
+ // Optional named attributes let an author pin behavior without a body.
149
+ // `config` sets the initial DSL string; `height` overrides the min
150
+ // height; `engine-base` overrides where docs-ui fetches the engine from.
151
+ const initialConfig = attrs.config || (defaults && defaults.config) || '';
152
+ const height = cssLength(attrs.height, warn);
153
+ const engineBase = rootRelativePath(attrs['engine-base'], warn);
154
+
155
+ const dataDefaults = defaults
156
+ ? escapeHtml(JSON.stringify(defaults))
157
+ : '';
158
+ const dataConfig = initialConfig ? escapeHtml(initialConfig) : '';
159
+ const styleAttr = height ? ` style="min-height:${height}"` : '';
160
+ const versionAttr = version ? ` data-version="${escapeHtml(version)}"` : '';
161
+ const engineAttr = engineBase ? ` data-engine-base="${escapeHtml(engineBase)}"` : '';
162
+
163
+ // The container is a mount point only; docs-ui replaces its contents on
164
+ // hydration. Until then the fallback paragraph is visible, so the two
165
+ // ways this can fail (JS disabled, or a docs-ui build without the
166
+ // explorer module) both show a message instead of a 0px-tall void. It is
167
+ // plain markup, not <noscript>, precisely because the common failure is
168
+ // JS running fine with the module missing, which <noscript> hides.
169
+ const html = `
170
+ <div class="${MOUNT_CLASS}" ${MOUNT_ATTRIBUTE}="${MOUNT_CONTRACT_VERSION}"${versionAttr}${engineAttr}${dataConfig ? ` data-config="${dataConfig}"` : ''}${dataDefaults ? ` data-defaults="${dataDefaults}"` : ''}${styleAttr}>
171
+ <p class="iceberg-explorer-fallback">Interactive Iceberg Mode Explorer. If this text is still here, the explorer could not load: it needs JavaScript and a docs-ui build that includes the explorer module. See the Iceberg topic configuration reference for the equivalent settings.</p>
172
+ </div>`;
173
+
174
+ return self.createBlock(parent, 'pass', html);
175
+ });
176
+ });
177
+ };
178
+
179
+ /**
180
+ * Describe a parsed-but-unusable JSON body for the build warning.
181
+ * @param {*} value
182
+ * @returns {string}
183
+ */
184
+ function describeJson (value) {
185
+ if (Array.isArray(value)) return 'an array';
186
+ if (value === null) return 'null';
187
+ return `a ${typeof value}`;
188
+ }
189
+
190
+ /**
191
+ * Validate the `height` attribute as a CSS length. A bare number is the natural
192
+ * thing to write and produces `min-height:400`, which browsers drop, so accept
193
+ * it and add `px`. Anything else is rejected rather than interpolated: the value
194
+ * lands inside a style attribute, where `1px;position:fixed;inset:0` would turn
195
+ * a doc page into a full-viewport overlay.
196
+ * @param {string} raw
197
+ * @param {function(string): void} warn
198
+ * @returns {string} A safe CSS length, or '' to omit the style attribute.
199
+ */
200
+ function cssLength (raw, warn) {
201
+ if (!raw) return '';
202
+ const value = String(raw).trim();
203
+ if (/^\d+$/.test(value)) return `${value}px`;
204
+ if (/^\d+(\.\d+)?(px|rem|em|vh|vw|%)$/.test(value)) return value;
205
+ warn(
206
+ `height="${value}" is not a CSS length (for example, 400, 400px, 30rem, 60vh). Ignoring it.`
207
+ );
208
+ return '';
209
+ }
210
+
211
+ /**
212
+ * Validate the `engine-base` attribute as a same-origin, root-relative path.
213
+ * docs-ui loads `<engine-base>/iceberg-engine-<version>.js` as a script, so any
214
+ * other value would let page source run another origin's JavaScript on the docs
215
+ * site. Escaping stops HTML injection but does not make a URL trustworthy, so
216
+ * reject schemes, protocol-relative paths (`//host`), backslashes (which
217
+ * browsers treat as `/`), whitespace, and raw or percent-encoded control
218
+ * characters.
219
+ * @param {string} raw
220
+ * @param {function(string): void} warn
221
+ * @returns {string} A safe root-relative path, or '' to use the docs-ui default.
222
+ */
223
+ function rootRelativePath (raw, warn) {
224
+ if (!raw) return '';
225
+ const value = String(raw);
226
+ const safe =
227
+ /^\/(?![\/\\])/.test(value) &&
228
+ !/[\\\s\x00-\x1f\x7f]/.test(value) &&
229
+ !/%(?:[01][0-9a-f]|7f|5c)/i.test(value);
230
+ if (safe) return value;
231
+ warn(
232
+ `engine-base="${value}" is not a root-relative path on this site (for example, /_/engine/26.2). ` +
233
+ 'Ignoring it, so docs-ui loads the engine from its default location.'
234
+ );
235
+ return '';
236
+ }
237
+
238
+ module.exports.MOUNT_ATTRIBUTE = MOUNT_ATTRIBUTE;
239
+ module.exports.MOUNT_CONTRACT_VERSION = MOUNT_CONTRACT_VERSION;
240
+ module.exports.MOUNT_CLASS = MOUNT_CLASS;
241
+ module.exports.MOUNT_SELECTOR = MOUNT_SELECTOR;
242
+ module.exports.rootRelativePath = rootRelativePath;
package/macros/prop.js CHANGED
@@ -91,6 +91,7 @@
91
91
  */
92
92
 
93
93
  const semver = require('semver')
94
+ const { escapeHtml } = require('../extension-utils/html-utils')
94
95
  const logger = require('@antora/logger')('prop-macro')
95
96
 
96
97
  const $propertyRegistry = Symbol('$propertyRegistry')
@@ -867,19 +868,8 @@ function buildPropContent ({ name, text, link, page, scope, role, helmPath = fal
867
868
  // attribute, docUrl from a page's published URL, and name from a dataset
868
869
  // key: an unescaped quote in any of them ends the attribute early and the
869
870
  // remainder becomes stray attributes on every marked property in the build.
870
- const docAttr = docUrl ? ` data-doc-url="${escapeAttribute(docUrl)}"` : ''
871
- return `<code class="${escapeAttribute(role)}" data-property-name="${escapeAttribute(name)}"${docAttr}>${inner}</code>`
872
- }
873
-
874
- /**
875
- * Escape a value for use inside a double-quoted HTML attribute.
876
- */
877
- function escapeAttribute (value) {
878
- return String(value === undefined || value === null ? '' : value)
879
- .replace(/&/g, '&amp;')
880
- .replace(/"/g, '&quot;')
881
- .replace(/</g, '&lt;')
882
- .replace(/>/g, '&gt;')
871
+ const docAttr = docUrl ? ` data-doc-url="${escapeHtml(docUrl)}"` : ''
872
+ return `<code class="${escapeHtml(role)}" data-property-name="${escapeHtml(name)}"${docAttr}>${inner}</code>`
883
873
  }
884
874
 
885
875
  function propInlineMacro (config) {