@inditextech/docouture-asciidoc-extensions 1.1.0 → 1.2.0-SNAPSHOT.135.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 +6 -3
- package/lib/accordion.js +11 -8
- package/lib/accordion.spec.js +1 -0
- package/lib/card-grid.js +22 -19
- package/lib/card-grid.spec.js +70 -0
- package/lib/cta.js +22 -15
- package/lib/cta.spec.js +59 -0
- package/lib/feature-tabs.js +16 -13
- package/lib/feature-tabs.spec.js +38 -0
- package/lib/first-positional.js +1 -1
- package/lib/html.js +22 -2
- package/lib/kroki-config.js +14 -3
- package/lib/kroki-instance.js +1 -1
- package/lib/kroki.js +5 -5
- package/lib/kroki.spec.js +27 -0
- package/lib/label-macro.js +13 -14
- package/lib/label-macro.spec.js +24 -0
- package/lib/mono-macro.js +10 -7
- package/lib/nowrap-cols.js +1 -1
- package/lib/nowrap-cols.spec.js +27 -0
- package/lib/shiki-syntax-highlighter.js +1 -1
- package/lib/table-container.js +64 -48
- package/lib/table-width.js +1 -1
- package/lib/tabs.js +12 -9
- package/lib/tabs.spec.js +43 -0
- package/lib/warn.js +44 -13
- package/lib/warn.spec.js +44 -26
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -139,8 +139,8 @@ Antora's — treat them as a typing aid, not as proof that an API exists in 2.2.
|
|
|
139
139
|
|
|
140
140
|
## Reporting authoring mistakes
|
|
141
141
|
|
|
142
|
-
|
|
143
|
-
the build
|
|
142
|
+
Scaffolded playbooks set `runtime.log.failure_level: error`, so **an error fails
|
|
143
|
+
the build** and a warning does not. Log authoring mistakes as errors: an unknown label
|
|
144
144
|
colour, a `[cards]` block with no cards, a tab set with no panels all render as
|
|
145
145
|
something plausible but wrong, and shipping that is worse than not building.
|
|
146
146
|
|
|
@@ -148,10 +148,13 @@ Use `lib/warn.js` so every extension reports in the same shape — what the auth
|
|
|
148
148
|
wrote, what is wrong, and what was expected:
|
|
149
149
|
|
|
150
150
|
```js
|
|
151
|
-
|
|
151
|
+
error(parent, 'label:mauve[]', 'unknown IDS Label variant "mauve"', VARIANTS)
|
|
152
152
|
// → label:mauve[] — unknown IDS Label variant "mauve"; expected one of white, grey, …
|
|
153
153
|
```
|
|
154
154
|
|
|
155
|
+
Output that is still correct but degraded ("only the first image is rendered")
|
|
156
|
+
goes through `notice()` instead, which logs at `warn` and does not fail the build.
|
|
157
|
+
|
|
155
158
|
## Using this package in a site
|
|
156
159
|
|
|
157
160
|
Two steps, both required. Doing only one fails silently.
|
package/lib/accordion.js
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
const { chain, chainAll, precomputeSubtree } = require('./async-compat')
|
|
8
8
|
const { escapeHtml, attr, stripTags } = require('./html')
|
|
9
9
|
const uniqueId = require('./unique-id')
|
|
10
|
-
const
|
|
10
|
+
const { error } = require('./warn')
|
|
11
11
|
|
|
12
12
|
// Accordion grouping — GH-61 Part 2. Adds only what a native `[%collapsible]`
|
|
13
13
|
// cannot express on its own: group semantics (`role=group`, an accessible
|
|
@@ -170,12 +170,12 @@ function collapsibleChildren(wrapper) {
|
|
|
170
170
|
function finish(parent, wrapper, attrs, self) {
|
|
171
171
|
const children = collapsibleChildren(wrapper)
|
|
172
172
|
if (!children.length) {
|
|
173
|
-
|
|
173
|
+
error(parent, '[accordion]', 'an accordion group with no `[%collapsible]` items in it')
|
|
174
174
|
}
|
|
175
175
|
|
|
176
176
|
const ariaLabel = ariaLabelFor(attrs, wrapper)
|
|
177
177
|
if (!ariaLabel) {
|
|
178
|
-
|
|
178
|
+
error(
|
|
179
179
|
parent,
|
|
180
180
|
'[accordion]',
|
|
181
181
|
'an accordion group has no `aria-label=` and no title; give it one — a `role=group` with no accessible name is a strongly-discouraged pattern for screen reader users'
|
|
@@ -194,7 +194,7 @@ function finish(parent, wrapper, attrs, self) {
|
|
|
194
194
|
.map((html, index) => {
|
|
195
195
|
if (!groupName) return html
|
|
196
196
|
if (!DETAILS_OPEN_RX.test(html)) {
|
|
197
|
-
|
|
197
|
+
error(
|
|
198
198
|
parent,
|
|
199
199
|
'[accordion]',
|
|
200
200
|
'child ' + (index + 1) + " didn't render as a <details>; single-open needs every item to be one"
|
|
@@ -226,9 +226,12 @@ function accordionBlock() {
|
|
|
226
226
|
// See steps.js's own comment: a literal JS `null` "source" crashes Opal
|
|
227
227
|
// (2.2) inside `Block#initialize`'s `.nil_or_empty?()` check.
|
|
228
228
|
const wrapper = this.createBlock(parent, 'open', '', attrs)
|
|
229
|
-
//
|
|
230
|
-
//
|
|
231
|
-
|
|
229
|
+
// No `self = this` needed: this callback is already an arrow
|
|
230
|
+
// function (see this.process(...) below), so `this` here is
|
|
231
|
+
// already the outer block processor's own `this` (Asciidoctor
|
|
232
|
+
// never rebinds it the way it does inside a plain `function`
|
|
233
|
+
// callback — see label-macro.js's own comment on that different
|
|
234
|
+
// case) — passed straight through to finish() below.
|
|
232
235
|
// See async-compat.js's own header comment: parseContent is sync under
|
|
233
236
|
// 2.2 (Opal, real Antora builds) and Promise-returning under 4.0 (the
|
|
234
237
|
// ui-bundle preview harness) — chain() handles either without making
|
|
@@ -236,7 +239,7 @@ function accordionBlock() {
|
|
|
236
239
|
// a `.Title` carrying inline markup (this block's own, or a child
|
|
237
240
|
// `[%collapsible]`'s) arrive converted rather than raw.
|
|
238
241
|
return chain(this.parseContent(wrapper, reader.getLines()), () =>
|
|
239
|
-
chain(precomputeSubtree(wrapper), () => finish(parent, wrapper, attrs,
|
|
242
|
+
chain(precomputeSubtree(wrapper), () => finish(parent, wrapper, attrs, this))
|
|
240
243
|
)
|
|
241
244
|
})
|
|
242
245
|
}
|
package/lib/accordion.spec.js
CHANGED
|
@@ -99,6 +99,7 @@ Just a stray paragraph.
|
|
|
99
99
|
.map((m) => m.getText())
|
|
100
100
|
.join('\n')
|
|
101
101
|
expect(messages).toContain('an accordion group with no `[%collapsible]` items in it')
|
|
102
|
+
expect(logger.getMessages().every((m) => m.getSeverity().toString() === 'ERROR')).toBe(true)
|
|
102
103
|
})
|
|
103
104
|
|
|
104
105
|
it('warns when a group has no aria-label and no title', async () => {
|
package/lib/card-grid.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
const { chain, chainAll, precomputeSubtree } = require('./async-compat')
|
|
8
8
|
const { escapeHtml } = require('./html')
|
|
9
|
-
const
|
|
9
|
+
const { error, notice } = require('./warn')
|
|
10
10
|
|
|
11
11
|
// Card grid — the ATD Card (Figma component set 2669:33855, states and types
|
|
12
12
|
// at 2610:29000 for light and 2669:42541 for dark), rendered as IDS Card
|
|
@@ -85,7 +85,7 @@ const BREAKPOINTS = ['s', 'm', 'l']
|
|
|
85
85
|
const DEFAULT_COLUMNS = '1 s:2 m:3'
|
|
86
86
|
const MAX_COLUMNS = 4
|
|
87
87
|
|
|
88
|
-
const COLUMN_RX = /^(?:([a-z]+):)?(
|
|
88
|
+
const COLUMN_RX = /^(?:([a-z]+):)?(\d+)$/
|
|
89
89
|
// A bare Lucide icon name (lucide.dev/icons/<name>), lowercase and
|
|
90
90
|
// hyphen-separated — the shape `icons.yml` uses. Whether that icon is
|
|
91
91
|
// actually MASKED is checked by ui-bundle's own `just icons-build`
|
|
@@ -120,17 +120,17 @@ function parseColumns(spec, parent) {
|
|
|
120
120
|
if (!token) continue
|
|
121
121
|
const match = COLUMN_RX.exec(token)
|
|
122
122
|
if (!match) {
|
|
123
|
-
|
|
123
|
+
error(parent, '[cards,columns="' + spec + '"]', 'cannot read the column "' + token + '"; expected `3` or `m:3`')
|
|
124
124
|
continue
|
|
125
125
|
}
|
|
126
126
|
const [, breakpoint, count] = match
|
|
127
127
|
if (breakpoint && !BREAKPOINTS.includes(breakpoint)) {
|
|
128
|
-
|
|
128
|
+
error(parent, '[cards,columns="' + spec + '"]', 'unknown breakpoint "' + breakpoint + '"', BREAKPOINTS)
|
|
129
129
|
continue
|
|
130
130
|
}
|
|
131
131
|
const columns = Number(count)
|
|
132
132
|
if (columns < 1 || columns > MAX_COLUMNS) {
|
|
133
|
-
|
|
133
|
+
error(
|
|
134
134
|
parent,
|
|
135
135
|
'[cards,columns="' + spec + '"]',
|
|
136
136
|
columns + ' columns is outside the supported range 1-' + MAX_COLUMNS
|
|
@@ -161,7 +161,7 @@ function renderHeader(icon, subheader, parent) {
|
|
|
161
161
|
if (ICON_RX.test(icon)) {
|
|
162
162
|
iconHtml = '<span class="docouture-card__icon dt-icon-mask--' + escapeHtml(icon) + '" aria-hidden="true"></span>'
|
|
163
163
|
} else {
|
|
164
|
-
|
|
164
|
+
error(
|
|
165
165
|
parent,
|
|
166
166
|
'[card,icon="' + icon + '"]',
|
|
167
167
|
'not an icon reference; expected a bare Lucide icon name, e.g. `store`'
|
|
@@ -221,13 +221,13 @@ function renderCard(block, type, parent) {
|
|
|
221
221
|
// already safe.
|
|
222
222
|
const title = block.getTitle()
|
|
223
223
|
if (!title) {
|
|
224
|
-
|
|
224
|
+
error(parent, '[card]', 'a card has no title; give it a `.Title` line carrying the link')
|
|
225
225
|
return ''
|
|
226
226
|
}
|
|
227
227
|
|
|
228
228
|
const href = (HREF_RX.exec(title) || [])[1]
|
|
229
229
|
if (!href) {
|
|
230
|
-
|
|
230
|
+
error(
|
|
231
231
|
parent,
|
|
232
232
|
'[card] ' + block.getAttribute('title'),
|
|
233
233
|
'a card title carries no link; make it an `xref:` or a `link:`'
|
|
@@ -236,17 +236,17 @@ function renderCard(block, type, parent) {
|
|
|
236
236
|
|
|
237
237
|
const { images, bodies } = cardParts(block)
|
|
238
238
|
if (type === 'no-image' && images.length) {
|
|
239
|
-
|
|
239
|
+
error(parent, '[card]', 'this card has an image but the block is `type=' + type + '`', TYPES.slice(1))
|
|
240
240
|
}
|
|
241
241
|
if (type !== 'no-image' && !images.length) {
|
|
242
|
-
|
|
242
|
+
error(parent, '[card]', 'a `type=' + type + '` card needs an `image::` of its own')
|
|
243
243
|
}
|
|
244
244
|
if (images.length > 1) {
|
|
245
|
-
|
|
245
|
+
notice(parent, '[card]', 'a card has ' + images.length + ' images; only the first is rendered')
|
|
246
246
|
}
|
|
247
247
|
for (const body of bodies) {
|
|
248
248
|
if (body.getContext() !== 'paragraph') {
|
|
249
|
-
|
|
249
|
+
error(parent, '[card]', 'a card body holds a ' + body.getContext() + ' block; only paragraphs are supported')
|
|
250
250
|
}
|
|
251
251
|
}
|
|
252
252
|
|
|
@@ -275,17 +275,17 @@ function renderCard(block, type, parent) {
|
|
|
275
275
|
function finish(parent, wrapper, attrs, self) {
|
|
276
276
|
const type = attrs.type || DEFAULT_TYPE
|
|
277
277
|
if (!TYPES.includes(type)) {
|
|
278
|
-
|
|
278
|
+
error(parent, '[cards,type=' + attrs.type + ']', 'unknown card type "' + attrs.type + '"', TYPES)
|
|
279
279
|
}
|
|
280
280
|
|
|
281
281
|
const width = attrs.width || DEFAULT_WIDTH
|
|
282
282
|
if (!WIDTHS.includes(width)) {
|
|
283
|
-
|
|
283
|
+
error(parent, '[cards,width=' + attrs.width + ']', 'unknown width "' + attrs.width + '"', WIDTHS)
|
|
284
284
|
}
|
|
285
285
|
|
|
286
286
|
const cards = wrapper.getBlocks().filter((block) => block.getStyle() === 'card')
|
|
287
287
|
if (!cards.length) {
|
|
288
|
-
|
|
288
|
+
error(parent, '[cards]', 'a cards block with no `[card]` in it')
|
|
289
289
|
}
|
|
290
290
|
|
|
291
291
|
const cardHtmls = cards.map((card) => renderCard(card, TYPES.includes(type) ? type : DEFAULT_TYPE, parent))
|
|
@@ -310,15 +310,18 @@ function cardsBlock() {
|
|
|
310
310
|
// See steps.js's own comment: a literal JS `null` "source" crashes Opal
|
|
311
311
|
// (2.2) inside `Block#initialize`'s `.nil_or_empty?()` check.
|
|
312
312
|
const wrapper = this.createBlock(parent, 'open', '', attrs)
|
|
313
|
-
//
|
|
314
|
-
//
|
|
315
|
-
|
|
313
|
+
// No `self = this` needed: this callback is already an arrow
|
|
314
|
+
// function (see this.process(...) below), so `this` here is
|
|
315
|
+
// already the outer block processor's own `this` (Asciidoctor
|
|
316
|
+
// never rebinds it the way it does inside a plain `function`
|
|
317
|
+
// callback — see label-macro.js's own comment on that different
|
|
318
|
+
// case) — passed straight through to finish() below.
|
|
316
319
|
// See async-compat.js's own header comment: parseContent is sync under
|
|
317
320
|
// 2.2 (Opal, real Antora builds) and Promise-returning under 4.0 (the
|
|
318
321
|
// ui-bundle preview harness) — chain() handles either without making
|
|
319
322
|
// this function `async` unconditionally.
|
|
320
323
|
return chain(this.parseContent(wrapper, reader.getLines()), () =>
|
|
321
|
-
chain(precomputeSubtree(wrapper), () => finish(parent, wrapper, attrs,
|
|
324
|
+
chain(precomputeSubtree(wrapper), () => finish(parent, wrapper, attrs, this))
|
|
322
325
|
)
|
|
323
326
|
})
|
|
324
327
|
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 INDUSTRIA DE DISEÑO TEXTIL S.A. (INDITEX S.A.)
|
|
2
|
+
//
|
|
3
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
|
|
5
|
+
'use strict'
|
|
6
|
+
|
|
7
|
+
import { describe, expect, it } from 'vitest'
|
|
8
|
+
|
|
9
|
+
const asciidoctor = require('@asciidoctor/core')
|
|
10
|
+
const registerCardGrid = require('./card-grid')
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* @param {string} source
|
|
14
|
+
* @returns {Promise<{ html: string, messages: { severity: string, text: string }[] }>}
|
|
15
|
+
*/
|
|
16
|
+
async function run(source) {
|
|
17
|
+
const logger = asciidoctor.MemoryLogger.create()
|
|
18
|
+
asciidoctor.LoggerManager.setLogger(logger)
|
|
19
|
+
const registry = asciidoctor.Extensions.create()
|
|
20
|
+
registerCardGrid(registry)
|
|
21
|
+
const html = await asciidoctor.convert(source, { extension_registry: registry, safe: 'safe' })
|
|
22
|
+
const messages = logger.getMessages().map((m) => ({ severity: m.getSeverity().toString(), text: m.getText() }))
|
|
23
|
+
return { html: String(html), messages }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const errorsOf = (messages) => messages.filter((m) => m.severity === 'ERROR').map((m) => m.text)
|
|
27
|
+
|
|
28
|
+
describe('cards block severities', () => {
|
|
29
|
+
it('errors on an unknown type, width and an empty block', async () => {
|
|
30
|
+
const { messages } = await run('[cards,type=bogus,width=huge]\n====\nJust text.\n====\n')
|
|
31
|
+
const text = errorsOf(messages).join('\n')
|
|
32
|
+
expect(text).toContain('unknown card type "bogus"')
|
|
33
|
+
expect(text).toContain('unknown width "huge"')
|
|
34
|
+
expect(text).toContain('a cards block with no `[card]` in it')
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('errors on unreadable columns, an unknown breakpoint and an out-of-range count', async () => {
|
|
38
|
+
const { messages } = await run('[cards,columns="x zz:3 m:99"]\n====\nJust text.\n====\n')
|
|
39
|
+
const text = errorsOf(messages).join('\n')
|
|
40
|
+
expect(text).toContain('cannot read the column "x"')
|
|
41
|
+
expect(text).toContain('unknown breakpoint "zz"')
|
|
42
|
+
expect(text).toContain('outside the supported range')
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('errors on a card without a title and on a title without a link', async () => {
|
|
46
|
+
const { messages } = await run(
|
|
47
|
+
'[cards,type=no-image]\n====\n[card]\nNo title here.\n\n[card]\n.Plain title\nBody.\n====\n'
|
|
48
|
+
)
|
|
49
|
+
const text = errorsOf(messages).join('\n')
|
|
50
|
+
expect(text).toContain('a card has no title')
|
|
51
|
+
expect(text).toContain('a card title carries no link')
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('errors when an image does not match the card type, or is missing', async () => {
|
|
55
|
+
const withImage = await run(
|
|
56
|
+
'[cards,type=no-image]\n====\n[card]\n.link:https://example.com[A]\n--\nimage::a.png[A]\n--\n====\n'
|
|
57
|
+
)
|
|
58
|
+
expect(errorsOf(withImage.messages).join('\n')).toContain('this card has an image but the block is `type=no-image`')
|
|
59
|
+
const missing = await run('[cards,type=image-square]\n====\n[card]\n.link:https://example.com[A]\nBody.\n====\n')
|
|
60
|
+
expect(errorsOf(missing.messages).join('\n')).toContain('needs an `image::` of its own')
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('notices extra images (warn, not error) and errors on a non-paragraph body', async () => {
|
|
64
|
+
const { messages } = await run(
|
|
65
|
+
'[cards,type=image-square]\n====\n[card]\n.link:https://example.com[A]\n--\nimage::a.png[A]\nimage::b.png[B]\n\n* a list\n--\n====\n'
|
|
66
|
+
)
|
|
67
|
+
expect(messages.some((m) => m.severity === 'WARN' && m.text.includes('only the first is rendered'))).toBe(true)
|
|
68
|
+
expect(errorsOf(messages).join('\n')).toContain('only paragraphs are supported')
|
|
69
|
+
})
|
|
70
|
+
})
|
package/lib/cta.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
const { chain, chainAll, precomputeSubtree } = require('./async-compat')
|
|
8
8
|
const { escapeHtml, attr } = require('./html')
|
|
9
|
-
const
|
|
9
|
+
const { error, notice } = require('./warn')
|
|
10
10
|
|
|
11
11
|
// Call to action — a full-width band closing out a landing section, styled
|
|
12
12
|
// after Fumadocs' "Free & Open Source" block on the Weave.js docs home
|
|
@@ -150,13 +150,13 @@ function splitImages(images, parent) {
|
|
|
150
150
|
const dark = images.filter((image) => image.hasRole(DARK_ROLE))
|
|
151
151
|
const light = images.filter((image) => !image.hasRole(DARK_ROLE))
|
|
152
152
|
if (light.length > 1) {
|
|
153
|
-
|
|
153
|
+
notice(parent, '[cta]', 'a cta has ' + light.length + ' mark images; only the first is rendered')
|
|
154
154
|
}
|
|
155
155
|
if (dark.length > 1) {
|
|
156
|
-
|
|
156
|
+
notice(parent, '[cta]', 'a cta has ' + dark.length + ' `role=dark` mark images; only the first is rendered')
|
|
157
157
|
}
|
|
158
158
|
if (dark.length && !light.length) {
|
|
159
|
-
|
|
159
|
+
error(parent, '[cta]', 'a cta has a `role=dark` mark image but no mark for the light theme')
|
|
160
160
|
}
|
|
161
161
|
return { light: light[0], dark: dark[0] }
|
|
162
162
|
}
|
|
@@ -198,7 +198,7 @@ function renderAction(converted, variant, role, parent) {
|
|
|
198
198
|
if (!converted) return ''
|
|
199
199
|
const match = CTA_RX.exec(converted)
|
|
200
200
|
if (!match) {
|
|
201
|
-
|
|
201
|
+
error(parent, '[.' + role + ']', 'a cta action carries no link; make it an `xref:` or a `link:`')
|
|
202
202
|
return ''
|
|
203
203
|
}
|
|
204
204
|
const href = match[1] || ''
|
|
@@ -235,7 +235,7 @@ function renderAction(converted, variant, role, parent) {
|
|
|
235
235
|
function finish(parent, wrapper, attrs, self) {
|
|
236
236
|
const align = attrs.align || DEFAULT_ALIGN
|
|
237
237
|
if (!ALIGNMENTS.includes(align)) {
|
|
238
|
-
|
|
238
|
+
error(parent, '[cta,align=' + attrs.align + ']', 'unknown alignment "' + attrs.align + '"', ALIGNMENTS)
|
|
239
239
|
}
|
|
240
240
|
|
|
241
241
|
// A block-style attribute, so raw, so escaped — see the header comment on
|
|
@@ -245,20 +245,24 @@ function finish(parent, wrapper, attrs, self) {
|
|
|
245
245
|
|
|
246
246
|
const { images, primaries, secondaries, bodies } = ctaParts(wrapper)
|
|
247
247
|
if (!primaries.length) {
|
|
248
|
-
|
|
248
|
+
error(parent, '[cta]', 'a cta has no `[.primary]` action')
|
|
249
249
|
}
|
|
250
250
|
if (primaries.length > 1) {
|
|
251
|
-
|
|
251
|
+
notice(parent, '[cta]', 'a cta has ' + primaries.length + ' `[.primary]` paragraphs; only the first is rendered')
|
|
252
252
|
}
|
|
253
253
|
if (secondaries.length > 1) {
|
|
254
|
-
|
|
254
|
+
notice(
|
|
255
|
+
parent,
|
|
256
|
+
'[cta]',
|
|
257
|
+
'a cta has ' + secondaries.length + ' `[.secondary]` paragraphs; only the first is rendered'
|
|
258
|
+
)
|
|
255
259
|
}
|
|
256
260
|
if (secondaries.length && !primaries.length) {
|
|
257
|
-
|
|
261
|
+
error(parent, '[cta]', 'a cta has a `[.secondary]` action but no `[.primary]` one')
|
|
258
262
|
}
|
|
259
263
|
for (const body of bodies) {
|
|
260
264
|
if (body.getContext() !== 'paragraph') {
|
|
261
|
-
|
|
265
|
+
error(parent, '[cta]', 'a cta body holds a ' + body.getContext() + ' block; only paragraphs are supported')
|
|
262
266
|
}
|
|
263
267
|
}
|
|
264
268
|
const { light, dark } = splitImages(images, parent)
|
|
@@ -314,16 +318,19 @@ function ctaBlock() {
|
|
|
314
318
|
// See steps.js's own comment: a literal JS `null` "source" crashes Opal
|
|
315
319
|
// (2.2) inside `Block#initialize`'s `.nil_or_empty?()` check.
|
|
316
320
|
const wrapper = this.createBlock(parent, 'open', '', attrs)
|
|
317
|
-
//
|
|
318
|
-
//
|
|
319
|
-
|
|
321
|
+
// No `self = this` needed: this callback is already an arrow
|
|
322
|
+
// function (see this.process(...) below), so `this` here is
|
|
323
|
+
// already the outer block processor's own `this` (Asciidoctor
|
|
324
|
+
// never rebinds it the way it does inside a plain `function`
|
|
325
|
+
// callback — see label-macro.js's own comment on that different
|
|
326
|
+
// case) — passed straight through to finish() below.
|
|
320
327
|
// See async-compat.js's own header comment: parseContent is sync under
|
|
321
328
|
// 2.2 (Opal, real Antora builds) and Promise-returning under 4.0 (the
|
|
322
329
|
// ui-bundle preview harness) — chain() handles either without making
|
|
323
330
|
// this function `async` unconditionally. precomputeSubtree is what makes
|
|
324
331
|
// a `.Title` carrying inline markup arrive converted rather than raw.
|
|
325
332
|
return chain(this.parseContent(wrapper, reader.getLines()), () =>
|
|
326
|
-
chain(precomputeSubtree(wrapper), () => finish(parent, wrapper, attrs,
|
|
333
|
+
chain(precomputeSubtree(wrapper), () => finish(parent, wrapper, attrs, this))
|
|
327
334
|
)
|
|
328
335
|
})
|
|
329
336
|
}
|
package/lib/cta.spec.js
CHANGED
|
@@ -179,4 +179,63 @@ https://example.com[Go]
|
|
|
179
179
|
.join('\n')
|
|
180
180
|
expect(messages).toContain('a cta body holds a')
|
|
181
181
|
})
|
|
182
|
+
|
|
183
|
+
it('logs a notice (warn, not error) when extra mark images are ignored', async () => {
|
|
184
|
+
const logger = asciidoctor.MemoryLogger.create()
|
|
185
|
+
await convert(
|
|
186
|
+
`[cta,title="T"]
|
|
187
|
+
====
|
|
188
|
+
image::a.svg[A]
|
|
189
|
+
image::b.svg[B]
|
|
190
|
+
|
|
191
|
+
[.primary]
|
|
192
|
+
https://example.com[Go]
|
|
193
|
+
====
|
|
194
|
+
`,
|
|
195
|
+
{ logger }
|
|
196
|
+
)
|
|
197
|
+
const msgs = logger.getMessages()
|
|
198
|
+
expect(msgs.length).toBeGreaterThan(0)
|
|
199
|
+
expect(msgs.every((m) => m.getSeverity().toString() === 'WARN')).toBe(true)
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
/** @param {string} source @returns {Promise<string[]>} severities logged */
|
|
203
|
+
async function severities(source) {
|
|
204
|
+
const logger = asciidoctor.MemoryLogger.create()
|
|
205
|
+
await convert(source, { logger })
|
|
206
|
+
return logger.getMessages().map((m) => m.getSeverity().toString())
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
it('errors on a dark-only mark and notices extra dark marks, extra primaries and secondaries', async () => {
|
|
210
|
+
const dark = await severities(`[cta,title="T"]
|
|
211
|
+
====
|
|
212
|
+
image::d.svg[role=dark]
|
|
213
|
+
|
|
214
|
+
[.primary]
|
|
215
|
+
https://example.com[Go]
|
|
216
|
+
====
|
|
217
|
+
`)
|
|
218
|
+
expect(dark).toContain('ERROR')
|
|
219
|
+
const extras = await severities(`[cta,title="T"]
|
|
220
|
+
====
|
|
221
|
+
image::a.svg[A]
|
|
222
|
+
image::d1.svg[role=dark]
|
|
223
|
+
image::d2.svg[role=dark]
|
|
224
|
+
|
|
225
|
+
[.primary]
|
|
226
|
+
https://example.com[One]
|
|
227
|
+
|
|
228
|
+
[.primary]
|
|
229
|
+
https://example.com[Two]
|
|
230
|
+
|
|
231
|
+
[.secondary]
|
|
232
|
+
https://example.com[S1]
|
|
233
|
+
|
|
234
|
+
[.secondary]
|
|
235
|
+
https://example.com[S2]
|
|
236
|
+
====
|
|
237
|
+
`)
|
|
238
|
+
expect(extras).toHaveLength(3)
|
|
239
|
+
expect(extras.every((x) => x === 'WARN')).toBe(true)
|
|
240
|
+
})
|
|
182
241
|
})
|
package/lib/feature-tabs.js
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
const { chain, chainAll, precomputeSubtree } = require('./async-compat')
|
|
8
8
|
const { escapeHtml, attr } = require('./html')
|
|
9
9
|
const uniqueId = require('./unique-id')
|
|
10
|
-
const
|
|
10
|
+
const { error, notice } = require('./warn')
|
|
11
11
|
|
|
12
12
|
// Feature tabs — the landing's "Key features" switcher (GH-22, Figma
|
|
13
13
|
// 2696:54801 at l, 2696:92583 at m, 2696:94328 at s, 2696:95691 at xs).
|
|
@@ -167,13 +167,13 @@ function splitImages(images, parent) {
|
|
|
167
167
|
const dark = images.filter((image) => image.hasRole(DARK_ROLE))
|
|
168
168
|
const light = images.filter((image) => !image.hasRole(DARK_ROLE))
|
|
169
169
|
if (light.length > 1) {
|
|
170
|
-
|
|
170
|
+
notice(parent, '[feature]', 'a slide has ' + light.length + ' images; only the first is rendered')
|
|
171
171
|
}
|
|
172
172
|
if (dark.length > 1) {
|
|
173
|
-
|
|
173
|
+
notice(parent, '[feature]', 'a slide has ' + dark.length + ' `role=dark` images; only the first is rendered')
|
|
174
174
|
}
|
|
175
175
|
if (dark.length && !light.length) {
|
|
176
|
-
|
|
176
|
+
error(parent, '[feature]', 'a slide has a `role=dark` image but no image for the light theme')
|
|
177
177
|
}
|
|
178
178
|
return { light: light[0], dark: dark[0] }
|
|
179
179
|
}
|
|
@@ -221,7 +221,7 @@ function renderCta(converted, parent) {
|
|
|
221
221
|
if (!converted) return ''
|
|
222
222
|
const match = CTA_RX.exec(converted)
|
|
223
223
|
if (!match) {
|
|
224
|
-
|
|
224
|
+
error(parent, '[.cta]', "a slide's call to action carries no link; make it an `xref:` or a `link:`")
|
|
225
225
|
return ''
|
|
226
226
|
}
|
|
227
227
|
const href = match[1] || ''
|
|
@@ -266,17 +266,17 @@ function renderFeature(block, index, parent) {
|
|
|
266
266
|
// A block attribute, so raw, so escaped — the other half of that same rule.
|
|
267
267
|
const label = block.getAttribute('label')
|
|
268
268
|
if (!title && !label) {
|
|
269
|
-
|
|
269
|
+
error(parent, '[feature]', 'a slide has no label; give it a `.Title` line or a `label=` attribute')
|
|
270
270
|
return { tab: '', panel: '' }
|
|
271
271
|
}
|
|
272
272
|
const tabLabel = label ? escapeHtml(label) : title
|
|
273
273
|
|
|
274
274
|
const { images, ctas, bodies } = featureParts(block)
|
|
275
275
|
if (!images.length) {
|
|
276
|
-
|
|
276
|
+
error(parent, '[feature] ' + (label || block.getAttribute('title')), 'a slide has no `image::` of its own')
|
|
277
277
|
}
|
|
278
278
|
if (ctas.length > 1) {
|
|
279
|
-
|
|
279
|
+
notice(parent, '[feature]', 'a slide has ' + ctas.length + ' `[.cta]` paragraphs; only the first is rendered')
|
|
280
280
|
}
|
|
281
281
|
const { light, dark } = splitImages(images, parent)
|
|
282
282
|
|
|
@@ -349,7 +349,7 @@ function renderFeature(block, index, parent) {
|
|
|
349
349
|
function finish(parent, wrapper, attrs, self) {
|
|
350
350
|
const features = wrapper.getBlocks().filter((block) => block.getStyle() === 'feature')
|
|
351
351
|
if (!features.length) {
|
|
352
|
-
|
|
352
|
+
error(parent, '[feature-tabs]', 'a feature-tabs block with no `[feature]` in it')
|
|
353
353
|
}
|
|
354
354
|
|
|
355
355
|
const rendered = features.map((feature, index) => renderFeature(feature, index, parent))
|
|
@@ -381,16 +381,19 @@ function featureTabsBlock() {
|
|
|
381
381
|
// See steps.js's own comment: a literal JS `null` "source" crashes Opal
|
|
382
382
|
// (2.2) inside `Block#initialize`'s `.nil_or_empty?()` check.
|
|
383
383
|
const wrapper = this.createBlock(parent, 'open', '', attrs)
|
|
384
|
-
//
|
|
385
|
-
//
|
|
386
|
-
|
|
384
|
+
// No `self = this` needed: this callback is already an arrow
|
|
385
|
+
// function (see this.process(...) below), so `this` here is
|
|
386
|
+
// already the outer block processor's own `this` (Asciidoctor
|
|
387
|
+
// never rebinds it the way it does inside a plain `function`
|
|
388
|
+
// callback — see label-macro.js's own comment on that different
|
|
389
|
+
// case) — passed straight through to finish() below.
|
|
387
390
|
// See async-compat.js's own header comment: parseContent is sync under
|
|
388
391
|
// 2.2 (Opal, real Antora builds) and Promise-returning under 4.0 (the
|
|
389
392
|
// ui-bundle preview harness) — chain() handles either without making
|
|
390
393
|
// this function `async` unconditionally. precomputeSubtree is what makes
|
|
391
394
|
// a `.Title` carrying inline markup arrive converted rather than raw.
|
|
392
395
|
return chain(this.parseContent(wrapper, reader.getLines()), () =>
|
|
393
|
-
chain(precomputeSubtree(wrapper), () => finish(parent, wrapper, attrs,
|
|
396
|
+
chain(precomputeSubtree(wrapper), () => finish(parent, wrapper, attrs, this))
|
|
394
397
|
)
|
|
395
398
|
})
|
|
396
399
|
}
|
package/lib/feature-tabs.spec.js
CHANGED
|
@@ -175,4 +175,42 @@ Just a paragraph, no open block at all.
|
|
|
175
175
|
expect(html).toContain('>Prose slide</a>')
|
|
176
176
|
expect(html).toContain('Just a paragraph, no open block at all.')
|
|
177
177
|
})
|
|
178
|
+
|
|
179
|
+
/** @param {string} source @returns {Promise<string[]>} severities logged */
|
|
180
|
+
async function severities(source) {
|
|
181
|
+
const logger = asciidoctor.MemoryLogger.create()
|
|
182
|
+
await convert(source, { logger })
|
|
183
|
+
return logger.getMessages().map((m) => m.getSeverity().toString())
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
it('errors on a dark-only slide image and notices extra images and ctas', async () => {
|
|
187
|
+
const dark = await severities(`[feature-tabs]
|
|
188
|
+
====
|
|
189
|
+
[feature,label="Dark only"]
|
|
190
|
+
--
|
|
191
|
+
image::d.png[role=dark]
|
|
192
|
+
--
|
|
193
|
+
====
|
|
194
|
+
`)
|
|
195
|
+
expect(dark).toContain('ERROR')
|
|
196
|
+
const extras = await severities(`[feature-tabs]
|
|
197
|
+
====
|
|
198
|
+
[feature,label="Extras"]
|
|
199
|
+
--
|
|
200
|
+
image::a.png[A]
|
|
201
|
+
image::b.png[B]
|
|
202
|
+
image::d1.png[role=dark]
|
|
203
|
+
image::d2.png[role=dark]
|
|
204
|
+
|
|
205
|
+
[.cta]
|
|
206
|
+
https://example.com[One]
|
|
207
|
+
|
|
208
|
+
[.cta]
|
|
209
|
+
https://example.com[Two]
|
|
210
|
+
--
|
|
211
|
+
====
|
|
212
|
+
`)
|
|
213
|
+
expect(extras.length).toBeGreaterThanOrEqual(3)
|
|
214
|
+
expect(extras.every((x) => x === 'WARN')).toBe(true)
|
|
215
|
+
})
|
|
178
216
|
})
|
package/lib/first-positional.js
CHANGED
|
@@ -12,6 +12,6 @@
|
|
|
12
12
|
// Both are the attribute list's first positional entry.
|
|
13
13
|
module.exports = function firstPositional(attrs) {
|
|
14
14
|
if (attrs && Array.isArray(attrs.$positional)) return attrs.$positional[0]
|
|
15
|
-
if (attrs && Object.
|
|
15
|
+
if (attrs && Object.hasOwn(attrs, '1')) return attrs['1']
|
|
16
16
|
return undefined
|
|
17
17
|
}
|
package/lib/html.js
CHANGED
|
@@ -59,6 +59,16 @@ const ESCAPES = {
|
|
|
59
59
|
|
|
60
60
|
const ESCAPE_RX = /[&<>"']/g
|
|
61
61
|
|
|
62
|
+
// `value` is typed `unknown` below (see its own JSDoc) precisely because it
|
|
63
|
+
// arrives however Asciidoctor handed it over — in real use always a string,
|
|
64
|
+
// number or nullish (the `== null` guard below), never a plain object. This
|
|
65
|
+
// is what makes stringifying it SAFE in practice — `String()` alone can't
|
|
66
|
+
// prove that statically, and would silently produce `'[object Object]'` if
|
|
67
|
+
// it were ever wrong, so this narrows first instead.
|
|
68
|
+
function safeStringify(value) {
|
|
69
|
+
return typeof value === 'string' || typeof value === 'number' ? String(value) : JSON.stringify(value)
|
|
70
|
+
}
|
|
71
|
+
|
|
62
72
|
/**
|
|
63
73
|
* Escapes a RAW authored string for interpolation into HTML.
|
|
64
74
|
*
|
|
@@ -72,7 +82,7 @@ const ESCAPE_RX = /[&<>"']/g
|
|
|
72
82
|
*/
|
|
73
83
|
function escapeHtml(value) {
|
|
74
84
|
if (value == null) return ''
|
|
75
|
-
return
|
|
85
|
+
return safeStringify(value).replace(ESCAPE_RX, (char) => ESCAPES[/** @type {keyof typeof ESCAPES} */ (char)])
|
|
76
86
|
}
|
|
77
87
|
|
|
78
88
|
/**
|
|
@@ -112,13 +122,23 @@ const TAG_RX = /<[^>]*>/g
|
|
|
112
122
|
* @param {string} html
|
|
113
123
|
* @returns {string}
|
|
114
124
|
*/
|
|
125
|
+
// Capped rather than unbounded: each pass removes at least one tag, so
|
|
126
|
+
// nesting depth (not input length) bounds how many passes a real page ever
|
|
127
|
+
// needs — legitimate content never comes close to this cap. Bounding it
|
|
128
|
+
// anyway turns adversarially-deep nesting into a fixed, constant amount of
|
|
129
|
+
// extra work, rather than relying on realistic content alone to keep this
|
|
130
|
+
// fast.
|
|
131
|
+
const MAX_STRIP_PASSES = 100
|
|
132
|
+
|
|
115
133
|
function stripTags(html) {
|
|
116
134
|
let previous
|
|
117
135
|
let current = html
|
|
136
|
+
let passes = 0
|
|
118
137
|
do {
|
|
119
138
|
previous = current
|
|
120
139
|
current = previous.replace(TAG_RX, '')
|
|
121
|
-
|
|
140
|
+
passes += 1
|
|
141
|
+
} while (current !== previous && passes < MAX_STRIP_PASSES)
|
|
122
142
|
return current
|
|
123
143
|
}
|
|
124
144
|
|