@pterodoc/core 0.2.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.
Files changed (130) hide show
  1. package/LICENCE.md +10 -0
  2. package/README.md +12 -0
  3. package/lib/chunks/capture-BhdvHwrN.js +428 -0
  4. package/lib/chunks/capture-BhdvHwrN.js.map +1 -0
  5. package/lib/chunks/issues-DBsPh6ek.js +55 -0
  6. package/lib/chunks/issues-DBsPh6ek.js.map +1 -0
  7. package/lib/chunks/mime-2SUyOolJ.js +62 -0
  8. package/lib/chunks/mime-2SUyOolJ.js.map +1 -0
  9. package/lib/chunks/paths-Bh6UsRI9.js +177 -0
  10. package/lib/chunks/paths-Bh6UsRI9.js.map +1 -0
  11. package/lib/config/load.d.ts +123 -0
  12. package/lib/config/load.d.ts.map +1 -0
  13. package/lib/config/types.d.ts +136 -0
  14. package/lib/config/types.d.ts.map +1 -0
  15. package/lib/errors.d.ts +53 -0
  16. package/lib/errors.d.ts.map +1 -0
  17. package/lib/index.d.ts +23 -0
  18. package/lib/index.d.ts.map +1 -0
  19. package/lib/index.js +807 -0
  20. package/lib/index.js.map +1 -0
  21. package/lib/model/capture.d.ts +37 -0
  22. package/lib/model/capture.d.ts.map +1 -0
  23. package/lib/model/index.d.ts +13 -0
  24. package/lib/model/index.d.ts.map +1 -0
  25. package/lib/model/index.js +7 -0
  26. package/lib/model/index.js.map +1 -0
  27. package/lib/model/reader.d.ts +25 -0
  28. package/lib/model/reader.d.ts.map +1 -0
  29. package/lib/model/tree.d.ts +81 -0
  30. package/lib/model/tree.d.ts.map +1 -0
  31. package/lib/model/types.d.ts +166 -0
  32. package/lib/model/types.d.ts.map +1 -0
  33. package/lib/render/admonitions.d.ts +26 -0
  34. package/lib/render/admonitions.d.ts.map +1 -0
  35. package/lib/render/blocks.d.ts +39 -0
  36. package/lib/render/blocks.d.ts.map +1 -0
  37. package/lib/render/code.d.ts +37 -0
  38. package/lib/render/code.d.ts.map +1 -0
  39. package/lib/render/components.d.ts +49 -0
  40. package/lib/render/components.d.ts.map +1 -0
  41. package/lib/render/excerpt.d.ts +9 -0
  42. package/lib/render/excerpt.d.ts.map +1 -0
  43. package/lib/render/images.d.ts +58 -0
  44. package/lib/render/images.d.ts.map +1 -0
  45. package/lib/render/index.d.ts +80 -0
  46. package/lib/render/index.d.ts.map +1 -0
  47. package/lib/render/index.js +1336 -0
  48. package/lib/render/index.js.map +1 -0
  49. package/lib/render/inline.d.ts +25 -0
  50. package/lib/render/inline.d.ts.map +1 -0
  51. package/lib/render/links.d.ts +45 -0
  52. package/lib/render/links.d.ts.map +1 -0
  53. package/lib/render/mdx.d.ts +37 -0
  54. package/lib/render/mdx.d.ts.map +1 -0
  55. package/lib/render/page.d.ts +85 -0
  56. package/lib/render/page.d.ts.map +1 -0
  57. package/lib/render/parse.d.ts +27 -0
  58. package/lib/render/parse.d.ts.map +1 -0
  59. package/lib/render/references.d.ts +20 -0
  60. package/lib/render/references.d.ts.map +1 -0
  61. package/lib/render/renderers.d.ts +57 -0
  62. package/lib/render/renderers.d.ts.map +1 -0
  63. package/lib/render/slug.d.ts +34 -0
  64. package/lib/render/slug.d.ts.map +1 -0
  65. package/lib/render/theme.d.ts +62 -0
  66. package/lib/render/theme.d.ts.map +1 -0
  67. package/lib/sync/artifacts.d.ts +46 -0
  68. package/lib/sync/artifacts.d.ts.map +1 -0
  69. package/lib/sync/plan.d.ts +59 -0
  70. package/lib/sync/plan.d.ts.map +1 -0
  71. package/lib/sync/run.d.ts +39 -0
  72. package/lib/sync/run.d.ts.map +1 -0
  73. package/lib/target/index.d.ts +8 -0
  74. package/lib/target/index.d.ts.map +1 -0
  75. package/lib/target/index.js +2 -0
  76. package/lib/target/index.js.map +1 -0
  77. package/lib/target/target.d.ts +152 -0
  78. package/lib/target/target.d.ts.map +1 -0
  79. package/lib/util/hash.d.ts +10 -0
  80. package/lib/util/hash.d.ts.map +1 -0
  81. package/lib/util/index.d.ts +14 -0
  82. package/lib/util/index.d.ts.map +1 -0
  83. package/lib/util/index.js +7 -0
  84. package/lib/util/index.js.map +1 -0
  85. package/lib/util/issues.d.ts +52 -0
  86. package/lib/util/issues.d.ts.map +1 -0
  87. package/lib/util/mime.d.ts +16 -0
  88. package/lib/util/mime.d.ts.map +1 -0
  89. package/lib/util/paths.d.ts +42 -0
  90. package/lib/util/paths.d.ts.map +1 -0
  91. package/lib/version.d.ts +6 -0
  92. package/lib/version.d.ts.map +1 -0
  93. package/package.json +43 -0
  94. package/src/config/load.ts +387 -0
  95. package/src/config/types.ts +137 -0
  96. package/src/errors.ts +77 -0
  97. package/src/globals.d.ts +10 -0
  98. package/src/index.ts +31 -0
  99. package/src/model/capture.ts +92 -0
  100. package/src/model/index.ts +25 -0
  101. package/src/model/reader.ts +37 -0
  102. package/src/model/tree.ts +416 -0
  103. package/src/model/types.ts +167 -0
  104. package/src/render/admonitions.ts +74 -0
  105. package/src/render/blocks.ts +79 -0
  106. package/src/render/code.ts +112 -0
  107. package/src/render/components.ts +230 -0
  108. package/src/render/excerpt.ts +16 -0
  109. package/src/render/images.ts +113 -0
  110. package/src/render/index.ts +129 -0
  111. package/src/render/inline.ts +62 -0
  112. package/src/render/links.ts +94 -0
  113. package/src/render/mdx.ts +178 -0
  114. package/src/render/page.ts +289 -0
  115. package/src/render/parse.ts +65 -0
  116. package/src/render/references.ts +69 -0
  117. package/src/render/renderers.ts +346 -0
  118. package/src/render/slug.ts +63 -0
  119. package/src/render/theme.ts +92 -0
  120. package/src/sync/artifacts.ts +70 -0
  121. package/src/sync/plan.ts +69 -0
  122. package/src/sync/run.ts +641 -0
  123. package/src/target/index.ts +18 -0
  124. package/src/target/target.ts +146 -0
  125. package/src/util/hash.ts +14 -0
  126. package/src/util/index.ts +23 -0
  127. package/src/util/issues.ts +86 -0
  128. package/src/util/mime.ts +47 -0
  129. package/src/util/paths.ts +101 -0
  130. package/src/version.ts +31 -0
@@ -0,0 +1,1336 @@
1
+ import matter from 'gray-matter';
2
+ import { unified } from 'unified';
3
+ import remarkParse from 'remark-parse';
4
+ import remarkGfm from 'remark-gfm';
5
+ import remarkDirective from 'remark-directive';
6
+ import remarkFrontmatter from 'remark-frontmatter';
7
+ import remarkMdx from 'remark-mdx';
8
+ import { visit } from 'unist-util-visit';
9
+ import path from 'node:path';
10
+ import { toHast } from 'mdast-util-to-hast';
11
+ import { toHtml } from 'hast-util-to-html';
12
+ import GithubSlugger from 'github-slugger';
13
+ import { toString } from 'mdast-util-to-string';
14
+ import { I as IssueCollector } from '../chunks/issues-DBsPh6ek.js';
15
+ import fs from 'node:fs';
16
+
17
+ /**
18
+ * Gutenberg block serialisation.
19
+ *
20
+ * WordPress stores post content as HTML annotated with block comments, and the
21
+ * editor re-parses that markup and compares it with what the block's own save
22
+ * function would produce. Everything here therefore follows core's output
23
+ * closely: the attribute encoding, the class names and the whitespace.
24
+ */
25
+ /**
26
+ * Encode block attributes the way WordPress encodes them, so the editor sees
27
+ * markup it would have written itself.
28
+ */
29
+ function serializeAttrs(attributes) {
30
+ return JSON.stringify(attributes)
31
+ .replace(/--/g, '\\u002d\\u002d')
32
+ .replace(/</g, '\\u003c')
33
+ .replace(/>/g, '\\u003e')
34
+ .replace(/&/g, '\\u0026')
35
+ .replace(/\\"/g, '\\u0022');
36
+ }
37
+ /**
38
+ * Wrap inner markup in a block comment pair.
39
+ *
40
+ * @param name Block name without the `core/` prefix.
41
+ * @param attributes Omitted from the output when empty.
42
+ * @param inner The block's stored markup.
43
+ */
44
+ function serializeBlock(name, attributes, inner) {
45
+ const attrs = attributes && Object.keys(attributes).length > 0 ? ` ${serializeAttrs(attributes)}` : '';
46
+ return `<!-- wp:${name}${attrs} -->\n${inner}\n<!-- /wp:${name} -->`;
47
+ }
48
+ /**
49
+ * A dynamic block: it renders on the server and stores no inner markup.
50
+ */
51
+ function serializeVoidBlock(name, attributes) {
52
+ const attrs = attributes && Object.keys(attributes).length > 0 ? ` ${serializeAttrs(attributes)}` : '';
53
+ return `<!-- wp:${name}${attrs} /-->`;
54
+ }
55
+ /** Join sibling blocks the way WordPress does. */
56
+ function joinBlocks(blocks) {
57
+ return blocks.filter((block) => typeof block === 'string' && block !== '').join('\n\n');
58
+ }
59
+ /**
60
+ * Escape text destined for a `<code>` element.
61
+ *
62
+ * `[` becomes an entity because WordPress expands shortcodes inside code as
63
+ * happily as anywhere else, and documentation is full of bracketed samples.
64
+ */
65
+ function escapeCode(text) {
66
+ return text
67
+ .replace(/&/g, '&amp;')
68
+ .replace(/</g, '&lt;')
69
+ .replace(/>/g, '&gt;')
70
+ .replace(/\[/g, '&#91;');
71
+ }
72
+ /** Escape text for use in an HTML text node or attribute value. */
73
+ function escapeText(text) {
74
+ return String(text)
75
+ .replace(/&/g, '&amp;')
76
+ .replace(/</g, '&lt;')
77
+ .replace(/>/g, '&gt;')
78
+ .replace(/"/g, '&quot;')
79
+ .replace(/\[/g, '&#91;');
80
+ }
81
+
82
+ /** Summaries used for excerpts, SEO descriptions and index listings. */
83
+ /**
84
+ * Shorten text to at most `max` characters, breaking on a word.
85
+ *
86
+ * @param text The full text.
87
+ * @param max Longest result, before the ellipsis.
88
+ */
89
+ function excerptFrom(text, max = 160) {
90
+ const clean = String(text ?? '').replace(/\s+/g, ' ').trim();
91
+ if (clean.length <= max)
92
+ return clean;
93
+ const cut = clean.slice(0, max);
94
+ const lastSpace = cut.lastIndexOf(' ');
95
+ const kept = lastSpace > max * 0.6 ? cut.slice(0, lastSpace) : cut;
96
+ return `${kept.replace(/[\s,;:.]+$/, '')}…`;
97
+ }
98
+
99
+ /**
100
+ * Page composition: the navigation and layout wrapped around a rendered body.
101
+ *
102
+ * This module deliberately describes the page tree structurally rather than
103
+ * importing it, so the renderer stays independent of where the model came from
104
+ * and of where the page is going.
105
+ */
106
+ /** The layout used when a site configures none. */
107
+ const DEFAULT_LAYOUT = {
108
+ kind: 'two-column',
109
+ navWidth: '25%',
110
+ mainWidth: '75%',
111
+ align: '',
112
+ nav: 'page-list',
113
+ breadcrumb: true,
114
+ pagination: true,
115
+ childIndex: 'auto',
116
+ };
117
+ /** A short summary of a page, for an index listing. */
118
+ function summaryOf(node, theme) {
119
+ if (node.description)
120
+ return excerptFrom(node.description, 140);
121
+ const count = node.children.length;
122
+ if (count === 1)
123
+ return theme.text('pageCountOne');
124
+ if (count > 1)
125
+ return theme.text('pageCount', { count });
126
+ return '';
127
+ }
128
+ /** The trail of links back to the root. */
129
+ function renderBreadcrumb(input) {
130
+ const trail = [];
131
+ for (let current = input.node.parent; current; current = current.parent)
132
+ trail.unshift(current);
133
+ if (trail.length === 0)
134
+ return '';
135
+ const links = trail.map((ancestor) => `<a href="${input.href(ancestor.path)}">${escapeText(ancestor.title)}</a>`);
136
+ links.push(escapeText(input.node.title));
137
+ const className = input.theme.cls('docs-breadcrumb');
138
+ // Marked up, the plugin can swap the separator for a configured one exactly
139
+ // rather than by guessing which run of text between two links is one. Left
140
+ // bare, it is the same characters it has always been.
141
+ const separator = escapeText(input.theme.strings.breadcrumbSeparator);
142
+ const joined = input.theme.blocks === 'plugin'
143
+ ? links.join(`<span class="${input.theme.cls('breadcrumb-separator')}">${separator}</span>`)
144
+ : links.join(separator);
145
+ return serializeBlock('group', { className }, `<div class="wp-block-group ${className}">${serializeBlock('paragraph', undefined, `<p>${joined}</p>`)}</div>`);
146
+ }
147
+ /** Previous and next links, following the site's own order. */
148
+ function renderPagination(input) {
149
+ const previous = input.node.previousPath === undefined ? undefined : input.lookup(input.node.previousPath);
150
+ const next = input.node.nextPath === undefined ? undefined : input.lookup(input.node.nextPath);
151
+ if (!previous && !next)
152
+ return '';
153
+ const parts = [];
154
+ if (previous) {
155
+ const className = input.theme.cls('docs-pagination-prev');
156
+ parts.push(serializeBlock('paragraph', { className }, `<p class="${className}"><a href="${input.href(previous.path)}">${escapeText(input.theme.text('previous', { title: previous.title }))}</a></p>`));
157
+ }
158
+ if (next) {
159
+ const className = input.theme.cls('docs-pagination-next');
160
+ parts.push(serializeBlock('paragraph', { className }, `<p class="${className}"><a href="${input.href(next.path)}">${escapeText(input.theme.text('next', { title: next.title }))}</a></p>`));
161
+ }
162
+ const className = input.theme.cls('docs-pagination');
163
+ return serializeBlock('group', { className }, `<div class="wp-block-group ${className}">${joinBlocks(parts)}</div>`);
164
+ }
165
+ /** A list of the pages below this one, grouped as the sidebar grouped them. */
166
+ function renderChildIndex(input, heading) {
167
+ const { node, theme } = input;
168
+ if (node.children.length === 0)
169
+ return '';
170
+ const parts = [];
171
+ if (heading) {
172
+ const className = theme.cls('docs-index-heading');
173
+ parts.push(serializeBlock('heading', { className }, `<h2 class="wp-block-heading ${className}" id="in-this-section">${escapeText(heading)}</h2>`));
174
+ }
175
+ for (const section of node.sections.length > 0 ? node.sections : [{ label: undefined, children: node.children }]) {
176
+ if (section.label) {
177
+ parts.push(serializeBlock('heading', { level: 3 }, `<h3 class="wp-block-heading">${escapeText(section.label)}</h3>`));
178
+ }
179
+ const items = section.children
180
+ .map((child) => {
181
+ const summary = summaryOf(child, theme);
182
+ const link = `<a href="${input.href(child.path)}">${escapeText(child.title)}</a>`;
183
+ return serializeBlock('list-item', undefined, `<li>${link}${summary ? ` — ${escapeText(summary)}` : ''}</li>`);
184
+ })
185
+ .join('\n\n');
186
+ const className = theme.cls('docs-index');
187
+ parts.push(serializeBlock('list', { className }, `<ul class="wp-block-list ${className}">${items}</ul>`));
188
+ }
189
+ return joinBlocks(parts);
190
+ }
191
+ /** The navigation column's contents. */
192
+ function renderNavigation(input) {
193
+ if (input.layout.nav === 'none')
194
+ return '';
195
+ return serializeVoidBlock('page-list', { parentPageID: input.navRootId ?? 0 });
196
+ }
197
+ /** Compose the stored content of one page. */
198
+ function composePage(input) {
199
+ const { node, theme, layout } = input;
200
+ let index = '';
201
+ if (node.children.length > 0 && layout.childIndex !== 'never') {
202
+ // A page that already links all of its children does not need a generated
203
+ // list; one that links none of them does.
204
+ const linksAllChildren = node.children.every((child) => input.links.has(child.path));
205
+ if (layout.childIndex === 'always' || !linksAllChildren) {
206
+ index = renderChildIndex(input, input.body ? theme.text('indexHeading') : '');
207
+ }
208
+ }
209
+ const main = joinBlocks([
210
+ input.banner ?? '',
211
+ layout.breadcrumb ? renderBreadcrumb(input) : '',
212
+ input.body,
213
+ index,
214
+ layout.pagination ? renderPagination(input) : '',
215
+ ]);
216
+ if (layout.kind === 'single' || layout.nav === 'none')
217
+ return main;
218
+ const navClass = theme.cls('docs-nav');
219
+ const mainClass = theme.cls('docs-main');
220
+ const navColumn = serializeBlock('column', { width: layout.navWidth, className: navClass }, `<div class="wp-block-column ${navClass}" style="flex-basis:${layout.navWidth}">${renderNavigation(input)}</div>`);
221
+ const mainColumn = serializeBlock('column', { width: layout.mainWidth, className: mainClass }, `<div class="wp-block-column ${mainClass}" style="flex-basis:${layout.mainWidth}">${main}</div>`);
222
+ const columnsClass = theme.cls('docs');
223
+ const attributes = { className: columnsClass };
224
+ if (layout.align)
225
+ attributes['align'] = layout.align;
226
+ const alignClass = layout.align ? ` align${layout.align}` : '';
227
+ return serializeBlock('columns', attributes, `<div class="wp-block-columns${alignClass} ${columnsClass}">${navColumn}\n\n${mainColumn}</div>`);
228
+ }
229
+ /** Body for a path segment that exists only so the documentation has a parent. */
230
+ function renderNavigationStub(selfId) {
231
+ return serializeVoidBlock('page-list', { parentPageID: selfId });
232
+ }
233
+ /** A notice shown on every page of a version that is not the current one. */
234
+ function renderVersionBanner(theme, label, kind) {
235
+ const className = `${theme.cls('version-banner')} ${theme.cls(`version-banner-${kind}`)}`;
236
+ return serializeBlock('group', { className }, `<div class="wp-block-group ${className}">${serializeBlock('paragraph', undefined, `<p>${escapeText(theme.text('versionBanner', { label }))}</p>`)}</div>`);
237
+ }
238
+
239
+ /**
240
+ * Every class name and every human-readable string the renderer emits.
241
+ *
242
+ * Kept in one place so a site can restyle the output without patching the
243
+ * renderer, and so a non-English site can translate it.
244
+ */
245
+ /** The strings used when a site configures none. */
246
+ const DEFAULT_STRINGS = {
247
+ indexHeading: 'In this section',
248
+ breadcrumbSeparator: ' › ',
249
+ previous: '← {title}',
250
+ next: '{title} →',
251
+ documentation: 'Documentation',
252
+ pageCount: '{count} pages',
253
+ pageCountOne: '1 page',
254
+ versionBanner: 'This is documentation for {label}.',
255
+ unsupportedNotice: 'Content omitted: {what}',
256
+ };
257
+ /**
258
+ * Build a theme.
259
+ *
260
+ * @param options Class prefix and any string overrides.
261
+ */
262
+ function createTheme(options = {}) {
263
+ const classPrefix = options.classPrefix ?? 'pterodoc';
264
+ const strings = { ...DEFAULT_STRINGS, ...options.strings };
265
+ return {
266
+ classPrefix,
267
+ blocks: options.blocks ?? 'core',
268
+ strings,
269
+ cls(name) {
270
+ return `${classPrefix}-${name}`;
271
+ },
272
+ text(key, values = {}) {
273
+ return strings[key].replace(/\{(\w+)\}/g, (match, name) => name in values ? String(values[name]) : match);
274
+ },
275
+ };
276
+ }
277
+
278
+ /**
279
+ * Markdown and MDX parsing.
280
+ *
281
+ * Two processors, built once and reused: Docusaurus decides per document
282
+ * whether a file is plain markdown or MDX, and the model carries that decision
283
+ * so it is never guessed here.
284
+ */
285
+ /** Extensions Docusaurus treats as plain markdown when `format: 'detect'`. */
286
+ const PLAIN_EXTENSIONS = new Set([
287
+ '.md',
288
+ '.markdown',
289
+ '.mdown',
290
+ '.mkdn',
291
+ '.mkd',
292
+ '.mdwn',
293
+ '.mkdown',
294
+ '.ron',
295
+ ]);
296
+ /**
297
+ * Resolve `format: 'detect'` the way Docusaurus does: by extension.
298
+ *
299
+ * @param filePath Path of the source file.
300
+ */
301
+ function detectFormat(filePath) {
302
+ const dot = filePath.lastIndexOf('.');
303
+ const extension = dot === -1 ? '' : filePath.slice(dot).toLowerCase();
304
+ return PLAIN_EXTENSIONS.has(extension) ? 'md' : 'mdx';
305
+ }
306
+ const base = () => unified()
307
+ .use(remarkParse)
308
+ .use(remarkFrontmatter, ['yaml'])
309
+ .use(remarkGfm)
310
+ .use(remarkDirective);
311
+ const markdownProcessor = base();
312
+ const mdxProcessor = base().use(remarkMdx);
313
+ /**
314
+ * Parse a document body.
315
+ *
316
+ * Only the parser runs: no transformers, no compiler. The tree is what the
317
+ * rest of the renderer works on.
318
+ *
319
+ * @param markdown The body, with front matter already removed.
320
+ * @param format Which flavour to parse as.
321
+ */
322
+ function parseMarkdown(markdown, format = 'md') {
323
+ const processor = format === 'mdx' ? mdxProcessor : markdownProcessor;
324
+ return processor.parse(markdown);
325
+ }
326
+
327
+ /**
328
+ * Reference-style links and images.
329
+ *
330
+ * `[text][ref]` with a `[ref]: ./target.md` definition elsewhere only works if
331
+ * something joins the two. Doing it here, as a pre-pass over the whole
332
+ * document, means the rest of the renderer never sees a reference node and the
333
+ * definitions never reach the page as visible text.
334
+ */
335
+ /**
336
+ * Turn resolvable references into ordinary links and images, and remove the
337
+ * definitions that backed them.
338
+ *
339
+ * An unresolvable reference is left alone: CommonMark says it renders as the
340
+ * literal text that was typed, which is what the HTML conversion then does.
341
+ *
342
+ * @param root The document, modified in place.
343
+ */
344
+ function resolveReferences(root) {
345
+ const definitions = new Map();
346
+ visit(root, 'definition', (node) => {
347
+ if (!definitions.has(node.identifier))
348
+ definitions.set(node.identifier, node);
349
+ });
350
+ visit(root, (node, index, parent) => {
351
+ if (!parent || index === undefined)
352
+ return;
353
+ if (node.type !== 'linkReference' && node.type !== 'imageReference')
354
+ return;
355
+ // A reference with no definition never reaches here: CommonMark says it is
356
+ // not a link at all, so the parser has already turned it into plain text.
357
+ const definition = definitions.get(node.identifier);
358
+ if (!definition)
359
+ return;
360
+ const replacement = node.type === 'linkReference'
361
+ ? {
362
+ type: 'link',
363
+ url: definition.url,
364
+ ...(definition.title != null ? { title: definition.title } : {}),
365
+ children: node.children,
366
+ ...(node.position ? { position: node.position } : {}),
367
+ }
368
+ : {
369
+ type: 'image',
370
+ url: definition.url,
371
+ ...(definition.title != null ? { title: definition.title } : {}),
372
+ alt: node.alt ?? '',
373
+ ...(node.position ? { position: node.position } : {}),
374
+ };
375
+ parent.children[index] = replacement;
376
+ });
377
+ // Definitions carry no visible content; leaving them in would print them.
378
+ removeDefinitions(root);
379
+ }
380
+ /** Drop every definition node, at any depth. */
381
+ function removeDefinitions(root) {
382
+ const prune = (node) => {
383
+ if (!Array.isArray(node.children))
384
+ return;
385
+ node.children = node.children.filter((child) => child.type !== 'definition');
386
+ for (const child of node.children)
387
+ prune(child);
388
+ };
389
+ prune(root);
390
+ }
391
+
392
+ /**
393
+ * Lowering MDX into something a Gutenberg page can hold.
394
+ *
395
+ * MDX is JavaScript, and a WordPress page is not. What can be translated is
396
+ * translated by the component table; the rest is reported with its position
397
+ * rather than dropped, because a page that quietly loses a third of its
398
+ * content is worse than one that tells you it did.
399
+ */
400
+ /** Elements that are plain HTML and can be carried through as written. */
401
+ const HTML_ELEMENTS = new Set([
402
+ 'a', 'abbr', 'b', 'br', 'code', 'div', 'em', 'figcaption', 'figure', 'h1', 'h2', 'h3',
403
+ 'h4', 'h5', 'h6', 'hr', 'i', 'img', 'kbd', 'li', 'mark', 'ol', 'p', 'pre', 's', 'samp',
404
+ 'small', 'span', 'strong', 'sub', 'sup', 'table', 'tbody', 'td', 'th', 'thead', 'tr',
405
+ 'u', 'ul', 'var',
406
+ ]);
407
+ /** Components the renderer knows how to turn into blocks. */
408
+ const KNOWN_COMPONENTS = new Set([
409
+ 'Tabs',
410
+ 'TabItem',
411
+ 'Details',
412
+ 'details',
413
+ 'summary',
414
+ 'CodeBlock',
415
+ 'Admonition',
416
+ ]);
417
+ /** The original text a node was parsed from. */
418
+ function sourceOf(node, source) {
419
+ const start = node.position?.start.offset;
420
+ const end = node.position?.end.offset;
421
+ return start !== undefined && end !== undefined ? source.slice(start, end) : '';
422
+ }
423
+ /** Read the import specifiers out of an ESM block, without evaluating it. */
424
+ function readImports(value, into) {
425
+ const pattern = /import\s+([A-Za-z_$][\w$]*)\s+from\s+['"]([^'"]+)['"]/g;
426
+ for (const match of value.matchAll(pattern))
427
+ into.set(match[1], match[2]);
428
+ }
429
+ /** True for an expression that holds nothing but a comment. */
430
+ function isCommentOnly(node) {
431
+ const estree = node.data?.estree;
432
+ if (estree)
433
+ return (estree.body?.length ?? 0) === 0 && (estree.comments?.length ?? 0) > 0;
434
+ return /^\s*\/[/*]/.test(node.value ?? '');
435
+ }
436
+ /**
437
+ * Replace MDX-only nodes so nothing downstream has to know about them.
438
+ *
439
+ * @param root The document, modified in place.
440
+ * @param ctx Where to report, and what to do about the untranslatable.
441
+ */
442
+ function lowerMdx(root, ctx) {
443
+ const imports = new Map();
444
+ const severity = ctx.onUnknown === 'error' ? 'error' : 'warning';
445
+ const report = (node, message, code) => {
446
+ ctx.issues.add({
447
+ code,
448
+ severity,
449
+ message,
450
+ file: ctx.file,
451
+ line: node.position?.start.line,
452
+ column: node.position?.start.column,
453
+ });
454
+ if (ctx.onUnknown !== 'placeholder')
455
+ return undefined;
456
+ return { type: 'html', value: `<!-- pterodoc: ${message} -->` };
457
+ };
458
+ const replacements = [];
459
+ visit(root, (node, index, parent) => {
460
+ if (!parent || index === undefined)
461
+ return;
462
+ const typed = node;
463
+ switch (typed.type) {
464
+ case 'mdxjsEsm': {
465
+ readImports(node.value ?? '', imports);
466
+ replacements.push({ parent: parent, index, with: undefined });
467
+ return;
468
+ }
469
+ case 'mdxFlowExpression':
470
+ case 'mdxTextExpression': {
471
+ const expression = node;
472
+ if (isCommentOnly(expression)) {
473
+ replacements.push({ parent: parent, index, with: undefined });
474
+ return;
475
+ }
476
+ replacements.push({
477
+ parent: parent,
478
+ index,
479
+ with: report(typed, `An MDX expression ({${(expression.value ?? '').trim().slice(0, 40)}}) has no fixed value outside the site, so it was left out.`, 'mdx-expression'),
480
+ });
481
+ return;
482
+ }
483
+ case 'mdxJsxTextElement': {
484
+ const element = node;
485
+ const name = element.name ?? '';
486
+ if (name && HTML_ELEMENTS.has(name)) {
487
+ // Plain HTML: keep exactly what the author wrote.
488
+ replacements.push({
489
+ parent: parent,
490
+ index,
491
+ with: { type: 'html', value: sourceOf(typed, ctx.source) },
492
+ });
493
+ return;
494
+ }
495
+ if (name === '') {
496
+ // A fragment: keep the children, drop the wrapper.
497
+ return;
498
+ }
499
+ replacements.push({
500
+ parent: parent,
501
+ index,
502
+ with: report(typed, `<${name}> is a React component, which a page cannot run, so it was left out.`, 'mdx-unknown-component'),
503
+ });
504
+ return;
505
+ }
506
+ default:
507
+ return;
508
+ }
509
+ });
510
+ // Applied afterwards so the walk is not disturbed by its own edits.
511
+ for (const replacement of replacements.reverse()) {
512
+ if (replacement.with)
513
+ replacement.parent.children.splice(replacement.index, 1, replacement.with);
514
+ else
515
+ replacement.parent.children.splice(replacement.index, 1);
516
+ }
517
+ return { imports };
518
+ }
519
+ /** True for an element name pterodoc can translate into blocks. */
520
+ function isTranslatable(name) {
521
+ return KNOWN_COMPONENTS.has(name) || HTML_ELEMENTS.has(name);
522
+ }
523
+
524
+ /**
525
+ * Link rewriting.
526
+ *
527
+ * A documentation link can be relative to the source file, absolute against
528
+ * the site's base URL, or an anchor. Whichever it is, it has to end up
529
+ * pointing at the right page on the target — or, when the target has no such
530
+ * page, somewhere honest.
531
+ */
532
+ /** True for a URL that already names its own scheme or host. */
533
+ function isAbsoluteUrl(href) {
534
+ return /^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(href) || href.startsWith('//');
535
+ }
536
+ /**
537
+ * Resolve a documentation link to the URL path it refers to.
538
+ *
539
+ * Relative targets are resolved against the linking document's own permalink,
540
+ * which is what makes this work regardless of where the docs are mounted.
541
+ *
542
+ * @param href The link as written.
543
+ * @param fromPermalink Permalink of the document containing the link.
544
+ * @returns The URL path and fragment, or undefined when the link is not internal.
545
+ */
546
+ function toInternalPath(href, fromPermalink) {
547
+ if (!href || href.startsWith('#') || isAbsoluteUrl(href))
548
+ return undefined;
549
+ const hashIndex = href.indexOf('#');
550
+ const hash = hashIndex === -1 ? '' : href.slice(hashIndex);
551
+ const target = hashIndex === -1 ? href : href.slice(0, hashIndex);
552
+ const withoutExtension = target.replace(/\.mdx?$/, '');
553
+ const base = fromPermalink.endsWith('/') ? fromPermalink : `${fromPermalink}/`;
554
+ const urlPath = withoutExtension.startsWith('/')
555
+ ? path.posix.normalize(withoutExtension)
556
+ : path.posix.normalize(path.posix.join(base, '..', withoutExtension));
557
+ return { urlPath, hash };
558
+ }
559
+ /**
560
+ * Rewrite every link in a document.
561
+ *
562
+ * @param root The document, modified in place.
563
+ * @param fromPermalink Permalink of the document.
564
+ * @param resolve Where each link should point.
565
+ * @returns Tree paths of the pages this document links to.
566
+ */
567
+ function rewriteLinks(root, fromPermalink, resolve, _issues) {
568
+ const linked = new Set();
569
+ visit(root, 'link', (node, index, parent) => {
570
+ const resolved = resolve(node.url, fromPermalink);
571
+ if (resolved.path !== undefined)
572
+ linked.add(resolved.path);
573
+ if (resolved.href === null) {
574
+ // The target is not published, so keep the words and drop the link.
575
+ if (parent && index !== undefined) {
576
+ parent.children.splice(index, 1, ...node.children);
577
+ return index;
578
+ }
579
+ return;
580
+ }
581
+ node.url = resolved.href;
582
+ return;
583
+ });
584
+ return linked;
585
+ }
586
+
587
+ /**
588
+ * Phrasing content to HTML.
589
+ *
590
+ * The whole run of nodes is converted in one pass, so `mdast-util-to-hast`
591
+ * keeps whatever state it needs, and the conversion happens inside a synthetic
592
+ * paragraph so it does not separate the children with newlines the way it does
593
+ * for a root.
594
+ */
595
+ /**
596
+ * Escape shortcode brackets inside text, leaving tags and attributes alone.
597
+ *
598
+ * Done on the tree rather than on the serialised string: a `>` inside an
599
+ * attribute value would fool any regex that tries to find tag boundaries.
600
+ * A `raw` node carries the entity through serialisation unescaped.
601
+ */
602
+ function escapeShortcodesInHast(nodes) {
603
+ const out = [];
604
+ for (const node of nodes) {
605
+ if (node.type === 'text' && node.value.includes('[')) {
606
+ const parts = node.value.split('[');
607
+ parts.forEach((part, index) => {
608
+ if (index > 0)
609
+ out.push({ type: 'raw', value: '&#91;' });
610
+ if (part !== '')
611
+ out.push({ type: 'text', value: part });
612
+ });
613
+ continue;
614
+ }
615
+ if ('children' in node && Array.isArray(node.children)) {
616
+ node.children = escapeShortcodesInHast(node.children);
617
+ }
618
+ out.push(node);
619
+ }
620
+ return out;
621
+ }
622
+ /** Convert phrasing content to the hast nodes that back it. */
623
+ function toInlineHast(nodes) {
624
+ const paragraph = toHast({ type: 'paragraph', children: nodes }, { allowDangerousHtml: true });
625
+ const children = (paragraph?.children ?? []);
626
+ return escapeShortcodesInHast(children);
627
+ }
628
+ /** Serialise hast nodes to HTML. */
629
+ function hastToHtml(nodes) {
630
+ const tree = Array.isArray(nodes) ? { type: 'root', children: nodes } : nodes;
631
+ return toHtml(tree, { allowDangerousHtml: true });
632
+ }
633
+ /** Render phrasing content to HTML. */
634
+ function renderInline(nodes) {
635
+ return hastToHtml(toInlineHast(nodes));
636
+ }
637
+
638
+ /**
639
+ * Heading identifiers.
640
+ *
641
+ * The anchors have to match the ones Docusaurus generated, or every in-page
642
+ * link that survived the move would break. That is why `github-slugger` is
643
+ * pinned to the major Docusaurus itself depends on.
644
+ */
645
+ /** Start a fresh slugger for a document. */
646
+ function createSlugger() {
647
+ return new GithubSlugger();
648
+ }
649
+ /**
650
+ * Work out a heading's identifier.
651
+ *
652
+ * Docusaurus lets an explicit `{#id}` override the generated slug. Both paths
653
+ * feed the same slugger so that a later duplicate is still disambiguated.
654
+ *
655
+ * @param heading The heading node.
656
+ * @param slugger The document's slugger.
657
+ * @param maintainCase Whether Docusaurus is configured to keep heading case.
658
+ */
659
+ function headingIdFor(heading, slugger, maintainCase = false) {
660
+ const children = heading.children.map((child) => ({ ...child }));
661
+ const last = children[children.length - 1];
662
+ let explicit = '';
663
+ if (last && last.type === 'text') {
664
+ const text = last;
665
+ const match = /\s*\{#([^}]+)\}\s*$/.exec(text.value);
666
+ if (match) {
667
+ explicit = match[1].trim();
668
+ text.value = text.value.slice(0, match.index).replace(/\s+$/, '');
669
+ if (text.value === '')
670
+ children.pop();
671
+ }
672
+ }
673
+ const text = toString({ type: 'root', children });
674
+ if (explicit) {
675
+ // Register it so a later generated slug cannot collide with it.
676
+ slugger.slug(explicit, true);
677
+ return { id: explicit, children, text };
678
+ }
679
+ return { id: slugger.slug(text, maintainCase), children, text };
680
+ }
681
+
682
+ /**
683
+ * Code blocks.
684
+ *
685
+ * Docusaurus carries extra instructions in the fence's metastring — a title,
686
+ * highlighted line ranges, line numbers. WordPress core's code block has
687
+ * nowhere to put them, so the title becomes a visible caption, line numbers
688
+ * become a class a theme can act on, and anything left over is reported rather
689
+ * than dropped in silence.
690
+ */
691
+ /**
692
+ * Read a fence's metastring.
693
+ *
694
+ * @param meta The text after the language on the opening fence.
695
+ */
696
+ function parseCodeMeta(meta) {
697
+ const source = meta ?? '';
698
+ const title = /(?:^|\s)title=(?:"([^"]*)"|'([^']*)'|([^\s]+))/.exec(source);
699
+ const highlight = /\{([\d,\s-]+)\}/.exec(source);
700
+ return {
701
+ ...(title ? { title: title[1] ?? title[2] ?? title[3] ?? '' } : {}),
702
+ showLineNumbers: /(?:^|\s)showLineNumbers(?:\s|=|$)/.test(source),
703
+ ...(highlight ? { highlight: highlight[1].trim() } : {}),
704
+ };
705
+ }
706
+ /**
707
+ * Render a fenced code block.
708
+ *
709
+ * @param node The code node.
710
+ * @param theme Class names and strings.
711
+ * @param issues Where unrepresentable instructions are reported.
712
+ * @param file Source file, for the issue's position.
713
+ */
714
+ function renderCode(node, theme, issues, file) {
715
+ const lang = (node.lang ?? '').toLowerCase();
716
+ const meta = parseCodeMeta(node.meta);
717
+ if (lang === 'mermaid') {
718
+ // Entities are decoded again when mermaid reads the element's text, so
719
+ // escaping keeps `A[Start]` away from the shortcode parser without
720
+ // changing the diagram.
721
+ return serializeBlock('html', undefined, `<pre class="mermaid">${escapeCode(node.value)}</pre>`);
722
+ }
723
+ const classes = [];
724
+ if (lang)
725
+ classes.push(`language-${lang}`);
726
+ if (meta.showLineNumbers)
727
+ classes.push(theme.cls('line-numbers'));
728
+ const className = classes.join(' ');
729
+ const preClasses = className ? `wp-block-code ${className}` : 'wp-block-code';
730
+ // Highlighted lines have no core equivalent, so with core blocks alone they
731
+ // can only be reported. The plugin can render them, and reads the range from
732
+ // the block comment — never from the markup, which stays exactly what core's
733
+ // own save function would have written.
734
+ const carried = theme.blocks === 'plugin' && meta.highlight !== undefined;
735
+ const attributes = {};
736
+ if (className)
737
+ attributes['className'] = className;
738
+ if (carried)
739
+ attributes['pterodocHighlight'] = meta.highlight;
740
+ const code = serializeBlock('code', Object.keys(attributes).length > 0 ? attributes : undefined, `<pre class="${preClasses}"><code>${escapeCode(node.value)}</code></pre>`);
741
+ if (meta.highlight && !carried) {
742
+ issues?.add({
743
+ code: 'code-highlight-dropped',
744
+ severity: 'info',
745
+ message: `Highlighted lines {${meta.highlight}} have no WordPress equivalent and were not carried over. Install the pterodoc WordPress plugin and set render.blocks to 'plugin' to keep them.`,
746
+ file,
747
+ line: node.position?.start.line,
748
+ });
749
+ }
750
+ if (!meta.title)
751
+ return code;
752
+ const caption = serializeBlock('paragraph', { className: theme.cls('code-title') }, `<p class="${theme.cls('code-title')}">${escapeCode(meta.title)}</p>`);
753
+ const groupClass = theme.cls('code-group');
754
+ return serializeBlock('group', { className: groupClass }, `<div class="wp-block-group ${groupClass}">${joinBlocks([caption, code])}</div>`);
755
+ }
756
+
757
+ /**
758
+ * Docusaurus admonitions.
759
+ *
760
+ * `:::note` and its siblings are container directives. WordPress has no
761
+ * equivalent block, so each becomes a group with stable classes a theme can
762
+ * style, opened by a bold label the way Docusaurus renders one.
763
+ */
764
+ /**
765
+ * The keywords Docusaurus recognises when a site configures none.
766
+ *
767
+ * Taken from the docs plugin's own defaults rather than guessed: a site that
768
+ * writes `:::success` gets an admonition on Docusaurus and must get one here.
769
+ */
770
+ const DEFAULT_ADMONITION_KEYWORDS = [
771
+ 'secondary',
772
+ 'info',
773
+ 'success',
774
+ 'danger',
775
+ 'note',
776
+ 'tip',
777
+ 'warning',
778
+ 'important',
779
+ 'caution',
780
+ ];
781
+ /**
782
+ * Render an admonition.
783
+ *
784
+ * @param node The container directive.
785
+ * @param theme Class names and strings.
786
+ * @param renderChild How to render each block inside the admonition.
787
+ */
788
+ function renderAdmonition(node, theme, renderChild) {
789
+ const type = String(node.name ?? '').toLowerCase();
790
+ const children = [...node.children];
791
+ let label = type.charAt(0).toUpperCase() + type.slice(1);
792
+ const first = children[0];
793
+ if (first?.data?.directiveLabel) {
794
+ label = toString(children[0]);
795
+ children.shift();
796
+ }
797
+ const base = theme.cls('admonition');
798
+ const className = `${base} ${base}-${type}`;
799
+ const titleClass = theme.cls('admonition-title');
800
+ const inner = joinBlocks([
801
+ serializeBlock('paragraph', { className: titleClass }, `<p class="${titleClass}"><strong>${renderInline([{ type: 'text', value: label }])}</strong></p>`),
802
+ ...children.map(renderChild),
803
+ ]);
804
+ return serializeBlock('group', { className }, `<div class="wp-block-group ${className}">${inner}</div>`);
805
+ }
806
+
807
+ /**
808
+ * The MDX component table.
809
+ *
810
+ * Docusaurus ships a small set of components that documentation actually uses.
811
+ * Each one here has a WordPress block that carries the same meaning; anything
812
+ * else is reported by the lowering pass rather than guessed at.
813
+ */
814
+ /** Read a string attribute, ignoring expression values we cannot evaluate. */
815
+ function attribute(element, name) {
816
+ for (const attr of element.attributes ?? []) {
817
+ if (attr.type !== 'mdxJsxAttribute' || attr.name !== name)
818
+ continue;
819
+ if (typeof attr.value === 'string')
820
+ return attr.value;
821
+ const value = attr.value;
822
+ if (value && typeof value.value === 'string')
823
+ return value.value;
824
+ return undefined;
825
+ }
826
+ return undefined;
827
+ }
828
+ /** A details block, which is what a collapsible section becomes. */
829
+ function detailsBlock(summary, inner) {
830
+ return serializeBlock('details', undefined, `<details class="wp-block-details"><summary>${escapeText(summary)}</summary>${inner}</details>`);
831
+ }
832
+ /** Render `<TabItem>` as one collapsible section. */
833
+ function renderTabItem(element, ctx) {
834
+ const label = attribute(element, 'label') ?? attribute(element, 'value') ?? 'Tab';
835
+ const inner = joinBlocks((element.children ?? []).map(ctx.renderChild));
836
+ return detailsBlock(label, inner);
837
+ }
838
+ /**
839
+ * Render `<Tabs>`.
840
+ *
841
+ * Tabs need JavaScript that a published page does not have, so each tab
842
+ * becomes a collapsible section instead. All of the content survives, and it
843
+ * stays readable without any script at all.
844
+ */
845
+ function renderTabs(element, ctx) {
846
+ const items = (element.children ?? []).filter((child) => child.name === 'TabItem');
847
+ const rendered = items.map((item) => renderTabItem(item, ctx));
848
+ const className = ctx.theme.cls('tabs');
849
+ return serializeBlock('group', { className }, `<div class="wp-block-group ${className}">${joinBlocks(rendered)}</div>`);
850
+ }
851
+ /** Render `<Details>` or a raw `<details>` element. */
852
+ function renderDetails(element, ctx) {
853
+ const children = [...(element.children ?? [])];
854
+ let summary = attribute(element, 'summary') ?? '';
855
+ const summaryIndex = children.findIndex((child) => child.name === 'summary');
856
+ if (summaryIndex !== -1) {
857
+ const node = children[summaryIndex];
858
+ summary = plainText$1(node, ctx.source);
859
+ children.splice(summaryIndex, 1);
860
+ }
861
+ return detailsBlock(summary || 'Details', joinBlocks(children.map(ctx.renderChild)));
862
+ }
863
+ /** Render `<CodeBlock language="ts" title="x">`. */
864
+ function renderCodeBlock(element, ctx) {
865
+ const language = attribute(element, 'language') ?? '';
866
+ const title = attribute(element, 'title');
867
+ // The children parse as ordinary markdown, so the code arrives as text
868
+ // inside paragraphs rather than as one raw string.
869
+ const value = textContent(element.children ?? []);
870
+ const classes = language ? `wp-block-code language-${language}` : 'wp-block-code';
871
+ const code = serializeBlock('code', language ? { className: `language-${language}` } : undefined, `<pre class="${classes}"><code>${escapeCode(value)}</code></pre>`);
872
+ if (!title)
873
+ return code;
874
+ const groupClass = ctx.theme.cls('code-group');
875
+ const titleClass = ctx.theme.cls('code-title');
876
+ return serializeBlock('group', { className: groupClass }, `<div class="wp-block-group ${groupClass}">${joinBlocks([
877
+ serializeBlock('paragraph', { className: titleClass }, `<p class="${titleClass}">${escapeText(title)}</p>`),
878
+ code,
879
+ ])}</div>`);
880
+ }
881
+ /** Render `<Admonition type="tip" title="…">`. */
882
+ function renderAdmonitionElement(element, ctx) {
883
+ const type = (attribute(element, 'type') ?? 'note').toLowerCase();
884
+ const label = attribute(element, 'title') ?? type.charAt(0).toUpperCase() + type.slice(1);
885
+ const base = ctx.theme.cls('admonition');
886
+ const className = `${base} ${base}-${type}`;
887
+ const titleClass = ctx.theme.cls('admonition-title');
888
+ const inner = joinBlocks([
889
+ serializeBlock('paragraph', { className: titleClass }, `<p class="${titleClass}"><strong>${escapeText(label)}</strong></p>`),
890
+ ...(element.children ?? []).map(ctx.renderChild),
891
+ ]);
892
+ return serializeBlock('group', { className }, `<div class="wp-block-group ${className}">${inner}</div>`);
893
+ }
894
+ /**
895
+ * The text a run of nodes carries.
896
+ *
897
+ * Block-level children are separated by a blank line, which is what keeps the
898
+ * lines of a code block apart once MDX has parsed them into paragraphs.
899
+ */
900
+ function textContent(nodes) {
901
+ const blocks = [];
902
+ for (const node of nodes) {
903
+ const parts = [];
904
+ const walk = (child) => {
905
+ if (typeof child.value === 'string' && child.type !== 'html')
906
+ parts.push(child.value);
907
+ for (const grandChild of (child.children ?? []))
908
+ walk(grandChild);
909
+ };
910
+ walk(node);
911
+ const text = parts.join('');
912
+ if (text !== '')
913
+ blocks.push(text);
914
+ }
915
+ return blocks.join('\n\n');
916
+ }
917
+ /** The text inside an element, for a summary. */
918
+ function plainText$1(element, source) {
919
+ const parts = [];
920
+ const walk = (node) => {
921
+ if (typeof node.value === 'string' && node.type === 'text')
922
+ parts.push(node.value);
923
+ for (const child of (node.children ?? []))
924
+ walk(child);
925
+ };
926
+ walk(element);
927
+ const text = parts.join('').trim();
928
+ if (text)
929
+ return text;
930
+ const start = element.position?.start.offset;
931
+ const end = element.position?.end.offset;
932
+ return start !== undefined && end !== undefined
933
+ ? source.slice(start, end).replace(/<[^>]*>/g, '').trim()
934
+ : '';
935
+ }
936
+ /**
937
+ * Render a block-level JSX element.
938
+ *
939
+ * @returns The block markup, or undefined when nothing here can render it.
940
+ */
941
+ function renderJsxBlock(element, ctx) {
942
+ const name = element.name ?? '';
943
+ switch (name) {
944
+ case 'Tabs':
945
+ return renderTabs(element, ctx);
946
+ case 'TabItem':
947
+ return renderTabItem(element, ctx);
948
+ case 'Details':
949
+ case 'details':
950
+ return renderDetails(element, ctx);
951
+ case 'CodeBlock':
952
+ return renderCodeBlock(element, ctx);
953
+ case 'Admonition':
954
+ return renderAdmonitionElement(element, ctx);
955
+ }
956
+ if (name === '') {
957
+ // A fragment contributes nothing of its own.
958
+ return joinBlocks((element.children ?? []).map(ctx.renderChild));
959
+ }
960
+ if (HTML_ELEMENTS.has(name)) {
961
+ const start = element.position?.start.offset;
962
+ const end = element.position?.end.offset;
963
+ if (start !== undefined && end !== undefined) {
964
+ return serializeBlock('html', undefined, ctx.source.slice(start, end));
965
+ }
966
+ }
967
+ return undefined;
968
+ }
969
+
970
+ /**
971
+ * Markdown block nodes to Gutenberg blocks.
972
+ *
973
+ * One function per node type, dispatched from `renderBlock`. Everything that
974
+ * cannot be represented is reported as an issue; nothing is dropped quietly.
975
+ */
976
+ /** A context with the defaults a caller usually wants. */
977
+ function createRenderContext(init) {
978
+ return {
979
+ maintainCase: false,
980
+ admonitionKeywords: new Set(DEFAULT_ADMONITION_KEYWORDS),
981
+ source: '',
982
+ ...init,
983
+ };
984
+ }
985
+ /** True when a list only holds shapes core's list block can carry. */
986
+ function listIsSimple(list) {
987
+ return list.children.every((item) => item.type === 'listItem' &&
988
+ item.children.every((child, index) => (child.type === 'paragraph' && index === 0) || child.type === 'list'));
989
+ }
990
+ function renderListItem(item, ctx) {
991
+ const parts = [];
992
+ for (const child of item.children) {
993
+ if (child.type === 'paragraph')
994
+ parts.push(renderInline(child.children));
995
+ else if (child.type === 'list')
996
+ parts.push(renderList(child));
997
+ }
998
+ const marker = item.checked === true ? '☑ ' : item.checked === false ? '☐ ' : '';
999
+ return serializeBlock('list-item', undefined, `<li>${marker}${parts.join('')}</li>`);
1000
+ }
1001
+ function renderList(node, ctx) {
1002
+ const tag = node.ordered ? 'ol' : 'ul';
1003
+ const attributes = {};
1004
+ if (node.ordered)
1005
+ attributes['ordered'] = true;
1006
+ const start = node.ordered && typeof node.start === 'number' && node.start !== 1 ? node.start : null;
1007
+ if (start !== null)
1008
+ attributes['start'] = start;
1009
+ const items = node.children.map((item) => renderListItem(item)).join('\n\n');
1010
+ const open = `<${tag}${start !== null ? ` start="${start}"` : ''} class="wp-block-list">`;
1011
+ return serializeBlock('list', attributes, `${open}${items}</${tag}>`);
1012
+ }
1013
+ function renderTable(node) {
1014
+ const align = node.align ?? [];
1015
+ const cell = (content, index, header) => {
1016
+ const tag = header ? 'th' : 'td';
1017
+ const alignment = align[index];
1018
+ const attrs = alignment ? ` class="has-text-align-${alignment}" data-align="${alignment}"` : '';
1019
+ return `<${tag}${attrs}>${renderInline(content.children ?? [])}</${tag}>`;
1020
+ };
1021
+ const [head, ...body] = node.children;
1022
+ const thead = head
1023
+ ? `<thead><tr>${head.children.map((c, i) => cell(c, i, true)).join('')}</tr></thead>`
1024
+ : '';
1025
+ const tbody = body.length
1026
+ ? `<tbody>${body
1027
+ .map((row) => `<tr>${row.children.map((c, i) => cell(c, i, false)).join('')}</tr>`)
1028
+ .join('')}</tbody>`
1029
+ : '';
1030
+ return serializeBlock('table', { hasFixedLayout: false }, `<figure class="wp-block-table"><table>${thead}${tbody}</table></figure>`);
1031
+ }
1032
+ /** A paragraph holding nothing but one image becomes an image block. */
1033
+ function loneImage(node) {
1034
+ const meaningful = node.children.filter((child) => !(child.type === 'text' && child.value.trim() === ''));
1035
+ return meaningful.length === 1 && meaningful[0].type === 'image'
1036
+ ? meaningful[0]
1037
+ : undefined;
1038
+ }
1039
+ function renderImageBlock(node, ctx) {
1040
+ const uploaded = ctx.media?.get(node.url);
1041
+ const src = uploaded?.url ?? node.url;
1042
+ const attributes = { sizeSlug: 'large', linkDestination: 'none' };
1043
+ if (uploaded)
1044
+ attributes['id'] = uploaded.id;
1045
+ const classes = `wp-block-image size-large`;
1046
+ const imgClass = uploaded ? ` class="wp-image-${uploaded.id}"` : '';
1047
+ const caption = node.title
1048
+ ? `<figcaption class="wp-element-caption">${escapeText(node.title)}</figcaption>`
1049
+ : '';
1050
+ return serializeBlock('image', attributes, `<figure class="${classes}"><img src="${escapeText(src)}" alt="${escapeText(node.alt ?? '')}"${imgClass}/>${caption}</figure>`);
1051
+ }
1052
+ /**
1053
+ * Render one top-level markdown node.
1054
+ *
1055
+ * @param node The node.
1056
+ * @param ctx Theme, slugger and issue collector.
1057
+ */
1058
+ function renderBlock(node, ctx) {
1059
+ switch (node.type) {
1060
+ case 'paragraph': {
1061
+ const image = loneImage(node);
1062
+ if (image)
1063
+ return renderImageBlock(image, ctx);
1064
+ const html = renderInline(node.children);
1065
+ if (html.trim() === '')
1066
+ return '';
1067
+ return serializeBlock('paragraph', undefined, `<p>${html}</p>`);
1068
+ }
1069
+ case 'heading': {
1070
+ const heading = node;
1071
+ const { id, children } = headingIdFor(heading, ctx.slugger, ctx.maintainCase);
1072
+ const level = heading.depth;
1073
+ const attributes = level === 2 ? undefined : { level };
1074
+ return serializeBlock('heading', attributes, `<h${level} class="wp-block-heading" id="${id}">${renderInline(children)}</h${level}>`);
1075
+ }
1076
+ case 'list': {
1077
+ const list = node;
1078
+ if (listIsSimple(list))
1079
+ return renderList(list);
1080
+ ctx.issues.add({
1081
+ code: 'list-not-representable',
1082
+ severity: 'info',
1083
+ message: 'A list item holds blocks the WordPress list block cannot carry; kept as HTML.',
1084
+ file: ctx.file,
1085
+ line: node.position?.start.line,
1086
+ });
1087
+ const hast = toHast(list, { allowDangerousHtml: true });
1088
+ const escaped = hast ? escapeShortcodesInHast([hast]) : [];
1089
+ return serializeBlock('html', undefined, hastToHtml(escaped));
1090
+ }
1091
+ case 'code':
1092
+ return renderCode(node, ctx.theme, ctx.issues, ctx.file);
1093
+ case 'blockquote': {
1094
+ const inner = joinBlocks(node.children.map((child) => renderBlock(child, ctx)));
1095
+ return serializeBlock('quote', undefined, `<blockquote class="wp-block-quote">${inner}</blockquote>`);
1096
+ }
1097
+ case 'table':
1098
+ return renderTable(node);
1099
+ case 'thematicBreak':
1100
+ return serializeBlock('separator', undefined, '<hr class="wp-block-separator has-alpha-channel-opacity"/>');
1101
+ case 'containerDirective': {
1102
+ const directive = node;
1103
+ const name = String(directive.name ?? '').toLowerCase();
1104
+ if (ctx.admonitionKeywords.has(name)) {
1105
+ return renderAdmonition(directive, ctx.theme, (child) => renderBlock(child, ctx));
1106
+ }
1107
+ ctx.issues.add({
1108
+ code: 'directive-unknown',
1109
+ severity: 'warning',
1110
+ message: `":::${directive.name}" is not an admonition on this site; its content was kept without the wrapper.`,
1111
+ file: ctx.file,
1112
+ line: node.position?.start.line,
1113
+ });
1114
+ return joinBlocks(directive.children.map((child) => renderBlock(child, ctx)));
1115
+ }
1116
+ case 'mdxJsxFlowElement': {
1117
+ const element = node;
1118
+ const rendered = renderJsxBlock(element, {
1119
+ theme: ctx.theme,
1120
+ issues: ctx.issues,
1121
+ source: ctx.source,
1122
+ file: ctx.file,
1123
+ renderChild: (child) => renderBlock(child, ctx),
1124
+ });
1125
+ if (rendered !== undefined)
1126
+ return rendered;
1127
+ ctx.issues.add({
1128
+ code: 'mdx-unknown-component',
1129
+ severity: ctx.onUnknownJsx === 'error' ? 'error' : 'warning',
1130
+ message: `<${element.name ?? 'fragment'}> is a React component, which a page cannot run, so it was left out.`,
1131
+ file: ctx.file,
1132
+ line: node.position?.start.line,
1133
+ column: node.position?.start.column,
1134
+ });
1135
+ return ctx.onUnknownJsx === 'placeholder'
1136
+ ? serializeBlock('html', undefined, `<!-- pterodoc: <${element.name ?? 'fragment'}> omitted -->`)
1137
+ : '';
1138
+ }
1139
+ case 'html': {
1140
+ const value = node.value;
1141
+ // Comments carry no content; Docusaurus hides them too.
1142
+ if (/^\s*<!--[\s\S]*-->\s*$/.test(value))
1143
+ return '';
1144
+ return serializeBlock('html', undefined, value);
1145
+ }
1146
+ case 'image':
1147
+ return renderImageBlock(node, ctx);
1148
+ case 'definition':
1149
+ case 'yaml':
1150
+ case 'footnoteDefinition':
1151
+ return '';
1152
+ default: {
1153
+ const position = node.position;
1154
+ if (position && ctx.source) {
1155
+ const raw = ctx.source.slice(position.start.offset ?? 0, position.end.offset ?? 0);
1156
+ ctx.issues.add({
1157
+ code: 'node-unhandled',
1158
+ severity: 'warning',
1159
+ message: `A "${node.type}" node has no WordPress equivalent; its source was kept verbatim.`,
1160
+ file: ctx.file,
1161
+ line: position.start.line,
1162
+ });
1163
+ return serializeBlock('paragraph', undefined, `<p>${escapeText(raw)}</p>`);
1164
+ }
1165
+ ctx.issues.add({
1166
+ code: 'node-unhandled',
1167
+ severity: 'warning',
1168
+ message: `A "${node.type}" node has no WordPress equivalent and was skipped.`,
1169
+ file: ctx.file,
1170
+ });
1171
+ return '';
1172
+ }
1173
+ }
1174
+ }
1175
+ /**
1176
+ * Render a document body.
1177
+ *
1178
+ * @param root The parsed document.
1179
+ * @param ctx Theme, slugger and issue collector.
1180
+ * @param options `dedupeTitle` drops a leading H1 that repeats the page title.
1181
+ */
1182
+ function renderBody(root, ctx, options = {}) {
1183
+ const children = [...root.children];
1184
+ if (options.dedupeTitle !== false) {
1185
+ // The theme prints the page title, so a body that opens with the same
1186
+ // heading would show it twice. Skipping anything that renders to nothing
1187
+ // first means a comment or a stripped import cannot hide the heading.
1188
+ const firstVisible = children.findIndex((child) => !(child.type === 'html' && /^\s*<!--[\s\S]*-->\s*$/.test(child.value)) &&
1189
+ child.type !== 'yaml' &&
1190
+ child.type !== 'definition');
1191
+ const candidate = firstVisible === -1 ? undefined : children[firstVisible];
1192
+ if (candidate && candidate.type === 'heading' && candidate.depth === 1) {
1193
+ children.splice(firstVisible, 1);
1194
+ }
1195
+ }
1196
+ const firstParagraph = children.find((child) => child.type === 'paragraph');
1197
+ const body = joinBlocks(children.map((child) => renderBlock(child, ctx)));
1198
+ return {
1199
+ body,
1200
+ firstParagraph: firstParagraph ? plainText(firstParagraph) : '',
1201
+ };
1202
+ }
1203
+ /** Plain text of a node, for excerpts. */
1204
+ function plainText(node) {
1205
+ const parts = [];
1206
+ const walk = (child) => {
1207
+ if (typeof child.value === 'string' && (child.type === 'text' || child.type === 'inlineCode')) {
1208
+ parts.push(child.value);
1209
+ }
1210
+ if (Array.isArray(child.children)) {
1211
+ for (const grandChild of child.children)
1212
+ walk(grandChild);
1213
+ }
1214
+ };
1215
+ walk(node);
1216
+ return parts.join('');
1217
+ }
1218
+
1219
+ /**
1220
+ * Finding and resolving the files a document references.
1221
+ *
1222
+ * Images are found wherever they sit — in a paragraph, behind a reference,
1223
+ * inside raw HTML — because the one place they never sit is at the top level
1224
+ * of a document, which is where an earlier version of this code looked.
1225
+ */
1226
+ /** Every image a document references, including ones behind a reference definition. */
1227
+ function collectImages(markdown, format = 'md') {
1228
+ const root = parseMarkdown(markdown, format);
1229
+ // Reference-style images become ordinary ones first, so they are not missed.
1230
+ resolveReferences(root);
1231
+ const found = [];
1232
+ const seen = new Set();
1233
+ visit(root, 'image', (node) => {
1234
+ if (seen.has(node.url))
1235
+ return;
1236
+ seen.add(node.url);
1237
+ found.push({
1238
+ url: node.url,
1239
+ alt: node.alt ?? '',
1240
+ ...(node.title != null ? { title: node.title } : {}),
1241
+ });
1242
+ });
1243
+ return found;
1244
+ }
1245
+ /**
1246
+ * Work out which file an image URL points at.
1247
+ *
1248
+ * The order matches how Docusaurus itself resolves them: an absolute URL is
1249
+ * left alone, `@site/` is site-relative, a leading slash is served from a
1250
+ * static directory, and anything else is relative to the document — preferring
1251
+ * the localised copy when there is one.
1252
+ */
1253
+ function resolveImage(url, ctx) {
1254
+ const exists = ctx.exists ?? ((file) => fs.existsSync(file));
1255
+ if (!url || url.startsWith('data:') || isAbsoluteUrl(url))
1256
+ return { kind: 'external', url };
1257
+ if (url.startsWith('@site/')) {
1258
+ const file = path.resolve(ctx.siteDir, url.slice('@site/'.length));
1259
+ return exists(file) ? { kind: 'file', url, file } : { kind: 'missing', url };
1260
+ }
1261
+ if (url.startsWith('/')) {
1262
+ const base = ctx.baseUrl.replace(/\/+$/, '');
1263
+ const withoutBase = base && url.startsWith(`${base}/`) ? url.slice(base.length) : url;
1264
+ for (const dir of ctx.staticDirs) {
1265
+ for (const candidate of [path.join(dir, url), path.join(dir, withoutBase)]) {
1266
+ if (exists(candidate))
1267
+ return { kind: 'file', url, file: candidate };
1268
+ }
1269
+ }
1270
+ return { kind: 'missing', url };
1271
+ }
1272
+ const sourceDir = path.dirname(ctx.sourceAbsolutePath);
1273
+ const direct = path.resolve(sourceDir, url);
1274
+ if (exists(direct))
1275
+ return { kind: 'file', url, file: direct };
1276
+ // The document may be the unlocalised original while its assets sit beside
1277
+ // the translated copy, or the other way round.
1278
+ if (ctx.contentPath && ctx.contentPathLocalized && ctx.contentPath !== ctx.contentPathLocalized) {
1279
+ const relative = path.relative(ctx.contentPath, direct);
1280
+ const twin = path.resolve(ctx.contentPathLocalized, relative);
1281
+ if (!relative.startsWith('..') && exists(twin))
1282
+ return { kind: 'file', url, file: twin };
1283
+ }
1284
+ return { kind: 'missing', url };
1285
+ }
1286
+
1287
+ /**
1288
+ * Rendering a document: parse, resolve, and serialise to blocks.
1289
+ *
1290
+ * This layer is pure. It never reaches the network, never loads Docusaurus and
1291
+ * knows nothing about the target beyond the URLs a resolver hands back.
1292
+ */
1293
+ /**
1294
+ * Render one document to Gutenberg block markup.
1295
+ */
1296
+ function renderDoc(input) {
1297
+ const issues = input.issues ?? new IssueCollector();
1298
+ const parsed = matter(input.markdown);
1299
+ const source = parsed.content.replace(/\r\n/g, '\n').replace(/^\n+/, '');
1300
+ const format = input.format ?? (input.file ? detectFormat(input.file) : 'md');
1301
+ const root = parseMarkdown(source, format);
1302
+ if (format === 'mdx') {
1303
+ lowerMdx(root, {
1304
+ source,
1305
+ file: input.file,
1306
+ issues,
1307
+ onUnknown: input.onUnknownJsx ?? 'report',
1308
+ });
1309
+ }
1310
+ resolveReferences(root);
1311
+ const links = input.resolveLink
1312
+ ? rewriteLinks(root, input.permalink, input.resolveLink)
1313
+ : new Set();
1314
+ const ctx = createRenderContext({
1315
+ theme: input.theme,
1316
+ slugger: createSlugger(),
1317
+ issues,
1318
+ maintainCase: input.maintainCase ?? false,
1319
+ admonitionKeywords: new Set(input.admonitionKeywords ?? DEFAULT_ADMONITION_KEYWORDS),
1320
+ file: input.file,
1321
+ source,
1322
+ media: input.media,
1323
+ onUnknownJsx: input.onUnknownJsx ?? 'report',
1324
+ });
1325
+ const { body, firstParagraph } = renderBody(root, ctx, {
1326
+ dedupeTitle: input.dedupeTitle ?? true,
1327
+ });
1328
+ return { body, links, firstParagraph, issues };
1329
+ }
1330
+ /** Build an excerpt from a description, falling back to the opening paragraph. */
1331
+ function excerptFor(description, firstParagraph, max = 160) {
1332
+ return excerptFrom(description || firstParagraph || '', max);
1333
+ }
1334
+
1335
+ export { DEFAULT_ADMONITION_KEYWORDS, DEFAULT_LAYOUT, DEFAULT_STRINGS, KNOWN_COMPONENTS, attribute, collectImages, composePage, createRenderContext, createSlugger, createTheme, detectFormat, escapeCode, escapeText, excerptFor, excerptFrom, headingIdFor, isAbsoluteUrl, isTranslatable, joinBlocks, lowerMdx, parseMarkdown, renderBlock, renderBody, renderDoc, renderInline, renderJsxBlock, renderNavigationStub, renderVersionBanner, resolveImage, resolveReferences, rewriteLinks, serializeAttrs, serializeBlock, serializeVoidBlock, toInternalPath };
1336
+ //# sourceMappingURL=index.js.map