dommief 0.1.0 → 0.1.2

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.
Files changed (46) hide show
  1. package/README.md +120 -0
  2. package/dist/cli.js +168 -143
  3. package/dist/index.js +5 -1
  4. package/package.json +1 -72
  5. package/src/build.ts +290 -0
  6. package/src/cache.ts +172 -0
  7. package/src/cli.ts +87 -0
  8. package/src/config.ts +258 -0
  9. package/src/exclude.ts +38 -0
  10. package/src/index.ts +17 -0
  11. package/src/link-check.ts +101 -0
  12. package/src/log.ts +10 -0
  13. package/src/markdown/errors.ts +28 -0
  14. package/src/markdown/index.ts +96 -0
  15. package/src/markdown/rehype-api.ts +131 -0
  16. package/src/markdown/rehype-code.ts +147 -0
  17. package/src/markdown/rehype-demo.ts +76 -0
  18. package/src/markdown/rehype-desc.ts +30 -0
  19. package/src/markdown/rehype-img.ts +33 -0
  20. package/src/markdown/rehype-link.ts +50 -0
  21. package/src/markdown/rehype-slug.ts +41 -0
  22. package/src/markdown/rehype-text.ts +53 -0
  23. package/src/markdown/remark-container.ts +76 -0
  24. package/src/markdown/remark-meta.ts +73 -0
  25. package/src/markdown/to-module.ts +35 -0
  26. package/src/paths.ts +38 -0
  27. package/src/plugins.ts +131 -0
  28. package/src/prefers-color.ts +28 -0
  29. package/src/preview.ts +127 -0
  30. package/src/routes.ts +232 -0
  31. package/src/search.ts +88 -0
  32. package/src/siteFeatures.ts +123 -0
  33. package/src/siteModule.ts +47 -0
  34. package/src/sitemap.ts +59 -0
  35. package/src/ssr-guard.ts +72 -0
  36. package/src/theme/index.ts +5 -0
  37. package/src/theme/loader.ts +151 -0
  38. package/src/theme/resolver.ts +115 -0
  39. package/src/theme/types.ts +25 -0
  40. package/src/theme-modules.d.ts +52 -0
  41. package/src/utils/path.ts +8 -0
  42. package/src/vite/plugin.ts +182 -0
  43. package/src/vite/routesPlugin.ts +39 -0
  44. package/src/vite/searchIndexPlugin.ts +66 -0
  45. package/src/vite/sitePlugin.ts +97 -0
  46. package/src/vite/state.ts +27 -0
@@ -0,0 +1,25 @@
1
+ export type ThemeSlotName = string;
2
+ export type ThemeLayoutName = 'GlobalLayout' | 'DocLayout' | 'DemoLayout';
3
+
4
+ /** Một thư mục theme đã quét (DM-501). Không phải `virtual:dommief/theme` của CONTRACTS §4.3 (hoãn: client không cần biết cấu trúc theme, chỉ cần alias đã resolve — xem resolver.ts). */
5
+ export interface ThemeManifest {
6
+ /** Tên package theme (vd 'dommief-theme-default'), chỉ để debug/log. */
7
+ name: string;
8
+ /** Absolute path thư mục theme đã resolve (hoặc thư mục `src` nếu theme nằm trong monorepo nguồn). */
9
+ root: string;
10
+ /** specifier (PascalCase) → absolute path file nguồn. */
11
+ builtins: Record<string, string>;
12
+ /** specifier (PascalCase) → absolute path file nguồn. */
13
+ slots: Record<string, string>;
14
+ /** Chỉ 3 tên hợp lệ; thiếu 1 trong 3 là lỗi (theme không hoàn chỉnh). */
15
+ layouts: Record<ThemeLayoutName, string>;
16
+ /** localeId (vd 'en-US') → absolute path file json. */
17
+ locales: Record<string, string>;
18
+ /** Absolute path tới `plugin.ts`/`plugin.js`, nếu có. */
19
+ plugin: string | null;
20
+ }
21
+
22
+ export interface ThemeLoaderOptions {
23
+ cwd: string;
24
+ theme?: string;
25
+ }
@@ -0,0 +1,52 @@
1
+ declare module 'dommief/theme/layouts/GlobalLayout' {
2
+ const Component: any;
3
+ export default Component;
4
+ }
5
+ declare module 'dommief/theme/layouts/DocLayout' {
6
+ const Component: any;
7
+ export default Component;
8
+ }
9
+ declare module 'dommief/theme/layouts/DemoLayout' {
10
+ const Component: any;
11
+ export default Component;
12
+ }
13
+ declare module 'dommief/theme/slots/NotFound' {
14
+ const Component: any;
15
+ export default Component;
16
+ }
17
+ declare module 'dommief/theme/slots/Navbar' {
18
+ const Component: any;
19
+ export default Component;
20
+ }
21
+ declare module 'dommief/theme/slots/Footer' {
22
+ const Component: any;
23
+ export default Component;
24
+ }
25
+ declare module 'dommief/theme/slots/Sidebar' {
26
+ const Component: any;
27
+ export default Component;
28
+ }
29
+ declare module 'dommief/theme/slots/Toc' {
30
+ const Component: any;
31
+ export default Component;
32
+ }
33
+ declare module 'dommief/theme/slots/Content' {
34
+ const Component: any;
35
+ export default Component;
36
+ }
37
+ declare module 'dommief/theme/slots/ColorSwitch' {
38
+ const Component: any;
39
+ export default Component;
40
+ }
41
+ declare module 'dommief/theme/slots/SearchBox' {
42
+ const Component: any;
43
+ export default Component;
44
+ }
45
+ declare module 'dommief/theme/slots/Api' {
46
+ const Component: any;
47
+ export default Component;
48
+ }
49
+ declare module 'dommief/theme/slots/Demo' {
50
+ const Component: any;
51
+ export default Component;
52
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Chuẩn hoá đường dẫn về dạng POSIX (CONTRACTS §1): mọi `\` thành `/`.
3
+ * Giữ nguyên UNC path của Windows (`\\?\C:\...`) vì đổi sẽ làm path không dùng được.
4
+ */
5
+ export function winPath(p: string): string {
6
+ if (/^\\\\\?\\/.test(p)) return p;
7
+ return p.replace(/\\/g, '/');
8
+ }
@@ -0,0 +1,182 @@
1
+ import type { Plugin } from 'vite';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { loadConfig, type ResolvedConfig } from '../config.js';
5
+ import { createPluginHost, type PluginHookHost } from '../plugins.js';
6
+ import { transformMarkdown } from '../markdown/index.js';
7
+ import { resolveClientFile, resolveThemeApiFile } from '../paths.js';
8
+ import { dommiefRoutesPlugin } from './routesPlugin.js';
9
+ import { dommiefSitePlugin } from './sitePlugin.js';
10
+ import { dommiefSearchIndexPlugin } from './searchIndexPlugin.js';
11
+ import { createState } from './state.js';
12
+ import { winPath } from '../utils/path.js';
13
+ import { escapeXml } from '../sitemap.js';
14
+ import { buildAliasChain } from '../theme/resolver.js';
15
+ import { computePipelineHash, computeRoutesHash, type TransformCache } from '../cache.js';
16
+ // Import từ prefers-color.js (KHÔNG phải theme-api.js): file này chạy trong tiến trình
17
+ // Node của CLI (build-time), không qua bundle SSR của Vite. Import trực tiếp theme-api.tsx
18
+ // (có JSX + hook 'react') từng nạp 'react' vào tiến trình CLI qua đường khác với bản mà
19
+ // server.mjs dùng để renderToPipeableStream → 2 dispatcher react khác nhau →
20
+ // `TypeError: dispatcher.getOwner is not a function` (đã bắt trực tiếp, không phải suy đoán).
21
+ import { PREFERS_COLOR_ATTR, PREFERS_COLOR_BOOT_SCRIPT, resolveConfiguredColor, type PrefersColorValue } from '../prefers-color.js';
22
+
23
+ const demoRegistry = new Map<string, string>();
24
+ // Thư mục file .md chứa demo inline: import tương đối trong demo resolve theo đây.
25
+ const demoDirs = new Map<string, string>();
26
+
27
+ export interface DommiefPluginOptions {
28
+ root: string;
29
+ /** Đã resolve sẵn (CLI): dùng lại, không đọc config lần nữa. */
30
+ config?: ResolvedConfig;
31
+ /** Plugin host dùng chung với CLI (để `onBuildEnd` chạy trên đúng host đã đăng ký hook). */
32
+ host?: PluginHookHost;
33
+ /** Chỉ `dommief build` truyền vào (DM-406); dev chạy trong bộ nhớ Vite nên không cache. */
34
+ cache?: TransformCache;
35
+ /** DM-801: thu `PageMeta.texts` ngay trong lượt transform đã có (cả cache hit lẫn miss —
36
+ * payload cache luôn chứa `meta.texts`), KHÔNG transform lại file .md ở bước search index
37
+ * riêng — nếu không phải tự dựng lại `routesHash`/`pipelineHash` khớp hệt để trúng cache
38
+ * (sai một chi tiết = miss toàn bộ, im lặng) và thổi gấp đôi số cache stats hiển thị cho
39
+ * người dùng. Chỉ `dommief build` truyền vào. */
40
+ collectTexts?: (file: string, texts: import('../markdown/index.js').TextFragment[]) => void;
41
+ }
42
+
43
+ // Plugin tổng hợp: transform md + ?meta + entry ảo, và uỷ thác virtual:dommief/routes
44
+ // + virtual:dommief/site cho hai plugin riêng để invalidate độc lập (DM-206/209).
45
+ export function dommiefPlugin(opts: DommiefPluginOptions) {
46
+ let loaded: { config: ResolvedConfig; host: PluginHookHost } | undefined =
47
+ opts.config ? { config: opts.config, host: opts.host ?? createPluginHost(opts.config.plugins ?? []) } : undefined;
48
+ const state = createState(opts.root, () => {
49
+ if (!loaded) throw new Error('dommief: config chưa được nạp');
50
+ return loaded;
51
+ });
52
+
53
+ const markdownPlugin: Plugin = {
54
+ name: 'dommief:markdown',
55
+ enforce: 'pre',
56
+ async config() {
57
+ if (!loaded) {
58
+ const config = await loadConfig(opts.root);
59
+ loaded = { config, host: createPluginHost(config.plugins ?? []) };
60
+ }
61
+ const aliases = buildAliasChain(opts.root, loaded.config);
62
+ // `package.json` export `./theme-api` trỏ `dist/theme-api.js`; theme (vd theme-default) import
63
+ // `dommief/theme-api` phải trúng file NGUỒN mà entry-client/entry-server import (resolveClientFile
64
+ // luôn chọn source), nếu không sẽ ra 2 module khác nhau → `SiteContext` khác nhau →
65
+ // `useSiteData()`/`useNavData()` trong theme luôn rỗng (xem paths.ts).
66
+ aliases['dommief/theme-api'] = resolveThemeApiFile();
67
+ return {
68
+ base: loaded.config.base,
69
+ resolve: { dedupe: ['react', 'react-dom', 'react-router'], alias: aliases },
70
+ ssr: { noExternal: ['dommief'] },
71
+ };
72
+ },
73
+ resolveId(id, importer) {
74
+ if (id === 'dommief/client') return resolveClientFile('index.tsx');
75
+ const bare = id.replace(/^\//, '');
76
+ if (bare === 'virtual:dommief/entry') return '\0virtual:dommief/entry';
77
+ if (bare.startsWith('virtual:dommief-demo/')) return '\0' + bare;
78
+ if (importer?.startsWith('\0virtual:dommief-demo/') && id.startsWith('.')) {
79
+ const dir = demoDirs.get(importer.slice('\0virtual:dommief-demo/'.length));
80
+ if (dir) return this.resolve(path.resolve(dir, id), path.join(dir, '_demo.tsx'), { skipSelf: true });
81
+ }
82
+ if (bare === 'virtual:dommief/routes' || bare === 'virtual:dommief/site') return null;
83
+ if (id.endsWith('.md') || id.endsWith('.md?meta')) {
84
+ const base = importer ? path.dirname(importer.replace(/\?meta$/, '')) : process.cwd();
85
+ const q = id.endsWith('?meta') ? '?meta' : '';
86
+ const clean = id.replace(/\?meta$/, '');
87
+ // Id = absolute POSIX path (+ `?meta`), KHÔNG `\0`: nhờ vậy `ModuleNode.file` là file md
88
+ // thật và Vite tự invalidate module trang khi file đổi (DM-209 ca (d)).
89
+ return winPath(path.isAbsolute(clean) ? clean : path.resolve(base, clean)) + q;
90
+ }
91
+ return null;
92
+ },
93
+ async load(id) {
94
+ if (id.startsWith('\0virtual:dommief-demo/')) {
95
+ const demoId = id.slice('\0virtual:dommief-demo/'.length);
96
+ const source = demoRegistry.get(demoId) ?? 'export default function Demo(){ return null; }';
97
+ return { code: source, map: null, moduleType: 'jsx' };
98
+ }
99
+ if (id === '\0virtual:dommief/entry') {
100
+ return { code: `import ${JSON.stringify(resolveClientFile('entry-client.tsx'))};`, map: null, moduleType: 'js' };
101
+ }
102
+ if (id.endsWith('.md') || id.endsWith('.md?meta')) {
103
+ const isMeta = id.endsWith('?meta');
104
+ const file = isMeta ? id.slice(0, -'?meta'.length) : id;
105
+ const code = await fs.promises.readFile(file, 'utf8');
106
+ const transformOpts = { hooks: state.host(), byFile: state.getTable().byFile, base: state.config().base, code: state.config().markdown };
107
+ let out: string; let meta: Awaited<ReturnType<typeof transformMarkdown>>['meta']; let warnings: string[]; let apiSrcFiles: string[] = [];
108
+ if (opts.cache) {
109
+ const host = state.host();
110
+ const input = {
111
+ md: code, file, base: transformOpts.base,
112
+ routesHash: computeRoutesHash(transformOpts.byFile),
113
+ pipelineHash: computePipelineHash({ code: transformOpts.code, plugins: [...host.collectRemarkPlugins(), ...host.collectRehypePlugins()] }),
114
+ };
115
+ // `<API src>` đọc file nguồn khác .md: nội dung phụ thuộc file đó, không nằm trong key => không cache trang này.
116
+ const cacheable = !/<API[\s/>]/.test(code);
117
+ const hit = await opts.cache.getOrCompute(file, input, async () => {
118
+ const r = await transformMarkdown(code, file, transformOpts);
119
+ return { code: r.code, meta: r.meta, warnings: r.warnings, demos: r.demos };
120
+ }, cacheable);
121
+ let hitDemos: { id: string; source: string; src?: string }[] = [];
122
+ ({ code: out, meta, warnings } = hit);
123
+ hitDemos = hit.demos ?? [];
124
+ for (const d of hitDemos) { demoRegistry.set(d.id, d.source); demoDirs.set(d.id, path.dirname(file)); if (d.src) this.addWatchFile(path.resolve(path.dirname(file), d.src)); }
125
+ } else {
126
+ const r = await transformMarkdown(code, file, transformOpts);
127
+ ({ code: out, meta, warnings, apiSrcFiles } = r);
128
+ const nonCacheDemos = (r as any).demos ?? [];
129
+ for (const d of nonCacheDemos as any) { demoRegistry.set(d.id, d.source); demoDirs.set(d.id, path.dirname(file)); if (d.src) this.addWatchFile(path.resolve(path.dirname(file), d.src)); }
130
+ }
131
+ for (const w of warnings) this.warn(w);
132
+ for (const apiFile of apiSrcFiles) this.addWatchFile(apiFile); // <API src> đọc file khác .md (#17)
133
+ if (!isMeta) opts.collectTexts?.(file, meta.texts); // DM-801: thu 1 lần/file, không tính với nhánh `?meta`
134
+ if (isMeta) {
135
+ // ?meta: import công khai cho code người dùng (vd `import m from './x.md?meta'`).
136
+ // KHÔNG còn ai trong core dùng đường này để dựng site module — xem `extractPageSummary` (#12).
137
+ // Trả frontmatter + toc (nhỏ) — không texts (nặng, chỉ để tìm kiếm).
138
+ return { code: `export default ${JSON.stringify({ frontmatter: meta.frontmatter, toc: meta.toc })};`, map: null, moduleType: 'js' };
139
+ }
140
+ return { code: out, map: null, moduleType: 'jsx' };
141
+ }
142
+ return null;
143
+ },
144
+ // order 'pre': script chèn ở đây phải đi qua bước bundle HTML của Vite, nếu không build không có JS.
145
+ transformIndexHtml: { order: 'pre', handler(html) {
146
+ const title = state.config().title;
147
+ let out = html;
148
+ if (!/<title>/.test(out)) out = out.replace('<head>', `<head><title>${escapeXml(title)}</title>`);
149
+ const tags = state.host().collectHtmlHeadTags();
150
+ if (tags.length) out = out.replace('</head>', tags.join('\n') + '</head>');
151
+ // THEME-API §9.2 + Amendment #1: inline script < 1KB, KHÔNG `async`/`defer`, chạy trước
152
+ // paint để không nháy sáng rồi sang tối. Nếu `themeConfig.prefersColor.default` khác
153
+ // 'auto', SSR ghi thẳng `data-prefers-color` vào `<html>` để trang đúng màu ngay cả khi
154
+ // tắt JS (gate Phase 4) — inline script chỉ GHI ĐÈ khi localStorage có lựa chọn khác.
155
+ const themeConfig = state.config().themeConfig as { prefersColor?: { default?: PrefersColorValue } } | undefined;
156
+ const configuredColor = resolveConfiguredColor(themeConfig?.prefersColor?.default);
157
+ if (configuredColor && !/data-prefers-color=/.test(out)) {
158
+ out = out.replace(/<html(\s|>)/, `<html ${PREFERS_COLOR_ATTR}="${configuredColor}"$1`);
159
+ }
160
+ if (!out.includes(PREFERS_COLOR_BOOT_SCRIPT)) {
161
+ out = out.replace('</head>', `<script>${PREFERS_COLOR_BOOT_SCRIPT}</script></head>`);
162
+ }
163
+ if (!out.includes('virtual:dommief/entry')) {
164
+ out = out.replace('</body>', `<script type="module" src="/virtual:dommief/entry"></script></body>`);
165
+ }
166
+ return out;
167
+ } },
168
+ configureServer(server) {
169
+ // DM-502: thêm/xoá file trong `.dommief/theme/**` phải đăng ký lại alias KHÔNG restart server.
170
+ // Alias cũ đã bị các module khác import tĩnh ⇒ không cách nào HMR theo chuẩn, phải full-reload.
171
+ const localThemeDir = winPath(path.resolve(opts.root, '.dommief', 'theme')) + '/';
172
+ const update = () => {
173
+ const aliases = buildAliasChain(opts.root, loaded!.config);
174
+ server.config.resolve.alias = { ...(server.config.resolve.alias as any), ...aliases };
175
+ server.ws.send({ type: 'full-reload' });
176
+ };
177
+ server.watcher.on('add', (f) => { if (winPath(f).startsWith(localThemeDir)) update(); });
178
+ server.watcher.on('unlink', (f) => { if (winPath(f).startsWith(localThemeDir)) update(); });
179
+ },
180
+ };
181
+ return [markdownPlugin, dommiefRoutesPlugin(state), dommiefSitePlugin(state), dommiefSearchIndexPlugin(state)] as Plugin[];
182
+ }
@@ -0,0 +1,39 @@
1
+ import type { Plugin, ViteDevServer } from 'vite';
2
+ import { createRoutesModule } from '../routesModule.js';
3
+ import type { DommiefState } from './state.js';
4
+
5
+ // Đặc tả: backlog DM-206. Plugin riêng `dommief:routes` để DM-303 invalidate độc lập.
6
+ // Invalidate chỉ khi topology đổi (md thêm/xoá/đổi tên). Sửa nội dung md (kể cả frontmatter) KHÔNG invalidate.
7
+
8
+ export function dommiefRoutesPlugin(state: DommiefState): Plugin {
9
+ const virtualId = 'virtual:dommief/routes';
10
+ const resolvedId = '\0' + virtualId;
11
+ let server: ViteDevServer | null = null;
12
+
13
+ const invalidate = () => {
14
+ state.resetTable();
15
+ const mod = server?.moduleGraph.getModuleById(resolvedId);
16
+ if (mod) server!.moduleGraph.invalidateModule(mod);
17
+ };
18
+
19
+ return {
20
+ name: 'dommief:routes',
21
+ enforce: 'pre',
22
+ configureServer(s) { server = s; },
23
+ resolveId(id) {
24
+ if (id === virtualId) return resolvedId;
25
+ return null;
26
+ },
27
+ load(id) {
28
+ if (id !== resolvedId) return null;
29
+ return { code: createRoutesModule(state.getTable().routes), map: null };
30
+ },
31
+ watchChange(id, { event }) {
32
+ if (!id.endsWith('.md')) return;
33
+ if (event === 'create' || event === 'delete') {
34
+ invalidate();
35
+ server?.ws.send({ type: 'full-reload' });
36
+ }
37
+ },
38
+ };
39
+ }
@@ -0,0 +1,66 @@
1
+ import type { Plugin } from 'vite';
2
+ import fs from 'node:fs';
3
+ import type { DommiefState } from './state.js';
4
+
5
+ // DM-801: `virtual:dommief/search-index` trả CHỈ MANIFEST (URL theo locale), KHÔNG trả dữ liệu
6
+ // index inline — tránh gửi hàng MB index vào bundle client (tiêu chí #2).
7
+ // - Dev: middleware phục vụ `*/search-index/<locale>.json` bằng buildSearchIndex on-the-fly (không cache).
8
+ // - Build: search emitter trong build.ts ghi file thật vào dist/search-index/; dev không cần ghi file.
9
+
10
+ const VIRTUAL_ID = 'virtual:dommief/search-index';
11
+ const RESOLVED_ID = '\0' + VIRTUAL_ID;
12
+ const PREFIX = '/search-index/';
13
+
14
+ export function dommiefSearchIndexPlugin(state: DommiefState): Plugin {
15
+ return {
16
+ name: 'dommief:search-index',
17
+ enforce: 'pre',
18
+ resolveId(id) {
19
+ if (id === VIRTUAL_ID) return RESOLVED_ID;
20
+ return null;
21
+ },
22
+ load(id) {
23
+ if (id !== RESOLVED_ID) return null;
24
+ const config = state.config();
25
+ if (!config.search.enabled) return { code: 'export const manifest = { locales: {} }; export default manifest;\n', map: null };
26
+ const locales = Object.fromEntries((config.locales ?? []).map((l) => [l.id, `${config.base}search-index/${l.id}.json`]));
27
+ return { code: `export const manifest = ${JSON.stringify({ locales })}; export default manifest;\n`, map: null };
28
+ },
29
+ configureServer(s) {
30
+ s.middlewares.use(async (req, res, next) => {
31
+ const url = req.url?.split('?')[0] ?? '';
32
+ if (!url.endsWith('.json')) return next();
33
+ const base = (state.config().base || '/').replace(/\/$/, '') || '';
34
+ const localPath = base && url.startsWith(base + PREFIX) ? url.slice((base + PREFIX).length) : (url.startsWith(PREFIX) ? url.slice(PREFIX.length) : null);
35
+ if (localPath === null) return next();
36
+ const localeId = localPath.replace(/\.json$/, '');
37
+ if (!state.config().locales?.some((l) => l.id === localeId)) return next();
38
+ if (!state.config().search.enabled) { res.statusCode = 404; return res.end(); }
39
+ try {
40
+ const { buildSearchIndex } = await import('../search.js');
41
+ const { transformMarkdown } = await import('../markdown/index.js');
42
+ const t = state.getTable();
43
+ const routes = t.routes.filter((r) => (r.locale ?? state.config().locales[0]?.id) === localeId);
44
+ // Dev không dùng cache; đọc trực tiếp file .md để có texts (extractPageSummary trong sitePlugin
45
+ // chỉ lấy frontmatter+toc để dựng site module nhẹ, KHÔNG có texts — texts chỉ nằm trong
46
+ // transformMarkdown/PageMeta).
47
+ const resIdx = await buildSearchIndex({
48
+ routes,
49
+ loadPageMeta: async (r) => {
50
+ let raw: string;
51
+ try { raw = fs.readFileSync(r.file, 'utf8'); } catch { raw = ''; }
52
+ const tr = await transformMarkdown(raw, r.file, { hooks: state.host(), byFile: t.byFile, base: state.config().base, code: state.config().markdown });
53
+ return tr.meta;
54
+ },
55
+ config: { locales: state.config().locales },
56
+ });
57
+ res.setHeader('Content-Type', 'application/json');
58
+ res.end(JSON.stringify(resIdx.locales[localeId] ?? {}));
59
+ } catch (e: unknown) {
60
+ res.statusCode = 500;
61
+ res.end(JSON.stringify({ error: e instanceof Error ? e.message : 'search index build failed' }));
62
+ }
63
+ });
64
+ },
65
+ };
66
+ }
@@ -0,0 +1,97 @@
1
+ import type { Plugin, ViteDevServer } from 'vite';
2
+ import { createHash } from 'node:crypto';
3
+ import { readFileSync } from 'node:fs';
4
+ import { createSiteModule, type SiteModuleMeta } from '../siteModule.js';
5
+ import { extractPageSummary } from '../markdown/remark-meta.js';
6
+ import type { DommiefState } from './state.js';
7
+
8
+ // Đặc tả: backlog DM-209. Plugin riêng `dommief:site` giữ `virtual:dommief/site` nhẹ.
9
+ // Invalidate chỉ khi frontmatter của md đổi, md thêm/xoá/đổi tên, hoặc config đổi.
10
+ // Sửa thân bài KHÔNG invalidate.
11
+
12
+ function frontmatterHash(file: string): string {
13
+ let raw: string;
14
+ try { raw = readFileSync(file, 'utf8'); } catch { return ''; }
15
+ const m = raw.match(/^---\r?\n([\s\S]*?)\r?\n---/);
16
+ const block = m?.[1] ?? '';
17
+ // heading cũng vào hash: `toc` nằm trong site module nên đổi tiêu đề phải invalidate nó.
18
+ const headings = raw.match(/^#{1,6}\s.*$/gm)?.join('\n') ?? '';
19
+ return createHash('sha256').update(block + '\n--\n' + headings).digest('hex').slice(0, 16);
20
+ }
21
+
22
+ export function dommiefSitePlugin(state: DommiefState): Plugin {
23
+ const virtualId = 'virtual:dommief/site';
24
+ const resolvedId = '\0' + virtualId;
25
+
26
+ let frontmatterHashes = new Map<string, string>();
27
+ let cache: string | null = null;
28
+ let server: ViteDevServer | null = null;
29
+
30
+ const invalidate = () => {
31
+ cache = null;
32
+ state.resetTable();
33
+ const mod = server?.moduleGraph.getModuleById(resolvedId);
34
+ if (mod) server!.moduleGraph.invalidateModule(mod);
35
+ };
36
+
37
+ return {
38
+ name: 'dommief:site',
39
+ enforce: 'pre',
40
+ configureServer(s) { server = s; },
41
+ resolveId(id) {
42
+ if (id === virtualId) return resolvedId;
43
+ return null;
44
+ },
45
+ load(this: any, id) {
46
+ if (id !== resolvedId) return null;
47
+ if (cache) return { code: cache, map: null };
48
+ const config = state.config();
49
+ const t = state.getTable();
50
+ // Một lần đọc file cho cả frontmatter lẫn toc (#12): không còn import `.md?meta` riêng
51
+ // cho từng route, nên cả nav/sidebar lẫn `site.meta` đều ra từ cùng một parser.
52
+ const summaries = new Map(t.routes.map((r) => {
53
+ let raw: string;
54
+ try { raw = readFileSync(r.file, 'utf8'); } catch { raw = ''; }
55
+ return [r.id, extractPageSummary(raw, r.file, (m) => this.warn(m))] as const;
56
+ }));
57
+ const frontmatter = Object.fromEntries([...summaries].map(([id, s]) => [id, s.frontmatter]));
58
+ const meta: SiteModuleMeta = Object.fromEntries([...summaries].map(([id, s]) => [id, s]));
59
+ const code = createSiteModule({
60
+ routes: t.routes,
61
+ config: {
62
+ base: config.base,
63
+ title: config.title,
64
+ description: config.description,
65
+ locales: config.locales,
66
+ themeConfig: config.themeConfig,
67
+ frontmatter,
68
+ search: { enabled: config.search.enabled },
69
+ },
70
+ meta,
71
+ });
72
+ // ghi nhớ hash frontmatter hiện tại cho từng file
73
+ frontmatterHashes = new Map(t.routes.map((r) => [r.file, frontmatterHash(r.file)]));
74
+ cache = code;
75
+ return { code, map: null };
76
+ },
77
+ handleHotUpdate(ctx) {
78
+ const id = ctx.file;
79
+ if (!id.endsWith('.md')) return;
80
+ const oldHash = frontmatterHashes.get(id);
81
+ const newHash = frontmatterHash(id);
82
+ // Chưa có hash cũ (site module chưa từng load) ⇒ cache cũng chưa có, không cần invalidate.
83
+ if (oldHash !== undefined && oldHash !== newHash) {
84
+ frontmatterHashes.set(id, newHash);
85
+ invalidate();
86
+ }
87
+ // trả undefined → Vite dùng hành vi mặc định (module trang vẫn HMR)
88
+ },
89
+ watchChange(id, { event }) {
90
+ if (!id.endsWith('.md')) return;
91
+ if (event === 'create' || event === 'delete') {
92
+ invalidate();
93
+ server?.ws.send({ type: 'full-reload' });
94
+ }
95
+ },
96
+ };
97
+ }
@@ -0,0 +1,27 @@
1
+ import { buildRouteTable } from '../routes.js';
2
+ import type { ResolvedConfig } from '../config.js';
3
+ import type { PluginHookHost } from '../plugins.js';
4
+ import type { RouteTable } from '../types.js';
5
+
6
+ /**
7
+ * Trạng thái dùng chung của ba plugin Vite (markdown/routes/site): MỘT config, MỘT plugin host,
8
+ * MỘT route table. Trước đây mỗi plugin tự quét và tự gọi `modifyRoutes`, nên hai bảng có thể lệch nhau.
9
+ */
10
+ export interface DommiefState {
11
+ cwd: string;
12
+ config(): ResolvedConfig;
13
+ host(): PluginHookHost;
14
+ getTable(): RouteTable;
15
+ resetTable(): void;
16
+ }
17
+
18
+ export function createState(cwd: string, get: () => { config: ResolvedConfig; host: PluginHookHost }): DommiefState {
19
+ let table: RouteTable | null = null;
20
+ return {
21
+ cwd,
22
+ config: () => get().config,
23
+ host: () => get().host,
24
+ getTable: () => (table ??= buildRouteTable({ cwd, config: get().config, hooks: get().host })),
25
+ resetTable: () => { table = null; },
26
+ };
27
+ }