@inditextech/docouture-asciidoc-extensions 0.1.0-SNAPSHOT.40.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/cta.js ADDED
@@ -0,0 +1,334 @@
1
+ 'use strict'
2
+
3
+ const { chain, chainAll, precomputeSubtree } = require('./async-compat')
4
+ const { escapeHtml, attr } = require('./html')
5
+ const warn = require('./warn')
6
+
7
+ // Call to action — a full-width band closing out a landing section, styled
8
+ // after Fumadocs' "Free & Open Source" block on the Weave.js docs home
9
+ // (`app/(home)/page.tsx`'s `OpenSource()`) rather than after any Figma frame:
10
+ // GH-23 notes this block has none. Composed from primitives already vendored
11
+ // for the landing (button, text) — no new DS component, no new CSS import
12
+ // beyond this file.
13
+ //
14
+ // WHY IT DEVIATES FROM THE ISSUE THAT PROPOSED IT
15
+ //
16
+ // GH-23 sketched `cta::[primary-url="…"]`, a block MACRO with the target as a
17
+ // raw attribute string. That can never resolve an `xref:` to a page in this
18
+ // site — only Asciidoctor itself does that, during its own inline
19
+ // substitution — which is the exact reason card-grid.js's card link and
20
+ // feature-tabs.js's slide CTA are both roled PARAGRAPHS instead of attributes.
21
+ // This block makes the same call, for the same reason: `[.primary]`/
22
+ // `[.secondary]` paragraphs carrying a real `xref:`/`link:`, resolved by the
23
+ // time this file ever sees them.
24
+ //
25
+ // It also drops the issue's inverse surface (`--ids-color-bg-inverse` +
26
+ // `--ids-color-content-inverse`) and its eyebrow chip, neither of which the
27
+ // Fumadocs reference has. An inverse band would need a real fix for its
28
+ // buttons too — the DS ships no inverse button variant, so a plain
29
+ // `.ids-button--primary` resolves BLACK text on a BLACK band in light theme
30
+ // (`--ids-comp-button-primary-default`). Fixing that means widening
31
+ // `tools/ids/sync.mjs`'s THEME_SCOPES the way the dark code-block surface
32
+ // already does (see that file's own comment) — deferred; this block instead
33
+ // sits on `--ids-color-bg-low`, which needs none of that: DS buttons already
34
+ // read correctly against it in both themes, no scope-inversion, no new
35
+ // token, no rule at all separating the band from the page.
36
+ //
37
+ // SYNTAX
38
+ //
39
+ // A `[cta]` example block, authored as its own `==` SECTION — like every
40
+ // other landing block (home.css's own header: "the content column is a
41
+ // stack of blocks... each a section of the AsciiDoc document"), it needs a
42
+ // real section heading to get the landing's uniform dashed-rule band, and
43
+ // that heading also names it in the document outline. `title=` is an
44
+ // optional block-style attribute (raw, so escaped) for a SECOND, larger line
45
+ // inside the band itself — the reference draws only one, so most callers
46
+ // need neither this nor a `.Title` line (which would attach to the image
47
+ // anyway, not to this wrapper — the same "an image consumes the marker
48
+ // immediately touching it" trap card-grid.js and feature-tabs.js hit for a
49
+ // block's STYLE, here for a title instead).
50
+ //
51
+ // Everything else is prose paragraphs, an optional pair of `image::` marks
52
+ // (light and `role=dark`, same split as feature-tabs.js's media still), and
53
+ // one or two role-marked action paragraphs:
54
+ //
55
+ // == Free & open source
56
+ //
57
+ // [cta]
58
+ // ====
59
+ // image::product-logo.svg[Product mark]
60
+ // image::product-logo-dark.svg[role=dark]
61
+ //
62
+ // Actively maintained and open for contributions. Comes with
63
+ // best-in-class documentation and developer experience.
64
+ //
65
+ // [.primary]
66
+ // https://github.com/InditexTech/weavejs-frontend/fork[Create a fork]
67
+ // ====
68
+ //
69
+ // Parts are emitted in a fixed order — title, prose, mark, actions —
70
+ // regardless of the order they were authored in, the same discipline
71
+ // feature-tabs.js applies to a slide: this is a fixed composition, not a
72
+ // flow of blocks, and an author reordering them by accident should not
73
+ // produce a layout nothing drew.
74
+ //
75
+ // `align=` on the block itself switches the inner column between
76
+ // `center` (default, the reference) and `start`.
77
+
78
+ /** The role marking the block's primary call-to-action paragraph. */
79
+ const PRIMARY_ROLE = 'primary'
80
+ /** The role marking the block's secondary call-to-action paragraph. */
81
+ const SECONDARY_ROLE = 'secondary'
82
+ /** The role marking a mark image meant for the dark theme. */
83
+ const DARK_ROLE = 'dark'
84
+
85
+ const ALIGNMENTS = ['center', 'start']
86
+ const DEFAULT_ALIGN = 'center'
87
+
88
+ /** The converter's own `<img>`, lifted whole — see card-grid.js/feature-tabs.js's identical note on why. */
89
+ const IMG_RX = /<img\b[^>]*>/i
90
+
91
+ /** The first anchor of a converted action paragraph: its href and its label. */
92
+ const CTA_RX = /<a\b[^>]*\bhref="([^"]*)"[^>]*>([\s\S]*?)<\/a>/i
93
+
94
+ /**
95
+ * A target on github.com — derived from the href, never authored. GitHub has
96
+ * no place in `icon-masks.css`: that file is generated exclusively from the
97
+ * design system's own icon mirror (its own header, `Do not edit by hand`),
98
+ * and this is a brand mark, not a DS icon — so it is the one hand-authored
99
+ * mask this package carries, in cta.css, and lives only there. No generic
100
+ * external-link icon accompanies it (there was one; removed) — a second,
101
+ * unbranded arrow beside the mark read as redundant rather than
102
+ * informative.
103
+ */
104
+ const GITHUB_RX = /^https?:\/\/(?:www\.)?github\.com\//i
105
+
106
+ /**
107
+ * An Asciidoctor block, in whichever major is running.
108
+ *
109
+ * @typedef {import('@asciidoctor/core').AbstractBlock} Block
110
+ */
111
+
112
+ /**
113
+ * The block's children, sorted into the four roles it has.
114
+ *
115
+ * @param {Block} wrapper - the parsed `[cta]` content.
116
+ * @returns {{ images: Block[], primaries: Block[], secondaries: Block[], bodies: Block[] }}
117
+ */
118
+ function ctaParts(wrapper) {
119
+ /** @type {Block[]} */
120
+ const images = []
121
+ /** @type {Block[]} */
122
+ const primaries = []
123
+ /** @type {Block[]} */
124
+ const secondaries = []
125
+ /** @type {Block[]} */
126
+ const bodies = []
127
+ for (const child of wrapper.getBlocks()) {
128
+ if (child.getContext() === 'image') images.push(child)
129
+ else if (child.hasRole(PRIMARY_ROLE)) primaries.push(child)
130
+ else if (child.hasRole(SECONDARY_ROLE)) secondaries.push(child)
131
+ else bodies.push(child)
132
+ }
133
+ return { images, primaries, secondaries, bodies }
134
+ }
135
+
136
+ /**
137
+ * Splits the mark images into the light still and its optional dark
138
+ * counterpart — the same split, and the same reason, as feature-tabs.js's
139
+ * `splitImages`.
140
+ *
141
+ * @param {Block[]} images
142
+ * @param {Block} parent - for warnings.
143
+ * @returns {{ light: Block | undefined, dark: Block | undefined }}
144
+ */
145
+ function splitImages(images, parent) {
146
+ const dark = images.filter((image) => image.hasRole(DARK_ROLE))
147
+ const light = images.filter((image) => !image.hasRole(DARK_ROLE))
148
+ if (light.length > 1) {
149
+ warn(parent, '[cta]', 'a cta has ' + light.length + ' mark images; only the first is rendered')
150
+ }
151
+ if (dark.length > 1) {
152
+ warn(parent, '[cta]', 'a cta has ' + dark.length + ' `role=dark` mark images; only the first is rendered')
153
+ }
154
+ if (dark.length && !light.length) {
155
+ warn(parent, '[cta]', 'a cta has a `role=dark` mark image but no mark for the light theme')
156
+ }
157
+ return { light: light[0], dark: dark[0] }
158
+ }
159
+
160
+ /**
161
+ * The mark: the light still, and the dark one when there is one — laid out
162
+ * exactly like feature-tabs.js's `renderMedia`.
163
+ *
164
+ * @param {string} lightHtml
165
+ * @param {string} darkHtml
166
+ * @returns {string}
167
+ */
168
+ function renderMark(lightHtml, darkHtml) {
169
+ const light = lightHtml ? (IMG_RX.exec(lightHtml) || [])[0] : ''
170
+ const dark = darkHtml ? (IMG_RX.exec(darkHtml) || [])[0] : ''
171
+ if (!light && !dark) return ''
172
+ const themed = light && dark
173
+ return (
174
+ '<div class="docouture-cta__mark">' +
175
+ (light
176
+ ? light.replace(
177
+ '<img',
178
+ '<img class="docouture-cta__mark-image' + (themed ? ' docouture-cta__mark-image--light' : '') + '"'
179
+ )
180
+ : '') +
181
+ (dark ? dark.replace('<img', '<img class="docouture-cta__mark-image docouture-cta__mark-image--dark"') : '') +
182
+ '</div>'
183
+ )
184
+ }
185
+
186
+ /**
187
+ * One action as a real DS Button, built around the converted paragraph's
188
+ * own href and label — the same construction as feature-tabs.js's
189
+ * `renderCta`, applied to either role.
190
+ *
191
+ * @param {string} converted - the converted action paragraph, or `''`.
192
+ * @param {'primary' | 'secondary'} variant
193
+ * @param {string} role - `[.primary]` or `[.secondary]`, for warnings.
194
+ * @param {Block} parent
195
+ * @returns {string}
196
+ */
197
+ function renderAction(converted, variant, role, parent) {
198
+ if (!converted) return ''
199
+ const match = CTA_RX.exec(converted)
200
+ if (!match) {
201
+ warn(parent, '[.' + role + ']', 'a cta action carries no link; make it an `xref:` or a `link:`')
202
+ return ''
203
+ }
204
+ const href = match[1] || ''
205
+ const label = match[2] || ''
206
+ const github = GITHUB_RX.test(href)
207
+ return (
208
+ '<a class="ids-button ids-button--' +
209
+ variant +
210
+ ' docouture-cta__action' +
211
+ (github ? ' ids-button--icon-and-label ids-button--icon-icon' : '') +
212
+ '"' +
213
+ attr('href', href) +
214
+ '>' +
215
+ '<span class="ids-button__content">' +
216
+ (github
217
+ ? '<span class="ids-button__icon ids-button__icon-icon docouture-cta__action-icon ' +
218
+ 'docouture-cta__action-icon--github" aria-hidden="true"></span>'
219
+ : '') +
220
+ label +
221
+ '</span>' +
222
+ '</a>'
223
+ )
224
+ }
225
+
226
+ /**
227
+ * Assembles the block once every part has been rendered.
228
+ *
229
+ * @param {Block} parent - the block this replaces.
230
+ * @param {Block} wrapper - the parsed `[cta]` content.
231
+ * @param {object} attrs - the block's own attributes.
232
+ * @param {{ createBlock: Function }} self
233
+ * @returns {object | Promise<object>}
234
+ */
235
+ function finish(parent, wrapper, attrs, self) {
236
+ const align = attrs.align || DEFAULT_ALIGN
237
+ if (!ALIGNMENTS.includes(align)) {
238
+ warn(parent, '[cta,align=' + attrs.align + ']', 'unknown alignment "' + attrs.align + '"', ALIGNMENTS)
239
+ }
240
+
241
+ // A block-style attribute, so raw, so escaped — see the header comment on
242
+ // why this is optional: the real heading is the `==` section's own, not
243
+ // this.
244
+ const title = escapeHtml(attrs.title)
245
+
246
+ const { images, primaries, secondaries, bodies } = ctaParts(wrapper)
247
+ if (!primaries.length) {
248
+ warn(parent, '[cta]', 'a cta has no `[.primary]` action')
249
+ }
250
+ if (primaries.length > 1) {
251
+ warn(parent, '[cta]', 'a cta has ' + primaries.length + ' `[.primary]` paragraphs; only the first is rendered')
252
+ }
253
+ if (secondaries.length > 1) {
254
+ warn(parent, '[cta]', 'a cta has ' + secondaries.length + ' `[.secondary]` paragraphs; only the first is rendered')
255
+ }
256
+ if (secondaries.length && !primaries.length) {
257
+ warn(parent, '[cta]', 'a cta has a `[.secondary]` action but no `[.primary]` one')
258
+ }
259
+ for (const body of bodies) {
260
+ if (body.getContext() !== 'paragraph') {
261
+ warn(parent, '[cta]', 'a cta body holds a ' + body.getContext() + ' block; only paragraphs are supported')
262
+ }
263
+ }
264
+ const { light, dark } = splitImages(images, parent)
265
+
266
+ const primary = primaries[0]
267
+ const secondary = secondaries[0]
268
+ const parts = [
269
+ light ? light.convert() : '',
270
+ dark ? dark.convert() : '',
271
+ primary ? primary.getContent() : '',
272
+ secondary ? secondary.getContent() : '',
273
+ ]
274
+ bodies.forEach((body) => parts.push(body.getContent()))
275
+
276
+ return chainAll(parts, ([lightHtml, darkHtml, primaryHtml, secondaryHtml, ...bodyHtmls]) => {
277
+ const titleHtml = title ? '<h2 class="docouture-cta__title discrete ids-text--title-l">' + title + '</h2>' : ''
278
+ const leadHtml = bodyHtmls
279
+ .filter(Boolean)
280
+ .map((text) => '<p class="docouture-cta__lead ids-text--body-l">' + text + '</p>')
281
+ .join('')
282
+ const markHtml = renderMark(lightHtml, darkHtml)
283
+ const actionsHtml =
284
+ primaryHtml || secondaryHtml
285
+ ? '<div class="docouture-cta__actions">' +
286
+ renderAction(primaryHtml, 'primary', 'primary', parent) +
287
+ renderAction(secondaryHtml, 'secondary', 'secondary', parent) +
288
+ '</div>'
289
+ : ''
290
+
291
+ const html =
292
+ '<div class="docouture-cta docouture-cta--' +
293
+ (ALIGNMENTS.includes(align) ? align : DEFAULT_ALIGN) +
294
+ '">' +
295
+ '<div class="docouture-cta__body">' +
296
+ titleHtml +
297
+ leadHtml +
298
+ '</div>' +
299
+ markHtml +
300
+ actionsHtml +
301
+ '</div>'
302
+ return self.createBlock(parent, 'pass', html, attrs)
303
+ })
304
+ }
305
+
306
+ function ctaBlock() {
307
+ this.named('cta')
308
+ this.onContext('example')
309
+ this.process((parent, reader, attrs) => {
310
+ // See steps.js's own comment: Opal (2.2) can hand this a bare JS `null`
311
+ // for a block with no attributes beyond its style, and `createBlock`
312
+ // crashes on that.
313
+ attrs = attrs || {}
314
+ // See steps.js's own comment: a literal JS `null` "source" crashes Opal
315
+ // (2.2) inside `Block#initialize`'s `.nil_or_empty?()` check.
316
+ 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
320
+ // See async-compat.js's own header comment: parseContent is sync under
321
+ // 2.2 (Opal, real Antora builds) and Promise-returning under 4.0 (the
322
+ // ui-bundle preview harness) — chain() handles either without making
323
+ // this function `async` unconditionally. precomputeSubtree is what makes
324
+ // a `.Title` carrying inline markup arrive converted rather than raw.
325
+ return chain(this.parseContent(wrapper, reader.getLines()), () =>
326
+ chain(precomputeSubtree(wrapper), () => finish(parent, wrapper, attrs, self))
327
+ )
328
+ })
329
+ }
330
+
331
+ module.exports = function registerCta(registry) {
332
+ registry.block(ctaBlock)
333
+ }
334
+ module.exports.ctaBlock = ctaBlock