@omnifyjp/omnify 3.12.4 → 3.13.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/package.json +6 -6
- package/ts-dist/cli.d.ts +13 -0
- package/ts-dist/cli.js +83 -33
- package/ts-dist/enum-generator.js +20 -0
- package/ts-dist/generator.js +101 -1
- package/ts-dist/input-resolver.d.ts +84 -0
- package/ts-dist/input-resolver.js +246 -0
- package/ts-dist/metadata-generator.d.ts +76 -0
- package/ts-dist/metadata-generator.js +329 -0
- package/ts-dist/payload-builder-generator.d.ts +50 -0
- package/ts-dist/payload-builder-generator.js +271 -0
- package/ts-dist/zod-generator.d.ts +1 -1
- package/ts-dist/zod-generator.js +68 -1
- package/types/config.d.ts +14 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@omnifyjp/omnify",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.13.0",
|
|
4
4
|
"description": "Schema-driven code generation for Laravel, TypeScript, and SQL",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -36,10 +36,10 @@
|
|
|
36
36
|
"zod": "^3.24.0"
|
|
37
37
|
},
|
|
38
38
|
"optionalDependencies": {
|
|
39
|
-
"@omnifyjp/omnify-darwin-arm64": "3.
|
|
40
|
-
"@omnifyjp/omnify-darwin-x64": "3.
|
|
41
|
-
"@omnifyjp/omnify-linux-x64": "3.
|
|
42
|
-
"@omnifyjp/omnify-linux-arm64": "3.
|
|
43
|
-
"@omnifyjp/omnify-win32-x64": "3.
|
|
39
|
+
"@omnifyjp/omnify-darwin-arm64": "3.13.0",
|
|
40
|
+
"@omnifyjp/omnify-darwin-x64": "3.13.0",
|
|
41
|
+
"@omnifyjp/omnify-linux-x64": "3.13.0",
|
|
42
|
+
"@omnifyjp/omnify-linux-arm64": "3.13.0",
|
|
43
|
+
"@omnifyjp/omnify-win32-x64": "3.13.0"
|
|
44
44
|
}
|
|
45
45
|
}
|
package/ts-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/ts-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
|
-
//
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
if (
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
|
45
|
-
const
|
|
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
|
-
|
|
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 <
|
|
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
|
-
.
|
|
84
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
112
|
-
|
|
113
|
-
|
|
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.
|
|
235
|
+
program.parseAsync().catch((err) => {
|
|
236
|
+
console.error(err);
|
|
237
|
+
process.exit(1);
|
|
238
|
+
});
|
|
@@ -208,12 +208,32 @@ export function formatTypeAlias(alias) {
|
|
|
208
208
|
/** Extract inline enums from Enum/Select properties. */
|
|
209
209
|
export function extractInlineEnums(schemas, options) {
|
|
210
210
|
const results = [];
|
|
211
|
+
// Pre-compute the set of real `kind: enum` schema names so we can skip
|
|
212
|
+
// inline enums whose generated name (`{Schema}{Property}`) would collide.
|
|
213
|
+
// This happens when a project has e.g. both `kind: enum MenuStatus` and
|
|
214
|
+
// a `Menu.status: { type: Enum, enum: [...] }` inline declaration — the
|
|
215
|
+
// generator used to emit BOTH into index.ts, producing duplicate
|
|
216
|
+
// exports. The real enum schema is canonical; the inline duplicate is
|
|
217
|
+
// skipped so the user can clean it up by switching the property to
|
|
218
|
+
// `type: EnumRef, enum: MenuStatus`.
|
|
219
|
+
const realEnumNames = new Set();
|
|
220
|
+
for (const s of Object.values(schemas)) {
|
|
221
|
+
if (s.kind === 'enum')
|
|
222
|
+
realEnumNames.add(s.name);
|
|
223
|
+
}
|
|
211
224
|
for (const schema of Object.values(schemas)) {
|
|
212
225
|
if (schema.kind === 'enum' || !schema.properties)
|
|
213
226
|
continue;
|
|
214
227
|
for (const [propName, property] of Object.entries(schema.properties)) {
|
|
215
228
|
if (property.type === 'Enum' && Array.isArray(property.enum) && property.enum.length > 0) {
|
|
216
229
|
const typeName = `${schema.name}${toPascalCase(propName)}`;
|
|
230
|
+
if (realEnumNames.has(typeName)) {
|
|
231
|
+
// eslint-disable-next-line no-console
|
|
232
|
+
console.warn(`[omnify-ts] Skipping inline enum ${schema.name}.${propName} — ` +
|
|
233
|
+
`its generated name "${typeName}" collides with the real enum schema "${typeName}". ` +
|
|
234
|
+
`Consider switching the property to \`type: EnumRef, enum: ${typeName}\` to remove the duplication.`);
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
217
237
|
const displayName = resolveString(schema.displayName, options);
|
|
218
238
|
// Check if values have labels (i.e., are objects not strings)
|
|
219
239
|
const enumValues = property.enum;
|
package/ts-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>;
|