dommief 0.1.1 → 0.1.3
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/dist/cli.js +182 -147
- package/dist/index.js +5 -1
- package/package.json +1 -1
- 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 +193 -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,131 @@
|
|
|
1
|
+
import { visit } from 'unist-util-visit';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { createRequire } from 'node:module';
|
|
6
|
+
import { DommiefError } from './errors.js';
|
|
7
|
+
|
|
8
|
+
// Nạp react-docgen-typescript (kéo theo cả TypeScript) CHỈ khi trang có `<API>`: import ở đầu file
|
|
9
|
+
// làm mọi site crash lúc khởi động nếu typescript cài được không có API JS cũ (TS 7).
|
|
10
|
+
let docgen: typeof import('react-docgen-typescript') | undefined;
|
|
11
|
+
function loadDocgen(): typeof import('react-docgen-typescript') {
|
|
12
|
+
if (docgen) return docgen;
|
|
13
|
+
try {
|
|
14
|
+
docgen = createRequire(import.meta.url)('react-docgen-typescript');
|
|
15
|
+
return docgen!;
|
|
16
|
+
} catch (e) {
|
|
17
|
+
throw new DommiefError(`<API> cần react-docgen-typescript + typescript 5.x: ${(e as Error).message}`, { code: 'api_docgen_unavailable' });
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface ApiProp {
|
|
22
|
+
name: string;
|
|
23
|
+
type?: string;
|
|
24
|
+
required?: boolean;
|
|
25
|
+
description?: string;
|
|
26
|
+
defaultValue?: any;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface ApiEntry {
|
|
30
|
+
displayName: string;
|
|
31
|
+
description?: string;
|
|
32
|
+
props: ApiProp[];
|
|
33
|
+
src: string;
|
|
34
|
+
hash: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Cache theo file tuyệt đối (một entry mỗi file, thay bằng bản mới khi hash đổi — map không phình mãi theo #17).
|
|
38
|
+
const apiCache = new Map<string, { hash: string; entries: ApiEntry[] }>();
|
|
39
|
+
|
|
40
|
+
export function clearApiCache(): void {
|
|
41
|
+
apiCache.clear();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function hashFileContent(content: string): string {
|
|
45
|
+
return createHash('sha256').update(content).digest('hex');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function resolveApiSrc(src: string, filePath: string): string {
|
|
49
|
+
const base = filePath ? path.dirname(filePath) : process.cwd();
|
|
50
|
+
return path.isAbsolute(src) ? src : path.resolve(base, src);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function buildApiTable(src: string, filePath: string = ''): ApiEntry[] | null {
|
|
54
|
+
const abs = resolveApiSrc(src, filePath);
|
|
55
|
+
let content: string;
|
|
56
|
+
try {
|
|
57
|
+
content = fs.readFileSync(abs, 'utf8');
|
|
58
|
+
} catch {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
const hash = hashFileContent(content);
|
|
62
|
+
const cached = apiCache.get(abs);
|
|
63
|
+
if (cached && cached.hash === hash) return cached.entries;
|
|
64
|
+
|
|
65
|
+
// loadDocgen() ngoài try: thiếu/sai bản typescript phải báo lỗi, không được nuốt thành "No API data".
|
|
66
|
+
const parser = loadDocgen().withDefaultConfig({});
|
|
67
|
+
let docs: any[];
|
|
68
|
+
try {
|
|
69
|
+
docs = parser.parse(abs) ?? [];
|
|
70
|
+
} catch {
|
|
71
|
+
return null; // file không parse được (cú pháp lỗi) → caller cảnh báo, không nuốt im lặng
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const entries: ApiEntry[] = (docs || []).map((d: any) => ({
|
|
75
|
+
displayName: d.displayName,
|
|
76
|
+
description: d.description || '',
|
|
77
|
+
src: abs,
|
|
78
|
+
hash,
|
|
79
|
+
props: Object.values(d.props || {}).map((p: any) => ({
|
|
80
|
+
name: p.name,
|
|
81
|
+
type: p.type?.name ?? p.type?.raw ?? '',
|
|
82
|
+
required: !!p.required,
|
|
83
|
+
description: p.description || '',
|
|
84
|
+
defaultValue: p.defaultValue?.value,
|
|
85
|
+
})),
|
|
86
|
+
}));
|
|
87
|
+
|
|
88
|
+
apiCache.set(abs, { hash, entries });
|
|
89
|
+
return entries;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function rehypeApi() {
|
|
93
|
+
return (tree: any, file: any) => {
|
|
94
|
+
// file.data.apiSrcFiles: để caller (vite/plugin.ts) `addWatchFile` từng file — Vite chỉ tự
|
|
95
|
+
// watch file.md, không biết plugin có đọc thêm .tsx (#17).
|
|
96
|
+
const apis: any[] = Array.isArray((file?.data as any)?.apis) ? (file.data as any).apis : [];
|
|
97
|
+
const watched: string[] = Array.isArray((file?.data as any)?.apiSrcFiles) ? (file.data as any).apiSrcFiles : [];
|
|
98
|
+
const warnings: string[] = Array.isArray((file?.data as any)?.warnings) ? (file.data as any).warnings : [];
|
|
99
|
+
const filePath = String((file as any)?.path ?? '');
|
|
100
|
+
|
|
101
|
+
visit(tree, 'element', (node: any, index: number | undefined, parent: any) => {
|
|
102
|
+
if (!parent || typeof index !== 'number') return;
|
|
103
|
+
if (String(node.tagName).toLowerCase() !== 'api') return;
|
|
104
|
+
const src = String(node.properties?.src ?? '');
|
|
105
|
+
if (!src) return;
|
|
106
|
+
|
|
107
|
+
const abs = resolveApiSrc(src, filePath);
|
|
108
|
+
const data = buildApiTable(src, filePath);
|
|
109
|
+
watched.push(abs);
|
|
110
|
+
if (!data?.length) warnings.push(`<API src="${src}">: ${data ? 'no component found (syntax error or no exported component)' : 'file missing or failed to parse'}: ${abs}`);
|
|
111
|
+
|
|
112
|
+
apis.push({ id: hashFileContent(src + filePath).slice(0, 8), src: abs, data });
|
|
113
|
+
|
|
114
|
+
parent.children[index] = {
|
|
115
|
+
type: 'element',
|
|
116
|
+
tagName: 'DomApi',
|
|
117
|
+
properties: {
|
|
118
|
+
src: abs,
|
|
119
|
+
data: data ? JSON.stringify(data) : undefined,
|
|
120
|
+
},
|
|
121
|
+
children: [],
|
|
122
|
+
};
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
if (file?.data) {
|
|
126
|
+
(file.data as any).apis = apis;
|
|
127
|
+
(file.data as any).apiSrcFiles = watched;
|
|
128
|
+
(file.data as any).warnings = warnings;
|
|
129
|
+
}
|
|
130
|
+
};
|
|
131
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { visit } from 'unist-util-visit';
|
|
2
|
+
import { bundledLanguages, createHighlighter, type Highlighter } from 'shiki';
|
|
3
|
+
|
|
4
|
+
// Đặc tả: docs/spec/MARKDOWN.md §3.8 (highlight dòng), §3.16 (rehypeCode), §8 (Shiki lúc build);
|
|
5
|
+
// backlog DM-705, DM-706. Highlight MỘT lần lúc transform, gửi HTML tĩnh: không có highlighter
|
|
6
|
+
// trong bundle client. `defaultColor: false` bắt buộc — đẩy cả 2 màu vào biến CSS để
|
|
7
|
+
// `[data-prefers-color='dark']` đổi màu bằng CSS thuần (§8.2 điểm 2).
|
|
8
|
+
|
|
9
|
+
export interface ShikiOptions {
|
|
10
|
+
themes: { light: string; dark: string };
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface RehypeCodeOptions {
|
|
14
|
+
/** `false` = tắt Shiki (code block giữ `<pre><code>` thô). */
|
|
15
|
+
shiki?: ShikiOptions | false;
|
|
16
|
+
lineNumbers?: boolean;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export const DEFAULT_SHIKI: ShikiOptions = { themes: { light: 'github-light', dark: 'github-dark' } };
|
|
20
|
+
|
|
21
|
+
/** Version của output pipeline code block — cache transform (DM-406) phải đưa vào key. */
|
|
22
|
+
export const CODE_PIPELINE_VERSION = 'shiki4-v1';
|
|
23
|
+
|
|
24
|
+
const HIGHLIGHT_RE = /\{((?:\d+(?:-\d+)?,?)+)\}/;
|
|
25
|
+
|
|
26
|
+
/** `{1,3-5}` → [1,3,4,5] (1-based, sort + dedupe), và meta còn lại đã bỏ phần `{…}`. */
|
|
27
|
+
export function parseHighlightLines(meta: string): { lines: number[]; rest: string } {
|
|
28
|
+
const m = meta.match(HIGHLIGHT_RE);
|
|
29
|
+
if (!m) return { lines: [], rest: meta.trim() };
|
|
30
|
+
const set = new Set<number>();
|
|
31
|
+
for (const part of m[1]!.split(',').filter(Boolean)) {
|
|
32
|
+
const [a, b] = part.split('-').map(Number) as [number, number | undefined];
|
|
33
|
+
const end = b ?? a;
|
|
34
|
+
for (let n = Math.min(a, end); n <= Math.max(a, end); n++) if (n >= 1) set.add(n);
|
|
35
|
+
}
|
|
36
|
+
return { lines: [...set].sort((x, y) => x - y), rest: meta.replace(HIGHLIGHT_RE, '').trim() };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** remark: tách `{1,3-5}` + `[title]` khỏi meta của fence, gắn vào hProperties (sống sót qua rehype-raw). */
|
|
40
|
+
export function remarkCodeMeta() {
|
|
41
|
+
return (tree: any) => {
|
|
42
|
+
visit(tree, 'code', (node: any) => {
|
|
43
|
+
const meta = String(node.meta ?? '');
|
|
44
|
+
if (!meta) return;
|
|
45
|
+
const { lines, rest } = parseHighlightLines(meta);
|
|
46
|
+
const title = rest.match(/\[([^\]]+)\]/)?.[1];
|
|
47
|
+
const props: Record<string, string> = {};
|
|
48
|
+
if (lines.length) props['data-highlight-lines'] = lines.join(',');
|
|
49
|
+
if (title) props['data-title'] = title;
|
|
50
|
+
if (Object.keys(props).length) node.data = { ...node.data, hProperties: { ...node.data?.hProperties, ...props } };
|
|
51
|
+
node.meta = rest || null;
|
|
52
|
+
});
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const highlighters = new Map<string, Promise<Highlighter>>();
|
|
57
|
+
|
|
58
|
+
/** Một highlighter / bộ theme cho cả tiến trình; grammar nạp dần theo ngôn ngữ thật sự gặp (langs: 'auto'). */
|
|
59
|
+
function getHighlighter(opts: ShikiOptions): Promise<Highlighter> {
|
|
60
|
+
const key = `${opts.themes.light}|${opts.themes.dark}`;
|
|
61
|
+
let h = highlighters.get(key);
|
|
62
|
+
if (!h) {
|
|
63
|
+
h = createHighlighter({ themes: [opts.themes.light, opts.themes.dark], langs: [] });
|
|
64
|
+
highlighters.set(key, h);
|
|
65
|
+
}
|
|
66
|
+
return h;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function textOf(node: any): string {
|
|
70
|
+
let out = '';
|
|
71
|
+
visit(node, 'text', (t: any) => { out += t.value; });
|
|
72
|
+
return out;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function langOf(code: any): string | undefined {
|
|
76
|
+
const cls = code.properties?.className;
|
|
77
|
+
const list: string[] = Array.isArray(cls) ? cls.map(String) : typeof cls === 'string' ? cls.split(/\s+/) : [];
|
|
78
|
+
return list.map((c) => /^language-(.+)$/.exec(c)?.[1]).find(Boolean);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function markLines(pre: any, highlight: Set<number>, lineNumbers: boolean): void {
|
|
82
|
+
const code = pre.children?.find((c: any) => c.tagName === 'code');
|
|
83
|
+
let n = 0;
|
|
84
|
+
for (const child of code?.children ?? []) {
|
|
85
|
+
if (child.type !== 'element' || child.tagName !== 'span') continue;
|
|
86
|
+
const cls = String(child.properties?.class ?? child.properties?.className ?? '');
|
|
87
|
+
if (!/\bline\b/.test(cls)) continue;
|
|
88
|
+
n++;
|
|
89
|
+
const classes = cls.split(/\s+/).filter(Boolean);
|
|
90
|
+
if (highlight.has(n)) classes.push('highlighted');
|
|
91
|
+
child.properties = { ...child.properties, class: classes.join(' ') };
|
|
92
|
+
if (lineNumbers) child.properties['data-line'] = String(n);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* rehype: `pre > code` → `<div class="dommief-code" data-lang>` (+ title) bọc `<pre class="shiki">` của Shiki.
|
|
98
|
+
* Ngôn ngữ không có grammar (vd `mermaid`) → warning + giữ code thô, KHÔNG fail build (§8.1).
|
|
99
|
+
* Fence không ghi lang → code thô (Shiki ném lỗi với lang rỗng, §3.16).
|
|
100
|
+
*/
|
|
101
|
+
export function rehypeCode(options: RehypeCodeOptions = {}) {
|
|
102
|
+
const shiki = options.shiki === undefined ? DEFAULT_SHIKI : options.shiki;
|
|
103
|
+
const lineNumbers = !!options.lineNumbers;
|
|
104
|
+
return async (tree: any, file: any) => {
|
|
105
|
+
const jobs: { parent: any; index: number; pre: any; code: any }[] = [];
|
|
106
|
+
visit(tree, 'element', (node: any, index: number | undefined, parent: any) => {
|
|
107
|
+
if (node.tagName !== 'pre' || !parent || typeof index !== 'number') return;
|
|
108
|
+
const kids = (node.children ?? []).filter((c: any) => !(c.type === 'text' && !c.value.trim()));
|
|
109
|
+
if (kids.length !== 1 || kids[0].tagName !== 'code') return;
|
|
110
|
+
jobs.push({ parent, index, pre: node, code: kids[0] });
|
|
111
|
+
});
|
|
112
|
+
if (!jobs.length) return;
|
|
113
|
+
const highlighter = shiki ? await getHighlighter(shiki) : null;
|
|
114
|
+
for (const { parent, index, pre, code } of jobs) {
|
|
115
|
+
const lang = langOf(code);
|
|
116
|
+
const title = code.properties?.dataTitle as string | undefined;
|
|
117
|
+
const hl = new Set(String(code.properties?.dataHighlightLines ?? '').split(',').filter(Boolean).map(Number));
|
|
118
|
+
let inner: any = pre;
|
|
119
|
+
if (highlighter && shiki && lang) {
|
|
120
|
+
if (lang in bundledLanguages) {
|
|
121
|
+
if (!highlighter.getLoadedLanguages().includes(lang)) await highlighter.loadLanguage(lang as any);
|
|
122
|
+
const source = textOf(code).replace(/\n$/, '');
|
|
123
|
+
const root: any = highlighter.codeToHast(source, { lang, themes: { light: shiki.themes.light, dark: shiki.themes.dark }, defaultColor: false });
|
|
124
|
+
inner = root.children[0];
|
|
125
|
+
markLines(inner, hl, lineNumbers);
|
|
126
|
+
} else {
|
|
127
|
+
const warnings: string[] = ((file.data ??= {}).warnings ??= []);
|
|
128
|
+
const pos = pre.position?.start;
|
|
129
|
+
warnings.push(`${file.path ?? ''}${pos ? `:${pos.line}` : ''} Shiki không có grammar cho "${lang}"; hiển thị code thô`);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
// Code thô vẫn mang highlight dòng được dưới dạng data-attr (theme không-Shiki tự CSS).
|
|
133
|
+
if (inner === pre && hl.size) pre.properties = { ...pre.properties, 'data-highlight-lines': [...hl].join(',') };
|
|
134
|
+
delete code.properties?.dataHighlightLines;
|
|
135
|
+
delete code.properties?.dataTitle;
|
|
136
|
+
const children: any[] = [];
|
|
137
|
+
if (title) children.push({ type: 'element', tagName: 'div', properties: { className: ['dommief-code__title'] }, children: [{ type: 'text', value: title }] });
|
|
138
|
+
children.push(inner);
|
|
139
|
+
parent.children[index] = {
|
|
140
|
+
type: 'element',
|
|
141
|
+
tagName: 'div',
|
|
142
|
+
properties: { className: ['dommief-code'], ...(lang ? { 'data-lang': lang } : {}) },
|
|
143
|
+
children,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
};
|
|
147
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { visit } from 'unist-util-visit';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* remark: ```tsx demo → đánh dấu `data-demo` lên <code>. Phải làm ở remark vì `rehype-raw`
|
|
6
|
+
* dựng lại cây hast và làm mất `node.data.meta`, còn `hProperties` thì sống sót.
|
|
7
|
+
*/
|
|
8
|
+
export function remarkDemoMeta() {
|
|
9
|
+
return (tree: any) => {
|
|
10
|
+
visit(tree, 'code', (node: any) => {
|
|
11
|
+
if (/\bdemo\b/.test(String(node.meta ?? ''))) {
|
|
12
|
+
node.data = { ...node.data, hProperties: { ...node.data?.hProperties, 'data-demo': 'true' } };
|
|
13
|
+
}
|
|
14
|
+
});
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface DemoEntry {
|
|
19
|
+
id: string;
|
|
20
|
+
source: string;
|
|
21
|
+
src?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function demoId(source: string, file: string): string {
|
|
25
|
+
return createHash('sha256').update(file + '\n' + source).digest('hex').slice(0, 8);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function codeText(node: any): string {
|
|
29
|
+
let out = '';
|
|
30
|
+
visit(node, 'text', (t: any) => { out += t.value; });
|
|
31
|
+
return out;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function rehypeDemo() {
|
|
35
|
+
return (tree: any, file: any) => {
|
|
36
|
+
const demos: DemoEntry[] = Array.isArray((file.data as any)?.demos) ? (file.data as any).demos : [];
|
|
37
|
+
const filePath = String((file as any).path ?? '');
|
|
38
|
+
|
|
39
|
+
visit(tree, 'element', (node: any, index: number | undefined, parent: any) => {
|
|
40
|
+
if (!parent || typeof index !== 'number') return;
|
|
41
|
+
|
|
42
|
+
// <code src="./Foo.tsx"> form
|
|
43
|
+
if (node.tagName === 'code' && node.properties?.src) {
|
|
44
|
+
const src = String(node.properties.src);
|
|
45
|
+
const source = codeText(node).trim();
|
|
46
|
+
const id = demoId(source || src, filePath);
|
|
47
|
+
demos.push({ id, source, src });
|
|
48
|
+
parent.children[index] = {
|
|
49
|
+
type: 'element',
|
|
50
|
+
tagName: 'DomDemo',
|
|
51
|
+
properties: { id, src, source },
|
|
52
|
+
children: [],
|
|
53
|
+
};
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (node.tagName !== 'pre') return;
|
|
58
|
+
const codeNode = Array.isArray(node.children) ? node.children.find((c: any) => c?.tagName === 'code') : null;
|
|
59
|
+
if (!codeNode) return;
|
|
60
|
+
// Chỉ block khai báo `demo` trong meta (```tsx demo) mới là demo; ```tsx thường vẫn là code.
|
|
61
|
+
if (!codeNode.properties?.dataDemo) return;
|
|
62
|
+
|
|
63
|
+
const source = codeText(codeNode).replace(/\n$/, '');
|
|
64
|
+
const id = demoId(source, filePath);
|
|
65
|
+
demos.push({ id, source });
|
|
66
|
+
parent.children[index] = {
|
|
67
|
+
type: 'element',
|
|
68
|
+
tagName: 'DomDemo',
|
|
69
|
+
properties: { id, source },
|
|
70
|
+
children: [],
|
|
71
|
+
};
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
(file.data as any).demos = demos;
|
|
75
|
+
};
|
|
76
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { visit } from 'unist-util-visit';
|
|
2
|
+
|
|
3
|
+
export function rehypeDesc() {
|
|
4
|
+
return (tree: any, file: any) => {
|
|
5
|
+
file.data = file.data || {};
|
|
6
|
+
let desc: string | undefined;
|
|
7
|
+
visit(tree, 'element', (node: any) => {
|
|
8
|
+
if (!desc && node.tagName === 'p') {
|
|
9
|
+
const text = (node.children || []).map((c: any) => c.value || '').join('').trim();
|
|
10
|
+
if (text) desc = text.slice(0, 160);
|
|
11
|
+
}
|
|
12
|
+
});
|
|
13
|
+
if (desc) {
|
|
14
|
+
if (!file.data.frontmatter) file.data.frontmatter = {};
|
|
15
|
+
if (file.data.frontmatter.description == null) file.data.frontmatter.description = desc;
|
|
16
|
+
}
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function rehypeStrip() {
|
|
21
|
+
// strip dangerous raw html? minimal: remove script tags
|
|
22
|
+
return (tree: any) => {
|
|
23
|
+
visit(tree, 'element', (node: any, index: number | undefined, parent: any) => {
|
|
24
|
+
if (node.tagName === 'script' && parent && index !== undefined) {
|
|
25
|
+
parent.children.splice(index, 1);
|
|
26
|
+
return index; // restart
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
};
|
|
30
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { visit } from 'unist-util-visit';
|
|
3
|
+
|
|
4
|
+
const PLACEHOLDER = '__DOMMIEF_IMG_';
|
|
5
|
+
const SKIP = /^([a-z][a-z0-9+.-]*:|\/\/|#|\/)/i; // http:, data:, //cdn, #x, /public-abs
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Ảnh tương đối `./a.png` (theo vị trí file md) được thành `import` để Vite bundle + băm tên + gắn base.
|
|
9
|
+
* Ở đây chỉ đặt placeholder và ghi `file.data.images`; `toModule` sinh dòng import.
|
|
10
|
+
*/
|
|
11
|
+
export function rehypeImg() {
|
|
12
|
+
return (tree: any, file: any) => {
|
|
13
|
+
const images: { name: string; specifier: string }[] = [];
|
|
14
|
+
visit(tree, 'element', (node: any) => {
|
|
15
|
+
if (node.tagName !== 'img' || !node.properties?.src) return;
|
|
16
|
+
const src = String(node.properties.src);
|
|
17
|
+
if (SKIP.test(src)) return;
|
|
18
|
+
const clean = src.replace(/[?#].*$/, '');
|
|
19
|
+
const specifier = clean.startsWith('.') ? clean : './' + path.posix.normalize(clean);
|
|
20
|
+
const name = `${PLACEHOLDER}${images.length}__`;
|
|
21
|
+
images.push({ name, specifier });
|
|
22
|
+
node.properties.src = name;
|
|
23
|
+
});
|
|
24
|
+
// DM-410: như rehype-link.ts, gắn dòng nguồn để link-check báo đúng `file:line` cho asset chết.
|
|
25
|
+
visit(tree, 'element', (node: any) => {
|
|
26
|
+
if (node.tagName !== 'img' || !node.properties?.src || node.properties['data-src-line']) return;
|
|
27
|
+
const line = node.position?.start?.line;
|
|
28
|
+
if (line) node.properties['data-src-line'] = String(line);
|
|
29
|
+
});
|
|
30
|
+
file.data = file.data || {};
|
|
31
|
+
file.data.images = images;
|
|
32
|
+
};
|
|
33
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { visit } from 'unist-util-visit';
|
|
3
|
+
import { winPath } from '../utils/path.js';
|
|
4
|
+
|
|
5
|
+
export interface RehypeLinkOptions {
|
|
6
|
+
/** file md tuyệt đối (POSIX) → path của route. Không có ⇒ chỉ bỏ đuôi `.md` (không kiểm tra). */
|
|
7
|
+
byFile?: Record<string, { path: string }>;
|
|
8
|
+
/** `config.base` ('/sub/'): `<a>` thường không có basename của router nên phải gắn tiền tố ở đây. */
|
|
9
|
+
base?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const EXTERNAL = /^([a-z][a-z0-9+.-]*:|\/\/|#|\/)/i; // http:, mailto:, //cdn, #hash, /abs
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* `[x](./getStarted.md#a)` → path của route thật (`/get-started#a`), tính theo VỊ TRÍ FILE md chứ không theo URL.
|
|
16
|
+
* Link `.md` không khớp route nào được giữ nguyên và ghi warning vào `file.data.warnings`.
|
|
17
|
+
*/
|
|
18
|
+
export function rehypeLink(options: RehypeLinkOptions = {}) {
|
|
19
|
+
return (tree: any, file: any) => {
|
|
20
|
+
const from = winPath(String(file?.path ?? ''));
|
|
21
|
+
const prefix = (options.base ?? '/').replace(/\/$/, '');
|
|
22
|
+
const warnings: string[] = Array.isArray(file?.data?.warnings) ? file.data.warnings : [];
|
|
23
|
+
visit(tree, 'element', (node: any) => {
|
|
24
|
+
if (node.tagName !== 'a' || !node.properties?.href) return;
|
|
25
|
+
const href = String(node.properties.href);
|
|
26
|
+
if (EXTERNAL.test(href)) return;
|
|
27
|
+
const m = href.match(/^([^#?]*\.md)([?#].*)?$/);
|
|
28
|
+
if (!m || m[1] === undefined) return;
|
|
29
|
+
const tail = m[2] ?? '';
|
|
30
|
+
if (!options.byFile) {
|
|
31
|
+
node.properties.href = m[1].replace(/\.md$/, '') + tail;
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
const abs = path.posix.resolve(path.posix.dirname(from), m[1]);
|
|
35
|
+
const route = options.byFile[abs];
|
|
36
|
+
if (route) node.properties.href = (route.path === '/' ? prefix + '/' : prefix + route.path) + tail;
|
|
37
|
+
else warnings.push(`link_unresolved: "${href}" trong ${from} không trỏ tới file md nào thuộc docDirs`);
|
|
38
|
+
});
|
|
39
|
+
// DM-410: gắn dòng nguồn lên MọI `<a>` nội bộ (kể cả link không đổi ở trên) để link-check
|
|
40
|
+
// báo đúng `file:line` thay vì chỉ `routeId`. ~20 byte/link, cố ý giữ lại trong HTML để debug.
|
|
41
|
+
visit(tree, 'element', (node: any) => {
|
|
42
|
+
if (node.tagName !== 'a' || !node.properties?.href || node.properties['data-src-line']) return;
|
|
43
|
+
const href = String(node.properties.href);
|
|
44
|
+
if (EXTERNAL.test(href) && !href.startsWith('/')) return;
|
|
45
|
+
const line = node.position?.start?.line;
|
|
46
|
+
if (line) node.properties['data-src-line'] = String(line);
|
|
47
|
+
});
|
|
48
|
+
if (file?.data) file.data.warnings = warnings;
|
|
49
|
+
};
|
|
50
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { visit } from 'unist-util-visit';
|
|
2
|
+
import Slugger from 'github-slugger';
|
|
3
|
+
|
|
4
|
+
export interface TocItem { id: string; depth: number; title: string; }
|
|
5
|
+
|
|
6
|
+
function textOf(node: any): string {
|
|
7
|
+
if (node.type === 'text') return node.value || '';
|
|
8
|
+
if (node.children) return node.children.map(textOf).join('');
|
|
9
|
+
return '';
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function rehypeSlug() {
|
|
13
|
+
return (tree: any, file: any) => {
|
|
14
|
+
file.data = file.data || {};
|
|
15
|
+
const toc: TocItem[] = [];
|
|
16
|
+
const slugger = new Slugger();
|
|
17
|
+
visit(tree, 'element', (node: any) => {
|
|
18
|
+
if (/^h[1-6]$/.test(node.tagName)) {
|
|
19
|
+
const text = textOf(node).trim();
|
|
20
|
+
const depth = Number(node.tagName.slice(1));
|
|
21
|
+
let id = node.properties?.id;
|
|
22
|
+
if (!id) id = slugger.slug(text);
|
|
23
|
+
if (!node.properties) node.properties = {};
|
|
24
|
+
node.properties.id = id;
|
|
25
|
+
toc.push({ id, depth, title: text });
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
file.data.toc = toc;
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function rehypeAutolinkHeadings() {
|
|
33
|
+
return (tree: any) => {
|
|
34
|
+
visit(tree, 'element', (node: any) => {
|
|
35
|
+
if (/^h[1-6]$/.test(node.tagName) && node.properties?.id) {
|
|
36
|
+
const anchor = { type: 'element', tagName: 'a', properties: { href: '#' + node.properties.id, className: ['dommief-heading-anchor'], tabIndex: -1 }, children: [] };
|
|
37
|
+
node.children = [anchor, ...node.children];
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
};
|
|
41
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
export function rehypeText() {
|
|
2
|
+
return (tree: any, file: any) => {
|
|
3
|
+
file.data = file.data || {};
|
|
4
|
+
const toc: any[] = file.data.toc || [];
|
|
5
|
+
const texts: any[] = [];
|
|
6
|
+
const paraIdOf = new WeakMap<any, number>();
|
|
7
|
+
let nextParaId = 0;
|
|
8
|
+
let tocCursor = -1;
|
|
9
|
+
let currentTocIndex: number | undefined;
|
|
10
|
+
|
|
11
|
+
const isContainer = (node: any) => {
|
|
12
|
+
if (!node || node.type !== 'element') return false;
|
|
13
|
+
const tag = node.tagName;
|
|
14
|
+
if (tag === 'p' || tag === 'ul' || tag === 'ol' || tag === 'root') return true;
|
|
15
|
+
return typeof tag === 'string' && /^[A-Z]/.test(tag);
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const containerOf = (ancestors: any[]) => {
|
|
19
|
+
for (let i = ancestors.length - 1; i >= 0; i--) {
|
|
20
|
+
if (isContainer(ancestors[i])) return ancestors[i];
|
|
21
|
+
}
|
|
22
|
+
return tree;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const walk = (node: any, ancestors: any[]) => {
|
|
26
|
+
if (node && node.type === 'element' && /^h[1-6]$/.test(node.tagName)) {
|
|
27
|
+
tocCursor += 1;
|
|
28
|
+
if (tocCursor < toc.length) currentTocIndex = tocCursor;
|
|
29
|
+
for (const c of node.children || []) walk(c, [...ancestors, node]);
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
if (node && node.type === 'text') {
|
|
33
|
+
const parent = ancestors[ancestors.length - 1];
|
|
34
|
+
if (parent && /^h[1-6]$/.test(parent.tagName)) return;
|
|
35
|
+
const v = node.value || '';
|
|
36
|
+
if (!v.trim()) return;
|
|
37
|
+
const container = containerOf(ancestors);
|
|
38
|
+
if (!paraIdOf.has(container)) paraIdOf.set(container, nextParaId++);
|
|
39
|
+
const paraId = paraIdOf.get(container)!;
|
|
40
|
+
const frag: any = { type: 'content', value: v, paraId };
|
|
41
|
+
if (currentTocIndex !== undefined) frag.tocIndex = currentTocIndex;
|
|
42
|
+
texts.push(frag);
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
if (node && Array.isArray(node.children)) {
|
|
46
|
+
for (const c of node.children) walk(c, [...ancestors, node]);
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
walk(tree, []);
|
|
51
|
+
file.data.texts = texts;
|
|
52
|
+
};
|
|
53
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { visit } from 'unist-util-visit';
|
|
2
|
+
|
|
3
|
+
// Đặc tả: docs/spec/MARKDOWN.md §3.4, §5.1, backlog DM-703.
|
|
4
|
+
// `:::info … :::` → <div class="dommief-container dommief-container--info"> (+ title, role).
|
|
5
|
+
// Không render qua component `Container` riêng: HTML thuần đủ cho 4 loại cố định, SSR/JS-off
|
|
6
|
+
// đều đúng, và theme override bằng CSS (`.dommief-container--*`) như mọi phần khác.
|
|
7
|
+
|
|
8
|
+
export const CONTAINER_TYPES = ['info', 'warning', 'success', 'error'] as const;
|
|
9
|
+
type ContainerType = (typeof CONTAINER_TYPES)[number];
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* `remark-directive` bật cả 3 cú pháp (`:text`, `::leaf`, `:::container`). Hai cú pháp đầu
|
|
13
|
+
* nuốt chữ thật trong tài liệu (`10:30`, `ghi chú: xem`). Gỡ `text` và chỉ giữ construct
|
|
14
|
+
* container trong `flow`, CÓ KIỂM TRA cấu trúc (§3.4) thay vì xoá theo chỉ số mảng như dumi.
|
|
15
|
+
*/
|
|
16
|
+
function restrictToContainerSyntax(extensions: any[]): void {
|
|
17
|
+
// Chữ ký: `{ text: {58: …}, flow: {58: [container, leaf]} }` — khóa 58 = ':'. remark-gfm cũng có
|
|
18
|
+
// extension `{text, flow}` nên phải lọc theo tokenizer `…Directive…`, không chỉ theo shape.
|
|
19
|
+
const isDirective = (e: any) => {
|
|
20
|
+
const f = e?.flow?.[58];
|
|
21
|
+
return !!e?.text?.[58] && (Array.isArray(f) ? f : [f]).some((c: any) => /Directive/.test(c?.tokenize?.name ?? ''));
|
|
22
|
+
};
|
|
23
|
+
const hit = extensions.filter(isDirective);
|
|
24
|
+
if (hit.length !== 1) throw new Error('remark-directive đổi cấu trúc nội bộ (không thấy đúng 1 extension directive {text, flow}); cập nhật remarkContainer');
|
|
25
|
+
const ext = hit[0];
|
|
26
|
+
const flow = ext.flow[58];
|
|
27
|
+
const constructs = Array.isArray(flow) ? flow : [flow];
|
|
28
|
+
const containers = constructs.filter((c: any) => /Container/.test(c?.tokenize?.name ?? ''));
|
|
29
|
+
if (containers.length !== 1) throw new Error('remark-directive đổi cấu trúc nội bộ (không thấy đúng 1 container construct); cập nhật remarkContainer');
|
|
30
|
+
delete ext.text;
|
|
31
|
+
ext.flow = { 58: containers };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function remarkContainer(this: any) {
|
|
35
|
+
const data = this.data();
|
|
36
|
+
restrictToContainerSyntax(data.micromarkExtensions ?? []);
|
|
37
|
+
return (tree: any, file: any) => {
|
|
38
|
+
visit(tree, 'containerDirective', (node: any, index: number | undefined, parent: any) => {
|
|
39
|
+
if (!parent || typeof index !== 'number') return;
|
|
40
|
+
const name = String(node.name);
|
|
41
|
+
if (!(CONTAINER_TYPES as readonly string[]).includes(name)) {
|
|
42
|
+
const warnings: string[] = ((file.data ??= {}).warnings ??= []);
|
|
43
|
+
const pos = node.position?.start;
|
|
44
|
+
warnings.push(`${file.path ?? ''}${pos ? `:${pos.line}:${pos.column}` : ''} container ":::${name}" không được hỗ trợ (chỉ ${CONTAINER_TYPES.join('/')}); render thành <div> thường`);
|
|
45
|
+
node.data = { hName: 'div', hProperties: {} };
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
const type = name as ContainerType;
|
|
49
|
+
// `:::info[Tiêu đề]` (label) hoặc `:::info{title="…"}` (attribute) đều thành title.
|
|
50
|
+
const labelIdx = node.children.findIndex((c: any) => c.data?.directiveLabel);
|
|
51
|
+
const labelNode = labelIdx >= 0 ? node.children.splice(labelIdx, 1)[0] : null;
|
|
52
|
+
const title = node.attributes?.title ?? (labelNode ? textOf(labelNode) : undefined);
|
|
53
|
+
const children = [...node.children];
|
|
54
|
+
if (title) {
|
|
55
|
+
children.unshift({ type: 'paragraph', data: { hName: 'p', hProperties: { className: ['dommief-container__title'] } }, children: [{ type: 'text', value: title }] });
|
|
56
|
+
}
|
|
57
|
+
node.children = children;
|
|
58
|
+
node.data = {
|
|
59
|
+
hName: 'div',
|
|
60
|
+
hProperties: {
|
|
61
|
+
className: ['dommief-container', `dommief-container--${type}`],
|
|
62
|
+
// warning/error là thông tin cần chú ý ngay → role="alert" không đúng (nó ngắt screen reader);
|
|
63
|
+
// dùng role="note" cho mọi loại, kiểu nằm ở class.
|
|
64
|
+
role: 'note',
|
|
65
|
+
...(title ? {} : { 'aria-label': type }),
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
});
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function textOf(node: any): string {
|
|
73
|
+
let out = '';
|
|
74
|
+
visit(node, 'text', (t: any) => { out += t.value; });
|
|
75
|
+
return out.trim();
|
|
76
|
+
}
|