@redocly/realm-plugin-asciidoc 0.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.
@@ -0,0 +1,628 @@
1
+ import React, { useCallback, useMemo, useState } from 'react';
2
+ import styled from 'styled-components';
3
+ import { Admonition, CodeBlock, Markdown, useThemeHooks } from '@redocly/theme';
4
+
5
+ import type {
6
+ AsciidocFrontmatter,
7
+ AsciidocSection,
8
+ CodeBlockNode,
9
+ ContentNode,
10
+ InlineIcon,
11
+ ListItemNode,
12
+ DescriptionListItemNode,
13
+ TableCellNode,
14
+ } from './types.js';
15
+
16
+ import { DocumentationLayout } from '@redocly/theme/layouts/DocumentationLayout';
17
+ import { TableOfContent } from '@redocly/theme/components/TableOfContent/TableOfContent';
18
+ import { Heading } from '@redocly/theme/markdoc/components/Heading/Heading';
19
+ import { CDNIcon } from '@redocly/theme/icons/CDNIcon/CDNIcon';
20
+
21
+ type PageProps = {
22
+ title: string;
23
+ contentNodes: ContentNode[];
24
+ sections: AsciidocSection[];
25
+ links: Array<{ href: string; text: string; type: string }>;
26
+ frontmatter: AsciidocFrontmatter;
27
+ };
28
+
29
+ type AsciidocDocsProps = {
30
+ pageProps: PageProps;
31
+ };
32
+
33
+ function buildTocHeadings(
34
+ sections: AsciidocSection[],
35
+ ): Array<{ id: string; value: string; depth: number }> {
36
+ const items: Array<{ id: string; value: string; depth: number }> = [];
37
+ for (const section of sections) {
38
+ items.push({ id: section.id, value: section.title, depth: section.level });
39
+ if (section.children.length > 0) {
40
+ items.push(...buildTocHeadings(section.children));
41
+ }
42
+ }
43
+ return items;
44
+ }
45
+
46
+ /**
47
+ * Renders an HTML string that may contain `<!--icon:N-->` placeholders,
48
+ * splitting the HTML and interleaving CDNIcon React components.
49
+ */
50
+ function renderHtmlWithIcons(
51
+ html: string,
52
+ icons: InlineIcon[],
53
+ keyPrefix: string,
54
+ ): React.ReactNode {
55
+ if (!icons.length || !html.includes('<!--icon:')) {
56
+ return <span key={keyPrefix} dangerouslySetInnerHTML={{ __html: html }} />;
57
+ }
58
+
59
+ const parts = html.split(/(<!--icon:\d+-->)/);
60
+ const nodes: React.ReactNode[] = [];
61
+
62
+ for (let j = 0; j < parts.length; j++) {
63
+ const fragment = parts[j];
64
+ const iconMatch = fragment.match(/<!--icon:(\d+)-->/);
65
+ if (iconMatch) {
66
+ const idx = parseInt(iconMatch[1], 10);
67
+ const icon = icons[idx];
68
+ if (icon) {
69
+ nodes.push(
70
+ <CDNIcon
71
+ key={`${keyPrefix}-icon-${idx}`}
72
+ name={icon.name}
73
+ type={icon.style}
74
+ size={icon.size}
75
+ />,
76
+ );
77
+ }
78
+ } else if (fragment) {
79
+ nodes.push(
80
+ <span key={`${keyPrefix}-f-${j}`} dangerouslySetInnerHTML={{ __html: fragment }} />,
81
+ );
82
+ }
83
+ }
84
+
85
+ return <>{nodes}</>;
86
+ }
87
+
88
+ function injectCalloutBadges(html: string, callouts: { line: number; number: number }[]): string {
89
+ if (!callouts.length) return html;
90
+
91
+ const calloutByLine = new Map(callouts.map((c) => [c.line, c.number]));
92
+ let lineNumber = 0;
93
+
94
+ return html.replace(/<span class="line">(.*?)<\/span>/g, (match, inner) => {
95
+ lineNumber++;
96
+ const calloutNumber = calloutByLine.get(lineNumber);
97
+ if (calloutNumber != null) {
98
+ return `<span class="line">${inner}<span class="code-callout-badge" data-callout="${calloutNumber}">${calloutNumber}</span></span>`;
99
+ }
100
+ return match;
101
+ });
102
+ }
103
+
104
+ function AsciidocCodeBlock({ node }: { node: CodeBlockNode }): React.ReactElement {
105
+ const { useCodeHighlight } = useThemeHooks();
106
+ const { highlight } = useCodeHighlight() || {};
107
+
108
+ const highlightLines = node.callouts?.map((c) => String(c.line)).join(',');
109
+
110
+ const highlightedHtml = useMemo(() => {
111
+ if (!highlight) return undefined;
112
+
113
+ let html = highlight(node.source, node.lang, {
114
+ highlight: highlightLines,
115
+ });
116
+
117
+ if (html && node.callouts?.length) {
118
+ html = injectCalloutBadges(html, node.callouts);
119
+ }
120
+
121
+ return html || undefined;
122
+ }, [highlight, node.source, node.lang, highlightLines, node.callouts]);
123
+
124
+ return (
125
+ <CodeBlock
126
+ lang={node.lang}
127
+ source={node.source}
128
+ highlightedHtml={highlightedHtml}
129
+ header={
130
+ node.title || node.lang
131
+ ? {
132
+ title: node.title || node.lang,
133
+ controls: { copy: {} },
134
+ }
135
+ : { controls: { copy: {} } }
136
+ }
137
+ />
138
+ );
139
+ }
140
+
141
+ // Counter to track heading index for PageActions (Copy button shown on first heading)
142
+ let headingCounter = 0;
143
+
144
+ function RenderNode({
145
+ node,
146
+ index,
147
+ }: {
148
+ node: ContentNode;
149
+ index: number;
150
+ }): React.ReactElement | null {
151
+ const key = `node-${index}`;
152
+
153
+ switch (node.type) {
154
+ case 'section': {
155
+ const __idx = headingCounter++;
156
+ return (
157
+ <React.Fragment key={key}>
158
+ <Heading level={node.level} id={node.id} __idx={__idx}>
159
+ {renderHtmlWithIcons(node.titleHtml, node.titleIcons, `${key}-title`)}
160
+ </Heading>
161
+ <RenderNodes nodes={node.children} keyPrefix={key} />
162
+ </React.Fragment>
163
+ );
164
+ }
165
+
166
+ case 'heading': {
167
+ const __idx = headingCounter++;
168
+ return (
169
+ <Heading key={key} level={node.level} id={node.id} __idx={__idx}>
170
+ <span dangerouslySetInnerHTML={{ __html: node.contentHtml }} />
171
+ </Heading>
172
+ );
173
+ }
174
+
175
+ case 'paragraph': {
176
+ const Tag = node.role ? 'div' : 'p';
177
+ const className = node.role || undefined;
178
+ return (
179
+ <Tag key={key} className={className}>
180
+ {renderHtmlWithIcons(node.contentHtml, node.icons, key)}
181
+ </Tag>
182
+ );
183
+ }
184
+
185
+ case 'codeBlock': {
186
+ return (
187
+ <React.Fragment key={key}>
188
+ <AsciidocCodeBlock node={node} />
189
+ {node.calloutDescriptions && node.calloutDescriptions.length > 0 && (
190
+ <ol className="callout-list">
191
+ {node.calloutDescriptions.map((desc, i) => (
192
+ <li key={`${key}-co-${i}`}>
193
+ <span className="callout-badge">{i + 1}</span>
194
+ <span dangerouslySetInnerHTML={{ __html: desc }} />
195
+ </li>
196
+ ))}
197
+ </ol>
198
+ )}
199
+ </React.Fragment>
200
+ );
201
+ }
202
+
203
+ case 'admonition':
204
+ return (
205
+ <Admonition key={key} type={node.admonitionType} name={node.name}>
206
+ <RenderNodes nodes={node.children} keyPrefix={key} />
207
+ </Admonition>
208
+ );
209
+
210
+ case 'list': {
211
+ const ListTag = node.ordered ? 'ol' : 'ul';
212
+ return (
213
+ <ListTag key={key} className="md">
214
+ {node.items.map((item, i) => (
215
+ <RenderListItem key={`${key}-li-${i}`} item={item} keyPrefix={`${key}-li-${i}`} />
216
+ ))}
217
+ </ListTag>
218
+ );
219
+ }
220
+
221
+ case 'descriptionList':
222
+ return (
223
+ <dl key={key} className="dlist">
224
+ {node.items.map((item, i) => (
225
+ <RenderDlistItem key={`${key}-dl-${i}`} item={item} keyPrefix={`${key}-dl-${i}`} />
226
+ ))}
227
+ </dl>
228
+ );
229
+
230
+ case 'table':
231
+ return (
232
+ <div key={key} className="md-table-wrapper">
233
+ <table className="md">
234
+ {node.title && <caption>{node.title}</caption>}
235
+ {node.head.length > 0 && (
236
+ <thead>
237
+ {node.head.map((row, ri) => (
238
+ <tr key={`${key}-th-${ri}`}>
239
+ {row.map((cell, ci) => (
240
+ <RenderTableCell key={`${key}-th-${ri}-${ci}`} cell={cell} tag="th" />
241
+ ))}
242
+ </tr>
243
+ ))}
244
+ </thead>
245
+ )}
246
+ <tbody>
247
+ {node.body.map((row, ri) => (
248
+ <tr key={`${key}-tb-${ri}`}>
249
+ {row.map((cell, ci) => (
250
+ <RenderTableCell key={`${key}-tb-${ri}-${ci}`} cell={cell} tag="td" />
251
+ ))}
252
+ </tr>
253
+ ))}
254
+ </tbody>
255
+ {node.foot.length > 0 && (
256
+ <tfoot>
257
+ {node.foot.map((row, ri) => (
258
+ <tr key={`${key}-tf-${ri}`}>
259
+ {row.map((cell, ci) => (
260
+ <RenderTableCell key={`${key}-tf-${ri}-${ci}`} cell={cell} tag="td" />
261
+ ))}
262
+ </tr>
263
+ ))}
264
+ </tfoot>
265
+ )}
266
+ </table>
267
+ </div>
268
+ );
269
+
270
+ case 'image':
271
+ return (
272
+ <div key={key} className="imageblock">
273
+ <img
274
+ src={node.src}
275
+ alt={node.alt}
276
+ {...(node.width ? { width: node.width } : {})}
277
+ {...(node.height ? { height: node.height } : {})}
278
+ />
279
+ {node.title && <div className="title">{node.title}</div>}
280
+ </div>
281
+ );
282
+
283
+ case 'quote':
284
+ return (
285
+ <div key={key} className="quoteblock">
286
+ <blockquote>
287
+ <RenderNodes nodes={node.children} keyPrefix={key} />
288
+ </blockquote>
289
+ {node.attribution && (
290
+ <div className="attribution">
291
+ — {node.attribution}
292
+ {node.citeTitle && (
293
+ <>
294
+ , <cite>{node.citeTitle}</cite>
295
+ </>
296
+ )}
297
+ </div>
298
+ )}
299
+ </div>
300
+ );
301
+
302
+ case 'sidebar':
303
+ return (
304
+ <div key={key} className="sidebarblock">
305
+ <div className="content">
306
+ {node.title && <div className="title">{node.title}</div>}
307
+ <RenderNodes nodes={node.children} keyPrefix={key} />
308
+ </div>
309
+ </div>
310
+ );
311
+
312
+ case 'example':
313
+ return (
314
+ <div key={key} className="exampleblock">
315
+ <div className="content">
316
+ {node.title && <div className="title">{node.title}</div>}
317
+ <RenderNodes nodes={node.children} keyPrefix={key} />
318
+ </div>
319
+ </div>
320
+ );
321
+
322
+ case 'collapsible':
323
+ return (
324
+ <details key={key}>
325
+ {node.title && <summary>{node.title}</summary>}
326
+ <RenderNodes nodes={node.children} keyPrefix={key} />
327
+ </details>
328
+ );
329
+
330
+ case 'thematicBreak':
331
+ return <hr key={key} />;
332
+
333
+ case 'htmlPassthrough':
334
+ return <div key={key} dangerouslySetInnerHTML={{ __html: node.html }} />;
335
+
336
+ default:
337
+ return null;
338
+ }
339
+ }
340
+
341
+ function RenderNodes({
342
+ nodes,
343
+ keyPrefix,
344
+ }: {
345
+ nodes: ContentNode[];
346
+ keyPrefix: string;
347
+ }): React.ReactElement {
348
+ return (
349
+ <>
350
+ {nodes.map((node, i) => (
351
+ <RenderNode key={`${keyPrefix}-${i}`} node={node} index={i} />
352
+ ))}
353
+ </>
354
+ );
355
+ }
356
+
357
+ function RenderListItem({
358
+ item,
359
+ keyPrefix,
360
+ }: {
361
+ item: ListItemNode;
362
+ keyPrefix: string;
363
+ }): React.ReactElement {
364
+ return (
365
+ <li>
366
+ {renderHtmlWithIcons(item.contentHtml, item.icons, keyPrefix)}
367
+ {item.children.length > 0 && <RenderNodes nodes={item.children} keyPrefix={keyPrefix} />}
368
+ </li>
369
+ );
370
+ }
371
+
372
+ function RenderDlistItem({
373
+ item,
374
+ keyPrefix,
375
+ }: {
376
+ item: DescriptionListItemNode;
377
+ keyPrefix: string;
378
+ }): React.ReactElement {
379
+ return (
380
+ <>
381
+ {item.termsHtml.map((termHtml, ti) => (
382
+ <dt key={`${keyPrefix}-dt-${ti}`} dangerouslySetInnerHTML={{ __html: termHtml }} />
383
+ ))}
384
+ <dd>
385
+ {renderHtmlWithIcons(item.descriptionHtml, item.descriptionIcons, `${keyPrefix}-dd`)}
386
+ {item.children.length > 0 && (
387
+ <RenderNodes nodes={item.children} keyPrefix={`${keyPrefix}-dd`} />
388
+ )}
389
+ </dd>
390
+ </>
391
+ );
392
+ }
393
+
394
+ function RenderTableCell({
395
+ cell,
396
+ tag: Tag,
397
+ }: {
398
+ cell: TableCellNode;
399
+ tag: 'th' | 'td';
400
+ }): React.ReactElement {
401
+ return (
402
+ <Tag
403
+ {...(cell.colSpan ? { colSpan: cell.colSpan } : {})}
404
+ {...(cell.rowSpan ? { rowSpan: cell.rowSpan } : {})}
405
+ dangerouslySetInnerHTML={{ __html: cell.contentHtml }}
406
+ />
407
+ );
408
+ }
409
+
410
+ const Template: React.FC<AsciidocDocsProps> = ({ pageProps }) => {
411
+ const { useSidebarSiblingsData } = useThemeHooks();
412
+ const { nextPage, prevPage } = useSidebarSiblingsData() || {};
413
+ const [wrapperElement, setWrapperElement] = useState<HTMLDivElement | null>(null);
414
+
415
+ const wrapperRefCb = useCallback((node: HTMLDivElement) => {
416
+ if (!node) return;
417
+ setWrapperElement(node);
418
+ }, []);
419
+
420
+ const tocHeadings = useMemo(() => {
421
+ const items = buildTocHeadings(pageProps.sections);
422
+ return items.filter((item) => Boolean(item.id) && Boolean(item.value));
423
+ }, [pageProps.sections]);
424
+
425
+ const tableOfContent = <TableOfContent headings={tocHeadings} contentWrapper={wrapperElement} />;
426
+
427
+ // Reset heading counter for each page render (used for __idx tracking)
428
+ headingCounter = 0;
429
+
430
+ return (
431
+ <DocumentationLayout
432
+ tableOfContent={tableOfContent}
433
+ feedback={undefined}
434
+ nextPage={nextPage}
435
+ prevPage={prevPage}
436
+ >
437
+ <AsciidocWrapper ref={wrapperRefCb} as="div" className="asciidoc-content">
438
+ <RenderNodes nodes={pageProps.contentNodes} keyPrefix="root" />
439
+ </AsciidocWrapper>
440
+ </DocumentationLayout>
441
+ );
442
+ };
443
+
444
+ export default Template;
445
+
446
+ const AsciidocWrapper = styled(Markdown)`
447
+ /* === Fix list item <p> margins === */
448
+ li > p:only-child {
449
+ margin: 0;
450
+ }
451
+
452
+ li > .paragraph:first-child > p:first-child {
453
+ margin-top: 0;
454
+ }
455
+
456
+ li > .paragraph:last-child > p:last-child {
457
+ margin-bottom: 0;
458
+ }
459
+
460
+ li > p:first-child {
461
+ margin-top: 0;
462
+ }
463
+
464
+ li > p:last-child {
465
+ margin-bottom: 0;
466
+ }
467
+
468
+ /* === AsciiDoc description lists === */
469
+ .dlist dt,
470
+ dl.dlist dt {
471
+ font-weight: var(--font-weight-semibold, 600);
472
+ margin-top: var(--spacing-xs, 0.5em);
473
+ }
474
+
475
+ .dlist dd,
476
+ dl.dlist dd {
477
+ margin-left: var(--md-list-left-padding, 1.25rem);
478
+ margin-bottom: calc(var(--spacing-xxs, 0.25em) / 2);
479
+ }
480
+
481
+ /* === AsciiDoc images === */
482
+ .imageblock {
483
+ margin: var(--spacing-base, 1em) 0;
484
+ text-align: center;
485
+ }
486
+
487
+ .imageblock img {
488
+ max-width: 100%;
489
+ height: auto;
490
+ }
491
+
492
+ .imageblock .title {
493
+ font-style: italic;
494
+ margin-top: var(--spacing-xxs, 0.25em);
495
+ color: var(--text-color-secondary, #666);
496
+ font-size: var(--font-size-sm, 0.875em);
497
+ }
498
+
499
+ /* === AsciiDoc quote blocks === */
500
+ .quoteblock {
501
+ margin: var(--md-blockquote-margin-vertical, 1em) var(--md-blockquote-margin-horizontal, 0);
502
+ padding: var(--md-blockquote-padding-vertical, 0.5em) var(--md-blockquote-padding-horizontal, 1em);
503
+ border-left: var(--md-blockquote-border-left, 3px solid var(--border-color-secondary, #ddd));
504
+ background-color: var(--md-blockquote-bg-color, transparent);
505
+ color: var(--md-blockquote-text-color, var(--text-color-secondary, #666));
506
+ }
507
+
508
+ .quoteblock blockquote {
509
+ margin: 0;
510
+ padding: 0;
511
+ border: none;
512
+ background: transparent;
513
+ }
514
+
515
+ .quoteblock .attribution {
516
+ margin-top: var(--spacing-xs, 0.5em);
517
+ font-style: italic;
518
+ }
519
+
520
+ /* === AsciiDoc sidebar blocks === */
521
+ .sidebarblock {
522
+ margin: var(--spacing-base, 1em) 0;
523
+ padding: var(--spacing-sm, 0.75em) var(--spacing-base, 1em);
524
+ background-color: var(--layer-color, #f8f8f8);
525
+ border-radius: var(--border-radius-lg, 6px);
526
+ border: 1px solid var(--border-color-secondary, #e0e0e0);
527
+ }
528
+
529
+ .sidebarblock > .content > .title {
530
+ font-weight: var(--font-weight-semibold, 600);
531
+ margin-bottom: var(--spacing-xs, 0.5em);
532
+ }
533
+
534
+ /* === AsciiDoc example blocks === */
535
+ .exampleblock > .content {
536
+ padding: var(--spacing-sm, 0.75em) var(--spacing-base, 1em);
537
+ border: 1px solid var(--border-color-secondary, #e0e0e0);
538
+ border-radius: var(--border-radius-lg, 6px);
539
+ margin: var(--spacing-xs, 0.5em) 0;
540
+ }
541
+
542
+ /* === Callout badges in code blocks === */
543
+ .code-callout-badge {
544
+ display: inline-flex;
545
+ align-items: center;
546
+ justify-content: center;
547
+ min-width: 1.25em;
548
+ height: 1.25em;
549
+ border-radius: 50%;
550
+ background-color: var(--callout-badge-bg, #333);
551
+ color: var(--callout-badge-color, #fff);
552
+ font-size: 0.75em;
553
+ font-weight: 600;
554
+ margin-left: 0.5em;
555
+ vertical-align: middle;
556
+ user-select: none;
557
+ }
558
+
559
+ /* === Callout lists === */
560
+ .callout-list {
561
+ margin-top: var(--spacing-xs, 0.5em);
562
+ margin-bottom: var(--spacing-base, 1em);
563
+ padding-left: 0;
564
+ list-style: none;
565
+ }
566
+
567
+ .callout-list li {
568
+ display: flex;
569
+ align-items: baseline;
570
+ gap: 0.5em;
571
+ margin-bottom: 0.25em;
572
+ }
573
+
574
+ .callout-badge {
575
+ display: inline-flex;
576
+ align-items: center;
577
+ justify-content: center;
578
+ min-width: 1.25em;
579
+ height: 1.25em;
580
+ border-radius: 50%;
581
+ background-color: var(--callout-badge-bg, #333);
582
+ color: var(--callout-badge-color, #fff);
583
+ font-size: 0.75em;
584
+ font-weight: var(--font-weight-semibold, 600);
585
+ flex-shrink: 0;
586
+ }
587
+
588
+ /* === AsciiDoc roles (custom CSS classes) === */
589
+ .underline {
590
+ text-decoration: underline;
591
+ }
592
+
593
+ .line-through {
594
+ text-decoration: line-through;
595
+ }
596
+
597
+ .text-center {
598
+ text-align: center;
599
+ }
600
+
601
+ .text-right {
602
+ text-align: right;
603
+ }
604
+
605
+ /* === AsciiDoc footer rows === */
606
+ table tfoot {
607
+ font-weight: var(--font-weight-semibold, 600);
608
+ background-color: var(--layer-color, #f8f8f8);
609
+ }
610
+
611
+ /* === Collapsible blocks === */
612
+ details {
613
+ margin: var(--spacing-base, 1em) 0;
614
+ border: 1px solid var(--border-color-secondary, #e0e0e0);
615
+ border-radius: var(--border-radius-lg, 6px);
616
+ padding: var(--spacing-sm, 0.75em) var(--spacing-base, 1em);
617
+ }
618
+
619
+ details summary {
620
+ cursor: pointer;
621
+ font-weight: var(--font-weight-semibold, 600);
622
+ margin-bottom: var(--spacing-xs, 0.5em);
623
+ }
624
+
625
+ details[open] summary {
626
+ margin-bottom: var(--spacing-sm, 0.75em);
627
+ }
628
+ `;