@inditextech/docouture-asciidoc-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/README.md ADDED
@@ -0,0 +1,254 @@
1
+ # @inditextech/docouture-asciidoc-extensions
2
+
3
+ Asciidoctor extensions shared by the docouture documentation sites.
4
+
5
+ They exist for one reason: **authored content that AsciiDoc has no syntax for**.
6
+ A card grid, a set of steps, a landing hero, a tab switcher — none of these are
7
+ expressible in AsciiDoc, and none of them belong baked into a Handlebars layout,
8
+ because then they stop being content an author can write and become markup only
9
+ a UI developer can change. An extension is the seam between the two: the author
10
+ writes AsciiDoc, the extension emits the design system's markup.
11
+
12
+ Where a thing belongs, before you write anything here:
13
+
14
+ | the thing is | it lives in |
15
+ | ---------------------------------------------- | ------------------------------------------------ |
16
+ | site chrome, present on every page | a Handlebars partial in `ui-bundle/src/partials` |
17
+ | derived from HTML Asciidoctor already produces | CSS alone, in `ui-bundle/src/css` |
18
+ | authored content with no AsciiDoc equivalent | **an extension here** |
19
+
20
+ If Asciidoctor already emits the element and you only need it to look right,
21
+ this package is the wrong tool — style it in `ui-bundle`. Admonitions, tables and
22
+ code blocks are all handled that way.
23
+
24
+ ## The contract
25
+
26
+ Every extension in this package keeps all eight of these.
27
+
28
+ ### 1. One file per extension, registered in `index.js`
29
+
30
+ `lib/<name>.js` exports a single `register<Name>(registry)` function.
31
+ `index.js` requires it and calls it from `registerAll`. Nothing else registers
32
+ anything — a site lists this package once and gets the whole set.
33
+
34
+ `lib/` also holds modules that register nothing: `async-compat.js`,
35
+ `first-positional.js`, `html.js`, `unique-id.js`, `warn.js`, `kroki-config.js`
36
+ and `kroki-instance.js`. They are helpers, required directly by the
37
+ extensions, and are deliberately absent from `index.js`.
38
+
39
+ ### 2. `register` is exported as both a named export and `module.exports.register`
40
+
41
+ Only `index.js` deals with this, and it already does. The shape is not
42
+ cosmetic — it is what makes registration work in two different hosts, and each
43
+ one fails silently when it is wrong:
44
+
45
+ - **Antora** matches `register.toString()` against a regex requiring the first
46
+ parameter to be **literally named `registry`**. Rename it, or ship a bundled
47
+ build that renames it, and Antora decides the module is an _Antora_ extension
48
+ listed under the wrong key, logs `Skipping possible Antora extension
49
+ registered as an Asciidoctor extension`, and moves on. Nothing renders. No
50
+ error.
51
+ - **The ui-bundle preview harness** calls `register.call(Asciidoctor.Extensions)`
52
+ with no argument — `this` is the namespace, not a registry — and does so once
53
+ per rebuild in the same process, which is why `index.js` carries an
54
+ idempotency guard. Without it, a table ends up wrapped in one more nested
55
+ container per rebuild.
56
+
57
+ Read `index.js`'s own header comment before touching any of it, and
58
+ `.opencode/skills/asciidoc/reference/extensions.md` for the registration
59
+ mechanics in full.
60
+
61
+ ### 3. Emit IOP DS BEM markup, exactly
62
+
63
+ Copy the DS component's real markup and class names. Never invent a class on a
64
+ DS block, never re-implement a component's styling from memory. If nothing in
65
+ the DS models what you need, say so before inventing — it usually means the
66
+ wrong component was identified. See `.opencode/skills/iop-ds-components`.
67
+
68
+ ### 4. Ship no CSS
69
+
70
+ This package emits markup and nothing else. Styling lives in
71
+ `ui-bundle/src/css`. An extension that needs new CSS is a change to two
72
+ packages, in that order — never a `<style>` tag, never a stylesheet here.
73
+
74
+ ### 5. Degrade without JavaScript
75
+
76
+ A page must be readable, navigable and correctly styled with JavaScript off.
77
+ Tabs render as sequential sections, each under its own heading; accordions
78
+ render as `<details>`; anything clickable is a real `<a href>`. Behaviour is
79
+ layered onto server-rendered markup by `ui-bundle/src/js` — it never creates
80
+ markup.
81
+
82
+ ### 6. Set real ARIA, and never an inline `style`
83
+
84
+ Interactive markup carries the roles and relationships its pattern requires
85
+ (`role=tablist`/`tab`/`tabpanel`, `aria-controls`, `aria-selected`), keyboard
86
+ operability included. Use `lib/unique-id.js` for the ids those relationships
87
+ need; see its header for why a module-level counter is wrong here.
88
+
89
+ Inline `style` is not a styling escape hatch. The one existing exception —
90
+ `video-size.js` — sets _custom properties_ that the stylesheet consumes, because
91
+ a browser will not derive `aspect-ratio` from an iframe's HTML attributes and
92
+ there is no CSS-only fix. That is the bar: a documented impossibility, not a
93
+ convenience.
94
+
95
+ ### 7. Escape raw attribute values — and only those
96
+
97
+ Asciidoctor substitutes some authored strings before an extension sees them and
98
+ others not at all. Escaping the wrong ones double-encodes (`&` renders as
99
+ `&amp;`); missing the right ones lets authored text inject markup. Measured on
100
+ both majors this repo runs, with `A & B <x>`:
101
+
102
+ | source | arrives as | escape? |
103
+ | ------------------------------------- | --------------------- | ------- |
104
+ | inline macro, positional or named | `A &amp; B &lt;x&gt;` | no |
105
+ | document attribute (`:page-k:`) | `A &amp; B &lt;x&gt;` | no |
106
+ | **block macro attribute** (`x::t[…]`) | `A & B <x>` | **yes** |
107
+ | **block style attribute** (`[x,k=…]`) | `A & B <x>` | **yes** |
108
+
109
+ Identical on 2.2.9 and 4.0.8 — it is a block-vs-inline split, not a version
110
+ split. Separately, anything from `getText()` is **already converted HTML** (a
111
+ dlist term with an `xref:` arrives as a full `<a>`), so it must never be
112
+ escaped.
113
+
114
+ Use `lib/html.js`: `escapeHtml(value)` and `attr(name, value)`. Its header
115
+ carries the full table and the probe behind it.
116
+
117
+ ### 8. Stay portable across both Asciidoctor majors
118
+
119
+ Site builds run **2.2** (via Antora); the ui-bundle preview harness runs **4.0**.
120
+ Touch only `registry`/`this` — never the `@asciidoctor/core` module object,
121
+ whose export shape differs between the two.
122
+
123
+ Two behavioural splits are already solved, and both must be reused rather than
124
+ rediscovered:
125
+
126
+ - **Attribute shape.** An inline macro's first positional attribute is
127
+ `attrs.$positional[0]` under 2.2 and `attrs['1']` under 4.0 — use
128
+ `lib/first-positional.js`.
129
+ - **Sync vs async.** `parseContent`, `convert` and `precomputeText` are
130
+ synchronous under 2.2 and Promise-returning under 4.0. An extension cannot
131
+ simply be `async`: that hands 2.2's synchronous Opal caller a Promise, which
132
+ renders as the literal text `[object Promise]`. Use `chain`/`chainAll`/
133
+ `precomputeSubtree` from `lib/async-compat.js`.
134
+
135
+ `@asciidoctor/core` is a devDependency for its TypeScript types only. It is
136
+ version 4.0, so those types describe the preview harness's runtime, not
137
+ Antora's — treat them as a typing aid, not as proof that an API exists in 2.2.
138
+
139
+ ## Reporting authoring mistakes
140
+
141
+ Both site playbooks set `runtime.log.failure_level: warn`, so **a warning fails
142
+ the build**. That is the right severity for an authoring error: an unknown label
143
+ colour, a `[cards]` block with no cards, a tab set with no panels all render as
144
+ something plausible but wrong, and shipping that is worse than not building.
145
+
146
+ Use `lib/warn.js` so every extension reports in the same shape — what the author
147
+ wrote, what is wrong, and what was expected:
148
+
149
+ ```js
150
+ warn(parent, 'label:mauve[]', 'unknown IDS Label variant "mauve"', VARIANTS)
151
+ // → label:mauve[] — unknown IDS Label variant "mauve"; expected one of white, grey, …
152
+ ```
153
+
154
+ ## Using this package in a site
155
+
156
+ Two steps, both required. Doing only one fails silently.
157
+
158
+ 1. List it under `asciidoc.extensions` in the site's `antora-playbook.yml`
159
+ (or, for the ui-bundle preview, in `preview-src/ui-model.yml`).
160
+ 2. Add it as a dependency with the workspace protocol:
161
+ `"@inditextech/docouture-asciidoc-extensions": "workspace:*"`.
162
+
163
+ Note the neighbouring package: `@inditextech/docouture-antora-extensions` hooks
164
+ Antora's own pipeline under the **`antora.extensions`** key. Listing either
165
+ package under the other's key makes Antora log a warning and skip it.
166
+
167
+ ### Kroki: opt-in, disabled by default
168
+
169
+ `[mermaid]`, `[plantuml]`, `[graphviz]` and the rest of `kroki.js`'s
170
+ `SUPPORTED_TYPES` are the one extension in this package that is **not** active
171
+ just by listing the package — see that file's own header. A site turns it on
172
+ with two more `asciidoc.attributes`:
173
+
174
+ ```yaml
175
+ asciidoc:
176
+ attributes:
177
+ kroki-enabled: true
178
+ kroki-diagram-types: mermaid,plantuml # optional; omitted = every supported type
179
+ ```
180
+
181
+ Per-block, `[mermaid,format=png]` renders a transparent PNG `<img>` instead of the
182
+ default inline SVG — see `kroki-config.js`'s `PNG_SUPPORTED_TYPES` for which of
183
+ `SUPPORTED_TYPES` actually support it (`bpmn` and `excalidraw` notably don't; Kroki
184
+ itself rejects those two output formats outright). An unsupported combination falls
185
+ back to `svg` with a build warning.
186
+
187
+ Mermaid diagrams also get IOP DS-aligned styling (square corners, DS colors, DS body
188
+ typography) baked in server-side via a `%%{init: {...}}%%` directive
189
+ `kroki-mermaid-theme.js` prepends to the diagram's own source before it reaches Kroki
190
+ — not a CSS override, so it applies identically whether the block renders as `svg` or
191
+ `format=png`. An author who opens their own diagram with `%%{init...}%%` opts out
192
+ automatically (Mermaid only honors the first one); every other `SUPPORTED_TYPES` entry
193
+ keeps its own baked-in look, font aside (ui-bundle's `diagram.css` normalizes that one
194
+ blanket, safely, for every type).
195
+
196
+ It also needs a Kroki service reachable at build time, at the fixed local URL
197
+ `kroki-config.js` hardcodes (not itself configurable — see that file's own
198
+ header for why). No manual setup: the sibling `@inditextech/docouture-antora-
199
+ extensions` package's `kroki-prewarm.js` starts one itself, via `docker
200
+ compose`, the first time a build needs it and finds nothing already
201
+ listening — on every invocation path (`docouture dev`/`docouture build`, this
202
+ monorepo's own `just dev`/`just build-site`, a raw `antora` call, any
203
+ consumer's own CI) equally, with no automatic teardown (a stopped-and-
204
+ restarted Kroki on every build would only add latency back). Run `docouture
205
+ eject kroki` to copy the bundled compose file into a site's own repo for
206
+ customization (a different image version, a companion container for
207
+ another diagram type); run `docouture teardown kroki` (or, in this monorepo,
208
+ `just kroki-down`) to stop it manually once you're actually done with it.
209
+ Without `kroki-enabled: true`, or if Docker/the service never becomes
210
+ reachable, these blocks render exactly as plain AsciiDoc already would — a
211
+ disabled or unavailable Kroki is never itself a build failure.
212
+
213
+ A healthy run is otherwise silent: `kroki-docker.js` and `kroki-prewarm.js`
214
+ log their whole lifecycle (already-reachable / starting via which compose
215
+ file / `docker compose up -d` succeeded / became reachable after Ns /
216
+ render summary) at `info`, so nothing about a working setup looks different
217
+ from Nx quietly replaying a stale cached build. Antora's own default log
218
+ level is `warn`, so these are invisible unless you ask for them — as is
219
+ every other docouture extension's own `getLogger('docouture-...')` observability
220
+ (search-index's per-component summary, llms-txt's, footer's, ...), same
221
+ reasoning throughout. This monorepo's own `just dev`/`just build-site`
222
+ recipes, and `docouture dev`/`docouture build` (`antora-log.ts` in the `cli`
223
+ package), all pass `--log-level=info` to every Antora invocation they make
224
+ for exactly this reason, so no extra flag is needed there — a raw `antora`
225
+ invocation of your own still needs `--log-level=info` (or
226
+ `ANTORA_LOG_LEVEL=info`) passed explicitly. A real failure (Docker missing,
227
+ daemon unreachable, service never comes up, an individual diagram failing
228
+ to render) still logs at `warn` unconditionally either way.
229
+
230
+ ## Targets
231
+
232
+ ```
233
+ pnpm nx run @inditextech/docouture-asciidoc-extensions:lint
234
+ pnpm nx run @inditextech/docouture-asciidoc-extensions:typecheck
235
+ ```
236
+
237
+ There is deliberately **no `build` and no `clean`**: the package is plain
238
+ CommonJS, consumed by `require()` straight from source. Nothing is compiled,
239
+ nothing is emitted, so there is nothing to remove. Adding no-op targets to
240
+ match the other packages would be noise.
241
+
242
+ `typecheck` runs TypeScript over the JavaScript (`allowJs` + `checkJs`) using
243
+ the JSDoc annotations. `noImplicitAny` and `noImplicitThis` are off, and only
244
+ those two — see `tsconfig.json` for the measurement behind that and for how they
245
+ get retired. New files are written fully annotated; `lib/html.js`,
246
+ `lib/unique-id.js` and `lib/warn.js` are the reference for what that looks like.
247
+
248
+ ## Further reading
249
+
250
+ - `.opencode/skills/asciidoc/reference/extensions.md` — extension points, the
251
+ DSL, and the registration rules in full
252
+ - `.opencode/skills/iop-ds-components` — choosing a DS component and emitting
253
+ its markup
254
+ - `.opencode/skills/iop-ds-foundations` — tokens, theming, breakpoints
package/index.js ADDED
@@ -0,0 +1,117 @@
1
+ 'use strict'
2
+
3
+ // Every extension in lib/ is registered here, and only here — see README.md
4
+ // for the contract each one keeps. Note that lib/ also holds modules that
5
+ // register nothing and are absent from this list on purpose: async-compat.js,
6
+ // first-positional.js, html.js, unique-id.js, warn.js, shiki-instance.js and
7
+ // shiki-config.js are shared helpers the extensions require directly.
8
+ // kroki-config.js and kroki-instance.js are the same kind of helper, shared
9
+ // between kroki.js (below) and the sibling
10
+ // @inditextech/docouture-antora-extensions package's kroki-prewarm.js — see
11
+ // kroki.js's own header. shiki-syntax-highlighter.js is the one exception
12
+ // that DOES register something outside `registerAll` — see its own
13
+ // `require(...)` below.
14
+ const registerLabelMacro = require('./lib/label-macro')
15
+ const registerMonoMacro = require('./lib/mono-macro')
16
+ const registerTableWidth = require('./lib/table-width')
17
+ const registerTableContainer = require('./lib/table-container')
18
+ const registerNowrapCols = require('./lib/nowrap-cols')
19
+ const registerVideoSize = require('./lib/video-size')
20
+ const registerCardGrid = require('./lib/card-grid')
21
+ const registerFeatureTabs = require('./lib/feature-tabs')
22
+ const registerCta = require('./lib/cta')
23
+ const registerAccordion = require('./lib/accordion')
24
+ const registerTabs = require('./lib/tabs')
25
+ const registerKroki = require('./lib/kroki')
26
+
27
+ // GH-89: registers Asciidoctor's 'shiki' SyntaxHighlighter adapter. Required
28
+ // here, at module top level, NOT inside `registerAll` below — this is a
29
+ // GLOBAL, one-time registration on the `@asciidoctor/core` module itself
30
+ // (node's require cache is what makes "once" hold), not a per-page
31
+ // `registry` registration like everything else in this file. See that
32
+ // module's own header for the full explanation.
33
+ require('./lib/shiki-syntax-highlighter')
34
+
35
+ function registerAll(target) {
36
+ registerLabelMacro(target)
37
+ registerMonoMacro(target)
38
+ // table-width.js's tree processor must run (be registered) before
39
+ // table-container.js's postprocessor for its stash to exist by the time
40
+ // the postprocessor reads it — tree processors always run before
41
+ // postprocessors regardless of registration order (Asciidoctor's own
42
+ // fixed processor-phase pipeline), but keeping the registration order
43
+ // matching that phase order here too, for anyone reading top to bottom.
44
+ registerTableWidth(target)
45
+ registerTableContainer(target)
46
+ registerNowrapCols(target)
47
+ registerVideoSize(target)
48
+ // [cards] — Weave.js migration Phase 2, see its own
49
+ // header comment for the DS components behind it and why.
50
+ registerCardGrid(target)
51
+ // [feature-tabs] — the landing's Key features switcher (GH-22). The only
52
+ // block here with behaviour layered on top of it; see its own header for
53
+ // why the tab ARIA is applied by ui-bundle's 07-feature-tabs.ts rather
54
+ // than emitted from here.
55
+ registerFeatureTabs(target)
56
+ // [cta] — the landing's call to action (GH-23), styled after Fumadocs'
57
+ // "Free & Open Source" block rather than a Figma frame; see its own header
58
+ // for why it deviates from GH-23's original macro sketch.
59
+ registerCta(target)
60
+ // [accordion] — GH-61 Part 2, grouping semantics (role=group, single-open)
61
+ // over a run of ordinary [%collapsible] blocks (Part 1's own restyle of
62
+ // those, in ui-bundle/src/css/accordion.css). An OPEN block, not an
63
+ // example block like every entry above it — see its own header for why.
64
+ registerAccordion(target)
65
+ // [tabs] — GH-45, real tabs for the migrated quickstart's package-manager
66
+ // code blocks (and any other authored content wanting a switcher). Also
67
+ // an OPEN block, for the mirror-image reason accordion.js gives — see its
68
+ // own header.
69
+ registerTabs(target)
70
+ // [mermaid]/[plantuml]/etc — GH-44, real diagrams via a self-hosted Kroki
71
+ // service, over the literal diagram source the Weave.js migration left
72
+ // behind (see tools/fumadocs-migrate/lib/emit.mjs). Unlike every extension
73
+ // above, OPT IN and disabled by default per site — see its own header for
74
+ // why, and for the `kroki-enabled`/`kroki-diagram-types` attributes that
75
+ // turn it on. Registers once per supported diagram type, not once overall.
76
+ registerKroki(target)
77
+ }
78
+
79
+ /**
80
+ * Registers docouture' AsciiDoc extensions.
81
+ *
82
+ * Antora calls this once per page with a fresh, per-page `registry` (see
83
+ * @antora/asciidoc-loader's resolve-asciidoc-config.js) — the first
84
+ * parameter must be literally named `registry` or Antora's own regex match
85
+ * against `register.toString()` fails, decides this is an Antora pipeline
86
+ * extension mistakenly listed here, logs a warning, and skips it entirely.
87
+ *
88
+ * The ui-bundle preview harness (gulp.d/tasks/build-preview-pages.js) calls
89
+ * this differently: `extension.register.call(Asciidoctor.Extensions)` — no
90
+ * argument, `this` bound to the Asciidoctor 4.0 `Extensions` NAMESPACE, not
91
+ * a registry. That object has `.register`/`.create` but no `.inlineMacro`
92
+ * of its own (verified empirically) — `.register(fn)` is what hands `fn` a
93
+ * real registry as ITS `this`, one more hop than the Antora case.
94
+ *
95
+ * That global path is a one-time registration for the life of the process —
96
+ * fine for a single `gulp preview:build` run, but `gulp preview` (the
97
+ * watch server) re-runs build-preview-pages.js, and so this whole module,
98
+ * on every rebuild WITHOUT restarting the node process. `Extensions.register`
99
+ * has no dedupe of its own: each rebuild adds one more global registration,
100
+ * so a table ends up wrapped in as many nested `.tableblock-wrap`s as
101
+ * rebuilds have happened — a real bug reproduced live in that watch server
102
+ * (`.tableblock-wrap` nested inside its own `.tablecontainer`, doubling the
103
+ * width cap). Guarded here with a flag on the namespace object itself,
104
+ * rather than in gulp.d (out of this package's own control) — idempotent
105
+ * across any number of calls in the same process.
106
+ */
107
+ module.exports.register = function (registry) {
108
+ const target = registry || this
109
+ if (typeof target.inlineMacro === 'function') {
110
+ registerAll(target)
111
+ } else if (!target.$docoutureAsciidocExtensionsRegistered) {
112
+ target.$docoutureAsciidocExtensionsRegistered = true
113
+ target.register(function () {
114
+ registerAll(this)
115
+ })
116
+ }
117
+ }
@@ -0,0 +1,243 @@
1
+ 'use strict'
2
+
3
+ const { chain, chainAll, precomputeSubtree } = require('./async-compat')
4
+ const { escapeHtml, attr, stripTags } = require('./html')
5
+ const uniqueId = require('./unique-id')
6
+ const warn = require('./warn')
7
+
8
+ // Accordion grouping — GH-61 Part 2. Adds only what a native `[%collapsible]`
9
+ // cannot express on its own: group semantics (`role=group`, an accessible
10
+ // name) and, optionally, single-open behaviour. Part 1 (accordion.css,
11
+ // ui-bundle) already restyles every `[%collapsible]` as a DS accordion item,
12
+ // independent-mode by construction — this block is the thinnest possible
13
+ // wrapper around a run of them, never a reimplementation.
14
+ //
15
+ // SYNTAX
16
+ //
17
+ // An OPEN block (`--`/`--`), not an example block (`====`) like every other
18
+ // grouping extension in this package (`[cards]`, `[steps]`, `[feature-tabs]`
19
+ // all use `onContext('example')`). Deliberate: the children here are
20
+ // THEMSELVES example blocks — a `[%collapsible]` is `[%collapsible]====…====`
21
+ // — and nesting an example block inside another example block forces the
22
+ // child to `=====` (one more `=`), which is easy to get wrong and stops
23
+ // looking like an ordinary, standalone collapsible. An open block's own
24
+ // delimiter is `--`, so the children stay ordinary `====` blocks, unchanged,
25
+ // exactly as they would read outside a group:
26
+ //
27
+ // [accordion%single-open,aria-label="Frequently asked questions"]
28
+ // --
29
+ // .Can I use Weave.js with any UI framework?
30
+ // [%collapsible]
31
+ // ====
32
+ // Yes, Weave.js is framework-agnostic by design.
33
+ // ====
34
+ //
35
+ // .Can I deploy the frontend and backend on a single artifact?
36
+ // [%collapsible]
37
+ // ====
38
+ // Yes, you can bundle them together.
39
+ // ====
40
+ // --
41
+ //
42
+ // `single-open` is the DS component's own vocabulary (`AccordionProps.
43
+ // singleOpen`, `accordion.d.ts`), spelled as Asciidoctor's own option
44
+ // shorthand — the same `%name` mechanism `[%collapsible]` itself already
45
+ // uses (`Parser.parseStyleAttribute`, verified against the vendored parser:
46
+ // a style token can carry a style name AND `%option`s together, e.g. the
47
+ // `[%collapsible%open]` an author can already write today). Absent the
48
+ // option, items are independent — the same default the DS component's own
49
+ // `singleOpen` prop has.
50
+ //
51
+ // WHAT THIS EMITS
52
+ //
53
+ // `<div class="docouture-accordion-group" role="group" aria-label="…">` wrapping
54
+ // the children's OWN converted markup verbatim — nothing here re-renders a
55
+ // collapsible; `child.convert()` is Asciidoctor's own `convert_example`
56
+ // (`node.hasOption('collapsible')` branch), unchanged. `docouture-accordion-group`
57
+ // is not an invented DS class — the real `Accordion` component's own wrapper
58
+ // `<div>` (`accordion.js`) carries no class of its own beyond whatever the
59
+ // CALLER passes; there is nothing to match here, so this file names its own
60
+ // wrapper the same way every other non-DS wrapper in this package does
61
+ // (`docouture-cta`, `docouture-card-grid`, `docouture-feature-tabs`). Styled in
62
+ // `ui-bundle/src/css/accordion.css`, alongside the items it groups.
63
+ //
64
+ // Single-open is implemented with the native `<details name="…">` radio-group
65
+ // behaviour, `name` shared across every child in the group via
66
+ // `lib/unique-id.js` — regex-patched onto each child's own rendered
67
+ // `<details>` tag, the same "lift the converter's own tag, patch one
68
+ // attribute" technique `video-size.js`/`cta.js` already use. That makes
69
+ // single-open work with NO JavaScript in every browser new enough to
70
+ // support `<details name>` (Chrome 120+, Safari 17.2+, Firefox 130+) — a
71
+ // degradation to independent mode elsewhere, never a break. Real keyboard
72
+ // roving focus (arrow/Home/End across headers) is layered on top of this
73
+ // same markup by `ui-bundle/src/js/10-accordion.ts`; this file emits no
74
+ // script and no inline `style`.
75
+
76
+ /** The first `<details` of a child's own converted markup — see header. */
77
+ const DETAILS_OPEN_RX = /^<details\b/
78
+
79
+ /**
80
+ * Cross-major `hasOption` — the same split `first-positional.js`'s own
81
+ * header documents for a different method, here for this one: 2.2's JS API
82
+ * names it `isOption` (verified against the vendored dist,
83
+ * `AbstractNode.prototype.isOption`); 4.0 renamed it to `hasOption`
84
+ * (`abstract_node.js`'s own comment: "option? → hasOption"). Needed here,
85
+ * unlike `child.hasRole(...)` elsewhere in this package's other extensions —
86
+ * `hasRole` kept its name across both majors, `hasOption` did not.
87
+ *
88
+ * @param {Block} node
89
+ * @param {string} name
90
+ * @returns {boolean}
91
+ */
92
+ function hasOption(node, name) {
93
+ if (typeof node.hasOption === 'function') return node.hasOption(name)
94
+ // 2.2's own JS API (Opal, real Antora builds) — absent from 4.0's
95
+ // `AbstractNode` type declarations, so a plain property access is a type
96
+ // error even though it exists at runtime; only reachable when the
97
+ // `hasOption` branch above is false, i.e. actually running 2.2.
98
+ return /** @type {{ isOption(name: string): boolean }} */ (/** @type {unknown} */ (node)).isOption(name)
99
+ }
100
+
101
+ /**
102
+ * An Asciidoctor block, in whichever major is running.
103
+ *
104
+ * @typedef {import('@asciidoctor/core').AbstractBlock} Block
105
+ */
106
+
107
+ /**
108
+ * Escapes a value ALREADY produced by the converter (`getTitle()`) for
109
+ * placement inside a double-quoted HTML attribute. Deliberately not
110
+ * `lib/html.js`'s `escapeHtml` — that helper is for RAW block attributes
111
+ * Asciidoctor never substitutes; a block's title is the opposite case
112
+ * (`lib/html.js`'s own header: "anything from `getText()`/`getTitle()` is
113
+ * already converted HTML"), so `&`/`<`/`>` are already entities and escaping
114
+ * them again would double-encode (`&amp;` → `&amp;amp;`). Only the quote
115
+ * that would otherwise close the attribute early needs handling here.
116
+ *
117
+ * @param {string} html - converted HTML, e.g. `wrapper.getTitle()`.
118
+ * @returns {string} the same text, safe inside `"…"`.
119
+ */
120
+ function escapeConvertedAttribute(html) {
121
+ return html.replace(/"/g, '&quot;')
122
+ }
123
+
124
+ /**
125
+ * The group's accessible name: the `aria-label=` attribute when given (a raw
126
+ * block attribute, so escaped normally), falling back to the wrapper's own
127
+ * block title with its markup stripped (converted HTML, so quote-escaped
128
+ * only — see {@link escapeConvertedAttribute}) — an ARIA attribute value
129
+ * cannot itself carry HTML, so any tags a titled `.Title` line converted to
130
+ * (a bold word, an `xref:`) are dropped rather than left as visible text.
131
+ *
132
+ * @param {object} attrs - the block's own attributes.
133
+ * @param {Block} wrapper - the parsed `[accordion]` content, for its title.
134
+ * @returns {string} the label, or `''` when there is none.
135
+ */
136
+ function ariaLabelFor(attrs, wrapper) {
137
+ const raw = attrs['aria-label']
138
+ if (raw) return escapeHtml(raw)
139
+ const title = wrapper.getTitle()
140
+ if (!title) return ''
141
+ return escapeConvertedAttribute(stripTags(String(title)))
142
+ }
143
+
144
+ /**
145
+ * The group's `[%collapsible]` children — anything else authored inside the
146
+ * open block (a stray paragraph, a non-collapsible example block) is left
147
+ * out silently, the same judgement `card-grid.js`/`feature-tabs.js` make for
148
+ * a stray child that isn't their own `[card]`/`[feature]` style.
149
+ *
150
+ * @param {Block} wrapper
151
+ * @returns {Block[]}
152
+ */
153
+ function collapsibleChildren(wrapper) {
154
+ return wrapper.getBlocks().filter((block) => block.getContext() === 'example' && hasOption(block, 'collapsible'))
155
+ }
156
+
157
+ /**
158
+ * Assembles the block once every child has been rendered.
159
+ *
160
+ * @param {Block} parent - the block this replaces.
161
+ * @param {Block} wrapper - the parsed `[accordion]` content.
162
+ * @param {object} attrs - the block's own attributes.
163
+ * @param {{ createBlock: Function }} self
164
+ * @returns {object | Promise<object>}
165
+ */
166
+ function finish(parent, wrapper, attrs, self) {
167
+ const children = collapsibleChildren(wrapper)
168
+ if (!children.length) {
169
+ warn(parent, '[accordion]', 'an accordion group with no `[%collapsible]` items in it')
170
+ }
171
+
172
+ const ariaLabel = ariaLabelFor(attrs, wrapper)
173
+ if (!ariaLabel) {
174
+ warn(
175
+ parent,
176
+ '[accordion]',
177
+ 'an accordion group has no `aria-label=` and no title; give it one — a `role=group` with no accessible name is a strongly-discouraged pattern for screen reader users'
178
+ )
179
+ }
180
+
181
+ // Shared across every child so native `<details name>` groups them into
182
+ // one mutually-exclusive set — `null` (and no `name=` patched in below)
183
+ // leaves every child independent, the DS's own `singleOpen` default.
184
+ const groupName = 'single-open-option' in attrs ? uniqueId(wrapper, 'accordion-group') : null
185
+
186
+ const rendered = children.map((child) => child.convert())
187
+
188
+ return chainAll(rendered, (htmls) => {
189
+ const itemsHtml = htmls
190
+ .map((html, index) => {
191
+ if (!groupName) return html
192
+ if (!DETAILS_OPEN_RX.test(html)) {
193
+ warn(
194
+ parent,
195
+ '[accordion]',
196
+ 'child ' + (index + 1) + " didn't render as a <details>; single-open needs every item to be one"
197
+ )
198
+ return html
199
+ }
200
+ return html.replace(DETAILS_OPEN_RX, '<details' + attr('name', groupName))
201
+ })
202
+ .join('')
203
+
204
+ const html =
205
+ '<div class="docouture-accordion-group" role="group"' +
206
+ (ariaLabel ? ' aria-label="' + ariaLabel + '"' : '') +
207
+ '>' +
208
+ itemsHtml +
209
+ '</div>'
210
+ return self.createBlock(parent, 'pass', html, attrs)
211
+ })
212
+ }
213
+
214
+ function accordionBlock() {
215
+ this.named('accordion')
216
+ this.onContext('open')
217
+ this.process((parent, reader, attrs) => {
218
+ // See steps.js's own comment: Opal (2.2) can hand this a bare JS `null`
219
+ // for a block with no attributes beyond its style, and `createBlock`
220
+ // crashes on that.
221
+ attrs = attrs || {}
222
+ // See steps.js's own comment: a literal JS `null` "source" crashes Opal
223
+ // (2.2) inside `Block#initialize`'s `.nil_or_empty?()` check.
224
+ const wrapper = this.createBlock(parent, 'open', '', attrs)
225
+ // See label-macro.js's own comment on this same pattern.
226
+ // eslint-disable-next-line @typescript-eslint/no-this-alias
227
+ const self = this
228
+ // See async-compat.js's own header comment: parseContent is sync under
229
+ // 2.2 (Opal, real Antora builds) and Promise-returning under 4.0 (the
230
+ // ui-bundle preview harness) — chain() handles either without making
231
+ // this function `async` unconditionally. precomputeSubtree is what makes
232
+ // a `.Title` carrying inline markup (this block's own, or a child
233
+ // `[%collapsible]`'s) arrive converted rather than raw.
234
+ return chain(this.parseContent(wrapper, reader.getLines()), () =>
235
+ chain(precomputeSubtree(wrapper), () => finish(parent, wrapper, attrs, self))
236
+ )
237
+ })
238
+ }
239
+
240
+ module.exports = function registerAccordion(registry) {
241
+ registry.block(accordionBlock)
242
+ }
243
+ module.exports.accordionBlock = accordionBlock