openink 0.3.0 → 0.4.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/CHANGELOG.md +14 -0
- package/README.md +1 -0
- package/docs/architecture.md +1 -1
- package/docs/getting-started.md +1 -1
- package/package.json +1 -1
- package/src/build.js +43 -12
- package/src/cli.js +22 -3
- package/src/dev.js +16 -4
- package/src/render/page.js +17 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,20 @@ All notable changes are documented here. The format follows [Keep a Changelog](h
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [0.4.0] - 2026-10-06
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- `openink dev --open` opens the prototype in the default browser once the server is running (#25).
|
|
12
|
+
- Spec errors and warnings start with the file, line and column (`spec.yaml:42:7`), in `validate`, `build`, `dev` and
|
|
13
|
+
the browser overlay. Most terminals and editors open that spot with a click (#24).
|
|
14
|
+
- `openink dev` shows spec errors over the prototype in the browser, and hides them as soon as the spec builds again.
|
|
15
|
+
Before, a failed rebuild only showed in the terminal, and the browser kept the last good build (#23).
|
|
16
|
+
|
|
17
|
+
### Fixed
|
|
18
|
+
|
|
19
|
+
- `openink dev` prints each warning with its path and message, not only how many there are (#22).
|
|
20
|
+
|
|
7
21
|
## [0.3.0] - 2026-10-05
|
|
8
22
|
|
|
9
23
|
### Added
|
package/README.md
CHANGED
|
@@ -71,6 +71,7 @@ npx openink pdf # dist/<name>.pdf, one screen per page
|
|
|
71
71
|
npx openink png # dist/png/<screen>.png
|
|
72
72
|
npx openink blocks # list every block and its props
|
|
73
73
|
npx openink dev --theme dark # try a colour theme without editing the spec
|
|
74
|
+
npx openink dev --open # also open the preview in your browser
|
|
74
75
|
```
|
|
75
76
|
|
|
76
77
|
Or install it once and drop the `npx`:
|
package/docs/architecture.md
CHANGED
|
@@ -17,7 +17,7 @@ dist/ ──► http server + fs.watch + reload poller (dev.js)
|
|
|
17
17
|
|
|
18
18
|
**One definition per block.** `src/render/blocks/` is the single source of truth. The validator, the JSON Schema, `docs/blocks.md` and `openink blocks` are all generated from it, and tests fail when the generated files are stale. Adding a block is one object.
|
|
19
19
|
|
|
20
|
-
**Validate everything, early.** Assistants and humans both make typos. The validator never throws; it returns `{ errors, warnings }` with a path into the spec and a suggestion
|
|
20
|
+
**Validate everything, early.** Assistants and humans both make typos. The validator never throws; it returns `{ errors, warnings }` with a path into the spec and a suggestion; the CLI adds the line and column from the YAML source. `build` refuses to write output if there are errors.
|
|
21
21
|
|
|
22
22
|
**Bundled runtime.** wired-elements and RoughJS are bundled into `openink.js` at build time, so a prototype has no CDN dependency (except the Google Fonts stylesheet, which falls back to a system cursive font offline).
|
|
23
23
|
|
package/docs/getting-started.md
CHANGED
|
@@ -8,7 +8,7 @@ cd my-wireframes
|
|
|
8
8
|
npx openink dev
|
|
9
9
|
```
|
|
10
10
|
|
|
11
|
-
Open <http://localhost:3000
|
|
11
|
+
Open <http://localhost:3000> (or start with `npx openink dev --open` to open it for you). Edit `spec.yaml` and the page reloads on save.
|
|
12
12
|
|
|
13
13
|
**Skipping `npx`.** `npx openink` downloads the tool on first use. To pin a version per project, or to type just `openink`, install it:
|
|
14
14
|
|
package/package.json
CHANGED
package/src/build.js
CHANGED
|
@@ -19,17 +19,46 @@ export class SpecError extends Error {
|
|
|
19
19
|
}
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
-
/**
|
|
22
|
+
/** The source range of a validator path such as `screens[3].blocks[2].type`, or of its nearest existing parent. */
|
|
23
|
+
function rangeAt(doc, issuePath) {
|
|
24
|
+
const keys = (issuePath.match(/[^.[\]]+/g) || []).map((k) => (/^\d+$/.test(k) ? Number(k) : k));
|
|
25
|
+
let node = doc.contents;
|
|
26
|
+
let range = node?.range;
|
|
27
|
+
for (const key of keys) {
|
|
28
|
+
if (YAML.isAlias(node)) node = node.resolve(doc);
|
|
29
|
+
if (YAML.isMap(node)) {
|
|
30
|
+
// point at the key (`type:`), which is where an editor should put the cursor
|
|
31
|
+
const pair = node.items.find((p) => String(YAML.isScalar(p.key) ? p.key.value : p.key) === String(key));
|
|
32
|
+
if (!pair) break;
|
|
33
|
+
range = pair.key?.range ?? range;
|
|
34
|
+
node = pair.value;
|
|
35
|
+
} else if (YAML.isSeq(node) && typeof key === "number" && node.items[key]) {
|
|
36
|
+
node = node.items[key];
|
|
37
|
+
range = node.range ?? range;
|
|
38
|
+
} else break;
|
|
39
|
+
}
|
|
40
|
+
return range;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Find and parse the spec in a project directory.
|
|
45
|
+
* `locate(issue)` returns the issue with `loc` set to `file:line:col` (relative to the working directory).
|
|
46
|
+
*/
|
|
23
47
|
export function loadSpec(dir = ".") {
|
|
24
48
|
const file = SPEC_FILES.map((f) => path.join(dir, f)).find(fs.existsSync);
|
|
25
49
|
if (!file) throw new SpecError(`No spec.yaml found in ${path.resolve(dir)}. Run \`openink init\` to create one.`);
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
50
|
+
const lineCounter = new YAML.LineCounter();
|
|
51
|
+
const doc = YAML.parseDocument(fs.readFileSync(file, "utf8"), { lineCounter });
|
|
52
|
+
if (doc.errors.length) throw new SpecError(`${path.basename(file)} is not valid YAML: ${doc.errors[0].message}`);
|
|
53
|
+
const spec = doc.toJS();
|
|
54
|
+
const name = path.relative(process.cwd(), file) || path.basename(file);
|
|
55
|
+
const locate = (issue) => {
|
|
56
|
+
const range = rangeAt(doc, issue.path || "");
|
|
57
|
+
if (!range) return issue;
|
|
58
|
+
const { line, col } = lineCounter.linePos(range[0]);
|
|
59
|
+
return { ...issue, loc: `${name}:${line}:${col}` };
|
|
60
|
+
};
|
|
61
|
+
return { spec, file, locate };
|
|
33
62
|
}
|
|
34
63
|
|
|
35
64
|
let runtimeCache;
|
|
@@ -44,7 +73,7 @@ async function bundleRuntime() {
|
|
|
44
73
|
/**
|
|
45
74
|
* Build a project into static files.
|
|
46
75
|
* @param {{ dir?: string, out?: string, dev?: boolean, theme?: string }} [opts] `theme` overrides the spec's theme
|
|
47
|
-
* @returns {Promise<{ outDir: string, spec: object, warnings: {path:string,message:string}[] }>}
|
|
76
|
+
* @returns {Promise<{ outDir: string, spec: object, warnings: {path:string,message:string,loc?:string}[] }>}
|
|
48
77
|
*/
|
|
49
78
|
export async function build({ dir = ".", out = "dist", dev = false, theme } = {}) {
|
|
50
79
|
const projectDir = path.resolve(dir);
|
|
@@ -53,14 +82,16 @@ export async function build({ dir = ".", out = "dist", dev = false, theme } = {}
|
|
|
53
82
|
throw new Error(`Output directory ${outDir} would contain the project itself. Choose a different --out.`);
|
|
54
83
|
}
|
|
55
84
|
|
|
56
|
-
const { spec } = loadSpec(projectDir);
|
|
85
|
+
const { spec, locate } = loadSpec(projectDir);
|
|
57
86
|
if (theme) spec.theme = theme;
|
|
58
|
-
const
|
|
87
|
+
const result = validate(spec);
|
|
88
|
+
const errors = result.errors;
|
|
59
89
|
const customTheme = typeof spec?.theme === "string" && !isPreset(spec.theme) && spec.theme.endsWith(".css");
|
|
60
90
|
if (customTheme && !fs.existsSync(path.join(projectDir, spec.theme))) {
|
|
61
91
|
errors.push({ path: "theme", message: `Theme file "${spec.theme}" not found next to the spec.` });
|
|
62
92
|
}
|
|
63
|
-
if (errors.length) throw new SpecError(`${errors.length} problem${errors.length > 1 ? "s" : ""} in the spec`, errors);
|
|
93
|
+
if (errors.length) throw new SpecError(`${errors.length} problem${errors.length > 1 ? "s" : ""} in the spec`, errors.map(locate));
|
|
94
|
+
const warnings = result.warnings.map(locate);
|
|
64
95
|
|
|
65
96
|
fs.rmSync(outDir, { recursive: true, force: true });
|
|
66
97
|
fs.mkdirSync(outDir, { recursive: true });
|
package/src/cli.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
1
2
|
import fs from "node:fs";
|
|
2
3
|
import path from "node:path";
|
|
3
4
|
import { fileURLToPath } from "node:url";
|
|
@@ -24,6 +25,7 @@ Commands:
|
|
|
24
25
|
Options:
|
|
25
26
|
--out <dir> Output directory, relative to the project (default: dist)
|
|
26
27
|
--port <n> Dev server port (default: 3000; the next free port if taken)
|
|
28
|
+
--open Open the prototype in the default browser when the dev server starts
|
|
27
29
|
--theme <name> Try a colour theme without editing the spec: sketch, color, pastel, blueprint, dark
|
|
28
30
|
-v, --version Print the version
|
|
29
31
|
-h, --help Show this help
|
|
@@ -40,6 +42,7 @@ function parse(argv) {
|
|
|
40
42
|
const a = argv[i];
|
|
41
43
|
if (a === "-h" || a === "--help") opts.help = true;
|
|
42
44
|
else if (a === "-v" || a === "--version") opts.version = true;
|
|
45
|
+
else if (a === "--open") opts.open = true;
|
|
43
46
|
else if (a === "--out" || a === "--port" || a === "--theme") {
|
|
44
47
|
if (argv[i + 1] === undefined) throw new Error(`${a} needs a value`);
|
|
45
48
|
opts[a.slice(2)] = argv[++i];
|
|
@@ -49,8 +52,9 @@ function parse(argv) {
|
|
|
49
52
|
return opts;
|
|
50
53
|
}
|
|
51
54
|
|
|
55
|
+
// `loc` (file:line:col) comes first, so terminals and editors can open the spot with a click
|
|
52
56
|
const printIssues = (list, color, label) =>
|
|
53
|
-
list.forEach((i) => console.error(` ${color(label)} ${dim(i.path || "spec")}: ${i.message}`));
|
|
57
|
+
list.forEach((i) => console.error(` ${color(label)} ${i.loc ? `${i.loc} ` : ""}${dim(i.path || "spec")}: ${i.message}`));
|
|
54
58
|
|
|
55
59
|
function init(dir = ".") {
|
|
56
60
|
const target = path.resolve(dir);
|
|
@@ -65,6 +69,18 @@ function init(dir = ".") {
|
|
|
65
69
|
console.log(green("✓") + ` Created ${rel || "."}/spec.yaml\n\nNext:\n ${rel ? `cd ${rel} && ` : ""}npx openink dev`);
|
|
66
70
|
}
|
|
67
71
|
|
|
72
|
+
/** The command that opens a URL in the default browser on this platform. */
|
|
73
|
+
export const openCommand = (url, platform = process.platform) =>
|
|
74
|
+
platform === "win32" ? ["cmd", ["/c", "start", '""', url]] : [platform === "darwin" ? "open" : "xdg-open", [url]];
|
|
75
|
+
|
|
76
|
+
// a browser that fails to open is only worth a hint: the server keeps running
|
|
77
|
+
function openBrowser(url) {
|
|
78
|
+
const [cmd, args] = openCommand(url);
|
|
79
|
+
const child = spawn(cmd, args, { stdio: "ignore", detached: true, windowsVerbatimArguments: process.platform === "win32" });
|
|
80
|
+
child.on("error", () => console.error(yellow(`Could not open a browser. Open ${url} yourself.`)));
|
|
81
|
+
child.unref();
|
|
82
|
+
}
|
|
83
|
+
|
|
68
84
|
/** @param {string[]} argv */
|
|
69
85
|
export async function run(argv) {
|
|
70
86
|
const opts = parse(argv);
|
|
@@ -81,8 +97,9 @@ export async function run(argv) {
|
|
|
81
97
|
return console.log(blocksMarkdown());
|
|
82
98
|
|
|
83
99
|
case "validate": {
|
|
84
|
-
const { spec } = loadSpec(dir);
|
|
85
|
-
const
|
|
100
|
+
const { spec, locate } = loadSpec(dir);
|
|
101
|
+
const result = validate(spec);
|
|
102
|
+
const errors = result.errors.map(locate), warnings = result.warnings.map(locate);
|
|
86
103
|
printIssues(warnings, yellow, "warn ");
|
|
87
104
|
printIssues(errors, red, "error");
|
|
88
105
|
if (errors.length) throw new SpecError(`${errors.length} error${errors.length > 1 ? "s" : ""}`);
|
|
@@ -109,8 +126,10 @@ export async function run(argv) {
|
|
|
109
126
|
port: opts.port ? +opts.port : 3000,
|
|
110
127
|
theme: opts.theme,
|
|
111
128
|
onIssues: (e) => { console.error(red("✗ " + e.message)); printIssues(e.issues, red, "error"); },
|
|
129
|
+
onWarnings: (warnings) => printIssues(warnings, yellow, "warn "),
|
|
112
130
|
});
|
|
113
131
|
console.log(green("✓") + ` Serving ${server.url} ${dim("(Ctrl+C to stop)")}`);
|
|
132
|
+
if (opts.open) openBrowser(server.url);
|
|
114
133
|
process.on("SIGINT", () => { server.close(); process.exit(0); });
|
|
115
134
|
return new Promise(() => {}); // keep running
|
|
116
135
|
}
|
package/src/dev.js
CHANGED
|
@@ -2,6 +2,7 @@ import fs from "node:fs";
|
|
|
2
2
|
import http from "node:http";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { build, SpecError } from "./build.js";
|
|
5
|
+
import { devErrorPage } from "./render/page.js";
|
|
5
6
|
|
|
6
7
|
const MIME = {
|
|
7
8
|
".html": "text/html; charset=utf-8", ".js": "text/javascript", ".css": "text/css", ".json": "application/json",
|
|
@@ -11,21 +12,28 @@ const MIME = {
|
|
|
11
12
|
|
|
12
13
|
const MAX_PORT_TRIES = 10;
|
|
13
14
|
|
|
15
|
+
/** A failed build as plain text, for the overlay in the browser. */
|
|
16
|
+
const describe = (e) => [e.message, ...(e.issues || []).map((i) => ` ${i.loc ? `${i.loc} ` : ""}${i.path || "spec"}: ${i.message}`)].join("\n");
|
|
17
|
+
|
|
14
18
|
/**
|
|
15
19
|
* Serve the project with live reload: rebuilds when files change and the browser refreshes itself.
|
|
16
|
-
* @param {{ dir?: string, out?: string, port?: number, theme?: string, log?: (msg: string) => void, onIssues?: (e: SpecError) => void }} [opts]
|
|
20
|
+
* @param {{ dir?: string, out?: string, port?: number, theme?: string, log?: (msg: string) => void, onIssues?: (e: SpecError) => void, onWarnings?: (warnings: {path:string,message:string}[]) => void }} [opts]
|
|
17
21
|
*/
|
|
18
|
-
export async function dev({ dir = ".", out = ".openink-dev", port = 3000, theme, log = console.log, onIssues = () => {} } = {}) {
|
|
22
|
+
export async function dev({ dir = ".", out = ".openink-dev", port = 3000, theme, log = console.log, onIssues = () => {}, onWarnings = () => {} } = {}) {
|
|
19
23
|
const projectDir = path.resolve(dir);
|
|
20
24
|
const outDir = path.resolve(projectDir, out);
|
|
21
25
|
let version = String(Date.now());
|
|
26
|
+
let error = null; // the last build's failure, shown in the browser until a build succeeds
|
|
22
27
|
|
|
23
28
|
const rebuild = async () => {
|
|
24
29
|
try {
|
|
25
30
|
const { warnings } = await build({ dir, out, dev: true, theme });
|
|
26
31
|
version = String(Date.now());
|
|
32
|
+
error = null;
|
|
27
33
|
log(`✓ built${warnings.length ? ` (${warnings.length} warning${warnings.length > 1 ? "s" : ""})` : ""}`);
|
|
34
|
+
if (warnings.length) onWarnings(warnings);
|
|
28
35
|
} catch (e) {
|
|
36
|
+
error = describe(e);
|
|
29
37
|
if (e instanceof SpecError) onIssues(e);
|
|
30
38
|
else log(`✗ ${e.message}`);
|
|
31
39
|
}
|
|
@@ -34,8 +42,12 @@ export async function dev({ dir = ".", out = ".openink-dev", port = 3000, theme,
|
|
|
34
42
|
|
|
35
43
|
const server = http.createServer((req, res) => {
|
|
36
44
|
const url = decodeURIComponent(new URL(req.url, "http://x").pathname);
|
|
37
|
-
if (url === "/__version")
|
|
45
|
+
if (url === "/__version") {
|
|
46
|
+
return void res.writeHead(200, { "Content-Type": MIME[".json"], "Cache-Control": "no-store" }).end(JSON.stringify({ version, error }));
|
|
47
|
+
}
|
|
38
48
|
const file = path.join(outDir, url === "/" ? "index.html" : url);
|
|
49
|
+
// no good build yet: a page that shows the errors and reloads once the spec builds
|
|
50
|
+
if (url === "/" && !fs.existsSync(file)) return void res.writeHead(200, { "Content-Type": MIME[".html"], "Cache-Control": "no-store" }).end(devErrorPage());
|
|
39
51
|
if (!file.startsWith(outDir) || !fs.existsSync(file) || fs.statSync(file).isDirectory()) return void res.writeHead(404).end("Not found");
|
|
40
52
|
res.writeHead(200, { "Content-Type": MIME[path.extname(file)] || "application/octet-stream", "Cache-Control": "no-store" });
|
|
41
53
|
fs.createReadStream(file).pipe(res);
|
|
@@ -64,5 +76,5 @@ export async function dev({ dir = ".", out = ".openink-dev", port = 3000, theme,
|
|
|
64
76
|
});
|
|
65
77
|
|
|
66
78
|
const close = () => { watcher.close(); server.close(); fs.rmSync(outDir, { recursive: true, force: true }); };
|
|
67
|
-
return { url: `http://localhost:${port}`, close };
|
|
79
|
+
return { url: `http://localhost:${server.address().port}`, close };
|
|
68
80
|
}
|
package/src/render/page.js
CHANGED
|
@@ -1,7 +1,23 @@
|
|
|
1
1
|
import { createContext, esc } from "./context.js";
|
|
2
2
|
import { colorVar, isPreset } from "../themes.js";
|
|
3
3
|
|
|
4
|
-
|
|
4
|
+
// Dev mode: polls the dev server, reloads after a good build and shows the errors of a failed one.
|
|
5
|
+
const DEV_RELOAD = `<script>(function(){var v,box;
|
|
6
|
+
function show(e){if(!box){box=document.createElement("div");box.id="oi-dev-error";box.setAttribute("role","alert");box.style.cssText="position:fixed;inset:0;z-index:2147483647;overflow:auto;margin:0;padding:32px;background:rgba(40,0,0,.92);color:#fff;font:14px/1.6 ui-monospace,Menlo,Consolas,monospace;white-space:pre-wrap";document.body.appendChild(box)}box.textContent="\\u2717 "+e+"\\n\\nFix the spec and save: this page updates by itself."}
|
|
7
|
+
function hide(){if(box){box.remove();box=null}}
|
|
8
|
+
setInterval(function(){fetch("/__version").then(function(r){return r.json()}).then(function(s){if(v&&s.version!==v)return location.reload();v=s.version;s.error?show(s.error):hide()}).catch(function(){})},600)})()</script>`;
|
|
9
|
+
|
|
10
|
+
/** The page the dev server shows when there is no good build yet: only the poller, which shows the errors. */
|
|
11
|
+
export const devErrorPage = () => `<!DOCTYPE html>
|
|
12
|
+
<html lang="en">
|
|
13
|
+
<head>
|
|
14
|
+
<meta charset="UTF-8" />
|
|
15
|
+
<title>openink</title>
|
|
16
|
+
${DEV_RELOAD}
|
|
17
|
+
</head>
|
|
18
|
+
<body></body>
|
|
19
|
+
</html>
|
|
20
|
+
`;
|
|
5
21
|
|
|
6
22
|
/**
|
|
7
23
|
* Render a validated spec to a complete HTML document.
|