@vmz/vmz 0.0.1 → 0.0.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.
Files changed (79) hide show
  1. package/README.md +52 -2
  2. package/bin/vmz.js +4 -0
  3. package/dist/application-cmd.d.ts +21 -0
  4. package/dist/application-cmd.js +347 -0
  5. package/dist/bundler-adapter.d.ts +63 -0
  6. package/dist/bundler-adapter.js +110 -0
  7. package/dist/cli.d.ts +23 -0
  8. package/dist/cli.js +474 -0
  9. package/dist/dev-session.d.ts +35 -0
  10. package/dist/dev-session.js +290 -0
  11. package/dist/document-build.d.ts +99 -0
  12. package/dist/document-build.js +273 -0
  13. package/dist/document-check.d.ts +44 -0
  14. package/dist/document-check.js +246 -0
  15. package/dist/document-cmd.d.ts +8 -0
  16. package/dist/document-cmd.js +146 -0
  17. package/dist/document-designs.d.ts +9 -0
  18. package/dist/document-designs.js +126 -0
  19. package/dist/document-enrich.d.ts +23 -0
  20. package/dist/document-enrich.js +233 -0
  21. package/dist/document-evidence.d.ts +49 -0
  22. package/dist/document-evidence.js +509 -0
  23. package/dist/document-integrate.d.ts +34 -0
  24. package/dist/document-integrate.js +88 -0
  25. package/dist/document-interactive.d.ts +69 -0
  26. package/dist/document-interactive.js +254 -0
  27. package/dist/document-locale.d.ts +31 -0
  28. package/dist/document-locale.js +59 -0
  29. package/dist/document-markdown.d.ts +12 -0
  30. package/dist/document-markdown.js +45 -0
  31. package/dist/document-scan.d.ts +21 -0
  32. package/dist/document-scan.js +151 -0
  33. package/dist/document-schema.d.ts +86 -0
  34. package/dist/document-schema.js +87 -0
  35. package/dist/explain-cmd.d.ts +5 -0
  36. package/dist/explain-cmd.js +123 -0
  37. package/dist/index.d.ts +359 -0
  38. package/dist/index.js +580 -0
  39. package/dist/invocation.d.ts +91 -0
  40. package/dist/invocation.js +190 -0
  41. package/dist/locale-check.d.ts +106 -0
  42. package/dist/locale-check.js +736 -0
  43. package/dist/locale-cmd.d.ts +5 -0
  44. package/dist/locale-cmd.js +442 -0
  45. package/dist/locale-delivery.d.ts +298 -0
  46. package/dist/locale-delivery.js +443 -0
  47. package/dist/locale-router.d.ts +206 -0
  48. package/dist/locale-router.js +507 -0
  49. package/dist/locale-runtime.d.ts +406 -0
  50. package/dist/locale-runtime.js +541 -0
  51. package/dist/locale-schema.d.ts +8 -0
  52. package/dist/locale-schema.js +9 -0
  53. package/dist/locale-tooling.d.ts +118 -0
  54. package/dist/locale-tooling.js +357 -0
  55. package/dist/log.d.ts +19 -0
  56. package/dist/log.js +42 -0
  57. package/dist/packages.d.ts +26 -0
  58. package/dist/packages.js +146 -0
  59. package/dist/plugin-host.d.ts +29 -0
  60. package/dist/plugin-host.js +369 -0
  61. package/dist/refactor-cmd.d.ts +8 -0
  62. package/dist/refactor-cmd.js +156 -0
  63. package/dist/resolve-native-cli.d.ts +14 -0
  64. package/dist/resolve-native-cli.js +84 -0
  65. package/dist/resolve.d.ts +24 -0
  66. package/dist/resolve.js +55 -0
  67. package/dist/test-cmd.d.ts +9 -0
  68. package/dist/test-cmd.js +363 -0
  69. package/dist/test-compile.d.ts +2 -0
  70. package/dist/test-compile.js +3 -0
  71. package/dist/test-discover.d.ts +2 -0
  72. package/dist/test-discover.js +3 -0
  73. package/dist/test-logic.d.ts +2 -0
  74. package/dist/test-logic.js +3 -0
  75. package/dist/test-protocol.d.ts +2 -0
  76. package/dist/test-protocol.js +3 -0
  77. package/dist/watch-diff.d.ts +17 -0
  78. package/dist/watch-diff.js +56 -0
  79. package/package.json +96 -3
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Strip tags / collapse whitespace for search body text.
3
+ * @param {string} html
4
+ */
5
+ export declare function htmlToSearchText(html: any): string;
6
+ /**
7
+ * @param {{
8
+ * manifest: any,
9
+ * enriched: { byId: Map<string, any> },
10
+ * evidence: any,
11
+ * version?: string | null,
12
+ * }} opts
13
+ */
14
+ export declare function buildDocumentSearch(opts: any): {
15
+ schema: string;
16
+ status: string;
17
+ version: any;
18
+ records: any[];
19
+ };
20
+ /**
21
+ * Island-only resume plan for document surfaces.
22
+ * @param {{
23
+ * evidence: any,
24
+ * searchHref?: string,
25
+ * fenceBodies?: Map<string, string>,
26
+ * }} opts
27
+ */
28
+ export declare function buildDocumentIslands(opts: any): {
29
+ schema: string;
30
+ hydrate: string;
31
+ fullPageHydrate: boolean;
32
+ islands: {
33
+ name: string;
34
+ kind: string;
35
+ resume: string;
36
+ index: any;
37
+ }[];
38
+ status: string;
39
+ };
40
+ /**
41
+ * Relative href from an HTML page to a root artifact (posix).
42
+ * @param {string} htmlRel
43
+ * @param {string} artifactName
44
+ */
45
+ export declare function artifactHrefFromHtml(htmlRel: any, artifactName: any): string;
46
+ /**
47
+ * Stable map key for fence body lookup.
48
+ * @param {{ locale: string, pageKey: string, lineStart: number }} f
49
+ */
50
+ export declare function fenceBodyKey(f: any): string;
51
+ /**
52
+ * Collect fence bodies from analyzeMarkdown results for playground islands.
53
+ * @param {Map<string, { fences?: any[] }>} analyzedByPageId locale:pageKey → analyze result
54
+ * @param {any[]} pages manifest.pages
55
+ */
56
+ export declare function collectFenceBodies(analyzedByPageId: any, pages: any): Map<any, any>;
57
+ /**
58
+ * Render SSR island shells (no script — resume later).
59
+ * @param {{
60
+ * islands: any,
61
+ * searchIndexHref: string,
62
+ * pageKey: string,
63
+ * locale: string,
64
+ * }} opts
65
+ */
66
+ export declare function renderIslandShellsHtml(opts: any): {
67
+ searchHtml: string;
68
+ playgroundHtml: any;
69
+ };
@@ -0,0 +1,254 @@
1
+ // @ts-nocheck
2
+ /**
3
+ * Document Interactive — search index + Island-only resume plan.
4
+ *
5
+ * Not a Doc IR: build artifacts + ResumeEntry-shaped island hosts.
6
+ * Static HTML stays no-JS readable; islands resume later, never full-page hydrate.
7
+ */
8
+ import { DOCUMENT_ISLANDS_SCHEMA, DOCUMENT_SEARCH_SCHEMA } from './document-schema.js';
9
+ import { parseFenceInfo } from './document-evidence.js';
10
+ /**
11
+ * Strip tags / collapse whitespace for search body text.
12
+ * @param {string} html
13
+ */
14
+ export function htmlToSearchText(html) {
15
+ return String(html || '')
16
+ .replace(/<script[\s\S]*?<\/script>/gi, ' ')
17
+ .replace(/<style[\s\S]*?<\/style>/gi, ' ')
18
+ .replace(/<[^>]+>/g, ' ')
19
+ .replace(/&nbsp;/g, ' ')
20
+ .replace(/&amp;/g, '&')
21
+ .replace(/&lt;/g, '<')
22
+ .replace(/&gt;/g, '>')
23
+ .replace(/&quot;/g, '"')
24
+ .replace(/\s+/g, ' ')
25
+ .trim();
26
+ }
27
+ /**
28
+ * @param {{
29
+ * manifest: any,
30
+ * enriched: { byId: Map<string, any> },
31
+ * evidence: any,
32
+ * version?: string | null,
33
+ * }} opts
34
+ */
35
+ export function buildDocumentSearch(opts) {
36
+ const { manifest, enriched, evidence } = opts;
37
+ const version = opts.version ?? null;
38
+ /** @type {any[]} */
39
+ const records = [];
40
+ for (const page of manifest.pages || []) {
41
+ const id = `${page.identity.locale}:${page.identity.pageKey}`;
42
+ const info = enriched.byId.get(id);
43
+ if (!info)
44
+ continue;
45
+ const locale = page.identity.locale;
46
+ const pageKey = page.identity.pageKey;
47
+ const route = info.route || page.route;
48
+ const title = info.title || pageKey;
49
+ const text = htmlToSearchText(info.html);
50
+ const headings = (info.headings || []).map((h) => ({
51
+ id: h.id,
52
+ level: h.level,
53
+ text: h.text,
54
+ }));
55
+ const apiSymbolIds = (evidence?.apiRefs || [])
56
+ .filter((r) => r.locale === locale && r.pageKey === pageKey && r.status === 'ok')
57
+ .flatMap((r) => (r.matches || []).map((m) => m.chunkId || m.name))
58
+ .filter(Boolean);
59
+ records.push({
60
+ kind: 'page',
61
+ id: `page:${locale}:${pageKey}`,
62
+ locale,
63
+ pageKey,
64
+ route,
65
+ title,
66
+ text,
67
+ headings,
68
+ apiSymbolIds,
69
+ version,
70
+ });
71
+ for (const h of headings) {
72
+ records.push({
73
+ kind: 'heading',
74
+ id: `heading:${locale}:${pageKey}#${h.id}`,
75
+ locale,
76
+ pageKey,
77
+ route: `${route}#${h.id}`,
78
+ title: h.text,
79
+ text: h.text,
80
+ headingId: h.id,
81
+ headingLevel: h.level,
82
+ version,
83
+ });
84
+ }
85
+ }
86
+ for (const ref of evidence?.apiRefs || []) {
87
+ if (ref.status !== 'ok')
88
+ continue;
89
+ for (const m of ref.matches || []) {
90
+ const page = (manifest.pages || []).find((p) => p.identity.locale === ref.locale && p.identity.pageKey === ref.pageKey);
91
+ const info = page ? enriched.byId.get(`${ref.locale}:${ref.pageKey}`) : null;
92
+ records.push({
93
+ kind: 'api',
94
+ id: `api:${m.chunkId || m.name}`,
95
+ locale: ref.locale,
96
+ pageKey: ref.pageKey,
97
+ route: info?.route || null,
98
+ title: m.name || m.chunkId,
99
+ text: `${m.name || ''} ${m.chunkId || ''} ${(m.capabilities || []).join(' ')}`.trim(),
100
+ apiSymbolId: m.chunkId || m.name,
101
+ stableId: m.stableId || { kind: 'chunk', id: m.chunkId },
102
+ version,
103
+ });
104
+ }
105
+ }
106
+ return {
107
+ schema: DOCUMENT_SEARCH_SCHEMA,
108
+ status: records.length ? 'ready' : 'empty',
109
+ version,
110
+ records,
111
+ };
112
+ }
113
+ /**
114
+ * Island-only resume plan for document surfaces.
115
+ * @param {{
116
+ * evidence: any,
117
+ * searchHref?: string,
118
+ * fenceBodies?: Map<string, string>,
119
+ * }} opts
120
+ */
121
+ export function buildDocumentIslands(opts) {
122
+ const searchHref = opts.searchHref || 'document.search.json';
123
+ /** @type {any[]} */
124
+ const islands = [
125
+ {
126
+ name: 'DocumentSearch',
127
+ kind: 'search',
128
+ resume: 'island',
129
+ index: searchHref,
130
+ },
131
+ ];
132
+ const fences = opts.evidence?.fences || [];
133
+ for (let i = 0; i < fences.length; i++) {
134
+ const f = fences[i];
135
+ const meta = parseFenceInfo(f.info);
136
+ const interactive = Boolean(meta.playground || meta.run);
137
+ if (!interactive)
138
+ continue;
139
+ if (f.status && f.status !== 'ok' && f.status !== 'highlight')
140
+ continue;
141
+ if (meta.lang !== 'vmz' && !meta.playground)
142
+ continue;
143
+ const name = `DocumentPlayground:${f.locale}:${f.pageKey}:${f.lineStart || i}`;
144
+ const bodyKey = `${f.locale}:${f.pageKey}:${f.lineStart}`;
145
+ islands.push({
146
+ name,
147
+ kind: 'playground',
148
+ resume: 'island',
149
+ fence: {
150
+ lang: meta.lang,
151
+ run: meta.run,
152
+ playground: Boolean(meta.playground),
153
+ source: meta.source,
154
+ pageKey: f.pageKey,
155
+ locale: f.locale,
156
+ path: f.path,
157
+ lineStart: f.lineStart,
158
+ lineEnd: f.lineEnd,
159
+ status: f.status,
160
+ },
161
+ preview: opts.fenceBodies?.get(bodyKey) ?? null,
162
+ });
163
+ }
164
+ return {
165
+ schema: DOCUMENT_ISLANDS_SCHEMA,
166
+ hydrate: 'island-only',
167
+ fullPageHydrate: false,
168
+ islands,
169
+ status: 'ready',
170
+ };
171
+ }
172
+ /**
173
+ * Relative href from an HTML page to a root artifact (posix).
174
+ * @param {string} htmlRel
175
+ * @param {string} artifactName
176
+ */
177
+ export function artifactHrefFromHtml(htmlRel, artifactName) {
178
+ const depth = String(htmlRel).replace(/\\/g, '/').split('/').length - 1;
179
+ const prefix = depth > 0 ? '../'.repeat(depth) : './';
180
+ return prefix + artifactName;
181
+ }
182
+ /**
183
+ * Stable map key for fence body lookup.
184
+ * @param {{ locale: string, pageKey: string, lineStart: number }} f
185
+ */
186
+ export function fenceBodyKey(f) {
187
+ return `${f.locale}:${f.pageKey}:${f.lineStart}`;
188
+ }
189
+ /**
190
+ * Collect fence bodies from analyzeMarkdown results for playground islands.
191
+ * @param {Map<string, { fences?: any[] }>} analyzedByPageId locale:pageKey → analyze result
192
+ * @param {any[]} pages manifest.pages
193
+ */
194
+ export function collectFenceBodies(analyzedByPageId, pages) {
195
+ /** @type {Map<string, string>} */
196
+ const out = new Map();
197
+ for (const page of pages || []) {
198
+ const id = `${page.identity.locale}:${page.identity.pageKey}`;
199
+ const analyzed = analyzedByPageId.get(id);
200
+ if (!analyzed?.fences)
201
+ continue;
202
+ for (const fence of analyzed.fences) {
203
+ const meta = parseFenceInfo(fence.info);
204
+ if (!(meta.playground || meta.run) || meta.lang !== 'vmz')
205
+ continue;
206
+ out.set(fenceBodyKey({
207
+ locale: page.identity.locale,
208
+ pageKey: page.identity.pageKey,
209
+ lineStart: fence.lineStart,
210
+ }), String(fence.content || '').trim());
211
+ }
212
+ }
213
+ return out;
214
+ }
215
+ /**
216
+ * Render SSR island shells (no script — resume later).
217
+ * @param {{
218
+ * islands: any,
219
+ * searchIndexHref: string,
220
+ * pageKey: string,
221
+ * locale: string,
222
+ * }} opts
223
+ */
224
+ export function renderIslandShellsHtml(opts) {
225
+ const esc = (s) => String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
226
+ const search = `
227
+ <div
228
+ data-vmz-island="DocumentSearch"
229
+ data-vmz-resume="island"
230
+ data-vmz-search-index="${esc(opts.searchIndexHref)}"
231
+ role="search"
232
+ aria-label="Document search"
233
+ >
234
+ <p class="doc-search-fallback">Search requires Island resume; use navigation without JavaScript.</p>
235
+ </div>`;
236
+ const playgrounds = (opts.islands?.islands || [])
237
+ .filter((isl) => isl.kind === 'playground' && isl.fence?.locale === opts.locale && isl.fence?.pageKey === opts.pageKey)
238
+ .map((isl) => {
239
+ const preview = isl.preview ? `<pre class="doc-playground-source"><code>${esc(isl.preview)}</code></pre>` : '';
240
+ return `
241
+ <div
242
+ data-vmz-island="${esc(isl.name)}"
243
+ data-vmz-resume="island"
244
+ data-vmz-playground="1"
245
+ data-vmz-fence-lang="${esc(isl.fence?.lang || 'vmz')}"
246
+ aria-label="Interactive example"
247
+ >
248
+ <p class="doc-playground-fallback">Interactive example resumes as an Island; source remains readable above.</p>
249
+ ${preview}
250
+ </div>`;
251
+ })
252
+ .join('');
253
+ return { searchHtml: search, playgroundHtml: playgrounds };
254
+ }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Locale key validation & canonicalization .
3
+ */
4
+ /**
5
+ * Soft-normalize for alias / conflict detection (does not validate).
6
+ * @param {string} raw
7
+ */
8
+ export declare function softNormalizeLocale(raw: any): string;
9
+ /**
10
+ * Map soft-normalized key through alias table.
11
+ * @param {string} soft
12
+ */
13
+ export declare function canonicalLocale(soft: any): any;
14
+ /**
15
+ * Validate a top-level locale directory name.
16
+ * @param {string} literal
17
+ * @returns {{ ok: true, soft: string, canonical: string } | { ok: false, code: string, message: string }}
18
+ */
19
+ export declare function validateLocaleLiteral(literal: any): {
20
+ ok: boolean;
21
+ code: string;
22
+ message: string;
23
+ soft?: undefined;
24
+ canonical?: undefined;
25
+ } | {
26
+ ok: boolean;
27
+ soft: string;
28
+ canonical: any;
29
+ code?: undefined;
30
+ message?: undefined;
31
+ };
@@ -0,0 +1,59 @@
1
+ // @ts-nocheck
2
+ /**
3
+ * Locale key validation & canonicalization .
4
+ */
5
+ import { LOCALE_ALIASES } from './document-schema.js';
6
+ /** Literal must be lowercase ASCII BCP 47-ish with `-` separators only. */
7
+ const LOCALE_LITERAL_RE = /^[a-z]{2,3}(-[a-z0-9]+)*$/;
8
+ /**
9
+ * Soft-normalize for alias / conflict detection (does not validate).
10
+ * @param {string} raw
11
+ */
12
+ export function softNormalizeLocale(raw) {
13
+ return String(raw || '')
14
+ .trim()
15
+ .toLowerCase()
16
+ .replace(/_/g, '-');
17
+ }
18
+ /**
19
+ * Map soft-normalized key through alias table.
20
+ * @param {string} soft
21
+ */
22
+ export function canonicalLocale(soft) {
23
+ const s = softNormalizeLocale(soft);
24
+ return LOCALE_ALIASES[s] || s;
25
+ }
26
+ /**
27
+ * Validate a top-level locale directory name.
28
+ * @param {string} literal
29
+ * @returns {{ ok: true, soft: string, canonical: string } | { ok: false, code: string, message: string }}
30
+ */
31
+ export function validateLocaleLiteral(literal) {
32
+ const name = String(literal || '');
33
+ if (!name) {
34
+ return { ok: false, code: 'document::locale::invalid', message: 'empty locale key' };
35
+ }
36
+ if (name.includes('_')) {
37
+ return {
38
+ ok: false,
39
+ code: 'document::locale::separator',
40
+ message: `locale key must use '-' not '_': ${JSON.stringify(name)}`,
41
+ };
42
+ }
43
+ if (name !== name.toLowerCase()) {
44
+ return {
45
+ ok: false,
46
+ code: 'document::locale::case',
47
+ message: `locale key must be lowercase ASCII (got ${JSON.stringify(name)}; use ${JSON.stringify(name.toLowerCase())})`,
48
+ };
49
+ }
50
+ if (!LOCALE_LITERAL_RE.test(name)) {
51
+ return {
52
+ ok: false,
53
+ code: 'document::locale::invalid',
54
+ message: `locale key is not a valid lowercase BCP 47 form: ${JSON.stringify(name)}`,
55
+ };
56
+ }
57
+ const soft = softNormalizeLocale(name);
58
+ return { ok: true, soft, canonical: canonicalLocale(soft) };
59
+ }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Document — resolve engines.markdown via the official plugin runtime.
3
+ */
4
+ /**
5
+ * @param {{ engines?: { markdown?: string } }} [opts]
6
+ */
7
+ export declare function resolveMarkdownEngine(opts?: {}): Promise<{
8
+ engine: any;
9
+ analyzeMarkdown: any;
10
+ renderMarkdown: any;
11
+ slugify: any;
12
+ }>;
@@ -0,0 +1,45 @@
1
+ // @ts-nocheck
2
+ /**
3
+ * Document — resolve engines.markdown via the official plugin runtime.
4
+ */
5
+ import { createRequire } from 'node:module';
6
+ import { existsSync } from 'node:fs';
7
+ import path from 'node:path';
8
+ import { fileURLToPath } from 'node:url';
9
+ import { importMaybeTs } from './plugin-host.js';
10
+ const require = createRequire(import.meta.url);
11
+ /**
12
+ * @param {{ engines?: { markdown?: string } }} [opts]
13
+ */
14
+ export async function resolveMarkdownEngine(opts = {}) {
15
+ const id = opts.engines?.markdown || 'markdown-it';
16
+ if (id !== 'markdown-it') {
17
+ throw new Error(`unsupported engines.markdown ${JSON.stringify(id)} (markdown-it only)`);
18
+ }
19
+ let runtimeFile = null;
20
+ try {
21
+ const pkg = require.resolve('@vmz/plugin-markdown-it/package.json');
22
+ runtimeFile = path.join(path.dirname(pkg), 'runtime.ts');
23
+ }
24
+ catch {
25
+ // Developer mode: monorepo source checkout.
26
+ const here = path.dirname(fileURLToPath(import.meta.url));
27
+ const fallback = path.resolve(here, '../../../plugins/vmz-plugin-markdown-it/runtime.ts');
28
+ if (existsSync(fallback))
29
+ runtimeFile = fallback;
30
+ }
31
+ if (!runtimeFile || !existsSync(runtimeFile)) {
32
+ throw new Error('markdown engine needs `@vmz/plugin-markdown-it` (optional peer of `@vmz/vmz`).\n' +
33
+ ' Install: pnpm add -D @vmz/plugin-markdown-it');
34
+ }
35
+ const mod = await importMaybeTs(runtimeFile);
36
+ if (typeof mod.analyzeMarkdown !== 'function') {
37
+ throw new Error(`cannot load markdown engine markdown-it from ${runtimeFile}`);
38
+ }
39
+ return {
40
+ engine: id,
41
+ analyzeMarkdown: mod.analyzeMarkdown,
42
+ renderMarkdown: mod.renderMarkdown,
43
+ slugify: mod.slugify,
44
+ };
45
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Scan /documents tree → pages + locale dirs .
3
+ */
4
+ /**
5
+ * PageKey from locale-relative markdown path (strip extension; index → parent or "index").
6
+ * @param {string} relMd e.g. guide/install.md | index.md | guide/index.md
7
+ */
8
+ export declare function pageKeyFromRel(relMd: any): any;
9
+ /**
10
+ * @param {string} documentsRoot absolute path to .../documents
11
+ * @returns {{
12
+ * locales: string[],
13
+ * pages: Array<{ identity: { pageKey: string, locale: string }, sourcePath: string }>,
14
+ * diagnostics: Array<{ code: string, severity: string, message: string, path?: string }>,
15
+ * }}
16
+ */
17
+ export declare function scanDocumentsTree(documentsRoot: any): {
18
+ locales: any[];
19
+ pages: any[];
20
+ diagnostics: any[];
21
+ };
@@ -0,0 +1,151 @@
1
+ // @ts-nocheck
2
+ /**
3
+ * Scan /documents tree → pages + locale dirs .
4
+ */
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
7
+ import { DIAG, DOCUMENT_RESERVED_TOP } from './document-schema.js';
8
+ import { canonicalLocale, softNormalizeLocale, validateLocaleLiteral } from './document-locale.js';
9
+ /**
10
+ * @param {string} dir
11
+ * @param {(rel: string) => void} visit
12
+ * @param {string} [prefix]
13
+ */
14
+ function walkMd(dir, visit, prefix = '') {
15
+ let entries;
16
+ try {
17
+ entries = fs.readdirSync(dir, { withFileTypes: true });
18
+ }
19
+ catch {
20
+ return;
21
+ }
22
+ for (const ent of entries) {
23
+ if (ent.name.startsWith('.'))
24
+ continue;
25
+ const rel = prefix ? `${prefix}/${ent.name}` : ent.name;
26
+ const full = path.join(dir, ent.name);
27
+ if (ent.isDirectory()) {
28
+ walkMd(full, visit, rel);
29
+ }
30
+ else if (ent.isFile() && /\.md$/i.test(ent.name)) {
31
+ visit(rel.replace(/\\/g, '/'));
32
+ }
33
+ }
34
+ }
35
+ /**
36
+ * PageKey from locale-relative markdown path (strip extension; index → parent or "index").
37
+ * @param {string} relMd e.g. guide/install.md | index.md | guide/index.md
38
+ */
39
+ export function pageKeyFromRel(relMd) {
40
+ const norm = relMd.replace(/\\/g, '/').replace(/^\.\//, '');
41
+ let withoutExt = norm.replace(/\.md$/i, '');
42
+ if (withoutExt.endsWith('/index')) {
43
+ withoutExt = withoutExt.slice(0, -'/index'.length);
44
+ }
45
+ else if (withoutExt === 'index') {
46
+ withoutExt = 'index';
47
+ }
48
+ return withoutExt.replace(/^\/+|\/+$/g, '') || 'index';
49
+ }
50
+ /**
51
+ * @param {string} documentsRoot absolute path to .../documents
52
+ * @returns {{
53
+ * locales: string[],
54
+ * pages: Array<{ identity: { pageKey: string, locale: string }, sourcePath: string }>,
55
+ * diagnostics: Array<{ code: string, severity: string, message: string, path?: string }>,
56
+ * }}
57
+ */
58
+ export function scanDocumentsTree(documentsRoot) {
59
+ /** @type {Array<{ code: string, severity: string, message: string, path?: string }>} */
60
+ const diagnostics = [];
61
+ /** @type {string[]} */
62
+ const locales = [];
63
+ /** @type {Array<{ identity: { pageKey: string, locale: string }, sourcePath: string }>} */
64
+ const pages = [];
65
+ if (!fs.existsSync(documentsRoot) || !fs.statSync(documentsRoot).isDirectory()) {
66
+ diagnostics.push({
67
+ code: DIAG.LAYOUT_MISSING_DOCUMENTS,
68
+ severity: 'error',
69
+ message: `documents root missing: ${documentsRoot}`,
70
+ path: documentsRoot,
71
+ });
72
+ return { locales, pages, diagnostics };
73
+ }
74
+ /** @type {Map<string, string[]>} canonical → literals */
75
+ const byCanonical = new Map();
76
+ const top = fs.readdirSync(documentsRoot, { withFileTypes: true });
77
+ for (const ent of top) {
78
+ if (ent.name.startsWith('.'))
79
+ continue;
80
+ const full = path.join(documentsRoot, ent.name);
81
+ if (DOCUMENT_RESERVED_TOP.has(ent.name))
82
+ continue;
83
+ if (ent.isFile()) {
84
+ diagnostics.push({
85
+ code: DIAG.LAYOUT_ILLEGAL_TOP,
86
+ severity: 'error',
87
+ message: `illegal top-level file under documents/ (only package.json, documents.config.*, public/ reserved): ${ent.name}`,
88
+ path: full,
89
+ });
90
+ continue;
91
+ }
92
+ if (!ent.isDirectory())
93
+ continue;
94
+ const v = validateLocaleLiteral(ent.name);
95
+ if (!v.ok) {
96
+ diagnostics.push({
97
+ code: v.code,
98
+ severity: 'error',
99
+ message: v.message,
100
+ path: full,
101
+ });
102
+ // Still try to detect alias conflicts with soft form
103
+ const soft = softNormalizeLocale(ent.name);
104
+ const can = canonicalLocale(soft);
105
+ const list = byCanonical.get(can) || [];
106
+ list.push(ent.name);
107
+ byCanonical.set(can, list);
108
+ continue;
109
+ }
110
+ const list = byCanonical.get(v.canonical) || [];
111
+ list.push(ent.name);
112
+ byCanonical.set(v.canonical, list);
113
+ locales.push(ent.name);
114
+ const seenKeys = new Set();
115
+ walkMd(full, (relMd) => {
116
+ const pageKey = pageKeyFromRel(relMd);
117
+ const sourcePath = path.join(ent.name, relMd).replace(/\\/g, '/');
118
+ if (seenKeys.has(pageKey)) {
119
+ diagnostics.push({
120
+ code: DIAG.PAGE_DUPLICATE,
121
+ severity: 'error',
122
+ message: `duplicate PageKey ${JSON.stringify(pageKey)} under locale ${ent.name}`,
123
+ path: sourcePath,
124
+ });
125
+ return;
126
+ }
127
+ seenKeys.add(pageKey);
128
+ pages.push({
129
+ identity: { pageKey, locale: ent.name },
130
+ sourcePath,
131
+ });
132
+ });
133
+ }
134
+ for (const [canonical, literals] of byCanonical) {
135
+ const uniq = [...new Set(literals)];
136
+ if (uniq.length > 1) {
137
+ diagnostics.push({
138
+ code: DIAG.LOCALE_CONFLICT,
139
+ severity: 'error',
140
+ message: `locale identity conflict for canonical ${JSON.stringify(canonical)}: directories ${uniq.map((s) => JSON.stringify(s)).join(', ')} must not coexist`,
141
+ path: documentsRoot,
142
+ });
143
+ }
144
+ }
145
+ locales.sort();
146
+ pages.sort((a, b) => {
147
+ const c = a.identity.locale.localeCompare(b.identity.locale);
148
+ return c !== 0 ? c : a.identity.pageKey.localeCompare(b.identity.pageKey);
149
+ });
150
+ return { locales, pages, diagnostics };
151
+ }