@tinyvolt/pie 0.0.0-stage → 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.
Files changed (73) hide show
  1. package/.nvmrc +1 -0
  2. package/LICENSE +21 -0
  3. package/README.md +68 -2
  4. package/functions/midpoint.ts +29 -0
  5. package/functions/square.ts +20 -0
  6. package/init/index.js +87 -0
  7. package/init/site-agents.md +257 -0
  8. package/init/site-gitignore +28 -0
  9. package/init/site-tsconfig.json +20 -0
  10. package/init/site-vite.config.ts +6 -0
  11. package/layouts/header.tsx +16 -0
  12. package/layouts/index.tsx +10 -0
  13. package/nodes/square.tsx +36 -0
  14. package/package.json +63 -4
  15. package/pages/about.md +5 -0
  16. package/pages/example.md +7 -0
  17. package/pages/python-editor.md +29 -0
  18. package/public/favicon.ico +0 -0
  19. package/public/logo.svg +5 -0
  20. package/site.yml +1 -0
  21. package/src/app.css +387 -0
  22. package/src/app.tsx +30 -0
  23. package/src/authorStyles.ts +3 -0
  24. package/src/components/Header.tsx +16 -0
  25. package/src/components/PageMeta.tsx +40 -0
  26. package/src/components/PageTitle.tsx +10 -0
  27. package/src/components/SplitPane.tsx +170 -0
  28. package/src/components/icons.tsx +94 -0
  29. package/src/entry-client.tsx +4 -0
  30. package/src/entry-server.tsx +21 -0
  31. package/src/global.d.ts +7 -0
  32. package/src/lib/appDir.ts +4 -0
  33. package/src/lib/autodiff/index.ts +49 -0
  34. package/src/lib/layouts/layout.ts +20 -0
  35. package/src/lib/layouts/vitePlugin.ts +39 -0
  36. package/src/lib/markdown/components/CanvasPage.tsx +25 -0
  37. package/src/lib/markdown/components/MarkdownBody.tsx +42 -0
  38. package/src/lib/markdown/components/PythonEditor.tsx +254 -0
  39. package/src/lib/markdown/frontmatter.ts +83 -0
  40. package/src/lib/markdown/pages.ts +23 -0
  41. package/src/lib/markdown/render.ts +62 -0
  42. package/src/lib/markdown/vitePlugin.ts +94 -0
  43. package/src/lib/nodes/components/CenteredGrid.tsx +59 -0
  44. package/src/lib/nodes/components/NodeCanvas.tsx +58 -0
  45. package/src/lib/nodes/execute.ts +166 -0
  46. package/src/lib/nodes/functions/Node2NodeFunc.ts +307 -0
  47. package/src/lib/nodes/functions/array.ts +36 -0
  48. package/src/lib/nodes/functions/autodiff.ts +64 -0
  49. package/src/lib/nodes/functions/basic.ts +298 -0
  50. package/src/lib/nodes/functions/registry.ts +46 -0
  51. package/src/lib/nodes/functions/sideEffects.ts +193 -0
  52. package/src/lib/nodes/functions/vitePlugin.ts +40 -0
  53. package/src/lib/nodes/hooks/useCanvasResize.ts +38 -0
  54. package/src/lib/nodes/jax.ts +191 -0
  55. package/src/lib/nodes/render/renderLogic.tsx +306 -0
  56. package/src/lib/nodes/state/NameGenerator.ts +31 -0
  57. package/src/lib/nodes/state/Node.ts +211 -0
  58. package/src/lib/nodes/state/NodeStore.ts +110 -0
  59. package/src/lib/nodes/tensors/broadcast.ts +101 -0
  60. package/src/lib/nodes/tensors/tensor.ts +42 -0
  61. package/src/lib/python/lift.ts +105 -0
  62. package/src/lib/python/py/__pycache__/pie.cpython-314.pyc +0 -0
  63. package/src/lib/python/py/pie.py +231 -0
  64. package/src/lib/python/py/pie_mpl.py +15 -0
  65. package/src/lib/python/py/show_figures.py +5 -0
  66. package/src/lib/python/runner.ts +202 -0
  67. package/src/lib/python/worker.ts +109 -0
  68. package/src/lib/site/vitePlugin.ts +58 -0
  69. package/src/routes/[...slug].tsx +39 -0
  70. package/src/routes/index.tsx +24 -0
  71. package/src/routes/pages/index.tsx +20 -0
  72. package/styles/theme.css +27 -0
  73. package/vite.ts +96 -0
@@ -0,0 +1,254 @@
1
+ import { Accessor, Component, createSignal, For, onCleanup, onMount, Setter, Show } from 'solid-js';
2
+ import { Dynamic } from 'solid-js/web';
3
+ import { basicSetup, EditorView } from 'codemirror';
4
+ import { keymap } from '@codemirror/view';
5
+ import { python } from '@codemirror/lang-python';
6
+ import { CheckIcon, Chevron, CopyIcon, CrossIcon, PlayIcon, ResetIcon, StopIcon } from '~/components/icons';
7
+ import type { PythonScope, RunStatus } from '~/lib/python/runner';
8
+
9
+ const STATUS_TEXT: Record<RunStatus | 'idle', string> = {
10
+ idle: '',
11
+ waiting: 'Waiting for another block…',
12
+ loading: 'Loading Python…',
13
+ running: 'Running…',
14
+ finished: '',
15
+ stopped: '',
16
+ killed: '',
17
+ };
18
+
19
+ const END_NOTE: Partial<Record<RunStatus, string>> = {
20
+ stopped: 'Stopped.',
21
+ killed: 'Stopped. Python restarted, so variables from earlier blocks are gone.',
22
+ };
23
+
24
+ /** How the last run ended, shown in the toolbar. */
25
+ type RunResult = 'success' | 'error' | 'stopped';
26
+
27
+ const RESULT: Record<RunResult, { icon: Component; text: string }> = {
28
+ success: { icon: CheckIcon, text: 'Ran successfully' },
29
+ error: { icon: CrossIcon, text: 'Error' },
30
+ stopped: { icon: StopIcon, text: 'Stopped' },
31
+ };
32
+
33
+ const isActive = (status: RunStatus | 'idle') =>
34
+ status === 'waiting' || status === 'loading' || status === 'running';
35
+
36
+ /** One piece of a run's output. Text printed in a row is one entry. */
37
+ type OutputEntry =
38
+ | { type: 'text'; text: Accessor<string>; setText: Setter<string> }
39
+ | { type: 'plot'; png: string };
40
+
41
+ /**
42
+ * An editable block of Python. Run draws its `pie.*` calls on the scope's canvas.
43
+ * `collapsed`: start with only the toolbar showing.
44
+ */
45
+ export function PythonEditor(props: { code: string; collapsed?: boolean; scope: PythonScope }) {
46
+ let host!: HTMLDivElement;
47
+ let view: EditorView | undefined;
48
+ let stop: (() => void) | undefined;
49
+
50
+ const [status, setStatus] = createSignal<RunStatus | 'idle'>('idle');
51
+ const [output, setOutput] = createSignal<OutputEntry[]>([]);
52
+ const [error, setError] = createSignal('');
53
+ const [note, setNote] = createSignal('');
54
+ const [collapsed, setCollapsed] = createSignal(props.collapsed ?? false);
55
+ const [copied, setCopied] = createSignal(false);
56
+ const active = () => isActive(status());
57
+ const result = (): RunResult | undefined => {
58
+ if (status() === 'finished') return error() ? 'error' : 'success';
59
+ if (status() === 'stopped' || status() === 'killed') return 'stopped';
60
+ };
61
+ const initialCode = props.code.replace(/\n$/, '');
62
+
63
+ const addText = (text: string) => {
64
+ const last = output().at(-1);
65
+ if (last?.type === 'text') {
66
+ last.setText((prev) => prev + text);
67
+ return;
68
+ }
69
+ const [value, setValue] = createSignal(text);
70
+ setOutput((prev) => [...prev, { type: 'text', text: value, setText: setValue }]);
71
+ };
72
+
73
+ const run = () => {
74
+ if (active() || !view) return;
75
+ setOutput([]);
76
+ setError('');
77
+ setNote('');
78
+ stop = props.scope.run(
79
+ view.state.doc.toString(),
80
+ (next, err) => {
81
+ setStatus(next);
82
+ if (isActive(next)) return;
83
+ stop = undefined;
84
+ if (err) setError(err);
85
+ setNote(END_NOTE[next] ?? '');
86
+ },
87
+ addText,
88
+ (png) => setOutput((prev) => [...prev, { type: 'plot', png }]),
89
+ );
90
+ };
91
+
92
+ /** Printed text and the error, without plots or the stop note. */
93
+ const copyText = () =>
94
+ [
95
+ ...output().flatMap((entry) => (entry.type === 'text' ? [entry.text()] : [])),
96
+ error(),
97
+ ]
98
+ .map((part) => part.replace(/\n+$/, ''))
99
+ .filter(Boolean)
100
+ .join('\n');
101
+
102
+ let copiedTimer: ReturnType<typeof setTimeout> | undefined;
103
+ const copy = async () => {
104
+ try {
105
+ await navigator.clipboard.writeText(copyText());
106
+ } catch {
107
+ return; // Clipboard is only available on https or localhost.
108
+ }
109
+ setCopied(true);
110
+ clearTimeout(copiedTimer);
111
+ copiedTimer = setTimeout(() => setCopied(false), 1500);
112
+ };
113
+
114
+ /** Puts the original code back. Ctrl/Cmd+Z undoes it. */
115
+ const reset = () => {
116
+ if (!view) return;
117
+ view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: initialCode } });
118
+ };
119
+
120
+ const toggleCollapsed = () => {
121
+ setCollapsed((prev) => !prev);
122
+ // CodeMirror can't measure while hidden, so measure again once it's shown.
123
+ if (!collapsed()) view?.requestMeasure();
124
+ };
125
+
126
+ /** Drag the bar under the code to set the editor's height. */
127
+ const startResize = (e: PointerEvent) => {
128
+ if (!view || e.button !== 0) return;
129
+ e.preventDefault(); // no text selection while dragging
130
+ const handle = e.currentTarget as HTMLElement;
131
+ const editor = view.dom;
132
+ const startY = e.clientY;
133
+ const startHeight = editor.getBoundingClientRect().height;
134
+ // About one line, plus the 4px top and bottom padding CodeMirror puts on its content.
135
+ const minHeight = view.defaultLineHeight + 8;
136
+
137
+ const move = (ev: PointerEvent) => {
138
+ editor.style.maxHeight = 'none';
139
+ editor.style.height = `${Math.max(minHeight, startHeight + ev.clientY - startY)}px`;
140
+ };
141
+ const end = () => {
142
+ handle.removeEventListener('pointermove', move);
143
+ handle.removeEventListener('pointerup', end);
144
+ handle.removeEventListener('pointercancel', end);
145
+ };
146
+ handle.setPointerCapture(e.pointerId);
147
+ handle.addEventListener('pointermove', move);
148
+ handle.addEventListener('pointerup', end);
149
+ handle.addEventListener('pointercancel', end);
150
+ };
151
+
152
+ const runFromKeyboard = () => {
153
+ run();
154
+ return true;
155
+ };
156
+
157
+ onMount(() => {
158
+ view = new EditorView({
159
+ doc: initialCode,
160
+ extensions: [
161
+ // Before basicSetup, so these win over its Enter bindings.
162
+ keymap.of([
163
+ { key: 'Shift-Enter', run: runFromKeyboard },
164
+ { key: 'Mod-Enter', run: runFromKeyboard },
165
+ ]),
166
+ basicSetup,
167
+ python(),
168
+ ],
169
+ parent: host,
170
+ });
171
+ });
172
+
173
+ onCleanup(() => {
174
+ stop?.();
175
+ clearTimeout(copiedTimer);
176
+ view?.destroy();
177
+ });
178
+
179
+ return (
180
+ <div class="python-editor" classList={{ 'python-editor-collapsed': collapsed() }}>
181
+ <div class="python-editor-code" ref={host} />
182
+ <div class="python-editor-resize" title="Drag to resize" onPointerDown={startResize} />
183
+ <div class="python-editor-toolbar">
184
+ <button
185
+ type="button"
186
+ class="python-editor-button"
187
+ title={active() ? 'Stop' : 'Run (Shift+Enter)'}
188
+ aria-label={active() ? 'Stop' : 'Run'}
189
+ onClick={() => (active() ? stop?.() : run())}
190
+ >
191
+ {active() ? <StopIcon /> : <PlayIcon />}
192
+ </button>
193
+ <button
194
+ type="button"
195
+ class="python-editor-button python-editor-reset"
196
+ title="Reset code"
197
+ aria-label="Reset code"
198
+ onClick={reset}
199
+ >
200
+ <ResetIcon />
201
+ </button>
202
+ <span class="python-editor-status">{STATUS_TEXT[status()]}</span>
203
+ <Show when={result()}>
204
+ {(r) => (
205
+ <span class={`python-editor-result python-editor-result-${r()}`}>
206
+ <Dynamic component={RESULT[r()].icon} />
207
+ {RESULT[r()].text}
208
+ </span>
209
+ )}
210
+ </Show>
211
+ <button
212
+ type="button"
213
+ class="python-editor-button python-editor-collapse"
214
+ title={collapsed() ? 'Expand' : 'Collapse'}
215
+ aria-label={collapsed() ? 'Expand' : 'Collapse'}
216
+ aria-expanded={!collapsed()}
217
+ onClick={toggleCollapsed}
218
+ >
219
+ <Chevron direction={collapsed() ? 'down' : 'up'} />
220
+ </button>
221
+ </div>
222
+ <Show when={output().length > 0 || error() || note()}>
223
+ <div class="python-editor-output">
224
+ <Show when={copyText()}>
225
+ <button
226
+ type="button"
227
+ class="python-editor-button python-editor-copy"
228
+ title={copied() ? 'Copied' : 'Copy output'}
229
+ aria-label={copied() ? 'Copied' : 'Copy output'}
230
+ onClick={copy}
231
+ >
232
+ {copied() ? <CheckIcon /> : <CopyIcon />}
233
+ </button>
234
+ </Show>
235
+ <For each={output()}>
236
+ {(entry) =>
237
+ entry.type === 'text' ? (
238
+ <pre class="python-editor-text">{entry.text()}</pre>
239
+ ) : (
240
+ <img class="python-editor-plot" src={`data:image/png;base64,${entry.png}`} alt="Plot" />
241
+ )
242
+ }
243
+ </For>
244
+ <Show when={error() || note()}>
245
+ <pre class="python-editor-text">
246
+ <span class="python-editor-error">{error()}</span>
247
+ <span class="python-editor-note">{note()}</span>
248
+ </pre>
249
+ </Show>
250
+ </div>
251
+ </Show>
252
+ </div>
253
+ );
254
+ }
@@ -0,0 +1,83 @@
1
+ /** How one field is read: its value when absent, and how to check the raw text. */
2
+ export interface Field<T> {
3
+ default: T;
4
+ /** Throws a message that reads after the field name, e.g. "must be one of …". */
5
+ parse: (raw: string) => T;
6
+ }
7
+
8
+ export type Values<F extends Record<string, Field<unknown>>> = { [K in keyof F]: F[K]['default'] };
9
+
10
+ function oneOf<const T extends readonly string[]>(values: T, fallback: T[number]): Field<T[number]> {
11
+ return {
12
+ default: fallback,
13
+ parse(raw) {
14
+ if (!(values as readonly string[]).includes(raw)) {
15
+ throw new Error(`must be one of ${values.join(', ')}, got ${JSON.stringify(raw)}`);
16
+ }
17
+ return raw as T[number];
18
+ },
19
+ };
20
+ }
21
+
22
+ export function text(fallback: string): Field<string> {
23
+ return { default: fallback, parse: raw => raw };
24
+ }
25
+
26
+ /** Every frontmatter field a page can set. Keys not listed here are ignored. */
27
+ const FIELDS = {
28
+ canvas: oneOf(['left', 'right', 'top', 'bottom', 'none'], 'none'),
29
+ // Empty: the tab shows the site title alone.
30
+ title: text(''),
31
+ // Empty: `site.yml`'s `description`.
32
+ description: text(''),
33
+ };
34
+
35
+ export type Frontmatter = Values<typeof FIELDS>;
36
+ export type CanvasSide = Frontmatter['canvas'];
37
+
38
+ /**
39
+ * Reads flat `key: value` lines, skipping blank ones. Values may be wrapped in single or double quotes.
40
+ * Shared by frontmatter and `site.yml`.
41
+ */
42
+ export function parseKeyValues(text: string): Record<string, string> {
43
+ const raw: Record<string, string> = {};
44
+ for (const line of text.split(/\r?\n/)) {
45
+ if (line.trim() === '') continue;
46
+ const kv = /^([A-Za-z_][\w-]*)[ \t]*:[ \t]*(.*?)[ \t]*$/.exec(line);
47
+ if (!kv) throw new Error(`line is not \`key: value\`: ${JSON.stringify(line)}`);
48
+ const [, key, value] = kv;
49
+ if (Object.hasOwn(raw, key)) throw new Error(`key \`${key}\` appears twice`);
50
+ raw[key] = /^(["']).*\1$/.test(value) ? value.slice(1, -1) : value;
51
+ }
52
+ return raw;
53
+ }
54
+
55
+ /** Reads each of `fields` from `raw`, using its default when absent. Unknown keys are ignored. */
56
+ export function readFields<F extends Record<string, Field<unknown>>>(fields: F, raw: Record<string, string>): Values<F> {
57
+ const values: Record<string, unknown> = {};
58
+ for (const [key, field] of Object.entries(fields)) {
59
+ try {
60
+ values[key] = Object.hasOwn(raw, key) ? field.parse(raw[key]) : field.default;
61
+ } catch (e) {
62
+ throw new Error(`\`${key}\` ${(e as Error).message}`);
63
+ }
64
+ }
65
+ return values as Values<F>;
66
+ }
67
+
68
+ /** Splits off a `---` block at the very start of the markdown. No block: no fields, whole markdown as body. */
69
+ function splitFrontmatter(markdown: string): { raw: Record<string, string>; body: string } {
70
+ const match = /^---\r?\n([\s\S]*?)\r?\n?^---[ \t]*(?:\r?\n|$)/m.exec(markdown);
71
+ if (!match || match.index !== 0) return { raw: {}, body: markdown };
72
+ return { raw: parseKeyValues(match[1]), body: markdown.slice(match[0].length) };
73
+ }
74
+
75
+ /** Reads the frontmatter of the file at `path`. Throws, naming `path`, on a malformed block or a bad value. */
76
+ export function parseFrontmatter(path: string, markdown: string): { frontmatter: Frontmatter; body: string } {
77
+ try {
78
+ const { raw, body } = splitFrontmatter(markdown);
79
+ return { frontmatter: readFields(FIELDS, raw), body };
80
+ } catch (e) {
81
+ throw new Error(`${path}: ${(e as Error).message}`);
82
+ }
83
+ }
@@ -0,0 +1,23 @@
1
+ import type { Frontmatter } from './frontmatter';
2
+
3
+ /** One `pages/**\/*.md`, as `vitePlugin.ts` turns it into a module at build time. */
4
+ export interface Page {
5
+ frontmatter: Frontmatter;
6
+ html: string;
7
+ }
8
+
9
+ // Not eager: each page is its own file, downloaded only when that page is opened.
10
+ const loaders = import.meta.glob<Page>('/pages/**/*.md', { import: 'default' });
11
+
12
+ // Slug -> loader. `/pages/a/b.md` has the slug `a/b`.
13
+ const bySlug = new Map(
14
+ Object.entries(loaders).map(([path, load]) => [path.replace(/^\/pages\//, '').replace(/\.md$/, ''), load]),
15
+ );
16
+
17
+ export const slugs = [...bySlug.keys()];
18
+
19
+ /** `null` when no page has this slug. */
20
+ export async function loadPage(slug: string): Promise<Page | null> {
21
+ const load = bySlug.get(slug);
22
+ return load ? load() : null;
23
+ }
@@ -0,0 +1,62 @@
1
+ import katex from 'katex';
2
+ import { Marked, Renderer } from 'marked';
3
+
4
+ function renderMath(formula: string, displayMode: boolean): string {
5
+ return katex.renderToString(formula, { displayMode, throwOnError: false });
6
+ }
7
+
8
+ const marked = new Marked({
9
+ extensions: [
10
+ {
11
+ // `$$…$$` that fills its own lines.
12
+ name: 'mathblock',
13
+ level: 'block',
14
+ start(src) {
15
+ const at = src.indexOf('\n$$');
16
+ return at === -1 ? undefined : at + 1;
17
+ },
18
+ tokenizer(src) {
19
+ const match = /^\$\$([^$]+)\$\$[ \t]*(?:\n|$)/.exec(src);
20
+ if (!match) return undefined;
21
+ return { type: 'mathblock', raw: match[0], formula: match[1].trim() };
22
+ },
23
+ renderer(token) {
24
+ return `${renderMath(token.formula, true)}\n`;
25
+ },
26
+ },
27
+ {
28
+ // `$…$`, and `$$…$$` inside a line of text.
29
+ name: 'math',
30
+ level: 'inline',
31
+ start(src) {
32
+ const at = src.indexOf('$');
33
+ return at === -1 ? undefined : at;
34
+ },
35
+ tokenizer(src) {
36
+ const match = /^\$\$([^$]+)\$\$/.exec(src) ?? /^\$([^$]+)\$/.exec(src);
37
+ if (!match) return undefined;
38
+ return {
39
+ type: 'math',
40
+ raw: match[0],
41
+ formula: match[1].trim(),
42
+ displayMode: match[0].startsWith('$$'),
43
+ };
44
+ },
45
+ renderer(token) {
46
+ return renderMath(token.formula, token.displayMode);
47
+ },
48
+ },
49
+ ],
50
+ renderer: {
51
+ // ```python-editor collapsed: marked keeps only the first word, so mark the block for the editor here.
52
+ code(token) {
53
+ const [lang, ...flags] = (token.lang ?? '').split(/\s+/);
54
+ if (lang !== 'python-editor' || !flags.includes('collapsed')) return false;
55
+ return new Renderer().code(token).replace('<code ', '<code data-collapsed ');
56
+ },
57
+ },
58
+ });
59
+
60
+ export function renderMarkdown(markdown: string): string {
61
+ return marked.parse(markdown) as string;
62
+ }
@@ -0,0 +1,94 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import type { Plugin } from 'vite';
4
+ import { parseFrontmatter } from './frontmatter';
5
+ import type { Page } from './pages';
6
+ import { renderMarkdown } from './render';
7
+ import { APP_DIR } from '../appDir';
8
+
9
+ /**
10
+ * Turns each `pages/**\/*.md` into a module whose default export is a `Page`, at build time,
11
+ * so the browser gets finished HTML and never runs the markdown tools.
12
+ * Bad frontmatter fails the build; in dev it shows Vite's error screen.
13
+ */
14
+ export function markdownPages(): Plugin {
15
+ let root = '';
16
+
17
+ return {
18
+ name: 'markdown-pages',
19
+ enforce: 'pre',
20
+ configResolved(config) {
21
+ root = config.root;
22
+ },
23
+ transform(markdown, id) {
24
+ const file = path.relative(root, id.split('?')[0]);
25
+ if (!file.startsWith(`pages${path.sep}`) || !file.endsWith('.md')) return;
26
+ const { frontmatter, body } = parseFrontmatter(file, markdown);
27
+ const page: Page = { frontmatter, html: renderMarkdown(body) };
28
+ return { code: `export default ${JSON.stringify(page)};`, map: null };
29
+ },
30
+ };
31
+ }
32
+
33
+ // The route that serves markdown pages. Every other route wins over it.
34
+ const PAGE_ROUTE = '[...slug].tsx';
35
+
36
+ /**
37
+ * Fails the build when a `pages/**\/*.md` has the same URL as a route in `src/routes/`,
38
+ * e.g. `pages/about.md` and `src/routes/about.tsx`, or `pages/blog/a.md` and `src/routes/blog/[id].tsx`.
39
+ * Without this, the route wins and the page can't be reached.
40
+ */
41
+ export function checkPageRoutes(): Plugin {
42
+ let root = '';
43
+ let checked = false;
44
+
45
+ return {
46
+ name: 'check-page-routes',
47
+ apply: 'build',
48
+ configResolved(config) {
49
+ root = config.root;
50
+ },
51
+ // Runs once per build environment (client, ssr, ...); one check is enough.
52
+ buildStart() {
53
+ if (checked) return;
54
+ checked = true;
55
+ const routes = listFiles(path.join(APP_DIR, 'routes'))
56
+ .filter(file => /\.(tsx|ts|jsx|js)$/.test(file) && file !== PAGE_ROUTE)
57
+ .map(file => ({ file, pattern: routePattern(file) }));
58
+ const clashes: string[] = [];
59
+ for (const file of listFiles(path.join(root, 'pages')).filter(file => file.endsWith('.md'))) {
60
+ const url = file.replace(/\.md$/, '').split('/');
61
+ for (const route of routes) {
62
+ if (matches(route.pattern, url)) {
63
+ clashes.push(`pages/${file} (/${url.join('/')}) clashes with src/routes/${route.file}`);
64
+ }
65
+ }
66
+ }
67
+ if (clashes.length) this.error(`Markdown pages clash with routes:\n${clashes.join('\n')}`);
68
+ },
69
+ };
70
+ }
71
+
72
+ /** Every file under `dir`, as a path relative to it with `/` separators. */
73
+ function listFiles(dir: string): string[] {
74
+ return fs.readdirSync(dir, { recursive: true, withFileTypes: true })
75
+ .filter(entry => entry.isFile())
76
+ .map(entry => path.relative(dir, path.join(entry.parentPath, entry.name)).split(path.sep).join('/'));
77
+ }
78
+
79
+ /** `blog/(group)/[id]/index.tsx` -> `['blog', '[id]']`, following SolidStart's file routes. */
80
+ function routePattern(file: string): string[] {
81
+ const segments = file.replace(/\.(tsx|ts|jsx|js)$/, '').split('/').filter(s => !/^\(.*\)$/.test(s));
82
+ if (segments.at(-1) === 'index') segments.pop();
83
+ return segments;
84
+ }
85
+
86
+ /** Whether a route pattern matches a URL, both as segments. */
87
+ function matches(pattern: string[], url: string[]): boolean {
88
+ const [head, ...rest] = pattern;
89
+ if (head === undefined) return url.length === 0;
90
+ if (/^\[\[?\.\.\./.test(head)) return true; // [...x] or [[...x]]: matches the rest
91
+ if (head.startsWith('[[')) return matches(rest, url) || (url.length > 0 && matches(rest, url.slice(1)));
92
+ if (head.startsWith('[')) return url.length > 0 && matches(rest, url.slice(1));
93
+ return url[0] === head && matches(rest, url.slice(1));
94
+ }
@@ -0,0 +1,59 @@
1
+ import { JSX, createMemo } from 'solid-js';
2
+ import { MIN_GRID_UNIT } from '../state/NodeStore';
3
+
4
+ interface CenteredGridProps {
5
+ /** Canvas size in viewBox units. The origin sits at the center. */
6
+ width: number;
7
+ height: number;
8
+ /** Pixels per grid unit. */
9
+ unit: number;
10
+ children: JSX.Element;
11
+ }
12
+
13
+ const GRID_OPACITY = 0.15;
14
+ const AXIS_OPACITY = 0.4;
15
+
16
+ export function CenteredGrid(props: CenteredGridProps) {
17
+ const originX = () => props.width / 2;
18
+ const originY = () => props.height / 2;
19
+
20
+ // Local (post-transform) extents of the canvas relative to the origin.
21
+ const extents = createMemo(() => ({
22
+ xLeft: -originX(),
23
+ xRight: props.width - originX(),
24
+ yBottom: -(props.height - originY()),
25
+ yTop: originY(),
26
+ }));
27
+
28
+ const gridLines = createMemo(() => {
29
+ const lines: JSX.Element[] = [];
30
+ const { xLeft, xRight, yBottom, yTop } = extents();
31
+
32
+ if (props.unit >= MIN_GRID_UNIT) {
33
+ // Vertical lines (every 1 unit)
34
+ for (let x = Math.ceil(xLeft); x <= Math.floor(xRight); x += 1) {
35
+ if (x === 0) continue;
36
+ lines.push(<line x1={x} y1={yBottom} x2={x} y2={yTop} stroke="currentColor" stroke-width={1 / props.unit} opacity={GRID_OPACITY} />);
37
+ }
38
+
39
+ // Horizontal lines (every 1 unit)
40
+ for (let y = Math.ceil(yBottom); y <= Math.floor(yTop); y += 1) {
41
+ if (y === 0) continue;
42
+ lines.push(<line x1={xLeft} y1={y} x2={xRight} y2={y} stroke="currentColor" stroke-width={1 / props.unit} opacity={GRID_OPACITY} />);
43
+ }
44
+ }
45
+
46
+ // Axes (thicker lines)
47
+ lines.push(<line x1={xLeft} y1={0} x2={xRight} y2={0} stroke="currentColor" stroke-width={2 / props.unit} opacity={AXIS_OPACITY} />);
48
+ lines.push(<line x1={0} y1={yBottom} x2={0} y2={yTop} stroke="currentColor" stroke-width={2 / props.unit} opacity={AXIS_OPACITY} />);
49
+
50
+ return lines;
51
+ });
52
+
53
+ return (
54
+ <g transform={`translate(${originX()}, ${originY()}) scale(1, -1)`}>
55
+ {gridLines()}
56
+ {props.children}
57
+ </g>
58
+ );
59
+ }
@@ -0,0 +1,58 @@
1
+ import { For, createMemo, JSX } from 'solid-js';
2
+ import { NodeStore, type NodeSignal } from '../state/NodeStore';
3
+ import { getNodeDefinition } from '../state/Node';
4
+ import { CenteredGrid } from './CenteredGrid';
5
+ import { useCanvasResize } from '../hooks/useCanvasResize';
6
+
7
+ interface NodeCanvasProps {
8
+ store: NodeStore;
9
+ }
10
+
11
+ export function NodeCanvas(props: NodeCanvasProps): JSX.Element {
12
+ let svgContainerRef: HTMLDivElement | undefined;
13
+ useCanvasResize(() => svgContainerRef, props.store);
14
+
15
+ // Canvas size in grid units: the viewBox maps one unit to `unit` pixels.
16
+ const viewWidth = () => props.store.canvasBounds().width / props.store.unit();
17
+ const viewHeight = () => props.store.canvasBounds().height / props.store.unit();
18
+
19
+ // One pass over the store, partitioned into the two draw layers.
20
+ const layers = createMemo(() => {
21
+ const geometry: NodeSignal[] = [];
22
+ const points: NodeSignal[] = [];
23
+ for (const nodeSignal of Object.values(props.store.nodes)) {
24
+ if (!nodeSignal) continue;
25
+ const node = nodeSignal.signal();
26
+ if (!node || node.type === 'Dummy' || node.hidden) continue;
27
+ if (node.type === 'Point') points.push(nodeSignal);
28
+ else geometry.push(nodeSignal);
29
+ }
30
+ return { geometry, points };
31
+ });
32
+
33
+ const nodeGroup = (nodeSignal: NodeSignal) => {
34
+ const node = createMemo(() => nodeSignal?.signal());
35
+ // The element is rebuilt only when the type changes.
36
+ const type = createMemo(() => node()?.type);
37
+ return (
38
+ <g data-node-id={node()?.id}>
39
+ {/* The browser shows the `<title>` as a tooltip when hovering any shape in the group. */}
40
+ <title>{node()?.id}</title>
41
+ {type() && (getNodeDefinition(type()!)?.render?.(node, props.store.unit) ?? null)}
42
+ </g>
43
+ );
44
+ };
45
+
46
+ return (
47
+ <div class="node-canvas" ref={svgContainerRef}>
48
+ <svg viewBox={`0 0 ${viewWidth()} ${viewHeight()}`}>
49
+ <CenteredGrid width={viewWidth()} height={viewHeight()} unit={props.store.unit()}>
50
+ {/* Step 1: Draw non-`Point` nodes first */}
51
+ <For each={layers().geometry}>{(nodeSignal) => nodeGroup(nodeSignal)}</For>
52
+ {/* Step 2: Draw `Point` nodes above geometry */}
53
+ <For each={layers().points}>{(nodeSignal) => nodeGroup(nodeSignal)}</For>
54
+ </CenteredGrid>
55
+ </svg>
56
+ </div>
57
+ );
58
+ }