@clidey/dory 0.47.0 → 0.49.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.
- package/bin/dist/dory.js +103 -45
- package/bin/dist/dory.js.map +1 -1
- package/index.html +15 -19
- package/package.json +35 -29
- package/scripts/render-routes.mjs +45 -14
- package/src/components/routes.tsx +19 -22
- package/src/components/store.ts +5 -5
- package/src/config/mdx.js +7 -3
- package/src/config/mdx.ts +6 -2
- package/src/entry-server.tsx +53 -20
- package/src/main.tsx +9 -1
- package/src/mdx/code.tsx +8 -5
- package/src/mdx/fence.tsx +10 -2
- package/src/mdx/icon.tsx +21 -14
- package/src/mdx/mermaid.tsx +10 -1
- package/src/mdx/processor.test.ts +47 -0
- package/src/mdx/step.tsx +3 -3
- package/src/plugins/analytics-injector.ts +7 -5
- package/src/plugins/docs-assets.ts +5 -4
- package/src/plugins/frontmatter-dev-server.ts +8 -159
- package/src/plugins/frontmatter-generator.ts +37 -129
- package/src/plugins/html-files-middleware.test.ts +21 -0
- package/src/plugins/html-files-middleware.ts +27 -5
- package/src/plugins/html-metadata-injector.ts +113 -53
- package/src/plugins/llm-txt-dev-server.ts +25 -178
- package/src/plugins/llm-txt-generator.ts +94 -133
- package/src/plugins/prerender.ts +156 -50
- package/src/plugins/robots-generator.ts +15 -16
- package/src/plugins/sanitize.test.ts +104 -0
- package/src/plugins/sanitize.ts +90 -59
- package/src/plugins/shared.ts +139 -0
- package/src/plugins/sitemap-generator.ts +58 -44
- package/src/theme.scss +138 -15
- package/src/types/config.ts +42 -2
- package/src/types/globals.d.ts +7 -0
- package/src/ui/header.tsx +12 -4
- package/src/ui/layout.tsx +66 -47
- package/src/ui/table-of-content.tsx +19 -2
- package/src/utils/colors.ts +38 -18
- package/src/utils/fonts.ts +55 -12
- package/src/utils/theme.ts +29 -0
- package/vite.config.embed-loader.ts +3 -11
- package/vite.config.embed-widget.ts +3 -10
- package/vite.config.ts +11 -7
- package/scripts/generate-frontmatter-json.js +0 -154
- package/scripts/generate-llm-txt.js +0 -115
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { preprocessMdxTags, isInsideCodeBlock, isInsideCodeSpan } from './sanitize';
|
|
3
|
+
|
|
4
|
+
/** Run the preprocessor over MDX input and return the transformed code. */
|
|
5
|
+
function run(input: string): string {
|
|
6
|
+
const result = preprocessMdxTags().transform(input, 'test.mdx');
|
|
7
|
+
return (result as { code: string }).code;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
describe('isInsideCodeBlock', () => {
|
|
11
|
+
it('detects content between line-start fences', () => {
|
|
12
|
+
const code = '```\nconst x = 1;\n```\nafter';
|
|
13
|
+
expect(isInsideCodeBlock(code, code.indexOf('const'))).toBe(true);
|
|
14
|
+
expect(isInsideCodeBlock(code, code.indexOf('after'))).toBe(false);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('ignores inline triple backticks (not at line start)', () => {
|
|
18
|
+
const code = 'Use ``` to fence code blocks.\nplain text here';
|
|
19
|
+
expect(isInsideCodeBlock(code, code.indexOf('plain'))).toBe(false);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('handles indented fences and CRLF line endings', () => {
|
|
23
|
+
const code = 'a\r\n ```js\r\ninside\r\n ```\r\nb';
|
|
24
|
+
expect(isInsideCodeBlock(code, code.indexOf('inside'))).toBe(true);
|
|
25
|
+
expect(isInsideCodeBlock(code, code.indexOf('b'))).toBe(false);
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
describe('isInsideCodeSpan', () => {
|
|
30
|
+
it('detects content inside a single-backtick span', () => {
|
|
31
|
+
const code = 'a `<div>` b';
|
|
32
|
+
expect(isInsideCodeSpan(code, code.indexOf('<div>'))).toBe(true);
|
|
33
|
+
expect(isInsideCodeSpan(code, code.indexOf('b'))).toBe(false);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('pairs equal-length backtick runs (double-backtick span with inner backtick)', () => {
|
|
37
|
+
const code = 'A ``b ` c`` and <Unknown> tag';
|
|
38
|
+
expect(isInsideCodeSpan(code, code.indexOf('b `'))).toBe(true);
|
|
39
|
+
// The single backtick inside the double-backtick span must not
|
|
40
|
+
// open a new span that swallows the rest of the line.
|
|
41
|
+
expect(isInsideCodeSpan(code, code.indexOf('<Unknown>'))).toBe(false);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('treats an unmatched backtick run as literal text', () => {
|
|
45
|
+
const code = 'odd ` backtick <Foo> here';
|
|
46
|
+
expect(isInsideCodeSpan(code, code.indexOf('<Foo>'))).toBe(false);
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe('preprocessMdxTags', () => {
|
|
51
|
+
it('returns undefined for non-mdx files', () => {
|
|
52
|
+
expect(preprocessMdxTags().transform('<Foo>', 'file.ts')).toBeUndefined();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('escapes bare unknown pseudo-tags in backticks', () => {
|
|
56
|
+
expect(run('This <UnknownTag> is not JSX')).toBe('This `<UnknownTag>` is not JSX');
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('preserves known components with matched closing tags', () => {
|
|
60
|
+
expect(run('<Note>hello</Note>')).toBe('<Note>hello</Note>');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('escapes known components without a closing tag', () => {
|
|
64
|
+
expect(run('<div>no closing tag')).toBe('`<div>`no closing tag');
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('preserves nested same-name tags without leaking an orphan closer', () => {
|
|
68
|
+
const input = '<div><div>inner</div></div>';
|
|
69
|
+
expect(run(input)).toBe(input);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('leaves tags inside fenced code blocks untouched', () => {
|
|
73
|
+
const input = '```\n<UnknownTag>\n<div>\n```\n';
|
|
74
|
+
expect(run(input)).toBe(input);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('handles CRLF input with fenced code blocks', () => {
|
|
78
|
+
const input = 'text\r\n```\r\n<UnknownTag>\r\n```\r\n';
|
|
79
|
+
expect(run(input)).toBe(input);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('does not corrupt double-backtick code spans', () => {
|
|
83
|
+
const input = 'A ``b `<div>` c`` d';
|
|
84
|
+
expect(run(input)).toBe(input);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('still escapes tags after a double-backtick span (no parity confusion)', () => {
|
|
88
|
+
expect(run('A ``b ` c`` and <Unknown> tag')).toBe('A ``b ` c`` and `<Unknown>` tag');
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('does not treat inline triple backticks as a code fence', () => {
|
|
92
|
+
expect(run('Use ``` to fence.\n\n<Unknown>')).toBe('Use ``` to fence.\n\n`<Unknown>`');
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('escapes unknown self-closing tags and preserves known ones', () => {
|
|
96
|
+
expect(run('<VideoPlayer src="x" />')).toBe('`<VideoPlayer src="x" />`');
|
|
97
|
+
expect(run('line<br/>break')).toBe('line<br/>break');
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('leaves tags inside single-backtick inline code untouched', () => {
|
|
101
|
+
const input = 'use `<UnknownTag>` in your doc';
|
|
102
|
+
expect(run(input)).toBe(input);
|
|
103
|
+
});
|
|
104
|
+
});
|
package/src/plugins/sanitize.ts
CHANGED
|
@@ -17,7 +17,7 @@ export const remarkSafeVars = () => {
|
|
|
17
17
|
const KNOWN_COMPONENTS = [
|
|
18
18
|
// Text components
|
|
19
19
|
'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'p', 'Row', 'Col', 'a', 'img',
|
|
20
|
-
// Code components
|
|
20
|
+
// Code components
|
|
21
21
|
'code', 'CodeGroup', 'pre',
|
|
22
22
|
// UI components
|
|
23
23
|
'Accordion', 'AccordionGroup', 'Icon', 'Card', 'CardGroup',
|
|
@@ -33,43 +33,85 @@ const KNOWN_COMPONENTS = [
|
|
|
33
33
|
'address', 'cite', 'q', 'dfn'
|
|
34
34
|
];
|
|
35
35
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
36
|
+
/**
|
|
37
|
+
* Returns true if the character at `idx` is inside a fenced code block.
|
|
38
|
+
* Fences only count when they start a line (optionally indented) — inline
|
|
39
|
+
* triple backticks in prose do not open or close a block. Offsets on a
|
|
40
|
+
* fence line itself are treated as inside, so fence lines are never modified.
|
|
41
|
+
*/
|
|
42
|
+
export function isInsideCodeBlock(code: string, idx: number): boolean {
|
|
43
|
+
let inFence = false;
|
|
44
|
+
let pos = 0;
|
|
45
|
+
for (const line of code.split('\n')) {
|
|
46
|
+
const lineEnd = pos + line.length;
|
|
47
|
+
const isFenceLine = /^\s*```/.test(line);
|
|
48
|
+
if (idx >= pos && idx <= lineEnd) {
|
|
49
|
+
return inFence || isFenceLine;
|
|
50
|
+
}
|
|
51
|
+
if (isFenceLine) {
|
|
52
|
+
inFence = !inFence;
|
|
53
|
+
}
|
|
54
|
+
pos = lineEnd + 1;
|
|
55
|
+
}
|
|
56
|
+
return inFence;
|
|
43
57
|
}
|
|
44
58
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
const
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
59
|
+
/**
|
|
60
|
+
* Returns true if the character at `idx` is inside an inline code span.
|
|
61
|
+
* Follows CommonMark pairing: a run of N backticks opens a span closed by
|
|
62
|
+
* the next run of exactly N backticks; unmatched runs are literal text.
|
|
63
|
+
* Backtick runs inside fenced code blocks are ignored.
|
|
64
|
+
*/
|
|
65
|
+
export function isInsideCodeSpan(code: string, idx: number): boolean {
|
|
66
|
+
const runs: { index: number; length: number }[] = [];
|
|
67
|
+
const re = /`+/g;
|
|
68
|
+
let m: RegExpExecArray | null;
|
|
69
|
+
while ((m = re.exec(code)) !== null) {
|
|
70
|
+
if (!isInsideCodeBlock(code, m.index)) {
|
|
71
|
+
runs.push({ index: m.index, length: m[0].length });
|
|
72
|
+
}
|
|
57
73
|
}
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
//
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
74
|
+
|
|
75
|
+
let i = 0;
|
|
76
|
+
while (i < runs.length) {
|
|
77
|
+
const open = runs[i];
|
|
78
|
+
if (open.index >= idx) return false;
|
|
79
|
+
|
|
80
|
+
// Find the matching closer: the next run of the same length
|
|
81
|
+
let j = i + 1;
|
|
82
|
+
while (j < runs.length && runs[j].length !== open.length) j++;
|
|
83
|
+
|
|
84
|
+
if (j < runs.length) {
|
|
85
|
+
if (idx > open.index && idx < runs[j].index) return true;
|
|
86
|
+
i = j + 1;
|
|
87
|
+
} else {
|
|
88
|
+
// Unmatched run is literal text, not a span opener
|
|
89
|
+
i++;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Helper: checks if a known component tag has a matching closing tag,
|
|
96
|
+
// tracking nesting depth so nested same-name tags pair up correctly.
|
|
97
|
+
function hasMatchingClosingTag(code: string, tagName: string, openingTagOffset: number): boolean {
|
|
98
|
+
const re = new RegExp(`</?${tagName}>`, 'gi');
|
|
99
|
+
re.lastIndex = openingTagOffset + `<${tagName}>`.length;
|
|
100
|
+
let depth = 1;
|
|
101
|
+
let m: RegExpExecArray | null;
|
|
102
|
+
while ((m = re.exec(code)) !== null) {
|
|
103
|
+
// Tags inside code blocks or inline code spans are literal text
|
|
104
|
+
if (isInsideCodeBlock(code, m.index) || isInsideCodeSpan(code, m.index)) {
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
if (m[0].startsWith('</')) {
|
|
108
|
+
depth--;
|
|
109
|
+
if (depth === 0) return true;
|
|
110
|
+
} else {
|
|
111
|
+
depth++;
|
|
68
112
|
}
|
|
69
|
-
return true;
|
|
70
113
|
}
|
|
71
|
-
|
|
72
|
-
return false; // Another opening tag comes before the closing tag, so this one is not properly closed
|
|
114
|
+
return false;
|
|
73
115
|
}
|
|
74
116
|
|
|
75
117
|
// Allow any <[a-z][a-z0-9]*> tag inside code blocks to be left as-is (not parsed as JSX).
|
|
@@ -80,16 +122,18 @@ export function preprocessMdxTags() {
|
|
|
80
122
|
transform(code: string, id: string) {
|
|
81
123
|
if (!id.endsWith('.mdx')) return;
|
|
82
124
|
|
|
83
|
-
// Remove leading whitespace from code fence blocks
|
|
84
|
-
|
|
125
|
+
// Remove leading whitespace from code fence blocks.
|
|
126
|
+
// Only horizontal whitespace: \s would also swallow the \n of CRLF
|
|
127
|
+
// line endings (multiline ^ matches after \r in JS).
|
|
128
|
+
let processed = code.replace(/^([ \t]+)(```\w*)/gm, '$2');
|
|
85
129
|
|
|
86
130
|
// Handle self-closing tags first (like <VideoPlayer />)
|
|
87
131
|
processed = processed.replace(/<([A-Za-z][A-Za-z0-9]*)[^>]*\/>/g, (match, tag, offset) => {
|
|
88
|
-
// If inside a code block, do not touch
|
|
89
|
-
if (isInsideCodeBlock(processed, offset)) {
|
|
132
|
+
// If inside a code block or inline code span, do not touch
|
|
133
|
+
if (isInsideCodeBlock(processed, offset) || isInsideCodeSpan(processed, offset)) {
|
|
90
134
|
return match;
|
|
91
135
|
}
|
|
92
|
-
|
|
136
|
+
|
|
93
137
|
// If unknown component, treat as text
|
|
94
138
|
if (!KNOWN_COMPONENTS.includes(tag)) {
|
|
95
139
|
return `\`${match}\``;
|
|
@@ -100,11 +144,11 @@ export function preprocessMdxTags() {
|
|
|
100
144
|
// Only replace unrecognized <someTag> with `"<someTag>"` if NOT inside a code block
|
|
101
145
|
// This regex finds all <tag> occurrences (fixed to handle uppercase)
|
|
102
146
|
processed = processed.replace(/<([A-Za-z][A-Za-z0-9]*)>/g, (match, tag, offset) => {
|
|
103
|
-
// If inside a code block, do not touch
|
|
104
|
-
if (isInsideCodeBlock(processed, offset)) {
|
|
147
|
+
// If inside a code block or inline code span, do not touch
|
|
148
|
+
if (isInsideCodeBlock(processed, offset) || isInsideCodeSpan(processed, offset)) {
|
|
105
149
|
return match;
|
|
106
150
|
}
|
|
107
|
-
|
|
151
|
+
|
|
108
152
|
// If known component, check if it has a closing tag
|
|
109
153
|
if (KNOWN_COMPONENTS.includes(tag)) {
|
|
110
154
|
// If it doesn't have a matching closing tag, treat it as text
|
|
@@ -115,23 +159,17 @@ export function preprocessMdxTags() {
|
|
|
115
159
|
return match;
|
|
116
160
|
}
|
|
117
161
|
// For unknown components, always treat as text (wrap in backticks)
|
|
118
|
-
// If already inside backticks, do not wrap again
|
|
119
|
-
const before = processed.slice(0, offset);
|
|
120
|
-
const backtickMatches = before.match(/`+/g);
|
|
121
|
-
const insideBackticks = backtickMatches ? backtickMatches.reduce((acc, s) => acc + s.length, 0) % 2 === 1 : false;
|
|
122
|
-
if (insideBackticks) {
|
|
123
|
-
return match;
|
|
124
|
-
}
|
|
125
162
|
return `\`<${tag}>\``;
|
|
126
163
|
});
|
|
127
164
|
|
|
128
165
|
// This regex finds all <tag> and </tag> occurrences (fixed to handle uppercase)
|
|
129
166
|
processed = processed.replace(/<\/?([A-Za-z][A-Za-z0-9]*)>/g, (match, tag, offset) => {
|
|
130
|
-
// If inside a code block
|
|
131
|
-
|
|
167
|
+
// If inside a code block or inline code span (including spans added
|
|
168
|
+
// by the previous pass), do not touch
|
|
169
|
+
if (isInsideCodeBlock(processed, offset) || isInsideCodeSpan(processed, offset)) {
|
|
132
170
|
return match;
|
|
133
171
|
}
|
|
134
|
-
|
|
172
|
+
|
|
135
173
|
// If known component, check if it has a closing tag (only for opening tags)
|
|
136
174
|
if (KNOWN_COMPONENTS.includes(tag)) {
|
|
137
175
|
// Only check for matching closing tag for opening tags
|
|
@@ -144,13 +182,6 @@ export function preprocessMdxTags() {
|
|
|
144
182
|
return match;
|
|
145
183
|
}
|
|
146
184
|
// For unknown components, always treat as text (wrap in backticks)
|
|
147
|
-
// If already inside backticks, do not wrap again
|
|
148
|
-
const before = processed.slice(0, offset);
|
|
149
|
-
const backtickMatches = before.match(/`+/g);
|
|
150
|
-
const insideBackticks = backtickMatches ? backtickMatches.reduce((acc, s) => acc + s.length, 0) % 2 === 1 : false;
|
|
151
|
-
if (insideBackticks) {
|
|
152
|
-
return match;
|
|
153
|
-
}
|
|
154
185
|
return `\`${match}\``;
|
|
155
186
|
});
|
|
156
187
|
|
|
@@ -160,4 +191,4 @@ export function preprocessMdxTags() {
|
|
|
160
191
|
};
|
|
161
192
|
},
|
|
162
193
|
};
|
|
163
|
-
}
|
|
194
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import matter from 'gray-matter';
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Shared helpers for build plugins that read docs/*.mdx and docs/dory.json.
|
|
7
|
+
* Consolidated from near-identical copies in frontmatter-generator,
|
|
8
|
+
* frontmatter-dev-server, llm-txt-generator, and llm-txt-dev-server.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Parses YAML frontmatter from MDX content using gray-matter.
|
|
13
|
+
* Handles CRLF line endings and real YAML (nested values, dates, quotes).
|
|
14
|
+
*/
|
|
15
|
+
export function parseFrontmatter(content: string): { data: Record<string, any>; content: string } {
|
|
16
|
+
const parsed = matter(content);
|
|
17
|
+
return { data: parsed.data, content: parsed.content };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Reads and parses docs/dory.json. Returns null when the file is missing
|
|
22
|
+
* or unparseable (a parse error is logged).
|
|
23
|
+
*/
|
|
24
|
+
export function readDoryConfig(docsDir?: string): Record<string, any> | null {
|
|
25
|
+
const doryJsonPath = path.join(docsDir || path.join(process.cwd(), 'docs'), 'dory.json');
|
|
26
|
+
if (!fs.existsSync(doryJsonPath)) return null;
|
|
27
|
+
try {
|
|
28
|
+
return JSON.parse(fs.readFileSync(doryJsonPath, 'utf-8'));
|
|
29
|
+
} catch (error) {
|
|
30
|
+
const detail = error instanceof SyntaxError ? error.message : String(error);
|
|
31
|
+
console.error(`Failed to parse ${doryJsonPath}: ${detail}`);
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Reads dory.json navigation and returns the flat, ordered list of page keys.
|
|
38
|
+
*/
|
|
39
|
+
export function getNavigationOrder(docsDir: string): string[] {
|
|
40
|
+
const doryJson = readDoryConfig(docsDir);
|
|
41
|
+
if (!doryJson) return [];
|
|
42
|
+
const order: string[] = [];
|
|
43
|
+
|
|
44
|
+
function walkPages(pages: any[]) {
|
|
45
|
+
for (const page of pages) {
|
|
46
|
+
if (typeof page === 'string') {
|
|
47
|
+
order.push(page);
|
|
48
|
+
} else if (typeof page === 'object' && page.pages) {
|
|
49
|
+
walkPages(page.pages);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (doryJson.navigation && Array.isArray(doryJson.navigation.tabs)) {
|
|
55
|
+
for (const tab of doryJson.navigation.tabs) {
|
|
56
|
+
if (tab.groups && Array.isArray(tab.groups)) {
|
|
57
|
+
for (const group of tab.groups) {
|
|
58
|
+
if (group.pages && Array.isArray(group.pages)) {
|
|
59
|
+
walkPages(group.pages);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return order;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Converts an MDX filename to the route path used by the store,
|
|
70
|
+
* e.g. docs/getting-started/installation.mdx -> /getting-started/installation.
|
|
71
|
+
*/
|
|
72
|
+
export function pathFromFilename(filename: string, docsDir: string): string {
|
|
73
|
+
return filename
|
|
74
|
+
.replace(docsDir, '')
|
|
75
|
+
.replace(/\/?index\.mdx$/, '/')
|
|
76
|
+
.replace(/\.mdx$/, '')
|
|
77
|
+
.replace(/\\/g, '/')
|
|
78
|
+
.toLowerCase();
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Recursively finds all MDX files under a directory, keyed by relative
|
|
83
|
+
* path without the .mdx extension (forward slashes).
|
|
84
|
+
*/
|
|
85
|
+
export function findMdxFilesMap(dir: string, baseDir: string): Record<string, string> {
|
|
86
|
+
const files: Record<string, string> = {};
|
|
87
|
+
const items = fs.readdirSync(dir, { withFileTypes: true });
|
|
88
|
+
|
|
89
|
+
for (const item of items) {
|
|
90
|
+
const fullPath = path.join(dir, item.name);
|
|
91
|
+
if (item.isDirectory()) {
|
|
92
|
+
Object.assign(files, findMdxFilesMap(fullPath, baseDir));
|
|
93
|
+
} else if (item.isFile() && item.name.endsWith('.mdx')) {
|
|
94
|
+
let rel = path.relative(baseDir, fullPath).replace(/\\/g, '/');
|
|
95
|
+
if (rel.endsWith('.mdx')) rel = rel.slice(0, -4);
|
|
96
|
+
files[rel] = fullPath;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return files;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Strips MDX/JSX syntax to extract plain text (for search indexing and
|
|
105
|
+
* meta description fallbacks).
|
|
106
|
+
*/
|
|
107
|
+
export function stripMdxToText(content: string): string {
|
|
108
|
+
return content
|
|
109
|
+
.replace(/^import\s+.*$/gm, '') // import statements
|
|
110
|
+
.replace(/^export\s+.*$/gm, '') // export statements
|
|
111
|
+
.replace(/```[\s\S]*?```/g, '') // fenced code blocks
|
|
112
|
+
.replace(/<[^>]+>/g, '') // JSX/HTML tags
|
|
113
|
+
.replace(/\[([^\]]*)\]\([^)]*\)/g, '$1') // markdown links -> text
|
|
114
|
+
.replace(/[#*_~`>|]/g, '') // markdown syntax chars
|
|
115
|
+
.replace(/\n{3,}/g, '\n\n') // collapse blank lines
|
|
116
|
+
.trim();
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Escapes a string for safe use in HTML/XML text and attribute values.
|
|
121
|
+
*/
|
|
122
|
+
export function escapeHtml(str: string): string {
|
|
123
|
+
return str
|
|
124
|
+
.replace(/&/g, '&')
|
|
125
|
+
.replace(/</g, '<')
|
|
126
|
+
.replace(/>/g, '>')
|
|
127
|
+
.replace(/"/g, '"')
|
|
128
|
+
.replace(/'/g, ''');
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Applies an seo.titleTemplate (e.g. "%s — Site") to a page title.
|
|
133
|
+
* Returns the title unchanged when no template is set or the title is
|
|
134
|
+
* already the bare site title.
|
|
135
|
+
*/
|
|
136
|
+
export function applyTitleTemplate(title: string, template: string | undefined, siteName: string): string {
|
|
137
|
+
if (!template || title === siteName) return title;
|
|
138
|
+
return template.replace('%s', title);
|
|
139
|
+
}
|
|
@@ -1,72 +1,78 @@
|
|
|
1
1
|
import type { Plugin } from 'vite';
|
|
2
|
-
import { readFileSync, existsSync
|
|
2
|
+
import { readFileSync, existsSync } from 'fs';
|
|
3
3
|
import { resolve } from 'path';
|
|
4
|
+
import {
|
|
5
|
+
readDoryConfig,
|
|
6
|
+
findMdxFilesMap,
|
|
7
|
+
pathFromFilename,
|
|
8
|
+
parseFrontmatter,
|
|
9
|
+
escapeHtml,
|
|
10
|
+
} from './shared';
|
|
4
11
|
|
|
5
12
|
/**
|
|
6
|
-
* Generates sitemap.xml from
|
|
7
|
-
* Uses
|
|
8
|
-
* based on route depth.
|
|
13
|
+
* Generates sitemap.xml at build time from all MDX files (not just those in
|
|
14
|
+
* navigation). Uses frontmatter `lastUpdated` for lastmod when present and
|
|
15
|
+
* calculates priority based on route depth. Skipped entirely when
|
|
16
|
+
* `url` is missing or `seo.noindex` is set.
|
|
9
17
|
*/
|
|
10
18
|
export function sitemapGenerator(): Plugin {
|
|
11
19
|
return {
|
|
12
20
|
name: 'sitemap-generator',
|
|
13
21
|
generateBundle() {
|
|
14
22
|
const docsDir = resolve(process.cwd(), 'docs');
|
|
15
|
-
const
|
|
16
|
-
if (!
|
|
23
|
+
const config = readDoryConfig(docsDir);
|
|
24
|
+
if (!config) {
|
|
17
25
|
console.warn('sitemap-generator: dory.json not found, skipping');
|
|
18
26
|
return;
|
|
19
27
|
}
|
|
20
28
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
walkPages(page.pages);
|
|
33
|
-
}
|
|
34
|
-
}
|
|
35
|
-
}
|
|
29
|
+
const baseUrl: string = config.url || '';
|
|
30
|
+
if (!baseUrl) {
|
|
31
|
+
console.warn(
|
|
32
|
+
'⚠️ sitemap-generator: `url` is not set in dory.json — sitemap.xml will NOT be generated. Set `url` to enable it.'
|
|
33
|
+
);
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
if (config.seo?.noindex) {
|
|
37
|
+
console.warn('⚠️ sitemap-generator: seo.noindex is set — sitemap.xml will NOT be generated.');
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
36
40
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
if (tab.groups) {
|
|
40
|
-
for (const group of tab.groups) {
|
|
41
|
-
if (group.pages) {
|
|
42
|
-
walkPages(group.pages);
|
|
43
|
-
}
|
|
44
|
-
}
|
|
45
|
-
}
|
|
46
|
-
}
|
|
47
|
-
}
|
|
41
|
+
try {
|
|
42
|
+
const mdxFilesMap = existsSync(docsDir) ? findMdxFilesMap(docsDir, docsDir) : {};
|
|
48
43
|
|
|
49
|
-
const fallbackDate = new Date().toISOString().split('T')[0];
|
|
50
44
|
let sitemap = '<?xml version="1.0" encoding="UTF-8"?>\n';
|
|
51
45
|
sitemap += '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n';
|
|
52
46
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
47
|
+
// Root URL first
|
|
48
|
+
sitemap += ' <url>\n';
|
|
49
|
+
sitemap += ` <loc>${escapeHtml(`${baseUrl}/`)}</loc>\n`;
|
|
50
|
+
sitemap += ' <priority>1.0</priority>\n';
|
|
51
|
+
sitemap += ' </url>\n';
|
|
52
|
+
|
|
53
|
+
let count = 1;
|
|
54
|
+
for (const filePath of Object.values(mdxFilesMap).sort()) {
|
|
55
|
+
const routePath = pathFromFilename(filePath, docsDir);
|
|
56
|
+
if (routePath === '/') continue;
|
|
57
|
+
|
|
58
|
+
const { data } = parseFrontmatter(readFileSync(filePath, 'utf-8'));
|
|
59
|
+
// Exclude pages marked noindex in frontmatter
|
|
60
|
+
if (typeof data.robots === 'string' && data.robots.includes('noindex')) continue;
|
|
61
|
+
|
|
62
|
+
const lastmod = toIsoDate(data.lastUpdated);
|
|
60
63
|
|
|
61
64
|
// Priority based on route depth: fewer segments = higher priority
|
|
62
|
-
const depth =
|
|
65
|
+
const depth = routePath.replace(/^\//, '').split('/').length;
|
|
63
66
|
const priority = Math.max(0.3, 1.0 - (depth - 1) * 0.2).toFixed(1);
|
|
64
67
|
|
|
65
68
|
sitemap += ' <url>\n';
|
|
66
|
-
sitemap += ` <loc>${baseUrl}
|
|
67
|
-
|
|
69
|
+
sitemap += ` <loc>${escapeHtml(`${baseUrl}${routePath}`)}</loc>\n`;
|
|
70
|
+
if (lastmod) {
|
|
71
|
+
sitemap += ` <lastmod>${lastmod}</lastmod>\n`;
|
|
72
|
+
}
|
|
68
73
|
sitemap += ` <priority>${priority}</priority>\n`;
|
|
69
74
|
sitemap += ' </url>\n';
|
|
75
|
+
count++;
|
|
70
76
|
}
|
|
71
77
|
|
|
72
78
|
sitemap += '</urlset>';
|
|
@@ -77,7 +83,7 @@ export function sitemapGenerator(): Plugin {
|
|
|
77
83
|
source: sitemap
|
|
78
84
|
});
|
|
79
85
|
|
|
80
|
-
console.log(`✅ Generated sitemap.xml with ${
|
|
86
|
+
console.log(`✅ Generated sitemap.xml with ${count} URLs`);
|
|
81
87
|
} catch (error) {
|
|
82
88
|
const detail = error instanceof SyntaxError ? error.message : String(error);
|
|
83
89
|
console.error(`Failed to generate sitemap: ${detail}`);
|
|
@@ -85,3 +91,11 @@ export function sitemapGenerator(): Plugin {
|
|
|
85
91
|
}
|
|
86
92
|
};
|
|
87
93
|
}
|
|
94
|
+
|
|
95
|
+
/** Normalizes a frontmatter lastUpdated value (string or Date) to an ISO date. */
|
|
96
|
+
function toIsoDate(value: unknown): string {
|
|
97
|
+
if (!value) return '';
|
|
98
|
+
const date = new Date(value as string | number | Date);
|
|
99
|
+
if (isNaN(date.getTime())) return '';
|
|
100
|
+
return date.toISOString().split('T')[0];
|
|
101
|
+
}
|