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.
- package/README.md +345 -29
- package/dist/bun.d.ts +34 -0
- package/dist/bun.d.ts.map +1 -0
- package/dist/bun.js +98 -0
- package/dist/bun.js.map +1 -0
- package/dist/bundle-CpUIWb9j.d.ts +6 -0
- package/dist/bundle-CpUIWb9j.d.ts.map +1 -0
- package/dist/bundle-DboItAre.js +111 -0
- package/dist/bundle-DboItAre.js.map +1 -0
- package/dist/cli.js +51 -56
- package/dist/cli.js.map +1 -1
- package/dist/compile-ClK9htKr.d.ts +16 -0
- package/dist/compile-ClK9htKr.d.ts.map +1 -0
- package/dist/{config-_4HfnJQu.d.ts → config-CzaJJQ60.d.ts} +75 -2
- package/dist/config-CzaJJQ60.d.ts.map +1 -0
- package/dist/{config-D--RITk2.js → config-nWZf9QmF.js} +42 -212
- package/dist/config-nWZf9QmF.js.map +1 -0
- package/dist/config.d.ts +2 -2
- package/dist/config.js +1 -1
- package/dist/{copy-DXnzXsJa.d.ts → copy-B3V_QRkT.d.ts} +20 -7
- package/dist/copy-B3V_QRkT.d.ts.map +1 -0
- package/dist/copy-Bo_FIoLy.js +154 -0
- package/dist/copy-Bo_FIoLy.js.map +1 -0
- package/dist/endpoints-BgG_5Vpm.js +96 -0
- package/dist/endpoints-BgG_5Vpm.js.map +1 -0
- package/dist/generate-BW3N8EGb.js +5488 -0
- package/dist/generate-BW3N8EGb.js.map +1 -0
- package/dist/helpers-BWGcD0kS.js +96 -0
- package/dist/helpers-BWGcD0kS.js.map +1 -0
- package/dist/icu-Dej0mNsF.js +218 -0
- package/dist/icu-Dej0mNsF.js.map +1 -0
- package/dist/index.d.ts +12 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -188
- package/dist/inspector.d.ts +4 -1
- package/dist/inspector.d.ts.map +1 -1
- package/dist/inspector.js +7 -2
- package/dist/inspector.js.map +1 -1
- package/dist/locale-DpQA1pqH.js +70 -0
- package/dist/locale-DpQA1pqH.js.map +1 -0
- package/dist/next-loader.d.ts +11 -0
- package/dist/next-loader.d.ts.map +1 -0
- package/dist/next-loader.js +17 -0
- package/dist/next-loader.js.map +1 -0
- package/dist/next.d.ts +30 -0
- package/dist/next.d.ts.map +1 -0
- package/dist/next.js +160 -0
- package/dist/next.js.map +1 -0
- package/dist/react/index.d.ts +3 -3
- package/dist/react/index.d.ts.map +1 -1
- package/dist/react/index.js.map +1 -1
- package/dist/react/server.d.ts +20 -0
- package/dist/react/server.d.ts.map +1 -0
- package/dist/react/server.js +34 -0
- package/dist/react/server.js.map +1 -0
- package/dist/{sarif-CI8HOokm.js → sarif-B5nrMqrT.js} +103 -87
- package/dist/sarif-B5nrMqrT.js.map +1 -0
- package/dist/scan-Bx0_yDSi.js +387 -0
- package/dist/scan-Bx0_yDSi.js.map +1 -0
- package/dist/tools.d.ts +83 -90
- package/dist/tools.d.ts.map +1 -1
- package/dist/tools.js +5 -4
- package/dist/vite.d.ts +3 -2
- package/dist/vite.d.ts.map +1 -1
- package/dist/vite.js +29 -109
- package/dist/vite.js.map +1 -1
- package/package.json +37 -4
- package/src/bun.ts +132 -0
- package/src/cli.ts +60 -58
- package/src/index.ts +1 -1
- package/src/inspector.ts +8 -3
- package/src/next-loader.ts +16 -0
- package/src/next.ts +188 -0
- package/src/react/index.tsx +6 -6
- package/src/react/server.ts +37 -0
- package/src/runtime/copy.ts +22 -3
- package/src/runtime/helpers.ts +54 -18
- package/src/runtime/locale.ts +19 -0
- package/src/runtime/types.ts +21 -6
- package/src/tools/bundle.ts +131 -0
- package/src/tools/catalog.ts +2 -2
- package/src/tools/compile.ts +60 -23
- package/src/tools/config.ts +57 -0
- package/src/tools/endpoints.ts +121 -0
- package/src/tools/entry.ts +77 -0
- package/src/tools/generate.ts +2 -0
- package/src/tools/icu.ts +63 -7
- package/src/tools/index.ts +6 -3
- package/src/tools/lock.ts +7 -0
- package/src/tools/mf1.ts +21 -7
- package/src/tools/mf2.ts +249 -57
- package/src/tools/migrate.ts +3 -2
- package/src/tools/native/android.ts +201 -0
- package/src/tools/native/apple.ts +139 -0
- package/src/tools/native/emit.ts +87 -0
- package/src/tools/native/index.ts +48 -0
- package/src/tools/native/kotlin.ts +269 -0
- package/src/tools/native/model.ts +292 -0
- package/src/tools/native/number.ts +210 -0
- package/src/tools/native/plurals.ts +103 -0
- package/src/tools/native/printf.ts +95 -0
- package/src/tools/native/rust.ts +367 -0
- package/src/tools/native/swift.ts +326 -0
- package/src/tools/syntax.ts +3 -3
- package/src/tools/todo.ts +118 -0
- package/src/vite.ts +28 -114
- package/dist/config-D--RITk2.js.map +0 -1
- package/dist/config-_4HfnJQu.d.ts.map +0 -1
- package/dist/copy-DXnzXsJa.d.ts.map +0 -1
- package/dist/helpers-BPuPGjQP.js +0 -61
- package/dist/helpers-BPuPGjQP.js.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/sarif-CI8HOokm.js.map +0 -1
- package/dist/scan-Ch2BfIUD.js +0 -2561
- 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
|
+
}
|
package/src/tools/syntax.ts
CHANGED
|
@@ -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
|
+
}
|