diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c8a49cc..b372724 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -128,7 +128,7 @@ jobs: release: name: release - needs: [check, e2e] + needs: [check, site, e2e] # Off until launch: the repository variable RELEASE_ENABLED is set to true once v0.0.0 is tagged and staging exists. # Without the tag, semantic-release counts every commit and picks 1.0.0. if: github.event_name == 'push' && vars.RELEASE_ENABLED == 'true' @@ -177,3 +177,20 @@ jobs: git checkout -B staging origin/staging git merge --no-ff origin/main -m "chore(release): merge main into staging [skip ci]" git push origin staging + + deploy-site: + name: deploy site + needs: release + # Only main is production; Vercel no longer deploys it on push (site/vercel.json), so this is the one production deploy, + # made after the release job has pushed its version commit. + if: github.ref_name == 'main' + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + # The hook builds main's tip, so an older commit can never replace a newer one in production. + - name: Trigger the production deploy + env: + HOOK: ${{ secrets.VERCEL_DEPLOY_HOOK_MAIN }} + run: | + if [ -z "$HOOK" ]; then echo "::error::VERCEL_DEPLOY_HOOK_MAIN is not set"; exit 1; fi + curl --fail-with-body -sS -X POST "$HOOK" diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 99bb0db..42b4bab 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -101,3 +101,7 @@ workflow's `release` job runs them after the checks pass. the bot commits `chore(release): x.y.z [skip ci]` to `main`, and then merges `main` back into `staging`. Release candidates only change the published package, so the repo keeps the last release's version between releases. Never edit these pins by hand. +- **The website** deploys to production from CI, not from Vercel's push hook: after the `release` job, the + `deploy-site` job calls a Vercel Deploy Hook (the `VERCEL_DEPLOY_HOOK_MAIN` secret), which builds `main`'s tip. + That way the release commit, not the merge before it, is what goes live. Pull requests and `staging` still get + Vercel previews on push. diff --git a/bun.lock b/bun.lock index e46f438..ff79da0 100644 --- a/bun.lock +++ b/bun.lock @@ -26,7 +26,7 @@ "stackmap": "./dist/cli.js", }, "dependencies": { - "elkjs": "^0.12.0", + "elkjs": "0.12.0", }, "devDependencies": { "@playwright/test": "^1.63.0", @@ -47,7 +47,7 @@ "version": "0.0.0", "dependencies": { "@stackmap/core": "workspace:*", - "elkjs": "^0.12.0", + "elkjs": "0.12.0", }, }, "packages/schema": { diff --git a/packages/cli/package.json b/packages/cli/package.json index 13a02ea..f27653b 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -49,7 +49,7 @@ "prepack": "bun run build && node scripts/prepack.mjs" }, "dependencies": { - "elkjs": "^0.12.0" + "elkjs": "0.12.0" }, "devDependencies": { "@playwright/test": "^1.63.0", diff --git a/packages/cli/src/open.ts b/packages/cli/src/open.ts index 7d8fdf5..c5d1ff7 100644 --- a/packages/cli/src/open.ts +++ b/packages/cli/src/open.ts @@ -1,10 +1,17 @@ import { spawn } from 'node:child_process'; -/** The command that opens a file in the default browser on `platform`. */ -export function browserCommand(file: string, platform: NodeJS.Platform = process.platform): { cmd: string; args: string[] } { +/** The command that opens a file in the default browser on `platform`, or why it can't be done safely. */ +export function browserCommand( + file: string, + platform: NodeJS.Platform = process.platform, +): { cmd: string; args: string[] } | { refused: string } { if (platform === 'darwin') return { cmd: 'open', args: [file] }; // `start` is a cmd builtin, and its first quoted argument is the window title. - if (platform === 'win32') return { cmd: 'cmd', args: ['/c', 'start', '""', file] }; + // Quoted, cmd takes & | < > ^ ( ) literally; it still expands %VAR% inside quotes, and " would end the quoting. + if (platform === 'win32') + return /["%]/.test(file) + ? { refused: 'the path has a " or % that cmd would interpret' } + : { cmd: 'cmd', args: ['/c', 'start', '""', `"${file}"`] }; return { cmd: 'xdg-open', args: [file] }; } @@ -13,7 +20,9 @@ export function browserCommand(file: string, platform: NodeJS.Platform = process * opener installed, a headless machine). An opener that is still running after a moment is taken as success. */ export function openInBrowser(file: string): Promise { - const { cmd, args } = browserCommand(file); + const command = browserCommand(file); + if ('refused' in command) return Promise.resolve(command.refused); + const { cmd, args } = command; return new Promise((resolve) => { const child = spawn(cmd, args, { stdio: 'ignore', detached: true, windowsVerbatimArguments: cmd === 'cmd' }); const timer = setTimeout(() => resolve(null), 3000); diff --git a/packages/cli/test/open.test.ts b/packages/cli/test/open.test.ts index a1ccec0..0f51633 100644 --- a/packages/cli/test/open.test.ts +++ b/packages/cli/test/open.test.ts @@ -2,9 +2,24 @@ import { describe, expect, it } from 'vitest'; import { browserCommand } from '../src/open'; describe('browserCommand', () => { - it("uses each platform's own opener", () => { + it('uses the native opener on darwin and linux', () => { expect(browserCommand('/tmp/a b.html', 'darwin')).toEqual({ cmd: 'open', args: ['/tmp/a b.html'] }); - expect(browserCommand('C:\\d\\a.html', 'win32')).toEqual({ cmd: 'cmd', args: ['/c', 'start', '""', 'C:\\d\\a.html'] }); expect(browserCommand('/tmp/a.html', 'linux')).toEqual({ cmd: 'xdg-open', args: ['/tmp/a.html'] }); }); + + it('quotes the path for cmd on win32', () => { + expect(browserCommand('C:\\d\\a.html', 'win32')).toEqual({ cmd: 'cmd', args: ['/c', 'start', '""', '"C:\\d\\a.html"'] }); + }); + + it('keeps spaces and cmd metacharacters inside the quotes', () => { + const file = 'C:\\Users\\First Last\\a&b (1).html'; + expect(browserCommand(file, 'win32')).toEqual({ cmd: 'cmd', args: ['/c', 'start', '""', `"${file}"`] }); + }); + + it('refuses win32 paths with a % or " that cmd would interpret', () => { + for (const file of ['C:\\d\\100%.html', 'C:\\d\\a"b.html']) { + const result = browserCommand(file, 'win32'); + expect('refused' in result && typeof result.refused).toBe('string'); + } + }); }); diff --git a/packages/cli/test/skill.test.ts b/packages/cli/test/skill.test.ts index 4d35725..1e1b1f4 100644 --- a/packages/cli/test/skill.test.ts +++ b/packages/cli/test/skill.test.ts @@ -25,6 +25,13 @@ describe('skill ↔ CLI version', () => { }); describe('package.json', () => { + it('pins elkjs exactly, and to the version the layout package uses, so a CLI version always gives the same layout', () => { + const dep = (p: string) => (JSON.parse(readFileSync(new URL(p, import.meta.url), 'utf8')) as { dependencies: Record }).dependencies.elkjs; + const cli = dep('../package.json'); + expect(cli).toMatch(/^\d+\.\d+\.\d+$/); + expect(dep('../../layout/package.json')).toBe(cli); + }); + it('names every diagram kind in its npm keywords, so a search for any of them finds the package', () => { const { keywords } = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')) as { keywords: string[] }; for (const kind of DIAGRAM_KINDS) expect(keywords, kind).toContain(kind); diff --git a/packages/layout/package.json b/packages/layout/package.json index 07dd8e8..325bd4c 100644 --- a/packages/layout/package.json +++ b/packages/layout/package.json @@ -13,6 +13,6 @@ }, "dependencies": { "@stackmap/core": "workspace:*", - "elkjs": "^0.12.0" + "elkjs": "0.12.0" } } diff --git a/packages/layout/src/elk-io.ts b/packages/layout/src/elk-io.ts new file mode 100644 index 0000000..3576ffe --- /dev/null +++ b/packages/layout/src/elk-io.ts @@ -0,0 +1,75 @@ +import type { ChildProcessWithoutNullStreams } from 'node:child_process'; +import type { EventEmitter } from 'node:events'; + +/** What threadElk needs from a worker thread: enough to fake in a test. */ +export interface ThreadLike extends Pick { + postMessage(msg: unknown): void; + terminate(): unknown; +} + +/** elkjs's worker port over a thread; a thread that dies or exits fails what it was asked instead of hanging. */ +export function threadPort(thread: ThreadLike) { + const pending = new Set(); + const port = { + onmessage: null as ((answer: { data: unknown }) => void) | null, + postMessage(msg: { id: number }) { + pending.add(msg.id); + thread.postMessage(msg); + }, + terminate: () => void thread.terminate(), + }; + thread.on('message', (data: { id: number; error?: { message: string } }) => { + pending.delete(data.id); + port.onmessage?.({ data: data.error ? { ...data, error: new Error(data.error.message) } : data }); + }); + // A thread that dies (out of memory, say) answers nothing: fail what it was asked instead of hanging. + thread.on('error', (error) => { + for (const id of pending) port.onmessage?.({ data: { id, error } }); + pending.clear(); + }); + // An exit (terminate, process.exit, some resource limits) can come without an 'error': fail what's still waiting. + thread.on('exit', (code: number) => { + const error = new Error(`ELK thread stopped (exit code ${code})`); + for (const id of pending) port.onmessage?.({ data: { id, error } }); + pending.clear(); + }); + return port; +} + +/** Sends `input` to a child ELK and resolves its JSON answer; every way the child can fail rejects, once. */ +export function runChild(child: ChildProcessWithoutNullStreams, input: string): Promise { + return new Promise((resolve, reject) => { + let out = ''; + let err = ''; + let settled = false; + const succeed = (value: T) => { + if (!settled) { + settled = true; + resolve(value); + } + }; + const fail = (error: Error) => { + if (!settled) { + settled = true; + reject(error); + } + }; + child.stdout.on('data', (c: Buffer) => (out += c)); + child.stderr.on('data', (c: Buffer) => (err += c)); + child.on('error', (e) => fail(e)); + // EPIPE when the child dies before reading its input: 'close' reports why. + child.stdin.on('error', () => {}); + child.on('close', (code, signal) => { + if (code === 0) { + try { + succeed(JSON.parse(out) as T); + } catch (e) { + fail(new Error(`ELK answered with invalid JSON: ${(e as Error).message}`)); + } + } else { + fail(new Error(err.trim() || (signal ? `ELK was killed by ${signal}` : `ELK exited with code ${code}`))); + } + }); + child.stdin.end(input); + }); +} diff --git a/packages/layout/src/index.ts b/packages/layout/src/index.ts index 674a0a6..7d9d352 100644 --- a/packages/layout/src/index.ts +++ b/packages/layout/src/index.ts @@ -1,4 +1,4 @@ -import { spawn } from 'node:child_process'; +import { spawn, type ChildProcess } from 'node:child_process'; import { readFileSync } from 'node:fs'; import { createRequire } from 'node:module'; import { Worker as Thread } from 'node:worker_threads'; @@ -6,6 +6,7 @@ import ElkApi from 'elkjs/lib/elk-api.js'; import ElkBundled from 'elkjs/lib/elk.bundled.js'; import type { ElkExtendedEdge, ElkNode } from 'elkjs/lib/elk-api'; import { cardSize, isLaneKind, usesCompactCards, type DiagramDraft, type Direction, type LaidOutDiagram, type Point, type Rect } from '@stackmap/core'; +import { runChild, threadPort } from './elk-io'; import { placeLabels } from './labels'; import { layoutLanes } from './lanes'; import { layoutSequence } from './sequence'; @@ -53,31 +54,14 @@ parentPort.on('message', (msg) => elk.postMessage(msg)); function threadElk(workerUrl: string): { elk: InstanceType; dispose: () => void } { const thread = new Thread(NODE_THREAD, { eval: true, workerData: workerUrl, resourceLimits: { stackSizeMb: ELK_STACK_MB } }); - const pending = new Set(); - const port = { - onmessage: null as ((answer: { data: unknown }) => void) | null, - postMessage(msg: { id: number }) { - pending.add(msg.id); - thread.postMessage(msg); - }, - terminate: () => void thread.terminate(), - }; - thread.on('message', (data: { id: number; error?: { message: string } }) => { - pending.delete(data.id); - port.onmessage?.({ data: data.error ? { ...data, error: new Error(data.error.message) } : data }); - }); - // A thread that dies (out of memory, say) answers nothing: fail what it was asked instead of hanging. - thread.on('error', (error) => { - for (const id of pending) port.onmessage?.({ data: { id, error } }); - pending.clear(); - }); + const port = threadPort(thread); const elk = new ElkApi({ workerFactory: () => port as never }); return { elk, dispose: () => elk.terminateWorker() }; } /** * ELK in-process where a global `self` exists: its worker script, run with `self` hidden, exports the in-process worker - * instead of taking over `self.onmessage`. Bun's fallback for a large graph where a child process can't be started. + * instead of taking over `self.onmessage`. Bun's large-graph host on Windows, where the child process (a POSIX shell with ulimit) isn't available. */ function mainThreadElk(workerUrl: string): InstanceType { const mod: { exports: { Worker?: new () => unknown } } = { exports: {} }; @@ -106,22 +90,19 @@ process.stdin.on('data', (c) => (input += c)).on('end', () => `; /** For Bun's large graphs: ELK in a child Bun started with room for its recursion (the densest need ~100 MB). */ -function childElk(workerUrl: string): InstanceType { +function childElk(workerUrl: string): { elk: InstanceType; dispose: () => void } { const apiUrl = createRequire(import.meta.url).resolve('elkjs/lib/elk-api.js'); // The option only when the limit rose: telling JavaScriptCore the stack is bigger than it is crashes instead of throwing. const script = `if ulimit -s ${BUN_STACK_KB} 2>/dev/null; then export BUN_JSC_maxPerThreadStackUsage=${BUN_STACK_KB * 1024}; fi; exec "$0" -e "$1" "$2" "$3"`; - const layout = (graph: ElkNode) => - new Promise((resolve, reject) => { - const child = spawn('/bin/sh', ['-c', script, process.execPath, BUN_CHILD, workerUrl, apiUrl]); - let out = ''; - let err = ''; - child.stdout.on('data', (c: Buffer) => (out += c)); - child.stderr.on('data', (c: Buffer) => (err += c)); - child.on('error', reject); - child.on('close', (code) => (code === 0 ? resolve(JSON.parse(out) as ElkNode) : reject(new Error(err.trim() || `ELK exited with code ${code}`)))); - child.stdin.end(JSON.stringify(graph)); - }); - return { layout } as never; + const live = new Set(); + const layout = (graph: ElkNode) => { + const child = spawn('/bin/sh', ['-c', script, process.execPath, BUN_CHILD, workerUrl, apiUrl]); + live.add(child); + child.once('close', () => live.delete(child)); + return runChild(child, JSON.stringify(graph)); + }; + // A layout that threw elsewhere leaves its child running: kill it with the rest. + return { elk: { layout } as never, dispose: () => live.forEach((c) => c.kill()) }; } // elkjs's bundled build treats any runtime with a global `self` and no `document` as a web worker @@ -134,7 +115,7 @@ function createElk(large: boolean): { elk: InstanceType; disp if (node ? !large : g.document !== undefined || g.Worker === undefined) return { elk: new ElkBundled(), dispose: () => {} }; const workerUrl = createRequire(import.meta.url).resolve('elkjs/lib/elk-worker.min.js'); if (node) return threadElk(workerUrl); - if (large) return { elk: process.platform === 'win32' ? mainThreadElk(workerUrl) : childElk(workerUrl), dispose: () => {} }; + if (large) return process.platform === 'win32' ? { elk: mainThreadElk(workerUrl), dispose: () => {} } : childElk(workerUrl); const elk = new ElkApi({ workerUrl }); // A real Worker keeps the process alive until terminated; the in-process one has no terminate(). return { elk, dispose: () => elk.terminateWorker() }; diff --git a/packages/layout/test/elk-io.test.ts b/packages/layout/test/elk-io.test.ts new file mode 100644 index 0000000..1237a61 --- /dev/null +++ b/packages/layout/test/elk-io.test.ts @@ -0,0 +1,103 @@ +import type { ChildProcessWithoutNullStreams } from 'node:child_process'; +import { EventEmitter } from 'node:events'; +import { PassThrough } from 'node:stream'; +import { describe, expect, it } from 'vitest'; +import { runChild, threadPort } from '../src/elk-io'; + +function fakeThread() { + const emitter = new EventEmitter(); + const thread = Object.assign(emitter, { + posted: [] as unknown[], + postMessage(msg: unknown) { + thread.posted.push(msg); + }, + terminate() { + emitter.emit('exit', 1); + }, + }); + return thread; +} + +function fakeChild() { + const child = Object.assign(new EventEmitter(), { stdout: new PassThrough(), stderr: new PassThrough(), stdin: new PassThrough() }); + return child as unknown as ChildProcessWithoutNullStreams & { stdout: PassThrough; stderr: PassThrough; stdin: PassThrough }; +} + +describe('threadPort', () => { + it('fails a pending request when the thread exits', () => { + const thread = fakeThread(); + const port = threadPort(thread); + const answers: { data: { id: number; error?: Error } }[] = []; + port.onmessage = (a) => answers.push(a as never); + port.postMessage({ id: 1 }); + thread.emit('exit', 1); + expect(answers).toHaveLength(1); + expect(answers[0]!.data.id).toBe(1); + expect(answers[0]!.data.error?.message).toMatch(/exit code 1/); + }); + + it('does not fail an answered request when the thread later exits', () => { + const thread = fakeThread(); + const port = threadPort(thread); + const answers: { data: { id: number; error?: Error } }[] = []; + port.onmessage = (a) => answers.push(a as never); + port.postMessage({ id: 1 }); + thread.emit('message', { id: 1 }); + thread.emit('exit', 0); + expect(answers).toHaveLength(1); + expect(answers[0]!.data.error).toBeUndefined(); + }); + + it('fails a pending request when the thread errors', () => { + const thread = fakeThread(); + const port = threadPort(thread); + const answers: { data: { id: number; error?: Error } }[] = []; + port.onmessage = (a) => answers.push(a as never); + port.postMessage({ id: 1 }); + thread.emit('error', new Error('out of memory')); + expect(answers).toHaveLength(1); + expect(answers[0]!.data.error?.message).toBe('out of memory'); + }); +}); + +describe('runChild', () => { + it('resolves the JSON the child prints', async () => { + const child = fakeChild(); + const p = runChild(child, '{}'); + child.stdout.write('{"id":"root"}'); + child.emit('close', 0, null); + await expect(p).resolves.toEqual({ id: 'root' }); + }); + + it('rejects, rather than throwing outside the promise, on truncated output', async () => { + const child = fakeChild(); + const p = runChild(child, '{}'); + child.stdout.write('{"id":'); + child.emit('close', 0, null); + await expect(p).rejects.toThrow(/invalid JSON/); + }); + + it('rejects with the signal when the child is killed silently', async () => { + const child = fakeChild(); + const p = runChild(child, '{}'); + child.emit('close', null, 'SIGKILL'); + await expect(p).rejects.toThrow(/killed by SIGKILL/); + }); + + it('survives EPIPE on stdin and reports the child stderr', async () => { + const child = fakeChild(); + const p = runChild(child, '{}'); + child.stdin.emit('error', Object.assign(new Error('write EPIPE'), { code: 'EPIPE' })); + child.stderr.write('boom'); + child.emit('close', 1, null); + await expect(p).rejects.toThrow(/boom/); + }); + + it('settles with the first failure only', async () => { + const child = fakeChild(); + const p = runChild(child, '{}'); + child.emit('error', new Error('spawn failed')); + child.emit('close', 1, null); + await expect(p).rejects.toThrow('spawn failed'); + }); +}); diff --git a/packages/viewer/src/explore/ExploreContext.test.tsx b/packages/viewer/src/explore/ExploreContext.test.tsx new file mode 100644 index 0000000..8460b28 --- /dev/null +++ b/packages/viewer/src/explore/ExploreContext.test.tsx @@ -0,0 +1,54 @@ +import { act, render } from '@testing-library/react'; +import { afterEach, describe, expect, it } from 'vitest'; +import { commerceApiLayout } from '../samples/commerce-api.layout'; +import { ExploreProvider, useExplore } from './ExploreContext'; +import type { Emphasis } from './emphasis'; +import type { ExploreAction } from './state'; + +const draft = commerceApiLayout.draft; +const first = draft.nodes[0]!; + +afterEach(() => history.replaceState(null, '', location.pathname)); + +function mount() { + const seen: Emphasis[] = []; + let send!: (a: ExploreAction) => void; + function Probe() { + const { emphasis, dispatch } = useExplore(); + seen.push(emphasis); + send = dispatch; + return null; + } + render( + + + , + ); + return { latest: () => seen[seen.length - 1]!, dispatch: (a: ExploreAction) => act(() => send(a)) }; +} + +describe('ExploreProvider emphasis', () => { + it('keeps the same emphasis through play and speed changes', () => { + const { latest, dispatch } = mount(); + const before = latest(); + dispatch({ type: 'togglePlay' }); + dispatch({ type: 'cycleSpeed' }); + expect(latest()).toBe(before); + }); + + it('rebuilds emphasis when the selection changes', () => { + const { latest, dispatch } = mount(); + const before = latest(); + dispatch({ type: 'select', id: first.id }); + expect(latest()).not.toBe(before); + expect(latest().nodes.get(first.id)).toBe('focus'); + }); + + it('rebuilds emphasis when a type is hidden', () => { + const { latest, dispatch } = mount(); + const before = latest(); + dispatch({ type: 'toggleType', nodeType: first.type }); + expect(latest()).not.toBe(before); + expect(latest().nodes.get(first.id)).toBe('dim'); + }); +}); diff --git a/packages/viewer/src/explore/ExploreContext.tsx b/packages/viewer/src/explore/ExploreContext.tsx index 38e721e..32fd7e7 100644 --- a/packages/viewer/src/explore/ExploreContext.tsx +++ b/packages/viewer/src/explore/ExploreContext.tsx @@ -49,7 +49,12 @@ export function ExploreProvider({ const known = useMemo(() => knownOf(draft), [draft]); const [state, dispatch] = useReducer(explore, undefined, () => (syncHash ? parseHash(location.hash, known) : INITIAL)); const graph = useMemo(() => buildGraph(draft.nodes.map((n) => n.id), draft.edges, { timed: draft.kind === 'sequence' }), [draft]); - const em = useMemo(() => emphasis(draft, graph, state), [draft, graph, state]); + const { peek, view, query, trace, selected, route, routing, hiddenTypes } = state; + // Only what emphasis reads: a speed or play toggle must not rebuild it, or the playing flow restarts from 0. + const em = useMemo( + () => emphasis(draft, graph, { peek, view, query, trace, selected, route, routing, hiddenTypes }), + [draft, graph, peek, view, query, trace, selected, route, routing, hiddenTypes], + ); const stateRef = useRef(state); stateRef.current = state; diff --git a/packages/viewer/src/explore/emphasis.ts b/packages/viewer/src/explore/emphasis.ts index 6d510e5..97f59f0 100644 --- a/packages/viewer/src/explore/emphasis.ts +++ b/packages/viewer/src/explore/emphasis.ts @@ -26,7 +26,10 @@ export function searchMatches(draft: DiagramDraft, query: string): DiagramNode[] return hits.map((n, i) => ({ n, i })).sort((a, b) => prefix(a.n) - prefix(b.n) || a.i - b.i).map((x) => x.n); } -export function emphasis(draft: DiagramDraft, graph: Graph, s: ExploreState): Emphasis { +/** The explorer state emphasis reads; playback fields (playing, speed) and reveal don't change what's lit. */ +export type EmphasisInput = Pick; + +export function emphasis(draft: DiagramDraft, graph: Graph, s: EmphasisInput): Emphasis { if (s.peek) return peekAt(draft, s.peek); const view = s.view ? new Set(draft.views?.find((v) => v.id === s.view)?.nodes ?? []) : null; const q = s.query?.trim() ? new Set(searchMatches(draft, s.query).map((n) => n.id)) : null; diff --git a/site/app/docs/cli/page.tsx b/site/app/docs/cli/page.tsx index 5dd2346..cb61962 100644 --- a/site/app/docs/cli/page.tsx +++ b/site/app/docs/cli/page.tsx @@ -79,7 +79,7 @@ export default function CliPage() {
-

The receipt is the only thing on stdout. Warnings go to stderr; with errors, nothing is written and the command exits 1.

+

The receipt is the only thing on stdout. Warnings go to stderr, and so does a notice when a large diagram starts laying out; with errors, nothing is written and the command exits 1.

serve

diff --git a/site/scripts/ignore-build.sh b/site/scripts/ignore-build.sh new file mode 100644 index 0000000..e8eb992 --- /dev/null +++ b/site/scripts/ignore-build.sh @@ -0,0 +1,10 @@ +#!/bin/sh +# Vercel's ignoreCommand (run from site/): exit 0 skips the build, 1 builds. Lives here because vercel.json caps the +# command at 256 characters. Builds when there's no last deploy to compare with, or its commit is missing from the +# shallow clone, or anything the site reads changed since it. +if [ -n "$VERCEL_GIT_PREVIOUS_SHA" ] && + git cat-file -e "$VERCEL_GIT_PREVIOUS_SHA^{commit}" 2>/dev/null && + git diff --quiet "$VERCEL_GIT_PREVIOUS_SHA" HEAD -- . ../packages ../skill ../package.json ../tsconfig.base.json ../bun.lock; then + exit 0 +fi +exit 1 diff --git a/site/vercel.json b/site/vercel.json index cc02795..9813c11 100644 --- a/site/vercel.json +++ b/site/vercel.json @@ -3,5 +3,6 @@ "framework": "nextjs", "installCommand": "cd .. && bun install --frozen-lockfile", "buildCommand": "cd .. && bun run --filter @stackmap/viewer build && cd site && bun run build", - "ignoreCommand": "if [ -n \"$VERCEL_GIT_PREVIOUS_SHA\" ] && git cat-file -e \"$VERCEL_GIT_PREVIOUS_SHA^{commit}\" 2>/dev/null && git diff --quiet \"$VERCEL_GIT_PREVIOUS_SHA\" HEAD -- . ../packages ../package.json ../tsconfig.base.json ../bun.lock; then exit 0; else exit 1; fi" + "git": { "deploymentEnabled": { "main": false } }, + "ignoreCommand": "sh scripts/ignore-build.sh" }