@zntc/core 0.1.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.
@@ -0,0 +1,473 @@
1
+ /**
2
+ * ZNTC CLI flag registry + parsing helpers.
3
+ *
4
+ * `zntc.mjs` 의 `parseArgs` 가 60+ flag 를 hand-rolled if-chain 으로 처리하던 것을
5
+ * 메타데이터로 통합. 새 flag 추가 시 `FLAG_REGISTRY` 에 entry 한 줄만 추가하면
6
+ * `parseArgs` / `KNOWN_FLAGS` / typo-suggest / schema-sync 모두 자동 합류.
7
+ *
8
+ * 별도 파일로 분리한 이유: `zntc.mjs` 의 top-level `await import("../dist/index.js")` 가
9
+ * NAPI 바인딩을 로드하므로 테스트가 `zntc.mjs` 를 직접 import 하기 어렵다. registry 를
10
+ * 분리해 schema-sync 테스트가 정적 정규식 파싱 대신 실제 export 를 import 하도록 함
11
+ * (formatter 가 spec 을 multi-line 으로 reformat 해도 회귀 없음).
12
+ *
13
+ * spec 필드:
14
+ * kind: flag 의 의미 종류 (아래 종류 표 참조)
15
+ * flag: canonical flag (`--bundle`, `--outfile`)
16
+ * target: opts 객체의 key (`bundle`, `outfile`)
17
+ * forms: ["equal"] (`--key=val` 만), ["pair"] (`--key val` 만), ["equal","pair"] (둘 다)
18
+ * kind 별 기본값 차이: bool/array/key-value/ns-array/enum-bool/string-bool 은 무시,
19
+ * string/int 는 ["equal","pair"], csv 는 ["equal","pair"], ns-string 은 ["equal"].
20
+ * aliases: 추가 flag 이름 (`-o`, `--tsconfig-path`).
21
+ * extra: 부수효과 — 매칭 시 함께 set 할 키-값 (`{ watch: true }`).
22
+ * default: bool 단독 등장 시 default 값 (kind="string-default" 의 metafile 패턴).
23
+ * value: bool flag 가 설정할 값. 생략 시 true (`--no-*` alias 는 false 지정).
24
+ * enum: enum→bool 매핑 (`{ utf8: true }` → `--charset=utf8` 시 charsetUtf8=true).
25
+ * noop: true 면 매칭만 하고 set 안 함 (deprecated/TODO flag).
26
+ *
27
+ * kind:
28
+ * bool — boolean toggle. 정확 매칭만.
29
+ * string — string scalar.
30
+ * int — parseInt.
31
+ * array — array push (반복 지정 시 누적). `--external foo --external bar` → ["foo","bar"].
32
+ * csv — comma 분리 → array. `--env-prefix=A,B` → ["A","B"]. forms 기본 ["equal","pair"].
33
+ * key-value — colon prefix `--key:K=V` → opts[target][K] = V. `--define:NODE_ENV=prod`.
34
+ * ns-array — colon prefix `--key:VALUE` → opts[target].push(VALUE). `--inject:./prelude`.
35
+ * ns-string — colon prefix `--key:NS=VALUE` → opts[target] = VALUE (namespace 무시).
36
+ * `--banner:js=text` 가 `--banner=text` 와 동일 의미 (esbuild 호환).
37
+ * string-default — bool 단독 시 default 값, `--key=val` 시 val 사용. `--metafile` 패턴.
38
+ * enum-bool — `--key=enumValue` 시 spec.enum 의 매핑 적용. `--charset=utf8` → charsetUtf8=true.
39
+ * string-bool — `--key=anything` → true, `--key=false` → false. default=true 옵션의 toggle.
40
+ */
41
+ export const FLAG_REGISTRY = [
42
+ // ─── kind=bool — boolean toggle ───
43
+ { kind: 'bool', flag: '--help', target: 'help', aliases: ['-h'] },
44
+ // 버전 출력 후 즉시 종료 (config/NAPI 로드 불필요 — main 에서 early dispatch).
45
+ { kind: 'bool', flag: '--version', target: 'version', aliases: ['-v'] },
46
+ // config 파일 자동 탐색·로드 우회 (CLI flag 만으로 빌드 — 재현/테스트). --config 보다 우선.
47
+ { kind: 'bool', flag: '--no-config', target: 'noConfig' },
48
+ // 색상 출력 강제/억제. 미지정 시 NO_COLOR/FORCE_COLOR env + TTY 자동 판정.
49
+ { kind: 'bool', flag: '--color', target: 'color' },
50
+ { kind: 'bool', flag: '--no-color', target: 'color', value: false },
51
+ { kind: 'bool', flag: '--bundle', target: 'bundle' },
52
+ { kind: 'bool', flag: '--watch', target: 'watch', aliases: ['-w'] },
53
+ { kind: 'bool', flag: '--watch-json', target: 'watchJson', extra: { watch: true } },
54
+ { kind: 'bool', flag: '--open', target: 'open' },
55
+ // RN CLI 호환 (#2605 audit P0)
56
+ { kind: 'bool', flag: '--reset-cache', target: 'resetCache' },
57
+ { kind: 'bool', flag: '--sourcemap-use-absolute-path', target: 'sourcemapUseAbsolutePath' },
58
+ // Metro UI 의 `--no-interactive` 호환 — terminal actions 비활성. RN CLI 호출 시.
59
+ { kind: 'bool', flag: '--no-interactive', target: 'noInteractive' },
60
+ { kind: 'bool', flag: '--minify', target: 'minify' },
61
+ { kind: 'bool', flag: '--minify-whitespace', target: 'minifyWhitespace' },
62
+ { kind: 'bool', flag: '--minify-identifiers', target: 'minifyIdentifiers' },
63
+ { kind: 'bool', flag: '--minify-syntax', target: 'minifySyntax' },
64
+ // `--sourcemap` 단독 → sourcemap=true + mode="linked" (default).
65
+ // `--sourcemap=inline/external/linked` → mode 명시. extra 가 sourcemap=true 같이 set.
66
+ // backward compat: 과거 boolean-only 사용자도 `--sourcemap` 그대로 동작.
67
+ {
68
+ kind: 'string-default',
69
+ flag: '--sourcemap',
70
+ target: 'sourcemapMode',
71
+ default: 'linked',
72
+ extra: { sourcemap: true },
73
+ },
74
+ { kind: 'bool', flag: '--sourcemap-debug-ids', target: 'sourcemapDebugIds' },
75
+ { kind: 'bool', flag: '--splitting', target: 'splitting' },
76
+ { kind: 'bool', flag: '--no-splitting', target: 'splitting', value: false },
77
+ // tree shaking / scope hoisting / .map 디스크 쓰기 — 엔진 기본값 true.
78
+ // string-bool toggle (`--tree-shaking=false`) + 명시적 `--no-*` bool 양쪽 제공
79
+ // (`--no-splitting` 선례와 동형). `--no-*` 는 opts=false 로 두는데, config 머지
80
+ // default=true 루프가 `opts===true` 일 때만 동작하므로 config true 가 못 덮는다.
81
+ { kind: 'bool', flag: '--no-tree-shaking', target: 'treeShaking', value: false },
82
+ { kind: 'bool', flag: '--no-scope-hoist', target: 'scopeHoist', value: false },
83
+ { kind: 'bool', flag: '--no-emit-disk-sourcemap', target: 'emitDiskSourcemap', value: false },
84
+ { kind: 'bool', flag: '--analyze', target: 'analyze', extra: { metafile: 'meta.json' } },
85
+ { kind: 'bool', flag: '--flow', target: 'flow' },
86
+ { kind: 'bool', flag: '--experimental-decorators', target: 'experimentalDecorators' },
87
+ { kind: 'bool', flag: '--emit-decorator-metadata', target: 'emitDecoratorMetadata' },
88
+ { kind: 'bool', flag: '--jsx-in-js', target: 'jsxInJs' },
89
+ { kind: 'bool', flag: '--verbatim-module-syntax', target: 'verbatimModuleSyntax' },
90
+ { kind: 'bool', flag: '--keep-names', target: 'keepNames' },
91
+ { kind: 'bool', flag: '--shim-missing-exports', target: 'shimMissingExports' },
92
+ { kind: 'bool', flag: '--ascii-only', target: 'asciiOnly' },
93
+ { kind: 'bool', flag: '--preserve-modules', target: 'preserveModules' },
94
+ { kind: 'bool', flag: '--inline-dynamic-imports', target: 'inlineDynamicImports' },
95
+ { kind: 'bool', flag: '--preserve-symlinks', target: 'preserveSymlinks' },
96
+ { kind: 'bool', flag: '--resolve-symlink-siblings', target: 'resolveSymlinkSiblings' },
97
+ { kind: 'bool', flag: '--disable-hierarchical-lookup', target: 'disableHierarchicalLookup' },
98
+ { kind: 'bool', flag: '--jsx-dev', target: 'jsxDev' },
99
+ { kind: 'bool', flag: '--clean', target: 'clean' },
100
+ { kind: 'bool', flag: '--strict-port', target: 'strictPort' },
101
+ { kind: 'bool', flag: '--allow-overwrite', target: 'allowOverwrite' },
102
+ { kind: 'bool', flag: '--jsx-side-effects', target: 'jsxSideEffects' },
103
+ { kind: 'bool', flag: '--ignore-annotations', target: 'ignoreAnnotations' },
104
+ // dev 모드 — HMR 런타임 주입 등 dev 동작 활성화. NAPI BuildOptions.devMode 와 동일.
105
+ { kind: 'bool', flag: '--dev', target: 'devMode' },
106
+
107
+ // ─── kind=string — string scalar (`--key=value` 단방향) ───
108
+ { kind: 'string', flag: '--format', target: 'format', forms: ['equal'] },
109
+ { kind: 'string', flag: '--platform', target: 'platform', forms: ['equal'] },
110
+ { kind: 'string', flag: '--jsx', target: 'jsx', forms: ['equal'] },
111
+ { kind: 'string', flag: '--jsx-factory', target: 'jsxFactory', forms: ['equal'] },
112
+ { kind: 'string', flag: '--jsx-fragment', target: 'jsxFragment', forms: ['equal'] },
113
+ { kind: 'string', flag: '--jsx-import-source', target: 'jsxImportSource', forms: ['equal'] },
114
+ { kind: 'string', flag: '--global-name', target: 'globalName', forms: ['equal'] },
115
+ { kind: 'string', flag: '--public-path', target: 'publicPath', forms: ['equal'] },
116
+ { kind: 'string', flag: '--entry-names', target: 'entryNames', forms: ['equal'] },
117
+ { kind: 'string', flag: '--chunk-names', target: 'chunkNames', forms: ['equal'] },
118
+ { kind: 'string', flag: '--asset-names', target: 'assetNames', forms: ['equal'] },
119
+ { kind: 'string', flag: '--quotes', target: 'quotes', forms: ['equal'] },
120
+ { kind: 'string', flag: '--log-level', target: 'logLevel', forms: ['equal'] },
121
+ { kind: 'string', flag: '--legal-comments', target: 'legalComments', forms: ['equal'] },
122
+ {
123
+ kind: 'string',
124
+ flag: '--preserve-modules-root',
125
+ target: 'preserveModulesRoot',
126
+ forms: ['equal'],
127
+ },
128
+ { kind: 'string', flag: '--rn-platform', target: 'rnPlatform', forms: ['equal', 'pair'] },
129
+ // #2540 PR #7 — RN preset 의 projectRoot. 기본 cwd, 사용자 monorepo root 지정 시 사용.
130
+ { kind: 'string', flag: '--rn-project-root', target: 'rnProjectRoot', forms: ['equal', 'pair'] },
131
+ // ─── RN CLI 호환 flag 매트릭스 (#2605 audit P0) ───
132
+ // `react-native bundle` / Metro CLI 의 standard flag 들 — `zntc bundle --platform=react-native`
133
+ // 가 dropin 으로 동작하기 위한 호환 layer. zntc 내부 옵션 (outfile 등) 과 별개로 받아 매핑.
134
+ { kind: 'string', flag: '--bundle-output', target: 'bundleOutput' },
135
+ { kind: 'string', flag: '--sourcemap-output', target: 'sourcemapOutput' },
136
+ { kind: 'string', flag: '--source-map-url', target: 'sourceMapUrl' },
137
+ { kind: 'string', flag: '--sourcemap-sources-root', target: 'sourcemapSourcesRoot' },
138
+ { kind: 'string', flag: '--assets-dest', target: 'assetsDest' },
139
+ { kind: 'string', flag: '--asset-catalog-dest', target: 'assetCatalogDest' },
140
+ { kind: 'string', flag: '--bundle-encoding', target: 'bundleEncoding' },
141
+ { kind: 'string', flag: '--unstable-transform-profile', target: 'unstableTransformProfile' },
142
+ { kind: 'string', flag: '--source-root', target: 'sourceRoot', forms: ['equal'] },
143
+ // #2159 — `--output-exports=auto|named|default|none` (Rollup output.exports 호환).
144
+ { kind: 'string', flag: '--output-exports', target: 'outputExports', forms: ['equal'] },
145
+ { kind: 'string', flag: '--banner', target: 'banner', forms: ['equal'] },
146
+ { kind: 'string', flag: '--footer', target: 'footer', forms: ['equal'] },
147
+ { kind: 'string', flag: '--intro', target: 'intro', forms: ['equal'] },
148
+ { kind: 'string', flag: '--outro', target: 'outro', forms: ['equal'] },
149
+ { kind: 'string', flag: '--profile-level', target: 'profileLevel', forms: ['equal'] },
150
+ { kind: 'string', flag: '--profile-format', target: 'profileFormat', forms: ['equal'] },
151
+ { kind: 'string', flag: '--stop-after', target: 'stopAfter', forms: ['equal'] },
152
+ { kind: 'string', flag: '--tokenize-format', target: 'tokenizeFormat', forms: ['equal'] },
153
+ { kind: 'string', flag: '--runtime-polyfills', target: 'runtimePolyfills', forms: ['equal'] },
154
+ { kind: 'string', flag: '--core-js', target: 'coreJs', forms: ['equal'] },
155
+
156
+ // ─── kind=string — `--key value` 또는 `--key=value` 둘 다 ───
157
+ { kind: 'string', flag: '--target', target: 'target' },
158
+ { kind: 'string', flag: '--browserslist', target: 'browserslist' },
159
+ { kind: 'string', flag: '--outbase', target: 'outbase' },
160
+ { kind: 'string', flag: '--outfile', target: 'outfile', aliases: ['-o'], forms: ['pair'] },
161
+ { kind: 'string', flag: '--outdir', target: 'outdir', forms: ['pair'] },
162
+ { kind: 'string', flag: '--certfile', target: 'certfile', forms: ['pair'] },
163
+ { kind: 'string', flag: '--keyfile', target: 'keyfile', forms: ['pair'] },
164
+ // tsc-style alias (`-p`, `--project`) + NAPI naming alias (`--tsconfig-path`).
165
+ { kind: 'string', flag: '--project', target: 'project', aliases: ['-p', '--tsconfig-path'] },
166
+ { kind: 'string', flag: '--tsconfig-raw', target: 'tsconfigRaw', forms: ['equal'] },
167
+ { kind: 'string', flag: '--config', target: 'configPath' },
168
+ { kind: 'string', flag: '--mode', target: 'mode' },
169
+ { kind: 'string', flag: '--workspace-config', target: 'workspaceConfig' },
170
+ { kind: 'string', flag: '--workspace', target: 'workspace' },
171
+ { kind: 'string', flag: '--env-dir', target: 'envDir' },
172
+ { kind: 'string', flag: '--entry-html', target: 'entryHtml' },
173
+ { kind: 'string', flag: '--public-dir', target: 'publicDir' },
174
+ { kind: 'string', flag: '--base', target: 'base' },
175
+ { kind: 'string', flag: '--test262', target: 'test262' },
176
+ { kind: 'string', flag: '--host', target: 'host', forms: ['equal'] },
177
+
178
+ // ─── kind=int — parseInt ───
179
+ { kind: 'int', flag: '--watch-delay', target: 'watchDelay', forms: ['equal'] },
180
+ { kind: 'int', flag: '--jobs', target: 'jobs', forms: ['equal'] },
181
+ { kind: 'int', flag: '--port', target: 'port' },
182
+ { kind: 'int', flag: '--log-limit', target: 'logLimit', forms: ['equal'] },
183
+ { kind: 'int', flag: '--line-limit', target: 'lineLimit', forms: ['equal'] },
184
+ // Rollup output.experimentalMinChunkSize 류 — 작은 common 청크 자동 병합 (bytes).
185
+ { kind: 'int', flag: '--min-chunk-size', target: 'minChunkSize', forms: ['equal'] },
186
+ // RN CLI 호환 — `--max-workers N` (Metro). zntc `--jobs` 와 의미 동일이므로 alias.
187
+ { kind: 'int', flag: '--max-workers', target: 'jobs', forms: ['equal'] },
188
+
189
+ // ─── kind=string-default — bool 단독 시 default, `--key=val` 시 val ───
190
+ { kind: 'string-default', flag: '--metafile', target: 'metafile', default: 'meta.json' },
191
+ { kind: 'string-default', flag: '--spa-fallback', target: 'spaFallback', default: 'index.html' },
192
+
193
+ // ─── kind=array — push (반복 지정) ───
194
+ { kind: 'array', flag: '--external', target: 'external' },
195
+ { kind: 'array', flag: '--drop', target: 'drop', forms: ['equal'] },
196
+ // 해석 차단 패턴 (Metro resolver.blockList / webpack IgnorePlugin 호환). 반복 지정.
197
+ { kind: 'array', flag: '--block-list', target: 'blockList', forms: ['equal'] },
198
+ { kind: 'array', flag: '--plugin', target: 'pluginPaths', forms: ['pair'] },
199
+ { kind: 'array', flag: '--runtime-target', target: 'runtimeTargetQueries' },
200
+ // scope hoisting 시 예약할 전역 식별자 (반복 가능). NAPI BuildOptions.globalIdentifiers.
201
+ { kind: 'array', flag: '--global-identifier', target: 'globalIdentifiers', forms: ['equal'] },
202
+ // 번들 시작 시 즉시 실행할 폴리필 경로 / 엔트리 모듈 직전 실행 모듈 (반복 가능, 경로 abs 변환).
203
+ { kind: 'array', flag: '--polyfill', target: 'polyfills', forms: ['equal'] },
204
+ { kind: 'array', flag: '--run-before-main', target: 'runBeforeMain', forms: ['equal'] },
205
+ // 번들 그래프 밖 디렉토리를 감시 루트에 추가 (반복 가능, abs 변환). NAPI BuildOptions.watchFolders.
206
+ // 주의: RN-CLI 호환 csv flag `--watchFolders` (target=rnWatchFolders, 위 csv 섹션) 와 별개 —
207
+ // 이쪽은 zntc 네이티브 watcher, 저쪽은 Metro graph-bundler 경로.
208
+ { kind: 'array', flag: '--watch-folder', target: 'watchFolders', forms: ['equal'] },
209
+ // watchFolders 스캔 시 포함/제외 glob (반복 가능, 루트 기준 상대 경로 — abs 변환 안 함).
210
+ { kind: 'array', flag: '--watch-include', target: 'watchInclude', forms: ['equal'] },
211
+ { kind: 'array', flag: '--watch-exclude', target: 'watchExclude', forms: ['equal'] },
212
+
213
+ // ─── kind=csv — `,` 분리 → array ───
214
+ { kind: 'csv', flag: '--drop-labels', target: 'dropLabels', forms: ['equal'] },
215
+ { kind: 'csv', flag: '--env-prefix', target: 'envPrefixes' },
216
+ { kind: 'csv', flag: '--resolve-extensions', target: 'resolveExtensions', forms: ['equal'] },
217
+ { kind: 'csv', flag: '--main-fields', target: 'mainFields', forms: ['equal'] },
218
+ { kind: 'csv', flag: '--conditions', target: 'conditions', forms: ['equal'] },
219
+ { kind: 'csv', flag: '--node-paths', target: 'nodePaths', forms: ['equal'] },
220
+ { kind: 'csv', flag: '--profile', target: 'profile', forms: ['equal'] },
221
+ // RN CLI 호환 (#2605 audit P0) — Metro 의 camelCase flag.
222
+ // 주의: zntc 네이티브 watcher 용 kebab flag `--watch-folder` (target=watchFolders, 위 array 섹션) 와 별개.
223
+ { kind: 'csv', flag: '--watchFolders', target: 'rnWatchFolders' },
224
+ { kind: 'csv', flag: '--sourceExts', target: 'rnSourceExts' },
225
+
226
+ // ─── kind=key-value — `--key:K=V` → opts[target][K]=V ───
227
+ { kind: 'key-value', flag: '--define', target: 'define' },
228
+ { kind: 'key-value', flag: '--alias', target: 'alias' },
229
+ // Fallback resolution — 해석 실패 시에만 적용 (webpack resolve.fallback /
230
+ // Metro extraNodeModules 호환). `--fallback:crypto=crypto-browserify`.
231
+ // 값 "false" → 빈-모듈 강제 + specifier 제약은 normalizeFallback 참조.
232
+ { kind: 'key-value', flag: '--fallback', target: 'fallback' },
233
+ { kind: 'key-value', flag: '--loader', target: 'loader' },
234
+ { kind: 'key-value', flag: '--global', target: 'globals' },
235
+ // RN CLI 호환 (#2605 audit P0) — Metro `--transform-option key=value` /
236
+ // `--resolver-option key=value`. 반복 지정 가능. zntc 내부에는 직접 매핑되지
237
+ // 않지만 `runRnBundle` 이 collected dict 를 platform-specific 처리 (현재는
238
+ // 무시 — Metro graph-bundler 전용. 미지원 stderr 경고만).
239
+ { kind: 'key-value', flag: '--transform-option', target: 'transformOptions' },
240
+ { kind: 'key-value', flag: '--resolver-option', target: 'resolverOptions' },
241
+
242
+ // ─── kind=ns-array — `--key:VALUE` → opts[target].push(VALUE) ───
243
+ { kind: 'ns-array', flag: '--inject', target: 'inject' },
244
+ { kind: 'ns-array', flag: '--pure', target: 'pure' },
245
+
246
+ // ─── kind=ns-string — `--key:NS=VALUE` 호환 alias (NS 무시, banner=value 와 동일) ───
247
+ // BuildOptions 가 단일 string 인 동안 namespace key 는 무의미하지만 esbuild 사용자 호환.
248
+ // 향후 CSS bundling 도입 시 namespace 의미 회복.
249
+ { kind: 'ns-string', flag: '--banner:js', target: 'banner' },
250
+ { kind: 'ns-string', flag: '--footer:js', target: 'footer' },
251
+ { kind: 'ns-string', flag: '--out-extension:.js', target: 'outExtensionJs' },
252
+
253
+ // ─── kind=enum-bool — `--key=enumValue` → opts[target] = spec.enum[enumValue] ───
254
+ {
255
+ kind: 'enum-bool',
256
+ flag: '--packages',
257
+ target: 'packagesExternal',
258
+ enum: { external: true },
259
+ },
260
+ { kind: 'enum-bool', flag: '--charset', target: 'charsetUtf8', enum: { utf8: true } },
261
+
262
+ // ─── kind=string-bool — default=true 의 toggle. `--key=false` → false, 그 외 → true ───
263
+ { kind: 'string-bool', flag: '--sources-content', target: 'sourcesContent' },
264
+ { kind: 'string-bool', flag: '--tree-shaking', target: 'treeShaking' },
265
+ { kind: 'string-bool', flag: '--scope-hoist', target: 'scopeHoist' },
266
+ { kind: 'string-bool', flag: '--emit-disk-sourcemap', target: 'emitDiskSourcemap' },
267
+ { kind: 'string-bool', flag: '--use-define-for-class-fields', target: 'useDefineForClassFields' },
268
+ { kind: 'string-bool', flag: '--tokenize', target: 'tokenize' },
269
+
270
+ // ─── `zntc verify` 전용. 다른 모드와 silent 충돌 방지를 위해 prefix 강제 ───
271
+ { kind: 'int', flag: '--verify-timeout', target: 'verifyTimeout' },
272
+ { kind: 'array', flag: '--verify-ignore', target: 'verifyIgnore' },
273
+ { kind: 'bool', flag: '--verify-allow-console-error', target: 'verifyAllowConsoleError' },
274
+ { kind: 'bool', flag: '--verify-json', target: 'verifyJson' },
275
+ { kind: 'string', flag: '--verify-report', target: 'verifyReport' },
276
+ ];
277
+
278
+ /**
279
+ * `--fallback:K=V` 로 수집된 dict 를 BuildOptions.fallback (`Record<string,string|false>`)
280
+ * 으로 정규화. CLI key-value 는 RHS 를 항상 string 으로 수집하므로 `"false"` 를 boolean
281
+ * false (빈-모듈 대체) 로 강제한다. config 가 준 실제 boolean false 는 strict `===` 에
282
+ * 안 걸려 그대로 보존. 빈 dict 는 undefined (NAPI 미전달).
283
+ *
284
+ * 한계: 값이 정확히 `"false"` 인 specifier 는 CLI 로 지정 불가 (항상 빈-모듈로 강제됨).
285
+ * 그 경우는 `zntc.config` 의 `fallback` 을 사용.
286
+ */
287
+ export function normalizeFallback(fb) {
288
+ const keys = Object.keys(fb);
289
+ if (keys.length === 0) return undefined;
290
+ return Object.fromEntries(keys.map((k) => [k, fb[k] === 'false' ? false : fb[k]]));
291
+ }
292
+
293
+ /** spec 의 canonical + alias 를 한 array 로. spec.flag (canonical) 항상 첫번째. */
294
+ export const flagsOf = (spec) => [spec.flag, ...(spec.aliases ?? [])];
295
+
296
+ /**
297
+ * 알려진 CLI flag 이름 (canonical + aliases) — typo-suggest 등 외부 소비자가 사용.
298
+ * `--serve`/`--host`/`--proxy` 는 if-chain 잔존이라 미포함 (next-arg optional / 특수 parser).
299
+ */
300
+ export const KNOWN_FLAGS = Object.freeze(FLAG_REGISTRY.flatMap(flagsOf).sort());
301
+
302
+ /**
303
+ * 단일 flag 토큰을 registry spec 들과 매칭. 매칭 시 `{ spec, action, consumed }` 반환.
304
+ * `action` 은 `applyFlagAction(opts, ...)` 가 해석할 결과 (kind 별 형태 다름).
305
+ *
306
+ * - bool / string-default(단독) → { type: "set", value }
307
+ * - string / int → { type: "set", value }
308
+ * - string-default(=val) → { type: "set", value }
309
+ * - array / ns-array → { type: "push", value }
310
+ * - csv → { type: "set", value: string[] }
311
+ * - key-value → { type: "kv", key, value }
312
+ * - ns-string → { type: "set", value }
313
+ * - enum-bool → { type: "set", value: bool|undefined }
314
+ * - string-bool → { type: "set", value: bool }
315
+ * - noop → { type: "noop" }
316
+ */
317
+ export function matchFlagFromRegistry(arg, args, i) {
318
+ for (const spec of FLAG_REGISTRY) {
319
+ const result = tryMatchSpec(spec, arg, args, i);
320
+ if (result) return result;
321
+ }
322
+ return null;
323
+ }
324
+
325
+ function tryMatchSpec(spec, arg, args, i) {
326
+ const allFlags = flagsOf(spec);
327
+ const formsDefault = ['equal', 'pair'];
328
+ const forms = spec.forms ?? formsDefault;
329
+
330
+ switch (spec.kind) {
331
+ case 'bool':
332
+ if (allFlags.includes(arg)) {
333
+ return spec.noop
334
+ ? { spec, action: { type: 'noop' }, consumed: 1 }
335
+ : { spec, action: { type: 'set', value: spec.value ?? true }, consumed: 1 };
336
+ }
337
+ return null;
338
+
339
+ case 'string':
340
+ case 'int':
341
+ return tryMatchValueForms(spec, arg, args, i, allFlags, forms, (raw) =>
342
+ spec.kind === 'int' ? parseInt(raw, 10) : raw,
343
+ );
344
+
345
+ case 'csv':
346
+ return tryMatchValueForms(spec, arg, args, i, allFlags, forms, (raw) =>
347
+ raw.split(',').filter(Boolean),
348
+ );
349
+
350
+ case 'string-default': {
351
+ // 단독 (`--metafile`) → default 값. `--metafile=path` → path.
352
+ if (allFlags.includes(arg)) {
353
+ return { spec, action: { type: 'set', value: spec.default }, consumed: 1 };
354
+ }
355
+ return tryMatchValueForms(spec, arg, args, i, allFlags, ['equal'], (raw) => raw);
356
+ }
357
+
358
+ case 'array':
359
+ return tryMatchValueForms(spec, arg, args, i, allFlags, forms, (raw) => raw, 'push');
360
+
361
+ case 'ns-array': {
362
+ // `--inject:./prelude` — primary flag 만, alias 미지원. forms 무시.
363
+ const prefix = spec.flag + ':';
364
+ if (arg.startsWith(prefix)) {
365
+ const value = arg.slice(prefix.length);
366
+ return { spec, action: { type: 'push', value }, consumed: 1 };
367
+ }
368
+ return null;
369
+ }
370
+
371
+ case 'key-value': {
372
+ // `--define:K=V` — primary flag 만, alias 미지원. forms 무시.
373
+ const prefix = spec.flag + ':';
374
+ if (arg.startsWith(prefix)) {
375
+ const [k, ...v] = arg.slice(prefix.length).split('=');
376
+ return { spec, action: { type: 'kv', key: k, value: v.join('=') }, consumed: 1 };
377
+ }
378
+ return null;
379
+ }
380
+
381
+ case 'ns-string': {
382
+ // `--banner:js=text` — spec.flag 가 이미 `--banner:js` 형태. forms=["equal"] 강제.
383
+ const prefix = spec.flag + '=';
384
+ if (arg.startsWith(prefix)) {
385
+ return { spec, action: { type: 'set', value: arg.slice(prefix.length) }, consumed: 1 };
386
+ }
387
+ return null;
388
+ }
389
+
390
+ case 'enum-bool': {
391
+ // `--charset=utf8` → opts.charsetUtf8 = true. enum 에 없는 값이면 noop (기존 동작 유지).
392
+ const prefix = spec.flag + '=';
393
+ if (arg.startsWith(prefix)) {
394
+ const raw = arg.slice(prefix.length);
395
+ const mapped = spec.enum[raw];
396
+ if (mapped === undefined) return { spec, action: { type: 'noop' }, consumed: 1 };
397
+ return { spec, action: { type: 'set', value: mapped }, consumed: 1 };
398
+ }
399
+ return null;
400
+ }
401
+
402
+ case 'string-bool': {
403
+ // default=true 의 toggle. `--sources-content=false` → false, 그 외 → true.
404
+ if (allFlags.includes(arg)) {
405
+ return { spec, action: { type: 'set', value: true }, consumed: 1 };
406
+ }
407
+ const prefix = spec.flag + '=';
408
+ if (arg.startsWith(prefix)) {
409
+ const raw = arg.slice(prefix.length);
410
+ return { spec, action: { type: 'set', value: raw !== 'false' }, consumed: 1 };
411
+ }
412
+ return null;
413
+ }
414
+
415
+ default:
416
+ throw new Error(`unknown flag kind: ${spec.kind}`);
417
+ }
418
+ }
419
+
420
+ function tryMatchValueForms(spec, arg, args, i, allFlags, forms, parseValue, op = 'set') {
421
+ const toAction = (raw, consumed) => {
422
+ const value = parseValue(raw);
423
+ if (spec.kind === 'int' && Number.isNaN(value)) {
424
+ return { spec, action: { type: 'invalid-int', raw }, consumed };
425
+ }
426
+ return { spec, action: { type: op, value }, consumed };
427
+ };
428
+
429
+ if (forms.includes('equal')) {
430
+ for (const f of allFlags) {
431
+ // single-letter short flag (`-X`) 는 equal-form 미지원 — esbuild/getopt 관습.
432
+ if (f.length <= 2) continue;
433
+ const prefix = f + '=';
434
+ if (arg.startsWith(prefix)) {
435
+ return toAction(arg.slice(prefix.length), 1);
436
+ }
437
+ }
438
+ }
439
+ if (forms.includes('pair') && allFlags.includes(arg)) {
440
+ const raw = args[i + 1];
441
+ if (raw === undefined) return { spec, action: { type: 'noop' }, consumed: 1 };
442
+ return toAction(raw, 2);
443
+ }
444
+ return null;
445
+ }
446
+
447
+ /**
448
+ * matchFlagFromRegistry 결과를 opts 에 적용. extra 가 있으면 함께 set.
449
+ */
450
+ export function applyFlagAction(opts, spec, action) {
451
+ if (action.type === 'invalid-int') {
452
+ console.error(`zntc: ${spec.flag} requires a number: ${action.raw}`);
453
+ opts.parseError = true;
454
+ } else if (action.type === 'noop') {
455
+ // pass — noop spec 또는 누락된 pair-form value
456
+ } else if (action.type === 'set') {
457
+ opts[spec.target] = action.value;
458
+ } else if (action.type === 'push') {
459
+ if (!Array.isArray(opts[spec.target])) opts[spec.target] = [];
460
+ opts[spec.target].push(action.value);
461
+ } else if (action.type === 'kv') {
462
+ if (typeof opts[spec.target] !== 'object' || opts[spec.target] === null) {
463
+ opts[spec.target] = {};
464
+ }
465
+ opts[spec.target][action.key] = action.value;
466
+ }
467
+
468
+ if (spec.extra) {
469
+ for (const k of Object.keys(spec.extra)) {
470
+ opts[k] = spec.extra[k];
471
+ }
472
+ }
473
+ }
@@ -0,0 +1,151 @@
1
+ // RN production bundle 의 asset 복사. Metro saveAssets/getAssetDestPath{IOS,Android}
2
+ // 호환 경로를 사용한다 — bundler 가 `--asset-registry` 호출 시 emit 한 asset
3
+ // metadata (result.rnAssetMetadata) 를 직접 받아 release 산출물을 자동 prune.
4
+ // `runRnBundle` 이 dev=false + `--assets-dest` 명시 시 호출.
5
+
6
+ import { copyFile, mkdir, writeFile } from 'node:fs/promises';
7
+ import { basename, dirname, join } from 'node:path';
8
+
9
+ /** iOS 가 native 로 인식하는 scale set. 그 외 (`@4x` 등) 는 production bundle 에서 제외. */
10
+ export const IOS_SCALES = new Set([1, 2, 3]);
11
+
12
+ /** Metro scale → Android drawable folder. Metro `assetPathUtils.js` 와 동일. */
13
+ const SCALE_TO_DRAWABLE = {
14
+ 0.75: 'ldpi',
15
+ 1: 'mdpi',
16
+ 1.5: 'hdpi',
17
+ 2: 'xhdpi',
18
+ 3: 'xxhdpi',
19
+ 4: 'xxxhdpi',
20
+ };
21
+
22
+ const DRAWABLE_EXT_RE = /\.(gif|jpeg|jpg|png|webp|xml)$/i;
23
+
24
+ /**
25
+ * Metro 호환 Android drawable folder name. table miss 시 `drawable-{round(160*scale)}dpi`
26
+ * fallback (Metro custom scale). 음수/0 같은 invalid scale 은 `drawable-mdpi` (default).
27
+ */
28
+ export function getAndroidDrawableFolder(scale) {
29
+ if (SCALE_TO_DRAWABLE[scale]) return `drawable-${SCALE_TO_DRAWABLE[scale]}`;
30
+ if (Number.isFinite(scale) && scale > 0) return `drawable-${Math.round(160 * scale)}dpi`;
31
+ return 'drawable-mdpi';
32
+ }
33
+
34
+ /**
35
+ * Metro Android resource-identifier sanitize — lowercase, `/` → `_`, non-alnum
36
+ * 제거, `assets_`/`assetsunstable_path_` prefix 제거. `asset.httpServerLocation`
37
+ * 이 항상 `/assets/...` 또는 `/assetsunstable_path/...` 로 시작하므로 prefix
38
+ * 제거가 resource identifier 의 일부.
39
+ */
40
+ function metroSanitizeResourceName(rawPath) {
41
+ return rawPath
42
+ .toLowerCase()
43
+ .replace(/\//g, '_')
44
+ .replace(/[^a-z0-9_]/g, '')
45
+ .replace(/^(?:assets|assetsunstable_path)_/, '');
46
+ }
47
+
48
+ export function getAndroidResourceIdentifier(asset) {
49
+ let basePath = asset.httpServerLocation ?? '';
50
+ if (basePath.startsWith('/')) basePath = basePath.slice(1);
51
+ return metroSanitizeResourceName(`${basePath}/${asset.name}`);
52
+ }
53
+
54
+ /** Android `keep.xml` body — drawable/raw 자원 보존 list. */
55
+ export function buildKeepXml(drawableNames) {
56
+ const items = drawableNames.map((n) => (n.startsWith('@') ? n : `@drawable/${n}`)).join(',');
57
+ return [
58
+ '<?xml version="1.0" encoding="utf-8"?>',
59
+ `<resources xmlns:tools="http://schemas.android.com/tools" tools:keep="${items}" />`,
60
+ '',
61
+ ].join('\n');
62
+ }
63
+
64
+ function sourceFileForScale(asset, scale) {
65
+ const suffix = scale === 1 ? '' : `@${scale}x`;
66
+ return join(asset.fileSystemLocation, `${asset.name}${suffix}.${asset.type}`);
67
+ }
68
+
69
+ async function copyRegisteredAssetFile(src, destPath) {
70
+ try {
71
+ await copyFile(src, destPath);
72
+ } catch (err) {
73
+ if (err && err.code === 'ENOENT') {
74
+ throw new Error(`registered RN asset file not found: ${src}`);
75
+ }
76
+ throw err;
77
+ }
78
+ }
79
+
80
+ export async function copyRegisteredAssetsForIos(assets, assetsDest) {
81
+ const tasks = [];
82
+ const dirs = new Set();
83
+ for (const asset of assets) {
84
+ for (const scale of asset.scales) {
85
+ if (!IOS_SCALES.has(scale)) continue;
86
+ const src = sourceFileForScale(asset, scale);
87
+ const destPath = join(
88
+ assetsDest,
89
+ asset.httpServerLocation.slice(1).replace(/\.\.\//g, '_'),
90
+ basename(src),
91
+ );
92
+ dirs.add(dirname(destPath));
93
+ tasks.push({ src, destPath });
94
+ }
95
+ }
96
+ await Promise.all([...dirs].map((d) => mkdir(d, { recursive: true })));
97
+ await Promise.all(tasks.map(({ src, destPath }) => copyRegisteredAssetFile(src, destPath)));
98
+ return tasks.length;
99
+ }
100
+
101
+ export async function copyRegisteredAssetsForAndroid(assets, assetsDest) {
102
+ const keepRefs = new Set();
103
+ const dirs = new Set();
104
+ const tasks = [];
105
+
106
+ for (const asset of assets) {
107
+ const isDrawable = DRAWABLE_EXT_RE.test(`.${asset.type}`);
108
+ const resourceName = getAndroidResourceIdentifier(asset);
109
+ for (const scale of asset.scales) {
110
+ const folder = isDrawable ? getAndroidDrawableFolder(scale) : 'raw';
111
+ const targetDir = join(assetsDest, folder);
112
+ dirs.add(targetDir);
113
+ tasks.push({
114
+ src: sourceFileForScale(asset, scale),
115
+ destPath: join(targetDir, `${resourceName}.${asset.type}`),
116
+ });
117
+ }
118
+ keepRefs.add(`@${isDrawable ? 'drawable' : 'raw'}/${resourceName}`);
119
+ }
120
+
121
+ const keepDir = keepRefs.size > 0 ? join(assetsDest, 'raw') : null;
122
+ if (keepDir) dirs.add(keepDir);
123
+
124
+ await Promise.all([...dirs].map((d) => mkdir(d, { recursive: true })));
125
+ await Promise.all(tasks.map(({ src, destPath }) => copyRegisteredAssetFile(src, destPath)));
126
+
127
+ if (keepDir) {
128
+ await writeFile(join(keepDir, 'keep.xml'), buildKeepXml([...keepRefs].sort()));
129
+ }
130
+
131
+ return tasks.length;
132
+ }
133
+
134
+ /**
135
+ * Production asset 복사 entry — caller (runRnBundle) 에서 dev=false + assetsDest
136
+ * 명시 시 호출. Bundler 가 emit 한 `result.rnAssetMetadata` 를 그대로 받아
137
+ * Metro 호환 경로로 복사.
138
+ *
139
+ * @returns 복사된 파일 수.
140
+ */
141
+ export async function copyRnAssets({ assetsDest, rnPlatform, assets }) {
142
+ if (!assetsDest) return 0;
143
+ if (!Array.isArray(assets)) {
144
+ throw new Error('RN release asset copy requires assets metadata array');
145
+ }
146
+ if (assets.length === 0) return 0;
147
+ if (rnPlatform === 'android') {
148
+ return copyRegisteredAssetsForAndroid(assets, assetsDest);
149
+ }
150
+ return copyRegisteredAssetsForIos(assets, assetsDest);
151
+ }