@gregiteen/ssss-cli 0.7.0 → 0.8.0
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/CHANGELOG.md +63 -4
- package/README.md +29 -13
- package/VERSION +1 -1
- package/conformance/README.md +17 -11
- package/conformance/fixtures.json +68 -2
- package/conformance/reference-bundle.ucw.json +39 -5
- package/docs/help/bundle.md +6 -2
- package/docs/help/import.md +6 -2
- package/docs/help/leases.md +31 -0
- package/docs/help/localization.md +43 -0
- package/docs/help/overview.md +9 -5
- package/docs/help/portability.md +11 -3
- package/docs/help/runtime.md +3 -3
- package/docs/help/scaffold.md +2 -2
- package/docs/help/semantic.md +37 -0
- package/docs/ssss-spec.md +112 -7
- package/package.json +8 -3
- package/registry/core.json +43 -3
- package/scripts/audit-registry-field-usage.mjs +10 -1
- package/scripts/build-reference-bundle.mjs +25 -2
- package/scripts/cmd-export.mjs +3 -3
- package/scripts/cmd-import.mjs +5 -1
- package/scripts/cmd-localize.mjs +44 -0
- package/scripts/cmd-new.mjs +11 -11
- package/scripts/cmd-semantic.mjs +88 -0
- package/scripts/conformance.mjs +574 -2
- package/scripts/ssss.mjs +6 -2
- package/skills/ssss/SKILL.md +225 -0
- package/skills/ssss/evals/evals.json +37 -0
- package/skills/ssss/references/contract-reference.md +96 -0
- package/skills/ssss/scripts/validate-schema.mjs +258 -0
- package/skills/ssss/subagents/audit-ssss-contract.md +22 -0
- package/src/bundle.mjs +266 -41
- package/src/engine.mjs +78 -4
- package/src/frontmatter.mjs +141 -38
- package/src/registry.mjs +249 -7
- package/src/semantic.mjs +402 -0
package/src/semantic.mjs
ADDED
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deterministic semantic projection and non-destructive localization helpers
|
|
3
|
+
* (spec §11.9). No database, network, embedding provider, or YAML dependency.
|
|
4
|
+
*/
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import crypto from 'node:crypto';
|
|
8
|
+
import { parseDocument, serializeDocument } from './frontmatter.mjs';
|
|
9
|
+
import {
|
|
10
|
+
documentHash,
|
|
11
|
+
isSafeDocumentPath,
|
|
12
|
+
loadRegistries,
|
|
13
|
+
resolvePortability,
|
|
14
|
+
validateDataConstraints,
|
|
15
|
+
validateReferenceConstraints,
|
|
16
|
+
} from './registry.mjs';
|
|
17
|
+
|
|
18
|
+
const DEFAULT_LOCALE_PATTERN = /^[A-Za-z]{2,3}(?:-[A-Za-z0-9]{2,8})*$/u;
|
|
19
|
+
const TRANSLATABLE_FIELDS = new Set(['title', 'description', 'body']);
|
|
20
|
+
const APPROVED_STATUSES = new Set(['approved']);
|
|
21
|
+
|
|
22
|
+
function asStrings(value) {
|
|
23
|
+
if (Array.isArray(value)) return value.filter((item) => typeof item === 'string' && item);
|
|
24
|
+
return typeof value === 'string' && value ? [value] : [];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function normalizeLocale(value, pattern = DEFAULT_LOCALE_PATTERN) {
|
|
28
|
+
if (typeof value !== 'string' || !pattern.test(value)) {
|
|
29
|
+
throw new Error(`Invalid locale '${value}'. Use a BCP47-style tag such as en, es-MX, or zh-Hant.`);
|
|
30
|
+
}
|
|
31
|
+
return value.split('-').map((part, index) => {
|
|
32
|
+
if (index === 0) return part.toLowerCase();
|
|
33
|
+
if (part.length === 2 || /^\d{3}$/.test(part)) return part.toUpperCase();
|
|
34
|
+
if (part.length === 4) return part[0].toUpperCase() + part.slice(1).toLowerCase();
|
|
35
|
+
return part.toLowerCase();
|
|
36
|
+
}).join('-');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function walkDocuments(vaultRoot) {
|
|
40
|
+
const root = path.resolve(vaultRoot);
|
|
41
|
+
if (!fs.existsSync(root) || !fs.statSync(root).isDirectory()) {
|
|
42
|
+
throw new Error(`Vault directory does not exist: ${root}`);
|
|
43
|
+
}
|
|
44
|
+
const documents = [];
|
|
45
|
+
const stack = [root];
|
|
46
|
+
while (stack.length) {
|
|
47
|
+
const dir = stack.pop();
|
|
48
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true })
|
|
49
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
50
|
+
for (const entry of entries) {
|
|
51
|
+
if (entry.name.startsWith('.') || entry.name === 'node_modules') continue;
|
|
52
|
+
const absolute = path.join(dir, entry.name);
|
|
53
|
+
const relative = path.relative(root, absolute).split(path.sep).join('/');
|
|
54
|
+
if (entry.isSymbolicLink()) {
|
|
55
|
+
throw new Error(`Refusing symlinked vault entry: ${relative}`);
|
|
56
|
+
}
|
|
57
|
+
if (entry.isDirectory()) stack.push(absolute);
|
|
58
|
+
else if (entry.isFile() && entry.name.endsWith('.md') &&
|
|
59
|
+
entry.name !== 'index.md' && entry.name !== 'log.md') {
|
|
60
|
+
if (!isSafeDocumentPath(relative)) throw new Error(`Unsafe vault path: ${relative}`);
|
|
61
|
+
const content = fs.readFileSync(absolute, 'utf8');
|
|
62
|
+
const parsed = parseDocument(content);
|
|
63
|
+
if (!parsed.data.type) continue;
|
|
64
|
+
documents.push({ path: relative, absolute, content, ...parsed });
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return documents.sort((a, b) => a.path.localeCompare(b.path));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function documentMap(documents) {
|
|
72
|
+
return new Map(documents.map((document) => [document.path, document]));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function validateReferences(document, documents, types) {
|
|
76
|
+
const definition = types.get(document.data.type);
|
|
77
|
+
return validateReferenceConstraints(
|
|
78
|
+
definition,
|
|
79
|
+
document.data,
|
|
80
|
+
(referencePath) => documents.get(referencePath) || null,
|
|
81
|
+
types
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function translationMap(documents, locale, types, { includeDrafts = false } = {}) {
|
|
86
|
+
const wanted = normalizeLocale(locale);
|
|
87
|
+
const bySource = new Map();
|
|
88
|
+
for (const document of documents.values()) {
|
|
89
|
+
if (document.data.type !== 'translation') continue;
|
|
90
|
+
const normalized = normalizeLocale(document.data.locale);
|
|
91
|
+
if (normalized !== wanted) continue;
|
|
92
|
+
if (!includeDrafts && !APPROVED_STATUSES.has(document.data.status)) continue;
|
|
93
|
+
const key = document.data.source_path;
|
|
94
|
+
if (bySource.has(key)) {
|
|
95
|
+
throw new Error(
|
|
96
|
+
`Duplicate translation overlays for '${key}' and locale '${wanted}': ` +
|
|
97
|
+
`${bySource.get(key).path}, ${document.path}`
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
const issues = validateReferences(document, documents, types);
|
|
101
|
+
if (issues.length) {
|
|
102
|
+
throw new Error(`${document.path}: ${issues.map((issue) => `${issue.field}: ${issue.issue}`).join('; ')}`);
|
|
103
|
+
}
|
|
104
|
+
bySource.set(key, document);
|
|
105
|
+
}
|
|
106
|
+
return bySource;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function applyTranslationOverlay(sourceContent, translationContent, options = {}) {
|
|
110
|
+
const { types, core } = loadRegistries(options.registryDir);
|
|
111
|
+
const source = parseDocument(sourceContent);
|
|
112
|
+
const overlay = parseDocument(translationContent);
|
|
113
|
+
if (overlay.data.type !== 'translation') throw new Error('Overlay must declare type: translation.');
|
|
114
|
+
if (source.data.type === 'translation') throw new Error('Translations may not target another translation.');
|
|
115
|
+
const sourcePath = options.sourcePath || overlay.data.source_path;
|
|
116
|
+
if (!isSafeDocumentPath(sourcePath) || overlay.data.source_path !== sourcePath) {
|
|
117
|
+
throw new Error(`Translation source_path '${overlay.data.source_path}' does not match '${sourcePath}'.`);
|
|
118
|
+
}
|
|
119
|
+
if (resolvePortability(types.get(source.data.type), source.data) !== 'structural') {
|
|
120
|
+
throw new Error('Translation overlays may target structural documents only.');
|
|
121
|
+
}
|
|
122
|
+
const expectedHash = documentHash(sourceContent);
|
|
123
|
+
if (overlay.data.source_hash !== expectedHash) {
|
|
124
|
+
throw new Error(`Stale translation for '${sourcePath}'; expected source_hash '${expectedHash}'.`);
|
|
125
|
+
}
|
|
126
|
+
const localePattern = new RegExp(core.semantic?.locale_pattern || DEFAULT_LOCALE_PATTERN.source, 'u');
|
|
127
|
+
const locale = normalizeLocale(overlay.data.locale, localePattern);
|
|
128
|
+
const translatedFields = [...new Set(asStrings(overlay.data.translated_fields))];
|
|
129
|
+
if (!translatedFields.length) throw new Error('translated_fields must not be empty.');
|
|
130
|
+
for (const field of translatedFields) {
|
|
131
|
+
if (!TRANSLATABLE_FIELDS.has(field)) {
|
|
132
|
+
throw new Error(`Field '${field}' is a symbolic control field and may not be translated.`);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const data = { ...source.data };
|
|
137
|
+
let body = source.body;
|
|
138
|
+
if (translatedFields.includes('title')) {
|
|
139
|
+
if (!overlay.data.translated_title) throw new Error('translated_title is required when translating title.');
|
|
140
|
+
data.title = overlay.data.translated_title;
|
|
141
|
+
}
|
|
142
|
+
if (translatedFields.includes('description')) {
|
|
143
|
+
if (!overlay.data.translated_description) {
|
|
144
|
+
throw new Error('translated_description is required when translating description.');
|
|
145
|
+
}
|
|
146
|
+
data.description = overlay.data.translated_description;
|
|
147
|
+
}
|
|
148
|
+
if (translatedFields.includes('body')) {
|
|
149
|
+
if (!overlay.body.trim()) throw new Error('Translation body must not be empty when translating body.');
|
|
150
|
+
body = overlay.body;
|
|
151
|
+
}
|
|
152
|
+
data.locale = locale;
|
|
153
|
+
data.translation_of = sourcePath;
|
|
154
|
+
data.translation_source_hash = expectedHash;
|
|
155
|
+
|
|
156
|
+
return {
|
|
157
|
+
content: serializeDocument(data, body),
|
|
158
|
+
data,
|
|
159
|
+
body,
|
|
160
|
+
locale,
|
|
161
|
+
status: overlay.data.status,
|
|
162
|
+
translated_fields: translatedFields,
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function tokenize(value) {
|
|
167
|
+
const normalized = String(value || '').normalize('NFKC').toLocaleLowerCase();
|
|
168
|
+
return [...new Set(normalized.match(/[\p{L}\p{N}]+/gu) || [])].sort();
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function extractEdges(documentPath, data, body) {
|
|
172
|
+
const edges = [];
|
|
173
|
+
for (const match of body.matchAll(/\[\[([^\]]+)\]\]/g)) {
|
|
174
|
+
edges.push({ source: documentPath, relation: 'mentions', target: match[1].trim() });
|
|
175
|
+
}
|
|
176
|
+
for (const match of body.matchAll(/\[[^\]]*\]\((\/[^)]+\.md)\)/g)) {
|
|
177
|
+
edges.push({ source: documentPath, relation: 'links_to', target: match[1].slice(1) });
|
|
178
|
+
}
|
|
179
|
+
for (const relation of Array.isArray(data.relations) ? data.relations : []) {
|
|
180
|
+
if (relation && typeof relation === 'object' && relation.target) {
|
|
181
|
+
edges.push({
|
|
182
|
+
source: documentPath,
|
|
183
|
+
relation: String(relation.relation || relation.type || 'related_to'),
|
|
184
|
+
target: String(relation.target),
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
return edges;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function canonicalHash(value) {
|
|
192
|
+
return `sha256:${crypto.createHash('sha256').update(JSON.stringify(value)).digest('hex')}`;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function localizedDocuments(vaultRoot, options = {}) {
|
|
196
|
+
const { types, core } = loadRegistries(options.registryDir);
|
|
197
|
+
const all = walkDocuments(vaultRoot);
|
|
198
|
+
const documents = documentMap(all);
|
|
199
|
+
for (const document of all) {
|
|
200
|
+
const definition = types.get(document.data.type);
|
|
201
|
+
if (!definition) throw new Error(`${document.path}: unknown type '${document.data.type}'.`);
|
|
202
|
+
const issues = [
|
|
203
|
+
...validateDataConstraints(
|
|
204
|
+
definition,
|
|
205
|
+
document.data,
|
|
206
|
+
core.universal_frontmatter?.required || ['type']
|
|
207
|
+
),
|
|
208
|
+
...(document.data.type === 'translation' ? [] : validateReferences(document, documents, types)),
|
|
209
|
+
];
|
|
210
|
+
if (issues.length) {
|
|
211
|
+
throw new Error(`${document.path}: ${issues.map((issue) => `${issue.field}: ${issue.issue}`).join('; ')}`);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
const overlays = options.locale
|
|
215
|
+
? translationMap(documents, options.locale, types, options)
|
|
216
|
+
: new Map();
|
|
217
|
+
const output = [];
|
|
218
|
+
let excluded = 0;
|
|
219
|
+
|
|
220
|
+
for (const source of all) {
|
|
221
|
+
if (source.data.type === 'translation') continue;
|
|
222
|
+
const portability = resolvePortability(types.get(source.data.type), source.data);
|
|
223
|
+
if (!options.includePrivate && portability !== 'structural') {
|
|
224
|
+
excluded++;
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
const overlay = overlays.get(source.path);
|
|
228
|
+
const localized = overlay
|
|
229
|
+
? applyTranslationOverlay(source.content, overlay.content, {
|
|
230
|
+
sourcePath: source.path,
|
|
231
|
+
registryDir: options.registryDir,
|
|
232
|
+
})
|
|
233
|
+
: { content: source.content, data: source.data, body: source.body, locale: source.data.locale || null };
|
|
234
|
+
output.push({
|
|
235
|
+
path: source.path,
|
|
236
|
+
source,
|
|
237
|
+
overlay: overlay || null,
|
|
238
|
+
portability,
|
|
239
|
+
...localized,
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
return { documents: output, excluded };
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
export function buildSemanticIndex(vaultRoot, options = {}) {
|
|
246
|
+
const locale = options.locale ? normalizeLocale(options.locale) : null;
|
|
247
|
+
const localized = localizedDocuments(vaultRoot, { ...options, locale });
|
|
248
|
+
const records = [];
|
|
249
|
+
const edges = [];
|
|
250
|
+
|
|
251
|
+
for (const document of localized.documents) {
|
|
252
|
+
const data = document.data;
|
|
253
|
+
const identity = data.semantic_id || data.resource || `${data.type}:${document.path}`;
|
|
254
|
+
const labels = [data.title, data.description, data.name, data.slug, ...asStrings(data.aliases)];
|
|
255
|
+
const tags = asStrings(data.tags);
|
|
256
|
+
const record = {
|
|
257
|
+
semantic_id: identity,
|
|
258
|
+
path: document.path,
|
|
259
|
+
type: data.type,
|
|
260
|
+
portability: document.portability,
|
|
261
|
+
locale: document.locale || null,
|
|
262
|
+
title: data.title || null,
|
|
263
|
+
description: data.description || null,
|
|
264
|
+
resource: data.resource || null,
|
|
265
|
+
aliases: asStrings(data.aliases),
|
|
266
|
+
tags,
|
|
267
|
+
content_hash: documentHash(document.content),
|
|
268
|
+
translation: document.overlay ? {
|
|
269
|
+
path: document.overlay.path,
|
|
270
|
+
status: document.overlay.data.status,
|
|
271
|
+
source_hash: document.overlay.data.source_hash,
|
|
272
|
+
} : null,
|
|
273
|
+
search: {
|
|
274
|
+
identity: tokenize([identity, data.resource, data.type, document.path].filter(Boolean).join(' ')),
|
|
275
|
+
labels: tokenize(labels.filter(Boolean).join(' ')),
|
|
276
|
+
tags: tokenize(tags.join(' ')),
|
|
277
|
+
text: tokenize(`${data.description || ''}\n${document.body || ''}`),
|
|
278
|
+
},
|
|
279
|
+
};
|
|
280
|
+
records.push(record);
|
|
281
|
+
edges.push(...extractEdges(document.path, data, document.body));
|
|
282
|
+
}
|
|
283
|
+
records.sort((a, b) => a.path.localeCompare(b.path));
|
|
284
|
+
edges.sort((a, b) =>
|
|
285
|
+
a.source.localeCompare(b.source) || a.relation.localeCompare(b.relation) || a.target.localeCompare(b.target)
|
|
286
|
+
);
|
|
287
|
+
const payload = {
|
|
288
|
+
version: 1,
|
|
289
|
+
locale,
|
|
290
|
+
include_private: !!options.includePrivate,
|
|
291
|
+
excluded_documents: localized.excluded,
|
|
292
|
+
documents: records,
|
|
293
|
+
edges,
|
|
294
|
+
};
|
|
295
|
+
return { ...payload, index_hash: canonicalHash(payload) };
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
export function searchSemanticIndex(index, query, options = {}) {
|
|
299
|
+
const queryTokens = tokenize(query);
|
|
300
|
+
if (!queryTokens.length) return [];
|
|
301
|
+
const allowedTypes = options.types ? new Set(options.types) : null;
|
|
302
|
+
const limit = Number.isInteger(options.limit) && options.limit > 0 ? options.limit : 10;
|
|
303
|
+
const results = [];
|
|
304
|
+
for (const document of index.documents || []) {
|
|
305
|
+
if (allowedTypes && !allowedTypes.has(document.type)) continue;
|
|
306
|
+
const buckets = [
|
|
307
|
+
[document.search?.identity || [], 12],
|
|
308
|
+
[document.search?.labels || [], 7],
|
|
309
|
+
[document.search?.tags || [], 5],
|
|
310
|
+
[document.search?.text || [], 1],
|
|
311
|
+
];
|
|
312
|
+
let score = 0;
|
|
313
|
+
const matched = new Set();
|
|
314
|
+
for (const token of queryTokens) {
|
|
315
|
+
for (const [values, weight] of buckets) {
|
|
316
|
+
if (values.includes(token)) {
|
|
317
|
+
score += weight;
|
|
318
|
+
matched.add(token);
|
|
319
|
+
break;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
if (score > 0) results.push({ score, matched: [...matched].sort(), document });
|
|
324
|
+
}
|
|
325
|
+
return results
|
|
326
|
+
.sort((a, b) => b.score - a.score || a.document.path.localeCompare(b.document.path))
|
|
327
|
+
.slice(0, limit);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function atomicWrite(target, content) {
|
|
331
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
332
|
+
const temp = path.join(path.dirname(target), `.${path.basename(target)}.${crypto.randomUUID()}.tmp`);
|
|
333
|
+
fs.writeFileSync(temp, content);
|
|
334
|
+
fs.renameSync(temp, target);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function resolvePhysicalTarget(value) {
|
|
338
|
+
const requested = path.resolve(value);
|
|
339
|
+
let cursor = requested;
|
|
340
|
+
const suffix = [];
|
|
341
|
+
while (!fs.existsSync(cursor)) {
|
|
342
|
+
suffix.unshift(path.basename(cursor));
|
|
343
|
+
const parent = path.dirname(cursor);
|
|
344
|
+
if (parent === cursor) break;
|
|
345
|
+
cursor = parent;
|
|
346
|
+
}
|
|
347
|
+
const physical = fs.realpathSync(cursor);
|
|
348
|
+
return path.join(physical, ...suffix);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
export function materializeLocale(vaultRoot, locale, outputDir, options = {}) {
|
|
352
|
+
const sourceRoot = fs.realpathSync(path.resolve(vaultRoot));
|
|
353
|
+
const targetRoot = resolvePhysicalTarget(outputDir);
|
|
354
|
+
const normalizedLocale = normalizeLocale(locale);
|
|
355
|
+
if (targetRoot === sourceRoot || targetRoot.startsWith(sourceRoot + path.sep)) {
|
|
356
|
+
throw new Error('Localization output must be outside the source vault.');
|
|
357
|
+
}
|
|
358
|
+
if (fs.existsSync(targetRoot)) {
|
|
359
|
+
const targetStat = fs.lstatSync(targetRoot);
|
|
360
|
+
if (targetStat.isSymbolicLink() || !targetStat.isDirectory()) {
|
|
361
|
+
throw new Error(`Localization output must be a real directory: ${targetRoot}`);
|
|
362
|
+
}
|
|
363
|
+
if (fs.readdirSync(targetRoot).length > 0) {
|
|
364
|
+
throw new Error(`Localization output directory must be empty: ${targetRoot}`);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
const localized = localizedDocuments(sourceRoot, { ...options, locale: normalizedLocale });
|
|
368
|
+
const parent = path.dirname(targetRoot);
|
|
369
|
+
fs.mkdirSync(parent, { recursive: true });
|
|
370
|
+
const stageRoot = path.join(parent, `.${path.basename(targetRoot)}.${crypto.randomUUID()}.stage`);
|
|
371
|
+
const inventory = [];
|
|
372
|
+
let targetWasEmpty = fs.existsSync(targetRoot);
|
|
373
|
+
try {
|
|
374
|
+
fs.mkdirSync(stageRoot);
|
|
375
|
+
for (const document of localized.documents) {
|
|
376
|
+
if (!isSafeDocumentPath(document.path)) throw new Error(`Unsafe projection path: ${document.path}`);
|
|
377
|
+
const target = path.resolve(stageRoot, document.path);
|
|
378
|
+
if (!target.startsWith(stageRoot + path.sep)) throw new Error(`Projection path escaped output: ${document.path}`);
|
|
379
|
+
atomicWrite(target, document.content);
|
|
380
|
+
inventory.push({ path: document.path, content_hash: documentHash(document.content) });
|
|
381
|
+
}
|
|
382
|
+
inventory.sort((a, b) => a.path.localeCompare(b.path));
|
|
383
|
+
const manifest = {
|
|
384
|
+
type: 'ssss_localization_projection',
|
|
385
|
+
version: 1,
|
|
386
|
+
locale: normalizedLocale,
|
|
387
|
+
include_private: !!options.includePrivate,
|
|
388
|
+
document_count: inventory.length,
|
|
389
|
+
excluded_documents: localized.excluded,
|
|
390
|
+
source_hash: canonicalHash(inventory),
|
|
391
|
+
documents: inventory,
|
|
392
|
+
};
|
|
393
|
+
atomicWrite(path.join(stageRoot, '.ssss-projection.json'), JSON.stringify(manifest, null, 2) + '\n');
|
|
394
|
+
if (targetWasEmpty) fs.rmdirSync(targetRoot);
|
|
395
|
+
fs.renameSync(stageRoot, targetRoot);
|
|
396
|
+
return manifest;
|
|
397
|
+
} catch (error) {
|
|
398
|
+
fs.rmSync(stageRoot, { recursive: true, force: true });
|
|
399
|
+
if (targetWasEmpty && !fs.existsSync(targetRoot)) fs.mkdirSync(targetRoot);
|
|
400
|
+
throw error;
|
|
401
|
+
}
|
|
402
|
+
}
|