prosebook 0.1.0 → 0.3.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 +311 -57
- 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-DboItAre.js +111 -0
- package/dist/bundle-DboItAre.js.map +1 -0
- package/dist/bundle-KD-DiiFa.d.ts +6 -0
- package/dist/bundle-KD-DiiFa.d.ts.map +1 -0
- package/dist/cli.js +51 -56
- package/dist/cli.js.map +1 -1
- package/dist/compile-xF3F4oIp.d.ts +16 -0
- package/dist/compile-xF3F4oIp.d.ts.map +1 -0
- package/dist/{config-CxVMn19f.js → config-BPSmoYel.js} +82 -196
- package/dist/config-BPSmoYel.js.map +1 -0
- package/dist/{config-CF7HziDa.d.ts → config-DdMkKt_N.d.ts} +85 -7
- package/dist/config-DdMkKt_N.d.ts.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-CnUfCkIk.js +96 -0
- package/dist/endpoints-CnUfCkIk.js.map +1 -0
- package/dist/generate-9pR3i5Bj.js +5173 -0
- package/dist/generate-9pR3i5Bj.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-PkPvsB07.js → sarif-CWyTAQ0y.js} +103 -87
- package/dist/sarif-CWyTAQ0y.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 -64
- package/dist/tools.d.ts.map +1 -1
- package/dist/tools.js +5 -4
- package/dist/vite.d.ts +14 -4
- package/dist/vite.d.ts.map +1 -1
- package/dist/vite.js +60 -114
- 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 +61 -13
- package/src/tools/config.ts +58 -3
- package/src/tools/endpoints.ts +121 -0
- package/src/tools/entry.ts +77 -0
- package/src/tools/generate.ts +32 -32
- package/src/tools/icu.ts +93 -8
- package/src/tools/index.ts +20 -4
- package/src/tools/lint.ts +49 -12
- 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/emit.ts +87 -0
- package/src/tools/native/index.ts +36 -0
- package/src/tools/native/kotlin.ts +269 -0
- package/src/tools/native/model.ts +280 -0
- package/src/tools/native/number.ts +210 -0
- package/src/tools/native/plurals.ts +103 -0
- package/src/tools/native/rust.ts +367 -0
- package/src/tools/native/swift.ts +326 -0
- package/src/tools/scan.ts +134 -4
- package/src/tools/syntax.ts +3 -3
- package/src/tools/todo.ts +118 -0
- package/src/vite.ts +74 -116
- package/dist/config-CF7HziDa.d.ts.map +0 -1
- package/dist/config-CxVMn19f.js.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-PkPvsB07.js.map +0 -1
- package/dist/scan-B0tZ7y-D.js +0 -2447
- package/dist/scan-B0tZ7y-D.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/scan.ts
CHANGED
|
@@ -81,10 +81,8 @@ export function looksLikeCode(text: string): boolean {
|
|
|
81
81
|
const NON_PROSE_ELEMENTS = new Set(['code', 'pre', 'kbd', 'samp', 'script', 'style', 'svg', 'path']);
|
|
82
82
|
const LETTER = /\p{L}/u;
|
|
83
83
|
|
|
84
|
-
/**
|
|
85
|
-
|
|
86
|
-
const { program } = parseSync(file, code);
|
|
87
|
-
const found: Hardcoded[] = [];
|
|
84
|
+
/** Offsets in `code` as lines and columns, and whether a line is marked `prosebook-ignore`, on it or the line above. */
|
|
85
|
+
function lineIndex(code: string) {
|
|
88
86
|
const lines = code.split('\n');
|
|
89
87
|
const lineStarts: number[] = [];
|
|
90
88
|
let offset = 0;
|
|
@@ -104,6 +102,14 @@ export function hardcodedText(file: string, code: string, attributes: string[]):
|
|
|
104
102
|
};
|
|
105
103
|
const ignored = (line: number) =>
|
|
106
104
|
(lines[line - 1] ?? '').includes('prosebook-ignore') || (lines[line - 2] ?? '').includes('prosebook-ignore');
|
|
105
|
+
return { position, ignored };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Literal text in JSX that a person reads, where it should be a catalog entry. */
|
|
109
|
+
export function hardcodedText(file: string, code: string, attributes: string[]): Hardcoded[] {
|
|
110
|
+
const { program } = parseSync(file, code);
|
|
111
|
+
const found: Hardcoded[] = [];
|
|
112
|
+
const { position, ignored } = lineIndex(code);
|
|
107
113
|
const report = (node: Node, text: string, where: string) => {
|
|
108
114
|
const trimmed = text.replace(/\s+/g, ' ').trim();
|
|
109
115
|
if (!LETTER.test(trimmed) || looksLikeCode(trimmed)) return;
|
|
@@ -171,3 +177,127 @@ export function scan(dirs: string[], extensions: string[], attributes: string[],
|
|
|
171
177
|
}
|
|
172
178
|
return { hardcoded, refs, files: files.length };
|
|
173
179
|
}
|
|
180
|
+
|
|
181
|
+
/** A copy's methods that take a key first. */
|
|
182
|
+
export const READERS = new Set(['t', 'rich']);
|
|
183
|
+
|
|
184
|
+
export interface UnseenRead {
|
|
185
|
+
file: string;
|
|
186
|
+
line: number;
|
|
187
|
+
column: number;
|
|
188
|
+
/** The read as written, on one line. */
|
|
189
|
+
code: string;
|
|
190
|
+
/** What it calls: a reader, the name it was imported by, or `Text`. */
|
|
191
|
+
name: string;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export interface Reads {
|
|
195
|
+
/** Reads whose key a build can't see, in calls that may be to a copy's `t` or `rich`. */
|
|
196
|
+
unseen: UnseenRead[];
|
|
197
|
+
/** Names a copy's readers are bound to here, as in `const { rich: tRich } = copy`. */
|
|
198
|
+
aliases: Set<string>;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const PREFIX = /^[a-z0-9][\w-]*(?:\.[\w-]+)*\.$/;
|
|
202
|
+
const WRAPPERS = new Set(['ParenthesizedExpression', 'TSAsExpression', 'TSSatisfiesExpression', 'TSNonNullExpression', 'TSTypeAssertion']);
|
|
203
|
+
|
|
204
|
+
/** Whether `keyRefs` finds every key an expression can be: a literal, or a template or sum after a key prefix. */
|
|
205
|
+
function keySeen(node: Node | undefined): boolean {
|
|
206
|
+
if (node === undefined) return false;
|
|
207
|
+
if (WRAPPERS.has(node.type)) return keySeen(node.expression as Node);
|
|
208
|
+
switch (node.type) {
|
|
209
|
+
case 'Literal':
|
|
210
|
+
return typeof node.value === 'string';
|
|
211
|
+
case 'TemplateLiteral': {
|
|
212
|
+
const [head] = node.quasis as { value: { cooked: string | null } }[];
|
|
213
|
+
return (node.expressions as Node[]).length === 0 || PREFIX.test(head?.value.cooked ?? '');
|
|
214
|
+
}
|
|
215
|
+
case 'BinaryExpression': {
|
|
216
|
+
const left = node.left as Node;
|
|
217
|
+
return node.operator === '+' && left.type === 'Literal' && typeof left.value === 'string' && PREFIX.test(left.value);
|
|
218
|
+
}
|
|
219
|
+
case 'ConditionalExpression':
|
|
220
|
+
return keySeen(node.consequent as Node) && keySeen(node.alternate as Node);
|
|
221
|
+
case 'LogicalExpression':
|
|
222
|
+
return keySeen(node.left as Node) && keySeen(node.right as Node);
|
|
223
|
+
default:
|
|
224
|
+
return false;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const nameOf = (node: Node | undefined): string | undefined =>
|
|
229
|
+
node?.type === 'Identifier' || node?.type === 'JSXIdentifier'
|
|
230
|
+
? (node.name as string)
|
|
231
|
+
: node?.type === 'Literal'
|
|
232
|
+
? String(node.value)
|
|
233
|
+
: undefined;
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* The reads in a module whose key a pruned build can't see, as in `t(item.key)` or `<Text id={id} />`:
|
|
237
|
+
* the build would leave the message out, and the key would show in its place. A build has no types,
|
|
238
|
+
* so readers are known by name: `t` and `rich` called alone or as methods, and any name imported here,
|
|
239
|
+
* which may be bound to one elsewhere (`aliases`); the caller keeps those whose name turns out to be a
|
|
240
|
+
* reader. `<Text>` counts where it is imported from `prosebook/react`. Lines marked `prosebook-ignore`
|
|
241
|
+
* are skipped.
|
|
242
|
+
*/
|
|
243
|
+
export function unseenReads(file: string, code: string): Reads {
|
|
244
|
+
const { program } = parseSync(file, code);
|
|
245
|
+
const { position, ignored } = lineIndex(code);
|
|
246
|
+
const unseen: UnseenRead[] = [];
|
|
247
|
+
const aliases = new Set<string>();
|
|
248
|
+
/** Local name to the name it was imported by. */
|
|
249
|
+
const imported = new Map<string, string>();
|
|
250
|
+
const texts = new Set<string>();
|
|
251
|
+
for (const statement of (program as unknown as Node).body as Node[]) {
|
|
252
|
+
if (statement.type !== 'ImportDeclaration') continue;
|
|
253
|
+
for (const s of statement.specifiers as Node[]) {
|
|
254
|
+
const name = s.type === 'ImportSpecifier' ? nameOf(s.imported as Node) : undefined;
|
|
255
|
+
const local = nameOf(s.local as Node);
|
|
256
|
+
if (name === undefined || local === undefined) continue;
|
|
257
|
+
if (name === 'Text' && (statement.source as Node).value === 'prosebook/react') texts.add(local);
|
|
258
|
+
else imported.set(local, name);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
const report = (node: Node, name: string) => {
|
|
262
|
+
const at = position(node.start);
|
|
263
|
+
if (ignored(at.line)) return;
|
|
264
|
+
const text = code.slice(node.start, node.end).replace(/\s+/g, ' ');
|
|
265
|
+
unseen.push({ file, ...at, name, code: text.length > 80 ? `${text.slice(0, 77)}...` : text });
|
|
266
|
+
};
|
|
267
|
+
// Calls by a plain name, kept until every alias in the module is known.
|
|
268
|
+
const calls: [Node, string][] = [];
|
|
269
|
+
const visit = (node: Node) => {
|
|
270
|
+
if (node.type === 'ObjectPattern') {
|
|
271
|
+
for (const p of node.properties as Node[]) {
|
|
272
|
+
if (p.type === 'Property' && READERS.has(nameOf(p.key as Node) ?? '') && (p.value as Node).type === 'Identifier')
|
|
273
|
+
aliases.add(nameOf(p.value as Node)!);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
if (node.type === 'CallExpression' && !keySeen((node.arguments as Node[])[0])) {
|
|
277
|
+
const callee = node.callee as Node;
|
|
278
|
+
if (callee.type === 'Identifier') calls.push([node, callee.name as string]);
|
|
279
|
+
else if (callee.type === 'MemberExpression' && !callee.computed && READERS.has(nameOf(callee.property as Node) ?? ''))
|
|
280
|
+
report(node, nameOf(callee.property as Node)!);
|
|
281
|
+
}
|
|
282
|
+
if (node.type === 'JSXOpeningElement' && texts.has(nameOf(node.name as Node) ?? '')) {
|
|
283
|
+
const id = (node.attributes as Node[]).find((a) => a.type === 'JSXAttribute' && nameOf(a.name as Node) === 'id');
|
|
284
|
+
const value = id?.value as Node | null | undefined;
|
|
285
|
+
if (value !== null && value !== undefined && !keySeen(value.type === 'JSXExpressionContainer' ? (value.expression as Node) : value))
|
|
286
|
+
report(node, 'Text');
|
|
287
|
+
}
|
|
288
|
+
for (const value of Object.values(node)) {
|
|
289
|
+
if (Array.isArray(value)) {
|
|
290
|
+
for (const child of value) if (isNode(child)) visit(child);
|
|
291
|
+
} else if (isNode(value)) {
|
|
292
|
+
visit(value);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
};
|
|
296
|
+
visit(program as unknown as Node);
|
|
297
|
+
for (const [node, local] of calls) {
|
|
298
|
+
if (READERS.has(local) || aliases.has(local)) report(node, local);
|
|
299
|
+
else if (imported.has(local)) report(node, imported.get(local)!);
|
|
300
|
+
}
|
|
301
|
+
unseen.sort((a, b) => (a.line === b.line ? a.column - b.column : a.line - b.line));
|
|
302
|
+
return { unseen, aliases };
|
|
303
|
+
}
|
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
|
+
}
|