@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,218 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* 插件前端的样式基线,托管为 /frames/_base/frame.css,插件前端可选引用:
|
|
3
|
+
* <link rel="stylesheet" href="/frames/_base/frame.css">
|
|
4
|
+
* 只给元素级的样式,不设按类名选的变体。颜色只引用主题令牌(助手收到 init 时写在根元素上,主题变了跟着更新);
|
|
5
|
+
* 尺寸写在本文件里,取值与宿主界面相同,触屏与减少动效由本文件自己的媒体查询求值。圆角用 --radius-sm、--radius-md
|
|
6
|
+
* 两个名字写默认值,宿主以后推入的值写在根元素的内联样式上,盖过这里的默认值。
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
*,
|
|
10
|
+
*::before,
|
|
11
|
+
*::after {
|
|
12
|
+
box-sizing: border-box;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
:root {
|
|
16
|
+
--radius-sm: 4px;
|
|
17
|
+
--radius-md: 6px;
|
|
18
|
+
font-family:
|
|
19
|
+
system-ui, -apple-system, "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Noto Sans CJK SC",
|
|
20
|
+
sans-serif;
|
|
21
|
+
font-size: 100%;
|
|
22
|
+
line-height: 1.375rem;
|
|
23
|
+
color: var(--color-text);
|
|
24
|
+
-webkit-text-size-adjust: 100%;
|
|
25
|
+
text-size-adjust: 100%;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
body {
|
|
29
|
+
margin: 0;
|
|
30
|
+
padding: 1rem;
|
|
31
|
+
font-size: 0.875rem;
|
|
32
|
+
background: var(--color-bg);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
h1,
|
|
36
|
+
h2,
|
|
37
|
+
h3 {
|
|
38
|
+
margin: 0 0 0.5rem;
|
|
39
|
+
font-size: 1rem;
|
|
40
|
+
font-weight: 600;
|
|
41
|
+
line-height: 1.5rem;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
p,
|
|
45
|
+
ul,
|
|
46
|
+
ol,
|
|
47
|
+
table,
|
|
48
|
+
pre {
|
|
49
|
+
margin: 0 0 0.75rem;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
ul,
|
|
53
|
+
ol {
|
|
54
|
+
padding-left: 1.5rem;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
li + li {
|
|
58
|
+
margin-top: 0.25rem;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
a {
|
|
62
|
+
color: var(--color-accent-text);
|
|
63
|
+
text-decoration: underline 1px;
|
|
64
|
+
text-underline-offset: 2px;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/* 焦点环用 outline:Windows 高对比度模式会去掉阴影 */
|
|
68
|
+
:focus-visible {
|
|
69
|
+
outline: 2px solid var(--color-focus);
|
|
70
|
+
outline-offset: 2px;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
button,
|
|
74
|
+
input,
|
|
75
|
+
select,
|
|
76
|
+
textarea {
|
|
77
|
+
font: inherit;
|
|
78
|
+
color: inherit;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/* 按钮只有一种,与宿主的次要按钮一致 */
|
|
82
|
+
button {
|
|
83
|
+
min-width: 1.75rem;
|
|
84
|
+
min-height: 1.75rem;
|
|
85
|
+
padding: 0 0.5rem;
|
|
86
|
+
border: 0;
|
|
87
|
+
border-radius: var(--radius-sm);
|
|
88
|
+
font-size: 0.8125rem;
|
|
89
|
+
font-weight: 400;
|
|
90
|
+
line-height: 1.25rem;
|
|
91
|
+
background: var(--color-fill-2);
|
|
92
|
+
cursor: pointer;
|
|
93
|
+
transition: background-color 100ms cubic-bezier(0.2, 0, 0, 1);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/* 只写底色:简写 background 会复位 background-clip,触屏上底色就铺进透明边框(见文件末尾) */
|
|
97
|
+
button:hover:not(:disabled) {
|
|
98
|
+
background-color: var(--color-fill-3);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
button:disabled {
|
|
102
|
+
color: var(--color-text-disabled);
|
|
103
|
+
cursor: default;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
input:not([type="checkbox"]):not([type="radio"]),
|
|
107
|
+
select,
|
|
108
|
+
textarea {
|
|
109
|
+
min-height: 1.75rem;
|
|
110
|
+
padding: 0 0.75rem;
|
|
111
|
+
font-size: 0.875rem;
|
|
112
|
+
background: transparent;
|
|
113
|
+
border: 1px solid var(--color-border-control);
|
|
114
|
+
border-radius: var(--radius-sm);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
textarea {
|
|
118
|
+
padding: 0.5rem 0.75rem;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
input:focus-visible,
|
|
122
|
+
select:focus-visible,
|
|
123
|
+
textarea:focus-visible {
|
|
124
|
+
outline-offset: 0;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
::placeholder {
|
|
128
|
+
color: var(--color-text-3);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
input[type="checkbox"],
|
|
132
|
+
input[type="radio"] {
|
|
133
|
+
accent-color: var(--color-accent);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
table {
|
|
137
|
+
width: 100%;
|
|
138
|
+
border-collapse: collapse;
|
|
139
|
+
font-size: 0.8125rem;
|
|
140
|
+
line-height: 1.25rem;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
th,
|
|
144
|
+
td {
|
|
145
|
+
height: 2.25rem;
|
|
146
|
+
padding: 0 0.75rem;
|
|
147
|
+
border-bottom: 1px solid var(--color-border);
|
|
148
|
+
text-align: left;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
th {
|
|
152
|
+
height: 2rem;
|
|
153
|
+
font-size: 0.75rem;
|
|
154
|
+
font-weight: 500;
|
|
155
|
+
color: var(--color-text-2);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
code,
|
|
159
|
+
kbd,
|
|
160
|
+
pre {
|
|
161
|
+
font-family: ui-monospace, "SF Mono", "JetBrains Mono", "Cascadia Mono", Menlo, Consolas, monospace;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
:not(pre) > code {
|
|
165
|
+
padding: 0 0.25rem;
|
|
166
|
+
border-radius: var(--radius-sm);
|
|
167
|
+
font-size: 0.9em;
|
|
168
|
+
background: var(--color-fill-2);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
pre {
|
|
172
|
+
padding: 0.75rem;
|
|
173
|
+
overflow-x: auto;
|
|
174
|
+
border-radius: var(--radius-md);
|
|
175
|
+
font-size: 0.8125rem;
|
|
176
|
+
line-height: 1.25rem;
|
|
177
|
+
background: var(--color-fill-1);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/*
|
|
181
|
+
* 触屏:点按范围 44 像素。按钮外面一圈 0.25rem 的透明边框占排版位置,底色只画到边框以内,看得见的是 36 像素;
|
|
182
|
+
* 圆角加上边框宽,里面的角仍是 --radius-sm;焦点环往回收到看得见的框外 2 像素。
|
|
183
|
+
* 输入框 44 像素高,字号不小于 16 像素,免得 iOS 聚焦时放大整页
|
|
184
|
+
*/
|
|
185
|
+
@media (pointer: coarse) {
|
|
186
|
+
button {
|
|
187
|
+
min-width: 2.75rem;
|
|
188
|
+
min-height: 2.75rem;
|
|
189
|
+
border: 0.25rem solid transparent;
|
|
190
|
+
border-radius: calc(var(--radius-sm) + 0.25rem);
|
|
191
|
+
background-clip: padding-box;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
button:focus-visible {
|
|
195
|
+
outline-offset: -2px;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
input:not([type="checkbox"]):not([type="radio"]),
|
|
199
|
+
select {
|
|
200
|
+
min-height: 2.75rem;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
input:not([type="checkbox"]):not([type="radio"]),
|
|
204
|
+
select,
|
|
205
|
+
textarea {
|
|
206
|
+
font-size: 1rem;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
td {
|
|
210
|
+
height: 3rem;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
@media (prefers-reduced-motion: reduce) {
|
|
215
|
+
button {
|
|
216
|
+
transition: none;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
// 插件前端的助手,托管为 /frames/_base/frame.js。片段里的 nonce 原样读出、不清:清片段会往会话历史里加条目,
|
|
2
|
+
// nonce 对框架自己本来也不保密。同一页上的框架都是不透明来源、能互相发窗口消息,但只有父文档发得出来源是
|
|
3
|
+
// window.parent 的消息,所以只认来自它、带端口的第一个 init,之后只用这个端口。主题令牌、这个插件的字典与 <html lang>
|
|
4
|
+
// 在收到 init 的同一拍写上,框架显示出来时已是宿主的配色与语言。导入的 schema-i18n 构建时换成随本包带的那一份
|
|
5
|
+
// (/frames/_base/schema-i18n/,见 build.mjs)
|
|
6
|
+
import { fingerprint, normalizeLanguages, pickLanguage, registerCatalogEntry, resolveSource, resolveText, } from './schema-i18n/index.js';
|
|
7
|
+
/** 与 api-webui 的 FRAME_PROTOCOL_VERSION 同值:助手随 api-webui 同版本发布 */
|
|
8
|
+
const PROTOCOL = '1.1';
|
|
9
|
+
/** 发出 hello 之后等 init 的时长 */
|
|
10
|
+
const INIT_TIMEOUT_MS = 10_000;
|
|
11
|
+
function applyTheme({ kind, tokens }) {
|
|
12
|
+
const root = document.documentElement;
|
|
13
|
+
for (const [name, value] of Object.entries(tokens))
|
|
14
|
+
root.style.setProperty(name, value);
|
|
15
|
+
root.dataset.theme = kind;
|
|
16
|
+
root.style.colorScheme = kind;
|
|
17
|
+
}
|
|
18
|
+
/** 宿主给的服务进程时区:认不出的(不按协议的宿主)当作没给,免得插件拿去格式化时抛错 */
|
|
19
|
+
function zoneOf(value) {
|
|
20
|
+
if (typeof value !== 'string')
|
|
21
|
+
return undefined;
|
|
22
|
+
try {
|
|
23
|
+
new Intl.DateTimeFormat('en', { timeZone: value });
|
|
24
|
+
return value;
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return undefined;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* 语言包给这个插件补的译文:只认 language、text、from 都是字符串的条目,形状不对的丢掉,与宿主一侧同一读法。按协议的宿主
|
|
32
|
+
* 交来的已经筛过;不按协议的宿主交来坏条目时,握手照样完成,t() 也不因它抛错
|
|
33
|
+
*/
|
|
34
|
+
function packed(packs) {
|
|
35
|
+
if (typeof packs !== 'object' || packs === null)
|
|
36
|
+
return {};
|
|
37
|
+
return Object.fromEntries(Object.entries(packs).map(([key, candidates]) => [
|
|
38
|
+
key,
|
|
39
|
+
(Array.isArray(candidates) ? candidates : []).filter((candidate) => typeof candidate?.language === 'string' &&
|
|
40
|
+
typeof candidate.text === 'string' &&
|
|
41
|
+
typeof candidate.from === 'string'),
|
|
42
|
+
]));
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* 登记这个插件的字典,按字典实际选中的语言设 <html lang>(原文、字典自带的译文与原文指纹相符的语言包补充里,按语言链选),
|
|
46
|
+
* 回这个语言与 t()。次版本更早的宿主不给这几项,插件也可以没有字典:这时 t() 回键名,lang 不动,语言为 undefined
|
|
47
|
+
*/
|
|
48
|
+
function translator({ languages, texts, packs }, timeZone) {
|
|
49
|
+
if (!texts || !registerCatalogEntry(texts))
|
|
50
|
+
return { t: key => key };
|
|
51
|
+
const chain = Array.isArray(languages) ? languages : [];
|
|
52
|
+
const supplied = packed(packs);
|
|
53
|
+
// 标签按规范写法比与写(目录里登记的也是规范写法),不照抄宿主给的大小写
|
|
54
|
+
const tags = [texts.language, ...Object.keys(texts.locales)];
|
|
55
|
+
for (const [key, candidates] of Object.entries(supplied)) {
|
|
56
|
+
const from = Object.hasOwn(texts.source, key) ? fingerprint(texts.source[key]) : undefined;
|
|
57
|
+
for (const { language, from: given } of candidates)
|
|
58
|
+
if (given === from)
|
|
59
|
+
tags.push(language);
|
|
60
|
+
}
|
|
61
|
+
const offered = normalizeLanguages(tags);
|
|
62
|
+
const language = offered[chain.length > 0 ? (pickLanguage(chain, offered) ?? 0) : 0];
|
|
63
|
+
document.documentElement.lang = language;
|
|
64
|
+
const options = { packs: new Map([[texts.ns, new Map(Object.entries(supplied))]]), timeZone };
|
|
65
|
+
return {
|
|
66
|
+
language,
|
|
67
|
+
t: (key, params) => {
|
|
68
|
+
const ref = { i18n: texts.ns, key, ...(params && { params }), source: key };
|
|
69
|
+
return chain.length > 0 ? resolveText(ref, chain, options) : resolveSource(ref, options);
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
function connected(port, given) {
|
|
74
|
+
const pending = new Map();
|
|
75
|
+
const invalidated = new Set();
|
|
76
|
+
const changed = new Set();
|
|
77
|
+
let lastId = 0;
|
|
78
|
+
const send = (message) => port.postMessage(message);
|
|
79
|
+
function request(message) {
|
|
80
|
+
lastId += 1;
|
|
81
|
+
const id = lastId;
|
|
82
|
+
return new Promise((resolve, reject) => {
|
|
83
|
+
pending.set(id, { resolve, reject });
|
|
84
|
+
send({ ...message, id });
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
function listen(listeners, listener) {
|
|
88
|
+
listeners.add(listener);
|
|
89
|
+
return () => listeners.delete(listener);
|
|
90
|
+
}
|
|
91
|
+
port.onmessage = ({ data }) => {
|
|
92
|
+
if (data.type === 'result') {
|
|
93
|
+
const waiting = pending.get(data.id);
|
|
94
|
+
pending.delete(data.id);
|
|
95
|
+
if (data.ok)
|
|
96
|
+
waiting?.resolve(data.value);
|
|
97
|
+
else
|
|
98
|
+
waiting?.reject(Object.assign(new Error(data.error.message), { code: data.error.code }));
|
|
99
|
+
}
|
|
100
|
+
else if (data.type === 'theme') {
|
|
101
|
+
applyTheme(data);
|
|
102
|
+
}
|
|
103
|
+
else if (data.type === 'invalidate') {
|
|
104
|
+
for (const listener of invalidated)
|
|
105
|
+
listener(data.source);
|
|
106
|
+
}
|
|
107
|
+
else if (data.type === 'sessions.changed') {
|
|
108
|
+
for (const listener of changed)
|
|
109
|
+
listener({ upserts: data.upserts, removes: data.removes });
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
addEventListener('pagehide', () => send({ type: 'leaving' }));
|
|
113
|
+
return {
|
|
114
|
+
...given,
|
|
115
|
+
data: (source, input) => request({ type: 'data', source, ...(input && { input }) }),
|
|
116
|
+
action: (action, input) => request({ type: 'action', action, ...(input && { input }) }),
|
|
117
|
+
open: async (target) => {
|
|
118
|
+
await request({ type: 'open', target });
|
|
119
|
+
},
|
|
120
|
+
onInvalidate: listener => listen(invalidated, listener),
|
|
121
|
+
listSessions: params => request({ type: 'sessions.list', ...params }),
|
|
122
|
+
onSessionsChanged: listener => listen(changed, listener),
|
|
123
|
+
abortTurn: async (params) => {
|
|
124
|
+
await request({ type: 'turn.abort', ...params });
|
|
125
|
+
},
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
function handshake() {
|
|
129
|
+
return new Promise((resolve, reject) => {
|
|
130
|
+
const timer = setTimeout(() => {
|
|
131
|
+
removeEventListener('message', onInit);
|
|
132
|
+
reject(new Error('宿主没有回应握手'));
|
|
133
|
+
}, INIT_TIMEOUT_MS);
|
|
134
|
+
function onInit(event) {
|
|
135
|
+
if (event.source !== parent || event.ports.length !== 1 || event.data?.type !== 'init')
|
|
136
|
+
return;
|
|
137
|
+
removeEventListener('message', onInit);
|
|
138
|
+
clearTimeout(timer);
|
|
139
|
+
const { theme, sessionId, timeZone } = event.data;
|
|
140
|
+
if (typeof theme?.tokens !== 'object' || theme.tokens === null) {
|
|
141
|
+
reject(new Error('宿主的 init 不合格:theme 要是带 tokens 的对象'));
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
applyTheme(theme);
|
|
145
|
+
const zone = zoneOf(timeZone);
|
|
146
|
+
resolve(connected(event.ports[0], { sessionId, timeZone: zone, ...translator(event.data, zone) }));
|
|
147
|
+
}
|
|
148
|
+
addEventListener('message', onInit);
|
|
149
|
+
const hello = { type: 'hello', protocol: PROTOCOL, nonce: location.hash.slice(1) };
|
|
150
|
+
parent.postMessage(hello, '*');
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
let connection;
|
|
154
|
+
/** 与宿主握手,拿到这一页的连接;调多次是同一个连接 */
|
|
155
|
+
export function connect() {
|
|
156
|
+
connection ??= handshake();
|
|
157
|
+
return connection;
|
|
158
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// 进程级目录:各包的原文字典登记在这里,解析时按 ns 查。
|
|
2
|
+
//
|
|
3
|
+
// 目录放在 globalThis[Symbol.for('aalis.i18n.catalog')],进程里装了几份本包(第三方插件带的嵌套副本)都共用它,
|
|
4
|
+
// 所以目录与条目的形状是跨副本的协议:目录是 { entries: Map<ns, 条目>, issues: 冲突[] },
|
|
5
|
+
// 条目是 { v: 1, ns, language, source, locales },只放字符串表,不放函数与解析结果。
|
|
6
|
+
// 条目结构有破坏性改动时升 v;解析遇到不认识的 v 退回 text.source。
|
|
7
|
+
import { canonicalLanguage } from './locale.js';
|
|
8
|
+
const SLOT = Symbol.for('aalis.i18n.catalog');
|
|
9
|
+
function catalog() {
|
|
10
|
+
const global = globalThis;
|
|
11
|
+
global[SLOT] ??= { entries: new Map(), issues: [] };
|
|
12
|
+
return global[SLOT];
|
|
13
|
+
}
|
|
14
|
+
function isCurrent(entry) {
|
|
15
|
+
return entry?.v === 1;
|
|
16
|
+
}
|
|
17
|
+
/** 表里的一条:只认自有的字符串值 */
|
|
18
|
+
export function entryText(table, key) {
|
|
19
|
+
const text = Object.hasOwn(table, key) ? table[key] : undefined;
|
|
20
|
+
return typeof text === 'string' ? text : undefined;
|
|
21
|
+
}
|
|
22
|
+
/** 本版协议的条目,没登记或版本不认识时 undefined */
|
|
23
|
+
export function catalogEntry(ns) {
|
|
24
|
+
const entry = catalog().entries.get(ns);
|
|
25
|
+
return isCurrent(entry) ? entry : undefined;
|
|
26
|
+
}
|
|
27
|
+
/** 补入 from 里 into 没有的键;同键不同文记一条冲突。没有新键时返回 into 本身 */
|
|
28
|
+
function mergeTable(into, from, conflict, issues) {
|
|
29
|
+
let merged;
|
|
30
|
+
for (const key of Object.keys(from)) {
|
|
31
|
+
const text = entryText(from, key);
|
|
32
|
+
if (text === undefined)
|
|
33
|
+
continue;
|
|
34
|
+
const existing = entryText(into, key);
|
|
35
|
+
if (existing === undefined) {
|
|
36
|
+
merged ??= { ...into };
|
|
37
|
+
merged[key] = text;
|
|
38
|
+
}
|
|
39
|
+
else if (existing !== text) {
|
|
40
|
+
issues.push(conflict(key));
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return merged ?? into;
|
|
44
|
+
}
|
|
45
|
+
/** 登记一个字典。同一 ns 再次登记按键合并:已有键保留先登记的,新键补入,冲突记进目录(库本身不打日志) */
|
|
46
|
+
export function registerTexts(entry) {
|
|
47
|
+
const { entries, issues } = catalog();
|
|
48
|
+
const { ns, language } = entry;
|
|
49
|
+
const existing = entries.get(ns);
|
|
50
|
+
if (existing === undefined) {
|
|
51
|
+
entries.set(ns, { v: 1, ...entry });
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
if (!isCurrent(existing)) {
|
|
55
|
+
issues.push({ ns, kind: 'version' });
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
if (existing.language !== language) {
|
|
59
|
+
issues.push({ ns, kind: 'language', language });
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
const source = mergeTable(existing.source, entry.source, key => ({ ns, kind: 'source', key }), issues);
|
|
63
|
+
const locales = { ...existing.locales };
|
|
64
|
+
for (const [tag, table] of Object.entries(entry.locales)) {
|
|
65
|
+
locales[tag] = mergeTable(locales[tag] ?? {}, table, key => ({ ns, kind: 'locale', language: tag, key }), issues);
|
|
66
|
+
}
|
|
67
|
+
entries.set(ns, { ...existing, source: source, locales });
|
|
68
|
+
}
|
|
69
|
+
const isRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
70
|
+
const isStringTable = (value) => isRecord(value) && Object.values(value).every(text => typeof text === 'string');
|
|
71
|
+
/**
|
|
72
|
+
* 登记外来的目录条目(前端构建时写下的、插件前端随 init 收到的,都是 {@link CatalogEntry} 写成的 JSON):按本版协议核形状——
|
|
73
|
+
* v 为 1,ns 是非空字符串,语言标签合法、规范化后不重复,原文与各语言译文表的值都是字符串——语言标签规范化;不合的整条
|
|
74
|
+
* 不登记,回 false。同一 ns 已登记时与 defineTexts 一样按键合并
|
|
75
|
+
*/
|
|
76
|
+
export function registerCatalogEntry(value) {
|
|
77
|
+
if (!isRecord(value) || value.v !== 1 || typeof value.ns !== 'string' || value.ns === '')
|
|
78
|
+
return false;
|
|
79
|
+
const language = typeof value.language === 'string' ? canonicalLanguage(value.language) : undefined;
|
|
80
|
+
const { source, locales } = value;
|
|
81
|
+
if (language === undefined || !isStringTable(source) || !isRecord(locales))
|
|
82
|
+
return false;
|
|
83
|
+
const tables = {};
|
|
84
|
+
for (const [tag, table] of Object.entries(locales)) {
|
|
85
|
+
const canonical = canonicalLanguage(tag);
|
|
86
|
+
if (canonical === undefined || Object.hasOwn(tables, canonical) || !isStringTable(table))
|
|
87
|
+
return false;
|
|
88
|
+
tables[canonical] = table;
|
|
89
|
+
}
|
|
90
|
+
registerTexts({ ns: value.ns, language, source, locales: tables });
|
|
91
|
+
return true;
|
|
92
|
+
}
|
|
93
|
+
/** 目录里本版协议的全部字典 */
|
|
94
|
+
export function catalogEntries() {
|
|
95
|
+
return [...catalog().entries.values()].filter(isCurrent);
|
|
96
|
+
}
|
|
97
|
+
/** 目录记下的冲突(副本) */
|
|
98
|
+
export function catalogIssues() {
|
|
99
|
+
return [...catalog().issues];
|
|
100
|
+
}
|
|
101
|
+
//# sourceMappingURL=catalog.js.map
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// 字典自检:第三方在单测里一行调用 `expect(checkTexts(t)).toEqual([])`,第一方由守卫调用
|
|
2
|
+
import { entryText } from './catalog.js';
|
|
3
|
+
import { incompatibleParams, paramKinds, parseMessage } from './message.js';
|
|
4
|
+
/** 引号里形如参数的写法:奇数个撇号接 {名} 或 {名, */
|
|
5
|
+
const QUOTED_ARGUMENT = /(?:^|[^'])(?:'')*'\{\s*([A-Za-z_][A-Za-z0-9_]*)\s*[},]/g;
|
|
6
|
+
/** 会按 Markdown 转义的参数(字符串原样渲染的那几种)紧挨着反引号 */
|
|
7
|
+
function argumentInBackticks(message) {
|
|
8
|
+
return message.some((part, i) => {
|
|
9
|
+
if (typeof part === 'string' || part.type === 'pound')
|
|
10
|
+
return false;
|
|
11
|
+
if (part.type === 'plural' || part.type === 'select')
|
|
12
|
+
return [...part.branches.values()].some(argumentInBackticks);
|
|
13
|
+
if (part.type !== 'plain' && part.type !== 'list' && part.type !== 'lines')
|
|
14
|
+
return false;
|
|
15
|
+
const before = message[i - 1];
|
|
16
|
+
const after = message[i + 1];
|
|
17
|
+
return (typeof before === 'string' && before.endsWith('`')) || (typeof after === 'string' && after.startsWith('`'));
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
/** 检查一个字典(defineTexts 返回的 t(),或目录里的条目) */
|
|
21
|
+
export function checkTexts(domain) {
|
|
22
|
+
const issues = [];
|
|
23
|
+
const locales = Object.entries(domain.locales);
|
|
24
|
+
for (const [key, template] of Object.entries(domain.source)) {
|
|
25
|
+
const source = parseMessage(template);
|
|
26
|
+
const expected = source && paramKinds(source);
|
|
27
|
+
const check = (language, text, message) => {
|
|
28
|
+
if (expected && [...text.matchAll(QUOTED_ARGUMENT)].some(match => expected.has(match[1]))) {
|
|
29
|
+
issues.push({ kind: 'quote', language, key });
|
|
30
|
+
}
|
|
31
|
+
if (argumentInBackticks(message))
|
|
32
|
+
issues.push({ kind: 'code', language, key });
|
|
33
|
+
};
|
|
34
|
+
if (source && expected) {
|
|
35
|
+
const conflicting = [...expected].filter(([, accepts]) => accepts === 0).map(([name]) => name);
|
|
36
|
+
if (conflicting.length > 0)
|
|
37
|
+
issues.push({ kind: 'params', language: domain.language, key, params: conflicting });
|
|
38
|
+
check(domain.language, template, source);
|
|
39
|
+
}
|
|
40
|
+
else {
|
|
41
|
+
issues.push({ kind: 'parse', language: domain.language, key });
|
|
42
|
+
}
|
|
43
|
+
for (const [language, table] of locales) {
|
|
44
|
+
const text = entryText(table, key);
|
|
45
|
+
if (text === undefined)
|
|
46
|
+
continue;
|
|
47
|
+
const message = parseMessage(text);
|
|
48
|
+
if (!message) {
|
|
49
|
+
issues.push({ kind: 'parse', language, key });
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
const params = expected ? incompatibleParams(expected, message) : [];
|
|
53
|
+
if (params.length > 0)
|
|
54
|
+
issues.push({ kind: 'params', language, key, params });
|
|
55
|
+
check(language, text, message);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
for (const [language, table] of locales) {
|
|
59
|
+
const keys = Object.keys(domain.source).filter(key => entryText(table, key) === undefined);
|
|
60
|
+
if (keys.length > 0)
|
|
61
|
+
issues.push({ kind: 'missing', language, keys });
|
|
62
|
+
}
|
|
63
|
+
return issues;
|
|
64
|
+
}
|
|
65
|
+
//# sourceMappingURL=check.js.map
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// 原文字典与译文的声明:defineTexts 登记字典、返回按键产出文字对象的 t();defineLocale 在编译期核译文
|
|
2
|
+
import { catalogEntry, entryText, registerTexts } from './catalog.js';
|
|
3
|
+
import { formatMessage } from './format.js';
|
|
4
|
+
import { canonicalLanguage } from './locale.js';
|
|
5
|
+
import { parseMessage } from './message.js';
|
|
6
|
+
import { listSeparator } from './resolve.js';
|
|
7
|
+
import { isTextRef } from './text-ref.js';
|
|
8
|
+
function languageTag(tag, ns) {
|
|
9
|
+
const canonical = canonicalLanguage(tag);
|
|
10
|
+
if (canonical === undefined)
|
|
11
|
+
throw new RangeError(`${ns}:语言标签不合法:${tag}`);
|
|
12
|
+
return canonical;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* 声明一个包的原文字典并登记进进程级目录。ns 写包名;language 与 locales 的键过 `Intl.getCanonicalLocales`,非法即抛。
|
|
16
|
+
* source 写成 `as const` 的对象(或就地写字面量),t() 才推得出参数;locales 的值用 {@link defineLocale} 写
|
|
17
|
+
*/
|
|
18
|
+
export function defineTexts(definition) {
|
|
19
|
+
const { ns } = definition;
|
|
20
|
+
const source = definition.source;
|
|
21
|
+
const domain = {
|
|
22
|
+
ns,
|
|
23
|
+
language: languageTag(definition.language, ns),
|
|
24
|
+
source,
|
|
25
|
+
locales: Object.fromEntries(Object.entries(definition.locales ?? {}).map(([tag, table]) => [languageTag(tag, ns), table])),
|
|
26
|
+
};
|
|
27
|
+
registerTexts(domain);
|
|
28
|
+
const t = (key, given) => {
|
|
29
|
+
const template = entryText(source, key);
|
|
30
|
+
if (template === undefined)
|
|
31
|
+
throw new RangeError(`${ns} 的原文字典里没有键 ${key}`);
|
|
32
|
+
// 复制一层(数组也复制):调用方之后改自己的对象或数组,文字对象不跟着变
|
|
33
|
+
const params = given &&
|
|
34
|
+
Object.fromEntries(Object.entries(given).map(([name, value]) => [name, Array.isArray(value) ? [...value] : value]));
|
|
35
|
+
return { i18n: ns, key, ...(params && { params }), source: renderSource(template, params, domain.language) };
|
|
36
|
+
};
|
|
37
|
+
return Object.assign(t, domain);
|
|
38
|
+
}
|
|
39
|
+
/** 按原文渲染整句,参数里的文字对象取各自的 source;渲染失败时为原文模板原样 */
|
|
40
|
+
function renderSource(template, params, language) {
|
|
41
|
+
const message = parseMessage(template);
|
|
42
|
+
const options = { language, nested: (text) => text.source, listSeparator: () => listSeparator(language) };
|
|
43
|
+
return (message && formatMessage(message, params ?? {}, options)) ?? template;
|
|
44
|
+
}
|
|
45
|
+
/** value 的前 max 个码点,不从代理对中间切开 */
|
|
46
|
+
function headOf(value, max) {
|
|
47
|
+
if (value.length <= max)
|
|
48
|
+
return value;
|
|
49
|
+
let end = 0;
|
|
50
|
+
let count = 0;
|
|
51
|
+
for (const char of value) {
|
|
52
|
+
if (count++ === max)
|
|
53
|
+
break;
|
|
54
|
+
end += char.length;
|
|
55
|
+
}
|
|
56
|
+
return value.slice(0, end);
|
|
57
|
+
}
|
|
58
|
+
/** 截完以后参数里字符串的总字数上限是 max 的这么多倍 */
|
|
59
|
+
const TOTAL_FACTOR = 16;
|
|
60
|
+
/** 参数里字符串的总字数(含参数里的文字对象与列表元素);嵌套文字对象各自渲染好的 source 不算,它们由参数推出 */
|
|
61
|
+
function paramChars(text) {
|
|
62
|
+
let total = 0;
|
|
63
|
+
const count = (value) => {
|
|
64
|
+
if (typeof value === 'string')
|
|
65
|
+
total += value.length;
|
|
66
|
+
else if (isTextRef(value))
|
|
67
|
+
total += paramChars(value);
|
|
68
|
+
};
|
|
69
|
+
for (const value of Object.values(text.params ?? {})) {
|
|
70
|
+
if (Array.isArray(value))
|
|
71
|
+
value.forEach(count);
|
|
72
|
+
else
|
|
73
|
+
count(value);
|
|
74
|
+
}
|
|
75
|
+
return total;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* 把文字对象里的字符串参数(含参数里的文字对象与列表元素)逐个截到 max 个码点,不从代理对中间切开;source 按截后的参数
|
|
79
|
+
* 重新按原文渲染。所属字典不在目录里时 source 也截到 max,没有参数的也截;在目录里且没有参数的原样返回。
|
|
80
|
+
* 截完参数里字符串的总字数仍超过 max 的 16 倍(几十条以上带长参数的分句、上百行)时,退为截到 max 的原文字符串。
|
|
81
|
+
* 落盘方用它给参数里可能带着上游整段报错的文字对象封顶,存它的地方本来就收 `string | TextRef`
|
|
82
|
+
*/
|
|
83
|
+
export function truncateParams(text, max) {
|
|
84
|
+
const cut = cutParams(text, max);
|
|
85
|
+
return paramChars(cut) > max * TOTAL_FACTOR ? headOf(cut.source, max) : cut;
|
|
86
|
+
}
|
|
87
|
+
function cutParams(text, max) {
|
|
88
|
+
const entry = catalogEntry(text.i18n);
|
|
89
|
+
const template = entry && entryText(entry.source, text.key);
|
|
90
|
+
if (text.params === undefined)
|
|
91
|
+
return template === undefined ? { ...text, source: headOf(text.source, max) } : text;
|
|
92
|
+
const cut = (value) => typeof value === 'string' ? headOf(value, max) : isTextRef(value) ? cutParams(value, max) : value;
|
|
93
|
+
const params = Object.fromEntries(Object.entries(text.params).map(([name, value]) => [name, Array.isArray(value) ? value.map(cut) : cut(value)]));
|
|
94
|
+
const source = entry && template !== undefined ? renderSource(template, params, entry.language) : headOf(text.source, max);
|
|
95
|
+
return { ...text, params, source };
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* 声明一种语言的译文:`defineLocale<Source>()({ … })`,就地写对象字面量。原文里没有的键、不是字面量的译文、
|
|
99
|
+
* 参数与原文不兼容的译文(原文 `{count}` 而译文用 plural:数量参数在原文里也要写成 `{count, number}` 或 plural)
|
|
100
|
+
* 编译报错;缺的键允许
|
|
101
|
+
*/
|
|
102
|
+
export function defineLocale() {
|
|
103
|
+
return entries => entries;
|
|
104
|
+
}
|
|
105
|
+
//# sourceMappingURL=define.js.map
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { isTextRef } from './text-ref.js';
|
|
2
|
+
/**
|
|
3
|
+
* 给人看的错误:message 是 `text.source`(日志照旧可读),出口按读者语言渲染 `text`;`status` 是 HTTP 出口用的状态码。
|
|
4
|
+
* 判定用 {@link isTextError},不用 instanceof:进程里装了两份本包时,两份的类互不认得
|
|
5
|
+
*/
|
|
6
|
+
export class TextError extends Error {
|
|
7
|
+
name = 'TextError';
|
|
8
|
+
text;
|
|
9
|
+
constructor(text, options = {}) {
|
|
10
|
+
super(text.source, 'cause' in options ? { cause: options.cause } : undefined);
|
|
11
|
+
this.text = text;
|
|
12
|
+
if (options.status !== undefined)
|
|
13
|
+
this.status = options.status;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* 给任意 Error 挂上文字对象并返回它本身,name 与类不变(ConfigError、SessionSettingsError 这类按 name 判定的错误照旧认得)。
|
|
18
|
+
* message 不动,由调用方给(通常就是 `text.source`)
|
|
19
|
+
*/
|
|
20
|
+
export function withText(error, text) {
|
|
21
|
+
return Object.assign(error, { text });
|
|
22
|
+
}
|
|
23
|
+
/** 是不是挂着文字对象的错误:按结构判(Error 且 `text` 是文字对象),另一份包的 TextError、改了 name 的子类都认 */
|
|
24
|
+
export function isTextError(error) {
|
|
25
|
+
return error instanceof Error && isTextRef(error.text);
|
|
26
|
+
}
|
|
27
|
+
/** 接住的报错作参数用:有文字对象取它,否则 message,再否则 String(err)。不要取 `.message` 拼句 */
|
|
28
|
+
export function textOf(error) {
|
|
29
|
+
if (isTextError(error))
|
|
30
|
+
return error.text;
|
|
31
|
+
const message = error?.message;
|
|
32
|
+
if (typeof message === 'string')
|
|
33
|
+
return message;
|
|
34
|
+
try {
|
|
35
|
+
return String(error);
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return Object.prototype.toString.call(error);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
//# sourceMappingURL=errors.js.map
|