@lingfan-engine/editor 0.1.0 → 0.1.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.
@@ -1,112 +1,112 @@
1
- /**
2
- * i18n 工具链 · overlay 骨架生成。
3
- *
4
- * 给定语言与分组策略,产出**文件内容**(原文 → 既有译文或占位);落盘归调用方
5
- * (编辑器保存 / CLI),本模块保持纯函数(不碰平台 API)。合并语义与运行期
6
- * `mergeOverlayFiles` 对齐(main.json 兜底 + 其余按供给序覆盖)⇒ 骨架喂回合并后
7
- * 键集合与 `extractStoryKeys` 输出**双向一致**(互锁测试锁定)。
8
- *
9
- * 布局覆盖 overlay 形态:「平铺」= 条目即键值平面(三形态共用);
10
- * 单大文件 `Lang/{lang}.json`、目录 `Lang/{lang}/main.json`、按故事分类
11
- * `Lang/{lang}/{章节子目录}/{故事}.json`——**per-story 镜像 `Stories/` 的递归目录**
12
- * (章节子目录更好区分归类;`.json` / `.json.enc`
13
- * 双识别归运行期供给,骨架产明文)。**增量模式**:命中的既有译文原样保留(含空串
14
- * 译文——运行期「命中即用」语义),绝不覆盖。
15
- */
16
-
17
- import type { EditorTexts } from "../contracts/index.ts";
18
- import { resolveEditorTexts } from "../contracts/index.ts";
19
- import { isSafeFileNameSegment } from "@lingfan-engine/core";
20
-
21
- /** 占位策略:original = 原文占位(未翻译时画面可读,翻译工作表语义);empty = 空串(运行期命中即隐句) */
22
- export type SkeletonPlaceholder = "original" | "empty";
23
-
24
- /** 骨架布局:single = `Lang/{lang}.json`;main = `Lang/{lang}/main.json`;per-story = 镜像 Stories/ 递归目录 */
25
- export type SkeletonLayout = "single" | "main" | "per-story";
26
-
27
- export interface OverlaySkeletonFile {
28
- /** 相对资源根的 overlay 路径 */
29
- path: string;
30
- /** 键值平面(键 = 原文,稳定排序;值 = 既有译文(增量保留)或占位) */
31
- entries: Record<string, string>;
32
- /** 序列化好的文件内容(2 空格缩进 + 结尾换行;落盘即用,同输入逐字节确定) */
33
- content: string;
34
- }
35
-
36
- /** overlay 根目录名(与运行期 Lang/ 供给面同源;单一出处供路径模板与测试共用) */
37
- export const LANG_DIR = "Lang";
38
-
39
- export interface SkeletonOptions {
40
- lang: string;
41
- layout: SkeletonLayout;
42
- /**
43
- * 故事 → 该故事的原文键(`extractStoryKeys` 输出;键序不要求,产出内会排序)。
44
- * **键 = 故事文件相对 `Stories/` 的路径(不含扩展名)**,如 `chapter1/tavern`——
45
- * per-story 布局按它镜像 `Stories/` 递归目录(平铺工程的键 = 故事 id,语义一致)。
46
- */
47
- storyKeys: ReadonlyMap<string, readonly string[]>;
48
- /** 增量模式:该语言已存在的 overlay(路径 → 条目);命中的键保留原值 */
49
- existing?: ReadonlyMap<string, Record<string, string>>;
50
- placeholder?: SkeletonPlaceholder;
51
- /** 集中文案表注入(缺省 defaultEditorTexts) */
52
- texts?: Partial<EditorTexts>;
53
- }
54
-
55
- /**
56
- * 产出骨架文件列表。fail-closed:`lang` / 故事 id 不合法即抛——骨架路径要能落进
57
- * `Lang/` 而不越界(与故事文件名同一安全口径 `isSafeFileNameSegment`)。
58
- */
59
- export function planOverlaySkeleton(
60
- options: SkeletonOptions,
61
- ): OverlaySkeletonFile[] {
62
- const {
63
- lang,
64
- layout,
65
- storyKeys,
66
- existing,
67
- placeholder = "original",
68
- } = options;
69
- const t: EditorTexts = resolveEditorTexts(options.texts);
70
- if (typeof lang !== "string" || !isSafeFileNameSegment(lang)) {
71
- throw new Error(t.invalidLangCode(String(lang)));
72
- }
73
- const build = (
74
- path: string,
75
- keys: readonly string[],
76
- ): OverlaySkeletonFile => {
77
- const prior = existing?.get(path) ?? {};
78
- const entries: Record<string, string> = {};
79
- for (const key of [...keys].sort()) {
80
- // 既有条目原样保留(含空串译文);`undefined` = 未命中 → 占位
81
- const kept = prior[key];
82
- entries[key] =
83
- typeof kept === "string" ? kept : placeholder === "empty" ? "" : key;
84
- }
85
- return { path, entries, content: `${JSON.stringify(entries, null, 2)}\n` };
86
- };
87
- if (layout === "single" || layout === "main") {
88
- const union = new Set<string>();
89
- for (const keys of storyKeys.values()) {
90
- for (const key of keys) union.add(key);
91
- }
92
- const path =
93
- layout === "single"
94
- ? `${LANG_DIR}/${lang}.json`
95
- : `${LANG_DIR}/${lang}/main.json`;
96
- return [build(path, [...union])];
97
- }
98
- // per-story:镜像 `Stories/` 的递归目录——
99
- // 键 = 故事相对 `Stories/` 的路径(不含扩展名);**段级**安全校验防路径越界。
100
- const files: OverlaySkeletonFile[] = [];
101
- for (const [storyPath, keys] of storyKeys) {
102
- const segments = storyPath.split("/");
103
- if (
104
- storyPath === "" ||
105
- segments.some((segment) => !isSafeFileNameSegment(segment))
106
- ) {
107
- throw new Error(t.invalidStoryPath(storyPath));
108
- }
109
- files.push(build(`${LANG_DIR}/${lang}/${storyPath}.json`, keys));
110
- }
111
- return files;
112
- }
1
+ /**
2
+ * i18n 工具链 · overlay 骨架生成。
3
+ *
4
+ * 给定语言与分组策略,产出**文件内容**(原文 → 既有译文或占位);落盘归调用方
5
+ * (编辑器保存 / CLI),本模块保持纯函数(不碰平台 API)。合并语义与运行期
6
+ * `mergeOverlayFiles` 对齐(main.json 兜底 + 其余按供给序覆盖)⇒ 骨架喂回合并后
7
+ * 键集合与 `extractStoryKeys` 输出**双向一致**(互锁测试锁定)。
8
+ *
9
+ * 布局覆盖 overlay 形态:「平铺」= 条目即键值平面(三形态共用);
10
+ * 单大文件 `Lang/{lang}.json`、目录 `Lang/{lang}/main.json`、按故事分类
11
+ * `Lang/{lang}/{章节子目录}/{故事}.json`——**per-story 镜像 `Stories/` 的递归目录**
12
+ * (章节子目录更好区分归类;`.json` / `.json.enc`
13
+ * 双识别归运行期供给,骨架产明文)。**增量模式**:命中的既有译文原样保留(含空串
14
+ * 译文——运行期「命中即用」语义),绝不覆盖。
15
+ */
16
+
17
+ import type { EditorTexts } from "../contracts/index.ts";
18
+ import { resolveEditorTexts } from "../contracts/index.ts";
19
+ import { isSafeFileNameSegment } from "@lingfan-engine/core";
20
+
21
+ /** 占位策略:original = 原文占位(未翻译时画面可读,翻译工作表语义);empty = 空串(运行期命中即隐句) */
22
+ export type SkeletonPlaceholder = "original" | "empty";
23
+
24
+ /** 骨架布局:single = `Lang/{lang}.json`;main = `Lang/{lang}/main.json`;per-story = 镜像 Stories/ 递归目录 */
25
+ export type SkeletonLayout = "single" | "main" | "per-story";
26
+
27
+ export interface OverlaySkeletonFile {
28
+ /** 相对资源根的 overlay 路径 */
29
+ path: string;
30
+ /** 键值平面(键 = 原文,稳定排序;值 = 既有译文(增量保留)或占位) */
31
+ entries: Record<string, string>;
32
+ /** 序列化好的文件内容(2 空格缩进 + 结尾换行;落盘即用,同输入逐字节确定) */
33
+ content: string;
34
+ }
35
+
36
+ /** overlay 根目录名(与运行期 Lang/ 供给面同源;单一出处供路径模板与测试共用) */
37
+ export const LANG_DIR = "Lang";
38
+
39
+ export interface SkeletonOptions {
40
+ lang: string;
41
+ layout: SkeletonLayout;
42
+ /**
43
+ * 故事 → 该故事的原文键(`extractStoryKeys` 输出;键序不要求,产出内会排序)。
44
+ * **键 = 故事文件相对 `Stories/` 的路径(不含扩展名)**,如 `chapter1/tavern`——
45
+ * per-story 布局按它镜像 `Stories/` 递归目录(平铺工程的键 = 故事 id,语义一致)。
46
+ */
47
+ storyKeys: ReadonlyMap<string, readonly string[]>;
48
+ /** 增量模式:该语言已存在的 overlay(路径 → 条目);命中的键保留原值 */
49
+ existing?: ReadonlyMap<string, Record<string, string>>;
50
+ placeholder?: SkeletonPlaceholder;
51
+ /** 集中文案表注入(缺省 defaultEditorTexts) */
52
+ texts?: Partial<EditorTexts>;
53
+ }
54
+
55
+ /**
56
+ * 产出骨架文件列表。fail-closed:`lang` / 故事 id 不合法即抛——骨架路径要能落进
57
+ * `Lang/` 而不越界(与故事文件名同一安全口径 `isSafeFileNameSegment`)。
58
+ */
59
+ export function planOverlaySkeleton(
60
+ options: SkeletonOptions,
61
+ ): OverlaySkeletonFile[] {
62
+ const {
63
+ lang,
64
+ layout,
65
+ storyKeys,
66
+ existing,
67
+ placeholder = "original",
68
+ } = options;
69
+ const t: EditorTexts = resolveEditorTexts(options.texts);
70
+ if (typeof lang !== "string" || !isSafeFileNameSegment(lang)) {
71
+ throw new Error(t.invalidLangCode(String(lang)));
72
+ }
73
+ const build = (
74
+ path: string,
75
+ keys: readonly string[],
76
+ ): OverlaySkeletonFile => {
77
+ const prior = existing?.get(path) ?? {};
78
+ const entries: Record<string, string> = {};
79
+ for (const key of [...keys].sort()) {
80
+ // 既有条目原样保留(含空串译文);`undefined` = 未命中 → 占位
81
+ const kept = prior[key];
82
+ entries[key] =
83
+ typeof kept === "string" ? kept : placeholder === "empty" ? "" : key;
84
+ }
85
+ return { path, entries, content: `${JSON.stringify(entries, null, 2)}\n` };
86
+ };
87
+ if (layout === "single" || layout === "main") {
88
+ const union = new Set<string>();
89
+ for (const keys of storyKeys.values()) {
90
+ for (const key of keys) union.add(key);
91
+ }
92
+ const path =
93
+ layout === "single"
94
+ ? `${LANG_DIR}/${lang}.json`
95
+ : `${LANG_DIR}/${lang}/main.json`;
96
+ return [build(path, [...union])];
97
+ }
98
+ // per-story:镜像 `Stories/` 的递归目录——
99
+ // 键 = 故事相对 `Stories/` 的路径(不含扩展名);**段级**安全校验防路径越界。
100
+ const files: OverlaySkeletonFile[] = [];
101
+ for (const [storyPath, keys] of storyKeys) {
102
+ const segments = storyPath.split("/");
103
+ if (
104
+ storyPath === "" ||
105
+ segments.some((segment) => !isSafeFileNameSegment(segment))
106
+ ) {
107
+ throw new Error(t.invalidStoryPath(storyPath));
108
+ }
109
+ files.push(build(`${LANG_DIR}/${lang}/${storyPath}.json`, keys));
110
+ }
111
+ return files;
112
+ }