twombly 0.1.0 → 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/dist/cli.js +461 -157
- package/dist/ui/assets/Landing-COHWYWgT.js +109 -0
- package/dist/ui/assets/Landing-I1VCX2fy.css +1 -0
- package/dist/ui/assets/LandingStage-D1WT-zxZ.css +1 -0
- package/dist/ui/assets/LandingStage-DQBFsXtF.js +1 -0
- package/dist/ui/assets/agentPrompt-CELnjtMR.js +1 -0
- package/dist/ui/assets/google-sheets-C6gOto98.svg +55 -0
- package/dist/ui/assets/google-slides-B6uumWEX.svg +61 -0
- package/dist/ui/assets/index-C8yZ6_xT.css +1 -0
- package/dist/ui/assets/index-NwhZOePg.js +34 -0
- package/dist/ui/assets/rolldown-runtime-WkwZ5CU-.js +1 -0
- package/dist/ui/favicon.png +0 -0
- package/dist/ui/index.html +5 -3
- package/dist/ui/legal.css +1 -0
- package/dist/ui/privacy.html +2 -1
- package/dist/ui/skill.md +6 -0
- package/dist/ui/terms.html +2 -1
- package/dist/ui/twombly-wordmark.svg +9 -0
- package/package.json +1 -1
- package/dist/ui/assets/index-BHNy3m8-.js +0 -33
- package/dist/ui/assets/index-CL4B1FMQ.css +0 -1
package/dist/cli.js
CHANGED
|
@@ -3,9 +3,9 @@
|
|
|
3
3
|
// src/cli.ts
|
|
4
4
|
import { spawn as spawn2 } from "node:child_process";
|
|
5
5
|
import { randomBytes as randomBytes2 } from "node:crypto";
|
|
6
|
-
import { copyFile, mkdir as
|
|
6
|
+
import { copyFile, mkdir as mkdir7, mkdtemp, readFile as readFile7, rm as rm5, stat as stat5, writeFile as writeFile7 } from "node:fs/promises";
|
|
7
7
|
import { tmpdir } from "node:os";
|
|
8
|
-
import { basename as basename2, extname as extname3, join as
|
|
8
|
+
import { basename as basename2, extname as extname3, join as join9, resolve as resolve2 } from "node:path";
|
|
9
9
|
import { fileURLToPath as fileURLToPath2 } from "node:url";
|
|
10
10
|
|
|
11
11
|
// ../protocol/src/gates.ts
|
|
@@ -17,7 +17,9 @@ var LIMITS = {
|
|
|
17
17
|
scanBytes: 8 * 1024 * 1024,
|
|
18
18
|
titleChars: 120,
|
|
19
19
|
nameChars: 120,
|
|
20
|
-
commentChars: 5e3
|
|
20
|
+
commentChars: 5e3,
|
|
21
|
+
strokePoints: 3e3,
|
|
22
|
+
canvasStrokes: 2e3
|
|
21
23
|
};
|
|
22
24
|
var STATIC_FIX = "Frames are static front-end files only. Delete it from the folder; if the design needs what it provided, use mock data.";
|
|
23
25
|
var FORBIDDEN = [
|
|
@@ -231,8 +233,18 @@ var FRAME_ONLY_MESSAGE = "This frame only opens inside a canvas.";
|
|
|
231
233
|
|
|
232
234
|
// ../protocol/src/index.ts
|
|
233
235
|
var FIDELITY_LEVELS = ["build", "component", "capture", "image"];
|
|
236
|
+
function cleanStroke(input) {
|
|
237
|
+
const s = input;
|
|
238
|
+
if (!s || typeof s !== "object" || typeof s.id !== "string" || !/^[a-z0-9]{8,32}$/.test(s.id)) return null;
|
|
239
|
+
if (typeof s.color !== "string" || !/^#[0-9a-f]{6}$/i.test(s.color)) return null;
|
|
240
|
+
if (typeof s.size !== "number" || !Number.isFinite(s.size) || s.size <= 0 || s.size > 2e3) return null;
|
|
241
|
+
if (!Array.isArray(s.points) || !s.points.length || s.points.length > LIMITS.strokePoints) return null;
|
|
242
|
+
if (!s.points.every((p) => Array.isArray(p) && p.length === 3 && p.every((n) => typeof n === "number" && Number.isFinite(n)))) return null;
|
|
243
|
+
return { id: s.id, frameId: typeof s.frameId === "string" && FRAME_ID.test(s.frameId) ? s.frameId : null, points: s.points, color: s.color, size: s.size };
|
|
244
|
+
}
|
|
234
245
|
var WORKING_MS = 3 * 6e4;
|
|
235
246
|
var WORKING_TAIL_MS = 3e4;
|
|
247
|
+
var FRAME_ID = /^[a-km-z2-9]{8,32}$/;
|
|
236
248
|
var FRAME_GAP = 80;
|
|
237
249
|
function nextPosition(frames, newRow = false) {
|
|
238
250
|
if (!frames.length) return { x: 0, y: 0 };
|
|
@@ -243,7 +255,7 @@ function nextPosition(frames, newRow = false) {
|
|
|
243
255
|
}
|
|
244
256
|
|
|
245
257
|
// ../../skills/twombly/SKILL.md
|
|
246
|
-
var SKILL_default = "---\nname: twombly\ndescription: Lay out design explorations on a shared canvas. Use when asked to \"put this on Twombly\", \"put these on a canvas\", \"lay out these versions\", \"show me all the variations side by side\", or to share explorations with real-time cursors and comments. Works with any framework in any repo.\n---\n\n# Twombly\n\nTwombly shows design work as live frames on an infinite canvas. You build each version, turn it into a folder of static files, and add it. The canvas opens on `localhost`; `init` returns its address. Nothing is online until the person clicks Share. After that the local canvas and the link are one state: everything you add, update or move appears for everyone straight away.\n\nRun commands with `twombly <command>`, or `npx -y twombly <command>` if it isn't installed. Output is JSON.\n\n## The rule that decides everything\n\n**A frame is a folder of static files that runs with no server and no network.** It can use anything in its own folder and nothing else. The browser enforces this: outside requests, form posts and server code all fail, locally and once shared.\n\nThe person's repo may have a database, an API, auth and environment keys. None of that goes in a frame. Your job is to adapt the design so it works without them:\n\n- **Data and APIs:** replace with realistic mock data, written inline or as JSON in the folder.\n- **Auth and providers:** skip login, and mount the design with mock providers and a fake signed-in user.\n- **Environment values:** never include them. Build with none set. Don't copy `.env` files. If the build needs a value to compile, use an obviously fake one.\n- **Analytics and widgets:** remove tag managers, analytics, chat widgets and similar scripts, including any in page templates such as `app.html` or `_document`.\n- **Real customer data:** never copy it from a database, fixture or log. Invent names, emails and numbers. The gates can't detect this, so it's on you. Links are public.\n- **Fonts and images:** copy the files into the folder with relative or root paths. For a hosted font such as Google Fonts, download the files and use `@font-face`. If a font is licensed, tell the person it will be publicly hosted. Leave out images the design doesn't show: a frame is limited to 100 MB.\n- **One screen of a routed app:** a router reads the page's address, which won't match the screen you want. Write a small entry file that mounts that screen with a memory or hash router, instead of trying to reach its route.\n\n## Steps\n\n1. **Start the canvas.** From the project root: `twombly init --name \"Notification Explorations\" --no-open`. Pick a short, specific name. Run it once per canvas. The result's `local` field is the canvas's address.\n2. **Open it beside the chat.** The person should see the canvas next to this conversation, in the app they're already using. Open the `local` address in that app's built-in browser: the Claude desktop app's browser pane, the ChatGPT app's in-app browser, or the built-in browser in Cursor, VS Code or any other editor. Only if the app has none, run `twombly open`, which uses their default browser.\n3. **Build each version** as its own static folder, keeping work under `.twombly/work/<version>` so the person's source stays clean. Use the first approach below that works. Run `twombly working` before each build: the canvas background moves for three minutes so people can see you're at work, and running it again extends that.\n4. **Add it:** `twombly add <folder> --title \"V1 Compact\" --width 1280 --height 800 --level build`, saying which level below it is. Use the design's real size, such as `--width 390 --height 844` for a phone. Use `--height auto` to fit a tall page.\n5. **Read the output.** If `ok` is false, fix every item in `problems` and run `add` again. Each one includes a `fix`. The frame isn't placed until it passes.\n6. **Look at it.** The result has a `screenshot` path. Open it and check the frame shows the design you meant, with the content in the right state. The gates can't tell if a page came out unstyled or on the wrong screen; a common miss is Tailwind classes that were never generated.\n7. **Finish:** run `twombly done` so the background goes still, then tell the person the canvas is open. They click Share when they're ready. The first time, Google sign-in opens in their regular browser, and sharing continues on its own once they finish.\n\n## Choosing how to build a frame\n\nUse the first one that fits. Say so if you end up lower than the first, because the frame is less faithful. **If the app has server routes, auth or a database, skip straight to `component`**: a static export of that kind of app rarely works and isn't worth the attempt.\n\n1. `--level build`: **a real static build.** Vite: `vite build --base ./ --outDir .twombly/work/v1`. Next.js: `output: 'export'` in a temporary config (static export can't use server features). SvelteKit: `adapter-static` with a `fallback`, `ssr = false` in the root layout, and any server `load` swapped for mock data in a universal `+layout.ts`. Astro: `astro build`. Plain HTML: use the folder as is. To show one state of a bigger app, add a build entry that renders just that screen.\n2. `--level component`: **the component on its own.** Write a tiny entry file that imports the real component with mock props, and bundle it with Vite or esbuild into a folder. This is the usual answer when the app needs a backend. It keeps the real code and styles.\n3. `--level capture`: **freeze what's on screen.** With the app already running: `twombly capture http://localhost:3000/page --out .twombly/work/v1 --width 1280 --height 800`, then add that folder. It looks right but no longer responds, because scripts are removed.\n4. `--level image`: **a picture**, as a last resort. `twombly capture <url> --out <folder> --image`, or `twombly add shot.png --title \"V1\"`.\n\nSeveral versions can share one build: read the variant from `location.search` and pass `--entry \"index.html?variant=2\"`. Shared files are only uploaded once.\n\n### Example: the component level with Vite\n\nFor most projects, this is all it takes. It works the same for React, Svelte or Vue with that framework's Vite plugin. Keep it under `.twombly/work`.\n\n```js\n// .twombly/work/vite.config.mjs\nimport { defineConfig } from 'vite';\nimport { resolve } from 'node:path';\nimport react from '@vitejs/plugin-react';\n\nconst root = resolve(import.meta.dirname, '../..');\nexport default defineConfig({\n root: import.meta.dirname,\n base: './',\n plugins: [react()],\n resolve: { alias: { '@': root } }, // match the project's path aliases\n define: { 'process.env': {} },\n build: { outDir: 'out', emptyOutDir: true, sourcemap: false },\n});\n```\n\n```html\n<!-- .twombly/work/index.html -->\n<!doctype html><html><body><div id=\"root\"></div><script type=\"module\" src=\"./main.jsx\"></script></body></html>\n```\n\n```jsx\n// .twombly/work/main.jsx: the real component, the real styles, invented data.\nimport { createRoot } from 'react-dom/client';\nimport '@/styles/globals.css';\nimport { Composer } from '@/components/composer';\n\nconst variant = new URLSearchParams(location.search).get('variant');\ncreateRoot(document.getElementById('root')).render(<Composer variant={variant} onSend={() => {}} messages={MOCK_MESSAGES} />);\n```\n\nThen: `npx vite build --config .twombly/work/vite.config.mjs` and `twombly add .twombly/work/out --title \"A\" --entry \"index.html?variant=a\" --level component`. If the project uses Tailwind, add its Vite or PostCSS plugin to the config so the classes are generated.\n\n## Commands\n\n```sh\ntwombly init [--name \"Name\"] [--no-open] # create or reopen this project's canvas\ntwombly add <folder|file> --title \"T\" [--width 1280] [--height 800|auto] [--entry index.html] [--level build] [--new-row] [--x N --y N]\ntwombly update <frame> <folder|file> [--title \"T\"] [--width N] [--height N|auto] [--entry path] [--level build]\ntwombly check <folder> [--entry index.html] [--width 1280] [--height 800] # run the gates without adding\ntwombly list # frames, positions and the live link\ntwombly move <frame> --x N --y N\ntwombly resize <frame> --width N --height N\ntwombly retitle <frame> \"New title\"\ntwombly remove <frame>\ntwombly arrange [--columns N] # tidy into a grid\ntwombly rename \"Canvas name\"\ntwombly capture <url> --out <folder> [--width N] [--height N] [--image] [--selector css]\ntwombly working # show on the canvas that you're at work, for three minutes\ntwombly done # you've finished: the canvas goes still\ntwombly open # open the canvas in the default browser\ntwombly status # sign-in and canvas state\n```\n\n`<frame>` is a frame's id or its exact title. Frames fill in left to right; use `--new-row` to start a new group, for example one row per direction.\n\nUse `update` to change a version. Don't remove and re-add it, because that loses its comments. Each frame keeps its own copy of its files, so if several frames came from one build, `update` each one.\n\n## Sharing\n\nThe person shares by clicking Share. Only run `twombly share` if they explicitly ask you to, because it publishes the canvas to a public link. If they ask you to take it down, run `twombly unshare`. That deletes the link and its comments and leaves the local canvas.\n\nOnce it's shared, every `add`, `update`, `move` and `remove` goes live immediately. Check your work with `twombly check` before updating a canvas people are looking at.\n";
|
|
258
|
+
var SKILL_default = "---\nname: twombly\ndescription: Lay out design explorations on a shared canvas. Use when asked to \"put this on Twombly\", \"put these on a canvas\", \"lay out these versions\", \"show me all the variations side by side\", or to share explorations with real-time cursors and comments. Works with any framework in any repo.\n---\n\n# Twombly\n\nTwombly shows design work as live frames on an infinite canvas. You build each version, turn it into a folder of static files, and add it. The canvas opens on `localhost`; `init` returns its address. Nothing is online until the person clicks Share. After that the local canvas and the link are one state: everything you add, update or move appears for everyone straight away.\n\nRun every command as `npx -y twombly@latest <command>`; this guide shortens that to `twombly <command>`. Output is JSON.\n\n## The rule that decides everything\n\n**A frame is a folder of static files that runs with no server and no network.** It can use anything in its own folder and nothing else. The browser enforces this: outside requests, form posts and server code all fail, locally and once shared.\n\nThe person's repo may have a database, an API, auth and environment keys. None of that goes in a frame. Your job is to adapt the design so it works without them:\n\n- **Data and APIs:** replace with realistic mock data, written inline or as JSON in the folder. For public CMS content, save a snapshot as JSON when you build.\n- **Auth and providers:** skip login, and mount the design with mock providers and a fake signed-in user.\n- **Environment values:** never include them. Build with none set. Don't copy `.env` files. If the build needs a value to compile, use an obviously fake one. **Never run or read a secret manager such as Infisical or Doppler for this, even if the project is set up for it.** If you can't get what you need without one, ask the person.\n- **Analytics and widgets:** remove tag managers, analytics, chat widgets and similar scripts, including any in page templates such as `app.html` or `_document`.\n- **Real customer data:** never copy it from a database, fixture or log. Invent names, emails and numbers. The gates can't detect this, so it's on you. Links are public.\n- **Fonts, images and video:** copy the files into the folder with relative or root paths. For a hosted font such as Google Fonts, download the files and use `@font-face`; Google only serves `woff2` to a browser user agent. If a font is licensed, tell the person it will be publicly hosted. Add up media sizes before downloading, take only what the design shows, and compress video.\n- **One screen of a routed app:** a router reads the page's address, which won't match the screen you want. Write a small entry file that mounts that screen with a memory or hash router, instead of trying to reach its route.\n\n## Limits\n\n100 MB per frame, 25 MB per file, 5000 files per frame, 200 frames per canvas. A URL can't be added, not even `localhost`: build it or `capture` it. Refused files: `.env`, credentials, keys, databases, source maps, server code, `node_modules` and project files such as `package.json`. Pop-ups, camera and microphone are off.\n\n## Steps\n\n1. **Start the canvas.** From the project root, or in a monorepo from the app's own folder so `.twombly/work` can find its packages: `twombly init --name \"Notification Explorations\" --no-open`. Pick a short, specific name. Run it once per canvas. The result's `local` field is the canvas's address.\n2. **Open it beside the chat.** The person should see the canvas next to this conversation, in the app they're already using. Open the `local` address in that app's built-in browser: the Claude desktop app's browser pane, the ChatGPT app's in-app browser, or the built-in browser in Cursor, VS Code or any other editor. Only if the app has none, run `twombly open`, which uses their default browser.\n3. **Build each version** as its own static folder, keeping work under `.twombly/work/<version>` so the person's source stays clean. Use the first approach below that works. The canvas background moves so people can see you're at work: `init` and the slow commands keep it going for three minutes. Run `twombly working` only before a longer gap.\n4. **Add it:** `twombly add <folder> --title \"V1 Compact\" --width 1280 --height 800 --level build`, saying which level below it is. Use the design's real size, such as `--width 390 --height 844` for a phone. Use `--height auto` to fit a tall page.\n5. **Read the output.** If `ok` is false, fix every item in `problems` and run `add` again. Each one includes a `fix`. The frame isn't placed until it passes.\n6. **Look at it.** The result has a `screenshot` path. Open it and check the frame shows the design you meant, with the content in the right state. The gates can't tell if a page came out unstyled or on the wrong screen; a common miss is Tailwind classes that were never generated. Tailwind 4 skips gitignored folders, so source copied into `.twombly` needs an `@source`.\n7. **Test interactions, if they matter.** Open the `url` from `twombly preview <frame>` in your browser tool and use the page inside its iframe. On the canvas, a frame ignores clicks until it's double-clicked, and frame URLs refuse to open on their own. Tell the person what you didn't test.\n8. **Finish:** run `twombly done` so the background goes still, then tell the person the canvas is open. They click Share when they're ready. The first time, Google sign-in opens in their regular browser, and sharing continues on its own once they finish.\n\n## Choosing how to build a frame\n\nUse the first one that fits. Say so if you end up lower than the first, because the frame is less faithful. **If the app has server routes, auth or a database, skip straight to `component`**: a static export of that kind of app rarely works and isn't worth the attempt.\n\n1. `--level build`: **a real static build.** Vite: `vite build --base ./ --outDir .twombly/work/v1`. Next.js: `output: 'export'` in a temporary config (static export can't use server features). SvelteKit: `adapter-static` with a `fallback`, `ssr = false` in the root layout, and any server `load` swapped for mock data in a universal `+layout.ts`. Astro: `astro build`. Plain HTML: use the folder as is. To show one state of a bigger app, add a build entry that renders just that screen.\n2. `--level component`: **the component on its own.** Write a tiny entry file that imports the real component with mock props, and bundle it with Vite or esbuild into a folder. This is the usual answer when the app needs a backend. It keeps the real code and styles.\n3. `--level capture`: **freeze what's on screen.** With the app already running: `twombly capture http://localhost:3000/page --out .twombly/work/v1 --width 1280 --height 800`, then add that folder. It looks right but no longer responds, because scripts are removed.\n4. `--level image`: **a picture**, as a last resort. `twombly capture <url> --out <folder> --image`, or `twombly add shot.png --title \"V1\"`.\n\nSeveral versions can share one build: read the variant from `location.search` and pass `--entry \"index.html?variant=2\"`. Shared files are only uploaded once.\n\nAdd several frames in one command, which is faster, instead of writing a loop. Nothing is placed unless all of them pass. Each item takes `folder`, `title`, `entry`, `width`, `height`, `level`, `newRow`, `x` and `y`, and flags on the command apply to all:\n\n```sh\ntwombly add out --width 1280 --height 800 --level component --frames '[{\"title\":\"A\",\"entry\":\"index.html?variant=a\"},{\"title\":\"B\",\"entry\":\"index.html?variant=b\"}]'\n```\n\n### Example: the component level with Vite\n\nFor most projects, this is all it takes. It works the same for React, Svelte or Vue with that framework's Vite plugin. Keep it under `.twombly/work`.\n\n```js\n// .twombly/work/vite.config.mjs\nimport { defineConfig } from 'vite';\nimport { resolve } from 'node:path';\nimport react from '@vitejs/plugin-react';\n\nconst root = resolve(import.meta.dirname, '../..');\nexport default defineConfig({\n root: import.meta.dirname,\n base: './',\n plugins: [react()],\n resolve: { alias: { '@': root } }, // match the project's path aliases\n define: { 'process.env': {} },\n build: { outDir: 'out', emptyOutDir: true, sourcemap: false },\n});\n```\n\n```html\n<!-- .twombly/work/index.html -->\n<!doctype html><html><body><div id=\"root\"></div><script type=\"module\" src=\"./main.jsx\"></script></body></html>\n```\n\n```jsx\n// .twombly/work/main.jsx: the real component, the real styles, invented data.\nimport { createRoot } from 'react-dom/client';\nimport '@/styles/globals.css';\nimport { Composer } from '@/components/composer';\n\nconst variant = new URLSearchParams(location.search).get('variant');\ncreateRoot(document.getElementById('root')).render(<Composer variant={variant} onSend={() => {}} messages={MOCK_MESSAGES} />);\n```\n\nThen: `npx vite build --config .twombly/work/vite.config.mjs` and `twombly add .twombly/work/out --title \"A\" --entry \"index.html?variant=a\" --level component`. If the project uses Tailwind, add its Vite or PostCSS plugin to the config so the classes are generated.\n\n## Commands\n\n`twombly help` lists every command and flag. `<frame>` is a frame's id or its exact title. Frames fill in left to right; use `--new-row` to start a new group, for example one row per direction.\n\nUse `update` to change a version. Don't remove and re-add it, because that loses its comments. It keeps the frame's entry and size unless you change them. Each frame keeps its own copy of its files, so after rebuilding, update the set together: `twombly update out --frames '[{\"frame\":\"A\"},{\"frame\":\"B\"}]'`. Nothing changes unless all of them pass.\n\n## Sharing\n\nThe person shares by clicking Share. Only run `twombly share` if they explicitly ask you to, because it publishes the canvas to a public link. If they ask you to take it down, run `twombly unshare`. That deletes the link and its comments and leaves the local canvas.\n\nOnce it's shared, every `add`, `update`, `move` and `remove` goes live immediately. Check your work with `twombly check` before updating a canvas people are looking at.\n";
|
|
247
259
|
|
|
248
260
|
// src/capture.ts
|
|
249
261
|
import { createHash } from "node:crypto";
|
|
@@ -284,10 +296,10 @@ async function capture(url, out, options) {
|
|
|
284
296
|
await writeFile(join(out, "index.html"), `<!doctype html><meta charset="utf-8"><style>html,body{margin:0;background:#fff}img{display:block;max-width:100%;height:auto}</style><img src="assets/capture.png" alt="">`);
|
|
285
297
|
return { folder: out, assets: 1, failed: [] };
|
|
286
298
|
}
|
|
287
|
-
const
|
|
299
|
+
const saved2 = /* @__PURE__ */ new Map(), failed = [];
|
|
288
300
|
const save = async (assetUrl) => {
|
|
289
301
|
if (assetUrl.startsWith("data:") || assetUrl.startsWith("blob:")) return null;
|
|
290
|
-
if (
|
|
302
|
+
if (saved2.has(assetUrl)) return saved2.get(assetUrl);
|
|
291
303
|
try {
|
|
292
304
|
const response = await context.request.get(assetUrl, { timeout: 2e4 });
|
|
293
305
|
if (!response.ok()) throw new Error(String(response.status()));
|
|
@@ -295,7 +307,7 @@ async function capture(url, out, options) {
|
|
|
295
307
|
let bytes = await response.body();
|
|
296
308
|
const hash = createHash("sha256").update(assetUrl).digest("hex").slice(0, 16);
|
|
297
309
|
const name = `assets/${hash}${EXT[type] || extname(new URL(assetUrl).pathname) || ""}`;
|
|
298
|
-
|
|
310
|
+
saved2.set(assetUrl, name);
|
|
299
311
|
if (type === "text/css") bytes = Buffer.from(await rewriteCss(bytes.toString("utf8"), assetUrl, "css"));
|
|
300
312
|
await writeFile(join(out, name), bytes);
|
|
301
313
|
return name;
|
|
@@ -388,7 +400,7 @@ async function capture(url, out, options) {
|
|
|
388
400
|
return "<!doctype html>\n" + document.documentElement.outerHTML;
|
|
389
401
|
}, { replacements, styles, inline });
|
|
390
402
|
await writeFile(join(out, "index.html"), html);
|
|
391
|
-
return { folder: out, assets:
|
|
403
|
+
return { folder: out, assets: saved2.size, failed };
|
|
392
404
|
} finally {
|
|
393
405
|
await browser.close().catch(() => {
|
|
394
406
|
});
|
|
@@ -400,8 +412,7 @@ import { createHash as createHash3, randomBytes } from "node:crypto";
|
|
|
400
412
|
import { createReadStream as createReadStream2 } from "node:fs";
|
|
401
413
|
import { readFile as readFile5, stat as stat3 } from "node:fs/promises";
|
|
402
414
|
import { createServer as createServer2 } from "node:http";
|
|
403
|
-
import {
|
|
404
|
-
import { fileURLToPath } from "node:url";
|
|
415
|
+
import { extname as extname2, join as join8 } from "node:path";
|
|
405
416
|
import { WebSocketServer, WebSocket } from "ws";
|
|
406
417
|
|
|
407
418
|
// src/credentials.ts
|
|
@@ -545,36 +556,125 @@ async function startFrameServer(folder, poster) {
|
|
|
545
556
|
} };
|
|
546
557
|
}
|
|
547
558
|
|
|
559
|
+
// src/interface.ts
|
|
560
|
+
import { createPublicKey, verify } from "node:crypto";
|
|
561
|
+
import { mkdir as mkdir3, readdir, rename, rm as rm3, writeFile as writeFile3 } from "node:fs/promises";
|
|
562
|
+
import { dirname, join as join5 } from "node:path";
|
|
563
|
+
import { fileURLToPath } from "node:url";
|
|
564
|
+
|
|
565
|
+
// src/release.ts
|
|
566
|
+
var INTERFACE_VERSION = 1;
|
|
567
|
+
var INTERFACE_PUBLIC_KEY = "MCowBQYDK2VwAyEA2/JCcP+q2Ht/fjTqlMvBzwrUS07UAXNp1z6bqY0hG9U=";
|
|
568
|
+
|
|
569
|
+
// src/interface.ts
|
|
570
|
+
var OVERRIDE = process.env.TWOMBLY_UI;
|
|
571
|
+
var UPDATES = !OVERRIDE && process.env.TWOMBLY_NO_INTERFACE_UPDATES !== "1";
|
|
572
|
+
var BUNDLED = join5(dirname(fileURLToPath(import.meta.url)), "ui");
|
|
573
|
+
var STORE = join5(HOME, "interface", String(INTERFACE_VERSION));
|
|
574
|
+
var PUBLIC_KEY = createPublicKey({ key: Buffer.from(process.env.TWOMBLY_INTERFACE_KEY || INTERFACE_PUBLIC_KEY, "base64"), format: "der", type: "spki" });
|
|
575
|
+
var KEEP = 3;
|
|
576
|
+
var CHECK_MS = 36e5;
|
|
577
|
+
var CHECK_ON_OPEN_MS = 5 * 6e4;
|
|
578
|
+
var WAIT_ON_OPEN_MS = 600;
|
|
579
|
+
var DOWNLOAD_MS = 3e4;
|
|
580
|
+
var MAX_CHARS = 64 * 1024 * 1024;
|
|
581
|
+
var SAFE_PATH = /^[\w.@-]+(\/[\w.@-]+)*$/;
|
|
582
|
+
var saved = [];
|
|
583
|
+
var etag = "";
|
|
584
|
+
var checking = null;
|
|
585
|
+
var checkedAt = 0;
|
|
586
|
+
function interfaceFolders() {
|
|
587
|
+
if (OVERRIDE) return [OVERRIDE];
|
|
588
|
+
const folder = (released) => join5(STORE, String(released));
|
|
589
|
+
return [...saved.filter((released) => released > 1793896544071).map(folder), BUNDLED, ...saved.filter((released) => released <= 1793896544071).map(folder)];
|
|
590
|
+
}
|
|
591
|
+
function unpack(body) {
|
|
592
|
+
const { payload, signature } = JSON.parse(body);
|
|
593
|
+
if (typeof payload !== "string" || typeof signature !== "string") return null;
|
|
594
|
+
if (!verify(null, Buffer.from(payload), PUBLIC_KEY, Buffer.from(signature, "base64"))) return null;
|
|
595
|
+
const release = JSON.parse(payload);
|
|
596
|
+
if (release.interface !== INTERFACE_VERSION || !Number.isSafeInteger(release.released) || !release.files?.["index.html"]) return null;
|
|
597
|
+
const safe = Object.entries(release.files).every(([path, bytes]) => SAFE_PATH.test(path) && !path.split("/").includes("..") && typeof bytes === "string");
|
|
598
|
+
return safe ? release : null;
|
|
599
|
+
}
|
|
600
|
+
async function install(released, files) {
|
|
601
|
+
const temporary = join5(STORE, `.${released}-${process.pid}`);
|
|
602
|
+
try {
|
|
603
|
+
for (const [path, bytes] of Object.entries(files)) {
|
|
604
|
+
await mkdir3(dirname(join5(temporary, path)), { recursive: true });
|
|
605
|
+
await writeFile3(join5(temporary, path), Buffer.from(bytes, "base64"));
|
|
606
|
+
}
|
|
607
|
+
await rename(temporary, join5(STORE, String(released)));
|
|
608
|
+
} catch (error) {
|
|
609
|
+
await rm3(temporary, { recursive: true, force: true });
|
|
610
|
+
throw error;
|
|
611
|
+
}
|
|
612
|
+
saved = [released, ...saved].sort((a, b) => b - a);
|
|
613
|
+
for (const old of saved.splice(KEEP)) await rm3(join5(STORE, String(old)), { recursive: true, force: true });
|
|
614
|
+
}
|
|
615
|
+
async function check() {
|
|
616
|
+
const response = await fetch(`${ENDPOINT}/interface/${INTERFACE_VERSION}`, { headers: etag ? { "if-none-match": etag } : {}, signal: AbortSignal.timeout(DOWNLOAD_MS) });
|
|
617
|
+
if (response.status !== 200) return;
|
|
618
|
+
const body = await response.text();
|
|
619
|
+
if (body.length > MAX_CHARS) return;
|
|
620
|
+
const release = unpack(body);
|
|
621
|
+
if (!release) return;
|
|
622
|
+
etag = response.headers.get("etag") || "";
|
|
623
|
+
if (release.released > Math.max(saved[0] ?? 0, 1793896544071)) await install(release.released, release.files);
|
|
624
|
+
}
|
|
625
|
+
function startCheck() {
|
|
626
|
+
if (checking) return;
|
|
627
|
+
checkedAt = Date.now();
|
|
628
|
+
checking = check().catch(() => {
|
|
629
|
+
}).finally(() => {
|
|
630
|
+
checking = null;
|
|
631
|
+
});
|
|
632
|
+
}
|
|
633
|
+
async function freshInterface() {
|
|
634
|
+
if (!UPDATES) return;
|
|
635
|
+
if (Date.now() - checkedAt > CHECK_ON_OPEN_MS) startCheck();
|
|
636
|
+
if (checking) await Promise.race([checking, new Promise((done) => setTimeout(done, WAIT_ON_OPEN_MS))]);
|
|
637
|
+
}
|
|
638
|
+
async function startInterfaceUpdates() {
|
|
639
|
+
if (!UPDATES) return;
|
|
640
|
+
await mkdir3(STORE, { recursive: true });
|
|
641
|
+
const names = await readdir(STORE);
|
|
642
|
+
for (const name of names) if (name.startsWith(".")) await rm3(join5(STORE, name), { recursive: true, force: true });
|
|
643
|
+
saved = names.filter((name) => /^\d+$/.test(name)).map(Number).sort((a, b) => b - a);
|
|
644
|
+
startCheck();
|
|
645
|
+
setInterval(startCheck, CHECK_MS).unref();
|
|
646
|
+
}
|
|
647
|
+
|
|
548
648
|
// src/project.ts
|
|
549
649
|
import { createHash as createHash2 } from "node:crypto";
|
|
550
|
-
import { cp, mkdir as
|
|
551
|
-
import { basename, dirname, join as
|
|
650
|
+
import { cp, mkdir as mkdir4, readdir as readdir2, readFile as readFile3, rename as rename2, rm as rm4, stat as stat2, writeFile as writeFile4 } from "node:fs/promises";
|
|
651
|
+
import { basename, dirname as dirname2, join as join6, relative, resolve, sep } from "node:path";
|
|
552
652
|
var DIR = ".twombly";
|
|
553
653
|
var exists = (path) => stat2(path).then(() => true, () => false);
|
|
554
654
|
async function findProject(from) {
|
|
555
655
|
let dir = resolve(from);
|
|
556
656
|
while (true) {
|
|
557
|
-
if (await exists(
|
|
558
|
-
const up =
|
|
657
|
+
if (await exists(join6(dir, DIR, "canvas.json"))) return dir;
|
|
658
|
+
const up = dirname2(dir);
|
|
559
659
|
if (up === dir) return null;
|
|
560
660
|
dir = up;
|
|
561
661
|
}
|
|
562
662
|
}
|
|
563
|
-
var canvasFile = (project) =>
|
|
564
|
-
var frameDir = (project, id) =>
|
|
565
|
-
var posterFile = (project, id) =>
|
|
663
|
+
var canvasFile = (project) => join6(project, DIR, "canvas.json");
|
|
664
|
+
var frameDir = (project, id) => join6(project, DIR, "frames", id);
|
|
665
|
+
var posterFile = (project, id) => join6(project, DIR, "posters", `${id}.png`);
|
|
566
666
|
async function readCanvas(project) {
|
|
567
667
|
return JSON.parse(await readFile3(canvasFile(project), "utf8"));
|
|
568
668
|
}
|
|
569
669
|
async function writeCanvas(project, canvas) {
|
|
570
670
|
const path = canvasFile(project), temp = `${path}.${process.pid}.tmp`;
|
|
571
|
-
await
|
|
572
|
-
await
|
|
671
|
+
await writeFile4(temp, JSON.stringify(canvas, null, 2) + "\n");
|
|
672
|
+
await rename2(temp, path);
|
|
573
673
|
}
|
|
574
674
|
async function ensureProjectDir(project) {
|
|
575
|
-
await
|
|
576
|
-
await
|
|
577
|
-
await
|
|
675
|
+
await mkdir4(join6(project, DIR, "frames"), { recursive: true });
|
|
676
|
+
await mkdir4(join6(project, DIR, "posters"), { recursive: true });
|
|
677
|
+
await writeFile4(join6(project, DIR, ".gitignore"), "*\n");
|
|
578
678
|
}
|
|
579
679
|
function defaultName(project) {
|
|
580
680
|
return basename(project).replace(/[-_]+/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()) || "Untitled";
|
|
@@ -582,8 +682,8 @@ function defaultName(project) {
|
|
|
582
682
|
async function hashFolder(folder) {
|
|
583
683
|
const out = [];
|
|
584
684
|
const walk = async (dir) => {
|
|
585
|
-
for (const entry of await
|
|
586
|
-
const absolute =
|
|
685
|
+
for (const entry of await readdir2(dir, { withFileTypes: true })) {
|
|
686
|
+
const absolute = join6(dir, entry.name);
|
|
587
687
|
if (entry.isDirectory()) await walk(absolute);
|
|
588
688
|
else if (entry.isFile()) {
|
|
589
689
|
if (entry.name === ".DS_Store") continue;
|
|
@@ -601,13 +701,13 @@ async function folderContent(folder) {
|
|
|
601
701
|
}
|
|
602
702
|
async function replaceFolder(source, target) {
|
|
603
703
|
const temp = `${target}.${process.pid}.tmp`;
|
|
604
|
-
await
|
|
605
|
-
await
|
|
704
|
+
await rm4(temp, { recursive: true, force: true });
|
|
705
|
+
await mkdir4(dirname2(target), { recursive: true });
|
|
606
706
|
await cp(source, temp, { recursive: true, verbatimSymlinks: false, filter: (path) => basename(path) !== ".DS_Store" });
|
|
607
|
-
await
|
|
608
|
-
await
|
|
707
|
+
await rm4(target, { recursive: true, force: true });
|
|
708
|
+
await rename2(temp, target);
|
|
609
709
|
}
|
|
610
|
-
var registryFile =
|
|
710
|
+
var registryFile = join6(HOME, "registry.json");
|
|
611
711
|
async function readRegistry() {
|
|
612
712
|
try {
|
|
613
713
|
return JSON.parse(await readFile3(registryFile, "utf8"));
|
|
@@ -619,27 +719,26 @@ async function register(id, project) {
|
|
|
619
719
|
const registry = await readRegistry();
|
|
620
720
|
if (registry[id] === project) return;
|
|
621
721
|
registry[id] = project;
|
|
622
|
-
await
|
|
623
|
-
await
|
|
722
|
+
await mkdir4(HOME, { recursive: true });
|
|
723
|
+
await writeFile4(registryFile, JSON.stringify(registry, null, 2));
|
|
624
724
|
}
|
|
625
725
|
|
|
626
726
|
// src/settings.ts
|
|
627
|
-
import { mkdir as
|
|
628
|
-
import { join as
|
|
629
|
-
var FILE =
|
|
727
|
+
import { mkdir as mkdir5, readFile as readFile4, rename as rename3, writeFile as writeFile5 } from "node:fs/promises";
|
|
728
|
+
import { join as join7 } from "node:path";
|
|
729
|
+
var FILE = join7(HOME, "settings.json");
|
|
630
730
|
async function readSettings() {
|
|
631
|
-
const
|
|
632
|
-
return { invertScroll:
|
|
731
|
+
const saved2 = await readFile4(FILE, "utf8").then(JSON.parse).catch(() => null);
|
|
732
|
+
return { invertScroll: saved2?.invertScroll === true };
|
|
633
733
|
}
|
|
634
734
|
async function writeSettings(settings2) {
|
|
635
|
-
await
|
|
735
|
+
await mkdir5(HOME, { recursive: true });
|
|
636
736
|
const temporary = `${FILE}.${process.pid}`;
|
|
637
|
-
await
|
|
638
|
-
await
|
|
737
|
+
await writeFile5(temporary, JSON.stringify(settings2));
|
|
738
|
+
await rename3(temporary, FILE);
|
|
639
739
|
}
|
|
640
740
|
|
|
641
741
|
// src/daemon.ts
|
|
642
|
-
var UI = process.env.TWOMBLY_UI || join7(dirname2(fileURLToPath(import.meta.url)), "ui");
|
|
643
742
|
var IDLE_MS = 6 * 36e5;
|
|
644
743
|
var LOGIN_MS = 10 * 6e4;
|
|
645
744
|
var ID_ALPHABET = "abcdefghijkmnpqrstuvwxyz23456789";
|
|
@@ -682,6 +781,7 @@ async function edge(path, init = {}) {
|
|
|
682
781
|
if (!response.ok) throw new Failure(response.status, data.error || `The server answered ${response.status}`, data.problems ? { problems: data.problems } : void 0);
|
|
683
782
|
return data;
|
|
684
783
|
}
|
|
784
|
+
var STROKE_UPLOAD = 40;
|
|
685
785
|
var Canvas = class {
|
|
686
786
|
id;
|
|
687
787
|
project;
|
|
@@ -782,7 +882,7 @@ var Canvas = class {
|
|
|
782
882
|
t: "hello",
|
|
783
883
|
self,
|
|
784
884
|
role: "owner",
|
|
785
|
-
doc: { id: this.id, name: this.data.name, frames },
|
|
885
|
+
doc: { id: this.id, name: this.data.name, frames, strokes: this.data.strokes ?? [] },
|
|
786
886
|
threads: [],
|
|
787
887
|
peers: [this.peer(self)],
|
|
788
888
|
capabilities: { comments: false, huddle: false },
|
|
@@ -815,6 +915,22 @@ var Canvas = class {
|
|
|
815
915
|
this.save();
|
|
816
916
|
return;
|
|
817
917
|
}
|
|
918
|
+
if (m.t === "stroke.add") {
|
|
919
|
+
const strokes = this.data.strokes ??= [];
|
|
920
|
+
const clean = cleanStroke(m.stroke);
|
|
921
|
+
if (!clean || strokes.length >= LIMITS.canvasStrokes && !strokes.some((item) => item.id === clean.id)) return this.broadcast({ t: "stroke.remove", id: String(m.stroke?.id) });
|
|
922
|
+
const stroke = { ...clean, authorId: "local", createdAt: Date.now() };
|
|
923
|
+
this.data.strokes = [...strokes.filter((item) => item.id !== stroke.id), stroke];
|
|
924
|
+
this.broadcast({ t: "strokes", strokes: [stroke] });
|
|
925
|
+
this.save();
|
|
926
|
+
return;
|
|
927
|
+
}
|
|
928
|
+
if (m.t === "stroke.remove") {
|
|
929
|
+
this.data.strokes = (this.data.strokes ?? []).filter((item) => item.id !== m.id);
|
|
930
|
+
this.broadcast({ t: "stroke.remove", id: m.id });
|
|
931
|
+
this.save();
|
|
932
|
+
return;
|
|
933
|
+
}
|
|
818
934
|
if (m.t === "canvas.rename") {
|
|
819
935
|
const name = String(m.name || "").trim().slice(0, LIMITS.nameChars);
|
|
820
936
|
if (name) {
|
|
@@ -852,6 +968,7 @@ var Canvas = class {
|
|
|
852
968
|
throw error;
|
|
853
969
|
}
|
|
854
970
|
this.data.name = doc.name;
|
|
971
|
+
if (doc.strokes) this.data.strokes = doc.strokes;
|
|
855
972
|
const known = new Map(this.data.frames.map((frame) => [frame.id, frame]));
|
|
856
973
|
this.data.frames = doc.frames.map((frame) => ({
|
|
857
974
|
id: frame.id,
|
|
@@ -910,6 +1027,10 @@ var Canvas = class {
|
|
|
910
1027
|
this.event({ t: "share-progress", done: ++done, total });
|
|
911
1028
|
});
|
|
912
1029
|
}
|
|
1030
|
+
const strokes = this.data.strokes ?? [];
|
|
1031
|
+
for (let i = 0; i < strokes.length; i += STROKE_UPLOAD) {
|
|
1032
|
+
await edge(`/api/canvases/${created.id}/strokes`, { method: "POST", body: JSON.stringify({ strokes: strokes.slice(i, i + STROKE_UPLOAD) }) });
|
|
1033
|
+
}
|
|
913
1034
|
} catch (error) {
|
|
914
1035
|
await edge(`/api/canvases/${created.id}`, { method: "DELETE" }).catch(() => {
|
|
915
1036
|
});
|
|
@@ -1006,7 +1127,7 @@ async function command(name, input) {
|
|
|
1006
1127
|
found = project;
|
|
1007
1128
|
}
|
|
1008
1129
|
const canvas2 = await canvasForProject(found);
|
|
1009
|
-
if (input.rename && input.name && input.name !== canvas2.data.name) await
|
|
1130
|
+
if (input.rename && input.name && input.name !== canvas2.data.name) await rename4(canvas2, input.name);
|
|
1010
1131
|
if (input.open !== false && !canvas2.watchers) openBrowser(`${DAEMON_ORIGIN}/${canvas2.id}`);
|
|
1011
1132
|
canvas2.working(WORKING_MS);
|
|
1012
1133
|
return summary(canvas2);
|
|
@@ -1021,6 +1142,10 @@ async function command(name, input) {
|
|
|
1021
1142
|
case "open":
|
|
1022
1143
|
openBrowser(`${DAEMON_ORIGIN}/${canvas.id}`);
|
|
1023
1144
|
return summary(canvas);
|
|
1145
|
+
case "preview": {
|
|
1146
|
+
const { id, title, w, h } = findFrame(canvas, input.frame);
|
|
1147
|
+
return { frame: { id, title, w, h }, url: `${DAEMON_ORIGIN}/preview/${canvas.id}/${id}` };
|
|
1148
|
+
}
|
|
1024
1149
|
case "place":
|
|
1025
1150
|
return place(canvas, input);
|
|
1026
1151
|
case "revise":
|
|
@@ -1054,7 +1179,7 @@ async function command(name, input) {
|
|
|
1054
1179
|
return summary(canvas);
|
|
1055
1180
|
}
|
|
1056
1181
|
case "rename":
|
|
1057
|
-
await
|
|
1182
|
+
await rename4(canvas, input.name);
|
|
1058
1183
|
return summary(canvas);
|
|
1059
1184
|
case "share": {
|
|
1060
1185
|
if (!credential) throw new Failure(401, "The person needs to sign in first. Ask them to click Share on the canvas.");
|
|
@@ -1069,7 +1194,7 @@ async function command(name, input) {
|
|
|
1069
1194
|
}
|
|
1070
1195
|
});
|
|
1071
1196
|
}
|
|
1072
|
-
async function
|
|
1197
|
+
async function rename4(canvas, name) {
|
|
1073
1198
|
const clean = String(name || "").trim().slice(0, LIMITS.nameChars);
|
|
1074
1199
|
if (!clean) throw new Failure(400, "Expected a name");
|
|
1075
1200
|
if (canvas.data.remote) await edge(`/api/canvases/${canvas.data.remote.id}`, { method: "PATCH", body: JSON.stringify({ name: clean }) });
|
|
@@ -1167,9 +1292,20 @@ var APP_CSP = [
|
|
|
1167
1292
|
].join("; ");
|
|
1168
1293
|
async function serveUi(response, pathname) {
|
|
1169
1294
|
const clean = pathname.replace(/^\/+/, "");
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1295
|
+
let file2 = null;
|
|
1296
|
+
if (clean && !clean.includes("..")) {
|
|
1297
|
+
for (const folder of interfaceFolders()) {
|
|
1298
|
+
const candidate = join8(folder, clean);
|
|
1299
|
+
if ((await stat3(candidate).catch(() => null))?.isFile()) {
|
|
1300
|
+
file2 = candidate;
|
|
1301
|
+
break;
|
|
1302
|
+
}
|
|
1303
|
+
}
|
|
1304
|
+
}
|
|
1305
|
+
if (!file2) {
|
|
1306
|
+
await freshInterface();
|
|
1307
|
+
file2 = join8(interfaceFolders()[0], "index.html");
|
|
1308
|
+
}
|
|
1173
1309
|
const type = contentType(file2);
|
|
1174
1310
|
const headers = { "content-type": type, "x-content-type-options": "nosniff", "cache-control": extname2(file2) === ".html" ? "no-cache" : "public, max-age=3600" };
|
|
1175
1311
|
if (type.startsWith("text/html")) {
|
|
@@ -1179,10 +1315,23 @@ async function serveUi(response, pathname) {
|
|
|
1179
1315
|
response.writeHead(200, headers);
|
|
1180
1316
|
createReadStream2(file2).pipe(response);
|
|
1181
1317
|
}
|
|
1318
|
+
var escapeHtml = (text2) => text2.replace(/[&<>"]/g, (c) => `&#${c.charCodeAt(0)};`);
|
|
1319
|
+
async function servePreview(response, canvasId, frameId) {
|
|
1320
|
+
const canvas = await canvasById(canvasId);
|
|
1321
|
+
const frame = await canvas.toFrame(findFrame(canvas, frameId));
|
|
1322
|
+
response.writeHead(200, {
|
|
1323
|
+
"content-type": "text/html; charset=utf-8",
|
|
1324
|
+
"cache-control": "no-store",
|
|
1325
|
+
"referrer-policy": "no-referrer",
|
|
1326
|
+
"content-security-policy": "default-src 'none'; style-src 'unsafe-inline'; frame-src http://localhost:*; base-uri 'none'; frame-ancestors 'none'"
|
|
1327
|
+
});
|
|
1328
|
+
const title = escapeHtml(frame.title);
|
|
1329
|
+
response.end(`<!doctype html><meta charset="utf-8"><title>${title}</title><style>html,body{margin:0;background:#fff}iframe{display:block;border:0;width:${frame.w}px;height:${frame.h}px}</style><iframe src="${escapeHtml(frame.url)}" title="${title}" sandbox="${FRAME_SANDBOX}" referrerpolicy="no-referrer" allow=""></iframe>`);
|
|
1330
|
+
}
|
|
1182
1331
|
async function route(request, response) {
|
|
1183
1332
|
const url = new URL(request.url || "/", DAEMON_ORIGIN);
|
|
1184
1333
|
const path = url.pathname, method = request.method || "GET";
|
|
1185
|
-
if (path === "/health") return reply(response, 200, { ok: true, build: "0.
|
|
1334
|
+
if (path === "/health") return reply(response, 200, { ok: true, build: "0.2.0+1793896544071", pid: process.pid });
|
|
1186
1335
|
if (path.startsWith("/cli/") && method === "POST") return reply(response, 200, await command(path.slice(5), await readBody(request)));
|
|
1187
1336
|
if (path === "/auth/callback" && method === "GET") {
|
|
1188
1337
|
const code = url.searchParams.get("code"), state = url.searchParams.get("state");
|
|
@@ -1233,6 +1382,8 @@ async function route(request, response) {
|
|
|
1233
1382
|
}
|
|
1234
1383
|
if (path.startsWith("/api/")) return reply(response, 404, { error: "Not found" });
|
|
1235
1384
|
if (method !== "GET" && method !== "HEAD") return reply(response, 405, { error: "Method not allowed" });
|
|
1385
|
+
const preview = path.match(/^\/preview\/([a-z0-9]+)\/([a-z0-9]+)$/);
|
|
1386
|
+
if (preview) return servePreview(response, preview[1], preview[2]);
|
|
1236
1387
|
return serveUi(response, path);
|
|
1237
1388
|
}
|
|
1238
1389
|
var idleTimer = null;
|
|
@@ -1286,17 +1437,19 @@ async function runDaemon() {
|
|
|
1286
1437
|
server.listen(PORT, "127.0.0.1", resolve3);
|
|
1287
1438
|
});
|
|
1288
1439
|
touch();
|
|
1440
|
+
startInterfaceUpdates().catch(() => {
|
|
1441
|
+
});
|
|
1289
1442
|
}
|
|
1290
1443
|
|
|
1291
1444
|
// src/gates.ts
|
|
1292
1445
|
import { createServer as createServer3 } from "node:http";
|
|
1293
|
-
import { mkdir as
|
|
1446
|
+
import { mkdir as mkdir6, readFile as readFile6, stat as stat4, writeFile as writeFile6 } from "node:fs/promises";
|
|
1294
1447
|
import { dirname as dirname3 } from "node:path";
|
|
1295
1448
|
var dedupe = (problems) => [...new Map(problems.map((p) => [`${p.code}:${p.file || ""}:${p.message}`, p])).values()];
|
|
1296
|
-
async function
|
|
1449
|
+
async function scanFolder(folder) {
|
|
1297
1450
|
const problems = [];
|
|
1298
1451
|
const info = await stat4(folder).catch(() => null);
|
|
1299
|
-
if (!info?.isDirectory()) return { problems: [{ code: "NOT_A_FOLDER", message: `"${folder}" is not a folder.`, fix: "Point at a folder of built static files." }], files:
|
|
1452
|
+
if (!info?.isDirectory()) return { problems: [{ code: "NOT_A_FOLDER", message: `"${folder}" is not a folder.`, fix: "Point at a folder of built static files." }], files: [], bytes: 0 };
|
|
1300
1453
|
const files = await hashFolder(folder);
|
|
1301
1454
|
let bytes = 0;
|
|
1302
1455
|
for (const file2 of files) {
|
|
@@ -1314,13 +1467,26 @@ async function staticGates(folder, entry) {
|
|
|
1314
1467
|
const largest = [...files].sort((a, b) => b.size - a.size).slice(0, 5).map((file2) => `${file2.path} (${(file2.size / 1048576).toFixed(1)} MB)`).join(", ");
|
|
1315
1468
|
problems.push({ code: "FRAME_TOO_LARGE", message: `${(bytes / 1048576).toFixed(1)} MB in total; the limit is ${LIMITS.frameBytes / 1048576} MB. Largest: ${largest}.`, fix: "Remove what this design doesn't show, and compress the rest." });
|
|
1316
1469
|
}
|
|
1470
|
+
return { problems, files, bytes };
|
|
1471
|
+
}
|
|
1472
|
+
function entryProblems(files, entry) {
|
|
1317
1473
|
const entryPath = entry.split(/[?#]/)[0];
|
|
1318
1474
|
const candidates = candidatePaths("/" + entryPath);
|
|
1319
|
-
if (
|
|
1320
|
-
return {
|
|
1475
|
+
if (files.some((file2) => candidates.includes(file2.path))) return [];
|
|
1476
|
+
return [{ code: "NO_ENTRY", message: `"${entryPath}" is not in the folder.`, fix: "Build to a folder with an index.html at its top level, or pass --entry." }];
|
|
1477
|
+
}
|
|
1478
|
+
async function findReferences(folder, needle) {
|
|
1479
|
+
const found = [];
|
|
1480
|
+
for (const file2 of await hashFolder(folder)) {
|
|
1481
|
+
const bytes = await readFile6(file2.absolute);
|
|
1482
|
+
if (looksLikeText(bytes) && bytes.includes(needle)) found.push(file2.path);
|
|
1483
|
+
if (found.length === 3) break;
|
|
1484
|
+
}
|
|
1485
|
+
return found;
|
|
1321
1486
|
}
|
|
1322
|
-
async function browserGates(folder, options) {
|
|
1487
|
+
async function browserGates(folder, options, launch) {
|
|
1323
1488
|
const problems = [], warnings = [];
|
|
1489
|
+
const traces = /* @__PURE__ */ new Map();
|
|
1324
1490
|
const server = await startFrameServer(folder);
|
|
1325
1491
|
const width = options.width, height = options.height === "auto" ? 800 : options.height;
|
|
1326
1492
|
const harness = createServer3((_, response) => {
|
|
@@ -1331,7 +1497,7 @@ async function browserGates(folder, options) {
|
|
|
1331
1497
|
const harnessOrigin = `http://localhost:${harness.address().port}`;
|
|
1332
1498
|
let browser;
|
|
1333
1499
|
try {
|
|
1334
|
-
browser = await
|
|
1500
|
+
browser = await launch();
|
|
1335
1501
|
} catch {
|
|
1336
1502
|
harness.close();
|
|
1337
1503
|
server.close();
|
|
@@ -1347,7 +1513,9 @@ async function browserGates(folder, options) {
|
|
|
1347
1513
|
host = new URL(url).host;
|
|
1348
1514
|
} catch {
|
|
1349
1515
|
}
|
|
1350
|
-
|
|
1516
|
+
const problem = { code: "EXTERNAL_REQUEST", message: `The frame tried to reach ${host} (${url.slice(0, 160)}).`, fix: /google-analytics|googletagmanager|clarity\.ms|segment\.|sentry|hotjar|posthog|plausible|intercom|mixpanel|amplitude/i.test(url) ? "That is analytics or a third-party widget, which a frame doesn't need. Delete the tag or skip loading it in this build." : /fonts\.(googleapis|gstatic)|typekit|fontshare|fonts\.bunny/i.test(url) ? "Download the font files into the folder and load them with @font-face and a local path." : "Frames can only use their own files. For an asset, copy it into the folder and use a relative path. For an API call, replace it with mock data. For a tag or widget in the page template, delete it." };
|
|
1517
|
+
problems.push(problem);
|
|
1518
|
+
traces.set(problem, host);
|
|
1351
1519
|
};
|
|
1352
1520
|
page.on("pageerror", (error) => {
|
|
1353
1521
|
problems.push({ code: "UNCAUGHT_ERROR", message: `The page threw: ${String(error.message).slice(0, 400)}`, fix: "Usually a missing provider, env value or API response, or a router reading the file path as a route. Mount the design with mock props and data, and use a memory or hash router." });
|
|
@@ -1368,7 +1536,9 @@ async function browserGates(folder, options) {
|
|
|
1368
1536
|
const url = response.url();
|
|
1369
1537
|
if (!isFrameOrigin(url) || response.status() < 400 || url.endsWith("/favicon.ico")) return;
|
|
1370
1538
|
const path = new URL(url).pathname;
|
|
1371
|
-
|
|
1539
|
+
const problem = { code: "MISSING_FILE", message: `The frame asked for ${path} and it isn't in the folder (${response.status()}).`, fix: /^\/(api|graphql|trpc|auth)(\/|$)/.test(path) ? "That is a server route, and frames have no server. Replace the call with mock data." : "Copy the file into the folder, or remove the reference. If it is an API call, replace it with mock data.", file: path.slice(1) };
|
|
1540
|
+
problems.push(problem);
|
|
1541
|
+
traces.set(problem, path.slice(1));
|
|
1372
1542
|
});
|
|
1373
1543
|
page.on("requestfailed", (request) => {
|
|
1374
1544
|
const url = request.url();
|
|
@@ -1401,8 +1571,8 @@ async function browserGates(folder, options) {
|
|
|
1401
1571
|
const shot = await page.locator("#frame").screenshot({ type: "png", timeout: 15e3 }).catch(() => null);
|
|
1402
1572
|
if (shot) {
|
|
1403
1573
|
if (options.poster) {
|
|
1404
|
-
await
|
|
1405
|
-
await
|
|
1574
|
+
await mkdir6(dirname3(options.poster), { recursive: true });
|
|
1575
|
+
await writeFile6(options.poster, shot);
|
|
1406
1576
|
}
|
|
1407
1577
|
const colors = await page.evaluate(async (base64) => {
|
|
1408
1578
|
const image = new Image();
|
|
@@ -1423,40 +1593,79 @@ async function browserGates(folder, options) {
|
|
|
1423
1593
|
if (content && content.width > width * 1.02) warnings.push(`The page is ${content.width}px wide and the frame is ${width}px; it overflows sideways.`);
|
|
1424
1594
|
await context.close();
|
|
1425
1595
|
const unique = dedupe(problems);
|
|
1596
|
+
for (const problem of unique) {
|
|
1597
|
+
const trace = traces.get(problem);
|
|
1598
|
+
const references = trace ? await findReferences(folder, trace) : [];
|
|
1599
|
+
if (references.length) problem.message += ` Mentioned in ${references.join(", ")}.`;
|
|
1600
|
+
}
|
|
1426
1601
|
return { ok: unique.length === 0, problems: unique, warnings: [...new Set(warnings)], content, height: finalHeight, screenshot: options.poster };
|
|
1427
1602
|
} finally {
|
|
1428
|
-
await browser.close().catch(() => {
|
|
1429
|
-
});
|
|
1430
1603
|
harness.closeAllConnections();
|
|
1431
1604
|
harness.close();
|
|
1432
1605
|
server.close();
|
|
1433
1606
|
}
|
|
1434
1607
|
}
|
|
1608
|
+
function gateSession() {
|
|
1609
|
+
const scans = /* @__PURE__ */ new Map();
|
|
1610
|
+
let browser = null;
|
|
1611
|
+
const launch = () => browser ??= launchBrowser();
|
|
1612
|
+
return {
|
|
1613
|
+
async run(folder, options) {
|
|
1614
|
+
if (!scans.has(folder)) scans.set(folder, scanFolder(folder));
|
|
1615
|
+
const scan = await scans.get(folder);
|
|
1616
|
+
const size2 = { files: scan.files.length, bytes: scan.bytes };
|
|
1617
|
+
const notFolder = scan.problems.some((problem) => problem.code === "NOT_A_FOLDER");
|
|
1618
|
+
const problems = notFolder ? scan.problems : [...scan.problems, ...entryProblems(scan.files, options.entry)];
|
|
1619
|
+
if (problems.length) return { ok: false, problems: dedupe(problems), warnings: [], ...size2 };
|
|
1620
|
+
return { ...await browserGates(folder, options, launch), ...size2 };
|
|
1621
|
+
},
|
|
1622
|
+
close: async () => {
|
|
1623
|
+
await browser?.then((open) => open.close()).catch(() => {
|
|
1624
|
+
});
|
|
1625
|
+
}
|
|
1626
|
+
};
|
|
1627
|
+
}
|
|
1435
1628
|
async function runGates(folder, options) {
|
|
1436
|
-
const
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1629
|
+
const session = gateSession();
|
|
1630
|
+
try {
|
|
1631
|
+
return await session.run(folder, options);
|
|
1632
|
+
} finally {
|
|
1633
|
+
await session.close();
|
|
1634
|
+
}
|
|
1440
1635
|
}
|
|
1441
1636
|
|
|
1442
1637
|
// src/cli.ts
|
|
1443
|
-
var HELP = `twombly ${"0.
|
|
1638
|
+
var HELP = `twombly ${"0.2.0"}: put design explorations on a shared canvas.
|
|
1444
1639
|
|
|
1445
1640
|
init [--name "Name"] [--no-open] create or reopen this project's canvas
|
|
1446
1641
|
add <folder|file> --title "T" run the gates, then place a frame
|
|
1447
1642
|
[--width 1280] [--height 800|auto] [--entry index.html] [--level build|component|capture|image]
|
|
1448
1643
|
[--new-row] [--x N --y N]
|
|
1644
|
+
[--frames '[{"title":"A","entry":"index.html?v=a"}]'] several at once, as JSON or a JSON file
|
|
1449
1645
|
update <frame> <folder|file> replace a frame's contents, keeping its comments
|
|
1450
1646
|
[--title "T"] [--width N] [--height N|auto] [--entry path] [--level ...]
|
|
1451
|
-
|
|
1647
|
+
update <folder> --frames '[{"frame":"A"},{"frame":"B"}]' several at once
|
|
1648
|
+
check <folder> run the gates without placing anything
|
|
1452
1649
|
capture <url> --out <folder> freeze a running page into a static folder
|
|
1453
1650
|
[--width 1280] [--height 800] [--image] [--selector css]
|
|
1651
|
+
preview <frame> a page with just that frame, for testing interactions
|
|
1454
1652
|
working | done show on the canvas that you're building, or that you've finished
|
|
1455
1653
|
list | move <frame> --x N --y N | resize <frame> --width N --height N
|
|
1456
1654
|
retitle <frame> "T" | remove <frame> | arrange [--columns N] | rename "Name"
|
|
1457
1655
|
open | status | login | logout | stop
|
|
1458
1656
|
share | unshare only when the person asks: this publishes a public link
|
|
1459
1657
|
guide how to adapt a project into frames. Read this first.`;
|
|
1658
|
+
var SWITCHES = /* @__PURE__ */ new Set(["no-open", "new-row", "image"]);
|
|
1659
|
+
var FLAGS = {
|
|
1660
|
+
init: ["name", "no-open"],
|
|
1661
|
+
add: ["title", "width", "height", "entry", "level", "new-row", "x", "y", "frames"],
|
|
1662
|
+
update: ["title", "width", "height", "entry", "level", "frames"],
|
|
1663
|
+
check: ["entry", "width", "height"],
|
|
1664
|
+
capture: ["out", "width", "height", "image", "selector", "wait"],
|
|
1665
|
+
move: ["x", "y"],
|
|
1666
|
+
resize: ["width", "height"],
|
|
1667
|
+
arrange: ["columns"]
|
|
1668
|
+
};
|
|
1460
1669
|
function parse(argv) {
|
|
1461
1670
|
const positional = [], flags = {};
|
|
1462
1671
|
for (let i = 0; i < argv.length; i++) {
|
|
@@ -1471,13 +1680,19 @@ function parse(argv) {
|
|
|
1471
1680
|
continue;
|
|
1472
1681
|
}
|
|
1473
1682
|
const next = argv[i + 1];
|
|
1474
|
-
if (next !== void 0 && !next.startsWith("--")) {
|
|
1683
|
+
if (!SWITCHES.has(arg.slice(2)) && next !== void 0 && !next.startsWith("--")) {
|
|
1475
1684
|
flags[arg.slice(2)] = next;
|
|
1476
1685
|
i++;
|
|
1477
1686
|
} else flags[arg.slice(2)] = true;
|
|
1478
1687
|
}
|
|
1479
1688
|
return { positional, flags };
|
|
1480
1689
|
}
|
|
1690
|
+
function checkFlags(name, flags) {
|
|
1691
|
+
const known = FLAGS[name] || [];
|
|
1692
|
+
for (const flag of Object.keys(flags)) {
|
|
1693
|
+
if (!known.includes(flag)) fail(`Unknown flag --${flag}. ${known.length ? `"${name}" takes: ${known.map((item) => `--${item}`).join(", ")}.` : `"${name}" takes no flags.`}`);
|
|
1694
|
+
}
|
|
1695
|
+
}
|
|
1481
1696
|
var print = (value) => process.stdout.write(JSON.stringify(value, null, 2) + "\n");
|
|
1482
1697
|
function fail(message, extra = {}) {
|
|
1483
1698
|
print({ ok: false, error: message, ...extra });
|
|
@@ -1515,7 +1730,7 @@ async function health() {
|
|
|
1515
1730
|
}
|
|
1516
1731
|
async function ensureDaemon() {
|
|
1517
1732
|
const running = await health();
|
|
1518
|
-
if (running?.build === "0.
|
|
1733
|
+
if (running?.build === "0.2.0+1793896544071") return;
|
|
1519
1734
|
if (running) {
|
|
1520
1735
|
await fetch(`${DAEMON_ORIGIN}/cli/stop`, { method: "POST", body: "{}" }).catch(() => {
|
|
1521
1736
|
});
|
|
@@ -1524,7 +1739,7 @@ async function ensureDaemon() {
|
|
|
1524
1739
|
spawn2(process.execPath, [fileURLToPath2(import.meta.url), "__daemon"], { detached: true, stdio: "ignore" }).unref();
|
|
1525
1740
|
for (let attempt = 0; attempt < 60; attempt++) {
|
|
1526
1741
|
await new Promise((done) => setTimeout(done, 100));
|
|
1527
|
-
if ((await health())?.build === "0.
|
|
1742
|
+
if ((await health())?.build === "0.2.0+1793896544071") return;
|
|
1528
1743
|
}
|
|
1529
1744
|
fail(`Could not start the background server on port ${PORT}. Is something else using it?`);
|
|
1530
1745
|
}
|
|
@@ -1543,13 +1758,13 @@ async function prepare(source) {
|
|
|
1543
1758
|
const extension = extname3(source).toLowerCase();
|
|
1544
1759
|
const isImage = IMAGE.has(extension);
|
|
1545
1760
|
if (!isImage && extension !== ".html" && extension !== ".htm") fail("Point at a folder of static files, an HTML file or an image.");
|
|
1546
|
-
const temporary = await mkdtemp(
|
|
1761
|
+
const temporary = await mkdtemp(join9(tmpdir(), "twombly-"));
|
|
1547
1762
|
if (isImage) {
|
|
1548
1763
|
const name = `image${extension}`;
|
|
1549
|
-
await copyFile(source,
|
|
1550
|
-
await
|
|
1764
|
+
await copyFile(source, join9(temporary, name));
|
|
1765
|
+
await writeFile7(join9(temporary, "index.html"), `<!doctype html><meta charset="utf-8"><title>${basename2(source)}</title><style>html,body{margin:0;background:#fff}img{display:block;width:100%;height:auto}</style><img src="${name}" alt="">`);
|
|
1551
1766
|
} else {
|
|
1552
|
-
await copyFile(source,
|
|
1767
|
+
await copyFile(source, join9(temporary, "index.html"));
|
|
1553
1768
|
}
|
|
1554
1769
|
return { folder: temporary, temporary, image: isImage };
|
|
1555
1770
|
}
|
|
@@ -1560,15 +1775,169 @@ async function currentProject() {
|
|
|
1560
1775
|
}
|
|
1561
1776
|
async function markWorking() {
|
|
1562
1777
|
const project = await findProject(process.cwd());
|
|
1563
|
-
if (project) await call("working", { project, ms:
|
|
1778
|
+
if (project) await call("working", { project, ms: WORKING_MS });
|
|
1564
1779
|
}
|
|
1565
1780
|
function gateFailure(result) {
|
|
1566
1781
|
print({ ok: false, placed: false, problems: result.problems, warnings: result.warnings, screenshot: result.screenshot });
|
|
1567
|
-
process.
|
|
1782
|
+
process.exitCode = 1;
|
|
1783
|
+
}
|
|
1784
|
+
var NEXT = "Open the screenshot. Passing the gates does not prove it looks right. To test interactions, open the url from: twombly preview <frame>";
|
|
1785
|
+
var CONCURRENT_GATES = 4;
|
|
1786
|
+
async function frameItems(flags, known) {
|
|
1787
|
+
const value = flags.frames;
|
|
1788
|
+
if (value === void 0) return [flags];
|
|
1789
|
+
if (typeof value !== "string") fail("--frames needs JSON or a JSON file.");
|
|
1790
|
+
const { frames: _, ...shared } = flags;
|
|
1791
|
+
let items;
|
|
1792
|
+
try {
|
|
1793
|
+
items = JSON.parse(value.trimStart().startsWith("[") ? value : await readFile7(value, "utf8"));
|
|
1794
|
+
} catch (error) {
|
|
1795
|
+
fail(`--frames: ${error instanceof Error ? error.message : error}`);
|
|
1796
|
+
}
|
|
1797
|
+
if (!Array.isArray(items) || !items.length) fail("--frames needs a list of frames.");
|
|
1798
|
+
return items.map((item) => {
|
|
1799
|
+
const unknown = Object.keys(item).find((key) => !known.includes(key));
|
|
1800
|
+
if (unknown) fail(`--frames: unknown field "${unknown}". Each frame takes: ${known.join(", ")}.`);
|
|
1801
|
+
const merged = { ...shared };
|
|
1802
|
+
for (const [key, field] of Object.entries(item)) {
|
|
1803
|
+
if (key === "newRow") {
|
|
1804
|
+
if (field) merged["new-row"] = true;
|
|
1805
|
+
else delete merged["new-row"];
|
|
1806
|
+
} else merged[key] = String(field);
|
|
1807
|
+
}
|
|
1808
|
+
return merged;
|
|
1809
|
+
});
|
|
1810
|
+
}
|
|
1811
|
+
async function placeFrames(project, many, jobs, place2) {
|
|
1812
|
+
await markWorking();
|
|
1813
|
+
const scratch = await mkdtemp(join9(tmpdir(), "twombly-gates-"));
|
|
1814
|
+
const gates = gateSession(), prepared = /* @__PURE__ */ new Map(), temporary = [];
|
|
1815
|
+
let keepScratch = false;
|
|
1816
|
+
try {
|
|
1817
|
+
for (const job of jobs) {
|
|
1818
|
+
if (prepared.has(job.source)) continue;
|
|
1819
|
+
const ready = await prepare(job.source);
|
|
1820
|
+
prepared.set(job.source, ready);
|
|
1821
|
+
if (ready.temporary) temporary.push(ready.temporary);
|
|
1822
|
+
}
|
|
1823
|
+
const results = [];
|
|
1824
|
+
let next = 0;
|
|
1825
|
+
await Promise.all(Array.from({ length: CONCURRENT_GATES }, async () => {
|
|
1826
|
+
while (next < jobs.length) {
|
|
1827
|
+
const index = next++, job = jobs[index];
|
|
1828
|
+
results[index] = await gates.run(prepared.get(job.source).folder, { entry: job.entry, width: job.width, height: job.height, poster: join9(scratch, `${job.id}.png`) });
|
|
1829
|
+
}
|
|
1830
|
+
}));
|
|
1831
|
+
if (results.some((result) => !result.ok)) {
|
|
1832
|
+
keepScratch = true;
|
|
1833
|
+
if (!many) return gateFailure(results[0]);
|
|
1834
|
+
return gateFailure({
|
|
1835
|
+
problems: results.flatMap((result, index) => result.problems.map((problem) => ({ ...problem, frame: jobs[index].label, screenshot: result.screenshot }))),
|
|
1836
|
+
warnings: results.flatMap((result, index) => result.warnings.map((warning) => `${jobs[index].label}: ${warning}`))
|
|
1837
|
+
});
|
|
1838
|
+
}
|
|
1839
|
+
const placed = [];
|
|
1840
|
+
for (const [index, job] of jobs.entries()) {
|
|
1841
|
+
const result = results[index], { folder, image } = prepared.get(job.source);
|
|
1842
|
+
const poster = result.screenshot ? posterFile(project, job.id) : void 0;
|
|
1843
|
+
await replaceFolder(folder, frameDir(project, job.id));
|
|
1844
|
+
if (poster) {
|
|
1845
|
+
await mkdir7(join9(project, ".twombly/posters"), { recursive: true });
|
|
1846
|
+
await copyFile(result.screenshot, poster);
|
|
1847
|
+
}
|
|
1848
|
+
placed.push({ ...await place2(job, { result, folder, image, poster }), warnings: result.warnings, screenshot: poster, contentSize: result.content });
|
|
1849
|
+
}
|
|
1850
|
+
if (!many) return print({ ok: true, placed: true, ...placed[0], next: NEXT });
|
|
1851
|
+
print({ ok: true, placed: true, frames: placed.map(({ frame, canvas: _, ...rest }) => ({ ...frame, ...rest })), canvas: placed[placed.length - 1].canvas, next: NEXT });
|
|
1852
|
+
} finally {
|
|
1853
|
+
await gates.close();
|
|
1854
|
+
for (const folder of temporary) await rm5(folder, { recursive: true, force: true });
|
|
1855
|
+
if (!keepScratch) await rm5(scratch, { recursive: true, force: true });
|
|
1856
|
+
}
|
|
1857
|
+
}
|
|
1858
|
+
async function addFrames(project, source, many, items) {
|
|
1859
|
+
const jobs = items.map((flags) => {
|
|
1860
|
+
const folder = text(flags, "folder") || source, title = text(flags, "title");
|
|
1861
|
+
if (!folder || !title) fail('twombly add <folder|file> --title "Title"');
|
|
1862
|
+
level(flags, "build");
|
|
1863
|
+
number(flags, "x");
|
|
1864
|
+
number(flags, "y");
|
|
1865
|
+
return {
|
|
1866
|
+
flags,
|
|
1867
|
+
label: title,
|
|
1868
|
+
source: resolve2(folder),
|
|
1869
|
+
entry: text(flags, "entry") || "index.html",
|
|
1870
|
+
width: size(flags, "width", 1280),
|
|
1871
|
+
height: flags.height === "auto" ? "auto" : size(flags, "height", 800),
|
|
1872
|
+
id: randomBytes2(8).toString("hex").replace(/[01lo]/g, "a").slice(0, 12)
|
|
1873
|
+
};
|
|
1874
|
+
});
|
|
1875
|
+
const titles = (await call("list", { project })).frames.map((frame) => frame.title);
|
|
1876
|
+
await placeFrames(project, many, jobs, async (job, { result, image, poster }) => {
|
|
1877
|
+
const { content } = await folderContent(frameDir(project, job.id));
|
|
1878
|
+
if (titles.includes(job.label)) result.warnings.push(`Another frame is already titled "${job.label}". Give each a distinct title, or use update to replace it.`);
|
|
1879
|
+
titles.push(job.label);
|
|
1880
|
+
return call("place", {
|
|
1881
|
+
project,
|
|
1882
|
+
id: job.id,
|
|
1883
|
+
title: job.label,
|
|
1884
|
+
w: job.width,
|
|
1885
|
+
h: result.height,
|
|
1886
|
+
entry: job.entry,
|
|
1887
|
+
content,
|
|
1888
|
+
level: level(job.flags, image ? "image" : "build"),
|
|
1889
|
+
hasPoster: !!poster,
|
|
1890
|
+
x: number(job.flags, "x"),
|
|
1891
|
+
y: number(job.flags, "y"),
|
|
1892
|
+
newRow: job.flags["new-row"] === true
|
|
1893
|
+
});
|
|
1894
|
+
});
|
|
1895
|
+
}
|
|
1896
|
+
async function updateFrames(project, source, many, items) {
|
|
1897
|
+
const { frames } = await call("list", { project });
|
|
1898
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1899
|
+
const jobs = items.map((flags) => {
|
|
1900
|
+
const ref = text(flags, "frame"), folder = text(flags, "folder") || source;
|
|
1901
|
+
if (!ref || !folder) fail("twombly update <frame> <folder|file>");
|
|
1902
|
+
const matches = frames.filter((frame2) => frame2.id === ref || frame2.title === ref);
|
|
1903
|
+
if (matches.length !== 1) fail(matches.length ? `More than one frame is titled "${ref}". Use its id.` : `No frame "${ref}". Run: twombly list`);
|
|
1904
|
+
const frame = matches[0];
|
|
1905
|
+
if (seen.has(frame.id)) fail(`--frames lists "${ref}" twice.`);
|
|
1906
|
+
seen.add(frame.id);
|
|
1907
|
+
level(flags, "build");
|
|
1908
|
+
return {
|
|
1909
|
+
flags,
|
|
1910
|
+
label: frame.title,
|
|
1911
|
+
id: frame.id,
|
|
1912
|
+
source: resolve2(folder),
|
|
1913
|
+
entry: text(flags, "entry") || frame.entry,
|
|
1914
|
+
width: size(flags, "width", frame.w),
|
|
1915
|
+
height: flags.height === "auto" ? "auto" : size(flags, "height", frame.h)
|
|
1916
|
+
};
|
|
1917
|
+
});
|
|
1918
|
+
await placeFrames(project, many, jobs, async (job, { result, image, poster }) => {
|
|
1919
|
+
const { content } = await folderContent(frameDir(project, job.id));
|
|
1920
|
+
return call("revise", {
|
|
1921
|
+
project,
|
|
1922
|
+
id: job.id,
|
|
1923
|
+
title: text(job.flags, "title"),
|
|
1924
|
+
w: job.width,
|
|
1925
|
+
h: result.height,
|
|
1926
|
+
entry: job.entry,
|
|
1927
|
+
content,
|
|
1928
|
+
hasPoster: poster ? true : void 0,
|
|
1929
|
+
level: text(job.flags, "level") || image ? level(job.flags, image ? "image" : "build") : void 0
|
|
1930
|
+
});
|
|
1931
|
+
});
|
|
1568
1932
|
}
|
|
1569
1933
|
async function main() {
|
|
1570
1934
|
const [name, ...rest] = process.argv.slice(2);
|
|
1571
1935
|
const { positional, flags } = parse(rest);
|
|
1936
|
+
if (flags.help !== void 0 || positional.includes("-h")) {
|
|
1937
|
+
process.stdout.write(HELP + "\n");
|
|
1938
|
+
return;
|
|
1939
|
+
}
|
|
1940
|
+
if (name && !["--help", "-h", "--version", "help", "version", "guide", "__daemon"].includes(name)) checkFlags(name, flags);
|
|
1572
1941
|
switch (name) {
|
|
1573
1942
|
case "__daemon":
|
|
1574
1943
|
return runDaemon();
|
|
@@ -1580,7 +1949,7 @@ async function main() {
|
|
|
1580
1949
|
return;
|
|
1581
1950
|
case "--version":
|
|
1582
1951
|
case "version":
|
|
1583
|
-
process.stdout.write("0.
|
|
1952
|
+
process.stdout.write("0.2.0\n");
|
|
1584
1953
|
return;
|
|
1585
1954
|
case "guide":
|
|
1586
1955
|
process.stdout.write(SKILL_default);
|
|
@@ -1588,23 +1957,23 @@ async function main() {
|
|
|
1588
1957
|
case "init": {
|
|
1589
1958
|
const project = await findProject(process.cwd()) || process.cwd();
|
|
1590
1959
|
const given = text(flags, "name");
|
|
1591
|
-
print({ ok: true, ...await call("init", { project, name: given || defaultName(project), rename: !!given, open: flags["no-open"] === void 0 }) });
|
|
1960
|
+
print({ ok: true, ...await call("init", { project, name: given || defaultName(project), rename: !!given, open: flags["no-open"] === void 0 }), next: "Frames must be static files that work with no server or network. Read how to adapt this project: twombly guide" });
|
|
1592
1961
|
return;
|
|
1593
1962
|
}
|
|
1594
1963
|
case "check": {
|
|
1595
1964
|
const source = positional[0];
|
|
1596
|
-
if (!source) fail("
|
|
1965
|
+
if (!source) fail("twombly check <folder> checks a build before it is added. It needs the build's folder.");
|
|
1597
1966
|
await markWorking();
|
|
1598
1967
|
const { folder, temporary } = await prepare(resolve2(source));
|
|
1599
|
-
const scratch = await mkdtemp(
|
|
1600
|
-
const shot =
|
|
1968
|
+
const scratch = await mkdtemp(join9(tmpdir(), "twombly-check-"));
|
|
1969
|
+
const shot = join9(scratch, "screenshot.png");
|
|
1601
1970
|
try {
|
|
1602
1971
|
const height = flags.height === "auto" ? "auto" : size(flags, "height", 800);
|
|
1603
1972
|
const result = await runGates(folder, { entry: text(flags, "entry") || "index.html", width: size(flags, "width", 1280), height, poster: shot });
|
|
1604
1973
|
print(result);
|
|
1605
1974
|
if (!result.ok) process.exitCode = 1;
|
|
1606
1975
|
} finally {
|
|
1607
|
-
if (temporary) await
|
|
1976
|
+
if (temporary) await rm5(temporary, { recursive: true, force: true });
|
|
1608
1977
|
}
|
|
1609
1978
|
return;
|
|
1610
1979
|
}
|
|
@@ -1617,83 +1986,12 @@ async function main() {
|
|
|
1617
1986
|
return;
|
|
1618
1987
|
}
|
|
1619
1988
|
case "add": {
|
|
1620
|
-
|
|
1621
|
-
if (!source || !title) fail('twombly add <folder|file> --title "Title"');
|
|
1622
|
-
const project = await currentProject();
|
|
1623
|
-
await markWorking();
|
|
1624
|
-
const { folder, temporary, image } = await prepare(resolve2(source));
|
|
1625
|
-
const id = randomBytes2(8).toString("hex").replace(/[01lo]/g, "a").slice(0, 12);
|
|
1626
|
-
const entry = text(flags, "entry") || "index.html", width = size(flags, "width", 1280);
|
|
1627
|
-
const height = flags.height === "auto" ? "auto" : size(flags, "height", 800);
|
|
1628
|
-
const shot = posterFile(project, id);
|
|
1629
|
-
try {
|
|
1630
|
-
const result = await runGates(folder, { entry, width, height, poster: shot });
|
|
1631
|
-
if (!result.ok) {
|
|
1632
|
-
await rm4(shot, { force: true });
|
|
1633
|
-
gateFailure(result);
|
|
1634
|
-
}
|
|
1635
|
-
await replaceFolder(folder, frameDir(project, id));
|
|
1636
|
-
const { content } = await folderContent(frameDir(project, id));
|
|
1637
|
-
const existing = await call("list", { project });
|
|
1638
|
-
if (existing.frames.some((frame) => frame.title === title)) result.warnings.push(`Another frame is already titled "${title}". Give each a distinct title, or use update to replace it.`);
|
|
1639
|
-
const placed = await call("place", {
|
|
1640
|
-
project,
|
|
1641
|
-
id,
|
|
1642
|
-
title,
|
|
1643
|
-
w: width,
|
|
1644
|
-
h: result.height,
|
|
1645
|
-
entry,
|
|
1646
|
-
content,
|
|
1647
|
-
level: level(flags, image ? "image" : "build"),
|
|
1648
|
-
hasPoster: !!result.screenshot,
|
|
1649
|
-
x: number(flags, "x"),
|
|
1650
|
-
y: number(flags, "y"),
|
|
1651
|
-
newRow: flags["new-row"] === true
|
|
1652
|
-
});
|
|
1653
|
-
print({ ok: true, placed: true, ...placed, warnings: result.warnings, screenshot: result.screenshot, next: "Open the screenshot. Passing the gates does not prove it looks right.", contentSize: result.content });
|
|
1654
|
-
} finally {
|
|
1655
|
-
if (temporary) await rm4(temporary, { recursive: true, force: true });
|
|
1656
|
-
}
|
|
1657
|
-
return;
|
|
1989
|
+
return addFrames(await currentProject(), positional[0], flags.frames !== void 0, await frameItems(flags, ["folder", "title", "width", "height", "entry", "level", "newRow", "x", "y"]));
|
|
1658
1990
|
}
|
|
1659
1991
|
case "update": {
|
|
1660
|
-
const
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
await markWorking();
|
|
1664
|
-
const before = await call("list", { project });
|
|
1665
|
-
const matches = before.frames.filter((frame2) => frame2.id === ref || frame2.title === ref);
|
|
1666
|
-
if (matches.length !== 1) fail(matches.length ? `More than one frame is titled "${ref}". Use its id.` : `No frame "${ref}". Run: twombly list`);
|
|
1667
|
-
const frame = matches[0];
|
|
1668
|
-
const { folder, temporary, image } = await prepare(resolve2(source));
|
|
1669
|
-
const entry = text(flags, "entry") || frame.entry, width = size(flags, "width", frame.w);
|
|
1670
|
-
const height = flags.height === "auto" ? "auto" : size(flags, "height", frame.h);
|
|
1671
|
-
const scratch = await mkdtemp(join8(tmpdir(), "twombly-update-"));
|
|
1672
|
-
const candidate = join8(scratch, "poster.png");
|
|
1673
|
-
try {
|
|
1674
|
-
const result = await runGates(folder, { entry, width, height, poster: candidate });
|
|
1675
|
-
if (!result.ok) gateFailure(result);
|
|
1676
|
-
await replaceFolder(folder, frameDir(project, frame.id));
|
|
1677
|
-
await mkdir6(join8(project, ".twombly/posters"), { recursive: true });
|
|
1678
|
-
await copyFile(candidate, posterFile(project, frame.id));
|
|
1679
|
-
const { content } = await folderContent(frameDir(project, frame.id));
|
|
1680
|
-
const updated = await call("revise", {
|
|
1681
|
-
project,
|
|
1682
|
-
id: frame.id,
|
|
1683
|
-
title: text(flags, "title"),
|
|
1684
|
-
w: width,
|
|
1685
|
-
h: result.height,
|
|
1686
|
-
entry,
|
|
1687
|
-
content,
|
|
1688
|
-
hasPoster: true,
|
|
1689
|
-
level: text(flags, "level") || image ? level(flags, image ? "image" : "build") : void 0
|
|
1690
|
-
});
|
|
1691
|
-
print({ ok: true, placed: true, ...updated, warnings: result.warnings, screenshot: posterFile(project, frame.id), next: "Open the screenshot. Passing the gates does not prove it looks right.", contentSize: result.content });
|
|
1692
|
-
} finally {
|
|
1693
|
-
if (temporary) await rm4(temporary, { recursive: true, force: true });
|
|
1694
|
-
await rm4(scratch, { recursive: true, force: true });
|
|
1695
|
-
}
|
|
1696
|
-
return;
|
|
1992
|
+
const many = flags.frames !== void 0;
|
|
1993
|
+
const items = many ? await frameItems(flags, ["frame", "folder", "title", "width", "height", "entry", "level"]) : [{ ...flags, ...positional[0] ? { frame: positional[0] } : {} }];
|
|
1994
|
+
return updateFrames(await currentProject(), many ? positional[0] : positional[1], many, items);
|
|
1697
1995
|
}
|
|
1698
1996
|
case "working":
|
|
1699
1997
|
print({ ok: true, ...await call("working", { project: await currentProject(), ms: WORKING_MS }) });
|
|
@@ -1707,6 +2005,11 @@ async function main() {
|
|
|
1707
2005
|
case "open":
|
|
1708
2006
|
print({ ok: true, ...await call("open", { project: await currentProject() }) });
|
|
1709
2007
|
return;
|
|
2008
|
+
case "preview": {
|
|
2009
|
+
if (!positional[0]) fail("twombly preview <frame>");
|
|
2010
|
+
print({ ok: true, ...await call("preview", { project: await currentProject(), frame: positional[0] }) });
|
|
2011
|
+
return;
|
|
2012
|
+
}
|
|
1710
2013
|
case "status":
|
|
1711
2014
|
print({ ok: true, ...await call("status", { project: await findProject(process.cwd()) }) });
|
|
1712
2015
|
return;
|
|
@@ -1722,6 +2025,7 @@ async function main() {
|
|
|
1722
2025
|
return;
|
|
1723
2026
|
}
|
|
1724
2027
|
case "resize": {
|
|
2028
|
+
if (flags.height === "auto") fail("resize takes a number. To fit the page: twombly update <frame> <folder> --height auto");
|
|
1725
2029
|
if (!positional[0] || flags.width === void 0 || flags.height === void 0) fail("twombly resize <frame> --width N --height N");
|
|
1726
2030
|
print({ ok: true, ...await call("patch", { project: await currentProject(), frame: positional[0], patch: { w: size(flags, "width", 1280), h: size(flags, "height", 800) } }) });
|
|
1727
2031
|
return;
|