@stackql/docusaurus-plugin-aeo 0.4.2 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +55 -0
- package/LICENSE +0 -0
- package/README.md +179 -45
- package/package.json +21 -5
- package/src/askAiPrompt.js +22 -0
- package/src/companionPath.js +89 -0
- package/src/features/aiRoutes.js +15 -6
- package/src/features/alternateLinks.js +133 -0
- package/src/features/companions.js +93 -104
- package/src/features/llmsTxt.js +126 -84
- package/src/features/plainMarkdown.js +623 -0
- package/src/helpers.js +34 -10
- package/src/index.js +70 -10
- package/src/routeGlob.js +60 -0
- package/src/theme/AskAiButton/brand-icons/ChatGpt.jsx +0 -0
- package/src/theme/AskAiButton/brand-icons/Claude.jsx +0 -0
- package/src/theme/AskAiButton/brand-icons/Perplexity.jsx +0 -0
- package/src/theme/AskAiButton/icons.js +0 -0
- package/src/theme/AskAiButton/index.jsx +64 -12
- package/src/theme/AskAiButton/styles.module.css +0 -0
- package/src/theme/BlogPostItem/Header/Title/index.jsx +0 -0
- package/src/theme/BlogPostItem/Header/Title/styles.module.css +0 -0
- package/src/theme/DocBreadcrumbs/index.jsx +0 -0
- package/src/theme/DocBreadcrumbs/styles.module.css +0 -0
|
@@ -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 ? `` : '');
|
|
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 ? `` : '');
|
|
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
|
+
};
|
package/src/helpers.js
CHANGED
|
@@ -3,18 +3,36 @@
|
|
|
3
3
|
// These are pure data helpers - consumers spread them into other plugin
|
|
4
4
|
// configs so that /ai/* routes don't pollute artifacts aimed at humans
|
|
5
5
|
// (sitemaps, breadcrumb graphs, etc.).
|
|
6
|
+
//
|
|
7
|
+
// The convention is relative to the site's base URL. Docusaurus routes
|
|
8
|
+
// carry it, so on a site built at '/docs/' the routes are '/docs/ai/...'
|
|
9
|
+
// and in a localized build they are '/de/ai/...'. Every helper takes the
|
|
10
|
+
// base URL as an optional argument; without one it means '/'.
|
|
11
|
+
|
|
12
|
+
const { stripBase } = require('./companionPath');
|
|
6
13
|
|
|
7
|
-
// Glob patterns matching the /ai/* convention
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
|
|
14
|
+
// Glob patterns matching the /ai/* convention under a base URL:
|
|
15
|
+
// aiRoutePatterns('/de/') is ['/de/ai', '/de/ai/**']. Suitable for plugins
|
|
16
|
+
// whose exclude lists accept globs and match full route paths (notably
|
|
17
|
+
// @docusaurus/plugin-sitemap's `ignorePatterns`). One list can cover every
|
|
18
|
+
// locale of a site:
|
|
19
|
+
// ignorePatterns: ['/', '/de/'].flatMap(aiRoutePatterns)
|
|
20
|
+
function aiRoutePatterns(baseUrl) {
|
|
21
|
+
const base = String(baseUrl || '/').replace(/\/+$/, '');
|
|
22
|
+
return [`${base}/ai`, `${base}/ai/**`];
|
|
23
|
+
}
|
|
11
24
|
|
|
12
|
-
//
|
|
13
|
-
|
|
25
|
+
// The patterns for a site at the root with one locale.
|
|
26
|
+
const AI_ROUTE_PATTERNS = aiRoutePatterns('/');
|
|
27
|
+
|
|
28
|
+
// Sitemap slice for @docusaurus/plugin-sitemap (standalone or via preset),
|
|
29
|
+
// for a site at the root with one locale. Usage:
|
|
14
30
|
// [
|
|
15
31
|
// '@docusaurus/plugin-sitemap',
|
|
16
32
|
// { ...sitemapExclude, /* your other sitemap options */ },
|
|
17
33
|
// ]
|
|
34
|
+
// With a base URL or several locales, build `ignorePatterns` from
|
|
35
|
+
// aiRoutePatterns instead.
|
|
18
36
|
const sitemapExclude = {
|
|
19
37
|
ignorePatterns: [...AI_ROUTE_PATTERNS],
|
|
20
38
|
};
|
|
@@ -23,8 +41,8 @@ const sitemapExclude = {
|
|
|
23
41
|
// excludedRoutes list for plugins that don't accept globs (e.g.
|
|
24
42
|
// @stackql/docusaurus-plugin-structured-data, whose `excludedRoutes`
|
|
25
43
|
// is an exact-match array).
|
|
26
|
-
function isAiRoute(permalink) {
|
|
27
|
-
return typeof permalink === 'string' && permalink.startsWith('/ai/');
|
|
44
|
+
function isAiRoute(permalink, baseUrl) {
|
|
45
|
+
return typeof permalink === 'string' && stripBase(permalink, baseUrl).startsWith('/ai/');
|
|
28
46
|
}
|
|
29
47
|
|
|
30
48
|
// Build an exact-match exclude list for @stackql/docusaurus-plugin-
|
|
@@ -48,13 +66,19 @@ function isAiRoute(permalink) {
|
|
|
48
66
|
// If you don't have the route list at config time, list the known
|
|
49
67
|
// /ai/* parents by hand (e.g. ['/ai', '/ai/faqs/install', ...]); the
|
|
50
68
|
// glob-based AI_ROUTE_PATTERNS will NOT work for that plugin.
|
|
51
|
-
|
|
69
|
+
//
|
|
70
|
+
// With a base URL, pass it as the second argument: the routes are matched
|
|
71
|
+
// under it and the parent route returned is '<baseUrl>ai'.
|
|
72
|
+
function buildStructuredDataAiExcludes(allRoutePaths, baseUrl) {
|
|
52
73
|
if (!Array.isArray(allRoutePaths)) return [];
|
|
53
|
-
return allRoutePaths
|
|
74
|
+
return allRoutePaths
|
|
75
|
+
.filter((permalink) => isAiRoute(permalink, baseUrl))
|
|
76
|
+
.concat([aiRoutePatterns(baseUrl)[0]]);
|
|
54
77
|
}
|
|
55
78
|
|
|
56
79
|
module.exports = {
|
|
57
80
|
AI_ROUTE_PATTERNS,
|
|
81
|
+
aiRoutePatterns,
|
|
58
82
|
sitemapExclude,
|
|
59
83
|
isAiRoute,
|
|
60
84
|
buildStructuredDataAiExcludes,
|