openuispec 0.2.3 → 0.2.4
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 +42 -3
- package/cli/init.ts +2 -0
- package/examples/social-app/AGENTS.md +1 -1
- package/examples/social-app/CLAUDE.md +1 -1
- package/examples/social-app/generated/android/social-app/app/.paparazzi-hashes.json +3 -0
- package/examples/social-app/generated/android/social-app/app/build.gradle.kts +2 -0
- package/examples/social-app/generated/android/social-app/app/src/main/java/com/social/app/ui/screens/HomeFeedScreen.kt +12 -0
- package/examples/social-app/generated/android/social-app/app/src/test/kotlin/com/social/app/screenshots/HomeFeedScreenshotTest.kt +34 -0
- package/examples/social-app/generated/android/social-app/build.gradle.kts +1 -0
- package/examples/social-app/generated/android/social-app/gradle/libs.versions.toml +2 -0
- package/examples/social-app/generated/android/social-app/gradle/wrapper/gradle-wrapper.jar +0 -0
- package/examples/social-app/generated/android/social-app/gradlew +239 -16
- package/examples/social-app/generated/android/social-app/settings.gradle.kts +4 -0
- package/examples/todo-orbit/generated/ios/Todo Orbit/.screenshot-uitest/Sources/ScreenshotUITest.swift +36 -0
- package/examples/todo-orbit/generated/ios/Todo Orbit/TodoOrbit.xcodeproj/project.pbxproj +204 -212
- package/examples/todo-orbit/generated/ios/Todo Orbit/project.yml +1 -0
- package/mcp-server/index.ts +64 -1
- package/mcp-server/screenshot-android.ts +462 -0
- package/mcp-server/screenshot-ios.ts +541 -0
- package/mcp-server/screenshot-shared.ts +200 -0
- package/mcp-server/screenshot.ts +15 -1
- package/package.json +3 -2
- package/examples/todo-orbit/generated/ios/Todo Orbit/TodoOrbit.xcodeproj/xcshareddata/xcschemes/TodoOrbit.xcscheme +0 -79
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared utilities for platform screenshot tools (web, android, ios).
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { existsSync, readFileSync, writeFileSync, readdirSync } from "node:fs";
|
|
6
|
+
import { join, resolve, relative } from "node:path";
|
|
7
|
+
import { createHash } from "node:crypto";
|
|
8
|
+
import YAML from "yaml";
|
|
9
|
+
import { findProjectDir } from "../drift/index.js";
|
|
10
|
+
|
|
11
|
+
// ── shared result type ──────────────────────────────────────────────
|
|
12
|
+
|
|
13
|
+
export interface ScreenshotResult {
|
|
14
|
+
content: Array<{ type: "text"; text: string } | { type: "image"; data: string; mimeType: string }>;
|
|
15
|
+
isError?: true;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// ── manifest loading ────────────────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
export interface ManifestInfo {
|
|
21
|
+
projectDir: string;
|
|
22
|
+
projectRoot: string;
|
|
23
|
+
manifest: any;
|
|
24
|
+
projectName: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function loadManifest(projectCwd: string): ManifestInfo {
|
|
28
|
+
const projectDir = findProjectDir(projectCwd);
|
|
29
|
+
const manifestPath = join(projectDir, "openuispec.yaml");
|
|
30
|
+
const manifest = YAML.parse(readFileSync(manifestPath, "utf-8"));
|
|
31
|
+
const projectName = manifest.project?.name ?? "app";
|
|
32
|
+
const projectRoot = resolve(projectDir, "..");
|
|
33
|
+
return { projectDir, projectRoot, manifest, projectName };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// ── generic platform app directory discovery ────────────────────────
|
|
37
|
+
|
|
38
|
+
function tryFindProjectDir(cwd: string): string | null {
|
|
39
|
+
if (existsSync(join(cwd, "openuispec.yaml"))) {
|
|
40
|
+
return cwd;
|
|
41
|
+
}
|
|
42
|
+
try { return findProjectDir(cwd); } catch { return null; }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function findPlatformAppDir(
|
|
46
|
+
projectCwd: string,
|
|
47
|
+
platform: "web" | "android" | "ios",
|
|
48
|
+
existsCheck: (dir: string) => boolean,
|
|
49
|
+
directDir?: string,
|
|
50
|
+
): string {
|
|
51
|
+
const label = platform.charAt(0).toUpperCase() + platform.slice(1);
|
|
52
|
+
|
|
53
|
+
// If a direct project dir is provided, use it without manifest lookup
|
|
54
|
+
if (directDir) {
|
|
55
|
+
const resolved = resolve(directDir);
|
|
56
|
+
if (existsCheck(resolved)) return resolved;
|
|
57
|
+
throw new Error(`${label} project not found at provided path: ${resolved}`);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Check if projectCwd has an openuispec.yaml — if so, use manifest-based discovery
|
|
61
|
+
const manifestDir = tryFindProjectDir(projectCwd);
|
|
62
|
+
|
|
63
|
+
if (manifestDir) {
|
|
64
|
+
const manifestPath = join(manifestDir, "openuispec.yaml");
|
|
65
|
+
const manifest = YAML.parse(readFileSync(manifestPath, "utf-8"));
|
|
66
|
+
const projectName = manifest.project?.name ?? "app";
|
|
67
|
+
const projectRoot = resolve(manifestDir, "..");
|
|
68
|
+
|
|
69
|
+
// Check custom output_dir first
|
|
70
|
+
const customDir = manifest.generation?.output_dir?.[platform];
|
|
71
|
+
if (customDir) {
|
|
72
|
+
const resolved = resolve(manifestDir, customDir);
|
|
73
|
+
if (existsCheck(resolved)) return resolved;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Default: generated/<platform>/<project-name>/
|
|
77
|
+
const defaultDir = join(projectRoot, "generated", platform, projectName);
|
|
78
|
+
if (existsCheck(defaultDir)) return defaultDir;
|
|
79
|
+
|
|
80
|
+
throw new Error(
|
|
81
|
+
`${label} app not found. Checked:\n` +
|
|
82
|
+
(customDir ? ` - ${resolve(manifestDir, customDir)}\n` : "") +
|
|
83
|
+
` - ${defaultDir}\n` +
|
|
84
|
+
`Generate the ${platform} target first, then try again.`,
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// No manifest — treat projectCwd itself as the platform project dir
|
|
89
|
+
const resolved = resolve(projectCwd);
|
|
90
|
+
if (existsCheck(resolved)) return resolved;
|
|
91
|
+
|
|
92
|
+
throw new Error(
|
|
93
|
+
`${label} project not found at ${resolved}. ` +
|
|
94
|
+
`Provide a project_dir parameter or create an openuispec.yaml manifest.`,
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ── file hashing / caching ──────────────────────────────────────────
|
|
99
|
+
|
|
100
|
+
export function hashContent(content: string): string {
|
|
101
|
+
return createHash("md5").update(content).digest("hex");
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function loadHashes(dir: string, hashFile: string): Record<string, string> {
|
|
105
|
+
const hashPath = join(dir, hashFile);
|
|
106
|
+
try {
|
|
107
|
+
return JSON.parse(readFileSync(hashPath, "utf-8"));
|
|
108
|
+
} catch {
|
|
109
|
+
return {};
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function saveHashes(dir: string, hashFile: string, hashes: Record<string, string>): void {
|
|
114
|
+
writeFileSync(join(dir, hashFile), JSON.stringify(hashes, null, 2));
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ── screen name filter ──────────────────────────────────────────────
|
|
118
|
+
|
|
119
|
+
export function matchesScreenFilter(screenName: string, filter: string): boolean {
|
|
120
|
+
const normalizedFilter = filter.toLowerCase().replace(/_/g, "");
|
|
121
|
+
const normalizedScreen = screenName.toLowerCase().replace(/_/g, "");
|
|
122
|
+
return normalizedScreen.includes(normalizedFilter) || normalizedFilter.includes(normalizedScreen);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ── recursive file walker ───────────────────────────────────────────
|
|
126
|
+
|
|
127
|
+
export function walkFiles(
|
|
128
|
+
dir: string,
|
|
129
|
+
ext: string,
|
|
130
|
+
skipDirs: string[] = [],
|
|
131
|
+
): string[] {
|
|
132
|
+
const results: string[] = [];
|
|
133
|
+
if (!existsSync(dir)) return results;
|
|
134
|
+
|
|
135
|
+
const walk = (d: string) => {
|
|
136
|
+
for (const entry of readdirSync(d, { withFileTypes: true })) {
|
|
137
|
+
const fullPath = join(d, entry.name);
|
|
138
|
+
if (entry.isDirectory()) {
|
|
139
|
+
if (!skipDirs.includes(entry.name)) walk(fullPath);
|
|
140
|
+
} else if (entry.name.endsWith(ext)) {
|
|
141
|
+
results.push(fullPath);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
walk(dir);
|
|
147
|
+
return results;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// ── PNG snapshot collector ──────────────────────────────────────────
|
|
151
|
+
|
|
152
|
+
export function collectPngSnapshots(
|
|
153
|
+
dirs: string[],
|
|
154
|
+
rootDir: string,
|
|
155
|
+
screenFilter: string | undefined,
|
|
156
|
+
nameExtractor: (filename: string) => string,
|
|
157
|
+
): Array<{ screen: string; path: string; data: string }> {
|
|
158
|
+
const snapshots: Array<{ screen: string; path: string; data: string }> = [];
|
|
159
|
+
const seen = new Set<string>();
|
|
160
|
+
|
|
161
|
+
for (const dir of dirs) {
|
|
162
|
+
const pngs = walkFiles(dir, ".png");
|
|
163
|
+
for (const fullPath of pngs) {
|
|
164
|
+
const filename = fullPath.split("/").pop()!;
|
|
165
|
+
const screen = nameExtractor(filename);
|
|
166
|
+
|
|
167
|
+
if (screenFilter && !matchesScreenFilter(screen, screenFilter)) continue;
|
|
168
|
+
|
|
169
|
+
const key = relative(rootDir, fullPath);
|
|
170
|
+
if (seen.has(key)) continue;
|
|
171
|
+
seen.add(key);
|
|
172
|
+
|
|
173
|
+
const data = readFileSync(fullPath).toString("base64");
|
|
174
|
+
snapshots.push({ screen, path: key, data });
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return snapshots;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// ── screenshot response builder ─────────────────────────────────────
|
|
182
|
+
|
|
183
|
+
export function buildScreenshotResponse(
|
|
184
|
+
snapshots: Array<{ screen: string; path: string; data: string }>,
|
|
185
|
+
metadataFn: (snapshot: { screen: string; path: string }) => Record<string, unknown>,
|
|
186
|
+
): ScreenshotResult {
|
|
187
|
+
if (snapshots.length === 0) {
|
|
188
|
+
return {
|
|
189
|
+
content: [{ type: "text", text: "No screenshots generated. Check build output." }],
|
|
190
|
+
isError: true,
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const content: ScreenshotResult["content"] = [];
|
|
195
|
+
for (const snapshot of snapshots) {
|
|
196
|
+
content.push({ type: "image" as const, data: snapshot.data, mimeType: "image/png" });
|
|
197
|
+
content.push({ type: "text" as const, text: JSON.stringify(metadataFn(snapshot), null, 2) });
|
|
198
|
+
}
|
|
199
|
+
return { content };
|
|
200
|
+
}
|
package/mcp-server/screenshot.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { spawn, type ChildProcess, execSync } from "node:child_process";
|
|
9
|
-
import { existsSync, readFileSync } from "node:fs";
|
|
9
|
+
import { existsSync, readFileSync, mkdirSync, writeFileSync } from "node:fs";
|
|
10
10
|
import { join, resolve } from "node:path";
|
|
11
11
|
import { createServer, type AddressInfo } from "node:net";
|
|
12
12
|
import YAML from "yaml";
|
|
@@ -21,6 +21,7 @@ export interface ScreenshotOptions {
|
|
|
21
21
|
wait_for?: number;
|
|
22
22
|
full_page?: boolean;
|
|
23
23
|
selector?: string;
|
|
24
|
+
output_dir?: string;
|
|
24
25
|
}
|
|
25
26
|
|
|
26
27
|
export interface ScreenshotResult {
|
|
@@ -180,6 +181,7 @@ export async function takeScreenshot(
|
|
|
180
181
|
wait_for = 1000,
|
|
181
182
|
full_page = false,
|
|
182
183
|
selector,
|
|
184
|
+
output_dir,
|
|
183
185
|
} = options;
|
|
184
186
|
|
|
185
187
|
// 1. Find and start
|
|
@@ -223,6 +225,17 @@ export async function takeScreenshot(
|
|
|
223
225
|
|
|
224
226
|
const base64 = buffer.toString("base64");
|
|
225
227
|
|
|
228
|
+
// Save to output_dir if specified
|
|
229
|
+
let savedPath: string | undefined;
|
|
230
|
+
if (output_dir) {
|
|
231
|
+
const outDir = resolve(webDir, output_dir);
|
|
232
|
+
mkdirSync(outDir, { recursive: true });
|
|
233
|
+
const routeSlug = route.replace(/^\//, "").replace(/\//g, "_") || "index";
|
|
234
|
+
const themeLabel = theme ?? "default";
|
|
235
|
+
savedPath = join(outDir, `${routeSlug}_${themeLabel}.png`);
|
|
236
|
+
writeFileSync(savedPath, buffer);
|
|
237
|
+
}
|
|
238
|
+
|
|
226
239
|
return {
|
|
227
240
|
content: [
|
|
228
241
|
{ type: "image" as const, data: base64, mimeType: "image/png" },
|
|
@@ -235,6 +248,7 @@ export async function takeScreenshot(
|
|
|
235
248
|
theme: theme ?? "default",
|
|
236
249
|
full_page,
|
|
237
250
|
selector: selector ?? null,
|
|
251
|
+
path: savedPath ?? null,
|
|
238
252
|
}, null, 2),
|
|
239
253
|
},
|
|
240
254
|
],
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "openuispec",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.4",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "A semantic UI specification format for AI-native, platform-native app development",
|
|
@@ -42,7 +42,8 @@
|
|
|
42
42
|
"drift:snapshot": "tsx drift/index.ts --snapshot --target",
|
|
43
43
|
"prepare:target": "tsx prepare/index.ts",
|
|
44
44
|
"status": "tsx status/index.ts",
|
|
45
|
-
"postinstall": "echo \"\\n ✓ openuispec installed — if upgrading, run: openuispec update-rules\\n\""
|
|
45
|
+
"postinstall": "echo \"\\n ✓ openuispec installed — if upgrading, run: openuispec update-rules\\n\"",
|
|
46
|
+
"cloc": "cloc --exclude-dir=$(tr '\n' ',' < .cloc-ignore) ."
|
|
46
47
|
},
|
|
47
48
|
"dependencies": {
|
|
48
49
|
"@modelcontextprotocol/sdk": "^1.27.1",
|
|
@@ -1,79 +0,0 @@
|
|
|
1
|
-
<?xml version="1.0" encoding="UTF-8"?>
|
|
2
|
-
<Scheme
|
|
3
|
-
LastUpgradeVersion = "2600"
|
|
4
|
-
version = "1.7">
|
|
5
|
-
<BuildAction
|
|
6
|
-
parallelizeBuildables = "YES"
|
|
7
|
-
buildImplicitDependencies = "YES"
|
|
8
|
-
buildArchitectures = "Automatic">
|
|
9
|
-
<BuildActionEntries>
|
|
10
|
-
<BuildActionEntry
|
|
11
|
-
buildForTesting = "YES"
|
|
12
|
-
buildForRunning = "YES"
|
|
13
|
-
buildForProfiling = "YES"
|
|
14
|
-
buildForArchiving = "YES"
|
|
15
|
-
buildForAnalyzing = "YES">
|
|
16
|
-
<BuildableReference
|
|
17
|
-
BuildableIdentifier = "primary"
|
|
18
|
-
BlueprintIdentifier = "E00000010000000000000000"
|
|
19
|
-
BuildableName = "TodoOrbit.app"
|
|
20
|
-
BlueprintName = "TodoOrbit"
|
|
21
|
-
ReferencedContainer = "container:TodoOrbit.xcodeproj">
|
|
22
|
-
</BuildableReference>
|
|
23
|
-
</BuildActionEntry>
|
|
24
|
-
</BuildActionEntries>
|
|
25
|
-
</BuildAction>
|
|
26
|
-
<TestAction
|
|
27
|
-
buildConfiguration = "Debug"
|
|
28
|
-
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
|
29
|
-
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
|
30
|
-
shouldUseLaunchSchemeArgsEnv = "YES">
|
|
31
|
-
<Testables>
|
|
32
|
-
</Testables>
|
|
33
|
-
</TestAction>
|
|
34
|
-
<LaunchAction
|
|
35
|
-
buildConfiguration = "Debug"
|
|
36
|
-
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
|
37
|
-
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
|
38
|
-
launchStyle = "0"
|
|
39
|
-
useCustomWorkingDirectory = "NO"
|
|
40
|
-
ignoresPersistentStateOnLaunch = "NO"
|
|
41
|
-
debugDocumentVersioning = "YES"
|
|
42
|
-
debugServiceExtension = "internal"
|
|
43
|
-
allowLocationSimulation = "YES">
|
|
44
|
-
<BuildableProductRunnable
|
|
45
|
-
runnableDebuggingMode = "0">
|
|
46
|
-
<BuildableReference
|
|
47
|
-
BuildableIdentifier = "primary"
|
|
48
|
-
BlueprintIdentifier = "E00000010000000000000000"
|
|
49
|
-
BuildableName = "TodoOrbit.app"
|
|
50
|
-
BlueprintName = "TodoOrbit"
|
|
51
|
-
ReferencedContainer = "container:TodoOrbit.xcodeproj">
|
|
52
|
-
</BuildableReference>
|
|
53
|
-
</BuildableProductRunnable>
|
|
54
|
-
</LaunchAction>
|
|
55
|
-
<ProfileAction
|
|
56
|
-
buildConfiguration = "Release"
|
|
57
|
-
shouldUseLaunchSchemeArgsEnv = "YES"
|
|
58
|
-
savedToolIdentifier = ""
|
|
59
|
-
useCustomWorkingDirectory = "NO"
|
|
60
|
-
debugDocumentVersioning = "YES">
|
|
61
|
-
<BuildableProductRunnable
|
|
62
|
-
runnableDebuggingMode = "0">
|
|
63
|
-
<BuildableReference
|
|
64
|
-
BuildableIdentifier = "primary"
|
|
65
|
-
BlueprintIdentifier = "E00000010000000000000000"
|
|
66
|
-
BuildableName = "TodoOrbit.app"
|
|
67
|
-
BlueprintName = "TodoOrbit"
|
|
68
|
-
ReferencedContainer = "container:TodoOrbit.xcodeproj">
|
|
69
|
-
</BuildableReference>
|
|
70
|
-
</BuildableProductRunnable>
|
|
71
|
-
</ProfileAction>
|
|
72
|
-
<AnalyzeAction
|
|
73
|
-
buildConfiguration = "Debug">
|
|
74
|
-
</AnalyzeAction>
|
|
75
|
-
<ArchiveAction
|
|
76
|
-
buildConfiguration = "Release"
|
|
77
|
-
revealArchiveInOrganizer = "YES">
|
|
78
|
-
</ArchiveAction>
|
|
79
|
-
</Scheme>
|