@oxide/react-asciidoc 1.2.0 → 1.3.0-canary.5535dcb

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 CHANGED
@@ -1,98 +1,385 @@
1
- # React AsciiDoc
1
+ # `@oxide/react-asciidoc`
2
2
 
3
- > **Warning** Work in progress, not ready for use
3
+ A React renderer for [AsciiDoc](https://asciidoc.org/), built on top of
4
+ [Asciidoctor.js](https://github.com/asciidoctor/asciidoctor.js/). It parses a document with
5
+ stock Asciidoctor, walks the resulting AST into a plain serialisable tree, and renders that
6
+ tree with React components. The goal is to reproduce the HTML of Asciidoctor's built-in
7
+ HTML5 converter while letting you swap individual block and inline templates for your own
8
+ React components.
4
9
 
5
- A React renderer for AsciiDoc. Built on top of
6
- [Asciidoctor.js](https://github.com/asciidoctor/asciidoctor.js/). Designed to be equivalent
7
- in output of the built-in HTML5 converter. Will allow for custom components to be passed in
8
- similar to
9
- [Custom converters in Asciidoctor.js](https://docs.asciidoctor.org/asciidoctor.js/latest/extend/converter/custom-converter/)
10
- and custom block types.
10
+ It's aimed at rendering AsciiDoc documents for the web — a document at a time — when you
11
+ want real React components in the output instead of a string of HTML.
11
12
 
12
- Currently just a proof of concept, working towards style parity with the built-in converter.
13
+ Built and maintained at [Oxide](https://oxide.computer).
13
14
 
14
- ## Installing
15
+ ## Status
15
16
 
16
- To view any of the examples first install all necessary modules:
17
+ Output is checked against stock Asciidoctor with a diff suite: each document is converted
18
+ both ways and the HTML compared. Current results:
19
+
20
+ - **Examples:** 78 / 79 hand-written fixtures match (including the full Asciidoctor writer's
21
+ guide). The lone miss is a table using `{set:cellbgcolor}`.
22
+ - **Corpus:** 1894 / 2001 real-world documents match (~95%) — a mix of Oxide RFDs and
23
+ Asciidoctor's own test fixtures.
24
+
25
+ Every block and inline type the HTML5 converter emits is implemented. The remaining
26
+ mismatches are a small set of edge cases — see [Known differences](#known-differences). If
27
+ your content steers clear of those, the output matches Asciidoctor.
28
+
29
+ ## Installation
17
30
 
18
31
  ```
19
- npm install
32
+ npm install @oxide/react-asciidoc @asciidoctor/core
33
+ ```
34
+
35
+ `@asciidoctor/core`, `react`, and `react-dom` are peer dependencies (React 18 or 19).
36
+
37
+ ## Usage
38
+
39
+ The flow is always: load the source with Asciidoctor → `prepareDocument(doc)` → render
40
+ `<Asciidoc>`.
41
+
42
+ ```tsx
43
+ import asciidoctor from '@asciidoctor/core'
44
+ import { Asciidoc, prepareDocument } from '@oxide/react-asciidoc'
45
+
46
+ const ad = asciidoctor()
47
+
48
+ function Page({ source }: { source: string }) {
49
+ const doc = ad.load(source, {
50
+ standalone: true,
51
+ attributes: { sectlinks: 'true', icons: 'font' },
52
+ })
53
+ const prepared = prepareDocument(doc)
54
+ return <Asciidoc document={prepared} />
55
+ }
56
+ ```
57
+
58
+ `prepareDocument` returns a plain, JSON-serialisable `DocumentBlock`. You can run
59
+ `ad.load` + `prepareDocument` on a server (or at build time), serialise the result, and
60
+ hydrate `<Asciidoc>` on the client without shipping Asciidoctor.js to the browser.
61
+
62
+ > Asciidoctor's default stylesheet is not injected. Pull in
63
+ > [`asciidoctor.css`](https://github.com/asciidoctor/asciidoctor/blob/main/data/stylesheets/asciidoctor.css)
64
+ > (or your own) to style the output.
65
+
66
+ ### Block-level overrides
67
+
68
+ Replace the component used for any block type via `options.overrides`. Your component
69
+ receives the prepared `node`; render `<Content blocks={node.blocks} />` to recurse into
70
+ children.
71
+
72
+ ```tsx
73
+ import { Asciidoc, type Block, Content, type DocumentBlock } from '@oxide/react-asciidoc'
74
+
75
+ function Callout({ node }: { node: Block }) {
76
+ return (
77
+ <aside className={`callout callout-${node.attributes?.name}`}>
78
+ <Content blocks={node.blocks} />
79
+ </aside>
80
+ )
81
+ }
82
+
83
+ function Doc({ document }: { document: DocumentBlock }) {
84
+ return <Asciidoc document={document} options={{ overrides: { admonition: Callout } }} />
85
+ }
86
+ ```
87
+
88
+ Override keys are the block type names:
89
+
90
+ ```
91
+ admonition · audio · colist · dlist · example · floating_title · image · listing ·
92
+ literal · olist · open · page_break · paragraph · pass · preamble · quote · section ·
93
+ sidebar · stem · table · toc · thematic_break · ulist · verse · video
20
94
  ```
21
95
 
22
- ## Previewing
96
+ ### Inline-level overrides
97
+
98
+ Inline content (emphasis, links, footnotes, etc.) is parsed into its own AST. By default
99
+ it's rendered to an HTML string, which is the form that matches Asciidoctor most precisely.
100
+ To render individual inline nodes as React components instead, pass
101
+ `options.inlineOverrides`:
23
102
 
24
- You can then preview any of the examples by running `npm run dev` and visting
25
- `http://localhost:8000/?example=[EXAMPLE]&renderer=[RENDERER]`. See
26
- [here](https://github.com/oxidecomputer/react-asciidoc/tree/main/src/examples) for the
27
- example names. Use `renderer=react` for the React renderer and `renderer=html` for the HTML
28
- one.
103
+ ```tsx
104
+ import { Asciidoc, type DocumentBlock, type InlineOverrides } from '@oxide/react-asciidoc'
29
105
 
30
- > **Note** The example name must be identical to the file name without the file ending, it
31
- > is case sensitive
106
+ const inlineOverrides: InlineOverrides = {
107
+ // node.subtype is 'link' | 'xref' | 'ref' | 'bibref'
108
+ anchor: ({ node, children }) =>
109
+ node.subtype === 'xref' ? (
110
+ <a className="xref" href={node.target}>
111
+ {children}
112
+ </a>
113
+ ) : (
114
+ <a href={node.target}>{children}</a>
115
+ ),
116
+ }
32
117
 
33
- ## Running tests
118
+ function Doc({ document }: { document: DocumentBlock }) {
119
+ return <Asciidoc document={document} options={{ inlineOverrides }} />
120
+ }
121
+ ```
34
122
 
35
- Before running the full set of tests we must first generate the screenshots. The tests are
36
- running a visual diff on the original renderer and the React one. You must therefore run the
37
- following command first:
123
+ Override keys (with the node passed to each):
38
124
 
39
125
  ```
40
- npm run test:init
126
+ quoted · anchor · image · footnote · indexterm · callout · break · button · kbd · menu
41
127
  ```
42
128
 
43
- This will produce an error on first run; this is expected, as it needs to generate the
44
- screenshots. You should then run the following command, which runs all of the React renderer
45
- tests, comparing them to the baseline.
129
+ `quoted` covers
130
+ `emphasis | strong | monospaced | mark | superscript | subscript | double | single | unquoted`
131
+ (distinguished by `node.subtype`).
132
+
133
+ #### Bibliography cross-references as direct links
134
+
135
+ When an `<<id>>` cross-reference resolves to a `[bibliography]` entry whose citation carries
136
+ a URL, `prepareDocument` records that URL on the anchor node as `node.externalHref`. The
137
+ default output is unchanged (it still links to the in-page `#id` anchor, matching
138
+ Asciidoctor), but an `anchor` override can read `externalHref` to link straight to the
139
+ external resource instead:
140
+
141
+ ```asciidoc
142
+ See <<pegjs>> for the parser generator.
143
+
144
+ [bibliography]
145
+ == References
146
+
147
+ - [[[pegjs]]] PEG.js https://pegjs.org
148
+ ```
46
149
 
150
+ ```tsx
151
+ const inlineOverrides: InlineOverrides = {
152
+ anchor: ({ node, children }) =>
153
+ node.subtype === 'xref' && node.externalHref ? (
154
+ <a href={node.externalHref} target="_blank" rel="noopener">
155
+ {children}
156
+ </a>
157
+ ) : (
158
+ <a href={node.target.startsWith('#') ? node.target : `#${node.target}`}>{children}</a>
159
+ ),
160
+ }
47
161
  ```
48
- npm run test:run
162
+
163
+ `externalHref` is only set when the reference resolves to that entry under stock rules; an
164
+ unresolved citation is left as the normal fallback.
165
+
166
+ Alongside `externalHref`, `prepareDocument` also records the entry's citation body on the
167
+ anchor as `node.referenceInlines` — the entry's inline AST with the leading `[[[id]]]`
168
+ bibref label stripped (the free text and links that follow it). It is set for every xref
169
+ that resolves to a `[bibliography]` entry, whether or not the citation carries a URL. The
170
+ default renderer ignores it (preserving stock parity); an `anchor` override can render it
171
+ through `<RenderInline>` to show the reference's content in a hover tooltip:
172
+
173
+ ```tsx
174
+ import { RenderInline } from '@oxide/react-asciidoc'
175
+
176
+ const inlineOverrides: InlineOverrides = {
177
+ anchor: ({ node, children }) =>
178
+ node.subtype === 'xref' && node.referenceInlines ? (
179
+ <Tooltip content={<RenderInline nodes={node.referenceInlines} />}>
180
+ <a href={`#${node.target}`}>{children}</a>
181
+ </Tooltip>
182
+ ) : (
183
+ <a href={node.target.startsWith('#') ? node.target : `#${node.target}`}>{children}</a>
184
+ ),
185
+ }
49
186
  ```
50
187
 
51
- ![GIF of the visual diff tests](https://user-images.githubusercontent.com/4020798/196468163-a3fac4eb-ddec-43d1-99ee-a38fe7cd7062.gif)
188
+ > **Inline overrides change the inline output path.** Without them, inline content is
189
+ > emitted as an HTML string and matches Asciidoctor exactly. Supplying any `inlineOverrides`
190
+ > switches to a React element tree, which entity-decodes text (e.g. `&#8217;` renders as a
191
+ > literal `'`). The visible result is the same, but the HTML is no longer
192
+ > character-for-character identical to Asciidoctor — leave `inlineOverrides` unset if you
193
+ > need an exact match.
194
+
195
+ ### Custom document shell
196
+
197
+ `options.customDocument` replaces the outer document component (header, TOC, footnotes
198
+ section) while still using the built-in block templates for the body. See `src/App.tsx` and
199
+ `tests/helpers.tsx` for a worked example.
200
+
201
+ ### Other exports
202
+
203
+ | Export | Purpose |
204
+ | ----------------------- | ------------------------------------------------------------------------------------- |
205
+ | `Content` | Renders an array of blocks; use inside block overrides to recurse. |
206
+ | `RenderInline` | Renders an inline AST (`node.inlines`) as a React tree (honours `inlineOverrides`). |
207
+ | `inlineHtml` | Renders an inline AST to an HTML string (`{ __html }`) for `dangerouslySetInnerHTML`. |
208
+ | `Inline` | The standalone inline parser/renderer (`parseInline`, `renderInline`). |
209
+ | `prepareDocument` | Asciidoctor AST → serialisable `DocumentBlock`. |
210
+ | `processDocument` | Walk/transform the prepared tree (e.g. to post-process listing blocks). |
211
+ | Block templates & types | `Paragraph`, `Listing`, …, `ParagraphBlock`, `ListingBlock`, … re-exported. |
212
+
213
+ ## How it works
214
+
215
+ 1. You call `ad.load(...)` to get an Asciidoctor AST.
216
+ 2. `prepareDocument()` walks that tree into plain `Block` objects. For each leaf block it
217
+ feeds the raw source through a vendored TypeScript port of Asciidoctor's inline
218
+ substitution pipeline (`src/asciidoc/inline/`), producing an `InlineNode[]` AST.
219
+ 3. `<Asciidoc>` puts options into context and renders `<Document>` (or your
220
+ `customDocument`).
221
+ 4. `<Content>` dispatches each block to its template, preferring `options.overrides`.
222
+ 5. Inline content renders via `inlineHtml` + `dangerouslySetInnerHTML` (the default, closest
223
+ to Asciidoctor) or, when `inlineOverrides` are set, via `<RenderInline>` (a React tree).
52
224
 
53
225
  ## Limitations
54
226
 
55
- We do not have access to the inline nodes. They can be customised but not with React. You
56
- can write a standard asciidoctor.js custom converter instead. Using a JSX to string
57
- converter is planned to make it easier to create converters for inline nodes.
58
-
59
- ## Current Progress
60
-
61
- Tests are showing the following examples are identical to the HTML5 renderer using the
62
- [default asciidoctor stylesheet](https://github.com/asciidoctor/asciidoctor/blob/f80441fd2ad46439cce19bf54581eb9d79097f3d/src/stylesheets/asciidoctor.css).
63
-
64
- - [x] Audio
65
- - [x] Admonition
66
- - [x] CoList
67
- - [x] DList
68
- - [x] Example
69
- - [x] Floating Title
70
- - [x] Inline Anchor
71
- - [x] Inline Break
72
- - [x] Inline Button
73
- - [x] Inline Callout
74
- - [x] Inline Image
75
- - [x] Inline KBD
76
- - [x] Inline Menu
77
- - [x] Inline Quoteed
78
- - [x] Listing
79
- - [x] Literal
80
- - [x] OList
81
- - [x] Open
82
- - [x] Page Break
83
- - [x] Paragraph
84
- - [x] Pass
85
- - [x] Preamble
86
- - [x] Quote
87
- - [x] Sidebar
88
- - [x] Thematic Break
89
- - [x] Toc
90
- - [ ] Document
91
- - [ ] Embedded
92
- - [ ] Image
93
- - [ ] Inline Footnote
94
- - [ ] Outline
95
- - [ ] Section
96
- - [ ] Stem
97
- - [ ] Table
98
- - [ ] Video
227
+ - **Inline parsing is reimplemented, not delegated.** To expose inline nodes to React we
228
+ vendored Asciidoctor's substitution pipeline rather than calling its converter. It mirrors
229
+ `apply_normal_subs` closely, but a few constructs that rely on Asciidoctor's sequential
230
+ `gsub` model don't round-trip perfectly — see [Known differences](#known-differences).
231
+ - **The default stylesheet is not bundled.**
232
+ - **Inline overrides change the inline output path** (see above).
233
+ - **No file-system features.** `include::` directives are resolved by Asciidoctor at
234
+ `ad.load` time; this library never touches the file system itself. An unresolved
235
+ `include::` becomes a link, exactly as in stock Asciidoctor.
236
+ - **Syntax highlighting is left to you.** Source blocks emit
237
+ `<pre class="highlight"><code class="language-…">`; run a client-side highlighter (e.g.
238
+ highlight.js, Shiki) over that. Server-side `source-highlighter` backends
239
+ (Rouge/CodeRay/Pygments) are not reproduced.
240
+
241
+ ## Known differences
242
+
243
+ The ~107 corpus mismatches fall into the buckets below. Each shows a minimal source, what
244
+ **Asciidoctor** emits, and what this renderer emits.
245
+
246
+ ### More likely to surface
247
+
248
+ **1. Escaped `\#` in inline monospace** — authors escape `#` (the highlight delimiter) so a
249
+ literal `#` survives in code, e.g. when documenting Rust attributes.
250
+
251
+ ```
252
+ `\#[endpoint]` is the macro.
253
+ ```
254
+
255
+ Asciidoctor strips the backslash in some block contexts and keeps it in others, so the
256
+ expected output depends on context. We always keep it literal:
257
+
258
+ ```html
259
+ <!-- asciidoctor (in a paragraph after other text): -->
260
+ <code>#[endpoint]</code>
261
+ <!-- this renderer: -->
262
+ <code>\#[endpoint]</code>
263
+ ```
264
+
265
+ Where you can, writing the literal `#` unescaped avoids the difference.
266
+
267
+ **2. Highlight (`#…#`) spanning multiple inline code spans.** Two `#` characters across
268
+ separate code spans on one line are treated as a `<mark>` pair:
269
+
270
+ ```
271
+ use `#[a]` ... `#[b]` here
272
+ ```
273
+
274
+ ```html
275
+ <!-- asciidoctor (the mark tags interleave across the code spans): -->
276
+ use <code><mark>[a]</code>…<code></mark>[b]</code>here
277
+ <!-- this renderer (no mark applied): -->
278
+ use <code>#[a]</code>…<code>#[b]</code>here
279
+ ```
280
+
281
+ Comes up when a line of prose has two pieces of inline code that each contain a `#` (again,
282
+ Rust attributes are a common case). The two outputs differ.
283
+
284
+ **3. Sentinel / placeholder leak (bug).** In rare combinations — e.g. a bare URL whose
285
+ fragment looks like a callout — an internal placeholder can leak into the output:
286
+
287
+ ```html
288
+ <!-- asciidoctor: -->
289
+ …slack#verifying-requests…
290
+ <!-- this renderer: -->
291
+ …slack&lt;0&gt;verifying-requests…
292
+ ```
293
+
294
+ Rare (seen once in ~700 documents), but it produces visibly wrong output when hit. This is a
295
+ bug on our side, not a feature gap.
296
+
297
+ **4. Footnote edge cases.** Footnotes defined in section titles are numbered out of
298
+ sequence, and a footnote defined inside an AsciiDoc table cell can be mis-shared with the
299
+ main document. Can surface in long documents that footnote heavily.
300
+
301
+ ### Rarely an issue
302
+
303
+ | Difference | Example | Asciidoctor | This renderer | Notes |
304
+ | ------------------------------------------------------------------------------------------------------------------- | ---------------------------------- | ----------------------------- | --------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
305
+ | Inline formatting straddling a span boundary | `` `a __b` c__ `` | `<code>a <em>b</code>c</em>` | `<code>a __b</code>c__` | Rare in practice. |
306
+ | `manpage` doctype | `:doctype: manpage` + NAME section | special NAME header block | rendered as ordinary sections | Only relevant if you render man pages (~12 failures). |
307
+ | `source-highlighter` with a `>` in the language token | `[source,console?prompt=$>]` | `data-lang="…$>"` (raw) | `data-lang="…$&gt;"` | Needs an unusual language token. |
308
+ | Server-side highlighter markup (Rouge/CodeRay) | `:source-highlighter: rouge` | per-token `<span>` markup | plain `<code class="language-…">` | Highlight on the client instead. |
309
+ | `xref` nested inside another link's text | `link:…[… <<id>> …]` | resolved `<a>` | literal `&lt;&lt;id&gt;&gt;` | Uncommon nesting. |
310
+ | `{set:cellbgcolor}`, nested-document table cells, compat-mode toggling, `pass:` inside a body-level attribute value | — | — | — | Rare/legacy features. |
311
+ | Audio/video `controls`/`autoplay`/`loop` attribute casing | `audio::…[] controls` | `controls` (bare) | `controls=""` via React | Default templates use raw HTML to match Asciidoctor; override `options.overrides.audio` / `video` to use React elements instead. |
312
+ | `inlineOverrides` silently bypassed for blocks with straddling passthrough HTML | `pass:q[<u>x *y*</u>]` | `<u>x <strong>y</strong></u>` | same (string path, no override) | Any `<`/`>` in the inline AST triggers the HTML-string fallback, bypassing registered `inlineOverrides` for that block. |
313
+
314
+ For ordinary technical prose — code blocks, tables, admonitions, lists, links, images,
315
+ xrefs, and footnotes — output should match Asciidoctor. The things most worth watching are
316
+ the two `#`-related cases above (inline code containing `#`) and the placeholder-leak bug.
317
+
318
+ ## Development
319
+
320
+ ### Previewing
321
+
322
+ ```
323
+ npm install
324
+ npm run dev
325
+ ```
326
+
327
+ Visit `http://localhost:8000/?example=<EXAMPLE>&renderer=<react|html>`, where `<EXAMPLE>` is
328
+ a file in `src/examples/` (case-sensitive, no extension). `renderer=react` uses this
329
+ renderer; `renderer=html` uses stock Asciidoctor, for side-by-side comparison.
330
+
331
+ ### Running tests
332
+
333
+ The suite is the diff-against-Asciidoctor check described in [Status](#status):
334
+
335
+ ```
336
+ npm test # everything
337
+ npx vitest run tests/renderer.test.tsx # just the hand-written examples
338
+ npx vitest run tests/corpus.*.test.tsx # just the corpus (sharded across the worker pool)
339
+ npx vitest run -t "rfds/0042" # a single document, whichever shard it's in
340
+ ```
341
+
342
+ The corpus is split into eight shard files so vitest can parallelise across cores (it
343
+ parallelises across files, not within one).
344
+
345
+ For triaging failures without the test framework:
346
+
347
+ ```
348
+ npx vite-node tests/triage.ts 2>/dev/null # pass/fail + first divergence per doc
349
+ npx vite-node tests/triage.ts -- --group --summary # counts grouped by category
350
+ npx vite-node tests/triage.ts -- --filter "tables_test" # restrict to matching docs
351
+ ```
352
+
353
+ ### Fetching the corpus
354
+
355
+ The corpus lives under `tests/corpus/` and is **git-ignored** — you fetch it locally. It has
356
+ two parts:
357
+
358
+ - `tests/corpus/rfds/` — Oxide RFDs, pulled with the
359
+ [`rfd-cli`](https://github.com/oxidecomputer/rfd-api). **~80 RFDs are currently public**;
360
+ internal access yields more.
361
+ - `tests/corpus/asciidoctor-extracted/` — fixtures lifted from Asciidoctor's own test suite
362
+ (optional; used to exercise feature edge cases).
363
+
364
+ To pull the RFDs, install `rfd-cli` from the
365
+ [rfd-api repo](https://github.com/oxidecomputer/rfd-api), point it at the public host, and
366
+ run the fetch script:
367
+
368
+ ```
369
+ rfd-cli config set host https://rfd-api.shared.oxide.computer
370
+
371
+ # RFD_CLI defaults to ~/Development/rfd-cli; override if yours lives elsewhere.
372
+ RFD_CLI=$(command -v rfd-cli) ./tests/fetch-corpus.sh
373
+ ```
374
+
375
+ The script lists every AsciiDoc-format RFD (`rfd-cli list`), writes each to
376
+ `tests/corpus/rfds/NNNN.adoc` (`rfd-cli view --number N`), and skips empty bodies. Re-run
377
+ any time to refresh. With no corpus present, the corpus shard tests simply find no
378
+ documents; the example suite (`renderer.test.tsx`) always runs.
379
+
380
+ ## Conventions
381
+
382
+ - Prettier: 92-column width, no semicolons, single quotes, trailing commas.
383
+ - ESLint ignores `.js` files (the examples are intentionally plain JS).
384
+ - Releases are triggered manually via the GitHub Actions `workflow_dispatch` event with an
385
+ explicit version number. Release notes are generated automatically in GitHub Releases.