prosebook 0.3.0 → 0.5.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 (125) hide show
  1. package/README.md +357 -26
  2. package/dist/bind-BIZNv8nU.js +62 -0
  3. package/dist/bind-BIZNv8nU.js.map +1 -0
  4. package/dist/bind-ZS86jHXk.d.ts +31 -0
  5. package/dist/bind-ZS86jHXk.d.ts.map +1 -0
  6. package/dist/bun.d.ts +12 -3
  7. package/dist/bun.d.ts.map +1 -1
  8. package/dist/bun.js +77 -26
  9. package/dist/bun.js.map +1 -1
  10. package/dist/{bundle-KD-DiiFa.d.ts → bundle-CLHY_97D.d.ts} +3 -3
  11. package/dist/bundle-CLHY_97D.d.ts.map +1 -0
  12. package/dist/bundle-Ut6Y2X6N.js +217 -0
  13. package/dist/bundle-Ut6Y2X6N.js.map +1 -0
  14. package/dist/cli.js +55 -12
  15. package/dist/cli.js.map +1 -1
  16. package/dist/{config-BPSmoYel.js → config-7T4hX_f7.js} +13 -5
  17. package/dist/{config-BPSmoYel.js.map → config-7T4hX_f7.js.map} +1 -1
  18. package/dist/{config-DdMkKt_N.d.ts → config-CzaJJQ60.d.ts} +19 -5
  19. package/dist/{config-DdMkKt_N.d.ts.map → config-CzaJJQ60.d.ts.map} +1 -1
  20. package/dist/config.d.ts +1 -1
  21. package/dist/config.js +1 -1
  22. package/dist/{copy-B3V_QRkT.d.ts → copy-BXn0aYQn.d.ts} +37 -3
  23. package/dist/copy-BXn0aYQn.d.ts.map +1 -0
  24. package/dist/copy-Bv6S-Hr2.js +235 -0
  25. package/dist/copy-Bv6S-Hr2.js.map +1 -0
  26. package/dist/endpoints-Dhgowovz.js +217 -0
  27. package/dist/endpoints-Dhgowovz.js.map +1 -0
  28. package/dist/generate-1BeQAd1O.d.ts +72 -0
  29. package/dist/generate-1BeQAd1O.d.ts.map +1 -0
  30. package/dist/{generate-9pR3i5Bj.js → generate-CKv86Iki.js} +1724 -627
  31. package/dist/generate-CKv86Iki.js.map +1 -0
  32. package/dist/index.d.ts +2 -2
  33. package/dist/index.js +2 -2
  34. package/dist/next-loader.d.ts +9 -2
  35. package/dist/next-loader.d.ts.map +1 -1
  36. package/dist/next-loader.js +12 -3
  37. package/dist/next-loader.js.map +1 -1
  38. package/dist/next.d.ts +11 -3
  39. package/dist/next.d.ts.map +1 -1
  40. package/dist/next.js +94 -21
  41. package/dist/next.js.map +1 -1
  42. package/dist/preact/index.d.ts +34 -0
  43. package/dist/preact/index.d.ts.map +1 -0
  44. package/dist/preact/index.js +72 -0
  45. package/dist/preact/index.js.map +1 -0
  46. package/dist/react/index.d.ts +5 -14
  47. package/dist/react/index.d.ts.map +1 -1
  48. package/dist/react/index.js +9 -33
  49. package/dist/react/index.js.map +1 -1
  50. package/dist/react/server.d.ts +1 -1
  51. package/dist/react/server.js +1 -1
  52. package/dist/{sarif-CWyTAQ0y.js → sarif-BnwYC78_.js} +8 -98
  53. package/dist/sarif-BnwYC78_.js.map +1 -0
  54. package/dist/{scan-Bx0_yDSi.js → scan-C0ap4Mpp.js} +145 -20
  55. package/dist/scan-C0ap4Mpp.js.map +1 -0
  56. package/dist/solid/index.d.ts +29 -0
  57. package/dist/solid/index.d.ts.map +1 -0
  58. package/dist/solid/index.js +48 -0
  59. package/dist/solid/index.js.map +1 -0
  60. package/dist/svelte/Text.svelte +13 -0
  61. package/dist/svelte/Text.svelte.d.ts +20 -0
  62. package/dist/svelte/index.d.ts +31 -0
  63. package/dist/svelte/index.d.ts.map +1 -0
  64. package/dist/svelte/index.js +35 -0
  65. package/dist/svelte/index.js.map +1 -0
  66. package/dist/tools.d.ts +44 -65
  67. package/dist/tools.d.ts.map +1 -1
  68. package/dist/tools.js +5 -5
  69. package/dist/vite.d.ts +13 -3
  70. package/dist/vite.d.ts.map +1 -1
  71. package/dist/vite.js +82 -25
  72. package/dist/vite.js.map +1 -1
  73. package/dist/vue/index.d.ts +43 -0
  74. package/dist/vue/index.d.ts.map +1 -0
  75. package/dist/vue/index.js +76 -0
  76. package/dist/vue/index.js.map +1 -0
  77. package/package.json +63 -7
  78. package/src/bun.ts +77 -20
  79. package/src/cli.ts +62 -8
  80. package/src/index.ts +1 -1
  81. package/src/next-loader.ts +17 -5
  82. package/src/next.ts +120 -16
  83. package/src/preact/index.ts +91 -0
  84. package/src/react/index.tsx +12 -42
  85. package/src/runtime/bind.ts +96 -0
  86. package/src/runtime/copy.ts +148 -24
  87. package/src/solid/index.ts +67 -0
  88. package/src/svelte/Text.svelte +13 -0
  89. package/src/svelte/Text.svelte.d.ts +20 -0
  90. package/src/svelte/index.ts +65 -0
  91. package/src/tools/bundle.ts +143 -2
  92. package/src/tools/catalog.ts +10 -1
  93. package/src/tools/chunks.ts +142 -0
  94. package/src/tools/config.ts +25 -7
  95. package/src/tools/entry.ts +19 -20
  96. package/src/tools/exchange.ts +275 -0
  97. package/src/tools/extract.ts +2 -2
  98. package/src/tools/generate.ts +7 -1
  99. package/src/tools/index.ts +2 -1
  100. package/src/tools/native/android.ts +186 -0
  101. package/src/tools/native/apple.ts +201 -0
  102. package/src/tools/native/date.ts +219 -0
  103. package/src/tools/native/index.ts +17 -5
  104. package/src/tools/native/kotlin.ts +47 -6
  105. package/src/tools/native/model.ts +67 -29
  106. package/src/tools/native/printf.ts +137 -0
  107. package/src/tools/native/rust.ts +86 -5
  108. package/src/tools/native/swift.ts +62 -6
  109. package/src/tools/scan.ts +146 -25
  110. package/src/tools/todo.ts +6 -1
  111. package/src/vite.ts +121 -28
  112. package/src/vue/index.ts +117 -0
  113. package/dist/bundle-DboItAre.js +0 -111
  114. package/dist/bundle-DboItAre.js.map +0 -1
  115. package/dist/bundle-KD-DiiFa.d.ts.map +0 -1
  116. package/dist/compile-xF3F4oIp.d.ts +0 -16
  117. package/dist/compile-xF3F4oIp.d.ts.map +0 -1
  118. package/dist/copy-B3V_QRkT.d.ts.map +0 -1
  119. package/dist/copy-Bo_FIoLy.js +0 -154
  120. package/dist/copy-Bo_FIoLy.js.map +0 -1
  121. package/dist/endpoints-CnUfCkIk.js +0 -96
  122. package/dist/endpoints-CnUfCkIk.js.map +0 -1
  123. package/dist/generate-9pR3i5Bj.js.map +0 -1
  124. package/dist/sarif-CWyTAQ0y.js.map +0 -1
  125. package/dist/scan-Bx0_yDSi.js.map +0 -1
@@ -0,0 +1,201 @@
1
+ /**
2
+ * The catalog as Apple `.strings` tables: `<locale>.lproj/<name>.strings` per locale, each holding
3
+ * what that locale shows, every fallback resolved. Plain `.strings` and not a String Catalog, which
4
+ * only Xcode compiles: an app bundled by other tools copies these into its `Resources` as they are.
5
+ *
6
+ * The Swift target covers what code shows; these are for what the system reads from a bundle. With
7
+ * `plist`, the table is `InfoPlist`: the permission prompts and names in that Info.plist, an entry
8
+ * per key it has, which its own text must match in the source locale.
9
+ */
10
+ import { existsSync, readFileSync } from 'node:fs';
11
+ import { join, relative } from 'node:path';
12
+
13
+ import type { Catalog, Message, Problem } from '#src/tools/catalog.ts';
14
+ import type { Entry } from '#src/tools/compile.ts';
15
+ import type { NativeTarget } from '#src/tools/config.ts';
16
+ import { staticLiteral, TYPE, type MessageFormatElement } from '#src/tools/icu.ts';
17
+ import { checkNames } from './emit.ts';
18
+ import { fileOf, foundIn, LEAVE_OUT, restOf, wordsFor } from './model.ts';
19
+ import { checkPlural, formatString, noteOf, type PluralHolder, type Printf } from './printf.ts';
20
+
21
+ /** Text inside a `.strings` file's quotes; `%` doubled where the string has arguments. */
22
+ function escape(text: string, formatted: boolean): string {
23
+ const out = text.replaceAll('\\', '\\\\').replaceAll('"', '\\"').replaceAll('\n', '\\n').replaceAll('\r', '\\r').replaceAll('\t', '\\t');
24
+ return formatted ? out.replaceAll('%', '%%') : out;
25
+ }
26
+
27
+ const PRINTF: Printf = {
28
+ platform: 'Apple',
29
+ formatter: 'String(format:)',
30
+ holder: '.strings files',
31
+ string: '@',
32
+ count: 'lld',
33
+ escape,
34
+ // A plural at the top goes to the .stringsdict; one under a select never gets here, as the select fails first.
35
+ plural: (name) => `{${name}} is a plural inside another, which a .strings file can't hold: say it without, ${LEAVE_OUT}`,
36
+ };
37
+
38
+ const xml = (text: string) => text.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;');
39
+
40
+ const DICT: Printf = {
41
+ ...PRINTF,
42
+ holder: '.stringsdict files',
43
+ escape: (text, formatted) => (formatted ? xml(text).replaceAll('%', '%%') : xml(text)),
44
+ plural: (name) =>
45
+ `{${name}} is a plural inside another plural's arm, which a .stringsdict here can't hold: write a key per case and pick one in code, ${LEAVE_OUT}`,
46
+ };
47
+
48
+ const PLURALS: PluralHolder = { platform: 'Apple', plurals: "a .stringsdict's plurals", exact: '' };
49
+
50
+ /**
51
+ * A message holding plurals as a `.stringsdict` entry: its format, with each plural a variable
52
+ * (`%1$#@count@`) whose arms the system picks by the count's category, from its own CLDR rules.
53
+ */
54
+ function dictEntry(name: string, ast: MessageFormatElement[], args: string[]): string {
55
+ const variables: string[] = [];
56
+ const taken = new Map<string, number>();
57
+ let format = '';
58
+ for (const el of ast) {
59
+ if (el.type !== TYPE.plural) {
60
+ format += formatString([el], args, DICT);
61
+ continue;
62
+ }
63
+ checkPlural(el, PLURALS);
64
+ const base = el.value.replace(/\W/g, '_');
65
+ const seen = (taken.get(base) ?? 0) + 1;
66
+ taken.set(base, seen);
67
+ const variable = seen === 1 ? base : `${base}${seen}`;
68
+ format += `%${args.indexOf(el.value) + 1}$#@${variable}@`;
69
+ const arms = Object.entries(el.options).map(
70
+ ([category, option]) =>
71
+ ` <key>${category}</key>\n <string>${formatString(option.value, args, DICT, el.value)}</string>\n`,
72
+ );
73
+ variables.push(
74
+ ` <key>${variable}</key>\n <dict>\n <key>NSStringFormatSpecTypeKey</key>\n <string>NSStringPluralRuleType</string>\n <key>NSStringFormatValueTypeKey</key>\n <string>${DICT.count}</string>\n${arms.join('')} </dict>\n`,
75
+ );
76
+ }
77
+ return ` <key>${xml(name)}</key>\n <dict>\n <key>NSStringLocalizedFormatKey</key>\n <string>${format}</string>\n${variables.join('')} </dict>\n`;
78
+ }
79
+
80
+ const ENTITIES: Record<string, string> = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'" };
81
+
82
+ /** An XML plist's string values by key, read as its text is: every `<key>` followed by a `<string>`. */
83
+ function plistStrings(xml: string): Map<string, string> {
84
+ const strings = new Map<string, string>();
85
+ for (const [, key, value] of xml.matchAll(/<key>([^<]*)<\/key>\s*(?:<string>([^<]*)<\/string>|<string\/>)/g)) {
86
+ strings.set(
87
+ key!,
88
+ (value ?? '').replace(/&(amp|lt|gt|quot|apos|#x?[0-9a-f]+);/gi, (_, e: string) =>
89
+ e.startsWith('#')
90
+ ? String.fromCodePoint(Number.parseInt(e.slice(/x/i.test(e[1]!) ? 2 : 1), /x/i.test(e[1]!) ? 16 : 10))
91
+ : ENTITIES[e.toLowerCase()]!,
92
+ ),
93
+ );
94
+ }
95
+ return strings;
96
+ }
97
+
98
+ /** The files `target` writes, by path, or a problem for every message it can't hold. */
99
+ export function appleStrings(
100
+ target: NativeTarget & { name: string },
101
+ root: string,
102
+ catalog: Catalog,
103
+ locales: string[],
104
+ entries: Map<string, Entry[]>,
105
+ banner: string,
106
+ ): { files: Map<string, string>; problems: Problem[] } {
107
+ const source = locales[0]!;
108
+ const asts = new Map([...entries].map(([locale, list]) => [locale, new Map(list)]));
109
+ const problems: Problem[] = [];
110
+ const error = (m: Message, message: string, file = m.file) =>
111
+ problems.push({ severity: 'error', file, key: m.key, message: `apple: ${message}` });
112
+
113
+ const plist = target.plist;
114
+ const shown = plist === undefined ? '' : relative(root, plist).replaceAll('\\', '/');
115
+ if (plist !== undefined && !existsSync(plist)) throw new Error(`apple: ${shown} is missing: name the Info.plist the table translates`);
116
+ const strings = plist === undefined ? undefined : plistStrings(readFileSync(plist, 'utf8'));
117
+ const byLower = new Map([...(strings?.keys() ?? [])].map((k) => [k.toLowerCase(), k]));
118
+
119
+ // Under `plist`, a key's words spell the plist key, which catalog keys can't hold as written.
120
+ const taken = catalog.messages.flatMap((m) => {
121
+ const rest = restOf(target, m.key);
122
+ if (rest === undefined) return [];
123
+ if (strings === undefined) return [{ m, name: rest }];
124
+ const name = byLower.get(wordsFor(target, m.key)!.join('').toLowerCase());
125
+ if (name === undefined) {
126
+ error(
127
+ m,
128
+ `${m.key} names no string in ${shown}: spell one of its keys, as ns-camera-usage-description is NSCameraUsageDescription, or add it there`,
129
+ );
130
+ return [];
131
+ }
132
+ return [{ m, name }];
133
+ });
134
+ checkNames(
135
+ 'apple',
136
+ taken.map(({ m, name }) => [m.key, name]),
137
+ [],
138
+ );
139
+
140
+ for (const { m, name } of taken) {
141
+ if (strings === undefined) continue;
142
+ if (m.args.size > 0) {
143
+ error(m, `${name} is shown as it is written, with nothing filled in: say it without {${[...m.args.keys()].join('}, {')}}`);
144
+ continue;
145
+ }
146
+ // The plist's own text shows where no .lproj matches, so it can't be left to say something else.
147
+ const text = staticLiteral(asts.get(source)?.get(m.key) ?? []);
148
+ if (text !== undefined && text !== strings.get(name))
149
+ error(m, `${shown} says ${JSON.stringify(strings.get(name))} for ${name}: make it ${JSON.stringify(text)}, as the catalog says`);
150
+ }
151
+
152
+ const lines = new Map<string, string[]>(locales.map((l) => [l, []]));
153
+ const dicts = new Map<string, string[]>(locales.map((l) => [l, []]));
154
+ const failed = new Set(problems.map((p) => p.key));
155
+ for (const { m, name } of taken) {
156
+ if (failed.has(m.key)) continue;
157
+ const args = [...m.args.keys()];
158
+ const note = noteOf(m);
159
+ const head = note === '' ? '' : `/* ${note.replaceAll('*/', '* /')} */\n`;
160
+ const comment = note === '' ? '' : ` <!-- ${note.replace(/-{2,}/g, '-')} -->\n`;
161
+ const written = new Map<string, { dict: boolean; entry: string }>();
162
+ for (const locale of locales) {
163
+ const found = foundIn(m.key, locale, locales, asts);
164
+ if (found === undefined) continue;
165
+ let entry = written.get(found);
166
+ if (entry === undefined) {
167
+ const ast = asts.get(found)!.get(m.key)!;
168
+ const dict = ast.some((el) => el.type === TYPE.plural);
169
+ try {
170
+ entry = dict
171
+ ? { dict, entry: `${comment}${dictEntry(name, ast, args)}` }
172
+ : { dict, entry: `${head}"${escape(name, false)}" = "${formatString(ast, args, PRINTF)}";\n` };
173
+ } catch (e) {
174
+ error(m, (e as Error).message, fileOf(m, found, source));
175
+ break;
176
+ }
177
+ written.set(found, entry);
178
+ }
179
+ (entry.dict ? dicts : lines).get(locale)!.push(entry.entry);
180
+ }
181
+ }
182
+
183
+ const said = banner.replace(/^\/\/ /, '');
184
+ const files = new Map(
185
+ locales.map((locale) => [
186
+ join(target.out, `${locale}.lproj`, `${target.name}.strings`),
187
+ [`/* ${said} */\n`, ...lines.get(locale)!].join('\n'),
188
+ ]),
189
+ );
190
+ // The system looks a key up in the .stringsdict of the table's name first, so a plural lands there.
191
+ if ([...dicts.values()].some((d) => d.length > 0)) {
192
+ for (const locale of locales) {
193
+ const body = dicts.get(locale)!.join('');
194
+ files.set(
195
+ join(target.out, `${locale}.lproj`, `${target.name}.stringsdict`),
196
+ `<?xml version="1.0" encoding="UTF-8"?>\n<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">\n<!-- ${said.replace(/-{2,}/g, '-')} -->\n<plist version="1.0">\n<dict>\n${body}</dict>\n</plist>\n`,
197
+ );
198
+ }
199
+ }
200
+ return { files, problems };
201
+ }
@@ -0,0 +1,219 @@
1
+ /**
2
+ * A date format taken apart at build time, as `number.ts` takes numbers apart: the text between the
3
+ * fields, how each field is written (a number, padded or not, or a name from a table), and the
4
+ * locale's digits. Read from `Intl.DateTimeFormat` and checked against it on dates that cover every
5
+ * month, weekday and hour before any code is written, so native code says what the browser says, or
6
+ * the build says why it can't.
7
+ *
8
+ * Native code reads the fields of the date as the clock shows them in its time zone, and only the
9
+ * Gregorian calendar: a format that names a time zone, or counts in another calendar, is refused.
10
+ */
11
+
12
+ /** A part of a formatted date. */
13
+ export type DatePiece =
14
+ | { type: 'text'; value: string }
15
+ /** The field as a number, padded with zeros to `pad` digits. `year2` is the year's last two digits. */
16
+ | { type: 'number'; field: 'year' | 'year2' | 'month' | 'day' | 'minute' | 'second'; pad: number }
17
+ /** The hour on the locale's clock: 1 to 12 (`h12`), 0 to 11 (`h11`), 0 to 23 (`h23`) or 1 to 24 (`h24`). */
18
+ | { type: 'hour'; cycle: HourCycle; pad: number }
19
+ /**
20
+ * The field as a name from `names`: a month's January first, a weekday's Sunday first, and the day
21
+ * period's before noon, then after, or one for each hour of the day.
22
+ */
23
+ | { type: 'name'; field: 'month' | 'weekday' | 'dayPeriod'; names: string[] };
24
+
25
+ export type HourCycle = 'h11' | 'h12' | 'h23' | 'h24';
26
+
27
+ export interface DateSpec {
28
+ pieces: DatePiece[];
29
+ /** The numbering system's digits, 0 to 9, where it isn't ASCII's. */
30
+ digits?: string[];
31
+ }
32
+
33
+ /** A date as the clock shows it. */
34
+ export interface DateFields {
35
+ year: number;
36
+ /** 1 to 12. */
37
+ month: number;
38
+ day: number;
39
+ hour: number;
40
+ minute: number;
41
+ second: number;
42
+ /** 0 is Sunday. */
43
+ weekday: number;
44
+ }
45
+
46
+ /** Dates the spec must format as `Intl` does: every month and weekday, midnight, noon and the hours around them, and two-digit years. */
47
+ const SAMPLES: number[] = [
48
+ Date.UTC(2001, 0, 5, 1, 2, 3),
49
+ Date.UTC(2001, 10, 25, 13, 42, 53),
50
+ Date.UTC(1999, 11, 31, 23, 59, 59),
51
+ Date.UTC(2000, 1, 29, 0, 0, 0),
52
+ Date.UTC(2024, 6, 4, 12, 0, 0),
53
+ Date.UTC(2024, 6, 4, 12, 30, 5),
54
+ Date.UTC(1970, 0, 1, 0, 0, 0),
55
+ Date.UTC(2099, 8, 9, 9, 9, 9),
56
+ Date.UTC(2010, 4, 15, 18, 45, 0),
57
+ Date.UTC(2026, 2, 10, 6, 15, 30),
58
+ Date.UTC(2005, 9, 31, 21, 7, 41),
59
+ ...Array.from({ length: 12 }, (_, m) => Date.UTC(2023, m, 1 + m * 2, m * 2, m * 5, m * 4)),
60
+ ...Array.from({ length: 24 }, (_, h) => Date.UTC(2022, 3, 3 + (h % 7), h, 30, 0)),
61
+ ];
62
+
63
+ /** The fields of a date in UTC, which the spec is checked in. */
64
+ export function fieldsOf(date: number): DateFields {
65
+ const d = new Date(date);
66
+ return {
67
+ year: d.getUTCFullYear(),
68
+ month: d.getUTCMonth() + 1,
69
+ day: d.getUTCDate(),
70
+ hour: d.getUTCHours(),
71
+ minute: d.getUTCMinutes(),
72
+ second: d.getUTCSeconds(),
73
+ weekday: d.getUTCDay(),
74
+ };
75
+ }
76
+
77
+ const HOUR_CYCLES = new Set<string>(['h11', 'h12', 'h23', 'h24']);
78
+
79
+ /**
80
+ * What a format with `options` takes in `locale`. Throws a sentence saying what a native target
81
+ * can't reproduce, which the caller puts in front of a fix.
82
+ */
83
+ export function dateSpec(locale: string, options: Intl.DateTimeFormatOptions = {}): DateSpec {
84
+ if (options.timeZone !== undefined) throw new Error(`it names the time zone ${options.timeZone}, which native code can't look up`);
85
+ const format = new Intl.DateTimeFormat(locale, { ...options, timeZone: 'UTC' });
86
+ const resolved = format.resolvedOptions();
87
+ if (resolved.calendar !== 'gregory' && resolved.calendar !== 'iso8601') throw new Error(`it counts in the ${resolved.calendar} calendar`);
88
+
89
+ const native = new Intl.NumberFormat(locale, { numberingSystem: resolved.numberingSystem, useGrouping: false });
90
+ const digits = Array.from({ length: 10 }, (_, d) => native.format(d));
91
+ const ascii = (text: string) => text.replace(/./gu, (c) => (digits.includes(c) ? String(digits.indexOf(c)) : c));
92
+ /** The text `type` takes at `date`, in ASCII digits. */
93
+ const partAt = (date: number, type: string) => ascii(format.formatToParts(date).find((p) => p.type === type)?.value ?? '');
94
+ const isNumber = (text: string) => /^[0-9]+$/.test(text);
95
+ const padOf = (date: number, type: string) => (partAt(date, type).startsWith('0') ? 2 : 1);
96
+
97
+ const pieces: DatePiece[] = [];
98
+ for (const part of format.formatToParts(SAMPLES[1])) {
99
+ switch (part.type) {
100
+ case 'literal':
101
+ pieces.push({ type: 'text', value: part.value });
102
+ break;
103
+ case 'year':
104
+ pieces.push(ascii(part.value) === '01' ? { type: 'number', field: 'year2', pad: 2 } : { type: 'number', field: 'year', pad: 1 });
105
+ break;
106
+ case 'month': {
107
+ const names = Array.from(
108
+ { length: 12 },
109
+ (_, m) => format.formatToParts(Date.UTC(2023, m, 15, 12)).find((p) => p.type === 'month')!.value,
110
+ );
111
+ if (names.every((name) => isNumber(ascii(name))))
112
+ pieces.push({ type: 'number', field: 'month', pad: padOf(Date.UTC(2023, 0, 15, 12), 'month') });
113
+ else pieces.push({ type: 'name', field: 'month', names });
114
+ break;
115
+ }
116
+ case 'day':
117
+ pieces.push({ type: 'number', field: 'day', pad: padOf(Date.UTC(2023, 0, 5, 12), 'day') });
118
+ break;
119
+ case 'weekday':
120
+ // 2023-01-01 is a Sunday.
121
+ pieces.push({
122
+ type: 'name',
123
+ field: 'weekday',
124
+ names: Array.from(
125
+ { length: 7 },
126
+ (_, d) => format.formatToParts(Date.UTC(2023, 0, 1 + d, 12)).find((p) => p.type === 'weekday')!.value,
127
+ ),
128
+ });
129
+ break;
130
+ case 'hour': {
131
+ const cycle = resolved.hourCycle;
132
+ if (cycle === undefined || !HOUR_CYCLES.has(cycle)) throw new Error('its hours follow no clock native code knows');
133
+ pieces.push({ type: 'hour', cycle, pad: padOf(Date.UTC(2023, 0, 5, 1), 'hour') });
134
+ break;
135
+ }
136
+ case 'minute':
137
+ case 'second':
138
+ pieces.push({ type: 'number', field: part.type, pad: padOf(Date.UTC(2023, 0, 5, 1, 5, 5), part.type) });
139
+ break;
140
+ case 'dayPeriod': {
141
+ const byHour = Array.from(
142
+ { length: 24 },
143
+ (_, hour) => format.formatToParts(Date.UTC(2023, 0, 5, hour, 30)).find((p) => p.type === 'dayPeriod')!.value,
144
+ );
145
+ // Before noon and after, or a period by the hour (`zh-TW`'s 晚上, English's "at night").
146
+ const halves = byHour.every((name, hour) => name === byHour[hour < 12 ? 0 : 12]);
147
+ pieces.push({ type: 'name', field: 'dayPeriod', names: halves ? [byHour[0]!, byHour[12]!] : byHour });
148
+ break;
149
+ }
150
+ case 'era':
151
+ case 'fractionalSecond':
152
+ case 'timeZoneName':
153
+ case 'unknown':
154
+ // A calendar's own parts, such as `relatedYear`, which the calendar check has already refused.
155
+ default:
156
+ throw new Error(`it writes the ${part.type as string}, which native code can't`);
157
+ }
158
+ }
159
+ const spec: DateSpec = { pieces, ...(digits.join('') !== '0123456789' && { digits }) };
160
+ for (const sample of SAMPLES) {
161
+ const expected = format.format(sample);
162
+ const actual = formatDate(spec, fieldsOf(sample));
163
+ if (actual !== expected)
164
+ throw new Error(`it writes ${new Date(sample).toISOString()} as "${expected}", which native code would write as "${actual}"`);
165
+ }
166
+ return spec;
167
+ }
168
+
169
+ /** The hour as a clock with `cycle` shows it. */
170
+ export function hourOn(cycle: HourCycle, hour: number): number {
171
+ if (cycle === 'h23') return hour;
172
+ if (cycle === 'h24') return hour === 0 ? 24 : hour;
173
+ if (cycle === 'h11') return hour % 12;
174
+ return ((hour + 11) % 12) + 1;
175
+ }
176
+
177
+ /** A date field as a target reads it, an integer expression. */
178
+ export type FieldAccess = (field: keyof DateFields) => string;
179
+
180
+ /**
181
+ * A piece's number, or a name piece's index into its names, as an expression in the integer
182
+ * arithmetic Rust, Swift and Kotlin share. Keep in step with `formatDate`.
183
+ */
184
+ export function pieceExpr(piece: Exclude<DatePiece, { type: 'text' }>, at: FieldAccess): string {
185
+ if (piece.type === 'hour') {
186
+ const hour = at('hour');
187
+ if (piece.cycle === 'h23') return hour;
188
+ if (piece.cycle === 'h24') return `(${hour} + 23) % 24 + 1`;
189
+ return piece.cycle === 'h11' ? `${hour} % 12` : `(${hour} + 11) % 12 + 1`;
190
+ }
191
+ if (piece.type === 'number') return piece.field === 'year2' ? `(${at('year')} % 100 + 100) % 100` : at(piece.field);
192
+ if (piece.field === 'month') return `${at('month')} - 1`;
193
+ if (piece.field === 'weekday') return at('weekday');
194
+ return piece.names.length === 24 ? at('hour') : `${at('hour')} / 12`;
195
+ }
196
+
197
+ /** Which of a name piece's `count` names `date` takes. */
198
+ function nameIndex(field: 'month' | 'weekday' | 'dayPeriod', count: number, date: DateFields): number {
199
+ if (field === 'month') return date.month - 1;
200
+ if (field === 'weekday') return date.weekday;
201
+ return count === 24 ? date.hour : date.hour < 12 ? 0 : 1;
202
+ }
203
+
204
+ /** `date` as the spec writes it: the reference for the code each target generates. */
205
+ export function formatDate(spec: DateSpec, date: DateFields): string {
206
+ const digits = spec.digits;
207
+ const number = (value: number, pad: number) => {
208
+ const text = String(value).padStart(pad, '0');
209
+ return digits === undefined ? text : text.replace(/[0-9]/g, (d) => digits[Number(d)]!);
210
+ };
211
+ return spec.pieces
212
+ .map((piece) => {
213
+ if (piece.type === 'text') return piece.value;
214
+ if (piece.type === 'hour') return number(hourOn(piece.cycle, date.hour), piece.pad);
215
+ if (piece.type === 'number') return number(piece.field === 'year2' ? ((date.year % 100) + 100) % 100 : date[piece.field], piece.pad);
216
+ return piece.names[nameIndex(piece.field, piece.names.length, date)]!;
217
+ })
218
+ .join('');
219
+ }
@@ -1,10 +1,13 @@
1
1
  /**
2
- * The native files `generate` writes beside the TypeScript, one per target in the config's `native`.
3
- * A message a target can't render fails the build with the reason, as a translation that drifted does.
2
+ * The native files `generate` writes beside the TypeScript, one per target in the config's `native`
3
+ * (Android's and Apple's string resources, a file per locale). A message a target can't render
4
+ * fails the build with the reason, as a translation that drifted does.
4
5
  */
5
6
  import { CatalogError, type Catalog, type Problem } from '#src/tools/catalog.ts';
6
7
  import type { Entry } from '#src/tools/compile.ts';
7
8
  import type { ResolvedConfig } from '#src/tools/config.ts';
9
+ import { androidResources } from './android.ts';
10
+ import { appleStrings } from './apple.ts';
8
11
  import { kotlinFile } from './kotlin.ts';
9
12
  import { nativeModel } from './model.ts';
10
13
  import { rustModule } from './rust.ts';
@@ -22,10 +25,19 @@ export function nativeFiles(
22
25
  const files = new Map<string, string>();
23
26
  const problems: Problem[] = [];
24
27
  for (const target of config.native) {
25
- const { model, problems: found } = nativeModel(target, catalog, locales, entries);
26
- problems.push(...found);
27
- if (found.length > 0) continue;
28
28
  try {
29
+ if (target.language === 'android' || target.language === 'apple') {
30
+ const { files: written, problems: found } =
31
+ target.language === 'android'
32
+ ? androidResources(target, catalog, locales, entries, banner)
33
+ : appleStrings(target, config.root, catalog, locales, entries, banner);
34
+ problems.push(...found);
35
+ for (const [path, content] of written) files.set(path, content);
36
+ continue;
37
+ }
38
+ const { model, problems: found } = nativeModel(target, catalog, locales, entries);
39
+ problems.push(...found);
40
+ if (found.length > 0) continue;
29
41
  files.set(target.out, WRITERS[target.language](model, target, banner));
30
42
  } catch (e) {
31
43
  problems.push({ severity: 'error', message: (e as Error).message });
@@ -3,6 +3,7 @@
3
3
  * a file-private object holding what formats them. For the JVM and Android.
4
4
  */
5
5
  import type { NativeTarget } from '#src/tools/config.ts';
6
+ import { pieceExpr, type DateSpec } from './date.ts';
6
7
  import { camel, checkNames, conditionCode, float, leading, stringLiteral, upper, wordsOf } from './emit.ts';
7
8
  import type { NativeMessage, NativeModel, Part } from './model.ts';
8
9
  import type { NumberSpec } from './number.ts';
@@ -36,6 +37,7 @@ function value(part: Exclude<Part, { type: 'text' }>, indent: string, rt: string
36
37
  const of = part.offset === 0 ? argName(part.arg) : `${argName(part.arg)} - ${float(part.offset)}`;
37
38
  return `${rt}.number(${of}, ${rt}.F${part.format}, ${lit(part.fallback)})`;
38
39
  }
40
+ if (part.type === 'date') return `${rt}.d${part.format}(${argName(part.arg)})`;
39
41
  if (part.type === 'select') {
40
42
  const arms = part.arms.map((a) => `${inner}${lit(a.key)} -> ${expr(a.parts, inner, rt)}\n`).join('');
41
43
  return `when (${argName(part.arg)}) {\n${arms}${inner}else -> ${expr(part.other, inner, rt)}\n${indent}}`;
@@ -59,10 +61,17 @@ function member(m: NativeMessage, name: string, rt: string): string {
59
61
  const head = doc(argDocs.length > 0 ? [...m.doc, '', ...argDocs] : m.doc, ' ');
60
62
  const body = `when (this) {\n${branches} }`;
61
63
  if (m.args.length === 0) return `${head} val ${name}: String\n get() = ${body}\n`;
62
- const params = m.args.map((a) => `${argName(a.name)}: ${a.type === 'number' ? 'Number' : 'String'}`).join(', ');
64
+ const params = m.args
65
+ .map((a) => `${argName(a.name)}: ${a.type === 'number' ? 'Number' : a.type === 'date' ? 'java.time.Instant' : 'String'}`)
66
+ .join(', ');
63
67
  const numbers = m.args
64
- .filter((a) => a.type === 'number')
65
- .map((a) => ` val ${argName(a.name)} = ${argName(a.name)}.toDouble()\n`)
68
+ .map((a) =>
69
+ a.type === 'number'
70
+ ? ` val ${argName(a.name)} = ${argName(a.name)}.toDouble()\n`
71
+ : a.type === 'date'
72
+ ? ` val ${argName(a.name)} = ${argName(a.name)}.atZone(java.time.ZoneId.systemDefault())\n`
73
+ : '',
74
+ )
66
75
  .join('');
67
76
  return `${head} fun ${name}(${params}): String {\n${numbers} return ${body}\n }\n`;
68
77
  }
@@ -90,6 +99,38 @@ function formatConst(spec: NumberSpec, i: number): string {
90
99
  return ` val F${i} = Format(\n${fields.map((f) => ` ${f}`).join(',\n')},\n )\n`;
91
100
  }
92
101
 
102
+ const FIELDS: Record<string, string> = {
103
+ year: 't.year',
104
+ month: 't.monthValue',
105
+ day: 't.dayOfMonth',
106
+ hour: 't.hour',
107
+ minute: 't.minute',
108
+ second: 't.second',
109
+ weekday: '(t.dayOfWeek.value % 7)',
110
+ };
111
+
112
+ /** A date format as a function of the date in the system's time zone. */
113
+ function dateFn(spec: DateSpec, i: number): string {
114
+ const digits = spec.digits === undefined ? 'null' : `G${i}`;
115
+ const at = (field: string) => FIELDS[field]!;
116
+ const lines = spec.pieces.map((piece) => {
117
+ if (piece.type === 'text') return `append(${lit(piece.value)})`;
118
+ if (piece.type === 'name') return `append(arrayOf(${piece.names.map(lit).join(', ')})[${pieceExpr(piece, at)}])`;
119
+ return `append(pad(${pieceExpr(piece, at)}, ${piece.pad}, ${digits}))`;
120
+ });
121
+ const table = spec.digits === undefined ? '' : ` private val G${i} = arrayOf(${spec.digits.map(lit).join(', ')})\n`;
122
+ const body = lines.map((l) => ` ${l}\n`).join('');
123
+ return `${table} fun d${i}(t: java.time.ZonedDateTime): String = buildString {\n${body} }\n`;
124
+ }
125
+
126
+ const DATE = `
127
+ /** \`value\` written with at least \`width\` digits, in \`digits\` where the locale has its own. */
128
+ private fun pad(value: Int, width: Int, digits: Array<String>?): String {
129
+ val text = value.toString().padStart(width, '0')
130
+ return if (digits == null) text else text.map { if (it in '0'..'9') digits[it - '0'] else it.toString() }.joinToString("")
131
+ }
132
+ `;
133
+
93
134
  const NUMBER = `
94
135
  /** A number format, taken apart when the file was generated. */
95
136
  class Format(
@@ -225,12 +266,12 @@ export function kotlinFile(model: NativeModel, target: NativeTarget & { name: st
225
266
  })
226
267
  .join('\n');
227
268
  const runtime =
228
- model.formats.length + model.rules.length === 0
269
+ model.formats.length + model.rules.length + model.dates.length === 0
229
270
  ? ''
230
271
  : `
231
272
  /** What formats the messages, generated with them. */
232
- private object ${rt} {${NUMBER}${model.rules.length > 0 ? PLURAL : ''}
233
- ${model.formats.map(formatConst).join('\n')}${rules === '' ? '' : `\n${rules}`}}
273
+ private object ${rt} {${model.formats.length + model.rules.length > 0 ? NUMBER : ''}${model.rules.length > 0 ? PLURAL : ''}${model.dates.length > 0 ? DATE : ''}
274
+ ${[...model.formats.map(formatConst), ...model.dates.map(dateFn)].join('\n')}${rules === '' ? '' : `\n${rules}`}}
234
275
  `;
235
276
 
236
277
  return `${banner}