openink 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/CHANGELOG.md +23 -0
- package/LICENSE +21 -0
- package/README.md +164 -0
- package/bin/openink.js +4 -0
- package/docs/ai-assistants.md +35 -0
- package/docs/architecture.md +50 -0
- package/docs/blocks.md +604 -0
- package/docs/extending.md +86 -0
- package/docs/getting-started.md +97 -0
- package/docs/spec.md +215 -0
- package/package.json +38 -0
- package/schema/spec.schema.json +2641 -0
- package/src/build.js +82 -0
- package/src/cli.js +133 -0
- package/src/dev.js +53 -0
- package/src/export.js +68 -0
- package/src/icons.js +47 -0
- package/src/index.js +14 -0
- package/src/render/blocks/actions.js +68 -0
- package/src/render/blocks/content.js +86 -0
- package/src/render/blocks/data.js +24 -0
- package/src/render/blocks/feedback.js +29 -0
- package/src/render/blocks/forms.js +86 -0
- package/src/render/blocks/index.js +42 -0
- package/src/render/blocks/layout.js +102 -0
- package/src/render/blocks/media.js +65 -0
- package/src/render/blocks/navigation.js +67 -0
- package/src/render/blocks/overlay.js +18 -0
- package/src/render/blocks/shared.js +20 -0
- package/src/render/blocks/text.js +49 -0
- package/src/render/context.js +63 -0
- package/src/render/page.js +76 -0
- package/src/runtime/chart.js +85 -0
- package/src/runtime/dom.js +21 -0
- package/src/runtime/i18n.js +24 -0
- package/src/runtime/icon.js +28 -0
- package/src/runtime/index.js +64 -0
- package/src/runtime/navigation.js +27 -0
- package/src/runtime/placeholder.js +88 -0
- package/src/spec/docs.js +65 -0
- package/src/spec/schema.js +110 -0
- package/src/spec/validate.js +219 -0
- package/src/styles/openink.css +250 -0
- package/src/styles/themes/blueprint.css +31 -0
- package/src/styles/themes/color.css +28 -0
- package/src/styles/themes/dark.css +31 -0
- package/src/styles/themes/pastel.css +26 -0
- package/src/themes.js +10 -0
- package/templates/starter/AGENTS.md +18 -0
- package/templates/starter/README.md +11 -0
- package/templates/starter/gitignore +3 -0
- package/templates/starter/spec.yaml +55 -0
package/src/build.js
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import YAML from "yaml";
|
|
5
|
+
import * as esbuild from "esbuild";
|
|
6
|
+
import { validate } from "./spec/validate.js";
|
|
7
|
+
import { renderPage } from "./render/page.js";
|
|
8
|
+
import { isPreset } from "./themes.js";
|
|
9
|
+
|
|
10
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
11
|
+
const SPEC_FILES = ["spec.yaml", "spec.yml", "spec.json"];
|
|
12
|
+
|
|
13
|
+
/** A spec that failed validation. `issues` is the list from validate(). */
|
|
14
|
+
export class SpecError extends Error {
|
|
15
|
+
constructor(message, issues = []) {
|
|
16
|
+
super(message);
|
|
17
|
+
this.name = "SpecError";
|
|
18
|
+
this.issues = issues;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Find and parse the spec in a project directory. */
|
|
23
|
+
export function loadSpec(dir = ".") {
|
|
24
|
+
const file = SPEC_FILES.map((f) => path.join(dir, f)).find(fs.existsSync);
|
|
25
|
+
if (!file) throw new SpecError(`No spec.yaml found in ${path.resolve(dir)}. Run \`openink init\` to create one.`);
|
|
26
|
+
let spec;
|
|
27
|
+
try {
|
|
28
|
+
spec = YAML.parse(fs.readFileSync(file, "utf8"));
|
|
29
|
+
} catch (e) {
|
|
30
|
+
throw new SpecError(`${path.basename(file)} is not valid YAML: ${e.message}`);
|
|
31
|
+
}
|
|
32
|
+
return { spec, file };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
let runtimeCache;
|
|
36
|
+
/** Bundle the browser runtime (wired-elements + roughjs inlined, so output works offline and from file://). */
|
|
37
|
+
async function bundleRuntime() {
|
|
38
|
+
runtimeCache ??= esbuild
|
|
39
|
+
.build({ entryPoints: [path.join(here, "runtime/index.js")], bundle: true, minify: true, format: "iife", write: false, logLevel: "silent" })
|
|
40
|
+
.then((r) => r.outputFiles[0].text);
|
|
41
|
+
return runtimeCache;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Build a project into static files.
|
|
46
|
+
* @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}[] }>}
|
|
48
|
+
*/
|
|
49
|
+
export async function build({ dir = ".", out = "dist", dev = false, theme } = {}) {
|
|
50
|
+
const projectDir = path.resolve(dir);
|
|
51
|
+
const outDir = path.resolve(projectDir, out);
|
|
52
|
+
if (projectDir === outDir || projectDir.startsWith(outDir + path.sep)) {
|
|
53
|
+
throw new Error(`Output directory ${outDir} would contain the project itself. Choose a different --out.`);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const { spec } = loadSpec(projectDir);
|
|
57
|
+
if (theme) spec.theme = theme;
|
|
58
|
+
const { errors, warnings } = validate(spec);
|
|
59
|
+
const customTheme = typeof spec?.theme === "string" && !isPreset(spec.theme) && spec.theme.endsWith(".css");
|
|
60
|
+
if (customTheme && !fs.existsSync(path.join(projectDir, spec.theme))) {
|
|
61
|
+
errors.push({ path: "theme", message: `Theme file "${spec.theme}" not found next to the spec.` });
|
|
62
|
+
}
|
|
63
|
+
if (errors.length) throw new SpecError(`${errors.length} problem${errors.length > 1 ? "s" : ""} in the spec`, errors);
|
|
64
|
+
|
|
65
|
+
fs.rmSync(outDir, { recursive: true, force: true });
|
|
66
|
+
fs.mkdirSync(outDir, { recursive: true });
|
|
67
|
+
fs.writeFileSync(path.join(outDir, "index.html"), renderPage(spec, { dev }));
|
|
68
|
+
fs.writeFileSync(path.join(outDir, "openink.js"), await bundleRuntime());
|
|
69
|
+
fs.copyFileSync(path.join(here, "styles/openink.css"), path.join(outDir, "openink.css"));
|
|
70
|
+
if (customTheme) {
|
|
71
|
+
fs.mkdirSync(path.dirname(path.join(outDir, spec.theme)), { recursive: true });
|
|
72
|
+
fs.copyFileSync(path.join(projectDir, spec.theme), path.join(outDir, spec.theme));
|
|
73
|
+
} else if (spec.theme && spec.theme !== "sketch") {
|
|
74
|
+
fs.copyFileSync(path.join(here, "styles/themes", `${spec.theme}.css`), path.join(outDir, `theme-${spec.theme}.css`));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Optional real images / logos: put them in <project>/assets and reference them as assets/…
|
|
78
|
+
const assets = path.join(projectDir, "assets");
|
|
79
|
+
if (fs.existsSync(assets)) fs.cpSync(assets, path.join(outDir, "assets"), { recursive: true });
|
|
80
|
+
|
|
81
|
+
return { outDir, spec, warnings };
|
|
82
|
+
}
|
package/src/cli.js
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { build, loadSpec, SpecError } from "./build.js";
|
|
5
|
+
import { validate } from "./spec/validate.js";
|
|
6
|
+
import { blocksMarkdown } from "./spec/docs.js";
|
|
7
|
+
|
|
8
|
+
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
9
|
+
const { version } = JSON.parse(fs.readFileSync(path.join(root, "package.json"), "utf8"));
|
|
10
|
+
|
|
11
|
+
const HELP = `openink ${version}: sketchy, clickable wireframes from a YAML spec
|
|
12
|
+
|
|
13
|
+
Usage: openink <command> [dir] [options]
|
|
14
|
+
|
|
15
|
+
Commands:
|
|
16
|
+
init [dir] Create a new project (spec.yaml + AI-assistant instructions)
|
|
17
|
+
build [dir] Build static files into <dir>/dist
|
|
18
|
+
dev [dir] Build, serve and live-reload while you edit the spec
|
|
19
|
+
validate [dir] Check the spec without building
|
|
20
|
+
pdf [dir] Build and export a PDF (one screen per page)
|
|
21
|
+
png [dir] Build and export one PNG per screen
|
|
22
|
+
blocks Print the reference for every block type
|
|
23
|
+
|
|
24
|
+
Options:
|
|
25
|
+
--out <dir> Output directory, relative to the project (default: dist)
|
|
26
|
+
--port <n> Dev server port (default: 3000)
|
|
27
|
+
--theme <name> Try a colour theme without editing the spec: sketch, color, pastel, blueprint, dark
|
|
28
|
+
-v, --version Print the version
|
|
29
|
+
-h, --help Show this help
|
|
30
|
+
|
|
31
|
+
[dir] defaults to the current directory. PDF/PNG export needs Chrome, Chromium or Edge.`;
|
|
32
|
+
|
|
33
|
+
const useColor = process.stdout.isTTY && !process.env.NO_COLOR;
|
|
34
|
+
const paint = (code) => (s) => (useColor ? `\x1b[${code}m${s}\x1b[0m` : s);
|
|
35
|
+
const red = paint(31), yellow = paint(33), green = paint(32), dim = paint(2);
|
|
36
|
+
|
|
37
|
+
function parse(argv) {
|
|
38
|
+
const opts = { _: [] };
|
|
39
|
+
for (let i = 0; i < argv.length; i++) {
|
|
40
|
+
const a = argv[i];
|
|
41
|
+
if (a === "-h" || a === "--help") opts.help = true;
|
|
42
|
+
else if (a === "-v" || a === "--version") opts.version = true;
|
|
43
|
+
else if (a === "--out" || a === "--port" || a === "--theme") {
|
|
44
|
+
if (argv[i + 1] === undefined) throw new Error(`${a} needs a value`);
|
|
45
|
+
opts[a.slice(2)] = argv[++i];
|
|
46
|
+
} else if (a.startsWith("-")) throw new Error(`Unknown option ${a}. Try --help.`);
|
|
47
|
+
else opts._.push(a);
|
|
48
|
+
}
|
|
49
|
+
return opts;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const printIssues = (list, color, label) =>
|
|
53
|
+
list.forEach((i) => console.error(` ${color(label)} ${dim(i.path || "spec")}: ${i.message}`));
|
|
54
|
+
|
|
55
|
+
function init(dir = ".") {
|
|
56
|
+
const target = path.resolve(dir);
|
|
57
|
+
if (fs.existsSync(path.join(target, "spec.yaml"))) throw new Error(`${path.join(target, "spec.yaml")} already exists.`);
|
|
58
|
+
fs.mkdirSync(target, { recursive: true });
|
|
59
|
+
const name = path.basename(target);
|
|
60
|
+
for (const file of fs.readdirSync(path.join(root, "templates/starter"))) {
|
|
61
|
+
const text = fs.readFileSync(path.join(root, "templates/starter", file), "utf8").replaceAll("{{name}}", name);
|
|
62
|
+
fs.writeFileSync(path.join(target, file === "gitignore" ? ".gitignore" : file), text);
|
|
63
|
+
}
|
|
64
|
+
const rel = path.relative(process.cwd(), target);
|
|
65
|
+
console.log(green("✓") + ` Created ${rel || "."}/spec.yaml\n\nNext:\n ${rel ? `cd ${rel} && ` : ""}npx openink dev`);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** @param {string[]} argv */
|
|
69
|
+
export async function run(argv) {
|
|
70
|
+
const opts = parse(argv);
|
|
71
|
+
const [cmd, dir] = opts._;
|
|
72
|
+
if (opts.version) return console.log(version);
|
|
73
|
+
if (!cmd || opts.help || cmd === "help") return console.log(HELP);
|
|
74
|
+
|
|
75
|
+
const out = opts.out ?? "dist";
|
|
76
|
+
switch (cmd) {
|
|
77
|
+
case "init":
|
|
78
|
+
return init(dir);
|
|
79
|
+
|
|
80
|
+
case "blocks":
|
|
81
|
+
return console.log(blocksMarkdown());
|
|
82
|
+
|
|
83
|
+
case "validate": {
|
|
84
|
+
const { spec } = loadSpec(dir);
|
|
85
|
+
const { errors, warnings } = validate(spec);
|
|
86
|
+
printIssues(warnings, yellow, "warn ");
|
|
87
|
+
printIssues(errors, red, "error");
|
|
88
|
+
if (errors.length) throw new SpecError(`${errors.length} error${errors.length > 1 ? "s" : ""}`);
|
|
89
|
+
return console.log(green("✓") + ` Spec is valid${warnings.length ? ` (${warnings.length} warning${warnings.length > 1 ? "s" : ""})` : ""}`);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
case "build": {
|
|
93
|
+
const { outDir, spec, warnings } = await build({ dir, out, theme: opts.theme });
|
|
94
|
+
printIssues(warnings, yellow, "warn ");
|
|
95
|
+
return console.log(green("✓") + ` ${spec.screens.length} screens → ${path.relative(process.cwd(), path.join(outDir, "index.html")) || "index.html"}`);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
case "pdf":
|
|
99
|
+
case "png": {
|
|
100
|
+
const { exportFiles } = await import("./export.js");
|
|
101
|
+
const { files } = await exportFiles({ dir, out, png: cmd === "png", theme: opts.theme });
|
|
102
|
+
return console.log(green("✓") + ` ${files.length === 1 ? path.relative(process.cwd(), files[0]) : `${files.length} PNGs in ${path.relative(process.cwd(), path.dirname(files[0]))}`}`);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
case "dev": {
|
|
106
|
+
const { dev } = await import("./dev.js");
|
|
107
|
+
const server = await dev({
|
|
108
|
+
dir,
|
|
109
|
+
port: opts.port ? +opts.port : 3000,
|
|
110
|
+
theme: opts.theme,
|
|
111
|
+
onIssues: (e) => { console.error(red("✗ " + e.message)); printIssues(e.issues, red, "error"); },
|
|
112
|
+
});
|
|
113
|
+
console.log(green("✓") + ` Serving ${server.url} ${dim("(Ctrl+C to stop)")}`);
|
|
114
|
+
process.on("SIGINT", () => { server.close(); process.exit(0); });
|
|
115
|
+
return new Promise(() => {}); // keep running
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
default:
|
|
119
|
+
throw new Error(`Unknown command "${cmd}". Try \`openink --help\`.`);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export async function main(argv = process.argv.slice(2)) {
|
|
124
|
+
try {
|
|
125
|
+
await run(argv);
|
|
126
|
+
} catch (e) {
|
|
127
|
+
if (e instanceof SpecError) {
|
|
128
|
+
console.error(red("✗ " + e.message));
|
|
129
|
+
printIssues(e.issues, red, "error");
|
|
130
|
+
} else console.error(red("✗ " + e.message));
|
|
131
|
+
process.exitCode = 1;
|
|
132
|
+
}
|
|
133
|
+
}
|
package/src/dev.js
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import http from "node:http";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { build, SpecError } from "./build.js";
|
|
5
|
+
|
|
6
|
+
const MIME = {
|
|
7
|
+
".html": "text/html; charset=utf-8", ".js": "text/javascript", ".css": "text/css", ".json": "application/json",
|
|
8
|
+
".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".gif": "image/gif", ".svg": "image/svg+xml",
|
|
9
|
+
".webp": "image/webp", ".ico": "image/x-icon", ".woff2": "font/woff2", ".pdf": "application/pdf",
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Serve the project with live reload: rebuilds when files change and the browser refreshes itself.
|
|
14
|
+
* @param {{ dir?: string, out?: string, port?: number, theme?: string, log?: (msg: string) => void, onIssues?: (e: SpecError) => void }} [opts]
|
|
15
|
+
*/
|
|
16
|
+
export async function dev({ dir = ".", out = ".openink-dev", port = 3000, theme, log = console.log, onIssues = () => {} } = {}) {
|
|
17
|
+
const projectDir = path.resolve(dir);
|
|
18
|
+
const outDir = path.resolve(projectDir, out);
|
|
19
|
+
let version = String(Date.now());
|
|
20
|
+
|
|
21
|
+
const rebuild = async () => {
|
|
22
|
+
try {
|
|
23
|
+
const { warnings } = await build({ dir, out, dev: true, theme });
|
|
24
|
+
version = String(Date.now());
|
|
25
|
+
log(`✓ built${warnings.length ? ` (${warnings.length} warning${warnings.length > 1 ? "s" : ""})` : ""}`);
|
|
26
|
+
} catch (e) {
|
|
27
|
+
if (e instanceof SpecError) onIssues(e);
|
|
28
|
+
else log(`✗ ${e.message}`);
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
await rebuild();
|
|
32
|
+
|
|
33
|
+
const server = http.createServer((req, res) => {
|
|
34
|
+
const url = decodeURIComponent(new URL(req.url, "http://x").pathname);
|
|
35
|
+
if (url === "/__version") return void res.writeHead(200, { "Cache-Control": "no-store" }).end(version);
|
|
36
|
+
const file = path.join(outDir, url === "/" ? "index.html" : url);
|
|
37
|
+
if (!file.startsWith(outDir) || !fs.existsSync(file) || fs.statSync(file).isDirectory()) return void res.writeHead(404).end("Not found");
|
|
38
|
+
res.writeHead(200, { "Content-Type": MIME[path.extname(file)] || "application/octet-stream", "Cache-Control": "no-store" });
|
|
39
|
+
fs.createReadStream(file).pipe(res);
|
|
40
|
+
});
|
|
41
|
+
await new Promise((resolve, reject) => server.once("error", reject).listen(port, resolve));
|
|
42
|
+
|
|
43
|
+
let timer;
|
|
44
|
+
const watcher = fs.watch(projectDir, { recursive: true }, (_, name) => {
|
|
45
|
+
const changed = path.resolve(projectDir, name || "");
|
|
46
|
+
if (changed.startsWith(outDir) || changed.includes(`${path.sep}node_modules`)) return;
|
|
47
|
+
clearTimeout(timer);
|
|
48
|
+
timer = setTimeout(rebuild, 150);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
const close = () => { watcher.close(); server.close(); fs.rmSync(outDir, { recursive: true, force: true }); };
|
|
52
|
+
return { url: `http://localhost:${port}`, close };
|
|
53
|
+
}
|
package/src/export.js
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { pathToFileURL } from "node:url";
|
|
4
|
+
import { build } from "./build.js";
|
|
5
|
+
|
|
6
|
+
const BROWSERS = [
|
|
7
|
+
process.env.CHROME_PATH,
|
|
8
|
+
"C:/Program Files/Google/Chrome/Application/chrome.exe",
|
|
9
|
+
"C:/Program Files (x86)/Google/Chrome/Application/chrome.exe",
|
|
10
|
+
"C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe",
|
|
11
|
+
"C:/Program Files/Microsoft/Edge/Application/msedge.exe",
|
|
12
|
+
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
|
13
|
+
"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
|
|
14
|
+
"/usr/bin/google-chrome",
|
|
15
|
+
"/usr/bin/google-chrome-stable",
|
|
16
|
+
"/usr/bin/chromium",
|
|
17
|
+
"/usr/bin/chromium-browser",
|
|
18
|
+
"/usr/bin/microsoft-edge",
|
|
19
|
+
].filter(Boolean);
|
|
20
|
+
|
|
21
|
+
export const findBrowser = () => BROWSERS.find((p) => fs.existsSync(p));
|
|
22
|
+
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
23
|
+
const slug = (s) => String(s).toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "wireframe";
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Build the project, then export a PDF (one screen per A4 landscape page) or PNG screenshots.
|
|
27
|
+
* Needs Chrome, Chromium or Edge installed (set CHROME_PATH to point at one).
|
|
28
|
+
* @param {{ dir?: string, out?: string, png?: boolean, theme?: string }} [opts]
|
|
29
|
+
* @returns {Promise<{ files: string[] }>}
|
|
30
|
+
*/
|
|
31
|
+
export async function exportFiles({ dir = ".", out = "dist", png = false, theme } = {}) {
|
|
32
|
+
const executablePath = findBrowser();
|
|
33
|
+
if (!executablePath) throw new Error("No Chrome, Chromium or Edge found. Install one, or set CHROME_PATH to its executable.");
|
|
34
|
+
|
|
35
|
+
const { outDir, spec } = await build({ dir, out, theme });
|
|
36
|
+
const { default: puppeteer } = await import("puppeteer-core");
|
|
37
|
+
const browser = await puppeteer.launch({ executablePath, headless: true });
|
|
38
|
+
try {
|
|
39
|
+
const page = await browser.newPage();
|
|
40
|
+
await page.setViewport({ width: 1100, height: 800 });
|
|
41
|
+
await page.goto(pathToFileURL(path.join(outDir, "index.html")).href, { waitUntil: "networkidle0" });
|
|
42
|
+
await wait(500); // let wired-elements finish drawing
|
|
43
|
+
|
|
44
|
+
if (png) {
|
|
45
|
+
const dir = path.join(outDir, "png");
|
|
46
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
47
|
+
const files = [];
|
|
48
|
+
for (const id of await page.$$eval(".screen", (s) => s.map((x) => x.id))) {
|
|
49
|
+
await page.evaluate((i) => { location.hash = i; }, id);
|
|
50
|
+
await wait(400);
|
|
51
|
+
const file = path.join(dir, `${id}.png`);
|
|
52
|
+
await page.screenshot({ path: file, fullPage: true });
|
|
53
|
+
files.push(file);
|
|
54
|
+
}
|
|
55
|
+
return { files };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Print media shows every screen; the ones that were hidden have never been drawn, so force a render.
|
|
59
|
+
await page.emulateMediaType("print");
|
|
60
|
+
await page.evaluate(() => document.querySelectorAll("*").forEach((e) => e.wiredRender?.(true)));
|
|
61
|
+
await wait(500);
|
|
62
|
+
const file = path.join(outDir, `${slug(spec.name)}.pdf`);
|
|
63
|
+
await page.pdf({ path: file, format: "A4", landscape: true, printBackground: true, preferCSSPageSize: true });
|
|
64
|
+
return { files: [file] };
|
|
65
|
+
} finally {
|
|
66
|
+
await browser.close();
|
|
67
|
+
}
|
|
68
|
+
}
|
package/src/icons.js
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// Icon paths on a 24x24 grid. Pure data: used by the validator (to list valid names) and by the
|
|
2
|
+
// browser runtime (which draws them hand-drawn with RoughJS).
|
|
3
|
+
// Each icon is a list of SVG path strings. `filled` icons can be solid when the `filled` attribute is set.
|
|
4
|
+
|
|
5
|
+
const dot = (x, y, r = 1) => `M${x - r} ${y}a${r} ${r} 0 1 0 ${2 * r} 0a${r} ${r} 0 1 0 ${-2 * r} 0`;
|
|
6
|
+
|
|
7
|
+
export const ICONS = {
|
|
8
|
+
home: ["M3 11.5L12 3l9 8.5", "M5.5 10v10.5h13V10", "M10 20.5v-6h4v6"],
|
|
9
|
+
search: ["M10.5 3a7.5 7.5 0 1 0 0 15 7.5 7.5 0 0 0 0-15z", "M16 16l5 5"],
|
|
10
|
+
heart: ["M12 20.5C5 15 3 11.5 3 8.6A4.6 4.6 0 0 1 12 7A4.6 4.6 0 0 1 21 8.6c0 2.9-2 6.4-9 11.9z"],
|
|
11
|
+
comment: ["M4 5h16v11H10l-4 4v-4H4z"],
|
|
12
|
+
share: ["M21 3L3 10.5l7 2.5 2.5 7z", "M10 13L21 3"],
|
|
13
|
+
bookmark: ["M6 3h12v18l-6-4.5L6 21z"],
|
|
14
|
+
plus: ["M12 4v16M4 12h16"],
|
|
15
|
+
bell: ["M6 16.5V11a6 6 0 0 1 12 0v5.5l2 2H4z", "M10 21h4"],
|
|
16
|
+
user: ["M12 3.5a4 4 0 1 0 0 8 4 4 0 0 0 0-8z", "M4 21c0-4.5 3.6-7 8-7s8 2.5 8 7"],
|
|
17
|
+
users: ["M9 4a3.5 3.5 0 1 0 0 7 3.5 3.5 0 0 0 0-7z", "M2 20c0-4 3-6 7-6s7 2 7 6", "M16 4.5a3.3 3.3 0 0 1 0 6.3", "M18 14.5c2.5.6 4 2.4 4 5.5"],
|
|
18
|
+
mail: ["M3 5.5h18v13H3z", "M3 6l9 7 9-7"],
|
|
19
|
+
settings: ["M12 8.5a3.5 3.5 0 1 0 0 7 3.5 3.5 0 0 0 0-7z", "M12 2.5v3M12 18.5v3M2.5 12h3M18.5 12h3", "M5.3 5.3l2.1 2.1M16.6 16.6l2.1 2.1M5.3 18.7l2.1-2.1M16.6 7.4l2.1-2.1"],
|
|
20
|
+
camera: ["M3 8h4l2-3h6l2 3h4v12H3z", "M12 10.5a3.5 3.5 0 1 0 0 7 3.5 3.5 0 0 0 0-7z"],
|
|
21
|
+
image: ["M3 5h18v14H3z", "M3 17l6-6 5 5 3-3 4 4", dot(8.5, 9.5, 1.5)],
|
|
22
|
+
video: ["M3 6h13v12H3z", "M16 10l5-3v10l-5-3"],
|
|
23
|
+
star: ["M12 3l2.7 5.9 6.3.7-4.7 4.3 1.3 6.3L12 17l-5.6 3.2 1.3-6.3L3 9.6l6.3-.7z"],
|
|
24
|
+
menu: ["M4 6h16M4 12h16M4 18h16"],
|
|
25
|
+
more: [dot(5, 12), dot(12, 12), dot(19, 12)],
|
|
26
|
+
close: ["M5 5l14 14M19 5L5 19"],
|
|
27
|
+
check: ["M4 12.5l5 5L20 6.5"],
|
|
28
|
+
"arrow-right": ["M4 12h16M14 6l6 6-6 6"],
|
|
29
|
+
"arrow-left": ["M20 12H4M10 6l-6 6 6 6"],
|
|
30
|
+
"chevron-down": ["M6 9l6 6 6-6"],
|
|
31
|
+
"chevron-right": ["M9 6l6 6-6 6"],
|
|
32
|
+
play: ["M7 4l13 8-13 8z"],
|
|
33
|
+
pin: ["M12 21s7-6.2 7-11.5a7 7 0 0 0-14 0C5 14.8 12 21 12 21z", "M12 7a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5z"],
|
|
34
|
+
trash: ["M4 7h16M9 7V4h6v3", "M6 7l1 14h10l1-14"],
|
|
35
|
+
edit: ["M4 20l1-5L16 4l4 4L9 19z", "M14 6l4 4"],
|
|
36
|
+
upload: ["M12 16V4M6 10l6-6 6 6", "M4 20h16"],
|
|
37
|
+
download: ["M12 4v12M6 10l6 6 6-6", "M4 20h16"],
|
|
38
|
+
lock: ["M6 11h12v10H6z", "M8.5 11V8a3.5 3.5 0 0 1 7 0v3"],
|
|
39
|
+
cart: ["M3 4h3l2.5 11h10L21 7H7", dot(10, 20), dot(18, 20)],
|
|
40
|
+
chart: ["M4 4v16h16", "M8 15v-4M12 15V8M16 15v-6"],
|
|
41
|
+
calendar: ["M4 6h16v14H4z", "M4 10h16M8 3v4M16 3v4"],
|
|
42
|
+
filter: ["M3 5h18l-7 8v6l-4-2v-4z"],
|
|
43
|
+
info: ["M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18z", "M12 11v6", dot(12, 7.5, 0.6)],
|
|
44
|
+
send: ["M3 11l18-8-8 18-2-8z"],
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
export const ICON_NAMES = Object.keys(ICONS);
|
package/src/index.js
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Programmatic API.
|
|
3
|
+
*
|
|
4
|
+
* import { build } from "openink";
|
|
5
|
+
* await build({ dir: "./my-project", out: "dist" });
|
|
6
|
+
*/
|
|
7
|
+
export { build, loadSpec, SpecError } from "./build.js";
|
|
8
|
+
export { exportFiles } from "./export.js";
|
|
9
|
+
export { dev } from "./dev.js";
|
|
10
|
+
export { validate } from "./spec/validate.js";
|
|
11
|
+
export { buildSchema } from "./spec/schema.js";
|
|
12
|
+
export { blocksMarkdown } from "./spec/docs.js";
|
|
13
|
+
export { blocks } from "./render/blocks/index.js";
|
|
14
|
+
export { renderPage } from "./render/page.js";
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { ACTION, ICON } from "./shared.js";
|
|
2
|
+
|
|
3
|
+
/** Nested blocks of a `{ label, children }` list (tabs, accordion sections). */
|
|
4
|
+
export const sectionsNested = (key) => (b) =>
|
|
5
|
+
(b[key] || []).flatMap((t, i) => (t?.children || []).map((x, j) => [`.${key}[${i}].children[${j}]`, x]));
|
|
6
|
+
|
|
7
|
+
/** @type {import("./index.js").BlockDef[]} */
|
|
8
|
+
export default [
|
|
9
|
+
{
|
|
10
|
+
name: "button",
|
|
11
|
+
group: "Actions",
|
|
12
|
+
summary: "Button, optionally with an icon. Navigates with `go`, opens a `modal` with `open`, shows a message with `toast`.",
|
|
13
|
+
props: {
|
|
14
|
+
label: { type: "text", doc: "Button text. Leave out for an icon-only button." },
|
|
15
|
+
icon: { ...ICON, doc: "Icon shown before the label." },
|
|
16
|
+
primary: { type: "boolean", doc: "Heavier border: the main action on the screen." },
|
|
17
|
+
disabled: { type: "boolean", doc: "Grey out the button." },
|
|
18
|
+
...ACTION,
|
|
19
|
+
},
|
|
20
|
+
render: (b, c) =>
|
|
21
|
+
`<wired-button elevation="${b.primary ? 3 : 1}"${c.act(b)}${b.disabled ? " disabled" : ""}>${c.inner(b)}</wired-button>`,
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
name: "chips",
|
|
25
|
+
group: "Actions",
|
|
26
|
+
summary: "Filter pills. Clicking one selects it and shows a “Results updated” toast.",
|
|
27
|
+
props: {
|
|
28
|
+
options: { type: "text[]", doc: "Pill labels.", required: true },
|
|
29
|
+
active: { type: "number", doc: "Index of the selected pill (default 0)." },
|
|
30
|
+
},
|
|
31
|
+
render: (b, c) => {
|
|
32
|
+
const active = +b.active || 0;
|
|
33
|
+
return `<div class="row chips" data-chips>${(b.options || [])
|
|
34
|
+
.map((o, i) => `<wired-button class="chip${i === active ? " on" : ""}" elevation="${i === active ? 3 : 1}">${c.tx(o)}</wired-button>`)
|
|
35
|
+
.join("")}</div>`;
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
name: "tabs",
|
|
40
|
+
group: "Actions",
|
|
41
|
+
summary: "Tab strip. Each tab has its own list of blocks.",
|
|
42
|
+
props: { tabs: { type: "sections", doc: "List of `{ label, children }`.", required: true } },
|
|
43
|
+
nested: sectionsNested("tabs"),
|
|
44
|
+
render: (b, c) => {
|
|
45
|
+
const tabs = b.tabs || [];
|
|
46
|
+
return `<div class="tabs" data-tabs><div class="row">${tabs
|
|
47
|
+
.map((t, i) => `<wired-button class="tab${i === 0 ? " on" : ""}" data-tab="${i}" elevation="${i === 0 ? 3 : 1}">${c.tx(t.label)}</wired-button>`)
|
|
48
|
+
.join("")}</div>${tabs
|
|
49
|
+
.map((t, i) => `<div class="tab-panel${i === 0 ? " on" : ""}" data-panel="${i}"><div class="stack">${(t.children || []).map(c.block).join("")}</div></div>`)
|
|
50
|
+
.join("")}</div>`;
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
name: "nextbar",
|
|
55
|
+
group: "Actions",
|
|
56
|
+
summary: "“Next →” strip at the bottom of a screen. Hidden in the PDF.",
|
|
57
|
+
props: {
|
|
58
|
+
label: { type: "text", doc: "Bold lead-in (default “Next →”)." },
|
|
59
|
+
text: { type: "text", doc: "What happens next." },
|
|
60
|
+
button: { type: "text", doc: "Button text (default “Continue →”)." },
|
|
61
|
+
...ACTION,
|
|
62
|
+
},
|
|
63
|
+
render: (b, c) =>
|
|
64
|
+
`<div class="nextbar"><span><strong>${c.tx(b.label || "Next →")}</strong> ${c.tx(b.text)}</span><wired-button elevation="2"${c.act(b)}>${c.tx(
|
|
65
|
+
b.button || "Continue →"
|
|
66
|
+
)}</wired-button></div>`,
|
|
67
|
+
},
|
|
68
|
+
];
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { ACTION } from "./shared.js";
|
|
2
|
+
import { ICON_NAMES } from "../../icons.js";
|
|
3
|
+
|
|
4
|
+
/** @type {import("./index.js").BlockDef[]} */
|
|
5
|
+
export default [
|
|
6
|
+
{
|
|
7
|
+
name: "icon",
|
|
8
|
+
group: "Content",
|
|
9
|
+
summary: "Hand-drawn icon.",
|
|
10
|
+
props: {
|
|
11
|
+
name: { type: "string", enum: ICON_NAMES, doc: "Icon name.", required: true },
|
|
12
|
+
size: { type: "number", doc: "Size in px (default 22)." },
|
|
13
|
+
filled: { type: "boolean", doc: "Solid instead of outline (heart, star, bookmark…)." },
|
|
14
|
+
},
|
|
15
|
+
render: (b, c) => c.icon(b.name, +b.size || 22, b.filled),
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
name: "avatar",
|
|
19
|
+
group: "Content",
|
|
20
|
+
summary: "Round profile picture with an optional name and sub-line. Clickable.",
|
|
21
|
+
props: {
|
|
22
|
+
name: { type: "text", doc: "Bold name next to the picture." },
|
|
23
|
+
sub: { type: "text", doc: "Smaller line under the name." },
|
|
24
|
+
size: { type: "number", doc: "Picture size in px (default 44)." },
|
|
25
|
+
...ACTION,
|
|
26
|
+
},
|
|
27
|
+
render: (b, c) =>
|
|
28
|
+
`<div class="avatar${b.go || b.open ? " click" : ""}"${c.act(b)}><oi-placeholder h="${+b.size || 44}" round></oi-placeholder>${
|
|
29
|
+
b.name || b.sub ? `<div class="avatar-text">${b.name ? `<strong>${c.tx(b.name)}</strong>` : ""}${b.sub ? `<span class="muted">${c.tx(b.sub)}</span>` : ""}</div>` : ""
|
|
30
|
+
}</div>`,
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
name: "hero",
|
|
34
|
+
group: "Content",
|
|
35
|
+
summary: "Big headline block for landing pages. Put call-to-action buttons (or an image) in `children`.",
|
|
36
|
+
children: true,
|
|
37
|
+
props: {
|
|
38
|
+
title: { type: "text", doc: "Headline.", required: true },
|
|
39
|
+
text: { type: "text", doc: "Supporting sentence." },
|
|
40
|
+
align: { type: "string", enum: ["left", "center"], doc: "Text alignment (default `center`)." },
|
|
41
|
+
},
|
|
42
|
+
render: (b, c) =>
|
|
43
|
+
`<section class="hero${b.align === "left" ? " left" : ""}"><h1>${c.tx(b.title)}</h1>${b.text ? `<p class="lead">${c.tx(b.text)}</p>` : ""}${
|
|
44
|
+
b.children?.length ? `<div class="stack hero-body">${c.kids(b)}</div>` : ""
|
|
45
|
+
}</section>`,
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
name: "stat",
|
|
49
|
+
group: "Content",
|
|
50
|
+
summary: "Number card for dashboards: label, big value and a change indicator.",
|
|
51
|
+
props: {
|
|
52
|
+
label: { type: "text", doc: "What is measured.", required: true },
|
|
53
|
+
value: { type: "text", doc: "The number.", required: true },
|
|
54
|
+
delta: { type: "text", doc: "Change, e.g. `+8%`." },
|
|
55
|
+
trend: { type: "string", enum: ["up", "down", "flat"], doc: "Colours the change: `up` green, `down` orange, `flat` grey (default `up`)." },
|
|
56
|
+
},
|
|
57
|
+
render: (b, c) =>
|
|
58
|
+
`<wired-card elevation="1" class="card stat"><div class="stack tight"><span class="muted">${c.tx(b.label)}</span><div class="stat-value">${c.tx(b.value)}</div>${
|
|
59
|
+
b.delta ? `<span class="badge ${{ up: "on", down: "wait", flat: "off" }[b.trend || "up"]}">${c.tx(b.delta)}</span>` : ""
|
|
60
|
+
}</div></wired-card>`,
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
name: "rating",
|
|
64
|
+
group: "Content",
|
|
65
|
+
summary: "Star rating.",
|
|
66
|
+
props: {
|
|
67
|
+
value: { type: "number", doc: "Filled stars (default 4)." },
|
|
68
|
+
max: { type: "number", doc: "Total stars (default 5)." },
|
|
69
|
+
text: { type: "text", doc: "Text after the stars, e.g. `(128 reviews)`." },
|
|
70
|
+
},
|
|
71
|
+
render: (b, c) => {
|
|
72
|
+
const max = +b.max || 5;
|
|
73
|
+
const value = b.value ?? 4;
|
|
74
|
+
return `<div class="row rating">${Array.from({ length: max }, (_, i) => c.icon("star", 18, i < value)).join("")}${
|
|
75
|
+
b.text ? `<span class="muted">${c.tx(b.text)}</span>` : ""
|
|
76
|
+
}</div>`;
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
name: "link",
|
|
81
|
+
group: "Content",
|
|
82
|
+
summary: "Underlined text link.",
|
|
83
|
+
props: { text: { type: "text", doc: "Link text.", required: true }, ...ACTION },
|
|
84
|
+
render: (b, c) => `<a class="oi-link"${c.act(b)}>${c.tx(b.text)}</a>`,
|
|
85
|
+
},
|
|
86
|
+
];
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
const isBlock = (x) => x && typeof x === "object" && !Array.isArray(x) && x.type;
|
|
2
|
+
const cellsOf = (row) => (Array.isArray(row) ? row : row?.cells || []);
|
|
3
|
+
|
|
4
|
+
/** @type {import("./index.js").BlockDef[]} */
|
|
5
|
+
export default [
|
|
6
|
+
{
|
|
7
|
+
name: "table",
|
|
8
|
+
group: "Data",
|
|
9
|
+
summary: "Table. A cell is text or a block (e.g. `badge`, `button`). A row can be `{ cells, go }` to make it clickable.",
|
|
10
|
+
props: {
|
|
11
|
+
columns: { type: "text[]", doc: "Column headings.", required: true },
|
|
12
|
+
rows: { type: "rows", doc: "List of rows: `[cell, cell]` or `{ cells: [...], go: screenId }`.", required: true },
|
|
13
|
+
},
|
|
14
|
+
nested: (b) =>
|
|
15
|
+
(b.rows || []).flatMap((r, i) => cellsOf(r).map((x, j) => [Array.isArray(r) ? `.rows[${i}][${j}]` : `.rows[${i}].cells[${j}]`, x]).filter(([, x]) => isBlock(x))),
|
|
16
|
+
targets: (b) => (b.rows || []).map((r) => r?.go).filter(Boolean),
|
|
17
|
+
render: (b, c) => {
|
|
18
|
+
const cell = (x) => (isBlock(x) ? c.block(x) : c.tx(x));
|
|
19
|
+
return `<table class="oi-table"><thead><tr>${(b.columns || []).map((x) => `<th>${c.tx(x)}</th>`).join("")}</tr></thead><tbody>${(b.rows || [])
|
|
20
|
+
.map((r) => `<tr${r?.go ? ` class="click" data-go="${c.esc(r.go)}"` : ""}>${cellsOf(r).map((x) => `<td>${cell(x)}</td>`).join("")}</tr>`)
|
|
21
|
+
.join("")}</tbody></table>`;
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
];
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/** @type {import("./index.js").BlockDef[]} */
|
|
2
|
+
export default [
|
|
3
|
+
{
|
|
4
|
+
name: "alert",
|
|
5
|
+
group: "Feedback",
|
|
6
|
+
summary: "Banner for information, success, warnings and errors.",
|
|
7
|
+
props: {
|
|
8
|
+
text: { type: "text", doc: "Message.", required: true },
|
|
9
|
+
title: { type: "text", doc: "Bold first line." },
|
|
10
|
+
kind: { type: "string", enum: ["info", "success", "warning", "error"], doc: "Type and colour (default `info`)." },
|
|
11
|
+
},
|
|
12
|
+
render: (b, c) => {
|
|
13
|
+
const kind = b.kind || "info";
|
|
14
|
+
const icon = { info: "info", success: "check", warning: "bell", error: "close" }[kind];
|
|
15
|
+
return `<div class="alert alert-${kind}">${c.icon(icon, 22)}<div>${b.title ? `<strong>${c.tx(b.title)}</strong> ` : ""}${c.tx(b.text)}</div></div>`;
|
|
16
|
+
},
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
name: "progress",
|
|
20
|
+
group: "Feedback",
|
|
21
|
+
summary: "Progress bar.",
|
|
22
|
+
props: {
|
|
23
|
+
value: { type: "number", doc: "Percent complete, 0 to 100.", required: true },
|
|
24
|
+
label: { type: "text", doc: "Caption above the bar." },
|
|
25
|
+
},
|
|
26
|
+
render: (b, c) =>
|
|
27
|
+
`<div class="field">${c.label(b)}<wired-progress value="${Math.min(100, Math.max(0, +b.value || 0))}" percentage></wired-progress></div>`,
|
|
28
|
+
},
|
|
29
|
+
];
|