openuispec 0.1.13 → 0.1.15
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 +15 -0
- package/cli/init.ts +38 -1
- package/drift/index.ts +53 -20
- package/package.json +1 -1
- package/schema/locale.schema.json +19 -3
- package/schema/openuispec.schema.json +7 -0
- package/schema/validate.ts +58 -14
package/README.md
CHANGED
|
@@ -155,6 +155,21 @@ brand:
|
|
|
155
155
|
|
|
156
156
|
Validate with: `openuispec validate`
|
|
157
157
|
|
|
158
|
+
## Output directories
|
|
159
|
+
|
|
160
|
+
By default, drift stores state in `generated/<target>/<project>/`. To point targets to your actual code directories, add `output_dir` to `openuispec.yaml`:
|
|
161
|
+
|
|
162
|
+
```yaml
|
|
163
|
+
generation:
|
|
164
|
+
targets: [ios, android, web]
|
|
165
|
+
output_dir:
|
|
166
|
+
web: "../web-ui/"
|
|
167
|
+
android: "../kmp-ui/"
|
|
168
|
+
ios: "../kmp-ui/iosApp/"
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
Paths are relative to `openuispec.yaml`. The `.openuispec-state.json` file is stored inside each output directory.
|
|
172
|
+
|
|
158
173
|
## Spec at a glance
|
|
159
174
|
|
|
160
175
|
| Section | What it defines |
|
package/cli/init.ts
CHANGED
|
@@ -106,6 +106,10 @@ i18n:
|
|
|
106
106
|
|
|
107
107
|
generation:
|
|
108
108
|
targets: [${targetList}]
|
|
109
|
+
# output_dir: # Optional: map targets to code directories
|
|
110
|
+
# ios: "../ios-app/" # relative to this file
|
|
111
|
+
# android: "../android-app/"
|
|
112
|
+
# web: "../web-ui/"
|
|
109
113
|
output_format:
|
|
110
114
|
${outputLines}
|
|
111
115
|
|
|
@@ -137,6 +141,12 @@ OpenUISpec is a YAML-based format that describes your app's UI semantically —
|
|
|
137
141
|
| \`platform/\` | Platform overrides — per-target (iOS, Android, Web) behaviors |
|
|
138
142
|
| \`locales/\` | Localization — i18n strings (JSON, ICU MessageFormat) |
|
|
139
143
|
|
|
144
|
+
All directory paths are configured in \`openuispec.yaml\` under \`includes:\` and support relative paths. For example, to share locales across projects:
|
|
145
|
+
\`\`\`yaml
|
|
146
|
+
includes:
|
|
147
|
+
locales: "../../shared/locales" # resolved relative to openuispec.yaml
|
|
148
|
+
\`\`\`
|
|
149
|
+
|
|
140
150
|
## Getting started
|
|
141
151
|
|
|
142
152
|
**Start here:** read \`openuispec.yaml\` — it's the root manifest that defines the project structure, data model, API endpoints, and generation targets.
|
|
@@ -244,10 +254,23 @@ openuispec drift --snapshot --target ${targets[0]} # Snapshot current state
|
|
|
244
254
|
openuispec drift --all # Include stubs in drift check
|
|
245
255
|
\`\`\`
|
|
246
256
|
|
|
247
|
-
## Targets
|
|
257
|
+
## Targets and output directories
|
|
248
258
|
|
|
249
259
|
This project generates native code for: **${targetList}**
|
|
250
260
|
|
|
261
|
+
By default, drift stores state in \`generated/<target>/<project>/\`. To point targets to your actual code directories, add \`output_dir\` to \`openuispec.yaml\`:
|
|
262
|
+
|
|
263
|
+
\`\`\`yaml
|
|
264
|
+
generation:
|
|
265
|
+
targets: [ios, android, web]
|
|
266
|
+
output_dir:
|
|
267
|
+
web: "../web-ui/"
|
|
268
|
+
android: "../kmp-ui/"
|
|
269
|
+
ios: "../kmp-ui/iosApp/"
|
|
270
|
+
\`\`\`
|
|
271
|
+
|
|
272
|
+
Paths are relative to \`openuispec.yaml\`.
|
|
273
|
+
|
|
251
274
|
## Learn more
|
|
252
275
|
|
|
253
276
|
All docs and examples are in the installed \`openuispec\` package — check \`node_modules/openuispec/\` or run \`npm root -g\` for the global install path.
|
|
@@ -281,6 +304,8 @@ OpenUISpec is a YAML-based spec format that describes an app's UI semantically
|
|
|
281
304
|
- Platform: \`${specDir}/platform/\` — per-target overrides (iOS, Android, Web)
|
|
282
305
|
- Locales: \`${specDir}/locales/\` — i18n strings (JSON, ICU MessageFormat)
|
|
283
306
|
|
|
307
|
+
**Note:** These are the default paths. Actual paths are in \`includes:\` in \`openuispec.yaml\` and may use relative paths (e.g. \`../../shared/locales\`). Always read \`openuispec.yaml\` to find the real directories.
|
|
308
|
+
|
|
284
309
|
## If spec directories are empty (first-time setup)
|
|
285
310
|
This means the project has existing UI code but hasn't been specced yet. Your job:
|
|
286
311
|
|
|
@@ -369,6 +394,18 @@ Workflow: read the schema → read an example from \`examples/taskflow/\` → cr
|
|
|
369
394
|
- Actions: typed objects (navigate, api_call, set_state, confirm, sequence, feedback, etc.)
|
|
370
395
|
- Adaptive layout: size classes (compact, regular, expanded) with per-section overrides
|
|
371
396
|
|
|
397
|
+
## Output directories
|
|
398
|
+
Drift tracks spec changes per target. By default state is stored in \`generated/<target>/<project>/\`.
|
|
399
|
+
To map targets to actual code directories, set \`generation.output_dir\` in \`openuispec.yaml\`:
|
|
400
|
+
\`\`\`yaml
|
|
401
|
+
generation:
|
|
402
|
+
output_dir:
|
|
403
|
+
web: "../web-ui/"
|
|
404
|
+
android: "../kmp-ui/"
|
|
405
|
+
ios: "../kmp-ui/iosApp/"
|
|
406
|
+
\`\`\`
|
|
407
|
+
Paths are relative to \`openuispec.yaml\`. The \`.openuispec-state.json\` file is stored inside each output directory.
|
|
408
|
+
|
|
372
409
|
## CLI commands
|
|
373
410
|
- \`openuispec init\` — scaffold a new spec project
|
|
374
411
|
- \`openuispec validate [group...]\` — validate spec files against schemas
|
package/drift/index.ts
CHANGED
|
@@ -159,27 +159,60 @@ function readProjectName(projectDir: string): string {
|
|
|
159
159
|
return doc.project?.name ?? basename(projectDir);
|
|
160
160
|
}
|
|
161
161
|
|
|
162
|
+
/** Read per-target output_dir map from the manifest. */
|
|
163
|
+
function readOutputDirs(projectDir: string): Record<string, string> {
|
|
164
|
+
try {
|
|
165
|
+
const doc = YAML.parse(readFileSync(join(projectDir, "openuispec.yaml"), "utf-8"));
|
|
166
|
+
return doc.generation?.output_dir ?? {};
|
|
167
|
+
} catch {
|
|
168
|
+
return {};
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
162
172
|
/** Resolve the generated output directory for a target. */
|
|
163
|
-
function resolveOutputDir(
|
|
164
|
-
|
|
173
|
+
function resolveOutputDir(projectDir: string, projectName: string, target: string): string {
|
|
174
|
+
const outputDirs = readOutputDirs(projectDir);
|
|
175
|
+
if (outputDirs[target]) {
|
|
176
|
+
return resolve(projectDir, outputDirs[target]);
|
|
177
|
+
}
|
|
178
|
+
// Default: generated/<target>/<project_name> relative to cwd
|
|
179
|
+
return resolve(projectDir, "..", "generated", target, projectName);
|
|
165
180
|
}
|
|
166
181
|
|
|
167
|
-
function stateFilePath(
|
|
168
|
-
return join(resolveOutputDir(
|
|
182
|
+
function stateFilePath(projectDir: string, projectName: string, target: string): string {
|
|
183
|
+
return join(resolveOutputDir(projectDir, projectName, target), STATE_FILE);
|
|
169
184
|
}
|
|
170
185
|
|
|
171
|
-
function discoverTargets(
|
|
172
|
-
const
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
.
|
|
180
|
-
|
|
181
|
-
|
|
186
|
+
function discoverTargets(projectDir: string, projectName: string): string[] {
|
|
187
|
+
const outputDirs = readOutputDirs(projectDir);
|
|
188
|
+
const targets: string[] = [];
|
|
189
|
+
|
|
190
|
+
// Check configured output_dir entries
|
|
191
|
+
for (const [target, dir] of Object.entries(outputDirs)) {
|
|
192
|
+
const resolved = resolve(projectDir, dir);
|
|
193
|
+
if (existsSync(join(resolved, STATE_FILE))) {
|
|
194
|
+
targets.push(target);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Also check default generated/ directory
|
|
199
|
+
const generatedDir = resolve(projectDir, "..", "generated");
|
|
200
|
+
if (existsSync(generatedDir)) {
|
|
201
|
+
try {
|
|
202
|
+
for (const entry of readdirSync(generatedDir)) {
|
|
203
|
+
if (!targets.includes(entry)) {
|
|
204
|
+
const defaultPath = join(generatedDir, entry, projectName, STATE_FILE);
|
|
205
|
+
if (existsSync(defaultPath)) {
|
|
206
|
+
targets.push(entry);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
} catch {
|
|
211
|
+
// ignore
|
|
212
|
+
}
|
|
182
213
|
}
|
|
214
|
+
|
|
215
|
+
return targets.sort();
|
|
183
216
|
}
|
|
184
217
|
|
|
185
218
|
/**
|
|
@@ -197,7 +230,7 @@ function normalizeEntry(value: string | FileEntry): FileEntry {
|
|
|
197
230
|
|
|
198
231
|
function snapshot(cwd: string, projectDir: string, target: string): void {
|
|
199
232
|
const projectName = readProjectName(projectDir);
|
|
200
|
-
const outDir = resolveOutputDir(
|
|
233
|
+
const outDir = resolveOutputDir(projectDir, projectName, target);
|
|
201
234
|
if (!existsSync(outDir)) {
|
|
202
235
|
console.error(
|
|
203
236
|
`Error: Output directory not found: ${relative(cwd, outDir)}\n` +
|
|
@@ -226,7 +259,7 @@ function snapshot(cwd: string, projectDir: string, target: string): void {
|
|
|
226
259
|
files: entries,
|
|
227
260
|
};
|
|
228
261
|
|
|
229
|
-
const outPath = stateFilePath(
|
|
262
|
+
const outPath = stateFilePath(projectDir, projectName, target);
|
|
230
263
|
writeFileSync(outPath, JSON.stringify(state, null, 2) + "\n");
|
|
231
264
|
console.log(`Snapshot saved: ${relative(cwd, outPath)}`);
|
|
232
265
|
console.log(` ${Object.keys(entries).length} files hashed`);
|
|
@@ -301,7 +334,7 @@ function check(
|
|
|
301
334
|
includeAll: boolean
|
|
302
335
|
): void {
|
|
303
336
|
const projectName = readProjectName(projectDir);
|
|
304
|
-
const statePath = stateFilePath(
|
|
337
|
+
const statePath = stateFilePath(projectDir, projectName, target);
|
|
305
338
|
if (!existsSync(statePath)) {
|
|
306
339
|
console.error(
|
|
307
340
|
`No snapshot found for target "${target}".\n` +
|
|
@@ -331,7 +364,7 @@ function checkAll(
|
|
|
331
364
|
includeAll: boolean
|
|
332
365
|
): void {
|
|
333
366
|
const projectName = readProjectName(projectDir);
|
|
334
|
-
const targets = discoverTargets(
|
|
367
|
+
const targets = discoverTargets(projectDir, projectName);
|
|
335
368
|
if (targets.length === 0) {
|
|
336
369
|
console.error(
|
|
337
370
|
"No snapshots found. Run: openuispec drift --snapshot --target <target>"
|
|
@@ -342,7 +375,7 @@ function checkAll(
|
|
|
342
375
|
let anyDrift = false;
|
|
343
376
|
|
|
344
377
|
for (const target of targets) {
|
|
345
|
-
const statePath = stateFilePath(
|
|
378
|
+
const statePath = stateFilePath(projectDir, projectName, target);
|
|
346
379
|
const state: StateFile = JSON.parse(readFileSync(statePath, "utf-8"));
|
|
347
380
|
const result = computeDrift(projectDir, state, includeAll);
|
|
348
381
|
|
package/package.json
CHANGED
|
@@ -18,9 +18,25 @@
|
|
|
18
18
|
},
|
|
19
19
|
"patternProperties": {
|
|
20
20
|
"^[a-zA-Z_][a-zA-Z0-9_.]*$": {
|
|
21
|
-
"
|
|
22
|
-
"description": "Translation string (ICU MessageFormat)"
|
|
21
|
+
"$ref": "#/$defs/message_value"
|
|
23
22
|
}
|
|
24
23
|
},
|
|
25
|
-
"additionalProperties": false
|
|
24
|
+
"additionalProperties": false,
|
|
25
|
+
"$defs": {
|
|
26
|
+
"message_value": {
|
|
27
|
+
"description": "Translation string (ICU MessageFormat) or nested group of translations",
|
|
28
|
+
"oneOf": [
|
|
29
|
+
{ "type": "string" },
|
|
30
|
+
{
|
|
31
|
+
"type": "object",
|
|
32
|
+
"patternProperties": {
|
|
33
|
+
"^[a-zA-Z_][a-zA-Z0-9_.]*$": {
|
|
34
|
+
"$ref": "#/$defs/message_value"
|
|
35
|
+
}
|
|
36
|
+
},
|
|
37
|
+
"additionalProperties": false
|
|
38
|
+
}
|
|
39
|
+
]
|
|
40
|
+
}
|
|
41
|
+
}
|
|
26
42
|
}
|
|
@@ -104,6 +104,13 @@
|
|
|
104
104
|
"type": "string"
|
|
105
105
|
}
|
|
106
106
|
},
|
|
107
|
+
"output_dir": {
|
|
108
|
+
"type": "object",
|
|
109
|
+
"description": "Per-target output directory (relative to openuispec.yaml). Defaults to generated/<target>/<project_name> if not set.",
|
|
110
|
+
"additionalProperties": {
|
|
111
|
+
"type": "string"
|
|
112
|
+
}
|
|
113
|
+
},
|
|
107
114
|
"output_format": {
|
|
108
115
|
"type": "object",
|
|
109
116
|
"description": "Per-target generation config",
|
package/schema/validate.ts
CHANGED
|
@@ -148,9 +148,46 @@ function validateFile(
|
|
|
148
148
|
|
|
149
149
|
// ── validation groups ────────────────────────────────────────────────
|
|
150
150
|
|
|
151
|
+
// ── includes resolution ──────────────────────────────────────────────
|
|
152
|
+
|
|
153
|
+
interface Includes {
|
|
154
|
+
tokens: string;
|
|
155
|
+
contracts: string;
|
|
156
|
+
screens: string;
|
|
157
|
+
flows: string;
|
|
158
|
+
platform: string;
|
|
159
|
+
locales: string;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const DEFAULT_INCLUDES: Includes = {
|
|
163
|
+
tokens: "./tokens/",
|
|
164
|
+
contracts: "./contracts/",
|
|
165
|
+
screens: "./screens/",
|
|
166
|
+
flows: "./flows/",
|
|
167
|
+
platform: "./platform/",
|
|
168
|
+
locales: "./locales/",
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
function readIncludes(projectDir: string): Includes {
|
|
172
|
+
const manifestPath = join(projectDir, "openuispec.yaml");
|
|
173
|
+
try {
|
|
174
|
+
const manifest = loadYaml(manifestPath) as Record<string, unknown>;
|
|
175
|
+
const inc = manifest?.includes as Partial<Includes> | undefined;
|
|
176
|
+
return { ...DEFAULT_INCLUDES, ...inc };
|
|
177
|
+
} catch {
|
|
178
|
+
return DEFAULT_INCLUDES;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function resolveInclude(projectDir: string, includePath: string): string {
|
|
183
|
+
return resolve(projectDir, includePath);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// ── validation groups ────────────────────────────────────────────────
|
|
187
|
+
|
|
151
188
|
interface ValidationGroup {
|
|
152
189
|
label: string;
|
|
153
|
-
run(ajv: AjvInstance, projectDir: string): number;
|
|
190
|
+
run(ajv: AjvInstance, projectDir: string, includes: Includes): number;
|
|
154
191
|
}
|
|
155
192
|
|
|
156
193
|
const GROUPS: Record<string, ValidationGroup> = {
|
|
@@ -167,8 +204,9 @@ const GROUPS: Record<string, ValidationGroup> = {
|
|
|
167
204
|
|
|
168
205
|
tokens: {
|
|
169
206
|
label: "Tokens",
|
|
170
|
-
run(ajv, projectDir) {
|
|
207
|
+
run(ajv, projectDir, includes) {
|
|
171
208
|
let errors = 0;
|
|
209
|
+
const tokensDir = resolveInclude(projectDir, includes.tokens);
|
|
172
210
|
const tokenMap: Record<string, string> = {
|
|
173
211
|
"color.yaml": "color.schema.json",
|
|
174
212
|
"typography.yaml": "typography.schema.json",
|
|
@@ -180,7 +218,7 @@ const GROUPS: Record<string, ValidationGroup> = {
|
|
|
180
218
|
"icons.yaml": "icons.schema.json",
|
|
181
219
|
};
|
|
182
220
|
for (const [data, schema] of Object.entries(tokenMap)) {
|
|
183
|
-
const filePath = join(
|
|
221
|
+
const filePath = join(tokensDir, data);
|
|
184
222
|
if (existsSync(filePath)) {
|
|
185
223
|
errors += validateFile(ajv, filePath, `${BASE}tokens/${schema}`);
|
|
186
224
|
}
|
|
@@ -191,9 +229,10 @@ const GROUPS: Record<string, ValidationGroup> = {
|
|
|
191
229
|
|
|
192
230
|
screens: {
|
|
193
231
|
label: "Screens",
|
|
194
|
-
run(ajv, projectDir) {
|
|
232
|
+
run(ajv, projectDir, includes) {
|
|
195
233
|
let errors = 0;
|
|
196
|
-
|
|
234
|
+
const dir = resolveInclude(projectDir, includes.screens);
|
|
235
|
+
for (const f of listFiles(dir, ".yaml")) {
|
|
197
236
|
errors += validateFile(ajv, f, `${BASE}screen.schema.json`);
|
|
198
237
|
}
|
|
199
238
|
return errors;
|
|
@@ -202,9 +241,10 @@ const GROUPS: Record<string, ValidationGroup> = {
|
|
|
202
241
|
|
|
203
242
|
flows: {
|
|
204
243
|
label: "Flows",
|
|
205
|
-
run(ajv, projectDir) {
|
|
244
|
+
run(ajv, projectDir, includes) {
|
|
206
245
|
let errors = 0;
|
|
207
|
-
|
|
246
|
+
const dir = resolveInclude(projectDir, includes.flows);
|
|
247
|
+
for (const f of listFiles(dir, ".yaml")) {
|
|
208
248
|
errors += validateFile(ajv, f, `${BASE}flow.schema.json`);
|
|
209
249
|
}
|
|
210
250
|
return errors;
|
|
@@ -213,9 +253,10 @@ const GROUPS: Record<string, ValidationGroup> = {
|
|
|
213
253
|
|
|
214
254
|
platform: {
|
|
215
255
|
label: "Platform",
|
|
216
|
-
run(ajv, projectDir) {
|
|
256
|
+
run(ajv, projectDir, includes) {
|
|
217
257
|
let errors = 0;
|
|
218
|
-
|
|
258
|
+
const dir = resolveInclude(projectDir, includes.platform);
|
|
259
|
+
for (const f of listFiles(dir, ".yaml")) {
|
|
219
260
|
errors += validateFile(ajv, f, `${BASE}platform.schema.json`);
|
|
220
261
|
}
|
|
221
262
|
return errors;
|
|
@@ -224,9 +265,10 @@ const GROUPS: Record<string, ValidationGroup> = {
|
|
|
224
265
|
|
|
225
266
|
locales: {
|
|
226
267
|
label: "Locales",
|
|
227
|
-
run(ajv, projectDir) {
|
|
268
|
+
run(ajv, projectDir, includes) {
|
|
228
269
|
let errors = 0;
|
|
229
|
-
|
|
270
|
+
const dir = resolveInclude(projectDir, includes.locales);
|
|
271
|
+
for (const f of listFiles(dir, ".json")) {
|
|
230
272
|
errors += validateFile(ajv, f, `${BASE}locale.schema.json`);
|
|
231
273
|
}
|
|
232
274
|
return errors;
|
|
@@ -235,9 +277,10 @@ const GROUPS: Record<string, ValidationGroup> = {
|
|
|
235
277
|
|
|
236
278
|
custom_contracts: {
|
|
237
279
|
label: "Custom contracts",
|
|
238
|
-
run(ajv, projectDir) {
|
|
280
|
+
run(ajv, projectDir, includes) {
|
|
239
281
|
let errors = 0;
|
|
240
|
-
|
|
282
|
+
const dir = resolveInclude(projectDir, includes.contracts);
|
|
283
|
+
for (const f of listFiles(dir, ".yaml")) {
|
|
241
284
|
if (basename(f).startsWith("x_")) {
|
|
242
285
|
errors += validateFile(
|
|
243
286
|
ajv,
|
|
@@ -291,13 +334,14 @@ export function runValidate(argv: string[]): void {
|
|
|
291
334
|
}
|
|
292
335
|
|
|
293
336
|
const projectDir = findProjectDir(process.cwd());
|
|
337
|
+
const includes = readIncludes(projectDir);
|
|
294
338
|
const ajv = buildAjv();
|
|
295
339
|
let totalErrors = 0;
|
|
296
340
|
|
|
297
341
|
for (const key of selected) {
|
|
298
342
|
const group = GROUPS[key];
|
|
299
343
|
console.log(`\n${group.label}:`);
|
|
300
|
-
totalErrors += group.run(ajv, projectDir);
|
|
344
|
+
totalErrors += group.run(ajv, projectDir, includes);
|
|
301
345
|
}
|
|
302
346
|
|
|
303
347
|
console.log(`\n${"=".repeat(50)}`);
|