-
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathraw-md.plugin.ts
More file actions
100 lines (95 loc) · 3.31 KB
/
Copy pathraw-md.plugin.ts
File metadata and controls
100 lines (95 loc) · 3.31 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
import {readdirSync, readFileSync, statSync} from 'node:fs';
import {extname, join, relative} from 'node:path';
import type {Plugin} from 'vite';
import {substituteMdVars} from './vars.plugin';
/**
* Serves the raw markdown body at the same URL plus a `.md` suffix.
*
* /concepts/theming -> rendered prose page
* /concepts/theming.md -> the literal `.md` source as `text/markdown`
*
* Powers the "Copy Markdown" / "Open in ChatGPT" / "Open in Claude"
* dropdown so LLMs can fetch a page by URL and get clean markdown back
* instead of compiled HTML. Pattern is the same one react.dev and the
* PrimeNG docs site ship for their LLM-friendly pages.
*
* Dev mode: middleware reads from `src/content` on each request.
* Build mode: emits each `.md` body as a static asset at the matching
* route + `.md` so the same paths work after `vite build`.
*/
export function rawMdPlugin(): Plugin {
let root = process.cwd();
function resolveMd(rawPath: string): string | null {
if (!rawPath.endsWith('.md')) return null;
const route = rawPath.slice(0, -3).replace(/^\//, '');
if (!route) return null;
if (route.includes('..')) return null;
const abs = join(root, 'src/content', `${route}.md`);
try {
const s = statSync(abs);
if (!s.isFile()) return null;
} catch {
return null;
}
try {
return substituteMdVars(readFileSync(abs, 'utf8'), root);
} catch {
return null;
}
}
return {
name: 'ngmd-raw-md',
configResolved(cfg) {
root = cfg.root;
},
configureServer(server) {
server.middlewares.use((req, res, next) => {
const raw = req.url?.split('?')[0] ?? '';
if (extname(raw) !== '.md') return next();
// URL paths arrive percent-encoded (`/concepts/some%20page.md`),
// but the on-disk filename is `some page.md`. Decode before
// resolving so the lookup matches. Malformed sequences fall
// through to the next middleware.
let url: string;
try {
url = decodeURIComponent(raw);
} catch {
return next();
}
const body = resolveMd(url);
if (body == null) return next();
res.setHeader('Content-Type', 'text/markdown; charset=utf-8');
res.setHeader('Cache-Control', 'public, max-age=0, must-revalidate');
res.end(body);
});
},
generateBundle() {
// Emit one `<route>.md` asset per markdown source so the same URL
// works in production. Mirrors the dev middleware.
const contentDir = join(root, 'src/content');
const walk = (dir: string): string[] => {
const out: string[] = [];
for (const entry of readdirSync(dir, {withFileTypes: true})) {
const full = join(dir, entry.name);
if (entry.isDirectory()) out.push(...walk(full));
else if (entry.isFile() && entry.name.endsWith('.md')) out.push(full);
}
return out;
};
try {
statSync(contentDir);
} catch {
return;
}
for (const file of walk(contentDir)) {
const route = relative(contentDir, file).replace(/\\/g, '/');
const body = substituteMdVars(readFileSync(file, 'utf8'), root);
this.emitFile({
type: 'asset',
fileName: route,
source: body,
});
}
},
};
}