@inditextech/docouture-asciidoc-extensions 1.0.0 → 1.1.0
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/lib/accordion.js +1 -1
- package/lib/accordion.spec.js +124 -0
- package/lib/cta.js +2 -6
- package/lib/cta.spec.js +182 -0
- package/lib/feature-tabs.js +4 -7
- package/lib/feature-tabs.spec.js +178 -0
- package/lib/kroki-config.js +1 -1
- package/lib/kroki-config.spec.js +196 -0
- package/lib/shiki-syntax-highlighter.js +1 -1
- package/lib/shiki-syntax-highlighter.spec.js +92 -0
- package/lib/svg-namespace.js +3 -3
- package/lib/svg-namespace.spec.js +62 -0
- package/lib/warn.js +1 -1
- package/lib/warn.spec.js +57 -0
- package/package.json +3 -2
package/lib/accordion.js
CHANGED
|
@@ -0,0 +1,124 @@
|
|
|
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 { afterEach, describe, expect, it } from 'vitest'
|
|
8
|
+
|
|
9
|
+
const asciidoctor = require('@asciidoctor/core')
|
|
10
|
+
const registerAccordion = require('./accordion')
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* @param {string} source
|
|
14
|
+
* @param {{ logger?: { warn: (message: string) => void } }} [options]
|
|
15
|
+
* @returns {Promise<string>}
|
|
16
|
+
*/
|
|
17
|
+
function convert(source, { logger } = {}) {
|
|
18
|
+
const registry = asciidoctor.Extensions.create()
|
|
19
|
+
registerAccordion(registry)
|
|
20
|
+
if (logger) asciidoctor.LoggerManager.setLogger(logger)
|
|
21
|
+
return /** @type {Promise<string>} */ (asciidoctor.convert(source, { extension_registry: registry, safe: 'safe' }))
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
describe('accordion block', () => {
|
|
25
|
+
afterEach(() => {
|
|
26
|
+
asciidoctor.LoggerManager.setLogger(asciidoctor.MemoryLogger.create())
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('wraps every [%collapsible] child in a role=group with the given aria-label', async () => {
|
|
30
|
+
const html = await convert(`[accordion,aria-label="Frequently asked questions"]
|
|
31
|
+
--
|
|
32
|
+
.Question one
|
|
33
|
+
[%collapsible]
|
|
34
|
+
====
|
|
35
|
+
Answer one.
|
|
36
|
+
====
|
|
37
|
+
|
|
38
|
+
.Question two
|
|
39
|
+
[%collapsible]
|
|
40
|
+
====
|
|
41
|
+
Answer two.
|
|
42
|
+
====
|
|
43
|
+
--
|
|
44
|
+
`)
|
|
45
|
+
expect(html).toContain('role="group" aria-label="Frequently asked questions"')
|
|
46
|
+
expect(html).toContain('Question one')
|
|
47
|
+
expect(html).toContain('Question two')
|
|
48
|
+
// Independent mode by default — no shared `name=` attribute.
|
|
49
|
+
expect(html).not.toContain('name=')
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('escapes a quote inside an authored aria-label', async () => {
|
|
53
|
+
const html = await convert(`[accordion,aria-label="Say \\"hi\\""]
|
|
54
|
+
--
|
|
55
|
+
.Q
|
|
56
|
+
[%collapsible]
|
|
57
|
+
====
|
|
58
|
+
A.
|
|
59
|
+
====
|
|
60
|
+
--
|
|
61
|
+
`)
|
|
62
|
+
expect(html).toContain('aria-label="Say "hi""')
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('single-open shares one native <details name> across every child', async () => {
|
|
66
|
+
const html = await convert(`[accordion%single-open,aria-label="FAQ"]
|
|
67
|
+
--
|
|
68
|
+
.Q1
|
|
69
|
+
[%collapsible]
|
|
70
|
+
====
|
|
71
|
+
A1.
|
|
72
|
+
====
|
|
73
|
+
|
|
74
|
+
.Q2
|
|
75
|
+
[%collapsible]
|
|
76
|
+
====
|
|
77
|
+
A2.
|
|
78
|
+
====
|
|
79
|
+
--
|
|
80
|
+
`)
|
|
81
|
+
const names = [...html.matchAll(/<details name="([^"]+)"/g)].map((m) => m[1])
|
|
82
|
+
expect(names).toHaveLength(2)
|
|
83
|
+
expect(names[0]).toBe(names[1])
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('warns when a group has no [%collapsible] items in it', async () => {
|
|
87
|
+
const logger = asciidoctor.MemoryLogger.create()
|
|
88
|
+
const html = await convert(
|
|
89
|
+
`[accordion,aria-label="Empty"]
|
|
90
|
+
--
|
|
91
|
+
Just a stray paragraph.
|
|
92
|
+
--
|
|
93
|
+
`,
|
|
94
|
+
{ logger }
|
|
95
|
+
)
|
|
96
|
+
expect(html).toContain('docouture-accordion-group')
|
|
97
|
+
const messages = logger
|
|
98
|
+
.getMessages()
|
|
99
|
+
.map((m) => m.getText())
|
|
100
|
+
.join('\n')
|
|
101
|
+
expect(messages).toContain('an accordion group with no `[%collapsible]` items in it')
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('warns when a group has no aria-label and no title', async () => {
|
|
105
|
+
const logger = asciidoctor.MemoryLogger.create()
|
|
106
|
+
await convert(
|
|
107
|
+
`[accordion]
|
|
108
|
+
--
|
|
109
|
+
.Q
|
|
110
|
+
[%collapsible]
|
|
111
|
+
====
|
|
112
|
+
A.
|
|
113
|
+
====
|
|
114
|
+
--
|
|
115
|
+
`,
|
|
116
|
+
{ logger }
|
|
117
|
+
)
|
|
118
|
+
const messages = logger
|
|
119
|
+
.getMessages()
|
|
120
|
+
.map((m) => m.getText())
|
|
121
|
+
.join('\n')
|
|
122
|
+
expect(messages).toContain('has no `aria-label=` and no title')
|
|
123
|
+
})
|
|
124
|
+
})
|
package/lib/cta.js
CHANGED
|
@@ -174,14 +174,10 @@ function renderMark(lightHtml, darkHtml) {
|
|
|
174
174
|
const dark = darkHtml ? (IMG_RX.exec(darkHtml) || [])[0] : ''
|
|
175
175
|
if (!light && !dark) return ''
|
|
176
176
|
const themed = light && dark
|
|
177
|
+
const lightModifierClass = themed ? ' docouture-cta__mark-image--light' : ''
|
|
177
178
|
return (
|
|
178
179
|
'<div class="docouture-cta__mark">' +
|
|
179
|
-
(light
|
|
180
|
-
? light.replace(
|
|
181
|
-
'<img',
|
|
182
|
-
'<img class="docouture-cta__mark-image' + (themed ? ' docouture-cta__mark-image--light' : '') + '"'
|
|
183
|
-
)
|
|
184
|
-
: '') +
|
|
180
|
+
(light ? light.replace('<img', '<img class="docouture-cta__mark-image' + lightModifierClass + '"') : '') +
|
|
185
181
|
(dark ? dark.replace('<img', '<img class="docouture-cta__mark-image docouture-cta__mark-image--dark"') : '') +
|
|
186
182
|
'</div>'
|
|
187
183
|
)
|
package/lib/cta.spec.js
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
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 { afterEach, describe, expect, it } from 'vitest'
|
|
8
|
+
|
|
9
|
+
// @asciidoctor/core 4.0's `convert` is Promise-returning (this package's own
|
|
10
|
+
// async-compat.js header explains why); asciidoctor-core-2.2 is what real
|
|
11
|
+
// Antora builds actually use and converts synchronously. Both run
|
|
12
|
+
// registerCta identically — 4.0 here since it is this package's own
|
|
13
|
+
// devDependency and its registry.create()/Extensions API needs no special
|
|
14
|
+
// alias (see cta.js's own comment on the 2.2/4.0 split for why 2.2 must be
|
|
15
|
+
// required under an alias elsewhere in this package).
|
|
16
|
+
const asciidoctor = require('@asciidoctor/core')
|
|
17
|
+
const registerCta = require('./cta')
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* @param {string} source
|
|
21
|
+
* @param {{ logger?: { warn: (message: string) => void } }} [options]
|
|
22
|
+
* @returns {Promise<string>}
|
|
23
|
+
*/
|
|
24
|
+
function convert(source, { logger } = {}) {
|
|
25
|
+
const registry = asciidoctor.Extensions.create()
|
|
26
|
+
registerCta(registry)
|
|
27
|
+
if (logger) asciidoctor.LoggerManager.setLogger(logger)
|
|
28
|
+
return /** @type {Promise<string>} */ (asciidoctor.convert(source, { extension_registry: registry, safe: 'safe' }))
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
describe('cta block', () => {
|
|
32
|
+
afterEach(() => {
|
|
33
|
+
asciidoctor.LoggerManager.setLogger(asciidoctor.MemoryLogger.create())
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('renders a themed mark (light + dark), title, lead, primary and secondary actions', async () => {
|
|
37
|
+
const html = await convert(`[cta,title="Get started",align=start]
|
|
38
|
+
====
|
|
39
|
+
image::mark.svg[Mark]
|
|
40
|
+
image::mark-dark.svg[role=dark]
|
|
41
|
+
|
|
42
|
+
Actively maintained and open source.
|
|
43
|
+
|
|
44
|
+
[.primary]
|
|
45
|
+
https://github.com/InditexTech/docouture[Fork it]
|
|
46
|
+
|
|
47
|
+
[.secondary]
|
|
48
|
+
https://example.com/docs[Read the docs]
|
|
49
|
+
====
|
|
50
|
+
`)
|
|
51
|
+
expect(html).toContain('docouture-cta--start')
|
|
52
|
+
expect(html).toContain('<h2 class="docouture-cta__title discrete dt-text--title-l">Get started</h2>')
|
|
53
|
+
expect(html).toContain('docouture-cta__mark-image--light')
|
|
54
|
+
expect(html).toContain('docouture-cta__mark-image--dark')
|
|
55
|
+
// GitHub targets get the brand icon; a plain https link does not.
|
|
56
|
+
expect(html).toContain('docouture-cta__action-icon--github')
|
|
57
|
+
expect(html).toContain('dt-button--secondary docouture-cta__action" href="https://example.com/docs"')
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('defaults to center alignment and renders an untethed single mark image with no --light modifier', async () => {
|
|
61
|
+
const html = await convert(`[cta]
|
|
62
|
+
====
|
|
63
|
+
image::mark.svg[Mark]
|
|
64
|
+
|
|
65
|
+
Some lead text.
|
|
66
|
+
|
|
67
|
+
[.primary]
|
|
68
|
+
https://example.com[Go]
|
|
69
|
+
====
|
|
70
|
+
`)
|
|
71
|
+
expect(html).toContain('docouture-cta--center')
|
|
72
|
+
// themed is false (only a light image) so no --light modifier is added.
|
|
73
|
+
expect(html).toContain('<img class="docouture-cta__mark-image" src="mark.svg" alt="Mark">')
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('warns and falls back to the default alignment for an unknown align value', async () => {
|
|
77
|
+
const logger = asciidoctor.MemoryLogger.create()
|
|
78
|
+
const html = await convert(
|
|
79
|
+
`[cta,align=bogus]
|
|
80
|
+
====
|
|
81
|
+
Lead only.
|
|
82
|
+
|
|
83
|
+
[.primary]
|
|
84
|
+
https://example.com[Go]
|
|
85
|
+
====
|
|
86
|
+
`,
|
|
87
|
+
{ logger }
|
|
88
|
+
)
|
|
89
|
+
expect(html).toContain('docouture-cta--center')
|
|
90
|
+
expect(
|
|
91
|
+
logger
|
|
92
|
+
.getMessages()
|
|
93
|
+
.map((m) => m.getText())
|
|
94
|
+
.join('\n')
|
|
95
|
+
).toContain('unknown alignment "bogus"')
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('warns when there is no [.primary] action', async () => {
|
|
99
|
+
const logger = asciidoctor.MemoryLogger.create()
|
|
100
|
+
await convert(
|
|
101
|
+
`[cta]
|
|
102
|
+
====
|
|
103
|
+
Lead only, no actions.
|
|
104
|
+
====
|
|
105
|
+
`,
|
|
106
|
+
{ logger }
|
|
107
|
+
)
|
|
108
|
+
expect(
|
|
109
|
+
logger
|
|
110
|
+
.getMessages()
|
|
111
|
+
.map((m) => m.getText())
|
|
112
|
+
.join('\n')
|
|
113
|
+
).toContain('a cta has no `[.primary]` action')
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it('warns when a [.secondary] action is present without a [.primary] one', async () => {
|
|
117
|
+
const logger = asciidoctor.MemoryLogger.create()
|
|
118
|
+
await convert(
|
|
119
|
+
`[cta]
|
|
120
|
+
====
|
|
121
|
+
Lead.
|
|
122
|
+
|
|
123
|
+
[.secondary]
|
|
124
|
+
https://example.com[Only secondary]
|
|
125
|
+
====
|
|
126
|
+
`,
|
|
127
|
+
{ logger }
|
|
128
|
+
)
|
|
129
|
+
const messages = logger
|
|
130
|
+
.getMessages()
|
|
131
|
+
.map((m) => m.getText())
|
|
132
|
+
.join('\n')
|
|
133
|
+
expect(messages).toContain('a cta has a `[.secondary]` action but no `[.primary]` one')
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
it('warns and renders nothing for an action paragraph carrying no link', async () => {
|
|
137
|
+
const logger = asciidoctor.MemoryLogger.create()
|
|
138
|
+
const html = await convert(
|
|
139
|
+
`[cta]
|
|
140
|
+
====
|
|
141
|
+
Lead.
|
|
142
|
+
|
|
143
|
+
[.primary]
|
|
144
|
+
Just plain text, no link.
|
|
145
|
+
====
|
|
146
|
+
`,
|
|
147
|
+
{ logger }
|
|
148
|
+
)
|
|
149
|
+
// The paragraph converted fine, but renderAction found no anchor in it,
|
|
150
|
+
// so the actions wrapper renders empty rather than a real button.
|
|
151
|
+
expect(html).not.toContain('dt-button--primary')
|
|
152
|
+
const messages = logger
|
|
153
|
+
.getMessages()
|
|
154
|
+
.map((m) => m.getText())
|
|
155
|
+
.join('\n')
|
|
156
|
+
expect(messages).toContain('a cta action carries no link')
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
it('warns about a non-paragraph body block', async () => {
|
|
160
|
+
const logger = asciidoctor.MemoryLogger.create()
|
|
161
|
+
await convert(
|
|
162
|
+
`[cta]
|
|
163
|
+
=====
|
|
164
|
+
Lead paragraph.
|
|
165
|
+
|
|
166
|
+
======
|
|
167
|
+
Nested example block, not a paragraph.
|
|
168
|
+
======
|
|
169
|
+
|
|
170
|
+
[.primary]
|
|
171
|
+
https://example.com[Go]
|
|
172
|
+
=====
|
|
173
|
+
`,
|
|
174
|
+
{ logger }
|
|
175
|
+
)
|
|
176
|
+
const messages = logger
|
|
177
|
+
.getMessages()
|
|
178
|
+
.map((m) => m.getText())
|
|
179
|
+
.join('\n')
|
|
180
|
+
expect(messages).toContain('a cta body holds a')
|
|
181
|
+
})
|
|
182
|
+
})
|
package/lib/feature-tabs.js
CHANGED
|
@@ -193,14 +193,10 @@ function renderMedia(lightHtml, darkHtml) {
|
|
|
193
193
|
const dark = darkHtml ? (IMG_RX.exec(darkHtml) || [])[0] : ''
|
|
194
194
|
if (!light && !dark) return ''
|
|
195
195
|
const themed = light && dark
|
|
196
|
+
const lightModifierClass = themed ? ' docouture-feature-tabs__image--light' : ''
|
|
196
197
|
return (
|
|
197
198
|
'<div class="docouture-feature-tabs__media">' +
|
|
198
|
-
(light
|
|
199
|
-
? light.replace(
|
|
200
|
-
'<img',
|
|
201
|
-
'<img class="docouture-feature-tabs__image' + (themed ? ' docouture-feature-tabs__image--light' : '') + '"'
|
|
202
|
-
)
|
|
203
|
-
: '') +
|
|
199
|
+
(light ? light.replace('<img', '<img class="docouture-feature-tabs__image' + lightModifierClass + '"') : '') +
|
|
204
200
|
(dark
|
|
205
201
|
? dark.replace('<img', '<img class="docouture-feature-tabs__image docouture-feature-tabs__image--dark"')
|
|
206
202
|
: '') +
|
|
@@ -305,11 +301,12 @@ function renderFeature(block, index, parent) {
|
|
|
305
301
|
'</a>' +
|
|
306
302
|
'</li>'
|
|
307
303
|
|
|
304
|
+
const headingModifierClass = label ? '' : ' docouture-feature-tabs__heading--redundant'
|
|
308
305
|
const heading = title
|
|
309
306
|
? '<h3 class="docouture-feature-tabs__heading' +
|
|
310
307
|
// No explicit label means the heading and the tab say the same thing, so
|
|
311
308
|
// the enhanced state hides the heading rather than printing it twice.
|
|
312
|
-
|
|
309
|
+
headingModifierClass +
|
|
313
310
|
'"' +
|
|
314
311
|
attr('id', headingId) +
|
|
315
312
|
'>' +
|
|
@@ -0,0 +1,178 @@
|
|
|
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 { afterEach, describe, expect, it } from 'vitest'
|
|
8
|
+
|
|
9
|
+
const asciidoctor = require('@asciidoctor/core')
|
|
10
|
+
const registerFeatureTabs = require('./feature-tabs')
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* @param {string} source
|
|
14
|
+
* @param {{ logger?: { warn: (message: string) => void } }} [options]
|
|
15
|
+
* @returns {Promise<string>}
|
|
16
|
+
*/
|
|
17
|
+
function convert(source, { logger } = {}) {
|
|
18
|
+
const registry = asciidoctor.Extensions.create()
|
|
19
|
+
registerFeatureTabs(registry)
|
|
20
|
+
if (logger) asciidoctor.LoggerManager.setLogger(logger)
|
|
21
|
+
return /** @type {Promise<string>} */ (asciidoctor.convert(source, { extension_registry: registry, safe: 'safe' }))
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
describe('feature-tabs block', () => {
|
|
25
|
+
afterEach(() => {
|
|
26
|
+
asciidoctor.LoggerManager.setLogger(asciidoctor.MemoryLogger.create())
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('renders a slide with both title and label, a themed media pair and an external cta', async () => {
|
|
30
|
+
const html = await convert(`[feature-tabs]
|
|
31
|
+
====
|
|
32
|
+
[feature,label="UI-agnostic"]
|
|
33
|
+
.Integrates with any UI framework
|
|
34
|
+
--
|
|
35
|
+
image::feature-1.png[Alt text]
|
|
36
|
+
image::feature-1-dark.png[role=dark]
|
|
37
|
+
|
|
38
|
+
Change the UI using our included primitives.
|
|
39
|
+
|
|
40
|
+
[.cta]
|
|
41
|
+
https://example.com/learn-more[Learn more]
|
|
42
|
+
--
|
|
43
|
+
====
|
|
44
|
+
`)
|
|
45
|
+
expect(html).toContain('data-feature-tabs')
|
|
46
|
+
expect(html).toContain('dt-tabs-item--selected')
|
|
47
|
+
expect(html).toContain('>UI-agnostic</a>')
|
|
48
|
+
expect(html).toContain('docouture-feature-tabs__heading" id=')
|
|
49
|
+
expect(html).toContain('Integrates with any UI framework</h3>')
|
|
50
|
+
expect(html).toContain('docouture-feature-tabs__image--light')
|
|
51
|
+
expect(html).toContain('docouture-feature-tabs__image--dark')
|
|
52
|
+
// An absolute https target gets the external-link icon on the CTA.
|
|
53
|
+
expect(html).toContain('dt-icon-mask--external-link')
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('title-only slide: the tab uses the title, and the heading is marked redundant', async () => {
|
|
57
|
+
const html = await convert(`[feature-tabs]
|
|
58
|
+
====
|
|
59
|
+
[feature]
|
|
60
|
+
.Title-only slide
|
|
61
|
+
--
|
|
62
|
+
image::feature-2.png[Alt]
|
|
63
|
+
--
|
|
64
|
+
====
|
|
65
|
+
`)
|
|
66
|
+
expect(html).toContain('>Title-only slide</a>')
|
|
67
|
+
expect(html).toContain('docouture-feature-tabs__heading--redundant')
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('an internal (same-site) cta target gets no external-link icon', async () => {
|
|
71
|
+
const html = await convert(`[feature-tabs]
|
|
72
|
+
====
|
|
73
|
+
[feature,label="Internal"]
|
|
74
|
+
--
|
|
75
|
+
image::feature-3.png[Alt]
|
|
76
|
+
|
|
77
|
+
[.cta]
|
|
78
|
+
xref:main:quickstart.adoc[Learn more]
|
|
79
|
+
--
|
|
80
|
+
====
|
|
81
|
+
`)
|
|
82
|
+
expect(html).not.toContain('dt-icon-mask--external-link')
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('warns when a feature-tabs block has no [feature] slides at all', async () => {
|
|
86
|
+
const logger = asciidoctor.MemoryLogger.create()
|
|
87
|
+
await convert(
|
|
88
|
+
`[feature-tabs]
|
|
89
|
+
====
|
|
90
|
+
Just a stray paragraph, no [feature] style.
|
|
91
|
+
====
|
|
92
|
+
`,
|
|
93
|
+
{ logger }
|
|
94
|
+
)
|
|
95
|
+
const messages = logger
|
|
96
|
+
.getMessages()
|
|
97
|
+
.map((m) => m.getText())
|
|
98
|
+
.join('\n')
|
|
99
|
+
expect(messages).toContain('a feature-tabs block with no `[feature]` in it')
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it('warns when a slide has neither a title nor a label', async () => {
|
|
103
|
+
const logger = asciidoctor.MemoryLogger.create()
|
|
104
|
+
const html = await convert(
|
|
105
|
+
`[feature-tabs]
|
|
106
|
+
====
|
|
107
|
+
[feature]
|
|
108
|
+
--
|
|
109
|
+
image::feature-4.png[Alt]
|
|
110
|
+
--
|
|
111
|
+
====
|
|
112
|
+
`,
|
|
113
|
+
{ logger }
|
|
114
|
+
)
|
|
115
|
+
// renderFeature returns { tab: '', panel: '' } for the unlabelled slide.
|
|
116
|
+
expect(html).toContain('<ul class="docouture-feature-tabs__list"></ul>')
|
|
117
|
+
const messages = logger
|
|
118
|
+
.getMessages()
|
|
119
|
+
.map((m) => m.getText())
|
|
120
|
+
.join('\n')
|
|
121
|
+
expect(messages).toContain('a slide has no label')
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
it('warns when a slide has no image at all', async () => {
|
|
125
|
+
const logger = asciidoctor.MemoryLogger.create()
|
|
126
|
+
await convert(
|
|
127
|
+
`[feature-tabs]
|
|
128
|
+
====
|
|
129
|
+
[feature,label="No image"]
|
|
130
|
+
--
|
|
131
|
+
Prose only, no image::.
|
|
132
|
+
--
|
|
133
|
+
====
|
|
134
|
+
`,
|
|
135
|
+
{ logger }
|
|
136
|
+
)
|
|
137
|
+
const messages = logger
|
|
138
|
+
.getMessages()
|
|
139
|
+
.map((m) => m.getText())
|
|
140
|
+
.join('\n')
|
|
141
|
+
expect(messages).toContain('a slide has no `image::` of its own')
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
it('warns and renders nothing for a cta paragraph carrying no link', async () => {
|
|
145
|
+
const logger = asciidoctor.MemoryLogger.create()
|
|
146
|
+
const html = await convert(
|
|
147
|
+
`[feature-tabs]
|
|
148
|
+
====
|
|
149
|
+
[feature,label="Broken cta"]
|
|
150
|
+
--
|
|
151
|
+
image::feature-5.png[Alt]
|
|
152
|
+
|
|
153
|
+
[.cta]
|
|
154
|
+
Just text, no link.
|
|
155
|
+
--
|
|
156
|
+
====
|
|
157
|
+
`,
|
|
158
|
+
{ logger }
|
|
159
|
+
)
|
|
160
|
+
expect(html).not.toContain('docouture-feature-tabs__cta"')
|
|
161
|
+
const messages = logger
|
|
162
|
+
.getMessages()
|
|
163
|
+
.map((m) => m.getText())
|
|
164
|
+
.join('\n')
|
|
165
|
+
expect(messages).toContain("a slide's call to action carries no link")
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
it('accepts a bare-paragraph [feature] slide (not an open block)', async () => {
|
|
169
|
+
const html = await convert(`[feature-tabs]
|
|
170
|
+
====
|
|
171
|
+
[feature,label="Prose slide"]
|
|
172
|
+
Just a paragraph, no open block at all.
|
|
173
|
+
====
|
|
174
|
+
`)
|
|
175
|
+
expect(html).toContain('>Prose slide</a>')
|
|
176
|
+
expect(html).toContain('Just a paragraph, no open block at all.')
|
|
177
|
+
})
|
|
178
|
+
})
|
package/lib/kroki-config.js
CHANGED
|
@@ -260,7 +260,7 @@ function resolveFormat(type, requestedFormat, onUnsupported) {
|
|
|
260
260
|
if (requestedFormat == null || requestedFormat === '' || requestedFormat === DEFAULT_FORMAT) return DEFAULT_FORMAT
|
|
261
261
|
const normalized = String(requestedFormat === JPG_ALIAS ? 'jpeg' : requestedFormat)
|
|
262
262
|
const accepted = FORMAT_SUPPORT[type]
|
|
263
|
-
if (accepted
|
|
263
|
+
if (accepted?.includes(normalized))
|
|
264
264
|
return /** @type {'svg' | 'png' | 'jpeg' | 'pdf' | 'base64' | 'txt' | 'atxt' | 'utxt'} */ (normalized)
|
|
265
265
|
if (onUnsupported) onUnsupported(type, String(requestedFormat))
|
|
266
266
|
return DEFAULT_FORMAT
|
|
@@ -0,0 +1,196 @@
|
|
|
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
|
+
const {
|
|
9
|
+
KROKI_URL,
|
|
10
|
+
SUPPORTED_TYPES,
|
|
11
|
+
PNG_SUPPORTED_TYPES,
|
|
12
|
+
FORMAT_SUPPORT,
|
|
13
|
+
TEXT_FORMATS,
|
|
14
|
+
DEFAULT_FORMAT,
|
|
15
|
+
ENABLED_ATTR,
|
|
16
|
+
TYPES_ATTR,
|
|
17
|
+
BUILTIN_ATTRIBUTES,
|
|
18
|
+
isTruthy,
|
|
19
|
+
resolveEnabledTypes,
|
|
20
|
+
resolveFormat,
|
|
21
|
+
isTextFormat,
|
|
22
|
+
isPositionalOrOptionKey,
|
|
23
|
+
resolveDiagramOptions,
|
|
24
|
+
} = require('./kroki-config')
|
|
25
|
+
|
|
26
|
+
describe('constants', () => {
|
|
27
|
+
it('exposes the fixed loopback Kroki URL', () => {
|
|
28
|
+
expect(KROKI_URL).toBe('http://localhost:8500')
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('exposes the attribute names both halves of Kroki support agree on', () => {
|
|
32
|
+
expect(ENABLED_ATTR).toBe('kroki-enabled')
|
|
33
|
+
expect(TYPES_ATTR).toBe('kroki-diagram-types')
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('exposes svg as the default format', () => {
|
|
37
|
+
expect(DEFAULT_FORMAT).toBe('svg')
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('derives PNG_SUPPORTED_TYPES from FORMAT_SUPPORT', () => {
|
|
41
|
+
for (const type of PNG_SUPPORTED_TYPES) {
|
|
42
|
+
expect(FORMAT_SUPPORT[type]).toContain('png')
|
|
43
|
+
}
|
|
44
|
+
for (const type of SUPPORTED_TYPES) {
|
|
45
|
+
if (FORMAT_SUPPORT[type].includes('png')) {
|
|
46
|
+
expect(PNG_SUPPORTED_TYPES.has(type)).toBe(true)
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('every SUPPORTED_TYPES entry has a FORMAT_SUPPORT row', () => {
|
|
52
|
+
for (const type of SUPPORTED_TYPES) {
|
|
53
|
+
expect(Array.isArray(FORMAT_SUPPORT[type])).toBe(true)
|
|
54
|
+
expect(FORMAT_SUPPORT[type]).toContain('svg')
|
|
55
|
+
}
|
|
56
|
+
})
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
describe('isTruthy', () => {
|
|
60
|
+
it('treats the real boolean true as truthy', () => {
|
|
61
|
+
expect(isTruthy(true)).toBe(true)
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('treats the string "true" as truthy', () => {
|
|
65
|
+
expect(isTruthy('true')).toBe(true)
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it.each([false, 'false', undefined, null, '', 0, 'yes'])('treats %s as not truthy', (value) => {
|
|
69
|
+
expect(isTruthy(value)).toBe(false)
|
|
70
|
+
})
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
describe('resolveEnabledTypes', () => {
|
|
74
|
+
it('returns an empty set when kroki-enabled is not truthy', () => {
|
|
75
|
+
const onUnknownType = () => {
|
|
76
|
+
throw new Error('should not be called')
|
|
77
|
+
}
|
|
78
|
+
expect(resolveEnabledTypes(false, 'mermaid', onUnknownType).size).toBe(0)
|
|
79
|
+
expect(resolveEnabledTypes(undefined, 'mermaid', onUnknownType).size).toBe(0)
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('returns every supported type when types are omitted', () => {
|
|
83
|
+
const active = resolveEnabledTypes(true, undefined)
|
|
84
|
+
expect(active.size).toBe(SUPPORTED_TYPES.length)
|
|
85
|
+
expect(active.has('mermaid')).toBe(true)
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('returns every supported type when types is an empty string', () => {
|
|
89
|
+
const active = resolveEnabledTypes('true', '')
|
|
90
|
+
expect(active.size).toBe(SUPPORTED_TYPES.length)
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it('parses a comma-separated, whitespace-tolerant list of requested types', () => {
|
|
94
|
+
const active = resolveEnabledTypes(true, ' mermaid, plantuml ,graphviz')
|
|
95
|
+
expect([...active].sort()).toEqual(['graphviz', 'mermaid', 'plantuml'])
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('reports each unknown type through onUnknownType and drops it', () => {
|
|
99
|
+
const unknown = []
|
|
100
|
+
const active = resolveEnabledTypes(true, 'mermaid,bogus,plantuml', (type) => unknown.push(type))
|
|
101
|
+
expect(unknown).toEqual(['bogus'])
|
|
102
|
+
expect([...active].sort()).toEqual(['mermaid', 'plantuml'])
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it('does not call onUnknownType when typesAttr is omitted', () => {
|
|
106
|
+
const onUnknownType = () => {
|
|
107
|
+
throw new Error('should not be called when "all types" is meant')
|
|
108
|
+
}
|
|
109
|
+
expect(() => resolveEnabledTypes(true, undefined, onUnknownType)).not.toThrow()
|
|
110
|
+
})
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
describe('resolveFormat', () => {
|
|
114
|
+
it('resolves the default format when the requested format is absent', () => {
|
|
115
|
+
expect(resolveFormat('mermaid', undefined)).toBe('svg')
|
|
116
|
+
expect(resolveFormat('mermaid', '')).toBe('svg')
|
|
117
|
+
expect(resolveFormat('mermaid', 'svg')).toBe('svg')
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
it('resolves a supported non-default format', () => {
|
|
121
|
+
expect(resolveFormat('mermaid', 'png')).toBe('png')
|
|
122
|
+
expect(resolveFormat('plantuml', 'txt')).toBe('txt')
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
it('normalizes the jpg alias to jpeg for a type that supports it', () => {
|
|
126
|
+
expect(resolveFormat('graphviz', 'jpg')).toBe('jpeg')
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
it('falls back to svg and reports via onUnsupported for an unsupported format', () => {
|
|
130
|
+
const reported = []
|
|
131
|
+
const format = resolveFormat('mermaid', 'jpeg', (type, requested) => reported.push([type, requested]))
|
|
132
|
+
expect(format).toBe('svg')
|
|
133
|
+
expect(reported).toEqual([['mermaid', 'jpeg']])
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
it('falls back to svg for an entirely unrecognized format string', () => {
|
|
137
|
+
expect(resolveFormat('mermaid', 'bogus')).toBe('svg')
|
|
138
|
+
})
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
describe('isTextFormat', () => {
|
|
142
|
+
it('recognizes every TEXT_FORMATS entry', () => {
|
|
143
|
+
for (const format of TEXT_FORMATS) {
|
|
144
|
+
expect(isTextFormat(format)).toBe(true)
|
|
145
|
+
}
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
it('does not treat svg/png as text formats', () => {
|
|
149
|
+
expect(isTextFormat('svg')).toBe(false)
|
|
150
|
+
expect(isTextFormat('png')).toBe(false)
|
|
151
|
+
})
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
describe('isPositionalOrOptionKey', () => {
|
|
155
|
+
it('recognizes a bare numeric index', () => {
|
|
156
|
+
expect(isPositionalOrOptionKey('1')).toBe(true)
|
|
157
|
+
expect(isPositionalOrOptionKey('42')).toBe(true)
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
it('recognizes an -option flag key', () => {
|
|
161
|
+
expect(isPositionalOrOptionKey('collapsible-option')).toBe(true)
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
it('does not flag an ordinary named attribute', () => {
|
|
165
|
+
expect(isPositionalOrOptionKey('theme')).toBe(false)
|
|
166
|
+
expect(isPositionalOrOptionKey('view-key')).toBe(false)
|
|
167
|
+
})
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
describe('resolveDiagramOptions', () => {
|
|
171
|
+
it('forwards only the non-builtin named attributes, coerced to strings', () => {
|
|
172
|
+
const attrs = {
|
|
173
|
+
target: 'diagram.puml',
|
|
174
|
+
format: 'svg',
|
|
175
|
+
theme: 'hacker',
|
|
176
|
+
'view-key': 'SystemContext',
|
|
177
|
+
1: 'mermaid',
|
|
178
|
+
'collapsible-option': '',
|
|
179
|
+
}
|
|
180
|
+
expect(resolveDiagramOptions(attrs)).toEqual({ theme: 'hacker', 'view-key': 'SystemContext' })
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
it('excludes every BUILTIN_ATTRIBUTES entry', () => {
|
|
184
|
+
/** @type {Record<string, string>} */
|
|
185
|
+
const attrs = {}
|
|
186
|
+
for (const key of BUILTIN_ATTRIBUTES) attrs[key] = 'x'
|
|
187
|
+
expect(resolveDiagramOptions(attrs)).toEqual({})
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
it('returns an empty object for no attributes at all', () => {
|
|
191
|
+
// Real callers always pass an object; this only proves the `attrs || {}`
|
|
192
|
+
// guard inside resolveDiagramOptions holds for a caller that doesn't.
|
|
193
|
+
expect(resolveDiagramOptions(/** @type {any} */ (undefined))).toEqual({})
|
|
194
|
+
expect(resolveDiagramOptions({})).toEqual({})
|
|
195
|
+
})
|
|
196
|
+
})
|
|
@@ -134,7 +134,7 @@ asciidoctor.SyntaxHighlighter.register('shiki', {
|
|
|
134
134
|
// return value above — is read directly, rather than trusting an
|
|
135
135
|
// ivar-driven default this adapter doesn't fully participate in.
|
|
136
136
|
format(node, lang, opts) {
|
|
137
|
-
const preClass = opts
|
|
137
|
+
const preClass = opts?.nowrap ? 'shiki highlight nowrap' : 'shiki highlight'
|
|
138
138
|
const langAttr = lang ? ' data-lang="' + escapeHtml(lang) + '"' : ''
|
|
139
139
|
return `<pre class="${preClass}"><code${langAttr}>${node.getContent()}</code></pre>`
|
|
140
140
|
},
|
|
@@ -0,0 +1,92 @@
|
|
|
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 { afterEach, describe, expect, it } from 'vitest'
|
|
8
|
+
const shikiInstance = require('./shiki-instance')
|
|
9
|
+
// Registering the 'shiki' SyntaxHighlighter is a side effect of requiring
|
|
10
|
+
// this module (see its own header) — there is no other exported surface to
|
|
11
|
+
// call directly, so every case here drives it the same way Antora would:
|
|
12
|
+
// converting a `[source]` block through the real 2.2 Asciidoctor factory
|
|
13
|
+
// with `source-highlighter=shiki`.
|
|
14
|
+
require('./shiki-syntax-highlighter')
|
|
15
|
+
|
|
16
|
+
const asciidoctor = /** @type {any} */ (require('asciidoctor-core-2.2'))()
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* @param {string | undefined} lang
|
|
20
|
+
* @param {string} body
|
|
21
|
+
*/
|
|
22
|
+
function convertSource(lang, body) {
|
|
23
|
+
const source = (lang ? '[source,' + lang + ']' : '[source]') + '\n----\n' + body + '\n----'
|
|
24
|
+
return asciidoctor.convert(source, { attributes: { 'source-highlighter': 'shiki' }, safe: 'safe' })
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
describe('shiki syntax highlighter', () => {
|
|
28
|
+
afterEach(() => {
|
|
29
|
+
// Reset the module-level singleton between tests — shiki-instance.js is
|
|
30
|
+
// one shared handle for the whole process (see its own header). `set`'s
|
|
31
|
+
// own JSDoc requires a real highlighter/rootStyle; this is the one place
|
|
32
|
+
// that intentionally puts it back to the "never warmed" state its own
|
|
33
|
+
// `get()` documents returning `null` for.
|
|
34
|
+
shikiInstance.set(/** @type {any} */ (null), /** @type {any} */ (null))
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('degrades to plain escaped text when the pre-warm listener never ran', () => {
|
|
38
|
+
const html = convertSource('javascript', 'const x = 1 < 2;')
|
|
39
|
+
expect(html).toContain('<pre class="shiki highlight"><code data-lang="javascript">const x = 1 < 2;</code></pre>')
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it("strips Shiki's own <pre><code> wrapper and re-declares the root style", () => {
|
|
43
|
+
shikiInstance.set(
|
|
44
|
+
{
|
|
45
|
+
getLoadedLanguages: () => ['javascript'],
|
|
46
|
+
codeToHtml: (source) =>
|
|
47
|
+
'<pre class="shiki" style="--shiki-fake:1"><code>' + source.toUpperCase() + '</code></pre>',
|
|
48
|
+
},
|
|
49
|
+
'--root-style:1'
|
|
50
|
+
)
|
|
51
|
+
const html = convertSource('javascript', 'const x = 1;')
|
|
52
|
+
expect(html).toContain(
|
|
53
|
+
'<pre class="shiki highlight"><code data-lang="javascript"><span style="--root-style:1">CONST X = 1;</span></code></pre>'
|
|
54
|
+
)
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('falls back to the "text" grammar for a language the loaded set does not carry', () => {
|
|
58
|
+
shikiInstance.set(
|
|
59
|
+
{
|
|
60
|
+
getLoadedLanguages: () => ['text'],
|
|
61
|
+
codeToHtml: (source, opts) => '<pre><code>' + opts.lang + ':' + source + '</code></pre>',
|
|
62
|
+
},
|
|
63
|
+
''
|
|
64
|
+
)
|
|
65
|
+
const html = convertSource('ruby', 'puts 1')
|
|
66
|
+
expect(html).toContain('text:puts 1')
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('resolves an unannotated [source] block (lang "none") to the "text" grammar', () => {
|
|
70
|
+
shikiInstance.set(
|
|
71
|
+
{
|
|
72
|
+
getLoadedLanguages: () => ['text'],
|
|
73
|
+
codeToHtml: (source, opts) => '<pre><code>' + opts.lang + ':' + source + '</code></pre>',
|
|
74
|
+
},
|
|
75
|
+
''
|
|
76
|
+
)
|
|
77
|
+
const html = convertSource(undefined, 'plain text')
|
|
78
|
+
expect(html).toContain('text:plain text')
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('maps a LANG_ALIASES entry (objectivec) to its Shiki grammar id', () => {
|
|
82
|
+
shikiInstance.set(
|
|
83
|
+
{
|
|
84
|
+
getLoadedLanguages: () => ['objective-c'],
|
|
85
|
+
codeToHtml: (source, opts) => '<pre><code>' + opts.lang + ':' + source + '</code></pre>',
|
|
86
|
+
},
|
|
87
|
+
''
|
|
88
|
+
)
|
|
89
|
+
const html = convertSource('objectivec', '@interface Foo @end')
|
|
90
|
+
expect(html).toContain('objective-c:@interface Foo @end')
|
|
91
|
+
})
|
|
92
|
+
})
|
package/lib/svg-namespace.js
CHANGED
|
@@ -61,7 +61,7 @@ const ID_DECLARATION_RX = /id=["']([^"']+)["']/g
|
|
|
61
61
|
* @returns {string}
|
|
62
62
|
*/
|
|
63
63
|
function escapeRegExp(value) {
|
|
64
|
-
return value.replace(/[.*+?^${}()|[\]\\]/g,
|
|
64
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`)
|
|
65
65
|
}
|
|
66
66
|
|
|
67
67
|
/**
|
|
@@ -89,12 +89,12 @@ function namespaceSvgIds(svgMarkup, prefix) {
|
|
|
89
89
|
const sortedIds = Array.from(ids).sort((a, b) => b.length - a.length)
|
|
90
90
|
const idAlternation = sortedIds.map(escapeRegExp).join('|')
|
|
91
91
|
|
|
92
|
-
const declarationRx = new RegExp(
|
|
92
|
+
const declarationRx = new RegExp(String.raw`id=(["'])(${idAlternation})\1`, 'g')
|
|
93
93
|
// Not followed by another id-safe character (a word character or `-`) —
|
|
94
94
|
// same reasoning as the longest-first sort, belt and suspenders: even if
|
|
95
95
|
// two ids somehow shared a length, this stops a partial match at the
|
|
96
96
|
// reference site too.
|
|
97
|
-
const referenceRx = new RegExp(
|
|
97
|
+
const referenceRx = new RegExp(String.raw`#(${idAlternation})(?![\w-])`, 'g')
|
|
98
98
|
|
|
99
99
|
return svgMarkup
|
|
100
100
|
.replace(declarationRx, (_, quote, id) => 'id=' + quote + prefix + '-' + id + quote)
|
|
@@ -0,0 +1,62 @@
|
|
|
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
|
+
const namespaceSvgIds = require('./svg-namespace')
|
|
9
|
+
|
|
10
|
+
describe('namespaceSvgIds', () => {
|
|
11
|
+
it('returns the markup unchanged when it carries no ids', () => {
|
|
12
|
+
const svg = '<svg><rect width="10" height="10"/></svg>'
|
|
13
|
+
expect(namespaceSvgIds(svg, 'prefix')).toBe(svg)
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
it('rewrites a single id declaration and its reference', () => {
|
|
17
|
+
const svg = '<svg id="container"><use href="#container"/></svg>'
|
|
18
|
+
const result = namespaceSvgIds(svg, 'diagram-1')
|
|
19
|
+
expect(result).toBe('<svg id="diagram-1-container"><use href="#diagram-1-container"/></svg>')
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('rewrites both single- and double-quoted id declarations', () => {
|
|
23
|
+
const svg = `<svg id='container'><g id="node1"></g></svg>`
|
|
24
|
+
const result = namespaceSvgIds(svg, 'p')
|
|
25
|
+
expect(result).toContain(`id='p-container'`)
|
|
26
|
+
expect(result).toContain(`id="p-node1"`)
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('rewrites a url(#id) reference inside a fill/stroke/clip-path/mask/filter attribute', () => {
|
|
30
|
+
const svg = '<svg id="graph0"><path fill="url(#graph0)"/></svg>'
|
|
31
|
+
const result = namespaceSvgIds(svg, 'x')
|
|
32
|
+
expect(result).toBe('<svg id="x-graph0"><path fill="url(#x-graph0)"/></svg>')
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('rewrites a CSS id-selector inside an embedded <style> block', () => {
|
|
36
|
+
const svg = '<svg id="container"><style>#container{fill:red}</style></svg>'
|
|
37
|
+
const result = namespaceSvgIds(svg, 'y')
|
|
38
|
+
expect(result).toBe('<svg id="y-container"><style>#y-container{fill:red}</style></svg>')
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('rewrites longer ids before shorter ones so node1 does not eat into node10', () => {
|
|
42
|
+
const svg = '<svg><g id="node1"></g><g id="node10"></g><use href="#node1"/><use href="#node10"/></svg>'
|
|
43
|
+
const result = namespaceSvgIds(svg, 'p')
|
|
44
|
+
expect(result).toBe(
|
|
45
|
+
'<svg><g id="p-node1"></g><g id="p-node10"></g><use href="#p-node1"/><use href="#p-node10"/></svg>'
|
|
46
|
+
)
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('does not let a reference bleed into a longer word-character run', () => {
|
|
50
|
+
// #node1 followed by more id-safe characters must not be treated as a
|
|
51
|
+
// reference to "node1" — the (?![\w-]) guard in referenceRx.
|
|
52
|
+
const svg = '<svg id="node1"><a href="#node1x">not a reference to node1</a></svg>'
|
|
53
|
+
const result = namespaceSvgIds(svg, 'p')
|
|
54
|
+
expect(result).toBe('<svg id="p-node1"><a href="#node1x">not a reference to node1</a></svg>')
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('escapes ids that contain regex-special characters', () => {
|
|
58
|
+
const svg = '<svg id="a.b+c"><use href="#a.b+c"/></svg>'
|
|
59
|
+
const result = namespaceSvgIds(svg, 'p')
|
|
60
|
+
expect(result).toBe('<svg id="p-a.b+c"><use href="#p-a.b+c"/></svg>')
|
|
61
|
+
})
|
|
62
|
+
})
|
package/lib/warn.js
CHANGED
|
@@ -72,7 +72,7 @@ function warn(node, source, problem, expected) {
|
|
|
72
72
|
const doc = (typeof node.getDocument === 'function' && node.getDocument()) || node
|
|
73
73
|
const logger = typeof doc.getLogger === 'function' ? doc.getLogger() : undefined
|
|
74
74
|
if (!logger || typeof logger.warn !== 'function') return
|
|
75
|
-
const suffix = expected
|
|
75
|
+
const suffix = expected?.length ? '; expected one of ' + expected.join(', ') : ''
|
|
76
76
|
logger.warn(source + ' — ' + problem + suffix)
|
|
77
77
|
}
|
|
78
78
|
|
package/lib/warn.spec.js
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
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, vi } from 'vitest'
|
|
8
|
+
const warn = require('./warn')
|
|
9
|
+
|
|
10
|
+
describe('warn', () => {
|
|
11
|
+
it('logs through node.getDocument().getLogger() when a document is reachable', () => {
|
|
12
|
+
const warnFn = vi.fn()
|
|
13
|
+
const node = { getDocument: () => ({ getLogger: () => ({ warn: warnFn }) }) }
|
|
14
|
+
warn(node, '[cards]', 'a cards block with no cards in it')
|
|
15
|
+
expect(warnFn).toHaveBeenCalledWith('[cards] — a cards block with no cards in it')
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it('logs through node.getLogger() directly when node is itself a document', () => {
|
|
19
|
+
const warnFn = vi.fn()
|
|
20
|
+
const node = { getLogger: () => ({ warn: warnFn }) }
|
|
21
|
+
warn(node, '[cards]', 'no cards in it')
|
|
22
|
+
expect(warnFn).toHaveBeenCalledWith('[cards] — no cards in it')
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('appends the expected values when given a closed set', () => {
|
|
26
|
+
const warnFn = vi.fn()
|
|
27
|
+
const node = { getLogger: () => ({ warn: warnFn }) }
|
|
28
|
+
warn(node, 'label:mauve[]', 'unknown IDS Label variant "mauve"', ['white', 'grey'])
|
|
29
|
+
expect(warnFn).toHaveBeenCalledWith(
|
|
30
|
+
'label:mauve[] — unknown IDS Label variant "mauve"; expected one of white, grey'
|
|
31
|
+
)
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('appends nothing when expected is omitted or empty', () => {
|
|
35
|
+
const warnFn = vi.fn()
|
|
36
|
+
const node = { getLogger: () => ({ warn: warnFn }) }
|
|
37
|
+
warn(node, '[cta]', 'no primary action', [])
|
|
38
|
+
expect(warnFn).toHaveBeenCalledWith('[cta] — no primary action')
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('tolerates a node with no getDocument and no getLogger at all', () => {
|
|
42
|
+
expect(() => warn({}, '[cta]', 'problem')).not.toThrow()
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('tolerates getDocument() returning an object with no getLogger', () => {
|
|
46
|
+
const node = { getDocument: () => ({}) }
|
|
47
|
+
expect(() => warn(node, '[cta]', 'problem')).not.toThrow()
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('tolerates a logger object whose warn is not a function', () => {
|
|
51
|
+
// Intentionally not a real Logger shape — proves the `typeof
|
|
52
|
+
// logger.warn !== 'function'` guard, not something a real caller
|
|
53
|
+
// would ever construct.
|
|
54
|
+
const node = /** @type {any} */ ({ getLogger: () => ({ warn: 'not-a-function' }) })
|
|
55
|
+
expect(() => warn(node, '[cta]', 'problem')).not.toThrow()
|
|
56
|
+
})
|
|
57
|
+
})
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@inditextech/docouture-asciidoc-extensions",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"description": "Asciidoctor extensions shared by docouture documentation sites — authored content that has no AsciiDoc equivalent, rendered as this project's own component markup",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -30,6 +30,7 @@
|
|
|
30
30
|
"scripts": {
|
|
31
31
|
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
32
32
|
"lint": "eslint .",
|
|
33
|
-
"lint:fix": "eslint . --fix"
|
|
33
|
+
"lint:fix": "eslint . --fix",
|
|
34
|
+
"test": "vitest run"
|
|
34
35
|
}
|
|
35
36
|
}
|