dommief 0.1.1 → 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 (45) hide show
  1. package/dist/cli.js +168 -143
  2. package/dist/index.js +5 -1
  3. package/package.json +1 -1
  4. package/src/build.ts +290 -0
  5. package/src/cache.ts +172 -0
  6. package/src/cli.ts +87 -0
  7. package/src/config.ts +258 -0
  8. package/src/exclude.ts +38 -0
  9. package/src/index.ts +17 -0
  10. package/src/link-check.ts +101 -0
  11. package/src/log.ts +10 -0
  12. package/src/markdown/errors.ts +28 -0
  13. package/src/markdown/index.ts +96 -0
  14. package/src/markdown/rehype-api.ts +131 -0
  15. package/src/markdown/rehype-code.ts +147 -0
  16. package/src/markdown/rehype-demo.ts +76 -0
  17. package/src/markdown/rehype-desc.ts +30 -0
  18. package/src/markdown/rehype-img.ts +33 -0
  19. package/src/markdown/rehype-link.ts +50 -0
  20. package/src/markdown/rehype-slug.ts +41 -0
  21. package/src/markdown/rehype-text.ts +53 -0
  22. package/src/markdown/remark-container.ts +76 -0
  23. package/src/markdown/remark-meta.ts +73 -0
  24. package/src/markdown/to-module.ts +35 -0
  25. package/src/paths.ts +38 -0
  26. package/src/plugins.ts +131 -0
  27. package/src/prefers-color.ts +28 -0
  28. package/src/preview.ts +127 -0
  29. package/src/routes.ts +232 -0
  30. package/src/search.ts +88 -0
  31. package/src/siteFeatures.ts +123 -0
  32. package/src/siteModule.ts +47 -0
  33. package/src/sitemap.ts +59 -0
  34. package/src/ssr-guard.ts +72 -0
  35. package/src/theme/index.ts +5 -0
  36. package/src/theme/loader.ts +151 -0
  37. package/src/theme/resolver.ts +115 -0
  38. package/src/theme/types.ts +25 -0
  39. package/src/theme-modules.d.ts +52 -0
  40. package/src/utils/path.ts +8 -0
  41. package/src/vite/plugin.ts +182 -0
  42. package/src/vite/routesPlugin.ts +39 -0
  43. package/src/vite/searchIndexPlugin.ts +66 -0
  44. package/src/vite/sitePlugin.ts +97 -0
  45. package/src/vite/state.ts +27 -0
package/src/search.ts ADDED
@@ -0,0 +1,88 @@
1
+ import MiniSearch from 'minisearch';
2
+ import type { Route, ResolvedLocale } from './types.js';
3
+ import type { PageMeta, TextFragment } from './markdown/index.js';
4
+ import { createMiniSearchConfig, SEARCH_FIELDS, SEARCH_STORE_FIELDS, type MiniSearchConfig } from './search-config.js';
5
+
6
+ export const INDEX_BUDGET_BYTES_PER_PAGE = 5 * 1024;
7
+
8
+ // Giữ re-export để không phá import cũ (`from './search.js'`) — nguồn thật đã chuyển sang
9
+ // search-config.ts để worker (DM-802) dùng chung mà không kéo theo `minisearch`/Node-only code.
10
+ export { createMiniSearchConfig, SEARCH_FIELDS, SEARCH_STORE_FIELDS };
11
+ export type { MiniSearchConfig };
12
+
13
+ export interface SearchDocument {
14
+ id: string;
15
+ routeId: string;
16
+ path: string;
17
+ pageTitle: string;
18
+ sectionTitle: string;
19
+ paraId: number;
20
+ value: string;
21
+ pageKey: string;
22
+ sectionKey: string;
23
+ }
24
+
25
+ export interface BuildSearchIndexOptions {
26
+ routes: Route[];
27
+ loadPageMeta: (route: Route) => PageMeta | Promise<PageMeta>;
28
+ config: { locales: ResolvedLocale[] };
29
+ }
30
+
31
+ export interface BuildSearchIndexResult {
32
+ locales: Record<string, unknown>;
33
+ stats: Record<string, { docs: number; bytes: number; pages: number }>;
34
+ manifest: { locales: Record<string, string> };
35
+ }
36
+
37
+ export async function buildSearchIndex(opts: BuildSearchIndexOptions): Promise<BuildSearchIndexResult> {
38
+ const { routes, loadPageMeta, config } = opts;
39
+ const cfg = createMiniSearchConfig();
40
+ const defaultLocale = config.locales[0]?.id;
41
+ const localesOut: Record<string, unknown> = {};
42
+ const stats: Record<string, { docs: number; bytes: number; pages: number }> = {};
43
+ const manifestLocales: Record<string, string> = {};
44
+
45
+ for (const loc of config.locales) {
46
+ const locRoutes = routes.filter((r) => (r.locale ?? defaultLocale) === loc.id);
47
+ if (locRoutes.length === 0) continue;
48
+ const ms = new MiniSearch({
49
+ fields: [...SEARCH_FIELDS],
50
+ storeFields: [...SEARCH_STORE_FIELDS],
51
+ ...cfg,
52
+ });
53
+ const docs: SearchDocument[] = [];
54
+ for (const route of locRoutes) {
55
+ const meta = await loadPageMeta(route);
56
+ const title = (meta.frontmatter?.title as string) || route.id;
57
+ const toc = meta.toc || [];
58
+ const fragments = meta.texts || [];
59
+ // group by tocIndex / orphan
60
+ for (let i = 0; i < fragments.length; i++) {
61
+ const f = fragments[i] as TextFragment;
62
+ const tocIndex = f.tocIndex;
63
+ const sectionTitle = tocIndex !== undefined && toc[tocIndex] ? toc[tocIndex].title : title;
64
+ const pageKey = route.id;
65
+ const sectionKey = `${route.id}::${tocIndex ?? 'orphan'}`;
66
+ docs.push({
67
+ id: `${route.id}#${f.paraId ?? i}#${i}`,
68
+ routeId: route.id,
69
+ path: route.path,
70
+ pageTitle: title,
71
+ sectionTitle,
72
+ paraId: f.paraId ?? 0,
73
+ value: f.value,
74
+ pageKey,
75
+ sectionKey,
76
+ });
77
+ }
78
+ }
79
+ ms.addAll(docs);
80
+ const json = ms.toJSON();
81
+ localesOut[loc.id] = json;
82
+ const bytes = Buffer.byteLength(JSON.stringify(json));
83
+ stats[loc.id] = { docs: docs.length, bytes, pages: locRoutes.length };
84
+ manifestLocales[loc.id] = `/search-index/${loc.id}.json`;
85
+ }
86
+
87
+ return { locales: localesOut, stats, manifest: { locales: manifestLocales } };
88
+ }
@@ -0,0 +1,123 @@
1
+ import type { Route, NavItem, SidebarGroup, SidebarItem, ResolvedLocale } from './types.js';
2
+ import { parseFrontmatterYaml } from './markdown/remark-meta.js';
3
+
4
+ export type FrontmatterMap = Record<string, Record<string, unknown>>;
5
+
6
+ function fmFor(map: FrontmatterMap | undefined, route: Route): Record<string, unknown> {
7
+ if (!map) return {};
8
+ return map[route.id] ?? map[route.file] ?? {};
9
+ }
10
+
11
+ function localeId(route: Route, locales: ResolvedLocale[]): string {
12
+ if (route.locale) return route.locale;
13
+ return locales[0]?.id ?? 'default';
14
+ }
15
+
16
+ function titleOf(route: Route, fm: Record<string, unknown>): string {
17
+ const t = fm.title ?? fm.navTitle ?? fm.sidebarTitle;
18
+ if (typeof t === 'string' && t.trim()) return t;
19
+ return route.id.split('/').pop() ?? route.id;
20
+ }
21
+
22
+ function orderOf(fm: Record<string, unknown>): number {
23
+ const v = fm.order ?? fm.navOrder ?? fm.sidebarOrder;
24
+ return typeof v === 'number' ? v : Number(v ?? 0) || 0;
25
+ }
26
+
27
+ export function buildNav(
28
+ routes: Route[],
29
+ frontmatter?: FrontmatterMap,
30
+ locales: ResolvedLocale[] = [],
31
+ ): Record<string, NavItem[]> {
32
+ const out: Record<string, NavItem[]> = {};
33
+ const groups = new Map<string, NavItem[]>();
34
+ for (const route of routes) {
35
+ const lid = localeId(route, locales);
36
+ const fm = fmFor(frontmatter, route);
37
+ const navFlag = fm.nav;
38
+ if (navFlag === false) continue;
39
+ const item: NavItem = {
40
+ title: titleOf(route, fm),
41
+ link: route.path,
42
+ order: orderOf(fm),
43
+ activePath: route.path,
44
+ };
45
+ const arr = groups.get(lid) ?? [];
46
+ arr.push(item);
47
+ groups.set(lid, arr);
48
+ }
49
+ for (const [lid, items] of groups) {
50
+ out[lid] = items.sort((a, b) => a.order - b.order || String(a.link).localeCompare(String(b.link)));
51
+ }
52
+ // ensure every locale has an entry
53
+ for (const l of locales) {
54
+ if (!out[l.id]) out[l.id] = [];
55
+ }
56
+ return out;
57
+ }
58
+
59
+ export function buildSidebar(
60
+ routes: Route[],
61
+ frontmatter?: FrontmatterMap,
62
+ locales: ResolvedLocale[] = [],
63
+ ): Record<string, SidebarGroup[]> {
64
+ const out: Record<string, SidebarGroup[]> = {};
65
+ const byLocale = new Map<string, Map<string, SidebarGroup>>();
66
+ for (const route of routes) {
67
+ const lid = localeId(route, locales);
68
+ const fm = fmFor(frontmatter, route);
69
+ const groupTitle = typeof fm.group === 'string' && fm.group.trim() ? fm.group : (route.id.split('/')[0] ?? 'General');
70
+ const item: SidebarItem = {
71
+ title: titleOf(route, fm),
72
+ link: route.path,
73
+ order: orderOf(fm),
74
+ ...(Object.keys(fm).length ? { frontmatter: fm } : {}),
75
+ };
76
+ let groups = byLocale.get(lid);
77
+ if (!groups) {
78
+ groups = new Map();
79
+ byLocale.set(lid, groups);
80
+ }
81
+ let g = groups.get(groupTitle);
82
+ if (!g) {
83
+ g = { title: groupTitle, order: orderOf(fm), children: [] };
84
+ groups.set(groupTitle, g);
85
+ }
86
+ g.children.push(item);
87
+ g.order = Math.min(g.order, orderOf(fm));
88
+ }
89
+ for (const [lid, groups] of byLocale) {
90
+ const arr = [...groups.values()].map((g) => ({
91
+ ...g,
92
+ children: g.children.sort((a, b) => a.order - b.order || a.link.localeCompare(b.link)),
93
+ }));
94
+ out[lid] = arr.sort((a, b) => a.order - b.order || String(a.title).localeCompare(String(b.title)));
95
+ }
96
+ for (const l of locales) {
97
+ if (!out[l.id]) out[l.id] = [];
98
+ }
99
+ return out;
100
+ }
101
+
102
+ export interface PaginationResult {
103
+ prev?: Route;
104
+ next?: Route;
105
+ }
106
+
107
+ export function paginate(routes: Route[], currentPath: string): PaginationResult {
108
+ const current = routes.find((r) => r.path === currentPath);
109
+ if (!current) return {};
110
+ // chỉ trong cùng locale; thứ tự theo code point như route table
111
+ const cmp = (x: string, y: string) => (x < y ? -1 : x > y ? 1 : 0);
112
+ const sorted = routes.filter((r) => r.locale === current.locale).sort((a, b) => cmp(a.path, b.path));
113
+ const idx = sorted.findIndex((r) => r.path === currentPath);
114
+ return {
115
+ ...(idx > 0 ? { prev: sorted[idx - 1] } : {}),
116
+ ...(idx < sorted.length - 1 ? { next: sorted[idx + 1] } : {}),
117
+ };
118
+ }
119
+
120
+ /** @deprecated dùng `parseFrontmatterYaml` (yaml thật). Giữ lại cho tương thích export cũ, cùng chung một parser (#12). */
121
+ export function parseFrontmatter(fileContent: string): Record<string, unknown> {
122
+ return parseFrontmatterYaml(fileContent);
123
+ }
@@ -0,0 +1,47 @@
1
+ import type { Route, SiteData, TocEntry } from './types.js';
2
+ import { buildNav, buildSidebar, type FrontmatterMap } from './siteFeatures.js';
3
+
4
+ // Đặc tả: docs/spec/CONTRACTS.md §4–§6, backlog DM-209.
5
+
6
+ /** Phần config `virtual:dommief/site` cần (ResolvedConfig của DM-201 thoả). */
7
+ export interface SiteModuleConfig {
8
+ base: string;
9
+ title: string;
10
+ description?: string | undefined;
11
+ locales: SiteData['locales'];
12
+ themeConfig?: Record<string, unknown> | undefined;
13
+ frontmatter?: FrontmatterMap | undefined;
14
+ search?: { enabled: boolean } | undefined;
15
+ }
16
+
17
+ /** `meta[id]` cho mỗi route: do caller tính sẵn (sitePlugin dùng `extractPageSummary`, #12 — một nguồn sự thật, không đọc `.md?meta` lần nữa qua import). */
18
+ export type SiteModuleMeta = Record<string, { frontmatter: Record<string, unknown>; toc: TocEntry[] }>;
19
+
20
+ /**
21
+ * Sinh code cho `virtual:dommief/site`. Mọi thứ nhúng thẳng vào JSON (không import `.md?meta`
22
+ * cho từng route — mở một trang trong dev từng kéo theo transform MỌI file md, #12), và không
23
+ * import `virtual:dommief/routes` (hai module invalidate độc lập).
24
+ */
25
+ export function createSiteModule({ routes, config, meta }: { routes: Route[]; config: SiteModuleConfig; meta: SiteModuleMeta }): string {
26
+ const routesById = Object.fromEntries(routes.map((r) => [r.id, r]));
27
+ const frontmatter = config.frontmatter ?? {};
28
+ const nav = buildNav(routes, frontmatter, config.locales);
29
+ const sidebar = buildSidebar(routes, frontmatter, config.locales);
30
+ const head = {
31
+ base: config.base,
32
+ title: config.title,
33
+ ...(config.description === undefined ? {} : { description: config.description }),
34
+ locales: config.locales,
35
+ themeConfig: config.themeConfig ?? {},
36
+ routes: routesById,
37
+ search: config.search ?? { enabled: true },
38
+ };
39
+ const json = JSON.stringify(head);
40
+ return `export default {
41
+ ...${json},
42
+ meta: ${JSON.stringify(meta)},
43
+ nav: ${JSON.stringify(nav)},
44
+ sidebar: ${JSON.stringify(sidebar)},
45
+ };
46
+ `;
47
+ }
package/src/sitemap.ts ADDED
@@ -0,0 +1,59 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import type { Route } from './types.js';
4
+ import { matchExclude } from './exclude.js';
5
+
6
+ // Đặc tả: docs/backlog/DM-708.md, docs/spec/CONFIG.md §1.5. URL tuyệt đối theo `base` + hostname
7
+ // cấu hình (KHÔNG đoán domain — thiếu hostname thì không phát gì, không crash). Chỉ route tĩnh
8
+ // đã prerender (routes truyền vào phải là `plan.prerender`, không phải mọi route).
9
+
10
+ export interface SitemapConfig {
11
+ hostname: string;
12
+ exclude: string[];
13
+ }
14
+
15
+ export function canonicalUrl(hostname: string, base: string, routePath: string): string {
16
+ const host = hostname.replace(/\/$/, '');
17
+ const b = base.replace(/\/$/, '');
18
+ const p = routePath === '/' ? '/' : routePath;
19
+ return host + (b + p).replace(/\/{2,}/g, '/').replace(/^(https?:\/)([^/])/, '$1/$2');
20
+ }
21
+
22
+ /** `#404`, route động (`:`/`*`), và route trong `sitemap.exclude` không vào sitemap. */
23
+ export function buildSitemap(routes: Route[], config: SitemapConfig, base: string): string {
24
+ const urls = routes
25
+ .filter((r) => r.path !== '/404' && !/[:*]/.test(r.path) && !matchExclude(r.path, config.exclude))
26
+ .map((r) => ` <url><loc>${escapeXml(canonicalUrl(config.hostname, base, r.path))}</loc></url>`)
27
+ .join('\n');
28
+ return `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${urls}\n</urlset>\n`;
29
+ }
30
+
31
+ export function buildRobots(sitemapUrl: string): string {
32
+ return `User-agent: *\nAllow: /\n\nSitemap: ${sitemapUrl}\n`;
33
+ }
34
+
35
+ export function escapeXml(s: string): string {
36
+ return s.replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&apos;' })[c]!);
37
+ }
38
+
39
+ /** emitter nối vào `build.ts`: ghi `sitemap.xml`/`robots.txt` khi config có `sitemap` (khác `false`). */
40
+ export function writeSitemapAndRobots(opts: {
41
+ outDir: string;
42
+ routes: Route[];
43
+ sitemap: false | SitemapConfig;
44
+ robots: boolean;
45
+ base: string;
46
+ log: { warn(msg: string): void };
47
+ }): void {
48
+ if (opts.sitemap === false) return;
49
+ if (!opts.sitemap.hostname) {
50
+ opts.log.warn('sitemap.hostname thiếu — bỏ qua sitemap.xml/robots.txt');
51
+ return;
52
+ }
53
+ const xml = buildSitemap(opts.routes, opts.sitemap, opts.base);
54
+ fs.writeFileSync(path.join(opts.outDir, 'sitemap.xml'), xml);
55
+ if (opts.robots) {
56
+ const sitemapUrl = canonicalUrl(opts.sitemap.hostname, opts.base, '/').replace(/\/$/, '') + '/sitemap.xml';
57
+ fs.writeFileSync(path.join(opts.outDir, 'robots.txt'), buildRobots(sitemapUrl));
58
+ }
59
+ }
@@ -0,0 +1,72 @@
1
+ import { DommiefError } from './markdown/errors.js';
2
+
3
+ // Đặc tả: docs/backlog/DM-408.md. Cổng ở mức prerender: trước renderToString, cài getter trên
4
+ // `window`/`document` để BẤT KỲ truy cập nào trong thân render ném lỗi NGAY (không chỉ warn) —
5
+ // chạy đúng điều kiện production khi người dùng chỉ "warn rồi bỏ qua" là nhận trang trắng/
6
+ // hydration lỗi trên site thật. Throw sớm, tại route hỏng, kèm routeId sửa được trong 1 phút.
7
+
8
+ type PropertyKey2 = 'window' | 'document';
9
+
10
+ interface SavedDescriptor { key: PropertyKey2; descriptor: PropertyDescriptor | undefined; }
11
+
12
+ let installed: SavedDescriptor[] | null = null;
13
+ // Đếm số lần `withSsrGuard` đang CHẠY ĐỖNG THờI: khi `config.ssg.concurrency > 1`,
14
+ // nhiều route render SONG SONG (build.ts `mapLimit`) dùng chung `globalThis`. Nếu không đếm,
15
+ // route A xong trước sẽ `uninstall()` và gỡ guard giữa chừng khi route B vẫn đang render
16
+ // — B mất bảo vệ mà không ai biết. Chỉ `uninstall()` thật khi đếm về 0.
17
+ let refCount = 0;
18
+
19
+ function guardGetter(name: PropertyKey2): PropertyDescriptor {
20
+ return {
21
+ configurable: true,
22
+ enumerable: false,
23
+ get(): never {
24
+ throw new DommiefError(
25
+ `truy cập "${name}" lúc render (SSR) — lỗi \`ssr_unsafe_access\`. Chuyển vào useEffect/event handler, hoặc bọc nội dung trong <ClientOnly>. Nếu đây là code của thư viện bên thứ 3 đọc "${name}" ngay lúc import (top-level), chuyển sang import động bên trong useEffect.`,
26
+ { code: 'ssr_unsafe_access' },
27
+ );
28
+ },
29
+ };
30
+ }
31
+
32
+ /**
33
+ * Cài getter chặn `window`/`document` trên `globalThis`. Idempotent: gọi 2 lần không đè descriptor gốc lần 2
34
+ * (nếu không, `uninstall()` sau đó sẽ khôi phục nhầm descriptor-chặn thay vì descriptor thật).
35
+ * Tắt bằng `DOMMIEF_SSR_GUARD=0` — đường thoát cho theme bên thứ 3 đang chuyển đổi.
36
+ */
37
+ export function install(): void {
38
+ if (process.env.DOMMIEF_SSR_GUARD === '0') return;
39
+ if (installed) return;
40
+ const saved: SavedDescriptor[] = [];
41
+ for (const key of ['window', 'document'] as const) {
42
+ saved.push({ key, descriptor: Object.getOwnPropertyDescriptor(globalThis, key) });
43
+ Object.defineProperty(globalThis, key, guardGetter(key));
44
+ }
45
+ installed = saved;
46
+ }
47
+
48
+ /** Gỡ getter, khôi phục descriptor gốc (kể cả khi gốc là `undefined` — Node không có sẵn `window`). PHẢI gọi trong `finally` để không rò getter sang route sau khi route hiện tại throw. */
49
+ export function uninstall(): void {
50
+ if (!installed) return;
51
+ for (const { key, descriptor } of installed) {
52
+ if (descriptor) Object.defineProperty(globalThis, key, descriptor);
53
+ else delete (globalThis as any)[key];
54
+ }
55
+ installed = null;
56
+ }
57
+
58
+ /**
59
+ * Chạy `fn` với cổng SSR-safety bật, luôn gỡ sau khi xong (kể cả throw). An toàn khi nhiều
60
+ * lời gọi chạy SONG SONG (reference counting): guard thật chỉ bị gỡ khi lần gọi CUỐI
61
+ * CÙNG đang chạy hoàn tất, không phải lần đầu tiên xong.
62
+ */
63
+ export async function withSsrGuard<T>(fn: () => Promise<T>): Promise<T> {
64
+ refCount++;
65
+ install();
66
+ try {
67
+ return await fn();
68
+ } finally {
69
+ refCount--;
70
+ if (refCount === 0) uninstall();
71
+ }
72
+ }
@@ -0,0 +1,5 @@
1
+ export * from './types.js';
2
+ export { loadTheme } from './loader.js';
3
+ export type { ThemeManifest as ThemeManifestType } from './types.js';
4
+ export { resolveSlot, resolveThemeSlots, buildAliasChain } from './resolver.js';
5
+ export type { ThemeKind, AliasMap } from './resolver.js';
@@ -0,0 +1,151 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { DommiefError } from '../markdown/errors.js';
5
+ import type { ThemeManifest, ThemeLoaderOptions, ThemeLayoutName } from './types.js';
6
+
7
+ // Đặc tả: docs/spec/THEME-API.md §2, backlog DM-501.
8
+ // Luật quét giữ y dumi (`features/theme/loader.ts`): chỉ file/thư mục tên bắt đầu bằng chữ HOA
9
+ // hoặc chữ số mới được coi là component; `Foo.ext` và `Foo/index.ext` cho CÙNG specifier `Foo`.
10
+
11
+ const EXTS = ['.tsx', '.ts', '.jsx', '.js'];
12
+ const DEFAULT_THEME = 'dommief-theme-default';
13
+
14
+ function isComponentName(name: string): boolean {
15
+ return /^[A-Z0-9]/.test(name);
16
+ }
17
+
18
+ /** Thư mục chứa `packages/` của chính monorepo dommief (để test/dev dùng theme-default từ source, không cần build trước). */
19
+ function repoPackagesDir(): string | null {
20
+ let cur = path.dirname(fileURLToPath(import.meta.url));
21
+ while (cur !== path.parse(cur).root) {
22
+ if (fs.existsSync(path.join(cur, 'pnpm-workspace.yaml'))) return path.join(cur, 'packages');
23
+ cur = path.dirname(cur);
24
+ }
25
+ return null;
26
+ }
27
+
28
+ function assertInside(cwd: string, target: string, theme: string): void {
29
+ const rel = path.relative(cwd, target);
30
+ const insideCwd = rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
31
+ const insideNodeModules = target.split(path.sep).includes('node_modules');
32
+ if (!insideCwd && !insideNodeModules) {
33
+ throw new DommiefError(`đường dẫn theme "${theme}" thoát ra ngoài thư mục dự án: ${target}`, { code: 'theme_path_escape', file: target });
34
+ }
35
+ }
36
+
37
+ /** `theme` là path tương đối (bắt đầu bằng `.`), tên package trong `node_modules`, hoặc tên package nguồn trong monorepo (chỉ khi dev/test dommief chính nó). */
38
+ export function resolveThemeRoot(cwd: string, theme: string): string {
39
+ const isPathLike = theme.startsWith('.') || theme.startsWith('/');
40
+ let root: string | null = null;
41
+ if (isPathLike) {
42
+ // Chỉ ĐƯỜNG DẪN tường minh (`theme: './my-theme'`) mới chịu kiểm "không thoát khỏi dự án":
43
+ // đây là input user viết tay trong config, khác với resolve theo tên package (node_modules/
44
+ // workspace symlink đã tự do trỏ ra ngoài cwd theo thiết kế của Node/pnpm).
45
+ const candidate = path.resolve(cwd, theme);
46
+ if (fs.existsSync(candidate) && fs.statSync(candidate).isDirectory()) root = candidate;
47
+ if (!root) throw new DommiefError(`không tìm thấy theme tại đường dẫn "${theme}"`, { code: 'theme_package_not_found' });
48
+ assertInside(cwd, root, theme);
49
+ } else {
50
+ const nmCandidate = path.resolve(cwd, 'node_modules', theme);
51
+ if (fs.existsSync(nmCandidate)) root = nmCandidate;
52
+ else {
53
+ // Fallback chỉ dùng khi chạy BÊN TRONG chính monorepo dommief (test/dogfood không cài qua
54
+ // node_modules): không áp dụng assertInside vì đây là quy ước nội bộ, không phải input user.
55
+ const packages = repoPackagesDir();
56
+ const pkgDirName = theme === DEFAULT_THEME ? 'theme-default' : theme;
57
+ const srcCandidate = packages ? path.join(packages, pkgDirName) : null;
58
+ if (srcCandidate && fs.existsSync(srcCandidate)) root = srcCandidate;
59
+ }
60
+ if (!root) {
61
+ throw new DommiefError(`không tìm thấy theme package "${theme}" (đã thử node_modules/${theme})`, { code: 'theme_package_not_found' });
62
+ }
63
+ }
64
+ // Package nguồn trong monorepo (hoặc workspace link): component nằm ở `src/`, không phải `dist/`.
65
+ if (fs.existsSync(path.join(root, 'src', 'layouts'))) root = path.join(root, 'src');
66
+ return root;
67
+ }
68
+
69
+ function pickEntry(dir: string, name: string): string | null {
70
+ for (const ext of EXTS) {
71
+ const f = path.join(dir, name + ext);
72
+ if (fs.existsSync(f) && fs.statSync(f).isFile()) return f;
73
+ }
74
+ const subIndexDir = path.join(dir, name);
75
+ if (fs.existsSync(subIndexDir) && fs.statSync(subIndexDir).isDirectory()) {
76
+ for (const ext of EXTS) {
77
+ const f = path.join(subIndexDir, 'index' + ext);
78
+ if (fs.existsSync(f) && fs.statSync(f).isFile()) return f;
79
+ }
80
+ }
81
+ return null;
82
+ }
83
+
84
+ /** Quét một thư mục (`slots/`, `builtins/`) ra `specifier -> absolute path`, theo luật "chữ hoa/chữ số đầu". */
85
+ export function scanComponents(dir: string): Record<string, string> {
86
+ const out: Record<string, string> = {};
87
+ if (!fs.existsSync(dir)) return out;
88
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
89
+ const name = entry.name;
90
+ if (!isComponentName(name)) continue;
91
+ if (entry.isFile()) {
92
+ const ext = EXTS.find((e) => name.endsWith(e));
93
+ if (!ext) continue;
94
+ const specifier = name.slice(0, -ext.length);
95
+ if (!isComponentName(specifier)) continue;
96
+ out[specifier] ??= path.resolve(dir, name);
97
+ } else if (entry.isDirectory()) {
98
+ const found = pickEntry(dir, name);
99
+ if (found) out[name] = path.resolve(found);
100
+ }
101
+ }
102
+ return out;
103
+ }
104
+
105
+ function scanLocales(dir: string): Record<string, string> {
106
+ const out: Record<string, string> = {};
107
+ if (!fs.existsSync(dir)) return out;
108
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
109
+ if (entry.isFile() && entry.name.endsWith('.json')) {
110
+ out[entry.name.slice(0, -'.json'.length)] = path.resolve(dir, entry.name);
111
+ }
112
+ }
113
+ return out;
114
+ }
115
+
116
+ /** Quét một thư mục theme (đã resolve) ra `ThemeManifest`. KHÔNG áp dụng override — đó là việc của resolver.ts (merge theo từng component qua 2-3 manifest). */
117
+ export function loadThemeManifest(root: string, name: string): ThemeManifest {
118
+ const layoutsDir = path.join(root, 'layouts');
119
+ const layouts = {} as Record<ThemeLayoutName, string>;
120
+ const missing: ThemeLayoutName[] = [];
121
+ for (const layoutName of ['GlobalLayout', 'DocLayout', 'DemoLayout'] as const) {
122
+ const p = pickEntry(layoutsDir, layoutName);
123
+ if (p) layouts[layoutName] = p;
124
+ else missing.push(layoutName);
125
+ }
126
+ if (missing.length) {
127
+ throw new DommiefError(`theme "${name}" thiếu layout: ${missing.join(', ')}`, { code: 'theme_layout_missing', file: layoutsDir, layout: missing.join(',') });
128
+ }
129
+ const pluginJs = ['plugin.ts', 'plugin.js', path.join('plugin', 'index.ts'), path.join('plugin', 'index.js')]
130
+ .map((f) => path.join(root, f))
131
+ .find((f) => fs.existsSync(f));
132
+ return {
133
+ name,
134
+ root,
135
+ builtins: scanComponents(path.join(root, 'builtins')),
136
+ slots: scanComponents(path.join(root, 'slots')),
137
+ layouts,
138
+ locales: scanLocales(path.join(root, 'locales')),
139
+ plugin: pluginJs ?? null,
140
+ };
141
+ }
142
+
143
+ /** Resolve + quét theme `opts.theme` (mặc định `dommief-theme-default`). Không áp override local — xem `loadThemeChain` trong resolver.ts. */
144
+ export function loadTheme(opts: ThemeLoaderOptions): ThemeManifest {
145
+ const cwd = path.resolve(opts.cwd);
146
+ const theme = opts.theme ?? DEFAULT_THEME;
147
+ const root = resolveThemeRoot(cwd, theme);
148
+ return loadThemeManifest(root, theme);
149
+ }
150
+
151
+ export { DEFAULT_THEME };
@@ -0,0 +1,115 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { DommiefError } from '../markdown/errors.js';
4
+ import type { ResolvedConfig } from '../config.js';
5
+ import { loadTheme, loadThemeManifest, DEFAULT_THEME } from './loader.js';
6
+ import { scanComponents } from './loader.js';
7
+ import type { ThemeLayoutName, ThemeManifest } from './types.js';
8
+
9
+ // Đặc tả: docs/spec/THEME-API.md §3, backlog DM-502.
10
+ // Thứ tự ưu tiên (THẮNG → THUA): `.dommief/theme/**` > theme package (config `theme`) > `dommief-theme-default`.
11
+ // Resolve chạy THEO TỪNG component, không theo cả theme: core quét cả 3 nguồn, merge nông theo key.
12
+
13
+ export type ThemeKind = 'slots' | 'builtins' | 'layouts' | 'locales';
14
+ export type AliasMap = Record<string, string>;
15
+
16
+ export interface ThemeChain {
17
+ /** `.dommief/theme/**` nếu tồn tại (thư mục không cần có cả 4 kind con). */
18
+ local: ThemeManifest | null;
19
+ /** Theme package khai ở config `theme`; bằng `fallback` nếu config trỏ default. */
20
+ pkg: ThemeManifest;
21
+ /** `dommief-theme-default`, luôn là đáy. */
22
+ fallback: ThemeManifest;
23
+ }
24
+
25
+ function tryLoadLocal(cwd: string): ThemeManifest | null {
26
+ const localRoot = path.resolve(cwd, '.dommief', 'theme');
27
+ if (!fs.existsSync(localRoot)) return null;
28
+ // Local override không bắt buộc có layouts (THEME-API §3: override từng component).
29
+ // loadThemeManifest ném lỗi khi thiếu layout -> quét thủ công, không gọi loadThemeManifest.
30
+ const layoutsDir = path.join(localRoot, 'layouts');
31
+ const layouts = {} as Record<ThemeLayoutName, string>;
32
+ for (const name of ['GlobalLayout', 'DocLayout', 'DemoLayout'] as const) {
33
+ const found = scanComponents(layoutsDir)[name];
34
+ if (found) layouts[name] = found;
35
+ }
36
+ return {
37
+ name: '.dommief/theme',
38
+ root: localRoot,
39
+ builtins: scanComponents(path.join(localRoot, 'builtins')),
40
+ slots: scanComponents(path.join(localRoot, 'slots')),
41
+ layouts,
42
+ locales: {},
43
+ plugin: null,
44
+ };
45
+ }
46
+
47
+ /** Quét `.dommief/theme/**` + theme package + `dommief-theme-default`. `pkg === fallback` khi config.theme là mặc định. */
48
+ export function loadThemeChain(cwd: string, config: Pick<ResolvedConfig, 'theme'>): ThemeChain {
49
+ const themeName = config.theme ?? DEFAULT_THEME;
50
+ const pkg = loadTheme({ cwd, theme: themeName });
51
+ const fallback = themeName === DEFAULT_THEME ? pkg : loadTheme({ cwd, theme: DEFAULT_THEME });
52
+ return { local: tryLoadLocal(cwd), pkg, fallback };
53
+ }
54
+
55
+ function manifestsOf(chain: ThemeChain): ThemeManifest[] {
56
+ // Thứ tự ưu tiên: local trước (thắng), rồi pkg, rồi fallback. Nếu pkg === fallback, liệt kê 1 lần (không đổi kết quả, chỉ tránh việc lặp vô nghĩa).
57
+ const list = [chain.local, chain.pkg, chain.pkg === chain.fallback ? null : chain.fallback].filter((m): m is ThemeManifest => m !== null);
58
+ return list;
59
+ }
60
+
61
+ /** Trả absolute path thắng của `name` trong `kind`, duyệt `chain` theo thứ tự ưu tiên. Ném lỗi nếu không ai có. */
62
+ export function resolveSlot(name: string, kind: ThemeKind, chain: ThemeChain): string {
63
+ for (const m of manifestsOf(chain)) {
64
+ const hit = kind === 'layouts' ? (m.layouts as Record<string, string>)[name] : kind === 'locales' ? m.locales[name] : m[kind][name];
65
+ if (hit) return hit;
66
+ }
67
+ throw new DommiefError(`không có theme nào cung cấp ${kind}/${name}`, { code: 'theme_slot_missing' });
68
+ }
69
+
70
+ /** Danh sách mọi specifier từng xuất hiện trong `kind`, gộp từ cả 3 manifest (để biết override gì có thể alias). */
71
+ function allNames(chain: ThemeChain, kind: ThemeKind): string[] {
72
+ const names = new Set<string>();
73
+ for (const m of manifestsOf(chain)) {
74
+ const map = kind === 'layouts' ? m.layouts : kind === 'locales' ? m.locales : m[kind];
75
+ for (const k of Object.keys(map)) names.add(k);
76
+ }
77
+ return [...names];
78
+ }
79
+
80
+ /**
81
+ * Sinh bảng alias Vite cho mọi component đã quét được: `dommief/theme/<kind>/<Name>` → absolute path thắng.
82
+ * Cũng alias `dommief/theme` → root của theme package (không phải local, không phải default — đúng ý
83
+ * "theme đang dùng" cho mục đích debug/log; component thật đi qua alias riêng từng cái).
84
+ */
85
+ export function buildAliasChain(cwd: string, config: Pick<ResolvedConfig, 'theme'>): AliasMap {
86
+ const chain = loadThemeChain(cwd, config);
87
+ const aliases: AliasMap = {};
88
+ for (const kind of ['slots', 'builtins', 'layouts'] as const) {
89
+ for (const name of allNames(chain, kind)) {
90
+ aliases[`dommief/theme/${kind}/${name}`] = resolveSlot(name, kind, chain);
91
+ }
92
+ }
93
+ // QUAN TRỌNG: alias tổng quát `dommief/theme` PHẢI được thêm SAU cùng. Vite/`@rollup/plugin-alias`
94
+ // khớp theo thứ tự chuỗi key (object → array, giữ thứ tự chèn) và dùng pattern string TRÙNG
95
+ // PREFIX (`importee.startsWith(pattern + '/')`) — nếu `dommief/theme` đứng TRƯỚC, nó khớp
96
+ // ngay cả `dommief/theme/slots/Footer` và thắng trước alias cụ thể hơn, thay thế đúng đoạn
97
+ // prefix bằng `pkg.root` rồi nối `/slots/Footer` → luôn ra đường dẫn DEFAULT, bỏ qua local
98
+ // override hoàn toàn (đã bắt trực tiếp bằng build-e2e thật: alias đúng nhưng output sai).
99
+ aliases['dommief/theme'] = chain.pkg.root;
100
+ return aliases;
101
+ }
102
+
103
+ /** Merge nông theo key, local > pkg > default (THEME-API §7.5). */
104
+ export function mergeLocaleStrings(chain: ThemeChain, localeId: string): Record<string, string> {
105
+ const readJson = (file: string | undefined): Record<string, string> => {
106
+ if (!file) return {};
107
+ try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return {}; }
108
+ };
109
+ // local override không có bản dịch riêng ở v0.1 (chỉ builtins/slots/layouts) — merge chỉ pkg + fallback.
110
+ return { ...readJson(chain.fallback.locales[localeId]), ...readJson(chain.pkg.locales[localeId]) };
111
+ }
112
+
113
+ export function resolveThemeSlots(opts: { cwd: string; config: Pick<ResolvedConfig, 'theme'> }): AliasMap {
114
+ return buildAliasChain(opts.cwd, opts.config);
115
+ }