@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,90 @@
1
+ 'use strict'
2
+
3
+ // Bridges the two Asciidoctor.js majors this repo runs — see
4
+ // first-positional.js's own comment for the first instance of this split.
5
+ // 2.2 (Opal, real Antora site builds) is fully synchronous throughout its
6
+ // JS bridge. 4.0 (native JS, the ui-bundle preview harness) makes
7
+ // `parseContent`/`convert`/`precomputeText` all return Promises instead.
8
+ // Neither extension using this can be written `async` unconditionally: that
9
+ // would hand 2.2's synchronous Opal caller a Promise where it expects a
10
+ // real value, since Opal's own dispatch loop never awaits a plain JS
11
+ // function's return value — verified empirically (a bare `async` process
12
+ // function renders literally "[object Promise]" under Antora's 2.2, not an
13
+ // error, which is what surfaced this in the first place).
14
+ //
15
+ // `chain`/`chainAll` run `fn` immediately if the input is already a plain
16
+ // value (2.2), or after it resolves if it's a thenable (4.0). The
17
+ // extension's own `process()` callback then returns whatever `fn` returns —
18
+ // a plain value under 2.2, a Promise under 4.0 — matching what each
19
+ // version's own block-processing loop expects (4.0's parser `await`s the
20
+ // return value of `processMethod`; 2.2's Opal loop uses it directly).
21
+ function chain(value, fn) {
22
+ if (value && typeof value.then === 'function') return value.then(fn)
23
+ return fn(value)
24
+ }
25
+
26
+ function chainAll(values, fn) {
27
+ if (values.some((v) => v && typeof v.then === 'function')) {
28
+ return Promise.all(values).then(fn)
29
+ }
30
+ return fn(values)
31
+ }
32
+
33
+ // A ListItem's `.getText()` needs `.precomputeText()` called first when the
34
+ // item was parsed outside Document's own top-level parse — exactly what
35
+ // these two extensions do via `parseContent`. The substituted text (inline
36
+ // macros like `xref:` resolved) is normally pre-computed during
37
+ // `Document.parse()`'s own post-processing walk (see @asciidoctor/core
38
+ // 4.0's document.js) — a walk that only ever reaches blocks left attached
39
+ // to the real document tree. Both extensions here discard their
40
+ // `parseContent` wrapper (replacing it with a single `pass` block holding
41
+ // pre-rendered HTML — see steps.js/card-grid.js's own `finish()`), so
42
+ // nothing in it is EVER part of that tree, and nothing in it gets
43
+ // precomputed automatically — verified empirically: without this, any
44
+ // `*bold*`/`xref:`/etc. inside a nested list item renders as literal,
45
+ // unsubstituted source text under 4.0 (2.2 is unaffected — its `getText()`
46
+ // always returns already-substituted text, no separate precompute step
47
+ // exists). `precomputeSubtree` runs the same precompute pass by hand, once,
48
+ // over everything `parseContent` produced.
49
+ //
50
+ // A Block's own `.getTitle()` has exactly the same problem, for exactly the
51
+ // same reason, and needs `.precomputeTitle()` — measured: a `.Title` line
52
+ // carrying an `xref:` or a `link:` comes back as raw, unsubstituted source
53
+ // (`https://x.com[Label]`) from a block parsed through `parseContent`, and
54
+ // as converted HTML (`<a href="https://x.com">Label</a>`) once precomputed.
55
+ // That is load-bearing for card-grid.js, whose card titles ARE links, and it
56
+ // was a latent bug for steps.js's own `.Title` lines before this was added.
57
+ // 2.2 has no `precomputeTitle` at all and needs none — its `getTitle()` is
58
+ // always already substituted — hence the `typeof` guard, the same one
59
+ // `precomputeText` gets.
60
+ //
61
+ // Recurses through both list shapes the Asciidoctor object model uses:
62
+ // `getBlocks()` for any ordinary block INCLUDING ulist/olist (where each
63
+ // child is a ListItem directly), and a dlist's own `[terms[], description]`
64
+ // tuple shape specifically (its `blocks` array holds pairs, not ListItems,
65
+ // so a plain `getBlocks()` recursion alone would silently skip every term
66
+ // and description inside it).
67
+ function precomputeSubtree(node) {
68
+ const jobs = []
69
+ function visitItem(item) {
70
+ if (!item) return
71
+ if (typeof item.precomputeText === 'function') jobs.push(item.precomputeText())
72
+ if (typeof item.precomputeTitle === 'function') jobs.push(item.precomputeTitle())
73
+ if (typeof item.getBlocks === 'function') visitChildren(item.getBlocks())
74
+ }
75
+ function visitChildren(blocks) {
76
+ blocks.forEach((block) => {
77
+ if (Array.isArray(block)) {
78
+ const [terms, description] = block
79
+ terms.forEach(visitItem)
80
+ visitItem(description)
81
+ return
82
+ }
83
+ visitItem(block)
84
+ })
85
+ }
86
+ visitItem(node)
87
+ return chainAll(jobs, () => node)
88
+ }
89
+
90
+ module.exports = { chain, chainAll, precomputeSubtree }
@@ -0,0 +1,327 @@
1
+ 'use strict'
2
+
3
+ const { chain, chainAll, precomputeSubtree } = require('./async-compat')
4
+ const { escapeHtml } = require('./html')
5
+ const warn = require('./warn')
6
+
7
+ // Card grid — the ATD Card (Figma component set 2669:33855, states and types
8
+ // at 2610:29000 for light and 2669:42541 for dark), rendered as IDS Card
9
+ // (card/card.css) in a CSS grid. It is the block behind the landing's
10
+ // Quicklinks row (GH-20) AND the section-landing card grids the Weave.js
11
+ // migration brought over (Fumadocs' `<Cards>`/`<Card>`, 210 uses across 11
12
+ // files) — one component, one look, doc pages and landings alike.
13
+ //
14
+ // The design's card is a slot of three optional groups over an optional
15
+ // image:
16
+ //
17
+ // Header info a 20x20 icon and a `label/m` subheader
18
+ // Main info the title (`title/s`) and description (`body/l`)
19
+ // Meta `.ids-label--grey` chips
20
+ //
21
+ // The frames' date field is deliberately not modelled, and neither is
22
+ // `Selected`: a documentation card has nothing to select. Nor is there a
23
+ // call to action — the ATD Card catalogue has no button in any of its four
24
+ // types, and the whole card is already the link, so a "Know more" would be a
25
+ // second affordance for the one thing clicking anywhere on the card does.
26
+ // (An earlier Quicklinks frame, 2696:54698, drew a ghost button; the
27
+ // catalogue supersedes it.)
28
+ //
29
+ // Every colour in both theme frames resolves to a semantic IDS token that
30
+ // ids-tokens.css already swaps under `.ids-theme-dark`, so dark theme costs
31
+ // this extension and card-grid.css nothing — see that file's own header.
32
+ //
33
+ // SYNTAX
34
+ //
35
+ // A `[cards]` example block holding one `[card]` per card. A card is either
36
+ // a paragraph — title plus description, which is what the migrated corpus
37
+ // needs — or an open block, when it also carries an image:
38
+ //
39
+ // [cards,type=image-portrait,columns="1 s:2 m:3",width=container]
40
+ // ====
41
+ // [card,icon="business/file-outlined",subheader="Getting started",labels="java, spring"]
42
+ // .xref:quickstart.adoc[Quickstart]
43
+ // --
44
+ // image::quickstart.svg[Woven cloth, folded]
45
+ //
46
+ // Create your first application with AMIGA Framework Java.
47
+ // --
48
+ //
49
+ // [card]
50
+ // .xref:sdk:index.adoc[SDK]
51
+ // The headless library the canvas is built on.
52
+ // ====
53
+ //
54
+ // The block title carries the link, exactly as the dlist term did in this
55
+ // block's first form: Asciidoctor runs inline substitutions over a title, so
56
+ // an `xref:` arrives here already converted to an `<a>`, and Antora's own
57
+ // page resolution is used rather than reimplemented. The image is a real
58
+ // `image::` macro for the same reason — the resource ID resolves through
59
+ // Antora, and alt text is native AsciiDoc rather than a second attribute.
60
+ //
61
+ // Why the open block is required for the image form: `[card]` on an
62
+ // `image::` line is consumed as the BLOCK MACRO's own style and never
63
+ // reaches `getStyle()` (measured: the block reports `style: null` and its
64
+ // first positional attribute is the image's alt text), so an image can never
65
+ // itself be the card marker. An open block keeps the style, keeps the title,
66
+ // and delimits the card explicitly instead of relying on a "blocks until the
67
+ // next marker" heuristic.
68
+
69
+ const TYPES = ['no-image', 'image-landscape', 'image-square', 'image-portrait']
70
+ const DEFAULT_TYPE = 'no-image'
71
+
72
+ const WIDTHS = ['content', 'container']
73
+ const DEFAULT_WIDTH = 'content'
74
+
75
+ // `xs` is the base, unprefixed, so it has no name here — a bare `3` sets the
76
+ // base count. The rest are the PROJECT's breakpoints (ids-breakpoints.css:
77
+ // s 513, m 1240, l 1680), not the design system package's own, and the
78
+ // classes they map to are mobile-first and non-exclusive, like the DS grid's
79
+ // `--span-*` utilities.
80
+ const BREAKPOINTS = ['s', 'm', 'l']
81
+ const DEFAULT_COLUMNS = '1 s:2 m:3'
82
+ const MAX_COLUMNS = 4
83
+
84
+ const COLUMN_RX = /^(?:([a-z]+):)?([0-9]+)$/
85
+ // `group/name`, both lowercase and hyphen-separated — the shape
86
+ // `icons.yml` uses. Whether that icon is actually MASKED is checked by
87
+ // ui-bundle's own `just icons-build` (scripts/build-sprite.mjs), which owns
88
+ // the manifest; duplicating the list here would be a second source of truth
89
+ // that drifts. An icon that passes the shape check but has no mask falls
90
+ // back to a visible marker in card-grid.css rather than rendering nothing.
91
+ const ICON_RX = /^[a-z0-9]+(?:-[a-z0-9]+)*\/[a-z0-9]+(?:-[a-z0-9]+)*$/
92
+
93
+ // The first `href` of the converted title, which is the card's own link.
94
+ const HREF_RX = /<a\b[^>]*\bhref="([^"]*)"/i
95
+ // Lifted whole from the converted image block rather than rebuilt from the
96
+ // target: the host converter is what resolves an image URL (Antora's
97
+ // `imagesdir` per page, its own resource resolution), and `getImageUri` does
98
+ // not apply `imagesdir` unless handed the asset key explicitly. Taking the
99
+ // converter's own `<img>` also keeps the alt, width and height it derived.
100
+ const IMG_RX = /<img\b[^>]*>/i
101
+
102
+ /** `image-portrait` -> `portrait`; `no-image` has no modifier of its own. */
103
+ const typeModifier = (type) => (type === 'no-image' ? '' : ' docouture-card--' + type.slice('image-'.length))
104
+
105
+ /**
106
+ * Parse the `columns` spec into grid classes.
107
+ *
108
+ * `"1 s:2 m:3"` -> base 1, from `s` 2, from `m` 3. Anything malformed, out of
109
+ * range, or naming a breakpoint that does not exist is an authoring error and
110
+ * fails the build.
111
+ */
112
+ function parseColumns(spec, parent) {
113
+ const classes = []
114
+ for (const token of String(spec).trim().split(/\s+/)) {
115
+ if (!token) continue
116
+ const match = COLUMN_RX.exec(token)
117
+ if (!match) {
118
+ warn(parent, '[cards,columns="' + spec + '"]', 'cannot read the column "' + token + '"; expected `3` or `m:3`')
119
+ continue
120
+ }
121
+ const [, breakpoint, count] = match
122
+ if (breakpoint && !BREAKPOINTS.includes(breakpoint)) {
123
+ warn(parent, '[cards,columns="' + spec + '"]', 'unknown breakpoint "' + breakpoint + '"', BREAKPOINTS)
124
+ continue
125
+ }
126
+ const columns = Number(count)
127
+ if (columns < 1 || columns > MAX_COLUMNS) {
128
+ warn(
129
+ parent,
130
+ '[cards,columns="' + spec + '"]',
131
+ columns + ' columns is outside the supported range 1-' + MAX_COLUMNS
132
+ )
133
+ continue
134
+ }
135
+ classes.push('docouture-card-grid--cols-' + (breakpoint ? breakpoint + '-' : '') + columns)
136
+ }
137
+ return classes
138
+ }
139
+
140
+ /** The image blocks and the body blocks of one card, whichever form it took. */
141
+ function cardParts(block) {
142
+ if (block.getContext() !== 'open') return { images: [], bodies: [block] }
143
+ const images = []
144
+ const bodies = []
145
+ for (const child of block.getBlocks()) {
146
+ if (child.getContext() === 'image') images.push(child)
147
+ else bodies.push(child)
148
+ }
149
+ return { images, bodies }
150
+ }
151
+
152
+ function renderHeader(icon, subheader, parent) {
153
+ if (!icon && !subheader) return ''
154
+ let iconHtml = ''
155
+ if (icon) {
156
+ if (ICON_RX.test(icon)) {
157
+ iconHtml =
158
+ '<span class="docouture-card__icon ids-icon-mask--' +
159
+ escapeHtml(icon.replace('/', '-')) +
160
+ '" aria-hidden="true"></span>'
161
+ } else {
162
+ warn(
163
+ parent,
164
+ '[card,icon="' + icon + '"]',
165
+ 'not an icon reference; expected `group/name`, e.g. `business/file-outlined`'
166
+ )
167
+ }
168
+ }
169
+ const subheaderHtml = subheader ? '<span class="docouture-card__subheader">' + escapeHtml(subheader) + '</span>' : ''
170
+ return '<div class="docouture-card__header">' + iconHtml + subheaderHtml + '</div>'
171
+ }
172
+
173
+ function renderMeta(labels) {
174
+ const chips = String(labels || '')
175
+ .split(',')
176
+ .map((label) => label.trim())
177
+ .filter(Boolean)
178
+ if (!chips.length) return ''
179
+ return (
180
+ '<div class="docouture-card__meta">' +
181
+ chips
182
+ .map(
183
+ (label) =>
184
+ '<span class="ids-label ids-label--grey"><span class="ids-label__content">' +
185
+ escapeHtml(label) +
186
+ '</span></span>'
187
+ )
188
+ .join('') +
189
+ '</div>'
190
+ )
191
+ }
192
+
193
+ function buildCardHtml({ type, imageHtml, header, title, description, meta }) {
194
+ return (
195
+ '<div class="ids-card ids-card--vertical docouture-card' +
196
+ typeModifier(type) +
197
+ '">' +
198
+ imageHtml +
199
+ '<div class="ids-card__content">' +
200
+ header +
201
+ '<div class="docouture-card__main">' +
202
+ '<div class="docouture-card__title">' +
203
+ title +
204
+ '</div>' +
205
+ description +
206
+ '</div>' +
207
+ meta +
208
+ '</div>' +
209
+ '</div>'
210
+ )
211
+ }
212
+
213
+ function renderCard(block, type, parent) {
214
+ // NOT escaped, deliberately — `getTitle()` returns CONVERTED HTML, not
215
+ // text. The `xref:` this block is built around arrives as
216
+ // `<a href="...">Label</a>`, so escaping it would render the anchor as
217
+ // visible source and break every card link. See lib/html.js's header for
218
+ // which strings do need escaping (raw block attributes) and which are
219
+ // already safe.
220
+ const title = block.getTitle()
221
+ if (!title) {
222
+ warn(parent, '[card]', 'a card has no title; give it a `.Title` line carrying the link')
223
+ return ''
224
+ }
225
+
226
+ const href = (HREF_RX.exec(title) || [])[1]
227
+ if (!href) {
228
+ warn(
229
+ parent,
230
+ '[card] ' + block.getAttribute('title'),
231
+ 'a card title carries no link; make it an `xref:` or a `link:`'
232
+ )
233
+ }
234
+
235
+ const { images, bodies } = cardParts(block)
236
+ if (type === 'no-image' && images.length) {
237
+ warn(parent, '[card]', 'this card has an image but the block is `type=' + type + '`', TYPES.slice(1))
238
+ }
239
+ if (type !== 'no-image' && !images.length) {
240
+ warn(parent, '[card]', 'a `type=' + type + '` card needs an `image::` of its own')
241
+ }
242
+ if (images.length > 1) {
243
+ warn(parent, '[card]', 'a card has ' + images.length + ' images; only the first is rendered')
244
+ }
245
+ for (const body of bodies) {
246
+ if (body.getContext() !== 'paragraph') {
247
+ warn(parent, '[card]', 'a card body holds a ' + body.getContext() + ' block; only paragraphs are supported')
248
+ }
249
+ }
250
+
251
+ const parts = []
252
+ parts.push(images.length ? images[0].convert() : '')
253
+ bodies.forEach((body) => parts.push(body.getContent()))
254
+
255
+ return chainAll(parts, ([converted, ...texts]) => {
256
+ const image = converted ? (IMG_RX.exec(converted) || [])[0] : ''
257
+ const imageHtml = image ? '<div class="ids-card__image docouture-card__image">' + image + '</div>' : ''
258
+ const description = texts
259
+ .filter(Boolean)
260
+ .map((text) => '<p>' + text + '</p>')
261
+ .join('')
262
+ return buildCardHtml({
263
+ type,
264
+ imageHtml,
265
+ header: renderHeader(block.getAttribute('icon'), block.getAttribute('subheader'), parent),
266
+ title,
267
+ description,
268
+ meta: renderMeta(block.getAttribute('labels')),
269
+ })
270
+ })
271
+ }
272
+
273
+ function finish(parent, wrapper, attrs, self) {
274
+ const type = attrs.type || DEFAULT_TYPE
275
+ if (!TYPES.includes(type)) {
276
+ warn(parent, '[cards,type=' + attrs.type + ']', 'unknown card type "' + attrs.type + '"', TYPES)
277
+ }
278
+
279
+ const width = attrs.width || DEFAULT_WIDTH
280
+ if (!WIDTHS.includes(width)) {
281
+ warn(parent, '[cards,width=' + attrs.width + ']', 'unknown width "' + attrs.width + '"', WIDTHS)
282
+ }
283
+
284
+ const cards = wrapper.getBlocks().filter((block) => block.getStyle() === 'card')
285
+ if (!cards.length) {
286
+ warn(parent, '[cards]', 'a cards block with no `[card]` in it')
287
+ }
288
+
289
+ const cardHtmls = cards.map((card) => renderCard(card, TYPES.includes(type) ? type : DEFAULT_TYPE, parent))
290
+
291
+ return chainAll(cardHtmls, (rendered) => {
292
+ const classes = ['docouture-card-grid']
293
+ .concat(parseColumns(attrs.columns || DEFAULT_COLUMNS, parent))
294
+ .concat('docouture-card-grid--width-' + (WIDTHS.includes(width) ? width : DEFAULT_WIDTH))
295
+ const html = '<div class="' + classes.join(' ') + '">' + rendered.join('') + '</div>'
296
+ return self.createBlock(parent, 'pass', html, attrs)
297
+ })
298
+ }
299
+
300
+ function cardsBlock() {
301
+ this.named('cards')
302
+ this.onContext('example')
303
+ this.process((parent, reader, attrs) => {
304
+ // See steps.js's own comment: Opal (2.2) can hand this a bare JS `null`
305
+ // for a block with no attributes beyond its style, and `createBlock`
306
+ // crashes on that.
307
+ attrs = attrs || {}
308
+ // See steps.js's own comment: a literal JS `null` "source" crashes Opal
309
+ // (2.2) inside `Block#initialize`'s `.nil_or_empty?()` check.
310
+ const wrapper = this.createBlock(parent, 'open', '', attrs)
311
+ // See label-macro.js's own comment on this same pattern.
312
+ // eslint-disable-next-line @typescript-eslint/no-this-alias
313
+ const self = this
314
+ // See async-compat.js's own header comment: parseContent is sync under
315
+ // 2.2 (Opal, real Antora builds) and Promise-returning under 4.0 (the
316
+ // ui-bundle preview harness) — chain() handles either without making
317
+ // this function `async` unconditionally.
318
+ return chain(this.parseContent(wrapper, reader.getLines()), () =>
319
+ chain(precomputeSubtree(wrapper), () => finish(parent, wrapper, attrs, self))
320
+ )
321
+ })
322
+ }
323
+
324
+ module.exports = function registerCardGrid(registry) {
325
+ registry.block(cardsBlock)
326
+ }
327
+ module.exports.cardsBlock = cardsBlock