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
package/package.json CHANGED
@@ -1,72 +1 @@
1
- {
2
- "name": "dommief",
3
- "version": "0.1.0",
4
- "type": "module",
5
- "main": "dist/index.js",
6
- "types": "dist/index.d.ts",
7
- "bin": {
8
- "dommief": "dist/cli.js"
9
- },
10
- "exports": {
11
- ".": {
12
- "types": "./dist/index.d.ts",
13
- "default": "./dist/index.js"
14
- },
15
- "./theme-api": {
16
- "types": "./dist/theme-api.d.ts",
17
- "default": "./dist/theme-api.js"
18
- },
19
- "./client": {
20
- "types": "./src/client/index.tsx",
21
- "default": "./src/client/index.tsx"
22
- }
23
- },
24
- "files": [
25
- "dist",
26
- "src/client",
27
- "src/types.ts",
28
- "src/routesModule.ts",
29
- "src/theme-api.tsx",
30
- "src/search-config.ts"
31
- ],
32
- "scripts": {
33
- "build": "tsup",
34
- "dev": "tsup --watch"
35
- },
36
- "dependencies": {
37
- "cac": "^7.1.0",
38
- "chokidar": "^6.0.0",
39
- "estree-util-to-js": "^2.0.0",
40
- "github-slugger": "^2.0.0",
41
- "hast-util-to-estree": "^3.1.3",
42
- "minisearch": "^7.2.0",
43
- "picocolors": "^1.1.1",
44
- "react-docgen-typescript": "^2.4.0",
45
- "rehype-raw": "^7.0.0",
46
- "remark-directive": "^4.0.0",
47
- "remark-frontmatter": "^5.0.0",
48
- "remark-gfm": "^4.0.1",
49
- "remark-parse": "^11.0.0",
50
- "remark-rehype": "^11.1.2",
51
- "shiki": "^4.5.0",
52
- "unified": "^11.0.5",
53
- "unist-util-visit": "^5.0.0",
54
- "yaml": "^2.9.1",
55
- "zod": "^4.6.7"
56
- },
57
- "peerDependencies": {
58
- "react": "^19.0.0",
59
- "react-dom": "^19.0.0",
60
- "react-router": "^8.0.0",
61
- "typescript": "^5.0.0",
62
- "vite": "^8.0.0"
63
- },
64
- "devDependencies": {
65
- "react": "^19.3.1",
66
- "react-dom": "^19.3.1",
67
- "react-router": "^8.5.0",
68
- "tsup": "^9.0.0",
69
- "typescript": "^5.9.3",
70
- "vite": "^8.3.5"
71
- }
72
- }
1
+ {"name":"dommief","version":"0.1.2","type":"module","main":"dist/index.js","types":"dist/index.d.ts","bin":{"dommief":"dist/cli.js"},"exports":{".":{"types":"./dist/index.d.ts","default":"./dist/index.js"},"./theme-api":{"types":"./dist/theme-api.d.ts","default":"./dist/theme-api.js"},"./client":{"types":"./src/client/index.tsx","default":"./src/client/index.tsx"}},"files":["dist","src"],"scripts":{"build":"tsup","dev":"tsup --watch"},"dependencies":{"cac":"^7.1.0","chokidar":"^6.0.0","estree-util-to-js":"^2.0.0","github-slugger":"^2.0.0","hast-util-to-estree":"^3.1.3","minisearch":"^7.2.0","picocolors":"^1.1.1","react-docgen-typescript":"^2.4.0","rehype-raw":"^7.0.0","remark-directive":"^4.0.0","remark-frontmatter":"^5.0.0","remark-gfm":"^4.0.1","remark-parse":"^11.0.0","remark-rehype":"^11.1.2","shiki":"^4.5.0","unified":"^11.0.5","unist-util-visit":"^5.0.0","yaml":"^2.9.1","zod":"^4.6.7"},"peerDependencies":{"react":"^19.0.0","react-dom":"^19.0.0","react-router":"^8.0.0","typescript":"^5.0.0","vite":"^8.0.0"},"devDependencies":{"react":"^19.3.1","react-dom":"^19.3.1","react-router":"^8.5.0","tsup":"^9.0.0","typescript":"^5.9.3","vite":"^8.3.5"}}
package/src/build.ts ADDED
@@ -0,0 +1,290 @@
1
+ import path from 'node:path';
2
+ import fs from 'node:fs';
3
+ import { pathToFileURL } from 'node:url';
4
+ import { build } from 'vite';
5
+ import { dommiefPlugin } from './vite/plugin.js';
6
+ import { resolveClientFile } from './paths.js';
7
+ import { formatError, DommiefError } from './markdown/errors.js';
8
+ import type { ResolvedConfig } from './config.js';
9
+ import type { PluginHookHost } from './plugins.js';
10
+ import type { Logger } from './log.js';
11
+ import type { Route } from './types.js';
12
+ import { collect, check, formatIssues } from './link-check.js';
13
+ import { TransformCache, type CacheStats } from './cache.js';
14
+ import { matchExclude } from './exclude.js';
15
+ import { writeSitemapAndRobots, canonicalUrl } from './sitemap.js';
16
+ import type { TocItem } from './markdown/index.js';
17
+
18
+ const ROOT_PLACEHOLDER = /<div id="root"><\/div>/;
19
+
20
+ /** Chạy `fn` cho từng phần tử, tối đa `limit` việc cùng lúc. */
21
+ export async function mapLimit<T>(items: T[], limit: number, fn: (item: T) => Promise<void>): Promise<void> {
22
+ let i = 0;
23
+ const worker = async () => {
24
+ while (i < items.length) await fn(items[i++]!);
25
+ };
26
+ await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
27
+ }
28
+
29
+ export interface BuildOptions {
30
+ root: string;
31
+ config: ResolvedConfig;
32
+ host: PluginHookHost;
33
+ log: Logger;
34
+ }
35
+
36
+ /** DM-409 lát mỏng: `runBuild` trả số thay vì chỉ ghi log, để test đọc trực tiếp (không parse
37
+ * stdout). `cacheStats` là `TransformCache.stats` hiện có (DM-406) — không đổi shape, chỉ lộ ra. */
38
+ export interface BuildResult {
39
+ timings: Record<string, number>;
40
+ cacheStats: CacheStats;
41
+ }
42
+
43
+ export class LinkCheckFailedError extends Error {
44
+ constructor(public readonly report: string) {
45
+ super('link nội bộ chết; chạy với --no-link-check để bỏ qua');
46
+ }
47
+ }
48
+
49
+ /** Ngữ cảnh cho các bước phát file bổ sung (sitemap, robots, search index...).
50
+ * `routes` CHỈ chứa route đã PRERENDER thật (`plan.prerender`) — KHÔNG phải toàn bộ cây route
51
+ * từ SSR bundle. Route bị `ssg.exclude`/`ssg.noEmit` không có nội dung thật, nên không được
52
+ * vào sitemap/search index — đã bắt trực tiếp: emitter trước đây nhận `routes` toàn bộ nên
53
+ * `ssg.exclude` không được sitemap tôn trọng trừ khi cấu hình `sitemap.exclude` trùng (REVIEW). */
54
+ export interface EmitContext {
55
+ root: string;
56
+ config: ResolvedConfig;
57
+ outDir: string;
58
+ routes: Route[];
59
+ log: Logger;
60
+ }
61
+
62
+ /** Bước phát thêm file vào `outDir` sau SSG. Thêm tính năng = thêm một emitter, không sửa `runBuild`. */
63
+ export type Emitter = { name: string; run: (ctx: EmitContext) => void | Promise<void> };
64
+
65
+ /** Danh sách emitter mặc định; lane sitemap/search đăng ký vào đây. */
66
+ export const emitters: Emitter[] = [];
67
+
68
+ // DM-708: sitemap/robots phát sau khi SSG ghi HTML (dùng danh sách routes đã prerender).
69
+ emitters.push({
70
+ name: 'sitemap',
71
+ run: ({ config, outDir, routes, log }) => {
72
+ writeSitemapAndRobots({ outDir, routes, sitemap: config.sitemap, robots: config.robots, base: config.base, log });
73
+ },
74
+ });
75
+
76
+ export interface StageTiming { stage: string; ms: number }
77
+
78
+ /**
79
+ * Lát mỏng DM-409 (xem docs/backlog/DM-409.md): thêm cột `%` + luôn in (không cần `--verbose`).
80
+ * CHƯA đạt: 9 khâu riêng (config/scan/parse/highlight tách khỏi build:client) — ở kiến trúc
81
+ * hiện tại `parse`/`highlight` chạy LỒNG trong `build:client`/`build:ssr` (qua
82
+ * `dommiefPlugin.load()`, không phải một lời gọi `vite.build` riêng) nên tách đúng đòi thu thập
83
+ * thời gian qua callback xuyên plugin (kiểu `collectTexts` ở DM-801), không phải thêm một
84
+ * `timed()` mới — để lại cho phần 2 của DM-409 khi có quyết định tối ưu cần số đó.
85
+ * Các khâu liệt kê ở đây KHÔNG rời nhau tuyệt đối (`search-index`/sitemap nằm SAU
86
+ * `build:client`/`build:ssr`, không nằm trong) nên `%` cộng xấp xỉ 100% nhưng không chính xác
87
+ * tuyệt đối nếu sau này có emitter đan xen — đủ đúng để thấy khâu nào đáng đụng trước.
88
+ */
89
+ export function formatTimings(timings: StageTiming[]): string {
90
+ const total = timings.reduce((s, t) => s + t.ms, 0);
91
+ const w = Math.max(...timings.map((t) => t.stage.length), 5);
92
+ const pct = (ms: number) => (total > 0 ? `${Math.round((ms / total) * 100)}%` : '—').padStart(4);
93
+ const sec = (ms: number) => ((ms / 1000).toFixed(2) + 's').padStart(8);
94
+ const rows = timings.map((t) => ` ${t.stage.padEnd(w)} ${sec(t.ms)} ${pct(t.ms)}`);
95
+ return ['build timings:', ...rows, ` ${'total'.padEnd(w)} ${sec(total)} ${pct(total)}`].join('\n');
96
+ }
97
+
98
+ /**
99
+ * Quyết định route nào được prerender / chỉ phát shell / bỏ qua (REVIEW §1 #17):
100
+ * - `exclude`: KHÔNG prerender nhưng VẪN ghi shell (index.html chưa có nội dung) để host không SPA-fallback không 404.
101
+ * - `noEmit`: không ghi file nào.
102
+ */
103
+ export function planRoutes(routes: Route[], ssg: { exclude: string[]; noEmit: string[] }, log?: Logger): { prerender: Route[]; shell: Route[] } {
104
+ const prerender: Route[] = [];
105
+ const shell: Route[] = [];
106
+ for (const r of routes) {
107
+ if (matchesAny(r.path, ssg.noEmit)) continue;
108
+ if (matchesAny(r.path, ssg.exclude)) {
109
+ shell.push(r);
110
+ // DM-411: exclude một route KHÔNG PHẢI lá (có route con) nghĩa là điều hướng thẳng vào
111
+ // URL đó trên static host sẽ 404 (host không phục vụ shell cho prefix). Cảnh báo,
112
+ // không fail — người dùng có quyền tự biết host của mình có SPA fallback hay không.
113
+ if (log && routes.some((other) => other !== r && other.path.startsWith(r.path === '/' ? '/' : r.path + '/'))) {
114
+ log.warn(`ssg.exclude đang loại route "${r.path}" NHƯNG nó có route con — điều hướng thẳng vào "${r.path}" trên host không SPA fallback sẽ 404`);
115
+ }
116
+ continue;
117
+ }
118
+ prerender.push(r);
119
+ }
120
+ return { prerender, shell };
121
+ }
122
+
123
+ /**
124
+ * Khớp route path với pattern glob (`*` một segment, `**` nhiều segment — exclude.ts). Thêm một
125
+ * quy ước cũ đã có test: `/a/*` khớp cả chính `/a` (loại cả trang mục lục lẫn trang con).
126
+ */
127
+ export function matchesAny(p: string, patterns: string[]): boolean {
128
+ return patterns.some((pat) => (pat.endsWith('/*') && p === pat.slice(0, -2)) || matchExclude(p, [pat]));
129
+ }
130
+
131
+ function routesByPath(routes: Route[]): Record<string, { path: string }> {
132
+ return Object.fromEntries(routes.map((r) => [r.path, { path: r.path }]));
133
+ }
134
+
135
+ export async function runBuild({ root, config, host, log }: BuildOptions): Promise<BuildResult> {
136
+ // `config.linkCheck` đã gộp `--no-link-check` qua `loadConfig` (config.ts); không cần tham số riêng ở đây.
137
+ const linkCheckEnabled = config.linkCheck !== false;
138
+ const timings: StageTiming[] = [];
139
+ const timed = async <T>(stage: string, fn: () => Promise<T>): Promise<T> => {
140
+ const t0 = performance.now();
141
+ try { return await fn(); } finally { timings.push({ stage, ms: performance.now() - t0 }); }
142
+ };
143
+ // DM-406: cache nằm ở `.dommief/cache` (KHÔNG nằm trong `.dommief/server`, vì thư mục đó bị emptyOutDir mỗi build).
144
+ const cache = new TransformCache(path.join(root, '.dommief', 'cache'), config.cache);
145
+ // DM-801: thu `texts` mỗi file .md ngay trong lượt transform của build client (đủ 1 lần cho
146
+ // cả build — build ssr dùng chung `dommiefPlugin` nên transform lại trang đã cache sẽ không
147
+ // gọi lại `load()`, chỉ cần gắn collector ở MỖI lần `plugins()` để cả 2 lượt đều ghi được vo cùng Map).
148
+ const textsByFile = new Map<string, import('./markdown/index.js').TextFragment[]>();
149
+ const plugins = () => [dommiefPlugin({ root, config, host, cache, collectTexts: (file, texts) => textsByFile.set(file, texts) })];
150
+ const distDir = path.resolve(root, config.outDir);
151
+
152
+ // (a) client build → outDir/
153
+ await timed('client', () => build({ root, plugins: plugins(), base: config.base, build: { outDir: distDir, emptyOutDir: true } }));
154
+
155
+ // (b) SSR build → .dommief/server/server.mjs.
156
+ // KHÔNG dùng `.dommief` thẳng làm outDir: `emptyOutDir: true` xóa SẠCH thư mục đó trước
157
+ // mỗi build, mà `.dommief/theme/**` (override của user, DM-502) và `.dommief/cache/**`
158
+ // (DM-406) cũng sống trong `.dommief`. Xóa cả hai là mất dữ liệu người dùng mỗi lần build.
159
+ const ssrDir = path.join(root, '.dommief', 'server');
160
+ fs.mkdirSync(ssrDir, { recursive: true });
161
+ await timed('ssr', () => build({
162
+ root,
163
+ plugins: plugins(),
164
+ base: config.base,
165
+ build: {
166
+ outDir: ssrDir,
167
+ emptyOutDir: true,
168
+ ssr: resolveClientFile('entry-server.tsx'),
169
+ rollupOptions: { output: { entryFileNames: 'server.mjs', format: 'esm' } },
170
+ },
171
+ }));
172
+
173
+ // (c) SSG: chèn markup vào index.html của Vite (script + css) thay vì ghi markup trần.
174
+ // Phải đọc template TRƯỚC vòng lặp: route "/" ghi đè đúng file này.
175
+ const templatePath = path.join(distDir, 'index.html');
176
+ // Thiếu `<meta viewport>` thì mobile render như desktop 980px (e2e Pixel 7 bắt được) — mặc định chèn
177
+ // cho mọi dự án; người dùng đã tự khai (kể cả giá trị khác) thì giữ nguyên.
178
+ const rawTemplate = fs.readFileSync(templatePath, 'utf8');
179
+ const template = /<meta[^>]+name=["']viewport["']/i.test(rawTemplate)
180
+ ? rawTemplate
181
+ : rawTemplate.replace(/<\/head>/i, ' <meta name="viewport" content="width=device-width, initial-scale=1">\n</head>');
182
+ if (!ROOT_PLACEHOLDER.test(template)) {
183
+ throw new DommiefError('index.html không có <div id="root"></div> để chèn nội dung prerender', { file: templatePath, code: 'ssg_no_root' });
184
+ }
185
+ const serverMod: any = await import(pathToFileURL(path.join(ssrDir, 'server.mjs')).href);
186
+ // CHÚ Ý: đây là `RouteModule[]` (routesModule.ts: {id, path, component, preload}), KHÔNG phải
187
+ // `Route[]` đầy đủ (không có `file`) — đặt tên `routes` theo code cũ, nhưng đừng đọc
188
+ // `r.file` trên nó (luôn `undefined`, đã bắt trực tiếp lúc test link-check thật). File thật
189
+ // nằm ở `site.routes[r.id].file` (sinh bởi siteModule.ts, đầy đủ kiểu `Route`).
190
+ const routes: Route[] = serverMod.routes ?? serverMod.default?.routes ?? [];
191
+ const render: (url: string) => Promise<string> = serverMod.render ?? serverMod.default?.render;
192
+ const site: { routes?: Record<string, { file: string }>; meta?: Record<string, { toc?: TocItem[]; frontmatter?: Record<string, unknown> }> } | undefined = serverMod.site ?? serverMod.default?.site;
193
+ const fileOf = (routeId: string): string => site?.routes?.[routeId]?.file ?? routeId;
194
+ const canonicalHostname: string | undefined = config.sitemap === false ? undefined : config.sitemap.hostname;
195
+ const escapeAttr = (s: string) => s.replace(/"/g, '&quot;');
196
+ const wrap = (html: string, routePath: string) => {
197
+ let out = template.replace(ROOT_PLACEHOLDER, () => `<div id="root">${html}</div>`);
198
+ if (canonicalHostname && routePath !== '/404') {
199
+ const href = canonicalUrl(canonicalHostname, config.base, routePath);
200
+ if (!/<link[^>]+rel=["']canonical["']/i.test(out)) {
201
+ out = out.replace(/<\/head>/i, ` <link rel="canonical" href="${escapeAttr(href)}">\n</head>`);
202
+ }
203
+ }
204
+ return out;
205
+ };
206
+ const plan = planRoutes(routes, config.ssg, log);
207
+ const writePage = (r: Route, html: string) => {
208
+ const targetDir = r.path === '/' ? distDir : path.join(distDir, r.path.replace(/^\//, ''));
209
+ fs.mkdirSync(targetDir, { recursive: true });
210
+ fs.writeFileSync(path.join(targetDir, 'index.html'), html);
211
+ };
212
+ const linkRefs: ReturnType<typeof collect> = [];
213
+ await timed('prerender', async () => {
214
+ await mapLimit(plan.prerender, config.ssg.concurrency, async (r) => {
215
+ let html: string;
216
+ try {
217
+ html = await render(r.path);
218
+ } catch (e) {
219
+ throw new DommiefError(`prerender ${r.path}: ${formatError(e)}`, { code: 'ssg_render_failed' });
220
+ }
221
+ if (linkCheckEnabled) linkRefs.push(...collect(r.id, html, fileOf(r.id)));
222
+ writePage(r, wrap(html, r.path));
223
+ });
224
+ for (const r of plan.shell) writePage(r, wrap(template, r.path));
225
+ // 404.html: route "*" của cùng cây <Routes>
226
+ try {
227
+ fs.writeFileSync(path.join(distDir, '404.html'), wrap(await render('/404'), '/404'));
228
+ } catch (e) {
229
+ log.warn('không render được 404.html: ' + formatError(e));
230
+ }
231
+ });
232
+
233
+ if (linkCheckEnabled) {
234
+ await timed('link-check', async () => {
235
+ // route bị `ssg.exclude` không có trong site.meta (prerender bỏ qua) → không khóa anchor check nhầm lỗi (DM-410 ca h).
236
+ const tocByRoute: Record<string, TocItem[]> = {};
237
+ for (const r of plan.prerender) {
238
+ const meta = site?.meta?.[r.id];
239
+ if (meta?.toc) tocByRoute[r.path] = meta.toc;
240
+ }
241
+ const issues = check(linkRefs, { byPath: routesByPath(routes), tocByRoute, base: config.base, outDir: distDir });
242
+ if (issues.length) throw new LinkCheckFailedError(formatIssues(issues));
243
+ });
244
+ }
245
+
246
+ if (config.search.enabled) {
247
+ await timed('search-index', async () => {
248
+ const { buildSearchIndex, INDEX_BUDGET_BYTES_PER_PAGE } = await import('./search.js');
249
+ const res = await buildSearchIndex({
250
+ routes: plan.prerender,
251
+ loadPageMeta: (r) => ({
252
+ frontmatter: site?.meta?.[r.id]?.frontmatter ?? {},
253
+ toc: site?.meta?.[r.id]?.toc ?? [],
254
+ texts: textsByFile.get(fileOf(r.id)) ?? [],
255
+ }),
256
+ config: { locales: config.locales },
257
+ });
258
+ const searchDir = path.join(distDir, 'search-index');
259
+ fs.mkdirSync(searchDir, { recursive: true });
260
+ for (const [localeId, json] of Object.entries(res.locales)) {
261
+ fs.writeFileSync(path.join(searchDir, `${localeId}.json`), JSON.stringify(json));
262
+ }
263
+ for (const [localeId, s] of Object.entries(res.stats)) {
264
+ const kbPerPage = s.pages ? (s.bytes / 1024 / s.pages).toFixed(1) : '0';
265
+ log.info(`search index [${localeId}]: ${s.docs} docs, ${(s.bytes / 1024).toFixed(1)} KB, ${kbPerPage} KB/trang`);
266
+ // DM-805: chỉ cảnh báo, KHÔNG fail build ở v0.1 (tiêu chí #3 — siết lại là việc của DM-1102).
267
+ if (s.bytes > INDEX_BUDGET_BYTES_PER_PAGE * s.pages) {
268
+ log.warn(`search index [${localeId}] vượt ngân sách ${(INDEX_BUDGET_BYTES_PER_PAGE / 1024).toFixed(0)} KB/trang (đang ở ${kbPerPage} KB/trang)`);
269
+ }
270
+ }
271
+ });
272
+ }
273
+
274
+ for (const e of emitters) await timed(e.name, async () => { await e.run({ root, config, outDir: distDir, routes: plan.prerender, log }); });
275
+ await timed('plugins', () => host.runBuildEnd({ config, outDir: distDir, routes }));
276
+
277
+ cache.finish();
278
+ if (config.cache) {
279
+ log.info(cache.summary());
280
+ if (log.verbose) for (const [f, why] of Object.entries(cache.stats.missReasons)) log.info(` miss ${path.relative(root, f)} (${why})`);
281
+ }
282
+ // DM-409 lát mỏng: bảng timing LUÔN in (tiêu chí spec — không cần `--verbose`; trước đây chỉ
283
+ // in khi verbose, mâu thuẫn trực tiếp với "bảng ~10 dòng, giá trị cao hơn phiền toái").
284
+ log.info(formatTimings(timings));
285
+ log.info('build done');
286
+ return {
287
+ timings: Object.fromEntries(timings.map((t) => [t.stage, t.ms])),
288
+ cacheStats: cache.stats,
289
+ };
290
+ }
package/src/cache.ts ADDED
@@ -0,0 +1,172 @@
1
+ import { createHash } from 'node:crypto';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import type { PageMeta } from './markdown/index.js';
5
+ import { CODE_PIPELINE_VERSION } from './markdown/rehype-code.js';
6
+
7
+ // Đặc tả: docs/backlog/DM-406.md (+ Amendment: payload là { code, meta }, không cache HTML).
8
+ // Cache transform markdown theo NỘI DUNG (không phải mtime: git checkout đổi mtime dù nội dung y hệt).
9
+ // Chỉ dùng cho `dommief build`; dev chạy trong bộ nhớ Vite.
10
+
11
+ /** Bump khi đổi shape payload hoặc logic transform: mọi entry cũ bị bỏ qua, không phục vụ `code` cũ. */
12
+ export const CACHE_VERSION = 3; // v3: rename DumiDemo/DumiApi -> DomDemo/DomApi (aliases giữ lại)
13
+
14
+ export interface CachePayload {
15
+ code: string;
16
+ meta: PageMeta;
17
+ warnings: string[];
18
+ /** Demo của trang; thiếu = payload cũ, không dùng được (module trang import virtual demo). */
19
+ demos?: { id: string; source: string; src?: string }[];
20
+ }
21
+
22
+ /** Mọi thứ làm đổi output của MỘT trang. Thiếu một mục ở đây = bug "build cũ dính vào build mới". */
23
+ export interface KeyInput {
24
+ /** Nội dung file md. */
25
+ md: string;
26
+ /** Đường dẫn file (demo id + data-src-line + link tương đối phụ thuộc vào nó). */
27
+ file: string;
28
+ /** config.base: rehypeLink gắn tiền tố này vào href nội bộ. */
29
+ base: string;
30
+ /** Hash bảng route (byFile→path): đổi tên/thêm/xoá một file khác làm đổi href đã resolve của trang này. */
31
+ routesHash: string;
32
+ /** Option markdown (shiki themes, lineNumbers) + danh sách plugin. */
33
+ pipelineHash: string;
34
+ version?: number;
35
+ }
36
+
37
+ /** JSON ổn định: key object sắp xếp, không phụ thuộc thứ tự chèn (2 process cho cùng key). */
38
+ export function stableStringify(value: unknown): string {
39
+ if (Array.isArray(value)) return `[${value.map(stableStringify).join(',')}]`;
40
+ if (value && typeof value === 'object') {
41
+ const o = value as Record<string, unknown>;
42
+ return `{${Object.keys(o).sort().map((k) => `${JSON.stringify(k)}:${stableStringify(o[k])}`).join(',')}}`;
43
+ }
44
+ return JSON.stringify(value) ?? 'null';
45
+ }
46
+
47
+ const sha = (s: string) => createHash('sha256').update(s).digest('hex');
48
+
49
+ export function hashKey(input: KeyInput): string {
50
+ return sha(stableStringify({ ...input, version: input.version ?? CACHE_VERSION }));
51
+ }
52
+
53
+ export function computeRoutesHash(byFile: Record<string, { path: string }>): string {
54
+ return sha(stableStringify(Object.fromEntries(Object.entries(byFile).map(([f, r]) => [f, r.path]))));
55
+ }
56
+
57
+ /** Hash option markdown + plugin. Hàm plugin được băm theo mã nguồn (đổi plugin => miss). */
58
+ export function computePipelineHash(opts: { code?: unknown; plugins?: unknown[] }): string {
59
+ const plugins = (opts.plugins ?? []).map((p) => (typeof p === 'function' ? String(p) : stableStringify(p)));
60
+ return sha(stableStringify({ code: opts.code ?? null, plugins, codeVersion: CODE_PIPELINE_VERSION }));
61
+ }
62
+
63
+ export type MissReason = 'new' | 'md-changed' | 'config-changed' | 'pipeline-changed';
64
+
65
+ export interface CacheStats {
66
+ hit: number;
67
+ miss: number;
68
+ missReasons: Record<string, MissReason>;
69
+ }
70
+
71
+ interface ManifestEntry { md: string; context: string; key: string }
72
+ type Manifest = Record<string, ManifestEntry>;
73
+
74
+ export class TransformCache {
75
+ readonly stats: CacheStats = { hit: 0, miss: 0, missReasons: {} };
76
+ private readonly counted = new Set<string>();
77
+ private readonly used = new Set<string>();
78
+ private manifest: Manifest = {};
79
+ private nextManifest: Manifest = {};
80
+
81
+ constructor(readonly dir: string, private readonly enabled = true) {
82
+ if (!enabled) return;
83
+ try { this.manifest = JSON.parse(fs.readFileSync(path.join(dir, 'manifest.json'), 'utf8')); } catch { this.manifest = {}; }
84
+ }
85
+
86
+ private fileOf(key: string): string { return path.join(this.dir, key.slice(0, 2), `${key}.json`); }
87
+
88
+ read(key: string): CachePayload | null {
89
+ if (!this.enabled) return null;
90
+ try {
91
+ const v = JSON.parse(fs.readFileSync(this.fileOf(key), 'utf8')) as CachePayload;
92
+ return typeof v?.code === 'string' && v.meta ? v : null;
93
+ } catch { return null; }
94
+ }
95
+
96
+ /** Ghi nguyên tử: tmp cùng thư mục rồi rename (build bị kill giữa chừng không để lại JSON cụt; cùng ổ nên rename không fail trên Windows). */
97
+ write(key: string, payload: CachePayload): void {
98
+ if (!this.enabled) return;
99
+ const file = this.fileOf(key);
100
+ fs.mkdirSync(path.dirname(file), { recursive: true });
101
+ const tmp = `${file}.${process.pid}.tmp`;
102
+ fs.writeFileSync(tmp, JSON.stringify(payload));
103
+ fs.renameSync(tmp, file);
104
+ }
105
+
106
+ /** Xoá file cache không còn dùng (trang bị xoá/đổi tên/đổi nội dung). Best-effort, không throw. */
107
+ gc(keepKeys: Set<string>): void {
108
+ if (!this.enabled) return;
109
+ try {
110
+ for (const shard of fs.readdirSync(this.dir, { withFileTypes: true })) {
111
+ if (!shard.isDirectory()) continue;
112
+ for (const f of fs.readdirSync(path.join(this.dir, shard.name))) {
113
+ if (!keepKeys.has(f.replace(/\.json$/, '')) || f.endsWith('.tmp')) fs.rmSync(path.join(this.dir, shard.name, f), { force: true });
114
+ }
115
+ }
116
+ } catch { /* best-effort */ }
117
+ }
118
+
119
+ /**
120
+ * Lấy từ cache hoặc tính mới. Mỗi file chỉ tính vào `stats` ở lần tra ĐẦU: build chạy plugin 2 lần
121
+ * (client + SSR), lần 2 luôn hit vì lần 1 vừa ghi — đếm cả hai sẽ làm số liệu vô nghĩa.
122
+ */
123
+ async getOrCompute(file: string, input: KeyInput, compute: () => Promise<CachePayload>, cacheable = true): Promise<CachePayload> {
124
+ const key = hashKey(input);
125
+ const context = sha(stableStringify({ base: input.base, routesHash: input.routesHash }));
126
+ const first = !this.counted.has(file);
127
+ const hit = cacheable ? this.read(key) : null;
128
+ if (hit) {
129
+ if (first) this.stats.hit++;
130
+ this.record(file, key, input, context);
131
+ return hit;
132
+ }
133
+ if (first) {
134
+ this.stats.miss++;
135
+ this.stats.missReasons[file] = this.reasonFor(file, input, context);
136
+ }
137
+ this.counted.add(file);
138
+ const payload = await compute();
139
+ if (cacheable) { this.write(key, payload); this.record(file, key, input, context); }
140
+ return payload;
141
+ }
142
+
143
+ private record(file: string, key: string, input: KeyInput, context: string): void {
144
+ this.counted.add(file);
145
+ this.used.add(key);
146
+ this.nextManifest[file] = { md: sha(input.md), context: `${context}|${input.pipelineHash}`, key };
147
+ }
148
+
149
+ private reasonFor(file: string, input: KeyInput, context: string): MissReason {
150
+ const prev = this.manifest[file];
151
+ if (!prev) return 'new';
152
+ if (prev.md !== sha(input.md)) return 'md-changed';
153
+ const [prevCtx, prevPipe] = [prev.context.split('|')[0], prev.context.split('|')[1]];
154
+ if (prevPipe !== input.pipelineHash) return 'pipeline-changed';
155
+ return prevCtx !== context ? 'config-changed' : 'md-changed';
156
+ }
157
+
158
+ /** Cuối build: ghi manifest + gc theo các key vừa dùng. Chỉ giữ entry của file còn tồn tại. */
159
+ finish(): void {
160
+ if (!this.enabled) return;
161
+ this.gc(this.used);
162
+ try {
163
+ fs.mkdirSync(this.dir, { recursive: true });
164
+ fs.writeFileSync(path.join(this.dir, 'manifest.json'), JSON.stringify(this.nextManifest));
165
+ } catch { /* best-effort */ }
166
+ }
167
+
168
+ summary(): string {
169
+ const total = this.stats.hit + this.stats.miss;
170
+ return `cache ${this.stats.hit}/${total} hit`;
171
+ }
172
+ }
package/src/cli.ts ADDED
@@ -0,0 +1,87 @@
1
+ import path from 'node:path';
2
+ import { cac } from 'cac';
3
+ import { createServer } from 'vite';
4
+ import { dommiefPlugin } from './vite/plugin.js';
5
+ import { createLogger } from './log.js';
6
+ import { loadConfig } from './config.js';
7
+ import { createPluginHost } from './plugins.js';
8
+ import { runBuild, LinkCheckFailedError } from './build.js';
9
+ import { startPreview } from './preview.js';
10
+ import { DommiefError, formatError } from './markdown/errors.js';
11
+
12
+ const cli = cac('dommief');
13
+
14
+ cli.command('dev', 'start dev server')
15
+ .option('--verbose', 'verbose logging')
16
+ .option('--base <base>', 'public base path (vd /docs/)')
17
+ .option('--port <port>', 'dev server port')
18
+ .action(async (opts: { verbose?: boolean; base?: string; port?: number | string }) => {
19
+ const log = createLogger(!!opts.verbose);
20
+ const root = process.cwd();
21
+ log.debug('root=' + root);
22
+ const config = await loadConfig(root, { base: opts.base });
23
+ const host = createPluginHost(config.plugins ?? []);
24
+ const server = await createServer({
25
+ root,
26
+ plugins: [dommiefPlugin({ root, config, host })],
27
+ server: { port: Number(opts.port ?? config.dev.port) },
28
+ });
29
+ await server.listen();
30
+ server.printUrls();
31
+ });
32
+
33
+ cli.command('build', 'build site')
34
+ .option('--verbose', 'verbose logging')
35
+ .option('--base <base>', 'public base path (vd /docs/)')
36
+ .option('--out-dir <dir>', 'output directory')
37
+ .option('--no-cache', 'bỏ qua cache transform')
38
+ .option('--no-link-check', 'không kiểm link nội bộ chết')
39
+ .action(async (opts: { verbose?: boolean; base?: string; outDir?: string; cache?: boolean; linkCheck?: boolean }) => {
40
+ const log = createLogger(!!opts.verbose);
41
+ const root = process.cwd();
42
+ log.debug('root=' + root);
43
+ const config = await loadConfig(root, { base: opts.base, outDir: opts.outDir, cache: opts.cache, linkCheck: opts.linkCheck });
44
+ const host = createPluginHost(config.plugins ?? []);
45
+ try {
46
+ await runBuild({ root, config, host, log });
47
+ } catch (e) {
48
+ if (e instanceof LinkCheckFailedError) {
49
+ log.error('link nội bộ chết:\n' + e.report);
50
+ process.exitCode = 1;
51
+ return;
52
+ }
53
+ throw e;
54
+ }
55
+ });
56
+
57
+ cli.command('preview', 'preview built site')
58
+ .option('--base <base>', 'public base path (vd /docs/)')
59
+ .option('--out-dir <dir>', 'output directory')
60
+ .option('--port <port>', 'preview server port')
61
+ .option('--host [host]', 'expose ra network')
62
+ .option('--open', 'mở trình duyệt')
63
+ .action(async (opts: { base?: string; outDir?: string; port?: number | string; host?: string | boolean; open?: boolean }) => {
64
+ const root = process.cwd();
65
+ const config = await loadConfig(root, { base: opts.base, outDir: opts.outDir });
66
+ try {
67
+ const server = await startPreview({
68
+ root,
69
+ base: config.base,
70
+ outDir: path.resolve(root, config.outDir),
71
+ ...(opts.port !== undefined ? { port: Number(opts.port) } : {}),
72
+ ...(opts.host !== undefined ? { host: opts.host } : {}),
73
+ ...(opts.open !== undefined ? { open: opts.open } : {}),
74
+ });
75
+ server.printUrls();
76
+ } catch (e) {
77
+ if (e instanceof DommiefError) {
78
+ console.error('[error] ' + formatError(e));
79
+ process.exitCode = 1;
80
+ return;
81
+ }
82
+ throw e;
83
+ }
84
+ });
85
+
86
+ cli.help();
87
+ cli.parse();