config2ts 2.9.0 → 2.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,181 +1,227 @@
1
- # config2ts
2
-
3
- convert config to ts file.
4
-
5
- # install
6
-
7
- run command: `npm install -g config2ts`
8
-
9
- # documentation
10
-
11
- Full documentation is published via GitHub Pages (this repo has Pages enabled):
12
-
13
- - English: https://livingyang.github.io/config2ts/
14
- - 中文: https://livingyang.github.io/config2ts/index-zh.html
15
-
16
- # usage
17
-
18
- `config2ts` supports two output modes selected by `-m, --mode`:
19
-
20
- - `single` (default): merge every table into one file (`-n`, default `csv.ts`).
21
- - `split`: write one `.ts` file per table plus an auto-generated `index.ts`.
22
-
23
- This repo's `config/` folder ships example outputs generated from the same source
24
- tables, so you can compare both layouts directly:
25
-
26
- - single mode example: [`config/total.ts`](config/total.ts)
27
- - split mode example: [`config/split/`](config/split/) (one file per table + `index.ts`)
28
-
29
- ## single mode
30
-
31
- Merge all config tables into a single TypeScript file.
32
-
33
- ```bash
34
- config2ts -d config -o dist -m single -n csv.ts -a public
35
- ```
36
-
37
- - `-n` is the merged output file name (default `csv.ts`).
38
- - `-a` is optional; omit it to skip `assets.ts`.
39
- - Example output: [`config/total.ts`](config/total.ts) — all tables as `export namespace XxxCsv { ... }` in one file.
40
-
41
- ## split mode
42
-
43
- Write one `.ts` file per config table (named after the source file, e.g.
44
- `skill.csv` → `skill.ts`) plus an auto-generated `index.ts` that re-exports every
45
- table and `assets.ts`. Cross-table `Ref`/`RefEnum`/`Template` references are
46
- resolved with `import` statements injected at the top of the referencing file, so
47
- every table is independently usable and tree-shakable.
48
-
49
- ```bash
50
- config2ts -d config -o dist -m split -a public
51
- ```
52
-
53
- - `-n` is reused as the index file name; the single-mode default `csv.ts` is
54
- replaced by `index.ts` unless you pass `-n` explicitly.
55
- - `-a` is optional; omit it to skip `assets.ts`.
56
- - Example output: [`config/split/`](config/split/) — `z-base.ts`, `a-user.ts`,
57
- `skill.ts`, … , `assets.ts`, and `index.ts`.
58
-
59
- Output layout under `dist`:
60
-
61
- ```
62
- dist/
63
- z-base.ts # export namespace ZBaseCsv { ... }
64
- a-user.ts # import { ZBaseCsv } from "./z-base"; export namespace AUserCsv { ... }
65
- skill.ts
66
- ...
67
- assets.ts # generated only when -a is given
68
- index.ts # export * from "./z-base"; export * from "./a-user"; ...; export * from "./assets";
69
- ```
70
-
71
- Notes:
72
- - Tables are emitted in topological order; a table only `import`s tables it
73
- actually references, and missing/circular references print the same warnings as
74
- single mode.
75
-
76
- # support type
77
-
78
- | csv field | typescript type |
79
- | :--------: | :---------------: |
80
- | Index | string |
81
- | String | string |
82
- | Number | number |
83
- | Boolean | boolean |
84
- | Enum | type |
85
- | EnumIndex | type |
86
- | String[] | string[] |
87
- | Number[] | number[] |
88
- | Enum[] | type[] |
89
- | Ref | namespace.Record |
90
- | RefEnum | namespace.type |
91
- | RefEnum[] | namespace.type[] |
92
- | Template | namespace.Record |
93
- | Template[] | namespace.Record[] |
94
- | Object | type (Record) |
95
- | Object[] | type[] (Record[]) |
96
-
97
- * `Number` support Infinity and NaN
98
- * `Enum` support empty string type
99
- * `Enum[]` union contains every value actually present in the data (including `""` for empty slots)
100
- * `EnumIndex` will generate index type, and will use `Enum` type to generate interface
101
- * `Ref` / `RefEnum` reference another csv's Record/enum by id: `Ref[file]` is one row (`file.Map["id"]`), `Ref[file][]` is an array of rows with comma-separated ids (`[file.Map["1"],file.Map["2"]]`, typed `File.Record[]`); use `Object[]` for inline private structures and `Ref[]` for shared, identity-bearing rows
102
- * `Template[file]` inherits a whole row from another table and overrides selected fields: a cell is `<baseId>|<key>:<value>,<key>:<value>` — a pipe separates the base row id from override pairs (e.g. `101|damage:150,range:8`), emitting `{ ...file.Map["101"], damage: 150, range: 8 }` typed `File.Record`; with no overrides the cell is just the id and emits a plain `file.Map["id"]` like `Ref`. Override values may be bare scalars or bracket-wrapped composites mirroring the generated TS syntax — arrays as `[v1,v2]` (e.g. `Params:[6,2.07]`, required for array-typed fields; a bare `Params:6,2.07` gets chopped by the pair comma and prints a warning) and flat objects as `{k:v,k:v}`. `Template[file][]` separates entries with `;` (the same element separator as `Object[]`), e.g. `101|damage:150;102|heal:500` emits `[{ ...file.Map["101"], damage: 150 },{ ...file.Map["102"], heal: 500 }]`. Override key typos and value-type mismatches fail at tsc compile time; overrides are flat top-level fields (no dot-path deep merge), and the target table must have an Index/EnumIndex Map
103
- * Merge order is resolved automatically: tables are topologically sorted so every referenced table (`Ref`/`RefEnum`/`Template`) is emitted before the tables referencing it — no filename tricks needed; tables without a dependency relation keep alphabetical order. Circular references (`a.csv -> b.csv -> a.csv`) and references to missing files print a warning
104
- * `Object` parse `key:value,key:value` format, auto-infer value types (number/boolean/string), generates a dedicated `type` that merges all keys
105
- * `Object[]` separates objects with `;` while commas still separate `key:value` pairs inside each object (same syntax as the `Object` type), e.g. `num:1,,str:a;num:2` makes `[{num:1,str:'a'},{num:2}]`, a bare `;` makes `[{},{}]`; generates a dedicated element `type` merging all keys with the field typed as `type[]`
106
- * Array convention: primitive arrays (`String[]`/`Number[]`/`Enum[]`/`RefEnum[...] []`) separate elements with `,` and `Object[]` separates objects with `;`; raw cell values are normalized (CRLF/CR unified to LF, whitespace trimmed); an empty cell generates `[]`; otherwise n separators make n+1 slots and every slot is kept (including interior or trailing ones) so parallel arrays stay index-aligned. Empty slots use the type's own zero value — `''` for string/enum/ref-enum, `0` for number, `{}` for empty object slots; `null` is never generated
107
- * Line breaks inside a cell (quote-wrapped in CSV) are preserved for `String` fields (emitted as `\n`) and act as the equivalent separator in structural fields — a line feed equals a comma between array elements / Object pairs / Template override pairs, and equals a semicolon between Object[] elements / Template[] entries (one entry per line works; a blank line is an empty slot by the n+1 rule). Scalar fields (Number/Boolean/Enum/single Ref) have no separator semantics, so a line break there prints a warning and must be removed
108
- * unrecognized field types fall back to `string` and print a warning (check for typos)
109
-
110
- ## assets2ts
111
-
112
- Scan assets directory and generate an `assets.ts` index file with nested resource tree.
113
-
114
- - `-a, --assets <path>` set assets resource directory (optional, skip asset index if omitted)
115
- - Output file: `assets.ts` (alongside the merged config file)
116
- - File and directory names are used exactly as-is (e.g., `Direction.png` → `Direction`, `adjust-horizontal.png` → `'adjust-horizontal'`); keys with special characters are automatically quoted
117
- - `type` field uses file extension (lowercase), e.g. `'png'`, `'mp3'`, `'svg'`
118
- - Directories containing 2+ files of the same extension get a dedicated type (e.g. `PngAsset`, `Mp3Asset`) and `satisfies Record<string, XxxAsset>` annotation
119
- - Directories with ≤1 file or mixed extensions do not get a type annotation
120
- - Nested directories are supported (e.g. `public/sub/image/`)
121
-
122
- ### generated example
123
-
124
- ```typescript
125
- // Auto Generated by config2ts, DO NOT EDIT
126
-
127
- export type Mp3Asset = { path: string; type: 'mp3' };
128
- export type PngAsset = { path: string; type: 'png' };
129
- export type SvgAsset = { path: string; type: 'svg' };
130
-
131
- export const ASSETS = {
132
- public: {
133
- image: {
134
- 'adjust-horizontal': {path:'public/image/adjust-horizontal.png',type:'png'},
135
- Direction: {path:'public/image/Direction.png',type:'png'}
136
- } satisfies Record<string, PngAsset>,
137
- music: {
138
- effect1: {path:'public/music/effect1.mp3',type:'mp3'},
139
- effect2: {path:'public/music/effect2.mp3',type:'mp3'}
140
- } satisfies Record<string, Mp3Asset>,
141
- 'single-file': {
142
- effect1: {path:'public/single-file/effect1.mp3',type:'mp3'}
143
- }
144
- }
145
- };
146
- ```
147
-
148
- ### usage
149
-
150
- ```typescript
151
- import { ASSETS } from "./assets";
152
-
153
- const meta = ASSETS.public.image.Direction;
154
- // meta.path → 'public/image/Direction.png'
155
- // meta.type → 'png'
156
-
157
- const adjustMeta = ASSETS.public.image['adjust-horizontal'];
158
- // adjustMeta.path → 'public/image/adjust-horizontal.png'
159
- ```
160
-
161
- ## Options
162
-
163
- ```
164
- Options:
165
-
166
- -h, --help output usage information
167
- -V, --version output the version number
168
- -d, --dir <path> set convert path. default: ./
169
- -o, --outDir <path> set outDir path. default: same as -d
170
- -n, --name <name> output file name (single mode) / index file name (split mode). default: csv.ts
171
- -a, --assets <path> set assets resource directory for asset index (optional, skip asset index if omitted)
172
- -m, --mode <mode> output mode: single (merge into one file) or split (one file per table + index.ts). default: single
173
- ```
174
-
175
- ## supported file formats
176
-
177
- | format | extension |
178
- | :----: | :-------: |
179
- | csv | .csv |
180
- | ini | .ini |
181
- | toml | .toml |
1
+ # config2ts
2
+
3
+ convert config to ts file.
4
+
5
+ # install
6
+
7
+ run command: `npm install -g config2ts`
8
+
9
+ # documentation
10
+
11
+ Full documentation is published via GitHub Pages (this repo has Pages enabled):
12
+
13
+ - English: https://livingyang.github.io/config2ts/#/docs/en
14
+ - 中文: https://livingyang.github.io/config2ts/#/docs/zh
15
+
16
+ Both the documentation pages and the per-feature demo cases are served by the
17
+ Vue site in [`website/`](website/) (built into `docs/`).
18
+
19
+ # usage
20
+
21
+ `config2ts` supports two output modes selected by `-m, --mode`:
22
+
23
+ - `single` (default): merge every table into one file (`-n`, default `csv.ts`).
24
+ - `split`: write one `.ts` file per table plus an auto-generated `index.ts`.
25
+
26
+ Every case under [`fixtures/`](fixtures/) pairs its source tables with the
27
+ single-mode merge of those tables, so you can compare input and output directly
28
+ (the demo site renders the same pairs):
29
+
30
+ - single mode example: [`fixtures/03-cross-ref/expected.ts`](fixtures/03-cross-ref/expected.ts)
31
+ - split mode: one file per table plus `index.ts` (layout shown below)
32
+
33
+ ## fixtures (single source of truth)
34
+
35
+ The [`fixtures/`](fixtures/) folder holds one self-contained case directory per
36
+ feature (`01-basic-types`, `03-cross-ref`, ...). Each case contains its source
37
+ tables plus a committed golden file `expected.ts` generated from them. The same
38
+ `(input.csv, expected.ts)` pairs are used by the golden-file unit tests
39
+ ([`tests/fixtures.test.ts`](tests/fixtures.test.ts)) and by the demo site, so
40
+ documentation, tests and demos can never drift from actual tool behavior.
41
+
42
+ A case may also use subdirectories: when every csv file inside a subdirectory
43
+ shares the same header and type rows, they are merged into ONE table named
44
+ after the directory (`fixtures/08-story/story/*.csv` → `StoryCsv`) — see
45
+ [fixtures/08-story](fixtures/08-story) and the "Isomorphic subdirectory merge"
46
+ section of the docs.
47
+
48
+ - Regenerate golden files: `npm run fixtures:regen` (rebuilds lib, then rewrites
49
+ every `expected.ts`) — review the diff manually before committing; never
50
+ blindly rewrite a failing golden file.
51
+ - Tests: every case is regenerated in-memory and compared byte-for-byte against
52
+ its golden file, and all golden files are additionally checked with `tsc`.
53
+ - [`tests/golden/merged-formats.expected.ts`](tests/golden/merged-formats.expected.ts)
54
+ is a composite golden: it merges tables from several cases (CSV with refs +
55
+ INI + TOML) in a single call, which per-case goldens can't cover. It is
56
+ regenerated by the same `fixtures:regen` run.
57
+
58
+ ## demo site
59
+
60
+ [`website/`](website/) is a Vue3 + Vite site that renders every fixture case as
61
+ an input → output comparison page. It reads the same `fixtures/` pairs the tests
62
+ verify via `import.meta.glob(..., { query: "?raw" })` — no copies, no codegen —
63
+ so the demo can never drift from actual tool behavior.
64
+
65
+ - Local dev: `npm run demo:dev`
66
+ - Build: `npm run demo:build` — output goes to `docs/`, published on GitHub
67
+ Pages at `/config2ts/` (the site owns the Pages root; `docs/` holds only the
68
+ build output)
69
+
70
+ ## single mode
71
+
72
+ Merge all config tables into a single TypeScript file.
73
+
74
+ ```bash
75
+ config2ts -d fixtures/03-cross-ref -o dist -m single -n csv.ts
76
+ ```
77
+
78
+ - `-n` is the merged output file name (default `csv.ts`).
79
+ - `-a` is optional; omit it to skip `assets.ts` (e.g. `-a public` indexes this
80
+ repo's sample asset tree).
81
+ - Example output: [`fixtures/03-cross-ref/expected.ts`](fixtures/03-cross-ref/expected.ts) — all tables as `export namespace XxxCsv { ... }` in one file.
82
+
83
+ ## split mode
84
+
85
+ Write one `.ts` file per config table (named after the source file, e.g.
86
+ `skill.csv` → `skill.ts`) plus an auto-generated `index.ts` that re-exports every
87
+ table and `assets.ts`. Cross-table `Ref`/`RefEnum`/`Template` references are
88
+ resolved with `import` statements injected at the top of the referencing file, so
89
+ every table is independently usable and tree-shakable.
90
+
91
+ ```bash
92
+ config2ts -d fixtures/03-cross-ref -o dist -m split -a public
93
+ ```
94
+
95
+ - `-n` is reused as the index file name; the single-mode default `csv.ts` is
96
+ replaced by `index.ts` unless you pass `-n` explicitly.
97
+ - `-a` is optional; omit it to skip `assets.ts`.
98
+ - Example output: one file per table (`z-base.ts`, `a-user.ts`, `skill.ts`, …),
99
+ plus `assets.ts` and `index.ts` (layout below).
100
+
101
+ Output layout under `dist`:
102
+
103
+ ```
104
+ dist/
105
+ z-base.ts # export namespace ZBaseCsv { ... }
106
+ a-user.ts # import { ZBaseCsv } from "./z-base"; export namespace AUserCsv { ... }
107
+ skill.ts
108
+ ...
109
+ assets.ts # generated only when -a is given
110
+ index.ts # export * from "./z-base"; export * from "./a-user"; ...; export * from "./assets";
111
+ ```
112
+
113
+ Notes:
114
+ - Tables are emitted in topological order; a table only `import`s tables it
115
+ actually references, and missing/circular references print the same warnings as
116
+ single mode.
117
+
118
+ # support type
119
+
120
+ | csv field | typescript type |
121
+ | :--------: | :---------------: |
122
+ | Index | string |
123
+ | String | string |
124
+ | Number | number |
125
+ | Boolean | boolean |
126
+ | Enum | type |
127
+ | EnumIndex | type |
128
+ | String[] | string[] |
129
+ | Number[] | number[] |
130
+ | Enum[] | type[] |
131
+ | Ref | namespace.Record |
132
+ | RefEnum | namespace.type |
133
+ | RefEnum[] | namespace.type[] |
134
+ | RefTable | namespace.Record[] |
135
+ | RefTable[] | namespace.Record[][] |
136
+ | Template | namespace.Record |
137
+ | Template[] | namespace.Record[] |
138
+ | Object | type (Record) |
139
+ | Object[] | type[] (Record[]) |
140
+
141
+ * `Number` support Infinity and NaN
142
+ * `Enum` support empty string type
143
+ * `Enum[]` union contains every value actually present in the data (including `""` for empty slots)
144
+ * `EnumIndex` will generate index type, and will use `Enum` type to generate interface
145
+ * `Ref` / `RefEnum` reference another csv's Record/enum by id: `Ref[table]` is one row (`table.Map["id"]`), `Ref[table][]` is an array of rows with comma-separated ids (`[table.Map["1"],table.Map["2"]]`, typed `File.Record[]`); use `Object[]` for inline private structures and `Ref[]` for shared, identity-bearing rows
146
+ * `Template[table]` inherits a whole row from another table and overrides selected fields: a cell is `<baseId>|<key>:<value>,<key>:<value>` — a pipe separates the base row id from override pairs (e.g. `101|damage:150,range:8`), emitting `{ ...table.Map["101"], damage: 150, range: 8 }` typed `File.Record`; with no overrides the cell is just the id and emits a plain `table.Map["id"]` like `Ref`. Override values may be bare scalars or bracket-wrapped composites mirroring the generated TS syntax — arrays as `[v1,v2]` (e.g. `Params:[6,2.07]`, required for array-typed fields; a bare `Params:6,2.07` gets chopped by the pair comma and prints a warning) and flat objects as `{k:v,k:v}`. `Template[table][]` separates entries with `;` (the same element separator as `Object[]`), e.g. `101|damage:150;102|heal:500` emits `[{ ...table.Map["101"], damage: 150 },{ ...table.Map["102"], heal: 500 }]`. Override key typos and value-type mismatches fail at tsc compile time; overrides are flat top-level fields (no dot-path deep merge), and the target table must have an Index/EnumIndex Map
147
+ * Reference targets are written as **table names without the extension** (`Ref[skill]` → `skill.csv`); the full file name (`Ref[skill.csv]`) still works, and an ambiguous stem (`skill.csv` + `skill.ini`) warns and needs the full name
148
+ * **Homogeneous subdirectories merge into one table named after the directory**: when every csv in a subdirectory shares the same header and type rows, `story/ch01.csv` … → `StoryCsv`, rows concatenated in file-name order. The merged table has no flat `List`; it exports `FileMap` grouped by member file plus `type File = "ch01" | ...` and `refTable(file: File)`; `RefTable[story]` references a whole member sub-table by file name (typed `StoryCsv.Record[]`, `File` union makes an unknown name a compile error). Member files that nothing references are allowed. See [`fixtures/08-story`](fixtures/08-story)
149
+ * Merge order is resolved automatically: tables are topologically sorted so every referenced table (`Ref`/`RefEnum`/`Template`/`RefTable`) is emitted before the tables referencing it — no filename tricks needed; tables without a dependency relation keep alphabetical order. Circular references (`a.csv -> b.csv -> a.csv`) and references to missing files print a warning
150
+ * `Object` parse `key:value,key:value` format, auto-infer value types (number/boolean/string), generates a dedicated `type` that merges all keys
151
+ * `Object[]` separates objects with `;` while commas still separate `key:value` pairs inside each object (same syntax as the `Object` type), e.g. `num:1,,str:a;num:2` makes `[{num:1,str:'a'},{num:2}]`, a bare `;` makes `[{},{}]`; generates a dedicated element `type` merging all keys with the field typed as `type[]`
152
+ * Array convention: primitive arrays (`String[]`/`Number[]`/`Enum[]`/`RefEnum[...] []`) separate elements with `,` and `Object[]` separates objects with `;`; raw cell values are normalized (CRLF/CR unified to LF, whitespace trimmed); an empty cell generates `[]`; otherwise n separators make n+1 slots and every slot is kept (including interior or trailing ones) so parallel arrays stay index-aligned. Empty slots use the type's own zero value — `''` for string/enum/ref-enum, `0` for number, `{}` for empty object slots; `null` is never generated
153
+ * Line breaks inside a cell (quote-wrapped in CSV) are preserved for `String` fields (emitted as `\n`) and act as the equivalent separator in structural fields — a line feed equals a comma between array elements / Object pairs / Template override pairs, and equals a semicolon between Object[] elements / Template[] entries (one entry per line works; a blank line is an empty slot by the n+1 rule). Scalar fields (Number/Boolean/Enum/single Ref) have no separator semantics, so a line break there prints a warning and must be removed
154
+ * unrecognized field types fall back to `string` and print a warning (check for typos)
155
+
156
+ ## assets2ts
157
+
158
+ Scan assets directory and generate an `assets.ts` index file with nested resource tree.
159
+
160
+ - `-a, --assets <path>` set assets resource directory (optional, skip asset index if omitted)
161
+ - Output file: `assets.ts` (alongside the merged config file)
162
+ - File and directory names are used exactly as-is (e.g., `Direction.png` → `Direction`, `adjust-horizontal.png` → `'adjust-horizontal'`); keys with special characters are automatically quoted
163
+ - `type` field uses file extension (lowercase), e.g. `'png'`, `'mp3'`, `'svg'`
164
+ - Directories containing 2+ files of the same extension get a dedicated type (e.g. `PngAsset`, `Mp3Asset`) and `satisfies Record<string, XxxAsset>` annotation
165
+ - Directories with ≤1 file or mixed extensions do not get a type annotation
166
+ - Nested directories are supported (e.g. `public/sub/image/`)
167
+
168
+ ### generated example
169
+
170
+ ```typescript
171
+ // Auto Generated by config2ts, DO NOT EDIT
172
+
173
+ export type Mp3Asset = { path: string; type: 'mp3' };
174
+ export type PngAsset = { path: string; type: 'png' };
175
+ export type SvgAsset = { path: string; type: 'svg' };
176
+
177
+ export const ASSETS = {
178
+ public: {
179
+ image: {
180
+ 'adjust-horizontal': {path:'public/image/adjust-horizontal.png',type:'png'},
181
+ Direction: {path:'public/image/Direction.png',type:'png'}
182
+ } satisfies Record<string, PngAsset>,
183
+ music: {
184
+ effect1: {path:'public/music/effect1.mp3',type:'mp3'},
185
+ effect2: {path:'public/music/effect2.mp3',type:'mp3'}
186
+ } satisfies Record<string, Mp3Asset>,
187
+ 'single-file': {
188
+ effect1: {path:'public/single-file/effect1.mp3',type:'mp3'}
189
+ }
190
+ }
191
+ };
192
+ ```
193
+
194
+ ### usage
195
+
196
+ ```typescript
197
+ import { ASSETS } from "./assets";
198
+
199
+ const meta = ASSETS.public.image.Direction;
200
+ // meta.path → 'public/image/Direction.png'
201
+ // meta.type → 'png'
202
+
203
+ const adjustMeta = ASSETS.public.image['adjust-horizontal'];
204
+ // adjustMeta.path → 'public/image/adjust-horizontal.png'
205
+ ```
206
+
207
+ ## Options
208
+
209
+ ```
210
+ Options:
211
+
212
+ -h, --help output usage information
213
+ -V, --version output the version number
214
+ -d, --dir <path> set convert path. default: ./
215
+ -o, --outDir <path> set outDir path. default: same as -d
216
+ -n, --name <name> output file name (single mode) / index file name (split mode). default: csv.ts
217
+ -a, --assets <path> set assets resource directory for asset index (optional, skip asset index if omitted)
218
+ -m, --mode <mode> output mode: single (merge into one file) or split (one file per table + index.ts). default: single
219
+ ```
220
+
221
+ ## supported file formats
222
+
223
+ | format | extension |
224
+ | :----: | :-------: |
225
+ | csv | .csv |
226
+ | ini | .ini |
227
+ | toml | .toml |
@@ -1,7 +1,22 @@
1
- type ConvertHandler = (str: string, moduleName: string) => string;
1
+ type ConvertHandler = (str: string, moduleName: string, subTables?: SubTableInfo[]) => string;
2
2
  export declare const Convert: Record<string, ConvertHandler>;
3
3
  export declare function assets2ts(assetsDir: string): string;
4
4
  export declare function GetTsString(filePath: string): string;
5
+ export interface SubTableInfo {
6
+ name: string;
7
+ rowCount: number;
8
+ }
9
+ export interface ConvertUnit {
10
+ name: string;
11
+ ext: string;
12
+ content: string;
13
+ sourcePaths: string[];
14
+ refs: string[];
15
+ subTables?: SubTableInfo[];
16
+ }
17
+ export declare function resolveTableName(ref: string, names: Iterable<string>): string | null;
18
+ export declare function collectConvertUnits(dir: string): ConvertUnit[];
19
+ export declare function GetTsStringFromUnits(units: ConvertUnit[]): string;
5
20
  export declare function GetTsStringFromFileList(fileList: string[]): string;
6
21
  export declare function GetValidFileList(fileList: string[]): string[];
7
22
  export declare function startConvert(dir: string, outDir: string, merge: string, assetsDir?: string, mode?: "single" | "split"): void;
package/lib/config2ts.js CHANGED
@@ -36,6 +36,9 @@ Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.Convert = void 0;
37
37
  exports.assets2ts = assets2ts;
38
38
  exports.GetTsString = GetTsString;
39
+ exports.resolveTableName = resolveTableName;
40
+ exports.collectConvertUnits = collectConvertUnits;
41
+ exports.GetTsStringFromUnits = GetTsStringFromUnits;
39
42
  exports.GetTsStringFromFileList = GetTsStringFromFileList;
40
43
  exports.GetValidFileList = GetValidFileList;
41
44
  exports.startConvert = startConvert;
@@ -54,6 +57,7 @@ const ObjectArrayString = "Object[]";
54
57
  const RefPrefix = "Ref[";
55
58
  const RefSuffix = "]";
56
59
  const RefEnumPrefix = "RefEnum[";
60
+ const RefTablePrefix = "RefTable[";
57
61
  const RefEnumArraySuffix = "][]";
58
62
  const TemplatePrefix = "Template[";
59
63
  // Inside a Template cell, '|' separates the referenced base row id from the
@@ -66,18 +70,30 @@ exports.Convert = {
66
70
  const obj = toml.parse(str);
67
71
  return `export const ${moduleName} = ${json5.stringify(obj, null, 4)};`;
68
72
  },
69
- csv: function (str, moduleName) {
70
- return csv2ts(str, moduleName);
73
+ csv: function (str, moduleName, subTables) {
74
+ return csv2ts(str, moduleName, subTables);
71
75
  },
72
76
  toml: function (str, moduleName) {
73
77
  const obj = toml.parse(str);
74
78
  return `export const ${moduleName} = ${json5.stringify(obj, null, 4)};`;
75
79
  },
76
80
  };
77
- function csv2ts(csvString, moduleName) {
81
+ function csv2ts(csvString, moduleName, subTables) {
78
82
  let convert = {};
79
83
  const refFields = [];
80
84
  const refEnumFields = [];
85
+ // Sub-table binding for merged subdirectories: each data row is tagged with
86
+ // the member file it came from, so the namespace can export a FileMap
87
+ // (file name -> that file's rows) in addition to the flat List.
88
+ const groupOffsets = [];
89
+ if (subTables && subTables.length > 0) {
90
+ let cursor = 0;
91
+ for (const subTable of subTables) {
92
+ groupOffsets.push({ name: subTable.name, start: cursor, end: cursor + subTable.rowCount });
93
+ cursor += subTable.rowCount;
94
+ }
95
+ }
96
+ const rowGroups = [];
81
97
  const result = (0, d3_dsv_1.csvParse)(csvString, function (d, i) {
82
98
  if (i === 0) {
83
99
  convert = d;
@@ -95,6 +111,11 @@ function csv2ts(csvString, moduleName) {
95
111
  return null;
96
112
  }
97
113
  else {
114
+ const dataRowIndex = i - 1;
115
+ if (groupOffsets.length > 0) {
116
+ const group = groupOffsets.find((g) => dataRowIndex >= g.start && dataRowIndex < g.end);
117
+ rowGroups[dataRowIndex] = group ? group.name : undefined;
118
+ }
98
119
  for (const k in d) {
99
120
  d[k] = normalizeCell(d[k]);
100
121
  // Line feeds are preserved on purpose for free-form String fields
@@ -174,6 +195,19 @@ function csv2ts(csvString, moduleName) {
174
195
  d[k] = entry;
175
196
  }
176
197
  }
198
+ else if (parseRefTable(convert[k])) {
199
+ const refTable = parseRefTable(convert[k]);
200
+ if (refTable.isArray) {
201
+ const names = splitStringArray(d[k]);
202
+ d[k] = names;
203
+ if (names.includes("")) {
204
+ console.warn(`[config2ts] warning: ${moduleName} row ${i} field "${k}" ref-table array contains an empty file name`);
205
+ }
206
+ }
207
+ else if (d[k] === "") {
208
+ console.warn(`[config2ts] warning: ${moduleName} row ${i} field "${k}" ref-table value is empty`);
209
+ }
210
+ }
177
211
  else {
178
212
  d[k] = String(d[k]);
179
213
  }
@@ -182,6 +216,11 @@ function csv2ts(csvString, moduleName) {
182
216
  }
183
217
  });
184
218
  delete result.columns;
219
+ if (groupOffsets.length > 0 && rowGroups.length !== groupOffsets[groupOffsets.length - 1].end) {
220
+ console.warn(`[config2ts] warning: ${moduleName} sub-table row counts do not match the merged content, FileMap will not be generated`);
221
+ groupOffsets.length = 0;
222
+ rowGroups.length = 0;
223
+ }
185
224
  if (result.length <= 0) {
186
225
  return "";
187
226
  }
@@ -280,6 +319,11 @@ function csv2ts(csvString, moduleName) {
280
319
  const refModuleName = changeCase.pascalCase(refEnum.refModule);
281
320
  fieldType = refEnum.isArray ? `${refModuleName}.${refEnum.refField}[]` : `${refModuleName}.${refEnum.refField}`;
282
321
  }
322
+ else if (parseRefTable(convert[field])) {
323
+ const refTable = parseRefTable(convert[field]);
324
+ const refModuleName = changeCase.pascalCase(refTable.refModule);
325
+ fieldType = refTable.isArray ? `${refModuleName}.Record[][]` : `${refModuleName}.Record[]`;
326
+ }
283
327
  else if (parseTemplate(convert[field])) {
284
328
  const template = parseTemplate(convert[field]);
285
329
  const refModuleName = changeCase.pascalCase(template.refModule);
@@ -294,18 +338,27 @@ function csv2ts(csvString, moduleName) {
294
338
  }
295
339
  template += " };\n\n";
296
340
  if (indexField) {
297
- const filteredResult = result.filter((v) => {
298
- return v[indexField] !== "";
299
- });
300
- for (let i = 0; i < filteredResult.length; i++) {
301
- result[i] = filteredResult[i];
341
+ const kept = [];
342
+ for (let i = 0; i < result.length; i++) {
343
+ if (result[i][indexField] !== "") {
344
+ kept.push(i);
345
+ }
302
346
  }
303
- result.length = filteredResult.length;
347
+ // Compact result and rowGroups in lockstep so each row keeps its sub-table
348
+ // tag even when empty-index rows are dropped.
349
+ for (let i = 0; i < kept.length; i++) {
350
+ result[i] = result[kept[i]];
351
+ rowGroups[i] = rowGroups[kept[i]];
352
+ }
353
+ result.length = kept.length;
354
+ rowGroups.length = kept.length;
304
355
  }
305
- template += " export const List: Record[] = [\n";
356
+ // Serialize every surviving row once; the same literals feed both the flat
357
+ // List and the per-file FileMap groups.
358
+ const rowStrings = [];
306
359
  for (let i = 0; i < result.length; i++) {
307
360
  const row = result[i];
308
- template += " {\n";
361
+ let rowString = " {\n";
309
362
  for (const field in convert) {
310
363
  let value = row[field];
311
364
  if (parseRef(convert[field])) {
@@ -313,10 +366,21 @@ function csv2ts(csvString, moduleName) {
313
366
  const refModuleName = changeCase.pascalCase(ref.refModule);
314
367
  if (ref.isArray) {
315
368
  const refs = value.map((id) => `${refModuleName}.Map[${JSON.stringify(id)}]`).join(",");
316
- template += ` ${serializeField(field)}: [${refs}],\n`;
369
+ rowString += ` ${serializeField(field)}: [${refs}],\n`;
317
370
  }
318
371
  else {
319
- template += ` ${serializeField(field)}: ${refModuleName}.Map[${JSON.stringify(value)}],\n`;
372
+ rowString += ` ${serializeField(field)}: ${refModuleName}.Map[${JSON.stringify(value)}],\n`;
373
+ }
374
+ }
375
+ else if (parseRefTable(convert[field])) {
376
+ const refTable = parseRefTable(convert[field]);
377
+ const refModuleName = changeCase.pascalCase(refTable.refModule);
378
+ if (refTable.isArray) {
379
+ const names = value.map((name) => `${refModuleName}.refTable(${JSON.stringify(name)})`).join(",");
380
+ rowString += ` ${serializeField(field)}: [${names}],\n`;
381
+ }
382
+ else {
383
+ rowString += ` ${serializeField(field)}: ${refModuleName}.refTable(${JSON.stringify(value)}),\n`;
320
384
  }
321
385
  }
322
386
  else if (parseTemplate(convert[field])) {
@@ -324,26 +388,65 @@ function csv2ts(csvString, moduleName) {
324
388
  const refModuleName = changeCase.pascalCase(templateType.refModule);
325
389
  if (templateType.isArray) {
326
390
  const entries = value.map((entry) => serializeTemplateEntry(entry, refModuleName)).join(",");
327
- template += ` ${serializeField(field)}: [${entries}],\n`;
391
+ rowString += ` ${serializeField(field)}: [${entries}],\n`;
328
392
  }
329
393
  else {
330
- template += ` ${serializeField(field)}: ${serializeTemplateEntry(value, refModuleName)},\n`;
394
+ rowString += ` ${serializeField(field)}: ${serializeTemplateEntry(value, refModuleName)},\n`;
331
395
  }
332
396
  }
333
397
  else {
334
- template += ` ${serializeField(field)}: ${json5.stringify(value)},\n`;
398
+ rowString += ` ${serializeField(field)}: ${json5.stringify(value)},\n`;
335
399
  }
336
400
  }
337
- template += " }";
338
- if (i < result.length - 1) {
339
- template += ",";
401
+ rowString += " }";
402
+ rowStrings.push(rowString);
403
+ }
404
+ // A merged subdirectory exposes its rows through FileMap only — a flat List
405
+ // would duplicate every row literal.
406
+ if (groupOffsets.length === 0) {
407
+ template += " export const List: Record[] = [\n";
408
+ for (let i = 0; i < rowStrings.length; i++) {
409
+ template += rowStrings[i];
410
+ if (i < rowStrings.length - 1) {
411
+ template += ",";
412
+ }
413
+ template += "\n";
340
414
  }
341
- template += "\n";
415
+ template += " ];\n\n";
416
+ }
417
+ if (groupOffsets.length > 0) {
418
+ // The FileMap keys are a literal union of the member file names, so a
419
+ // RefTable cell that names a file which does not exist fails at tsc
420
+ // compile time (FileMap["ch99"] is not indexable) instead of silently
421
+ // producing undefined at runtime. A mapped type is used instead of
422
+ // `Record<File, ...>` because the namespace's own `Record` interface
423
+ // shadows the global utility type; it also rejects a missing group.
424
+ const fileNames = groupOffsets.map((group) => JSON.stringify(group.name));
425
+ template += ` export type File = ${fileNames.join(" | ")};\n`;
426
+ template += ` export const FileList: File[] = [${fileNames.join(", ")}];\n\n`;
427
+ template += " export const FileMap: { [file in File]: Record[] } = {\n";
428
+ for (const group of groupOffsets) {
429
+ template += ` ${serializeField(group.name)}: [\n`;
430
+ for (let i = group.start; i < group.end; i++) {
431
+ template += rowStrings[i] + ",\n";
432
+ }
433
+ template += " ],\n";
434
+ }
435
+ template += " };\n\n";
436
+ // RefTable resolves through this accessor instead of indexing FileMap
437
+ // directly: indexing an unknown literal key is only an error under
438
+ // noImplicitAny, while passing a wrong argument to a `File` parameter is
439
+ // an error under every tsc flag combination.
440
+ template += " export function refTable(file: File): Record[] { return FileMap[file]; }\n\n";
342
441
  }
343
- template += " ];\n\n";
344
442
  if (indexField != null) {
345
443
  template += " export const Map: { [id: string]: Record } = {};\n";
346
- template += ` for (const v of List) { Map[v.${indexField}] = v; };\n\n`;
444
+ if (groupOffsets.length > 0) {
445
+ template += ` for (const file in FileMap) { for (const v of FileMap[file]) { Map[v.${indexField}] = v; } };\n\n`;
446
+ }
447
+ else {
448
+ template += ` for (const v of List) { Map[v.${indexField}] = v; };\n\n`;
449
+ }
347
450
  }
348
451
  template += "};";
349
452
  return template;
@@ -441,6 +544,9 @@ function isKnownType(typeStr) {
441
544
  if (parseRefEnum(typeStr)) {
442
545
  return true;
443
546
  }
547
+ if (parseRefTable(typeStr)) {
548
+ return true;
549
+ }
444
550
  if (parseTemplate(typeStr)) {
445
551
  return true;
446
552
  }
@@ -465,6 +571,10 @@ function isStructuralType(typeStr) {
465
571
  if (refEnum && refEnum.isArray) {
466
572
  return true;
467
573
  }
574
+ const refTable = parseRefTable(typeStr);
575
+ if (refTable && refTable.isArray) {
576
+ return true;
577
+ }
468
578
  if (parseTemplate(typeStr)) {
469
579
  return true;
470
580
  }
@@ -510,6 +620,29 @@ function parseRefEnum(typeStr) {
510
620
  }
511
621
  return null;
512
622
  }
623
+ // Parse RefTable[module] (single) and RefTable[module][] (array of sub-table
624
+ // references). A RefTable cell holds a sub-table file name of a merged
625
+ // subdirectory unit (the exported FileMap key) and resolves to that file's
626
+ // row list. The prefixes are matched in full, so "RefTable[" never collides
627
+ // with "Ref[" / "RefEnum[".
628
+ function parseRefTable(typeStr) {
629
+ if (!typeStr.startsWith(RefTablePrefix)) {
630
+ return null;
631
+ }
632
+ let isArray = false;
633
+ let inner = typeStr;
634
+ if (typeStr.endsWith(RefEnumArraySuffix)) {
635
+ isArray = true;
636
+ inner = typeStr.slice(0, -RefEnumArraySuffix.length) + RefSuffix;
637
+ }
638
+ if (inner.startsWith(RefTablePrefix) && inner.endsWith(RefSuffix)) {
639
+ const refModule = inner.slice(RefTablePrefix.length, -RefSuffix.length);
640
+ if (refModule !== "") {
641
+ return { refModule, isArray };
642
+ }
643
+ }
644
+ return null;
645
+ }
513
646
  // Parse Template[module] (single) and Template[module][] (array of templated
514
647
  // row references). "Template[" starts with 'T', so it never collides with the
515
648
  // "Ref[" / "RefEnum[" prefixes.
@@ -636,6 +769,13 @@ function GetFileExt(filePath) {
636
769
  const pathObject = path.parse(filePath);
637
770
  return pathObject.ext.slice(1);
638
771
  }
772
+ // Read a config file and strip the UTF-8 BOM Excel likes to add.
773
+ function readConfigFile(filePath) {
774
+ let fileString = fs.readFileSync(filePath).toString();
775
+ if (fileString.charAt(0) === "\uFEFF")
776
+ fileString = fileString.substr(1);
777
+ return fileString;
778
+ }
639
779
  function scanDir(dir, basePath) {
640
780
  const node = {};
641
781
  let hasContent = false;
@@ -754,14 +894,10 @@ function assets2ts(assetsDir) {
754
894
  return template;
755
895
  }
756
896
  function GetTsString(filePath) {
757
- const pathObject = path.parse(filePath);
758
897
  const handle = exports.Convert[GetFileExt(filePath)];
759
898
  if (handle) {
760
- const moduleName = changeCase.pascalCase(pathObject.base);
761
- let fileString = fs.readFileSync(filePath).toString();
762
- if (fileString.charAt(0) === "\uFEFF")
763
- fileString = fileString.substr(1);
764
- return handle(fileString, moduleName);
899
+ const moduleName = changeCase.pascalCase(path.parse(filePath).base);
900
+ return handle(readConfigFile(filePath), moduleName);
765
901
  }
766
902
  else {
767
903
  return "";
@@ -770,23 +906,17 @@ function GetTsString(filePath) {
770
906
  // Collect the config file names a CSV references from its type row
771
907
  // (Ref[file], RefEnum[file.field], Template[file] and their array forms).
772
908
  // ini/toml files never reference other config tables.
773
- function collectCsvReferences(filePath) {
774
- if (GetFileExt(filePath) !== "csv") {
775
- return [];
776
- }
777
- let fileString = fs.readFileSync(filePath).toString();
778
- if (fileString.charAt(0) === "\uFEFF") {
779
- fileString = fileString.substr(1);
780
- }
909
+ function collectCsvReferencesFromContent(csvString) {
781
910
  const references = [];
782
- (0, d3_dsv_1.csvParse)(fileString, function (d, i) {
911
+ (0, d3_dsv_1.csvParse)(csvString, function (d, i) {
783
912
  if (i === 0) {
784
913
  for (const k in d) {
785
914
  const typeStr = d[k];
786
915
  const ref = parseRef(typeStr);
787
916
  const refEnum = parseRefEnum(typeStr);
917
+ const refTable = parseRefTable(typeStr);
788
918
  const template = parseTemplate(typeStr);
789
- const target = ref ? ref.refModule : refEnum ? refEnum.refModule : template ? template.refModule : null;
919
+ const target = ref ? ref.refModule : refEnum ? refEnum.refModule : refTable ? refTable.refModule : template ? template.refModule : null;
790
920
  if (target && !references.includes(target)) {
791
921
  references.push(target);
792
922
  }
@@ -796,31 +926,263 @@ function collectCsvReferences(filePath) {
796
926
  });
797
927
  return references;
798
928
  }
799
- // Order files so every referenced table is emitted before the table that
929
+ // Resolve a table reference to a full unit name. References are written
930
+ // without the extension by default ("Ref[skill]" -> "skill.csv"), matching
931
+ // how table names read in generated code (pascalCase("skill.csv") ===
932
+ // "SkillCsv"). Exact unit names still win, so legacy refs that spell out the
933
+ // extension ("Ref[skill.csv]") keep working; a stem shared by several units
934
+ // (e.g. skill.csv + skill.ini) is ambiguous and reported.
935
+ function resolveTableName(ref, names) {
936
+ const nameList = Array.from(names);
937
+ if (nameList.includes(ref)) {
938
+ return ref;
939
+ }
940
+ const stemMatches = nameList.filter((name) => path.parse(name).name === ref);
941
+ if (stemMatches.length === 1) {
942
+ return stemMatches[0];
943
+ }
944
+ if (stemMatches.length > 1) {
945
+ console.warn(`[config2ts] warning: table reference "${ref}" is ambiguous (${stemMatches.join(", ")}), write the full file name in the ref`);
946
+ }
947
+ return null;
948
+ }
949
+ // Rewrite the type row's table references from stem form to full unit names,
950
+ // so downstream steps (namespace derivation, import lines, dependency
951
+ // sorting) keep matching refs against unit names exactly. The content is only
952
+ // re-serialized when something actually changed.
953
+ function normalizeCsvRefs(content, allNames) {
954
+ if (allNames.size === 0) {
955
+ return content;
956
+ }
957
+ const rows = (0, d3_dsv_1.csvParseRows)(content);
958
+ if (rows.length < 2) {
959
+ return content;
960
+ }
961
+ let changed = false;
962
+ rows[1] = rows[1].map((cell) => {
963
+ const ref = parseRef(cell);
964
+ if (ref) {
965
+ const resolved = resolveTableName(ref.refModule, allNames);
966
+ if (resolved) {
967
+ changed = true;
968
+ return `Ref[${resolved}]` + (ref.isArray ? "[]" : "");
969
+ }
970
+ return cell;
971
+ }
972
+ const refEnum = parseRefEnum(cell);
973
+ if (refEnum) {
974
+ const resolved = resolveTableName(refEnum.refModule, allNames);
975
+ if (resolved) {
976
+ changed = true;
977
+ return `RefEnum[${resolved}.${refEnum.refField}]` + (refEnum.isArray ? "[]" : "");
978
+ }
979
+ return cell;
980
+ }
981
+ const refTable = parseRefTable(cell);
982
+ if (refTable) {
983
+ const resolved = resolveTableName(refTable.refModule, allNames);
984
+ if (resolved) {
985
+ changed = true;
986
+ return `RefTable[${resolved}]` + (refTable.isArray ? "[]" : "");
987
+ }
988
+ return cell;
989
+ }
990
+ const template = parseTemplate(cell);
991
+ if (template) {
992
+ const resolved = resolveTableName(template.refModule, allNames);
993
+ if (resolved) {
994
+ changed = true;
995
+ return `Template[${resolved}]` + (template.isArray ? "[]" : "");
996
+ }
997
+ return cell;
998
+ }
999
+ return cell;
1000
+ });
1001
+ return changed ? (0, d3_dsv_1.csvFormatRows)(rows) : content;
1002
+ }
1003
+ function makeFileUnit(filePath, allNames) {
1004
+ const name = path.basename(filePath);
1005
+ const ext = GetFileExt(name);
1006
+ const content = readConfigFile(filePath);
1007
+ const normalized = ext === "csv" && allNames ? normalizeCsvRefs(content, allNames) : content;
1008
+ return {
1009
+ name,
1010
+ ext,
1011
+ content: normalized,
1012
+ sourcePaths: [filePath],
1013
+ refs: ext === "csv" ? collectCsvReferencesFromContent(normalized) : [],
1014
+ };
1015
+ }
1016
+ // A subdirectory whose convertible files are ALL csv and share identical
1017
+ // header + type rows is merged into one virtual table named after the
1018
+ // directory ("<dir>.csv" -> pascalCase -> "<Dir>Csv"). Data rows are
1019
+ // concatenated in file-name order, so per-chapter/per-language splits stay
1020
+ // manageable on disk while the runtime sees a single table.
1021
+ function tryMergeSubdir(dir, dirName, allNames) {
1022
+ const entries = fs.readdirSync(dir, { withFileTypes: true }).filter((e) => e.isFile());
1023
+ const files = entries
1024
+ .map((e) => path.join(dir, e.name))
1025
+ .filter((f) => exports.Convert[GetFileExt(f)] != null)
1026
+ .sort();
1027
+ if (files.length === 0) {
1028
+ console.warn(`[config2ts] warning: directory "${dirName}" contains no convertible config files, skipped`);
1029
+ return null;
1030
+ }
1031
+ const nonCsv = files.filter((f) => GetFileExt(f) !== "csv");
1032
+ if (nonCsv.length > 0) {
1033
+ console.warn(`[config2ts] warning: directory "${dirName}" mixes non-csv files (${nonCsv.map((f) => path.basename(f)).join(", ")}), cannot merge into one table, skipped`);
1034
+ return null;
1035
+ }
1036
+ const contents = files.map(readConfigFile);
1037
+ const headRows = contents.map((c) => (0, d3_dsv_1.csvParseRows)(c).slice(0, 2));
1038
+ const headKey = JSON.stringify(headRows[0]);
1039
+ const uniform = headRows.every((rows) => rows.length === 2 && JSON.stringify(rows) === headKey);
1040
+ if (!uniform) {
1041
+ console.warn(`[config2ts] warning: directory "${dirName}" contains csv files with different header/type rows, cannot merge into one table, skipped`);
1042
+ return null;
1043
+ }
1044
+ // Keep the first file's header + type rows once; every following file
1045
+ // contributes its data rows only.
1046
+ const allRows = [
1047
+ ...(0, d3_dsv_1.csvParseRows)(contents[0]),
1048
+ ...contents.slice(1).flatMap((c) => (0, d3_dsv_1.csvParseRows)(c).slice(2)),
1049
+ ];
1050
+ const mergedContent = (0, d3_dsv_1.csvFormatRows)(allRows);
1051
+ const keyType = headRows[0][1][0];
1052
+ if (keyType === IndexStr || keyType === EnumIndexStr) {
1053
+ const seen = new Set();
1054
+ for (let i = 2; i < allRows.length; i++) {
1055
+ const key = allRows[i][0];
1056
+ if (key !== "" && seen.has(key)) {
1057
+ console.warn(`[config2ts] warning: merged table "${dirName}" has duplicate ${keyType} key "${key}"`);
1058
+ }
1059
+ seen.add(key);
1060
+ }
1061
+ }
1062
+ const name = dirName.endsWith(".csv") ? dirName : dirName + ".csv";
1063
+ const subTables = files.map((f, i) => ({
1064
+ name: path.parse(f).name,
1065
+ rowCount: Math.max(0, (0, d3_dsv_1.csvParseRows)(contents[i]).length - 2),
1066
+ }));
1067
+ const mergedRefsContent = allNames ? normalizeCsvRefs(mergedContent, allNames) : mergedContent;
1068
+ return {
1069
+ name,
1070
+ ext: "csv",
1071
+ content: mergedRefsContent,
1072
+ sourcePaths: files,
1073
+ refs: collectCsvReferencesFromContent(mergedRefsContent),
1074
+ subTables,
1075
+ };
1076
+ }
1077
+ // RefTable cells must name an existing member file of the target merged
1078
+ // table. The generated FileMap is keyed by a literal union, so a wrong name
1079
+ // also fails tsc; checking here surfaces the mistake at conversion time with
1080
+ // the valid keys listed, and without needing a compile step.
1081
+ function validateRefTableValues(units) {
1082
+ var _a;
1083
+ const unitByName = new Map();
1084
+ for (const unit of units) {
1085
+ unitByName.set(unit.name, unit);
1086
+ }
1087
+ for (const unit of units) {
1088
+ if (unit.ext !== "csv")
1089
+ continue;
1090
+ const rows = (0, d3_dsv_1.csvParseRows)(unit.content);
1091
+ if (rows.length < 2)
1092
+ continue;
1093
+ const headerRow = rows[0];
1094
+ const typeRow = rows[1];
1095
+ for (let column = 0; column < typeRow.length; column++) {
1096
+ const refTable = parseRefTable(typeRow[column]);
1097
+ if (!refTable)
1098
+ continue;
1099
+ const target = unitByName.get(refTable.refModule);
1100
+ if (!target)
1101
+ continue;
1102
+ if (!target.subTables) {
1103
+ // The target table exists but is a plain file, so it has no FileMap /
1104
+ // refTable() at all — the generated call would fail tsc with a
1105
+ // confusing "property does not exist" error, so say it plainly here.
1106
+ console.warn(`[config2ts] warning: ${unit.name} field "${headerRow[column]}" uses RefTable[${refTable.refModule}], but "${target.name}" is not a merged subdirectory table (it has no sub-tables)`);
1107
+ continue;
1108
+ }
1109
+ const validKeys = new Set(target.subTables.map((subTable) => subTable.name));
1110
+ for (let rowIndex = 2; rowIndex < rows.length; rowIndex++) {
1111
+ const cell = normalizeCell((_a = rows[rowIndex][column]) !== null && _a !== void 0 ? _a : "");
1112
+ const names = refTable.isArray ? splitStringArray(cell) : cell === "" ? [] : [cell];
1113
+ for (const name of names) {
1114
+ if (name !== "" && !validKeys.has(name)) {
1115
+ // Row numbers follow the other csv warnings: the type row is 0,
1116
+ // so the first data row (csvParseRows index 2) is row 1.
1117
+ console.warn(`[config2ts] warning: ${unit.name} row ${rowIndex - 1} field "${headerRow[column]}" references sub-table "${name}" which does not exist in "${target.name}" (valid: ${Array.from(validKeys).join(", ")})`);
1118
+ }
1119
+ }
1120
+ }
1121
+ }
1122
+ }
1123
+ }
1124
+ // Resolve a config directory into convert units: every convertible file
1125
+ // becomes one unit; every subdirectory is merged into a single unit when its
1126
+ // csv files are homogeneous (warned and skipped otherwise). Unit names are
1127
+ // predicted first, so extension-less table refs in the type rows can be
1128
+ // resolved before dependency collection.
1129
+ function collectConvertUnits(dir) {
1130
+ const entries = fs.readdirSync(dir, { withFileTypes: true }).sort((a, b) => (a.name < b.name ? -1 : 1));
1131
+ const allNames = new Set();
1132
+ for (const entry of entries) {
1133
+ if (entry.isFile()) {
1134
+ if (exports.Convert[GetFileExt(entry.name)] == null)
1135
+ continue;
1136
+ allNames.add(entry.name);
1137
+ }
1138
+ else if (entry.isDirectory()) {
1139
+ allNames.add(entry.name.endsWith(".csv") ? entry.name : entry.name + ".csv");
1140
+ }
1141
+ }
1142
+ const units = [];
1143
+ const takenNames = new Set();
1144
+ const take = function (unit) {
1145
+ if (takenNames.has(unit.name)) {
1146
+ console.warn(`[config2ts] warning: duplicate table name "${unit.name}", the generated namespaces will collide`);
1147
+ }
1148
+ takenNames.add(unit.name);
1149
+ units.push(unit);
1150
+ };
1151
+ for (const entry of entries) {
1152
+ const full = path.join(dir, entry.name);
1153
+ if (entry.isFile()) {
1154
+ if (exports.Convert[GetFileExt(entry.name)] == null)
1155
+ continue;
1156
+ take(makeFileUnit(full, allNames));
1157
+ }
1158
+ else if (entry.isDirectory()) {
1159
+ const merged = tryMergeSubdir(full, entry.name, allNames);
1160
+ if (merged)
1161
+ take(merged);
1162
+ }
1163
+ }
1164
+ validateRefTableValues(units);
1165
+ return units;
1166
+ }
1167
+ // Order units so every referenced table is emitted before the table that
800
1168
  // references it (a referenced namespace must exist when the referencing
801
1169
  // table's data initializes), so authors no longer need filename tricks (e.g.
802
- // an "!" prefix) to control order. Files without a dependency relation keep
803
- // their alphabetical order. References to missing files and circular
1170
+ // an "!" prefix) to control order. Units without a dependency relation keep
1171
+ // their alphabetical order. References to missing tables and circular
804
1172
  // references are reported; a cycle is broken at the back edge so output is
805
1173
  // still generated (the runtime data still needs the cycle fixed by hand).
806
- function sortFilesByReferences(fileList) {
807
- const ordered = fileList.slice().sort((a, b) => {
808
- const baseA = path.basename(a);
809
- const baseB = path.basename(b);
810
- return baseA < baseB ? -1 : baseA > baseB ? 1 : 0;
811
- });
812
- const fileByBase = new Map();
813
- for (const filePath of ordered) {
814
- fileByBase.set(path.basename(filePath), filePath);
1174
+ function sortUnitsByReferences(units) {
1175
+ const ordered = units.slice().sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
1176
+ const unitByName = new Map();
1177
+ for (const unit of ordered) {
1178
+ unitByName.set(unit.name, unit);
815
1179
  }
816
- const depsByBase = new Map();
817
- for (const filePath of ordered) {
818
- const base = path.basename(filePath);
819
- const deps = collectCsvReferences(filePath);
820
- depsByBase.set(base, deps);
821
- for (const dep of deps) {
822
- if (!fileByBase.has(dep)) {
823
- console.warn(`[config2ts] warning: ${base} references "${dep}" but no config file with that name was found in the config directory`);
1180
+ const depsByName = new Map();
1181
+ for (const unit of ordered) {
1182
+ depsByName.set(unit.name, unit.refs);
1183
+ for (const dep of unit.refs) {
1184
+ if (!unitByName.has(dep)) {
1185
+ console.warn(`[config2ts] warning: ${unit.name} references "${dep}" but no config file with that name was found in the config directory`);
824
1186
  }
825
1187
  }
826
1188
  }
@@ -828,62 +1190,76 @@ function sortFilesByReferences(fileList) {
828
1190
  // 0 = unvisited, 1 = visiting (on the current DFS trail), 2 = done
829
1191
  const state = new Map();
830
1192
  const reportedCycles = new Set();
831
- function visit(base, trail) {
1193
+ function visit(name, trail) {
832
1194
  var _a, _b;
833
- const current = (_a = state.get(base)) !== null && _a !== void 0 ? _a : 0;
1195
+ const current = (_a = state.get(name)) !== null && _a !== void 0 ? _a : 0;
834
1196
  if (current === 2) {
835
1197
  return;
836
1198
  }
837
1199
  if (current === 1) {
838
- const cycleStart = trail.indexOf(base);
839
- const cycleKey = trail.slice(cycleStart).concat(base).join(" -> ");
1200
+ const cycleStart = trail.indexOf(name);
1201
+ const cycleKey = trail.slice(cycleStart).concat(name).join(" -> ");
840
1202
  if (!reportedCycles.has(cycleKey)) {
841
1203
  reportedCycles.add(cycleKey);
842
1204
  console.warn(`[config2ts] warning: circular table reference detected: ${cycleKey}; tables in the cycle may reference an undefined namespace at runtime, please break the reference cycle`);
843
1205
  }
844
1206
  return;
845
1207
  }
846
- state.set(base, 1);
847
- trail.push(base);
848
- for (const dep of (_b = depsByBase.get(base)) !== null && _b !== void 0 ? _b : []) {
849
- if (fileByBase.has(dep)) {
1208
+ state.set(name, 1);
1209
+ trail.push(name);
1210
+ for (const dep of (_b = depsByName.get(name)) !== null && _b !== void 0 ? _b : []) {
1211
+ if (unitByName.has(dep)) {
850
1212
  visit(dep, trail);
851
1213
  }
852
1214
  }
853
1215
  trail.pop();
854
- state.set(base, 2);
855
- sorted.push(fileByBase.get(base));
1216
+ state.set(name, 2);
1217
+ sorted.push(unitByName.get(name));
856
1218
  }
857
- for (const filePath of ordered) {
858
- visit(path.basename(filePath), []);
1219
+ for (const unit of ordered) {
1220
+ visit(unit.name, []);
859
1221
  }
860
1222
  return sorted;
861
1223
  }
862
- function GetTsStringFromFileList(fileList) {
863
- return sortFilesByReferences(fileList)
864
- .map(function (filePath) {
865
- return GetTsString(filePath);
866
- })
1224
+ function renderUnit(unit) {
1225
+ const moduleName = changeCase.pascalCase(unit.name);
1226
+ // Merged subdirectories render through the sub-table-aware path so the
1227
+ // generated namespace also exposes FileMap (file name -> rows).
1228
+ if (unit.ext === "csv" && unit.subTables && unit.subTables.length > 0) {
1229
+ return csv2ts(unit.content, moduleName, unit.subTables);
1230
+ }
1231
+ const handle = exports.Convert[unit.ext];
1232
+ if (!handle) {
1233
+ return "";
1234
+ }
1235
+ return handle(unit.content, moduleName);
1236
+ }
1237
+ function GetTsStringFromUnits(units) {
1238
+ return sortUnitsByReferences(units)
1239
+ .map(renderUnit)
867
1240
  .join("\n\n");
868
1241
  }
1242
+ function GetTsStringFromFileList(fileList) {
1243
+ const allNames = new Set(fileList.map((filePath) => path.basename(filePath)));
1244
+ return GetTsStringFromUnits(fileList.map((filePath) => makeFileUnit(filePath, allNames)));
1245
+ }
869
1246
  function GetValidFileList(fileList) {
870
1247
  return fileList.filter(function (filePath) {
871
1248
  return exports.Convert[GetFileExt(filePath)] != null;
872
1249
  });
873
1250
  }
874
1251
  // Build the `import { XxxCsv } from "./xxx";` lines a split-mode file needs so
875
- // its cross-table Ref/RefEnum/Template references resolve. `collectCsvReferences`
876
- // returns the full base filenames (e.g. "z-base.csv") of referenced tables, which
877
- // map 1:1 to the split output file "./z-base.ts" and to the namespace
1252
+ // its cross-table Ref/RefEnum/Template references resolve. `unit.refs` holds the
1253
+ // full base names (e.g. "z-base.csv") of referenced tables, which map 1:1 to
1254
+ // the split output file "./z-base.ts" and to the namespace
878
1255
  // `pascalCase("z-base.csv")` === "ZBaseCsv".
879
- function buildImportLines(filePath, validBases) {
880
- const base = path.basename(filePath);
881
- const refs = collectCsvReferences(filePath).filter((ref) => ref !== base);
1256
+ function buildImportLines(unit, validNames) {
1257
+ const refs = unit.refs.filter((ref) => ref !== unit.name);
882
1258
  const imported = new Set();
883
1259
  const lines = [];
884
1260
  for (const ref of refs) {
885
- if (!validBases.has(ref)) {
886
- continue; // missing referenced table; already warned by sortFilesByReferences
1261
+ if (!validNames.has(ref)) {
1262
+ continue; // missing referenced table; already warned by sortUnitsByReferences
887
1263
  }
888
1264
  if (imported.has(ref)) {
889
1265
  continue;
@@ -896,24 +1272,22 @@ function buildImportLines(filePath, validBases) {
896
1272
  return lines.join("\n");
897
1273
  }
898
1274
  function startConvert(dir, outDir, merge, assetsDir, mode = "single") {
899
- const fileList = fs.readdirSync(dir);
900
- const fullFileList = GetValidFileList(fileList).map(function (filename) {
901
- return path.join(dir, filename);
902
- });
1275
+ const units = collectConvertUnits(dir);
1276
+ const fileCount = units.reduce((n, u) => n + u.sourcePaths.length, 0);
903
1277
  if (mode === "split") {
904
- const validBases = new Set(fullFileList.map((f) => path.basename(f)));
905
- const orderedFiles = sortFilesByReferences(fullFileList);
1278
+ const validNames = new Set(units.map((u) => u.name));
1279
+ const orderedUnits = sortUnitsByReferences(units);
906
1280
  const exportedNames = [];
907
- for (const filePath of orderedFiles) {
908
- const base = path.basename(filePath);
909
- const outName = path.parse(base).name + ".ts";
910
- exportedNames.push(path.parse(base).name);
911
- const imports = buildImportLines(filePath, validBases);
912
- const body = GetTsString(filePath);
1281
+ for (const unit of orderedUnits) {
1282
+ const outName = path.parse(unit.name).name + ".ts";
1283
+ exportedNames.push(path.parse(unit.name).name);
1284
+ const imports = buildImportLines(unit, validNames);
1285
+ const body = renderUnit(unit);
913
1286
  const content = AutoGenHeader + (imports ? imports + "\n\n" : "") + body + "\n";
914
1287
  const outPath = path.join(outDir, outName);
915
1288
  fs.writeFileSync(outPath, content, { encoding: "utf-8" });
916
- console.log(`config2ts, ${base} -> ${outPath}`);
1289
+ const source = unit.sourcePaths.length > 1 ? `${unit.name} (${unit.sourcePaths.length} files)` : path.basename(unit.sourcePaths[0]);
1290
+ console.log(`config2ts, ${source} -> ${outPath}`);
917
1291
  }
918
1292
  let assetsExport = null;
919
1293
  if (assetsDir) {
@@ -937,12 +1311,12 @@ function startConvert(dir, outDir, merge, assetsDir, mode = "single") {
937
1311
  }
938
1312
  const indexPath = path.join(outDir, indexName);
939
1313
  fs.writeFileSync(indexPath, indexContent, { encoding: "utf-8" });
940
- console.log(`config2ts, ${orderedFiles.length} config files, split mode, index: ${indexPath}`);
1314
+ console.log(`config2ts, ${orderedUnits.length} config tables (${fileCount} files), split mode, index: ${indexPath}`);
941
1315
  return;
942
1316
  }
943
1317
  const mergeFile = path.join(outDir, merge);
944
- fs.writeFileSync(mergeFile, AutoGenHeader + GetTsStringFromFileList(fullFileList), { encoding: "utf-8" });
945
- console.log(`config2ts, ${fullFileList.length} config files, merge into: ${mergeFile}`);
1318
+ fs.writeFileSync(mergeFile, AutoGenHeader + GetTsStringFromUnits(units), { encoding: "utf-8" });
1319
+ console.log(`config2ts, ${units.length} config tables (${fileCount} files), merge into: ${mergeFile}`);
946
1320
  if (assetsDir) {
947
1321
  const assetsOutput = assets2ts(assetsDir);
948
1322
  if (assetsOutput) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "config2ts",
3
- "version": "2.9.0",
3
+ "version": "2.10.0",
4
4
  "description": "Generate type-safe TypeScript from CSV/INI/TOML config tables — inferred types, enums, cross-table references, and a typed asset index, all from one CLI command.",
5
5
  "main": "lib/config2ts.js",
6
6
  "types": "lib/config2ts.d.ts",
@@ -13,9 +13,12 @@
13
13
  ],
14
14
  "scripts": {
15
15
  "build": "tsc",
16
- "convert": "node ./bin/config2ts -n total.ts -d ./config -o ./config",
17
- "test": "jest",
18
- "watch-test": "node_modules/.bin/jest --watch"
16
+ "fixtures:regen": "npm run build && node ./scripts/regen-fixtures.js",
17
+ "demo:dev": "npm --prefix website run dev",
18
+ "demo:build": "npm --prefix website run build",
19
+ "pretest": "node -e \"require('fs').rmSync('.test-build',{recursive:true,force:true})\"",
20
+ "test": "npm run build && tsc -p tsconfig.test.json && node --test \".test-build/tests/*.test.js\"",
21
+ "test:coverage": "npm run build && tsc -p tsconfig.test.json && node --test --experimental-test-coverage \".test-build/tests/*.test.js\""
19
22
  },
20
23
  "repository": {
21
24
  "type": "git",
@@ -39,19 +42,7 @@
39
42
  "toml": "^4.1.2"
40
43
  },
41
44
  "devDependencies": {
42
- "@types/jest": "^30.0.0",
43
45
  "@types/node": "^24.5.2",
44
- "jest": "^30.5.1",
45
- "ts-jest": "^29.4.12",
46
46
  "typescript": "^5.9.2"
47
- },
48
- "overrides": {
49
- "@babel/core": "^7.29.6",
50
- "browserslist": "^4.28.7",
51
- "handlebars": "^4.7.9",
52
- "js-yaml": "^3.14.2",
53
- "minimatch@^3.0.0": "^3.1.3",
54
- "picomatch@^2.0.0": "^2.3.2",
55
- "brace-expansion@^1.0.0": "^1.1.16"
56
47
  }
57
48
  }