@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.
@@ -0,0 +1,61 @@
1
+ 'use strict'
2
+
3
+ // `[cols="1,3,2",nowrap-cols="1,2"]` — an author-facing way to pin
4
+ // `white-space: nowrap` to specific columns (e.g. a mono-token column that
5
+ // should never break mid-value), without a new inline macro: `m|`/`mono:`
6
+ // already exist for "this cell is code" and neither carries a per-COLUMN
7
+ // concept, and the html5 converter hardcodes cell class to
8
+ // `tableblock halign-* valign-*` (see doc.css's own `td.tableblock`
9
+ // comment) — no room there either. A tree processor turning the attribute
10
+ // into roles is the only hook that reaches the table node itself before it
11
+ // converts to `<table class="tableblock …">`.
12
+ //
13
+ // `nowrap-cols="*"` (or any non-numeric token) is rejected per-token with a
14
+ // warning rather than silently doing nothing — a typo'd column index should
15
+ // fail loudly. Valid range is 1-12: doc.css only ever ships nth-child rules
16
+ // up to that ceiling (comfortably past any real `cols=` in this codebase),
17
+ // and a document needing more is almost certainly better served by
18
+ // `[cols=]`'s own per-column `.nowrap` role below instead (every column).
19
+ const MAX_COLS = 12
20
+ const NOWRAP_ALL_ATTR = 'nowrap' // `[cols=...,%nowrap]` shorthand — every column
21
+
22
+ function parseColumnIndexes(raw, doc, table) {
23
+ const logger = doc.getLogger()
24
+ const indexes = []
25
+ raw
26
+ .split(',')
27
+ .map((token) => token.trim())
28
+ .filter(Boolean)
29
+ .forEach((token) => {
30
+ const n = Number(token)
31
+ if (!Number.isInteger(n) || n < 1 || n > MAX_COLS) {
32
+ logger.warn(
33
+ 'nowrap-cols="' +
34
+ raw +
35
+ '" on table "' +
36
+ (table.getTitle() || '(untitled)') +
37
+ '" — ignoring invalid column "' +
38
+ token +
39
+ '"; expected an integer between 1 and ' +
40
+ MAX_COLS
41
+ )
42
+ return
43
+ }
44
+ indexes.push(n)
45
+ })
46
+ return indexes
47
+ }
48
+
49
+ module.exports = function registerNowrapCols(registry) {
50
+ registry.treeProcessor(function () {
51
+ this.process(function (doc) {
52
+ doc.findBy({ context: 'table' }).forEach((table) => {
53
+ if (table.hasAttribute(NOWRAP_ALL_ATTR)) table.addRole('nowrap')
54
+ const raw = table.getAttribute('nowrap-cols')
55
+ if (!raw) return
56
+ parseColumnIndexes(String(raw), doc, table).forEach((n) => table.addRole('nowrap-' + n))
57
+ })
58
+ return doc
59
+ })
60
+ })
61
+ }
@@ -0,0 +1,94 @@
1
+ 'use strict'
2
+
3
+ // Shared config between the two halves of the Shiki (GH-89) syntax
4
+ // highlighter: `shiki-prewarm.js` (an @inditextech/docouture-antora-extensions
5
+ // pipeline extension, builds the one shared highlighter instance
6
+ // asynchronously before any page converts) and `shiki-syntax-highlighter.js`
7
+ // (the Asciidoctor SyntaxHighlighter adapter that reads it back
8
+ // synchronously, once per source block). Both need the exact same language
9
+ // and theme set — if the prewarm loads a language the highlighter never
10
+ // asks for, or vice versa, `getLoadedLanguages()` and the actual grammar set
11
+ // silently disagree.
12
+
13
+ module.exports = {
14
+ // Shiki's "css variables" dual-theme mode (`defaultColor: false` — see
15
+ // shiki-syntax-highlighter.js) emits `--shiki-light`/`--shiki-dark` custom
16
+ // properties instead of literal colours; ui-bundle's doc.css switches
17
+ // between them on `.ids-theme-dark`, the same toggle the rest of the site
18
+ // themes off. Named themes, not hljs's theme FILES (GH-12) — @shikijs/themes
19
+ // ships them as data, no CSS import/PostCSS scoping step needed at all.
20
+ //
21
+ // Both slots are the SAME theme — deliberately, not an oversight. The
22
+ // code surface itself (doc.css) is black in EITHER site theme by design
23
+ // (GH-12 again: the design keeps a dark code surface regardless of
24
+ // light/dark site mode), so there is no separate "light" palette to
25
+ // switch to in the first place — a genuinely light-background theme
26
+ // (tried: 'github-light') renders near-illegible dark-grey text on that
27
+ // black surface (verified empirically). 'github-dark'/'github-dark-dimmed'
28
+ // (matching hljs's own old GH-12 pairing) and 'monokai' were tried next
29
+ // and both worked; settled on 'dark-plus' — Shiki's bundled name for VS
30
+ // Code's own default "Dark+" theme (GH-89 follow-up request) — for the
31
+ // familiarity of VS Code's own colour choices. One name in both slots
32
+ // means the code blocks look identical regardless of site theme —
33
+ // intentional, not a placeholder half-migration.
34
+ LIGHT_THEME: 'dark-plus',
35
+ DARK_THEME: 'dark-plus',
36
+
37
+ // @shikijs/langs module ids to bundle into the one synchronous highlighter
38
+ // instance — see shiki-prewarm.js's own header for why this must be a
39
+ // fixed, synchronously-loadable list rather than Shiki's normal on-demand
40
+ // dynamic import. Mirrors the language set
41
+ // ui-bundle/src/js/vendor/highlight.bundle.ts used to register under
42
+ // highlight.js, plus 'html', 'typescript' and 'tsx' — actually used by
43
+ // authored content (`[source,html]`, `[source,ts]`, `[source,tsx]`) but
44
+ // never registered under the old hljs bundle, so those three blocks were
45
+ // never really syntax-highlighted before this.
46
+ LANGS: [
47
+ 'asciidoc',
48
+ 'bash',
49
+ 'clojure',
50
+ 'cpp',
51
+ 'csharp',
52
+ 'css',
53
+ 'diff',
54
+ 'dockerfile',
55
+ 'elixir',
56
+ 'go',
57
+ 'groovy',
58
+ 'haskell',
59
+ 'html',
60
+ 'java',
61
+ 'javascript',
62
+ 'json',
63
+ 'julia',
64
+ 'kotlin',
65
+ 'lua',
66
+ 'markdown',
67
+ 'nix',
68
+ 'objective-c',
69
+ 'perl',
70
+ 'php',
71
+ 'properties',
72
+ 'puppet',
73
+ 'python',
74
+ 'ruby',
75
+ 'rust',
76
+ 'scala',
77
+ 'shell',
78
+ 'sql',
79
+ 'swift',
80
+ 'tsx',
81
+ 'typescript',
82
+ 'xml',
83
+ 'yaml',
84
+ ],
85
+
86
+ // hljs's own name for a grammar Shiki registers under a different id.
87
+ // highlight.js's objectivec.js self-registers as 'objectivec' with alias
88
+ // 'objc'; Shiki's equivalent grammar is 'objective-c' (aliased to 'objc',
89
+ // not 'objectivec') — so `[source,objectivec]`, if ever authored, needs an
90
+ // explicit redirect. 'ts'/'tsx'/'js' need no entry here: Shiki auto-registers
91
+ // a loaded grammar's own `aliases` (typescript's include 'ts', 'cts', 'mts'),
92
+ // so `getLoadedLanguages()` already reports them once 'typescript' loads.
93
+ LANG_ALIASES: { objectivec: 'objective-c' },
94
+ }
@@ -0,0 +1,64 @@
1
+ 'use strict'
2
+
3
+ // Shared handle to the one Shiki highlighter instance for an entire Antora
4
+ // build.
5
+ //
6
+ // `shiki-prewarm.js` — an @inditextech/docouture-antora-extensions PIPELINE
7
+ // extension, hooking Antora's own generator lifecycle — creates it exactly
8
+ // once, asynchronously, before any page is converted. This file's
9
+ // `highlight()` counterpart in shiki-syntax-highlighter.js is an ASCIIDOCTOR
10
+ // extension instead: called synchronously, per source block, by
11
+ // @asciidoctor/core 2.2's fully-synchronous Opal conversion loop, which
12
+ // cannot itself await anything (see asciidoc-extensions/lib/async-compat.js
13
+ // for the first instance of that exact constraint). Splitting the async
14
+ // setup from the sync use is GH-89's whole answer to the issue's own "Key
15
+ // Risk / Spike Required" section — this module is the seam between the two
16
+ // halves, since they live in different packages and are wired into the
17
+ // build by different playbook keys (`antora.extensions` vs
18
+ // `asciidoc.extensions`).
19
+ //
20
+ // A plain module-level variable is enough: @antora/site-generator runs one
21
+ // build per process (see `just build-site`), so there is exactly one
22
+ // instance to hold, and both packages `require()` the exact same file (node's
23
+ // module cache, not a singleton class) — no DI container needed.
24
+ let state = null
25
+
26
+ module.exports = {
27
+ /**
28
+ * Called once by shiki-prewarm.js, after its async Shiki setup resolves.
29
+ *
30
+ * @param {object} highlighter - the synchronous-capable Shiki core
31
+ * instance (`createHighlighterCoreSync`, built from an
32
+ * already-instantiated engine — see that file). Typed loosely as
33
+ * `object`, not `import('shiki/core').HighlighterCore`: shiki ships ESM-
34
+ * only types, and importing an ESM type from this CommonJS package needs
35
+ * a `resolution-mode` assertion this tsconfig doesn't carry — not worth
36
+ * adding for one call site when `noImplicitAny`/`noImplicitThis` are
37
+ * already off package-wide (see tsconfig.json's own header).
38
+ * @param {string} rootStyle - the `style` attribute value Shiki itself puts
39
+ * on the `<pre>` it generates for a throwaway probe block — the
40
+ * `--shiki-light`/`--shiki-dark`/`-*-bg` custom property declarations
41
+ * that carry each theme's default foreground/background. Needed because
42
+ * `highlight()` returns only the INNER content of the `<code>`
43
+ * Asciidoctor wraps it in (see that file's own header for why) — those
44
+ * declarations, which normally live on the `<pre>` element Shiki
45
+ * generates itself, have nowhere to go, so `highlight()` re-declares them
46
+ * verbatim on its own outermost wrapper span instead of trying to parse
47
+ * them back out of markup it already discarded.
48
+ * @returns {void}
49
+ */
50
+ set(highlighter, rootStyle) {
51
+ state = { highlighter, rootStyle }
52
+ },
53
+
54
+ /**
55
+ * @returns {{ highlighter: object, rootStyle: string } | null} `null`
56
+ * before the prewarm listener has run — e.g. the ui-bundle preview
57
+ * harness, which never registers `@inditextech/docouture-antora-extensions`
58
+ * at all (see extensions.md: it has no content catalog and no Antora
59
+ * pipeline). `highlight()` degrades to plain escaped text in that case.
60
+ */
61
+ get() {
62
+ return state
63
+ },
64
+ }
@@ -0,0 +1,137 @@
1
+ 'use strict'
2
+
3
+ const { escapeHtml } = require('./html')
4
+ const shikiInstance = require('./shiki-instance')
5
+ const { LIGHT_THEME, DARK_THEME, LANG_ALIASES } = require('./shiki-config')
6
+
7
+ // Registers a custom Asciidoctor SyntaxHighlighter adapter, 'shiki' (GH-89),
8
+ // against `@asciidoctor/core ~2.2` — the version actually used to convert
9
+ // pages, via `antora@3.1.15` → `@antora/asciidoc-loader` (see
10
+ // .opencode/skills/asciidoc/reference/extensions.md's version table).
11
+ //
12
+ // THIS IS NOT AN `asciidoc.extensions` REGISTRY EXTENSION. Every other
13
+ // module in this package (label-macro.js, card-grid.js, …) is registered
14
+ // through the per-page `registry` object Antora hands `index.js`'s
15
+ // `register(registry)` — see that file's own header. A syntax highlighter is
16
+ // different: Asciidoctor.js exposes it as a GLOBAL, STATIC registry —
17
+ // `Asciidoctor.SyntaxHighlighter.register(name, functions)` — that lives on
18
+ // the `@asciidoctor/core` module object itself, not on any one page's
19
+ // registry instance. Requiring THIS file (done once, at the top of
20
+ // index.js, not inside `registerAll`) performs that global registration
21
+ // exactly once per process, thanks to node's module cache — which is
22
+ // correct here because pnpm dedupes `@asciidoctor/core` to the identical
23
+ // installed `~2.2` package Antora's own require of it resolves to, so both
24
+ // requires return the very same module singleton.
25
+ //
26
+ // `require('@asciidoctor/core')` is a FACTORY in 2.2 (call it to get the
27
+ // real API object) — see extensions.md's "2.2 — factory export" example.
28
+ //
29
+ // Required under an ALIAS ('asciidoctor-core-2.2', see package.json's
30
+ // `dependencies`), not the bare package name: this package's
31
+ // `devDependencies` already pins `@asciidoctor/core` to `~4.0.8` — a
32
+ // TYPE-ONLY dependency the rest of this package's extensions (accordion.js,
33
+ // cta.js, …) annotate their JSDoc against (see tsconfig.json's own header).
34
+ // pnpm can only link ONE physical `node_modules/@asciidoctor/core` per
35
+ // package, so declaring the real `~2.2` runtime dependency under that same
36
+ // bare name would silently replace those files' 4.0 types with 2.2's —
37
+ // verified empirically: it broke typecheck on every `AbstractBlock`
38
+ // reference elsewhere in this package. `npm:` aliasing keeps both physically
39
+ // installed side by side under different names.
40
+ //
41
+ // `2.2`'s own `.d.ts` types the module's export as a namespace object with no
42
+ // call signature, even though the actual JS bridge IS a factory function
43
+ // (verified empirically — the shape 2.2's own README documents, and the same
44
+ // shape `extensions.md`'s version table describes). `/** @type {any} */`
45
+ // bridges that one gap between the shipped types and the real runtime API,
46
+ // deliberately, rather than fighting it with an incorrect type assertion.
47
+ const asciidoctor = /** @type {any} */ (require('asciidoctor-core-2.2'))()
48
+
49
+ // Shiki always wraps its own output in `<pre class="shiki ..." style="...">
50
+ // <code>...</code></pre>` — there is no lower-level call that returns just
51
+ // the inner tokens. `highlight()` is only allowed to return the STRING that
52
+ // becomes the block's `content` (Asciidoctor's own `format()` — inherited
53
+ // unmodified from `SyntaxHighlighter::Base` since this adapter doesn't
54
+ // override it — wraps that in ITS OWN `<pre class="shiki highlight">
55
+ // <code data-lang="…">…</code></pre>`, which is what ui-bundle's
56
+ // 06-copy-to-clipboard.ts and doc.css actually key off: `pre.highlight` and
57
+ // `code[data-lang]`). So the outer tags Shiki generated have to be stripped
58
+ // back off; this regex depends on the exact shape Shiki 4.x's HTML renderer
59
+ // produces (verified empirically — see this package's README for how to
60
+ // re-check it against a future Shiki upgrade).
61
+ const SHIKI_WRAPPER_RX = /^<pre[^>]*><code[^>]*>([\s\S]*)<\/code>\s*<\/pre>\s*$/
62
+
63
+ /**
64
+ * Asciidoctor passes `lang: 'none'` for an unannotated `[source]` block —
65
+ * the same case highlight.bundle.ts used to alias to its own 'plaintext'
66
+ * grammar. Shiki needs no grammar loaded at all for this: 'text' is a
67
+ * reserved id its core always understands, same as 'plain'/'txt' upstream.
68
+ */
69
+ function resolveLang(lang) {
70
+ if (!lang || lang === 'none') return 'text'
71
+ return LANG_ALIASES[lang] || lang
72
+ }
73
+
74
+ /**
75
+ * @param {string} source - raw source text, already run through Asciidoctor's
76
+ * own callout extraction (this is the value `highlight()` receives, NOT
77
+ * `node.getSource()` — using the passed-in `source` rather than reading the
78
+ * node directly is what keeps `<1>` callout bubbles in source blocks
79
+ * working: Asciidoctor re-inserts their markup into this function's return
80
+ * value by string position, matched against `opts.callouts`).
81
+ */
82
+ function highlightSource(source, lang) {
83
+ const instance = shikiInstance.get()
84
+ if (!instance) {
85
+ // The pre-warm listener (@inditextech/docouture-antora-extensions'
86
+ // shiki-prewarm.js) never ran — the ui-bundle preview harness, which has
87
+ // no Antora pipeline at all (see extensions.md), or a future caller that
88
+ // forgets to list the extension. Degrade to plain escaped text rather
89
+ // than throwing: a build with `runtime.log.failure_level: warn` should
90
+ // fail loudly on a real authoring mistake, not on missing highlighting.
91
+ return escapeHtml(source)
92
+ }
93
+ const { highlighter, rootStyle } = instance
94
+ const resolved = resolveLang(lang)
95
+ const effective = highlighter.getLoadedLanguages().includes(resolved) ? resolved : 'text'
96
+ const html = highlighter.codeToHtml(source, {
97
+ lang: effective,
98
+ themes: { light: LIGHT_THEME, dark: DARK_THEME },
99
+ // "CSS variables" dual-theme mode: emits `--shiki-light`/`--shiki-dark`
100
+ // custom properties per token instead of a literal colour, so ONE build
101
+ // output serves both the site's light and dark theme — see
102
+ // ui-bundle/src/css/doc.css's own `--shiki-*` rules for the switch.
103
+ defaultColor: false,
104
+ })
105
+ const match = SHIKI_WRAPPER_RX.exec(html.trim())
106
+ const inner = match ? match[1] : escapeHtml(source)
107
+ // Re-declares the default-colour variables Shiki put on its own (now
108
+ // discarded) `<pre>` — see shiki-instance.js's own header for why this
109
+ // can't just read them back off `html` on every call instead.
110
+ return `<span style="${rootStyle}">${inner}</span>`
111
+ }
112
+
113
+ asciidoctor.SyntaxHighlighter.register('shiki', {
114
+ handlesHighlighting() {
115
+ return true
116
+ },
117
+ highlight(node, source, lang) {
118
+ return highlightSource(source, lang)
119
+ },
120
+ // Overridden rather than left to `SyntaxHighlighter::Base`'s own default:
121
+ // that default reads `self.pre_class`, an instance variable ONLY a real
122
+ // Ruby subclass's `initialize` sets (to its own registered `name`) —
123
+ // registering a plain JS functions object the way this file does (per
124
+ // `.register`'s own contract, see the header above) never runs that
125
+ // Ruby-side `initialize`, so `pre_class` comes back JS `undefined` and
126
+ // Base's format would literally emit `class="undefined highlight"`
127
+ // (verified empirically). Reimplementing `format` here sidesteps that
128
+ // entirely, for one extra benefit: `node.getContent()` — Asciidoctor's own
129
+ // already-substituted, callout-restored content, i.e. exactly `highlight()`'s
130
+ // return value above — is read directly, rather than trusting an
131
+ // ivar-driven default this adapter doesn't fully participate in.
132
+ format(node, lang, opts) {
133
+ const preClass = opts && opts.nowrap ? 'shiki highlight nowrap' : 'shiki highlight'
134
+ const langAttr = lang ? ' data-lang="' + escapeHtml(lang) + '"' : ''
135
+ return `<pre class="${preClass}"><code${langAttr}>${node.getContent()}</code></pre>`
136
+ },
137
+ })
@@ -0,0 +1,143 @@
1
+ 'use strict'
2
+
3
+ // GH-14 table-sizing follow-up: `display: block` on `table.tableblock` (the
4
+ // old doc.css mechanism) never actually sized the table — it sizes the
5
+ // BLOCK BOX, and a native table lays out its own anonymous table box inside
6
+ // that with `width: auto` (shrink-to-fit), completely ignoring the parent's
7
+ // width. There is no CSS-only fix: the anonymous box isn't selectable, so a
8
+ // real wrapper element is required. This postprocessor is that wrapper.
9
+ //
10
+ // A postprocessor, not a converter override: Antora hard-sets its own HTML5
11
+ // converter (@antora/asciidoc-loader/lib/load-asciidoc.js), so a custom
12
+ // converter would only ever run in the ui-bundle preview harness, not real
13
+ // site builds. A registry-scoped postprocessor runs identically in both
14
+ // (verified against @asciidoctor/core 4.0.8, embedded/standalone: false).
15
+ //
16
+ // Markup emitted, for every `<table class="tableblock …">…</table>`:
17
+ //
18
+ // <div class="tableblock-wrap">
19
+ // <div class="title">…</div> <!-- hoisted, if present -->
20
+ // <div class="tablecontainer"><table class="tableblock …">…</table></div>
21
+ // </div>
22
+ //
23
+ // Two wrapping divs, not one: `.tableblock-wrap` carries the width budget
24
+ // (and the title), `.tablecontainer` is the horizontal-scroll port — kept
25
+ // separate so the title does not scroll away with the table body.
26
+ //
27
+ // The `<caption class="title">` Asciidoctor emits for a `.Table title` is
28
+ // removed and its content re-emitted as a plain `.title` div OUTSIDE the
29
+ // table, at the wrapper's own (budget) width, rather than the table's own
30
+ // (possibly much narrower — `[width=]`/`%autowidth`) width: a caption is
31
+ // laid out as part of the table box it belongs to, so leaving it in place
32
+ // would have it wrap at the table's width instead of the wrapper's.
33
+ //
34
+ // Table/caption detection is a plain regex scan rather than parsing the
35
+ // converted HTML into a DOM: the postprocessor only ever sees Asciidoctor's
36
+ // own machine-generated output, whose `<table class="tableblock …">` /
37
+ // `</table>` shape is fixed, so a depth counter is sufficient and avoids an
38
+ // HTML-parser dependency for a single, narrow substitution.
39
+ const TABLE_OPEN_RX = /<table class="tableblock\b[^"]*"[^>]*>/g
40
+ const TABLE_TAG_RX = /<(\/?)table\b/g
41
+ const CAPTION_RX = /^\n?<caption class="title">([\s\S]*?)<\/caption>\n?/
42
+ // Idempotency guard: if this table is already the direct child of a
43
+ // `.tablecontainer` this same function placed it in, re-running (verified
44
+ // to happen in practice — `gulp preview`'s watch server re-registers this
45
+ // extension globally on every rebuild without restarting the node process,
46
+ // see index.js's own comment) must leave it alone rather than nesting a
47
+ // second `.tableblock-wrap` around the first, each one re-applying (and
48
+ // compounding) the same width cap.
49
+ const ALREADY_WRAPPED_RX = /<div class="tablecontainer">$/
50
+ // `table-width.js`'s own stash key (doc.findBy-order array of per-table
51
+ // literal CSS widths, `null` where the author didn't set one) — read here
52
+ // by the SAME index, since both this scan and that tree processor's
53
+ // `doc.findBy({context:'table'})` walk the document in the same order
54
+ // (verified empirically).
55
+ const { STASH_KEY: WIDTHS_STASH_KEY } = require('./table-width')
56
+
57
+ function wrapTables(html, widths = []) {
58
+ let result = ''
59
+ let cursor = 0
60
+ let match
61
+ let tableIndex = -1
62
+ while ((match = TABLE_OPEN_RX.exec(html))) {
63
+ tableIndex += 1
64
+ if (ALREADY_WRAPPED_RX.test(html.slice(0, match.index))) continue
65
+
66
+ const openTagEnd = match.index + match[0].length
67
+ // Depth-count `<table` / `</table>` tags from just past the opening tag
68
+ // to find THIS table's own matching close tag, tolerating any table
69
+ // nested inside a cell (a legitimate, if rare, authoring case).
70
+ TABLE_TAG_RX.lastIndex = openTagEnd
71
+ let depth = 1
72
+ let tagMatch
73
+ let closeEnd = -1
74
+ while ((tagMatch = TABLE_TAG_RX.exec(html))) {
75
+ depth += tagMatch[1] ? -1 : 1
76
+ if (depth === 0) {
77
+ closeEnd = html.indexOf('>', TABLE_TAG_RX.lastIndex) + 1
78
+ break
79
+ }
80
+ }
81
+ if (closeEnd === -1 || closeEnd === 0) break // malformed input; leave the rest untouched
82
+
83
+ result += html.slice(cursor, match.index)
84
+ let inner = html.slice(match.index, closeEnd)
85
+
86
+ // `table-width.js`'s literal CSS width (e.g. `2000px`) — set as an
87
+ // inline `style`, which wins over `.stretch`/`.fit-content` (external
88
+ // stylesheet rules) unconditionally, regardless of specificity, no
89
+ // `!important` needed. Spliced into the OPEN TAG specifically (not the
90
+ // whole `inner`, which also contains the table body) so a `style="…"`
91
+ // the author's own raw HTML passthrough might already carry is
92
+ // extended rather than clobbered.
93
+ //
94
+ // `bodyStart` (where the caption search below begins) is recomputed
95
+ // from the SPLICED open tag's own new length, not the original
96
+ // `openTagEnd - match.index` — that offset is in the pre-splice
97
+ // string's coordinates, and injecting a `style="…"` attribute makes
98
+ // the tag longer, so re-using it pointed a few characters short of the
99
+ // real body and silently broke caption hoisting (verified: the caption
100
+ // regex, anchored at the very start of what it thinks is the body,
101
+ // stopped matching entirely once a table also had a `table-width`).
102
+ const width = widths[tableIndex]
103
+ let bodyStart = openTagEnd - match.index
104
+ if (width) {
105
+ const openTag = inner.slice(0, bodyStart)
106
+ const styledOpenTag = /\sstyle="/.test(openTag)
107
+ ? openTag.replace(/\sstyle="/, ' style="width:' + width + ';')
108
+ : openTag.replace(/>$/, ' style="width:' + width + '">')
109
+ inner = styledOpenTag + inner.slice(bodyStart)
110
+ bodyStart = styledOpenTag.length
111
+ }
112
+
113
+ let title = null
114
+ const afterOpenTag = inner.slice(bodyStart)
115
+ const captionMatch = afterOpenTag.match(CAPTION_RX)
116
+ if (captionMatch) {
117
+ title = captionMatch[1]
118
+ inner = inner.slice(0, bodyStart) + afterOpenTag.slice(captionMatch[0].length)
119
+ }
120
+
121
+ result +=
122
+ '<div class="tableblock-wrap">' +
123
+ (title !== null ? '<div class="title">' + title + '</div>' : '') +
124
+ '<div class="tablecontainer">' +
125
+ inner +
126
+ '</div></div>'
127
+
128
+ cursor = closeEnd
129
+ TABLE_OPEN_RX.lastIndex = cursor
130
+ }
131
+ result += html.slice(cursor)
132
+ return result
133
+ }
134
+
135
+ module.exports = function registerTableContainer(registry) {
136
+ registry.postprocessor(function () {
137
+ this.process(function (doc, output) {
138
+ return wrapTables(output, doc[WIDTHS_STASH_KEY])
139
+ })
140
+ })
141
+ }
142
+
143
+ module.exports.wrapTables = wrapTables
@@ -0,0 +1,70 @@
1
+ 'use strict'
2
+
3
+ // `[table-width=2000px,cols="1,1"]` — an explicit, literal CSS width for a
4
+ // table, bypassing Asciidoctor's own `width=` attribute entirely: that
5
+ // attribute is percentage-only and HARD-CLAMPED to 1-100 (verified against
6
+ // `@asciidoctor/core`'s own `table.js` — `width=2000px` parses to the
7
+ // numeric prefix 2000, sees it's `> 100`, and silently resets it to 100,
8
+ // i.e. exactly the unmarked default, `.stretch`). There is no core AsciiDoc
9
+ // syntax for an absolute width on a table; this is a separate, custom
10
+ // attribute for it.
11
+ //
12
+ // A tree processor is the only hook that can read the RAW attribute value
13
+ // before Asciidoctor's own Table constructor has already computed (and
14
+ // clamped) `tablepcwidth` from it — but the built-in html5 converter only
15
+ // ever emits `id`/`class`/the derived `width="N%"` for a `<table>` tag (see
16
+ // its own table-tag-building code), no generic passthrough for an arbitrary
17
+ // attribute to an inline `style`. So this doesn't touch Asciidoctor's own
18
+ // `width` attribute at all (an author can still combine `width=50%` — a
19
+ // PERCENTAGE OF THE inline style set here — with `table-width`, though
20
+ // that's an unusual thing to want) and instead hands the value to
21
+ // table-container.js's postprocessor, the other half of this mechanism,
22
+ // via a plain array stashed on the `doc` object itself: `doc.findBy` (here)
23
+ // and the postprocessor's own table-order HTML scan (table-container.js)
24
+ // walk the document in the same order (verified empirically), so entry `i`
25
+ // in the array is table `i`'s width, `null` where the attribute is absent.
26
+ //
27
+ // Verified in the CSS as already correct for a literal width with no
28
+ // further changes: `table.tableblock` carries no width rule of its own
29
+ // (only `.stretch`/`.fit-content`, both irrelevant once an inline `style`
30
+ // wins over them unconditionally), and `.tablecontainer`'s `overflow-x:
31
+ // auto` scrolls a table wider than `.tableblock-wrap`'s own 150%-capped
32
+ // budget rather than blowing out the page — exactly the "respect the
33
+ // explicit width, but the SURROUNDING box stays capped" behaviour the
34
+ // percentage form already has.
35
+ const ATTR_NAME = 'table-width'
36
+ const VALID_RX = /^\d+(?:\.\d+)?(?:px|rem|em|ch|vw|vh|%)?$/
37
+ const STASH_KEY = '$docoutureTableWidths'
38
+
39
+ function parseWidth(raw, doc, table) {
40
+ const trimmed = String(raw).trim()
41
+ if (!VALID_RX.test(trimmed)) {
42
+ doc
43
+ .getLogger()
44
+ .warn(
45
+ ATTR_NAME +
46
+ '="' +
47
+ raw +
48
+ '" on table "' +
49
+ (table.getTitle() || '(untitled)') +
50
+ '" — ignoring invalid length; expected a plain number (px assumed) or a number with one of px/rem/em/ch/vw/vh/%'
51
+ )
52
+ return null
53
+ }
54
+ return /\d$/.test(trimmed) ? trimmed + 'px' : trimmed
55
+ }
56
+
57
+ module.exports = function registerTableWidth(registry) {
58
+ registry.treeProcessor(function () {
59
+ this.process(function (doc) {
60
+ const widths = (doc[STASH_KEY] = [])
61
+ doc.findBy({ context: 'table' }).forEach((table) => {
62
+ const raw = table.getAttribute(ATTR_NAME)
63
+ widths.push(raw ? parseWidth(raw, doc, table) : null)
64
+ })
65
+ return doc
66
+ })
67
+ })
68
+ }
69
+
70
+ module.exports.STASH_KEY = STASH_KEY