@omnifyjp/ts 3.12.5 → 3.13.1

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 ADDED
@@ -0,0 +1,175 @@
1
+ # @omnifyjp/ts
2
+
3
+ TypeScript / Zod / form-metadata / payload-builder generator for Omnify
4
+ schemas. Reads a `schemas.json` produced by `omnify generate` and emits a
5
+ single, type-safe data layer that frontend / consumer projects import
6
+ directly.
7
+
8
+ ## What it generates
9
+
10
+ Per model (in `<output>/base/<Model>.ts`):
11
+
12
+ - TypeScript **interface** for the row shape
13
+ - **Zod schemas** for create / update with validation rules
14
+ - **Per-locale Zod sub-objects** for `translatable: true` fields (issue #53)
15
+ - **`<modelName>Metadata`** constant — single source of truth for form
16
+ runners (field type taxonomy, validation, label dictionary, enum values,
17
+ relation pointers, aggregations) — issue #54
18
+ - **`<Model>CreateFormState` / `<Model>UpdateFormState`** interfaces and
19
+ **`empty<Model>CreateForm()` / `build<Model>CreatePayload()` /
20
+ `build<Model>UpdatePayload()`** helpers (issue #55)
21
+
22
+ Plus shared infrastructure at the codegen output root:
23
+
24
+ - `common.ts` — `LocaleMap`, `Locale`, `DateTimeString`, `OmnifyFile`
25
+ - `payload-helpers.ts` — `buildI18nPayload`, `emptyLocaleMap`,
26
+ `SUPPORTED_LOCALES`, `DEFAULT_LOCALE`
27
+ - `i18n.ts` — validation messages + locale helpers
28
+ - `enum/<Enum>.ts` — every enum + helpers
29
+ - `index.ts` — barrel re-exports
30
+ - `<Model>.ts` — editable wrapper (created once, never overwritten)
31
+
32
+ ## Two ways to invoke
33
+
34
+ ### Backend mode (auto)
35
+
36
+ When the backend's `omnify.yaml` sets `codegen.typescript.enable: true`,
37
+ the Go binary `omnify generate` shells out to `omnify-ts` automatically.
38
+ You don't run anything yourself.
39
+
40
+ ### Consumer mode (frontend repo, no Go binary)
41
+
42
+ Install:
43
+
44
+ ```bash
45
+ npm install --save-dev @omnifyjp/ts
46
+ ```
47
+
48
+ Create `omnify.yaml` at the consumer project root:
49
+
50
+ ```yaml
51
+ # yaml-language-server: $schema=https://cdn.jsdelivr.net/npm/@omnifyjp/omnify@latest/omnify-config-schema.json
52
+
53
+ # Top-level `input` is the consumer-mode shortcut. Three forms accepted:
54
+ # 1. Local path → ../backend/.omnify/schemas.json
55
+ # 2. http(s):// URL → https://raw.githubusercontent.com/org/repo/v1.2.3/.omnify/schemas.json
56
+ # 3. @org/pkg/file path → @famgia/dxs-product-schemas/schemas.json
57
+ input: ../backend/.omnify/schemas.json
58
+
59
+ codegen:
60
+ typescript:
61
+ enable: true
62
+ output: src/types/models # `output` is preferred; `modelsPath` still accepted
63
+ ```
64
+
65
+ Add to `package.json` scripts:
66
+
67
+ ```json
68
+ {
69
+ "scripts": {
70
+ "codegen": "omnify-ts",
71
+ "codegen:check": "omnify-ts && git diff --exit-code src/types/models/base src/types/models/enum"
72
+ }
73
+ }
74
+ ```
75
+
76
+ Run:
77
+
78
+ ```bash
79
+ npm run codegen
80
+ ```
81
+
82
+ ## CLI flags
83
+
84
+ ```
85
+ omnify-ts read omnify.yaml in cwd
86
+ omnify-ts -c, --config <path> specify a different config file
87
+ omnify-ts -i, --input <spec> override input (path / URL / npm spec)
88
+ omnify-ts -o, --output <dir> override output directory
89
+ omnify-ts --force regenerate auto files
90
+ omnify-ts --update force re-fetch remote, refresh lockfile
91
+ omnify-ts --frozen-lockfile CI mode: fail on upstream drift
92
+ ```
93
+
94
+ `<spec>` (the value of `input:` or `--input`) is sniffed by prefix:
95
+
96
+ | Prefix | Resolved as |
97
+ |---|---|
98
+ | `http://` / `https://` | HTTP fetch + cache + lockfile pin |
99
+ | `@<scope>/<pkg>...` | Node module resolution from cwd |
100
+ | anything else | Local file path (relative to `omnify.yaml`) |
101
+
102
+ ## Three input strategies
103
+
104
+ | Strategy | Use when | Example |
105
+ |---|---|---|
106
+ | **Local path** | Monorepo, sibling repos, git submodules — frontend can read backend over the filesystem | `input: ../backend/.omnify/schemas.json` |
107
+ | **HTTP(S) URL** | Backend and frontend are independent repos. Pin a tag/commit SHA in the URL for reproducible builds | `input: https://raw.githubusercontent.com/famgia/dxs-product/v3.13.0/.omnify/schemas.json` |
108
+ | **NPM package** | Backend publishes a tiny `@org/<project>-schemas` package containing only `schemas.json`. The frontend pins via `package-lock.json` | `input: "@famgia/dxs-product-schemas/schemas.json"` |
109
+
110
+ Same `omnify.yaml` shape for all three. Switching strategies is a one-line
111
+ change to the `input:` field — never a config restructure.
112
+
113
+ ## Reproducibility & lockfile (HTTP only)
114
+
115
+ For HTTP-sourced inputs, omnify-ts maintains:
116
+
117
+ - `.omnify/cache/schemas.json` — most recently fetched copy
118
+ - `.omnify/input.lock.json` — pins the input URL + sha256
119
+
120
+ **Commit `.omnify/input.lock.json`** so CI runs `omnify-ts --frozen-lockfile`
121
+ and refuses to silently fetch a different hash. Local dev runs without the
122
+ flag and gets a warning when upstream changes; the lockfile is auto-updated
123
+ and the dev commits it alongside the regenerated types.
124
+
125
+ | Mode | Behavior on upstream drift |
126
+ |---|---|
127
+ | Default (local dev) | Re-fetch + warn + auto-update lockfile |
128
+ | `--frozen-lockfile` (CI) | Fail loudly; refuse to overwrite the lockfile |
129
+ | `--update` | Force re-fetch even if cache + lockfile match |
130
+
131
+ Local files and npm packages don't need a separate omnify lockfile —
132
+ local files are read every run with no fetch step, and npm packages are
133
+ already version-pinned by `package-lock.json` / `pnpm-lock.yaml`.
134
+
135
+ ## Idiomatic CRUD form (with the generated builders)
136
+
137
+ ```tsx
138
+ import { useState } from 'react';
139
+ import {
140
+ emptyProductCreateForm,
141
+ buildProductCreatePayload,
142
+ type ProductCreateFormState,
143
+ } from '@/types/models/Product';
144
+
145
+ export function ProductCreateForm() {
146
+ const [form, setForm] = useState<ProductCreateFormState>(emptyProductCreateForm());
147
+
148
+ async function submit() {
149
+ await api.create(buildProductCreatePayload(form));
150
+ }
151
+
152
+ return (
153
+ <form onSubmit={submit}>
154
+ <Input translatable value={form.name} onChange={(v) => setForm({ ...form, name: v })} />
155
+ <Input value={form.slug} onChange={(e) => setForm({ ...form, slug: e.target.value })} />
156
+ {/* … */}
157
+ </form>
158
+ );
159
+ }
160
+ ```
161
+
162
+ The builder handles trimming, locale-mirroring (top-level field set to
163
+ default-locale value), nested per-locale sub-object construction, and
164
+ dropping empty optional fields. None of that boilerplate lives in the
165
+ form anymore.
166
+
167
+ ## Versioning
168
+
169
+ `@omnifyjp/ts` ships in lockstep with the `omnify` Go binary in the
170
+ `@omnifyjp/omnify` npm package. Bump both together; the version is the
171
+ single source of truth for the `schemas.json` shape they exchange.
172
+
173
+ In a sibling-repo / submodule layout the lockfiles take care of this
174
+ automatically. In the published-package layout (HTTP URL or `@org/pkg`),
175
+ pin the upstream version explicitly in the `input:` URL or npm dep.
package/dist/cli.d.ts CHANGED
@@ -5,9 +5,22 @@
5
5
  * Reads omnify.yaml to resolve schemas.json input and TypeScript output paths.
6
6
  * Falls back to explicit --input / --output flags.
7
7
  *
8
+ * Two omnify.yaml shapes are accepted:
9
+ *
10
+ * 1. Backend shape (legacy / source-of-truth): the schemas.json path is
11
+ * resolved indirectly via `connections.<default>.migrations[type=laravel].schemasPath`.
12
+ * The same file the omnify-go CLI reads.
13
+ *
14
+ * 2. Consumer shape (frontend): a top-level `input` field that accepts
15
+ * a local path, an http(s) URL, or an npm package specifier (e.g.
16
+ * `@org/pkg/schemas.json`). No connections / migrations needed —
17
+ * consumers don't have a database.
18
+ *
8
19
  * Usage:
9
20
  * omnify-ts # reads omnify.yaml in cwd
10
21
  * omnify-ts --config path/to/omnify.yaml
11
22
  * omnify-ts --input schemas.json --output ./types # explicit override
23
+ * omnify-ts --frozen-lockfile # CI mode: fail if upstream drift
24
+ * omnify-ts --update # force re-fetch remote input
12
25
  */
13
26
  export {};
package/dist/cli.js CHANGED
@@ -5,10 +5,23 @@
5
5
  * Reads omnify.yaml to resolve schemas.json input and TypeScript output paths.
6
6
  * Falls back to explicit --input / --output flags.
7
7
  *
8
+ * Two omnify.yaml shapes are accepted:
9
+ *
10
+ * 1. Backend shape (legacy / source-of-truth): the schemas.json path is
11
+ * resolved indirectly via `connections.<default>.migrations[type=laravel].schemasPath`.
12
+ * The same file the omnify-go CLI reads.
13
+ *
14
+ * 2. Consumer shape (frontend): a top-level `input` field that accepts
15
+ * a local path, an http(s) URL, or an npm package specifier (e.g.
16
+ * `@org/pkg/schemas.json`). No connections / migrations needed —
17
+ * consumers don't have a database.
18
+ *
8
19
  * Usage:
9
20
  * omnify-ts # reads omnify.yaml in cwd
10
21
  * omnify-ts --config path/to/omnify.yaml
11
22
  * omnify-ts --input schemas.json --output ./types # explicit override
23
+ * omnify-ts --frozen-lockfile # CI mode: fail if upstream drift
24
+ * omnify-ts --update # force re-fetch remote input
12
25
  */
13
26
  import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
14
27
  import { resolve, dirname, join } from 'node:path';
@@ -16,33 +29,57 @@ import { Command } from 'commander';
16
29
  import { parse as parseYaml } from 'yaml';
17
30
  import { generateTypeScript } from './generator.js';
18
31
  import { generatePhp } from './php/index.js';
32
+ import { resolveInput, sniffInputKind } from './input-resolver.js';
19
33
  function resolveFromConfig(configPath) {
20
34
  const raw = readFileSync(configPath, 'utf-8');
21
35
  const config = parseYaml(raw);
22
36
  const configDir = dirname(configPath);
23
- // Find default connection
24
- const defaultName = config.default ?? 'default';
25
- const connections = config.connections ?? {};
26
- const conn = connections[defaultName];
27
- if (!conn) {
28
- throw new Error(`Connection "${defaultName}" not found in ${configPath}`);
29
- }
30
- // Resolve schemas.json input path from migrations[type=laravel].schemasPath
31
- let schemasPath;
32
- for (const m of conn.migrations ?? []) {
33
- if (m.type === 'laravel' && m.schemasPath) {
34
- schemasPath = m.schemasPath;
35
- break;
37
+ // Determine the input spec. Two shapes are accepted; consumer shape wins
38
+ // when both are present (top-level `input` is the explicit signal that
39
+ // this is a consumer project).
40
+ let inputSpec;
41
+ if (config.input) {
42
+ // Consumer shape accept the spec as-is. The input resolver will
43
+ // sniff it (path / URL / npm) and translate it to a local file later.
44
+ // For local relative paths we still need to anchor them to configDir
45
+ // before handing off, so the resolver doesn't see a cwd-relative path.
46
+ if (sniffInputKind(config.input) === 'local' && !config.input.startsWith('/')) {
47
+ inputSpec = resolve(configDir, config.input);
48
+ }
49
+ else {
50
+ inputSpec = config.input;
36
51
  }
37
52
  }
38
- if (!schemasPath) {
39
- throw new Error(`No schemasPath found in laravel migration for connection "${defaultName}" in ${configPath}.\n` +
40
- `Either add schemasPath to your laravel migration config, or use --input explicitly.`);
53
+ else {
54
+ // Backend / legacy shape walk connections.<default>.migrations to
55
+ // find the laravel target's schemasPath.
56
+ const defaultName = config.default ?? 'default';
57
+ const connections = config.connections ?? {};
58
+ const conn = connections[defaultName];
59
+ if (!conn) {
60
+ throw new Error(`Connection "${defaultName}" not found in ${configPath}.\n` +
61
+ `For consumer projects (frontend), add a top-level "input:" field instead.`);
62
+ }
63
+ let schemasPath;
64
+ for (const m of conn.migrations ?? []) {
65
+ if (m.type === 'laravel' && m.schemasPath) {
66
+ schemasPath = m.schemasPath;
67
+ break;
68
+ }
69
+ }
70
+ if (!schemasPath) {
71
+ throw new Error(`No schemasPath found in laravel migration for connection "${defaultName}" in ${configPath}.\n` +
72
+ `Either add schemasPath to your laravel migration config, set top-level "input:", ` +
73
+ `or use --input explicitly.`);
74
+ }
75
+ inputSpec = resolve(configDir, schemasPath);
41
76
  }
42
- // TypeScript config (enable defaults to false)
77
+ // TypeScript config (enable defaults to false). Accept both `output`
78
+ // (new) and `modelsPath` (legacy) — `output` wins if both are set.
43
79
  const tsConfig = config.codegen?.typescript;
44
- const tsEnabled = tsConfig?.enable === true && !!tsConfig?.modelsPath;
45
- const tsOutput = tsEnabled ? resolve(configDir, tsConfig.modelsPath) : undefined;
80
+ const tsOutputRaw = tsConfig?.output ?? tsConfig?.modelsPath;
81
+ const tsEnabled = tsConfig?.enable === true && !!tsOutputRaw;
82
+ const tsOutput = tsEnabled ? resolve(configDir, tsOutputRaw) : undefined;
46
83
  // Laravel config (enable defaults to false)
47
84
  const laravelConfig = config.codegen?.laravel;
48
85
  const laravelEnabled = laravelConfig?.enable === true;
@@ -62,34 +99,34 @@ function resolveFromConfig(configPath) {
62
99
  nestedset: laravelConfig?.nestedset,
63
100
  } : undefined;
64
101
  return {
65
- input: resolve(configDir, schemasPath),
102
+ inputSpec,
66
103
  tsEnabled,
67
104
  tsOutput,
68
105
  laravelEnabled,
69
106
  laravelOverrides,
70
107
  };
71
108
  }
72
- // ============================================================================
73
- // CLI
74
- // ============================================================================
75
109
  const program = new Command();
76
110
  program
77
111
  .name('omnify-ts')
78
112
  .description('Generate TypeScript types and Laravel PHP code from Omnify schemas.json')
79
113
  .option('-c, --config <path>', 'Path to omnify.yaml (default: ./omnify.yaml)')
80
- .option('-i, --input <path>', 'Path to schemas.json (overrides config)')
114
+ .option('-i, --input <spec>', 'schemas.json source: local path, http(s) URL, or npm package spec (overrides config)')
81
115
  .option('-o, --output <path>', 'Output directory for TypeScript (overrides config)')
82
116
  .option('--force', 'Force regeneration of auto-generated files', false)
83
- .action((opts) => {
84
- let inputPath;
117
+ .option('--frozen-lockfile', 'Fail if remote input differs from .omnify/input.lock.json (CI mode)', false)
118
+ .option('--update', 'Force re-fetch of remote input and refresh the lockfile', false)
119
+ .action(async (opts) => {
120
+ let inputSpec;
85
121
  let tsEnabled = true;
86
122
  let tsOutput;
87
123
  let laravelEnabled = false;
88
124
  let laravelOverrides;
89
125
  let configDir = process.cwd();
90
126
  if (opts.input && opts.output) {
91
- // Explicit flags — skip config, only TS generation
92
- inputPath = resolve(opts.input);
127
+ // Explicit flags — skip config, only TS generation. The flag is
128
+ // passed through unchanged so URLs/npm specs work too.
129
+ inputSpec = opts.input;
93
130
  tsOutput = resolve(opts.output);
94
131
  }
95
132
  else {
@@ -102,17 +139,27 @@ program
102
139
  process.exit(1);
103
140
  }
104
141
  const resolved = resolveFromConfig(configPath);
105
- inputPath = opts.input ? resolve(opts.input) : resolved.input;
142
+ inputSpec = opts.input ?? resolved.inputSpec;
106
143
  tsEnabled = resolved.tsEnabled;
107
144
  tsOutput = opts.output ? resolve(opts.output) : resolved.tsOutput;
108
145
  laravelEnabled = resolved.laravelEnabled;
109
146
  laravelOverrides = resolved.laravelOverrides;
110
147
  }
111
- // Read schemas.json
112
- if (!existsSync(inputPath)) {
113
- console.error(`Error: schemas.json not found: ${inputPath}\nRun "omnify generate" first.`);
148
+ // Resolve the input spec to a local file path. This is where http
149
+ // fetch + lockfile + cache happen for remote sources.
150
+ let resolvedInput;
151
+ try {
152
+ resolvedInput = await resolveInput(inputSpec, {
153
+ configDir,
154
+ frozen: opts.frozenLockfile,
155
+ forceUpdate: opts.update,
156
+ });
157
+ }
158
+ catch (err) {
159
+ console.error(err.message);
114
160
  process.exit(1);
115
161
  }
162
+ const inputPath = resolvedInput.localPath;
116
163
  const raw = readFileSync(inputPath, 'utf-8');
117
164
  const input = JSON.parse(raw);
118
165
  console.log(`Reading schemas from ${inputPath}`);
@@ -185,4 +232,7 @@ program
185
232
  }
186
233
  console.log(`\nGeneration complete.`);
187
234
  });
188
- program.parse();
235
+ program.parseAsync().catch((err) => {
236
+ console.error(err);
237
+ process.exit(1);
238
+ });
package/dist/generator.js CHANGED
@@ -16,6 +16,8 @@ import { schemaToInterface, formatInterface, needsDateTimeImports, } from './int
16
16
  import { generateEnums, generatePluginEnums, formatEnum, formatTypeAlias, extractInlineEnums, } from './enum-generator.js';
17
17
  import { generateZodSchemas, generateDisplayNames, getExcludedFields, formatZodSchemasSection, formatZodModelFile, } from './zod-generator.js';
18
18
  import { generateI18nFileContent } from './i18n-generator.js';
19
+ import { buildSchemaMetadata, formatMetadataConst, } from './metadata-generator.js';
20
+ import { buildFormShape, formatPayloadBuilderSection, } from './payload-builder-generator.js';
19
21
  /** Auto-generated file header. */
20
22
  function generateBaseHeader() {
21
23
  return `/**
@@ -90,7 +92,21 @@ function generateBaseInterfaceFile(schemaName, schemas, options) {
90
92
  const displayNames = generateDisplayNames(schema, options);
91
93
  const excludedFields = getExcludedFields(schema);
92
94
  parts.push('\n');
93
- parts.push(formatZodSchemasSection(schemaName, zodSchemas, displayNames, excludedFields));
95
+ parts.push(formatZodSchemasSection(schemaName, zodSchemas, displayNames, excludedFields, schema, options));
96
+ // Form field metadata constant (issue #54)
97
+ const metadata = buildSchemaMetadata(schema, schemas, options, displayNames);
98
+ parts.push(formatMetadataConst(metadata));
99
+ // Form-state interfaces + payload builders (issue #55). Only emit when
100
+ // the schema has at least one form-relevant field; otherwise the helpers
101
+ // would be empty noise.
102
+ const formShape = buildFormShape(schema, schemas, options);
103
+ if (formShape.fields.length > 0) {
104
+ // The builders depend on `Locale` (from common.ts) and the shared
105
+ // `buildI18nPayload` / `emptyLocaleMap` helpers (from payload-helpers.ts).
106
+ // Inject the imports near the top of the file.
107
+ insertPayloadImports(parts);
108
+ parts.push(formatPayloadBuilderSection(formShape, options.defaultLocale));
109
+ }
94
110
  return {
95
111
  filePath: `base/${schemaName}.ts`,
96
112
  content: parts.join(''),
@@ -99,6 +115,24 @@ function generateBaseInterfaceFile(schemaName, schemas, options) {
99
115
  category: 'base',
100
116
  };
101
117
  }
118
+ /**
119
+ * Inject the imports the payload builder section needs into the parts
120
+ * array. Has to run BEFORE rendering the builder section but AFTER the
121
+ * existing import block. We rely on the fact that the file content is
122
+ * still string fragments in `parts` and we can splice in the imports near
123
+ * the top.
124
+ */
125
+ function insertPayloadImports(parts) {
126
+ // The first part is the header; subsequent parts include `import { z }`
127
+ // and other imports. Find the index of the first non-import / non-header
128
+ // part (where the interface or `\n` block begins) and inject before it.
129
+ // For simplicity, we just append the imports immediately after the
130
+ // header (index 1) since the existing imports below it don't conflict
131
+ // and the TS compiler is happy with imports interleaved at the top.
132
+ const importBlock = `import type { Locale } from '../common';\n` +
133
+ `import { buildI18nPayload, emptyLocaleMap } from '../payload-helpers';\n`;
134
+ parts.splice(1, 0, importBlock);
135
+ }
102
136
  /** Generate an enum file. */
103
137
  function generateEnumFile(enumDef, isPlugin) {
104
138
  const parts = [generateBaseHeader()];
@@ -212,6 +246,70 @@ ${fileSection}`;
212
246
  overwrite: true,
213
247
  };
214
248
  }
249
+ /** Generate the shared payload-helpers.ts file (used by per-model builders). */
250
+ function generatePayloadHelpersFile(options) {
251
+ const localesArrayLiteral = `[${options.locales.map((l) => `'${l}'`).join(', ')}]`;
252
+ const content = `${generateBaseHeader()}/**
253
+ * Shared utilities used by every model's payload builder. The per-model
254
+ * \`build<Model>CreatePayload\` / \`build<Model>UpdatePayload\` helpers
255
+ * import from this file rather than re-emitting the same code per model.
256
+ *
257
+ * Issue omnify-jp/omnify-go#55.
258
+ */
259
+
260
+ import type { Locale } from './common';
261
+
262
+ /** Locales the project supports — kept in sync with omnify.yaml's locale.locales. */
263
+ export const SUPPORTED_LOCALES: readonly Locale[] = ${localesArrayLiteral};
264
+
265
+ /** Default locale (mirrors omnify.yaml's locale.defaultLocale). */
266
+ export const DEFAULT_LOCALE: Locale = '${options.defaultLocale}';
267
+
268
+ /** Build a fresh locale map with every supported locale present and empty. */
269
+ export function emptyLocaleMap(): Record<Locale, string> {
270
+ const map = {} as Record<Locale, string>;
271
+ for (const locale of SUPPORTED_LOCALES) {
272
+ map[locale] = '';
273
+ }
274
+ return map;
275
+ }
276
+
277
+ /**
278
+ * Convert a flat \`{ fieldName: { ja: 'x', en: 'y', vi: 'z' } }\` form-state
279
+ * map into the nested locale-keyed wire shape that the create / update zod
280
+ * schemas accept (issue #53). Empty per-locale strings are preserved (the
281
+ * server treats them as "no translation"); fields where the entire dict is
282
+ * undefined are skipped so update payloads only include touched fields.
283
+ */
284
+ export function buildI18nPayload(
285
+ fields: Record<string, Record<string, string> | undefined>,
286
+ ): Record<Locale, Record<string, string>> {
287
+ const out = {} as Record<Locale, Record<string, string>>;
288
+ for (const locale of SUPPORTED_LOCALES) {
289
+ const sub: Record<string, string> = {};
290
+ let hasAny = false;
291
+ for (const [fieldName, dict] of Object.entries(fields)) {
292
+ if (!dict) continue;
293
+ const value = dict[locale];
294
+ if (value !== undefined) {
295
+ sub[fieldName] = value.trim();
296
+ hasAny = true;
297
+ }
298
+ }
299
+ if (hasAny) {
300
+ out[locale] = sub;
301
+ }
302
+ }
303
+ return out;
304
+ }
305
+ `;
306
+ return {
307
+ filePath: 'payload-helpers.ts',
308
+ content,
309
+ types: ['SUPPORTED_LOCALES', 'DEFAULT_LOCALE', 'emptyLocaleMap', 'buildI18nPayload'],
310
+ overwrite: true,
311
+ };
312
+ }
215
313
  /** Generate i18n.ts with validation messages. */
216
314
  function generateI18nFile(options) {
217
315
  return {
@@ -391,6 +489,8 @@ export function generateTypeScript(input) {
391
489
  });
392
490
  // Common types
393
491
  files.push(generateCommonFile(options, hasFiles));
492
+ // Shared payload-builder helpers (issue #55)
493
+ files.push(generatePayloadHelpersFile(options));
394
494
  // I18n
395
495
  files.push(generateI18nFile(options));
396
496
  // Index re-exports
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Input resolver for omnify-ts.
3
+ *
4
+ * Accepts a `schemas.json` source spec and returns a local file path the
5
+ * caller can read directly. Three source types are supported:
6
+ *
7
+ * - **Local** (path/relative path) — read directly from disk.
8
+ * - **HTTP/HTTPS** (URL) — fetch, cache under `.omnify/cache/`, and pin
9
+ * the SHA-256 hash in `.omnify/input.lock.json` for reproducible builds.
10
+ * - **NPM** (package specifier like `@org/pkg/schemas.json`) — resolve via
11
+ * Node module resolution from the consumer project's node_modules.
12
+ *
13
+ * Lockfile semantics (HTTP only):
14
+ * - Default mode: re-fetch when the cache is stale, auto-update the
15
+ * lockfile, and warn the user when the upstream hash changes so they
16
+ * can review and commit the lockfile change.
17
+ * - `--frozen-lockfile` mode: fail loudly if the lockfile is missing,
18
+ * points at a different URL, or its hash differs from upstream. Use
19
+ * this in CI to guarantee build reproducibility.
20
+ *
21
+ * Local and NPM specs do not need an omnify lockfile because:
22
+ * - Local files are read every run; there is no fetch step that could
23
+ * introduce drift.
24
+ * - NPM packages are already version-pinned by `package-lock.json` /
25
+ * `pnpm-lock.yaml` / `yarn.lock`.
26
+ */
27
+ export type InputKind = 'local' | 'http' | 'npm';
28
+ export interface ResolveOptions {
29
+ /**
30
+ * Directory containing the omnify.yaml that declared the input. Used as
31
+ * the base for resolving relative paths and as the root for the
32
+ * `.omnify/cache/` and `.omnify/input.lock.json` files.
33
+ */
34
+ configDir: string;
35
+ /**
36
+ * CI mode. When true, refuse to fetch if the lockfile is missing or its
37
+ * hash doesn't match upstream. Caller should set this from
38
+ * `--frozen-lockfile`.
39
+ */
40
+ frozen?: boolean;
41
+ /**
42
+ * Force re-fetch even when the cache is valid and the lockfile matches.
43
+ * Use this when the user explicitly asked for an update (e.g.
44
+ * `omnify-ts --update`).
45
+ */
46
+ forceUpdate?: boolean;
47
+ }
48
+ export interface ResolveResult {
49
+ /** Absolute path to a local file the caller can read with fs APIs. */
50
+ localPath: string;
51
+ kind: InputKind;
52
+ /** SHA-256 of the resolved file. Always set for http/npm; set for local
53
+ * too as a courtesy for diagnostics. */
54
+ sha256: string;
55
+ }
56
+ /**
57
+ * Subset of the WHATWG fetch API used by the resolver. Injectable so tests
58
+ * can stub it without monkey-patching globalThis.
59
+ */
60
+ export interface HttpFetcher {
61
+ (url: string): Promise<{
62
+ ok: boolean;
63
+ status: number;
64
+ statusText: string;
65
+ text(): Promise<string>;
66
+ }>;
67
+ }
68
+ /**
69
+ * Decide which kind of source a spec string is. Heuristic — explicit
70
+ * prefixes win, ambiguous bare names default to local. Unscoped npm
71
+ * packages are intentionally NOT supported because they collide with
72
+ * relative path semantics; users should publish under a scope.
73
+ */
74
+ export declare function sniffInputKind(spec: string): InputKind;
75
+ /**
76
+ * Resolve an input spec to a concrete local file path. Dispatches by kind:
77
+ * local files are read directly, npm packages are resolved through Node
78
+ * module resolution, and HTTP URLs are fetched, cached, and pinned in the
79
+ * lockfile.
80
+ *
81
+ * The `fetcher` parameter is exposed only for tests; production callers
82
+ * should leave it at the default (`globalThis.fetch`).
83
+ */
84
+ export declare function resolveInput(spec: string, opts: ResolveOptions, fetcher?: HttpFetcher): Promise<ResolveResult>;