openuispec 0.2.14 → 0.2.16
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 +4 -2
- package/check/audit.ts +251 -0
- package/check/index.ts +19 -3
- package/cli/init.ts +1 -0
- package/docs/cli.md +82 -3
- package/docs/file-formats.md +83 -0
- package/docs/implementation-notes.md +8 -0
- package/examples/social-app/openuispec/contracts/action_trigger.yaml +8 -0
- package/examples/social-app/openuispec/contracts/collection.yaml +8 -0
- package/examples/social-app/openuispec/contracts/data_display.yaml +8 -0
- package/examples/social-app/openuispec/contracts/feedback.yaml +8 -0
- package/examples/social-app/openuispec/contracts/input_field.yaml +8 -0
- package/examples/social-app/openuispec/contracts/nav_container.yaml +9 -0
- package/examples/social-app/openuispec/contracts/surface.yaml +8 -0
- package/examples/social-app/openuispec/openuispec.yaml +40 -0
- package/examples/social-app/openuispec/tokens/color.yaml +4 -0
- package/examples/social-app/openuispec/tokens/motion.yaml +4 -0
- package/examples/social-app/openuispec/tokens/typography.yaml +11 -0
- package/examples/taskflow/openuispec/contracts/action_trigger.yaml +9 -1
- package/examples/taskflow/openuispec/contracts/collection.yaml +9 -1
- package/examples/taskflow/openuispec/contracts/data_display.yaml +9 -1
- package/examples/taskflow/openuispec/contracts/feedback.yaml +9 -1
- package/examples/taskflow/openuispec/contracts/input_field.yaml +8 -0
- package/examples/taskflow/openuispec/contracts/nav_container.yaml +10 -1
- package/examples/taskflow/openuispec/contracts/surface.yaml +9 -1
- package/examples/taskflow/openuispec/openuispec.yaml +40 -0
- package/examples/taskflow/openuispec/tokens/color.yaml +4 -0
- package/examples/taskflow/openuispec/tokens/motion.yaml +4 -0
- package/examples/taskflow/openuispec/tokens/typography.yaml +11 -0
- package/examples/todo-orbit/openuispec/contracts/action_trigger.yaml +7 -0
- package/examples/todo-orbit/openuispec/contracts/collection.yaml +7 -0
- package/examples/todo-orbit/openuispec/contracts/data_display.yaml +7 -0
- package/examples/todo-orbit/openuispec/contracts/feedback.yaml +7 -0
- package/examples/todo-orbit/openuispec/contracts/input_field.yaml +7 -0
- package/examples/todo-orbit/openuispec/contracts/nav_container.yaml +8 -0
- package/examples/todo-orbit/openuispec/contracts/surface.yaml +7 -0
- package/examples/todo-orbit/openuispec/openuispec.yaml +40 -0
- package/examples/todo-orbit/openuispec/tokens/color.yaml +4 -0
- package/examples/todo-orbit/openuispec/tokens/motion.yaml +4 -0
- package/examples/todo-orbit/openuispec/tokens/typography.yaml +11 -0
- package/mcp-server/index.ts +22 -6
- package/mcp-server/screenshot-shared.ts +3 -4
- package/mcp-server/screenshot.ts +285 -70
- package/package.json +1 -1
- package/prepare/index.ts +96 -0
- package/schema/component.schema.json +5 -0
- package/schema/contract.schema.json +11 -1
- package/schema/custom-contract.schema.json +5 -0
- package/schema/openuispec.schema.json +47 -0
- package/schema/semantic-lint.ts +5 -3
- package/schema/tokens/color.schema.json +5 -0
- package/schema/tokens/motion.schema.json +5 -0
- package/schema/tokens/typography.schema.json +10 -0
|
@@ -48,10 +48,9 @@ export async function closeBrowser(): Promise<void> {
|
|
|
48
48
|
|
|
49
49
|
// ── shared result type ──────────────────────────────────────────────
|
|
50
50
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
}
|
|
51
|
+
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
|
|
52
|
+
|
|
53
|
+
export type ScreenshotResult = CallToolResult;
|
|
55
54
|
|
|
56
55
|
// ── manifest loading ────────────────────────────────────────────────
|
|
57
56
|
|
package/mcp-server/screenshot.ts
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
import { spawn, type ChildProcess, execSync } from "node:child_process";
|
|
9
9
|
import { existsSync, readFileSync, mkdirSync, writeFileSync } from "node:fs";
|
|
10
10
|
import { join, resolve } from "node:path";
|
|
11
|
-
import {
|
|
11
|
+
import { createConnection } from "node:net";
|
|
12
12
|
import YAML from "yaml";
|
|
13
13
|
import { findProjectDir } from "../drift/index.js";
|
|
14
14
|
import { getBrowser, closeBrowser, type ScreenshotResult } from "./screenshot-shared.js";
|
|
@@ -24,18 +24,186 @@ export interface ScreenshotOptions {
|
|
|
24
24
|
full_page?: boolean;
|
|
25
25
|
selector?: string;
|
|
26
26
|
output_dir?: string;
|
|
27
|
+
init_script?: string;
|
|
27
28
|
}
|
|
28
29
|
|
|
29
|
-
// ──
|
|
30
|
+
// ── framework config table ───────────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
interface FrameworkConfig {
|
|
33
|
+
/** Typed discriminant for branching logic */
|
|
34
|
+
kind: "node" | "bun" | "deno" | "python-django" | "python-flask" | "ruby" | "php" | "go" | "rust" | "java";
|
|
35
|
+
/** Human-readable name */
|
|
36
|
+
name: string;
|
|
37
|
+
/** Files whose presence identifies this framework (checked in webDir) */
|
|
38
|
+
indicators: string[];
|
|
39
|
+
/** Default dev port when none is configured */
|
|
40
|
+
defaultPort: number;
|
|
41
|
+
/** Command + args to start the dev server; PORT placeholder replaced at runtime */
|
|
42
|
+
devCommand: string[];
|
|
43
|
+
/** Install command to run when dependencies are missing (null = skip) */
|
|
44
|
+
installCommand: string[] | null;
|
|
45
|
+
/** Package manager env var used to set the port (e.g. PORT, APP_PORT) */
|
|
46
|
+
portEnvVar: string;
|
|
47
|
+
}
|
|
30
48
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
49
|
+
// Ordered by detection priority (most specific first)
|
|
50
|
+
const FRAMEWORKS: FrameworkConfig[] = [
|
|
51
|
+
{
|
|
52
|
+
kind: "bun",
|
|
53
|
+
name: "Bun",
|
|
54
|
+
indicators: ["bun.lockb", "bun.lock"],
|
|
55
|
+
defaultPort: 3000,
|
|
56
|
+
devCommand: ["bun", "run", "dev"],
|
|
57
|
+
installCommand: ["bun", "install"],
|
|
58
|
+
portEnvVar: "PORT",
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
kind: "deno",
|
|
62
|
+
name: "Deno",
|
|
63
|
+
indicators: ["deno.json", "deno.jsonc"],
|
|
64
|
+
defaultPort: 8000,
|
|
65
|
+
devCommand: ["deno", "task", "dev"],
|
|
66
|
+
installCommand: null,
|
|
67
|
+
portEnvVar: "PORT",
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
kind: "node",
|
|
71
|
+
name: "Node.js (npm/yarn/pnpm)",
|
|
72
|
+
indicators: ["package.json"],
|
|
73
|
+
defaultPort: 3000,
|
|
74
|
+
devCommand: ["npm", "run", "dev"],
|
|
75
|
+
installCommand: ["npm", "install"],
|
|
76
|
+
portEnvVar: "PORT",
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
kind: "python-django",
|
|
80
|
+
name: "Django",
|
|
81
|
+
indicators: ["manage.py"],
|
|
82
|
+
defaultPort: 8000,
|
|
83
|
+
devCommand: ["python", "manage.py", "runserver", "PORT"],
|
|
84
|
+
installCommand: null,
|
|
85
|
+
portEnvVar: "PORT",
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
kind: "python-flask",
|
|
89
|
+
name: "Flask / FastAPI",
|
|
90
|
+
indicators: ["requirements.txt", "Pipfile", "pyproject.toml"],
|
|
91
|
+
defaultPort: 5000,
|
|
92
|
+
devCommand: ["python", "-m", "flask", "run", "--port", "PORT"],
|
|
93
|
+
installCommand: null,
|
|
94
|
+
portEnvVar: "PORT",
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
kind: "ruby",
|
|
98
|
+
name: "Ruby on Rails",
|
|
99
|
+
indicators: ["Gemfile", "config/application.rb"],
|
|
100
|
+
defaultPort: 3000,
|
|
101
|
+
devCommand: ["bin/rails", "server", "-p", "PORT"],
|
|
102
|
+
installCommand: ["bundle", "install"],
|
|
103
|
+
portEnvVar: "PORT",
|
|
104
|
+
},
|
|
105
|
+
{
|
|
106
|
+
kind: "php",
|
|
107
|
+
name: "PHP (Laravel)",
|
|
108
|
+
indicators: ["artisan"],
|
|
109
|
+
defaultPort: 8000,
|
|
110
|
+
devCommand: ["php", "artisan", "serve", "--port=PORT"],
|
|
111
|
+
installCommand: null,
|
|
112
|
+
portEnvVar: "PORT",
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
kind: "go",
|
|
116
|
+
name: "Go",
|
|
117
|
+
indicators: ["go.mod"],
|
|
118
|
+
defaultPort: 8080,
|
|
119
|
+
devCommand: ["go", "run", "."],
|
|
120
|
+
installCommand: null,
|
|
121
|
+
portEnvVar: "PORT",
|
|
122
|
+
},
|
|
123
|
+
{
|
|
124
|
+
kind: "rust",
|
|
125
|
+
name: "Rust (Trunk)",
|
|
126
|
+
indicators: ["Trunk.toml", "Cargo.toml"],
|
|
127
|
+
defaultPort: 8080,
|
|
128
|
+
devCommand: ["trunk", "serve", "--port", "PORT"],
|
|
129
|
+
installCommand: null,
|
|
130
|
+
portEnvVar: "PORT",
|
|
131
|
+
},
|
|
132
|
+
{
|
|
133
|
+
kind: "java",
|
|
134
|
+
name: "Java / Spring Boot",
|
|
135
|
+
indicators: ["pom.xml", "build.gradle", "build.gradle.kts"],
|
|
136
|
+
defaultPort: 8080,
|
|
137
|
+
devCommand: ["./mvnw", "spring-boot:run"],
|
|
138
|
+
installCommand: null,
|
|
139
|
+
portEnvVar: "SERVER_PORT",
|
|
140
|
+
},
|
|
141
|
+
];
|
|
142
|
+
|
|
143
|
+
// ── framework detection ──────────────────────────────────────────────
|
|
144
|
+
|
|
145
|
+
function detectFramework(webDir: string): FrameworkConfig {
|
|
146
|
+
for (const fw of FRAMEWORKS) {
|
|
147
|
+
if (fw.indicators.some((f) => existsSync(join(webDir, f)))) return fw;
|
|
148
|
+
}
|
|
149
|
+
// Fallback: generic Node
|
|
150
|
+
return FRAMEWORKS.find((f) => f.kind === "node")!;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// ── port resolution ──────────────────────────────────────────────────
|
|
154
|
+
|
|
155
|
+
/** Parse --port / -p / --port=N from a script string. */
|
|
156
|
+
function parsePortFromScript(script: string): number | null {
|
|
157
|
+
const m = script.match(/(?:--port|-p)[=\s]+(\d+)/);
|
|
158
|
+
return m ? parseInt(m[1], 10) : null;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Read PORT (or custom var) from .env.local / .env.development / .env. */
|
|
162
|
+
function readEnvPort(webDir: string, varName = "PORT"): number | null {
|
|
163
|
+
for (const name of [".env.local", ".env.development", ".env"]) {
|
|
164
|
+
const envPath = join(webDir, name);
|
|
165
|
+
if (!existsSync(envPath)) continue;
|
|
166
|
+
try {
|
|
167
|
+
const re = new RegExp(`^\\s*${varName}\\s*=\\s*(\\d+)`, "m");
|
|
168
|
+
const m = readFileSync(envPath, "utf-8").match(re);
|
|
169
|
+
if (m) return parseInt(m[1], 10);
|
|
170
|
+
} catch { /* skip */ }
|
|
171
|
+
}
|
|
172
|
+
return null;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Resolve the port this project's dev server will use. */
|
|
176
|
+
function resolvePort(webDir: string, fw: FrameworkConfig): number {
|
|
177
|
+
// 1. .env files
|
|
178
|
+
const envPort = readEnvPort(webDir, fw.portEnvVar);
|
|
179
|
+
if (envPort) return envPort;
|
|
180
|
+
|
|
181
|
+
// 2. package.json dev/start script (Node-like only)
|
|
182
|
+
if (existsSync(join(webDir, "package.json"))) {
|
|
183
|
+
try {
|
|
184
|
+
const pkg = JSON.parse(readFileSync(join(webDir, "package.json"), "utf-8"));
|
|
185
|
+
const scripts: Record<string, string> = pkg.scripts ?? {};
|
|
186
|
+
for (const name of ["dev", "start", "serve", "develop"]) {
|
|
187
|
+
if (scripts[name]) {
|
|
188
|
+
const p = parsePortFromScript(scripts[name]);
|
|
189
|
+
if (p) return p;
|
|
190
|
+
break;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
} catch { /* ignore */ }
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// 3. Framework default
|
|
197
|
+
return fw.defaultPort;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function isPortListening(port: number, host = "127.0.0.1"): Promise<boolean> {
|
|
201
|
+
return new Promise((resolve) => {
|
|
202
|
+
const socket = createConnection({ port, host });
|
|
203
|
+
socket.setTimeout(500);
|
|
204
|
+
socket.on("connect", () => { socket.destroy(); resolve(true); });
|
|
205
|
+
socket.on("timeout", () => { socket.destroy(); resolve(false); });
|
|
206
|
+
socket.on("error", () => resolve(false));
|
|
39
207
|
});
|
|
40
208
|
}
|
|
41
209
|
|
|
@@ -47,18 +215,22 @@ export function findWebAppDir(projectCwd: string): string {
|
|
|
47
215
|
const manifest = YAML.parse(readFileSync(manifestPath, "utf-8"));
|
|
48
216
|
const projectName = manifest.project?.name ?? "app";
|
|
49
217
|
|
|
218
|
+
// Derive indicators from the FRAMEWORKS table so they stay in sync
|
|
219
|
+
const isWebDir = (d: string) =>
|
|
220
|
+
FRAMEWORKS.some((fw) => fw.indicators.some((f) => existsSync(join(d, f))));
|
|
221
|
+
|
|
50
222
|
// Check custom output_dir first
|
|
51
223
|
const customDir = manifest.generation?.output_dir?.web;
|
|
52
224
|
if (customDir) {
|
|
53
225
|
const resolved = resolve(projectDir, customDir);
|
|
54
|
-
if (
|
|
226
|
+
if (isWebDir(resolved)) return resolved;
|
|
55
227
|
}
|
|
56
228
|
|
|
57
229
|
// Default: generated/web/<project-name>/
|
|
58
230
|
// Try from the project root (parent of openuispec/)
|
|
59
231
|
const projectRoot = resolve(projectDir, "..");
|
|
60
232
|
const defaultDir = join(projectRoot, "generated", "web", projectName);
|
|
61
|
-
if (
|
|
233
|
+
if (isWebDir(defaultDir)) return defaultDir;
|
|
62
234
|
|
|
63
235
|
throw new Error(
|
|
64
236
|
`Web app not found. Checked:\n` +
|
|
@@ -71,78 +243,114 @@ export function findWebAppDir(projectCwd: string): string {
|
|
|
71
243
|
// ── dev server manager ──────────────────────────────────────────────
|
|
72
244
|
|
|
73
245
|
interface ServerInstance {
|
|
74
|
-
process: ChildProcess;
|
|
246
|
+
process: ChildProcess | null; // null = using an externally running server
|
|
75
247
|
port: number;
|
|
76
248
|
url: string;
|
|
77
249
|
}
|
|
78
250
|
|
|
79
251
|
const servers = new Map<string, ServerInstance>();
|
|
80
252
|
|
|
81
|
-
function ensureDepsInstalled(webDir: string): void {
|
|
82
|
-
if (
|
|
253
|
+
function ensureDepsInstalled(webDir: string, fw: FrameworkConfig): void {
|
|
254
|
+
if (!fw.installCommand) return;
|
|
255
|
+
// For Node.js check node_modules; for others always run
|
|
256
|
+
if (fw.kind === "node" && existsSync(join(webDir, "node_modules"))) return;
|
|
83
257
|
try {
|
|
84
|
-
execSync("
|
|
258
|
+
execSync(fw.installCommand.join(" "), { cwd: webDir, stdio: "pipe", timeout: 120_000 });
|
|
85
259
|
} catch (err) {
|
|
86
|
-
throw new Error(`Failed to install
|
|
260
|
+
throw new Error(`Failed to install dependencies in ${webDir}: ${err instanceof Error ? err.message : err}`);
|
|
87
261
|
}
|
|
88
262
|
}
|
|
89
263
|
|
|
264
|
+
const stripAnsi = (s: string) => s.replace(/\x1b\[[0-9;]*m/g, "");
|
|
265
|
+
|
|
266
|
+
/** Poll until the port accepts connections, or the timeout expires. */
|
|
267
|
+
async function waitForPort(port: number, timeoutMs = 60_000): Promise<boolean> {
|
|
268
|
+
const deadline = Date.now() + timeoutMs;
|
|
269
|
+
while (Date.now() < deadline) {
|
|
270
|
+
if (await isPortListening(port)) return true;
|
|
271
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
272
|
+
}
|
|
273
|
+
return false;
|
|
274
|
+
}
|
|
275
|
+
|
|
90
276
|
async function startDevServer(webDir: string): Promise<ServerInstance> {
|
|
91
277
|
const existing = servers.get(webDir);
|
|
92
278
|
if (existing) {
|
|
93
|
-
|
|
94
|
-
|
|
279
|
+
const alive = existing.process === null
|
|
280
|
+
? await isPortListening(existing.port) // external server
|
|
281
|
+
: existing.process.exitCode === null; // managed process
|
|
282
|
+
if (alive) return existing;
|
|
95
283
|
servers.delete(webDir);
|
|
96
284
|
}
|
|
97
285
|
|
|
98
|
-
|
|
99
|
-
const port =
|
|
286
|
+
const fw = detectFramework(webDir);
|
|
287
|
+
const port = resolvePort(webDir, fw);
|
|
288
|
+
|
|
289
|
+
// Always prefer an already-running server on the expected port
|
|
290
|
+
if (await isPortListening(port)) {
|
|
291
|
+
const instance: ServerInstance = { process: null, port, url: `http://localhost:${port}` };
|
|
292
|
+
servers.set(webDir, instance);
|
|
293
|
+
return instance;
|
|
294
|
+
}
|
|
100
295
|
|
|
101
|
-
|
|
296
|
+
// Start the dev server for this framework
|
|
297
|
+
ensureDepsInstalled(webDir, fw);
|
|
298
|
+
|
|
299
|
+
// Build command: replace "PORT" placeholder with actual port string
|
|
300
|
+
const [cmd, ...args] = fw.devCommand.map((part) => part === "PORT" ? String(port) : part);
|
|
301
|
+
|
|
302
|
+
// For Node.js, use the project's own dev script from package.json if available
|
|
303
|
+
let spawnCmd = cmd;
|
|
304
|
+
let spawnArgs = args;
|
|
305
|
+
if (fw.kind === "node" && existsSync(join(webDir, "package.json"))) {
|
|
306
|
+
try {
|
|
307
|
+
const pkg = JSON.parse(readFileSync(join(webDir, "package.json"), "utf-8"));
|
|
308
|
+
const scripts: Record<string, string> = pkg.scripts ?? {};
|
|
309
|
+
const scriptName = ["dev", "start", "serve", "develop"].find((n) => n in scripts);
|
|
310
|
+
if (scriptName) {
|
|
311
|
+
spawnCmd = "npm";
|
|
312
|
+
spawnArgs = ["run", scriptName];
|
|
313
|
+
}
|
|
314
|
+
} catch { /* ignore */ }
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const child = spawn(spawnCmd, spawnArgs, {
|
|
102
318
|
cwd: webDir,
|
|
103
319
|
stdio: ["ignore", "pipe", "pipe"],
|
|
104
|
-
env: { ...process.env, FORCE_COLOR: "0", BROWSER: "none" },
|
|
320
|
+
env: { ...process.env, FORCE_COLOR: "0", BROWSER: "none", [fw.portEnvVar]: String(port) },
|
|
105
321
|
});
|
|
106
322
|
|
|
107
|
-
//
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
if (match) {
|
|
122
|
-
clearTimeout(timeout);
|
|
123
|
-
child.stdout?.off("data", onData);
|
|
124
|
-
child.stderr?.off("data", onData);
|
|
125
|
-
resolveUrl(match[1]);
|
|
126
|
-
}
|
|
127
|
-
};
|
|
128
|
-
child.stdout?.on("data", onData);
|
|
129
|
-
child.stderr?.on("data", onData);
|
|
130
|
-
child.on("error", (err) => { clearTimeout(timeout); reject(err); });
|
|
131
|
-
child.on("exit", (code) => {
|
|
132
|
-
clearTimeout(timeout);
|
|
133
|
-
if (!output.includes("Local:")) {
|
|
134
|
-
reject(new Error(`Vite exited with code ${code} before ready. Output:\n${output.slice(-500)}`));
|
|
135
|
-
}
|
|
136
|
-
});
|
|
137
|
-
});
|
|
323
|
+
// Collect stderr from the start so we have output for error messages
|
|
324
|
+
let stderr = "";
|
|
325
|
+
child.stderr?.on("data", (d: Buffer) => { stderr += d.toString(); });
|
|
326
|
+
|
|
327
|
+
// Wait for the port to open (framework-agnostic — no stdout parsing needed)
|
|
328
|
+
const ready = await waitForPort(port, 60_000);
|
|
329
|
+
|
|
330
|
+
if (!ready) {
|
|
331
|
+
child.kill();
|
|
332
|
+
throw new Error(
|
|
333
|
+
`${fw.name} dev server did not open port ${port} within 60s.\n` +
|
|
334
|
+
(stderr ? `stderr:\n${stripAnsi(stderr).slice(-500)}` : ""),
|
|
335
|
+
);
|
|
336
|
+
}
|
|
138
337
|
|
|
139
|
-
const instance: ServerInstance = { process: child, port, url };
|
|
338
|
+
const instance: ServerInstance = { process: child, port, url: `http://localhost:${port}` };
|
|
140
339
|
servers.set(webDir, instance);
|
|
141
340
|
return instance;
|
|
142
341
|
}
|
|
143
342
|
|
|
144
343
|
// ── browser manager (imported from screenshot-shared.ts) ────────────
|
|
145
344
|
|
|
345
|
+
// ── init_script URL injection ────────────────────────────────────────
|
|
346
|
+
|
|
347
|
+
/** Append ?__ous_init=<base64> to a URL, respecting existing query params. */
|
|
348
|
+
function appendInitParam(targetUrl: string, initScript: string): string {
|
|
349
|
+
const url = new URL(targetUrl);
|
|
350
|
+
url.searchParams.set("__ous_init", Buffer.from(initScript).toString("base64"));
|
|
351
|
+
return url.toString();
|
|
352
|
+
}
|
|
353
|
+
|
|
146
354
|
// ── screenshot capture ──────────────────────────────────────────────
|
|
147
355
|
|
|
148
356
|
export async function takeScreenshot(
|
|
@@ -158,6 +366,7 @@ export async function takeScreenshot(
|
|
|
158
366
|
full_page = false,
|
|
159
367
|
selector,
|
|
160
368
|
output_dir,
|
|
369
|
+
init_script,
|
|
161
370
|
} = options;
|
|
162
371
|
|
|
163
372
|
// 1. Find and start
|
|
@@ -181,7 +390,8 @@ export async function takeScreenshot(
|
|
|
181
390
|
}
|
|
182
391
|
|
|
183
392
|
const base = server.url.replace(/\/+$/, "");
|
|
184
|
-
|
|
393
|
+
let targetUrl = `${base}${route.startsWith("/") ? "" : "/"}${route}`;
|
|
394
|
+
if (init_script) targetUrl = appendInitParam(targetUrl, init_script);
|
|
185
395
|
await page.goto(targetUrl, { waitUntil: "networkidle2", timeout: 30_000 });
|
|
186
396
|
|
|
187
397
|
if (wait_for > 0) {
|
|
@@ -230,6 +440,7 @@ export async function takeScreenshot(
|
|
|
230
440
|
full_page,
|
|
231
441
|
selector: selector ?? null,
|
|
232
442
|
path: savedPath ?? null,
|
|
443
|
+
init_script: init_script ?? null,
|
|
233
444
|
}, null, 2),
|
|
234
445
|
},
|
|
235
446
|
],
|
|
@@ -247,6 +458,7 @@ export interface WebBatchCapture {
|
|
|
247
458
|
selector?: string;
|
|
248
459
|
full_page?: boolean;
|
|
249
460
|
wait_for?: number;
|
|
461
|
+
init_script?: string;
|
|
250
462
|
}
|
|
251
463
|
|
|
252
464
|
export interface WebScreenshotBatchOptions {
|
|
@@ -255,6 +467,7 @@ export interface WebScreenshotBatchOptions {
|
|
|
255
467
|
scale?: number;
|
|
256
468
|
theme?: "light" | "dark";
|
|
257
469
|
output_dir?: string;
|
|
470
|
+
init_script?: string;
|
|
258
471
|
}
|
|
259
472
|
|
|
260
473
|
// ── batch screenshot ─────────────────────────────────────────────────
|
|
@@ -263,7 +476,7 @@ export async function takeScreenshotBatch(
|
|
|
263
476
|
projectCwd: string,
|
|
264
477
|
options: WebScreenshotBatchOptions,
|
|
265
478
|
): Promise<ScreenshotResult> {
|
|
266
|
-
const { captures, viewport = { width: 1280, height: 800 }, scale = 2, theme, output_dir } = options;
|
|
479
|
+
const { captures, viewport = { width: 1280, height: 800 }, scale = 2, theme, output_dir, init_script: sharedInitScript } = options;
|
|
267
480
|
|
|
268
481
|
if (captures.length === 0) {
|
|
269
482
|
return { content: [{ type: "text", text: "No web captures specified." }], isError: true };
|
|
@@ -286,10 +499,12 @@ export async function takeScreenshotBatch(
|
|
|
286
499
|
|
|
287
500
|
const base = server.url.replace(/\/+$/, "");
|
|
288
501
|
const themeLabel = theme ?? "default";
|
|
289
|
-
const snapshots: Array<{ screen: string; path: string; data: string }> = [];
|
|
502
|
+
const snapshots: Array<{ screen: string; path: string; data: string; init_script?: string }> = [];
|
|
290
503
|
|
|
291
504
|
for (const capture of captures) {
|
|
292
|
-
const
|
|
505
|
+
const effectiveInitScript = capture.init_script ?? sharedInitScript;
|
|
506
|
+
let targetUrl = `${base}${capture.route.startsWith("/") ? "" : "/"}${capture.route}`;
|
|
507
|
+
if (effectiveInitScript) targetUrl = appendInitParam(targetUrl, effectiveInitScript);
|
|
293
508
|
await page.goto(targetUrl, { waitUntil: "networkidle2", timeout: 30_000 });
|
|
294
509
|
await new Promise((r) => setTimeout(r, capture.wait_for ?? 1000));
|
|
295
510
|
|
|
@@ -310,7 +525,7 @@ export async function takeScreenshotBatch(
|
|
|
310
525
|
writeFileSync(savedPath, buffer);
|
|
311
526
|
}
|
|
312
527
|
|
|
313
|
-
snapshots.push({ screen: capture.screen, path: savedPath, data: buffer.toString("base64") });
|
|
528
|
+
snapshots.push({ screen: capture.screen, path: savedPath, data: buffer.toString("base64"), init_script: effectiveInitScript });
|
|
314
529
|
}
|
|
315
530
|
|
|
316
531
|
const content: ScreenshotResult["content"] = [];
|
|
@@ -318,7 +533,7 @@ export async function takeScreenshotBatch(
|
|
|
318
533
|
content.push({ type: "image" as const, data: s.data, mimeType: "image/png" });
|
|
319
534
|
content.push({
|
|
320
535
|
type: "text" as const,
|
|
321
|
-
text: JSON.stringify({ screen: s.screen, path: s.path, viewport, scale, theme: themeLabel }, null, 2),
|
|
536
|
+
text: JSON.stringify({ screen: s.screen, path: s.path, viewport, scale, theme: themeLabel, init_script: s.init_script ?? null }, null, 2),
|
|
322
537
|
});
|
|
323
538
|
}
|
|
324
539
|
return { content };
|
|
@@ -329,20 +544,20 @@ export async function takeScreenshotBatch(
|
|
|
329
544
|
|
|
330
545
|
// ── cleanup ─────────────────────────────────────────────────────────
|
|
331
546
|
|
|
332
|
-
|
|
547
|
+
function killAllServers() {
|
|
333
548
|
for (const [, instance] of servers) {
|
|
334
|
-
|
|
549
|
+
if (instance.process) {
|
|
550
|
+
try { instance.process.kill(); } catch { /* already dead */ }
|
|
551
|
+
}
|
|
335
552
|
}
|
|
336
553
|
servers.clear();
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
export async function shutdownAll() {
|
|
557
|
+
killAllServers();
|
|
337
558
|
await closeBrowser();
|
|
338
559
|
}
|
|
339
560
|
|
|
340
|
-
process.on("exit",
|
|
341
|
-
// Sync-only fallback for process exit
|
|
342
|
-
for (const [, instance] of servers) {
|
|
343
|
-
try { instance.process.kill(); } catch { /* already dead */ }
|
|
344
|
-
}
|
|
345
|
-
servers.clear();
|
|
346
|
-
});
|
|
561
|
+
process.on("exit", killAllServers);
|
|
347
562
|
process.on("SIGINT", () => { shutdownAll().then(() => process.exit(0)); });
|
|
348
563
|
process.on("SIGTERM", () => { shutdownAll().then(() => process.exit(0)); });
|
package/package.json
CHANGED
package/prepare/index.ts
CHANGED
|
@@ -172,6 +172,17 @@ export interface PrepareResult {
|
|
|
172
172
|
shared_layers?: SharedLayerInfo[];
|
|
173
173
|
bootstrap?: PrepareBootstrapBundle;
|
|
174
174
|
spec_contents?: SpecFileContent[];
|
|
175
|
+
anti_patterns?: {
|
|
176
|
+
universal: Record<string, string[]>;
|
|
177
|
+
contract_specific: Record<string, string[]>;
|
|
178
|
+
project_specific: string[];
|
|
179
|
+
};
|
|
180
|
+
design_context?: {
|
|
181
|
+
personality?: string;
|
|
182
|
+
complexity: 'restrained' | 'balanced' | 'elaborate';
|
|
183
|
+
audience?: string;
|
|
184
|
+
complexity_rule: string;
|
|
185
|
+
};
|
|
175
186
|
next_steps: string[];
|
|
176
187
|
}
|
|
177
188
|
|
|
@@ -643,9 +654,90 @@ function generationRules(target: string, outputDir: string, manifest: Record<str
|
|
|
643
654
|
rules.push(`Target "${target}" scope: ${structure.scope}`);
|
|
644
655
|
}
|
|
645
656
|
|
|
657
|
+
// Include extra_rules from manifest, filtered by target platform tag
|
|
658
|
+
const extraRules: string[] = Array.isArray(manifest.generation?.extra_rules)
|
|
659
|
+
? manifest.generation.extra_rules.filter((rule: any): rule is string => typeof rule === "string")
|
|
660
|
+
: [];
|
|
661
|
+
for (const rule of extraRules) {
|
|
662
|
+
if (matchesTargetPlatform(rule, target)) rules.push(rule);
|
|
663
|
+
}
|
|
664
|
+
|
|
646
665
|
return rules;
|
|
647
666
|
}
|
|
648
667
|
|
|
668
|
+
function matchesTargetPlatform(item: string, target: string): boolean {
|
|
669
|
+
const tagMatch = item.match(/^\[([a-z]+)\]/);
|
|
670
|
+
return !tagMatch || tagMatch[1] === target;
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
function complexityRule(complexity: string): string {
|
|
674
|
+
switch (complexity) {
|
|
675
|
+
case 'restrained':
|
|
676
|
+
return 'Minimal motion (required state transitions only). No decorative shadows. Clean whitespace. Precise token application. No background effects.';
|
|
677
|
+
case 'elaborate':
|
|
678
|
+
return 'Rich animations with staggered reveals. Creative elevation. Platform-specific flourishes.';
|
|
679
|
+
default:
|
|
680
|
+
return 'Apply all motion.patterns. Use elevation tokens fully. Standard state animations.';
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
function buildDesignContext(manifest: Record<string, any>): PrepareResult['design_context'] {
|
|
685
|
+
const design = manifest.design;
|
|
686
|
+
if (!design) return undefined;
|
|
687
|
+
const complexity = (design.complexity as 'restrained' | 'balanced' | 'elaborate') ?? 'balanced';
|
|
688
|
+
return {
|
|
689
|
+
...(design.personality ? { personality: design.personality } : {}),
|
|
690
|
+
complexity,
|
|
691
|
+
...(design.audience ? { audience: design.audience } : {}),
|
|
692
|
+
complexity_rule: complexityRule(complexity),
|
|
693
|
+
};
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
function buildAntiPatterns(
|
|
697
|
+
manifest: Record<string, any>,
|
|
698
|
+
projectDir: string,
|
|
699
|
+
target: string
|
|
700
|
+
): PrepareResult['anti_patterns'] {
|
|
701
|
+
// Universal anti-patterns from generation_guidance
|
|
702
|
+
const universal: Record<string, string[]> = {};
|
|
703
|
+
const universalRaw = manifest.generation_guidance?.universal_anti_patterns ?? {};
|
|
704
|
+
for (const [domain, items] of Object.entries(universalRaw)) {
|
|
705
|
+
if (Array.isArray(items)) {
|
|
706
|
+
const filtered = (items as string[]).filter((item) => matchesTargetPlatform(item, target));
|
|
707
|
+
if (filtered.length > 0) universal[domain] = filtered;
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
// Contract-specific must_avoid
|
|
712
|
+
const contract_specific: Record<string, string[]> = {};
|
|
713
|
+
try {
|
|
714
|
+
const contractsDir = resolve(projectDir, manifest.includes?.contracts ?? './contracts/');
|
|
715
|
+
if (existsSync(contractsDir)) {
|
|
716
|
+
for (const file of readdirSync(contractsDir).filter((f) => f.endsWith('.yaml') && !f.startsWith('x_'))) {
|
|
717
|
+
const content = YAML.parse(readFileSync(join(contractsDir, file), 'utf-8'));
|
|
718
|
+
const contractName = Object.keys(content)[0];
|
|
719
|
+
const mustAvoid: string[] = content[contractName]?.generation?.must_avoid ?? [];
|
|
720
|
+
if (mustAvoid.length > 0) {
|
|
721
|
+
const filtered = mustAvoid.filter((item: string) => matchesTargetPlatform(item, target));
|
|
722
|
+
if (filtered.length > 0) contract_specific[contractName] = filtered;
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
} catch { /* skip on error */ }
|
|
727
|
+
|
|
728
|
+
// Project-specific avoid from design section
|
|
729
|
+
const project_specific: string[] = [];
|
|
730
|
+
const designAvoid: string[] = manifest.design?.avoid ?? [];
|
|
731
|
+
for (const item of designAvoid) {
|
|
732
|
+
if (matchesTargetPlatform(item, target)) project_specific.push(item);
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
const hasContent = Object.keys(universal).length > 0 || Object.keys(contract_specific).length > 0 || project_specific.length > 0;
|
|
736
|
+
if (!hasContent) return undefined;
|
|
737
|
+
|
|
738
|
+
return { universal, contract_specific, project_specific };
|
|
739
|
+
}
|
|
740
|
+
|
|
649
741
|
function localizationConstraints(
|
|
650
742
|
target: string,
|
|
651
743
|
platformConfig?: Pick<PreparePlatformConfig, "framework">
|
|
@@ -1228,6 +1320,8 @@ function buildBootstrapPrepareResult(cwd: string, target: string, includeContent
|
|
|
1228
1320
|
const outputDirExists = existsSync(outputDir);
|
|
1229
1321
|
const snapshotPath = join(outputDir, ".openuispec-state.json");
|
|
1230
1322
|
const snapshotFileExists = existsSync(snapshotPath);
|
|
1323
|
+
const antiPatterns = buildAntiPatterns(manifest, projectDir, target);
|
|
1324
|
+
const designContext = buildDesignContext(manifest);
|
|
1231
1325
|
|
|
1232
1326
|
return {
|
|
1233
1327
|
mode: "bootstrap",
|
|
@@ -1259,6 +1353,8 @@ function buildBootstrapPrepareResult(cwd: string, target: string, includeContent
|
|
|
1259
1353
|
items: [],
|
|
1260
1354
|
...(sharedLayerInfos.length > 0 ? { shared_layers: sharedLayerInfos } : {}),
|
|
1261
1355
|
...(includeContents ? { spec_contents: readAllSpecContents(projectDir) } : {}),
|
|
1356
|
+
...(antiPatterns ? { anti_patterns: antiPatterns } : {}),
|
|
1357
|
+
...(designContext ? { design_context: designContext } : {}),
|
|
1262
1358
|
bootstrap: {
|
|
1263
1359
|
output_exists: existsSync(outputDir),
|
|
1264
1360
|
generation_ready: missingDecisions.length === 0 && backendContextReady && !pendingUserConfirmation,
|
|
@@ -107,6 +107,11 @@
|
|
|
107
107
|
"may_handle": {
|
|
108
108
|
"type": "array",
|
|
109
109
|
"items": { "type": "string" }
|
|
110
|
+
},
|
|
111
|
+
"must_avoid": {
|
|
112
|
+
"type": "array",
|
|
113
|
+
"items": { "type": "string" },
|
|
114
|
+
"description": "Anti-patterns for AI generators. Strings may start with [web], [ios], or [android] to scope to a platform. Unmarked items apply to all platforms."
|
|
110
115
|
}
|
|
111
116
|
},
|
|
112
117
|
"additionalProperties": false
|