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
@@ -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,13 +59,30 @@ 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
 
62
- /** Whether a target takes `key`, and the prefix that took it in (longest first), which its name leaves out. */
63
- function prefixOf(target: NativeTarget, key: string): string | undefined {
64
- if (target.keys === undefined) return '';
65
- return [...target.keys].sort((a, b) => b.length - a.length).find((p) => key.startsWith(`${p}.`));
66
+ /**
67
+ * The words a target names `key` by, less the prefix that took it in (longest first): `tray.quit-app`
68
+ * is quit, app under `['tray']`. Nothing when the target doesn't take the key.
69
+ */
70
+ export function wordsFor(target: NativeTarget, key: string): string[] | undefined {
71
+ return restOf(target, key)
72
+ ?.split(/[.\-_]/)
73
+ .filter(Boolean);
74
+ }
75
+
76
+ /** `key` less the prefix that took it in, as written: `tray.quit-app` is `quit-app` under `['tray']`. */
77
+ export function restOf(target: NativeTarget, key: string): string | undefined {
78
+ const prefix = target.keys === undefined ? '' : [...target.keys].sort((a, b) => b.length - a.length).find((p) => key.startsWith(`${p}.`));
79
+ if (prefix === undefined) return undefined;
80
+ return key.slice(prefix === '' ? 0 : prefix.length + 1);
81
+ }
82
+
83
+ /** The locale whose text `locale` shows for `key`: its own, else the first down its fallback chain that has it. */
84
+ export function foundIn(key: string, locale: string, locales: string[], asts: Map<string, Map<string, MessageFormatElement[]>>) {
85
+ return chainOf(locale, locales, locales[0]!).find((l) => asts.get(l)?.has(key));
66
86
  }
67
87
 
68
88
  /** Values a file declares once each, numbered by what they hold, so locales that format alike share one. */
@@ -90,21 +110,18 @@ export function nativeModel(
90
110
  const source = locales[0]!;
91
111
  const asts = new Map([...entries].map(([locale, list]) => [locale, new Map(list)]));
92
112
  const formats = new Numbered<NumberSpec>();
113
+ const dates = new Numbered<DateSpec>();
93
114
  const rules = new Numbered<{ name: string; rules: PluralRules }>();
94
115
  const problems: Problem[] = [];
95
116
  const messages: NativeMessage[] = [];
96
117
 
97
118
  for (const m of catalog.messages) {
98
- const prefix = prefixOf(target, m.key);
99
- if (prefix === undefined) continue;
100
- const words = m.key
101
- .slice(prefix === '' ? 0 : prefix.length + 1)
102
- .split(/[.\-_]/)
103
- .filter(Boolean);
119
+ const words = wordsFor(target, m.key);
120
+ if (words === undefined) continue;
104
121
  const bodies = new Map<string, { locales: string[]; parts: Part[] }>();
105
122
  let failed = false;
106
123
  for (const locale of locales) {
107
- const found = chainOf(locale, locales, source).find((l) => asts.get(l)?.has(m.key));
124
+ const found = foundIn(m.key, locale, locales, asts);
108
125
  if (found === undefined) continue;
109
126
  const body = bodies.get(found);
110
127
  if (body !== undefined) {
@@ -112,7 +129,7 @@ export function nativeModel(
112
129
  continue;
113
130
  }
114
131
  try {
115
- 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) });
116
133
  } catch (e) {
117
134
  failed = true;
118
135
  problems.push({
@@ -130,16 +147,16 @@ export function nativeModel(
130
147
  doc: docOf(m),
131
148
  args: [...m.args].map(([name, type]) => ({
132
149
  name,
133
- type: type === 'number' ? 'number' : 'string',
150
+ type: type === 'number' ? 'number' : type === 'Date | number' ? 'date' : 'string',
134
151
  doc: m.argDocs.has(name) ? docText(m.argDocs.get(name)!) : undefined,
135
152
  })),
136
153
  bodies: [...bodies.values()],
137
154
  });
138
155
  }
139
- 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 };
140
157
  }
141
158
 
142
- function fileOf(m: Message, locale: string, source: string): string {
159
+ export function fileOf(m: Message, locale: string, source: string): string {
143
160
  return locale === source ? m.file : (m.translations.get(locale)?.file ?? m.file);
144
161
  }
145
162
 
@@ -151,11 +168,38 @@ function docOf(m: Message): string[] {
151
168
  return lines.map(docText);
152
169
  }
153
170
 
154
- const LEAVE_OUT = "or leave the key out of this target's `keys`";
171
+ export const LEAVE_OUT = "or leave the key out of this target's `keys`";
155
172
 
156
173
  /** Specs by locale and options, kept across runs: taking a format apart checks it against `Intl` dozens of times. */
157
174
  const SPECS = new Map<string, NumberSpec | Error>();
158
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
+
159
203
  function format(formats: Numbered<NumberSpec>, locale: string, options: Intl.NumberFormatOptions | undefined): number {
160
204
  const key = JSON.stringify([locale, options ?? {}]);
161
205
  let spec = SPECS.get(key);
@@ -175,13 +219,8 @@ function format(formats: Numbered<NumberSpec>, locale: string, options: Intl.Num
175
219
 
176
220
  type Pound = { arg: string; offset: number } | null;
177
221
 
178
- function parts(
179
- elements: MessageFormatElement[],
180
- locale: string,
181
- formats: Numbered<NumberSpec>,
182
- rules: Numbered<{ name: string; rules: PluralRules }>,
183
- pound: Pound,
184
- ): Part[] {
222
+ function parts(elements: MessageFormatElement[], locale: string, declared: Declared, pound: Pound): Part[] {
223
+ const { formats, dates, rules } = declared;
185
224
  const out: Part[] = [];
186
225
  const text = (value: string) => {
187
226
  const last = out.at(-1);
@@ -195,7 +234,7 @@ function parts(
195
234
  if (isolate !== undefined) text(isolate[1]);
196
235
  };
197
236
  const arms = (options: Record<string, { value: MessageFormatElement[] }>, own: Pound) =>
198
- 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) }));
199
238
 
200
239
  for (const el of elements) {
201
240
  if (Object.keys(optionArgsOf(el) ?? {}).length > 0) {
@@ -204,9 +243,8 @@ function parts(
204
243
  );
205
244
  }
206
245
  if (isDateElement(el) || isTimeElement(el)) {
207
- throw new Error(
208
- `{${el.value}} is a date, which native code doesn't format: pass it formatted, as a plain {${el.value}}, ${LEAVE_OUT}`,
209
- );
246
+ placed(el, el.value, { type: 'date', arg: el.value, format: date(dates, locale, dateOptions(el)) });
247
+ continue;
210
248
  }
211
249
  if (isNumberElement(el)) {
212
250
  const fallback = placeholderOf(el, el.value).fallback;
@@ -0,0 +1,137 @@
1
+ /**
2
+ * Messages as printf-style format strings, for the targets whose platform fills values in itself:
3
+ * Android's resources and Apple's `.strings` and `.stringsdict` files. Text and plain `{name}` placeholders, as `%N$s`
4
+ * with N the argument's place in the message's arguments, so a translation can put them in another
5
+ * order; anything `Intl` would format fails with the fix.
6
+ */
7
+ import { isDateElement, isNumberElement, isTimeElement } from '@formatjs/icu-messageformat-parser';
8
+
9
+ import type { PluralElement } from '@formatjs/icu-messageformat-parser';
10
+ import type { Message } from '#src/tools/catalog.ts';
11
+
12
+ import { numberOptions, placeholderOf, selectionOf, TYPE, type MessageFormatElement } from '#src/tools/icu.ts';
13
+ import { docText } from './emit.ts';
14
+ import { LEAVE_OUT } from './model.ts';
15
+
16
+ export interface Printf {
17
+ /** Who fills values in, and with what, as problems name them: `Android`, `String.format`. */
18
+ platform: string;
19
+ formatter: string;
20
+ /** What holds the text, as problems name it: `Android resources`. */
21
+ holder: string;
22
+ /** A string argument's conversion: `s`, or `@` for Apple's objects. */
23
+ string: string;
24
+ /** A count's conversion: `d`, or `lld` for Apple's 64-bit `Int`. */
25
+ count: string;
26
+ /** Text as the file holds it; `formatted` when the string has arguments, so `%` must be doubled. */
27
+ escape: (text: string, formatted: boolean) => string;
28
+ /** The problem with a plural the target can't hold where it is. */
29
+ plural: (name: string) => string;
30
+ }
31
+
32
+ /** The context and what each argument holds, on one line, for the comment above an entry. */
33
+ export function noteOf(m: Message): string {
34
+ const lines = [
35
+ m.context,
36
+ ...[...m.args.keys()].map((a) => (m.argDocs.has(a) ? `${a}: ${m.argDocs.get(a)!.replace(/\.$/, '')}.` : undefined)),
37
+ ];
38
+ return docText(lines.filter((l) => l !== undefined && l !== '').join(' '));
39
+ }
40
+
41
+ /** What a plural's arms may hold as its count: `#`, or the count as a plain `:number` or `:integer`. */
42
+ function isCount(el: MessageFormatElement, count: string): boolean {
43
+ if (el.type === TYPE.pound) return true;
44
+ if (!isNumberElement(el) || el.value !== count) return false;
45
+ const options = Object.entries(numberOptions(el) ?? {});
46
+ return options.length === 0 || (options.length === 1 && options[0]![0] === 'maximumFractionDigits' && options[0]![1] === 0);
47
+ }
48
+
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`). */
88
+ export function formatString(elements: MessageFormatElement[], args: string[], printf: Printf, count?: string): string {
89
+ const formatted = args.length > 0;
90
+ const place = (el: MessageFormatElement, name: string, conversion: string) => {
91
+ const at = args.indexOf(name);
92
+ if (at < 0) throw new Error(`{${name}} is not an argument of this message: say it in the source text too`);
93
+ const { isolate } = placeholderOf(el, name);
94
+ const spec = `%${at + 1}$${conversion}`;
95
+ return isolate === undefined ? spec : `${printf.escape(isolate[0], formatted)}${spec}${printf.escape(isolate[1], formatted)}`;
96
+ };
97
+ let out = '';
98
+ for (const el of elements) {
99
+ if (count !== undefined && isCount(el, count)) {
100
+ out += place(el, count, printf.count);
101
+ continue;
102
+ }
103
+ if (isDateElement(el) || isTimeElement(el)) {
104
+ throw new Error(
105
+ `{${el.value}} is a date, which ${printf.holder} don't format: pass it formatted, as a plain {${el.value}}, ${LEAVE_OUT}`,
106
+ );
107
+ }
108
+ if (isNumberElement(el)) {
109
+ throw new Error(
110
+ `{${el.value}} is a formatted number, and ${printf.platform} fills values in with ${printf.formatter}, not Intl: pass it formatted, as a plain {${el.value}}, ${LEAVE_OUT}`,
111
+ );
112
+ }
113
+ switch (el.type) {
114
+ case TYPE.literal:
115
+ out += printf.escape(el.value, formatted);
116
+ break;
117
+ case TYPE.argument:
118
+ out += place(el, el.value, printf.string);
119
+ break;
120
+ case TYPE.pound:
121
+ // Outside a plural, `#` is text.
122
+ out += '#';
123
+ break;
124
+ case TYPE.select:
125
+ throw new Error(
126
+ `{${el.value}} is a select, which ${printf.holder} can't hold: write a key per case and pick one in code, ${LEAVE_OUT}`,
127
+ );
128
+ case TYPE.plural:
129
+ throw new Error(printf.plural(el.value));
130
+ case TYPE.tag:
131
+ throw new Error(`<${el.value}> is a tag, and ${printf.holder} here hold plain text: say it without the tag, ${LEAVE_OUT}`);
132
+ default:
133
+ throw new Error(`prosebook: no ${printf.platform} writer for ICU element type ${(el as MessageFormatElement).type}`);
134
+ }
135
+ }
136
+ return out;
137
+ }
@@ -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} {
@@ -3,6 +3,7 @@
3
3
  * message, and a file-private enum holding what formats them.
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, 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}case ${lit(a.key)}: return ${expr(a.parts, inner, rt)}\n`).join('');
41
43
  return `{ () -> String in\n${inner}switch ${argName(part.arg)} {\n${arms}${inner}default: return ${expr(part.other, inner, rt)}\n${inner}}\n${indent}}()`;
@@ -66,10 +68,17 @@ function member(m: NativeMessage, name: string, rt: string, number: NumberProtoc
66
68
  const head = doc(argDocs.length > 0 ? [...m.doc, '', ...argDocs] : m.doc, ' ');
67
69
  const body = ` switch self {\n${cases((parts) => expr(parts, ' ', rt))} }\n`;
68
70
  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(', ');
71
+ const params = m.args
72
+ .map((a) => `${argName(a.name)}: ${a.type === 'number' ? `some ${number.type}` : a.type === 'date' ? 'Date' : 'String'}`)
73
+ .join(', ');
70
74
  const numbers = m.args
71
- .filter((a) => a.type === 'number')
72
- .map((a) => ` let ${argName(a.name)} = ${argName(a.name)}.${number.value}\n`)
75
+ .map((a) =>
76
+ a.type === 'number'
77
+ ? ` let ${argName(a.name)} = ${argName(a.name)}.${number.value}\n`
78
+ : a.type === 'date'
79
+ ? ` let ${argName(a.name)} = ${rt}.clock(${argName(a.name)})\n`
80
+ : '',
81
+ )
73
82
  .join('');
74
83
  return `${head} public func ${name}(${params}) -> String {\n${numbers}${body} }\n`;
75
84
  }
@@ -97,6 +106,51 @@ function formatConst(spec: NumberSpec, i: number): string {
97
106
  return ` static let f${i} = Format(\n${fields.map((f) => ` ${f}`).join(',\n')}\n )\n`;
98
107
  }
99
108
 
109
+ /** A date format as a function of the clock's fields. */
110
+ function dateFn(spec: DateSpec, i: number): string {
111
+ const digits = spec.digits === undefined ? 'nil' : `g${i}`;
112
+ const at = (field: string) => `t.${field}`;
113
+ const lines = spec.pieces.map((piece) => {
114
+ if (piece.type === 'text') return `s += ${lit(piece.value)}`;
115
+ if (piece.type === 'name') return `s += [${piece.names.map(lit).join(', ')}][${pieceExpr(piece, at)}]`;
116
+ return `s += pad(${pieceExpr(piece, at)}, ${piece.pad}, ${digits})`;
117
+ });
118
+ const table = spec.digits === undefined ? '' : ` static let g${i} = [${spec.digits.map(lit).join(', ')}]\n`;
119
+ const body = lines.map((l) => ` ${l}\n`).join('');
120
+ return `${table} static func d${i}(_ t: Clock) -> String {\n var s = ""\n${body} return s\n }\n`;
121
+ }
122
+
123
+ const DATE = `
124
+ /// A date as the clock shows it in the device's time zone; \`weekday\` is 0 for Sunday.
125
+ struct Clock {
126
+ let year: Int
127
+ let month: Int
128
+ let day: Int
129
+ let hour: Int
130
+ let minute: Int
131
+ let second: Int
132
+ let weekday: Int
133
+ }
134
+
135
+ static func clock(_ date: Date) -> Clock {
136
+ var calendar = Calendar(identifier: .gregorian)
137
+ calendar.timeZone = .current
138
+ let c = calendar.dateComponents([.year, .month, .day, .hour, .minute, .second, .weekday], from: date)
139
+ return Clock(
140
+ year: c.year ?? 0, month: c.month ?? 1, day: c.day ?? 1, hour: c.hour ?? 0, minute: c.minute ?? 0,
141
+ second: c.second ?? 0, weekday: (c.weekday ?? 1) - 1
142
+ )
143
+ }
144
+
145
+ /// \`value\` written with at least \`width\` digits, in \`digits\` where the locale has its own.
146
+ private static func pad(_ value: Int, _ width: Int, _ digits: [String]?) -> String {
147
+ var text = String(value)
148
+ while text.count < width { text = "0" + text }
149
+ guard let digits else { return text }
150
+ return text.map { c in c.wholeNumberValue.map { digits[$0] } ?? String(c) }.joined()
151
+ }
152
+ `;
153
+
100
154
  const NUMBER = `
101
155
  /// A number format, taken apart when the file was generated.
102
156
  struct Format {
@@ -265,6 +319,8 @@ export function swiftFile(model: NativeModel, target: NativeTarget & { name: str
265
319
  const number = { type: `${name}Number`, value: `${name.charAt(0).toLowerCase()}${name.slice(1)}Value` };
266
320
  const members = model.messages.map((m, i) => member(m, names[i]![1], rt, number)).join('\n');
267
321
  const usesNumbers = model.messages.some((m) => m.args.some((a) => a.type === 'number'));
322
+ const usesDates = model.dates.length > 0;
323
+ const usesFormats = model.formats.length + model.rules.length > 0;
268
324
  const numberProtocol = !usesNumbers
269
325
  ? ''
270
326
  : `
@@ -285,12 +341,12 @@ ${NUMBER_TYPES.map((t) => `extension ${t}: ${number.type} { public var ${number.
285
341
  })
286
342
  .join('\n');
287
343
  const runtime =
288
- model.formats.length + model.rules.length === 0
344
+ !usesFormats && !usesDates
289
345
  ? ''
290
346
  : `
291
347
  /// 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}`}}
348
+ private enum ${rt} {${usesFormats ? NUMBER : ''}${model.rules.length > 0 ? PLURAL : ''}${usesDates ? DATE : ''}
349
+ ${[...model.formats.map(formatConst), ...model.dates.map(dateFn)].join('\n')}${rules === '' ? '' : `\n${rules}`}}
294
350
  `;
295
351
 
296
352
  return `${banner}