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.
- package/README.md +120 -0
- package/dist/cli.js +168 -143
- package/dist/index.js +5 -1
- package/package.json +1 -72
- package/src/build.ts +290 -0
- package/src/cache.ts +172 -0
- package/src/cli.ts +87 -0
- package/src/config.ts +258 -0
- package/src/exclude.ts +38 -0
- package/src/index.ts +17 -0
- package/src/link-check.ts +101 -0
- package/src/log.ts +10 -0
- package/src/markdown/errors.ts +28 -0
- package/src/markdown/index.ts +96 -0
- package/src/markdown/rehype-api.ts +131 -0
- package/src/markdown/rehype-code.ts +147 -0
- package/src/markdown/rehype-demo.ts +76 -0
- package/src/markdown/rehype-desc.ts +30 -0
- package/src/markdown/rehype-img.ts +33 -0
- package/src/markdown/rehype-link.ts +50 -0
- package/src/markdown/rehype-slug.ts +41 -0
- package/src/markdown/rehype-text.ts +53 -0
- package/src/markdown/remark-container.ts +76 -0
- package/src/markdown/remark-meta.ts +73 -0
- package/src/markdown/to-module.ts +35 -0
- package/src/paths.ts +38 -0
- package/src/plugins.ts +131 -0
- package/src/prefers-color.ts +28 -0
- package/src/preview.ts +127 -0
- package/src/routes.ts +232 -0
- package/src/search.ts +88 -0
- package/src/siteFeatures.ts +123 -0
- package/src/siteModule.ts +47 -0
- package/src/sitemap.ts +59 -0
- package/src/ssr-guard.ts +72 -0
- package/src/theme/index.ts +5 -0
- package/src/theme/loader.ts +151 -0
- package/src/theme/resolver.ts +115 -0
- package/src/theme/types.ts +25 -0
- package/src/theme-modules.d.ts +52 -0
- package/src/utils/path.ts +8 -0
- package/src/vite/plugin.ts +182 -0
- package/src/vite/routesPlugin.ts +39 -0
- package/src/vite/searchIndexPlugin.ts +66 -0
- package/src/vite/sitePlugin.ts +97 -0
- 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
|
+
}
|