@stackql/docusaurus-plugin-aeo 0.4.1 → 0.5.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,623 @@
1
+ // MDX source -> plain markdown, for the `.md` companions and llms-full.txt.
2
+ //
3
+ // Strategy: parse the source with the same remark toolchain Docusaurus uses
4
+ // (remark-mdx, gfm, directive, comments) to find every MDX construct, then
5
+ // edit the ORIGINAL text by source position:
6
+ //
7
+ // - imports, exports and `{expressions}` are cut
8
+ // - presentational elements (svg, video, iframe, ...) are dropped with
9
+ // their whole subtree
10
+ // - a handful of elements become their markdown equivalent (<a>, <img>,
11
+ // <b>, <i>, <code>, <h2>, <br>, <summary>, <TabItem> -> a bold label)
12
+ // - a self-closing component that carries a link (`to`/`href` plus
13
+ // `text`/`label`/`title`) becomes a markdown link
14
+ // - everything else is unwrapped: the tags go, the children stay
15
+ //
16
+ // Nothing that is not MDX is touched, and nothing is re-stringified, so the
17
+ // author's markdown survives byte for byte: no escaping drift (snake_case
18
+ // stays snake_case), and tables, admonitions and code fences are exactly as
19
+ // written. A parse failure falls back to the regex stripper from v0.4 so a
20
+ // build never breaks on one odd file.
21
+
22
+ const path = require('path');
23
+
24
+ // Elements whose whole subtree is presentational.
25
+ const DROP = new Set([
26
+ 'svg', 'path', 'g', 'circle', 'rect', 'line', 'polygon', 'polyline', 'use',
27
+ 'defs', 'symbol', 'iframe', 'video', 'audio', 'source', 'track', 'picture',
28
+ 'canvas', 'script', 'style', 'object', 'embed', 'noscript', 'template',
29
+ 'button', 'input', 'select', 'textarea', 'form', 'label', 'head',
30
+ 'Head', 'BrowserOnly', 'Redirect',
31
+ ]);
32
+
33
+ // Inline elements that become markdown delimiters: [open, close].
34
+ const INLINE_MARKS = {
35
+ b: ['**', '**'],
36
+ strong: ['**', '**'],
37
+ i: ['_', '_'],
38
+ em: ['_', '_'],
39
+ code: ['`', '`'],
40
+ kbd: ['`', '`'],
41
+ samp: ['`', '`'],
42
+ var: ['`', '`'],
43
+ summary: ['**', '**'],
44
+ del: ['~~', '~~'],
45
+ s: ['~~', '~~'],
46
+ };
47
+
48
+ // Attribute names a self-closing component might use for a link target and
49
+ // its text, in order of preference.
50
+ const LINK_HREF_ATTRS = ['href', 'to', 'url', 'link'];
51
+ const LINK_TEXT_ATTRS = ['text', 'label', 'title', 'children', 'name'];
52
+
53
+ const MD_EXTENSIONS = new Set([
54
+ '.md', '.markdown', '.mdown', '.mkdn', '.mkd', '.mdwn', '.mkdown', '.ron',
55
+ ]);
56
+
57
+ // --------------------------------------------------------------- helpers
58
+
59
+ // Resolve the Docusaurus markdown format for a file: the page's front
60
+ // matter `format`, else siteConfig.markdown.format, else by extension.
61
+ function resolveFormat({ filePath, frontMatterFormat, markdownConfigFormat }) {
62
+ const byExtension = () =>
63
+ MD_EXTENSIONS.has(path.extname(filePath || '').toLowerCase()) ? 'md' : 'mdx';
64
+ if (frontMatterFormat && frontMatterFormat !== 'detect') return frontMatterFormat;
65
+ if (frontMatterFormat === 'detect') return byExtension();
66
+ if (markdownConfigFormat && markdownConfigFormat !== 'detect') return markdownConfigFormat;
67
+ return byExtension();
68
+ }
69
+
70
+ // A JS string literal -> its value, else null. Good enough for the
71
+ // `{'text'}` / `label: 'macOS'` cases; template literals with `${}` are
72
+ // not literals.
73
+ function stringLiteral(expr) {
74
+ const s = String(expr == null ? '' : expr).trim();
75
+ const m = /^(['"`])([\s\S]*)\1$/.exec(s);
76
+ if (!m) return null;
77
+ const inner = m[2];
78
+ if (m[1] === '`' && inner.includes('${')) return null;
79
+ if (inner.includes(m[1]) && !inner.includes('\\' + m[1])) return null;
80
+ return inner.replace(/\\(['"`\\])/g, '$1');
81
+ }
82
+
83
+ // { name: string } for every attribute whose value is a string or a string
84
+ // literal expression. Expression attributes keep their raw source under
85
+ // `__expr:<name>` for the Tabs `values` lookup.
86
+ function attrMap(node) {
87
+ const out = {};
88
+ for (const a of node.attributes || []) {
89
+ if (a.type !== 'mdxJsxAttribute') continue;
90
+ if (a.value == null) {
91
+ out[a.name] = '';
92
+ } else if (typeof a.value === 'string') {
93
+ out[a.name] = a.value;
94
+ } else if (a.value && typeof a.value.value === 'string') {
95
+ out[`__expr:${a.name}`] = a.value.value;
96
+ const lit = stringLiteral(a.value.value);
97
+ if (lit !== null) out[a.name] = lit;
98
+ }
99
+ }
100
+ return out;
101
+ }
102
+
103
+ // Pull { value -> label } out of a Docusaurus <Tabs values={[...]}>
104
+ // expression. Labels are often JSX (`tabLabel(<Icon />, 'macOS')`), so the
105
+ // label is the last string literal inside the `label:` property. Objects
106
+ // are found with a brace counter so nested `{{ style }}` do not confuse it.
107
+ function parseTabsValues(expr) {
108
+ const labels = new Map();
109
+ if (!expr) return labels;
110
+ const objects = [];
111
+ let depth = 0;
112
+ let start = -1;
113
+ for (let i = 0; i < expr.length; i++) {
114
+ const c = expr[i];
115
+ if (c === '{') {
116
+ if (depth === 0) start = i;
117
+ depth++;
118
+ } else if (c === '}') {
119
+ depth--;
120
+ if (depth === 0 && start >= 0) {
121
+ objects.push(expr.slice(start + 1, i));
122
+ start = -1;
123
+ }
124
+ }
125
+ }
126
+ for (const obj of objects) {
127
+ const valueMatch = /\bvalue\s*:\s*(['"`])([^'"`]*)\1/.exec(obj);
128
+ if (!valueMatch) continue;
129
+ const value = valueMatch[2];
130
+ const labelIdx = obj.search(/\blabel\s*:/);
131
+ let label = null;
132
+ if (labelIdx >= 0) {
133
+ // The label property runs to the next top-level comma or the end.
134
+ let d = 0;
135
+ let end = obj.length;
136
+ for (let i = labelIdx; i < obj.length; i++) {
137
+ const c = obj[i];
138
+ if (c === '(' || c === '[' || c === '{' || c === '<') d++;
139
+ else if (c === ')' || c === ']' || c === '}' || c === '>') d--;
140
+ else if (c === ',' && d <= 0) {
141
+ end = i;
142
+ break;
143
+ }
144
+ }
145
+ const segment = obj.slice(labelIdx, end);
146
+ const literals = [...segment.matchAll(/(['"`])((?:\\.|(?!\1)[^\\])*)\1/g)].map((m) => m[2]);
147
+ if (literals.length > 0) label = literals[literals.length - 1];
148
+ }
149
+ labels.set(value, label || value);
150
+ }
151
+ return labels;
152
+ }
153
+
154
+ function titleCase(s) {
155
+ return String(s || '')
156
+ .replace(/[-_]+/g, ' ')
157
+ .replace(/\b\w/g, (c) => c.toUpperCase());
158
+ }
159
+
160
+ // Docusaurus preprocessing the parser needs to see the same document the
161
+ // site compiles, done on the text so positions stay aligned:
162
+ // - ```mdx-code-block fences are unwrapped (their JSX is then parsed and
163
+ // stripped like any other)
164
+ // - HTML comments are removed (the comment syntax leaves no node behind,
165
+ // so they would otherwise survive into the companion)
166
+ // - `{#custom-id}` heading ids are removed (Docusaurus escapes them before
167
+ // MDX sees them; unescaped they are an invalid expression)
168
+ // The last two skip fenced code blocks.
169
+ function preprocess(source) {
170
+ let text = source;
171
+ const unwrap = (re) =>
172
+ text.replace(re, (...args) => {
173
+ const g = args[args.length - 1];
174
+ return `${g.begin}${g.children}${g.end}`;
175
+ });
176
+ text = unwrap(
177
+ /(?<begin>^|\r?\n)(?<indentStart>\x20*)```(?<spaces>\x20*)mdx-code-block\r?\n(?<children>.*?)\r?\n(?<indentEnd>\x20*)```(?<end>\r?\n|$)/gs,
178
+ );
179
+ text = unwrap(
180
+ /(?<begin>^|\r?\n)(?<indentStart>\x20*)````(?<spaces>\x20*)mdx-code-block\r?\n(?<children>.*?)\r?\n(?<indentEnd>\x20*)````(?<end>\r?\n|$)/gs,
181
+ );
182
+
183
+ const lines = text.split('\n');
184
+ let fence = null;
185
+ let inComment = false;
186
+ for (let i = 0; i < lines.length; i++) {
187
+ let line = lines[i];
188
+ const fenceMatch = /^\s{0,3}(`{3,}|~{3,})/.exec(line);
189
+ if (fence) {
190
+ if (fenceMatch && fenceMatch[1][0] === fence[0] && fenceMatch[1].length >= fence.length) {
191
+ fence = null;
192
+ }
193
+ continue;
194
+ }
195
+ if (fenceMatch) {
196
+ fence = fenceMatch[1];
197
+ continue;
198
+ }
199
+ if (inComment) {
200
+ const close = line.indexOf('-->');
201
+ if (close === -1) {
202
+ lines[i] = '';
203
+ continue;
204
+ }
205
+ line = line.slice(close + 3);
206
+ inComment = false;
207
+ }
208
+ line = line.replace(/<!--[\s\S]*?-->/g, '');
209
+ const open = line.indexOf('<!--');
210
+ if (open !== -1) {
211
+ line = line.slice(0, open);
212
+ inComment = true;
213
+ }
214
+ if (/^\s{0,3}#{1,6}\s/.test(line)) {
215
+ line = line.replace(/\s*\{#[^}\n]*\}\s*$/, '');
216
+ }
217
+ lines[i] = line;
218
+ }
219
+ return lines.join('\n');
220
+ }
221
+
222
+ // Remove `{#id}` heading ids and HTML comments only - used before the
223
+ // CommonMark parse of `md` format files, which have no MDX syntax.
224
+ function preprocessMd(source) {
225
+ return preprocess(source);
226
+ }
227
+
228
+ // ------------------------------------------------------------ the planner
229
+
230
+ // Walk the tree and collect non-overlapping text edits.
231
+ function planEdits(tree, source) {
232
+ const edits = [];
233
+ const put = (start, end, text) => {
234
+ if (end > start || text) edits.push({ start, end, text });
235
+ };
236
+ const cut = (start, end) => put(start, end, '');
237
+ const tabsStack = [];
238
+ const htmlLinkStack = [];
239
+ const markStack = []; // inline marks currently open, to avoid **a **b** c**
240
+ // Content regions of unwrapped flow containers, outer before inner, for
241
+ // planDedents.
242
+ const dedentRegions = [];
243
+
244
+ const inTableCell = (parents) => parents.some((p) => p.type === 'tableCell');
245
+
246
+ function visitChildren(node, parents) {
247
+ for (const child of node.children || []) visit(child, [node, ...parents]);
248
+ }
249
+
250
+ function visit(node, parents) {
251
+ const pos = node.position;
252
+ switch (node.type) {
253
+ case 'yaml':
254
+ case 'toml':
255
+ case 'mdxjsEsm':
256
+ if (pos) cut(pos.start.offset, pos.end.offset);
257
+ return;
258
+ case 'mdxFlowExpression':
259
+ case 'mdxTextExpression': {
260
+ if (!pos) return;
261
+ const lit = stringLiteral(node.value);
262
+ put(pos.start.offset, pos.end.offset, lit === null ? '' : lit);
263
+ return;
264
+ }
265
+ case 'mdxJsxFlowElement':
266
+ case 'mdxJsxTextElement':
267
+ handleJsx(node, parents);
268
+ return;
269
+ case 'html':
270
+ handleHtml(node, parents);
271
+ return;
272
+ default:
273
+ visitChildren(node, parents);
274
+ }
275
+ }
276
+
277
+ function handleJsx(node, parents) {
278
+ const pos = node.position;
279
+ if (!pos) return;
280
+ const start = pos.start.offset;
281
+ const end = pos.end.offset;
282
+ const name = node.name || ''; // '' for a fragment <>...</>
283
+ const kids = (node.children || []).filter((k) => k.position);
284
+ const attrs = attrMap(node);
285
+ const openEnd = kids.length ? kids[0].position.start.offset : end;
286
+ const closeStart = kids.length ? kids[kids.length - 1].position.end.offset : end;
287
+
288
+ const isFlow = node.type === 'mdxJsxFlowElement';
289
+ // A block container's closing tag becomes a newline so the block that
290
+ // follows is separated from the last child by a blank line (the tidy
291
+ // pass collapses any surplus).
292
+ const replaceTags = (openText, closeText) => {
293
+ put(start, openEnd, openText);
294
+ const closing = isFlow && kids.length ? `${closeText}\n` : closeText;
295
+ if (kids.length) put(closeStart, end, closing);
296
+ else if (closing) put(end, end, closing);
297
+ if (kids.length && isFlow) {
298
+ dedentRegions.push([openEnd, closeStart]);
299
+ }
300
+ visitChildren(node, parents);
301
+ };
302
+ const unwrap = () => replaceTags('', '');
303
+
304
+ if (DROP.has(name)) {
305
+ cut(start, end);
306
+ return;
307
+ }
308
+
309
+ switch (name) {
310
+ case '':
311
+ unwrap();
312
+ return;
313
+ case 'img': {
314
+ const src = attrs.src;
315
+ put(start, end, src ? `![${attrs.alt || ''}](${src})` : '');
316
+ return;
317
+ }
318
+ case 'br':
319
+ put(start, end, inTableCell(parents) ? ' ' : '\n');
320
+ return;
321
+ case 'hr':
322
+ put(start, end, '\n---\n');
323
+ return;
324
+ case 'a': {
325
+ const href = attrs.href;
326
+ if (!href) {
327
+ unwrap();
328
+ } else if (!kids.length) {
329
+ put(start, end, `[${href}](${href})`);
330
+ } else {
331
+ replaceTags('[', `](${href})`);
332
+ }
333
+ return;
334
+ }
335
+ case 'Tabs': {
336
+ tabsStack.push(parseTabsValues(attrs['__expr:values']));
337
+ unwrap();
338
+ tabsStack.pop();
339
+ return;
340
+ }
341
+ case 'TabItem': {
342
+ const scope = tabsStack.length ? tabsStack[tabsStack.length - 1] : null;
343
+ const label =
344
+ attrs.label ||
345
+ (scope && attrs.value !== undefined && scope.get(attrs.value)) ||
346
+ titleCase(attrs.value) ||
347
+ 'Tab';
348
+ if (!kids.length) {
349
+ put(start, end, `**${label}**\n`);
350
+ } else {
351
+ replaceTags(`**${label}**\n\n`, '\n');
352
+ }
353
+ return;
354
+ }
355
+ case 'li':
356
+ replaceTags('- ', '\n');
357
+ return;
358
+ case 'tr':
359
+ replaceTags('', '\n');
360
+ return;
361
+ case 'td':
362
+ case 'th':
363
+ replaceTags('', ' | ');
364
+ return;
365
+ default:
366
+ break;
367
+ }
368
+
369
+ const heading = /^h([1-6])$/.exec(name);
370
+ if (heading) {
371
+ replaceTags(`${'#'.repeat(Number(heading[1]))} `, '\n');
372
+ return;
373
+ }
374
+
375
+ if (INLINE_MARKS[name]) {
376
+ const [open, close] = INLINE_MARKS[name];
377
+ if (!kids.length) {
378
+ cut(start, end);
379
+ } else if (markStack.includes(open)) {
380
+ unwrap(); // already inside the same mark
381
+ } else {
382
+ markStack.push(open);
383
+ replaceTags(open, close);
384
+ markStack.pop();
385
+ }
386
+ return;
387
+ }
388
+
389
+ if (kids.length) {
390
+ unwrap();
391
+ return;
392
+ }
393
+
394
+ // Self-closing component: recover a link if it plainly carries one,
395
+ // otherwise it is presentational (icon, embed, diagram, card list).
396
+ const href = LINK_HREF_ATTRS.map((k) => attrs[k]).find((v) => typeof v === 'string' && v);
397
+ const text = LINK_TEXT_ATTRS.map((k) => attrs[k]).find((v) => typeof v === 'string' && v);
398
+ if (href && /^(https?:\/\/|\/|\.\.?\/|#|mailto:)/.test(href)) {
399
+ put(start, end, `[${text || href}](${href})`);
400
+ } else {
401
+ cut(start, end);
402
+ }
403
+ }
404
+
405
+ // `md` format: raw HTML arrives as `html` nodes, one per tag run.
406
+ function handleHtml(node, parents) {
407
+ const pos = node.position;
408
+ if (!pos) return;
409
+ const start = pos.start.offset;
410
+ const end = pos.end.offset;
411
+ const value = String(node.value || '');
412
+ if (/^<!--[\s\S]*-->$/.test(value.trim())) {
413
+ cut(start, end);
414
+ return;
415
+ }
416
+ const single = /^<\/?([A-Za-z][A-Za-z0-9-]*)([^>]*)>$/.exec(value.trim());
417
+ if (single) {
418
+ const tag = single[1].toLowerCase();
419
+ const closing = value.trim().startsWith('</');
420
+ const attrSrc = single[2] || '';
421
+ const attr = (n) => {
422
+ const m = new RegExp(`\\b${n}\\s*=\\s*(["'])(.*?)\\1`).exec(attrSrc);
423
+ return m ? m[2] : null;
424
+ };
425
+ if (tag === 'br') {
426
+ put(start, end, inTableCell(parents) ? ' ' : '\n');
427
+ } else if (tag === 'img') {
428
+ const src = attr('src');
429
+ put(start, end, src ? `![${attr('alt') || ''}](${src})` : '');
430
+ } else if (tag === 'a') {
431
+ if (closing) {
432
+ const href = htmlLinkStack.pop();
433
+ put(start, end, href ? `](${href})` : '');
434
+ } else {
435
+ const href = attr('href');
436
+ htmlLinkStack.push(href);
437
+ put(start, end, href ? '[' : '');
438
+ }
439
+ } else if (INLINE_MARKS[tag]) {
440
+ put(start, end, INLINE_MARKS[tag][closing ? 1 : 0]);
441
+ } else {
442
+ cut(start, end);
443
+ }
444
+ return;
445
+ }
446
+ // A block of HTML with text inside: keep the text, lose the tags.
447
+ const text = value
448
+ .replace(/<!--[\s\S]*?-->/g, '')
449
+ .replace(/<(script|style|svg)\b[\s\S]*?<\/\1>/gi, '')
450
+ .replace(/<br\s*\/?>/gi, '\n')
451
+ .replace(/<[^>]+>/g, '');
452
+ put(start, end, text);
453
+ }
454
+
455
+ visit(tree, []);
456
+ planDedents(source, edits, dedentRegions);
457
+ return edits;
458
+ }
459
+
460
+ // Content written inside a JSX container is usually indented to match the
461
+ // tags. Removing the tags leaves that indentation behind, which reads as
462
+ // stray whitespace at best and as an indented code block at worst. For each
463
+ // container region, outer to inner, the indentation its content lines have
464
+ // in common is cut from every one of them; a nested container then cuts
465
+ // only what its own content adds on top, so the cuts never overlap. Lines
466
+ // that begin inside another edit (a nested tag, an expression) are left
467
+ // alone: that edit already removes them.
468
+ function planDedents(source, edits, regions) {
469
+ if (regions.length === 0) return;
470
+ const cutSoFar = new Map(); // lineStart -> spaces already removed
471
+ const taken = [...edits].sort((a, b) => a.start - b.start);
472
+ const insideEdit = (offset) => {
473
+ // binary search for the last edit starting at or before offset
474
+ let lo = 0;
475
+ let hi = taken.length - 1;
476
+ let found = -1;
477
+ while (lo <= hi) {
478
+ const mid = (lo + hi) >> 1;
479
+ if (taken[mid].start <= offset) {
480
+ found = mid;
481
+ lo = mid + 1;
482
+ } else {
483
+ hi = mid - 1;
484
+ }
485
+ }
486
+ for (let i = found; i >= 0 && i > found - 8; i--) {
487
+ const e = taken[i];
488
+ if (e.start <= offset && offset < e.end) return true;
489
+ }
490
+ return false;
491
+ };
492
+ const indentOf = (lineStart) => {
493
+ let i = lineStart;
494
+ while (i < source.length && (source[i] === ' ' || source[i] === '\t')) i++;
495
+ return { indent: i - lineStart, blank: i >= source.length || source[i] === '\n' || source[i] === '\r' };
496
+ };
497
+ for (const [regionStart, regionEnd] of regions) {
498
+ const lines = [];
499
+ let nl = source.indexOf('\n', regionStart);
500
+ while (nl !== -1 && nl + 1 < regionEnd) {
501
+ const lineStart = nl + 1;
502
+ if (!insideEdit(lineStart)) lines.push({ lineStart, ...indentOf(lineStart) });
503
+ nl = source.indexOf('\n', lineStart);
504
+ }
505
+ const remaining = (l) => l.indent - (cutSoFar.get(l.lineStart) || 0);
506
+ const content = lines.filter((l) => !l.blank);
507
+ if (content.length === 0) continue;
508
+ const common = Math.min(...content.map(remaining));
509
+ if (common <= 0) continue;
510
+ for (const l of lines) {
511
+ const done = cutSoFar.get(l.lineStart) || 0;
512
+ const n = Math.min(common, l.indent - done);
513
+ if (n > 0) {
514
+ edits.push({ start: l.lineStart + done, end: l.lineStart + done + n, text: '' });
515
+ cutSoFar.set(l.lineStart, done + n);
516
+ }
517
+ }
518
+ }
519
+ }
520
+
521
+ function applyEdits(source, edits) {
522
+ const sorted = [...edits].sort((a, b) => a.start - b.start || a.end - b.end);
523
+ let out = '';
524
+ let cursor = 0;
525
+ for (const e of sorted) {
526
+ if (e.start < cursor) {
527
+ throw new Error(`overlapping edits at offset ${e.start} (cursor ${cursor})`);
528
+ }
529
+ out += source.slice(cursor, e.start) + e.text;
530
+ cursor = e.end;
531
+ }
532
+ return out + source.slice(cursor);
533
+ }
534
+
535
+ function tidy(text) {
536
+ return (
537
+ text
538
+ // whitespace-only lines become empty (tags and indentation leave them)
539
+ .replace(/^[ \t]+$/gm, '')
540
+ // at most one blank line in a row
541
+ .replace(/\n{3,}/g, '\n\n')
542
+ .trim()
543
+ // a horizontal rule that opened or closed the page only separated a
544
+ // component that is gone now
545
+ .replace(/^(?:(?:---|\*\*\*|___)\n+)+/, '')
546
+ .replace(/(?:\n+(?:---|\*\*\*|___))+$/, '')
547
+ .trim() + '\n'
548
+ );
549
+ }
550
+
551
+ // The regex stripper from v0.4, kept as the fallback for a source that
552
+ // does not parse (it compiled for Docusaurus, so this should not happen,
553
+ // but one odd file must not fail the build).
554
+ function stripMdxRegex(source) {
555
+ let out = source;
556
+ out = out.replace(/^\s*import\s+[^\n]+\n/gm, '');
557
+ out = out.replace(/^\s*export\s+[^\n]+\n/gm, '');
558
+ out = out.replace(/<([A-Z][A-Za-z0-9]*)\b[^>]*\/>\s*/g, '');
559
+ out = out.replace(/<([A-Z][A-Za-z0-9]*)\b[^>]*>[\s\S]*?<\/\1>\s*/g, '');
560
+ out = out.replace(/<\/?[A-Z][A-Za-z0-9]*\b[^>]*>/g, '');
561
+ return tidy(out);
562
+ }
563
+
564
+ // ------------------------------------------------------------- the parser
565
+
566
+ let processors = null;
567
+
568
+ async function getProcessors() {
569
+ if (processors) return processors;
570
+ // These packages are ESM; the plugin is CommonJS, so load them lazily.
571
+ const [
572
+ { unified },
573
+ { default: remarkParse },
574
+ { default: remarkMdx },
575
+ { default: remarkGfm },
576
+ { default: remarkDirective },
577
+ { default: remarkComment },
578
+ ] = await Promise.all([
579
+ import('unified'),
580
+ import('remark-parse'),
581
+ import('remark-mdx'),
582
+ import('remark-gfm'),
583
+ import('remark-directive'),
584
+ import('@slorber/remark-comment'),
585
+ ]);
586
+ processors = {
587
+ mdx: unified()
588
+ .use(remarkParse)
589
+ .use(remarkMdx)
590
+ .use(remarkGfm)
591
+ .use(remarkDirective)
592
+ .use(remarkComment)
593
+ .freeze(),
594
+ md: unified().use(remarkParse).use(remarkGfm).use(remarkDirective).freeze(),
595
+ };
596
+ return processors;
597
+ }
598
+
599
+ // Convert one page body (front matter already removed) to plain markdown.
600
+ // Returns { markdown, fallback: boolean, error?: Error }.
601
+ async function toPlainMarkdown(body, { format = 'mdx', filePath } = {}) {
602
+ const { mdx, md } = await getProcessors();
603
+ // LF throughout: the companion is a file of ours, and the tidy pass and
604
+ // the dedent work on '\n'.
605
+ const normalized = String(body || '').replace(/\r\n?/g, '\n');
606
+ const source = format === 'md' ? preprocessMd(normalized) : preprocess(normalized);
607
+ try {
608
+ const tree = (format === 'md' ? md : mdx).parse(source);
609
+ const edits = planEdits(tree, source);
610
+ return { markdown: tidy(applyEdits(source, edits)), fallback: false };
611
+ } catch (error) {
612
+ return { markdown: stripMdxRegex(source), fallback: true, error };
613
+ }
614
+ }
615
+
616
+ module.exports = {
617
+ toPlainMarkdown,
618
+ resolveFormat,
619
+ // exported for tests
620
+ parseTabsValues,
621
+ preprocess,
622
+ stripMdxRegex,
623
+ };