dsh-cait 1.1.6 → 1.1.7

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.
@@ -0,0 +1,1098 @@
1
+ /**
2
+ * Key/value localization formats: Apple .strings/.stringsdict, Android
3
+ * strings.xml, .resx, ARB, flat YAML and Java .properties.
4
+ *
5
+ * The parser keeps an AST in formatMeta.localizationTemplate and puts the
6
+ * stable key on every segment. Export walks the AST again so comments, key
7
+ * order and plural quantities survive a round trip. Inline XML markup is
8
+ * tokenised into {n} placeholders and restored from segment.meta.inline.
9
+ */
10
+ import { CatError } from './errors.js'
11
+ import { decodeXml, escapeXml } from './xml.js'
12
+
13
+ const KINDS = new Set([
14
+ 'apple-strings',
15
+ 'apple-stringsdict',
16
+ 'android',
17
+ 'resx',
18
+ 'arb',
19
+ 'yaml',
20
+ 'properties',
21
+ ])
22
+
23
+ function formatError(code, message) {
24
+ return new CatError(code, message)
25
+ }
26
+
27
+ function normalizeKind(kind) {
28
+ const value = String(kind ?? '').trim().toLowerCase()
29
+ if (!KINDS.has(value)) throw formatError('LOCALIZATION_KIND_UNSUPPORTED', `不支持的本地化格式: ${kind}`)
30
+ return value
31
+ }
32
+
33
+ function cdata(value) {
34
+ return String(value ?? '').replace(/<!\[CDATA\[([\s\S]*?)\]\]>/g, '$1')
35
+ }
36
+
37
+ const INLINE_TAG = /<\/?(?:b|i|u|em|strong|font|a|br|span|xliff:g)\b[^>]*\/?>/gi
38
+
39
+ function extractInline(raw) {
40
+ const tokens = []
41
+ const text = String(raw ?? '').replace(INLINE_TAG, (xml) => {
42
+ const token = `{${tokens.length + 1}}`
43
+ tokens.push({ token, xml })
44
+ return token
45
+ })
46
+ return { text: decodeXml(cdata(text)).trim(), tokens }
47
+ }
48
+
49
+ function renderInline(value, tokens) {
50
+ const text = String(value ?? '')
51
+ if (!Array.isArray(tokens) || !tokens.length) return escapeXml(cdata(text))
52
+ const map = new Map(tokens.map((item) => [item.token, item.xml]))
53
+ let output = ''
54
+ let last = 0
55
+ for (const match of text.matchAll(/\{(\d+)\}/g)) {
56
+ output += escapeXml(text.slice(last, match.index))
57
+ output += map.get(match[0]) ?? escapeXml(match[0])
58
+ last = match.index + match[0].length
59
+ }
60
+ output += escapeXml(text.slice(last))
61
+ return output
62
+ }
63
+
64
+ function pushSegment(segments, kind, key, value, extra = {}, comment = '') {
65
+ const parsed = extractInline(value)
66
+ if (!parsed.text) return
67
+ const meta = {
68
+ format: kind,
69
+ identifier: key,
70
+ keyPath: extra.keyPath || key,
71
+ ...extra,
72
+ }
73
+ if (parsed.tokens.length) meta.inline = parsed.tokens
74
+ if (comment) meta.comments = [String(comment).slice(0, 500)]
75
+ segments.push({
76
+ source: parsed.text,
77
+ target: '',
78
+ paragraph: segments.length,
79
+ meta,
80
+ })
81
+ }
82
+
83
+ function decodeEscape(value, kind) {
84
+ if (kind === 'properties') {
85
+ return String(value ?? '').replace(/\\(u[0-9a-fA-F]{4}|.)/g, (all, body) => {
86
+ if (body[0] === 'u') return String.fromCharCode(Number.parseInt(body.slice(1), 16))
87
+ if (body === 'n') return '\n'
88
+ if (body === 't') return '\t'
89
+ if (body === 'r') return '\r'
90
+ return body
91
+ })
92
+ }
93
+ return String(value ?? '')
94
+ }
95
+
96
+ function escapeProperties(value, isKey = false) {
97
+ const escaped = String(value ?? '')
98
+ .replace(/\\/g, '\\\\')
99
+ .replace(/\n/g, '\\n')
100
+ .replace(/\t/g, '\\t')
101
+ .replace(/\r/g, '\\r')
102
+ return isKey ? escaped.replace(/([=:])/g, '\\$1') : escaped
103
+ }
104
+
105
+ function escapeApple(value) {
106
+ return String(value ?? '')
107
+ .replace(/\\/g, '\\\\')
108
+ .replace(/"/g, '\\"')
109
+ .replace(/\n/g, '\\n')
110
+ .replace(/\t/g, '\\t')
111
+ .replace(/\r/g, '\\r')
112
+ }
113
+
114
+ function decodeApple(value) {
115
+ return String(value ?? '').replace(/\\(u[0-9a-fA-F]{4}|U[0-9a-fA-F]{4}|.)/g, (all, body) => {
116
+ if (body[0] === 'u' || body[0] === 'U') return String.fromCharCode(Number.parseInt(body.slice(1), 16))
117
+ if (body === 'n') return '\n'
118
+ if (body === 't') return '\t'
119
+ if (body === 'r') return '\r'
120
+ return body
121
+ })
122
+ }
123
+
124
+ function parseAppleStrings(text, kind = 'apple-strings') {
125
+ const input = String(text ?? '').replace(/^\uFEFF/, '')
126
+ if (!input.trim()) throw formatError('LOCALIZATION_EMPTY', '文件内容为空。')
127
+ const entries = []
128
+ const segments = []
129
+ let index = 0
130
+ let pendingComment = ''
131
+
132
+ const skipSpace = () => {
133
+ while (index < input.length && /\s/.test(input[index])) index += 1
134
+ }
135
+ const readQuoted = () => {
136
+ const quote = input[index]
137
+ index += 1
138
+ let value = ''
139
+ while (index < input.length) {
140
+ const char = input[index]
141
+ if (char === '\\') {
142
+ value += char
143
+ index += 1
144
+ if (index < input.length) value += input[index]
145
+ index += 1
146
+ continue
147
+ }
148
+ if (char === quote) {
149
+ index += 1
150
+ return value
151
+ }
152
+ value += char
153
+ index += 1
154
+ }
155
+ throw formatError('LOCALIZATION_PARSE', '字符串没有闭合引号。')
156
+ }
157
+
158
+ while (index < input.length) {
159
+ skipSpace()
160
+ if (index >= input.length) break
161
+ if (input.startsWith('/*', index)) {
162
+ const end = input.indexOf('*/', index + 2)
163
+ if (end < 0) break
164
+ pendingComment = input.slice(index + 2, end).trim()
165
+ index = end + 2
166
+ continue
167
+ }
168
+ if (input.startsWith('//', index)) {
169
+ const end = input.indexOf('\n', index + 2)
170
+ pendingComment = input.slice(index + 2, end < 0 ? input.length : end).trim()
171
+ index = end < 0 ? input.length : end + 1
172
+ continue
173
+ }
174
+ let key
175
+ if (input[index] === '"') key = decodeApple(readQuoted())
176
+ else {
177
+ const start = index
178
+ while (index < input.length && input[index] !== '=' && !/\s/.test(input[index])) index += 1
179
+ key = input.slice(start, index).trim()
180
+ }
181
+ skipSpace()
182
+ if (input[index] !== '=') {
183
+ index += 1
184
+ continue
185
+ }
186
+ index += 1
187
+ skipSpace()
188
+ let value = ''
189
+ if (input[index] === '"') value = decodeApple(readQuoted())
190
+ else {
191
+ const start = index
192
+ while (index < input.length && input[index] !== ';') index += 1
193
+ value = input.slice(start, index).trim()
194
+ }
195
+ skipSpace()
196
+ if (input[index] === ';') index += 1
197
+ if (!key) continue
198
+ entries.push({ type: 'string', key, value, comment: pendingComment })
199
+ pushSegment(segments, kind, key, value, {}, pendingComment)
200
+ pendingComment = ''
201
+ }
202
+ if (!segments.length) throw formatError('LOCALIZATION_NO_UNITS', '文件中没有可翻译的字符串。')
203
+ return {
204
+ format: kind,
205
+ segments,
206
+ formatMeta: { localizationTemplate: JSON.stringify({ kind, entries }) },
207
+ warnings: [],
208
+ }
209
+ }
210
+
211
+ function readAttr(source, name) {
212
+ const match = String(source ?? '').match(new RegExp(`\\b${name}\\s*=\\s*["']([^"']*)["']`, 'i'))
213
+ return match ? decodeXml(match[1]) : ''
214
+ }
215
+
216
+ function parseAndroid(text) {
217
+ const input = String(text ?? '').replace(/^\uFEFF/, '')
218
+ if (!/<resources\b/i.test(input)) throw formatError('LOCALIZATION_PARSE', 'Android 字符串缺少 <resources> 根节点。')
219
+ const entries = []
220
+ const segments = []
221
+ let pendingComment = ''
222
+ const token = /<!--([\s\S]*?)-->|<\s*(string|plurals)\b([^>]*)>([\s\S]*?)<\/\s*\2\s*>/gi
223
+ let match
224
+ while ((match = token.exec(input))) {
225
+ if (match[1] != null) {
226
+ pendingComment = match[1].trim()
227
+ continue
228
+ }
229
+ const type = match[2].toLowerCase()
230
+ const attrs = match[3]
231
+ const body = match[4]
232
+ const key = readAttr(attrs, 'name')
233
+ if (!key) continue
234
+ const translatable = !/\btranslatable\s*=\s*["']false["']/i.test(attrs)
235
+ if (type === 'string') {
236
+ const parsed = extractInline(body)
237
+ entries.push({
238
+ type: 'string',
239
+ key,
240
+ value: body,
241
+ comment: pendingComment,
242
+ translatable,
243
+ })
244
+ if (translatable) pushSegment(segments, 'android', key, body, {}, pendingComment)
245
+ } else {
246
+ const items = []
247
+ const itemPattern = /<\s*item\b([^>]*)>([\s\S]*?)<\/\s*item\s*>/gi
248
+ let item
249
+ while ((item = itemPattern.exec(body))) {
250
+ const quantity = readAttr(item[1], 'quantity') || 'other'
251
+ items.push({ quantity, value: item[2] })
252
+ if (translatable) {
253
+ pushSegment(segments, 'android', key, item[2], {
254
+ keyPath: `${key}#${quantity}`,
255
+ context: quantity,
256
+ plural: true,
257
+ msgidPlural: key,
258
+ icuType: 'quantity',
259
+ }, pendingComment)
260
+ }
261
+ }
262
+ entries.push({ type: 'plural', key, items, comment: pendingComment, translatable })
263
+ }
264
+ pendingComment = ''
265
+ }
266
+ if (!segments.length) throw formatError('LOCALIZATION_NO_UNITS', 'Android 字符串中没有可翻译的文本。')
267
+ return {
268
+ format: 'android',
269
+ segments,
270
+ formatMeta: { localizationTemplate: JSON.stringify({ kind: 'android', entries }) },
271
+ warnings: [],
272
+ }
273
+ }
274
+
275
+ function parseResx(text) {
276
+ const input = String(text ?? '').replace(/^\uFEFF/, '')
277
+ const entries = []
278
+ const segments = []
279
+ const pattern = /<!--([\s\S]*?)-->|<data\b([^>]*)>([\s\S]*?)<\/data>/gi
280
+ let pendingComment = ''
281
+ let match
282
+ while ((match = pattern.exec(input))) {
283
+ if (match[1] != null) {
284
+ pendingComment = match[1].trim()
285
+ continue
286
+ }
287
+ const attrs = match[2]
288
+ const body = match[3]
289
+ const key = readAttr(attrs, 'name')
290
+ if (!key) continue
291
+ if (/\btype\s*=/.test(attrs)) continue
292
+ const valueMatch = body.match(/<value>([\s\S]*?)<\/value>/i)
293
+ if (!valueMatch) continue
294
+ const commentMatch = body.match(/<comment>([\s\S]*?)<\/comment>/i)
295
+ const comment = commentMatch ? decodeXml(cdata(commentMatch[1])).trim() : pendingComment
296
+ entries.push({ type: 'string', key, value: valueMatch[1], comment })
297
+ pushSegment(segments, 'resx', key, valueMatch[1], {}, comment)
298
+ pendingComment = ''
299
+ }
300
+ if (!segments.length) throw formatError('LOCALIZATION_NO_UNITS', '.resx 中没有可翻译的字符串。')
301
+ return {
302
+ format: 'resx',
303
+ segments,
304
+ formatMeta: { localizationTemplate: JSON.stringify({ kind: 'resx', entries }) },
305
+ warnings: [],
306
+ }
307
+ }
308
+
309
+ function parseArb(text) {
310
+ const input = String(text ?? '').replace(/^\uFEFF/, '')
311
+ let root
312
+ try {
313
+ root = JSON.parse(input)
314
+ } catch (error) {
315
+ throw formatError('LOCALIZATION_PARSE', `ARB 解析失败: ${error.message}`)
316
+ }
317
+ if (!root || typeof root !== 'object' || Array.isArray(root)) {
318
+ throw formatError('LOCALIZATION_PARSE', 'ARB 根节点必须是对象。')
319
+ }
320
+ const segments = []
321
+ const warnings = []
322
+ for (const [key, value] of Object.entries(root)) {
323
+ if (key.startsWith('@')) continue
324
+ if (typeof value !== 'string') {
325
+ warnings.push(`ARB 键 ${key} 不是字符串,已跳过。`)
326
+ continue
327
+ }
328
+ pushSegment(segments, 'arb', key, value)
329
+ }
330
+ if (!segments.length) throw formatError('LOCALIZATION_NO_UNITS', 'ARB 中没有可翻译的字符串。')
331
+ return {
332
+ format: 'arb',
333
+ segments,
334
+ formatMeta: { localizationTemplate: JSON.stringify({ kind: 'arb', data: root }) },
335
+ warnings,
336
+ }
337
+ }
338
+
339
+ function stripYamlQuote(value) {
340
+ const text = String(value ?? '').trim()
341
+ if (text.length >= 2 && text[0] === '"' && text[text.length - 1] === '"') {
342
+ try {
343
+ return JSON.parse(text)
344
+ } catch {
345
+ return text.slice(1, -1)
346
+ }
347
+ }
348
+ if (text.length >= 2 && text[0] === "'" && text[text.length - 1] === "'") {
349
+ return text.slice(1, -1).replace(/''/g, "'")
350
+ }
351
+ return text
352
+ }
353
+
354
+ function yamlValue(value) {
355
+ const text = String(value ?? '')
356
+ if (!text || /^[\s:#,[\]{}&*!|>'"%@`]/.test(text) || /:\s|#\s|\n/.test(text)) {
357
+ return JSON.stringify(text)
358
+ }
359
+ return text
360
+ }
361
+
362
+ function yamlScalarText(raw) {
363
+ let text = String(raw ?? '').trim()
364
+ if (!text) return ''
365
+ let quote = ''
366
+ let escaped = false
367
+ let cut = -1
368
+ for (let index = 0; index < text.length; index += 1) {
369
+ const char = text[index]
370
+ if (escaped) {
371
+ escaped = false
372
+ continue
373
+ }
374
+ if (char === '\\' && quote === '"') {
375
+ escaped = true
376
+ continue
377
+ }
378
+ if (quote) {
379
+ if (char === quote) quote = ''
380
+ continue
381
+ }
382
+ if (char === '"' || char === "'") {
383
+ quote = char
384
+ continue
385
+ }
386
+ if (char === '#' && (index === 0 || /\s/.test(text[index - 1]))) {
387
+ cut = index
388
+ break
389
+ }
390
+ }
391
+ if (cut >= 0) text = text.slice(0, cut).trim()
392
+ if (text.startsWith('&') || text.startsWith('*') || text === '<<') {
393
+ throw formatError('LOCALIZATION_YAML_UNSUPPORTED', '暂不支持 YAML 锚点、别名或 merge key。')
394
+ }
395
+ if (text.startsWith('{') || text.startsWith('[')) {
396
+ throw formatError('LOCALIZATION_YAML_UNSUPPORTED', '暂不支持 YAML flow 集合,请使用缩进块。')
397
+ }
398
+ return stripYamlQuote(text)
399
+ }
400
+
401
+ function yamlColon(text) {
402
+ let quote = ''
403
+ for (let index = 0; index < text.length; index += 1) {
404
+ const char = text[index]
405
+ if (quote) {
406
+ if (char === quote) quote = ''
407
+ continue
408
+ }
409
+ if (char === '"' || char === "'") {
410
+ quote = char
411
+ continue
412
+ }
413
+ if (char === ':' && (index === text.length - 1 || /\s/.test(text[index + 1]))) {
414
+ return index
415
+ }
416
+ }
417
+ return -1
418
+ }
419
+
420
+ function yamlIndent(line) {
421
+ const spaces = String(line).match(/^ */)?.[0]?.length ?? 0
422
+ if (String(line)[spaces] === '\t') {
423
+ throw formatError('LOCALIZATION_YAML_UNSUPPORTED', 'YAML 缩进不能使用 Tab。')
424
+ }
425
+ return spaces
426
+ }
427
+
428
+ function parseYamlBlock(text) {
429
+ const input = String(text ?? '').replace(/^\uFEFF/, '').replace(/\r\n?/g, '\n')
430
+ if (!input.trim()) throw formatError('LOCALIZATION_EMPTY', '文件内容为空。')
431
+ const lines = input.split('\n')
432
+ let index = 0
433
+ let pendingComments = []
434
+
435
+ const skip = () => {
436
+ while (index < lines.length) {
437
+ const line = lines[index]
438
+ if (!line.trim()) {
439
+ index += 1
440
+ continue
441
+ }
442
+ if (/^\s*#/.test(line)) {
443
+ pendingComments.push(line.replace(/^\s*#\s?/, '').trim())
444
+ index += 1
445
+ continue
446
+ }
447
+ break
448
+ }
449
+ }
450
+
451
+ const takeComment = () => {
452
+ const comment = pendingComments.join('\n').trim()
453
+ pendingComments = []
454
+ return comment
455
+ }
456
+
457
+ const parseScalar = (raw, style = 'plain') => ({
458
+ type: 'scalar',
459
+ value: yamlScalarText(raw),
460
+ style,
461
+ })
462
+
463
+ const parseBlockScalar = (parentIndent, header) => {
464
+ const literal = header.startsWith('|')
465
+ const chomp = header.includes('-') ? 'strip' : header.includes('+') ? 'keep' : 'clip'
466
+ const collected = []
467
+ let minIndent = Infinity
468
+ while (index < lines.length) {
469
+ const line = lines[index]
470
+ if (!line.trim()) {
471
+ collected.push('')
472
+ index += 1
473
+ continue
474
+ }
475
+ const indent = yamlIndent(line)
476
+ if (indent <= parentIndent) break
477
+ minIndent = Math.min(minIndent, indent)
478
+ collected.push(line)
479
+ index += 1
480
+ }
481
+ if (!collected.length) return parseScalar('', literal ? 'literal' : 'folded')
482
+ const dedented = collected.map((line) => (line ? line.slice(minIndent) : '')).join('\n')
483
+ let value = literal ? dedented : dedented.replace(/\n(?!\n)/g, ' ')
484
+ if (chomp === 'strip') value = value.replace(/\n+$/, '')
485
+ else if (chomp === 'clip') value = `${value.replace(/\n+$/, '')}\n`
486
+ return { type: 'scalar', value, style: literal ? 'literal' : 'folded' }
487
+ }
488
+
489
+ const parseNode = (indent) => {
490
+ skip()
491
+ if (index >= lines.length) return parseScalar('')
492
+ const line = lines[index]
493
+ const actual = yamlIndent(line)
494
+ if (actual < indent) return parseScalar('')
495
+ if (actual > indent) {
496
+ throw formatError('LOCALIZATION_YAML_PARSE', `第 ${index + 1} 行缩进不一致。`)
497
+ }
498
+ const content = line.slice(indent)
499
+ if (/^-(\s|$)/.test(content)) return parseSequence(indent)
500
+ if (yamlColon(content) >= 0) return parseMapping(indent)
501
+ index += 1
502
+ return parseScalar(content)
503
+ }
504
+
505
+ const parseMapping = (indent) => {
506
+ const entries = []
507
+ while (index < lines.length) {
508
+ skip()
509
+ if (index >= lines.length) break
510
+ const line = lines[index]
511
+ const actual = yamlIndent(line)
512
+ if (actual < indent) break
513
+ if (actual > indent) {
514
+ throw formatError('LOCALIZATION_YAML_PARSE', `第 ${index + 1} 行缩进不一致。`)
515
+ }
516
+ const content = line.slice(indent)
517
+ if (/^-(\s|$)/.test(content)) break
518
+ const colon = yamlColon(content)
519
+ if (colon < 0) {
520
+ throw formatError('LOCALIZATION_YAML_PARSE', `第 ${index + 1} 行缺少键值分隔符。`)
521
+ }
522
+ const key = stripYamlQuote(content.slice(0, colon).trim())
523
+ const rest = content.slice(colon + 1).trim()
524
+ const comment = takeComment()
525
+ index += 1
526
+ let value
527
+ if (!rest) {
528
+ skip()
529
+ value = index < lines.length && yamlIndent(lines[index]) > indent
530
+ ? parseNode(yamlIndent(lines[index]))
531
+ : parseScalar('')
532
+ } else if (rest.startsWith('|') || rest.startsWith('>')) {
533
+ value = parseBlockScalar(indent, rest)
534
+ } else {
535
+ value = parseScalar(rest)
536
+ }
537
+ entries.push({ key, value, ...(comment ? { comment } : {}) })
538
+ }
539
+ return { type: 'map', entries }
540
+ }
541
+
542
+ const parseSequence = (indent) => {
543
+ const items = []
544
+ while (index < lines.length) {
545
+ skip()
546
+ if (index >= lines.length) break
547
+ const line = lines[index]
548
+ const actual = yamlIndent(line)
549
+ if (actual < indent) break
550
+ if (actual > indent) {
551
+ throw formatError('LOCALIZATION_YAML_PARSE', `第 ${index + 1} 行缩进不一致。`)
552
+ }
553
+ const content = line.slice(indent)
554
+ const match = content.match(/^-(\s|$)([\s\S]*)$/)
555
+ if (!match) break
556
+ const rest = match[2].trim()
557
+ index += 1
558
+ const comment = takeComment()
559
+ let value
560
+ if (!rest) {
561
+ skip()
562
+ value = index < lines.length && yamlIndent(lines[index]) > indent
563
+ ? parseNode(yamlIndent(lines[index]))
564
+ : parseScalar('')
565
+ } else if (yamlColon(rest) >= 0) {
566
+ const colon = yamlColon(rest)
567
+ const key = stripYamlQuote(rest.slice(0, colon).trim())
568
+ const tail = rest.slice(colon + 1).trim()
569
+ const map = {
570
+ type: 'map',
571
+ entries: [{ key, value: tail ? parseScalar(tail) : parseScalar('') }],
572
+ }
573
+ skip()
574
+ if (index < lines.length && yamlIndent(lines[index]) > indent) {
575
+ const child = parseNode(yamlIndent(lines[index]))
576
+ if (child.type === 'map') map.entries.push(...child.entries)
577
+ }
578
+ value = map
579
+ } else {
580
+ value = parseScalar(rest)
581
+ }
582
+ items.push({ value, ...(comment ? { comment } : {}) })
583
+ }
584
+ return { type: 'seq', items }
585
+ }
586
+
587
+ skip()
588
+ if (index >= lines.length) throw formatError('LOCALIZATION_NO_UNITS', 'YAML 中没有可翻译的字符串。')
589
+ const root = parseNode(yamlIndent(lines[index]))
590
+ const trailing = takeComment()
591
+ return { root, ...(trailing ? { comment: trailing } : {}) }
592
+ }
593
+
594
+ function walkYaml(node, path, segments, comment = '') {
595
+ if (!node || typeof node !== 'object') return
596
+ if (node.type === 'map') {
597
+ for (const entry of node.entries || []) {
598
+ const next = path ? `${path}.${entry.key}` : entry.key
599
+ walkYaml(entry.value, next, segments, entry.comment || comment)
600
+ }
601
+ return
602
+ }
603
+ if (node.type === 'seq') {
604
+ for (const [itemIndex, item] of (node.items || []).entries()) {
605
+ walkYaml(item.value, `${path}[${itemIndex}]`, segments, item.comment || comment)
606
+ }
607
+ return
608
+ }
609
+ if (node.type !== 'scalar') return
610
+ const text = String(node.value ?? '').trim()
611
+ if (!text) return
612
+ if (/^(true|false|null|~)$/i.test(text)) return
613
+ if (/^-?\d+(?:\.\d+)?$/.test(text)) return
614
+ pushSegment(segments, 'yaml', path, node.value, { keyPath: path }, comment)
615
+ }
616
+
617
+ function parseYaml(text) {
618
+ const root = parseYamlBlock(text)
619
+ const segments = []
620
+ walkYaml(root.root, '', segments)
621
+ const rootComment = root.comment || ''
622
+ if (!segments.length) throw formatError('LOCALIZATION_NO_UNITS', 'YAML 中没有可翻译的字符串。')
623
+ return {
624
+ format: 'yaml',
625
+ segments,
626
+ formatMeta: {
627
+ localizationTemplate: JSON.stringify({
628
+ kind: 'yaml',
629
+ root: root.root,
630
+ ...(rootComment ? { comment: rootComment } : {}),
631
+ }),
632
+ },
633
+ warnings: [],
634
+ }
635
+ }
636
+
637
+ function yamlOut(value) {
638
+ return yamlValue(String(value ?? ''))
639
+ }
640
+
641
+ function buildYamlNode(node, indent, values, path) {
642
+ const pad = ' '.repeat(indent)
643
+ const lines = []
644
+ if (!node) return lines
645
+ if (node.type === 'scalar') {
646
+ return [`${pad}${yamlOut(values.get(path) ?? node.value)}`]
647
+ }
648
+ if (node.type === 'map') {
649
+ for (const entry of node.entries || []) {
650
+ const next = path ? `${path}.${entry.key}` : entry.key
651
+ if (entry.comment) {
652
+ for (const line of String(entry.comment).split('\n')) lines.push(`${pad}# ${line}`)
653
+ }
654
+ const child = entry.value
655
+ if (child?.type === 'scalar') {
656
+ const value = values.get(next) ?? child.value
657
+ if (child.style === 'literal' || child.style === 'folded') {
658
+ lines.push(`${pad}${entry.key}: ${child.style === 'literal' ? '|' : '>'}`)
659
+ for (const line of String(value).replace(/\n$/, '').split('\n')) {
660
+ lines.push(`${pad} ${line}`)
661
+ }
662
+ } else {
663
+ lines.push(`${pad}${entry.key}: ${yamlOut(value)}`)
664
+ }
665
+ } else {
666
+ lines.push(`${pad}${entry.key}:`)
667
+ lines.push(...buildYamlNode(child, indent + 2, values, next))
668
+ }
669
+ }
670
+ return lines
671
+ }
672
+ if (node.type === 'seq') {
673
+ for (const [itemIndex, item] of (node.items || []).entries()) {
674
+ const next = `${path}[${itemIndex}]`
675
+ if (item.comment) {
676
+ for (const line of String(item.comment).split('\n')) lines.push(`${pad}# ${line}`)
677
+ }
678
+ if (item.value?.type === 'scalar') {
679
+ lines.push(`${pad}- ${yamlOut(values.get(next) ?? item.value.value)}`)
680
+ } else {
681
+ lines.push(`${pad}-`)
682
+ lines.push(...buildYamlNode(item.value, indent + 2, values, next))
683
+ }
684
+ }
685
+ return lines
686
+ }
687
+ return lines
688
+ }
689
+
690
+ function buildYaml(doc, ast) {
691
+ const values = targetLookup(doc)
692
+ const lines = []
693
+ if (ast.comment) {
694
+ for (const line of String(ast.comment).split('\n')) lines.push(`# ${line}`)
695
+ }
696
+ lines.push(...buildYamlNode(ast.root, 0, values, ''))
697
+ return `${lines.join('\n')}\n`
698
+ }
699
+
700
+ function parseKeyValueLines(text, kind) {
701
+ const input = String(text ?? '').replace(/^\uFEFF/, '').replace(/\r\n?/g, '\n')
702
+ const entries = []
703
+ const segments = []
704
+ const warnings = []
705
+ let pendingComment = ''
706
+ const lines = input.split('\n')
707
+ for (let index = 0; index < lines.length; index += 1) {
708
+ const line = lines[index]
709
+ if (!line.trim()) continue
710
+ if (/^\s*[#!]/.test(line)) {
711
+ pendingComment = line.replace(/^\s*[#!]\s?/, '').trim()
712
+ continue
713
+ }
714
+ if (/^\s/.test(line) && kind === 'yaml') {
715
+ throw formatError('LOCALIZATION_YAML_NESTED', '当前只支持扁平 YAML(键值不缩进)。')
716
+ }
717
+ let body = line
718
+ if (kind === 'properties') {
719
+ while (body.endsWith('\\') && index + 1 < lines.length) {
720
+ body = `${body.slice(0, -1)}${lines[index + 1]}`
721
+ index += 1
722
+ }
723
+ }
724
+ let separatorIndex = -1
725
+ let separator = kind === 'yaml' ? ':' : '='
726
+ if (kind === 'yaml') {
727
+ separatorIndex = body.indexOf(':')
728
+ if (separatorIndex < 0) continue
729
+ } else {
730
+ let escaped = false
731
+ for (let cursor = 0; cursor < body.length; cursor += 1) {
732
+ const char = body[cursor]
733
+ if (escaped) {
734
+ escaped = false
735
+ continue
736
+ }
737
+ if (char === '\\') {
738
+ escaped = true
739
+ continue
740
+ }
741
+ if (char === '=' || char === ':') {
742
+ separatorIndex = cursor
743
+ separator = char
744
+ break
745
+ }
746
+ }
747
+ if (separatorIndex < 0) {
748
+ const match = body.match(/^(\S+)\s+(.*)$/)
749
+ if (!match) continue
750
+ separatorIndex = match[1].length
751
+ separator = ' '
752
+ }
753
+ }
754
+ const key = body.slice(0, separatorIndex).trim()
755
+ let value = body.slice(separatorIndex + (separator === ' ' ? 0 : 1)).trim()
756
+ if (!key) continue
757
+ if (kind === 'properties') value = decodeEscape(value, 'properties')
758
+ else value = stripYamlQuote(value)
759
+ entries.push({ type: 'string', key, value, comment: pendingComment, separator })
760
+ pushSegment(segments, kind, key, value, {}, pendingComment)
761
+ pendingComment = ''
762
+ }
763
+ if (!segments.length) throw formatError('LOCALIZATION_NO_UNITS', '文件中没有可翻译的键值对。')
764
+ return {
765
+ format: kind,
766
+ segments,
767
+ formatMeta: { localizationTemplate: JSON.stringify({ kind, entries }) },
768
+ warnings,
769
+ }
770
+ }
771
+
772
+ function parsePlist(xml) {
773
+ const tokens = String(xml ?? '')
774
+ .replace(/<!--[\s\S]*?-->/g, '')
775
+ .match(/<[^>]+>|[^<]+/g) ?? []
776
+ let index = 0
777
+
778
+ const skipNoise = () => {
779
+ while (index < tokens.length) {
780
+ const token = tokens[index]
781
+ if (/^\s*$/.test(token) || token.startsWith('<?') || token.startsWith('<!')) index += 1
782
+ else break
783
+ }
784
+ }
785
+ const parseText = (tag) => {
786
+ let output = ''
787
+ while (index < tokens.length && tokens[index] !== `</${tag}>`) {
788
+ output += tokens[index]
789
+ index += 1
790
+ }
791
+ if (tokens[index] === `</${tag}>`) index += 1
792
+ return decodeXml(cdata(output)).trim()
793
+ }
794
+ const parseValue = () => {
795
+ skipNoise()
796
+ const token = tokens[index]
797
+ if (!token) throw formatError('LOCALIZATION_PARSE', 'plist 结构不完整。')
798
+ if (token.startsWith('<plist')) {
799
+ index += 1
800
+ return parseValue()
801
+ }
802
+ if (token === '<dict>') {
803
+ index += 1
804
+ const result = {}
805
+ while (index < tokens.length && tokens[index] !== '</dict>') {
806
+ skipNoise()
807
+ if (tokens[index] !== '<key>') {
808
+ index += 1
809
+ continue
810
+ }
811
+ index += 1
812
+ const key = parseText('key')
813
+ skipNoise()
814
+ result[key] = parseValue()
815
+ }
816
+ index += 1
817
+ return result
818
+ }
819
+ if (token === '<array>') {
820
+ index += 1
821
+ const result = []
822
+ while (index < tokens.length && tokens[index] !== '</array>') {
823
+ result.push(parseValue())
824
+ }
825
+ index += 1
826
+ return result
827
+ }
828
+ if (token.startsWith('<string>')) {
829
+ index += 1
830
+ return parseText('string')
831
+ }
832
+ if (token.startsWith('<integer>') || token.startsWith('<real>')) {
833
+ index += 1
834
+ const value = Number(parseText(token.startsWith('<integer>') ? 'integer' : 'real'))
835
+ return Number.isFinite(value) ? value : 0
836
+ }
837
+ if (token === '<true/>' || token === '<false/>') {
838
+ index += 1
839
+ return token === '<true/>'
840
+ }
841
+ index += 1
842
+ return ''
843
+ }
844
+ const value = parseValue()
845
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
846
+ throw formatError('LOCALIZATION_PARSE', 'stringsdict 根节点必须是字典。')
847
+ }
848
+ return value
849
+ }
850
+
851
+ function parseStringsdict(text) {
852
+ const root = parsePlist(text)
853
+ const entries = []
854
+ const segments = []
855
+ for (const [key, config] of Object.entries(root)) {
856
+ if (!config || typeof config !== 'object' || Array.isArray(config)) continue
857
+ const formatKey = String(config.NSStringLocalizedFormatKey ?? '')
858
+ const variable = formatKey.match(/%#@([A-Za-z0-9_]+)@/)?.[1] ?? 'count'
859
+ const quantitySource = config[variable] && typeof config[variable] === 'object' && !Array.isArray(config[variable])
860
+ ? config[variable]
861
+ : config
862
+ const items = []
863
+ for (const [quantity, value] of Object.entries(quantitySource)) {
864
+ if (quantity.startsWith('NSString') || quantity === variable) continue
865
+ if (typeof value !== 'string') continue
866
+ items.push({ quantity, value })
867
+ pushSegment(segments, 'apple-stringsdict', key, value, {
868
+ keyPath: `${key}#${quantity}`,
869
+ context: quantity,
870
+ plural: true,
871
+ msgidPlural: key,
872
+ icuType: 'quantity',
873
+ })
874
+ }
875
+ entries.push({
876
+ type: 'stringsdict',
877
+ key,
878
+ variable,
879
+ formatKey: formatKey || `%#@${variable}@`,
880
+ valueType: String(quantitySource.NSStringFormatValueTypeKey ?? 'd'),
881
+ items,
882
+ })
883
+ }
884
+ if (!segments.length) throw formatError('LOCALIZATION_NO_UNITS', 'stringsdict 中没有可翻译的复数文本。')
885
+ return {
886
+ format: 'apple-stringsdict',
887
+ segments,
888
+ formatMeta: { localizationTemplate: JSON.stringify({ kind: 'apple-stringsdict', entries }) },
889
+ warnings: [],
890
+ }
891
+ }
892
+
893
+ export function parseLocalization(text, kind) {
894
+ const normalized = normalizeKind(kind)
895
+ if (normalized === 'apple-strings') return parseAppleStrings(text, normalized)
896
+ if (normalized === 'apple-stringsdict') return parseStringsdict(text)
897
+ if (normalized === 'android') return parseAndroid(text)
898
+ if (normalized === 'resx') return parseResx(text)
899
+ if (normalized === 'arb') return parseArb(text)
900
+ if (normalized === 'yaml') return parseYaml(text)
901
+ return parseKeyValueLines(text, normalized)
902
+ }
903
+
904
+ function targetLookup(doc) {
905
+ const map = new Map()
906
+ for (const segment of doc.segments) {
907
+ const id = segment.meta?.identifier || segment.meta?.keyPath
908
+ if (!id) continue
909
+ const context = segment.meta?.context || ''
910
+ const value = String(segment.target || segment.source || '')
911
+ map.set(`${id}\u0000${context}`, value)
912
+ if (!context) map.set(id, value)
913
+ }
914
+ return map
915
+ }
916
+
917
+ function segmentLookup(doc) {
918
+ const map = new Map()
919
+ for (const segment of doc.segments) {
920
+ const id = segment.meta?.identifier
921
+ if (!id) continue
922
+ const context = segment.meta?.context || ''
923
+ map.set(`${id}\u0000${context}`, segment)
924
+ }
925
+ return map
926
+ }
927
+
928
+ function templateOf(doc, kind) {
929
+ const raw = doc.formatMeta?.localizationTemplate
930
+ if (raw) {
931
+ try {
932
+ const parsed = JSON.parse(raw)
933
+ if (parsed && parsed.kind === kind) return parsed
934
+ } catch {
935
+ // fall through to a minimal rebuild
936
+ }
937
+ }
938
+ return {
939
+ kind,
940
+ entries: doc.segments.map((segment) => ({
941
+ type: 'string',
942
+ key: segment.meta?.identifier || segment.meta?.keyPath || String(segment.paragraph ?? 0),
943
+ value: segment.source,
944
+ })),
945
+ }
946
+ }
947
+
948
+ function buildAppleStrings(doc, ast) {
949
+ const values = targetLookup(doc)
950
+ const segments = segmentLookup(doc)
951
+ const lines = []
952
+ for (const entry of ast.entries || []) {
953
+ if (!entry || entry.type !== 'string') continue
954
+ if (entry.comment) lines.push(`/* ${entry.comment} */`)
955
+ const segment = segments.get(`${entry.key}\u0000`)
956
+ const value = values.get(`${entry.key}\u0000`) ?? entry.value
957
+ lines.push(`"${escapeApple(entry.key)}" = "${escapeApple(value)}";`)
958
+ }
959
+ return `${lines.join('\n')}\n`
960
+ }
961
+
962
+ function buildAndroid(doc, ast) {
963
+ const values = targetLookup(doc)
964
+ const segments = segmentLookup(doc)
965
+ const lines = ['<?xml version="1.0" encoding="utf-8"?>', '<resources>']
966
+ for (const entry of ast.entries || []) {
967
+ if (!entry || !entry.key) continue
968
+ if (entry.comment) lines.push(` <!-- ${entry.comment} -->`)
969
+ const translatable = entry.translatable === false ? ' translatable="false"' : ''
970
+ if (entry.type === 'string') {
971
+ const segment = segments.get(`${entry.key}\u0000`)
972
+ const value = values.get(`${entry.key}\u0000`) ?? entry.value
973
+ lines.push(` <string name="${escapeXml(entry.key)}"${translatable}>${segment ? renderInline(value, segment.meta?.inline) : escapeXml(cdata(value))}</string>`)
974
+ } else if (entry.type === 'plural') {
975
+ lines.push(` <plurals name="${escapeXml(entry.key)}"${translatable}>`)
976
+ for (const item of entry.items || []) {
977
+ const segment = segments.get(`${entry.key}\u0000${item.quantity}`)
978
+ const value = values.get(`${entry.key}\u0000${item.quantity}`) ?? item.value
979
+ lines.push(` <item quantity="${escapeXml(item.quantity)}">${segment ? renderInline(value, segment.meta?.inline) : escapeXml(cdata(value))}</item>`)
980
+ }
981
+ lines.push(' </plurals>')
982
+ }
983
+ }
984
+ lines.push('</resources>')
985
+ return `${lines.join('\n')}\n`
986
+ }
987
+
988
+ function buildResx(doc, ast) {
989
+ const values = targetLookup(doc)
990
+ const segments = segmentLookup(doc)
991
+ const lines = [
992
+ '<?xml version="1.0" encoding="utf-8"?>',
993
+ '<root>',
994
+ ' <resheader name="resmimetype"><value>text/microsoft-resx</value></resheader>',
995
+ ' <resheader name="version"><value>2.0</value></resheader>',
996
+ ' <resheader name="reader"><value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value></resheader>',
997
+ ' <resheader name="writer"><value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value></resheader>',
998
+ ]
999
+ for (const entry of ast.entries || []) {
1000
+ if (!entry || entry.type !== 'string' || !entry.key) continue
1001
+ const segment = segments.get(`${entry.key}\u0000`)
1002
+ const value = values.get(`${entry.key}\u0000`) ?? entry.value
1003
+ lines.push(` <data name="${escapeXml(entry.key)}" xml:space="preserve">`)
1004
+ lines.push(` <value>${segment ? renderInline(value, segment.meta?.inline) : escapeXml(cdata(value))}</value>`)
1005
+ if (entry.comment) lines.push(` <comment>${escapeXml(entry.comment)}</comment>`)
1006
+ lines.push(' </data>')
1007
+ }
1008
+ lines.push('</root>')
1009
+ return `${lines.join('\n')}\n`
1010
+ }
1011
+
1012
+ function buildArb(doc, ast) {
1013
+ const data = ast.data && typeof ast.data === 'object' && !Array.isArray(ast.data) ? structuredClone(ast.data) : {}
1014
+ for (const segment of doc.segments) {
1015
+ const key = segment.meta?.identifier
1016
+ if (!key || !(key in data)) continue
1017
+ data[key] = String(segment.target || segment.source || '')
1018
+ }
1019
+ return `${JSON.stringify(data, null, 2)}\n`
1020
+ }
1021
+
1022
+ function buildKeyValue(doc, ast, kind) {
1023
+ const values = targetLookup(doc)
1024
+ const lines = []
1025
+ for (const entry of ast.entries || []) {
1026
+ if (!entry || entry.type !== 'string' || !entry.key) continue
1027
+ if (entry.comment) lines.push(`${kind === 'yaml' ? '#' : '#'} ${entry.comment}`)
1028
+ const value = values.get(`${entry.key}\u0000`) ?? entry.value
1029
+ if (kind === 'yaml') lines.push(`${entry.key}: ${yamlValue(value)}`)
1030
+ else lines.push(`${escapeProperties(entry.key, true)}=${escapeProperties(value)}`)
1031
+ }
1032
+ return `${lines.join('\n')}\n`
1033
+ }
1034
+
1035
+ function buildStringsdict(doc, ast) {
1036
+ const values = targetLookup(doc)
1037
+ const lines = [
1038
+ '<?xml version="1.0" encoding="UTF-8"?>',
1039
+ '<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">',
1040
+ '<plist version="1.0">',
1041
+ '<dict>',
1042
+ ]
1043
+ for (const entry of ast.entries || []) {
1044
+ if (!entry || entry.type !== 'stringsdict' || !entry.key) continue
1045
+ const variable = entry.variable || 'count'
1046
+ lines.push(` <key>${escapeXml(entry.key)}</key>`)
1047
+ lines.push(' <dict>')
1048
+ lines.push(' <key>NSStringLocalizedFormatKey</key>')
1049
+ lines.push(` <string>${escapeXml(entry.formatKey || `%#@${variable}@`)}</string>`)
1050
+ lines.push(` <key>${escapeXml(variable)}</key>`)
1051
+ lines.push(' <dict>')
1052
+ lines.push(' <key>NSStringFormatSpecTypeKey</key>')
1053
+ lines.push(' <string>NSStringPluralRuleType</string>')
1054
+ lines.push(' <key>NSStringFormatValueTypeKey</key>')
1055
+ lines.push(` <string>${escapeXml(entry.valueType || 'd')}</string>`)
1056
+ for (const item of entry.items || []) {
1057
+ const value = values.get(`${entry.key}\u0000${item.quantity}`) ?? item.value
1058
+ lines.push(` <key>${escapeXml(item.quantity)}</key>`)
1059
+ lines.push(` <string>${escapeXml(value)}</string>`)
1060
+ }
1061
+ lines.push(' </dict>')
1062
+ lines.push(' </dict>')
1063
+ }
1064
+ lines.push('</dict>', '</plist>')
1065
+ return `${lines.join('\n')}\n`
1066
+ }
1067
+
1068
+ export function buildLocalization(doc, kind) {
1069
+ const normalized = normalizeKind(kind || doc.format)
1070
+ const ast = templateOf(doc, normalized)
1071
+ if (normalized === 'apple-strings') return buildAppleStrings(doc, ast)
1072
+ if (normalized === 'apple-stringsdict') return buildStringsdict(doc, ast)
1073
+ if (normalized === 'android') return buildAndroid(doc, ast)
1074
+ if (normalized === 'resx') return buildResx(doc, ast)
1075
+ if (normalized === 'arb') return buildArb(doc, ast)
1076
+ if (normalized === 'yaml' && ast.root) return buildYaml(doc, ast)
1077
+ return buildKeyValue(doc, ast, normalized)
1078
+ }
1079
+
1080
+ export const LOCALIZATION_EXTENSIONS = {
1081
+ strings: 'apple-strings',
1082
+ stringsdict: 'apple-stringsdict',
1083
+ resx: 'resx',
1084
+ arb: 'arb',
1085
+ yaml: 'yaml',
1086
+ yml: 'yaml',
1087
+ properties: 'properties',
1088
+ }
1089
+
1090
+ export const LOCALIZATION_EXPORT = {
1091
+ 'apple-strings': { extension: 'strings', mime: 'text/plain;charset=utf-8' },
1092
+ 'apple-stringsdict': { extension: 'stringsdict', mime: 'application/xml;charset=utf-8' },
1093
+ android: { extension: 'xml', mime: 'application/xml;charset=utf-8' },
1094
+ resx: { extension: 'resx', mime: 'application/xml;charset=utf-8' },
1095
+ arb: { extension: 'arb', mime: 'application/json;charset=utf-8' },
1096
+ yaml: { extension: 'yaml', mime: 'text/yaml;charset=utf-8' },
1097
+ properties: { extension: 'properties', mime: 'text/plain;charset=utf-8' },
1098
+ }