@aalis/api-webui 0.11.1 → 0.13.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/dist/frame/frame.css +218 -0
- package/dist/frame/frame.js +158 -0
- package/dist/frame/schema-i18n/catalog.js +101 -0
- package/dist/frame/schema-i18n/check.js +65 -0
- package/dist/frame/schema-i18n/define.js +105 -0
- package/dist/frame/schema-i18n/errors.js +41 -0
- package/dist/frame/schema-i18n/format.js +160 -0
- package/dist/frame/schema-i18n/index.js +17 -0
- package/dist/frame/schema-i18n/locale.js +100 -0
- package/dist/frame/schema-i18n/locales/en.js +3 -0
- package/dist/frame/schema-i18n/message.js +249 -0
- package/dist/frame/schema-i18n/packs.js +52 -0
- package/dist/frame/schema-i18n/params.js +2 -0
- package/dist/frame/schema-i18n/resolve.js +85 -0
- package/dist/frame/schema-i18n/source.js +4 -0
- package/dist/frame/schema-i18n/text-ref.js +94 -0
- package/dist/frame/schema-i18n/texts.js +29 -0
- package/dist/frame.d.ts +190 -0
- package/dist/frame.d.ts.map +1 -0
- package/dist/frame.js +121 -0
- package/dist/frame.js.map +1 -0
- package/dist/index.d.ts +137 -206
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +53 -32
- package/dist/index.js.map +1 -1
- package/dist/locales/en.d.ts +82 -0
- package/dist/locales/en.d.ts.map +1 -0
- package/dist/locales/en.js +82 -0
- package/dist/locales/en.js.map +1 -0
- package/dist/page.d.ts +433 -0
- package/dist/page.d.ts.map +1 -0
- package/dist/page.js +30 -0
- package/dist/page.js.map +1 -0
- package/dist/rest.d.ts +674 -0
- package/dist/rest.d.ts.map +1 -0
- package/dist/rest.js +21 -0
- package/dist/rest.js.map +1 -0
- package/dist/texts.d.ts +163 -0
- package/dist/texts.d.ts.map +1 -0
- package/dist/texts.js +85 -0
- package/dist/texts.js.map +1 -0
- package/dist/theme.d.ts +466 -0
- package/dist/theme.d.ts.map +1 -0
- package/dist/theme.js +298 -0
- package/dist/theme.js.map +1 -0
- package/dist/themes/aalis-dark.json +49 -0
- package/dist/themes/aalis-light.json +49 -0
- package/dist/themes/brand-dark.json +49 -0
- package/dist/themes/brand-light.json +49 -0
- package/dist/themes/calm-dark.json +49 -0
- package/dist/themes/calm-light.json +49 -0
- package/dist/themes/neutral-dark.json +49 -0
- package/dist/themes/neutral-light.json +49 -0
- package/package.json +14 -6
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { isTextRef } from './text-ref.js';
|
|
2
|
+
const FAILED = new Error('参数不合要求');
|
|
3
|
+
function fail() {
|
|
4
|
+
throw FAILED;
|
|
5
|
+
}
|
|
6
|
+
// Intl 对象构造比格式化贵得多,按语言、选项与时区缓存,条目数封顶
|
|
7
|
+
const intlCache = new Map();
|
|
8
|
+
function cached(key, make) {
|
|
9
|
+
let value = intlCache.get(key);
|
|
10
|
+
if (value === undefined) {
|
|
11
|
+
value = make();
|
|
12
|
+
if (intlCache.size >= 512)
|
|
13
|
+
intlCache.clear();
|
|
14
|
+
intlCache.set(key, value);
|
|
15
|
+
}
|
|
16
|
+
return value;
|
|
17
|
+
}
|
|
18
|
+
const numberFormat = (language, unit, display, digits) => cached(`number\0${language}\0${unit}\0${display}\0${digits}`, () => new Intl.NumberFormat(language, unit === undefined ? undefined : { style: 'unit', unit, unitDisplay: display, maximumFractionDigits: digits }));
|
|
19
|
+
/** 时区由调用方给,缺省按进程的时区;Intl 不认的当作没给,同样按进程时区:不让整句回落成原文 */
|
|
20
|
+
function dateFormat(type, style, o) {
|
|
21
|
+
return cached(`${type}\0${style}\0${o.language}\0${o.timeZone}`, () => {
|
|
22
|
+
const options = {};
|
|
23
|
+
if (type !== 'time')
|
|
24
|
+
options.dateStyle = style;
|
|
25
|
+
if (type !== 'date')
|
|
26
|
+
options.timeStyle = style;
|
|
27
|
+
try {
|
|
28
|
+
return new Intl.DateTimeFormat(o.language, { ...options, timeZone: o.timeZone });
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return new Intl.DateTimeFormat(o.language, options);
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
const MARKDOWN_SPECIAL = /[\\`*_{}[\]()#+\-.!|<>~=&$]/g;
|
|
36
|
+
/** 按 Markdown 转义:转义后的文字在 Markdown 里显示为原样 */
|
|
37
|
+
export function escapeMarkdown(text) {
|
|
38
|
+
return text.replace(MARKDOWN_SPECIAL, '\\$&');
|
|
39
|
+
}
|
|
40
|
+
function finite(value) {
|
|
41
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : fail();
|
|
42
|
+
}
|
|
43
|
+
function list(value) {
|
|
44
|
+
return Array.isArray(value) ? value : fail();
|
|
45
|
+
}
|
|
46
|
+
/** 普通参数与列表元素:字符串(按需转义)、数原样、文字对象交给 nested */
|
|
47
|
+
function item(value, o) {
|
|
48
|
+
if (typeof value === 'string')
|
|
49
|
+
return o.escape === 'markdown' ? escapeMarkdown(value) : value;
|
|
50
|
+
if (typeof value === 'number')
|
|
51
|
+
return String(value);
|
|
52
|
+
return isTextRef(value) ? o.nested(value) : fail();
|
|
53
|
+
}
|
|
54
|
+
const DURATION_UNITS = ['day', 'hour', 'minute', 'second'];
|
|
55
|
+
const DURATION_SECONDS = [86_400, 3_600, 60, 1];
|
|
56
|
+
/** 时长:天、时、分、秒最多两级,按显示的最小一级四舍五入;负数只在第一级带负号 */
|
|
57
|
+
function duration(ms, o) {
|
|
58
|
+
const seconds = Math.abs(ms) / 1000;
|
|
59
|
+
let top = DURATION_SECONDS.findIndex(size => seconds >= size);
|
|
60
|
+
if (top < 0)
|
|
61
|
+
top = DURATION_SECONDS.length - 1;
|
|
62
|
+
let step;
|
|
63
|
+
let rounded;
|
|
64
|
+
for (;;) {
|
|
65
|
+
step = DURATION_SECONDS[Math.min(top + 1, DURATION_SECONDS.length - 1)];
|
|
66
|
+
rounded = Math.round(seconds / step) * step;
|
|
67
|
+
// 进位跨过上一级(59.6 秒成了 60 秒)就升一级重算
|
|
68
|
+
if (top === 0 || rounded < DURATION_SECONDS[top - 1])
|
|
69
|
+
break;
|
|
70
|
+
top--;
|
|
71
|
+
}
|
|
72
|
+
const high = Math.floor(rounded / DURATION_SECONDS[top]);
|
|
73
|
+
const low = Math.round((rounded - high * DURATION_SECONDS[top]) / step);
|
|
74
|
+
const unit = (index, value) => numberFormat(o.language, DURATION_UNITS[index], 'short').format(value);
|
|
75
|
+
const parts = [unit(top, ms < 0 && rounded > 0 ? -high : high)];
|
|
76
|
+
if (low > 0)
|
|
77
|
+
parts.push(unit(top + 1, low));
|
|
78
|
+
return cached(`unit-list\0${o.language}`, () => new Intl.ListFormat(o.language, { type: 'unit', style: 'short' })).format(parts);
|
|
79
|
+
}
|
|
80
|
+
const SIZE_UNITS = ['byte', 'kilobyte', 'megabyte', 'gigabyte'];
|
|
81
|
+
/** 字节数:B、kB、MB、GB(Intl 单位的 narrow 写法),按 1024 进位,B 以上至多一位小数、整数不带小数;按显示精度舍入后到了 1024 就升一级 */
|
|
82
|
+
function size(bytes, o) {
|
|
83
|
+
let value = Math.abs(bytes);
|
|
84
|
+
let index = 0;
|
|
85
|
+
const shown = () => (index === 0 ? Math.round(value) : Math.round(value * 10) / 10);
|
|
86
|
+
while (shown() >= 1024 && index < SIZE_UNITS.length - 1) {
|
|
87
|
+
value /= 1024;
|
|
88
|
+
index++;
|
|
89
|
+
}
|
|
90
|
+
return numberFormat(o.language, SIZE_UNITS[index], 'narrow', index === 0 ? 0 : 1).format(bytes < 0 ? -value : value);
|
|
91
|
+
}
|
|
92
|
+
/** 行内代码:围栏比参数里最长的反引号串多一个,参数以反引号开头或结尾时两侧加空格;参数原样,不转义 */
|
|
93
|
+
function code(value) {
|
|
94
|
+
const longest = Math.max(0, ...(value.match(/`+/g) ?? []).map(run => run.length));
|
|
95
|
+
const fence = '`'.repeat(longest + 1);
|
|
96
|
+
const pad = value.startsWith('`') || value.endsWith('`') ? ' ' : '';
|
|
97
|
+
return `${fence}${pad}${value}${pad}${fence}`;
|
|
98
|
+
}
|
|
99
|
+
function formatArgument(arg, params, o, count) {
|
|
100
|
+
if (arg.type === 'pound')
|
|
101
|
+
return numberFormat(o.language).format(count);
|
|
102
|
+
const value = Object.hasOwn(params, arg.name) ? params[arg.name] : undefined;
|
|
103
|
+
switch (arg.type) {
|
|
104
|
+
case 'plain':
|
|
105
|
+
return item(value, o);
|
|
106
|
+
case 'number':
|
|
107
|
+
return numberFormat(o.language).format(finite(value));
|
|
108
|
+
case 'plural': {
|
|
109
|
+
const n = finite(value);
|
|
110
|
+
const category = cached(`plural\0${o.language}`, () => new Intl.PluralRules(o.language)).select(n);
|
|
111
|
+
const branch = arg.branches.get(`=${n}`) ?? arg.branches.get(category) ?? arg.branches.get('other');
|
|
112
|
+
return render(branch ?? fail(), params, o, n);
|
|
113
|
+
}
|
|
114
|
+
case 'select': {
|
|
115
|
+
if (typeof value !== 'string')
|
|
116
|
+
fail();
|
|
117
|
+
const branch = arg.branches.get(value) ?? arg.branches.get('other');
|
|
118
|
+
return render(branch ?? fail(), params, o, count);
|
|
119
|
+
}
|
|
120
|
+
case 'date':
|
|
121
|
+
case 'time':
|
|
122
|
+
case 'datetime':
|
|
123
|
+
return dateFormat(arg.type, arg.style, o).format(finite(value));
|
|
124
|
+
case 'list': {
|
|
125
|
+
const items = list(value).map(element => item(element, o));
|
|
126
|
+
// 罗列只用分隔符拼接,不加「和」「and」:Intl 的窄写法各语言、各浏览器带不带连接词不一。「或」照常用 Intl
|
|
127
|
+
if (arg.listType === 'conjunction')
|
|
128
|
+
return items.join(o.listSeparator());
|
|
129
|
+
return cached(`list-or\0${o.language}`, () => new Intl.ListFormat(o.language, { type: 'disjunction' })).format(items);
|
|
130
|
+
}
|
|
131
|
+
case 'lines':
|
|
132
|
+
return list(value)
|
|
133
|
+
.map(element => item(element, o))
|
|
134
|
+
.join('\n');
|
|
135
|
+
case 'duration':
|
|
136
|
+
return duration(finite(value), o);
|
|
137
|
+
case 'size':
|
|
138
|
+
return size(finite(value), o);
|
|
139
|
+
case 'code':
|
|
140
|
+
if (typeof value === 'number')
|
|
141
|
+
return code(String(value));
|
|
142
|
+
return typeof value === 'string' ? code(value) : fail();
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
function render(message, params, o, count) {
|
|
146
|
+
let out = '';
|
|
147
|
+
for (const part of message)
|
|
148
|
+
out += typeof part === 'string' ? part : formatArgument(part, params, o, count);
|
|
149
|
+
return out;
|
|
150
|
+
}
|
|
151
|
+
/** 渲染整句;失败(含 Intl 报错,如不认识的时区)返回 undefined */
|
|
152
|
+
export function formatMessage(message, params, options) {
|
|
153
|
+
try {
|
|
154
|
+
return render(message, params, options, Number.NaN);
|
|
155
|
+
}
|
|
156
|
+
catch {
|
|
157
|
+
return undefined;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
//# sourceMappingURL=format.js.map
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// ============================================================
|
|
2
|
+
// @aalis/schema-i18n — 给人看的文字对象
|
|
3
|
+
//
|
|
4
|
+
// 要翻译的文字是文字对象(TextRef):取自所属包原文字典的一个键,带参数与按原文渲染的整句,纯数据、可落盘。
|
|
5
|
+
// 产出文字的一方只依赖本包;出口在 i18n 服务在场时按读者语言渲染,不在场时按原文语言渲染(resolveSource)。
|
|
6
|
+
// 本包零运行时依赖、不用 node: 模块,浏览器里同样可用。
|
|
7
|
+
// ============================================================
|
|
8
|
+
export { catalogEntries, catalogIssues, registerCatalogEntry, } from './catalog.js';
|
|
9
|
+
export { checkTexts } from './check.js';
|
|
10
|
+
export { defineLocale, defineTexts, truncateParams, } from './define.js';
|
|
11
|
+
export { isTextError, TextError, textOf, withText } from './errors.js';
|
|
12
|
+
export { matchLanguage, normalizeLanguages, pickLanguage, selfName } from './locale.js';
|
|
13
|
+
export { fingerprint, indexPacks, } from './packs.js';
|
|
14
|
+
export { listSeparator, resolveSource, resolveText } from './resolve.js';
|
|
15
|
+
export { asText, isTextRef, localize, sameText, toSource, toSourceReplacer, } from './text-ref.js';
|
|
16
|
+
export { linesOf, textClauses, textLines } from './texts.js';
|
|
17
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// 语言标签的规范化与匹配
|
|
2
|
+
/** 系统与手填的写法先整理:下划线换连字符,去掉 `.UTF-8`、`@euro` 这类后缀 */
|
|
3
|
+
const tidy = (tag) => tag.replace(/[.@].*$/, '').replace(/_/g, '-');
|
|
4
|
+
/** 规范写法(`zh-cn` → `zh-CN`),不合法时 undefined */
|
|
5
|
+
export function canonicalLanguage(tag) {
|
|
6
|
+
try {
|
|
7
|
+
return Intl.getCanonicalLocales(tag)[0];
|
|
8
|
+
}
|
|
9
|
+
catch {
|
|
10
|
+
return undefined;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
/** 读者给的列表只看前几项:超长的列表不逐项规范化 */
|
|
14
|
+
const MAX_INPUT = 32;
|
|
15
|
+
/** 语言链最多几种:再多也轮不到 */
|
|
16
|
+
const MAX_LANGUAGES = 8;
|
|
17
|
+
/**
|
|
18
|
+
* 读者给的语言列表规范化:整理写法、过 getCanonicalLocales,不合法的(含不是字符串的)丢掉,去重保序。列表来自会话设置与调用方
|
|
19
|
+
* (可能是手写的配置),只看前 32 项,至多给出 8 种
|
|
20
|
+
*/
|
|
21
|
+
export function normalizeLanguages(tags) {
|
|
22
|
+
const out = [];
|
|
23
|
+
for (const tag of tags.slice(0, MAX_INPUT)) {
|
|
24
|
+
if (out.length === MAX_LANGUAGES)
|
|
25
|
+
break;
|
|
26
|
+
if (typeof tag !== 'string')
|
|
27
|
+
continue;
|
|
28
|
+
const canonical = canonicalLanguage(tidy(tag));
|
|
29
|
+
if (canonical !== undefined && !out.includes(canonical))
|
|
30
|
+
out.push(canonical);
|
|
31
|
+
}
|
|
32
|
+
return out;
|
|
33
|
+
}
|
|
34
|
+
// 标签来自设置与调用方,条目数封顶
|
|
35
|
+
const subtagCache = new Map();
|
|
36
|
+
/** maximize 之后的语言、文字与地区:zh、zh-CN、zh-SG 都是 zh-Hans,zh-TW、zh-HK 是 zh-Hant */
|
|
37
|
+
function subtags(tag) {
|
|
38
|
+
let found = subtagCache.get(tag);
|
|
39
|
+
if (found === undefined) {
|
|
40
|
+
try {
|
|
41
|
+
const { language, script, region } = new Intl.Locale(tidy(tag)).maximize();
|
|
42
|
+
found = { language, script, region };
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
found = null;
|
|
46
|
+
}
|
|
47
|
+
if (subtagCache.size >= 512)
|
|
48
|
+
subtagCache.clear();
|
|
49
|
+
subtagCache.set(tag, found);
|
|
50
|
+
}
|
|
51
|
+
return found ?? undefined;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* 读者语言链在可用语言里对得上的一种,返回下标:①链里逐项找语言与文字都相同的,同文字内地区相同者优先,
|
|
55
|
+
* 其余按 available 的先后;②链里逐项找语言相同、文字不同的(zh-TW 读者遇到只有简体时用简体)。都没有时 undefined。
|
|
56
|
+
* 不含 en 兜底(见 {@link pickLanguage}):判「某种语言有没有字典提供」用它
|
|
57
|
+
*/
|
|
58
|
+
export function matchLanguage(chain, available) {
|
|
59
|
+
const offered = available.map(subtags);
|
|
60
|
+
const sameScript = (reader) => {
|
|
61
|
+
let first;
|
|
62
|
+
for (const [i, candidate] of offered.entries()) {
|
|
63
|
+
if (candidate?.language !== reader.language || candidate.script !== reader.script)
|
|
64
|
+
continue;
|
|
65
|
+
if (candidate.region === reader.region)
|
|
66
|
+
return i;
|
|
67
|
+
first ??= i;
|
|
68
|
+
}
|
|
69
|
+
return first;
|
|
70
|
+
};
|
|
71
|
+
const readers = chain.map(subtags).filter(reader => reader !== undefined);
|
|
72
|
+
for (const reader of readers) {
|
|
73
|
+
const found = sameScript(reader);
|
|
74
|
+
if (found !== undefined)
|
|
75
|
+
return found;
|
|
76
|
+
}
|
|
77
|
+
for (const reader of readers) {
|
|
78
|
+
const found = offered.findIndex(candidate => candidate?.language === reader.language);
|
|
79
|
+
if (found >= 0)
|
|
80
|
+
return found;
|
|
81
|
+
}
|
|
82
|
+
return undefined;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* 渲染时按读者语言链在可用语言里选一种,返回下标:链里对得上的({@link matchLanguage}),没有时找 en。
|
|
86
|
+
* 都没有时 undefined,由调用方用原文
|
|
87
|
+
*/
|
|
88
|
+
export function pickLanguage(chain, available) {
|
|
89
|
+
return matchLanguage(chain, available) ?? matchLanguage(['en'], available);
|
|
90
|
+
}
|
|
91
|
+
/** 该语言的自称(English、日本語 (日本));带扩展的标签这类 DisplayNames 不收的,用标签本身 */
|
|
92
|
+
export function selfName(tag) {
|
|
93
|
+
try {
|
|
94
|
+
return new Intl.DisplayNames([tag], { type: 'language' }).of(tag) ?? tag;
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
return tag;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
//# sourceMappingURL=locale.js.map
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
// 消息格式(ICU 子集)的解析:文法见 README「消息格式」一节。解析失败不抛,返回 undefined,由调用方回落。
|
|
2
|
+
const MAX_DEPTH = 16;
|
|
3
|
+
const NAME = /[A-Za-z_][A-Za-z0-9_]*/y;
|
|
4
|
+
const WORD = /[A-Za-z0-9_-]+/y;
|
|
5
|
+
const PLURAL_SELECTOR = /=-?\d+(?:\.\d+)?|[a-z]+/y;
|
|
6
|
+
const PLURAL_CATEGORIES = new Set(['zero', 'one', 'two', 'few', 'many', 'other']);
|
|
7
|
+
const DATE_STYLES = new Set(['short', 'medium', 'long']);
|
|
8
|
+
const POUND = { type: 'pound' };
|
|
9
|
+
const FAILED = new Error('消息模板不合文法');
|
|
10
|
+
function fail() {
|
|
11
|
+
throw FAILED;
|
|
12
|
+
}
|
|
13
|
+
class Reader {
|
|
14
|
+
src;
|
|
15
|
+
pos = 0;
|
|
16
|
+
constructor(src) {
|
|
17
|
+
this.src = src;
|
|
18
|
+
}
|
|
19
|
+
skipSpace() {
|
|
20
|
+
while (/[ \t\n\r]/.test(this.src[this.pos] ?? ''))
|
|
21
|
+
this.pos++;
|
|
22
|
+
}
|
|
23
|
+
eat(char) {
|
|
24
|
+
if (this.src[this.pos] !== char)
|
|
25
|
+
return false;
|
|
26
|
+
this.pos++;
|
|
27
|
+
return true;
|
|
28
|
+
}
|
|
29
|
+
expect(char) {
|
|
30
|
+
if (!this.eat(char))
|
|
31
|
+
fail();
|
|
32
|
+
}
|
|
33
|
+
match(pattern) {
|
|
34
|
+
pattern.lastIndex = this.pos;
|
|
35
|
+
const found = pattern.exec(this.src)?.[0];
|
|
36
|
+
if (found !== undefined)
|
|
37
|
+
this.pos += found.length;
|
|
38
|
+
return found;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* 引号(ICU 的 DOUBLE_OPTIONAL,`#` 不分是否在 plural 分支里):`''` 是一个撇号;`'` 紧跟 `{`、`}`、`#` 时引用到
|
|
43
|
+
* 下一个单独的 `'`,引用里的 `''` 仍是撇号,没闭合就到串尾;其余单个 `'` 是字面。读者停在 `'` 上,返回这一段的字面文字
|
|
44
|
+
*/
|
|
45
|
+
function readQuote(r) {
|
|
46
|
+
const next = r.src[r.pos + 1];
|
|
47
|
+
if (next === "'") {
|
|
48
|
+
r.pos += 2;
|
|
49
|
+
return "'";
|
|
50
|
+
}
|
|
51
|
+
r.pos++;
|
|
52
|
+
if (next !== '{' && next !== '}' && next !== '#')
|
|
53
|
+
return "'";
|
|
54
|
+
let text = '';
|
|
55
|
+
while (r.pos < r.src.length) {
|
|
56
|
+
if (r.src[r.pos] === "'") {
|
|
57
|
+
if (r.src[r.pos + 1] !== "'") {
|
|
58
|
+
r.pos++;
|
|
59
|
+
return text;
|
|
60
|
+
}
|
|
61
|
+
r.pos++;
|
|
62
|
+
}
|
|
63
|
+
text += r.src[r.pos++];
|
|
64
|
+
}
|
|
65
|
+
return text;
|
|
66
|
+
}
|
|
67
|
+
/** 消息体:顶层读到串尾,分支读到收尾的 }(不吃掉它)。`inPlural`:直接所在的是 plural 分支,# 是数 */
|
|
68
|
+
function readMessage(r, depth, inPlural) {
|
|
69
|
+
if (depth > MAX_DEPTH)
|
|
70
|
+
fail();
|
|
71
|
+
const parts = [];
|
|
72
|
+
let text = '';
|
|
73
|
+
const flush = () => {
|
|
74
|
+
if (text)
|
|
75
|
+
parts.push(text);
|
|
76
|
+
text = '';
|
|
77
|
+
};
|
|
78
|
+
while (r.pos < r.src.length) {
|
|
79
|
+
const char = r.src[r.pos];
|
|
80
|
+
if (char === "'") {
|
|
81
|
+
text += readQuote(r);
|
|
82
|
+
}
|
|
83
|
+
else if (char === '{') {
|
|
84
|
+
flush();
|
|
85
|
+
parts.push(readArgument(r, depth));
|
|
86
|
+
}
|
|
87
|
+
else if (char === '}') {
|
|
88
|
+
if (depth === 0)
|
|
89
|
+
fail();
|
|
90
|
+
break;
|
|
91
|
+
}
|
|
92
|
+
else if (char === '#' && inPlural) {
|
|
93
|
+
flush();
|
|
94
|
+
parts.push(POUND);
|
|
95
|
+
r.pos++;
|
|
96
|
+
}
|
|
97
|
+
else {
|
|
98
|
+
text += char;
|
|
99
|
+
r.pos++;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
if (depth > 0 && r.src[r.pos] !== '}')
|
|
103
|
+
fail();
|
|
104
|
+
flush();
|
|
105
|
+
return parts;
|
|
106
|
+
}
|
|
107
|
+
/** 读者停在 { 上,读完收尾的 } */
|
|
108
|
+
function readArgument(r, depth) {
|
|
109
|
+
r.pos++;
|
|
110
|
+
r.skipSpace();
|
|
111
|
+
const name = r.match(NAME) ?? fail();
|
|
112
|
+
r.skipSpace();
|
|
113
|
+
if (r.eat('}'))
|
|
114
|
+
return { type: 'plain', name };
|
|
115
|
+
r.expect(',');
|
|
116
|
+
r.skipSpace();
|
|
117
|
+
const type = r.match(WORD);
|
|
118
|
+
r.skipSpace();
|
|
119
|
+
switch (type) {
|
|
120
|
+
case 'plural':
|
|
121
|
+
case 'select': {
|
|
122
|
+
r.expect(',');
|
|
123
|
+
const branches = new Map();
|
|
124
|
+
for (r.skipSpace(); !r.eat('}'); r.skipSpace()) {
|
|
125
|
+
const key = type === 'plural' ? pluralSelector(r) : (r.match(WORD) ?? fail());
|
|
126
|
+
if (branches.has(key))
|
|
127
|
+
fail();
|
|
128
|
+
r.skipSpace();
|
|
129
|
+
r.expect('{');
|
|
130
|
+
branches.set(key, readMessage(r, depth + 1, type === 'plural'));
|
|
131
|
+
r.expect('}');
|
|
132
|
+
}
|
|
133
|
+
if (!branches.has('other'))
|
|
134
|
+
fail();
|
|
135
|
+
return { type, name, branches };
|
|
136
|
+
}
|
|
137
|
+
case 'number':
|
|
138
|
+
case 'lines':
|
|
139
|
+
case 'duration':
|
|
140
|
+
case 'size':
|
|
141
|
+
case 'code':
|
|
142
|
+
r.expect('}');
|
|
143
|
+
return { type, name };
|
|
144
|
+
case 'list': {
|
|
145
|
+
let listType = 'conjunction';
|
|
146
|
+
if (r.eat(',')) {
|
|
147
|
+
r.skipSpace();
|
|
148
|
+
if (r.match(WORD) !== 'or')
|
|
149
|
+
fail();
|
|
150
|
+
listType = 'disjunction';
|
|
151
|
+
r.skipSpace();
|
|
152
|
+
}
|
|
153
|
+
r.expect('}');
|
|
154
|
+
return { type, name, listType };
|
|
155
|
+
}
|
|
156
|
+
case 'date':
|
|
157
|
+
case 'time':
|
|
158
|
+
case 'datetime': {
|
|
159
|
+
let style = 'medium';
|
|
160
|
+
if (r.eat(',')) {
|
|
161
|
+
r.skipSpace();
|
|
162
|
+
const word = r.match(WORD);
|
|
163
|
+
if (word === undefined || !DATE_STYLES.has(word))
|
|
164
|
+
fail();
|
|
165
|
+
style = word;
|
|
166
|
+
r.skipSpace();
|
|
167
|
+
}
|
|
168
|
+
r.expect('}');
|
|
169
|
+
return { type, name, style };
|
|
170
|
+
}
|
|
171
|
+
default:
|
|
172
|
+
return fail();
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
/** plural 的选择子:`=N`(规范成 `=` 加数的字符串写法,好与值直接比)或 CLDR 类别 */
|
|
176
|
+
function pluralSelector(r) {
|
|
177
|
+
const key = r.match(PLURAL_SELECTOR) ?? fail();
|
|
178
|
+
if (key.startsWith('='))
|
|
179
|
+
return `=${Number(key.slice(1))}`;
|
|
180
|
+
return PLURAL_CATEGORIES.has(key) ? key : fail();
|
|
181
|
+
}
|
|
182
|
+
const parsed = new Map();
|
|
183
|
+
/** 解析消息模板,结果按模板缓存(模板只来自原文字典与语言包) */
|
|
184
|
+
export function parseMessage(template) {
|
|
185
|
+
let message = parsed.get(template);
|
|
186
|
+
if (message === undefined) {
|
|
187
|
+
try {
|
|
188
|
+
message = readMessage(new Reader(template), 0, false);
|
|
189
|
+
}
|
|
190
|
+
catch {
|
|
191
|
+
message = null;
|
|
192
|
+
}
|
|
193
|
+
parsed.set(template, message);
|
|
194
|
+
}
|
|
195
|
+
return message ?? undefined;
|
|
196
|
+
}
|
|
197
|
+
// 参数收的值的种类,按位组合:同名参数取交集,译文的参数收得下原文交来的值才算兼容
|
|
198
|
+
const STRING = 1;
|
|
199
|
+
const NUMBER = 2;
|
|
200
|
+
const TEXT = 4;
|
|
201
|
+
const ARRAY = 8;
|
|
202
|
+
const ACCEPTS = {
|
|
203
|
+
plain: STRING | NUMBER | TEXT,
|
|
204
|
+
select: STRING,
|
|
205
|
+
plural: NUMBER,
|
|
206
|
+
number: NUMBER,
|
|
207
|
+
date: NUMBER,
|
|
208
|
+
time: NUMBER,
|
|
209
|
+
datetime: NUMBER,
|
|
210
|
+
duration: NUMBER,
|
|
211
|
+
size: NUMBER,
|
|
212
|
+
list: ARRAY,
|
|
213
|
+
lines: ARRAY,
|
|
214
|
+
code: STRING | NUMBER,
|
|
215
|
+
};
|
|
216
|
+
function collectKinds(message, kinds) {
|
|
217
|
+
for (const part of message) {
|
|
218
|
+
if (typeof part === 'string' || part.type === 'pound')
|
|
219
|
+
continue;
|
|
220
|
+
kinds.set(part.name, (kinds.get(part.name) ?? ~0) & ACCEPTS[part.type]);
|
|
221
|
+
if (part.type === 'plural' || part.type === 'select') {
|
|
222
|
+
for (const branch of part.branches.values())
|
|
223
|
+
collectKinds(branch, kinds);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
// 解析结果按模板缓存,种类跟着解析结果缓存
|
|
228
|
+
const kindsCache = new WeakMap();
|
|
229
|
+
/** 消息用到的参数与各自收的值(与类型层推参同一规则),按出现先后;同名参数的用法互不相容时为 0 */
|
|
230
|
+
export function paramKinds(message) {
|
|
231
|
+
let kinds = kindsCache.get(message);
|
|
232
|
+
if (kinds === undefined) {
|
|
233
|
+
const collected = new Map();
|
|
234
|
+
collectKinds(message, collected);
|
|
235
|
+
kinds = collected;
|
|
236
|
+
kindsCache.set(message, kinds);
|
|
237
|
+
}
|
|
238
|
+
return kinds;
|
|
239
|
+
}
|
|
240
|
+
/** 译文与原文不兼容的参数:原文没有的、或原文交来的值译文收不下的 */
|
|
241
|
+
export function incompatibleParams(source, translation) {
|
|
242
|
+
return [...paramKinds(translation)]
|
|
243
|
+
.filter(([name, accepts]) => {
|
|
244
|
+
const given = source.get(name);
|
|
245
|
+
return given === undefined || (given & ~accepts) !== 0;
|
|
246
|
+
})
|
|
247
|
+
.map(([name]) => name);
|
|
248
|
+
}
|
|
249
|
+
//# sourceMappingURL=message.js.map
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// 语言包:给字典补它没有的语言。条目带翻译时原文的指纹,原文改了措辞的条目当作缺失
|
|
2
|
+
import { normalizeLanguages } from './locale.js';
|
|
3
|
+
const isRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
4
|
+
/**
|
|
5
|
+
* 建语言包索引,同一语言按交来的先后取先(plugin-i18n 按贡献点的全局键交来,包的实例 id 在前)。语言包来自第三方:
|
|
6
|
+
* 形状不对或访问时抛错的包、语言标签不合法的包、形状不对的表与条目都跳过,不影响别的包;
|
|
7
|
+
* 取舍(只补缺、指纹、解析失败与参数不兼容)在解析时做
|
|
8
|
+
*/
|
|
9
|
+
export function indexPacks(packs) {
|
|
10
|
+
const index = new Map();
|
|
11
|
+
for (const pack of packs) {
|
|
12
|
+
for (const [ns, key, candidate] of readPack(pack)) {
|
|
13
|
+
const keys = index.get(ns) ?? new Map();
|
|
14
|
+
keys.set(key, [...(keys.get(key) ?? []), candidate]);
|
|
15
|
+
index.set(ns, keys);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
return index;
|
|
19
|
+
}
|
|
20
|
+
/** 一个包里有效的条目;读包时抛错(第三方的 getter、Proxy)整包跳过 */
|
|
21
|
+
function readPack(pack) {
|
|
22
|
+
const found = [];
|
|
23
|
+
try {
|
|
24
|
+
const [language] = normalizeLanguages([pack.language]);
|
|
25
|
+
const entries = pack.entries;
|
|
26
|
+
if (language === undefined || !isRecord(entries))
|
|
27
|
+
return [];
|
|
28
|
+
for (const [ns, table] of Object.entries(entries)) {
|
|
29
|
+
if (!isRecord(table))
|
|
30
|
+
continue;
|
|
31
|
+
for (const [key, entry] of Object.entries(table)) {
|
|
32
|
+
const { text, from } = (entry ?? {});
|
|
33
|
+
if (typeof text === 'string' && typeof from === 'string')
|
|
34
|
+
found.push([ns, key, { language, text, from }]);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return [];
|
|
40
|
+
}
|
|
41
|
+
return found;
|
|
42
|
+
}
|
|
43
|
+
/** 原文模板的指纹:FNV-1a 32 位,按 UTF-16 码元,写成 8 位十六进制 */
|
|
44
|
+
export function fingerprint(template) {
|
|
45
|
+
let hash = 0x811c9dc5;
|
|
46
|
+
for (let i = 0; i < template.length; i++) {
|
|
47
|
+
hash ^= template.charCodeAt(i);
|
|
48
|
+
hash = Math.imul(hash, 0x01000193);
|
|
49
|
+
}
|
|
50
|
+
return (hash >>> 0).toString(16).padStart(8, '0');
|
|
51
|
+
}
|
|
52
|
+
//# sourceMappingURL=packs.js.map
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// 按读者语言链把一个文字对象渲染成字符串(纯函数,出口与前端共用)
|
|
2
|
+
import { catalogEntry, entryText } from './catalog.js';
|
|
3
|
+
import { escapeMarkdown, formatMessage } from './format.js';
|
|
4
|
+
import { pickLanguage } from './locale.js';
|
|
5
|
+
import { incompatibleParams, paramKinds, parseMessage } from './message.js';
|
|
6
|
+
import { fingerprint } from './packs.js';
|
|
7
|
+
import { SOURCE } from './source.js';
|
|
8
|
+
/** 本包字典里罗列的分隔符那一键,同 texts.ts 的 t('listSeparator')(原文没有参数,source 就是原文) */
|
|
9
|
+
const LIST_SEPARATOR = { i18n: '@aalis/schema-i18n', key: 'listSeparator', source: SOURCE.listSeparator };
|
|
10
|
+
/**
|
|
11
|
+
* 一次渲染里参数中的文字对象最多渲染几个:再多的用那一个的 source。伪造的参数每层把下一层用两次,30 层就是 2^30 个,
|
|
12
|
+
* 环更是无穷,靠总量封顶
|
|
13
|
+
*/
|
|
14
|
+
const MAX_NESTED = 1024;
|
|
15
|
+
/** 参数里的文字对象最多嵌套几层:再深的用那一层的 source,只防栈太深 */
|
|
16
|
+
const MAX_DEPTH = 32;
|
|
17
|
+
/**
|
|
18
|
+
* 按读者的语言链渲染文字对象。可用语言 = 原文语言 ∪ 字典自带译文的语言 ∪ 语言包补的语言(只补缺,原文指纹相符才算),
|
|
19
|
+
* 解析失败与参数和原文不兼容的译文不算。选法见 pickLanguage:读者链里文字相同的 → 语言相同、文字不同的 → en → 原文;
|
|
20
|
+
* 命中原文语言就用原文。ns 不在目录、键不存在、原文解析失败、渲染失败时用 `text.source`(转义模式下整句转义)。
|
|
21
|
+
* 参数里的文字对象用同一条链递归:一次渲染至多渲染 1024 个、嵌套 32 层,超出的用那一个的 source
|
|
22
|
+
*/
|
|
23
|
+
export function resolveText(text, chain, options = {}) {
|
|
24
|
+
return resolve(text, chain, options, 0, { left: MAX_NESTED });
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* 罗列(`{x, list}`)的分隔符:本包字典的 listSeparator 按 language 取,可用语言与选法同 {@link resolveText}(链里只有这一种
|
|
28
|
+
* 语言),字典与语言包都没有这种语言时回落到 en 的「, 」。`{x, list}` 按句子选中的语言取它;自己罗列的出口(如前端)同样用它
|
|
29
|
+
*/
|
|
30
|
+
export function listSeparator(language, packs) {
|
|
31
|
+
return resolveText(LIST_SEPARATOR, [language], { packs });
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* 只按原文语言渲染文字对象:出口在 i18n 服务缺席时用(日志与交给模型的用 toSource)。参数里的文字对象同样取各自的原文,
|
|
35
|
+
* 回落规则同 {@link resolveText}
|
|
36
|
+
*/
|
|
37
|
+
export function resolveSource(text, options = {}) {
|
|
38
|
+
return resolve(text, undefined, options, 0, { left: MAX_NESTED });
|
|
39
|
+
}
|
|
40
|
+
/** chain 为 undefined 时只取原文;budget 是这次顶层渲染里参数中的文字对象还能渲染几个 */
|
|
41
|
+
function resolve(text, chain, options, depth, budget) {
|
|
42
|
+
const fallback = () => (options.escape === 'markdown' ? escapeMarkdown(text.source) : text.source);
|
|
43
|
+
const within = depth === 0 || (depth <= MAX_DEPTH && budget.left-- > 0);
|
|
44
|
+
const entry = within ? catalogEntry(text.i18n) : undefined;
|
|
45
|
+
const template = entry && entryText(entry.source, text.key);
|
|
46
|
+
const source = template === undefined ? undefined : parseMessage(template);
|
|
47
|
+
if (entry === undefined || template === undefined || source === undefined)
|
|
48
|
+
return fallback();
|
|
49
|
+
const languages = [entry.language];
|
|
50
|
+
const messages = [source];
|
|
51
|
+
if (chain !== undefined) {
|
|
52
|
+
const expected = paramKinds(source);
|
|
53
|
+
const offer = (language, translation) => {
|
|
54
|
+
const message = parseMessage(translation);
|
|
55
|
+
if (message === undefined || incompatibleParams(expected, message).length > 0)
|
|
56
|
+
return;
|
|
57
|
+
languages.push(language);
|
|
58
|
+
messages.push(message);
|
|
59
|
+
};
|
|
60
|
+
for (const [language, table] of Object.entries(entry.locales)) {
|
|
61
|
+
const translation = entryText(table, text.key);
|
|
62
|
+
if (translation !== undefined)
|
|
63
|
+
offer(language, translation);
|
|
64
|
+
}
|
|
65
|
+
const packed = options.packs?.get(text.i18n)?.get(text.key);
|
|
66
|
+
if (packed) {
|
|
67
|
+
const from = fingerprint(template);
|
|
68
|
+
for (const candidate of packed) {
|
|
69
|
+
if (candidate.from !== from || languages.includes(candidate.language))
|
|
70
|
+
continue;
|
|
71
|
+
offer(candidate.language, candidate.text);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
const chosen = chain === undefined ? 0 : (pickLanguage(chain, languages) ?? 0);
|
|
76
|
+
const rendered = formatMessage(messages[chosen], text.params ?? {}, {
|
|
77
|
+
language: languages[chosen],
|
|
78
|
+
timeZone: options.timeZone,
|
|
79
|
+
escape: options.escape,
|
|
80
|
+
nested: nested => resolve(nested, chain, options, depth + 1, budget),
|
|
81
|
+
listSeparator: () => listSeparator(languages[chosen], options.packs),
|
|
82
|
+
});
|
|
83
|
+
return rendered ?? fallback();
|
|
84
|
+
}
|
|
85
|
+
//# sourceMappingURL=resolve.js.map
|