@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,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 `&amp;` 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 &amp; B &lt;x&gt;` NO
21
+ // inline macro, named `x:t[k="A & B"]` `A &amp; B &lt;x&gt;` NO
22
+ // document attribute `:page-k: A & B` `A &amp; B &lt;x&gt;` 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
+ // &amp; 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
+ '&': '&amp;',
50
+ '<': '&lt;',
51
+ '>': '&gt;',
52
+ '"': '&quot;',
53
+ "'": '&#39;',
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 }