storyink 0.0.1 → 0.2.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/README.md +148 -3
- package/THIRD_PARTY_NOTICES.md +50 -0
- package/dist/chunks/index-90zf42zn.js +0 -0
- package/dist/chunks/index-aq1sxjmx.js +369 -0
- package/dist/chunks/index-xjaczbn5.js +5790 -0
- package/dist/cli.js +6358 -0
- package/dist/core/index.js +77 -0
- package/dist/index.js +326 -0
- package/dist/node/index.js +17 -0
- package/dist/types/cli.d.ts +2 -0
- package/dist/types/core/index.d.ts +16 -0
- package/dist/types/core/layout/graph.d.ts +9 -0
- package/dist/types/core/layout/index.d.ts +9 -0
- package/dist/types/core/layout/measure.d.ts +7 -0
- package/dist/types/core/layout/nodes.d.ts +27 -0
- package/dist/types/core/layout/paths.d.ts +21 -0
- package/dist/types/core/layout/route.d.ts +41 -0
- package/dist/types/core/layout/sequence.d.ts +3 -0
- package/dist/types/core/mermaid/flowchart.d.ts +8 -0
- package/dist/types/core/mermaid/index.d.ts +18 -0
- package/dist/types/core/mermaid/sequence.d.ts +6 -0
- package/dist/types/core/mermaid/state.d.ts +6 -0
- package/dist/types/core/render/App.d.ts +32 -0
- package/dist/types/core/render/Diagram.d.ts +14 -0
- package/dist/types/core/render/Story.d.ts +54 -0
- package/dist/types/core/render/css.d.ts +5 -0
- package/dist/types/core/render/index.d.ts +29 -0
- package/dist/types/core/scene.d.ts +136 -0
- package/dist/types/core/spec.d.ts +194 -0
- package/dist/types/core/story/auto.d.ts +12 -0
- package/dist/types/core/story/compile.d.ts +24 -0
- package/dist/types/core/story/ease.d.ts +22 -0
- package/dist/types/core/story/state.d.ts +36 -0
- package/dist/types/core/story/types.d.ts +142 -0
- package/dist/types/core/validate.d.ts +18 -0
- package/dist/types/generated/font.d.ts +3 -0
- package/dist/types/generated/meta.d.ts +1 -0
- package/dist/types/generated/rolling.d.ts +2 -0
- package/dist/types/generated/viewer.d.ts +1 -0
- package/dist/types/index.d.ts +7 -0
- package/dist/types/node/assets.d.ts +5 -0
- package/dist/types/node/chrome.d.ts +10 -0
- package/dist/types/node/index.d.ts +35 -0
- package/dist/types/node/snapshot.d.ts +78 -0
- package/dist/types/plugin.d.ts +8 -0
- package/dist/types/theme/tokens.d.ts +201 -0
- package/docs/spec.md +148 -0
- package/examples/agent-run.lifecycle.json +113 -0
- package/examples/analytics.dataflow.json +36 -0
- package/examples/checkout.architecture.json +214 -0
- package/examples/mermaid/cache.sequence.mmd +25 -0
- package/examples/mermaid/incident.flowchart.mmd +18 -0
- package/examples/mermaid/order.state.mmd +24 -0
- package/examples/oauth.sequence.json +213 -0
- package/examples/release.workflow.json +124 -0
- package/fonts/OFL.txt +90 -0
- package/package.json +81 -5
- package/schema/storyink.schema.json +785 -0
- package/server.js +3 -0
- package/skill/SKILL.md +111 -0
package/README.md
CHANGED
|
@@ -1,6 +1,151 @@
|
|
|
1
1
|
# storyink
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
Architecture, workflow, sequence, data-flow and lifecycle diagrams from a small JSON spec (or
|
|
4
|
+
Mermaid) into **one offline HTML file** (a React + Motion viewer over server-rendered SVG) and a
|
|
5
|
+
**static SVG**. Warm ink-on-paper style with Commit Mono labels, light and dark themes.
|
|
5
6
|
|
|
6
|
-
|
|
7
|
+
One package, four ways to use it: library, CLI, OpenCode plugin, and a skill for other agents.
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
npx storyink render examples/checkout.architecture.json -o checkout.html --svg checkout.svg
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
The HTML has no external requests. It includes pan/zoom (wheel at the cursor, drag, `+`/`-`/`0`,
|
|
14
|
+
fit on load), a theme toggle that follows `prefers-color-scheme` and is saved in `localStorage`,
|
|
15
|
+
SVG export, and 2× PNG export. It also works without JavaScript.
|
|
16
|
+
|
|
17
|
+
## 1. Library
|
|
18
|
+
|
|
19
|
+
```ts
|
|
20
|
+
import { validate, fromMermaid, renderHtml, renderSvg, layout } from "storyink/core" // browser-safe
|
|
21
|
+
import { writeDiagram, snapshot, findBrowser } from "storyink/node" // Node only
|
|
22
|
+
|
|
23
|
+
const v = validate(spec) // { ok, diagnostics: [{ severity, path, message, hint }], spec }
|
|
24
|
+
const html = renderHtml(v.spec!) // standalone page
|
|
25
|
+
const svg = renderSvg(v.spec!, { theme: "dark" }) // omit theme to follow prefers-color-scheme
|
|
26
|
+
const scene = layout(v.spec!) // absolute geometry with stable ids (nodes, groups, edges, ports, labels)
|
|
27
|
+
const m = fromMermaid("flowchart LR\n a --> b") // { ok, spec, diagnostics }
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
`storyink/core` has no Node APIs. The viewer bundle and the font are compiled in as string
|
|
31
|
+
modules, so it works with bundlers and in the browser. `storyink` (the root export) re-exports
|
|
32
|
+
both and has the OpenCode plugin as its default export. Runs on Node ≥ 20 and Bun.
|
|
33
|
+
|
|
34
|
+
## 2. CLI
|
|
35
|
+
|
|
36
|
+
```
|
|
37
|
+
storyink render <in.json|in.mmd|-> [-o out.html] [--svg out.svg] [--theme light|dark] [--story auto]
|
|
38
|
+
storyink mermaid <in.mmd> [-o out.json]
|
|
39
|
+
storyink validate <in> [--json]
|
|
40
|
+
storyink snapshot <out.html> [--theme light,dark] [--width N] [--sheet [themes|beats]|--no-sheet] [--at 0.5,1.2,end] [--scale 2] [-o dir] [--json]
|
|
41
|
+
storyink skill
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
`snapshot` finds a browser in this order: `$STORYINK_CHROME`, then the newest Playwright
|
|
45
|
+
`chrome-headless-shell`, then system Chrome (`--headless=new`). It writes one PNG per theme and
|
|
46
|
+
a light|dark contact sheet, reads the in-page lint through `--dump-dom`, and writes a receipt
|
|
47
|
+
JSON containing the browser, flags, sha256 of each image, lint results and gates. Exit codes:
|
|
48
|
+
0 pass, 1 gate failed, 2 no browser.
|
|
49
|
+
|
|
50
|
+
## 3. OpenCode plugin
|
|
51
|
+
|
|
52
|
+
From npm (once published), pinned or not:
|
|
53
|
+
|
|
54
|
+
```jsonc
|
|
55
|
+
// opencode.json (global ~/.config/opencode/ or project .opencode/)
|
|
56
|
+
{ "plugins": ["storyink"] } // or "storyink@0.1.0"
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
From a local checkout, for development: build first, then point `plugins` at the **directory**
|
|
60
|
+
(OpenCode v2 accepts directories here, not single files):
|
|
61
|
+
|
|
62
|
+
```sh
|
|
63
|
+
cd ~/projects/storyink && bun install && bun run build
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
```jsonc
|
|
67
|
+
// <project>/.opencode/opencode.json
|
|
68
|
+
{ "plugins": ["/absolute/path/to/storyink"] }
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
OpenCode resolves a local plugin directory as `<dir>/server` and then `<dir>/index`. It does not
|
|
72
|
+
read `package.json` `main`, which is why the repo ships a root `server.js` that re-exports
|
|
73
|
+
`dist/index.js`. npm packages resolve `storyink/server` and then `storyink`, and both are exported.
|
|
74
|
+
Rebuild after changes, then restart the server (or touch the config) to reload.
|
|
75
|
+
|
|
76
|
+
This adds the tools `storyink_render`, `storyink_from_mermaid`, `storyink_validate` and
|
|
77
|
+
`storyink_snapshot`. `storyink_snapshot` returns the contact sheet as an image, so the model can
|
|
78
|
+
see its own render. The plugin also adds the `storyink` skill (`skill/SKILL.md`), which covers
|
|
79
|
+
choosing a diagram type, writing the spec, and the render → look → fix loop. If you already have
|
|
80
|
+
a skill with the id `storyink`, yours is kept. Relative paths resolve against the project
|
|
81
|
+
directory.
|
|
82
|
+
|
|
83
|
+
## 4. Other agents
|
|
84
|
+
|
|
85
|
+
Use the CLI, and install the skill from the package:
|
|
86
|
+
|
|
87
|
+
```sh
|
|
88
|
+
npx storyink skill # prints the SKILL.md path and its content
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
An MCP server is planned.
|
|
92
|
+
|
|
93
|
+
## Storyboards (opt-in)
|
|
94
|
+
|
|
95
|
+
Add a `story` to play a diagram as a sequence of beats in the HTML viewer:
|
|
96
|
+
- nodes reveal;
|
|
97
|
+
- wires draw on under a travelling pulse;
|
|
98
|
+
- arrivals glow;
|
|
99
|
+
- captions type in;
|
|
100
|
+
- counters roll (via [@kitlangton/rolling-number](https://github.com/kitlangton/rolling-number)).
|
|
101
|
+
|
|
102
|
+
`"story": "auto"` (or `--story auto`) derives the beats from the graph or message order. Playback
|
|
103
|
+
has a click-to-play gate, play/pause, a tape-rewind replay and a scrubber with step and chapter
|
|
104
|
+
ticks. Space, ←/→ and R control it. When reduced motion is on, the viewer jumps to the final frame.
|
|
105
|
+
|
|
106
|
+
The final frame is always the static diagram. Every frame is a pure function of time
|
|
107
|
+
(`storyState(scene, timeline, t)`), so `#t=2.5` seeks exactly and
|
|
108
|
+
`storyink snapshot --at 1,2.5,end --sheet beats` renders stills and a beat contact sheet. The
|
|
109
|
+
receipt gates check that the end frame and the reduced-motion page match the static diagram. See
|
|
110
|
+
[docs/spec.md](docs/spec.md#storyboard-story-opt-in).
|
|
111
|
+
|
|
112
|
+

|
|
113
|
+
|
|
114
|
+
## Gallery
|
|
115
|
+
|
|
116
|
+
`bun run gallery` renders every example and Mermaid sample and writes one light and one dark PNG per
|
|
117
|
+
example to `docs/gallery/`.
|
|
118
|
+
|
|
119
|
+
| | |
|
|
120
|
+
| --- | --- |
|
|
121
|
+
|  |  |
|
|
122
|
+
|  |  |
|
|
123
|
+
|  |  |
|
|
124
|
+
|  |  |
|
|
125
|
+
|
|
126
|
+
## Spec
|
|
127
|
+
|
|
128
|
+
See [docs/spec.md](docs/spec.md) and [schema/storyink.schema.json](schema/storyink.schema.json).
|
|
129
|
+
Graph edges have no arrowheads by default, matching Kit's style. Set `"style": { "arrowheads": true }`
|
|
130
|
+
to draw them. If you leave out `direction`, the layout picks TB or LR, whichever gets the aspect
|
|
131
|
+
ratio closer to 16:10.
|
|
132
|
+
Examples of each type are in [examples/](examples), and Mermaid samples are in
|
|
133
|
+
[examples/mermaid/](examples/mermaid).
|
|
134
|
+
|
|
135
|
+
## Develop
|
|
136
|
+
|
|
137
|
+
```sh
|
|
138
|
+
bun install
|
|
139
|
+
bun test # generates src/generated/* first
|
|
140
|
+
bun run typecheck
|
|
141
|
+
bun run build # dist/ (ESM for node, .d.ts, CLI with a node shebang)
|
|
142
|
+
bun run gallery # docs/gallery/*.png (needs Chrome / Playwright headless shell)
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
All design tokens (palette, type, geometry, motion) live in `src/theme/tokens.ts` and are
|
|
146
|
+
exported as CSS variables.
|
|
147
|
+
|
|
148
|
+
## License
|
|
149
|
+
|
|
150
|
+
MIT © 2026 grenaad. The bundled font and libraries are listed in
|
|
151
|
+
[THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md).
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# Third-party notices
|
|
2
|
+
|
|
3
|
+
## Commit Mono (embedded in every HTML/SVG output)
|
|
4
|
+
|
|
5
|
+
Commit Mono v1.143 by Eigil Nikolajsen, <https://github.com/eigilnikolajsen/commit-mono>.
|
|
6
|
+
Licensed under the SIL Open Font License 1.1; full text in [`fonts/OFL.txt`](fonts/OFL.txt).
|
|
7
|
+
`fonts/CommitMono-400.woff2` and `CommitMono-700.woff2` are unmodified format conversions
|
|
8
|
+
(OTF → WOFF2 with fontTools) of `CommitMono-400-Regular.otf` / `CommitMono-700-Regular.otf`
|
|
9
|
+
from the official release zip. The font is embedded as base64 `@font-face`; it is not sold
|
|
10
|
+
by itself.
|
|
11
|
+
|
|
12
|
+
## React, React DOM, scheduler (bundled into the HTML viewer)
|
|
13
|
+
|
|
14
|
+
MIT License. Copyright (c) Meta Platforms, Inc. and affiliates. <https://github.com/facebook/react>
|
|
15
|
+
|
|
16
|
+
## Motion (motion, framer-motion, motion-dom, motion-utils; bundled into the HTML viewer)
|
|
17
|
+
|
|
18
|
+
MIT License. Copyright (c) 2024 Motion B.V.; Copyright (c) 2018 Framer B.V. <https://github.com/motiondivision/motion>
|
|
19
|
+
|
|
20
|
+
The viewer bundle inlined into each HTML file starts with a comment banner that carries
|
|
21
|
+
these notices and the MIT permission text.
|
|
22
|
+
|
|
23
|
+
## @kitlangton/rolling-number (bundled into the HTML viewer)
|
|
24
|
+
|
|
25
|
+
MIT License. Copyright (c) 2026 Kit Langton. <https://github.com/kitlangton/rolling-number>
|
|
26
|
+
Used unmodified from npm for live counter reels; its stylesheet is inlined when a story has
|
|
27
|
+
counters. The notice is also in the viewer bundle banner.
|
|
28
|
+
|
|
29
|
+
## Storyboard design
|
|
30
|
+
|
|
31
|
+
Timings and curves (beats, springs, pulse phases, rewind, gate) follow the numbers documented in
|
|
32
|
+
our own style study of the "OpenCode Reloaded" figures; the runtime is an independent
|
|
33
|
+
implementation. No code, shaders or figure content from anoma.ly are included.
|
|
34
|
+
|
|
35
|
+
## archify (design reference)
|
|
36
|
+
|
|
37
|
+
The spec's overall shape (one JSON document per diagram with `type`, `nodes`/`edges`/`groups`,
|
|
38
|
+
sequence `participants`/`messages`) was informed by the MIT-licensed archify skill's schemas.
|
|
39
|
+
No archify code was copied; storyink's schema, validator and renderer are independent.
|
|
40
|
+
|
|
41
|
+
## Visual design
|
|
42
|
+
|
|
43
|
+
The look is an independent reimplementation inspired by Kit Langton's "OpenCode Reloaded"
|
|
44
|
+
post (<https://anoma.ly/notes/opencode-reloaded/>). No code, fonts or figure content from that
|
|
45
|
+
site are included. Serif headings use system fonts ("Iowan Old Style", Charter, Georgia).
|
|
46
|
+
|
|
47
|
+
## Mermaid syntax
|
|
48
|
+
|
|
49
|
+
storyink parses a subset of Mermaid syntax with its own hand-written parsers; no Mermaid
|
|
50
|
+
or merman code is included.
|
|
File without changes
|
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
import {
|
|
2
|
+
detectMermaid,
|
|
3
|
+
fromMermaid,
|
|
4
|
+
renderHtml,
|
|
5
|
+
renderSvg,
|
|
6
|
+
validate
|
|
7
|
+
} from "./index-xjaczbn5.js";
|
|
8
|
+
|
|
9
|
+
// src/node/index.ts
|
|
10
|
+
import fs3 from "node:fs";
|
|
11
|
+
import path3 from "node:path";
|
|
12
|
+
|
|
13
|
+
// src/node/chrome.ts
|
|
14
|
+
import { execFileSync } from "node:child_process";
|
|
15
|
+
import fs from "node:fs";
|
|
16
|
+
import os from "node:os";
|
|
17
|
+
import path from "node:path";
|
|
18
|
+
function newestPlaywrightShell() {
|
|
19
|
+
const caches = [
|
|
20
|
+
path.join(os.homedir(), "Library/Caches/ms-playwright"),
|
|
21
|
+
path.join(os.homedir(), ".cache/ms-playwright"),
|
|
22
|
+
process.env.PLAYWRIGHT_BROWSERS_PATH ?? ""
|
|
23
|
+
].filter(Boolean);
|
|
24
|
+
const found = [];
|
|
25
|
+
for (const dir of caches) {
|
|
26
|
+
let entries = [];
|
|
27
|
+
try {
|
|
28
|
+
entries = fs.readdirSync(dir);
|
|
29
|
+
} catch {
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
for (const e of entries) {
|
|
33
|
+
const m = /^chromium_headless_shell-(\d+)$/.exec(e);
|
|
34
|
+
if (!m)
|
|
35
|
+
continue;
|
|
36
|
+
const base = path.join(dir, e);
|
|
37
|
+
let subs = [];
|
|
38
|
+
try {
|
|
39
|
+
subs = fs.readdirSync(base).filter((s) => s.startsWith("chrome-headless-shell-"));
|
|
40
|
+
} catch {}
|
|
41
|
+
for (const s of subs) {
|
|
42
|
+
const bin = path.join(base, s, process.platform === "win32" ? "chrome-headless-shell.exe" : "chrome-headless-shell");
|
|
43
|
+
if (fs.existsSync(bin))
|
|
44
|
+
found.push({ rev: Number(m[1]), file: bin });
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
found.sort((a, b) => b.rev - a.rev);
|
|
49
|
+
return found[0]?.file;
|
|
50
|
+
}
|
|
51
|
+
var SYSTEM = [
|
|
52
|
+
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
|
53
|
+
"/Applications/Chromium.app/Contents/MacOS/Chromium",
|
|
54
|
+
"/usr/bin/google-chrome",
|
|
55
|
+
"/usr/bin/google-chrome-stable",
|
|
56
|
+
"/usr/bin/chromium",
|
|
57
|
+
"/usr/bin/chromium-browser",
|
|
58
|
+
"/snap/bin/chromium"
|
|
59
|
+
];
|
|
60
|
+
function browserVersion(bin) {
|
|
61
|
+
try {
|
|
62
|
+
return execFileSync(bin, ["--version"], { encoding: "utf8", timeout: 5000, stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
63
|
+
} catch {
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
function findBrowser(env = process.env) {
|
|
68
|
+
const fromEnv = env.STORYINK_CHROME;
|
|
69
|
+
if (fromEnv && fs.existsSync(fromEnv))
|
|
70
|
+
return { path: fromEnv, flavor: /headless[-_]shell/.test(fromEnv) ? "headless-shell" : "chrome", source: "env" };
|
|
71
|
+
const pw = newestPlaywrightShell();
|
|
72
|
+
if (pw)
|
|
73
|
+
return { path: pw, flavor: "headless-shell", source: "playwright" };
|
|
74
|
+
for (const p of SYSTEM)
|
|
75
|
+
if (fs.existsSync(p))
|
|
76
|
+
return { path: p, flavor: "chrome", source: "system" };
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
// src/node/snapshot.ts
|
|
80
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
81
|
+
import { createHash } from "node:crypto";
|
|
82
|
+
import fs2 from "node:fs";
|
|
83
|
+
import os2 from "node:os";
|
|
84
|
+
import path2 from "node:path";
|
|
85
|
+
import { pathToFileURL } from "node:url";
|
|
86
|
+
var HARD_TIMEOUT = 15000;
|
|
87
|
+
function readScene(html) {
|
|
88
|
+
const m = /<script type="application\/json" id="storyink-data">([\s\S]*?)<\/script>/.exec(html);
|
|
89
|
+
if (!m)
|
|
90
|
+
throw new Error("not a storyink HTML file (no #storyink-data)");
|
|
91
|
+
const data = JSON.parse(m[1]);
|
|
92
|
+
return data.scene;
|
|
93
|
+
}
|
|
94
|
+
var live = new Set;
|
|
95
|
+
function killTree(child) {
|
|
96
|
+
try {
|
|
97
|
+
if (child.pid && process.platform !== "win32")
|
|
98
|
+
process.kill(-child.pid, "SIGKILL");
|
|
99
|
+
else
|
|
100
|
+
child.kill("SIGKILL");
|
|
101
|
+
} catch {
|
|
102
|
+
try {
|
|
103
|
+
child.kill("SIGKILL");
|
|
104
|
+
} catch {}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
var hooked = false;
|
|
108
|
+
function hookExit() {
|
|
109
|
+
if (hooked)
|
|
110
|
+
return;
|
|
111
|
+
hooked = true;
|
|
112
|
+
process.on("exit", () => {
|
|
113
|
+
for (const c of live)
|
|
114
|
+
killTree(c);
|
|
115
|
+
});
|
|
116
|
+
for (const sig of ["SIGINT", "SIGTERM", "SIGHUP"])
|
|
117
|
+
process.once(sig, () => {
|
|
118
|
+
for (const c of live)
|
|
119
|
+
killTree(c);
|
|
120
|
+
process.exit(128 + (sig === "SIGINT" ? 2 : sig === "SIGTERM" ? 15 : 1));
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
function run(bin, args, opts) {
|
|
124
|
+
hookExit();
|
|
125
|
+
return new Promise((resolve) => {
|
|
126
|
+
const t0 = performance.now();
|
|
127
|
+
const child = spawn(bin, args, { stdio: ["ignore", "pipe", "pipe"], detached: process.platform !== "win32" });
|
|
128
|
+
live.add(child);
|
|
129
|
+
let stdout = "";
|
|
130
|
+
let stderr = "";
|
|
131
|
+
let done = false;
|
|
132
|
+
let written = false;
|
|
133
|
+
const finish = (ok) => {
|
|
134
|
+
if (done)
|
|
135
|
+
return;
|
|
136
|
+
done = true;
|
|
137
|
+
clearTimeout(timer);
|
|
138
|
+
opts.signal?.removeEventListener("abort", onAbort);
|
|
139
|
+
killTree(child);
|
|
140
|
+
live.delete(child);
|
|
141
|
+
resolve({ ok, stdout, stderr, ms: Math.round(performance.now() - t0) });
|
|
142
|
+
};
|
|
143
|
+
const onAbort = () => finish(false);
|
|
144
|
+
opts.signal?.addEventListener("abort", onAbort);
|
|
145
|
+
const timer = setTimeout(() => finish(written), opts.timeoutMs);
|
|
146
|
+
child.stdout.on("data", (d) => {
|
|
147
|
+
stdout += d;
|
|
148
|
+
if (opts.untilStdout?.test(stdout))
|
|
149
|
+
finish(true);
|
|
150
|
+
});
|
|
151
|
+
child.stderr.on("data", (d) => {
|
|
152
|
+
stderr += d;
|
|
153
|
+
if (/bytes written to file/.test(stderr)) {
|
|
154
|
+
written = true;
|
|
155
|
+
finish(true);
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
child.on("error", (e) => {
|
|
159
|
+
stderr += String(e);
|
|
160
|
+
finish(false);
|
|
161
|
+
});
|
|
162
|
+
child.on("exit", (code) => {
|
|
163
|
+
live.delete(child);
|
|
164
|
+
finish(code === 0 || written);
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
function ffmpegSheet(inputs, out) {
|
|
169
|
+
const n = inputs.length;
|
|
170
|
+
const filter = `${inputs.map((_, i) => `[${i}:v]`).join("")}concat=n=${n}:v=1:a=0,tile=${n}x1`;
|
|
171
|
+
const ff = spawnSync("ffmpeg", ["-y", "-loglevel", "error", ...inputs.flatMap((p) => ["-i", p]), "-filter_complex", filter, "-frames:v", "1", out]);
|
|
172
|
+
return ff.status === 0 && fs2.existsSync(out) && fs2.statSync(out).size > 0;
|
|
173
|
+
}
|
|
174
|
+
var sha256 = (file) => createHash("sha256").update(fs2.readFileSync(file)).digest("hex");
|
|
175
|
+
async function snapshot(htmlPath, opts = {}) {
|
|
176
|
+
const browser = opts.browser ?? findBrowser();
|
|
177
|
+
if (!browser)
|
|
178
|
+
return { code: 2, error: "no Chrome/Chromium found (set STORYINK_CHROME)" };
|
|
179
|
+
const abs = path2.resolve(htmlPath);
|
|
180
|
+
const html = fs2.readFileSync(abs, "utf8");
|
|
181
|
+
const scene = readScene(html);
|
|
182
|
+
const vb = scene.viewBox;
|
|
183
|
+
const themes = opts.themes?.length ? opts.themes : ["light", "dark"];
|
|
184
|
+
const outDir = path2.resolve(opts.outDir ?? path2.dirname(abs));
|
|
185
|
+
fs2.mkdirSync(outDir, { recursive: true });
|
|
186
|
+
const base = path2.basename(abs).replace(/\.html?$/i, "");
|
|
187
|
+
const scale = opts.scale ?? 1;
|
|
188
|
+
const timeoutMs = opts.timeoutMs ?? HARD_TIMEOUT;
|
|
189
|
+
const budget = opts.budgetMs ?? 3000;
|
|
190
|
+
const tl = scene.timeline;
|
|
191
|
+
const headerH = (scene.subtitle ? 128 : 100) + (tl ? 54 : 0);
|
|
192
|
+
const ats = opts.at?.length ? opts.at : opts.t ? [opts.t === "end" ? "end" : Number(opts.t)] : ["end"];
|
|
193
|
+
const tq = (at) => tl ? `&t=${at === "end" ? "end" : +at.toFixed(3)}` : "";
|
|
194
|
+
const sheetMode = opts.sheet === false ? false : opts.sheet === "beats" ? "beats" : "themes";
|
|
195
|
+
const W = Math.round(Math.max(500, opts.width ?? Math.min(1600, vb.w + 64)));
|
|
196
|
+
const s = Math.min(1, (W - 64) / vb.w);
|
|
197
|
+
const H = Math.round(headerH + vb.h * s + 64 + 8);
|
|
198
|
+
const url = (hash) => `${pathToFileURL(abs).href}#${hash}`;
|
|
199
|
+
const baseFlags = [
|
|
200
|
+
...browser.flavor === "chrome" ? ["--headless=new"] : [],
|
|
201
|
+
"--no-first-run",
|
|
202
|
+
"--no-default-browser-check",
|
|
203
|
+
"--hide-scrollbars",
|
|
204
|
+
`--force-device-scale-factor=${scale}`,
|
|
205
|
+
"--force-prefers-no-reduced-motion",
|
|
206
|
+
"--disable-extensions",
|
|
207
|
+
"--mute-audio",
|
|
208
|
+
`--virtual-time-budget=${budget}`
|
|
209
|
+
];
|
|
210
|
+
const tmpRoot = fs2.mkdtempSync(path2.join(os2.tmpdir(), "storyink-chrome-"));
|
|
211
|
+
let n = 0;
|
|
212
|
+
const fresh = () => {
|
|
213
|
+
const d = path2.join(tmpRoot, `p${n++}`);
|
|
214
|
+
fs2.mkdirSync(d);
|
|
215
|
+
return `--user-data-dir=${d}`;
|
|
216
|
+
};
|
|
217
|
+
const shoot = async (hash, png, w, h) => {
|
|
218
|
+
fs2.rmSync(png, { force: true });
|
|
219
|
+
const args = [...baseFlags, fresh(), `--window-size=${w},${h}`, `--screenshot=${png}`, url(hash)];
|
|
220
|
+
let r = await run(browser.path, args, { timeoutMs, signal: opts.signal });
|
|
221
|
+
if ((!fs2.existsSync(png) || fs2.statSync(png).size === 0) && !opts.signal?.aborted) {
|
|
222
|
+
const retry = [...baseFlags, fresh(), `--window-size=${w},${h}`, `--screenshot=${png}`, url(hash)];
|
|
223
|
+
r = await run(browser.path, retry, { timeoutMs, signal: opts.signal });
|
|
224
|
+
}
|
|
225
|
+
if (!fs2.existsSync(png) || fs2.statSync(png).size === 0)
|
|
226
|
+
throw new Error(`screenshot failed (${r.ms} ms): ${r.stderr.split(`
|
|
227
|
+
`).filter((l) => l.trim()).slice(-3).join(" | ")}`);
|
|
228
|
+
return r.ms;
|
|
229
|
+
};
|
|
230
|
+
const gates = [];
|
|
231
|
+
const captures = [];
|
|
232
|
+
let sheetCap;
|
|
233
|
+
let beatCaps = [];
|
|
234
|
+
let lint;
|
|
235
|
+
try {
|
|
236
|
+
for (const theme of themes)
|
|
237
|
+
for (const at of ats) {
|
|
238
|
+
const tag = at === "end" ? "" : `.t${+at.toFixed(2)}`;
|
|
239
|
+
const png = path2.join(outDir, `${base}.${theme}${tag}.png`);
|
|
240
|
+
const ms = await shoot(`theme=${theme}&chrome=0${tq(at)}`, png, W, H);
|
|
241
|
+
captures.push({ theme, at, png, sha256: sha256(png), bytes: fs2.statSync(png).size, width: W * scale, height: H * scale, ms });
|
|
242
|
+
}
|
|
243
|
+
const first = captures[0];
|
|
244
|
+
const midT = tl ? ats.find((a) => a !== "end") ?? +(tl.duration / 2).toFixed(3) : "end";
|
|
245
|
+
const a1 = path2.join(tmpRoot, "same-1.png");
|
|
246
|
+
const a2 = path2.join(tmpRoot, "same-2.png");
|
|
247
|
+
await shoot(`theme=${first.theme}&chrome=0${tq(midT)}`, a1, W, H);
|
|
248
|
+
await shoot(`theme=${first.theme}&chrome=0${tq(midT)}`, a2, W, H);
|
|
249
|
+
const same = sha256(a1) === sha256(a2);
|
|
250
|
+
gates.push({ name: "deterministic", pass: same, detail: same ? `${first.theme} at t=${midT} captured twice: identical` : `${first.theme} at t=${midT} differs between runs` });
|
|
251
|
+
if (tl) {
|
|
252
|
+
const stat = path2.join(tmpRoot, "static.png");
|
|
253
|
+
const end = path2.join(tmpRoot, "end.png");
|
|
254
|
+
const red = path2.join(tmpRoot, "reduced.png");
|
|
255
|
+
await shoot(`theme=${first.theme}&chrome=0&static=1`, stat, W, H);
|
|
256
|
+
await shoot(`theme=${first.theme}&chrome=0&t=end`, end, W, H);
|
|
257
|
+
await shoot(`theme=${first.theme}&chrome=0&motion=reduced`, red, W, H);
|
|
258
|
+
const sStat = sha256(stat);
|
|
259
|
+
gates.push({ name: "end=static", pass: sha256(end) === sStat, detail: sha256(end) === sStat ? "t=end matches the static diagram" : "t=end differs from the static diagram" });
|
|
260
|
+
gates.push({ name: "reduced=static", pass: sha256(red) === sStat, detail: sha256(red) === sStat ? "reduced motion shows the static diagram" : "reduced motion differs from the static diagram" });
|
|
261
|
+
}
|
|
262
|
+
const beats = [];
|
|
263
|
+
if (sheetMode === "beats" && tl) {
|
|
264
|
+
const bw = 1440;
|
|
265
|
+
for (const theme of themes) {
|
|
266
|
+
const probe = await run(browser.path, [...baseFlags, fresh(), `--window-size=${bw},900`, "--dump-dom", url(`theme=${theme}&chrome=0&sheet=beats`)], {
|
|
267
|
+
timeoutMs,
|
|
268
|
+
untilStdout: /<\/html>\s*$/,
|
|
269
|
+
signal: opts.signal
|
|
270
|
+
});
|
|
271
|
+
const mh = /<html[^>]*data-content-height="(\d+)"/.exec(probe.stdout);
|
|
272
|
+
const bh = mh ? Number(mh[1]) : 1800;
|
|
273
|
+
const png = path2.join(outDir, `${base}.beats.${theme}.png`);
|
|
274
|
+
const ms = await shoot(`theme=${theme}&chrome=0&sheet=beats`, png, bw, bh);
|
|
275
|
+
beats.push({ theme: "beats", png, sha256: sha256(png), bytes: fs2.statSync(png).size, width: bw * scale, height: bh * scale, ms });
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
beatCaps = beats;
|
|
279
|
+
if (sheetMode === "themes" && themes.length > 1) {
|
|
280
|
+
const colW = Math.max(500, Math.min(1000, vb.w + 48));
|
|
281
|
+
const sw = colW * themes.length;
|
|
282
|
+
const sh = Math.round(headerH + 46 + vb.h * (colW - 48) / vb.w + 36);
|
|
283
|
+
const png = path2.join(outDir, `${base}.sheet.png`);
|
|
284
|
+
try {
|
|
285
|
+
if (process.env.STORYINK_SHEET === "ffmpeg")
|
|
286
|
+
throw new Error("forced ffmpeg sheet");
|
|
287
|
+
const ms = await shoot(`sheet=${themes.join(",")}&chrome=0`, png, sw, sh);
|
|
288
|
+
sheetCap = { theme: "sheet", png, sha256: sha256(png), bytes: fs2.statSync(png).size, width: sw * scale, height: sh * scale, ms };
|
|
289
|
+
} catch (e) {
|
|
290
|
+
const ff = ffmpegSheet(captures.map((c) => c.png), png);
|
|
291
|
+
if (ff)
|
|
292
|
+
sheetCap = { theme: "sheet", png, sha256: sha256(png), bytes: fs2.statSync(png).size, width: W * captures.length * scale, height: H * scale, ms: 0 };
|
|
293
|
+
else
|
|
294
|
+
gates.push({ name: "sheet", pass: false, detail: String(e.message) });
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
const dom = await run(browser.path, [...baseFlags, fresh(), `--window-size=${W},${H}`, "--dump-dom", url(`theme=${themes[0]}&chrome=0${tq("end")}`)], {
|
|
298
|
+
timeoutMs,
|
|
299
|
+
untilStdout: /<\/html>\s*$/,
|
|
300
|
+
signal: opts.signal
|
|
301
|
+
});
|
|
302
|
+
const lm = /<script type="application\/json" id="storyink-lint">([\s\S]*?)<\/script>/.exec(dom.stdout);
|
|
303
|
+
const ready = /<html[^>]*data-ready="1"/.test(dom.stdout);
|
|
304
|
+
gates.push({ name: "ready", pass: ready, detail: ready ? "hydrated, fonts ready" : "page never signalled ready" });
|
|
305
|
+
if (lm) {
|
|
306
|
+
lint = JSON.parse(lm[1]);
|
|
307
|
+
const l = lint;
|
|
308
|
+
gates.push({ name: "lint", pass: l.ok, detail: l.ok ? "no overflow or overlap" : `${l.issues.length} issue(s)` });
|
|
309
|
+
} else
|
|
310
|
+
gates.push({ name: "lint", pass: false, detail: "no lint output in DOM" });
|
|
311
|
+
} catch (e) {
|
|
312
|
+
gates.push({ name: "capture", pass: false, detail: e.message });
|
|
313
|
+
} finally {
|
|
314
|
+
fs2.rmSync(tmpRoot, { recursive: true, force: true });
|
|
315
|
+
}
|
|
316
|
+
const receipt = {
|
|
317
|
+
html: abs,
|
|
318
|
+
browser: { path: browser.path, version: browser.version ?? browserVersion(browser.path), flavor: browser.flavor, source: browser.source },
|
|
319
|
+
flags: [...baseFlags, "--user-data-dir=<fresh tmp>", `--window-size=${W},${H}`],
|
|
320
|
+
captures,
|
|
321
|
+
...sheetCap ? { sheet: sheetCap } : {},
|
|
322
|
+
...beatCaps.length ? { beats: beatCaps } : {},
|
|
323
|
+
...tl ? { story: { duration: tl.duration, steps: tl.steps.length } } : {},
|
|
324
|
+
...lint ? { lint } : {},
|
|
325
|
+
gates,
|
|
326
|
+
ok: gates.every((g) => g.pass),
|
|
327
|
+
createdAt: new Date().toISOString()
|
|
328
|
+
};
|
|
329
|
+
const receiptPath = path2.join(outDir, `${base}.receipt.json`);
|
|
330
|
+
fs2.writeFileSync(receiptPath, `${JSON.stringify(receipt, null, 2)}
|
|
331
|
+
`);
|
|
332
|
+
return { code: receipt.ok ? 0 : 1, receipt, receiptPath };
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// src/node/index.ts
|
|
336
|
+
function parseSource(text, hint) {
|
|
337
|
+
const trimmed = text.trimStart();
|
|
338
|
+
const isMermaid = hint ? /\.(mmd|mermaid)$/i.test(hint) : !trimmed.startsWith("{") && !!detectMermaid(text);
|
|
339
|
+
if (isMermaid) {
|
|
340
|
+
const r = fromMermaid(text);
|
|
341
|
+
return { ok: r.ok, spec: r.spec, diagnostics: r.diagnostics, source: "mermaid" };
|
|
342
|
+
}
|
|
343
|
+
const v = validate(text);
|
|
344
|
+
return { ok: v.ok, spec: v.spec, diagnostics: v.diagnostics, source: "json" };
|
|
345
|
+
}
|
|
346
|
+
function loadSpec(file) {
|
|
347
|
+
return parseSource(fs3.readFileSync(file, "utf8"), file);
|
|
348
|
+
}
|
|
349
|
+
function writeDiagram(input, out) {
|
|
350
|
+
const v = validate(input);
|
|
351
|
+
if (!v.ok || !v.spec)
|
|
352
|
+
return { ok: false, diagnostics: v.diagnostics };
|
|
353
|
+
const res = { ok: true, diagnostics: v.diagnostics };
|
|
354
|
+
if (out.html) {
|
|
355
|
+
const html = renderHtml(v.spec, { ...out.htmlOptions, ...out.theme ? { theme: out.theme } : {} });
|
|
356
|
+
fs3.mkdirSync(path3.dirname(out.html), { recursive: true });
|
|
357
|
+
fs3.writeFileSync(out.html, html);
|
|
358
|
+
res.html = { path: path3.resolve(out.html), bytes: Buffer.byteLength(html) };
|
|
359
|
+
}
|
|
360
|
+
if (out.svg) {
|
|
361
|
+
const svg = renderSvg(v.spec, out.theme ? { theme: out.theme } : {});
|
|
362
|
+
fs3.mkdirSync(path3.dirname(out.svg), { recursive: true });
|
|
363
|
+
fs3.writeFileSync(out.svg, svg);
|
|
364
|
+
res.svg = { path: path3.resolve(out.svg), bytes: Buffer.byteLength(svg) };
|
|
365
|
+
}
|
|
366
|
+
return res;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
export { browserVersion, findBrowser, snapshot, parseSource, loadSpec, writeDiagram };
|