@inditextech/docouture-asciidoc-extensions 1.1.1 → 1.2.0-SNAPSHOT.136.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 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
- Both site playbooks set `runtime.log.failure_level: warn`, so **a warning fails
143
- the build**. That is the right severity for an authoring error: an unknown label
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
- warn(parent, 'label:mauve[]', 'unknown IDS Label variant "mauve"', VARIANTS)
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 warn = require('./warn')
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
- warn(parent, '[accordion]', 'an accordion group with no `[%collapsible]` items in it')
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
- warn(
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
- warn(
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
- // See label-macro.js's own comment on this same pattern.
230
- // eslint-disable-next-line @typescript-eslint/no-this-alias
231
- const self = this
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, self))
242
+ chain(precomputeSubtree(wrapper), () => finish(parent, wrapper, attrs, this))
240
243
  )
241
244
  })
242
245
  }
@@ -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 warn = require('./warn')
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]+):)?([0-9]+)$/
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
- warn(parent, '[cards,columns="' + spec + '"]', 'cannot read the column "' + token + '"; expected `3` or `m:3`')
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
- warn(parent, '[cards,columns="' + spec + '"]', 'unknown breakpoint "' + breakpoint + '"', BREAKPOINTS)
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
- warn(
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
- warn(
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
- warn(parent, '[card]', 'a card has no title; give it a `.Title` line carrying the link')
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
- warn(
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
- warn(parent, '[card]', 'this card has an image but the block is `type=' + type + '`', TYPES.slice(1))
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
- warn(parent, '[card]', 'a `type=' + type + '` card needs an `image::` of its own')
242
+ error(parent, '[card]', 'a `type=' + type + '` card needs an `image::` of its own')
243
243
  }
244
244
  if (images.length > 1) {
245
- warn(parent, '[card]', 'a card has ' + images.length + ' images; only the first is rendered')
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
- warn(parent, '[card]', 'a card body holds a ' + body.getContext() + ' block; only paragraphs are supported')
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
- warn(parent, '[cards,type=' + attrs.type + ']', 'unknown card type "' + attrs.type + '"', TYPES)
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
- warn(parent, '[cards,width=' + attrs.width + ']', 'unknown width "' + attrs.width + '"', WIDTHS)
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
- warn(parent, '[cards]', 'a cards block with no `[card]` in it')
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
- // See label-macro.js's own comment on this same pattern.
314
- // eslint-disable-next-line @typescript-eslint/no-this-alias
315
- const self = this
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, self))
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 warn = require('./warn')
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
- warn(parent, '[cta]', 'a cta has ' + light.length + ' mark images; only the first is rendered')
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
- warn(parent, '[cta]', 'a cta has ' + dark.length + ' `role=dark` mark images; only the first is rendered')
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
- warn(parent, '[cta]', 'a cta has a `role=dark` mark image but no mark for the light theme')
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
- warn(parent, '[.' + role + ']', 'a cta action carries no link; make it an `xref:` or a `link:`')
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
- warn(parent, '[cta,align=' + attrs.align + ']', 'unknown alignment "' + attrs.align + '"', ALIGNMENTS)
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
- warn(parent, '[cta]', 'a cta has no `[.primary]` action')
248
+ error(parent, '[cta]', 'a cta has no `[.primary]` action')
249
249
  }
250
250
  if (primaries.length > 1) {
251
- warn(parent, '[cta]', 'a cta has ' + primaries.length + ' `[.primary]` paragraphs; only the first is rendered')
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
- warn(parent, '[cta]', 'a cta has ' + secondaries.length + ' `[.secondary]` paragraphs; only the first is rendered')
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
- warn(parent, '[cta]', 'a cta has a `[.secondary]` action but no `[.primary]` one')
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
- warn(parent, '[cta]', 'a cta body holds a ' + body.getContext() + ' block; only paragraphs are supported')
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
- // See label-macro.js's own comment on this same pattern.
318
- // eslint-disable-next-line @typescript-eslint/no-this-alias
319
- const self = this
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, self))
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
  })
@@ -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 warn = require('./warn')
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
- warn(parent, '[feature]', 'a slide has ' + light.length + ' images; only the first is rendered')
170
+ notice(parent, '[feature]', 'a slide has ' + light.length + ' images; only the first is rendered')
171
171
  }
172
172
  if (dark.length > 1) {
173
- warn(parent, '[feature]', 'a slide has ' + dark.length + ' `role=dark` images; only the first is rendered')
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
- warn(parent, '[feature]', 'a slide has a `role=dark` image but no image for the light theme')
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
- warn(parent, '[.cta]', "a slide's call to action carries no link; make it an `xref:` or a `link:`")
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
- warn(parent, '[feature]', 'a slide has no label; give it a `.Title` line or a `label=` attribute')
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
- warn(parent, '[feature] ' + (label || block.getAttribute('title')), 'a slide has no `image::` of its own')
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
- warn(parent, '[feature]', 'a slide has ' + ctas.length + ' `[.cta]` paragraphs; only the first is rendered')
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
- warn(parent, '[feature-tabs]', 'a feature-tabs block with no `[feature]` in it')
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
- // See label-macro.js's own comment on this same pattern.
385
- // eslint-disable-next-line @typescript-eslint/no-this-alias
386
- const self = this
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, self))
396
+ chain(precomputeSubtree(wrapper), () => finish(parent, wrapper, attrs, this))
394
397
  )
395
398
  })
396
399
  }
@@ -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
  })
@@ -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.prototype.hasOwnProperty.call(attrs, '1')) return attrs['1']
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 String(value).replace(ESCAPE_RX, (char) => ESCAPES[/** @type {keyof typeof ESCAPES} */ (char)])
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
- } while (current !== previous)
140
+ passes += 1
141
+ } while (current !== previous && passes < MAX_STRIP_PASSES)
122
142
  return current
123
143
  }
124
144