@aalis/api-webui 0.12.0 → 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.js +65 -7
- 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 +32 -7
- package/dist/frame.d.ts.map +1 -1
- package/dist/frame.js +2 -2
- package/dist/frame.js.map +1 -1
- package/dist/index.d.ts +15 -7
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +23 -20
- 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 +36 -26
- package/dist/page.d.ts.map +1 -1
- package/dist/page.js +2 -0
- package/dist/page.js.map +1 -1
- package/dist/rest.d.ts +115 -26
- package/dist/rest.d.ts.map +1 -1
- package/dist/rest.js +10 -1
- package/dist/rest.js.map +1 -1
- 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 +8 -11
- package/dist/theme.d.ts.map +1 -1
- package/dist/theme.js +75 -57
- package/dist/theme.js.map +1 -1
- package/dist/themes/aalis-dark.json +0 -1
- package/dist/themes/aalis-light.json +0 -1
- package/dist/themes/brand-dark.json +0 -1
- package/dist/themes/brand-light.json +0 -1
- package/dist/themes/calm-dark.json +0 -1
- package/dist/themes/calm-light.json +0 -1
- package/dist/themes/neutral-dark.json +0 -1
- package/dist/themes/neutral-light.json +0 -1
- package/package.json +12 -11
|
@@ -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
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
const TEXT_REF_KEYS = new Set(['i18n', 'key', 'params', 'source']);
|
|
2
|
+
/**
|
|
3
|
+
* 是不是文字对象:只按形状判(i18n、key、source 是字符串,params 缺省或为普通对象,没有别的键),
|
|
4
|
+
* 从 JSON 读回的、另一份本包产出的都认
|
|
5
|
+
*/
|
|
6
|
+
export function isTextRef(value) {
|
|
7
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value))
|
|
8
|
+
return false;
|
|
9
|
+
const v = value;
|
|
10
|
+
if (typeof v.i18n !== 'string' || typeof v.key !== 'string' || typeof v.source !== 'string')
|
|
11
|
+
return false;
|
|
12
|
+
if (v.params !== undefined && (typeof v.params !== 'object' || v.params === null || Array.isArray(v.params))) {
|
|
13
|
+
return false;
|
|
14
|
+
}
|
|
15
|
+
return Object.keys(v).every(key => TEXT_REF_KEYS.has(key));
|
|
16
|
+
}
|
|
17
|
+
/** 展示值(core 的插件显示名、服务 label 这类 unknown 字段)收窄成文字:字符串与文字对象原样,其余 undefined */
|
|
18
|
+
export function asText(value) {
|
|
19
|
+
return typeof value === 'string' || isTextRef(value) ? value : undefined;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* 两段文字是不是同一句:有一方是字符串时按原文比;文字对象之间按所属包、键与参数认,原文的措辞
|
|
23
|
+
* 变了也还是同一句。参数按结构比:参数键的先后不算,参数里的文字对象同样按包、键与参数认,数组逐个按位置比,
|
|
24
|
+
* 别的值(落盘读回的 null 一类)按 === 比
|
|
25
|
+
*/
|
|
26
|
+
export function sameText(a, b) {
|
|
27
|
+
if (typeof a === 'string' || typeof b === 'string')
|
|
28
|
+
return toSource(a) === toSource(b);
|
|
29
|
+
if (a.i18n !== b.i18n || a.key !== b.key)
|
|
30
|
+
return false;
|
|
31
|
+
const left = a.params ?? {};
|
|
32
|
+
const right = b.params ?? {};
|
|
33
|
+
const keys = Object.keys(left);
|
|
34
|
+
return (keys.length === Object.keys(right).length &&
|
|
35
|
+
keys.every(key => Object.hasOwn(right, key) && sameParam(left[key], right[key])));
|
|
36
|
+
}
|
|
37
|
+
const isList = (value) => Array.isArray(value);
|
|
38
|
+
function sameParam(a, b) {
|
|
39
|
+
if (isList(a) || isList(b)) {
|
|
40
|
+
return isList(a) && isList(b) && a.length === b.length && a.every((item, i) => sameParam(item, b[i]));
|
|
41
|
+
}
|
|
42
|
+
return isTextRef(a) && isTextRef(b) ? sameText(a, b) : a === b;
|
|
43
|
+
}
|
|
44
|
+
function isPlainObject(value) {
|
|
45
|
+
const proto = Object.getPrototypeOf(value);
|
|
46
|
+
return proto === Object.prototype || proto === null;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* 把值里的文字对象换成 `render(text)`。只进普通对象与数组(类实例原样),没有文字对象的子树原样返回、不复制,
|
|
50
|
+
* 有改动时只复制通往文字对象的那一路。`skip(key, parent)` 为真的属性不进:出口用它避开原样数据
|
|
51
|
+
* (工具参数与结果、审批的逐字参数、用户消息正文),那里形如文字对象的值可能是伪造的。
|
|
52
|
+
*/
|
|
53
|
+
export function localize(value, render, skip) {
|
|
54
|
+
const walk = (v) => {
|
|
55
|
+
if (typeof v !== 'object' || v === null)
|
|
56
|
+
return v;
|
|
57
|
+
if (isTextRef(v))
|
|
58
|
+
return render(v);
|
|
59
|
+
if (Array.isArray(v)) {
|
|
60
|
+
let out;
|
|
61
|
+
v.forEach((item, i) => {
|
|
62
|
+
const next = walk(item);
|
|
63
|
+
if (Object.is(next, item))
|
|
64
|
+
return;
|
|
65
|
+
out ??= [...v];
|
|
66
|
+
out[i] = next;
|
|
67
|
+
});
|
|
68
|
+
return out ?? v;
|
|
69
|
+
}
|
|
70
|
+
if (!isPlainObject(v))
|
|
71
|
+
return v;
|
|
72
|
+
let out;
|
|
73
|
+
for (const [key, item] of Object.entries(v)) {
|
|
74
|
+
if (skip?.(key, v))
|
|
75
|
+
continue;
|
|
76
|
+
const next = walk(item);
|
|
77
|
+
if (Object.is(next, item))
|
|
78
|
+
continue;
|
|
79
|
+
out ??= { ...v };
|
|
80
|
+
out[key] = next;
|
|
81
|
+
}
|
|
82
|
+
return out ?? v;
|
|
83
|
+
};
|
|
84
|
+
return walk(value);
|
|
85
|
+
}
|
|
86
|
+
/** 值里的文字对象换成 source(生成时的整句):写日志、交给模型时用 */
|
|
87
|
+
export function toSource(value) {
|
|
88
|
+
return localize(value, text => text.source);
|
|
89
|
+
}
|
|
90
|
+
/** 给 `JSON.stringify` 的 replacer:文字对象写成 source */
|
|
91
|
+
export function toSourceReplacer(_key, value) {
|
|
92
|
+
return isTextRef(value) ? value.source : value;
|
|
93
|
+
}
|
|
94
|
+
//# sourceMappingURL=text-ref.js.map
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// 本包自己的原文字典(原文表在 source.ts)
|
|
2
|
+
import { defineTexts } from './define.js';
|
|
3
|
+
import en from './locales/en.js';
|
|
4
|
+
import { SOURCE } from './source.js';
|
|
5
|
+
const t = defineTexts({ ns: '@aalis/schema-i18n', language: 'zh-CN', source: SOURCE, locales: { en } });
|
|
6
|
+
/** 把若干段(字符串或文字对象)按行连成一个文字对象:多行报告、列表类回复用它,每段按读者语言各自渲染 */
|
|
7
|
+
export function textLines(parts) {
|
|
8
|
+
return t('lines', { items: parts });
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* textLines 的逆:是 textLines 生成的文字对象(落盘读回的同样认)就给出各段,否则 undefined。要逐行加工(缩进、加前缀)
|
|
12
|
+
* 又不能先渲染的一方用它;段本身可能是多行的字符串或别的文字对象
|
|
13
|
+
*/
|
|
14
|
+
export function linesOf(text) {
|
|
15
|
+
const items = text.i18n === t.ns && text.key === 'lines' ? text.params?.items : undefined;
|
|
16
|
+
return Array.isArray(items) ? items : undefined;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* 把若干分句(字符串或文字对象)连成一句:原文以「;」相连,英文以「; 」。并列的几条原因、几个问题用它。两两二分着嵌套,
|
|
20
|
+
* 层数随句数按对数增长;n 句约占 2n 个参数里的文字对象,每句另带文字对象参数时更多(见 resolveText 的渲染上限)
|
|
21
|
+
*/
|
|
22
|
+
export function textClauses(parts) {
|
|
23
|
+
if (parts.length < 2)
|
|
24
|
+
return textLines(parts);
|
|
25
|
+
const half = Math.ceil(parts.length / 2);
|
|
26
|
+
const side = (slice) => (slice.length === 1 ? slice[0] : textClauses(slice));
|
|
27
|
+
return t('clauses', { head: side(parts.slice(0, half)), tail: side(parts.slice(half)) });
|
|
28
|
+
}
|
|
29
|
+
//# sourceMappingURL=texts.js.map
|