@mantou/gem 2.2.15 → 2.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/dist/gem.js +7 -7
  2. package/dist/gem.mjs +7 -7
  3. package/elements/base/dialog.d.ts.map +1 -1
  4. package/elements/base/link.d.ts.map +1 -1
  5. package/elements/base/link.js +1 -1
  6. package/elements/base/link.js.map +1 -1
  7. package/elements/base/modal-factory.d.ts +20 -510
  8. package/elements/base/modal-factory.d.ts.map +1 -1
  9. package/elements/base/modal-factory.js +3 -2
  10. package/elements/base/modal-factory.js.map +1 -1
  11. package/elements/base/reflect.d.ts +7 -4
  12. package/elements/base/reflect.d.ts.map +1 -1
  13. package/elements/base/reflect.js +30 -4
  14. package/elements/base/reflect.js.map +1 -1
  15. package/elements/base/route.d.ts +11 -9
  16. package/elements/base/route.d.ts.map +1 -1
  17. package/elements/base/route.js +84 -44
  18. package/elements/base/route.js.map +1 -1
  19. package/elements/base/title.d.ts.map +1 -1
  20. package/elements/base/title.js +1 -1
  21. package/elements/base/title.js.map +1 -1
  22. package/helper/hmr.js.map +1 -1
  23. package/helper/i18n.d.ts +21 -17
  24. package/helper/i18n.d.ts.map +1 -1
  25. package/helper/i18n.js +172 -116
  26. package/helper/i18n.js.map +1 -1
  27. package/helper/logger.d.ts.map +1 -1
  28. package/helper/mediaquery.d.ts.map +1 -1
  29. package/helper/request.d.ts +7 -1
  30. package/helper/request.d.ts.map +1 -1
  31. package/helper/request.js.map +1 -1
  32. package/helper/theme.d.ts +8 -4
  33. package/helper/theme.d.ts.map +1 -1
  34. package/helper/theme.js +12 -9
  35. package/helper/theme.js.map +1 -1
  36. package/lib/decorators.d.ts +3 -2
  37. package/lib/decorators.d.ts.map +1 -1
  38. package/lib/decorators.js +29 -44
  39. package/lib/decorators.js.map +1 -1
  40. package/lib/directive.d.ts.map +1 -1
  41. package/lib/history.d.ts +46 -42
  42. package/lib/history.d.ts.map +1 -1
  43. package/lib/history.js +19 -37
  44. package/lib/history.js.map +1 -1
  45. package/lib/lit-html.d.ts.map +1 -1
  46. package/lib/lit-html.js.map +1 -1
  47. package/lib/reactive.d.ts +7 -9
  48. package/lib/reactive.d.ts.map +1 -1
  49. package/lib/reactive.js +86 -115
  50. package/lib/reactive.js.map +1 -1
  51. package/lib/repeat.d.ts +1 -1
  52. package/lib/repeat.d.ts.map +1 -1
  53. package/lib/repeat.js.map +1 -1
  54. package/lib/server.d.ts +12 -0
  55. package/lib/server.d.ts.map +1 -1
  56. package/lib/server.js +2 -0
  57. package/lib/server.js.map +1 -1
  58. package/lib/store.d.ts +0 -6
  59. package/lib/store.d.ts.map +1 -1
  60. package/lib/store.js +5 -17
  61. package/lib/store.js.map +1 -1
  62. package/lib/utils.d.ts.map +1 -1
  63. package/lib/version.d.ts +1 -1
  64. package/lib/version.d.ts.map +1 -1
  65. package/lib/version.js +1 -1
  66. package/lib/version.js.map +1 -1
  67. package/package.json +7 -8
  68. package/src/elements/base/modal-factory.ts +22 -6
  69. package/src/elements/base/reflect.ts +30 -4
  70. package/src/elements/base/route.ts +80 -50
  71. package/src/helper/i18n.ts +184 -127
  72. package/src/helper/theme.ts +16 -9
  73. package/src/lib/decorators.ts +36 -49
  74. package/src/lib/history.ts +19 -32
  75. package/src/lib/reactive.ts +90 -126
  76. package/src/lib/server.ts +14 -0
  77. package/src/lib/store.ts +3 -19
  78. package/src/lib/version.ts +1 -1
  79. package/src/test/elements/gesture.test.ts +13 -5
  80. package/src/test/elements/link.test.ts +12 -2
  81. package/src/test/elements/reflect.test.ts +20 -1
  82. package/src/test/elements/route.test.ts +102 -0
  83. package/src/test/elements/title.test.ts +7 -0
  84. package/src/test/elements/unsafe.test.ts +22 -0
  85. package/src/test/gem-element/advance.test.ts +106 -0
  86. package/src/test/gem-element/basic.test.ts +13 -0
  87. package/src/test/history.test.ts +19 -0
  88. package/src/test/theme.test.ts +8 -1
  89. package/src/test/utils.test.ts +10 -0
  90. package/src/test/utils.ts +56 -0
@@ -2,7 +2,7 @@ import type { RouteTrigger } from '../elements/base/route';
2
2
  import type { UpdateHistoryParams } from '../lib/history';
3
3
  import type { TemplateResult } from '../lib/lit-html';
4
4
  import { html, UpdateToken } from '../lib/reactive';
5
- import { isolate } from '../lib/server';
5
+ import { isolate, isServer, ssrHooks } from '../lib/server';
6
6
  import { GemError } from '../lib/utils';
7
7
  import { logger } from './logger';
8
8
 
@@ -26,7 +26,8 @@ export function splice(rawValue: string, ...rest: (((s: string) => TemplateResul
26
26
  return html(templateArr, ...values);
27
27
  }
28
28
 
29
- const modules = new Set<I18n<any>>();
29
+ // 正在创建子模块,构造函数中用来区分主模块
30
+ let creatingSubModule = false;
30
31
 
31
32
  type Msg =
32
33
  | {
@@ -35,13 +36,20 @@ type Msg =
35
36
  }
36
37
  | string;
37
38
 
38
- export type I18nJSON<T> = {
39
+ type I18nJSON<T> = {
39
40
  currentLanguage: string;
40
41
  currentLanguagePack: Partial<T>;
41
42
  };
42
43
 
44
+ /**
45
+ * 语言包加载函数,例如 `() => import('./zh.json')`,打包时每个语言是单独的文件,只加载用到的语言
46
+ *
47
+ * 类型只从 `default` 推断,否则模块对象本身也会被推断成语言包
48
+ */
49
+ type PackLoader<T> = () => Promise<{ default: Partial<T> } | NoInfer<Partial<T>>>;
50
+
43
51
  interface Resources<T> {
44
- [key: string]: Partial<T> | string;
52
+ [key: string]: Partial<T> | PackLoader<T>;
45
53
  }
46
54
 
47
55
  interface I18nOptions<T> {
@@ -50,12 +58,12 @@ interface I18nOptions<T> {
50
58
  // 可以在构造函数中指定,但会被 URL 中的语言参数覆盖
51
59
  currentLanguage?: string;
52
60
  /**
53
- * `true` 表示在修改语言时自动缓存。
54
- * 为了避免缓存超出 `localStorage` 限制,语言包 URL 应该带版本号查询参数
61
+ * 缓存用户选择的语言,`true` 表示在修改语言时自动缓存,`manual` 时调用 `setCache` 缓存
62
+ *
63
+ * 同时缓存加载函数加载的语言包,下次加载完成前先使用缓存,加载完成后更新
55
64
  */
56
65
  cache?: boolean | 'manual';
57
- // lib 需要设置,避免被应用中的 i18n 一起清理掉语言缓存
58
- // 也用来作为 prerender 的 script type
66
+ // 缓存和 SSR 输出的 key,lib 需要设置,避免和应用中的 i18n 冲突
59
67
  cachePrefix?: string;
60
68
  // 为 `path` 时页面的路由不能和语言代码相同
61
69
  urlParamsType?: 'path' | 'querystring' | 'ccTLD' | 'gTLD';
@@ -72,8 +80,12 @@ export class I18n<T = Record<string, Msg>> implements RouteTrigger {
72
80
  urlParamsName?: string;
73
81
  onChange?: (currentLanguage: string) => void;
74
82
 
75
- // SSR 时每个请求使用独立的语言
76
- #state = isolate({ currentLanguage: '' });
83
+ // SSR 时每个请求使用独立的语言,`pending` 是正在切换的语言
84
+ #state = isolate({ currentLanguage: '', pending: undefined as Promise<string> | undefined });
85
+
86
+ // 子模块跟随主模块切换语言,不修改文档的语言,也不缓存语言,只缓存语言包
87
+ #isSub = creatingSubModule;
88
+ #subModules = new Set<I18n<any>>();
77
89
 
78
90
  get currentLanguage() {
79
91
  return this.#state.currentLanguage;
@@ -83,14 +95,23 @@ export class I18n<T = Record<string, Msg>> implements RouteTrigger {
83
95
  this.#state.currentLanguage = lang;
84
96
  }
85
97
 
98
+ /** 等待切换语言完成,包括加载语言包和所有子模块,例如渲染刚加载的模块前 */
99
+ get ready(): Promise<unknown> {
100
+ return Promise.all([this.#state.pending, ...[...this.#subModules].map((i18n) => i18n.ready)]);
101
+ }
102
+
86
103
  // 兼容 `<gem-route>`
87
104
  replace = ({ path }: UpdateHistoryParams) => this.setLanguage(path!);
88
105
  getParams = () => ({ path: this.currentLanguage });
89
106
 
90
- get #cacheCurrentKey() {
107
+ get #cacheKey() {
91
108
  return `${this.cachePrefix}:current`;
92
109
  }
93
110
 
111
+ get #storeKey() {
112
+ return `i18n:${this.cachePrefix}`;
113
+ }
114
+
94
115
  get #urlParamsLang() {
95
116
  const { hostname, pathname, search } = location;
96
117
  const parts = hostname.split('.');
@@ -109,21 +130,27 @@ export class I18n<T = Record<string, Msg>> implements RouteTrigger {
109
130
  }
110
131
 
111
132
  get #cacheLang() {
112
- return (this.cache && localStorage.getItem(this.#cacheCurrentKey)) || '';
113
- }
114
-
115
- get #isMain(): boolean {
116
- return !modules.has(this);
133
+ return (!this.#isSub && this.cache && localStorage.getItem(this.#cacheKey)) || '';
117
134
  }
118
135
 
119
136
  set #lang(lang: string) {
120
- if (this.#isMain) {
121
- document.documentElement.lang = lang;
122
- }
137
+ if (!this.#isSub) document.documentElement.lang = lang;
123
138
  this.currentLanguage = lang;
124
139
  this.onChange?.(lang);
125
140
  }
126
141
 
142
+ /**
143
+ * SSR 时使用过的模块记录到 store 中,渲染结束时读取,输出当前语言的语言包,
144
+ * 客户端构造时恢复,不需要再加载
145
+ */
146
+ #ssrData = Object.defineProperties(
147
+ {},
148
+ {
149
+ currentLanguage: { enumerable: true, get: () => this.currentLanguage },
150
+ currentLanguagePack: { enumerable: true, get: () => this.#getCompletePack() },
151
+ },
152
+ );
153
+
127
154
  #inConstructor = true;
128
155
  constructor(options: I18nOptions<T>) {
129
156
  if (!options.resources[options.fallbackLanguage]) {
@@ -132,35 +159,49 @@ export class I18n<T = Record<string, Msg>> implements RouteTrigger {
132
159
 
133
160
  Object.assign<I18n<T>, I18nOptions<T>>(this as I18n<T>, options);
134
161
 
135
- let currentLanguage = this.#urlParamsLang || this.currentLanguage || this.#cacheLang;
162
+ const ssrData = isServer ? undefined : (ssrHooks.createStore?.(this.#storeKey) as I18nJSON<T> | undefined);
163
+ // 已经包含后备语言中的消息
164
+ if (ssrData) this.resources[ssrData.currentLanguage] = ssrData.currentLanguagePack;
136
165
 
137
- const ele = document.querySelector(`[type*=${this.cachePrefix}]`);
138
- if (ele) {
139
- try {
140
- const prerenderData = JSON.parse(ele.textContent || '') as I18nJSON<T>;
141
- currentLanguage = prerenderData.currentLanguage;
142
- this.resources[prerenderData.currentLanguage] = prerenderData.currentLanguagePack;
143
- } catch {
144
- //
145
- }
146
- }
166
+ // 子模块使用主模块当前的语言,可能和服务端输出时不同,例如客户端切换过语言
167
+ const lang = this.#isSub
168
+ ? this.currentLanguage || ssrData?.currentLanguage
169
+ : ssrData?.currentLanguage || this.#urlParamsLang || this.currentLanguage || this.#cacheLang;
147
170
 
148
- if (currentLanguage) {
149
- this.#lang = currentLanguage;
150
- this.setLanguage(currentLanguage);
171
+ if (lang) {
172
+ this.#lang = lang;
173
+ this.setLanguage(lang);
151
174
  } else {
152
175
  this.resetLanguage();
153
176
  }
154
177
  this.#inConstructor = false;
155
178
  }
156
179
 
180
+ #getPack(lang: string): Record<string, Msg> | undefined {
181
+ const data = this.resources[lang];
182
+ return data && typeof data === 'object' ? (data as any) : undefined;
183
+ }
184
+
185
+ /** 当前语言包,包含后备语言中的消息,服务端输出后客户端不需要再加载后备语言 */
186
+ #getCompletePack() {
187
+ const pack: Record<string, Msg> = { ...this.#getPack(this.fallbackLanguage) };
188
+ for (const [key, msg] of Object.entries(this.#getPack(this.currentLanguage) || {})) {
189
+ if (msg) pack[key] = msg;
190
+ }
191
+ return pack as Partial<T>;
192
+ }
193
+
157
194
  get(s: keyof T): string;
158
195
  get(s: keyof T, ...rest: (((str: string) => TemplateResult) | string)[]): TemplateResult;
159
196
  get(s: keyof T, ...rest: (((str: string) => TemplateResult) | string)[]) {
160
- const currentLanguagePack = this.resources[this.currentLanguage] as any;
161
- const fallbackLanguagePack = this.resources[this.fallbackLanguage] as any;
162
- const msg = currentLanguagePack[s] || fallbackLanguagePack[s];
163
- const rawValue: string = msg?.message || msg || `[${s as string}]`;
197
+ if (isServer) ssrHooks.updateStore?.(this.#storeKey, this.#ssrData);
198
+ const key = s as string;
199
+ const currentPack = this.#getPack(this.currentLanguage);
200
+ const fallbackPack = this.#getPack(this.fallbackLanguage);
201
+ const msg = currentPack?.[key] || fallbackPack?.[key];
202
+ // 客户端只加载当前语言,缺少消息时才加载后备语言
203
+ if (!msg && currentPack && !fallbackPack) this.#loadFallback();
204
+ const rawValue: string = (typeof msg === 'object' ? msg.message : msg) || `[${key}]`;
164
205
  if (!rest.length) return rawValue;
165
206
  return splice(rawValue, ...rest);
166
207
  }
@@ -175,116 +216,132 @@ export class I18n<T = Record<string, Msg>> implements RouteTrigger {
175
216
  return this.fallbackLanguage;
176
217
  }
177
218
 
178
- async setLanguage(lang: string): Promise<string> {
179
- if (this.#isMain) {
180
- modules.forEach(async (i18n) => await i18n.setLanguage(lang));
181
- }
182
- let pack: Partial<T>;
183
- const data = this.resources[lang];
184
- if (!data) {
185
- logger.warn(`${this.cachePrefix}: not found \`${lang}\``);
186
- return this.resetLanguage();
187
- }
219
+ #packCacheKey(lang: string) {
220
+ return `${this.cachePrefix}:pack:${lang}`;
221
+ }
188
222
 
189
- if (typeof data === 'string') {
190
- const prefix = `${this.cachePrefix}:`;
191
- const localKey = `${prefix}${data}`;
192
- const localPackString = localStorage.getItem(localKey);
193
-
194
- const fetchPack = async () => {
195
- const pkg = await (await fetch(data)).json();
196
- if (this.cache) {
197
- // 移除之前的版本缓存
198
- for (let i = 0; i < localStorage.length; i++) {
199
- const key = localStorage.key(i) as string;
200
- if (key.startsWith(prefix)) {
201
- localStorage.removeItem(key);
202
- }
203
- }
204
- localStorage.setItem(localKey, JSON.stringify(pkg));
205
- }
206
- return pkg;
207
- };
208
-
209
- if (localPackString) {
210
- try {
211
- pack = JSON.parse(localPackString);
212
- } catch {
213
- pack = await fetchPack();
214
- }
215
- } else {
216
- pack = await fetchPack();
217
- }
218
- } else {
219
- pack = data;
223
+ #readPackCache(lang: string): Partial<T> | undefined {
224
+ if (!this.cache || isServer) return;
225
+ try {
226
+ return JSON.parse(localStorage.getItem(this.#packCacheKey(lang)) || 'null') || undefined;
227
+ } catch {
228
+ //
220
229
  }
221
- this.resources[lang] = pack;
222
-
223
- if (!this.#inConstructor) {
224
- // 更新所有元素,可能会更新许多不必要的元素
225
- const temp: Element[] = [document.documentElement];
226
- while (temp.length) {
227
- const element = temp.pop()!;
228
- (element as any)[UpdateToken]?.();
229
- // 和 gem 更新机制一样使用深度优先
230
- temp.push(...[...element.children, ...(element.shadowRoot?.children || [])].reverse());
230
+ }
231
+
232
+ /** 缓存语言包,只保留正在使用的语言,返回内容是否和缓存不同 */
233
+ #writePackCache(lang: string, pack: Partial<T>) {
234
+ if (!this.cache || isServer) return false;
235
+ const key = this.#packCacheKey(lang);
236
+ const json = JSON.stringify(pack);
237
+ try {
238
+ if (localStorage.getItem(key) === json) return false;
239
+ const keep = new Set([lang, this.currentLanguage, this.fallbackLanguage].map((e) => this.#packCacheKey(e)));
240
+ const prefix = this.#packCacheKey('');
241
+ for (const k of Object.keys(localStorage)) {
242
+ if (k.startsWith(prefix) && !keep.has(k)) localStorage.removeItem(k);
231
243
  }
244
+ localStorage.setItem(key, json);
245
+ } catch {
246
+ // 例如超出存储限制
232
247
  }
248
+ return true;
249
+ }
233
250
 
234
- if (lang !== this.currentLanguage) {
235
- this.#lang = lang;
251
+ /** 已经加载时同步返回,避免构造函数中异步更新,有缓存时先使用缓存,加载完成后更新 */
252
+ #loadPack(lang: string): Partial<T> | Promise<Partial<T>> {
253
+ const data = this.resources[lang];
254
+ if (typeof data !== 'function') return data;
255
+ const cached = this.#readPackCache(lang);
256
+ const loading = data().then((mod: any) => {
257
+ const pack = (mod && typeof mod.default === 'object' ? mod.default : mod) as Partial<T>;
258
+ this.resources[lang] = pack;
259
+ if (this.#writePackCache(lang, pack) && cached) this.#updateElements();
260
+ return pack;
261
+ });
262
+ if (!cached) return loading;
263
+ // 加载失败时继续使用缓存
264
+ loading.catch((err) => logger.warn(`${this.cachePrefix}: failed to load \`${lang}\``, err));
265
+ this.resources[lang] = cached;
266
+ return cached;
267
+ }
268
+
269
+ #fallbackLoading?: Promise<unknown>;
270
+ #loadFallback() {
271
+ this.#fallbackLoading ||= Promise.resolve(this.#loadPack(this.fallbackLanguage)).then(() => this.#updateElements());
272
+ }
273
+
274
+ // 更新所有元素,可能会更新许多不必要的元素
275
+ #updateElements() {
276
+ const temp: Element[] = [document.documentElement];
277
+ while (temp.length) {
278
+ const element = temp.pop()!;
279
+ (element as any)[UpdateToken]?.();
280
+ // 和 gem 更新机制一样使用深度优先
281
+ temp.push(...[...element.children, ...(element.shadowRoot?.children || [])].reverse());
236
282
  }
237
- // auto cache
238
- if (this.cache === true) {
239
- this.setCache();
283
+ }
284
+
285
+ setLanguage(lang: string): Promise<string> {
286
+ const promise = this.#setLanguage(lang);
287
+ this.#state.pending = promise;
288
+ return promise;
289
+ }
290
+
291
+ async #setLanguage(lang: string): Promise<string> {
292
+ if (!this.resources[lang]) {
293
+ logger.warn(`${this.cachePrefix}: not found \`${lang}\``);
294
+ return this.resetLanguage();
240
295
  }
296
+ const subModules = [...this.#subModules].map((i18n) => i18n.setLanguage(lang));
297
+
298
+ // 服务端渲染需要同步读取后备语言中的消息,输出的语言包也包含后备语言的消息
299
+ const langs = isServer && lang !== this.fallbackLanguage ? [lang, this.fallbackLanguage] : [lang];
300
+ const packs = langs.map((e) => this.#loadPack(e));
301
+ if (packs.some((e) => e instanceof Promise)) await Promise.all(packs);
302
+
303
+ if (!this.#inConstructor) this.#updateElements();
304
+ if (lang !== this.currentLanguage) this.#lang = lang;
305
+ if (this.cache === true) this.setCache();
306
+ await Promise.all(subModules);
241
307
  return lang;
242
308
  }
243
309
 
244
310
  resetLanguage() {
245
- if (this.#isMain) {
246
- modules.forEach((i18n) => i18n.resetLanguage());
247
- }
248
- this.#lang = this.fallbackLanguage;
249
- if (this.cache) {
250
- localStorage.removeItem(this.#cacheCurrentKey);
251
- }
311
+ if (this.cache) localStorage.removeItem(this.#cacheKey);
252
312
  return this.setLanguage(this.detectLanguage());
253
313
  }
254
314
 
255
- // 一般用在 `onChange` 中
315
+ // 一般用在 `onChange` 中,子模块的语言来自主模块,不缓存
256
316
  setCache() {
257
- if (this.#isMain) {
258
- modules.forEach((i18n) => i18n.setCache());
259
- }
260
- localStorage.setItem(this.#cacheCurrentKey, this.currentLanguage);
261
- }
262
-
263
- createSubModule<K = Record<string, Msg>>(name: string, resources: Resources<K>) {
264
- const module = new I18n<K>({
265
- resources,
266
- cachePrefix: `${this.cachePrefix}-${name}`,
267
- currentLanguage: this.currentLanguage,
268
- fallbackLanguage: this.fallbackLanguage,
269
- cache: this.cache,
270
- });
271
- modules.add(module);
272
- return module;
317
+ if (this.#isSub) return;
318
+ localStorage.setItem(this.#cacheKey, this.currentLanguage);
273
319
  }
274
320
 
275
321
  /**
322
+ * 子模块跟随主模块切换语言,可以按路由分割语言包
323
+ *
276
324
  * @example
277
325
  *
278
- * html`
279
- * <gem-reflect>
280
- * <script type="${i18n.cachePrefix}">${JSON.stringify(i18n)}</script>
281
- * </gem-reflect>
282
- * `
326
+ * const pageI18n = i18n.createSubModule('page', {
327
+ * en: () => import('./en.json'),
328
+ * zh: () => import('./zh.json'),
329
+ * });
283
330
  */
284
- toJSON() {
285
- return {
286
- currentLanguage: this.currentLanguage,
287
- currentLanguagePack: this.resources[this.currentLanguage],
288
- };
331
+ createSubModule<K = Record<string, Msg>>(name: string, resources: Resources<K>) {
332
+ creatingSubModule = true;
333
+ try {
334
+ const module = new I18n<K>({
335
+ resources,
336
+ cachePrefix: `${this.cachePrefix}-${name}`,
337
+ currentLanguage: this.currentLanguage,
338
+ fallbackLanguage: this.fallbackLanguage,
339
+ cache: this.cache,
340
+ });
341
+ this.#subModules.add(module);
342
+ return module;
343
+ } finally {
344
+ creatingSubModule = false;
345
+ }
289
346
  }
290
347
  }
@@ -26,8 +26,11 @@ export function getThemeProps<T>(theme: Theme<T>) {
26
26
  return themePropsMap.get(theme) as Theme<T>;
27
27
  }
28
28
 
29
- function createThemeFromProps<T extends Record<string, unknown>>(themeObj: T, props: Record<string, string> = {}) {
30
- const salt = randomStr();
29
+ function createThemeFromProps<T extends Record<string, unknown>>(
30
+ themeObj: T,
31
+ props: Record<string, string> = {},
32
+ salt = randomStr(),
33
+ ) {
31
34
  const styleSheet = css({})[SheetToken];
32
35
  const store = createStore<T>(themeObj);
33
36
  // 同步分配变量名,新增的键在更新后立即可读;样式内容随 store 异步更新
@@ -75,14 +78,17 @@ function createThemeFromProps<T extends Record<string, unknown>>(themeObj: T, pr
75
78
 
76
79
  /**
77
80
  * 用于 `@adoptedStyle(theme)`,类似 `css`
81
+ *
82
+ * `key` 用作 css 变量名的后缀,SSR 时让服务端和客户端的变量名一致,
83
+ * `swc-plugin-gem` 的 `ssr` 选项会为模块顶层的主题自动添加
78
84
  */
79
- export function createScopedTheme<T extends Record<string, unknown>>(themeObj: T) {
80
- return createThemeFromProps(themeObj);
85
+ export function createScopedTheme<T extends Record<string, unknown>>(themeObj: T, key?: string) {
86
+ return createThemeFromProps(themeObj, {}, key);
81
87
  }
82
88
 
83
- /** 全局主题 */
84
- export function createTheme<T extends Record<string, unknown>>(themeObj: T) {
85
- const result = createScopedTheme(themeObj);
89
+ /** 全局主题,`key` 同 `createScopedTheme` */
90
+ export function createTheme<T extends Record<string, unknown>>(themeObj: T, key?: string) {
91
+ const result = createScopedTheme(themeObj, key);
86
92
  document.adoptedStyleSheets.push(result[SheetToken].getStyle());
87
93
  return result;
88
94
  }
@@ -94,6 +100,7 @@ export function createOverrideTheme<T extends Record<string, unknown>>(theme: Th
94
100
 
95
101
  /**
96
102
  * 返回一个主题对象,但同时又是一个装饰器函数,用来装饰一个主题更新函数
103
+ * 所有实例共享变量名,`key` 同 `createScopedTheme`
97
104
  *
98
105
  * @example
99
106
  * ```ts
@@ -104,8 +111,8 @@ export function createOverrideTheme<T extends Record<string, unknown>>(theme: Th
104
111
  * }
105
112
  * ```
106
113
  * */
107
- export function createDecoratorTheme<T extends Record<string, unknown>>(themeObj: T) {
108
- const themeAsKeys = createThemeFromProps(themeObj);
114
+ export function createDecoratorTheme<T extends Record<string, unknown>>(themeObj: T, key?: string) {
115
+ const themeAsKeys = createThemeFromProps(themeObj, {}, key);
109
116
  const props = getThemeProps(themeAsKeys);
110
117
  const update = <E extends GemElement, V extends () => T, K = any[] | undefined>(
111
118
  getDep?: K extends readonly any[] ? (instance: E) => K : undefined,
@@ -7,7 +7,7 @@ import type { Store } from './store';
7
7
  import { camelToKebabCase, GemError, PropProxyMap } from './utils';
8
8
 
9
9
  type GemElementPrototype = GemElement & { '': never };
10
- type StaticField = Exclude<keyof Metadata, keyof ShadowRootInit | 'aria' | 'noBlocking' | 'penetrable'>;
10
+ type StaticField = Exclude<keyof Metadata, keyof ShadowRootInit | 'aria' | 'noBlocking' | 'penetrable' | 'attrProps'>;
11
11
 
12
12
  const { deleteProperty, getOwnPropertyDescriptor, defineProperty } = Reflect;
13
13
  const { getPrototypeOf, assign, hasOwn } = Object;
@@ -32,12 +32,31 @@ function clearField<T extends GemElement>(instance: T, prop: string) {
32
32
  (instance as any)[prop] = value;
33
33
  }
34
34
 
35
- const observedTargetAttributes = new WeakMap<GemElementPrototype, Map<string, string>>();
35
+ /**
36
+ * 定义响应式的成员:在原型上定义访问器(基类定义过就跳过),移除实例上的同名字段,
37
+ * `field` 记录到元数据中,供外部读取
38
+ */
39
+ function defineMember(
40
+ context: ClassFieldDecoratorContext,
41
+ field: StaticField,
42
+ name: string,
43
+ define: (target: GemElementPrototype, prop: string) => void,
44
+ ) {
45
+ const prop = context.name as string;
46
+ pushStaticField(context, field, name);
47
+ context.addInitializer(function (this: GemElement) {
48
+ const target = getDecoratorTarget(this, context);
49
+ if (!hasOwn(target, prop)) define(target, prop);
50
+ clearField(this, prop);
51
+ });
52
+ }
53
+
36
54
  // hack 修改 attribute 行为,如果是观察的,就使用 `setter`
37
55
  // 不在 Devtools 中工作 https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/Content_scripts#dom_access
38
56
  // 使用 Gem DevTools 注入脚本监听 attribute 变化
39
57
  const { setAttribute, toggleAttribute, removeAttribute } = Element.prototype;
40
- const getObservedPropName = (i: GemElement, n: string) => observedTargetAttributes.get(getPrototypeOf(i))?.get(n);
58
+ const getObservedPropName = (i: GemElement, n: string) =>
59
+ ((i.constructor as any)[Symbol.metadata] as Metadata | undefined)?.attrProps?.[n];
41
60
  GemElement.prototype.setAttribute = function (n: string, v: string) {
42
61
  const prop = getObservedPropName(this, n);
43
62
  if (!prop) return setAttribute.call(this, n, v);
@@ -130,19 +149,12 @@ type AttrType = BooleanConstructor | NumberConstructor | StringConstructor;
130
149
  function decoratorAttr<T extends GemElement>(context: ClassFieldDecoratorContext<T>, attrType: AttrType) {
131
150
  const prop = context.name as string;
132
151
  const attr = camelToKebabCase(prop);
133
- context.addInitializer(function (this: T) {
134
- const target = getDecoratorTarget(this, context);
135
- if (!hasOwn(target, prop)) {
136
- pushStaticField(context, 'observedAttributes', attr); // 没有 observe 的效果
137
- defineProp(target, prop, { attr, attrType });
138
- }
139
- clearField(this, prop);
140
- // 记录观察的 attribute, 用于 hack 的 setAttribute
141
- const proto = getPrototypeOf(this);
142
- const attrMap = observedTargetAttributes.get(proto) || new Map<string, string>();
143
- attrMap.set(attr, prop);
144
- observedTargetAttributes.set(proto, attrMap);
145
- });
152
+ const metadata = context.metadata as Metadata;
153
+ // 记录观察的 attribute,用于 hack 的 setAttribute,原型链继承基类的记录
154
+ if (!hasOwn(metadata, 'attrProps')) metadata.attrProps = Object.create(metadata.attrProps || null);
155
+ metadata.attrProps![attr] = prop;
156
+ // `observedAttributes` 没有 observe 的效果
157
+ defineMember(context, 'observedAttributes', attr, (target) => defineProp(target, prop, { attr, attrType }));
146
158
  // 延时定义的元素需要继承原实例属性值
147
159
  return function (this: any, initValue: any) {
148
160
  return this.getAttribute(attr) ?? initValue;
@@ -181,14 +193,7 @@ export function numattribute<T extends GemElement>(_: undefined, context: ClassF
181
193
  */
182
194
  export function property<T extends GemElement>(_: undefined, context: ClassFieldDecoratorContext<T>) {
183
195
  const prop = context.name as string;
184
- context.addInitializer(function (this: T) {
185
- const target = getDecoratorTarget(this, context);
186
- if (!hasOwn(target, prop)) {
187
- pushStaticField(context, 'observedProperties', prop);
188
- defineProp(target, prop);
189
- }
190
- clearField(this, prop);
191
- });
196
+ defineMember(context, 'observedProperties', prop, defineProp);
192
197
  // 延时定义的元素需要继承原实例属性值
193
198
  return function (this: any, initValue: any) {
194
199
  return this[prop] ?? initValue;
@@ -342,16 +347,8 @@ function defineCSSState(target: GemElementPrototype, prop: string, stateStr: str
342
347
  * ```
343
348
  */
344
349
  export function state<T extends GemElement>(_: undefined, context: ClassFieldDecoratorContext<T, boolean>) {
345
- const prop = context.name as string;
346
- const attr = camelToKebabCase(prop);
347
- context.addInitializer(function (this: T) {
348
- const target = getDecoratorTarget(this, context);
349
- if (!hasOwn(target, prop)) {
350
- pushStaticField(context, 'definedCSSStates', attr);
351
- defineCSSState(target, prop, attr);
352
- }
353
- clearField(this, prop);
354
- });
350
+ const stateStr = camelToKebabCase(context.name as string);
351
+ defineMember(context, 'definedCSSStates', stateStr, (target, prop) => defineCSSState(target, prop, stateStr));
355
352
  }
356
353
 
357
354
  function defineStaticField(
@@ -421,7 +418,8 @@ export type Emitter<T = any> = <_Listener = EmitterListener<T>>(
421
418
 
422
419
  /**
423
420
  * 定义一个事件发射器,类似 `HTMLElement.click`,
424
- * 调用时将同步发送一个和字段名称的烤串式格式的自定义事件
421
+ * 调用时将在微任务中发送一个和字段名称的烤串式格式的自定义事件,
422
+ * 避免同步触发时祖先元素还没挂载好(https://github.com/mantou132/gem/issues/203)
425
423
  *
426
424
  * @example
427
425
  * ```ts
@@ -436,20 +434,9 @@ export function emitter<T extends GemElement>(_: undefined, context: ClassFieldD
436
434
  export function globalemitter<T extends GemElement>(_: undefined, context: ClassFieldDecoratorContext<T, Emitter>) {
437
435
  defineEmitter(context, { bubbles: true, composed: true });
438
436
  }
439
- function defineEmitter<T extends GemElement>(
440
- context: ClassFieldDecoratorContext,
441
- eventOptions?: Omit<CustomEventInit<unknown>, 'detail'>,
442
- ) {
443
- const prop = context.name as string;
444
- const event = camelToKebabCase(prop);
445
- context.addInitializer(function (this: T) {
446
- const target = getDecoratorTarget(this, context);
447
- if (!hasOwn(target, prop)) {
448
- pushStaticField(context, 'definedEvents', event);
449
- defineProp(target, prop, { event, eventOptions });
450
- }
451
- clearField(this, prop);
452
- });
437
+ function defineEmitter(context: ClassFieldDecoratorContext, eventOptions?: Omit<CustomEventInit<unknown>, 'detail'>) {
438
+ const event = camelToKebabCase(context.name as string);
439
+ defineMember(context, 'definedEvents', event, (target, prop) => defineProp(target, prop, { event, eventOptions }));
453
440
  }
454
441
 
455
442
  /**