@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
|
@@ -1,193 +1,154 @@
|
|
|
1
1
|
import type { Plugin } from 'vite';
|
|
2
2
|
import fs from 'fs';
|
|
3
3
|
import path from 'path';
|
|
4
|
+
import {
|
|
5
|
+
readDoryConfig,
|
|
6
|
+
getNavigationOrder,
|
|
7
|
+
parseFrontmatter,
|
|
8
|
+
findMdxFilesMap,
|
|
9
|
+
pathFromFilename,
|
|
10
|
+
} from './shared';
|
|
4
11
|
|
|
5
12
|
interface LlmTxtGeneratorOptions {
|
|
6
13
|
docsDir?: string;
|
|
7
|
-
outputPath?: string;
|
|
8
14
|
}
|
|
9
15
|
|
|
10
|
-
|
|
11
|
-
function getNavigationOrder(docsDir: string): string[] {
|
|
12
|
-
const doryJsonPath = path.join(docsDir, 'dory.json');
|
|
13
|
-
if (!fs.existsSync(doryJsonPath)) return [];
|
|
14
|
-
let doryJson;
|
|
15
|
-
try {
|
|
16
|
-
doryJson = JSON.parse(fs.readFileSync(doryJsonPath, 'utf-8'));
|
|
17
|
-
} catch (error) {
|
|
18
|
-
const detail = error instanceof SyntaxError ? error.message : String(error);
|
|
19
|
-
console.error(`Failed to parse ${doryJsonPath}: ${detail}`);
|
|
20
|
-
return [];
|
|
21
|
-
}
|
|
22
|
-
const order: string[] = [];
|
|
23
|
-
|
|
24
|
-
function walkPages(pages: any[]) {
|
|
25
|
-
for (const page of pages) {
|
|
26
|
-
if (typeof page === 'string') {
|
|
27
|
-
order.push(page);
|
|
28
|
-
} else if (typeof page === 'object' && page.pages) {
|
|
29
|
-
walkPages(page.pages);
|
|
30
|
-
}
|
|
31
|
-
}
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
if (doryJson.navigation && Array.isArray(doryJson.navigation.tabs)) {
|
|
35
|
-
for (const tab of doryJson.navigation.tabs) {
|
|
36
|
-
if (tab.groups && Array.isArray(tab.groups)) {
|
|
37
|
-
for (const group of tab.groups) {
|
|
38
|
-
if (group.pages && Array.isArray(group.pages)) {
|
|
39
|
-
walkPages(group.pages);
|
|
40
|
-
}
|
|
41
|
-
}
|
|
42
|
-
}
|
|
43
|
-
}
|
|
44
|
-
}
|
|
45
|
-
return order;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
// Function to parse frontmatter from MDX content
|
|
49
|
-
function parseFrontmatter(content: string) {
|
|
50
|
-
const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---\n/);
|
|
51
|
-
if (!frontmatterMatch) return { data: {}, content };
|
|
52
|
-
|
|
53
|
-
const frontmatter = frontmatterMatch[1];
|
|
54
|
-
const data: Record<string, string> = {};
|
|
55
|
-
|
|
56
|
-
frontmatter.split('\n').forEach(line => {
|
|
57
|
-
const [key, ...valueParts] = line.split(':');
|
|
58
|
-
if (key && valueParts.length) {
|
|
59
|
-
const value = valueParts.join(':').trim();
|
|
60
|
-
data[key.trim()] = value.replace(/^["']|["']$/g, '');
|
|
61
|
-
}
|
|
62
|
-
});
|
|
63
|
-
|
|
64
|
-
return { data, content: content.slice(frontmatterMatch[0].length) };
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
// Function to recursively find all MDX files and map by relative path (without .mdx)
|
|
68
|
-
function findMdxFilesMap(dir: string, baseDir: string): Record<string, string> {
|
|
69
|
-
const files: Record<string, string> = {};
|
|
70
|
-
const items = fs.readdirSync(dir, { withFileTypes: true });
|
|
71
|
-
|
|
72
|
-
for (const item of items) {
|
|
73
|
-
const fullPath = path.join(dir, item.name);
|
|
74
|
-
if (item.isDirectory()) {
|
|
75
|
-
Object.assign(files, findMdxFilesMap(fullPath, baseDir));
|
|
76
|
-
} else if (item.isFile() && item.name.endsWith('.mdx')) {
|
|
77
|
-
// Key: relative path without .mdx, with forward slashes
|
|
78
|
-
let rel = path.relative(baseDir, fullPath).replace(/\\/g, '/');
|
|
79
|
-
if (rel.endsWith('.mdx')) rel = rel.slice(0, -4);
|
|
80
|
-
files[rel] = fullPath;
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
return files;
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
// Function to clean MDX content for LLM consumption
|
|
16
|
+
/** Cleans MDX content for LLM consumption (keeps code-fence languages). */
|
|
88
17
|
function cleanMdxContent(content: string): string {
|
|
89
|
-
|
|
90
|
-
let cleaned = content
|
|
18
|
+
return content
|
|
91
19
|
// Remove JSX opening/closing tags but keep content
|
|
92
20
|
.replace(/<(\w+)([^>]*?)>/g, '')
|
|
93
21
|
.replace(/<\/\w+>/g, '')
|
|
94
|
-
// Remove code block language specifiers
|
|
95
|
-
.replace(/```(\w+)/g, '```')
|
|
96
22
|
// Remove HTML comments
|
|
97
23
|
.replace(/<!--[\s\S]*?-->/g, '')
|
|
98
24
|
// Remove excessive whitespace
|
|
99
25
|
.replace(/\n\s*\n\s*\n/g, '\n\n')
|
|
100
|
-
// Remove leading/trailing whitespace
|
|
101
26
|
.trim();
|
|
27
|
+
}
|
|
102
28
|
|
|
103
|
-
|
|
29
|
+
/** Resolves a navigation page key to its MDX file key, or null when missing. */
|
|
30
|
+
function resolveFileKey(navKey: string, mdxFilesMap: Record<string, string>): string | null {
|
|
31
|
+
if (mdxFilesMap[navKey]) return navKey;
|
|
32
|
+
if (mdxFilesMap[`${navKey}/index`]) return `${navKey}/index`;
|
|
33
|
+
return null;
|
|
104
34
|
}
|
|
105
35
|
|
|
106
|
-
|
|
107
|
-
|
|
36
|
+
/**
|
|
37
|
+
* Generates a spec-shaped llms.txt (https://llmstxt.org): H1 site name,
|
|
38
|
+
* blockquote summary, then per-tab sections with link lists to the .mdx
|
|
39
|
+
* source files.
|
|
40
|
+
*/
|
|
41
|
+
export function generateLlmsIndex(docsDir: string): string {
|
|
42
|
+
const config = readDoryConfig(docsDir) || {};
|
|
43
|
+
const siteName: string = config.name || 'Documentation';
|
|
44
|
+
const baseUrl: string = config.url || '';
|
|
45
|
+
const mdxFilesMap = findMdxFilesMap(docsDir, docsDir);
|
|
46
|
+
|
|
47
|
+
let output = `# ${siteName}\n`;
|
|
48
|
+
if (config.description) {
|
|
49
|
+
output += `\n> ${config.description}\n`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const tabs: any[] = config.navigation?.tabs || [];
|
|
53
|
+
for (const tab of tabs) {
|
|
54
|
+
const links: string[] = [];
|
|
55
|
+
const walkPages = (pages: any[]) => {
|
|
56
|
+
for (const page of pages) {
|
|
57
|
+
if (typeof page === 'string') {
|
|
58
|
+
const fileKey = resolveFileKey(page, mdxFilesMap);
|
|
59
|
+
if (!fileKey) continue;
|
|
60
|
+
const raw = fs.readFileSync(mdxFilesMap[fileKey], 'utf-8');
|
|
61
|
+
const { data } = parseFrontmatter(raw);
|
|
62
|
+
const routePath = pathFromFilename(mdxFilesMap[fileKey], docsDir);
|
|
63
|
+
const url = `${baseUrl}${routePath}.mdx`;
|
|
64
|
+
const title = data.title || page;
|
|
65
|
+
links.push(data.description ? `- [${title}](${url}): ${data.description}` : `- [${title}](${url})`);
|
|
66
|
+
} else if (typeof page === 'object' && page.pages) {
|
|
67
|
+
walkPages(page.pages);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
for (const group of tab.groups || []) {
|
|
72
|
+
if (Array.isArray(group.pages)) walkPages(group.pages);
|
|
73
|
+
}
|
|
74
|
+
if (links.length > 0) {
|
|
75
|
+
output += `\n## ${tab.tab || 'Documentation'}\n\n${links.join('\n')}\n`;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return output.trim() + '\n';
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Generates the full concatenated documentation content (llms-full.txt),
|
|
84
|
+
* in dory.json navigation order with extra files appended.
|
|
85
|
+
*/
|
|
86
|
+
export function generateLlmContent(docsDir: string): string {
|
|
108
87
|
const navOrder = getNavigationOrder(docsDir);
|
|
109
88
|
const mdxFilesMap = findMdxFilesMap(docsDir, docsDir);
|
|
110
89
|
const usedKeys = new Set<string>();
|
|
111
90
|
let aggregatedContent = '';
|
|
112
91
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
// Try both with and without "index"
|
|
116
|
-
let fileKey = navKey;
|
|
117
|
-
if (mdxFilesMap[fileKey]) {
|
|
118
|
-
usedKeys.add(fileKey);
|
|
119
|
-
} else if (mdxFilesMap[`${fileKey}/index`]) {
|
|
120
|
-
fileKey = `${fileKey}/index`;
|
|
121
|
-
usedKeys.add(fileKey);
|
|
122
|
-
} else {
|
|
123
|
-
continue;
|
|
124
|
-
}
|
|
125
|
-
const filePath = mdxFilesMap[fileKey];
|
|
126
|
-
const rawContent = fs.readFileSync(filePath, 'utf-8');
|
|
92
|
+
const appendFile = (fileKey: string) => {
|
|
93
|
+
const rawContent = fs.readFileSync(mdxFilesMap[fileKey], 'utf-8');
|
|
127
94
|
const { data, content } = parseFrontmatter(rawContent);
|
|
128
|
-
const cleanedContent = cleanMdxContent(content);
|
|
129
95
|
|
|
130
|
-
// Add file header
|
|
131
96
|
aggregatedContent += `\n# ${fileKey}.mdx\n`;
|
|
132
|
-
|
|
133
|
-
// Add title if available
|
|
134
97
|
if (data.title) {
|
|
135
98
|
aggregatedContent += `## ${data.title}\n\n`;
|
|
136
99
|
}
|
|
137
|
-
|
|
138
|
-
// Add description if available
|
|
139
100
|
if (data.description) {
|
|
140
101
|
aggregatedContent += `${data.description}\n\n`;
|
|
141
102
|
}
|
|
142
|
-
|
|
143
|
-
// Add cleaned content
|
|
144
|
-
aggregatedContent += cleanedContent + '\n\n';
|
|
103
|
+
aggregatedContent += cleanMdxContent(content) + '\n\n';
|
|
145
104
|
aggregatedContent += '---\n\n';
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
// Output files in navigation order, in order as in dory.json
|
|
108
|
+
for (const navKey of navOrder) {
|
|
109
|
+
const fileKey = resolveFileKey(navKey, mdxFilesMap);
|
|
110
|
+
if (!fileKey) continue;
|
|
111
|
+
usedKeys.add(fileKey);
|
|
112
|
+
appendFile(fileKey);
|
|
146
113
|
}
|
|
147
114
|
|
|
148
115
|
// Output any extra files not in navigation order, sorted
|
|
149
116
|
const extraKeys = Object.keys(mdxFilesMap).filter(k => !usedKeys.has(k)).sort();
|
|
150
117
|
for (const fileKey of extraKeys) {
|
|
151
|
-
|
|
152
|
-
const rawContent = fs.readFileSync(filePath, 'utf-8');
|
|
153
|
-
const { data, content } = parseFrontmatter(rawContent);
|
|
154
|
-
const cleanedContent = cleanMdxContent(content);
|
|
155
|
-
|
|
156
|
-
aggregatedContent += `\n# ${fileKey}.mdx\n`;
|
|
157
|
-
if (data.title) {
|
|
158
|
-
aggregatedContent += `## ${data.title}\n\n`;
|
|
159
|
-
}
|
|
160
|
-
if (data.description) {
|
|
161
|
-
aggregatedContent += `${data.description}\n\n`;
|
|
162
|
-
}
|
|
163
|
-
aggregatedContent += cleanedContent + '\n\n';
|
|
164
|
-
aggregatedContent += '---\n\n';
|
|
118
|
+
appendFile(fileKey);
|
|
165
119
|
}
|
|
166
120
|
|
|
167
|
-
// Remove leading newlines for the very first file, so the first file is at the very top
|
|
168
121
|
return aggregatedContent.replace(/^\n+/, '').trim();
|
|
169
122
|
}
|
|
170
123
|
|
|
124
|
+
/**
|
|
125
|
+
* Build plugin that emits llms.txt (spec-shaped index) and llms-full.txt
|
|
126
|
+
* (full concatenated documentation).
|
|
127
|
+
*/
|
|
171
128
|
export function llmTxtGenerator(options: LlmTxtGeneratorOptions = {}): Plugin {
|
|
172
129
|
return {
|
|
173
130
|
name: 'llm-txt-generator',
|
|
174
131
|
generateBundle() {
|
|
175
132
|
const docsDir = options.docsDir || path.join(process.cwd(), 'docs');
|
|
176
|
-
const
|
|
133
|
+
const indexContent = generateLlmsIndex(docsDir);
|
|
134
|
+
const fullContent = generateLlmContent(docsDir);
|
|
177
135
|
|
|
178
136
|
this.emitFile({
|
|
179
137
|
type: 'asset',
|
|
180
138
|
fileName: 'llms.txt',
|
|
181
|
-
source:
|
|
139
|
+
source: indexContent
|
|
182
140
|
});
|
|
183
141
|
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
142
|
+
this.emitFile({
|
|
143
|
+
type: 'asset',
|
|
144
|
+
fileName: 'llms-full.txt',
|
|
145
|
+
source: fullContent
|
|
146
|
+
});
|
|
187
147
|
|
|
188
|
-
|
|
148
|
+
const mdxFilesCount = Object.keys(findMdxFilesMap(docsDir, docsDir)).length;
|
|
149
|
+
console.log(`✅ Generated llms.txt and llms-full.txt`);
|
|
189
150
|
console.log(`📄 Processed ${mdxFilesCount} MDX files`);
|
|
190
|
-
console.log(`📝
|
|
151
|
+
console.log(`📝 Full content size: ${fullContent.length} characters`);
|
|
191
152
|
}
|
|
192
153
|
};
|
|
193
|
-
}
|
|
154
|
+
}
|
package/src/plugins/prerender.ts
CHANGED
|
@@ -1,11 +1,20 @@
|
|
|
1
1
|
import type { Plugin, ResolvedConfig } from 'vite';
|
|
2
2
|
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'fs';
|
|
3
3
|
import { resolve } from 'path';
|
|
4
|
+
import {
|
|
5
|
+
readDoryConfig,
|
|
6
|
+
findMdxFilesMap,
|
|
7
|
+
pathFromFilename,
|
|
8
|
+
parseFrontmatter,
|
|
9
|
+
stripMdxToText,
|
|
10
|
+
escapeHtml,
|
|
11
|
+
applyTitleTemplate,
|
|
12
|
+
} from './shared';
|
|
4
13
|
|
|
5
14
|
/**
|
|
6
15
|
* Generates per-route HTML files with page-specific meta tags and JSON-LD
|
|
7
16
|
* at build time. The SSR step (render-routes.mjs) later injects rendered
|
|
8
|
-
* content into these files.
|
|
17
|
+
* content into these files. Also emits a prerendered 404.html.
|
|
9
18
|
*/
|
|
10
19
|
export function prerender(): Plugin {
|
|
11
20
|
let config: ResolvedConfig;
|
|
@@ -30,83 +39,140 @@ export function prerender(): Plugin {
|
|
|
30
39
|
const frontmatter: Array<Record<string, any>> = JSON.parse(readFileSync(frontmatterPath, 'utf-8'));
|
|
31
40
|
const baseHtml = readFileSync(indexHtmlPath, 'utf-8');
|
|
32
41
|
|
|
33
|
-
|
|
34
|
-
const
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
42
|
+
const siteConfig = readDoryConfig() || {};
|
|
43
|
+
const seo = siteConfig.seo || {};
|
|
44
|
+
const baseUrl: string = siteConfig.url || '';
|
|
45
|
+
const siteName: string = siteConfig.name || 'Documentation';
|
|
46
|
+
|
|
47
|
+
if (!baseUrl) {
|
|
48
|
+
console.warn(
|
|
49
|
+
'⚠️ prerender: `url` is not set in dory.json — canonical and og:url tags will be omitted from all pages. Set `url` for proper SEO.'
|
|
50
|
+
);
|
|
38
51
|
}
|
|
39
52
|
|
|
40
|
-
|
|
41
|
-
const
|
|
53
|
+
// Resolve seo.ogImage to an absolute URL when possible
|
|
54
|
+
const rawOgImage: string = seo.ogImage || '';
|
|
55
|
+
const ogImage = rawOgImage && baseUrl && !rawOgImage.startsWith('http')
|
|
56
|
+
? `${baseUrl}${rawOgImage.startsWith('/') ? '' : '/'}${rawOgImage}`
|
|
57
|
+
: rawOgImage;
|
|
58
|
+
const hasImage = Boolean(ogImage || siteConfig.image);
|
|
59
|
+
|
|
60
|
+
// Map route paths to MDX source files for description fallbacks
|
|
61
|
+
const docsDir = resolve(process.cwd(), 'docs');
|
|
62
|
+
const mdxFilesMap = existsSync(docsDir) ? findMdxFilesMap(docsDir, docsDir) : {};
|
|
63
|
+
const fileByRoute: Record<string, string> = {};
|
|
64
|
+
for (const filePath of Object.values(mdxFilesMap)) {
|
|
65
|
+
fileByRoute[pathFromFilename(filePath, docsDir)] = filePath;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const missingDescriptions: string[] = [];
|
|
42
69
|
let count = 0;
|
|
43
70
|
|
|
44
71
|
for (const page of frontmatter) {
|
|
45
72
|
const routePath = page.path;
|
|
46
73
|
if (!routePath || routePath === '/') continue;
|
|
47
74
|
|
|
48
|
-
const title = page.title || siteName;
|
|
49
|
-
const description = page.description ||
|
|
50
|
-
|
|
75
|
+
const title = applyTitleTemplate(page.title || siteName, seo.titleTemplate, siteName);
|
|
76
|
+
const description = page.description || fallbackDescription(fileByRoute[routePath]);
|
|
77
|
+
if (!page.description) {
|
|
78
|
+
missingDescriptions.push(routePath);
|
|
79
|
+
}
|
|
80
|
+
const fullUrl = baseUrl ? `${baseUrl}${routePath}` : '';
|
|
51
81
|
|
|
52
82
|
let html = baseHtml;
|
|
83
|
+
// Function replacements so `$&` etc. in values cannot corrupt output
|
|
84
|
+
const setTag = (re: RegExp, replacement: string) => {
|
|
85
|
+
html = html.replace(re, () => replacement);
|
|
86
|
+
};
|
|
87
|
+
const injectHeadTag = (tag: string) => {
|
|
88
|
+
html = html.replace('</head>', () => ` ${tag}\n </head>`);
|
|
89
|
+
};
|
|
53
90
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
// Replace meta description
|
|
58
|
-
html = html.replace(
|
|
91
|
+
setTag(/<title>.*?<\/title>/, `<title>${escapeHtml(title)}</title>`);
|
|
92
|
+
setTag(
|
|
59
93
|
/<meta name="description" content=".*?" \/>/,
|
|
60
|
-
`<meta name="description" content="${
|
|
94
|
+
`<meta name="description" content="${escapeHtml(description)}" />`
|
|
61
95
|
);
|
|
62
96
|
|
|
63
|
-
//
|
|
64
|
-
|
|
97
|
+
// Open Graph tags
|
|
98
|
+
setTag(
|
|
65
99
|
/<meta property="og:title" content=".*?" \/>/,
|
|
66
|
-
`<meta property="og:title" content="${
|
|
100
|
+
`<meta property="og:title" content="${escapeHtml(title)}" />`
|
|
67
101
|
);
|
|
68
|
-
|
|
102
|
+
setTag(
|
|
69
103
|
/<meta property="og:description" content=".*?" \/>/,
|
|
70
|
-
`<meta property="og:description" content="${
|
|
104
|
+
`<meta property="og:description" content="${escapeHtml(description)}" />`
|
|
71
105
|
);
|
|
72
|
-
|
|
73
|
-
/<meta property="og:
|
|
74
|
-
|
|
106
|
+
setTag(
|
|
107
|
+
/<meta property="og:type" content=".*?" \/>/,
|
|
108
|
+
'<meta property="og:type" content="article" />'
|
|
75
109
|
);
|
|
110
|
+
if (ogImage) {
|
|
111
|
+
setTag(
|
|
112
|
+
/<meta property="og:image" content=".*?" \/>/,
|
|
113
|
+
`<meta property="og:image" content="${escapeHtml(ogImage)}" />`
|
|
114
|
+
);
|
|
115
|
+
setTag(
|
|
116
|
+
/<meta name="twitter:image" content=".*?" \/>/,
|
|
117
|
+
`<meta name="twitter:image" content="${escapeHtml(ogImage)}" />`
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
if (!hasImage) {
|
|
121
|
+
// No shareable image — a large-image card would render broken
|
|
122
|
+
setTag(
|
|
123
|
+
/<meta name="twitter:card" content=".*?" \/>/,
|
|
124
|
+
'<meta name="twitter:card" content="summary" />'
|
|
125
|
+
);
|
|
126
|
+
}
|
|
76
127
|
|
|
77
|
-
//
|
|
78
|
-
|
|
128
|
+
// Twitter tags
|
|
129
|
+
setTag(
|
|
79
130
|
/<meta name="twitter:title" content=".*?" \/>/,
|
|
80
|
-
`<meta name="twitter:title" content="${
|
|
131
|
+
`<meta name="twitter:title" content="${escapeHtml(title)}" />`
|
|
81
132
|
);
|
|
82
|
-
|
|
133
|
+
setTag(
|
|
83
134
|
/<meta name="twitter:description" content=".*?" \/>/,
|
|
84
|
-
`<meta name="twitter:description" content="${
|
|
135
|
+
`<meta name="twitter:description" content="${escapeHtml(description)}" />`
|
|
85
136
|
);
|
|
86
137
|
|
|
87
|
-
//
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
138
|
+
// Canonical + og:url — only when config.url is set. When it is not,
|
|
139
|
+
// the metadata injector already removed these tags from the base HTML.
|
|
140
|
+
if (fullUrl) {
|
|
141
|
+
setTag(
|
|
142
|
+
/<meta property="og:url" content=".*?" \/>/,
|
|
143
|
+
`<meta property="og:url" content="${escapeHtml(fullUrl)}" />`
|
|
144
|
+
);
|
|
145
|
+
setTag(
|
|
146
|
+
/<link rel="canonical" href=".*?" \/>/,
|
|
147
|
+
`<link rel="canonical" href="${escapeHtml(fullUrl)}" />`
|
|
148
|
+
);
|
|
149
|
+
}
|
|
92
150
|
|
|
93
|
-
//
|
|
151
|
+
// Per-page robots directive from frontmatter
|
|
94
152
|
if (page.robots) {
|
|
95
|
-
|
|
96
|
-
'</head>',
|
|
97
|
-
` <meta name="robots" content="${escapeAttr(page.robots)}" />\n </head>`
|
|
98
|
-
);
|
|
153
|
+
injectHeadTag(`<meta name="robots" content="${escapeHtml(String(page.robots))}" />`);
|
|
99
154
|
}
|
|
100
155
|
|
|
101
|
-
//
|
|
156
|
+
// JSON-LD structured data
|
|
157
|
+
const dateModified = toIsoDate(page.lastUpdated);
|
|
102
158
|
const jsonLd = JSON.stringify({
|
|
103
159
|
'@context': 'https://schema.org',
|
|
104
160
|
'@graph': [
|
|
161
|
+
{
|
|
162
|
+
'@type': 'WebSite',
|
|
163
|
+
'name': siteName,
|
|
164
|
+
...(baseUrl ? { '@id': `${baseUrl}/#website`, 'url': baseUrl } : {}),
|
|
165
|
+
},
|
|
105
166
|
{
|
|
106
167
|
'@type': 'Article',
|
|
107
168
|
'headline': title,
|
|
108
169
|
'description': description,
|
|
109
170
|
...(fullUrl ? { 'url': fullUrl } : {}),
|
|
171
|
+
...(dateModified ? { 'dateModified': dateModified } : {}),
|
|
172
|
+
'publisher': { '@type': 'Organization', 'name': siteName },
|
|
173
|
+
'isPartOf': baseUrl
|
|
174
|
+
? { '@id': `${baseUrl}/#website` }
|
|
175
|
+
: { '@type': 'WebSite', 'name': siteName },
|
|
110
176
|
},
|
|
111
177
|
{
|
|
112
178
|
'@type': 'BreadcrumbList',
|
|
@@ -126,12 +192,9 @@ export function prerender(): Plugin {
|
|
|
126
192
|
]
|
|
127
193
|
}
|
|
128
194
|
]
|
|
129
|
-
});
|
|
195
|
+
}).replace(/</g, '\\u003c');
|
|
130
196
|
|
|
131
|
-
|
|
132
|
-
'</head>',
|
|
133
|
-
` <script type="application/ld+json">${jsonLd}</script>\n </head>`
|
|
134
|
-
);
|
|
197
|
+
injectHeadTag(`<script type="application/ld+json">${jsonLd}</script>`);
|
|
135
198
|
|
|
136
199
|
// Write to route directory
|
|
137
200
|
const routeDir = resolve(outDir, routePath.replace(/^\//, ''));
|
|
@@ -140,6 +203,14 @@ export function prerender(): Plugin {
|
|
|
140
203
|
count++;
|
|
141
204
|
}
|
|
142
205
|
|
|
206
|
+
if (missingDescriptions.length > 0) {
|
|
207
|
+
console.warn(
|
|
208
|
+
`⚠️ prerender: ${missingDescriptions.length} page(s) missing a frontmatter description (using page text fallback): ${missingDescriptions.join(', ')}`
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
write404(outDir, baseHtml, siteName, frontmatter);
|
|
213
|
+
|
|
143
214
|
console.log(`✅ Prerendered ${count} routes with page-specific meta tags`);
|
|
144
215
|
} catch (error) {
|
|
145
216
|
console.error('prerender failed:', error);
|
|
@@ -148,10 +219,45 @@ export function prerender(): Plugin {
|
|
|
148
219
|
};
|
|
149
220
|
}
|
|
150
221
|
|
|
151
|
-
|
|
152
|
-
|
|
222
|
+
/** Builds a ~155 char description from the page's stripped MDX text. */
|
|
223
|
+
function fallbackDescription(mdxFilePath: string | undefined): string {
|
|
224
|
+
if (!mdxFilePath || !existsSync(mdxFilePath)) return '';
|
|
225
|
+
const { content } = parseFrontmatter(readFileSync(mdxFilePath, 'utf-8'));
|
|
226
|
+
const text = stripMdxToText(content).replace(/\s+/g, ' ').trim();
|
|
227
|
+
if (text.length <= 155) return text;
|
|
228
|
+
return `${text.slice(0, 155).replace(/\s+\S*$/, '')}…`;
|
|
153
229
|
}
|
|
154
230
|
|
|
155
|
-
|
|
156
|
-
|
|
231
|
+
/** Normalizes a frontmatter lastUpdated value (string or Date) to an ISO date. */
|
|
232
|
+
function toIsoDate(value: unknown): string {
|
|
233
|
+
if (!value) return '';
|
|
234
|
+
const date = new Date(value as string | number | Date);
|
|
235
|
+
if (isNaN(date.getTime())) return '';
|
|
236
|
+
return date.toISOString().split('T')[0];
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** Emits a prerendered 404.html with noindex and a link to the first nav page. */
|
|
240
|
+
function write404(
|
|
241
|
+
outDir: string,
|
|
242
|
+
baseHtml: string,
|
|
243
|
+
siteName: string,
|
|
244
|
+
frontmatter: Array<Record<string, any>>
|
|
245
|
+
): void {
|
|
246
|
+
const firstPage = frontmatter.find(page => page.path && page.path !== '/');
|
|
247
|
+
const firstHref: string = firstPage?.path || '/';
|
|
248
|
+
const firstTitle: string = firstPage?.title || siteName;
|
|
249
|
+
|
|
250
|
+
let html = baseHtml;
|
|
251
|
+
html = html.replace(/<title>.*?<\/title>/, () => `<title>Page not found — ${escapeHtml(siteName)}</title>`);
|
|
252
|
+
html = html.replace('</head>', () => ' <meta name="robots" content="noindex" />\n </head>');
|
|
253
|
+
html = html.replace(
|
|
254
|
+
'<div id="app"></div>',
|
|
255
|
+
() =>
|
|
256
|
+
'<div id="app"><main style="max-width:36rem;margin:6rem auto;padding:0 1.5rem;font-family:sans-serif;text-align:center">' +
|
|
257
|
+
'<h1>Page not found</h1>' +
|
|
258
|
+
'<p>The page you are looking for does not exist.</p>' +
|
|
259
|
+
`<p><a href="${escapeHtml(firstHref)}">Go to ${escapeHtml(firstTitle)}</a></p>` +
|
|
260
|
+
'</main></div>'
|
|
261
|
+
);
|
|
262
|
+
writeFileSync(resolve(outDir, '404.html'), html, 'utf-8');
|
|
157
263
|
}
|
|
@@ -1,34 +1,33 @@
|
|
|
1
1
|
import type { Plugin } from 'vite';
|
|
2
|
-
import {
|
|
3
|
-
import { resolve } from 'path';
|
|
2
|
+
import { readDoryConfig } from './shared';
|
|
4
3
|
|
|
5
4
|
/**
|
|
6
|
-
* Generates robots.txt
|
|
5
|
+
* Generates robots.txt at build time. Emits `Disallow: /` when seo.noindex
|
|
6
|
+
* is set, appends any seo.robotsTxt lines, and references the sitemap when
|
|
7
|
+
* `url` is configured.
|
|
7
8
|
*/
|
|
8
9
|
export function robotsGenerator(): Plugin {
|
|
9
10
|
return {
|
|
10
11
|
name: 'robots-generator',
|
|
11
12
|
generateBundle() {
|
|
12
|
-
const
|
|
13
|
+
const config = readDoryConfig() || {};
|
|
14
|
+
const seo = config.seo || {};
|
|
13
15
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
sitemapLine = `\nSitemap: ${config.url}/sitemap.xml\n`;
|
|
20
|
-
}
|
|
21
|
-
} catch {
|
|
22
|
-
// Non-critical, continue without sitemap reference
|
|
23
|
-
}
|
|
16
|
+
const lines = ['User-agent: *'];
|
|
17
|
+
lines.push(seo.noindex ? 'Disallow: /' : 'Allow: /');
|
|
18
|
+
|
|
19
|
+
if (Array.isArray(seo.robotsTxt) && seo.robotsTxt.length > 0) {
|
|
20
|
+
lines.push('', ...seo.robotsTxt);
|
|
24
21
|
}
|
|
25
22
|
|
|
26
|
-
|
|
23
|
+
if (config.url && !seo.noindex) {
|
|
24
|
+
lines.push('', `Sitemap: ${config.url}/sitemap.xml`);
|
|
25
|
+
}
|
|
27
26
|
|
|
28
27
|
this.emitFile({
|
|
29
28
|
type: 'asset',
|
|
30
29
|
fileName: 'robots.txt',
|
|
31
|
-
source:
|
|
30
|
+
source: lines.join('\n') + '\n'
|
|
32
31
|
});
|
|
33
32
|
|
|
34
33
|
console.log('✅ Generated robots.txt');
|