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
package/src/config.ts
ADDED
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { loadConfigFromFile } from 'vite';
|
|
5
|
+
import { DommiefError } from './markdown/errors.js';
|
|
6
|
+
import type { ResolvedLocale, RouteConfig } from './types.js';
|
|
7
|
+
import { createPluginHost } from './plugins.js';
|
|
8
|
+
import type { DommiefPlugin } from './plugins.js';
|
|
9
|
+
|
|
10
|
+
export type { ResolvedLocale } from './types.js';
|
|
11
|
+
|
|
12
|
+
// Subset of CONFIG §1 needed by Phase 2 (DM-201). DM-901 extends it without restructuring.
|
|
13
|
+
const LocaleInputSchema = z.object({
|
|
14
|
+
id: z.string().min(1).refine(
|
|
15
|
+
(s) => { try { Intl.getCanonicalLocales(s); return true; } catch { return false; } },
|
|
16
|
+
'phải là mã locale BCP 47 (vd "en-US", "vi-VN"; không dùng "_")',
|
|
17
|
+
),
|
|
18
|
+
name: z.string().optional(),
|
|
19
|
+
base: z.string().startsWith('/', 'phải bắt đầu bằng "/"').optional(),
|
|
20
|
+
suffix: z.string().optional(),
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
export const ConfigSchema = z
|
|
24
|
+
.object({
|
|
25
|
+
title: z.string().default('Dommief'),
|
|
26
|
+
description: z.string().optional(),
|
|
27
|
+
base: z.string().startsWith('/', 'phải bắt đầu bằng "/" (URL tuyệt đối không được hỗ trợ)').default('/'),
|
|
28
|
+
outDir: z.string().default('dist'),
|
|
29
|
+
srcDir: z.string().default('docs'),
|
|
30
|
+
/** Khi `library`, tự động quét `src/` để hỗ trợ atom routing cho component library. */
|
|
31
|
+
preset: z.enum(['docs', 'library']).default('docs'),
|
|
32
|
+
docDirs: z.array(z.union([z.string(), z.object({ dir: z.string() })])).optional(),
|
|
33
|
+
forceKebabCase: z.boolean().default(true),
|
|
34
|
+
locales: z.array(LocaleInputSchema).default([{ id: 'en-US', name: 'English' }]),
|
|
35
|
+
themeConfig: z.record(z.string(), z.unknown()).default({}),
|
|
36
|
+
theme: z.string().default('dommief-theme-default'),
|
|
37
|
+
ssg: z
|
|
38
|
+
.object({
|
|
39
|
+
concurrency: z.number().int().positive().default(4),
|
|
40
|
+
/** Route không prerender nhưng VẪN phát shell (REVIEW §1 #17). Pattern: path chính xác hoặc `/prefix/*`. */
|
|
41
|
+
exclude: z.array(z.string()).default([]),
|
|
42
|
+
/** Route không phát file nào. */
|
|
43
|
+
noEmit: z.array(z.string()).default([]),
|
|
44
|
+
})
|
|
45
|
+
.default({ concurrency: 4, exclude: [], noEmit: [] }),
|
|
46
|
+
/** Cache transform md theo content-hash (DM-406). `--no-cache` tắt. */
|
|
47
|
+
cache: z.boolean().default(true),
|
|
48
|
+
/** Kiểm link nội bộ chết lúc build (DM-410). `--no-link-check` tắt. */
|
|
49
|
+
linkCheck: z.boolean().default(true),
|
|
50
|
+
dev: z.object({ port: z.number().int().positive().default(5173) }).default({ port: 5173 }),
|
|
51
|
+
/** sitemap.xml + robots.txt (DM-708, CONFIG §1.5). `false` (mặc định) = không phát; khai
|
|
52
|
+
* object thì `hostname` BẮT BUỘC (không đoán domain). */
|
|
53
|
+
sitemap: z
|
|
54
|
+
.union([z.literal(false), z.object({ hostname: z.string().url(), exclude: z.array(z.string()).default([]) })])
|
|
55
|
+
.default(false),
|
|
56
|
+
/** Ghi `robots.txt` trỏ tới sitemap. Mặc định: true nếu có sitemap, ngược lại false. */
|
|
57
|
+
robots: z.boolean().optional(),
|
|
58
|
+
/** Shiki lúc build + số dòng (CONFIG §1, DM-706). `shiki: false` = code thô. */
|
|
59
|
+
markdown: z
|
|
60
|
+
.object({
|
|
61
|
+
shiki: z.union([z.literal(false), z.object({ themes: z.object({ light: z.string(), dark: z.string() }) })]).default({ themes: { light: 'github-light', dark: 'github-dark' } }),
|
|
62
|
+
lineNumbers: z.boolean().default(false),
|
|
63
|
+
})
|
|
64
|
+
.default({ shiki: { themes: { light: 'github-light', dark: 'github-dark' } }, lineNumbers: false }),
|
|
65
|
+
/** Search index (DM-801). */
|
|
66
|
+
search: z.object({ enabled: z.boolean().default(true) }).default({ enabled: true }),
|
|
67
|
+
plugins: z.array(z.any()).default([]),
|
|
68
|
+
})
|
|
69
|
+
.strict();
|
|
70
|
+
|
|
71
|
+
/** What the user writes in dommief.config.ts (input type: optional fields, locale union fields). */
|
|
72
|
+
export type UserConfig = z.input<typeof ConfigSchema>;
|
|
73
|
+
|
|
74
|
+
export interface ResolvedConfig extends RouteConfig {
|
|
75
|
+
/** Absolute cwd the config was resolved against. */
|
|
76
|
+
root: string;
|
|
77
|
+
title: string;
|
|
78
|
+
description?: string;
|
|
79
|
+
base: string;
|
|
80
|
+
outDir: string;
|
|
81
|
+
themeConfig: Record<string, unknown>;
|
|
82
|
+
theme: string;
|
|
83
|
+
ssg: { concurrency: number; exclude: string[]; noEmit: string[] };
|
|
84
|
+
cache: boolean;
|
|
85
|
+
linkCheck: boolean;
|
|
86
|
+
dev: { port: number };
|
|
87
|
+
sitemap: false | { hostname: string; exclude: string[] };
|
|
88
|
+
robots: boolean;
|
|
89
|
+
search: { enabled: boolean };
|
|
90
|
+
markdown: { shiki: false | { themes: { light: string; dark: string } }; lineNumbers: boolean };
|
|
91
|
+
plugins?: DommiefPlugin[];
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export interface ResolveOptions {
|
|
95
|
+
cwd: string;
|
|
96
|
+
cli?: { base?: string | undefined; outDir?: string | undefined; cache?: boolean | undefined; linkCheck?: boolean | undefined };
|
|
97
|
+
env?: Record<string, string | undefined>;
|
|
98
|
+
/** Path of the loaded config file; only used to name the source in errors. */
|
|
99
|
+
configFile?: string | undefined;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function defineConfig(config: UserConfig): UserConfig {
|
|
103
|
+
return config;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const CONFIG_FILES = ['dommief.config.ts', 'dommief.config.mts', 'dommief.config.js', 'dommief.config.mjs'];
|
|
107
|
+
|
|
108
|
+
export async function loadConfigFile(opts: { cwd: string; configPath?: string }): Promise<{ path: string; config: UserConfig } | null> {
|
|
109
|
+
let file: string | undefined;
|
|
110
|
+
if (opts.configPath) {
|
|
111
|
+
file = path.resolve(opts.cwd, opts.configPath);
|
|
112
|
+
if (!fs.existsSync(file)) throw new DommiefError(`không tìm thấy file config: ${file}`, { code: 'config_not_found', file });
|
|
113
|
+
} else {
|
|
114
|
+
file = CONFIG_FILES.map((f) => path.join(opts.cwd, f)).find((p) => fs.existsSync(p));
|
|
115
|
+
if (!file) return null;
|
|
116
|
+
}
|
|
117
|
+
const loaded = await loadConfigFromFile({ command: 'serve', mode: 'development' }, file, opts.cwd, 'silent');
|
|
118
|
+
if (!loaded) return null;
|
|
119
|
+
return { path: loaded.path, config: loaded.config as UserConfig };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function normalizeBase(base: string): string {
|
|
123
|
+
return base.endsWith('/') ? base : base + '/';
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function resolveConfig(userConfig: UserConfig, opts: ResolveOptions): ResolvedConfig {
|
|
127
|
+
const fileLabel = opts.configFile ? path.basename(opts.configFile) : 'dommief.config';
|
|
128
|
+
const sources: Record<string, string> = {};
|
|
129
|
+
for (const k of Object.keys(userConfig ?? {})) sources[k] = fileLabel;
|
|
130
|
+
|
|
131
|
+
// ponytail: shallow merge is exact while CLI/env only set top-level scalars (base, outDir).
|
|
132
|
+
// DM-901 adds sitemap.hostname / ssg.concurrency → switch to deep merge (objects deep, arrays replace).
|
|
133
|
+
let merged: Record<string, unknown> = { ...userConfig };
|
|
134
|
+
const layer = (key: string, value: string | undefined, source: string) => {
|
|
135
|
+
if (value === undefined || value === '') return;
|
|
136
|
+
merged[key] = value;
|
|
137
|
+
sources[key] = source;
|
|
138
|
+
};
|
|
139
|
+
// cac: `--no-cache` ⇒ cache === false. Chỉ `false` mới ghi đè; undefined/true giữ giá trị file.
|
|
140
|
+
if (opts.cli?.cache === false) { merged.cache = false; sources.cache = '--no-cache'; }
|
|
141
|
+
if (opts.cli?.linkCheck === false) { merged.linkCheck = false; sources.linkCheck = '--no-link-check'; }
|
|
142
|
+
layer('base', opts.cli?.base, '--base');
|
|
143
|
+
layer('outDir', opts.cli?.outDir, '--out-dir');
|
|
144
|
+
layer('base', opts.env?.DOMMIEF_BASE, 'DOMMIEF_BASE'); // env beats CLI (CONFIG §3.1) — on purpose
|
|
145
|
+
layer('outDir', opts.env?.DOMMIEF_OUT_DIR, 'DOMMIEF_OUT_DIR');
|
|
146
|
+
|
|
147
|
+
const fail = (keyPath: (string | number)[], message: string): never => {
|
|
148
|
+
const top = String(keyPath[0] ?? '');
|
|
149
|
+
const value = keyPath.reduce<unknown>((v, k) => (v as Record<string | number, unknown> | undefined)?.[k], merged);
|
|
150
|
+
const shown = value === undefined ? '(thiếu)' : JSON.stringify(value);
|
|
151
|
+
throw new DommiefError(
|
|
152
|
+
`cấu hình không hợp lệ\n ${formatKeyPath(keyPath)} ${shown}\n ${message}\n (nguồn: ${sources[top] ?? 'default'})`,
|
|
153
|
+
{ code: 'config_invalid' },
|
|
154
|
+
);
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
const pluginHost = createPluginHost(Array.isArray(merged.plugins) ? merged.plugins : []);
|
|
158
|
+
const modified = pluginHost.applyModifyConfig(merged);
|
|
159
|
+
if (modified && typeof modified === 'object') merged = { ...merged, ...(modified as Record<string, unknown>) };
|
|
160
|
+
|
|
161
|
+
const parsed = ConfigSchema.safeParse(merged);
|
|
162
|
+
if (!parsed.success) {
|
|
163
|
+
const issue = parsed.error.issues[0]!;
|
|
164
|
+
if (issue.code === 'unrecognized_keys') {
|
|
165
|
+
const key = issue.keys[0]!;
|
|
166
|
+
const hint = suggestKey(key);
|
|
167
|
+
fail([key], `key không được hỗ trợ${hint ? `; bạn muốn nói "${hint}"?` : ''}`);
|
|
168
|
+
}
|
|
169
|
+
fail(issue.path as (string | number)[], issue.message);
|
|
170
|
+
}
|
|
171
|
+
const c = parsed.data!;
|
|
172
|
+
|
|
173
|
+
const locales = resolveLocales(c.locales, fail);
|
|
174
|
+
const config: ResolvedConfig = {
|
|
175
|
+
root: opts.cwd,
|
|
176
|
+
title: c.title,
|
|
177
|
+
base: normalizeBase(c.base),
|
|
178
|
+
outDir: c.outDir,
|
|
179
|
+
srcDir: c.srcDir,
|
|
180
|
+
docDirs: (c.docDirs ?? [c.srcDir]).map((d) => (typeof d === 'string' ? { dir: d } : { dir: d.dir })),
|
|
181
|
+
forceKebabCase: c.forceKebabCase,
|
|
182
|
+
preset: c.preset,
|
|
183
|
+
locales,
|
|
184
|
+
themeConfig: c.themeConfig,
|
|
185
|
+
theme: c.theme,
|
|
186
|
+
ssg: c.ssg,
|
|
187
|
+
cache: c.cache,
|
|
188
|
+
linkCheck: c.linkCheck,
|
|
189
|
+
dev: c.dev,
|
|
190
|
+
sitemap: c.sitemap,
|
|
191
|
+
robots: c.robots ?? c.sitemap !== false,
|
|
192
|
+
search: c.search,
|
|
193
|
+
markdown: c.markdown,
|
|
194
|
+
plugins: (c.plugins ?? []) as DommiefPlugin[],
|
|
195
|
+
};
|
|
196
|
+
if (c.description !== undefined) config.description = c.description;
|
|
197
|
+
return config;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Load file at `root` + resolve with process env (and optional CLI flags). */
|
|
201
|
+
export async function loadConfig(root: string, cli?: ResolveOptions['cli']): Promise<ResolvedConfig> {
|
|
202
|
+
const file = await loadConfigFile({ cwd: root });
|
|
203
|
+
return resolveConfig(file?.config ?? {}, { cwd: root, env: process.env, configFile: file?.path, ...(cli ? { cli } : {}) });
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function resolveLocales(
|
|
207
|
+
input: z.output<typeof LocaleInputSchema>[],
|
|
208
|
+
fail: (keyPath: (string | number)[], message: string) => never,
|
|
209
|
+
): ResolvedLocale[] {
|
|
210
|
+
// Mode is decided by locales[0] (CONFIG §1.2.1: base and suffix modes are mutually exclusive).
|
|
211
|
+
const suffixMode = input[0]?.suffix !== undefined;
|
|
212
|
+
const seen = new Set<string>();
|
|
213
|
+
return input.map((l, i) => {
|
|
214
|
+
if (seen.has(l.id)) fail(['locales', i, 'id'], `id "${l.id}" bị trùng`);
|
|
215
|
+
seen.add(l.id);
|
|
216
|
+
const name = l.name ?? l.id;
|
|
217
|
+
if (suffixMode) {
|
|
218
|
+
if (l.base !== undefined || l.suffix === undefined) {
|
|
219
|
+
fail(['locales', i], 'không trộn "base" và "suffix": locales[0] dùng chế độ suffix nên mọi locale phải khai "suffix" và không khai "base"');
|
|
220
|
+
}
|
|
221
|
+
return { id: l.id, name, base: '/', suffix: l.suffix! };
|
|
222
|
+
}
|
|
223
|
+
if (l.suffix !== undefined) {
|
|
224
|
+
fail(['locales', i], 'không trộn "base" và "suffix": locales[0] dùng chế độ base nên không locale nào được khai "suffix"');
|
|
225
|
+
}
|
|
226
|
+
const base = normalizeBase(l.base ?? (i === 0 ? '/' : `/${l.id}/`));
|
|
227
|
+
if (i > 0 && base === '/') {
|
|
228
|
+
fail(['locales', i, 'base'], `chỉ locale đầu tiên được dùng base "/". Đưa "${l.id}" lên đầu mảng hoặc đổi base.`);
|
|
229
|
+
}
|
|
230
|
+
return { id: l.id, name, base, suffix: '' };
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function formatKeyPath(keyPath: (string | number)[]): string {
|
|
235
|
+
return keyPath.map((k, i) => (typeof k === 'number' ? `[${k}]` : i === 0 ? k : `.${k}`)).join('');
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function suggestKey(key: string): string | undefined {
|
|
239
|
+
let best: string | undefined;
|
|
240
|
+
let bestDist = 3; // only suggest within edit distance ≤ 2
|
|
241
|
+
for (const known of Object.keys(ConfigSchema.shape)) {
|
|
242
|
+
const d = editDistance(key, known);
|
|
243
|
+
if (d < bestDist) [best, bestDist] = [known, d];
|
|
244
|
+
}
|
|
245
|
+
return best;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function editDistance(a: string, b: string): number {
|
|
249
|
+
let prev = Array.from({ length: b.length + 1 }, (_, j) => j);
|
|
250
|
+
for (let i = 1; i <= a.length; i++) {
|
|
251
|
+
const cur = [i];
|
|
252
|
+
for (let j = 1; j <= b.length; j++) {
|
|
253
|
+
cur[j] = Math.min(prev[j]! + 1, cur[j - 1]! + 1, prev[j - 1]! + (a[i - 1] === b[j - 1] ? 0 : 1));
|
|
254
|
+
}
|
|
255
|
+
prev = cur;
|
|
256
|
+
}
|
|
257
|
+
return prev[b.length]!;
|
|
258
|
+
}
|
package/src/exclude.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// Đặc tả: docs/backlog/DM-411.md. Khớp pattern trên ROUTE PATH (vd `/~demos/foo`), không phải
|
|
2
|
+
// đường dẫn file trên đĩa. Chỉ cần `*` (một segment) và `**` (nhiều segment) nên tự chuyển glob
|
|
3
|
+
// sang RegExp thay vì thêm dependency.
|
|
4
|
+
|
|
5
|
+
const cache = new Map<string, RegExp>();
|
|
6
|
+
|
|
7
|
+
/** `/~demos/*` -> /^\/~demos\/[^/]*$/ ; `**\/b` -> /^(?:.*\/)?b$/ */
|
|
8
|
+
export function globToRegExp(pattern: string): RegExp {
|
|
9
|
+
let src = '';
|
|
10
|
+
for (let i = 0; i < pattern.length; i++) {
|
|
11
|
+
const c = pattern.charAt(i);
|
|
12
|
+
if (c === '*' && pattern.charAt(i + 1) === '*') {
|
|
13
|
+
const slash = pattern.charAt(i + 2) === '/';
|
|
14
|
+
src += slash ? '(?:.*/)?' : '.*';
|
|
15
|
+
i += slash ? 2 : 1;
|
|
16
|
+
} else if (c === '*') {
|
|
17
|
+
src += '[^/]*';
|
|
18
|
+
} else if (c === '?') {
|
|
19
|
+
src += '[^/]';
|
|
20
|
+
} else {
|
|
21
|
+
src += c.replace(/[.+^${}()|[\]\\]/g, '\\$&');
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return new RegExp(`^${src}$`);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function matcher(pattern: string): RegExp {
|
|
28
|
+
const hit = cache.get(pattern);
|
|
29
|
+
if (hit) return hit;
|
|
30
|
+
const re = globToRegExp(pattern);
|
|
31
|
+
cache.set(pattern, re);
|
|
32
|
+
return re;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** `matchExclude('/~demos/foo', ['/~demos/*']) === true`. Pattern rỗng -> không khớp gì. */
|
|
36
|
+
export function matchExclude(path: string, patterns: string[]): boolean {
|
|
37
|
+
return patterns.some((p) => matcher(p).test(path));
|
|
38
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export { defineConfig, loadConfig, loadConfigFile, resolveConfig } from './config.js';
|
|
2
|
+
export type { ResolvedConfig, UserConfig } from './config.js';
|
|
3
|
+
export { createPluginHost, registerPlugin, definePlugin, PluginHookHost, Hook } from './plugins.js';
|
|
4
|
+
export type { DommiefPlugin, DommiefPluginObject, PluginSetup, BuildEndContext, PluginHookHost as PluginHooks } from './plugins.js';
|
|
5
|
+
export { buildRouteTable } from './routes.js';
|
|
6
|
+
export type { Route, RouteTable, RouteConfig, SiteData, ResolvedLocale } from './types.js';
|
|
7
|
+
export { buildNav, buildSidebar, paginate, parseFrontmatter } from './siteFeatures.js';
|
|
8
|
+
export type { FrontmatterMap, PaginationResult } from './siteFeatures.js';
|
|
9
|
+
export { transformMarkdown } from './markdown/index.js';
|
|
10
|
+
export { rehypeApi, buildApiTable, clearApiCache, hashFileContent } from './markdown/rehype-api.js';
|
|
11
|
+
export type { ApiEntry, ApiProp } from './markdown/rehype-api.js';
|
|
12
|
+
export { DommiefError, formatError } from './markdown/errors.js';
|
|
13
|
+
export type { PageMeta, TocItem, TextFragment } from './markdown/index.js';
|
|
14
|
+
export { createLogger } from './log.js';
|
|
15
|
+
export type { Logger } from './log.js';
|
|
16
|
+
export { buildSearchIndex, createMiniSearchConfig, INDEX_BUDGET_BYTES_PER_PAGE } from './search.js';
|
|
17
|
+
export type { BuildSearchIndexOptions, BuildSearchIndexResult, MiniSearchConfig, SearchDocument } from './search.js';
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import type { TocItem } from './markdown/index.js';
|
|
4
|
+
|
|
5
|
+
// Đặc tả: docs/backlog/DM-410.md. Neo vào HTML ĐÃ RENDER (không phải AST markdown): bắt được
|
|
6
|
+
// cả href do theme/nav sinh, không chỉ link tác giả viết tay. Không fetch mạng (build tất định,
|
|
7
|
+
// chạy offline được) — chỉ kiểm link nội bộ + asset.
|
|
8
|
+
|
|
9
|
+
export interface LinkRef {
|
|
10
|
+
href: string;
|
|
11
|
+
routeId: string;
|
|
12
|
+
file: string;
|
|
13
|
+
/** `—` khi HTML không có `data-src-line` (href do raw HTML nhúng tay, không qua rehypeLink). */
|
|
14
|
+
line: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export type LinkIssueCode = 'link_target_missing' | 'link_anchor_missing' | 'link_asset_missing';
|
|
18
|
+
export interface LinkIssue { code: LinkIssueCode; ref: LinkRef; }
|
|
19
|
+
|
|
20
|
+
const HREF_RE = /<a\b[^>]*\shref="([^"]*)"[^>]*>/g;
|
|
21
|
+
const ASSET_RE = /<(?:img|link)\b[^>]*\s(?:src|href)="([^"]*)"[^>]*>/g;
|
|
22
|
+
const LINE_RE = /\sdata-src-line="(\d+)"/;
|
|
23
|
+
const EXTERNAL = /^(?:[a-z][a-z0-9+.-]*:|\/\/)/i; // http:, mailto:, //cdn — không phải '/' (nội bộ) hay '#' (anchor)
|
|
24
|
+
|
|
25
|
+
function extractRefs(re: RegExp, html: string, routeId: string, file: string): LinkRef[] {
|
|
26
|
+
const out: LinkRef[] = [];
|
|
27
|
+
let m: RegExpExecArray | null;
|
|
28
|
+
re.lastIndex = 0;
|
|
29
|
+
while ((m = re.exec(html))) {
|
|
30
|
+
const tag = m[0];
|
|
31
|
+
const href = m[1]!;
|
|
32
|
+
if (!href || href.startsWith('#') || EXTERNAL.test(href)) continue;
|
|
33
|
+
const line = tag.match(LINE_RE)?.[1] ?? '—';
|
|
34
|
+
out.push({ href, routeId, file, line });
|
|
35
|
+
}
|
|
36
|
+
return out;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Thu link `<a href>` nội bộ + asset `<img src>`/`<link href>` từ HTML đã render của một route. */
|
|
40
|
+
export function collect(routeId: string, html: string, file: string): LinkRef[] {
|
|
41
|
+
return [...extractRefs(HREF_RE, html, routeId, file), ...extractRefs(ASSET_RE, html, routeId, file)];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
export interface CheckOptions {
|
|
47
|
+
byPath: Record<string, { path: string }>;
|
|
48
|
+
/** routeId -> toc; thiếu nghĩa là route bị `ssg.exclude` (không prerender) — bỏ qua anchor check (§ ca h). */
|
|
49
|
+
tocByRoute: Record<string, TocItem[]>;
|
|
50
|
+
base: string;
|
|
51
|
+
/** `outDir` đã BUILD XONG (không phải `public/` nguồn): asset tĩnh được Vite copy thẳng,
|
|
52
|
+
* asset tương đối qua `rehypeImg` được Vite băm tên vào `assets/` — cả 2 chỉ tồn tại ở `outDir`. */
|
|
53
|
+
outDir: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** `/sub/guide/intro` (base `/sub/`) -> `/guide/intro`. Bỏ base không idempotent: `/sub/sub/x` vẫn thiếu base lần 2 -> vẫn lỗi route, đúng ý DM-410 ca (e). */
|
|
57
|
+
function stripBase(p: string, base: string): string {
|
|
58
|
+
const b = base.replace(/\/$/, '');
|
|
59
|
+
if (b && p.startsWith(b + '/')) return p.slice(b.length) || '/';
|
|
60
|
+
if (b && p === b) return '/';
|
|
61
|
+
return p;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function check(refs: LinkRef[], opts: CheckOptions): LinkIssue[] {
|
|
65
|
+
const issues: LinkIssue[] = [];
|
|
66
|
+
for (const ref of refs) {
|
|
67
|
+
const isAsset = !!ref.href.match(/\.(png|jpe?g|gif|svg|webp|avif|ico|css|woff2?|ttf)(\?.*)?$/i);
|
|
68
|
+
const [rawPath, hash] = ref.href.split('#');
|
|
69
|
+
const cleanPath = (rawPath ?? '').split('?')[0]!;
|
|
70
|
+
if (isAsset) {
|
|
71
|
+
const relPath = stripBase(cleanPath, opts.base) || '/';
|
|
72
|
+
const onDisk = path.join(opts.outDir, decodeURIComponent(relPath));
|
|
73
|
+
if (!fs.existsSync(onDisk)) issues.push({ code: 'link_asset_missing', ref });
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
const routePath = stripBase(cleanPath, opts.base) || '/';
|
|
77
|
+
const target = opts.byPath[routePath];
|
|
78
|
+
if (!target) {
|
|
79
|
+
issues.push({ code: 'link_target_missing', ref });
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
if (hash) {
|
|
83
|
+
const toc = opts.tocByRoute[target.path] ?? opts.tocByRoute[routePath];
|
|
84
|
+
// route chưa prerender (ssg.exclude) -> không có toc -> không kiểm (không báo lỗi giả, ca h).
|
|
85
|
+
if (toc !== undefined && !toc.some((t) => t.id === hash)) {
|
|
86
|
+
issues.push({ code: 'link_anchor_missing', ref });
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return issues;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const MAX_LINES = 20;
|
|
94
|
+
|
|
95
|
+
/** Gom 1 lượt, tối đa 20 dòng; không log rải rác lúc kiểm (dễ đọc hơn khi có nhiều lỗi). */
|
|
96
|
+
export function formatIssues(issues: LinkIssue[]): string {
|
|
97
|
+
const lines = issues.slice(0, MAX_LINES).map((i) => `${i.ref.file}:${i.ref.line} ${i.ref.href} (${i.code})`);
|
|
98
|
+
const rest = issues.length - lines.length;
|
|
99
|
+
if (rest > 0) lines.push(`… và ${rest} lỗi nữa`);
|
|
100
|
+
return lines.join('\n');
|
|
101
|
+
}
|
package/src/log.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export interface Logger { verbose: boolean; info(msg: string): void; debug(msg: string): void; warn(msg: string): void; error(msg: string): void; }
|
|
2
|
+
export function createLogger(verbose = false): Logger {
|
|
3
|
+
return {
|
|
4
|
+
verbose,
|
|
5
|
+
info: (m) => console.log(m),
|
|
6
|
+
debug: (m) => { if (verbose) console.log('[debug]', m); },
|
|
7
|
+
warn: (m) => console.warn('[warn]', m),
|
|
8
|
+
error: (m) => console.error('[error]', m),
|
|
9
|
+
};
|
|
10
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export class DommiefError extends Error {
|
|
2
|
+
file: string;
|
|
3
|
+
line?: number | undefined;
|
|
4
|
+
col?: number | undefined;
|
|
5
|
+
code?: string | undefined;
|
|
6
|
+
layout?: string | undefined;
|
|
7
|
+
constructor(message: string, opts: { file?: string; line?: number; col?: number; code?: string; layout?: string } = {}) {
|
|
8
|
+
super(message);
|
|
9
|
+
this.name = 'DommiefError';
|
|
10
|
+
this.file = opts.file ?? '';
|
|
11
|
+
this.line = opts.line;
|
|
12
|
+
this.col = opts.col;
|
|
13
|
+
this.code = opts.code;
|
|
14
|
+
this.layout = opts.layout;
|
|
15
|
+
}
|
|
16
|
+
format(): string {
|
|
17
|
+
const loc = this.line != null ? `:${this.line}${this.col != null ? ':' + this.col : ''}` : '';
|
|
18
|
+
const head = this.file ? `${this.file}${loc}` : loc.slice(1) || '';
|
|
19
|
+
return head ? `${head}: ${this.message}` : this.message;
|
|
20
|
+
}
|
|
21
|
+
toString() { return this.format(); }
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function formatError(err: unknown): string {
|
|
25
|
+
if (err instanceof DommiefError) return err.format();
|
|
26
|
+
if (err instanceof Error) return err.message;
|
|
27
|
+
return String(err);
|
|
28
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { unified } from 'unified';
|
|
2
|
+
import remarkParse from 'remark-parse';
|
|
3
|
+
import remarkFrontmatter from 'remark-frontmatter';
|
|
4
|
+
import remarkGfm from 'remark-gfm';
|
|
5
|
+
import remarkDirective from 'remark-directive';
|
|
6
|
+
import remarkRehype from 'remark-rehype';
|
|
7
|
+
import rehypeRaw from 'rehype-raw';
|
|
8
|
+
import { visit } from 'unist-util-visit';
|
|
9
|
+
import type { Root as HastRoot } from 'hast';
|
|
10
|
+
import { remarkMeta } from './remark-meta.js';
|
|
11
|
+
import { rehypeSlug, rehypeAutolinkHeadings } from './rehype-slug.js';
|
|
12
|
+
import { rehypeLink } from './rehype-link.js';
|
|
13
|
+
import { rehypeImg } from './rehype-img.js';
|
|
14
|
+
import { rehypeDesc, rehypeStrip } from './rehype-desc.js';
|
|
15
|
+
import { rehypeText } from './rehype-text.js';
|
|
16
|
+
import { rehypeDemo, remarkDemoMeta } from './rehype-demo.js';
|
|
17
|
+
import { rehypeApi } from './rehype-api.js';
|
|
18
|
+
import { remarkContainer } from './remark-container.js';
|
|
19
|
+
import { remarkCodeMeta, rehypeCode, type RehypeCodeOptions } from './rehype-code.js';
|
|
20
|
+
import { toModule } from './to-module.js';
|
|
21
|
+
import { DommiefError } from './errors.js';
|
|
22
|
+
import { createPluginHost, type PluginHookHost } from '../plugins.js';
|
|
23
|
+
|
|
24
|
+
export interface TocItem { id: string; depth: number; title: string; }
|
|
25
|
+
export interface TextFragment { type?: 'content' | 'title'; value: string; paraId?: number; tocIndex?: number; }
|
|
26
|
+
export interface PageMeta {
|
|
27
|
+
frontmatter: Record<string, unknown>;
|
|
28
|
+
toc: TocItem[];
|
|
29
|
+
texts: TextFragment[];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface TransformMarkdownOptions {
|
|
33
|
+
remarkPlugins?: unknown[];
|
|
34
|
+
rehypePlugins?: unknown[];
|
|
35
|
+
hooks?: PluginHookHost;
|
|
36
|
+
plugins?: unknown[];
|
|
37
|
+
/** file md tuyệt đối → route; để `rehypeLink` đổi `./x.md` thành path route thật. */
|
|
38
|
+
byFile?: Record<string, { path: string }>;
|
|
39
|
+
/** `config.base` để gắn tiền tố cho link nội bộ. */
|
|
40
|
+
base?: string;
|
|
41
|
+
/** `config.markdown` (shiki + lineNumbers), DM-706. */
|
|
42
|
+
code?: RehypeCodeOptions;
|
|
43
|
+
}
|
|
44
|
+
export async function transformMarkdown(source: string, file: string, opts: TransformMarkdownOptions = {}): Promise<{ code: string; meta: PageMeta; warnings: string[]; apiSrcFiles: string[]; demos: { id: string; source: string }[] }> {
|
|
45
|
+
const host = opts.hooks ?? (opts.plugins ? createPluginHost(opts.plugins) : undefined);
|
|
46
|
+
const remarkPlugins = [...(opts.remarkPlugins ?? []), ...(host ? host.collectRemarkPlugins() : [])];
|
|
47
|
+
const rehypePlugins = [...(opts.rehypePlugins ?? []), ...(host ? host.collectRehypePlugins() : [])];
|
|
48
|
+
let processor: any;
|
|
49
|
+
try {
|
|
50
|
+
processor = unified()
|
|
51
|
+
.use(remarkParse)
|
|
52
|
+
.use(remarkFrontmatter, ['yaml'])
|
|
53
|
+
.use(remarkGfm)
|
|
54
|
+
.use(remarkDirective)
|
|
55
|
+
.use(remarkContainer)
|
|
56
|
+
.use(remarkMeta, { filename: file })
|
|
57
|
+
.use(remarkCodeMeta)
|
|
58
|
+
.use(remarkDemoMeta);
|
|
59
|
+
for (const p of remarkPlugins) processor = processor.use(p);
|
|
60
|
+
processor = processor
|
|
61
|
+
.use(remarkRehype, { allowDangerousHtml: true })
|
|
62
|
+
.use(rehypeRaw);
|
|
63
|
+
for (const p of rehypePlugins) processor = processor.use(p);
|
|
64
|
+
processor = processor
|
|
65
|
+
.use(rehypeSlug)
|
|
66
|
+
.use(rehypeAutolinkHeadings)
|
|
67
|
+
.use(rehypeLink, opts.byFile ? { byFile: opts.byFile, base: opts.base ?? '/' } : {})
|
|
68
|
+
.use(rehypeImg)
|
|
69
|
+
.use(rehypeDesc)
|
|
70
|
+
.use(rehypeStrip)
|
|
71
|
+
.use(rehypeText)
|
|
72
|
+
.use(rehypeDemo)
|
|
73
|
+
// sau rehypeDemo (fence `demo` đã thành DomDemo) và sau rehypeStrip/rehypeText (§3.16).
|
|
74
|
+
.use(rehypeCode, opts.code ?? {})
|
|
75
|
+
.use(rehypeApi);
|
|
76
|
+
} catch (e) {
|
|
77
|
+
throw new DommiefError('failed to build pipeline', { file });
|
|
78
|
+
}
|
|
79
|
+
const vfile: any = { data: {}, path: file };
|
|
80
|
+
let hast: HastRoot;
|
|
81
|
+
try {
|
|
82
|
+
const tree = processor.parse(source);
|
|
83
|
+
hast = await processor.run(tree, vfile) as HastRoot;
|
|
84
|
+
} catch (e) {
|
|
85
|
+
throw new DommiefError('markdown transform failed: ' + (e as Error).message, { file });
|
|
86
|
+
}
|
|
87
|
+
const apiSrcFiles = (vfile.data?.apiSrcFiles ?? []) as string[];
|
|
88
|
+
const meta: PageMeta = {
|
|
89
|
+
frontmatter: (vfile.data?.frontmatter ?? {}) as Record<string, unknown>,
|
|
90
|
+
toc: (vfile.data?.toc ?? []) as TocItem[],
|
|
91
|
+
texts: (vfile.data?.texts ?? []) as TextFragment[],
|
|
92
|
+
};
|
|
93
|
+
const demos = (vfile.data?.demos ?? []) as { id: string; source: string; src?: string }[];
|
|
94
|
+
const code = toModule(hast, meta, (vfile.data?.images ?? []) as { name: string; specifier: string }[], demos);
|
|
95
|
+
return { code, meta, warnings: (vfile.data?.warnings ?? []) as string[], apiSrcFiles, demos };
|
|
96
|
+
}
|