@mantou/gem 2.3.0 → 2.3.2

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 (74) hide show
  1. package/AGENTS.md +150 -0
  2. package/dist/gem.js +6 -6
  3. package/dist/gem.mjs +6 -6
  4. package/elements/base/dialog.d.ts.map +1 -1
  5. package/elements/base/gesture.d.ts.map +1 -1
  6. package/elements/base/gesture.js +20 -1
  7. package/elements/base/gesture.js.map +1 -1
  8. package/elements/base/link.d.ts.map +1 -1
  9. package/elements/base/link.js +1 -1
  10. package/elements/base/link.js.map +1 -1
  11. package/elements/base/modal-factory.d.ts +20 -511
  12. package/elements/base/modal-factory.d.ts.map +1 -1
  13. package/elements/base/modal-factory.js +3 -2
  14. package/elements/base/modal-factory.js.map +1 -1
  15. package/elements/base/reflect.d.ts +7 -4
  16. package/elements/base/reflect.d.ts.map +1 -1
  17. package/elements/base/reflect.js +30 -4
  18. package/elements/base/reflect.js.map +1 -1
  19. package/elements/base/route.d.ts +3 -6
  20. package/elements/base/route.d.ts.map +1 -1
  21. package/elements/base/route.js +1 -1
  22. package/elements/base/route.js.map +1 -1
  23. package/elements/base/title.d.ts.map +1 -1
  24. package/elements/base/title.js +1 -1
  25. package/elements/base/title.js.map +1 -1
  26. package/helper/hmr.js.map +1 -1
  27. package/helper/i18n.d.ts +21 -17
  28. package/helper/i18n.d.ts.map +1 -1
  29. package/helper/i18n.js +172 -116
  30. package/helper/i18n.js.map +1 -1
  31. package/helper/logger.d.ts.map +1 -1
  32. package/helper/mediaquery.d.ts.map +1 -1
  33. package/helper/request.d.ts +7 -1
  34. package/helper/request.d.ts.map +1 -1
  35. package/helper/request.js.map +1 -1
  36. package/helper/theme.d.ts.map +1 -1
  37. package/helper/theme.js.map +1 -1
  38. package/lib/decorators.d.ts +3 -2
  39. package/lib/decorators.d.ts.map +1 -1
  40. package/lib/decorators.js +2 -1
  41. package/lib/decorators.js.map +1 -1
  42. package/lib/directive.d.ts.map +1 -1
  43. package/lib/element.d.ts +5 -1
  44. package/lib/element.d.ts.map +1 -1
  45. package/lib/element.js.map +1 -1
  46. package/lib/history.d.ts +46 -42
  47. package/lib/history.d.ts.map +1 -1
  48. package/lib/lit-html.d.ts.map +1 -1
  49. package/lib/lit-html.js.map +1 -1
  50. package/lib/reactive.d.ts +1 -4
  51. package/lib/reactive.d.ts.map +1 -1
  52. package/lib/reactive.js +1 -3
  53. package/lib/reactive.js.map +1 -1
  54. package/lib/repeat.d.ts +1 -1
  55. package/lib/repeat.d.ts.map +1 -1
  56. package/lib/repeat.js.map +1 -1
  57. package/lib/utils.d.ts.map +1 -1
  58. package/lib/version.d.ts +1 -1
  59. package/lib/version.js +1 -1
  60. package/package.json +6 -5
  61. package/src/elements/base/gesture.ts +20 -1
  62. package/src/elements/base/modal-factory.ts +22 -6
  63. package/src/elements/base/reflect.ts +30 -4
  64. package/src/elements/base/route.ts +2 -7
  65. package/src/helper/i18n.ts +184 -127
  66. package/src/lib/decorators.ts +2 -1
  67. package/src/lib/element.ts +5 -4
  68. package/src/lib/reactive.ts +1 -6
  69. package/src/lib/version.ts +1 -1
  70. package/src/test/elements/link.test.ts +12 -2
  71. package/src/test/elements/reflect.test.ts +20 -1
  72. package/src/test/elements/unsafe.test.ts +22 -0
  73. package/src/test/gem-element/advance.test.ts +87 -0
  74. package/src/test/utils.test.ts +4 -0
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@mantou/gem",
3
- "version": "2.3.0",
4
- "description": "💎 使用自定义元素的轻量级 WebApp 开发框架",
3
+ "version": "2.3.2",
4
+ "description": "💎 Build reusable custom elements and web apps on Web Components",
5
5
  "keywords": [
6
6
  "frontend",
7
7
  "web components",
@@ -22,6 +22,7 @@
22
22
  "./helper/*": "./helper/*.js"
23
23
  },
24
24
  "files": [
25
+ "/AGENTS.md",
25
26
  "/index.*",
26
27
  "/dist/",
27
28
  "/elements/",
@@ -33,14 +34,14 @@
33
34
  "scripts": {
34
35
  "docs": "gem-book docs",
35
36
  "build:docs": "gem-book build docs",
36
- "build:module": "tsc -p tsconfig.build.json",
37
+ "build:module": "tsrs -p tsconfig.build.json",
37
38
  "build:lib": "rslib build",
38
39
  "build": "pnpm build:module && pnpm build:lib",
39
40
  "start": "pnpm build:module --watch",
40
41
  "test": "cross-env wtr"
41
42
  },
42
43
  "devDependencies": {
43
- "@gemjs/config": "^2.1.3",
44
+ "@gemjs/config": "^2.1.4",
44
45
  "@rslib/core": "^0.23.0",
45
46
  "@swc/helpers": "^0.5.23",
46
47
  "@types/chai": "^5.0.0",
@@ -50,7 +51,7 @@
50
51
  "@web/test-runner": "^0.18.1",
51
52
  "chai": "^5.1.1",
52
53
  "rimraf": "^3.0.2",
53
- "typescript": "^5.6.2"
54
+ "tsrs": "^0.18.0-ts7.1.0-dev.20260929"
54
55
  },
55
56
  "author": "mantou132",
56
57
  "license": "MIT",
@@ -137,7 +137,26 @@ const style = css`
137
137
  &:where(:not([hidden])) {
138
138
  display: block;
139
139
  user-select: none;
140
- touch-action: attr(touch-action, none);
140
+ touch-action: none;
141
+ }
142
+ /* 常用取值,兼容不支持在 touch-action 中使用 attr() 的浏览器 */
143
+ &:where([touch-action='auto']) {
144
+ touch-action: auto;
145
+ }
146
+ &:where([touch-action='manipulation']) {
147
+ touch-action: manipulation;
148
+ }
149
+ &:where([touch-action='pan-x']) {
150
+ touch-action: pan-x;
151
+ }
152
+ &:where([touch-action='pan-y']) {
153
+ touch-action: pan-y;
154
+ }
155
+ /* 支持任意取值;不带类型的 attr() 是字符串,对 touch-action 无效 */
156
+ @supports (touch-action: attr(touch-action type(*), none)) {
157
+ &:where([touch-action]) {
158
+ touch-action: attr(touch-action type(*), none);
159
+ }
141
160
  }
142
161
  }
143
162
  `;
@@ -1,9 +1,27 @@
1
1
  import { aria, mounted, shadow } from '../../lib/decorators';
2
2
  import { history } from '../../lib/history';
3
3
  import { GemElement } from '../../lib/reactive';
4
+ import type { Store } from '../../lib/store';
4
5
  import { connect, createStore } from '../../lib/store';
5
6
 
6
7
  const open = Symbol('open mark');
8
+ // 静态方法返回不可伪造的值,防止子类覆盖
9
+ const final = Symbol();
10
+
11
+ type ModalElement = GemElement & { label: string };
12
+
13
+ export interface ModalClass<T> extends Omit<typeof GemElement, 'prototype'> {
14
+ new (): ModalElement;
15
+ prototype: ModalElement;
16
+ inertStore: HTMLElement[];
17
+ instance: GemElement | null;
18
+ store: Store<{ [open]: boolean } & T>;
19
+ readonly isOpen: boolean;
20
+ open(opts: T): typeof final;
21
+ close(): typeof final;
22
+ closeHandle(): typeof final;
23
+ shouldClose(): boolean;
24
+ }
7
25
 
8
26
  /**
9
27
  * 需要使用范型,所以导出一个函数,用来*创建*类似 Confirm 元素的基类;
@@ -13,9 +31,7 @@ const open = Symbol('open mark');
13
31
  * 使用 `mounted`/`unmounted` 定义 `open`/`close` 回调;
14
32
  * 模拟 top layer:https://github.com/whatwg/html/issues/4633.
15
33
  */
16
- export function createModalClass<T extends Record<string, unknown>>(options: T) {
17
- const final = Symbol();
18
-
34
+ export function createModalClass<T extends Record<string, unknown>>(options: T): ModalClass<T> {
19
35
  @shadow()
20
36
  @aria({ role: 'alertdialog', ariaModal: 'true' })
21
37
  class ModalElement extends GemElement {
@@ -40,7 +56,7 @@ export function createModalClass<T extends Record<string, unknown>>(options: T)
40
56
  * 自带 100ms 延迟,以允许在其他 Dialog 的 `shouldClose` 中调用此方法;
41
57
  * 浏览器 history 为异步 API,需要设置较长延迟;
42
58
  */
43
- static open(opts: T) {
59
+ static open(opts: T): typeof final {
44
60
  const instance = new this();
45
61
  this.instance = instance;
46
62
  this.inertStore = ([...document.body.children] as HTMLElement[]).filter((e) => !e.inert);
@@ -63,7 +79,7 @@ export function createModalClass<T extends Record<string, unknown>>(options: T)
63
79
  * @final
64
80
  * UI 中执行关闭
65
81
  */
66
- static close() {
82
+ static close(): typeof final {
67
83
  history.back();
68
84
  return final;
69
85
  }
@@ -72,7 +88,7 @@ export function createModalClass<T extends Record<string, unknown>>(options: T)
72
88
  * @final
73
89
  * history 中自动调用
74
90
  */
75
- static closeHandle() {
91
+ static closeHandle(): typeof final {
76
92
  this.inertStore.forEach((e) => (e.inert = false));
77
93
  this.instance?.remove();
78
94
  this.store({ [open]: false, ...options });
@@ -1,9 +1,8 @@
1
1
  /**
2
- * - 预渲染可能造成重复的元素,在序列化时可以用正则移除 GemReflectElement 渲染的从开始到结束连续的元素
3
- * - `replaceAll(/<!--gem-reflect-start-->.*?<!--gem-reflect-end-->/gs, '')`
2
+ * 把子节点移动到 `target`(默认 `document.head`)中,用开始和结束注释标记这些节点,
3
+ * 修改 `target` 或者卸载时一起移动或移除
4
4
  *
5
- * 不能在在顶级代码块中清除这些元素,因为重复加载该代码时会把正常 reflect 元素清除,
6
- * 比如 duoyun-ui 文档站加载示例元素时把 GemReflectElement 渲染的元素清除
5
+ * 服务端渲染时不执行 effect,客户端水合后才移动,使用 `getLogicalParent` 从被移动的节点找到 reflect 元素
7
6
  */
8
7
 
9
8
  import { attribute, effect, property, willMount } from '../../lib/decorators';
@@ -25,6 +24,31 @@ const getReflectFragment = (startNode: Comment) => {
25
24
  return result;
26
25
  };
27
26
 
27
+ // 移动到的目标元素和片段开始注释对应的 reflect 元素,向上查找元素时跨越 reflect
28
+ const targets = new WeakSet<Node>();
29
+ const startNodes = new WeakMap<Node, GemReflectElement>();
30
+
31
+ // 向前查找片段的开始注释,遇到其他片段的结束注释时不在片段中
32
+ const getReflectElement = (node: Node) => {
33
+ for (let n = node.previousSibling; n; n = n.previousSibling) {
34
+ const ele = startNodes.get(n);
35
+ if (ele) return ele;
36
+ if (n.nodeValue === END) return;
37
+ }
38
+ };
39
+
40
+ /**
41
+ * 节点的逻辑父元素:被 reflect 移动的节点是 reflect 元素,ShadowRoot 中顶层的节点是宿主,其他是父元素
42
+ */
43
+ export function getLogicalParent(node: Node): Element | null {
44
+ const parent = node.parentNode;
45
+ if (parent && targets.has(parent)) {
46
+ const ele = getReflectElement(node);
47
+ if (ele) return ele;
48
+ }
49
+ return parent instanceof ShadowRoot ? parent.host : node.parentElement;
50
+ }
51
+
28
52
  export class GemReflectElement extends GemElement {
29
53
  @attribute method: 'prepend' | 'append';
30
54
 
@@ -45,6 +69,8 @@ export class GemReflectElement extends GemElement {
45
69
 
46
70
  @effect((i) => [i.target])
47
71
  #changeTarget = () => {
72
+ targets.add(this.target);
73
+ startNodes.set(this.#startNode, this);
48
74
  (this.#childNodes || [this.#startNode, ...this.childNodes, this.#endNode]).forEach((node, index, arr) => {
49
75
  const prev = arr[index - 1];
50
76
  if (prev) {
@@ -12,12 +12,6 @@ import { addListener } from '../../lib/utils';
12
12
  type Params = Record<string, string>;
13
13
  type Matcher = (path: string) => Params | null | undefined;
14
14
 
15
- declare global {
16
- // https://bugzilla.mozilla.org/show_bug.cgi?id=1731418
17
- // https://github.com/WebKit/standards-positions/issues/61
18
- var URLPattern: any;
19
- }
20
-
21
15
  const UNNAMED_PARAM_PREFIX = '_';
22
16
 
23
17
  // 不支持 `URLPattern` 时转换成正则
@@ -56,7 +50,8 @@ function createMatcher(pattern: string): Matcher {
56
50
  const urlPattern = new URLPattern({ pathname: pattern });
57
51
  // 参数前的 `/` 可能是可选的,例如 `/a/:b?` 匹配 `/a`
58
52
  const prefix = literal.replace(/\/$/, '');
59
- return (path) => (path.startsWith(prefix) ? urlPattern.exec({ pathname: path })?.pathname.groups : null);
53
+ return (path) =>
54
+ path.startsWith(prefix) ? (urlPattern.exec({ pathname: path })?.pathname.groups as Params | undefined) : null;
60
55
  }
61
56
 
62
57
  // pattern 来自路由定义,数量有限
@@ -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
  }
@@ -418,7 +418,8 @@ export type Emitter<T = any> = <_Listener = EmitterListener<T>>(
418
418
 
419
419
  /**
420
420
  * 定义一个事件发射器,类似 `HTMLElement.click`,
421
- * 调用时将同步发送一个和字段名称的烤串式格式的自定义事件
421
+ * 调用时将在微任务中发送一个和字段名称的烤串式格式的自定义事件,
422
+ * 避免同步触发时祖先元素还没挂载好(https://github.com/mantou132/gem/issues/203)
422
423
  *
423
424
  * @example
424
425
  * ```ts
@@ -13,11 +13,12 @@ export { createRef, mathml, svg, TemplateResult } from './lit-html';
13
13
  export * from './reactive';
14
14
  export { repeat } from './repeat';
15
15
 
16
+ type HookExports = typeof reactiveExports & typeof decoratorsExports & typeof storeExports & typeof versionExports;
17
+
16
18
  declare global {
17
- var __GEM_DEVTOOLS__HOOK__:
18
- | (typeof reactiveExports & typeof decoratorsExports & typeof storeExports & typeof versionExports)
19
- | Record<string, never>
20
- | undefined;
19
+ /** devtools 注入的钩子,其他包可以扩展,例如 gem-ssr */
20
+ interface GemDevToolsHook extends HookExports {}
21
+ var __GEM_DEVTOOLS__HOOK__: GemDevToolsHook | Record<string, never> | undefined;
21
22
  }
22
23
 
23
24
  // 只记录第一次定义,往往是最外层 App