@finesoft/front 0.1.53 → 0.1.55
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/README.md +253 -0
- package/dist/index.d.mts +492 -16
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +633 -84
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
package/dist/index.mjs
CHANGED
|
@@ -85,9 +85,10 @@ var IntentDispatcher = class {
|
|
|
85
85
|
};
|
|
86
86
|
//#endregion
|
|
87
87
|
//#region ../core/src/dependencies/container.ts
|
|
88
|
-
var Container = class {
|
|
88
|
+
var Container = class Container {
|
|
89
89
|
registrations = /* @__PURE__ */ new Map();
|
|
90
90
|
resolutionStack = /* @__PURE__ */ new Set();
|
|
91
|
+
parent;
|
|
91
92
|
/** 注册依赖(默认单例) */
|
|
92
93
|
register(key, factory, singleton = true) {
|
|
93
94
|
this.registrations.set(key, {
|
|
@@ -96,10 +97,13 @@ var Container = class {
|
|
|
96
97
|
});
|
|
97
98
|
return this;
|
|
98
99
|
}
|
|
99
|
-
/** 解析依赖 */
|
|
100
|
+
/** 解析依赖 — 当前容器未注册时回退到 parent */
|
|
100
101
|
resolve(key) {
|
|
101
102
|
const reg = this.registrations.get(key);
|
|
102
|
-
if (!reg)
|
|
103
|
+
if (!reg) {
|
|
104
|
+
if (this.parent) return this.parent.resolve(key);
|
|
105
|
+
throw new Error(`[Container] No registration for key: "${key}"`);
|
|
106
|
+
}
|
|
103
107
|
if (reg.singleton) {
|
|
104
108
|
if (reg.instance === void 0) {
|
|
105
109
|
if (this.resolutionStack.has(key)) throw new Error(`[Container] Circular dependency detected: ${[...this.resolutionStack, key].join(" → ")}`);
|
|
@@ -114,9 +118,20 @@ var Container = class {
|
|
|
114
118
|
}
|
|
115
119
|
return reg.factory();
|
|
116
120
|
}
|
|
117
|
-
/**
|
|
121
|
+
/** 检查是否已注册(含 parent) */
|
|
118
122
|
has(key) {
|
|
119
|
-
return this.registrations.has(key);
|
|
123
|
+
return this.registrations.has(key) || (this.parent?.has(key) ?? false);
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* 创建子容器(请求级 scope)
|
|
127
|
+
*
|
|
128
|
+
* 子容器可覆写父容器的依赖(如每请求的 locale、user),
|
|
129
|
+
* 未覆写的 key 自动回退到父容器解析。
|
|
130
|
+
*/
|
|
131
|
+
createScope() {
|
|
132
|
+
const child = new Container();
|
|
133
|
+
child.parent = this;
|
|
134
|
+
return child;
|
|
120
135
|
}
|
|
121
136
|
/** 销毁容器,清除所有缓存 */
|
|
122
137
|
dispose() {
|
|
@@ -125,6 +140,190 @@ var Container = class {
|
|
|
125
140
|
}
|
|
126
141
|
};
|
|
127
142
|
//#endregion
|
|
143
|
+
//#region ../core/src/i18n/locale.ts
|
|
144
|
+
/** RTL 语言列表 */
|
|
145
|
+
const RTL_LANGUAGES = new Set([
|
|
146
|
+
"ar",
|
|
147
|
+
"arc",
|
|
148
|
+
"dv",
|
|
149
|
+
"fa",
|
|
150
|
+
"ha",
|
|
151
|
+
"he",
|
|
152
|
+
"khw",
|
|
153
|
+
"ks",
|
|
154
|
+
"ku",
|
|
155
|
+
"ps",
|
|
156
|
+
"ur",
|
|
157
|
+
"yi"
|
|
158
|
+
]);
|
|
159
|
+
/** 检测语言是否为 RTL */
|
|
160
|
+
function isRtl(language) {
|
|
161
|
+
const primary = language.split("-")[0].toLowerCase();
|
|
162
|
+
return RTL_LANGUAGES.has(primary);
|
|
163
|
+
}
|
|
164
|
+
/** 获取文本方向 */
|
|
165
|
+
function getTextDirection(language) {
|
|
166
|
+
return isRtl(language) ? "rtl" : "ltr";
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* 从语言代码生成 HTML lang/dir 属性
|
|
170
|
+
*
|
|
171
|
+
* @example
|
|
172
|
+
* ```ts
|
|
173
|
+
* getLocaleAttributes("ar-SA") // { lang: "ar-SA", dir: "rtl" }
|
|
174
|
+
* getLocaleAttributes("en-US") // { lang: "en-US", dir: "ltr" }
|
|
175
|
+
* ```
|
|
176
|
+
*/
|
|
177
|
+
function getLocaleAttributes(language) {
|
|
178
|
+
return {
|
|
179
|
+
lang: language,
|
|
180
|
+
dir: getTextDirection(language)
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* 构建 LocaleInfo
|
|
185
|
+
*
|
|
186
|
+
* @param language - 语言代码(如 "zh-Hans")
|
|
187
|
+
* @param region - 地区代码(如 "CN"),可选
|
|
188
|
+
*/
|
|
189
|
+
function makeLocaleInfo(language, region) {
|
|
190
|
+
return {
|
|
191
|
+
language,
|
|
192
|
+
region,
|
|
193
|
+
bcp47: region ? `${language}-${region}` : language,
|
|
194
|
+
dir: getTextDirection(language)
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* 将 locale 属性应用到 `<html>` 元素
|
|
199
|
+
*
|
|
200
|
+
* 服务端渲染时可用于字符串拼接,浏览器端直接操作 DOM。
|
|
201
|
+
*/
|
|
202
|
+
function setHtmlLocaleAttributes(attrs) {
|
|
203
|
+
document.documentElement.lang = attrs.lang;
|
|
204
|
+
document.documentElement.dir = attrs.dir;
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* 从 URL 前缀中提取 locale
|
|
208
|
+
*
|
|
209
|
+
* @param url - 请求 URL(如 "/zh/about")
|
|
210
|
+
* @param supportedLocales - 支持的 locale 列表(如 ["zh", "en", "ja"])
|
|
211
|
+
* @returns 匹配时返回 `{ locale, strippedUrl }`,不匹配返回 null
|
|
212
|
+
*
|
|
213
|
+
* @example
|
|
214
|
+
* ```ts
|
|
215
|
+
* resolveLocaleFromUrl("/zh/about", ["zh", "en"])
|
|
216
|
+
* // → { locale: "zh", strippedUrl: "/about" }
|
|
217
|
+
*
|
|
218
|
+
* resolveLocaleFromUrl("/about", ["zh", "en"])
|
|
219
|
+
* // → null
|
|
220
|
+
* ```
|
|
221
|
+
*/
|
|
222
|
+
function resolveLocaleFromUrl(url, supportedLocales) {
|
|
223
|
+
const match = url.split("?")[0].match(/^\/([^/]+)(\/.*)?$/);
|
|
224
|
+
if (!match) return null;
|
|
225
|
+
const candidate = match[1];
|
|
226
|
+
const found = supportedLocales.find((l) => l.toLowerCase() === candidate.toLowerCase());
|
|
227
|
+
if (!found) return null;
|
|
228
|
+
return {
|
|
229
|
+
locale: found,
|
|
230
|
+
strippedUrl: match[2] || "/"
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
//#endregion
|
|
234
|
+
//#region ../core/src/i18n/interpolate.ts
|
|
235
|
+
/**
|
|
236
|
+
* ICU 消息格式插值
|
|
237
|
+
*
|
|
238
|
+
* 支持 `{name}` 占位符替换和基础复数规则。
|
|
239
|
+
*/
|
|
240
|
+
/** 将 `{key}` 占位符替换为 values 中的对应值 */
|
|
241
|
+
function interpolate(template, values) {
|
|
242
|
+
if (!values) return template;
|
|
243
|
+
return template.replace(/\{(\w+)\}/g, (_, key) => {
|
|
244
|
+
const val = values[key];
|
|
245
|
+
return val !== void 0 ? String(val) : `{${key}}`;
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
/**
|
|
249
|
+
* 英语复数规则(默认)
|
|
250
|
+
* 0 → other, 1 → one, 2+ → other
|
|
251
|
+
*/
|
|
252
|
+
function englishPlural(count) {
|
|
253
|
+
return count === 1 ? "one" : "other";
|
|
254
|
+
}
|
|
255
|
+
/**
|
|
256
|
+
* 解析带复数后缀的翻译 key
|
|
257
|
+
*
|
|
258
|
+
* 约定: `key.one`, `key.other`, `key.zero`, etc.
|
|
259
|
+
*/
|
|
260
|
+
function resolvePluralKey(key, category) {
|
|
261
|
+
return `${key}.${category}`;
|
|
262
|
+
}
|
|
263
|
+
//#endregion
|
|
264
|
+
//#region ../core/src/i18n/translator.ts
|
|
265
|
+
/**
|
|
266
|
+
* SimpleTranslator — 默认翻译器实现
|
|
267
|
+
*
|
|
268
|
+
* 从扁平的 key→string 映射提供翻译,支持 ICU 插值和复数规则。
|
|
269
|
+
*/
|
|
270
|
+
var SimpleTranslator = class {
|
|
271
|
+
locale;
|
|
272
|
+
messages;
|
|
273
|
+
pluralRule;
|
|
274
|
+
fallback;
|
|
275
|
+
constructor(options) {
|
|
276
|
+
this.locale = options.locale;
|
|
277
|
+
this.messages = options.messages;
|
|
278
|
+
this.pluralRule = options.pluralRule ?? englishPlural;
|
|
279
|
+
this.fallback = options.fallback ?? ((key) => key);
|
|
280
|
+
}
|
|
281
|
+
t(key, values) {
|
|
282
|
+
const template = this.messages[key];
|
|
283
|
+
if (template === void 0) return this.fallback(key);
|
|
284
|
+
return interpolate(template, values);
|
|
285
|
+
}
|
|
286
|
+
plural(key, count, values) {
|
|
287
|
+
const pluralKey = resolvePluralKey(key, this.pluralRule(count));
|
|
288
|
+
const mergedValues = {
|
|
289
|
+
count,
|
|
290
|
+
...values
|
|
291
|
+
};
|
|
292
|
+
return this.t(pluralKey, mergedValues);
|
|
293
|
+
}
|
|
294
|
+
};
|
|
295
|
+
//#endregion
|
|
296
|
+
//#region ../core/src/logger/composite.ts
|
|
297
|
+
var CompositeLoggerFactory = class {
|
|
298
|
+
constructor(factories) {
|
|
299
|
+
this.factories = factories;
|
|
300
|
+
}
|
|
301
|
+
loggerFor(name) {
|
|
302
|
+
return new CompositeLogger(this.factories.map((f) => f.loggerFor(name)));
|
|
303
|
+
}
|
|
304
|
+
};
|
|
305
|
+
var CompositeLogger = class {
|
|
306
|
+
constructor(loggers) {
|
|
307
|
+
this.loggers = loggers;
|
|
308
|
+
}
|
|
309
|
+
debug(...args) {
|
|
310
|
+
return this.callAll("debug", args);
|
|
311
|
+
}
|
|
312
|
+
info(...args) {
|
|
313
|
+
return this.callAll("info", args);
|
|
314
|
+
}
|
|
315
|
+
warn(...args) {
|
|
316
|
+
return this.callAll("warn", args);
|
|
317
|
+
}
|
|
318
|
+
error(...args) {
|
|
319
|
+
return this.callAll("error", args);
|
|
320
|
+
}
|
|
321
|
+
callAll(method, args) {
|
|
322
|
+
for (const logger of this.loggers) logger[method](...args);
|
|
323
|
+
return "";
|
|
324
|
+
}
|
|
325
|
+
};
|
|
326
|
+
//#endregion
|
|
128
327
|
//#region ../core/src/logger/base.ts
|
|
129
328
|
var BaseLogger = class {
|
|
130
329
|
category;
|
|
@@ -213,6 +412,111 @@ var ConsoleLoggerFactory = class {
|
|
|
213
412
|
}
|
|
214
413
|
};
|
|
215
414
|
//#endregion
|
|
415
|
+
//#region ../core/src/logger/reporting.ts
|
|
416
|
+
/**
|
|
417
|
+
* ReportingLogger — 上报型日志实现
|
|
418
|
+
*
|
|
419
|
+
* 将 warn/error 级别日志转发到外部监控服务(如 Sentry、Datadog)。
|
|
420
|
+
* 用户通过 ReportCallback 注入上报逻辑,框架不直接依赖任何第三方 SDK。
|
|
421
|
+
*/
|
|
422
|
+
const LEVEL_PRIORITY = {
|
|
423
|
+
debug: 0,
|
|
424
|
+
info: 1,
|
|
425
|
+
warn: 2,
|
|
426
|
+
error: 3
|
|
427
|
+
};
|
|
428
|
+
var ReportingLogger = class extends BaseLogger {
|
|
429
|
+
minPriority;
|
|
430
|
+
report;
|
|
431
|
+
constructor(category, options) {
|
|
432
|
+
super(category);
|
|
433
|
+
this.minPriority = LEVEL_PRIORITY[options.minLevel ?? "warn"];
|
|
434
|
+
this.report = options.report;
|
|
435
|
+
}
|
|
436
|
+
debug(...args) {
|
|
437
|
+
this.maybeReport("debug", args);
|
|
438
|
+
return "";
|
|
439
|
+
}
|
|
440
|
+
info(...args) {
|
|
441
|
+
this.maybeReport("info", args);
|
|
442
|
+
return "";
|
|
443
|
+
}
|
|
444
|
+
warn(...args) {
|
|
445
|
+
this.maybeReport("warn", args);
|
|
446
|
+
return "";
|
|
447
|
+
}
|
|
448
|
+
error(...args) {
|
|
449
|
+
this.maybeReport("error", args);
|
|
450
|
+
return "";
|
|
451
|
+
}
|
|
452
|
+
maybeReport(level, args) {
|
|
453
|
+
if (LEVEL_PRIORITY[level] >= this.minPriority) this.report(level, this.category, args);
|
|
454
|
+
}
|
|
455
|
+
};
|
|
456
|
+
var ReportingLoggerFactory = class {
|
|
457
|
+
options;
|
|
458
|
+
constructor(options) {
|
|
459
|
+
this.options = options;
|
|
460
|
+
}
|
|
461
|
+
loggerFor(category) {
|
|
462
|
+
return new ReportingLogger(category, this.options);
|
|
463
|
+
}
|
|
464
|
+
};
|
|
465
|
+
//#endregion
|
|
466
|
+
//#region ../core/src/metrics/console-recorder.ts
|
|
467
|
+
var ConsoleEventRecorder = class {
|
|
468
|
+
prefix;
|
|
469
|
+
constructor(prefix = "Metrics") {
|
|
470
|
+
this.prefix = prefix;
|
|
471
|
+
}
|
|
472
|
+
record(type, fields) {
|
|
473
|
+
console.info(`[${this.prefix}:${type}]`, fields ?? "");
|
|
474
|
+
}
|
|
475
|
+
async flush() {}
|
|
476
|
+
destroy() {}
|
|
477
|
+
};
|
|
478
|
+
//#endregion
|
|
479
|
+
//#region ../core/src/utils/platform.ts
|
|
480
|
+
/**
|
|
481
|
+
* 从 User-Agent 字符串解析平台信息
|
|
482
|
+
*
|
|
483
|
+
* @param ua - User-Agent 字符串(默认取 navigator.userAgent)
|
|
484
|
+
*/
|
|
485
|
+
function detectPlatform(ua) {
|
|
486
|
+
const agent = ua ?? (typeof navigator !== "undefined" ? navigator.userAgent : "");
|
|
487
|
+
const lower = agent.toLowerCase();
|
|
488
|
+
return {
|
|
489
|
+
os: detectOS(lower),
|
|
490
|
+
browser: detectBrowser(lower),
|
|
491
|
+
engine: detectEngine(lower),
|
|
492
|
+
isMobile: /mobile|android|iphone|ipad|ipod/i.test(agent),
|
|
493
|
+
isTouch: typeof navigator !== "undefined" && "maxTouchPoints" in navigator ? navigator.maxTouchPoints > 0 : false
|
|
494
|
+
};
|
|
495
|
+
}
|
|
496
|
+
function detectOS(ua) {
|
|
497
|
+
if (/iphone|ipad|ipod/.test(ua)) return "ios";
|
|
498
|
+
if (/android/.test(ua)) return "android";
|
|
499
|
+
if (/macintosh|mac os x/.test(ua)) return "macos";
|
|
500
|
+
if (/windows/.test(ua)) return "windows";
|
|
501
|
+
if (/linux/.test(ua)) return "linux";
|
|
502
|
+
return "unknown";
|
|
503
|
+
}
|
|
504
|
+
function detectBrowser(ua) {
|
|
505
|
+
if (/edg\//.test(ua)) return "edge";
|
|
506
|
+
if (/opr\/|opera/.test(ua)) return "opera";
|
|
507
|
+
if (/samsungbrowser/.test(ua)) return "samsung";
|
|
508
|
+
if (/chrome|crios/.test(ua) && !/edg\//.test(ua)) return "chrome";
|
|
509
|
+
if (/firefox|fxios/.test(ua)) return "firefox";
|
|
510
|
+
if (/safari/.test(ua) && !/chrome/.test(ua)) return "safari";
|
|
511
|
+
return "unknown";
|
|
512
|
+
}
|
|
513
|
+
function detectEngine(ua) {
|
|
514
|
+
if (/applewebkit/.test(ua) && !/chrome/.test(ua)) return "webkit";
|
|
515
|
+
if (/applewebkit/.test(ua) && /chrome/.test(ua)) return "blink";
|
|
516
|
+
if (/gecko\//.test(ua)) return "gecko";
|
|
517
|
+
return "unknown";
|
|
518
|
+
}
|
|
519
|
+
//#endregion
|
|
216
520
|
//#region ../core/src/dependencies/make-dependencies.ts
|
|
217
521
|
/**
|
|
218
522
|
* 依赖工厂 — 创建所有基础依赖
|
|
@@ -224,7 +528,11 @@ const DEP_KEYS = {
|
|
|
224
528
|
STORAGE: "storage",
|
|
225
529
|
FEATURE_FLAGS: "featureFlags",
|
|
226
530
|
METRICS: "metrics",
|
|
227
|
-
FETCH: "fetch"
|
|
531
|
+
FETCH: "fetch",
|
|
532
|
+
EVENT_RECORDER: "eventRecorder",
|
|
533
|
+
LOCALE: "locale",
|
|
534
|
+
PLATFORM: "platform",
|
|
535
|
+
TRANSLATOR: "translator"
|
|
228
536
|
};
|
|
229
537
|
var MemoryStorage = class {
|
|
230
538
|
store = /* @__PURE__ */ new Map();
|
|
@@ -240,38 +548,91 @@ var MemoryStorage = class {
|
|
|
240
548
|
};
|
|
241
549
|
var DefaultFeatureFlags = class {
|
|
242
550
|
flags;
|
|
551
|
+
providers = [];
|
|
243
552
|
constructor(flags = {}) {
|
|
244
553
|
this.flags = flags;
|
|
245
554
|
}
|
|
555
|
+
/** 注册外部 provider(如远程配置、A/B 测试 SDK) */
|
|
556
|
+
addProvider(provider) {
|
|
557
|
+
this.providers.push(provider);
|
|
558
|
+
}
|
|
246
559
|
isEnabled(key) {
|
|
560
|
+
for (let i = this.providers.length - 1; i >= 0; i--) if (this.providers[i].isEnabled(key)) return true;
|
|
247
561
|
return this.flags[key] === true;
|
|
248
562
|
}
|
|
249
563
|
getString(key) {
|
|
564
|
+
for (let i = this.providers.length - 1; i >= 0; i--) {
|
|
565
|
+
const result = this.providers[i].getString?.(key);
|
|
566
|
+
if (result !== void 0) return result;
|
|
567
|
+
}
|
|
250
568
|
const v = this.flags[key];
|
|
251
569
|
return typeof v === "string" ? v : void 0;
|
|
252
570
|
}
|
|
253
571
|
getNumber(key) {
|
|
572
|
+
for (let i = this.providers.length - 1; i >= 0; i--) {
|
|
573
|
+
const result = this.providers[i].getNumber?.(key);
|
|
574
|
+
if (result !== void 0) return result;
|
|
575
|
+
}
|
|
254
576
|
const v = this.flags[key];
|
|
255
577
|
return typeof v === "number" ? v : void 0;
|
|
256
578
|
}
|
|
257
579
|
};
|
|
258
580
|
var ConsoleMetrics = class {
|
|
581
|
+
record(type, fields) {
|
|
582
|
+
console.info(`[Metrics:${type}]`, fields ?? "");
|
|
583
|
+
}
|
|
259
584
|
recordPageView(page, fields) {
|
|
260
|
-
|
|
585
|
+
this.record("PageView", {
|
|
586
|
+
page,
|
|
587
|
+
...fields
|
|
588
|
+
});
|
|
261
589
|
}
|
|
262
590
|
recordEvent(name, fields) {
|
|
263
|
-
|
|
591
|
+
this.record("Event", {
|
|
592
|
+
name,
|
|
593
|
+
...fields
|
|
594
|
+
});
|
|
264
595
|
}
|
|
265
596
|
};
|
|
597
|
+
/**
|
|
598
|
+
* 从 TranslationMessages 中解析出 SimpleTranslator 所需的扁平 Record<string, string>
|
|
599
|
+
*
|
|
600
|
+
* - 扁平格式:直接返回
|
|
601
|
+
* - 嵌套格式:提取 locale 对应子表并展平复数 key(`{key}.{plural}` 拼接)
|
|
602
|
+
*/
|
|
603
|
+
function resolveMessages(messages, locale) {
|
|
604
|
+
const entries = Object.entries(messages);
|
|
605
|
+
if (entries.length === 0) return void 0;
|
|
606
|
+
if (typeof entries[0][1] === "string") return messages;
|
|
607
|
+
const localeMsgs = messages[locale];
|
|
608
|
+
if (!localeMsgs) return void 0;
|
|
609
|
+
const flat = {};
|
|
610
|
+
for (const [key, value] of Object.entries(localeMsgs)) if (typeof value === "string") flat[key] = value;
|
|
611
|
+
else for (const [suffix, text] of Object.entries(value)) flat[`${key}.${suffix}`] = text;
|
|
612
|
+
return flat;
|
|
613
|
+
}
|
|
266
614
|
function makeDependencies(container, options = {}) {
|
|
267
|
-
const { fetch: fetchFn = globalThis.fetch?.bind(globalThis), featureFlags = {} } = options;
|
|
268
|
-
const
|
|
615
|
+
const { fetch: fetchFn = globalThis.fetch?.bind(globalThis), featureFlags = {}, featureFlagsProviders = [], reportCallback, eventRecorder, locale, platform, messages } = options;
|
|
616
|
+
const consoleFactory = new ConsoleLoggerFactory();
|
|
617
|
+
const loggerFactory = reportCallback ? new CompositeLoggerFactory([consoleFactory, new ReportingLoggerFactory({ report: reportCallback })]) : consoleFactory;
|
|
269
618
|
container.register(DEP_KEYS.LOGGER_FACTORY, () => loggerFactory);
|
|
270
619
|
container.register(DEP_KEYS.LOGGER, () => loggerFactory.loggerFor("framework"));
|
|
271
620
|
container.register(DEP_KEYS.NET, () => ({ fetch: (url, opts) => fetchFn(url, opts) }));
|
|
272
621
|
container.register(DEP_KEYS.STORAGE, () => new MemoryStorage());
|
|
273
|
-
|
|
622
|
+
const flags = new DefaultFeatureFlags(featureFlags);
|
|
623
|
+
for (const provider of featureFlagsProviders) flags.addProvider(provider);
|
|
624
|
+
container.register(DEP_KEYS.FEATURE_FLAGS, () => flags);
|
|
274
625
|
container.register(DEP_KEYS.METRICS, () => new ConsoleMetrics());
|
|
626
|
+
container.register(DEP_KEYS.EVENT_RECORDER, () => eventRecorder ?? new ConsoleEventRecorder());
|
|
627
|
+
if (locale) container.register(DEP_KEYS.LOCALE, () => getLocaleAttributes(locale));
|
|
628
|
+
if (locale && messages) {
|
|
629
|
+
const flat = resolveMessages(messages, locale);
|
|
630
|
+
if (flat) container.register(DEP_KEYS.TRANSLATOR, () => new SimpleTranslator({
|
|
631
|
+
locale,
|
|
632
|
+
messages: flat
|
|
633
|
+
}));
|
|
634
|
+
}
|
|
635
|
+
container.register(DEP_KEYS.PLATFORM, () => platform ?? detectPlatform(typeof navigator !== "undefined" ? navigator.userAgent : void 0));
|
|
275
636
|
container.register(DEP_KEYS.FETCH, () => fetchFn);
|
|
276
637
|
}
|
|
277
638
|
//#endregion
|
|
@@ -355,37 +716,6 @@ var Router = class {
|
|
|
355
716
|
}
|
|
356
717
|
};
|
|
357
718
|
//#endregion
|
|
358
|
-
//#region ../core/src/logger/composite.ts
|
|
359
|
-
var CompositeLoggerFactory = class {
|
|
360
|
-
constructor(factories) {
|
|
361
|
-
this.factories = factories;
|
|
362
|
-
}
|
|
363
|
-
loggerFor(name) {
|
|
364
|
-
return new CompositeLogger(this.factories.map((f) => f.loggerFor(name)));
|
|
365
|
-
}
|
|
366
|
-
};
|
|
367
|
-
var CompositeLogger = class {
|
|
368
|
-
constructor(loggers) {
|
|
369
|
-
this.loggers = loggers;
|
|
370
|
-
}
|
|
371
|
-
debug(...args) {
|
|
372
|
-
return this.callAll("debug", args);
|
|
373
|
-
}
|
|
374
|
-
info(...args) {
|
|
375
|
-
return this.callAll("info", args);
|
|
376
|
-
}
|
|
377
|
-
warn(...args) {
|
|
378
|
-
return this.callAll("warn", args);
|
|
379
|
-
}
|
|
380
|
-
error(...args) {
|
|
381
|
-
return this.callAll("error", args);
|
|
382
|
-
}
|
|
383
|
-
callAll(method, args) {
|
|
384
|
-
for (const logger of this.loggers) logger[method](...args);
|
|
385
|
-
return "";
|
|
386
|
-
}
|
|
387
|
-
};
|
|
388
|
-
//#endregion
|
|
389
719
|
//#region ../core/src/middleware/pipeline.ts
|
|
390
720
|
/** 执行 beforeLoad 守卫链 */
|
|
391
721
|
async function runBeforeLoadGuards(guards, ctx) {
|
|
@@ -541,6 +871,18 @@ var Framework = class Framework {
|
|
|
541
871
|
title: page.title
|
|
542
872
|
});
|
|
543
873
|
}
|
|
874
|
+
/** 获取 locale 信息(如果已配置) */
|
|
875
|
+
getLocale() {
|
|
876
|
+
return this.container.has(DEP_KEYS.LOCALE) ? this.container.resolve(DEP_KEYS.LOCALE) : void 0;
|
|
877
|
+
}
|
|
878
|
+
/** 获取翻译器(如果已通过 messages + locale 配置) */
|
|
879
|
+
getTranslator() {
|
|
880
|
+
return this.container.has(DEP_KEYS.TRANSLATOR) ? this.container.resolve(DEP_KEYS.TRANSLATOR) : void 0;
|
|
881
|
+
}
|
|
882
|
+
/** 获取平台信息 */
|
|
883
|
+
getPlatform() {
|
|
884
|
+
return this.container.resolve(DEP_KEYS.PLATFORM);
|
|
885
|
+
}
|
|
544
886
|
/** 注册 Action 处理器 */
|
|
545
887
|
onAction(kind, handler) {
|
|
546
888
|
this.actionDispatcher.onAction(kind, handler);
|
|
@@ -606,10 +948,24 @@ var HttpClient = class {
|
|
|
606
948
|
baseUrl;
|
|
607
949
|
defaultHeaders;
|
|
608
950
|
fetchFn;
|
|
951
|
+
requestInterceptors;
|
|
952
|
+
responseInterceptors;
|
|
609
953
|
constructor(config) {
|
|
610
954
|
this.baseUrl = config.baseUrl;
|
|
611
955
|
this.defaultHeaders = config.defaultHeaders ?? {};
|
|
612
956
|
this.fetchFn = config.fetch ?? globalThis.fetch.bind(globalThis);
|
|
957
|
+
this.requestInterceptors = [...config.requestInterceptors ?? []];
|
|
958
|
+
this.responseInterceptors = [...config.responseInterceptors ?? []];
|
|
959
|
+
}
|
|
960
|
+
/** 动态添加请求拦截器 */
|
|
961
|
+
useRequestInterceptor(interceptor) {
|
|
962
|
+
this.requestInterceptors.push(interceptor);
|
|
963
|
+
return this;
|
|
964
|
+
}
|
|
965
|
+
/** 动态添加响应拦截器 */
|
|
966
|
+
useResponseInterceptor(interceptor) {
|
|
967
|
+
this.responseInterceptors.push(interceptor);
|
|
968
|
+
return this;
|
|
613
969
|
}
|
|
614
970
|
/** GET 请求,返回解析后的 JSON */
|
|
615
971
|
async get(path, params) {
|
|
@@ -649,7 +1005,7 @@ var HttpClient = class {
|
|
|
649
1005
|
...this.defaultHeaders,
|
|
650
1006
|
...options?.headers
|
|
651
1007
|
};
|
|
652
|
-
|
|
1008
|
+
let init = {
|
|
653
1009
|
method,
|
|
654
1010
|
headers
|
|
655
1011
|
};
|
|
@@ -657,7 +1013,9 @@ var HttpClient = class {
|
|
|
657
1013
|
headers["Content-Type"] = headers["Content-Type"] ?? "application/json";
|
|
658
1014
|
init.body = JSON.stringify(options.body);
|
|
659
1015
|
}
|
|
660
|
-
const
|
|
1016
|
+
for (const interceptor of this.requestInterceptors) init = await interceptor(url, init);
|
|
1017
|
+
let response = await this.fetchFn(url, init);
|
|
1018
|
+
for (const interceptor of this.responseInterceptors) response = await interceptor(response, url);
|
|
661
1019
|
if (!response.ok) {
|
|
662
1020
|
const body = await response.text().catch(() => void 0);
|
|
663
1021
|
throw new HttpError(response.status, response.statusText, body);
|
|
@@ -770,18 +1128,23 @@ function mapEach(mapper) {
|
|
|
770
1128
|
* ]);
|
|
771
1129
|
* ```
|
|
772
1130
|
*/
|
|
773
|
-
function defineRoutes(framework, definitions) {
|
|
1131
|
+
function defineRoutes(framework, definitions, options) {
|
|
774
1132
|
const registeredIntents = /* @__PURE__ */ new Set();
|
|
775
1133
|
for (const def of definitions) {
|
|
776
1134
|
if (def.controller && !registeredIntents.has(def.intentId)) {
|
|
777
1135
|
framework.registerIntent(def.controller);
|
|
778
1136
|
registeredIntents.add(def.intentId);
|
|
779
1137
|
}
|
|
780
|
-
|
|
1138
|
+
const routeOpts = {
|
|
781
1139
|
renderMode: def.renderMode,
|
|
782
1140
|
beforeGuards: def.beforeLoad,
|
|
783
1141
|
afterGuards: def.afterLoad
|
|
784
|
-
}
|
|
1142
|
+
};
|
|
1143
|
+
framework.router.add(def.path, def.intentId, routeOpts);
|
|
1144
|
+
if (options?.locales?.length) {
|
|
1145
|
+
const localePath = def.path === "/" ? "/:locale" : `/:locale${def.path}`;
|
|
1146
|
+
framework.router.add(localePath, def.intentId, routeOpts);
|
|
1147
|
+
}
|
|
785
1148
|
}
|
|
786
1149
|
}
|
|
787
1150
|
//#endregion
|
|
@@ -834,6 +1197,21 @@ function isNone(value) {
|
|
|
834
1197
|
return value === null || value === void 0;
|
|
835
1198
|
}
|
|
836
1199
|
//#endregion
|
|
1200
|
+
//#region ../core/src/utils/pwa.ts
|
|
1201
|
+
/**
|
|
1202
|
+
* 检测 PWA display mode
|
|
1203
|
+
*
|
|
1204
|
+
* - `standalone`: 已安装的 PWA(通过 Add to Home Screen)
|
|
1205
|
+
* - `twa`: Trusted Web Activity(Android 原生壳)
|
|
1206
|
+
* - `browser`: 普通浏览器标签页
|
|
1207
|
+
*/
|
|
1208
|
+
function getPWADisplayMode() {
|
|
1209
|
+
if (typeof window === "undefined") return "browser";
|
|
1210
|
+
if (document.referrer.startsWith("android-app://")) return "twa";
|
|
1211
|
+
if (window.matchMedia("(display-mode: standalone)").matches || "standalone" in window.navigator && window.navigator.standalone === true) return "standalone";
|
|
1212
|
+
return "browser";
|
|
1213
|
+
}
|
|
1214
|
+
//#endregion
|
|
837
1215
|
//#region ../core/src/utils/url.ts
|
|
838
1216
|
/**
|
|
839
1217
|
* URL 工具函数
|
|
@@ -953,6 +1331,109 @@ function deny(status = 403, message = "Forbidden") {
|
|
|
953
1331
|
};
|
|
954
1332
|
}
|
|
955
1333
|
//#endregion
|
|
1334
|
+
//#region ../core/src/metrics/composite-recorder.ts
|
|
1335
|
+
var CompositeEventRecorder = class {
|
|
1336
|
+
recorders;
|
|
1337
|
+
constructor(recorders) {
|
|
1338
|
+
this.recorders = recorders;
|
|
1339
|
+
}
|
|
1340
|
+
record(type, fields) {
|
|
1341
|
+
for (const recorder of this.recorders) recorder.record(type, fields);
|
|
1342
|
+
}
|
|
1343
|
+
async flush() {
|
|
1344
|
+
await Promise.all(this.recorders.map((r) => r.flush?.()));
|
|
1345
|
+
}
|
|
1346
|
+
destroy() {
|
|
1347
|
+
for (const recorder of this.recorders) recorder.destroy?.();
|
|
1348
|
+
}
|
|
1349
|
+
};
|
|
1350
|
+
//#endregion
|
|
1351
|
+
//#region ../core/src/metrics/impression-observer.ts
|
|
1352
|
+
var IntersectionImpressionObserver = class {
|
|
1353
|
+
observer;
|
|
1354
|
+
tracked = /* @__PURE__ */ new Map();
|
|
1355
|
+
captured = [];
|
|
1356
|
+
minDuration;
|
|
1357
|
+
constructor(options = {}) {
|
|
1358
|
+
this.minDuration = options.minVisibleDuration ?? 1e3;
|
|
1359
|
+
this.observer = new IntersectionObserver((entries) => {
|
|
1360
|
+
const now = Date.now();
|
|
1361
|
+
for (const entry of entries) {
|
|
1362
|
+
const tracked = this.tracked.get(entry.target);
|
|
1363
|
+
if (!tracked) continue;
|
|
1364
|
+
if (entry.isIntersecting) {
|
|
1365
|
+
if (tracked.visibleSince === null) tracked.visibleSince = now;
|
|
1366
|
+
} else if (tracked.visibleSince !== null) {
|
|
1367
|
+
if (now - tracked.visibleSince >= this.minDuration) this.captured.push({
|
|
1368
|
+
id: tracked.id,
|
|
1369
|
+
timestamp: tracked.visibleSince,
|
|
1370
|
+
metadata: tracked.metadata
|
|
1371
|
+
});
|
|
1372
|
+
tracked.visibleSince = null;
|
|
1373
|
+
}
|
|
1374
|
+
}
|
|
1375
|
+
}, { threshold: options.threshold ?? .5 });
|
|
1376
|
+
}
|
|
1377
|
+
observe(element, id, metadata) {
|
|
1378
|
+
this.tracked.set(element, {
|
|
1379
|
+
id,
|
|
1380
|
+
metadata,
|
|
1381
|
+
visibleSince: null
|
|
1382
|
+
});
|
|
1383
|
+
this.observer.observe(element);
|
|
1384
|
+
}
|
|
1385
|
+
unobserve(element) {
|
|
1386
|
+
this.observer.unobserve(element);
|
|
1387
|
+
this.tracked.delete(element);
|
|
1388
|
+
}
|
|
1389
|
+
consume() {
|
|
1390
|
+
const now = Date.now();
|
|
1391
|
+
for (const [, tracked] of this.tracked) if (tracked.visibleSince !== null) {
|
|
1392
|
+
if (now - tracked.visibleSince >= this.minDuration) {
|
|
1393
|
+
this.captured.push({
|
|
1394
|
+
id: tracked.id,
|
|
1395
|
+
timestamp: tracked.visibleSince,
|
|
1396
|
+
metadata: tracked.metadata
|
|
1397
|
+
});
|
|
1398
|
+
tracked.visibleSince = now;
|
|
1399
|
+
}
|
|
1400
|
+
}
|
|
1401
|
+
return this.captured.splice(0);
|
|
1402
|
+
}
|
|
1403
|
+
destroy() {
|
|
1404
|
+
this.observer.disconnect();
|
|
1405
|
+
this.tracked.clear();
|
|
1406
|
+
this.captured.length = 0;
|
|
1407
|
+
}
|
|
1408
|
+
};
|
|
1409
|
+
//#endregion
|
|
1410
|
+
//#region ../core/src/metrics/void-recorder.ts
|
|
1411
|
+
var VoidEventRecorder = class {
|
|
1412
|
+
record() {}
|
|
1413
|
+
async flush() {}
|
|
1414
|
+
destroy() {}
|
|
1415
|
+
};
|
|
1416
|
+
//#endregion
|
|
1417
|
+
//#region ../core/src/metrics/with-fields-recorder.ts
|
|
1418
|
+
var WithFieldsRecorder = class {
|
|
1419
|
+
constructor(inner, providers) {
|
|
1420
|
+
this.inner = inner;
|
|
1421
|
+
this.providers = providers;
|
|
1422
|
+
}
|
|
1423
|
+
record(type, fields) {
|
|
1424
|
+
let merged = {};
|
|
1425
|
+
for (const provider of this.providers) Object.assign(merged, provider.getFields());
|
|
1426
|
+
if (fields) Object.assign(merged, fields);
|
|
1427
|
+
this.inner.record(type, merged);
|
|
1428
|
+
}
|
|
1429
|
+
async flush() {
|
|
1430
|
+
return this.inner.flush?.();
|
|
1431
|
+
}
|
|
1432
|
+
destroy() {
|
|
1433
|
+
this.inner.destroy?.();
|
|
1434
|
+
}
|
|
1435
|
+
};
|
|
1436
|
+
//#endregion
|
|
956
1437
|
//#region ../browser/src/action-handlers/external-url-action.ts
|
|
957
1438
|
function registerExternalUrlHandler(deps) {
|
|
958
1439
|
const { framework, log } = deps;
|
|
@@ -1395,11 +1876,20 @@ function createPrefetchedIntentsFromDom() {
|
|
|
1395
1876
|
* 自动执行 hydration 全流程。
|
|
1396
1877
|
*/
|
|
1397
1878
|
async function startBrowserApp(config) {
|
|
1398
|
-
const { bootstrap, mountId = "app", mount, callbacks } = config;
|
|
1879
|
+
const { bootstrap, mountId = "app", mount, callbacks, onBeforeStart, onAfterStart, frameworkConfig } = config;
|
|
1399
1880
|
const prefetchedIntents = createPrefetchedIntentsFromDom();
|
|
1400
|
-
const framework = Framework.create({
|
|
1881
|
+
const framework = Framework.create({
|
|
1882
|
+
...frameworkConfig,
|
|
1883
|
+
prefetchedIntents
|
|
1884
|
+
});
|
|
1401
1885
|
bootstrap(framework);
|
|
1402
1886
|
const log = framework.container.resolve(DEP_KEYS.LOGGER_FACTORY).loggerFor("browser");
|
|
1887
|
+
const locale = framework.getLocale();
|
|
1888
|
+
if (locale) {
|
|
1889
|
+
setHtmlLocaleAttributes(locale);
|
|
1890
|
+
log.debug("[startBrowserApp] Applied locale attributes:", locale);
|
|
1891
|
+
}
|
|
1892
|
+
await onBeforeStart?.(framework);
|
|
1403
1893
|
const initialAction = framework.routeUrl(window.location.pathname + window.location.search);
|
|
1404
1894
|
const target = document.getElementById(mountId);
|
|
1405
1895
|
if (!target) throw new Error(`[startBrowserApp] Mount target not found: #${mountId}. Ensure your HTML has <div id="${mountId}"></div>.`);
|
|
@@ -1416,6 +1906,7 @@ async function startBrowserApp(config) {
|
|
|
1416
1906
|
page: Promise.reject(/* @__PURE__ */ new Error("404")),
|
|
1417
1907
|
isFirstPage: true
|
|
1418
1908
|
});
|
|
1909
|
+
await onAfterStart?.(framework);
|
|
1419
1910
|
}
|
|
1420
1911
|
//#endregion
|
|
1421
1912
|
//#region ../ssr/src/render.ts
|
|
@@ -1428,11 +1919,16 @@ async function startBrowserApp(config) {
|
|
|
1428
1919
|
* 4. 调用应用层提供的渲染函数
|
|
1429
1920
|
*/
|
|
1430
1921
|
async function ssrRender(options) {
|
|
1431
|
-
const { url, frameworkConfig, bootstrap, getErrorPage, renderApp, ssrContext } = options;
|
|
1432
|
-
const
|
|
1922
|
+
const { url, frameworkConfig, bootstrap, getErrorPage, renderApp, ssrContext, resolveLocale } = options;
|
|
1923
|
+
const resolvedLocale = resolveLocale?.(url, ssrContext?.request);
|
|
1924
|
+
const effectiveConfig = resolvedLocale ? {
|
|
1433
1925
|
...frameworkConfig,
|
|
1434
|
-
|
|
1926
|
+
locale: resolvedLocale.lang
|
|
1435
1927
|
} : frameworkConfig;
|
|
1928
|
+
const mergedConfig = ssrContext?.fetch ? {
|
|
1929
|
+
...effectiveConfig,
|
|
1930
|
+
fetch: ssrContext.fetch
|
|
1931
|
+
} : effectiveConfig;
|
|
1436
1932
|
const framework = Framework.create(mergedConfig);
|
|
1437
1933
|
bootstrap(framework);
|
|
1438
1934
|
try {
|
|
@@ -1481,13 +1977,15 @@ async function ssrRender(options) {
|
|
|
1481
1977
|
}
|
|
1482
1978
|
} else page = getErrorPage(404, "Page not found");
|
|
1483
1979
|
const result = await renderApp(page, framework);
|
|
1980
|
+
const locale = resolvedLocale ?? framework.getLocale();
|
|
1484
1981
|
return {
|
|
1485
1982
|
html: result.html,
|
|
1486
1983
|
head: result.head,
|
|
1487
1984
|
css: result.css,
|
|
1488
1985
|
serverData,
|
|
1489
1986
|
renderMode: match?.renderMode,
|
|
1490
|
-
slots: result.slots
|
|
1987
|
+
slots: result.slots,
|
|
1988
|
+
locale
|
|
1491
1989
|
};
|
|
1492
1990
|
} finally {
|
|
1493
1991
|
framework.dispose();
|
|
@@ -1540,14 +2038,15 @@ async function handleMiddlewareResult(result, getErrorPage, renderApp, framework
|
|
|
1540
2038
|
* @returns `render(url, ssrContext?)` — 供 @finesoft/server SSRModule 使用
|
|
1541
2039
|
*/
|
|
1542
2040
|
function createSSRRender(config) {
|
|
1543
|
-
const { bootstrap, getErrorPage, renderApp, frameworkConfig } = config;
|
|
2041
|
+
const { bootstrap, getErrorPage, renderApp, frameworkConfig, resolveLocale } = config;
|
|
1544
2042
|
return (url, ssrContext) => ssrRender({
|
|
1545
2043
|
url,
|
|
1546
2044
|
frameworkConfig: frameworkConfig ?? {},
|
|
1547
2045
|
bootstrap,
|
|
1548
2046
|
getErrorPage,
|
|
1549
|
-
renderApp: (page) => renderApp(page),
|
|
1550
|
-
ssrContext
|
|
2047
|
+
renderApp: (page, framework) => renderApp(page, framework),
|
|
2048
|
+
ssrContext,
|
|
2049
|
+
resolveLocale
|
|
1551
2050
|
});
|
|
1552
2051
|
}
|
|
1553
2052
|
//#endregion
|
|
@@ -1564,21 +2063,33 @@ const SSR_PLACEHOLDERS = {
|
|
|
1564
2063
|
/** 匹配所有 <!--ssr-xxx--> 占位符(含内置与自定义) */
|
|
1565
2064
|
const PLACEHOLDER_REGEX = /<!--ssr-([a-z][a-z0-9-]*)-->/g;
|
|
1566
2065
|
function injectSSRContent(options) {
|
|
1567
|
-
const { template, head, css, html, serializedData, slots } = options;
|
|
2066
|
+
const { template, head, css, html, serializedData, slots, locale } = options;
|
|
1568
2067
|
const replacements = {
|
|
1569
2068
|
head: `${head}\n${css ? `<style>${css}</style>` : ""}`,
|
|
1570
2069
|
body: html,
|
|
1571
2070
|
data: `<script id="serialized-server-data" type="application/json">${serializedData}<\/script>`,
|
|
1572
2071
|
...slots
|
|
1573
2072
|
};
|
|
1574
|
-
|
|
2073
|
+
let result = template.replace(PLACEHOLDER_REGEX, (_, name) => replacements[name] ?? "");
|
|
2074
|
+
if (locale) result = applyLocaleToHtml(result, locale);
|
|
2075
|
+
return result;
|
|
1575
2076
|
}
|
|
1576
2077
|
/**
|
|
1577
2078
|
* CSR 空壳注入 — 清空所有占位符
|
|
1578
2079
|
* 用于 renderMode === "csr" 的路由
|
|
2080
|
+
*
|
|
2081
|
+
* @param locale - 可选的 locale 属性,注入到 `<html lang="" dir="">`
|
|
1579
2082
|
*/
|
|
1580
|
-
function injectCSRShell(template) {
|
|
1581
|
-
|
|
2083
|
+
function injectCSRShell(template, locale) {
|
|
2084
|
+
let result = template.replace(PLACEHOLDER_REGEX, () => "");
|
|
2085
|
+
if (locale) result = applyLocaleToHtml(result, locale);
|
|
2086
|
+
return result;
|
|
2087
|
+
}
|
|
2088
|
+
/** 将 lang/dir 注入到 <html> 标签 */
|
|
2089
|
+
function applyLocaleToHtml(html, locale) {
|
|
2090
|
+
return html.replace(/(<html)([^>]*)(>)/i, (_match, open, attrs, close) => {
|
|
2091
|
+
return `${open}${attrs.replace(/\s+lang="[^"]*"/gi, "").replace(/\s+dir="[^"]*"/gi, "")} lang="${locale.lang}" dir="${locale.dir}"${close}`;
|
|
2092
|
+
});
|
|
1582
2093
|
}
|
|
1583
2094
|
//#endregion
|
|
1584
2095
|
//#region ../ssr/src/server-data.ts
|
|
@@ -1823,22 +2334,40 @@ ${setupImport}
|
|
|
1823
2334
|
|
|
1824
2335
|
const TEMPLATE = ${JSON.stringify(ctx.templateHtml)};
|
|
1825
2336
|
const RENDER_MODES = ${renderModes};
|
|
2337
|
+
const DEFAULT_LOCALE = ${JSON.stringify(ctx.defaultLocale ?? null)};
|
|
1826
2338
|
${cacheImpl}
|
|
1827
2339
|
|
|
1828
|
-
function injectSSR(t, head, css, html, data) {
|
|
1829
|
-
|
|
2340
|
+
function injectSSR(t, head, css, html, data, locale) {
|
|
2341
|
+
const injected = t
|
|
1830
2342
|
.replace(/<!--ssr-([a-z][a-z0-9-]*)-->/g, (_, name) => {
|
|
1831
2343
|
const replacements = {
|
|
1832
|
-
head: head + "
|
|
2344
|
+
head: head + "\n<style>" + css + "</style>",
|
|
1833
2345
|
body: html,
|
|
1834
2346
|
data: '<script id="serialized-server-data" type="application/json">' + data + "<\/script>",
|
|
1835
2347
|
};
|
|
1836
2348
|
return replacements[name] ?? "";
|
|
1837
2349
|
});
|
|
2350
|
+
return applyLocaleToHtml(injected, locale);
|
|
2351
|
+
}
|
|
2352
|
+
|
|
2353
|
+
function applyLocaleToHtml(html, locale) {
|
|
2354
|
+
if (!locale) return html;
|
|
2355
|
+
return html.replace(/<html([^>]*)>/, (_, attrs) => {
|
|
2356
|
+
let a = attrs.replace(/s*lang="[^"]*"/g, "").replace(/s*dir="[^"]*"/g, "");
|
|
2357
|
+
return "<html" + a + ' lang="' + locale.lang + '" dir="' + locale.dir + '">';
|
|
2358
|
+
});
|
|
2359
|
+
}
|
|
2360
|
+
|
|
2361
|
+
function getLocaleAttrs(lang) {
|
|
2362
|
+
if (!lang) return undefined;
|
|
2363
|
+
const RTL = new Set(["ar","arc","dv","fa","ha","he","khw","ks","ku","ps","ur","yi"]);
|
|
2364
|
+
const base = lang.split(/[-_]/)[0].toLowerCase();
|
|
2365
|
+
return { lang: lang, dir: RTL.has(base) ? "rtl" : "ltr" };
|
|
1838
2366
|
}
|
|
1839
2367
|
|
|
1840
|
-
function injectCSRShell(t) {
|
|
1841
|
-
|
|
2368
|
+
function injectCSRShell(t, locale) {
|
|
2369
|
+
const stripped = t.replace(/<!--ssr-([a-z][a-z0-9-]*)-->/g, () => "");
|
|
2370
|
+
return applyLocaleToHtml(stripped, locale);
|
|
1842
2371
|
}
|
|
1843
2372
|
|
|
1844
2373
|
function matchRenderMode(url) {
|
|
@@ -1887,22 +2416,23 @@ app.get("*", async (c) => {
|
|
|
1887
2416
|
// Vite 配置级别覆盖: CSR 直接返回空壳
|
|
1888
2417
|
const overrideMode = matchRenderMode(url);
|
|
1889
2418
|
if (overrideMode === "csr") {
|
|
1890
|
-
return c.html(injectCSRShell(TEMPLATE));
|
|
2419
|
+
return c.html(injectCSRShell(TEMPLATE, getLocaleAttrs(DEFAULT_LOCALE)));
|
|
1891
2420
|
}
|
|
1892
2421
|
|
|
1893
2422
|
// ISR 缓存命中
|
|
1894
2423
|
const cached = await platformCacheGet(url);
|
|
1895
2424
|
if (cached) return c.html(cached);
|
|
1896
2425
|
|
|
1897
|
-
const { html: appHtml, head, css, serverData, renderMode } = await render(url, { fetch: _createInternalFetch(_ssrDepth + 1) });
|
|
2426
|
+
const { html: appHtml, head, css, serverData, renderMode, locale } = await render(url, { fetch: _createInternalFetch(_ssrDepth + 1) });
|
|
2427
|
+
const localeAttrs = getLocaleAttrs(locale || DEFAULT_LOCALE);
|
|
1898
2428
|
|
|
1899
2429
|
// 路由级 CSR
|
|
1900
2430
|
if (renderMode === "csr") {
|
|
1901
|
-
return c.html(injectCSRShell(TEMPLATE));
|
|
2431
|
+
return c.html(injectCSRShell(TEMPLATE, localeAttrs));
|
|
1902
2432
|
}
|
|
1903
2433
|
|
|
1904
2434
|
const serializedData = serializeServerData(serverData);
|
|
1905
|
-
const finalHtml = injectSSR(TEMPLATE, head, css, appHtml, serializedData);
|
|
2435
|
+
const finalHtml = injectSSR(TEMPLATE, head, css, appHtml, serializedData, localeAttrs);
|
|
1906
2436
|
|
|
1907
2437
|
// Prerender ISR 缓存(包括 Vite 配置覆盖和路由级)
|
|
1908
2438
|
if (renderMode === "prerender" || overrideMode === "prerender") {
|
|
@@ -1978,20 +2508,34 @@ async function prerenderRoutes(ctx) {
|
|
|
1978
2508
|
if (ctx.renderModes) {
|
|
1979
2509
|
for (const [pattern, mode] of Object.entries(ctx.renderModes)) if (mode === "prerender" && !pattern.includes("*") && !pattern.includes(":")) prerenderPaths.add(pattern);
|
|
1980
2510
|
}
|
|
2511
|
+
if (ctx.locales?.length) {
|
|
2512
|
+
const basePaths = [...prerenderPaths];
|
|
2513
|
+
for (const locale of ctx.locales) for (const basePath of basePaths) {
|
|
2514
|
+
const localePath = basePath === "/" ? `/${locale}` : `/${locale}${basePath}`;
|
|
2515
|
+
prerenderPaths.add(localePath);
|
|
2516
|
+
}
|
|
2517
|
+
}
|
|
1981
2518
|
if (prerenderPaths.size === 0) return [];
|
|
1982
2519
|
const ssrPath = pathToFileURL(path.resolve(root, "dist/server/ssr.js")).href;
|
|
1983
2520
|
const ssrModule = await dynamicImport(ssrPath);
|
|
1984
2521
|
const results = [];
|
|
1985
2522
|
for (const url of prerenderPaths) try {
|
|
1986
|
-
const { html: appHtml, head, css, serverData } = await ssrModule.render(url);
|
|
2523
|
+
const { html: appHtml, head, css, serverData, locale } = await ssrModule.render(url);
|
|
1987
2524
|
const serializedData = ssrModule.serializeServerData(serverData);
|
|
1988
|
-
|
|
2525
|
+
let finalHtml = ctx.templateHtml.replace(/<!--ssr-([a-z][a-z0-9-]*)-->/g, (_match, name) => {
|
|
1989
2526
|
return {
|
|
1990
2527
|
head: head + "\n<style>" + css + "</style>",
|
|
1991
2528
|
body: appHtml,
|
|
1992
2529
|
data: "<script id=\"serialized-server-data\" type=\"application/json\">" + serializedData + "<\/script>"
|
|
1993
2530
|
}[name] ?? "";
|
|
1994
2531
|
});
|
|
2532
|
+
if (locale) {
|
|
2533
|
+
const { getLocaleAttributes } = await dynamicImport("@finesoft/core");
|
|
2534
|
+
const attrs = getLocaleAttributes(locale);
|
|
2535
|
+
finalHtml = finalHtml.replace(/<html([^>]*)>/, (_m, a) => {
|
|
2536
|
+
return `<html${a.replace(/\s*lang="[^"]*"/g, "").replace(/\s*dir="[^"]*"/g, "")} lang="${attrs.lang}" dir="${attrs.dir}">`;
|
|
2537
|
+
});
|
|
2538
|
+
}
|
|
1995
2539
|
results.push({
|
|
1996
2540
|
url,
|
|
1997
2541
|
html: finalHtml
|
|
@@ -2443,13 +2987,13 @@ function autoAdapter() {
|
|
|
2443
2987
|
return {
|
|
2444
2988
|
name: "auto",
|
|
2445
2989
|
async build(ctx) {
|
|
2446
|
-
const detected = detectPlatform();
|
|
2990
|
+
const detected = detectPlatform$1();
|
|
2447
2991
|
console.log(` [auto] Detected platform: ${detected}\n`);
|
|
2448
2992
|
return resolveAdapter(detected).build(ctx);
|
|
2449
2993
|
}
|
|
2450
2994
|
};
|
|
2451
2995
|
}
|
|
2452
|
-
function detectPlatform() {
|
|
2996
|
+
function detectPlatform$1() {
|
|
2453
2997
|
if (process.env.VERCEL) return "vercel";
|
|
2454
2998
|
if (process.env.CF_PAGES) return "cloudflare";
|
|
2455
2999
|
if (process.env.NETLIFY) return "netlify";
|
|
@@ -2508,7 +3052,7 @@ function matchRenderModeOverride(url, renderModes) {
|
|
|
2508
3052
|
return null;
|
|
2509
3053
|
}
|
|
2510
3054
|
function createSSRApp(options) {
|
|
2511
|
-
const { root, vite, isProduction, ssrEntryPath = "/src/ssr.ts", ssrProductionModule, parentFetch, renderModes } = options;
|
|
3055
|
+
const { root, vite, isProduction, ssrEntryPath = "/src/ssr.ts", ssrProductionModule, parentFetch, renderModes, defaultLocale } = options;
|
|
2512
3056
|
const app = new Hono();
|
|
2513
3057
|
/** ISR 内存缓存(prerender 路由首次请求后缓存,LRU 驱逐) */
|
|
2514
3058
|
const ISR_CACHE_MAX = 1e3;
|
|
@@ -2556,22 +3100,23 @@ function createSSRApp(options) {
|
|
|
2556
3100
|
if (typeof ssrMod.render !== "function" || typeof ssrMod.serializeServerData !== "function") throw new Error("[SSR] Module missing required exports: render, serializeServerData");
|
|
2557
3101
|
const { render, serializeServerData } = ssrMod;
|
|
2558
3102
|
const overrideMode = matchRenderModeOverride(url, renderModes);
|
|
2559
|
-
if (overrideMode === "csr") return c.html(injectCSRShell(template));
|
|
3103
|
+
if (overrideMode === "csr") return c.html(injectCSRShell(template, defaultLocale ? getLocaleAttributes(defaultLocale) : void 0));
|
|
2560
3104
|
const cached = isrCache.get(url);
|
|
2561
3105
|
if (cached) return c.html(cached);
|
|
2562
3106
|
const requestFetch = parentFetch ? createInternalFetch(parentFetch, ssrDepth + 1) : void 0;
|
|
2563
3107
|
const ssrContext = { request: c.req.raw };
|
|
2564
3108
|
if (requestFetch) ssrContext.fetch = requestFetch;
|
|
2565
|
-
const { html: appHtml, head, css, serverData, renderMode, redirect: middlewareRedirect, slots } = await render(url, ssrContext);
|
|
3109
|
+
const { html: appHtml, head, css, serverData, renderMode, redirect: middlewareRedirect, slots, locale } = await render(url, ssrContext);
|
|
2566
3110
|
if (middlewareRedirect) return c.redirect(middlewareRedirect.url, middlewareRedirect.status);
|
|
2567
|
-
if (renderMode === "csr") return c.html(injectCSRShell(template));
|
|
3111
|
+
if (renderMode === "csr") return c.html(injectCSRShell(template, locale));
|
|
2568
3112
|
const finalHtml = injectSSRContent({
|
|
2569
3113
|
template,
|
|
2570
3114
|
head,
|
|
2571
3115
|
css,
|
|
2572
3116
|
html: appHtml,
|
|
2573
3117
|
serializedData: serializeServerData(serverData),
|
|
2574
|
-
slots
|
|
3118
|
+
slots,
|
|
3119
|
+
locale
|
|
2575
3120
|
});
|
|
2576
3121
|
if (renderMode === "prerender" || overrideMode === "prerender") isrSet(url, finalHtml);
|
|
2577
3122
|
return c.html(finalHtml);
|
|
@@ -2883,7 +3428,8 @@ function finesoftFrontViteConfig(options = {}) {
|
|
|
2883
3428
|
isProduction: false,
|
|
2884
3429
|
ssrEntryPath: "/" + ssrEntry,
|
|
2885
3430
|
parentFetch: app.fetch.bind(app),
|
|
2886
|
-
renderModes: options.renderModes
|
|
3431
|
+
renderModes: options.renderModes,
|
|
3432
|
+
defaultLocale: options.defaultLocale
|
|
2887
3433
|
});
|
|
2888
3434
|
app.route("/", ssrApp);
|
|
2889
3435
|
const listener = getRequestListener(app.fetch);
|
|
@@ -2927,17 +3473,18 @@ function finesoftFrontViteConfig(options = {}) {
|
|
|
2927
3473
|
const url = c.req.path + (c.req.url.includes("?") ? "?" + c.req.url.split("?")[1] : "");
|
|
2928
3474
|
try {
|
|
2929
3475
|
const overrideMode = matchRenderModeConfig(url, options.renderModes);
|
|
2930
|
-
if (overrideMode === "csr") return c.html(injectCSRShell(template));
|
|
3476
|
+
if (overrideMode === "csr") return c.html(injectCSRShell(template, options.defaultLocale ? getLocaleAttributes(options.defaultLocale) : void 0));
|
|
2931
3477
|
const cached = isrCache.get(url);
|
|
2932
3478
|
if (cached) return c.html(cached);
|
|
2933
|
-
const { html: appHtml, head, css, serverData, renderMode } = await ssrModule.render(url, { fetch: createInternalFetch(app.fetch.bind(app), ssrDepth + 1) });
|
|
2934
|
-
if (renderMode === "csr") return c.html(injectCSRShell(template));
|
|
3479
|
+
const { html: appHtml, head, css, serverData, renderMode, locale } = await ssrModule.render(url, { fetch: createInternalFetch(app.fetch.bind(app), ssrDepth + 1) });
|
|
3480
|
+
if (renderMode === "csr") return c.html(injectCSRShell(template, locale));
|
|
2935
3481
|
const finalHtml = injectSSRContent({
|
|
2936
3482
|
template,
|
|
2937
3483
|
head,
|
|
2938
3484
|
css,
|
|
2939
3485
|
html: appHtml,
|
|
2940
|
-
serializedData: ssrModule.serializeServerData(serverData)
|
|
3486
|
+
serializedData: ssrModule.serializeServerData(serverData),
|
|
3487
|
+
locale
|
|
2941
3488
|
});
|
|
2942
3489
|
if (renderMode === "prerender" || overrideMode === "prerender") isrSet(url, finalHtml);
|
|
2943
3490
|
return c.html(finalHtml);
|
|
@@ -2995,6 +3542,8 @@ function finesoftFrontViteConfig(options = {}) {
|
|
|
2995
3542
|
templateHtml,
|
|
2996
3543
|
renderModes: options.renderModes,
|
|
2997
3544
|
proxies: options.proxies,
|
|
3545
|
+
locales: options.locales,
|
|
3546
|
+
defaultLocale: options.defaultLocale,
|
|
2998
3547
|
resolvedResolve,
|
|
2999
3548
|
resolvedCss,
|
|
3000
3549
|
vite,
|
|
@@ -3020,6 +3569,6 @@ function finesoftFrontViteConfig(options = {}) {
|
|
|
3020
3569
|
};
|
|
3021
3570
|
}
|
|
3022
3571
|
//#endregion
|
|
3023
|
-
export { ACTION_KINDS, ActionDispatcher, BaseController, BaseLogger, CompositeLogger, CompositeLoggerFactory, ConsoleLogger, ConsoleLoggerFactory, Container, DEP_KEYS, Framework, History, HttpClient, HttpError, IntentDispatcher, LruMap, PrefetchedIntents, Router, SSR_PLACEHOLDERS, autoAdapter, buildUrl, cloudflareAdapter, createBrowserContext, createPrefetchedIntentsFromDom, createSSRApp, createSSRRender, createServer, createServerContext, defineRoutes, deny, deserializeServerData, detectRuntime, finesoftFrontViteConfig, generateProxyCode, generateUuid, getBaseUrl, injectCSRShell, injectSSRContent, isCompoundAction, isExternalUrlAction, isFlowAction, isNone, isSome, makeDependencies, makeExternalUrlAction, makeFlowAction, mapEach, netlifyAdapter, next, nodeAdapter, parseAcceptLanguage, pipe, pipeAsync, redirect, registerActionHandlers, registerExternalUrlHandler, registerFlowActionHandler, registerProxyRoutes, removeHost, removeQueryParams, removeScheme, resetFilterCache, resolveAdapter, resolveRoot, rewrite, runAfterLoadGuards, runBeforeLoadGuards, serializeServerData, shouldLog, ssrRender, stableStringify, startBrowserApp, startServer, staticAdapter, tryScroll, vercelAdapter };
|
|
3572
|
+
export { ACTION_KINDS, ActionDispatcher, BaseController, BaseLogger, CompositeEventRecorder, CompositeLogger, CompositeLoggerFactory, ConsoleEventRecorder, ConsoleLogger, ConsoleLoggerFactory, Container, DEP_KEYS, Framework, History, HttpClient, HttpError, IntentDispatcher, IntersectionImpressionObserver, LruMap, PrefetchedIntents, ReportingLogger, ReportingLoggerFactory, Router, SSR_PLACEHOLDERS, SimpleTranslator, VoidEventRecorder, WithFieldsRecorder, autoAdapter, buildUrl, cloudflareAdapter, createBrowserContext, createPrefetchedIntentsFromDom, createSSRApp, createSSRRender, createServer, createServerContext, defineRoutes, deny, deserializeServerData, detectPlatform, detectRuntime, englishPlural, finesoftFrontViteConfig, generateProxyCode, generateUuid, getBaseUrl, getLocaleAttributes, getPWADisplayMode, getTextDirection, injectCSRShell, injectSSRContent, interpolate, isCompoundAction, isExternalUrlAction, isFlowAction, isNone, isRtl, isSome, makeDependencies, makeExternalUrlAction, makeFlowAction, makeLocaleInfo, mapEach, netlifyAdapter, next, nodeAdapter, parseAcceptLanguage, pipe, pipeAsync, redirect, registerActionHandlers, registerExternalUrlHandler, registerFlowActionHandler, registerProxyRoutes, removeHost, removeQueryParams, removeScheme, resetFilterCache, resolveAdapter, resolveLocaleFromUrl, resolvePluralKey, resolveRoot, rewrite, runAfterLoadGuards, runBeforeLoadGuards, serializeServerData, setHtmlLocaleAttributes, shouldLog, ssrRender, stableStringify, startBrowserApp, startServer, staticAdapter, tryScroll, vercelAdapter };
|
|
3024
3573
|
|
|
3025
3574
|
//# sourceMappingURL=index.mjs.map
|