@vmz/vmz 0.0.1 → 0.0.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 +48 -2
- package/bin/vmz.js +4 -0
- package/dist/application-cmd.d.ts +22 -0
- package/dist/application-cmd.js +348 -0
- package/dist/bundler-adapter.d.ts +64 -0
- package/dist/bundler-adapter.js +111 -0
- package/dist/cli.d.ts +15 -0
- package/dist/cli.js +370 -0
- package/dist/dev-session.d.ts +35 -0
- package/dist/dev-session.js +290 -0
- package/dist/document-build.d.ts +99 -0
- package/dist/document-build.js +274 -0
- package/dist/document-check.d.ts +44 -0
- package/dist/document-check.js +246 -0
- package/dist/document-cmd.d.ts +9 -0
- package/dist/document-cmd.js +147 -0
- package/dist/document-designs.d.ts +9 -0
- package/dist/document-designs.js +126 -0
- package/dist/document-enrich.d.ts +23 -0
- package/dist/document-enrich.js +234 -0
- package/dist/document-evidence.d.ts +49 -0
- package/dist/document-evidence.js +501 -0
- package/dist/document-integrate.d.ts +35 -0
- package/dist/document-integrate.js +89 -0
- package/dist/document-interactive.d.ts +69 -0
- package/dist/document-interactive.js +255 -0
- package/dist/document-locale.d.ts +31 -0
- package/dist/document-locale.js +59 -0
- package/dist/document-markdown.d.ts +13 -0
- package/dist/document-markdown.js +39 -0
- package/dist/document-scan.d.ts +21 -0
- package/dist/document-scan.js +151 -0
- package/dist/document-schema.d.ts +87 -0
- package/dist/document-schema.js +88 -0
- package/dist/explain-cmd.d.ts +5 -0
- package/dist/explain-cmd.js +123 -0
- package/dist/index.d.ts +808 -0
- package/dist/index.js +569 -0
- package/dist/locale-check.d.ts +106 -0
- package/dist/locale-check.js +737 -0
- package/dist/locale-cmd.d.ts +5 -0
- package/dist/locale-cmd.js +443 -0
- package/dist/locale-delivery.d.ts +298 -0
- package/dist/locale-delivery.js +444 -0
- package/dist/locale-router.d.ts +207 -0
- package/dist/locale-router.js +508 -0
- package/dist/locale-runtime.d.ts +406 -0
- package/dist/locale-runtime.js +542 -0
- package/dist/locale-schema.d.ts +9 -0
- package/dist/locale-schema.js +10 -0
- package/dist/locale-tooling.d.ts +118 -0
- package/dist/locale-tooling.js +358 -0
- package/dist/log.d.ts +19 -0
- package/dist/log.js +42 -0
- package/dist/packages.d.ts +27 -0
- package/dist/packages.js +147 -0
- package/dist/plugin-host.d.ts +30 -0
- package/dist/plugin-host.js +370 -0
- package/dist/refactor-cmd.d.ts +8 -0
- package/dist/refactor-cmd.js +156 -0
- package/dist/resolve.d.ts +25 -0
- package/dist/resolve.js +56 -0
- package/dist/test-cmd.d.ts +9 -0
- package/dist/test-cmd.js +343 -0
- package/dist/test-compile.d.ts +2 -0
- package/dist/test-compile.js +3 -0
- package/dist/test-discover.d.ts +2 -0
- package/dist/test-discover.js +3 -0
- package/dist/test-logic.d.ts +2 -0
- package/dist/test-logic.js +3 -0
- package/dist/test-protocol.d.ts +2 -0
- package/dist/test-protocol.js +3 -0
- package/dist/watch-diff.d.ts +17 -0
- package/dist/watch-diff.js +56 -0
- package/package.json +81 -3
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @param {{ projectRoot: string, outDir?: string, strict?: boolean, engines?: { markdown?: string } }} opts
|
|
3
|
+
*/
|
|
4
|
+
export declare function buildDocuments(opts: any): Promise<{
|
|
5
|
+
ok: boolean;
|
|
6
|
+
manifest: {
|
|
7
|
+
schema: string;
|
|
8
|
+
root: string;
|
|
9
|
+
defaultLocale: any;
|
|
10
|
+
locales: any[];
|
|
11
|
+
localeLabels: {};
|
|
12
|
+
collections: any[];
|
|
13
|
+
mounts: any[];
|
|
14
|
+
pages: {
|
|
15
|
+
identity: any;
|
|
16
|
+
sourcePath: any;
|
|
17
|
+
route: any;
|
|
18
|
+
anchors: any[];
|
|
19
|
+
}[];
|
|
20
|
+
diagnostics: any[];
|
|
21
|
+
};
|
|
22
|
+
outDir: string;
|
|
23
|
+
pages: any[];
|
|
24
|
+
search?: undefined;
|
|
25
|
+
islands?: undefined;
|
|
26
|
+
} | {
|
|
27
|
+
ok: boolean;
|
|
28
|
+
manifest: {
|
|
29
|
+
schema: string;
|
|
30
|
+
evidence: {
|
|
31
|
+
schema: string;
|
|
32
|
+
fences: any[];
|
|
33
|
+
apiRefs: any[];
|
|
34
|
+
testSelections: any[];
|
|
35
|
+
status: string;
|
|
36
|
+
};
|
|
37
|
+
search: {
|
|
38
|
+
schema: string;
|
|
39
|
+
status: string;
|
|
40
|
+
version: any;
|
|
41
|
+
records: any[];
|
|
42
|
+
};
|
|
43
|
+
islands: {
|
|
44
|
+
schema: string;
|
|
45
|
+
hydrate: string;
|
|
46
|
+
fullPageHydrate: boolean;
|
|
47
|
+
islands: {
|
|
48
|
+
name: string;
|
|
49
|
+
kind: string;
|
|
50
|
+
resume: string;
|
|
51
|
+
index: any;
|
|
52
|
+
}[];
|
|
53
|
+
status: string;
|
|
54
|
+
};
|
|
55
|
+
build: {
|
|
56
|
+
engine: any;
|
|
57
|
+
outDir: string;
|
|
58
|
+
designs: string;
|
|
59
|
+
designsCss: any;
|
|
60
|
+
pages: any[];
|
|
61
|
+
evidence: string;
|
|
62
|
+
search: string;
|
|
63
|
+
islands: string;
|
|
64
|
+
};
|
|
65
|
+
root: string;
|
|
66
|
+
defaultLocale: any;
|
|
67
|
+
locales: any[];
|
|
68
|
+
localeLabels: {};
|
|
69
|
+
collections: any[];
|
|
70
|
+
mounts: any[];
|
|
71
|
+
pages: {
|
|
72
|
+
identity: any;
|
|
73
|
+
sourcePath: any;
|
|
74
|
+
route: any;
|
|
75
|
+
anchors: any[];
|
|
76
|
+
}[];
|
|
77
|
+
diagnostics: any[];
|
|
78
|
+
};
|
|
79
|
+
outDir: string;
|
|
80
|
+
pages: any[];
|
|
81
|
+
search: {
|
|
82
|
+
schema: string;
|
|
83
|
+
status: string;
|
|
84
|
+
version: any;
|
|
85
|
+
records: any[];
|
|
86
|
+
};
|
|
87
|
+
islands: {
|
|
88
|
+
schema: string;
|
|
89
|
+
hydrate: string;
|
|
90
|
+
fullPageHydrate: boolean;
|
|
91
|
+
islands: {
|
|
92
|
+
name: string;
|
|
93
|
+
kind: string;
|
|
94
|
+
resume: string;
|
|
95
|
+
index: any;
|
|
96
|
+
}[];
|
|
97
|
+
status: string;
|
|
98
|
+
};
|
|
99
|
+
}>;
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
// @ts-nocheck
|
|
2
|
+
/**
|
|
3
|
+
* Document D1 Static + D3 Interactive artifacts.
|
|
4
|
+
* Design: 规划设计/vmz/19 §5–6 · D1/D3
|
|
5
|
+
*/
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import { checkDocuments, manifestHasErrors } from './document-check.js';
|
|
9
|
+
import { resolveDocumentDesignsCss } from './document-designs.js';
|
|
10
|
+
import { enrichDocumentContent, pageHtmlRel } from './document-enrich.js';
|
|
11
|
+
import { enrichDocumentEvidence } from './document-evidence.js';
|
|
12
|
+
import { artifactHrefFromHtml, buildDocumentIslands, buildDocumentSearch, collectFenceBodies, renderIslandShellsHtml, } from './document-interactive.js';
|
|
13
|
+
import { resolveMarkdownEngine } from './document-markdown.js';
|
|
14
|
+
import { DOCUMENT_VIEW_SCHEMA } from './document-schema.js';
|
|
15
|
+
import { createWorkspace } from './index.js';
|
|
16
|
+
/**
|
|
17
|
+
* @param {{ projectRoot: string, outDir?: string, strict?: boolean, engines?: { markdown?: string } }} opts
|
|
18
|
+
*/
|
|
19
|
+
export async function buildDocuments(opts) {
|
|
20
|
+
const projectRoot = path.resolve(opts.projectRoot);
|
|
21
|
+
const outDir = path.resolve(opts.outDir || path.join(projectRoot, 'dist', 'documents'));
|
|
22
|
+
const strict = Boolean(opts.strict);
|
|
23
|
+
const manifest = checkDocuments({ projectRoot, strict });
|
|
24
|
+
const engine = await resolveMarkdownEngine({ engines: opts.engines, projectRoot });
|
|
25
|
+
const enriched = enrichDocumentContent(manifest, {
|
|
26
|
+
analyzeMarkdown: engine.analyzeMarkdown,
|
|
27
|
+
projectRoot,
|
|
28
|
+
});
|
|
29
|
+
manifest.diagnostics = enriched.diagnostics;
|
|
30
|
+
const evidence = await enrichDocumentEvidence(manifest, {
|
|
31
|
+
analyzeMarkdown: engine.analyzeMarkdown,
|
|
32
|
+
projectRoot,
|
|
33
|
+
createWorkspace,
|
|
34
|
+
});
|
|
35
|
+
manifest.diagnostics = evidence.diagnostics;
|
|
36
|
+
manifest.evidence = evidence.evidence;
|
|
37
|
+
if (manifestHasErrors(manifest)) {
|
|
38
|
+
return { ok: false, manifest, outDir, pages: [] };
|
|
39
|
+
}
|
|
40
|
+
/** @type {Map<string, any>} */
|
|
41
|
+
const analyzedByPageId = new Map();
|
|
42
|
+
for (const page of manifest.pages) {
|
|
43
|
+
const abs = path.isAbsolute(page.sourcePath) ? page.sourcePath : path.join(manifest.root, page.sourcePath);
|
|
44
|
+
const source = fs.existsSync(abs) ? fs.readFileSync(abs, 'utf8') : '';
|
|
45
|
+
const id = `${page.identity.locale}:${page.identity.pageKey}`;
|
|
46
|
+
analyzedByPageId.set(id, engine.analyzeMarkdown(source));
|
|
47
|
+
}
|
|
48
|
+
const fenceBodies = collectFenceBodies(analyzedByPageId, manifest.pages);
|
|
49
|
+
const search = buildDocumentSearch({
|
|
50
|
+
manifest,
|
|
51
|
+
enriched,
|
|
52
|
+
evidence: evidence.evidence,
|
|
53
|
+
version: null,
|
|
54
|
+
});
|
|
55
|
+
const islands = buildDocumentIslands({
|
|
56
|
+
evidence: evidence.evidence,
|
|
57
|
+
searchHref: 'document.search.json',
|
|
58
|
+
fenceBodies,
|
|
59
|
+
});
|
|
60
|
+
manifest.search = search;
|
|
61
|
+
manifest.islands = islands;
|
|
62
|
+
const hostChrome = resolveHostSiteChrome(projectRoot);
|
|
63
|
+
const useHostShell = Boolean(hostChrome) && (manifest.mounts || []).some((m) => m.mode === 'integrated');
|
|
64
|
+
fs.mkdirSync(outDir, { recursive: true });
|
|
65
|
+
const designs = resolveDocumentDesignsCss(projectRoot);
|
|
66
|
+
/** @type {string | null} */
|
|
67
|
+
let designsHref = null;
|
|
68
|
+
if (designs.css && designs.href) {
|
|
69
|
+
const cssPath = path.join(outDir, designs.href);
|
|
70
|
+
fs.mkdirSync(path.dirname(cssPath), { recursive: true });
|
|
71
|
+
fs.writeFileSync(cssPath, designs.css, 'utf8');
|
|
72
|
+
designsHref = designs.href;
|
|
73
|
+
}
|
|
74
|
+
const viewsDir = path.join(outDir, 'views');
|
|
75
|
+
fs.mkdirSync(viewsDir, { recursive: true });
|
|
76
|
+
/** @type {Array<{ route: string, htmlPath: string, viewPath: string }>} */
|
|
77
|
+
const written = [];
|
|
78
|
+
for (const page of manifest.pages) {
|
|
79
|
+
const id = `${page.identity.locale}:${page.identity.pageKey}`;
|
|
80
|
+
const info = enriched.byId.get(id);
|
|
81
|
+
if (!info)
|
|
82
|
+
continue;
|
|
83
|
+
const nav = enriched.navByLocale[page.identity.locale] || [];
|
|
84
|
+
const htmlRel = pageHtmlRel(enriched.routeBase, page.identity.locale, page.identity.pageKey);
|
|
85
|
+
const htmlAbs = path.join(outDir, htmlRel);
|
|
86
|
+
fs.mkdirSync(path.dirname(htmlAbs), { recursive: true });
|
|
87
|
+
const searchIndexHref = artifactHrefFromHtml(htmlRel, 'document.search.json');
|
|
88
|
+
const shells = renderIslandShellsHtml({
|
|
89
|
+
islands,
|
|
90
|
+
searchIndexHref,
|
|
91
|
+
pageKey: page.identity.pageKey,
|
|
92
|
+
locale: page.identity.locale,
|
|
93
|
+
});
|
|
94
|
+
const view = {
|
|
95
|
+
schema: DOCUMENT_VIEW_SCHEMA,
|
|
96
|
+
pageKey: page.identity.pageKey,
|
|
97
|
+
locale: page.identity.locale,
|
|
98
|
+
route: info.route,
|
|
99
|
+
title: info.title,
|
|
100
|
+
headings: info.headings,
|
|
101
|
+
nav,
|
|
102
|
+
bodyKind: 'html',
|
|
103
|
+
html: info.html,
|
|
104
|
+
designsCss: designsHref,
|
|
105
|
+
noJsReadable: true,
|
|
106
|
+
hydrate: 'island-only',
|
|
107
|
+
hostShell: useHostShell,
|
|
108
|
+
islands: ['DocumentSearch'].concat((islands.islands || [])
|
|
109
|
+
.filter((isl) => isl.kind === 'playground' &&
|
|
110
|
+
isl.fence?.locale === page.identity.locale &&
|
|
111
|
+
isl.fence?.pageKey === page.identity.pageKey)
|
|
112
|
+
.map((isl) => isl.name)),
|
|
113
|
+
};
|
|
114
|
+
const viewRel = path.posix.join('views', page.identity.locale, `${page.identity.pageKey === 'index' ? 'index' : page.identity.pageKey}.view.json`);
|
|
115
|
+
const viewAbs = path.join(outDir, viewRel);
|
|
116
|
+
fs.mkdirSync(path.dirname(viewAbs), { recursive: true });
|
|
117
|
+
fs.writeFileSync(viewAbs, JSON.stringify(view, null, 2) + '\n', 'utf8');
|
|
118
|
+
const html = renderStaticHtml({
|
|
119
|
+
title: info.title,
|
|
120
|
+
locale: page.identity.locale,
|
|
121
|
+
route: info.route,
|
|
122
|
+
nav,
|
|
123
|
+
bodyHtml: info.html,
|
|
124
|
+
headings: info.headings,
|
|
125
|
+
designsHref,
|
|
126
|
+
htmlRel,
|
|
127
|
+
searchShellHtml: shells.searchHtml,
|
|
128
|
+
playgroundShellHtml: shells.playgroundHtml,
|
|
129
|
+
hostChrome: useHostShell ? hostChrome : null,
|
|
130
|
+
});
|
|
131
|
+
fs.writeFileSync(htmlAbs, html, 'utf8');
|
|
132
|
+
written.push({ route: info.route, htmlPath: htmlRel, viewPath: viewRel });
|
|
133
|
+
}
|
|
134
|
+
const manifestOut = {
|
|
135
|
+
...manifest,
|
|
136
|
+
schema: manifest.schema,
|
|
137
|
+
evidence: evidence.evidence,
|
|
138
|
+
search,
|
|
139
|
+
islands,
|
|
140
|
+
build: {
|
|
141
|
+
engine: engine.engine,
|
|
142
|
+
outDir: path.relative(projectRoot, outDir).replace(/\\/g, '/') || '.',
|
|
143
|
+
designs: designs.source,
|
|
144
|
+
designsCss: designsHref,
|
|
145
|
+
pages: written,
|
|
146
|
+
evidence: 'document.evidence.json',
|
|
147
|
+
search: 'document.search.json',
|
|
148
|
+
islands: 'document.islands.json',
|
|
149
|
+
},
|
|
150
|
+
};
|
|
151
|
+
fs.writeFileSync(path.join(outDir, 'document.manifest.json'), JSON.stringify(manifestOut, null, 2) + '\n', 'utf8');
|
|
152
|
+
fs.writeFileSync(path.join(outDir, 'document.evidence.json'), JSON.stringify(evidence.evidence, null, 2) + '\n', 'utf8');
|
|
153
|
+
fs.writeFileSync(path.join(outDir, 'document.search.json'), JSON.stringify(search, null, 2) + '\n', 'utf8');
|
|
154
|
+
fs.writeFileSync(path.join(outDir, 'document.islands.json'), JSON.stringify(islands, null, 2) + '\n', 'utf8');
|
|
155
|
+
return { ok: true, manifest: manifestOut, outDir, pages: written, search, islands };
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* No-JS readable static HTML: nav + main landmarks, Island shells without scripts.
|
|
159
|
+
* Integrated mounts reuse host SiteHeader/SiteFooter templates when present.
|
|
160
|
+
*/
|
|
161
|
+
function renderStaticHtml({ title, locale, route, nav, bodyHtml, headings, designsHref, htmlRel, searchShellHtml = '', playgroundShellHtml = '', hostChrome = null, }) {
|
|
162
|
+
const esc = (s) => String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
163
|
+
const depth = htmlRel.split('/').length - 1;
|
|
164
|
+
const prefix = depth > 0 ? '../'.repeat(depth) : './';
|
|
165
|
+
/** @type {string[]} */
|
|
166
|
+
const cssHrefs = [];
|
|
167
|
+
if (hostChrome) {
|
|
168
|
+
// Same application stylesheet as landing pages (header/footer chrome).
|
|
169
|
+
cssHrefs.push(`${prefix}vmz.css`);
|
|
170
|
+
}
|
|
171
|
+
if (designsHref)
|
|
172
|
+
cssHrefs.push(prefix + designsHref);
|
|
173
|
+
const cssLink = cssHrefs.map((href) => ` <link rel="stylesheet" href="${esc(href)}" />`).join('\n') + (cssHrefs.length ? '\n' : '');
|
|
174
|
+
const navItems = nav
|
|
175
|
+
.map((n) => {
|
|
176
|
+
const href = relativeHref(htmlRel, n.href, route);
|
|
177
|
+
const current = n.href === route ? ' aria-current="page"' : '';
|
|
178
|
+
return ` <li><a href="${esc(href)}"${current}>${esc(n.title)}</a></li>`;
|
|
179
|
+
})
|
|
180
|
+
.join('\n');
|
|
181
|
+
const toc = headings.length > 1
|
|
182
|
+
? `<nav aria-label="On this page" class="toc">\n <ol>\n${headings
|
|
183
|
+
.map((h) => ` <li class="h${h.level}"><a href="#${esc(h.id)}">${esc(h.text)}</a></li>`)
|
|
184
|
+
.join('\n')}\n </ol>\n </nav>\n`
|
|
185
|
+
: '';
|
|
186
|
+
const docsNav = ` <nav aria-label="Documents" class="doc-subnav">
|
|
187
|
+
<ul>
|
|
188
|
+
${navItems}
|
|
189
|
+
</ul>
|
|
190
|
+
</nav>`;
|
|
191
|
+
if (hostChrome) {
|
|
192
|
+
const header = hostChrome.header.replace(/(<a\s+href="\/d\/?")([^>]*>文档<\/a>)/, '$1 aria-current="page"$2');
|
|
193
|
+
return `<!DOCTYPE html>
|
|
194
|
+
<html lang="${esc(locale)}">
|
|
195
|
+
<head>
|
|
196
|
+
<meta charset="utf-8" />
|
|
197
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
198
|
+
<title>${esc(title)}</title>
|
|
199
|
+
${cssLink}</head>
|
|
200
|
+
<body data-vmz-hydrate="island-only">
|
|
201
|
+
<div class="site site--docs">
|
|
202
|
+
<a class="skip-link" href="#main">Skip to content</a>
|
|
203
|
+
${header}
|
|
204
|
+
${docsNav}
|
|
205
|
+
${searchShellHtml}
|
|
206
|
+
<div class="doc-body">
|
|
207
|
+
${toc}<main id="main">
|
|
208
|
+
${bodyHtml}
|
|
209
|
+
${playgroundShellHtml}
|
|
210
|
+
</main>
|
|
211
|
+
</div>
|
|
212
|
+
${hostChrome.footer}
|
|
213
|
+
</div>
|
|
214
|
+
</body>
|
|
215
|
+
</html>
|
|
216
|
+
`;
|
|
217
|
+
}
|
|
218
|
+
return `<!DOCTYPE html>
|
|
219
|
+
<html lang="${esc(locale)}">
|
|
220
|
+
<head>
|
|
221
|
+
<meta charset="utf-8" />
|
|
222
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
223
|
+
<title>${esc(title)}</title>
|
|
224
|
+
${cssLink}</head>
|
|
225
|
+
<body data-vmz-hydrate="island-only">
|
|
226
|
+
<a class="skip-link" href="#main">Skip to content</a>
|
|
227
|
+
${docsNav}
|
|
228
|
+
${searchShellHtml}
|
|
229
|
+
${toc}<main id="main">
|
|
230
|
+
${bodyHtml}
|
|
231
|
+
${playgroundShellHtml}
|
|
232
|
+
</main>
|
|
233
|
+
</body>
|
|
234
|
+
</html>
|
|
235
|
+
`;
|
|
236
|
+
}
|
|
237
|
+
/**
|
|
238
|
+
* Integrated DocumentMount: reuse host SiteHeader / SiteFooter .vmz templates.
|
|
239
|
+
* @param {string} projectRoot
|
|
240
|
+
* @returns {{ header: string, footer: string } | null}
|
|
241
|
+
*/
|
|
242
|
+
function resolveHostSiteChrome(projectRoot) {
|
|
243
|
+
const headerPath = path.join(projectRoot, 'src', 'components', 'SiteHeader.vmz');
|
|
244
|
+
const footerPath = path.join(projectRoot, 'src', 'components', 'SiteFooter.vmz');
|
|
245
|
+
if (!fs.existsSync(headerPath) || !fs.existsSync(footerPath))
|
|
246
|
+
return null;
|
|
247
|
+
const header = extractVmzTemplateHtml(headerPath);
|
|
248
|
+
const footer = extractVmzTemplateHtml(footerPath);
|
|
249
|
+
if (!header || !footer)
|
|
250
|
+
return null;
|
|
251
|
+
return { header, footer };
|
|
252
|
+
}
|
|
253
|
+
/** @param {string} filePath */
|
|
254
|
+
function extractVmzTemplateHtml(filePath) {
|
|
255
|
+
const src = fs.readFileSync(filePath, 'utf8');
|
|
256
|
+
const m = src.match(/<template>([\s\S]*?)<\/template>/);
|
|
257
|
+
return m ? m[1].trim() : '';
|
|
258
|
+
}
|
|
259
|
+
function relativeHref(fromHtmlRel, toRoute, _fromRoute) {
|
|
260
|
+
const toParts = String(toRoute).replace(/^\//, '').split('/').filter(Boolean);
|
|
261
|
+
let toRel;
|
|
262
|
+
if (toParts.length <= 2) {
|
|
263
|
+
toRel = [...toParts, 'index.html'].join('/');
|
|
264
|
+
}
|
|
265
|
+
else {
|
|
266
|
+
const file = toParts.slice(2).join('/') + '.html';
|
|
267
|
+
toRel = [...toParts.slice(0, 2), file].join('/');
|
|
268
|
+
}
|
|
269
|
+
const fromDir = path.posix.dirname(fromHtmlRel.replace(/\\/g, '/'));
|
|
270
|
+
let rel = path.posix.relative(fromDir, toRel);
|
|
271
|
+
if (!rel.startsWith('.') && !rel.startsWith('/'))
|
|
272
|
+
rel = './' + rel;
|
|
273
|
+
return rel || './index.html';
|
|
274
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build DocumentManifest + run D0 / --strict checks.
|
|
3
|
+
*/
|
|
4
|
+
/**
|
|
5
|
+
* Resolve project documents root.
|
|
6
|
+
* @param {string} projectRoot
|
|
7
|
+
*/
|
|
8
|
+
export declare function resolveDocumentsRoot(projectRoot: any): string;
|
|
9
|
+
/**
|
|
10
|
+
* Parse documents.config.json or a JSON-compatible export-default .ts/.js.
|
|
11
|
+
* @param {string} documentsRoot
|
|
12
|
+
* @returns {{ config: Record<string, any> | null, diagnostics: any[], configPath: string | null }}
|
|
13
|
+
*/
|
|
14
|
+
export declare function loadDocumentsConfig(documentsRoot: any): {
|
|
15
|
+
config: any;
|
|
16
|
+
diagnostics: any[];
|
|
17
|
+
configPath: string;
|
|
18
|
+
};
|
|
19
|
+
/**
|
|
20
|
+
* @param {object} opts
|
|
21
|
+
* @param {string} opts.projectRoot
|
|
22
|
+
* @param {boolean} [opts.strict]
|
|
23
|
+
* @returns {import('./document-schema.js').DocumentManifest}
|
|
24
|
+
*/
|
|
25
|
+
export declare function checkDocuments(opts: any): {
|
|
26
|
+
schema: string;
|
|
27
|
+
root: string;
|
|
28
|
+
defaultLocale: any;
|
|
29
|
+
locales: any[];
|
|
30
|
+
localeLabels: {};
|
|
31
|
+
collections: any[];
|
|
32
|
+
mounts: any[];
|
|
33
|
+
pages: {
|
|
34
|
+
identity: any;
|
|
35
|
+
sourcePath: any;
|
|
36
|
+
route: any;
|
|
37
|
+
anchors: any[];
|
|
38
|
+
}[];
|
|
39
|
+
diagnostics: any[];
|
|
40
|
+
};
|
|
41
|
+
/**
|
|
42
|
+
* @param {import('./document-schema.js').DocumentManifest} manifest
|
|
43
|
+
*/
|
|
44
|
+
export declare function manifestHasErrors(manifest: any): any;
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
// @ts-nocheck
|
|
2
|
+
/**
|
|
3
|
+
* Build DocumentManifest + run D0 / --strict checks.
|
|
4
|
+
*/
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { DIAG, DOCUMENT_MANIFEST_SCHEMA } from './document-schema.js';
|
|
8
|
+
import { scanDocumentsTree } from './document-scan.js';
|
|
9
|
+
/**
|
|
10
|
+
* Resolve project documents root.
|
|
11
|
+
* @param {string} projectRoot
|
|
12
|
+
*/
|
|
13
|
+
export function resolveDocumentsRoot(projectRoot) {
|
|
14
|
+
return path.resolve(projectRoot, 'documents');
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Parse documents.config.json or a JSON-compatible export-default .ts/.js.
|
|
18
|
+
* @param {string} documentsRoot
|
|
19
|
+
* @returns {{ config: Record<string, any> | null, diagnostics: any[], configPath: string | null }}
|
|
20
|
+
*/
|
|
21
|
+
export function loadDocumentsConfig(documentsRoot) {
|
|
22
|
+
/** @type {any[]} */
|
|
23
|
+
const diagnostics = [];
|
|
24
|
+
const candidates = ['documents.config.json', 'documents.config.ts', 'documents.config.js'];
|
|
25
|
+
for (const name of candidates) {
|
|
26
|
+
const p = path.join(documentsRoot, name);
|
|
27
|
+
if (!fs.existsSync(p))
|
|
28
|
+
continue;
|
|
29
|
+
try {
|
|
30
|
+
const raw = fs.readFileSync(p, 'utf8');
|
|
31
|
+
const config = parseConfigSource(raw, name);
|
|
32
|
+
return { config, diagnostics, configPath: p };
|
|
33
|
+
}
|
|
34
|
+
catch (e) {
|
|
35
|
+
diagnostics.push({
|
|
36
|
+
code: DIAG.CONFIG_INVALID,
|
|
37
|
+
severity: 'error',
|
|
38
|
+
message: `failed to parse ${name}: ${e instanceof Error ? e.message : String(e)}`,
|
|
39
|
+
path: p,
|
|
40
|
+
});
|
|
41
|
+
return { config: null, diagnostics, configPath: p };
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return { config: null, diagnostics, configPath: null };
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* D0: only declaration objects — no arbitrary hooks.
|
|
48
|
+
* @param {string} raw
|
|
49
|
+
* @param {string} filename
|
|
50
|
+
*/
|
|
51
|
+
function parseConfigSource(raw, filename) {
|
|
52
|
+
if (filename.endsWith('.json')) {
|
|
53
|
+
return JSON.parse(raw);
|
|
54
|
+
}
|
|
55
|
+
// Strip line/block comments, then require `export default { ... }`
|
|
56
|
+
let s = raw.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '');
|
|
57
|
+
s = s.trim();
|
|
58
|
+
const m = s.match(/^export\s+default\s+([\s\S]*?);?\s*$/);
|
|
59
|
+
if (!m) {
|
|
60
|
+
throw new Error('expected `export default { ... }` (JSON-compatible declaration only)');
|
|
61
|
+
}
|
|
62
|
+
let body = m[1].trim();
|
|
63
|
+
// Quote bare keys: { defaultLocale: "x" } → { "defaultLocale": "x" }
|
|
64
|
+
body = body.replace(/([,{]\s*)([A-Za-z_][A-Za-z0-9_]*)\s*:/g, '$1"$2":');
|
|
65
|
+
// Single-quoted strings → JSON double-quoted
|
|
66
|
+
body = body.replace(/'([^'\\]*(?:\\.[^'\\]*)*)'/g, (_, inner) => JSON.stringify(inner.replace(/\\'/g, "'").replace(/\\"/g, '"').replace(/\\\\/g, '\\')));
|
|
67
|
+
// Trailing commas
|
|
68
|
+
body = body.replace(/,\s*([}\]])/g, '$1');
|
|
69
|
+
return JSON.parse(body);
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* @param {object} opts
|
|
73
|
+
* @param {string} opts.projectRoot
|
|
74
|
+
* @param {boolean} [opts.strict]
|
|
75
|
+
* @returns {import('./document-schema.js').DocumentManifest}
|
|
76
|
+
*/
|
|
77
|
+
export function checkDocuments(opts) {
|
|
78
|
+
const projectRoot = path.resolve(opts.projectRoot);
|
|
79
|
+
const documentsRoot = resolveDocumentsRoot(projectRoot);
|
|
80
|
+
const strict = Boolean(opts.strict);
|
|
81
|
+
/** @type {import('./document-schema.js').DocumentDiagnostic[]} */
|
|
82
|
+
const diagnostics = [];
|
|
83
|
+
const scanned = scanDocumentsTree(documentsRoot);
|
|
84
|
+
diagnostics.push(...scanned.diagnostics);
|
|
85
|
+
const { config, diagnostics: cfgDiags, configPath } = loadDocumentsConfig(documentsRoot);
|
|
86
|
+
diagnostics.push(...cfgDiags);
|
|
87
|
+
if (!config) {
|
|
88
|
+
diagnostics.push({
|
|
89
|
+
code: DIAG.CONFIG_MISSING,
|
|
90
|
+
severity: strict ? 'error' : 'warning',
|
|
91
|
+
message: 'documents.config.json|ts missing; D0 requires defaultLocale + locales for strict coverage checks',
|
|
92
|
+
path: documentsRoot,
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
/** @type {Record<string, string>} */
|
|
96
|
+
const localeLabels = {};
|
|
97
|
+
let defaultLocale = null;
|
|
98
|
+
/** @type {import('./document-schema.js').DocumentCollection[]} */
|
|
99
|
+
const collections = [];
|
|
100
|
+
/** @type {import('./document-schema.js').DocumentMount[]} */
|
|
101
|
+
const mounts = [];
|
|
102
|
+
if (config && typeof config === 'object') {
|
|
103
|
+
if (typeof config.defaultLocale === 'string') {
|
|
104
|
+
defaultLocale = config.defaultLocale;
|
|
105
|
+
}
|
|
106
|
+
if (config.locales && typeof config.locales === 'object') {
|
|
107
|
+
for (const [k, v] of Object.entries(config.locales)) {
|
|
108
|
+
localeLabels[k] = v && typeof v === 'object' && typeof v.label === 'string' ? v.label : k;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
if (config.collections && typeof config.collections === 'object') {
|
|
112
|
+
for (const [id, c] of Object.entries(config.collections)) {
|
|
113
|
+
const sourceRoot = c && typeof c === 'object' && typeof c.source === 'string' ? c.source : '.';
|
|
114
|
+
const routeBase = c && typeof c === 'object' && typeof c.mount === 'string' ? c.mount : '/docs';
|
|
115
|
+
const pageKeys = [
|
|
116
|
+
...new Set(scanned.pages
|
|
117
|
+
.filter((p) => {
|
|
118
|
+
if (sourceRoot === '.' || sourceRoot === './')
|
|
119
|
+
return true;
|
|
120
|
+
const prefix = sourceRoot.replace(/^\.\//, '').replace(/\/$/, '');
|
|
121
|
+
return p.identity.pageKey === prefix || p.identity.pageKey.startsWith(prefix + '/');
|
|
122
|
+
})
|
|
123
|
+
.map((p) => p.identity.pageKey)),
|
|
124
|
+
].sort();
|
|
125
|
+
collections.push({ id, sourceRoot, pageKeys });
|
|
126
|
+
mounts.push({
|
|
127
|
+
collectionId: id,
|
|
128
|
+
routeBase,
|
|
129
|
+
mode: routeBase === '/' ? 'standalone' : 'integrated',
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
if (collections.length === 0) {
|
|
135
|
+
const pageKeys = [...new Set(scanned.pages.map((p) => p.identity.pageKey))].sort();
|
|
136
|
+
collections.push({ id: 'default', sourceRoot: '.', pageKeys });
|
|
137
|
+
mounts.push({ collectionId: 'default', routeBase: '/docs', mode: 'integrated' });
|
|
138
|
+
}
|
|
139
|
+
// Config locales vs disk
|
|
140
|
+
const diskLocales = new Set(scanned.locales);
|
|
141
|
+
const configLocales = Object.keys(localeLabels);
|
|
142
|
+
if (defaultLocale) {
|
|
143
|
+
if (!diskLocales.has(defaultLocale)) {
|
|
144
|
+
diagnostics.push({
|
|
145
|
+
code: DIAG.LOCALE_MISSING_DEFAULT,
|
|
146
|
+
severity: 'error',
|
|
147
|
+
message: `defaultLocale ${JSON.stringify(defaultLocale)} has no directory under documents/`,
|
|
148
|
+
path: configPath || documentsRoot,
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
if (configLocales.length && !configLocales.includes(defaultLocale)) {
|
|
152
|
+
diagnostics.push({
|
|
153
|
+
code: DIAG.CONFIG_DEFAULT_LOCALE,
|
|
154
|
+
severity: 'error',
|
|
155
|
+
message: `defaultLocale ${JSON.stringify(defaultLocale)} not listed in config.locales`,
|
|
156
|
+
path: configPath || documentsRoot,
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
else if (strict) {
|
|
161
|
+
diagnostics.push({
|
|
162
|
+
code: DIAG.CONFIG_DEFAULT_LOCALE,
|
|
163
|
+
severity: 'error',
|
|
164
|
+
message: 'defaultLocale is required under --strict',
|
|
165
|
+
path: configPath || documentsRoot,
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
for (const loc of configLocales) {
|
|
169
|
+
if (!diskLocales.has(loc)) {
|
|
170
|
+
diagnostics.push({
|
|
171
|
+
code: DIAG.LOCALE_MISSING_DEFAULT,
|
|
172
|
+
severity: 'error',
|
|
173
|
+
message: `config.locales entry ${JSON.stringify(loc)} has no documents/${loc}/ directory`,
|
|
174
|
+
path: configPath || documentsRoot,
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
// D0: no silent whole-page fallback by default
|
|
179
|
+
if (config && config.fallback === true) {
|
|
180
|
+
diagnostics.push({
|
|
181
|
+
code: DIAG.FALLBACK_SILENT,
|
|
182
|
+
severity: 'error',
|
|
183
|
+
message: 'silent whole-page fallback is forbidden; allow only explicit nav/metadata or per-page fallback',
|
|
184
|
+
path: configPath || documentsRoot,
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
// Coverage: default locale PageKeys are baseline; other locales missing/orphan under --strict
|
|
188
|
+
if (defaultLocale && diskLocales.has(defaultLocale)) {
|
|
189
|
+
const byLocale = new Map();
|
|
190
|
+
for (const p of scanned.pages) {
|
|
191
|
+
const set = byLocale.get(p.identity.locale) || new Set();
|
|
192
|
+
set.add(p.identity.pageKey);
|
|
193
|
+
byLocale.set(p.identity.locale, set);
|
|
194
|
+
}
|
|
195
|
+
const baseline = byLocale.get(defaultLocale) || new Set();
|
|
196
|
+
for (const loc of scanned.locales) {
|
|
197
|
+
if (loc === defaultLocale)
|
|
198
|
+
continue;
|
|
199
|
+
const keys = byLocale.get(loc) || new Set();
|
|
200
|
+
for (const pk of baseline) {
|
|
201
|
+
if (!keys.has(pk)) {
|
|
202
|
+
diagnostics.push({
|
|
203
|
+
code: DIAG.LOCALE_MISSING_PAGE,
|
|
204
|
+
severity: strict ? 'error' : 'warning',
|
|
205
|
+
message: `missing translation: locale ${loc} lacks PageKey ${JSON.stringify(pk)} (present in ${defaultLocale})`,
|
|
206
|
+
path: `documents/${loc}`,
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
for (const pk of keys) {
|
|
211
|
+
if (!baseline.has(pk)) {
|
|
212
|
+
diagnostics.push({
|
|
213
|
+
code: DIAG.LOCALE_ORPHAN_PAGE,
|
|
214
|
+
severity: strict ? 'error' : 'warning',
|
|
215
|
+
message: `orphan page: locale ${loc} has PageKey ${JSON.stringify(pk)} missing from defaultLocale ${defaultLocale}`,
|
|
216
|
+
path: `documents/${loc}/${pk}.md`,
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
/** @type {import('./document-schema.js').PageRecord[]} */
|
|
223
|
+
const pages = scanned.pages.map((p) => ({
|
|
224
|
+
identity: p.identity,
|
|
225
|
+
sourcePath: p.sourcePath,
|
|
226
|
+
route: null,
|
|
227
|
+
anchors: [],
|
|
228
|
+
}));
|
|
229
|
+
return {
|
|
230
|
+
schema: DOCUMENT_MANIFEST_SCHEMA,
|
|
231
|
+
root: documentsRoot,
|
|
232
|
+
defaultLocale,
|
|
233
|
+
locales: scanned.locales,
|
|
234
|
+
localeLabels,
|
|
235
|
+
collections,
|
|
236
|
+
mounts,
|
|
237
|
+
pages,
|
|
238
|
+
diagnostics,
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
/**
|
|
242
|
+
* @param {import('./document-schema.js').DocumentManifest} manifest
|
|
243
|
+
*/
|
|
244
|
+
export function manifestHasErrors(manifest) {
|
|
245
|
+
return (manifest.diagnostics || []).some((d) => d.severity === 'error');
|
|
246
|
+
}
|