@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 +254 -0
- package/index.js +117 -0
- package/lib/accordion.js +243 -0
- package/lib/async-compat.js +90 -0
- package/lib/card-grid.js +327 -0
- package/lib/cta.js +334 -0
- package/lib/feature-tabs.js +400 -0
- package/lib/first-positional.js +13 -0
- package/lib/html.js +121 -0
- package/lib/kroki-config.js +191 -0
- package/lib/kroki-instance.js +89 -0
- package/lib/kroki-mermaid-theme.js +93 -0
- package/lib/kroki.js +165 -0
- package/lib/label-macro.js +69 -0
- package/lib/mono-macro.js +41 -0
- package/lib/nowrap-cols.js +61 -0
- package/lib/shiki-config.js +94 -0
- package/lib/shiki-instance.js +64 -0
- package/lib/shiki-syntax-highlighter.js +137 -0
- package/lib/table-container.js +143 -0
- package/lib/table-width.js +70 -0
- package/lib/tabs.js +263 -0
- package/lib/unique-id.js +78 -0
- package/lib/video-size.js +89 -0
- package/lib/warn.js +75 -0
- package/package.json +33 -0
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const { chain, chainAll, precomputeSubtree } = require('./async-compat')
|
|
4
|
+
const { escapeHtml, attr } = require('./html')
|
|
5
|
+
const uniqueId = require('./unique-id')
|
|
6
|
+
const warn = require('./warn')
|
|
7
|
+
|
|
8
|
+
// Feature tabs — the landing's "Key features" switcher (GH-22, Figma
|
|
9
|
+
// 2696:54801 at l, 2696:92583 at m, 2696:94328 at s, 2696:95691 at xs).
|
|
10
|
+
//
|
|
11
|
+
// A column of labels beside a panel: selecting a label swaps the panel, and
|
|
12
|
+
// the panel is one slide — a media still, the feature's own prose, and an
|
|
13
|
+
// optional call to action. It is the only interactive block on the landing.
|
|
14
|
+
//
|
|
15
|
+
// WHAT THIS EMITS, AND WHAT IT DELIBERATELY DOES NOT
|
|
16
|
+
//
|
|
17
|
+
// Server-rendered, this is not a tab set at all. Every panel is present and
|
|
18
|
+
// visible, each under its own heading, and the label column is a list of
|
|
19
|
+
// in-page anchors pointing at them — a plain, complete, readable document
|
|
20
|
+
// section. `ui-bundle/src/js/07-feature-tabs.ts` is what turns that into a
|
|
21
|
+
// tablist: it sets `role=tablist`/`tab`/`tabpanel`, `aria-controls`,
|
|
22
|
+
// `aria-selected` and roving focus, and hides the panels that are not
|
|
23
|
+
// selected.
|
|
24
|
+
//
|
|
25
|
+
// So the ARIA the package contract asks for is real, but it is applied by
|
|
26
|
+
// the script that makes it true. Emitting `role=tab` and `aria-selected` here
|
|
27
|
+
// would announce a widget that does not exist until (and unless) JavaScript
|
|
28
|
+
// runs: with script off, every "tab" would be selected, none of them would do
|
|
29
|
+
// anything, and the panels would claim to be controlled by something inert.
|
|
30
|
+
// The markup below is honest in both states, which is the point of the
|
|
31
|
+
// contract's degradation rule rather than a way around it.
|
|
32
|
+
//
|
|
33
|
+
// SYNTAX
|
|
34
|
+
//
|
|
35
|
+
// A `[feature-tabs]` example block holding one `[feature]` open block per
|
|
36
|
+
// slide:
|
|
37
|
+
//
|
|
38
|
+
// [feature-tabs]
|
|
39
|
+
// ====
|
|
40
|
+
// [feature,label="UI-agnostic"]
|
|
41
|
+
// .Integrates with the UI framework of your choice
|
|
42
|
+
// --
|
|
43
|
+
// image::feature-1.png[A canvas rendered through three UI frameworks]
|
|
44
|
+
// image::feature-1-dark.png[role=dark]
|
|
45
|
+
//
|
|
46
|
+
// Change the UI using our included primitives or build a new one.
|
|
47
|
+
//
|
|
48
|
+
// [.cta]
|
|
49
|
+
// xref:main:architecture.adoc[Learn more]
|
|
50
|
+
// --
|
|
51
|
+
// ====
|
|
52
|
+
//
|
|
53
|
+
// `.Title` is the slide's heading and `label=` is the text in the label
|
|
54
|
+
// column. Both are optional, but not at the same time — a slide needs at least
|
|
55
|
+
// one of them, because the label column has to say something:
|
|
56
|
+
//
|
|
57
|
+
// title only the heading IS the label; the enhanced state hides the
|
|
58
|
+
// heading rather than printing the same words twice. This is
|
|
59
|
+
// the design's own copy (Figma 2696:54807 and its panel, which
|
|
60
|
+
// carries no heading of its own).
|
|
61
|
+
// label only a slide with no heading at all — again what the frames draw,
|
|
62
|
+
// for content whose label already says everything.
|
|
63
|
+
// both a short label beside a longer heading. This is what migrated
|
|
64
|
+
// content commonly needs.
|
|
65
|
+
//
|
|
66
|
+
// The call to action is an ordinary paragraph carrying the `cta` role, NOT a
|
|
67
|
+
// pair of `action=`/`url=` attributes. That is the same decision card-grid.js
|
|
68
|
+
// made for a card's link and for the same reason: only Asciidoctor resolves
|
|
69
|
+
// an `xref:`, so a target handed to this extension as a raw attribute string
|
|
70
|
+
// could never be turned into a working link to a page in this site. Written
|
|
71
|
+
// as a real inline macro it arrives here already converted, with Antora's own
|
|
72
|
+
// page resolution applied, and `link:`/`https://…` work identically.
|
|
73
|
+
//
|
|
74
|
+
// The dark media still is a second `image::` with `role=dark`. It has to be a
|
|
75
|
+
// separate image rather than one that adapts: an `<img>` does not inherit CSS
|
|
76
|
+
// from the page embedding it, which is why the hero's product mark takes the
|
|
77
|
+
// same two-asset shape (home-hero.hbs). Sites whose media reads correctly on
|
|
78
|
+
// both surfaces simply omit it.
|
|
79
|
+
//
|
|
80
|
+
// The slide's parts are emitted in the design's order — media, prose, call to
|
|
81
|
+
// action — regardless of the order they were authored in. A slide is a fixed
|
|
82
|
+
// composition, not a flow of blocks, and an author reordering them by accident
|
|
83
|
+
// should not produce a layout the frames never draw.
|
|
84
|
+
|
|
85
|
+
/** The role marking a slide's call-to-action paragraph. */
|
|
86
|
+
const CTA_ROLE = 'cta'
|
|
87
|
+
/** The role marking a slide's dark-theme media still. */
|
|
88
|
+
const DARK_ROLE = 'dark'
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* The converter's own `<img>`, lifted whole from a converted image block.
|
|
92
|
+
*
|
|
93
|
+
* Rebuilt from the target it would not be: the host converter is what resolves
|
|
94
|
+
* an image URL (Antora's per-page `imagesdir`, its own resource resolution),
|
|
95
|
+
* and taking its output keeps the alt, width and height it derived. Same
|
|
96
|
+
* reasoning, same regex, as card-grid.js.
|
|
97
|
+
*/
|
|
98
|
+
const IMG_RX = /<img\b[^>]*>/i
|
|
99
|
+
|
|
100
|
+
/** The first anchor of a converted call-to-action paragraph: its href and its label. */
|
|
101
|
+
const CTA_RX = /<a\b[^>]*\bhref="([^"]*)"[^>]*>([\s\S]*?)<\/a>/i
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* A target that leaves the site: any absolute URL, or a protocol-relative one.
|
|
105
|
+
*
|
|
106
|
+
* This is the icon rule, derived rather than authored — an author cannot
|
|
107
|
+
* forget the external-link icon and cannot put it on a link that stays here.
|
|
108
|
+
* It is the same rule home-hero.hbs applies, reached differently: that partial
|
|
109
|
+
* asks Antora's `resolvePageURL` whether the target names a page, which an
|
|
110
|
+
* extension has no access to. By the time a target reaches this file it is
|
|
111
|
+
* already a resolved href, so "does it point at another origin" is the
|
|
112
|
+
* question that can actually be answered.
|
|
113
|
+
*/
|
|
114
|
+
const EXTERNAL_RX = /^(?:[a-z][a-z0-9+.-]*:)?\/\//i
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* An Asciidoctor block, in whichever major is running — the 4.0 types are used
|
|
118
|
+
* for the shape only; every call below exists in 2.2 as well.
|
|
119
|
+
*
|
|
120
|
+
* @typedef {import('@asciidoctor/core').AbstractBlock} Block
|
|
121
|
+
*/
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* One slide's blocks, sorted into the three roles a slide has.
|
|
125
|
+
*
|
|
126
|
+
* A `[feature]` is normally an open block, which is what an author needs for a
|
|
127
|
+
* slide with an image in it: `[feature]` on an `image::` line is consumed as
|
|
128
|
+
* the BLOCK MACRO's own style and never reaches `getStyle()` (measured in
|
|
129
|
+
* card-grid.js's case, and the same here), so an image can never itself be the
|
|
130
|
+
* slide marker. A bare paragraph is still accepted, for a slide that is prose
|
|
131
|
+
* alone.
|
|
132
|
+
*
|
|
133
|
+
* @param {Block} block - the `[feature]` block.
|
|
134
|
+
* @returns {{ images: Block[], ctas: Block[], bodies: Block[] }} its parts.
|
|
135
|
+
*/
|
|
136
|
+
function featureParts(block) {
|
|
137
|
+
if (block.getContext() !== 'open') return { images: [], ctas: [], bodies: [block] }
|
|
138
|
+
/** @type {Block[]} */
|
|
139
|
+
const images = []
|
|
140
|
+
/** @type {Block[]} */
|
|
141
|
+
const ctas = []
|
|
142
|
+
/** @type {Block[]} */
|
|
143
|
+
const bodies = []
|
|
144
|
+
for (const child of block.getBlocks()) {
|
|
145
|
+
if (child.getContext() === 'image') images.push(child)
|
|
146
|
+
else if (child.hasRole(CTA_ROLE)) ctas.push(child)
|
|
147
|
+
else bodies.push(child)
|
|
148
|
+
}
|
|
149
|
+
return { images, ctas, bodies }
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Splits the images into the light still and its optional dark counterpart.
|
|
154
|
+
*
|
|
155
|
+
* Order does not decide which is which — the role does — so an author who
|
|
156
|
+
* writes the dark one first gets the same output rather than an inverted page.
|
|
157
|
+
*
|
|
158
|
+
* @param {Block[]} images - every `image::` in the slide.
|
|
159
|
+
* @param {Block} parent - the enclosing block, for warnings.
|
|
160
|
+
* @returns {{ light: Block | undefined, dark: Block | undefined }}
|
|
161
|
+
*/
|
|
162
|
+
function splitImages(images, parent) {
|
|
163
|
+
const dark = images.filter((image) => image.hasRole(DARK_ROLE))
|
|
164
|
+
const light = images.filter((image) => !image.hasRole(DARK_ROLE))
|
|
165
|
+
if (light.length > 1) {
|
|
166
|
+
warn(parent, '[feature]', 'a slide has ' + light.length + ' images; only the first is rendered')
|
|
167
|
+
}
|
|
168
|
+
if (dark.length > 1) {
|
|
169
|
+
warn(parent, '[feature]', 'a slide has ' + dark.length + ' `role=dark` images; only the first is rendered')
|
|
170
|
+
}
|
|
171
|
+
if (dark.length && !light.length) {
|
|
172
|
+
warn(parent, '[feature]', 'a slide has a `role=dark` image but no image for the light theme')
|
|
173
|
+
}
|
|
174
|
+
return { light: light[0], dark: dark[0] }
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* The media panel: the light still, and the dark one when there is one.
|
|
179
|
+
*
|
|
180
|
+
* Both are emitted, and CSS shows one per theme. The pair is only ever a pair —
|
|
181
|
+
* a slide with no dark still renders the light one in both themes, unclassed.
|
|
182
|
+
*
|
|
183
|
+
* @param {string} lightHtml - the converted light image block, or `''`.
|
|
184
|
+
* @param {string} darkHtml - the converted dark image block, or `''`.
|
|
185
|
+
* @returns {string} the media markup, or `''` when the slide has no image.
|
|
186
|
+
*/
|
|
187
|
+
function renderMedia(lightHtml, darkHtml) {
|
|
188
|
+
const light = lightHtml ? (IMG_RX.exec(lightHtml) || [])[0] : ''
|
|
189
|
+
const dark = darkHtml ? (IMG_RX.exec(darkHtml) || [])[0] : ''
|
|
190
|
+
if (!light && !dark) return ''
|
|
191
|
+
const themed = light && dark
|
|
192
|
+
return (
|
|
193
|
+
'<div class="docouture-feature-tabs__media">' +
|
|
194
|
+
(light
|
|
195
|
+
? light.replace(
|
|
196
|
+
'<img',
|
|
197
|
+
'<img class="docouture-feature-tabs__image' + (themed ? ' docouture-feature-tabs__image--light' : '') + '"'
|
|
198
|
+
)
|
|
199
|
+
: '') +
|
|
200
|
+
(dark
|
|
201
|
+
? dark.replace('<img', '<img class="docouture-feature-tabs__image docouture-feature-tabs__image--dark"')
|
|
202
|
+
: '') +
|
|
203
|
+
'</div>'
|
|
204
|
+
)
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* The slide's call to action, as an IDS ghost Button.
|
|
209
|
+
*
|
|
210
|
+
* The anchor the author wrote is not reused as-is — its href and label are
|
|
211
|
+
* taken and the DS's own markup is built around them, so the control is a real
|
|
212
|
+
* Button rather than a link wearing a button's classes. `getContent()` on a
|
|
213
|
+
* paragraph is already-converted HTML, so the label is passed through
|
|
214
|
+
* untouched (see lib/html.js on what must and must not be escaped).
|
|
215
|
+
*
|
|
216
|
+
* @param {string} converted - the converted `[.cta]` paragraph, or `''`.
|
|
217
|
+
* @param {Block} parent - the enclosing block, for warnings.
|
|
218
|
+
* @returns {string} the button markup, or `''`.
|
|
219
|
+
*/
|
|
220
|
+
function renderCta(converted, parent) {
|
|
221
|
+
if (!converted) return ''
|
|
222
|
+
const match = CTA_RX.exec(converted)
|
|
223
|
+
if (!match) {
|
|
224
|
+
warn(parent, '[.cta]', "a slide's call to action carries no link; make it an `xref:` or a `link:`")
|
|
225
|
+
return ''
|
|
226
|
+
}
|
|
227
|
+
const href = match[1] || ''
|
|
228
|
+
const label = match[2] || ''
|
|
229
|
+
const external = EXTERNAL_RX.test(href)
|
|
230
|
+
return (
|
|
231
|
+
'<a class="ids-button ids-button--ghost docouture-feature-tabs__cta' +
|
|
232
|
+
(external ? ' ids-button--icon-and-label ids-button--icon-action' : '') +
|
|
233
|
+
'"' +
|
|
234
|
+
attr('href', href) +
|
|
235
|
+
'>' +
|
|
236
|
+
'<span class="ids-button__content">' +
|
|
237
|
+
label +
|
|
238
|
+
(external
|
|
239
|
+
? '<span class="ids-button__icon ids-button__icon-action docouture-feature-tabs__cta-icon ' +
|
|
240
|
+
'ids-icon-mask--connectivity-link-external-outlined" aria-hidden="true"></span>'
|
|
241
|
+
: '') +
|
|
242
|
+
'</span>' +
|
|
243
|
+
'</a>'
|
|
244
|
+
)
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* One slide: its tab, and its panel.
|
|
249
|
+
*
|
|
250
|
+
* The two are built together so the ids that pair them come from the same
|
|
251
|
+
* iteration rather than being parsed back out of one another — see
|
|
252
|
+
* lib/unique-id.js on why the counter is per document.
|
|
253
|
+
*
|
|
254
|
+
* @param {Block} block - the `[feature]` block.
|
|
255
|
+
* @param {number} index - its position, zero-based.
|
|
256
|
+
* @param {Block} parent - the enclosing block, for warnings and for ids.
|
|
257
|
+
* @returns {{ tab: string, panel: string } | Promise<{ tab: string, panel: string }>}
|
|
258
|
+
*/
|
|
259
|
+
function renderFeature(block, index, parent) {
|
|
260
|
+
// NOT escaped: `getTitle()` returns CONVERTED HTML, not raw text (lib/html.js).
|
|
261
|
+
// Optional — a slide is a media panel with prose under it, and the design's
|
|
262
|
+
// own slides carry no heading at all (Figma 2696:54811). What is NOT optional
|
|
263
|
+
// is having something to put in the label column, so a slide with neither a
|
|
264
|
+
// title nor a `label=` is an authoring error rather than a nameless tab.
|
|
265
|
+
const title = block.getTitle()
|
|
266
|
+
// A block attribute, so raw, so escaped — the other half of that same rule.
|
|
267
|
+
const label = block.getAttribute('label')
|
|
268
|
+
if (!title && !label) {
|
|
269
|
+
warn(parent, '[feature]', 'a slide has no label; give it a `.Title` line or a `label=` attribute')
|
|
270
|
+
return { tab: '', panel: '' }
|
|
271
|
+
}
|
|
272
|
+
const tabLabel = label ? escapeHtml(label) : title
|
|
273
|
+
|
|
274
|
+
const { images, ctas, bodies } = featureParts(block)
|
|
275
|
+
if (!images.length) {
|
|
276
|
+
warn(parent, '[feature] ' + (label || block.getAttribute('title')), 'a slide has no `image::` of its own')
|
|
277
|
+
}
|
|
278
|
+
if (ctas.length > 1) {
|
|
279
|
+
warn(parent, '[feature]', 'a slide has ' + ctas.length + ' `[.cta]` paragraphs; only the first is rendered')
|
|
280
|
+
}
|
|
281
|
+
const { light, dark } = splitImages(images, parent)
|
|
282
|
+
|
|
283
|
+
const panelId = uniqueId(parent, 'feature-tabs-panel')
|
|
284
|
+
const tabId = uniqueId(parent, 'feature-tabs-tab')
|
|
285
|
+
const headingId = title ? uniqueId(parent, 'feature-tabs-label') : ''
|
|
286
|
+
|
|
287
|
+
const cta = ctas[0]
|
|
288
|
+
const parts = [light ? light.convert() : '', dark ? dark.convert() : '', cta ? cta.getContent() : '']
|
|
289
|
+
bodies.forEach((body) => parts.push(body.convert()))
|
|
290
|
+
|
|
291
|
+
return chainAll(parts, ([lightHtml, darkHtml, ctaHtml, ...bodyHtmls]) => {
|
|
292
|
+
const tab =
|
|
293
|
+
'<li class="docouture-feature-tabs__item">' +
|
|
294
|
+
'<a class="ids-tabs-item docouture-feature-tabs__tab' +
|
|
295
|
+
(index === 0 ? ' ids-tabs-item--selected' : '') +
|
|
296
|
+
'"' +
|
|
297
|
+
attr('id', tabId) +
|
|
298
|
+
attr('href', '#' + panelId) +
|
|
299
|
+
'>' +
|
|
300
|
+
tabLabel +
|
|
301
|
+
'</a>' +
|
|
302
|
+
'</li>'
|
|
303
|
+
|
|
304
|
+
const heading = title
|
|
305
|
+
? '<h3 class="docouture-feature-tabs__heading' +
|
|
306
|
+
// No explicit label means the heading and the tab say the same thing, so
|
|
307
|
+
// the enhanced state hides the heading rather than printing it twice.
|
|
308
|
+
(label ? '' : ' docouture-feature-tabs__heading--redundant') +
|
|
309
|
+
'"' +
|
|
310
|
+
attr('id', headingId) +
|
|
311
|
+
'>' +
|
|
312
|
+
title +
|
|
313
|
+
'</h3>'
|
|
314
|
+
: ''
|
|
315
|
+
|
|
316
|
+
const panel =
|
|
317
|
+
'<section class="docouture-feature-tabs__panel' +
|
|
318
|
+
(index === 0 ? ' is-selected' : '') +
|
|
319
|
+
'"' +
|
|
320
|
+
attr('id', panelId) +
|
|
321
|
+
// Named by its heading where there is one, and by the label column's own
|
|
322
|
+
// text where there is not — a panel with no accessible name at all would
|
|
323
|
+
// be an unnamed region in the unenhanced document, before the script that
|
|
324
|
+
// names it after its tab has run.
|
|
325
|
+
(headingId ? attr('aria-labelledby', headingId) : attr('aria-label', label || '')) +
|
|
326
|
+
'>' +
|
|
327
|
+
heading +
|
|
328
|
+
renderMedia(lightHtml, darkHtml) +
|
|
329
|
+
'<div class="docouture-feature-tabs__body">' +
|
|
330
|
+
bodyHtmls.filter(Boolean).join('') +
|
|
331
|
+
'</div>' +
|
|
332
|
+
renderCta(ctaHtml, parent) +
|
|
333
|
+
'</section>'
|
|
334
|
+
|
|
335
|
+
return { tab, panel }
|
|
336
|
+
})
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Assembles the block once every slide has been rendered.
|
|
341
|
+
*
|
|
342
|
+
* @param {Block} parent - the block this replaces.
|
|
343
|
+
* @param {Block} wrapper - the parsed `[feature-tabs]` content.
|
|
344
|
+
* @param {object} attrs - the block's own attributes.
|
|
345
|
+
* @param {{ createBlock: Function }} self - the block processor.
|
|
346
|
+
* @returns {object | Promise<object>} the `pass` block carrying the markup.
|
|
347
|
+
*/
|
|
348
|
+
function finish(parent, wrapper, attrs, self) {
|
|
349
|
+
const features = wrapper.getBlocks().filter((block) => block.getStyle() === 'feature')
|
|
350
|
+
if (!features.length) {
|
|
351
|
+
warn(parent, '[feature-tabs]', 'a feature-tabs block with no `[feature]` in it')
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
const rendered = features.map((feature, index) => renderFeature(feature, index, parent))
|
|
355
|
+
|
|
356
|
+
return chainAll(rendered, (slides) => {
|
|
357
|
+
const tabs = slides.map((slide) => slide.tab).join('')
|
|
358
|
+
const panels = slides.map((slide) => slide.panel).join('')
|
|
359
|
+
const html =
|
|
360
|
+
'<div class="docouture-feature-tabs" data-feature-tabs>' +
|
|
361
|
+
'<ul class="docouture-feature-tabs__list">' +
|
|
362
|
+
tabs +
|
|
363
|
+
'</ul>' +
|
|
364
|
+
'<div class="docouture-feature-tabs__panels">' +
|
|
365
|
+
panels +
|
|
366
|
+
'</div>' +
|
|
367
|
+
'</div>'
|
|
368
|
+
return self.createBlock(parent, 'pass', html, attrs)
|
|
369
|
+
})
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
function featureTabsBlock() {
|
|
373
|
+
this.named('feature-tabs')
|
|
374
|
+
this.onContext('example')
|
|
375
|
+
this.process((parent, reader, attrs) => {
|
|
376
|
+
// See steps.js's own comment: Opal (2.2) can hand this a bare JS `null`
|
|
377
|
+
// for a block with no attributes beyond its style, and `createBlock`
|
|
378
|
+
// crashes on that.
|
|
379
|
+
attrs = attrs || {}
|
|
380
|
+
// See steps.js's own comment: a literal JS `null` "source" crashes Opal
|
|
381
|
+
// (2.2) inside `Block#initialize`'s `.nil_or_empty?()` check.
|
|
382
|
+
const wrapper = this.createBlock(parent, 'open', '', attrs)
|
|
383
|
+
// See label-macro.js's own comment on this same pattern.
|
|
384
|
+
// eslint-disable-next-line @typescript-eslint/no-this-alias
|
|
385
|
+
const self = this
|
|
386
|
+
// See async-compat.js's own header comment: parseContent is sync under
|
|
387
|
+
// 2.2 (Opal, real Antora builds) and Promise-returning under 4.0 (the
|
|
388
|
+
// ui-bundle preview harness) — chain() handles either without making
|
|
389
|
+
// this function `async` unconditionally. precomputeSubtree is what makes
|
|
390
|
+
// a `.Title` carrying inline markup arrive converted rather than raw.
|
|
391
|
+
return chain(this.parseContent(wrapper, reader.getLines()), () =>
|
|
392
|
+
chain(precomputeSubtree(wrapper), () => finish(parent, wrapper, attrs, self))
|
|
393
|
+
)
|
|
394
|
+
})
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
module.exports = function registerFeatureTabs(registry) {
|
|
398
|
+
registry.block(featureTabsBlock)
|
|
399
|
+
}
|
|
400
|
+
module.exports.featureTabsBlock = featureTabsBlock
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
// The inline macro `attrs` parameter's shape differs between the two
|
|
4
|
+
// Asciidoctor majors this repo runs — verified empirically, not documented
|
|
5
|
+
// anywhere obvious:
|
|
6
|
+
// - 2.2 (site builds, via Antora): attrs.$positional[0]
|
|
7
|
+
// - 4.0 (ui-bundle preview harness): attrs['1']
|
|
8
|
+
// Both are the attribute list's first positional entry.
|
|
9
|
+
module.exports = function firstPositional(attrs) {
|
|
10
|
+
if (attrs && Array.isArray(attrs.$positional)) return attrs.$positional[0]
|
|
11
|
+
if (attrs && Object.prototype.hasOwnProperty.call(attrs, '1')) return attrs['1']
|
|
12
|
+
return undefined
|
|
13
|
+
}
|
package/lib/html.js
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
// HTML construction helpers for extensions that emit markup through a `pass`
|
|
4
|
+
// block. Shared so the escaping rule below is decided once, in one place,
|
|
5
|
+
// rather than re-derived (or forgotten) by each extension that interpolates
|
|
6
|
+
// authored text into a string of HTML.
|
|
7
|
+
//
|
|
8
|
+
// WHAT NEEDS ESCAPING, AND WHAT MUST NOT BE
|
|
9
|
+
//
|
|
10
|
+
// Asciidoctor applies its `specialcharacters` substitution to some authored
|
|
11
|
+
// strings before an extension ever sees them, and to others not at all.
|
|
12
|
+
// Escaping a string that was already substituted double-encodes it (`&` shows
|
|
13
|
+
// up as `&` on the page); failing to escape one that was not lets authored
|
|
14
|
+
// text inject markup. So the distinction has to be exact. Measured against
|
|
15
|
+
// BOTH majors this repo runs — 2.2 via Antora for site builds, 4.0 for the
|
|
16
|
+
// ui-bundle preview harness — with `A & B <x>` as the probe value:
|
|
17
|
+
//
|
|
18
|
+
// source arrives as escape?
|
|
19
|
+
// ---------------------------------------- -------------------- -------
|
|
20
|
+
// inline macro, positional `x:t[A & B]` `A & B <x>` NO
|
|
21
|
+
// inline macro, named `x:t[k="A & B"]` `A & B <x>` NO
|
|
22
|
+
// document attribute `:page-k: A & B` `A & B <x>` NO
|
|
23
|
+
// block macro, positional `x::t[A & B]` `A & B <x>` YES
|
|
24
|
+
// block macro, named `x::t[k="A & B"]` `A & B <x>` YES
|
|
25
|
+
// block style attribute `[x,k="A & B"]` `A & B <x>` YES
|
|
26
|
+
//
|
|
27
|
+
// Identical results on 2.2.9 and 4.0.8 — this is a block-vs-inline split, not
|
|
28
|
+
// a version split, so one rule covers both.
|
|
29
|
+
//
|
|
30
|
+
// Separately, and just as important: anything obtained from `getText()` on a
|
|
31
|
+
// Block or a ListItem is ALREADY CONVERTED HTML, not text — a dlist term
|
|
32
|
+
// carrying an `xref:` arrives as `<a href="some.html">Link <strong>bold</strong>
|
|
33
|
+
// & co</a>`. Escaping that would render the anchor as visible source. Never
|
|
34
|
+
// pass converted output through `escapeHtml`; it is for raw attribute values.
|
|
35
|
+
//
|
|
36
|
+
// Rule of thumb, and the one the README states: escape every value read off a
|
|
37
|
+
// BLOCK's attributes; escape nothing that came from an inline macro, a document
|
|
38
|
+
// attribute, or `getText()`.
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Characters that must not survive unescaped into an HTML text node or a
|
|
42
|
+
* double-quoted attribute value.
|
|
43
|
+
*
|
|
44
|
+
* `"` is included because {@link attr} places values inside double quotes;
|
|
45
|
+
* `'` is not needed for that but is escaped anyway, so a single value is safe
|
|
46
|
+
* in either quoting style.
|
|
47
|
+
*/
|
|
48
|
+
const ESCAPES = {
|
|
49
|
+
'&': '&',
|
|
50
|
+
'<': '<',
|
|
51
|
+
'>': '>',
|
|
52
|
+
'"': '"',
|
|
53
|
+
"'": ''',
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const ESCAPE_RX = /[&<>"']/g
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Escapes a RAW authored string for interpolation into HTML.
|
|
60
|
+
*
|
|
61
|
+
* `&` is replaced first by virtue of the single pass — a naive sequence of
|
|
62
|
+
* `.replace()` calls would re-escape the ampersands it had just introduced.
|
|
63
|
+
*
|
|
64
|
+
* @param {unknown} value - the raw value; `null`/`undefined` become `''`, so a
|
|
65
|
+
* missing optional attribute needs no separate check at the call site.
|
|
66
|
+
* @returns {string} the escaped string, safe in a text node or a quoted
|
|
67
|
+
* attribute value.
|
|
68
|
+
*/
|
|
69
|
+
function escapeHtml(value) {
|
|
70
|
+
if (value == null) return ''
|
|
71
|
+
return String(value).replace(ESCAPE_RX, (char) => ESCAPES[/** @type {keyof typeof ESCAPES} */ (char)])
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Builds a single HTML attribute, escaped, or the empty string when the value
|
|
76
|
+
* is absent — so optional attributes can be concatenated unconditionally
|
|
77
|
+
* instead of guarded one by one at the call site:
|
|
78
|
+
*
|
|
79
|
+
* ```js
|
|
80
|
+
* '<a' + attr('href', url) + attr('aria-label', label) + '>'
|
|
81
|
+
* ```
|
|
82
|
+
*
|
|
83
|
+
* An empty string IS emitted (`attr('alt', '')` → ` alt=""`), because an empty
|
|
84
|
+
* `alt` is meaningful — it marks a decorative image. Only `null`/`undefined`
|
|
85
|
+
* drop the attribute entirely.
|
|
86
|
+
*
|
|
87
|
+
* @param {string} name - the attribute name; assumed to be a literal from the
|
|
88
|
+
* extension itself, never authored input, so it is not escaped.
|
|
89
|
+
* @param {unknown} value - the raw attribute value.
|
|
90
|
+
* @returns {string} ` name="value"`, leading space included, or `''`.
|
|
91
|
+
*/
|
|
92
|
+
function attr(name, value) {
|
|
93
|
+
if (value == null) return ''
|
|
94
|
+
return ' ' + name + '="' + escapeHtml(value) + '"'
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const TAG_RX = /<[^>]*>/g
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Strips every HTML tag from a string, looping to a fixed point rather than
|
|
101
|
+
* a single `.replace()` pass — a single pass can be bypassed by
|
|
102
|
+
* overlapping/nested angle brackets (e.g. removing the inner tag out of
|
|
103
|
+
* `<scr<script>ipt>` can leave a well-formed `<script>` behind, since that
|
|
104
|
+
* one pass only ever removes the leftmost, shortest match). Used wherever
|
|
105
|
+
* converted HTML (not raw author input) needs to become plain text — an ARIA
|
|
106
|
+
* label, a title used as a `data-` attribute, and the like.
|
|
107
|
+
*
|
|
108
|
+
* @param {string} html
|
|
109
|
+
* @returns {string}
|
|
110
|
+
*/
|
|
111
|
+
function stripTags(html) {
|
|
112
|
+
let previous
|
|
113
|
+
let current = html
|
|
114
|
+
do {
|
|
115
|
+
previous = current
|
|
116
|
+
current = previous.replace(TAG_RX, '')
|
|
117
|
+
} while (current !== previous)
|
|
118
|
+
return current
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
module.exports = { escapeHtml, attr, stripTags }
|