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,73 @@
1
+ import yaml from 'yaml';
2
+ import { unified } from 'unified';
3
+ import remarkParse from 'remark-parse';
4
+ import remarkFrontmatter from 'remark-frontmatter';
5
+ import Slugger from 'github-slugger';
6
+ import { visit } from 'unist-util-visit';
7
+ import type { TocItem } from './index.js';
8
+
9
+ export interface RemarkMetaOptions { filename?: string; }
10
+
11
+ function textOf(node: any): string {
12
+ let out = '';
13
+ if (node.type === 'text') out += node.value || '';
14
+ if (node.children) for (const c of node.children) out += textOf(c);
15
+ return out;
16
+ }
17
+
18
+ /** Parser frontmatter dùng chung (yaml thật, không phải regex tự chế — tránh #12). */
19
+ export function parseFrontmatterYaml(raw: string, opts: { file?: string; onWarn?: (msg: string) => void } = {}): Record<string, unknown> {
20
+ const m = raw.match(/^---\r?\n([\s\S]*?)\r?\n---/);
21
+ if (!m || m[1] === undefined) return {};
22
+ try {
23
+ return (yaml.parse(m[1]) as Record<string, unknown>) ?? {};
24
+ } catch (e) {
25
+ opts.onWarn?.(`frontmatter_invalid_yaml: ${opts.file ?? ''}: ${(e as Error).message}`);
26
+ return {};
27
+ }
28
+ }
29
+
30
+ export function remarkMeta(options: RemarkMetaOptions = {}) {
31
+ return (tree: any, file: any) => {
32
+ const filename = options.filename ?? file?.path ?? file?.history?.[0] ?? '';
33
+ file.data = file.data || {};
34
+ // frontmatter: cùng một parser với `parseFrontmatterYaml` (#12) — không tự gọi `yaml.parse` lần thứ hai ở đây.
35
+ let frontmatter: Record<string, unknown> = {};
36
+ if (tree.children?.[0]?.type === 'yaml') {
37
+ frontmatter = parseFrontmatterYaml('---\n' + tree.children[0].value + '\n---', { file: filename });
38
+ tree.children.shift();
39
+ }
40
+ // title fallback from first h1
41
+ if (frontmatter.title == null) {
42
+ let firstH1: string | null = null;
43
+ visit(tree, 'heading', (node: any) => {
44
+ if (node.depth === 1 && firstH1 === null) firstH1 = textOf(node).trim();
45
+ });
46
+ if (firstH1) frontmatter.title = firstH1;
47
+ }
48
+ file.data.frontmatter = frontmatter;
49
+ file.data.filename = filename;
50
+ };
51
+ }
52
+
53
+ /**
54
+ * `frontmatter` + `toc` NHẸ cho `virtual:dommief/site` (#12): chỉ `remark-parse`, không chạy cả
55
+ * pipeline rehype (docgen `<API>`, demo, slug...). Dùng cùng logic title-fallback và cùng
56
+ * `parseFrontmatterYaml` với `remarkMeta`/`transformMarkdown` — một nguồn sự thật cho "title của trang là gì".
57
+ */
58
+ export function extractPageSummary(raw: string, file: string, onWarn?: (msg: string) => void): { frontmatter: Record<string, unknown>; toc: TocItem[] } {
59
+ const frontmatter = parseFrontmatterYaml(raw, onWarn ? { file, onWarn } : { file });
60
+ const tree = unified().use(remarkParse).use(remarkFrontmatter, ['yaml']).parse(raw);
61
+ const toc: TocItem[] = [];
62
+ const slugger = new Slugger();
63
+ let firstH1: string | null = null;
64
+ visit(tree, 'heading', (node: any) => {
65
+ const title = textOf(node).trim();
66
+ if (!title) return;
67
+ const id = slugger.slug(title);
68
+ toc.push({ id, depth: node.depth, title });
69
+ if (node.depth === 1 && firstH1 === null) firstH1 = title;
70
+ });
71
+ if (frontmatter.title == null && firstH1) frontmatter.title = firstH1;
72
+ return { frontmatter, toc };
73
+ }
@@ -0,0 +1,35 @@
1
+ import { toEstree } from 'hast-util-to-estree';
2
+ import { toJs, jsx } from 'estree-util-to-js';
3
+
4
+ /** Specifier mà plugin `dommief:markdown` resolve về `client/index.tsx` của package. */
5
+ export const CLIENT_MODULE = 'dommief/client';
6
+
7
+ export interface ModuleImage { name: string; specifier: string }
8
+
9
+ export function toModule(hast: any, meta: { frontmatter: unknown; toc: unknown; texts?: unknown }, images: ModuleImage[] = [], demos: { id: string; source: string; src?: string }[] = []): string {
10
+ // `texts` chỉ dùng để build search index lúc build (buildSearchIndex nhận trực tiếp PageMeta từ
11
+ // transformMarkdown, không đọc export này) — bỏ khỏi bundle gửi cho trình duyệt (#20).
12
+ const { texts: _texts, ...clientMeta } = meta;
13
+ const estree = toEstree(hast, { elementAttributeNameCase: 'react' }) as any;
14
+ const { value: code } = toJs(estree, { handlers: jsx });
15
+ let body = code.replace(/;\s*$/, '').trim();
16
+ const imageImports = images.map((im, i) => {
17
+ body = body.split(`src="${im.name}"`).join(`src={__dmImg${i}}`);
18
+ return `import __dmImg${i} from ${JSON.stringify(im.specifier)};\n`;
19
+ }).join('');
20
+ const used = ['DomDemo', 'DumiDemo', 'DomApi', 'DumiApi'].filter((n) => new RegExp(`<${n}[\\s/>]`).test(body));
21
+ const clientNames = [...used];
22
+ if (demos.length) clientNames.push('registerDemo');
23
+ const clientImport = clientNames.length ? `import { ${Array.from(new Set(clientNames)).join(', ')} } from '${CLIENT_MODULE}';\n` : '';
24
+ const demoImports = demos.map((d) => {
25
+ const specifier = d.src ? d.src : `virtual:dommief-demo/${d.id}`;
26
+ return `import Demo_${d.id} from ${JSON.stringify(specifier)};\n`;
27
+ }).join('');
28
+ const demoRegs = demos.map((d) => `registerDemo(${JSON.stringify(d.id)}, Demo_${d.id});\n`).join('');
29
+ return `import React from 'react';
30
+ ${imageImports}${clientImport}${demoImports}${demoRegs}export default function Content(){ return (
31
+ ${body}
32
+ ); }
33
+ export const meta = ${JSON.stringify(clientMeta)};
34
+ `;
35
+ }
package/src/paths.ts ADDED
@@ -0,0 +1,38 @@
1
+ import fs from 'node:fs';
2
+ import { fileURLToPath } from 'node:url';
3
+ import { DommiefError } from './markdown/errors.js';
4
+
5
+ /**
6
+ * Tìm file trong `client/` của chính package này. Từ source (`src/*.ts`) file nằm ở `./client/`;
7
+ * từ bản build (`dist/*.js`, tsup gộp mọi thứ vào một cấp) nằm ở `../src/client/`.
8
+ * Ném lỗi có tên nếu cả hai đều không có — thay vì để Vite báo UNRESOLVED_ENTRY khó hiểu.
9
+ */
10
+ export function resolveClientFile(name: string): string {
11
+ const candidates = [`./client/${name}`, `../src/client/${name}`].map((rel) => fileURLToPath(new URL(rel, import.meta.url)));
12
+ const hit = candidates.find((p) => fs.existsSync(p));
13
+ if (!hit) {
14
+ throw new DommiefError(`không tìm thấy client/${name} của dommief (đã thử: ${candidates.join(', ')}). Package cài thiếu thư mục "src".`, { code: 'client_entry_missing' });
15
+ }
16
+ return hit;
17
+ }
18
+
19
+ /**
20
+ * Absolute path tới `theme-api.tsx` NGUỒN (không phải `dist/theme-api.js`).
21
+ *
22
+ * Quan trọng: `package.json` export `./theme-api` trỏ `dist/theme-api.js`, nhưng `entry-client`/
23
+ * `entry-server` (bản thân `dommief/client`, luôn là SOURCE) import `theme-api` qua đường dẫn
24
+ * tương đối `../theme-api.js` — tức là SOURCE. Nếu theme-default import `dommief/theme-api`
25
+ * (qua alias package) mà Vite resolve ra `dist/theme-api.js`, đó là MỘT module KHÁC với
26
+ * `SiteContext`/`createContext` riêng — `SiteProvider` (trong entry) set context nguồn, còn
27
+ * `useSiteData()` (trong theme, qua dist) đọc context đích khác, luôn `null`. Hậu quả quan
28
+ * sát được: `Navbar`/`Sidebar` render rỗng dù `virtual:dommief/site` có đầy đủ dữ liệu.
29
+ * Vite plugin phải alias `dommief/theme-api` → đúng file này để luôn chỉ có MỘT instance.
30
+ */
31
+ export function resolveThemeApiFile(): string {
32
+ const candidates = ['./theme-api.tsx', '../src/theme-api.tsx'].map((rel) => fileURLToPath(new URL(rel, import.meta.url)));
33
+ const hit = candidates.find((p) => fs.existsSync(p));
34
+ if (!hit) {
35
+ throw new DommiefError(`không tìm thấy theme-api.tsx của dommief (đã thử: ${candidates.join(', ')})`, { code: 'client_entry_missing' });
36
+ }
37
+ return hit;
38
+ }
package/src/plugins.ts ADDED
@@ -0,0 +1,131 @@
1
+ import type { Route } from './types.js';
2
+ import { DommiefError } from './markdown/errors.js';
3
+ import type { ResolvedConfig } from './config.js';
4
+
5
+ export interface BuildEndContext {
6
+ config: ResolvedConfig;
7
+ outDir: string;
8
+ routes?: Route[];
9
+ }
10
+
11
+ export type DommiefPluginObject = {
12
+ name?: string;
13
+ /** Đồng bộ: hook phải được `tap` xong trước khi hàm trả về. */
14
+ setup?: (hooks: PluginHookHost) => void;
15
+ modifyConfig?: (config: unknown) => unknown;
16
+ modifyRoutes?: (routes: Route[]) => Route[] | void;
17
+ addRemarkPlugins?: () => unknown | unknown[];
18
+ addRehypePlugins?: () => unknown | unknown[];
19
+ addHtmlHeadTags?: () => string | string[] | void;
20
+ onBuildEnd?: (ctx: BuildEndContext) => void | Promise<void>;
21
+ };
22
+
23
+ export type PluginSetup = (hooks: PluginHookHost) => void;
24
+ export type DommiefPlugin = DommiefPluginObject | PluginSetup;
25
+
26
+ export class Hook<TArg = unknown, TResult = unknown> {
27
+ name: string;
28
+ constructor(name = '') { this.name = name; }
29
+ taps: { name: string; fn: (arg: TArg) => TResult | void | Promise<TResult | void> }[] = [];
30
+ tap(name: string, fn: (arg: TArg) => TResult | void | Promise<TResult | void>) {
31
+ this.taps.push({ name, fn });
32
+ }
33
+ call(arg: TArg): TArg {
34
+ let value: any = arg;
35
+ for (const t of this.taps) {
36
+ const r = (t.fn as any)(value);
37
+ if (r !== undefined) value = r;
38
+ }
39
+ return value as TArg;
40
+ }
41
+ async callAsync(arg: TArg): Promise<TArg> {
42
+ let value: any = arg;
43
+ for (const t of this.taps) {
44
+ const r = await (t.fn as any)(value);
45
+ if (r !== undefined) value = r;
46
+ }
47
+ return value as TArg;
48
+ }
49
+ }
50
+
51
+ export class PluginHookHost {
52
+ modifyConfig = new Hook<unknown, unknown>('modifyConfig' as any);
53
+ modifyRoutes = new Hook<Route[], Route[]>('modifyRoutes' as any);
54
+ addRemarkPlugins = new Hook<void, unknown[]>('addRemarkPlugins' as any);
55
+ addRehypePlugins = new Hook<void, unknown[]>('addRehypePlugins' as any);
56
+ addHtmlHeadTags = new Hook<void, string | string[]>('addHtmlHeadTags' as any);
57
+ onBuildEnd = new Hook<BuildEndContext, void>('onBuildEnd' as any);
58
+
59
+ applyModifyConfig(config: unknown): Record<string, unknown> {
60
+ return this.modifyConfig.call(config) as Record<string, unknown>;
61
+ }
62
+ applyModifyRoutes(routes: Route[]): Route[] {
63
+ const r = this.modifyRoutes.call(routes);
64
+ return (r as Route[]) ?? routes;
65
+ }
66
+ collectRemarkPlugins(): unknown[] {
67
+ const out: unknown[] = [];
68
+ for (const t of this.addRemarkPlugins.taps) {
69
+ const r = (t.fn as any)();
70
+ if (Array.isArray(r)) out.push(...r);
71
+ else if (r !== undefined && r !== null) out.push(r);
72
+ }
73
+ return out;
74
+ }
75
+ collectRehypePlugins(): unknown[] {
76
+ const out: unknown[] = [];
77
+ for (const t of this.addRehypePlugins.taps) {
78
+ const r = (t.fn as any)();
79
+ if (Array.isArray(r)) out.push(...r);
80
+ else if (r !== undefined && r !== null) out.push(r);
81
+ }
82
+ return out;
83
+ }
84
+ collectHtmlHeadTags(): string[] {
85
+ const out: string[] = [];
86
+ for (const t of this.addHtmlHeadTags.taps) {
87
+ const r = (t.fn as any)();
88
+ if (Array.isArray(r)) out.push(...r.map(String));
89
+ else if (r !== undefined && r !== null) out.push(String(r));
90
+ }
91
+ return out;
92
+ }
93
+ async runBuildEnd(ctx: BuildEndContext): Promise<void> {
94
+ for (const t of this.onBuildEnd.taps) {
95
+ await (t.fn as any)(ctx);
96
+ }
97
+ }
98
+ }
99
+
100
+ function assertSync(result: unknown, name: string): void {
101
+ if (result && typeof (result as { then?: unknown }).then === 'function') {
102
+ throw new DommiefError(`plugin "${name}": setup phải đồng bộ — hook tap sau \`await\` sẽ không bao giờ chạy`, { code: 'plugin_setup_async' });
103
+ }
104
+ }
105
+
106
+ export function registerPlugin(host: PluginHookHost, plugin: DommiefPlugin | null | undefined): void {
107
+ if (!plugin) return;
108
+ if (typeof plugin === 'function') {
109
+ assertSync((plugin as PluginSetup)(host), plugin.name || 'plugin');
110
+ return;
111
+ }
112
+ const p = plugin as DommiefPluginObject;
113
+ const name = p.name ?? 'plugin';
114
+ if (typeof p.setup === 'function') assertSync(p.setup(host), name); // không return: setup + hook khai báo cùng tồn tại
115
+ if (typeof p.modifyConfig === 'function') host.modifyConfig.tap(name, p.modifyConfig as any);
116
+ if (typeof p.modifyRoutes === 'function') host.modifyRoutes.tap(name, p.modifyRoutes as any);
117
+ if (typeof p.addRemarkPlugins === 'function') host.addRemarkPlugins.tap(name, p.addRemarkPlugins as any);
118
+ if (typeof p.addRehypePlugins === 'function') host.addRehypePlugins.tap(name, p.addRehypePlugins as any);
119
+ if (typeof p.addHtmlHeadTags === 'function') host.addHtmlHeadTags.tap(name, p.addHtmlHeadTags as any);
120
+ if (typeof p.onBuildEnd === 'function') host.onBuildEnd.tap(name, p.onBuildEnd as any);
121
+ }
122
+
123
+ export function createPluginHost(plugins: unknown[] = []): PluginHookHost {
124
+ const host = new PluginHookHost();
125
+ for (const p of plugins ?? []) registerPlugin(host, p as DommiefPlugin);
126
+ return host;
127
+ }
128
+
129
+ export function definePlugin(plugin: DommiefPlugin): DommiefPlugin {
130
+ return plugin;
131
+ }
@@ -0,0 +1,28 @@
1
+ // Hằng số + hàm thuần (KHÔNG import 'react') cho cơ chế dark mode (THEME-API §7.6, §9.2).
2
+ //
3
+ // LÝ DO TÁCH FILE RIÊNG (đã crash thật, không phải suy đoán): `vite/plugin.ts` chạy TRỰC TIẾP
4
+ // trong tiến trình Node của CLI (build-time), không qua bundle SSR của Vite. Nếu nó `import`
5
+ // thẳng từ `theme-api.tsx` (file có JSX + hook `react`), react bị nạp vào TIẾN TRÌNH CLI qua một
6
+ // đường resolve khác với bản mà `server.mjs` (nạp bằng `import()` động sau khi Vite build xong)
7
+ // dùng để `renderToPipeableStream` — kết quả: `TypeError: dispatcher.getOwner is not a function`
8
+ // (hai "bản" react không chia sẻ cùng dispatcher nội bộ). `theme-api.tsx` import lại từ đây để
9
+ // không trùng lặp định nghĩa, nhưng bản thân file này không bao giờ được import 'react'.
10
+ export type ColorValue = 'light' | 'dark';
11
+ export type PrefersColorValue = ColorValue | 'auto';
12
+
13
+ export const PREFERS_COLOR_ATTR = 'data-prefers-color';
14
+ export const PREFERS_COLOR_STORAGE_KEY = 'dommief:prefers-color';
15
+
16
+ /**
17
+ * Inline script < 1KB, không `async`/`defer` (DM-510 tiêu chí #1), chạy TRƯỚC paint để không
18
+ * nháy sáng rồi sang tối (FOUC). Chỉ **ghi đè** khi `localStorage` có lựa chọn (THEME-API
19
+ * Amendment 2026-10-22 #1): giá trị mặc định từ config đã được SSR render thẳng vào
20
+ * `<html data-prefers-color>` (xem vite/plugin.ts), nên trang vẫn đúng màu khi tắt JS.
21
+ */
22
+ export const PREFERS_COLOR_BOOT_SCRIPT = `(function(){try{var k=${JSON.stringify(PREFERS_COLOR_STORAGE_KEY)},a=${JSON.stringify(PREFERS_COLOR_ATTR)},v=localStorage.getItem(k);if(v!=='light'&&v!=='dark'&&v!=='auto')return;var c=v==='auto'?(matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light'):v;document.documentElement.setAttribute(a,c)}catch(e){}})();`;
23
+
24
+ /** `'auto'|'dark'|'light'` khác `'auto'` → màu áp dụng thật (để SSR render attribute trên `<html>`). */
25
+ export function resolveConfiguredColor(defaultValue: PrefersColorValue | undefined): ColorValue | undefined {
26
+ if (!defaultValue || defaultValue === 'auto') return undefined; // 'auto' KHÔNG BAO GIỜ ghi vào attribute (§9.2)
27
+ return defaultValue;
28
+ }
package/src/preview.ts ADDED
@@ -0,0 +1,127 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import type { IncomingMessage, ServerResponse } from 'node:http';
4
+ import { preview as vitePreview, type PreviewServer, type Connect, type Plugin } from 'vite';
5
+ import { DommiefError } from './markdown/errors.js';
6
+
7
+ // Đặc tả: docs/backlog/DM-411.md. Dùng `vite preview` thật (không tự viết static server) + SPA
8
+ // fallback cho route bị `ssg.exclude` (shell không có `index.html` ĐÚNG NGHĨA — build.ts đã ghi
9
+ // shell = template, nên thực ra MỌI route đều có file; fallback ở đây chỉ cần khi route không có
10
+ // thư mục nào khớp, vd điều hướng client-side tới URL động chưa từng build).
11
+
12
+ export interface PreviewOptions {
13
+ root: string;
14
+ base: string;
15
+ outDir: string;
16
+ port?: number;
17
+ host?: string | boolean;
18
+ open?: boolean;
19
+ }
20
+
21
+ /** Kiểm outDir TRƯỚC khi nghe cổng — preview không tự build (phải nhanh, tất định, "xem đúng cái vừa build"). */
22
+ export function assertBuildOutput(outDir: string): void {
23
+ const indexHtml = path.join(outDir, 'index.html');
24
+ if (!fs.existsSync(outDir) || !fs.existsSync(indexHtml)) {
25
+ throw new DommiefError(`không có build output ở "${outDir}". Chạy \`dommief build\` trước.`, { code: 'no_build_output', file: outDir });
26
+ }
27
+ }
28
+
29
+ export async function startPreview(opts: PreviewOptions): Promise<PreviewServer> {
30
+ assertBuildOutput(opts.outDir);
31
+ const server = await vitePreview({
32
+ root: opts.root,
33
+ base: opts.base,
34
+ preview: {
35
+ ...(opts.port !== undefined ? { port: opts.port } : {}),
36
+ ...(opts.host !== undefined ? { host: opts.host } : {}),
37
+ ...(opts.open !== undefined ? { open: opts.open } : {}),
38
+ },
39
+ build: { outDir: opts.outDir },
40
+ plugins: [spaFallbackPlugin(opts)],
41
+ });
42
+ return server;
43
+ }
44
+
45
+ /**
46
+ * `configurePreviewServer` chỉ tồn tại trên `Plugin`, không phải trên `InlineConfig` trực tiếp
47
+ * — phải khai qua `plugins: [...]`.
48
+ *
49
+ * Middleware được cài TRỰC TIẾP trong hook (pre-hook), KHÔNG qua hàm trả về (post-hook): Vite
50
+ * preview có sẵn SPA fallback riêng (`htmlFallbackMiddleware`) chạy trước post-hook, và nó trả
51
+ * `index.html` GỐC cho mọi URL không có dấu `/` cuối — đã bắt trực tiếp: `/sub/guide/get-started`
52
+ * trả nội dung trang chủ trong khi `/sub/guide/get-started/` trả đúng trang. Static host thật
53
+ * (Netlify, Cloudflare Pages, nginx try_files) đều phục vụ `<dir>/index.html` cho URL không dấu `/`,
54
+ * nên preview phải làm đúng như vậy, nếu không người dùng sẽ thấy bug chỉ có ở preview.
55
+ *
56
+ * Thứ tự: file trùng route (`<rel>/index.html` hoặc `<rel>.html`) -> phục vụ file đó; asset
57
+ * (không phải request HTML) -> để static handler của Vite lo; còn lại (URL client-only chưa build)
58
+ * -> shell gốc để router client tự render.
59
+ */
60
+ function spaFallbackPlugin(opts: PreviewOptions): Plugin {
61
+ const shellPath = path.join(opts.outDir, 'index.html');
62
+ const base = opts.base.replace(/\/$/, '');
63
+ const serveHtml = (res: ServerResponse, file: string, status = 200) => {
64
+ res.statusCode = status;
65
+ res.setHeader('Content-Type', 'text/html; charset=utf-8');
66
+ fs.createReadStream(file).pipe(res);
67
+ };
68
+ return {
69
+ name: 'dommief:preview-spa-fallback',
70
+ configurePreviewServer(s) {
71
+ s.middlewares.use((req: IncomingMessage, res: ServerResponse, next: Connect.NextFunction) => {
72
+ // KHÔNG lọc theo header `Accept`: trong Node, `fetch()` mặc định gửi `Accept: */*`
73
+ // (không phải `text/html` như trong trình duyệt), nên lọc theo header sẽ bỏ qua middleware
74
+ // cho chính cách test (và nhiều HTTP client khác) gọi đến — đã bắt trực tiếp: với header
75
+ // lọc, `fetch('/sub/guide/get-started')` trả nhầm trang chủ (Vite fallback thay vì middleware
76
+ // này xử lý). Lọc theo đuôi file: bỏ qua request rõ ràng là asset (có đuôi khác .html).
77
+ let urlPath: string;
78
+ try {
79
+ urlPath = decodeURIComponent((req.url ?? '/').split('?')[0] ?? '/');
80
+ } catch {
81
+ res.statusCode = 400;
82
+ res.end('Invalid URL');
83
+ return;
84
+ }
85
+ if (req.method !== 'GET') return next();
86
+ // Ngoài base: TỰ trả 404, KHÔNG `next()` — static handler của Vite (sirv) không biết về
87
+ // `base`, nó phục vụ `index.html` ở gốc `outDir` cho BẤT KỲ path nào không khớp asset —
88
+ // đã bắt trực tiếp: `next()` khi path ngoài base trả nhầm 200 + trang chủ (tiêu chí
89
+ // §3: GET '/' với `base: '/sub/'` PHẢI không phải 200).
90
+ if (base && urlPath !== base && !urlPath.startsWith(base + '/')) {
91
+ res.statusCode = 404;
92
+ res.end('Not Found');
93
+ return;
94
+ }
95
+ const rel = base ? urlPath.slice(base.length) || '/' : urlPath;
96
+ // THử tìm file HTML khớp route TRƯỚC khi quyết định next(): heuristic đuôi file cũ
97
+ // (`/\.[a-z0-9]+$/`) coi mọi segment cuối có dấu chấm là asset, nên route thật kiểu
98
+ // `/guide/v1.2` hay `/api/node.js` bị rơi về static handler và nhận lại đúng bug
99
+ // trailing-slash đã sửa (chưa thử khhớp đã kết luận là asset). Dùng `path.resolve`
100
+ // (không phải so sánh chuỗi prefix) cho traversal guard: `/dist-evil` không được
101
+ // qua chỉ vì cùng tiền tố ký tự với `outDir`.
102
+ const outDirResolved = path.resolve(opts.outDir);
103
+ const safe = (p: string) => {
104
+ const r = path.resolve(p);
105
+ return r === outDirResolved || r.startsWith(outDirResolved + path.sep);
106
+ };
107
+ const candidates = [
108
+ path.join(opts.outDir, rel, 'index.html'),
109
+ path.join(opts.outDir, rel.endsWith('.html') ? rel : rel.replace(/\/$/, '') + '.html'),
110
+ ];
111
+ const hit = candidates.find((p) => safe(p) && fs.existsSync(p) && fs.statSync(p).isFile());
112
+ if (hit) return serveHtml(res, hit);
113
+ // Không khớp HTML nào: request có đuôi file rõ ràng (asset thật, vd .js/.css/.png) ->
114
+ // nhường static handler của Vite xử lý (404 thật nếu không tồn tại); còn lại -> shell
115
+ // (SPA fallback cho URL client-only chưa build).
116
+ if (/\.[a-z0-9]+$/i.test(rel.replace(/\.html$/i, ''))) return next();
117
+ // URL chưa từng build: static host thật (Netlify/CF Pages/GitHub Pages) phục vụ `404.html` với
118
+ // status 404, không phải trang chủ — phục vụ shell ở đây từng làm preview hydrate nội dung
119
+ // trang chủ ở URL lạ (React #418, bắt bằng e2e trình duyệt thật). Chỉ rơi về shell khi build
120
+ // không có 404.html (render 404 lỗi).
121
+ const notFound = path.join(opts.outDir, '404.html');
122
+ if (fs.existsSync(notFound)) { serveHtml(res, notFound, 404); return; }
123
+ serveHtml(res, shellPath);
124
+ });
125
+ },
126
+ };
127
+ }
package/src/routes.ts ADDED
@@ -0,0 +1,232 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { createHash } from 'node:crypto';
4
+ import { DommiefError } from './markdown/errors.js';
5
+ import { winPath } from './utils/path.js';
6
+ import type { ResolvedLocale, Route, RouteConfig, RouteTable } from './types.js';
7
+ import { createPluginHost, type PluginHookHost } from './plugins.js';
8
+
9
+ // Đặc tả: docs/spec/ROUTING.md §1–§5. Thứ tự bắt buộc trong normalizeRoutes:
10
+ // locale → index/README → '.'→'/' → kebab → validate → trùng id/path.
11
+
12
+ export interface RouteScan { routes: Route[]; warnings: string[] }
13
+
14
+ const INDEX_NAMES = new Set(['index', 'README']); // case-sensitive (ROUTING §2.3.1)
15
+ const SEGMENT_OK = /^[\p{L}\p{N}_-]+$/u;
16
+
17
+ /**
18
+ * Liệt kê `*.md` (đuôi chữ thường; `.MD`/`.mdx` không nhận) dưới `dir`, absolute POSIX, đã sort.
19
+ * Bỏ tên bắt đầu bằng `.`/`_`, `node_modules`; không theo symlink.
20
+ */
21
+ function listMarkdownFiles(dir: string): string[] {
22
+ const out: string[] = [];
23
+ const walk = (d: string) => {
24
+ for (const e of fs.readdirSync(d, { withFileTypes: true })) {
25
+ if (e.name.startsWith('.') || e.name.startsWith('_') || e.name === 'node_modules') continue;
26
+ const p = path.join(d, e.name);
27
+ if (e.isDirectory()) walk(p);
28
+ else if (e.isFile() && e.name.endsWith('.md')) out.push(winPath(p));
29
+ }
30
+ };
31
+ walk(dir);
32
+ return out.sort();
33
+ }
34
+
35
+ /** Route thô: `id` = đường dẫn tương đối docDir bỏ `.md`, `path` tạm = `/${id}`. */
36
+ export function scanRoutes({ cwd, config }: { cwd: string; config: RouteConfig }): RouteScan {
37
+ const routes: Route[] = [];
38
+ const warnings: string[] = [];
39
+ const seen = new Set<string>();
40
+ const docDirs = config.preset === 'library' && !(config.docDirs || []).some((d) => d.dir === 'src')
41
+ ? [...(config.docDirs || []), { dir: 'src' }]
42
+ : (config.docDirs || []);
43
+ for (const { dir } of docDirs) {
44
+ const absDir = path.resolve(cwd, dir);
45
+ if (!fs.existsSync(absDir)) {
46
+ // ponytail: luôn là warning (RouteConfig không biết docDirs có phải mặc định không); CONFIG §3.3 lỗi cứng cho docDirs user khai thuộc config.ts.
47
+ warnings.push(`doc_dir_missing: docDirs "${dir}" không tồn tại (${winPath(absDir)})`);
48
+ continue;
49
+ }
50
+ for (const file of listMarkdownFiles(absDir)) {
51
+ if (seen.has(file)) {
52
+ warnings.push(`route_duplicate_file: ${file} nằm trong nhiều docDirs, giữ docDir khai trước`);
53
+ continue;
54
+ }
55
+ seen.add(file);
56
+ const id = path.posix.relative(winPath(absDir), file).slice(0, -'.md'.length);
57
+ routes.push({ id, path: `/${id}`, file });
58
+ }
59
+ }
60
+ return { routes, warnings };
61
+ }
62
+
63
+ /** ROUTING §3.1 — chỉ áp cho một segment, không tạo/xoá `/`. */
64
+ function kebab(segment: string, localeId: string): string {
65
+ return segment
66
+ .replace(/([a-z0-9])([A-Z])/g, '$1-$2')
67
+ .replace(/([A-Z]+)([A-Z][a-z])/g, '$1-$2')
68
+ .toLocaleLowerCase(localeId);
69
+ }
70
+
71
+ interface Normalized {
72
+ route: Route;
73
+ routePath: string; // trước khi ghép locale, dạng 'guide/intro'
74
+ locale: ResolvedLocale;
75
+ suffixStripped: boolean;
76
+ indexName?: string;
77
+ dir: string; // thư mục chứa file, tương đối docDir
78
+ dotted: boolean; // có '.' bị đổi thành '/'
79
+ }
80
+
81
+ /**
82
+ * Từ route thô (scanRoutes) tính `path`/`locale` theo ROUTING §2.2–§2.3, kiểm §4.
83
+ * Không mutate input. Gom mọi lỗi rồi ném **một** DommiefError (code của lỗi đầu tiên).
84
+ */
85
+ export function normalizeRoutes(raw: Route[], { cwd, config }: { cwd: string; config: RouteConfig }): RouteScan {
86
+ const warnings: string[] = [];
87
+ const errors: { code: string; message: string }[] = [];
88
+ const show = (file: string) => path.posix.relative(winPath(cwd), file) || file;
89
+ const defaultLocale = config.locales[0]!; // config luôn có ≥1 locale (zod default)
90
+ const items: Normalized[] = [];
91
+
92
+ for (const r of raw) {
93
+ const segs = r.id.split('/');
94
+ let name = segs.pop()!;
95
+ const dir = segs.join('/');
96
+
97
+ // 1. locale: hậu tố chỉ ở tên file, so theo locale.id, locale khai trước thắng
98
+ let locale = defaultLocale;
99
+ let suffixStripped = false;
100
+ const hit = config.locales.find((l) => name.endsWith('.' + l.id));
101
+ if (hit) {
102
+ locale = hit;
103
+ suffixStripped = true;
104
+ name = name.slice(0, -(hit.id.length + 1));
105
+ } else {
106
+ const last = name.slice(name.lastIndexOf('.') + 1);
107
+ const typo = name.includes('.') && config.locales.find((l) => l.id.toLowerCase() === last.toLowerCase());
108
+ if (typo) warnings.push(`locale_unknown_suffix: ${show(r.file)} có hậu tố ".${last}" gần giống locale "${typo.id}" nhưng không khớp; xử lý như file thường`);
109
+ }
110
+
111
+ // 2. index/README chỉ ở segment cuối
112
+ const indexName = INDEX_NAMES.has(name) ? name : undefined;
113
+ const parts = indexName ? segs : [...segs, name];
114
+
115
+ // 3. '.' → '/', 4. kebab, bỏ segment rỗng, NFC
116
+ const split = parts.flatMap((s) => s.split('.'));
117
+ const dotted = split.length !== parts.length;
118
+ const routePath = split
119
+ .filter(Boolean)
120
+ .map((s) => (config.forceKebabCase ? kebab(s, locale.id) : s))
121
+ .join('/')
122
+ .normalize('NFC');
123
+
124
+ // 5. validate ký tự (trước khi ghép suffix locale, vì suffix có '.')
125
+ const bad = routePath.split('/').find((s) => s && !SEGMENT_OK.test(s));
126
+ if (bad) {
127
+ const ch = [...bad].find((c) => !SEGMENT_OK.test(c))!;
128
+ errors.push({
129
+ code: 'route_invalid_path',
130
+ message: `dommief: routes: ký tự không hợp lệ "${ch}" trong đường dẫn của file\n ${show(r.file)}\n segment chỉ được chứa chữ, số, "-" và "_". Đổi tên file.`,
131
+ });
132
+ // eslint-disable-next-line no-control-regex -- \x00 là biên dưới của dải ASCII (cố ý), không phải ký tự điều khiển lọt vào nhầm.
133
+ } else if (/[^\x00-\x7F]/.test(routePath)) {
134
+ warnings.push(`route_non_ascii: ${show(r.file)} → "/${routePath}" có ký tự ngoài ASCII; NFC/NFD và hosting tĩnh có thể lệch, nên đặt tên file ASCII`);
135
+ }
136
+
137
+ // ghép locale: suffix mode khi suffix !== '', ngược lại base mode
138
+ const joined = locale.suffix ? `/${routePath}${locale.suffix}` : `${locale.base}${routePath}`;
139
+ const p = joined.replace(/\/{2,}/g, '/').replace(/(.)\/$/, '$1');
140
+ items.push({
141
+ // locale mặc định ⇒ không có key (JSON round-trip giữ nguyên object)
142
+ route: { id: r.id, path: p, file: r.file, ...(locale === defaultLocale ? {} : { locale: locale.id }) },
143
+ routePath, locale, suffixStripped, ...(indexName === undefined ? {} : { indexName }), dir, dotted,
144
+ });
145
+ }
146
+
147
+ // 6. trùng id (giữa các docDir)
148
+ const byId = new Map<string, Normalized[]>();
149
+ for (const it of items) byId.set(it.route.id, [...(byId.get(it.route.id) ?? []), it]);
150
+ for (const [id, group] of byId) {
151
+ if (group.length < 2) continue;
152
+ errors.push({
153
+ code: 'route_duplicate',
154
+ message: `dommief: routes: trùng route id "${id}" (${group.length} file)\n${group.map((g) => ` ${show(g.route.file)}`).join('\n')}\n id là đường dẫn tương đối trong docDir. Đổi tên file hoặc tách docDir thành 2 site.`,
155
+ });
156
+ }
157
+
158
+ // 7. trùng path — so cả biến thể lowercase để bắt Intro.md/intro.md
159
+ const byPath = new Map<string, Normalized[]>();
160
+ for (const it of items) {
161
+ if ((byId.get(it.route.id)?.length ?? 0) > 1) continue;
162
+ const key = it.route.path.toLowerCase();
163
+ byPath.set(key, [...(byPath.get(key) ?? []), it]);
164
+ }
165
+ for (const group of byPath.values()) {
166
+ if (group.length < 2) continue;
167
+ errors.push({
168
+ code: 'route_duplicate',
169
+ message: `dommief: routes: trùng đường dẫn "${group[0]!.route.path}" (${group.length} file)\n${group.map((g) => ` ${show(g.route.file)}`).join('\n')}\n nguyên nhân: ${duplicateCause(group[0]!, group[1]!)}`,
170
+ });
171
+ }
172
+
173
+ if (errors.length) {
174
+ throw new DommiefError(errors.map((e) => e.message).join('\n\n'), { code: errors[0]!.code });
175
+ }
176
+ return { routes: items.map((it) => it.route), warnings };
177
+ }
178
+
179
+ function duplicateCause(a: Normalized, b: Normalized): string {
180
+ const base = (n: Normalized) => path.posix.basename(n.route.file);
181
+ const stripped = a.suffixStripped ? a : b.suffixStripped ? b : undefined;
182
+ if (stripped && a.locale === b.locale) {
183
+ const other = stripped === a ? b : a;
184
+ const owner = stripped.route.locale ? `locale "${stripped.locale.id}"` : 'locale mặc định';
185
+ return `"${base(stripped)}" thuộc ${owner} nên trùng "${base(other)}"`;
186
+ }
187
+ const dirName = (n: Normalized) => n.dir || '/';
188
+ if (a.indexName && b.indexName) return `cả "${a.indexName}.md" và "${b.indexName}.md" đều gộp vào "${dirName(a)}"`;
189
+ const idx = a.indexName ? a : b.indexName ? b : undefined;
190
+ if (idx) return `"${idx.indexName}.md" gộp vào thư mục "${dirName(idx)}"`;
191
+ if (a.route.id.toLowerCase() === b.route.id.toLowerCase()) return 'trùng sau khi kebab (macOS/Windows không phân biệt hoa thường)';
192
+ if (a.dotted || b.dotted) return '"." trong tên file được đổi thành "/"';
193
+ return 'kebab-case làm hai file trùng nhau';
194
+ }
195
+
196
+ /** sha256 (8 ký tự) của các key ảnh hưởng route. Khác `transformHash` của cache md (DM-406). */
197
+ export function routeConfigHash(config: RouteConfig): string {
198
+ const { srcDir, docDirs, locales, forceKebabCase, preset } = config;
199
+ const json = JSON.stringify({
200
+ srcDir,
201
+ docDirs: docDirs.map((d) => d.dir),
202
+ locales: locales.map(({ id, name, base, suffix }) => ({ id, name, base, suffix })),
203
+ forceKebabCase,
204
+ preset: preset ?? 'docs',
205
+ });
206
+ return createHash('sha256').update(json).digest('hex').slice(0, 8);
207
+ }
208
+
209
+ /** Điểm vào duy nhất của tầng route: scan → normalize → sort → map tra cứu. JSON-safe. */
210
+ export function buildRouteTable({ cwd, config, hooks, plugins }: { cwd: string; config: RouteConfig; hooks?: PluginHookHost; plugins?: unknown[] }): RouteTable {
211
+ const host = hooks ?? (plugins ? createPluginHost(plugins) : (config as any)?.plugins ? createPluginHost((config as any).plugins) : undefined);
212
+ const scan = scanRoutes({ cwd, config });
213
+ const norm = normalizeRoutes(scan.routes, { cwd, config });
214
+ const cmp = (x: string, y: string) => (x < y ? -1 : x > y ? 1 : 0);
215
+ let routes = norm.routes;
216
+ if (host) routes = host.applyModifyRoutes(routes);
217
+ routes = routes.sort((a, b) => cmp(a.path, b.path) || cmp(a.id, b.id));
218
+ const table: RouteTable = {
219
+ warnings: [...scan.warnings, ...norm.warnings],
220
+ routes,
221
+ byId: {},
222
+ byPath: {},
223
+ byFile: {},
224
+ configHash: routeConfigHash(config),
225
+ };
226
+ for (const r of routes) {
227
+ table.byId[r.id] = r;
228
+ table.byPath[r.path] = r;
229
+ table.byFile[r.file] = r;
230
+ }
231
+ return table;
232
+ }