prosebook 0.2.0 → 0.4.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.
Files changed (115) hide show
  1. package/README.md +345 -29
  2. package/dist/bun.d.ts +34 -0
  3. package/dist/bun.d.ts.map +1 -0
  4. package/dist/bun.js +98 -0
  5. package/dist/bun.js.map +1 -0
  6. package/dist/bundle-CpUIWb9j.d.ts +6 -0
  7. package/dist/bundle-CpUIWb9j.d.ts.map +1 -0
  8. package/dist/bundle-DboItAre.js +111 -0
  9. package/dist/bundle-DboItAre.js.map +1 -0
  10. package/dist/cli.js +51 -56
  11. package/dist/cli.js.map +1 -1
  12. package/dist/compile-ClK9htKr.d.ts +16 -0
  13. package/dist/compile-ClK9htKr.d.ts.map +1 -0
  14. package/dist/{config-_4HfnJQu.d.ts → config-CzaJJQ60.d.ts} +75 -2
  15. package/dist/config-CzaJJQ60.d.ts.map +1 -0
  16. package/dist/{config-D--RITk2.js → config-nWZf9QmF.js} +42 -212
  17. package/dist/config-nWZf9QmF.js.map +1 -0
  18. package/dist/config.d.ts +2 -2
  19. package/dist/config.js +1 -1
  20. package/dist/{copy-DXnzXsJa.d.ts → copy-B3V_QRkT.d.ts} +20 -7
  21. package/dist/copy-B3V_QRkT.d.ts.map +1 -0
  22. package/dist/copy-Bo_FIoLy.js +154 -0
  23. package/dist/copy-Bo_FIoLy.js.map +1 -0
  24. package/dist/endpoints-BgG_5Vpm.js +96 -0
  25. package/dist/endpoints-BgG_5Vpm.js.map +1 -0
  26. package/dist/generate-BW3N8EGb.js +5488 -0
  27. package/dist/generate-BW3N8EGb.js.map +1 -0
  28. package/dist/helpers-BWGcD0kS.js +96 -0
  29. package/dist/helpers-BWGcD0kS.js.map +1 -0
  30. package/dist/icu-Dej0mNsF.js +218 -0
  31. package/dist/icu-Dej0mNsF.js.map +1 -0
  32. package/dist/index.d.ts +12 -2
  33. package/dist/index.d.ts.map +1 -1
  34. package/dist/index.js +4 -188
  35. package/dist/inspector.d.ts +4 -1
  36. package/dist/inspector.d.ts.map +1 -1
  37. package/dist/inspector.js +7 -2
  38. package/dist/inspector.js.map +1 -1
  39. package/dist/locale-DpQA1pqH.js +70 -0
  40. package/dist/locale-DpQA1pqH.js.map +1 -0
  41. package/dist/next-loader.d.ts +11 -0
  42. package/dist/next-loader.d.ts.map +1 -0
  43. package/dist/next-loader.js +17 -0
  44. package/dist/next-loader.js.map +1 -0
  45. package/dist/next.d.ts +30 -0
  46. package/dist/next.d.ts.map +1 -0
  47. package/dist/next.js +160 -0
  48. package/dist/next.js.map +1 -0
  49. package/dist/react/index.d.ts +3 -3
  50. package/dist/react/index.d.ts.map +1 -1
  51. package/dist/react/index.js.map +1 -1
  52. package/dist/react/server.d.ts +20 -0
  53. package/dist/react/server.d.ts.map +1 -0
  54. package/dist/react/server.js +34 -0
  55. package/dist/react/server.js.map +1 -0
  56. package/dist/{sarif-CI8HOokm.js → sarif-B5nrMqrT.js} +103 -87
  57. package/dist/sarif-B5nrMqrT.js.map +1 -0
  58. package/dist/scan-Bx0_yDSi.js +387 -0
  59. package/dist/scan-Bx0_yDSi.js.map +1 -0
  60. package/dist/tools.d.ts +83 -90
  61. package/dist/tools.d.ts.map +1 -1
  62. package/dist/tools.js +5 -4
  63. package/dist/vite.d.ts +3 -2
  64. package/dist/vite.d.ts.map +1 -1
  65. package/dist/vite.js +29 -109
  66. package/dist/vite.js.map +1 -1
  67. package/package.json +37 -4
  68. package/src/bun.ts +132 -0
  69. package/src/cli.ts +60 -58
  70. package/src/index.ts +1 -1
  71. package/src/inspector.ts +8 -3
  72. package/src/next-loader.ts +16 -0
  73. package/src/next.ts +188 -0
  74. package/src/react/index.tsx +6 -6
  75. package/src/react/server.ts +37 -0
  76. package/src/runtime/copy.ts +22 -3
  77. package/src/runtime/helpers.ts +54 -18
  78. package/src/runtime/locale.ts +19 -0
  79. package/src/runtime/types.ts +21 -6
  80. package/src/tools/bundle.ts +131 -0
  81. package/src/tools/catalog.ts +2 -2
  82. package/src/tools/compile.ts +60 -23
  83. package/src/tools/config.ts +57 -0
  84. package/src/tools/endpoints.ts +121 -0
  85. package/src/tools/entry.ts +77 -0
  86. package/src/tools/generate.ts +2 -0
  87. package/src/tools/icu.ts +63 -7
  88. package/src/tools/index.ts +6 -3
  89. package/src/tools/lock.ts +7 -0
  90. package/src/tools/mf1.ts +21 -7
  91. package/src/tools/mf2.ts +249 -57
  92. package/src/tools/migrate.ts +3 -2
  93. package/src/tools/native/android.ts +201 -0
  94. package/src/tools/native/apple.ts +139 -0
  95. package/src/tools/native/emit.ts +87 -0
  96. package/src/tools/native/index.ts +48 -0
  97. package/src/tools/native/kotlin.ts +269 -0
  98. package/src/tools/native/model.ts +292 -0
  99. package/src/tools/native/number.ts +210 -0
  100. package/src/tools/native/plurals.ts +103 -0
  101. package/src/tools/native/printf.ts +95 -0
  102. package/src/tools/native/rust.ts +367 -0
  103. package/src/tools/native/swift.ts +326 -0
  104. package/src/tools/syntax.ts +3 -3
  105. package/src/tools/todo.ts +118 -0
  106. package/src/vite.ts +28 -114
  107. package/dist/config-D--RITk2.js.map +0 -1
  108. package/dist/config-_4HfnJQu.d.ts.map +0 -1
  109. package/dist/copy-DXnzXsJa.d.ts.map +0 -1
  110. package/dist/helpers-BPuPGjQP.js +0 -61
  111. package/dist/helpers-BPuPGjQP.js.map +0 -1
  112. package/dist/index.js.map +0 -1
  113. package/dist/sarif-CI8HOokm.js.map +0 -1
  114. package/dist/scan-Ch2BfIUD.js +0 -2561
  115. package/dist/scan-Ch2BfIUD.js.map +0 -1
@@ -0,0 +1,326 @@
1
+ /**
2
+ * The model as a Swift file: a `String`-backed enum of locales with a property or method per
3
+ * message, and a file-private enum holding what formats them.
4
+ */
5
+ import type { NativeTarget } from '#src/tools/config.ts';
6
+ import { camel, checkNames, conditionCode, float, leading, stringLiteral, wordsOf } from './emit.ts';
7
+ import type { NativeMessage, NativeModel, Part } from './model.ts';
8
+ import type { NumberSpec } from './number.ts';
9
+
10
+ const KEYWORDS = new Set(
11
+ 'associatedtype class deinit enum extension fileprivate func import init inout internal let open operator private precedencegroup protocol public rethrows static struct subscript typealias var break case catch continue default defer do else fallthrough for guard if in repeat return throw switch where while Any as await false is nil self Self super throws true try'.split(
12
+ ' ',
13
+ ),
14
+ );
15
+
16
+ const ident = (name: string) => (KEYWORDS.has(name) ? `\`${name}\`` : name);
17
+ const lit = (value: string) => stringLiteral(value, 'swift');
18
+ const caseName = (tag: string) => ident(leading(camel(wordsOf(tag))));
19
+ const argName = (name: string) => ident(leading(camel(wordsOf(name))));
20
+
21
+ function doc(lines: string[], indent: string): string {
22
+ return lines.map((l) => (l === '' ? `${indent}///\n` : `${indent}/// ${l}\n`)).join('');
23
+ }
24
+
25
+ /** The parts as an expression of type `String`, with `rt` the runtime enum. */
26
+ function expr(parts: Part[], indent: string, rt: string): string {
27
+ if (parts.length === 0) return '""';
28
+ const values = parts.map((p) => (p.type === 'text' ? lit(p.value) : value(p, indent, rt)));
29
+ return values.length === 1 ? values[0]! : `[${values.join(', ')}].joined()`;
30
+ }
31
+
32
+ function value(part: Exclude<Part, { type: 'text' }>, indent: string, rt: string): string {
33
+ const inner = `${indent} `;
34
+ if (part.type === 'string') return argName(part.arg);
35
+ if (part.type === 'number') {
36
+ const of = part.offset === 0 ? argName(part.arg) : `${argName(part.arg)} - ${float(part.offset)}`;
37
+ return `${rt}.number(${of}, ${rt}.f${part.format}, ${lit(part.fallback)})`;
38
+ }
39
+ if (part.type === 'select') {
40
+ const arms = part.arms.map((a) => `${inner}case ${lit(a.key)}: return ${expr(a.parts, inner, rt)}\n`).join('');
41
+ return `{ () -> String in\n${inner}switch ${argName(part.arg)} {\n${arms}${inner}default: return ${expr(part.other, inner, rt)}\n${inner}}\n${indent}}()`;
42
+ }
43
+ // A plural, the one kind left. With only `other`, there is nothing to select on.
44
+ if (part.arms.length + part.exact.length === 0) return expr(part.other, indent, rt);
45
+ const arg = argName(part.arg);
46
+ const on = part.on === 'round' ? `${rt}.round(${arg})` : part.on === 'percent' ? `${arg} * 100.0` : arg;
47
+ const { minFrac, maxFrac } = part.rounding;
48
+ const exact = part.exact.map((e) => `(${float(e.value)}, ${lit(`=${e.value}`)})`).join(', ');
49
+ const arms = [...part.exact.map((e) => ({ key: `=${e.value}`, parts: e.parts })), ...part.arms]
50
+ .map((a) => `${inner}case ${lit(a.key)}: return ${expr(a.parts, inner, rt)}\n`)
51
+ .join('');
52
+ const pick = `${rt}.plural(${on}, ${float(part.offset)}, [${exact}], ${rt}.r${part.rules}, ${minFrac}, ${maxFrac})`;
53
+ return `{ () -> String in\n${inner}switch ${pick} {\n${arms}${inner}default: return ${expr(part.other, inner, rt)}\n${inner}}\n${indent}}()`;
54
+ }
55
+
56
+ /** The protocol a number argument takes, and the property that reads it as a `Double`. */
57
+ interface NumberProtocol {
58
+ type: string;
59
+ value: string;
60
+ }
61
+
62
+ function member(m: NativeMessage, name: string, rt: string, number: NumberProtocol): string {
63
+ const cases = (render: (parts: Part[]) => string) =>
64
+ m.bodies.map((b) => ` case ${b.locales.map((l) => `.${caseName(l)}`).join(', ')}: return ${render(b.parts)}\n`).join('');
65
+ const argDocs = m.args.filter((a) => a.doc !== undefined).map((a) => `- ${argName(a.name)}: ${a.doc}`);
66
+ const head = doc(argDocs.length > 0 ? [...m.doc, '', ...argDocs] : m.doc, ' ');
67
+ const body = ` switch self {\n${cases((parts) => expr(parts, ' ', rt))} }\n`;
68
+ if (m.args.length === 0) return `${head} public var ${name}: String {\n${body} }\n`;
69
+ const params = m.args.map((a) => `${argName(a.name)}: ${a.type === 'number' ? `some ${number.type}` : 'String'}`).join(', ');
70
+ const numbers = m.args
71
+ .filter((a) => a.type === 'number')
72
+ .map((a) => ` let ${argName(a.name)} = ${argName(a.name)}.${number.value}\n`)
73
+ .join('');
74
+ return `${head} public func ${name}(${params}) -> String {\n${numbers}${body} }\n`;
75
+ }
76
+
77
+ function formatConst(spec: NumberSpec, i: number): string {
78
+ const affix = ([prefix, suffix]: readonly [string, string]) => `(${lit(prefix)}, ${lit(suffix)})`;
79
+ const fields = [
80
+ `minInt: ${spec.minInt}`,
81
+ `minFrac: ${spec.minFrac}`,
82
+ `maxFrac: ${spec.maxFrac}`,
83
+ `shift: ${spec.shift}`,
84
+ `primary: ${spec.grouping.primary}`,
85
+ `secondary: ${spec.grouping.secondary}`,
86
+ `minGroup: ${spec.grouping.min}`,
87
+ `decimal: ${lit(spec.decimal)}`,
88
+ `group: ${lit(spec.group)}`,
89
+ `digits: ${spec.digits === undefined ? 'nil' : `[${spec.digits.map(lit).join(', ')}]`}`,
90
+ `positive: ${affix(spec.positive)}`,
91
+ `negative: ${affix(spec.negative)}`,
92
+ `zero: ${affix(spec.zero)}`,
93
+ `negativeZero: ${affix(spec.negativeZero)}`,
94
+ `infinity: ${lit(spec.infinity)}`,
95
+ `negativeInfinity: ${lit(spec.negativeInfinity)}`,
96
+ ];
97
+ return ` static let f${i} = Format(\n${fields.map((f) => ` ${f}`).join(',\n')}\n )\n`;
98
+ }
99
+
100
+ const NUMBER = `
101
+ /// A number format, taken apart when the file was generated.
102
+ struct Format {
103
+ let minInt: Int
104
+ let minFrac: Int
105
+ let maxFrac: Int
106
+ let shift: Int
107
+ let primary: Int
108
+ let secondary: Int
109
+ let minGroup: Int
110
+ let decimal: String
111
+ let group: String
112
+ let digits: [String]?
113
+ let positive: (String, String)
114
+ let negative: (String, String)
115
+ let zero: (String, String)
116
+ let negativeZero: (String, String)
117
+ let infinity: String
118
+ let negativeInfinity: String
119
+ }
120
+
121
+ /// \`value\` as \`format\` writes it, or \`fallback\` where it isn't a number.
122
+ static func number(_ value: Double, _ format: Format, _ fallback: String) -> String {
123
+ if value.isNaN { return fallback }
124
+ if value.isInfinite { return value > 0 ? format.infinity : format.negativeInfinity }
125
+ let (written, frac) = digits(abs(value), format.shift, format.minFrac, format.maxFrac)
126
+ var int = written
127
+ let zero = (written + frac).allSatisfy { $0 == "0" }
128
+ let negative = value.sign == .minus
129
+ let (prefix, suffix) = zero ? (negative ? format.negativeZero : format.zero) : (negative ? format.negative : format.positive)
130
+ while int.count < max(format.minInt, 1) { int = "0" + int }
131
+ let grouped = format.minGroup > 0 && int.count >= format.primary + format.minGroup
132
+ var out = prefix
133
+ for (i, digit) in int.enumerated() {
134
+ let left = int.count - i
135
+ if grouped && i > 0 && (left == format.primary || (left > format.primary && (left - format.primary) % format.secondary == 0)) {
136
+ out += format.group
137
+ }
138
+ out += shown(digit, format)
139
+ }
140
+ if !frac.isEmpty {
141
+ out += format.decimal
142
+ for digit in frac { out += shown(digit, format) }
143
+ }
144
+ return out + suffix
145
+ }
146
+
147
+ private static func shown(_ digit: Character, _ format: Format) -> String {
148
+ guard let digits = format.digits, let ascii = digit.asciiValue else { return String(digit) }
149
+ return digits[Int(ascii - 48)]
150
+ }
151
+
152
+ /// The shortest digits that read back as \`value\`, as \`Intl\` rounds them: moved \`shift\` places
153
+ /// right, rounded half away from zero to \`max\` fraction digits, and trailing zeros cut to \`min\`.
154
+ static func digits(_ value: Double, _ shift: Int, _ min: Int, _ max: Int) -> (String, String) {
155
+ // \`description\` writes the shortest digits that read back, with an exponent past a size.
156
+ let written = value.description.split(separator: "e", maxSplits: 1).map(String.init)
157
+ let exponent = written.count > 1 ? Int(written[1]) ?? 0 : 0
158
+ let pieces = written[0].split(separator: ".", maxSplits: 1, omittingEmptySubsequences: false).map(String.init)
159
+ var all = (pieces[0] + (pieces.count > 1 ? pieces[1] : "")).compactMap { $0.wholeNumberValue }
160
+ var point = pieces[0].count + exponent + shift
161
+ if point < 0 {
162
+ all = Array(repeating: 0, count: -point) + all
163
+ point = 0
164
+ }
165
+ while all.count <= point + max { all.append(0) }
166
+ let next = all[point + max]
167
+ all = Array(all.prefix(point + max))
168
+ if next >= 5 {
169
+ var at = all.count - 1
170
+ while at >= 0 && all[at] == 9 {
171
+ all[at] = 0
172
+ at -= 1
173
+ }
174
+ if at < 0 {
175
+ all.insert(1, at: 0)
176
+ point += 1
177
+ } else {
178
+ all[at] += 1
179
+ }
180
+ }
181
+ var frac = Array(all[point...])
182
+ while frac.count > min && frac.last == 0 { frac.removeLast() }
183
+ let int = all[..<point].drop(while: { $0 == 0 })
184
+ return (int.map(String.init).joined(), frac.map(String.init).joined())
185
+ }
186
+ `;
187
+
188
+ const PLURAL = `
189
+ /// CLDR's plural operands of a number as written.
190
+ struct Operands {
191
+ let n: Double
192
+ let i: Double
193
+ let v: Double
194
+ let w: Double
195
+ let f: Double
196
+ let t: Double
197
+ let c: Double
198
+ let e: Double
199
+ }
200
+
201
+ /// The arm a plural takes: the \`=N\` that \`value\` equals, else the category of \`value\` less \`offset\`.
202
+ static func plural(
203
+ _ value: Double, _ offset: Double, _ exact: [(Double, String)], _ rules: (Operands) -> String, _ min: Int, _ max: Int
204
+ ) -> String {
205
+ exact.first(where: { $0.0 == value })?.1 ?? category(rules, value - offset, min, max)
206
+ }
207
+
208
+ /// The category \`rules\` give \`value\`, rounded to between \`min\` and \`max\` fraction digits.
209
+ private static func category(_ rules: (Operands) -> String, _ value: Double, _ min: Int, _ max: Int) -> String {
210
+ guard value.isFinite else { return "other" }
211
+ let (written, frac) = digits(abs(value), 0, min, max)
212
+ let int = written.isEmpty ? "0" : written
213
+ var trimmed = Substring(frac)
214
+ while trimmed.last == "0" { trimmed.removeLast() }
215
+ let read = { (s: String) in Double(s) ?? 0 }
216
+ return rules(Operands(
217
+ n: read(frac.isEmpty ? int : int + "." + frac),
218
+ i: read(int),
219
+ v: Double(frac.count),
220
+ w: Double(trimmed.count),
221
+ f: read(frac),
222
+ t: read(String(trimmed)),
223
+ c: 0,
224
+ e: 0
225
+ ))
226
+ }
227
+
228
+ /// An integer in the range, as CLDR's ranges hold.
229
+ private static func within(_ value: Double, _ from: Double, _ to: Double) -> Bool {
230
+ value.truncatingRemainder(dividingBy: 1) == 0 && value >= from && value <= to
231
+ }
232
+
233
+ /// \`Math.round\`: half up, toward positive infinity.
234
+ static func round(_ value: Double) -> Double {
235
+ let floor = value.rounded(.down)
236
+ return value - floor >= 0.5 ? floor + 1 : floor
237
+ }
238
+ `;
239
+
240
+ const NUMBER_TYPES = [
241
+ 'Int',
242
+ 'Int8',
243
+ 'Int16',
244
+ 'Int32',
245
+ 'Int64',
246
+ 'UInt',
247
+ 'UInt8',
248
+ 'UInt16',
249
+ 'UInt32',
250
+ 'UInt64',
251
+ 'Double',
252
+ 'Float',
253
+ 'CGFloat',
254
+ ];
255
+
256
+ export function swiftFile(model: NativeModel, target: NativeTarget & { name: string }, banner: string): string {
257
+ const name = target.name;
258
+ const rt = `${name}Runtime`;
259
+ const names = model.messages.map((m): [string, string] => [m.key, ident(leading(camel(m.words)))]);
260
+ checkNames('swift', names, ['tag', 'source', 'negotiate', 'rawValue', 'allCases', 'hashValue']);
261
+ const cases = model.locales
262
+ .map((l) => ` /// \`${l}\`${l === model.source ? ', the source' : ''}.\n case ${caseName(l)} = ${lit(l)}\n`)
263
+ .join('');
264
+ // Named after the type, so two generated files in one module don't both add a property to `Int`.
265
+ const number = { type: `${name}Number`, value: `${name.charAt(0).toLowerCase()}${name.slice(1)}Value` };
266
+ const members = model.messages.map((m, i) => member(m, names[i]![1], rt, number)).join('\n');
267
+ const usesNumbers = model.messages.some((m) => m.args.some((a) => a.type === 'number'));
268
+ const numberProtocol = !usesNumbers
269
+ ? ''
270
+ : `
271
+ /// A number a message formats: any of Swift's integers or floats.
272
+ public protocol ${number.type} {
273
+ var ${number.value}: Double { get }
274
+ }
275
+
276
+ ${NUMBER_TYPES.map((t) => `extension ${t}: ${number.type} { public var ${number.value}: Double { Double(self) } }`).join('\n')}
277
+ `;
278
+ const rules = model.rules
279
+ .map(({ name: rule, rules }, i) => {
280
+ const checks = rules.map(
281
+ ([category, condition]) =>
282
+ ` if ${conditionCode(condition, { field: (op) => `o.${op}`, mod: (v, by) => `${v}.truncatingRemainder(dividingBy: ${by})`, within: (v, from, to) => `within(${v}, ${from}, ${to})` })} { return ${lit(category)} }\n`,
283
+ );
284
+ return ` /// ${rule}\n static func r${i}(_ o: Operands) -> String {\n${checks.join('')} return "other"\n }\n`;
285
+ })
286
+ .join('\n');
287
+ const runtime =
288
+ model.formats.length + model.rules.length === 0
289
+ ? ''
290
+ : `
291
+ /// What formats the messages, generated with them.
292
+ private enum ${rt} {${NUMBER}${model.rules.length > 0 ? PLURAL : ''}
293
+ ${model.formats.map(formatConst).join('\n')}${rules === '' ? '' : `\n${rules}`}}
294
+ `;
295
+
296
+ return `${banner}
297
+ import Foundation
298
+ ${numberProtocol}
299
+ /// A locale the catalog is written or translated in, and its messages.
300
+ public enum ${name}: String, CaseIterable, Sendable {
301
+ ${cases}
302
+ /// The locale the catalog is written in, which every message falls back to.
303
+ public static let source: ${name} = .${caseName(model.source)}
304
+
305
+ /// The locale's BCP 47 tag.
306
+ public var tag: String { rawValue }
307
+
308
+ /// The best locale for what was asked, most preferred first: each tag tries an exact match, then
309
+ /// its parents (\`pt-BR\` finds \`pt\`), then any locale of its language. The source if none is.
310
+ public static func negotiate(_ requested: [String]) -> ${name} {
311
+ let language = { (tag: String) in tag.split(separator: "-").first.map { $0.lowercased() } ?? "" }
312
+ for want in requested {
313
+ var tag = want
314
+ while true {
315
+ if let found = allCases.first(where: { $0.tag.lowercased() == tag.lowercased() }) { return found }
316
+ guard let cut = tag.lastIndex(of: "-") else { break }
317
+ tag = String(tag[..<cut])
318
+ }
319
+ if let found = allCases.first(where: { language($0.tag) == language(want) }) { return found }
320
+ }
321
+ return source
322
+ }
323
+
324
+ ${members}}
325
+ ${runtime}`;
326
+ }
@@ -4,7 +4,7 @@
4
4
  */
5
5
  import { parseMessage, type MessageFormatElement } from './icu.ts';
6
6
  import { toMf1 } from './mf1.ts';
7
- import { parseMf2, toMf2 } from './mf2.ts';
7
+ import { parseMf2, toMf2, type Isolate } from './mf2.ts';
8
8
 
9
9
  export type MessageFormatVersion = 1 | 2;
10
10
 
@@ -14,8 +14,8 @@ export const SYNTAX_NAME: Record<MessageFormatVersion, string> = { 1: 'ICU Messa
14
14
  * A message as the element tree. MF2 is read per locale: which variants a value can reach depends
15
15
  * on the locale's plural rules (see `matchTree` in mf2.ts).
16
16
  */
17
- export function readMessage(version: MessageFormatVersion, text: string, locale: string): MessageFormatElement[] {
18
- return version === 2 ? parseMf2(text, locale) : parseMessage(text);
17
+ export function readMessage(version: MessageFormatVersion, text: string, locale: string, isolate?: Isolate): MessageFormatElement[] {
18
+ return version === 2 ? parseMf2(text, locale, isolate) : parseMessage(text);
19
19
  }
20
20
 
21
21
  /** A tree as text in the given syntax. Throws, saying what the syntax can't express, where it can't. */
@@ -0,0 +1,118 @@
1
+ /**
2
+ * `prosebook todo`: a locale's missing and stale entries, each with what a translator needs above it,
3
+ * as the YAML to fill in. `prosebook import` reads it back filled.
4
+ *
5
+ * With `prompt`, it leads with what a model must be told to reply in that form. prosebook writes the
6
+ * prompt and checks the reply; which model answers it, and how, is left to whoever runs it.
7
+ */
8
+ import { stringify } from 'yaml';
9
+
10
+ import type { Catalog, Message } from './catalog.ts';
11
+ import type { ResolvedConfig } from './config.ts';
12
+ import { TYPE, walk } from './icu.ts';
13
+ import { changeOf, type StaleTranslation } from './lock.ts';
14
+ import { SYNTAX_NAME } from './syntax.ts';
15
+
16
+ export interface Todo {
17
+ /** How many entries want a translation. */
18
+ count: number;
19
+ /** The entries as YAML, by area, with the prompt first when one was asked for. */
20
+ text: string;
21
+ }
22
+
23
+ export function todoOf(
24
+ config: ResolvedConfig,
25
+ catalog: Catalog,
26
+ stale: ReadonlyMap<string, StaleTranslation>,
27
+ locale: string,
28
+ prompt = false,
29
+ ): Todo {
30
+ const wanted = catalog.messages.filter((m) => !m.translations.has(locale) || stale.has(m.key));
31
+ if (wanted.length === 0) return { count: 0, text: `# ${locale} is complete` };
32
+ const byArea = new Map<string, string[]>();
33
+ for (const m of wanted) {
34
+ const lines = byArea.get(m.area) ?? [];
35
+ lines.push('');
36
+ if (m.context) lines.push(`# ${m.context}`);
37
+ const what = [m.kind && `kind: ${m.kind}`, m.surface && `surface: ${m.surface}`].filter(Boolean).join(', ');
38
+ if (what) lines.push(`# ${what}`);
39
+ for (const [arg, said] of m.argDocs) lines.push(`# {${arg}}: ${said}`);
40
+ lines.push(`# ${config.source}: ${m.text.replaceAll('\n', ' ')}`);
41
+ const change = stale.get(m.key);
42
+ if (change !== undefined) lines.push(`# stale, the source changed: ${changeOf(change)}`);
43
+ lines.push(stringify({ [m.key]: m.translations.get(locale)?.text ?? '' }, { lineWidth: 0 }).trimEnd());
44
+ byArea.set(m.area, lines);
45
+ }
46
+ const entries = [...byArea].map(([area, lines]) => `\n# ── ${area}.yaml ──\n${lines.join('\n')}`).join('\n');
47
+ const count = wanted.length;
48
+ if (prompt) {
49
+ const head = promptFor(
50
+ config,
51
+ wanted,
52
+ locale,
53
+ wanted.some((m) => stale.has(m.key)),
54
+ );
55
+ return { count, text: `${head}\n${entries.trimStart()}\n\`\`\`` };
56
+ }
57
+ const head = `# prosebook todo: ${count} entr${count === 1 ? 'y' : 'ies'} for ${locale}. Fill them in, then \`prosebook import ${locale} <this file>\`.`;
58
+ return { count, text: `${head}\n${entries}` };
59
+ }
60
+
61
+ function promptFor(config: ResolvedConfig, wanted: Message[], locale: string, anyStale: boolean): string {
62
+ const mf2 = config.messageFormat === 2;
63
+ const [target, source] = [nameOf(locale), nameOf(config.source)];
64
+ const plurals = { cardinal: false, ordinal: false };
65
+ for (const m of wanted) {
66
+ walk(m.ast, (el) => {
67
+ if (el.type === TYPE.plural) plurals[el.pluralType === 'ordinal' ? 'ordinal' : 'cardinal'] = true;
68
+ });
69
+ }
70
+ const categories = (type: Intl.PluralRuleType) => {
71
+ const forms = new Intl.PluralRules(locale, { type }).resolvedOptions().pluralCategories;
72
+ return forms.length === 1 ? `only the form ${forms[0]}` : `the forms ${new Intl.ListFormat('en').format(forms)}`;
73
+ };
74
+ const exact = mf2 ? '`0`' : '`=0`';
75
+
76
+ const rules = [
77
+ mf2
78
+ ? `Each message is in MessageFormat 2. Keep every {$placeholder}, every markup tag ({#b}…{/b}) around the same words, and every .input, .local and .match line; translate the text, not the names.`
79
+ : `Each message is in ICU MessageFormat. Keep every {placeholder}, and every <tag> around the same words; translate the text inside plural and select arms, not the arm or argument names.`,
80
+ plurals.cardinal &&
81
+ `A plural in ${target} takes ${categories('cardinal')}: give each its own ${mf2 ? 'variant' : 'arm'}, and keep exact ones such as ${exact} where the ${source} has them.`,
82
+ plurals.ordinal && `An ordinal in ${target} takes ${categories('ordinal')}.`,
83
+ 'The comments above an entry say where it appears, what kind of string it is, and what each placeholder holds. Hold each to its kind, as described below: a button stays as short as the source.',
84
+ anyStale &&
85
+ 'An entry marked stale has a translation made from an earlier source. The diff shows what changed in the source ({+added+}, [-removed-]): change the translation only as much as that needs, and give it back unchanged if it still holds.',
86
+ 'Quote a value in single quotes when it starts with { or holds ": ", as YAML needs, and double any single quote inside it.',
87
+ ].filter((r): r is string => typeof r === 'string');
88
+
89
+ const kinds = new Set(wanted.flatMap((m) => (m.kind === undefined ? [] : [m.kind])));
90
+ const surfaces = new Set(wanted.flatMap((m) => (m.surface === undefined ? [] : [m.surface])));
91
+ const legend = [
92
+ ...(kinds.size > 0 ? ['', 'Kinds of string:'] : []),
93
+ ...[...kinds].map((k) => `- ${k}: ${config.kinds[k]?.doc ?? ''}`),
94
+ ...(surfaces.size > 0 ? ['', 'Surfaces:'] : []),
95
+ ...[...surfaces].map((s) => `- ${s}: ${config.surfaces[s] ?? ''}`),
96
+ ];
97
+
98
+ return [
99
+ `Translate the entries below from ${source} (${config.source}) into ${target} (${locale}). They are the text of an app's interface.`,
100
+ '',
101
+ 'Reply with one YAML code block holding every key below, each with its translation as the value, and nothing else in the block.',
102
+ '',
103
+ ...rules.map((r) => `- ${r}`),
104
+ ...legend,
105
+ '',
106
+ `The entries, in ${SYNTAX_NAME[config.messageFormat]}:`,
107
+ '',
108
+ '```yaml',
109
+ ].join('\n');
110
+ }
111
+
112
+ function nameOf(locale: string): string {
113
+ try {
114
+ return new Intl.DisplayNames(['en'], { type: 'language' }).of(locale) ?? locale;
115
+ } catch {
116
+ return locale;
117
+ }
118
+ }