@sketchscreens/viewer 0.1.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/bin/view.js +220 -0
- package/package.json +27 -0
package/bin/view.js
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* sketchscreens-view — serve the renderer + a map.json on localhost, open browser.
|
|
4
|
+
*
|
|
5
|
+
* Usage:
|
|
6
|
+
* sketchscreens-view <path-to-map.json> [--port 4318] [--no-open]
|
|
7
|
+
*
|
|
8
|
+
* Local-first by design: binds to 127.0.0.1 only. Nothing about the user's code
|
|
9
|
+
* leaves the machine. The map is validated before serving.
|
|
10
|
+
*/
|
|
11
|
+
import { createServer } from "node:http";
|
|
12
|
+
import { readFile, writeFile, readdir } from "node:fs/promises";
|
|
13
|
+
import { existsSync } from "node:fs";
|
|
14
|
+
import { fileURLToPath } from "node:url";
|
|
15
|
+
import { dirname, extname, join, resolve } from "node:path";
|
|
16
|
+
import { spawn } from "node:child_process";
|
|
17
|
+
|
|
18
|
+
import { validateProjectMap } from "@sketchscreens/core-schema";
|
|
19
|
+
|
|
20
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Build ONE self-contained HTML string: the renderer's built JS + CSS inlined
|
|
24
|
+
* (so there are no external asset requests) plus the map on `window`. The
|
|
25
|
+
* result opens in any browser offline and can be emailed/hosted as a single file.
|
|
26
|
+
* @param {string} dist
|
|
27
|
+
* @param {string} indexHtml
|
|
28
|
+
* @param {unknown} map
|
|
29
|
+
*/
|
|
30
|
+
async function buildStandaloneHtml(dist, indexHtml, map) {
|
|
31
|
+
const assetsDir = join(dist, "assets");
|
|
32
|
+
const files = existsSync(assetsDir) ? await readdir(assetsDir) : [];
|
|
33
|
+
const jsFile = files.find((f) => f.endsWith(".js"));
|
|
34
|
+
const cssFile = files.find((f) => f.endsWith(".css"));
|
|
35
|
+
const js = jsFile ? await readFile(join(assetsDir, jsFile), "utf8") : "";
|
|
36
|
+
const css = cssFile ? await readFile(join(assetsDir, cssFile), "utf8") : "";
|
|
37
|
+
|
|
38
|
+
// Base64 the JS bundle into a data: URI. This sidesteps every HTML-parsing
|
|
39
|
+
// pitfall of inlining a huge minified bundle (a literal "</script>" inside the
|
|
40
|
+
// JS would otherwise close the tag early and corrupt the document).
|
|
41
|
+
const jsDataUri = "data:text/javascript;base64," + Buffer.from(js, "utf8").toString("base64");
|
|
42
|
+
// The map JSON goes in a normal script; escape "</script" just in case.
|
|
43
|
+
const mapJson = JSON.stringify(map).replace(/<\/(script)/gi, "<\\/$1");
|
|
44
|
+
|
|
45
|
+
let html = indexHtml;
|
|
46
|
+
// Drop the external stylesheet link(s) and the external module script tag —
|
|
47
|
+
// we inline/redirect both below.
|
|
48
|
+
html = html.replace(/<link[^>]+rel="stylesheet"[^>]*>/g, "");
|
|
49
|
+
html = html.replace(/<script[^>]+src="[^"]+"[^>]*><\/script>/g, "");
|
|
50
|
+
|
|
51
|
+
const head =
|
|
52
|
+
`<script>window.__SKETCHSCREENS_MAP__ = ${mapJson};</script>` +
|
|
53
|
+
(css ? `\n<style>${css}</style>` : "");
|
|
54
|
+
html = html.replace("</head>", `${head}\n</head>`);
|
|
55
|
+
html = html.replace("</body>", `<script type="module" src="${jsDataUri}"></script>\n</body>`);
|
|
56
|
+
return html;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** @type {Record<string, string>} */
|
|
60
|
+
const MIME = {
|
|
61
|
+
".html": "text/html; charset=utf-8",
|
|
62
|
+
".js": "text/javascript; charset=utf-8",
|
|
63
|
+
".css": "text/css; charset=utf-8",
|
|
64
|
+
".json": "application/json; charset=utf-8",
|
|
65
|
+
".svg": "image/svg+xml",
|
|
66
|
+
".woff2": "font/woff2",
|
|
67
|
+
".map": "application/json",
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
/** @param {string[]} argv */
|
|
71
|
+
function parseArgs(argv) {
|
|
72
|
+
/** @type {{ mapPath: string | null, port: number, open: boolean, exportHtml: string | null }} */
|
|
73
|
+
const args = { mapPath: null, port: 4318, open: true, exportHtml: null };
|
|
74
|
+
for (let i = 0; i < argv.length; i++) {
|
|
75
|
+
const a = argv[i];
|
|
76
|
+
if (a === "--port") args.port = Number(argv[++i]);
|
|
77
|
+
else if (a === "--no-open") args.open = false;
|
|
78
|
+
else if (a === "--export-html") args.exportHtml = argv[++i] || "sketchscreens.html";
|
|
79
|
+
else if (!a.startsWith("--")) args.mapPath = a;
|
|
80
|
+
}
|
|
81
|
+
return args;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Locate the renderer's built dist/ directory. */
|
|
85
|
+
function findRendererDist() {
|
|
86
|
+
// Resolve the renderer package, then its dist. Works from the monorepo and
|
|
87
|
+
// from a global install where the package is a real dependency.
|
|
88
|
+
const candidates = [
|
|
89
|
+
resolve(here, "../../../packages/renderer/dist"),
|
|
90
|
+
(() => {
|
|
91
|
+
try {
|
|
92
|
+
const pkg = fileURLToPath(
|
|
93
|
+
import.meta.resolve("@sketchscreens/renderer/package.json"),
|
|
94
|
+
);
|
|
95
|
+
return join(dirname(pkg), "dist");
|
|
96
|
+
} catch {
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
})(),
|
|
100
|
+
].filter(Boolean);
|
|
101
|
+
|
|
102
|
+
for (const dir of candidates) {
|
|
103
|
+
if (dir && existsSync(join(dir, "index.html"))) return dir;
|
|
104
|
+
}
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** @param {string} url */
|
|
109
|
+
function openBrowser(url) {
|
|
110
|
+
const platform = process.platform;
|
|
111
|
+
const cmd = platform === "darwin" ? "open" : platform === "win32" ? "start" : "xdg-open";
|
|
112
|
+
spawn(cmd, [url], { stdio: "ignore", detached: true, shell: platform === "win32" }).unref();
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function main() {
|
|
116
|
+
const opts = parseArgs(process.argv.slice(2));
|
|
117
|
+
const { mapPath, port, open } = opts;
|
|
118
|
+
|
|
119
|
+
if (!mapPath) {
|
|
120
|
+
console.error("Usage: sketchscreens-view <path-to-map.json> [--port N] [--no-open] [--export-html [file]]");
|
|
121
|
+
process.exit(1);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const abs = resolve(process.cwd(), mapPath);
|
|
125
|
+
if (!existsSync(abs)) {
|
|
126
|
+
console.error(`Map file not found: ${abs}`);
|
|
127
|
+
process.exit(1);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const raw = JSON.parse(await readFile(abs, "utf8"));
|
|
131
|
+
const result = validateProjectMap(raw);
|
|
132
|
+
if (!result.ok || !result.map) {
|
|
133
|
+
console.error("The map is not a valid ProjectMap:");
|
|
134
|
+
for (const issue of result.issues) console.error(` - ${issue.message}`);
|
|
135
|
+
process.exit(1);
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
const map = result.map;
|
|
139
|
+
|
|
140
|
+
const dist = findRendererDist();
|
|
141
|
+
if (!dist) {
|
|
142
|
+
console.error(
|
|
143
|
+
"Renderer build not found. Run `pnpm --filter @sketchscreens/renderer build` first.",
|
|
144
|
+
);
|
|
145
|
+
process.exit(1);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const indexHtml = await readFile(join(dist, "index.html"), "utf8");
|
|
149
|
+
// Inject the validated map so the renderer reads it from window.
|
|
150
|
+
const injected = indexHtml.replace(
|
|
151
|
+
"<head>",
|
|
152
|
+
`<head>\n<script>window.__SKETCHSCREENS_MAP__ = ${JSON.stringify(map)};</script>`,
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
// --export-html: emit ONE self-contained .html (renderer JS+CSS + map all
|
|
156
|
+
// inlined) — a portable file you can email/host anywhere, no server, no
|
|
157
|
+
// account. The free "share a file" story.
|
|
158
|
+
if (opts.exportHtml) {
|
|
159
|
+
const html = await buildStandaloneHtml(dist, indexHtml, map);
|
|
160
|
+
const outPath = resolve(process.cwd(), opts.exportHtml);
|
|
161
|
+
await writeFile(outPath, html, "utf8");
|
|
162
|
+
const kb = Math.round(Buffer.byteLength(html) / 1024);
|
|
163
|
+
console.log(`\n Exported self-contained map → ${outPath} (${kb} KB)`);
|
|
164
|
+
console.log(" Open it in any browser, or email/host it anywhere.\n");
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const server = createServer(async (req, res) => {
|
|
169
|
+
const url = (req.url || "/").split("?")[0];
|
|
170
|
+
if (url === "/" || url === "/index.html") {
|
|
171
|
+
res.writeHead(200, { "content-type": MIME[".html"] });
|
|
172
|
+
res.end(injected);
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
// Serve built assets, guarding against path traversal.
|
|
176
|
+
const filePath = join(dist, url);
|
|
177
|
+
if (!filePath.startsWith(dist) || !existsSync(filePath)) {
|
|
178
|
+
res.writeHead(404).end("Not found");
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
try {
|
|
182
|
+
const body = await readFile(filePath);
|
|
183
|
+
res.writeHead(200, { "content-type": MIME[extname(filePath)] || "application/octet-stream" });
|
|
184
|
+
res.end(body);
|
|
185
|
+
} catch {
|
|
186
|
+
res.writeHead(500).end("Read error");
|
|
187
|
+
}
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
// Bind to the requested port; if it's busy, walk to the next free one (this
|
|
191
|
+
// tool is re-run-heavy — a stale server on the port shouldn't crash it).
|
|
192
|
+
let attempts = 0;
|
|
193
|
+
/** @param {number} p */
|
|
194
|
+
const listen = (p) => {
|
|
195
|
+
server.once("error", (/** @type {NodeJS.ErrnoException} */ err) => {
|
|
196
|
+
if (err.code === "EADDRINUSE" && attempts < 20) {
|
|
197
|
+
attempts++;
|
|
198
|
+
listen(p + 1);
|
|
199
|
+
} else {
|
|
200
|
+
console.error(`Could not start server: ${err.message}`);
|
|
201
|
+
process.exit(1);
|
|
202
|
+
}
|
|
203
|
+
});
|
|
204
|
+
server.listen(p, "127.0.0.1", () => {
|
|
205
|
+
const url = `http://127.0.0.1:${p}/`;
|
|
206
|
+
if (p !== port) console.log(` (port ${port} was busy — using ${p})`);
|
|
207
|
+
console.log(`\n SketchScreens — ${map.name}`);
|
|
208
|
+
console.log(` ${map.screens.length} screens · ${map.edges.length} flows`);
|
|
209
|
+
console.log(`\n ▶ ${url}\n`);
|
|
210
|
+
console.log(" (Ctrl+C to stop)\n");
|
|
211
|
+
if (open) openBrowser(url);
|
|
212
|
+
});
|
|
213
|
+
};
|
|
214
|
+
listen(port);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
main().catch((e) => {
|
|
218
|
+
console.error(e);
|
|
219
|
+
process.exit(1);
|
|
220
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@sketchscreens/viewer",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "A tiny local server that serves the SketchScreens renderer and a map.json, then opens the browser. Local-first: binds to localhost only.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"publishConfig": {
|
|
8
|
+
"access": "public"
|
|
9
|
+
},
|
|
10
|
+
"bin": {
|
|
11
|
+
"sketchscreens-view": "./bin/view.js"
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"bin"
|
|
15
|
+
],
|
|
16
|
+
"scripts": {
|
|
17
|
+
"typecheck": "tsc --noEmit"
|
|
18
|
+
},
|
|
19
|
+
"dependencies": {
|
|
20
|
+
"@sketchscreens/core-schema": "workspace:*",
|
|
21
|
+
"@sketchscreens/renderer": "workspace:*"
|
|
22
|
+
},
|
|
23
|
+
"devDependencies": {
|
|
24
|
+
"@types/node": "^22.0.0",
|
|
25
|
+
"typescript": "^5.7.2"
|
|
26
|
+
}
|
|
27
|
+
}
|