prosebook 0.4.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 (122) hide show
  1. package/README.md +288 -28
  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 +11 -2
  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-CpUIWb9j.d.ts → bundle-CLHY_97D.d.ts} +2 -2
  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-nWZf9QmF.js → config-7T4hX_f7.js} +5 -3
  17. package/dist/{config-nWZf9QmF.js.map → config-7T4hX_f7.js.map} +1 -1
  18. package/dist/config-CzaJJQ60.d.ts.map +1 -1
  19. package/dist/config.js +1 -1
  20. package/dist/{copy-B3V_QRkT.d.ts → copy-BXn0aYQn.d.ts} +37 -3
  21. package/dist/copy-BXn0aYQn.d.ts.map +1 -0
  22. package/dist/copy-Bv6S-Hr2.js +235 -0
  23. package/dist/copy-Bv6S-Hr2.js.map +1 -0
  24. package/dist/endpoints-Dhgowovz.js +217 -0
  25. package/dist/endpoints-Dhgowovz.js.map +1 -0
  26. package/dist/generate-1BeQAd1O.d.ts +72 -0
  27. package/dist/generate-1BeQAd1O.d.ts.map +1 -0
  28. package/dist/{generate-BW3N8EGb.js → generate-CKv86Iki.js} +1091 -309
  29. package/dist/generate-CKv86Iki.js.map +1 -0
  30. package/dist/index.d.ts +2 -2
  31. package/dist/index.js +2 -2
  32. package/dist/next-loader.d.ts +9 -2
  33. package/dist/next-loader.d.ts.map +1 -1
  34. package/dist/next-loader.js +12 -3
  35. package/dist/next-loader.js.map +1 -1
  36. package/dist/next.d.ts +10 -2
  37. package/dist/next.d.ts.map +1 -1
  38. package/dist/next.js +94 -21
  39. package/dist/next.js.map +1 -1
  40. package/dist/preact/index.d.ts +34 -0
  41. package/dist/preact/index.d.ts.map +1 -0
  42. package/dist/preact/index.js +72 -0
  43. package/dist/preact/index.js.map +1 -0
  44. package/dist/react/index.d.ts +5 -14
  45. package/dist/react/index.d.ts.map +1 -1
  46. package/dist/react/index.js +9 -33
  47. package/dist/react/index.js.map +1 -1
  48. package/dist/react/server.d.ts +1 -1
  49. package/dist/react/server.js +1 -1
  50. package/dist/{sarif-B5nrMqrT.js → sarif-BnwYC78_.js} +8 -98
  51. package/dist/sarif-BnwYC78_.js.map +1 -0
  52. package/dist/{scan-Bx0_yDSi.js → scan-C0ap4Mpp.js} +145 -20
  53. package/dist/scan-C0ap4Mpp.js.map +1 -0
  54. package/dist/solid/index.d.ts +29 -0
  55. package/dist/solid/index.d.ts.map +1 -0
  56. package/dist/solid/index.js +48 -0
  57. package/dist/solid/index.js.map +1 -0
  58. package/dist/svelte/Text.svelte +13 -0
  59. package/dist/svelte/Text.svelte.d.ts +20 -0
  60. package/dist/svelte/index.d.ts +31 -0
  61. package/dist/svelte/index.d.ts.map +1 -0
  62. package/dist/svelte/index.js +35 -0
  63. package/dist/svelte/index.js.map +1 -0
  64. package/dist/tools.d.ts +43 -64
  65. package/dist/tools.d.ts.map +1 -1
  66. package/dist/tools.js +5 -5
  67. package/dist/vite.d.ts +12 -2
  68. package/dist/vite.d.ts.map +1 -1
  69. package/dist/vite.js +82 -25
  70. package/dist/vite.js.map +1 -1
  71. package/dist/vue/index.d.ts +43 -0
  72. package/dist/vue/index.d.ts.map +1 -0
  73. package/dist/vue/index.js +76 -0
  74. package/dist/vue/index.js.map +1 -0
  75. package/package.json +63 -7
  76. package/src/bun.ts +77 -20
  77. package/src/cli.ts +62 -8
  78. package/src/index.ts +1 -1
  79. package/src/next-loader.ts +17 -5
  80. package/src/next.ts +120 -16
  81. package/src/preact/index.ts +91 -0
  82. package/src/react/index.tsx +12 -42
  83. package/src/runtime/bind.ts +96 -0
  84. package/src/runtime/copy.ts +148 -24
  85. package/src/solid/index.ts +67 -0
  86. package/src/svelte/Text.svelte +13 -0
  87. package/src/svelte/Text.svelte.d.ts +20 -0
  88. package/src/svelte/index.ts +65 -0
  89. package/src/tools/bundle.ts +143 -2
  90. package/src/tools/catalog.ts +10 -1
  91. package/src/tools/chunks.ts +142 -0
  92. package/src/tools/config.ts +1 -1
  93. package/src/tools/entry.ts +19 -20
  94. package/src/tools/exchange.ts +275 -0
  95. package/src/tools/extract.ts +2 -2
  96. package/src/tools/generate.ts +7 -1
  97. package/src/tools/index.ts +2 -1
  98. package/src/tools/native/android.ts +10 -25
  99. package/src/tools/native/apple.ts +70 -8
  100. package/src/tools/native/date.ts +219 -0
  101. package/src/tools/native/kotlin.ts +47 -6
  102. package/src/tools/native/model.ts +42 -16
  103. package/src/tools/native/printf.ts +46 -4
  104. package/src/tools/native/rust.ts +86 -5
  105. package/src/tools/native/swift.ts +62 -6
  106. package/src/tools/scan.ts +146 -25
  107. package/src/tools/todo.ts +6 -1
  108. package/src/vite.ts +121 -28
  109. package/src/vue/index.ts +117 -0
  110. package/dist/bundle-CpUIWb9j.d.ts.map +0 -1
  111. package/dist/bundle-DboItAre.js +0 -111
  112. package/dist/bundle-DboItAre.js.map +0 -1
  113. package/dist/compile-ClK9htKr.d.ts +0 -16
  114. package/dist/compile-ClK9htKr.d.ts.map +0 -1
  115. package/dist/copy-B3V_QRkT.d.ts.map +0 -1
  116. package/dist/copy-Bo_FIoLy.js +0 -154
  117. package/dist/copy-Bo_FIoLy.js.map +0 -1
  118. package/dist/endpoints-BgG_5Vpm.js +0 -96
  119. package/dist/endpoints-BgG_5Vpm.js.map +0 -1
  120. package/dist/generate-BW3N8EGb.js.map +0 -1
  121. package/dist/sarif-B5nrMqrT.js.map +0 -1
  122. package/dist/scan-Bx0_yDSi.js.map +0 -1
@@ -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
+ }
@@ -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}
@@ -9,7 +9,8 @@ import { chainOf } from '#src/runtime/locale.ts';
9
9
  import type { Catalog, Message, Problem } from '#src/tools/catalog.ts';
10
10
  import type { Entry } from '#src/tools/compile.ts';
11
11
  import type { NativeTarget } from '#src/tools/config.ts';
12
- import { numberOptions, optionArgsOf, placeholderOf, selectionOf, TYPE, type MessageFormatElement } from '#src/tools/icu.ts';
12
+ import { dateOptions, numberOptions, optionArgsOf, placeholderOf, selectionOf, TYPE, type MessageFormatElement } from '#src/tools/icu.ts';
13
+ import { dateSpec, type DateSpec } from './date.ts';
13
14
  import { docText } from './emit.ts';
14
15
  import { numberSpec, roundingOf, type NumberSpec, type Rounding } from './number.ts';
15
16
  import { pluralRules, type PluralRules } from './plurals.ts';
@@ -19,6 +20,8 @@ export type Part =
19
20
  | { type: 'string'; arg: string }
20
21
  /** The argument less `offset`, in format `format`; `fallback` where it isn't a number. */
21
22
  | { type: 'number'; arg: string; offset: number; format: number; fallback: string }
23
+ /** The argument, a date, in date format `format`. */
24
+ | { type: 'date'; arg: string; format: number }
22
25
  | { type: 'select'; arg: string; arms: Arm[]; other: Part[] }
23
26
  | {
24
27
  type: 'plural';
@@ -45,7 +48,7 @@ export interface NativeMessage {
45
48
  words: string[];
46
49
  /** The source text, its context, and what's asked of it. */
47
50
  doc: string[];
48
- args: { name: string; type: 'string' | 'number'; doc?: string }[];
51
+ args: { name: string; type: 'string' | 'number' | 'date'; doc?: string }[];
49
52
  /** A tree per locale that renders it its own way; every locale is in one. */
50
53
  bodies: { locales: string[]; parts: Part[] }[];
51
54
  }
@@ -56,6 +59,7 @@ export interface NativeModel {
56
59
  source: string;
57
60
  messages: NativeMessage[];
58
61
  formats: NumberSpec[];
62
+ dates: DateSpec[];
59
63
  rules: { name: string; rules: PluralRules }[];
60
64
  }
61
65
 
@@ -106,6 +110,7 @@ export function nativeModel(
106
110
  const source = locales[0]!;
107
111
  const asts = new Map([...entries].map(([locale, list]) => [locale, new Map(list)]));
108
112
  const formats = new Numbered<NumberSpec>();
113
+ const dates = new Numbered<DateSpec>();
109
114
  const rules = new Numbered<{ name: string; rules: PluralRules }>();
110
115
  const problems: Problem[] = [];
111
116
  const messages: NativeMessage[] = [];
@@ -124,7 +129,7 @@ export function nativeModel(
124
129
  continue;
125
130
  }
126
131
  try {
127
- bodies.set(found, { locales: [locale], parts: parts(asts.get(found)!.get(m.key)!, found, formats, rules, null) });
132
+ bodies.set(found, { locales: [locale], parts: parts(asts.get(found)!.get(m.key)!, found, { formats, dates, rules }, null) });
128
133
  } catch (e) {
129
134
  failed = true;
130
135
  problems.push({
@@ -142,13 +147,13 @@ export function nativeModel(
142
147
  doc: docOf(m),
143
148
  args: [...m.args].map(([name, type]) => ({
144
149
  name,
145
- type: type === 'number' ? 'number' : 'string',
150
+ type: type === 'number' ? 'number' : type === 'Date | number' ? 'date' : 'string',
146
151
  doc: m.argDocs.has(name) ? docText(m.argDocs.get(name)!) : undefined,
147
152
  })),
148
153
  bodies: [...bodies.values()],
149
154
  });
150
155
  }
151
- return { model: { locales, source, messages, formats: formats.list, rules: rules.list }, problems };
156
+ return { model: { locales, source, messages, formats: formats.list, dates: dates.list, rules: rules.list }, problems };
152
157
  }
153
158
 
154
159
  export function fileOf(m: Message, locale: string, source: string): string {
@@ -168,6 +173,33 @@ export const LEAVE_OUT = "or leave the key out of this target's `keys`";
168
173
  /** Specs by locale and options, kept across runs: taking a format apart checks it against `Intl` dozens of times. */
169
174
  const SPECS = new Map<string, NumberSpec | Error>();
170
175
 
176
+ /** What a file declares, numbered as the messages use it. */
177
+ interface Declared {
178
+ formats: Numbered<NumberSpec>;
179
+ dates: Numbered<DateSpec>;
180
+ rules: Numbered<{ name: string; rules: PluralRules }>;
181
+ }
182
+
183
+ /** Date specs as `SPECS` keeps number specs. */
184
+ const DATE_SPECS = new Map<string, DateSpec | Error>();
185
+
186
+ function date(dates: Numbered<DateSpec>, locale: string, options: Intl.DateTimeFormatOptions | undefined): number {
187
+ const key = JSON.stringify([locale, options ?? {}]);
188
+ let spec = DATE_SPECS.get(key);
189
+ if (spec === undefined) {
190
+ try {
191
+ spec = dateSpec(locale, options);
192
+ } catch (e) {
193
+ spec = new Error(`native code can't format this date in ${locale}: ${(e as Error).message}. Format it another way, ${LEAVE_OUT}`, {
194
+ cause: e,
195
+ });
196
+ }
197
+ DATE_SPECS.set(key, spec);
198
+ }
199
+ if (spec instanceof Error) throw spec;
200
+ return dates.add(spec);
201
+ }
202
+
171
203
  function format(formats: Numbered<NumberSpec>, locale: string, options: Intl.NumberFormatOptions | undefined): number {
172
204
  const key = JSON.stringify([locale, options ?? {}]);
173
205
  let spec = SPECS.get(key);
@@ -187,13 +219,8 @@ function format(formats: Numbered<NumberSpec>, locale: string, options: Intl.Num
187
219
 
188
220
  type Pound = { arg: string; offset: number } | null;
189
221
 
190
- function parts(
191
- elements: MessageFormatElement[],
192
- locale: string,
193
- formats: Numbered<NumberSpec>,
194
- rules: Numbered<{ name: string; rules: PluralRules }>,
195
- pound: Pound,
196
- ): Part[] {
222
+ function parts(elements: MessageFormatElement[], locale: string, declared: Declared, pound: Pound): Part[] {
223
+ const { formats, dates, rules } = declared;
197
224
  const out: Part[] = [];
198
225
  const text = (value: string) => {
199
226
  const last = out.at(-1);
@@ -207,7 +234,7 @@ function parts(
207
234
  if (isolate !== undefined) text(isolate[1]);
208
235
  };
209
236
  const arms = (options: Record<string, { value: MessageFormatElement[] }>, own: Pound) =>
210
- Object.entries(options).map(([key, option]) => ({ key, parts: parts(option.value, locale, formats, rules, own) }));
237
+ Object.entries(options).map(([key, option]) => ({ key, parts: parts(option.value, locale, declared, own) }));
211
238
 
212
239
  for (const el of elements) {
213
240
  if (Object.keys(optionArgsOf(el) ?? {}).length > 0) {
@@ -216,9 +243,8 @@ function parts(
216
243
  );
217
244
  }
218
245
  if (isDateElement(el) || isTimeElement(el)) {
219
- throw new Error(
220
- `{${el.value}} is a date, which native code doesn't format: pass it formatted, as a plain {${el.value}}, ${LEAVE_OUT}`,
221
- );
246
+ placed(el, el.value, { type: 'date', arg: el.value, format: date(dates, locale, dateOptions(el)) });
247
+ continue;
222
248
  }
223
249
  if (isNumberElement(el)) {
224
250
  const fallback = placeholderOf(el, el.value).fallback;
@@ -1,13 +1,15 @@
1
1
  /**
2
2
  * Messages as printf-style format strings, for the targets whose platform fills values in itself:
3
- * Android's resources and Apple's `.strings` files. Text and plain `{name}` placeholders, as `%N$s`
3
+ * Android's resources and Apple's `.strings` and `.stringsdict` files. Text and plain `{name}` placeholders, as `%N$s`
4
4
  * with N the argument's place in the message's arguments, so a translation can put them in another
5
5
  * order; anything `Intl` would format fails with the fix.
6
6
  */
7
7
  import { isDateElement, isNumberElement, isTimeElement } from '@formatjs/icu-messageformat-parser';
8
8
 
9
+ import type { PluralElement } from '@formatjs/icu-messageformat-parser';
9
10
  import type { Message } from '#src/tools/catalog.ts';
10
- import { numberOptions, placeholderOf, TYPE, type MessageFormatElement } from '#src/tools/icu.ts';
11
+
12
+ import { numberOptions, placeholderOf, selectionOf, TYPE, type MessageFormatElement } from '#src/tools/icu.ts';
11
13
  import { docText } from './emit.ts';
12
14
  import { LEAVE_OUT } from './model.ts';
13
15
 
@@ -19,6 +21,8 @@ export interface Printf {
19
21
  holder: string;
20
22
  /** A string argument's conversion: `s`, or `@` for Apple's objects. */
21
23
  string: string;
24
+ /** A count's conversion: `d`, or `lld` for Apple's 64-bit `Int`. */
25
+ count: string;
22
26
  /** Text as the file holds it; `formatted` when the string has arguments, so `%` must be doubled. */
23
27
  escape: (text: string, formatted: boolean) => string;
24
28
  /** The problem with a plural the target can't hold where it is. */
@@ -42,7 +46,45 @@ function isCount(el: MessageFormatElement, count: string): boolean {
42
46
  return options.length === 0 || (options.length === 1 && options[0]![0] === 'maximumFractionDigits' && options[0]![1] === 0);
43
47
  }
44
48
 
45
- /** `elements` as a format string over `args`; inside a plural, its `count` is `%N$d`. */
49
+ /** What a platform's plurals are called in problems, and what they can't hold. */
50
+ export interface PluralHolder {
51
+ /** `Android`, as in "Android selects on the count as an integer". */
52
+ platform: string;
53
+ /** `Android's <plurals>`. */
54
+ plurals: string;
55
+ /** Why an `=N` arm has no place, after "have none for one number". */
56
+ exact: string;
57
+ }
58
+
59
+ /**
60
+ * Throws, with the fix, where a plural asks for more than a platform's plurals pick on: one count,
61
+ * as an integer, by its cardinal category.
62
+ */
63
+ export function checkPlural(plural: PluralElement, holder: PluralHolder): void {
64
+ const named = `{${plural.value}}`;
65
+ if (plural.pluralType === 'ordinal') {
66
+ throw new Error(
67
+ `${named} is an ordinal, and ${holder.plurals} are cardinal only: write a key per case and pick one in code, ${LEAVE_OUT}`,
68
+ );
69
+ }
70
+ if (plural.offset !== 0)
71
+ throw new Error(`${named} has an offset, which ${holder.plurals} don't: count without it, and say the rest in each arm, ${LEAVE_OUT}`);
72
+
73
+ const selection = selectionOf(plural);
74
+ if (selection !== undefined && (selection.fn.name !== 'integer' || Object.keys(selection.fn.options).length > 0)) {
75
+ throw new Error(
76
+ `${named} selects with :${selection.fn.name} and its options, and ${holder.platform} selects on the count as an integer: select with a bare :number or :integer, ${LEAVE_OUT}`,
77
+ );
78
+ }
79
+ const exact = Object.keys(plural.options).filter((k) => k.startsWith('='));
80
+ if (exact.length > 0) {
81
+ throw new Error(
82
+ `${named} has the arm ${exact.join(', ')}, and ${holder.plurals} have none for one number${holder.exact}: say it in a category's arm, or write a key for it and pick that in code, ${LEAVE_OUT}`,
83
+ );
84
+ }
85
+ }
86
+
87
+ /** `elements` as a format string over `args`; inside a plural, its `count` is `%N$d` (or `lld`). */
46
88
  export function formatString(elements: MessageFormatElement[], args: string[], printf: Printf, count?: string): string {
47
89
  const formatted = args.length > 0;
48
90
  const place = (el: MessageFormatElement, name: string, conversion: string) => {
@@ -55,7 +97,7 @@ export function formatString(elements: MessageFormatElement[], args: string[], p
55
97
  let out = '';
56
98
  for (const el of elements) {
57
99
  if (count !== undefined && isCount(el, count)) {
58
- out += place(el, count, 'd');
100
+ out += place(el, count, printf.count);
59
101
  continue;
60
102
  }
61
103
  if (isDateElement(el) || isTimeElement(el)) {
@@ -3,6 +3,7 @@
3
3
  * a private `rt` module, so no argument a message names can shadow it.
4
4
  */
5
5
  import type { NativeTarget } from '#src/tools/config.ts';
6
+ import { pieceExpr, type DateSpec } from './date.ts';
6
7
  import { checkNames, conditionCode, float, leading, pascal, snake, stringLiteral, wordsOf } from './emit.ts';
7
8
  import type { NativeMessage, NativeModel, Part } from './model.ts';
8
9
  import type { NumberSpec } from './number.ts';
@@ -50,6 +51,7 @@ function value(part: Exclude<Part, { type: 'text' }>, indent: string): string {
50
51
  const of = part.offset === 0 ? argName(part.arg) : `${argName(part.arg)} - ${float(part.offset)}`;
51
52
  return `rt::number(${of}, &rt::F${part.format}, ${lit(part.fallback)})`;
52
53
  }
54
+ if (part.type === 'date') return `rt::D${part.format}(&${argName(part.arg)})`;
53
55
  if (part.type === 'select') {
54
56
  const arms = part.arms.map((a) => `${inner}${lit(a.key)} => ${expr(a.parts, inner)},\n`).join('');
55
57
  return `match ${argName(part.arg)} {\n${arms}${inner}_ => ${expr(part.other, inner)},\n${indent}}`;
@@ -74,7 +76,9 @@ function method(m: NativeMessage, name: string, enumName: string): string {
74
76
  const isStatic = m.bodies.every((b) => b.parts.every((p) => p.type === 'text'));
75
77
  if (isStatic && m.args.length === 0)
76
78
  return `${head} pub fn ${name}(self) -> &'static str {\n match self {\n${arms((parts) => lit(parts[0]?.type === 'text' ? parts[0].value : ''))} }\n }\n`;
77
- const params = m.args.map((a) => `, ${argName(a.name)}: ${a.type === 'number' ? 'impl Number' : '&str'}`).join('');
79
+ const params = m.args
80
+ .map((a) => `, ${argName(a.name)}: ${a.type === 'number' ? 'impl Number' : a.type === 'date' ? 'DateTime' : '&str'}`)
81
+ .join('');
78
82
  const argDocs = m.args.filter((a) => a.doc !== undefined).map((a) => `* \`${argName(a.name)}\`: ${a.doc}`);
79
83
  const docs = argDocs.length > 0 ? `${head} ///\n${doc(argDocs, ' ')}` : head;
80
84
  const numbers = m.args
@@ -107,6 +111,81 @@ function formatConst(spec: NumberSpec, i: number): string {
107
111
  return ` pub const F${i}: Format = Format {\n${fields.map((f) => ` ${f},\n`).join('')} };\n`;
108
112
  }
109
113
 
114
+ /** A date format as a function of the date. */
115
+ function dateFn(spec: DateSpec, i: number): string {
116
+ const digits = spec.digits === undefined ? 'None' : `Some(G${i})`;
117
+ const at = (field: string) => (field === 'weekday' ? 't.weekday() as i64' : `t.${field} as i64`);
118
+ const lines = spec.pieces.map((piece) => {
119
+ if (piece.type === 'text') return `s += ${lit(piece.value)};`;
120
+ if (piece.type === 'name') return `s += [${piece.names.map(lit).join(', ')}][(${pieceExpr(piece, at)}) as usize];`;
121
+ return `s += &pad(${pieceExpr(piece, at)}, ${piece.pad}, ${digits});`;
122
+ });
123
+ const table = spec.digits === undefined ? '' : ` const G${i}: [&str; 10] = [${spec.digits.map(lit).join(', ')}];\n`;
124
+ const body = lines.map((l) => ` ${l}\n`).join('');
125
+ return `${table} pub fn D${i}(t: &super::DateTime) -> String {\n let mut s = String::new();\n${body} s\n }\n`;
126
+ }
127
+
128
+ const DATE = `
129
+ /// \`value\` written with at least \`width\` digits, in \`digits\` where the locale has its own.
130
+ fn pad(value: i64, width: usize, digits: Option<[&'static str; 10]>) -> String {
131
+ let text = format!("{:0width$}", value, width = width);
132
+ match digits {
133
+ Some(digits) => text.bytes().map(|b| if b.is_ascii_digit() { digits[(b - b'0') as usize] } else { "-" }).collect(),
134
+ None => text,
135
+ }
136
+ }
137
+ `;
138
+
139
+ const DATE_TIME = `
140
+ /// A date and time as a clock shows it, in the time zone it is shown in.
141
+ #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
142
+ pub struct DateTime {
143
+ pub year: i32,
144
+ /// 1 to 12.
145
+ pub month: u8,
146
+ pub day: u8,
147
+ /// 0 to 23.
148
+ pub hour: u8,
149
+ pub minute: u8,
150
+ pub second: u8,
151
+ }
152
+
153
+ impl DateTime {
154
+ /// The time \`seconds\` after the Unix epoch, on a clock \`offset\` seconds ahead of UTC: the
155
+ /// local time's offset where it is shown, which Rust's standard library can't look up.
156
+ pub fn from_unix(seconds: i64, offset: i32) -> DateTime {
157
+ let local = seconds + offset as i64;
158
+ let (days, time) = (local.div_euclid(86_400), local.rem_euclid(86_400));
159
+ // Howard Hinnant's civil_from_days: the Gregorian date \`days\` after 1970-01-01.
160
+ let z = days + 719_468;
161
+ let (era, doe) = (z.div_euclid(146_097), z.rem_euclid(146_097));
162
+ let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
163
+ let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
164
+ let mp = (5 * doy + 2) / 153;
165
+ let month = if mp < 10 { mp + 3 } else { mp - 9 };
166
+ DateTime {
167
+ year: (yoe + era * 400 + if month <= 2 { 1 } else { 0 }) as i32,
168
+ month: month as u8,
169
+ day: (doy - (153 * mp + 2) / 5 + 1) as u8,
170
+ hour: (time / 3600) as u8,
171
+ minute: (time / 60 % 60) as u8,
172
+ second: (time % 60) as u8,
173
+ }
174
+ }
175
+
176
+ /// The day of the week, 0 for Sunday.
177
+ pub fn weekday(self) -> u8 {
178
+ // days_from_civil, its inverse.
179
+ let m = self.month as i64;
180
+ let y = self.year as i64 - if m <= 2 { 1 } else { 0 };
181
+ let (era, yoe) = (y.div_euclid(400), y.rem_euclid(400));
182
+ let doy = (153 * (if m > 2 { m - 3 } else { m + 9 }) + 2) / 5 + self.day as i64 - 1;
183
+ let days = era * 146_097 + yoe * 365 + yoe / 4 - yoe / 100 + doy - 719_468;
184
+ (days + 4).rem_euclid(7) as u8
185
+ }
186
+ }
187
+ `;
188
+
110
189
  const NUMBER = `
111
190
  /// A number format, taken apart when the file was generated.
112
191
  pub struct Format {
@@ -300,6 +379,8 @@ export function rustModule(model: NativeModel, target: NativeTarget & { name: st
300
379
  const tags = model.locales.map((l) => ` ${name}::${variant(l)} => ${lit(l)},\n`).join('');
301
380
  const methods = model.messages.map((m, i) => method(m, names[i]![1], name)).join('\n');
302
381
  const usesNumbers = model.messages.some((m) => m.args.some((a) => a.type === 'number'));
382
+ const usesDates = model.dates.length > 0;
383
+ const usesFormats = model.formats.length + model.rules.length > 0;
303
384
  const rules = model.rules
304
385
  .map(({ name: rule, rules }, i) => {
305
386
  const checks = rules.map(
@@ -310,17 +391,17 @@ export function rustModule(model: NativeModel, target: NativeTarget & { name: st
310
391
  })
311
392
  .join('\n');
312
393
  const runtime =
313
- model.formats.length + model.rules.length === 0
394
+ !usesFormats && !usesDates
314
395
  ? ''
315
396
  : `
316
397
  /// What formats the messages, generated with them.
317
- mod rt {${NUMBER}${model.rules.length > 0 ? PLURAL : ''}
318
- ${model.formats.map(formatConst).join('\n')}${rules === '' ? '' : `\n${rules}`}}
398
+ mod rt {${usesFormats ? NUMBER : ''}${model.rules.length > 0 ? PLURAL : ''}${usesDates ? DATE : ''}
399
+ ${[...model.formats.map(formatConst), ...model.dates.map(dateFn)].join('\n')}${rules === '' ? '' : `\n${rules}`}}
319
400
  `;
320
401
 
321
402
  return `${banner}
322
403
  #![allow(dead_code, non_snake_case, clippy::all)]
323
- ${usesNumbers ? NUMBER_TRAIT : ''}
404
+ ${usesNumbers ? NUMBER_TRAIT : ''}${usesDates ? DATE_TIME : ''}
324
405
  /// A locale the catalog is written or translated in, and its messages.
325
406
  #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
326
407
  pub enum ${name} {