prosebook 0.0.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +604 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +508 -0
- package/dist/cli.js.map +1 -0
- package/dist/config-D--RITk2.js +1208 -0
- package/dist/config-D--RITk2.js.map +1 -0
- package/dist/config-_4HfnJQu.d.ts +517 -0
- package/dist/config-_4HfnJQu.d.ts.map +1 -0
- package/dist/config.d.ts +2 -0
- package/dist/config.js +1 -0
- package/dist/copy-DXnzXsJa.d.ts +162 -0
- package/dist/copy-DXnzXsJa.d.ts.map +1 -0
- package/dist/helpers-BPuPGjQP.js +61 -0
- package/dist/helpers-BPuPGjQP.js.map +1 -0
- package/dist/index.d.ts +24 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +188 -0
- package/dist/index.js.map +1 -0
- package/dist/inspector.d.ts +37 -0
- package/dist/inspector.d.ts.map +1 -0
- package/dist/inspector.js +572 -0
- package/dist/inspector.js.map +1 -0
- package/dist/react/index.d.ts +37 -0
- package/dist/react/index.d.ts.map +1 -0
- package/dist/react/index.js +86 -0
- package/dist/react/index.js.map +1 -0
- package/dist/sarif-CI8HOokm.js +1033 -0
- package/dist/sarif-CI8HOokm.js.map +1 -0
- package/dist/scan-Ch2BfIUD.js +2561 -0
- package/dist/scan-Ch2BfIUD.js.map +1 -0
- package/dist/tools.d.ts +377 -0
- package/dist/tools.d.ts.map +1 -0
- package/dist/tools.js +4 -0
- package/dist/vite.d.ts +24 -0
- package/dist/vite.d.ts.map +1 -0
- package/dist/vite.js +264 -0
- package/dist/vite.js.map +1 -0
- package/package.json +148 -15
- package/src/cli.ts +528 -0
- package/src/index.ts +31 -0
- package/src/inspector-dialog.ts +450 -0
- package/src/inspector.ts +228 -0
- package/src/react/index.tsx +112 -0
- package/src/runtime/copy.ts +221 -0
- package/src/runtime/helpers.ts +66 -0
- package/src/runtime/locale.ts +49 -0
- package/src/runtime/types.ts +106 -0
- package/src/tools/catalog.ts +449 -0
- package/src/tools/compile.ts +121 -0
- package/src/tools/config.ts +211 -0
- package/src/tools/diff.ts +84 -0
- package/src/tools/entry.ts +325 -0
- package/src/tools/extract.ts +694 -0
- package/src/tools/generate.ts +257 -0
- package/src/tools/icu.ts +211 -0
- package/src/tools/index.ts +91 -0
- package/src/tools/lint.ts +865 -0
- package/src/tools/lock.ts +95 -0
- package/src/tools/mf1.ts +246 -0
- package/src/tools/mf2.ts +752 -0
- package/src/tools/migrate.ts +138 -0
- package/src/tools/presets.ts +159 -0
- package/src/tools/pseudo.ts +40 -0
- package/src/tools/sarif.ts +80 -0
- package/src/tools/scan.ts +303 -0
- package/src/tools/syntax.ts +24 -0
- package/src/tools/types.ts +137 -0
- package/src/vite.ts +296 -0
- package/index.js +0 -1
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stale translations: a translation made from source text that has since changed.
|
|
3
|
+
*
|
|
4
|
+
* The lock records, per translation, a hash of the source it was made from and a hash of the
|
|
5
|
+
* translation itself. Editing the translation re-anchors it to the current source; editing only
|
|
6
|
+
* the source leaves the old anchor in place, which is what marks it stale. Nobody has to remember
|
|
7
|
+
* to flag anything: `prosebook status` lists what is stale, and `prosebook accept` re-anchors a
|
|
8
|
+
* translation that was still right.
|
|
9
|
+
*
|
|
10
|
+
* The lock also keeps the text behind each anchor, once per distinct text, so a stale translation
|
|
11
|
+
* can show its translator what changed rather than only what the source says now.
|
|
12
|
+
*/
|
|
13
|
+
import { createHash } from 'node:crypto';
|
|
14
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
15
|
+
|
|
16
|
+
import type { Catalog } from './catalog.ts';
|
|
17
|
+
|
|
18
|
+
export interface Lock {
|
|
19
|
+
version: 2;
|
|
20
|
+
/** Source text by hash: every text some translation is anchored to. */
|
|
21
|
+
sources: Record<string, string>;
|
|
22
|
+
/** Per locale, per key: `<source hash>:<translation hash>`. */
|
|
23
|
+
translations: Record<string, Record<string, string>>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function hash(text: string): string {
|
|
27
|
+
return createHash('sha256').update(text).digest('hex').slice(0, 10);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function emptyLock(): Lock {
|
|
31
|
+
return { version: 2, sources: {}, translations: {} };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The lock at `path`. A version 1 lock, which held only the translations, reads with no source texts. */
|
|
35
|
+
export function readLock(path: string): Lock {
|
|
36
|
+
if (!existsSync(path)) return emptyLock();
|
|
37
|
+
const raw = JSON.parse(readFileSync(path, 'utf8')) as Partial<Lock> | Record<string, Record<string, string>>;
|
|
38
|
+
if ((raw as Partial<Lock>).version === 2) return { ...emptyLock(), ...(raw as Partial<Lock>) };
|
|
39
|
+
return { version: 2, sources: {}, translations: raw as Record<string, Record<string, string>> };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const sortedEntries = <T>(record: Record<string, T>) => Object.fromEntries(Object.entries(record).sort(([a], [b]) => (a < b ? -1 : 1)));
|
|
43
|
+
|
|
44
|
+
export function serializeLock(lock: Lock): string {
|
|
45
|
+
const translations = Object.fromEntries(
|
|
46
|
+
Object.keys(lock.translations)
|
|
47
|
+
.sort()
|
|
48
|
+
.map((locale) => [locale, sortedEntries(lock.translations[locale]!)]),
|
|
49
|
+
);
|
|
50
|
+
return `${JSON.stringify({ version: 2, sources: sortedEntries(lock.sources), translations }, null, 2)}\n`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface StaleTranslation {
|
|
54
|
+
/** The source the translation was made from, where the lock recorded it. */
|
|
55
|
+
was: string | undefined;
|
|
56
|
+
/** The source now. */
|
|
57
|
+
now: string;
|
|
58
|
+
/** The translation, made from `was`. */
|
|
59
|
+
translation: string;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface Reconciled {
|
|
63
|
+
lock: Lock;
|
|
64
|
+
/** Per locale, per key: the translations made from a source that has changed since. */
|
|
65
|
+
stale: Map<string, Map<string, StaleTranslation>>;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The lock brought up to date with the catalog, and what is stale. `accept` re-anchors the given
|
|
70
|
+
* translations (per locale, keys, or `true` for all of them) to the current source.
|
|
71
|
+
*/
|
|
72
|
+
export function reconcile(catalog: Catalog, previous: Lock, accept: Map<string, Set<string> | true> = new Map()): Reconciled {
|
|
73
|
+
const lock = emptyLock();
|
|
74
|
+
const stale = new Map<string, Map<string, StaleTranslation>>();
|
|
75
|
+
for (const message of catalog.messages) {
|
|
76
|
+
const source = hash(message.text);
|
|
77
|
+
for (const [locale, translation] of message.translations) {
|
|
78
|
+
const text = hash(translation.text);
|
|
79
|
+
const [anchor, recorded] = (previous.translations[locale]?.[message.key] ?? '').split(':');
|
|
80
|
+
const accepted = accept.get(locale);
|
|
81
|
+
const reanchor =
|
|
82
|
+
anchor === undefined || anchor === '' || recorded !== text || accepted === true || accepted?.has(message.key) === true;
|
|
83
|
+
const kept = reanchor ? source : anchor;
|
|
84
|
+
(lock.translations[locale] ??= {})[message.key] = `${kept}:${text}`;
|
|
85
|
+
const keptText = kept === source ? message.text : previous.sources[kept];
|
|
86
|
+
if (keptText !== undefined) lock.sources[kept] = keptText;
|
|
87
|
+
if (!reanchor && anchor !== source) {
|
|
88
|
+
let keys = stale.get(locale);
|
|
89
|
+
if (keys === undefined) stale.set(locale, (keys = new Map()));
|
|
90
|
+
keys.set(message.key, { was: keptText, now: message.text, translation: translation.text });
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return { lock, stale };
|
|
95
|
+
}
|
package/src/tools/mf1.ts
ADDED
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The element tree written as MF1 (ICU MessageFormat) text, for `migrate`. A tree read from MF2 holds
|
|
3
|
+
* `Intl` options where MF1 holds skeletons, so each is written as the skeleton that parses back to the
|
|
4
|
+
* same options, or as the named style MF2's function was mapped from; where neither exists the
|
|
5
|
+
* message is reported rather than changed.
|
|
6
|
+
*/
|
|
7
|
+
import { isDateElement, isNumberElement, isTimeElement, parse, TYPE } from '@formatjs/icu-messageformat-parser';
|
|
8
|
+
import type { DateElement, DateTimeSkeleton, NumberElement, NumberSkeleton, TimeElement } from '@formatjs/icu-messageformat-parser';
|
|
9
|
+
|
|
10
|
+
import type { MessageFormatElement } from './icu.ts';
|
|
11
|
+
import type { Mf2DateSkeleton, Mf2NumberSkeleton } from './mf2.ts';
|
|
12
|
+
|
|
13
|
+
type Element = MessageFormatElement;
|
|
14
|
+
|
|
15
|
+
export function toMf1(ast: Element[]): string {
|
|
16
|
+
return print(native(ast, undefined), false);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const CATEGORY_ORDER = ['zero', 'one', 'two', 'few', 'many', 'other'];
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Printed as a person would write it: `{n, plural, =0 {…} one {…} other {…}}`, exact arms first and
|
|
23
|
+
* `other` last. (formatjs's printer packs and reorders arms.)
|
|
24
|
+
*/
|
|
25
|
+
function print(elements: Element[], inPlural: boolean): string {
|
|
26
|
+
return elements
|
|
27
|
+
.map((el, i) => {
|
|
28
|
+
switch (el.type) {
|
|
29
|
+
case TYPE.literal:
|
|
30
|
+
return escape(el.value, inPlural, elements[i + 1] === undefined);
|
|
31
|
+
case TYPE.argument:
|
|
32
|
+
return `{${el.value}}`;
|
|
33
|
+
case TYPE.number:
|
|
34
|
+
case TYPE.date:
|
|
35
|
+
case TYPE.time: {
|
|
36
|
+
const kind = el.type === TYPE.number ? 'number' : el.type === TYPE.date ? 'date' : 'time';
|
|
37
|
+
const style = el.style;
|
|
38
|
+
if (style === null || style === undefined) return `{${el.value}, ${kind}}`;
|
|
39
|
+
if (typeof style === 'string') return `{${el.value}, ${kind}, ${style}}`;
|
|
40
|
+
const skeleton = 'tokens' in style ? style.tokens.map((t) => [t.stem, ...t.options].join('/')).join(' ') : style.pattern;
|
|
41
|
+
return `{${el.value}, ${kind}, ::${skeleton}}`;
|
|
42
|
+
}
|
|
43
|
+
case TYPE.pound:
|
|
44
|
+
return '#';
|
|
45
|
+
case TYPE.tag:
|
|
46
|
+
return `<${el.value}>${print(el.children, inPlural)}</${el.value}>`;
|
|
47
|
+
case TYPE.select:
|
|
48
|
+
case TYPE.plural: {
|
|
49
|
+
const arms = Object.entries(el.options);
|
|
50
|
+
const rank = (arm: string) => (arm.startsWith('=') ? -1 : arm === 'other' ? 99 : Math.max(0, CATEGORY_ORDER.indexOf(arm)));
|
|
51
|
+
if (el.type === TYPE.plural)
|
|
52
|
+
arms.sort(([a], [b]) => (rank(a) !== rank(b) ? rank(a) - rank(b) : Number(a.slice(1)) - Number(b.slice(1))));
|
|
53
|
+
else arms.sort(([a], [b]) => (a === 'other' ? 1 : 0) - (b === 'other' ? 1 : 0));
|
|
54
|
+
const head = el.type === TYPE.select ? 'select' : el.pluralType === 'ordinal' ? 'selectordinal' : 'plural';
|
|
55
|
+
const offset = el.type === TYPE.plural && el.offset !== 0 ? ` offset:${el.offset}` : '';
|
|
56
|
+
const body = arms.map(([arm, o]) => `${arm} {${print(o.value, el.type === TYPE.plural)}}`).join(' ');
|
|
57
|
+
return `{${el.value}, ${head},${offset} ${body}}`;
|
|
58
|
+
}
|
|
59
|
+
default:
|
|
60
|
+
return '';
|
|
61
|
+
}
|
|
62
|
+
})
|
|
63
|
+
.join('');
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Literal text, quoted where ICU would read it as syntax. An apostrophe starts a quote only before a
|
|
68
|
+
* special character, so one before ordinary text (`don't`) stays single.
|
|
69
|
+
*/
|
|
70
|
+
function escape(text: string, inPlural: boolean, last: boolean): string {
|
|
71
|
+
const special = (c: string | undefined) => c === '{' || c === '}' || c === '<' || (inPlural && c === '#');
|
|
72
|
+
let out = '';
|
|
73
|
+
for (let i = 0; i < text.length; i++) {
|
|
74
|
+
const c = text[i]!;
|
|
75
|
+
if (special(c)) out += `'${c}'`;
|
|
76
|
+
// Before the next placeholder or tag, a trailing apostrophe would quote its brace.
|
|
77
|
+
else if (c === "'") out += special(text[i + 1]) || text[i + 1] === "'" || (!last && i === text.length - 1) ? "''" : "'";
|
|
78
|
+
else out += c;
|
|
79
|
+
}
|
|
80
|
+
return out;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** The tree with MF2 styles replaced by MF1 ones, and `#` where a plural prints its own value. */
|
|
84
|
+
function native(elements: Element[], plural: { value: string; offset: number } | undefined): Element[] {
|
|
85
|
+
return elements.map((el): Element => {
|
|
86
|
+
if (isNumberElement(el)) {
|
|
87
|
+
if (plural?.offset === 0 && el.value === plural.value && plainNumber(el)) return { type: TYPE.pound };
|
|
88
|
+
return { ...el, style: numberStyle(el) };
|
|
89
|
+
}
|
|
90
|
+
if (isDateElement(el) || isTimeElement(el)) return dateElement(el);
|
|
91
|
+
if (el.type === TYPE.plural) {
|
|
92
|
+
const inner = { value: el.value, offset: el.offset };
|
|
93
|
+
return { ...el, options: Object.fromEntries(Object.entries(el.options).map(([k, o]) => [k, { value: native(o.value, inner) }])) };
|
|
94
|
+
}
|
|
95
|
+
// formatjs reads `#` as text inside a select, even one inside a plural.
|
|
96
|
+
if (el.type === TYPE.select)
|
|
97
|
+
return { ...el, options: Object.fromEntries(Object.entries(el.options).map(([k, o]) => [k, { value: native(o.value, undefined) }])) };
|
|
98
|
+
if (el.type === TYPE.tag) return { ...el, children: native(el.children, plural) };
|
|
99
|
+
return el;
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function plainNumber(el: NumberElement): boolean {
|
|
104
|
+
const style = el.style;
|
|
105
|
+
return (
|
|
106
|
+
style === null || style === undefined || ('mf2' in Object(style) && Object.keys((style as NumberSkeleton).parsedOptions).length === 0)
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function same(a: object, b: object): boolean {
|
|
111
|
+
const norm = (o: object) =>
|
|
112
|
+
JSON.stringify(
|
|
113
|
+
Object.entries(o)
|
|
114
|
+
.filter(([, v]) => v !== undefined)
|
|
115
|
+
.sort(([x], [y]) => x.localeCompare(y)),
|
|
116
|
+
);
|
|
117
|
+
return norm(a) === norm(b);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function numberStyle(el: NumberElement): NumberElement['style'] {
|
|
121
|
+
const style = el.style;
|
|
122
|
+
if (style === null || style === undefined || typeof style === 'string' || !('mf2' in style)) return style;
|
|
123
|
+
const options = (style as Mf2NumberSkeleton).parsedOptions as Record<string, unknown>;
|
|
124
|
+
if (Object.keys(options).length === 0) return null;
|
|
125
|
+
if (same(options, { maximumFractionDigits: 0 })) return 'integer';
|
|
126
|
+
if (same(options, { style: 'percent' })) return 'percent';
|
|
127
|
+
const skeleton = numberSkeleton(options);
|
|
128
|
+
const parsed = skeleton === undefined ? undefined : skeletonStyle(`{n, number, ::${skeleton}}`);
|
|
129
|
+
// `unit-width-*` sets both displays; `Intl` reads only the one its style uses.
|
|
130
|
+
const relevant = (o: Record<string, unknown>) => {
|
|
131
|
+
const { currencyDisplay, unitDisplay, ...rest } = o;
|
|
132
|
+
return { ...rest, ...(o.style === 'currency' ? { currencyDisplay } : {}), ...(o.style === 'unit' ? { unitDisplay } : {}) };
|
|
133
|
+
};
|
|
134
|
+
if (parsed === undefined || !same(relevant(parsed.parsedOptions as Record<string, unknown>), relevant(options)))
|
|
135
|
+
throw new Error(`{${el.value} ${mf2Text((style as Mf2NumberSkeleton).mf2)}} formats in a way no MF1 skeleton says`);
|
|
136
|
+
return parsed as NumberSkeleton;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function skeletonStyle(source: string): NumberSkeleton | DateTimeSkeleton | undefined {
|
|
140
|
+
try {
|
|
141
|
+
const el = parse(source, { shouldParseSkeletons: true })[0];
|
|
142
|
+
return el !== undefined && 'style' in el && typeof el.style === 'object' && el.style !== null ? el.style : undefined;
|
|
143
|
+
} catch {
|
|
144
|
+
return undefined;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** The ICU number skeleton for `Intl` options, as far as formatjs reads skeletons back. */
|
|
149
|
+
function numberSkeleton(o: Record<string, unknown>): string | undefined {
|
|
150
|
+
const tokens: string[] = [];
|
|
151
|
+
if (o.style === 'percent') tokens.push('percent');
|
|
152
|
+
if (o.style === 'currency') tokens.push(`currency/${String(o.currency)}`);
|
|
153
|
+
if (o.style === 'unit') tokens.push(`unit/${String(o.unit)}`);
|
|
154
|
+
const width =
|
|
155
|
+
o.style === 'currency'
|
|
156
|
+
? { narrowSymbol: 'unit-width-narrow', code: 'unit-width-short', name: 'unit-width-full-name' }[String(o.currencyDisplay)]
|
|
157
|
+
: o.style === 'unit'
|
|
158
|
+
? { narrow: 'unit-width-narrow', short: 'unit-width-short', long: 'unit-width-full-name' }[String(o.unitDisplay)]
|
|
159
|
+
: undefined;
|
|
160
|
+
if (width !== undefined) tokens.push(width);
|
|
161
|
+
if (o.currencySign === 'accounting') tokens.push('sign-accounting');
|
|
162
|
+
const sign = { always: 'sign-always', exceptZero: 'sign-except-zero', never: 'sign-never' }[String(o.signDisplay)];
|
|
163
|
+
if (sign !== undefined) tokens.push(sign);
|
|
164
|
+
if (o.useGrouping === false) tokens.push('group-off');
|
|
165
|
+
if (typeof o.minimumIntegerDigits === 'number') tokens.push(`integer-width/*${'0'.repeat(o.minimumIntegerDigits)}`);
|
|
166
|
+
|
|
167
|
+
const minF = o.minimumFractionDigits as number | undefined;
|
|
168
|
+
const maxF = o.maximumFractionDigits as number | undefined;
|
|
169
|
+
let fraction: string | undefined;
|
|
170
|
+
if (minF !== undefined && maxF !== undefined) fraction = `.${'0'.repeat(minF)}${'#'.repeat(maxF - minF)}`;
|
|
171
|
+
else if (maxF !== undefined) fraction = maxF === 0 ? '.' : `.${'#'.repeat(maxF)}`;
|
|
172
|
+
else if (minF !== undefined) fraction = `.${'0'.repeat(minF)}*`;
|
|
173
|
+
const minS = o.minimumSignificantDigits as number | undefined;
|
|
174
|
+
const maxS = o.maximumSignificantDigits as number | undefined;
|
|
175
|
+
let significant: string | undefined;
|
|
176
|
+
if (minS !== undefined && maxS !== undefined) significant = `${'@'.repeat(minS)}${'#'.repeat(maxS - minS)}`;
|
|
177
|
+
else if (maxS !== undefined) significant = `@${'#'.repeat(maxS - 1)}`;
|
|
178
|
+
else if (minS !== undefined) significant = `${'@'.repeat(minS)}+`;
|
|
179
|
+
if (significant !== undefined)
|
|
180
|
+
significant += o.roundingPriority === 'morePrecision' ? 'r' : o.roundingPriority === 'lessPrecision' ? 's' : '';
|
|
181
|
+
if (fraction !== undefined)
|
|
182
|
+
tokens.push(fraction + (o.trailingZeroDisplay === 'stripIfInteger' ? '/w' : significant !== undefined ? `/${significant}` : ''));
|
|
183
|
+
else if (significant !== undefined) tokens.push(significant);
|
|
184
|
+
|
|
185
|
+
const mode = {
|
|
186
|
+
floor: 'rounding-mode-floor',
|
|
187
|
+
ceil: 'rounding-mode-ceiling',
|
|
188
|
+
trunc: 'rounding-mode-down',
|
|
189
|
+
expand: 'rounding-mode-up',
|
|
190
|
+
halfEven: 'rounding-mode-half-even',
|
|
191
|
+
halfTrunc: 'rounding-mode-half-down',
|
|
192
|
+
halfExpand: 'rounding-mode-half-up',
|
|
193
|
+
}[String(o.roundingMode)];
|
|
194
|
+
if (mode !== undefined) tokens.push(mode);
|
|
195
|
+
return tokens.length > 0 ? tokens.join(' ') : undefined;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** MF2 date and time functions as the MF1 named style they were mapped from (see `dateFunction` in mf2.ts). */
|
|
199
|
+
const NAMED: Record<string, readonly [TYPE.date | TYPE.time, string]> = {
|
|
200
|
+
'date length=short': [TYPE.date, 'short'],
|
|
201
|
+
date: [TYPE.date, 'medium'],
|
|
202
|
+
'date length=long': [TYPE.date, 'long'],
|
|
203
|
+
'date fields=year-month-day-weekday length=long': [TYPE.date, 'full'],
|
|
204
|
+
time: [TYPE.time, 'short'],
|
|
205
|
+
'time precision=second': [TYPE.time, 'medium'],
|
|
206
|
+
'time precision=second timeZoneStyle=short': [TYPE.time, 'long'],
|
|
207
|
+
'time precision=second timeZoneStyle=long': [TYPE.time, 'full'],
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
function dateElement(el: DateElement | TimeElement): Element {
|
|
211
|
+
const style = el.style;
|
|
212
|
+
if (typeof style !== 'object' || style === null || !('mf2' in style)) return el;
|
|
213
|
+
const fn = (style as Mf2DateSkeleton).mf2;
|
|
214
|
+
const named = NAMED[mf2Text(fn, true).slice(1)];
|
|
215
|
+
if (named !== undefined) return { type: named[0], value: el.value, style: named[1] };
|
|
216
|
+
const skeleton = dateSkeleton(style.parsedOptions);
|
|
217
|
+
const parsed = skeleton === undefined ? undefined : skeletonStyle(`{d, date, ::${skeleton}}`);
|
|
218
|
+
if (parsed === undefined || !same(parsed.parsedOptions, style.parsedOptions))
|
|
219
|
+
throw new Error(`{${el.value} ${mf2Text(fn)}} formats in a way no MF1 date skeleton says: use :date or :time with a length`);
|
|
220
|
+
return { type: TYPE.date, value: el.value, style: parsed as DateTimeSkeleton };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function dateSkeleton(o: Intl.DateTimeFormatOptions): string | undefined {
|
|
224
|
+
if (o.hour !== undefined || o.timeZone !== undefined || o.calendar !== undefined || o.hour12 !== undefined) return undefined;
|
|
225
|
+
const letters: Record<string, Record<string, string>> = {
|
|
226
|
+
weekday: { short: 'EEE', long: 'EEEE', narrow: 'EEEEE' },
|
|
227
|
+
year: { numeric: 'y', '2-digit': 'yy' },
|
|
228
|
+
month: { numeric: 'M', '2-digit': 'MM', short: 'MMM', long: 'MMMM', narrow: 'MMMMM' },
|
|
229
|
+
day: { numeric: 'd', '2-digit': 'dd' },
|
|
230
|
+
};
|
|
231
|
+
let out = '';
|
|
232
|
+
for (const [field, forms] of Object.entries(letters)) {
|
|
233
|
+
const value = o[field as keyof Intl.DateTimeFormatOptions];
|
|
234
|
+
if (value === undefined) continue;
|
|
235
|
+
const letter = forms[String(value)];
|
|
236
|
+
if (letter === undefined) return undefined;
|
|
237
|
+
out += letter;
|
|
238
|
+
}
|
|
239
|
+
return out === '' ? undefined : out;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function mf2Text(fn: { name: string; options: Record<string, string> }, sortOptions = false): string {
|
|
243
|
+
const options = Object.entries(fn.options);
|
|
244
|
+
if (sortOptions) options.sort(([a], [b]) => a.localeCompare(b));
|
|
245
|
+
return [`:${fn.name}`, ...options.map(([k, v]) => `${k}=${v}`)].join(' ');
|
|
246
|
+
}
|