champollion 0.3.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +133 -0
- package/README.md +387 -0
- package/bin/cli.js +278 -0
- package/index.js +135 -0
- package/lib/api-key.js +127 -0
- package/lib/autofix.js +432 -0
- package/lib/bridge/method_bridge.py +430 -0
- package/lib/card-source-resolution.mjs +284 -0
- package/lib/cards/cache.js +169 -0
- package/lib/cards/env.js +82 -0
- package/lib/cards/fetch-card-child.js +38 -0
- package/lib/cards/reader.js +435 -0
- package/lib/cards/refresh.js +111 -0
- package/lib/cards/remote.js +387 -0
- package/lib/cldf-export.mjs +540 -0
- package/lib/cldf-terms.mjs +62 -0
- package/lib/command-help.js +790 -0
- package/lib/commands/audit.js +49 -0
- package/lib/commands/card.js +454 -0
- package/lib/commands/doctor.js +559 -0
- package/lib/commands/fonts.js +489 -0
- package/lib/commands/help.js +91 -0
- package/lib/commands/init.js +1259 -0
- package/lib/commands/integrity.js +148 -0
- package/lib/commands/leaderboard.js +478 -0
- package/lib/commands/lint.js +30 -0
- package/lib/commands/models.js +177 -0
- package/lib/commands/plugin.js +103 -0
- package/lib/commands/provenance.js +45 -0
- package/lib/commands/recommend.js +75 -0
- package/lib/commands/register-corpus.js +678 -0
- package/lib/commands/repair-script.js +42 -0
- package/lib/commands/seal-corpus.js +355 -0
- package/lib/commands/seo.js +72 -0
- package/lib/commands/serve.js +147 -0
- package/lib/commands/status.js +265 -0
- package/lib/commands/submit.js +332 -0
- package/lib/commands/sync.js +89 -0
- package/lib/commands/tm.js +573 -0
- package/lib/commands/verify.js +39 -0
- package/lib/commands/watch.js +20 -0
- package/lib/commands/wrap.js +138 -0
- package/lib/commands/xliff.js +327 -0
- package/lib/commercial-eligibility.js +235 -0
- package/lib/concurrent.js +87 -0
- package/lib/config.js +523 -0
- package/lib/contamination-lane.js +76 -0
- package/lib/content-sync.js +731 -0
- package/lib/content.js +733 -0
- package/lib/corpus-registration.mjs +608 -0
- package/lib/cost-report.js +346 -0
- package/lib/diff.js +155 -0
- package/lib/docusaurus-sync.js +1256 -0
- package/lib/flatten.js +55 -0
- package/lib/format.js +954 -0
- package/lib/hash.js +159 -0
- package/lib/icu.js +473 -0
- package/lib/integrity.js +689 -0
- package/lib/license-gate.mjs +478 -0
- package/lib/license-identify.mjs +229 -0
- package/lib/lint.js +629 -0
- package/lib/method-manifest.js +60 -0
- package/lib/methods/anthropic.js +140 -0
- package/lib/methods/apertium.js +163 -0
- package/lib/methods/api.js +316 -0
- package/lib/methods/base.js +184 -0
- package/lib/methods/content-separator.js +45 -0
- package/lib/methods/deepl.js +426 -0
- package/lib/methods/direct-llm.js +586 -0
- package/lib/methods/external.js +332 -0
- package/lib/methods/fetch-with-retry.js +124 -0
- package/lib/methods/gemini.js +147 -0
- package/lib/methods/google-translate.js +402 -0
- package/lib/methods/http-utils.js +122 -0
- package/lib/methods/libretranslate.js +314 -0
- package/lib/methods/llm-coached.js +670 -0
- package/lib/methods/llm.js +592 -0
- package/lib/methods/local.js +76 -0
- package/lib/methods/microsoft-translator.js +331 -0
- package/lib/methods/openai.js +131 -0
- package/lib/methods/openrouter-client.js +327 -0
- package/lib/methods/openrouter-pricing.js +156 -0
- package/lib/methods/provider-env.js +115 -0
- package/lib/methods/provider-pricing.js +310 -0
- package/lib/methods/tilde.js +150 -0
- package/lib/methods/translated.js +229 -0
- package/lib/methods/translation-error.js +80 -0
- package/lib/models.js +258 -0
- package/lib/no-translate.js +233 -0
- package/lib/output.js +238 -0
- package/lib/pairs.js +547 -0
- package/lib/plugins.js +447 -0
- package/lib/provenance.js +323 -0
- package/lib/recommend.js +648 -0
- package/lib/registers.js +1185 -0
- package/lib/repair-script.js +266 -0
- package/lib/scripts.js +994 -0
- package/lib/seal.mjs +464 -0
- package/lib/sealed-qualifier.mjs +211 -0
- package/lib/security.js +59 -0
- package/lib/segment.js +369 -0
- package/lib/seo.js +275 -0
- package/lib/serve.js +854 -0
- package/lib/string-classify.js +85 -0
- package/lib/submit.mjs +344 -0
- package/lib/sync.js +969 -0
- package/lib/tags/bcp47.js +202 -0
- package/lib/tags/resolve.js +314 -0
- package/lib/terminology.js +111 -0
- package/lib/tm-seed.js +294 -0
- package/lib/tm.js +515 -0
- package/lib/translate-pair.js +197 -0
- package/lib/translate.js +203 -0
- package/lib/types.js +230 -0
- package/lib/validate.js +510 -0
- package/lib/verify.js +451 -0
- package/lib/watch.js +145 -0
- package/lib/xliff.js +184 -0
- package/package.json +93 -0
- package/shared/ATTRIBUTION.md +145 -0
- package/shared/CORPORA-CARDS.md +288 -0
- package/shared/DATA-SOVEREIGNTY.md +500 -0
- package/shared/LANGUAGE-CARD-FIELDS.md +532 -0
- package/shared/card-lint-baseline.json +3189 -0
- package/shared/cards-fallback.json +1 -0
- package/shared/catalogue/card-config.json +6091 -0
- package/shared/catalogue/external-results.json +3888 -0
- package/shared/catalogue/gender-guidance.json +1038 -0
- package/shared/catalogue/method-coverage.json +1751 -0
- package/shared/catalogue/metric-coverage.json +170 -0
- package/shared/catalogue/metric-reliability.json +1 -0
- package/shared/catalogue/register-presets.json +3180 -0
- package/shared/catalogue/vitality-scales.json +55 -0
- package/shared/cldr-index.json +1115 -0
- package/shared/code-bridge.json +253 -0
- package/shared/corpora-cards-v1-reference.md +281 -0
- package/shared/curated-dictionary-flags.json +35 -0
- package/shared/curated-endonyms.json +35 -0
- package/shared/curated-fsts.json +51 -0
- package/shared/curated-orthography-conventions.json +26 -0
- package/shared/curated-sil-resources.json +374 -0
- package/shared/curated-tools.json +41 -0
- package/shared/docent/corpus.json +11333 -0
- package/shared/docent/faq.en.json +564 -0
- package/shared/docent/register-blocks.json +60 -0
- package/shared/docent/system-prompt.md +144 -0
- package/shared/domain-taxonomy.json +35 -0
- package/shared/explainers/glossary.json +2975 -0
- package/shared/explainers/tc-features.json +20112 -0
- package/shared/explainers/term-watchlist.json +147 -0
- package/shared/human-services.json +59 -0
- package/shared/license-corrections.json +261 -0
- package/shared/license-evidence.json +13452 -0
- package/shared/licenses.json +6781 -0
- package/shared/method-registry.json +236 -0
- package/shared/metric-registry.json +620 -0
- package/shared/model-aliases.json +7 -0
- package/shared/schemas/champollion-plugin.schema.json +206 -0
- package/shared/schemas/corpora-card.schema.json +957 -0
- package/shared/schemas/domain-taxonomy.schema.json +64 -0
- package/shared/schemas/external-results.schema.json +314 -0
- package/shared/schemas/human-services.schema.json +90 -0
- package/shared/schemas/language-card.schema.json +1308 -0
- package/shared/schemas/licenses.schema.json +155 -0
- package/shared/schemas/method-card.schema.json +412 -0
- package/shared/schemas/method-registry.schema.json +85 -0
- package/shared/schemas/metric-registry.schema.json +96 -0
- package/shared/schemas/metric-reliability.schema.json +178 -0
- package/shared/schemas/model-aliases.schema.json +27 -0
- package/shared/schemas/source-snapshot.schema.json +96 -0
package/lib/format.js
ADDED
|
@@ -0,0 +1,954 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Format adapter — reads and writes locale files in JSON, TOML, and YAML.
|
|
3
|
+
*
|
|
4
|
+
* WHY: Hugo uses TOML or YAML for i18n string files, not JSON.
|
|
5
|
+
* Hugo's i18n/ structure looks like:
|
|
6
|
+
*
|
|
7
|
+
* [home] # TOML section = translation key
|
|
8
|
+
* other = "Home" # 'other' = the default/plural form
|
|
9
|
+
*
|
|
10
|
+
* [items]
|
|
11
|
+
* one = "{{ .Count }} item"
|
|
12
|
+
* other = "{{ .Count }} items"
|
|
13
|
+
*
|
|
14
|
+
* This module converts between Hugo's format and our internal flat
|
|
15
|
+
* key→value map, so the diff/translate/hash engine stays format-agnostic.
|
|
16
|
+
*
|
|
17
|
+
* ZERO DEPENDENCIES: Hugo i18n files have a constrained, predictable
|
|
18
|
+
* structure. We don't need js-yaml or a full TOML parser — just
|
|
19
|
+
* targeted parsers for the subset Hugo actually uses.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import fs from 'node:fs';
|
|
23
|
+
|
|
24
|
+
// CLDR plural categories used by Hugo/go-i18n
|
|
25
|
+
const PLURAL_FORMS = new Set(['zero', 'one', 'two', 'few', 'many', 'other']);
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Strip a leading UTF-8 BOM (U+FEFF) from a decoded string.
|
|
29
|
+
*
|
|
30
|
+
* WHY: Editors on Windows (and some export pipelines) prepend a BOM to
|
|
31
|
+
* locale files. JSON.parse() and our hand-rolled TOML/YAML scanners choke
|
|
32
|
+
* on the invisible leading character — JSON.parse throws "Unexpected token",
|
|
33
|
+
* and the YAML/TOML line scanners fail to match the first key. A BOM is a
|
|
34
|
+
* pure encoding artifact, never semantic content, so we drop it on read.
|
|
35
|
+
*
|
|
36
|
+
* @param {string} str - Decoded file content
|
|
37
|
+
* @returns {string} Content with any leading BOM removed
|
|
38
|
+
*/
|
|
39
|
+
function stripBOM(str) {
|
|
40
|
+
return str.charCodeAt(0) === 0xFEFF ? str.slice(1) : str;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// -----------------------------------------------------------------
|
|
44
|
+
// Format detection
|
|
45
|
+
// -----------------------------------------------------------------
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Detect the locale file format from a file path's extension.
|
|
49
|
+
*
|
|
50
|
+
* @param {string} filePath - Path to a locale file
|
|
51
|
+
* @returns {'json'|'toml'|'yaml'} Detected format
|
|
52
|
+
*/
|
|
53
|
+
function detectFormat(filePath) {
|
|
54
|
+
if (filePath.endsWith('.toml')) return 'toml';
|
|
55
|
+
if (filePath.endsWith('.yaml') || filePath.endsWith('.yml')) return 'yaml';
|
|
56
|
+
return 'json';
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Get the file extension string for a format.
|
|
61
|
+
*
|
|
62
|
+
* @param {'json'|'toml'|'yaml'} format
|
|
63
|
+
* @returns {string} File extension including the dot
|
|
64
|
+
*/
|
|
65
|
+
function getExtension(format) {
|
|
66
|
+
if (format === 'toml') return '.toml';
|
|
67
|
+
if (format === 'yaml') return '.yaml';
|
|
68
|
+
return '.json';
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Auto-detect the format used in a locales directory by scanning
|
|
73
|
+
* for the most common file extension present.
|
|
74
|
+
*
|
|
75
|
+
* @param {string} localesDir - Path to the locales directory
|
|
76
|
+
* @returns {'json'|'toml'|'yaml'} Detected format, defaults to 'json'
|
|
77
|
+
*/
|
|
78
|
+
function detectFormatFromDir(localesDir) {
|
|
79
|
+
if (!fs.existsSync(localesDir)) return 'json';
|
|
80
|
+
|
|
81
|
+
const files = fs.readdirSync(localesDir);
|
|
82
|
+
const counts = { json: 0, toml: 0, yaml: 0 };
|
|
83
|
+
|
|
84
|
+
for (const file of files) {
|
|
85
|
+
if (file.endsWith('.toml')) counts.toml++;
|
|
86
|
+
else if (file.endsWith('.yaml') || file.endsWith('.yml')) counts.yaml++;
|
|
87
|
+
else if (file.endsWith('.json')) counts.json++;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Return whichever format has the most files
|
|
91
|
+
if (counts.toml > counts.json && counts.toml >= counts.yaml) return 'toml';
|
|
92
|
+
if (counts.yaml > counts.json && counts.yaml >= counts.toml) return 'yaml';
|
|
93
|
+
return 'json';
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// -----------------------------------------------------------------
|
|
97
|
+
// Read: format → flat key-value map
|
|
98
|
+
// -----------------------------------------------------------------
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Read a locale file and return a flat key→value map.
|
|
102
|
+
*
|
|
103
|
+
* For TOML/YAML Hugo files, simple keys (only 'other') flatten to
|
|
104
|
+
* { "key": "value" }. Plural keys flatten to { "key.one": "...",
|
|
105
|
+
* "key.other": "..." }.
|
|
106
|
+
*
|
|
107
|
+
* @param {string} filePath - Path to the locale file
|
|
108
|
+
* @param {'json'|'toml'|'yaml'} format - File format
|
|
109
|
+
* @returns {object} Flat key→value map
|
|
110
|
+
*/
|
|
111
|
+
function readLocaleFile(filePath, format) {
|
|
112
|
+
if (!fs.existsSync(filePath)) return {};
|
|
113
|
+
// Strip a leading BOM before any parsing — a U+FEFF prefix otherwise
|
|
114
|
+
// hard-crashes JSON.parse() and silently breaks the first key match in
|
|
115
|
+
// the TOML/YAML line scanners.
|
|
116
|
+
const raw = stripBOM(fs.readFileSync(filePath, 'utf-8'));
|
|
117
|
+
if (!raw.trim()) return {};
|
|
118
|
+
|
|
119
|
+
if (format === 'toml') return parseTOMLToFlat(raw);
|
|
120
|
+
if (format === 'yaml') return parseYAMLToFlat(raw);
|
|
121
|
+
|
|
122
|
+
// JSON: caller handles flattening. Wrap the raw V8 SyntaxError (which only
|
|
123
|
+
// gives a byte offset, no filename or hint) in a file-named, friendlier
|
|
124
|
+
// error so the user can actually find and fix the broken file.
|
|
125
|
+
let parsed;
|
|
126
|
+
try {
|
|
127
|
+
parsed = JSON.parse(raw);
|
|
128
|
+
} catch (err) {
|
|
129
|
+
const hint = /,\s*[}\]]/.test(raw)
|
|
130
|
+
? ' (looks like a trailing comma — JSON does not allow them)'
|
|
131
|
+
: '';
|
|
132
|
+
throw new Error(`Invalid JSON in ${filePath}: ${err.message}${hint}`);
|
|
133
|
+
}
|
|
134
|
+
// Duplicate JSON keys silently last-win in JSON.parse — warn so the loss
|
|
135
|
+
// is visible (a copy-pasted key that quietly overwrote an earlier value).
|
|
136
|
+
for (const dup of findDuplicateJSONKeys(raw)) {
|
|
137
|
+
console.warn(` [WARN] Duplicate JSON key "${dup}" in ${filePath} — later value wins, earlier value discarded.`);
|
|
138
|
+
}
|
|
139
|
+
return parsed;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Find object keys that appear more than once within the same object in a
|
|
144
|
+
* JSON document. JSON.parse() silently keeps the last value, masking
|
|
145
|
+
* copy-paste mistakes; this scanner re-walks the raw text (string- and
|
|
146
|
+
* escape-aware) so we can warn instead.
|
|
147
|
+
*
|
|
148
|
+
* @param {string} raw - Raw JSON text
|
|
149
|
+
* @returns {string[]} Duplicate key names (deduplicated, in first-seen order)
|
|
150
|
+
*/
|
|
151
|
+
function findDuplicateJSONKeys(raw) {
|
|
152
|
+
const duplicates = new Set();
|
|
153
|
+
// Stack of Sets — one per open object — tracking keys seen at that depth.
|
|
154
|
+
const objectStack = [];
|
|
155
|
+
let inString = false;
|
|
156
|
+
let escaped = false;
|
|
157
|
+
let stringStart = -1;
|
|
158
|
+
// `expectKey` is true when the next string token is an object key (i.e. we
|
|
159
|
+
// just opened an object or saw a comma inside an object).
|
|
160
|
+
let expectKey = false;
|
|
161
|
+
|
|
162
|
+
for (let i = 0; i < raw.length; i++) {
|
|
163
|
+
const ch = raw[i];
|
|
164
|
+
|
|
165
|
+
if (inString) {
|
|
166
|
+
if (escaped) { escaped = false; continue; }
|
|
167
|
+
if (ch === '\\') { escaped = true; continue; }
|
|
168
|
+
if (ch === '"') {
|
|
169
|
+
inString = false;
|
|
170
|
+
// If this string was a key, record it against the current object.
|
|
171
|
+
if (expectKey && objectStack.length > 0) {
|
|
172
|
+
const key = raw.slice(stringStart + 1, i);
|
|
173
|
+
const seen = objectStack[objectStack.length - 1];
|
|
174
|
+
if (seen.has(key)) duplicates.add(key);
|
|
175
|
+
else seen.add(key);
|
|
176
|
+
expectKey = false; // next is ':' then a value
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
switch (ch) {
|
|
183
|
+
case '"':
|
|
184
|
+
inString = true;
|
|
185
|
+
stringStart = i;
|
|
186
|
+
break;
|
|
187
|
+
case '{':
|
|
188
|
+
objectStack.push(new Set());
|
|
189
|
+
expectKey = true;
|
|
190
|
+
break;
|
|
191
|
+
case '}':
|
|
192
|
+
objectStack.pop();
|
|
193
|
+
expectKey = false;
|
|
194
|
+
break;
|
|
195
|
+
case '[':
|
|
196
|
+
// Array elements are never keys.
|
|
197
|
+
objectStack.push(null);
|
|
198
|
+
expectKey = false;
|
|
199
|
+
break;
|
|
200
|
+
case ']':
|
|
201
|
+
objectStack.pop();
|
|
202
|
+
expectKey = false;
|
|
203
|
+
break;
|
|
204
|
+
case ',':
|
|
205
|
+
// Inside an object, a comma means another key follows.
|
|
206
|
+
expectKey = objectStack.length > 0 &&
|
|
207
|
+
objectStack[objectStack.length - 1] !== null;
|
|
208
|
+
break;
|
|
209
|
+
default:
|
|
210
|
+
break;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
return [...duplicates];
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Write a nested data object to a locale file in the specified format.
|
|
219
|
+
*
|
|
220
|
+
* For JSON, this writes the nested structure directly.
|
|
221
|
+
* For TOML/YAML, this converts to the appropriate section format.
|
|
222
|
+
*
|
|
223
|
+
* @param {string} filePath - Output file path
|
|
224
|
+
* @param {object} data - Nested data object (for JSON) or flat map (for TOML/YAML)
|
|
225
|
+
* @param {'json'|'toml'|'yaml'} format - Target format
|
|
226
|
+
* @param {object} flatData - Flat key→value map (used for TOML/YAML reconstruction)
|
|
227
|
+
* @param {'hugo'|'nested'|null} yamlStyle - YAML variant: 'hugo' for Hugo i18n, 'nested' for standard nested YAML
|
|
228
|
+
*/
|
|
229
|
+
function writeLocaleFile(filePath, data, format, flatData, yamlStyle) {
|
|
230
|
+
let content;
|
|
231
|
+
if (format === 'toml') {
|
|
232
|
+
content = flatToTOML(flatData || data);
|
|
233
|
+
} else if (format === 'yaml') {
|
|
234
|
+
// Route to the correct YAML serializer based on detected style.
|
|
235
|
+
// Hugo YAML uses CLDR plural sub-keys (other:, one:, etc.).
|
|
236
|
+
// Standard nested YAML uses arbitrary nesting (nav: { home: ... }).
|
|
237
|
+
const serializer = yamlStyle === 'nested' ? flatToNestedYAML : flatToYAML;
|
|
238
|
+
content = serializer(flatData || data);
|
|
239
|
+
} else {
|
|
240
|
+
content = JSON.stringify(data, null, 2) + '\n';
|
|
241
|
+
}
|
|
242
|
+
writeFileAtomic(filePath, content);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Write a file atomically: serialize to a sibling temp file, then rename over
|
|
247
|
+
* the target. rename(2) is atomic within a filesystem, so a crash or a partial
|
|
248
|
+
* write can never leave a half-written locale file on disk — readers see either
|
|
249
|
+
* the old content or the complete new content, never garbage.
|
|
250
|
+
*
|
|
251
|
+
* @param {string} filePath - Destination path
|
|
252
|
+
* @param {string} content - Full file content
|
|
253
|
+
*/
|
|
254
|
+
function writeFileAtomic(filePath, content) {
|
|
255
|
+
// Co-locate the temp file in the same directory so the final rename stays on
|
|
256
|
+
// the same filesystem (cross-device rename is not atomic and would EXDEV).
|
|
257
|
+
const tmpPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
|
|
258
|
+
try {
|
|
259
|
+
fs.writeFileSync(tmpPath, content, 'utf-8');
|
|
260
|
+
fs.renameSync(tmpPath, filePath);
|
|
261
|
+
} catch (err) {
|
|
262
|
+
// Clean up the temp file on failure so we don't litter the locales dir.
|
|
263
|
+
try { fs.unlinkSync(tmpPath); } catch { /* temp may not exist */ }
|
|
264
|
+
throw err;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// -----------------------------------------------------------------
|
|
269
|
+
// TOML parser (Hugo i18n subset)
|
|
270
|
+
// -----------------------------------------------------------------
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Parse Hugo i18n TOML content into a flat key→value map.
|
|
274
|
+
*
|
|
275
|
+
* Handles:
|
|
276
|
+
* [section] → section header (translation key)
|
|
277
|
+
* other = "value" → simple string (flattens to { section: value })
|
|
278
|
+
* one = "singular" → plural form (flattens to { section.one: value })
|
|
279
|
+
* # comments → skipped
|
|
280
|
+
*
|
|
281
|
+
* @param {string} content - Raw TOML file content
|
|
282
|
+
* @returns {object} Flat key→value map
|
|
283
|
+
*/
|
|
284
|
+
function parseTOMLToFlat(content) {
|
|
285
|
+
const sections = parseTOMLSections(content);
|
|
286
|
+
return sectionsToFlat(sections);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Parse TOML into an intermediate section map.
|
|
291
|
+
*
|
|
292
|
+
* @param {string} content - Raw TOML content
|
|
293
|
+
* @returns {Object<string, Object<string, string>>} Section map: { sectionName: { subKey: value } }
|
|
294
|
+
*/
|
|
295
|
+
function parseTOMLSections(content) {
|
|
296
|
+
const sections = {};
|
|
297
|
+
let currentSection = null;
|
|
298
|
+
|
|
299
|
+
for (const line of content.split('\n')) {
|
|
300
|
+
const trimmed = line.trim();
|
|
301
|
+
|
|
302
|
+
// Skip empty lines and comments
|
|
303
|
+
if (!trimmed || trimmed.startsWith('#')) continue;
|
|
304
|
+
|
|
305
|
+
// Section header: [key_name]
|
|
306
|
+
const sectionMatch = trimmed.match(/^\[([^\]]+)\]$/);
|
|
307
|
+
if (sectionMatch) {
|
|
308
|
+
currentSection = sectionMatch[1].trim();
|
|
309
|
+
if (!sections[currentSection]) sections[currentSection] = {};
|
|
310
|
+
continue;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// Key-value pair within a section
|
|
314
|
+
if (currentSection) {
|
|
315
|
+
const kv = parseTOMLKeyValue(trimmed);
|
|
316
|
+
if (kv) {
|
|
317
|
+
// Duplicate key within the same section silently last-wins, which can
|
|
318
|
+
// mask a copy-paste mistake. Warn so the loss is visible.
|
|
319
|
+
if (Object.prototype.hasOwnProperty.call(sections[currentSection], kv.key)) {
|
|
320
|
+
console.warn(
|
|
321
|
+
` [WARN] Duplicate TOML key "${currentSection}.${kv.key}" — ` +
|
|
322
|
+
`later value wins, earlier value discarded.`
|
|
323
|
+
);
|
|
324
|
+
}
|
|
325
|
+
sections[currentSection][kv.key] = kv.value;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
return sections;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Parse a single TOML key = "value" line.
|
|
335
|
+
* Handles double-quoted, single-quoted, and bare values.
|
|
336
|
+
*
|
|
337
|
+
* @param {string} line - Trimmed TOML line
|
|
338
|
+
* @returns {{ key: string, value: string }|null} Parsed key-value pair, or null if not a valid pair
|
|
339
|
+
*/
|
|
340
|
+
function parseTOMLKeyValue(line) {
|
|
341
|
+
const eqIdx = line.indexOf('=');
|
|
342
|
+
if (eqIdx < 0) return null;
|
|
343
|
+
|
|
344
|
+
const key = line.slice(0, eqIdx).trim();
|
|
345
|
+
let value = line.slice(eqIdx + 1).trim();
|
|
346
|
+
|
|
347
|
+
// Double-quoted string
|
|
348
|
+
if (value.startsWith('"') && value.endsWith('"')) {
|
|
349
|
+
value = value.slice(1, -1)
|
|
350
|
+
.replace(/\\"/g, '"')
|
|
351
|
+
.replace(/\\\\/g, '\\')
|
|
352
|
+
.replace(/\\n/g, '\n')
|
|
353
|
+
.replace(/\\t/g, '\t');
|
|
354
|
+
return { key, value };
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// Single-quoted string (literal, no escapes in TOML spec)
|
|
358
|
+
if (value.startsWith("'") && value.endsWith("'")) {
|
|
359
|
+
value = value.slice(1, -1);
|
|
360
|
+
return { key, value };
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// Bare value (shouldn't happen in i18n files, but handle gracefully)
|
|
364
|
+
return { key, value };
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Serialize a flat key→value map to Hugo i18n TOML format.
|
|
369
|
+
*
|
|
370
|
+
* Simple keys write as:
|
|
371
|
+
* [key]
|
|
372
|
+
* other = "value"
|
|
373
|
+
*
|
|
374
|
+
* Plural keys (key.one, key.other) group under one section:
|
|
375
|
+
* [key]
|
|
376
|
+
* one = "singular"
|
|
377
|
+
* other = "plural"
|
|
378
|
+
*
|
|
379
|
+
* @param {object} flat - Flat key→value map
|
|
380
|
+
* @returns {string} TOML content
|
|
381
|
+
*/
|
|
382
|
+
function flatToTOML(flat) {
|
|
383
|
+
const grouped = groupFlatKeys(flat);
|
|
384
|
+
const lines = [];
|
|
385
|
+
|
|
386
|
+
for (const [section, values] of Object.entries(grouped)) {
|
|
387
|
+
lines.push(`[${section}]`);
|
|
388
|
+
for (const [subKey, value] of Object.entries(values)) {
|
|
389
|
+
const escaped = String(value)
|
|
390
|
+
.replace(/\\/g, '\\\\')
|
|
391
|
+
.replace(/"/g, '\\"')
|
|
392
|
+
.replace(/\n/g, '\\n')
|
|
393
|
+
.replace(/\t/g, '\\t');
|
|
394
|
+
lines.push(`${subKey} = "${escaped}"`);
|
|
395
|
+
}
|
|
396
|
+
lines.push('');
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
return lines.join('\n');
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// -----------------------------------------------------------------
|
|
403
|
+
// YAML parser (Hugo i18n subset)
|
|
404
|
+
// -----------------------------------------------------------------
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* Parse Hugo i18n YAML content into a flat key→value map.
|
|
408
|
+
*
|
|
409
|
+
* Handles the standard Hugo format:
|
|
410
|
+
* key:
|
|
411
|
+
* other: "value"
|
|
412
|
+
* items:
|
|
413
|
+
* one: "singular"
|
|
414
|
+
* other: "plural"
|
|
415
|
+
*
|
|
416
|
+
* @param {string} content - Raw YAML file content
|
|
417
|
+
* @returns {object} Flat key→value map
|
|
418
|
+
*/
|
|
419
|
+
function parseYAMLToFlat(content) {
|
|
420
|
+
const tree = parseYAMLNested(content);
|
|
421
|
+
return nestedTreeToFlat(tree);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* Parse YAML into an arbitrarily-deep nested object using an indentation
|
|
426
|
+
* stack — the real fix for silently-dropped middle keys.
|
|
427
|
+
*
|
|
428
|
+
* The previous flat 2-level scanner (parseYAMLSections) could only see a
|
|
429
|
+
* top-level section and its immediate children; a third level like
|
|
430
|
+
* `nav:\n menu:\n home: ...` lost the `menu` container entirely, so the
|
|
431
|
+
* write side (flatToNestedYAML, which DOES support arbitrary depth) round-
|
|
432
|
+
* tripped to a corrupted file. This parser tracks indentation columns on a
|
|
433
|
+
* stack so any depth re-nests correctly.
|
|
434
|
+
*
|
|
435
|
+
* Only the subset Hugo/Docusaurus i18n actually uses is supported: mappings
|
|
436
|
+
* of scalar leaves. Block scalars / sequences are out of scope (and rare in
|
|
437
|
+
* i18n files); a non-mapping line is treated as a scalar leaf.
|
|
438
|
+
*
|
|
439
|
+
* @param {string} content - Raw YAML content
|
|
440
|
+
* @returns {object} Nested object tree
|
|
441
|
+
*/
|
|
442
|
+
function parseYAMLNested(content) {
|
|
443
|
+
const root = {};
|
|
444
|
+
// Stack of { indent, node } frames. The top frame is the mapping that
|
|
445
|
+
// the next more-indented key belongs to.
|
|
446
|
+
const stack = [{ indent: -1, node: root }];
|
|
447
|
+
|
|
448
|
+
for (const rawLine of content.split('\n')) {
|
|
449
|
+
// Strip a trailing \r (Windows line endings) and skip blank/comment lines.
|
|
450
|
+
const line = rawLine.replace(/\r$/, '');
|
|
451
|
+
if (!line.trim() || line.trim().startsWith('#')) continue;
|
|
452
|
+
|
|
453
|
+
const indent = line.length - line.trimStart().length;
|
|
454
|
+
const trimmed = line.trim();
|
|
455
|
+
|
|
456
|
+
// Find the key boundary. We only handle `key:` and `key: value` here.
|
|
457
|
+
const colonIdx = trimmed.indexOf(':');
|
|
458
|
+
if (colonIdx < 0) continue; // not a mapping line — skip (out of subset)
|
|
459
|
+
|
|
460
|
+
const key = trimmed.slice(0, colonIdx).trim();
|
|
461
|
+
const valuePart = trimmed.slice(colonIdx + 1).trim();
|
|
462
|
+
|
|
463
|
+
// Pop frames until the top frame is shallower than this line's indent,
|
|
464
|
+
// so `node` is the correct parent mapping for this key.
|
|
465
|
+
while (stack.length > 1 && indent <= stack[stack.length - 1].indent) {
|
|
466
|
+
stack.pop();
|
|
467
|
+
}
|
|
468
|
+
const parent = stack[stack.length - 1].node;
|
|
469
|
+
|
|
470
|
+
if (valuePart === '') {
|
|
471
|
+
// A container key: its children live on the following, more-indented
|
|
472
|
+
// lines. Create the child mapping and push it as the active frame.
|
|
473
|
+
const child = {};
|
|
474
|
+
parent[key] = child;
|
|
475
|
+
stack.push({ indent, node: child });
|
|
476
|
+
} else {
|
|
477
|
+
// A scalar leaf.
|
|
478
|
+
parent[key] = unquoteYAML(valuePart);
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
return root;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/**
|
|
486
|
+
* Flatten a nested YAML tree into the dotted-key map the rest of the
|
|
487
|
+
* pipeline expects, applying Hugo-aware collapse rules:
|
|
488
|
+
* - A mapping whose ONLY child is `other` collapses to the parent key
|
|
489
|
+
* (Hugo's "simple string" shape: `home:\n other: Home` → home).
|
|
490
|
+
* - Plural/sub-keys and arbitrary nesting flatten to dotted paths.
|
|
491
|
+
*
|
|
492
|
+
* @param {object} tree - Nested object from parseYAMLNested
|
|
493
|
+
* @param {string} [prefix] - Accumulated dotted prefix (internal)
|
|
494
|
+
* @param {object} [out] - Accumulator (internal)
|
|
495
|
+
* @returns {object} Flat key→value map
|
|
496
|
+
*/
|
|
497
|
+
function nestedTreeToFlat(tree, prefix = '', out = {}) {
|
|
498
|
+
const keys = Object.keys(tree);
|
|
499
|
+
|
|
500
|
+
for (const key of keys) {
|
|
501
|
+
const value = tree[key];
|
|
502
|
+
const dotted = prefix ? `${prefix}.${key}` : key;
|
|
503
|
+
|
|
504
|
+
if (value !== null && typeof value === 'object') {
|
|
505
|
+
const childKeys = Object.keys(value);
|
|
506
|
+
// Hugo simple-string collapse: a lone `other:` child becomes the
|
|
507
|
+
// parent key itself (only at a Hugo plural section, i.e. leaf mapping).
|
|
508
|
+
if (childKeys.length === 1 && childKeys[0] === 'other' &&
|
|
509
|
+
typeof value.other !== 'object') {
|
|
510
|
+
out[dotted] = value.other;
|
|
511
|
+
} else {
|
|
512
|
+
nestedTreeToFlat(value, dotted, out);
|
|
513
|
+
}
|
|
514
|
+
} else {
|
|
515
|
+
out[dotted] = value;
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
return out;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/**
|
|
523
|
+
* Parse YAML into an intermediate section map.
|
|
524
|
+
*
|
|
525
|
+
* @param {string} content - Raw YAML content
|
|
526
|
+
* @returns {Object<string, Object<string, string>>} Section map: { sectionName: { subKey: value } }
|
|
527
|
+
*/
|
|
528
|
+
function parseYAMLSections(content) {
|
|
529
|
+
const sections = {};
|
|
530
|
+
let currentSection = null;
|
|
531
|
+
|
|
532
|
+
for (const line of content.split('\n')) {
|
|
533
|
+
// Skip empty lines and comments
|
|
534
|
+
if (!line.trim() || line.trim().startsWith('#')) continue;
|
|
535
|
+
|
|
536
|
+
// Top-level key (no leading whitespace)
|
|
537
|
+
if (!line.startsWith(' ') && !line.startsWith('\t')) {
|
|
538
|
+
// Section with sub-keys: "key:" (nothing after colon, or only whitespace)
|
|
539
|
+
const sectionMatch = line.match(/^([^\s:]+):\s*$/);
|
|
540
|
+
if (sectionMatch) {
|
|
541
|
+
currentSection = sectionMatch[1];
|
|
542
|
+
sections[currentSection] = {};
|
|
543
|
+
continue;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
// Flat key-value: "key: value"
|
|
547
|
+
const kvMatch = line.match(/^([^\s:]+):\s+(.+)$/);
|
|
548
|
+
if (kvMatch) {
|
|
549
|
+
const key = kvMatch[1];
|
|
550
|
+
const value = unquoteYAML(kvMatch[2]);
|
|
551
|
+
sections[key] = { other: value };
|
|
552
|
+
currentSection = null;
|
|
553
|
+
continue;
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
// Indented sub-key within a section
|
|
558
|
+
if (currentSection && (line.startsWith(' ') || line.startsWith('\t'))) {
|
|
559
|
+
const match = line.trim().match(/^([^\s:]+):\s+(.+)$/);
|
|
560
|
+
if (match) {
|
|
561
|
+
sections[currentSection][match[1]] = unquoteYAML(match[2]);
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
return sections;
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
/**
|
|
570
|
+
* Remove surrounding quotes from a YAML value.
|
|
571
|
+
*
|
|
572
|
+
* @param {string} value - Raw YAML value string
|
|
573
|
+
* @returns {string} Unquoted value
|
|
574
|
+
*/
|
|
575
|
+
function unquoteYAML(value) {
|
|
576
|
+
const trimmed = value.trim();
|
|
577
|
+
if (
|
|
578
|
+
(trimmed.startsWith('"') && trimmed.endsWith('"')) ||
|
|
579
|
+
(trimmed.startsWith("'") && trimmed.endsWith("'"))
|
|
580
|
+
) {
|
|
581
|
+
return trimmed.slice(1, -1);
|
|
582
|
+
}
|
|
583
|
+
return trimmed;
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
/**
|
|
587
|
+
* Serialize a flat key→value map to Hugo i18n YAML format.
|
|
588
|
+
*
|
|
589
|
+
* @param {object} flat - Flat key→value map
|
|
590
|
+
* @returns {string} YAML content
|
|
591
|
+
*/
|
|
592
|
+
function flatToYAML(flat) {
|
|
593
|
+
const grouped = groupFlatKeys(flat);
|
|
594
|
+
const lines = [];
|
|
595
|
+
|
|
596
|
+
for (const [section, values] of Object.entries(grouped)) {
|
|
597
|
+
lines.push(`${section}:`);
|
|
598
|
+
for (const [subKey, value] of Object.entries(values)) {
|
|
599
|
+
lines.push(` ${subKey}: ${quoteYAMLValue(String(value))}`);
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
return lines.join('\n') + '\n';
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
/**
|
|
607
|
+
* Serialize a flat key→value map to standard nested YAML.
|
|
608
|
+
*
|
|
609
|
+
* Re-nests dot-separated keys into a tree structure:
|
|
610
|
+
* { "nav.home": "Home", "nav.about": "About" }
|
|
611
|
+
* Becomes:
|
|
612
|
+
* nav:
|
|
613
|
+
* home: Home
|
|
614
|
+
* about: About
|
|
615
|
+
*
|
|
616
|
+
* Does NOT add Hugo-specific "other:" wrapping.
|
|
617
|
+
* Handles arbitrarily deep nesting (a.b.c.d → 4 levels).
|
|
618
|
+
*
|
|
619
|
+
* @param {object} flat - Flat key→value map
|
|
620
|
+
* @returns {string} YAML content
|
|
621
|
+
*/
|
|
622
|
+
function flatToNestedYAML(flat) {
|
|
623
|
+
// Build tree from flat keys
|
|
624
|
+
const tree = {};
|
|
625
|
+
for (const [key, value] of Object.entries(flat)) {
|
|
626
|
+
const parts = key.split('.');
|
|
627
|
+
let current = tree;
|
|
628
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
629
|
+
if (!(parts[i] in current) || typeof current[parts[i]] !== 'object') {
|
|
630
|
+
current[parts[i]] = {};
|
|
631
|
+
}
|
|
632
|
+
current = current[parts[i]];
|
|
633
|
+
}
|
|
634
|
+
current[parts[parts.length - 1]] = value;
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
// Recursively serialize the tree to YAML with proper indentation
|
|
638
|
+
return serializeNestedYAML(tree, 0);
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
/**
|
|
642
|
+
* Recursively serialize a nested object tree to YAML.
|
|
643
|
+
*
|
|
644
|
+
* @param {object} obj - Nested object
|
|
645
|
+
* @param {number} indent - Current indentation level (in spaces)
|
|
646
|
+
* @returns {string} YAML content
|
|
647
|
+
*/
|
|
648
|
+
function serializeNestedYAML(obj, indent) {
|
|
649
|
+
const lines = [];
|
|
650
|
+
const pad = ' '.repeat(indent);
|
|
651
|
+
|
|
652
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
653
|
+
if (typeof value === 'object' && value !== null) {
|
|
654
|
+
lines.push(`${pad}${key}:`);
|
|
655
|
+
lines.push(serializeNestedYAML(value, indent + 2));
|
|
656
|
+
} else {
|
|
657
|
+
lines.push(`${pad}${key}: ${quoteYAMLValue(String(value))}`);
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
// Only add trailing newline at the top level
|
|
662
|
+
return indent === 0 ? lines.join('\n') + '\n' : lines.join('\n');
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
/**
|
|
666
|
+
* Quote a YAML value if it contains special characters.
|
|
667
|
+
*
|
|
668
|
+
* Shared by flatToYAML (Hugo) and flatToNestedYAML (standard) to ensure
|
|
669
|
+
* consistent quoting behavior across both serializers.
|
|
670
|
+
*
|
|
671
|
+
* @param {string} strValue - Value to potentially quote
|
|
672
|
+
* @returns {string} Quoted or bare value
|
|
673
|
+
*/
|
|
674
|
+
function quoteYAMLValue(strValue) {
|
|
675
|
+
const needsQuotes = strValue.includes(':') || strValue.includes('#') ||
|
|
676
|
+
strValue.includes('{') || strValue.includes('}') ||
|
|
677
|
+
strValue.includes('[') || strValue.includes(']') ||
|
|
678
|
+
strValue.startsWith(' ') || strValue.endsWith(' ') ||
|
|
679
|
+
strValue.includes('"') || strValue.includes("'") ||
|
|
680
|
+
strValue === '' || strValue === 'true' || strValue === 'false' ||
|
|
681
|
+
strValue === 'null' || strValue === 'yes' || strValue === 'no';
|
|
682
|
+
return needsQuotes
|
|
683
|
+
? `"${strValue.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`
|
|
684
|
+
: strValue;
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
/**
|
|
688
|
+
* Detect whether a YAML file uses Hugo i18n format or standard nested format.
|
|
689
|
+
*
|
|
690
|
+
* Hugo i18n YAML uses CLDR plural sub-keys exclusively:
|
|
691
|
+
* home:
|
|
692
|
+
* other: "Home"
|
|
693
|
+
* items:
|
|
694
|
+
* one: "{{ .Count }} item"
|
|
695
|
+
* other: "{{ .Count }} items"
|
|
696
|
+
*
|
|
697
|
+
* Standard nested YAML uses arbitrary sub-keys:
|
|
698
|
+
* nav:
|
|
699
|
+
* home: Home
|
|
700
|
+
* about: About Us
|
|
701
|
+
*
|
|
702
|
+
* Detection: if ALL sub-keys across ALL sections are CLDR plural forms → 'hugo'.
|
|
703
|
+
* If any sub-key is not a plural form → 'nested'.
|
|
704
|
+
* Empty content defaults to 'nested'.
|
|
705
|
+
*
|
|
706
|
+
* @param {string} content - Raw YAML file content
|
|
707
|
+
* @returns {'hugo'|'nested'} Detected YAML style
|
|
708
|
+
*/
|
|
709
|
+
function detectYAMLStyle(content) {
|
|
710
|
+
if (!content || !content.trim()) return 'nested';
|
|
711
|
+
|
|
712
|
+
// Use the full nested parser, not the old 2-level scanner: a deep file like
|
|
713
|
+
// `nav:\n menu:\n home: …` would otherwise leave the parser blind to the
|
|
714
|
+
// `menu` container and misclassify the file as 'hugo', re-serializing it
|
|
715
|
+
// through the wrong writer and corrupting it.
|
|
716
|
+
const tree = parseYAMLNested(content);
|
|
717
|
+
const sectionEntries = Object.entries(tree);
|
|
718
|
+
|
|
719
|
+
if (sectionEntries.length === 0) return 'nested';
|
|
720
|
+
|
|
721
|
+
// Hugo i18n is exactly one level deep, and every section's sub-keys are
|
|
722
|
+
// CLDR plural forms. Any non-plural sub-key, or any deeper nesting, means
|
|
723
|
+
// standard nested YAML.
|
|
724
|
+
for (const [, values] of sectionEntries) {
|
|
725
|
+
if (values === null || typeof values !== 'object') continue;
|
|
726
|
+
for (const [subKey, subVal] of Object.entries(values)) {
|
|
727
|
+
if (!PLURAL_FORMS.has(subKey)) return 'nested';
|
|
728
|
+
// A plural-named key that itself nests another mapping is not Hugo.
|
|
729
|
+
if (subVal !== null && typeof subVal === 'object') return 'nested';
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
return 'hugo';
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
// -----------------------------------------------------------------
|
|
737
|
+
// Shared utilities
|
|
738
|
+
// -----------------------------------------------------------------
|
|
739
|
+
|
|
740
|
+
/**
|
|
741
|
+
* Convert an intermediate section map to a flat key→value map.
|
|
742
|
+
*
|
|
743
|
+
* If a section has only 'other', flatten to { section: value }.
|
|
744
|
+
* If a section has plural forms, flatten to { section.one: ..., section.other: ... }.
|
|
745
|
+
*
|
|
746
|
+
* @param {object} sections - { sectionName: { subKey: value } }
|
|
747
|
+
* @returns {object} Flat key→value map
|
|
748
|
+
*/
|
|
749
|
+
function sectionsToFlat(sections) {
|
|
750
|
+
const flat = {};
|
|
751
|
+
for (const [section, values] of Object.entries(sections)) {
|
|
752
|
+
const subKeys = Object.keys(values);
|
|
753
|
+
if (subKeys.length === 1 && subKeys[0] === 'other') {
|
|
754
|
+
// Simple string — just use the section name as the flat key
|
|
755
|
+
flat[section] = values.other;
|
|
756
|
+
} else {
|
|
757
|
+
// Plural forms or multiple sub-keys — preserve the structure
|
|
758
|
+
for (const [subKey, value] of Object.entries(values)) {
|
|
759
|
+
flat[`${section}.${subKey}`] = value;
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
return flat;
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
/**
|
|
767
|
+
* Group flat keys back into section → { subKey: value } structure
|
|
768
|
+
* for TOML/YAML serialization.
|
|
769
|
+
*
|
|
770
|
+
* Keys without plural suffixes get wrapped as { other: value }.
|
|
771
|
+
* Keys ending in a CLDR plural form (one, other, few, etc.)
|
|
772
|
+
* are grouped under their parent section.
|
|
773
|
+
*
|
|
774
|
+
* @param {object} flat - Flat key→value map
|
|
775
|
+
* @returns {object} Grouped sections: { section: { subKey: value } }
|
|
776
|
+
*/
|
|
777
|
+
function groupFlatKeys(flat) {
|
|
778
|
+
const sections = {};
|
|
779
|
+
|
|
780
|
+
for (const [key, value] of Object.entries(flat)) {
|
|
781
|
+
const lastDot = key.lastIndexOf('.');
|
|
782
|
+
const possibleSection = lastDot > 0 ? key.substring(0, lastDot) : null;
|
|
783
|
+
const possibleSubKey = lastDot > 0 ? key.substring(lastDot + 1) : null;
|
|
784
|
+
|
|
785
|
+
if (possibleSubKey && PLURAL_FORMS.has(possibleSubKey)) {
|
|
786
|
+
// This is a plural form — group under the parent section
|
|
787
|
+
if (!sections[possibleSection]) sections[possibleSection] = {};
|
|
788
|
+
sections[possibleSection][possibleSubKey] = value;
|
|
789
|
+
} else {
|
|
790
|
+
// Simple string — wrap as { other: value }
|
|
791
|
+
if (!sections[key]) sections[key] = {};
|
|
792
|
+
sections[key].other = value;
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
return sections;
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
// -----------------------------------------------------------------
|
|
800
|
+
// Docusaurus format helpers
|
|
801
|
+
//
|
|
802
|
+
// Docusaurus i18n JSON files use a {message, description} wrapper:
|
|
803
|
+
//
|
|
804
|
+
// {
|
|
805
|
+
// "theme.blog.title": {
|
|
806
|
+
// "message": "Blog",
|
|
807
|
+
// "description": "The title of the blog page"
|
|
808
|
+
// }
|
|
809
|
+
// }
|
|
810
|
+
//
|
|
811
|
+
// These helpers convert between this format and the flat key→value
|
|
812
|
+
// strings that champollion's diff/translate pipeline expects.
|
|
813
|
+
//
|
|
814
|
+
// WHY separate functions (not modifying readLocaleFile/writeLocaleFile):
|
|
815
|
+
// The Docusaurus format is structurally different from nested JSON,
|
|
816
|
+
// TOML, or YAML. Routing it through readLocaleFile would require
|
|
817
|
+
// format-aware branching inside that function, which would risk
|
|
818
|
+
// breaking the existing JSON path. Keeping these as standalone
|
|
819
|
+
// helpers that the Docusaurus sync path calls directly is safer.
|
|
820
|
+
// -----------------------------------------------------------------
|
|
821
|
+
|
|
822
|
+
/**
|
|
823
|
+
* Check if a parsed JSON object uses Docusaurus's {message, description} format.
|
|
824
|
+
*
|
|
825
|
+
* Detection heuristic: sample the first few values and check if they
|
|
826
|
+
* are objects with a 'message' string field. Docusaurus code.json has
|
|
827
|
+
* 90+ keys all in this format, so even a small sample is definitive.
|
|
828
|
+
*
|
|
829
|
+
* @param {object} data - Parsed JSON object
|
|
830
|
+
* @returns {boolean} True if the data uses Docusaurus message format
|
|
831
|
+
*/
|
|
832
|
+
function isDocusaurusJSON(data) {
|
|
833
|
+
if (!data || typeof data !== 'object') return false;
|
|
834
|
+
|
|
835
|
+
const values = Object.values(data);
|
|
836
|
+
if (values.length === 0) return false;
|
|
837
|
+
|
|
838
|
+
// Sample up to 5 values — if all are {message: string} objects,
|
|
839
|
+
// this is Docusaurus format. A single flat-string value disqualifies.
|
|
840
|
+
const sample = values.slice(0, 5);
|
|
841
|
+
return sample.every(
|
|
842
|
+
val => typeof val === 'object' && val !== null && typeof val.message === 'string'
|
|
843
|
+
);
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
/**
|
|
847
|
+
* Extract translatable message strings from a Docusaurus JSON file.
|
|
848
|
+
*
|
|
849
|
+
* Converts:
|
|
850
|
+
* { "key": { "message": "Hello", "description": "..." } }
|
|
851
|
+
* To:
|
|
852
|
+
* { "key": "Hello" }
|
|
853
|
+
*
|
|
854
|
+
* Keys whose values are plain strings (not wrapped in {message}) are
|
|
855
|
+
* passed through as-is, for forward compatibility with any Docusaurus
|
|
856
|
+
* files that mix formats.
|
|
857
|
+
*
|
|
858
|
+
* @param {object} data - Parsed Docusaurus JSON
|
|
859
|
+
* @returns {object} Flat key→message map
|
|
860
|
+
*/
|
|
861
|
+
function extractDocusaurusMessages(data) {
|
|
862
|
+
const flat = {};
|
|
863
|
+
for (const [key, val] of Object.entries(data)) {
|
|
864
|
+
if (typeof val === 'object' && val !== null && 'message' in val) {
|
|
865
|
+
flat[key] = val.message;
|
|
866
|
+
} else if (typeof val === 'string') {
|
|
867
|
+
flat[key] = val;
|
|
868
|
+
}
|
|
869
|
+
// Skip non-string, non-object values (shouldn't exist in Docusaurus files)
|
|
870
|
+
}
|
|
871
|
+
return flat;
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
/**
|
|
875
|
+
* Extract description context from Docusaurus {message, description} JSON.
|
|
876
|
+
*
|
|
877
|
+
* Returns a flat key→description map for entries that have a non-empty
|
|
878
|
+
* description field. Keys without descriptions are omitted (not set to null).
|
|
879
|
+
*
|
|
880
|
+
* WHY: Docusaurus includes developer-written descriptions like
|
|
881
|
+
* "The title of the blog page" or "Button to submit a form"
|
|
882
|
+
* that explain the string's UI context. Feeding these to the LLM
|
|
883
|
+
* alongside the source text improves translation accuracy — the model
|
|
884
|
+
* knows whether "Post" means "submit" or "blog post".
|
|
885
|
+
*
|
|
886
|
+
* @param {object} data - Parsed Docusaurus JSON
|
|
887
|
+
* @returns {object} Flat key→description map (only keys with descriptions)
|
|
888
|
+
*/
|
|
889
|
+
function extractDocusaurusDescriptions(data) {
|
|
890
|
+
const descriptions = {};
|
|
891
|
+
for (const [key, val] of Object.entries(data)) {
|
|
892
|
+
if (
|
|
893
|
+
typeof val === 'object' &&
|
|
894
|
+
val !== null &&
|
|
895
|
+
typeof val.description === 'string' &&
|
|
896
|
+
val.description.trim().length > 0
|
|
897
|
+
) {
|
|
898
|
+
descriptions[key] = val.description;
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
return descriptions;
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
/**
|
|
905
|
+
* Inject translated messages back into the Docusaurus {message, description} format.
|
|
906
|
+
*
|
|
907
|
+
* Preserves the original 'description' field (and any other metadata Docusaurus
|
|
908
|
+
* might add in future versions) for each key. Only the 'message' field is replaced.
|
|
909
|
+
*
|
|
910
|
+
* Keys in translatedFlat that don't exist in sourceData are ignored (defense).
|
|
911
|
+
*
|
|
912
|
+
* @param {object} sourceData - Original parsed Docusaurus JSON (with descriptions)
|
|
913
|
+
* @param {object} translatedFlat - Flat key→translated message map
|
|
914
|
+
* @returns {object} Docusaurus JSON with translated messages, descriptions preserved
|
|
915
|
+
*/
|
|
916
|
+
function injectDocusaurusMessages(sourceData, translatedFlat) {
|
|
917
|
+
const result = {};
|
|
918
|
+
for (const [key, val] of Object.entries(sourceData)) {
|
|
919
|
+
if (typeof val === 'object' && val !== null && 'message' in val) {
|
|
920
|
+
result[key] = {
|
|
921
|
+
...val, // preserve description and any other metadata
|
|
922
|
+
message: key in translatedFlat ? translatedFlat[key] : val.message,
|
|
923
|
+
};
|
|
924
|
+
} else if (typeof val === 'string') {
|
|
925
|
+
result[key] = key in translatedFlat ? translatedFlat[key] : val;
|
|
926
|
+
} else {
|
|
927
|
+
result[key] = val;
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
return result;
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
export {
|
|
934
|
+
detectFormat,
|
|
935
|
+
detectFormatFromDir,
|
|
936
|
+
getExtension,
|
|
937
|
+
readLocaleFile,
|
|
938
|
+
writeLocaleFile,
|
|
939
|
+
parseTOMLToFlat,
|
|
940
|
+
parseYAMLToFlat,
|
|
941
|
+
flatToTOML,
|
|
942
|
+
flatToYAML,
|
|
943
|
+
flatToNestedYAML,
|
|
944
|
+
detectYAMLStyle,
|
|
945
|
+
quoteYAMLValue,
|
|
946
|
+
sectionsToFlat,
|
|
947
|
+
groupFlatKeys,
|
|
948
|
+
PLURAL_FORMS,
|
|
949
|
+
isDocusaurusJSON,
|
|
950
|
+
extractDocusaurusMessages,
|
|
951
|
+
extractDocusaurusDescriptions,
|
|
952
|
+
injectDocusaurusMessages,
|
|
953
|
+
};
|
|
954
|
+
|