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 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(cwd: string, projectName: string, target: string): string {
164
- return resolve(cwd, "generated", target, projectName);
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(cwd: string, projectName: string, target: string): string {
168
- return join(resolveOutputDir(cwd, projectName, target), STATE_FILE);
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(cwd: string, projectName: string): string[] {
172
- const generatedDir = resolve(cwd, "generated");
173
- if (!existsSync(generatedDir)) return [];
174
- try {
175
- return readdirSync(generatedDir)
176
- .filter((entry) =>
177
- existsSync(stateFilePath(cwd, projectName, entry))
178
- )
179
- .sort();
180
- } catch {
181
- return [];
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(cwd, projectName, target);
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(cwd, projectName, target);
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(cwd, projectName, target);
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(cwd, projectName);
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(cwd, projectName, target);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openuispec",
3
- "version": "0.1.13",
3
+ "version": "0.1.15",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "description": "A semantic UI specification format for AI-native, platform-native app development",
@@ -18,9 +18,25 @@
18
18
  },
19
19
  "patternProperties": {
20
20
  "^[a-zA-Z_][a-zA-Z0-9_.]*$": {
21
- "type": "string",
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",
@@ -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(projectDir, "tokens", data);
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
- for (const f of listFiles(join(projectDir, "screens"), ".yaml")) {
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
- for (const f of listFiles(join(projectDir, "flows"), ".yaml")) {
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
- for (const f of listFiles(join(projectDir, "platform"), ".yaml")) {
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
- for (const f of listFiles(join(projectDir, "locales"), ".json")) {
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
- for (const f of listFiles(join(projectDir, "contracts"), ".yaml")) {
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)}`);