@tinyvolt/pie 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/.nvmrc +1 -0
  2. package/LICENSE +21 -0
  3. package/README.md +56 -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 +253 -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 +59 -4
  15. package/pages/example.md +7 -0
  16. package/pages/python-editor.md +29 -0
  17. package/public/favicon.ico +0 -0
  18. package/public/logo.svg +5 -0
  19. package/site.yml +1 -0
  20. package/src/app.css +343 -0
  21. package/src/app.tsx +30 -0
  22. package/src/authorStyles.ts +3 -0
  23. package/src/components/Header.tsx +16 -0
  24. package/src/components/PageTitle.tsx +10 -0
  25. package/src/components/SplitPane.tsx +170 -0
  26. package/src/components/icons.tsx +22 -0
  27. package/src/entry-client.tsx +4 -0
  28. package/src/entry-server.tsx +21 -0
  29. package/src/global.d.ts +7 -0
  30. package/src/lib/appDir.ts +4 -0
  31. package/src/lib/autodiff/index.ts +49 -0
  32. package/src/lib/layouts/layout.ts +20 -0
  33. package/src/lib/layouts/vitePlugin.ts +39 -0
  34. package/src/lib/markdown/components/CanvasPage.tsx +25 -0
  35. package/src/lib/markdown/components/MarkdownBody.tsx +41 -0
  36. package/src/lib/markdown/components/PythonEditor.tsx +166 -0
  37. package/src/lib/markdown/frontmatter.ts +81 -0
  38. package/src/lib/markdown/pages.ts +23 -0
  39. package/src/lib/markdown/render.ts +54 -0
  40. package/src/lib/markdown/vitePlugin.ts +94 -0
  41. package/src/lib/nodes/components/CenteredGrid.tsx +59 -0
  42. package/src/lib/nodes/components/NodeCanvas.tsx +58 -0
  43. package/src/lib/nodes/execute.ts +166 -0
  44. package/src/lib/nodes/functions/Node2NodeFunc.ts +307 -0
  45. package/src/lib/nodes/functions/array.ts +36 -0
  46. package/src/lib/nodes/functions/autodiff.ts +64 -0
  47. package/src/lib/nodes/functions/basic.ts +298 -0
  48. package/src/lib/nodes/functions/registry.ts +46 -0
  49. package/src/lib/nodes/functions/sideEffects.ts +193 -0
  50. package/src/lib/nodes/functions/vitePlugin.ts +40 -0
  51. package/src/lib/nodes/hooks/useCanvasResize.ts +38 -0
  52. package/src/lib/nodes/jax.ts +191 -0
  53. package/src/lib/nodes/render/renderLogic.tsx +306 -0
  54. package/src/lib/nodes/state/NameGenerator.ts +31 -0
  55. package/src/lib/nodes/state/Node.ts +211 -0
  56. package/src/lib/nodes/state/NodeStore.ts +110 -0
  57. package/src/lib/nodes/tensors/broadcast.ts +101 -0
  58. package/src/lib/nodes/tensors/tensor.ts +42 -0
  59. package/src/lib/python/lift.ts +105 -0
  60. package/src/lib/python/py/__pycache__/pie.cpython-314.pyc +0 -0
  61. package/src/lib/python/py/pie.py +231 -0
  62. package/src/lib/python/py/pie_mpl.py +15 -0
  63. package/src/lib/python/py/show_figures.py +5 -0
  64. package/src/lib/python/runner.ts +202 -0
  65. package/src/lib/python/worker.ts +109 -0
  66. package/src/lib/site/vitePlugin.ts +43 -0
  67. package/src/routes/[...slug].tsx +38 -0
  68. package/src/routes/about.tsx +10 -0
  69. package/src/routes/index.tsx +14 -0
  70. package/src/routes/pages/index.tsx +20 -0
  71. package/styles/theme.css +27 -0
  72. package/vite.ts +61 -0
@@ -0,0 +1,49 @@
1
+ import { grad, numpy as np } from '@jax-js/jax';
2
+
3
+ /**
4
+ * How to walk a graph whose items carry their own inputs.
5
+ * `inputs` gives undefined for an item that wasn't computed from others.
6
+ */
7
+ export interface Graph<T> {
8
+ id: (item: T) => string;
9
+ inputs: (item: T) => T[] | undefined;
10
+ /** Computes `item` again from new inputs. */
11
+ recompute: (item: T, inputs: T[]) => T;
12
+ }
13
+
14
+ /**
15
+ * `target` computed again, with the item whose id is `varId` swapped for
16
+ * `replacement`. Items that don't read that item, directly or through others,
17
+ * are returned as they are and not recomputed.
18
+ */
19
+ export function replay<T>(graph: Graph<T>, target: T, varId: string, replacement: T): T {
20
+ const done = new Map<string, T>();
21
+ const visit = (item: T): T => {
22
+ const id = graph.id(item);
23
+ if (id === varId) return replacement;
24
+ if (done.has(id)) return done.get(id)!;
25
+ const inputs = graph.inputs(item);
26
+ let result = item;
27
+ if (inputs) {
28
+ const newInputs = inputs.map(visit);
29
+ if (newInputs.some((input, i) => input !== inputs[i])) result = graph.recompute(item, newInputs);
30
+ }
31
+ done.set(id, result);
32
+ return result;
33
+ };
34
+ return visit(target);
35
+ }
36
+
37
+ /**
38
+ * df/dxᵢ for each xᵢ in `xs`.
39
+ *
40
+ * jax-js frees an array once it is used. This uses up `xs` and whatever `f`
41
+ * returns, so pass `.ref` copies of values you keep.
42
+ *
43
+ * Nesting works: `f` may itself call `gradient`. Any value the inner `f` reads
44
+ * from an outer one must be captured by `f`, not passed in as an extra argument —
45
+ * jax-js drops the gradient through arguments it is not differentiating.
46
+ */
47
+ export function gradient(f: (...xs: np.Array[]) => np.Array, xs: np.Array[]): np.Array[] {
48
+ return grad(f, { argnums: xs.map((_, i) => i) })(...xs);
49
+ }
@@ -0,0 +1,20 @@
1
+ import type { Component } from 'solid-js';
2
+
3
+ export type LayoutModules = Record<string, { default?: Component }>;
4
+
5
+ /**
6
+ * The default export of a `layouts/` file, given an `import.meta.glob` of that one file,
7
+ * or `builtIn` when the site has no such file. Throws, naming the file, when it has no default export.
8
+ */
9
+ export function layout(modules: LayoutModules, builtIn: Component): Component {
10
+ const [entry] = Object.entries(modules);
11
+ if (!entry) return builtIn;
12
+ const [file, mod] = entry;
13
+ if (typeof mod.default !== 'function') throw new Error(noDefaultExport(file.slice(1)));
14
+ return mod.default;
15
+ }
16
+
17
+ /** Shared with `vitePlugin.ts`, so dev and build say the same thing. */
18
+ export function noDefaultExport(file: string): string {
19
+ return `${file} must have a default export: a Solid component`;
20
+ }
@@ -0,0 +1,39 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { parseSync, type Plugin } from 'vite';
4
+ import { noDefaultExport } from './layout';
5
+
6
+ // Each `layouts/` file that replaces a built-in part of the site.
7
+ const LAYOUT_FILES = ['layouts/header.tsx', 'layouts/index.tsx'];
8
+
9
+ /**
10
+ * Fails the build when a `layouts/` file has no default export.
11
+ * Without this, the error only stops pages from prerendering.
12
+ */
13
+ export function checkLayouts(): Plugin {
14
+ let root = '';
15
+ let checked = false;
16
+
17
+ return {
18
+ name: 'check-layouts',
19
+ apply: 'build',
20
+ configResolved(config) {
21
+ root = config.root;
22
+ },
23
+ // Runs once per build environment (client, ssr, ...); one check is enough.
24
+ buildStart() {
25
+ if (checked) return;
26
+ checked = true;
27
+ const bad = LAYOUT_FILES.filter(file => {
28
+ const full = path.join(root, file);
29
+ if (!fs.existsSync(full)) return false;
30
+ const { module, errors } = parseSync(full, fs.readFileSync(full, 'utf8'));
31
+ // Vite reports syntax errors itself, with a better message.
32
+ if (errors.length) return false;
33
+ return !module.staticExports.some(e =>
34
+ e.entries.some(entry => entry.exportName.kind === 'Default' || entry.exportName.name === 'default'));
35
+ });
36
+ if (bad.length) this.error(bad.map(noDefaultExport).join('\n'));
37
+ },
38
+ };
39
+ }
@@ -0,0 +1,25 @@
1
+ import { SplitPane } from '~/components/SplitPane';
2
+ import { NodeCanvas } from '~/lib/nodes/components/NodeCanvas';
3
+ import { NodeStore } from '~/lib/nodes/state/NodeStore';
4
+ import type { CanvasSide } from '../frontmatter';
5
+ import { MarkdownBody } from './MarkdownBody';
6
+
7
+ /** A canvas on `side` of the page's text. `python-editor` blocks in the text draw on it. */
8
+ export function CanvasPage(props: { side: Exclude<CanvasSide, 'none'>; html: string }) {
9
+ const store = new NodeStore();
10
+
11
+ return (
12
+ <SplitPane
13
+ class="canvas-page"
14
+ side={props.side}
15
+ first={
16
+ <div class="canvas-page-canvas">
17
+ <NodeCanvas store={store} />
18
+ </div>
19
+ }
20
+ second={<MarkdownBody class="canvas-page-text" html={props.html} store={store} />}
21
+ firstLabel="canvas"
22
+ secondLabel="text"
23
+ />
24
+ );
25
+ }
@@ -0,0 +1,41 @@
1
+ import { onCleanup, onMount } from 'solid-js';
2
+ import { render } from 'solid-js/web';
3
+ import type { NodeStore } from '~/lib/nodes/state/NodeStore';
4
+ import { PythonScope } from '~/lib/python/runner';
5
+
6
+ const PYTHON_EDITOR_LANG = 'python-editor';
7
+
8
+ /**
9
+ * Rendered markdown. On the client, each ```python-editor block becomes a runnable editor;
10
+ * until then it shows as a plain code block. All editors on the page share Python variables.
11
+ * `store`: the canvas their `pie.*` calls draw on.
12
+ */
13
+ export function MarkdownBody(props: { html: string; store?: NodeStore; class?: string }) {
14
+ let ref!: HTMLElement;
15
+
16
+ onMount(() => {
17
+ const blocks = ref.querySelectorAll<HTMLElement>(`pre > code.language-${PYTHON_EDITOR_LANG}`);
18
+ if (blocks.length === 0) return;
19
+
20
+ const scope = new PythonScope(props.store);
21
+ const disposers: (() => void)[] = [];
22
+ onCleanup(() => {
23
+ disposers.forEach((dispose) => dispose());
24
+ scope.dispose();
25
+ });
26
+
27
+ // Loaded only on pages that have an editor.
28
+ import('./PythonEditor').then(({ PythonEditor }) => {
29
+ blocks.forEach((codeEl) => {
30
+ const pre = codeEl.parentElement!;
31
+ if (!pre.isConnected) return;
32
+ const code = codeEl.textContent ?? '';
33
+ const host = document.createElement('div');
34
+ pre.replaceWith(host);
35
+ disposers.push(render(() => <PythonEditor code={code} scope={scope} />, host));
36
+ });
37
+ });
38
+ });
39
+
40
+ return <main ref={ref} class={props.class} innerHTML={props.html} />;
41
+ }
@@ -0,0 +1,166 @@
1
+ import { Accessor, createSignal, For, onCleanup, onMount, Setter, Show } from 'solid-js';
2
+ import { basicSetup, EditorView } from 'codemirror';
3
+ import { keymap } from '@codemirror/view';
4
+ import { python } from '@codemirror/lang-python';
5
+ import type { PythonScope, RunStatus } from '~/lib/python/runner';
6
+
7
+ const STATUS_TEXT: Record<RunStatus | 'idle', string> = {
8
+ idle: '',
9
+ waiting: 'Waiting for another block…',
10
+ loading: 'Loading Python…',
11
+ running: 'Running…',
12
+ finished: '',
13
+ stopped: '',
14
+ killed: '',
15
+ };
16
+
17
+ const END_NOTE: Partial<Record<RunStatus, string>> = {
18
+ stopped: 'Stopped.',
19
+ killed: 'Stopped. Python restarted, so variables from earlier blocks are gone.',
20
+ };
21
+
22
+ const isActive = (status: RunStatus | 'idle') =>
23
+ status === 'waiting' || status === 'loading' || status === 'running';
24
+
25
+ /** One piece of a run's output. Text printed in a row is one entry. */
26
+ type OutputEntry =
27
+ | { type: 'text'; text: Accessor<string>; setText: Setter<string> }
28
+ | { type: 'plot'; png: string };
29
+
30
+ /** An editable block of Python. Run draws its `pie.*` calls on the scope's canvas. */
31
+ export function PythonEditor(props: { code: string; scope: PythonScope }) {
32
+ let host!: HTMLDivElement;
33
+ let view: EditorView | undefined;
34
+ let stop: (() => void) | undefined;
35
+
36
+ const [status, setStatus] = createSignal<RunStatus | 'idle'>('idle');
37
+ const [output, setOutput] = createSignal<OutputEntry[]>([]);
38
+ const [error, setError] = createSignal('');
39
+ const [note, setNote] = createSignal('');
40
+ const [collapsed, setCollapsed] = createSignal(false);
41
+ const active = () => isActive(status());
42
+ const initialCode = props.code.replace(/\n$/, '');
43
+
44
+ const addText = (text: string) => {
45
+ const last = output().at(-1);
46
+ if (last?.type === 'text') {
47
+ last.setText((prev) => prev + text);
48
+ return;
49
+ }
50
+ const [value, setValue] = createSignal(text);
51
+ setOutput((prev) => [...prev, { type: 'text', text: value, setText: setValue }]);
52
+ };
53
+
54
+ const run = () => {
55
+ if (active() || !view) return;
56
+ setOutput([]);
57
+ setError('');
58
+ setNote('');
59
+ stop = props.scope.run(
60
+ view.state.doc.toString(),
61
+ (next, err) => {
62
+ setStatus(next);
63
+ if (isActive(next)) return;
64
+ stop = undefined;
65
+ if (err) setError(err);
66
+ setNote(END_NOTE[next] ?? '');
67
+ },
68
+ addText,
69
+ (png) => setOutput((prev) => [...prev, { type: 'plot', png }]),
70
+ );
71
+ };
72
+
73
+ /** Puts the original code back. Ctrl/Cmd+Z undoes it. */
74
+ const reset = () => {
75
+ if (!view) return;
76
+ view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: initialCode } });
77
+ };
78
+
79
+ const toggleCollapsed = () => {
80
+ setCollapsed((prev) => !prev);
81
+ // CodeMirror can't measure while hidden, so measure again once it's shown.
82
+ if (!collapsed()) view?.requestMeasure();
83
+ };
84
+
85
+ const runFromKeyboard = () => {
86
+ run();
87
+ return true;
88
+ };
89
+
90
+ onMount(() => {
91
+ view = new EditorView({
92
+ doc: initialCode,
93
+ extensions: [
94
+ // Before basicSetup, so these win over its Enter bindings.
95
+ keymap.of([
96
+ { key: 'Shift-Enter', run: runFromKeyboard },
97
+ { key: 'Mod-Enter', run: runFromKeyboard },
98
+ ]),
99
+ basicSetup,
100
+ python(),
101
+ ],
102
+ parent: host,
103
+ });
104
+ });
105
+
106
+ onCleanup(() => {
107
+ stop?.();
108
+ view?.destroy();
109
+ });
110
+
111
+ return (
112
+ <div class="python-editor" classList={{ 'python-editor-collapsed': collapsed() }}>
113
+ <div class="python-editor-code" ref={host} />
114
+ <div class="python-editor-toolbar">
115
+ <button
116
+ type="button"
117
+ class="python-editor-button"
118
+ title={active() ? 'Stop' : 'Run (Shift+Enter)'}
119
+ aria-label={active() ? 'Stop' : 'Run'}
120
+ onClick={() => (active() ? stop?.() : run())}
121
+ >
122
+ {active() ? '■' : '▶'}
123
+ </button>
124
+ <button
125
+ type="button"
126
+ class="python-editor-button python-editor-reset"
127
+ title="Reset code"
128
+ aria-label="Reset code"
129
+ onClick={reset}
130
+ >
131
+ ↺
132
+ </button>
133
+ <span class="python-editor-status">{STATUS_TEXT[status()]}</span>
134
+ <button
135
+ type="button"
136
+ class="python-editor-button python-editor-collapse"
137
+ title={collapsed() ? 'Expand' : 'Collapse'}
138
+ aria-label={collapsed() ? 'Expand' : 'Collapse'}
139
+ aria-expanded={!collapsed()}
140
+ onClick={toggleCollapsed}
141
+ >
142
+ {collapsed() ? '▼' : '▲'}
143
+ </button>
144
+ </div>
145
+ <Show when={output().length > 0 || error() || note()}>
146
+ <div class="python-editor-output">
147
+ <For each={output()}>
148
+ {(entry) =>
149
+ entry.type === 'text' ? (
150
+ <pre class="python-editor-text">{entry.text()}</pre>
151
+ ) : (
152
+ <img class="python-editor-plot" src={`data:image/png;base64,${entry.png}`} alt="Plot" />
153
+ )
154
+ }
155
+ </For>
156
+ <Show when={error() || note()}>
157
+ <pre class="python-editor-text">
158
+ <span class="python-editor-error">{error()}</span>
159
+ <span class="python-editor-note">{note()}</span>
160
+ </pre>
161
+ </Show>
162
+ </div>
163
+ </Show>
164
+ </div>
165
+ );
166
+ }
@@ -0,0 +1,81 @@
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
+ };
32
+
33
+ export type Frontmatter = Values<typeof FIELDS>;
34
+ export type CanvasSide = Frontmatter['canvas'];
35
+
36
+ /**
37
+ * Reads flat `key: value` lines, skipping blank ones. Values may be wrapped in single or double quotes.
38
+ * Shared by frontmatter and `site.yml`.
39
+ */
40
+ export function parseKeyValues(text: string): Record<string, string> {
41
+ const raw: Record<string, string> = {};
42
+ for (const line of text.split(/\r?\n/)) {
43
+ if (line.trim() === '') continue;
44
+ const kv = /^([A-Za-z_][\w-]*)[ \t]*:[ \t]*(.*?)[ \t]*$/.exec(line);
45
+ if (!kv) throw new Error(`line is not \`key: value\`: ${JSON.stringify(line)}`);
46
+ const [, key, value] = kv;
47
+ if (Object.hasOwn(raw, key)) throw new Error(`key \`${key}\` appears twice`);
48
+ raw[key] = /^(["']).*\1$/.test(value) ? value.slice(1, -1) : value;
49
+ }
50
+ return raw;
51
+ }
52
+
53
+ /** Reads each of `fields` from `raw`, using its default when absent. Unknown keys are ignored. */
54
+ export function readFields<F extends Record<string, Field<unknown>>>(fields: F, raw: Record<string, string>): Values<F> {
55
+ const values: Record<string, unknown> = {};
56
+ for (const [key, field] of Object.entries(fields)) {
57
+ try {
58
+ values[key] = Object.hasOwn(raw, key) ? field.parse(raw[key]) : field.default;
59
+ } catch (e) {
60
+ throw new Error(`\`${key}\` ${(e as Error).message}`);
61
+ }
62
+ }
63
+ return values as Values<F>;
64
+ }
65
+
66
+ /** Splits off a `---` block at the very start of the markdown. No block: no fields, whole markdown as body. */
67
+ function splitFrontmatter(markdown: string): { raw: Record<string, string>; body: string } {
68
+ const match = /^---\r?\n([\s\S]*?)\r?\n?^---[ \t]*(?:\r?\n|$)/m.exec(markdown);
69
+ if (!match || match.index !== 0) return { raw: {}, body: markdown };
70
+ return { raw: parseKeyValues(match[1]), body: markdown.slice(match[0].length) };
71
+ }
72
+
73
+ /** Reads the frontmatter of the file at `path`. Throws, naming `path`, on a malformed block or a bad value. */
74
+ export function parseFrontmatter(path: string, markdown: string): { frontmatter: Frontmatter; body: string } {
75
+ try {
76
+ const { raw, body } = splitFrontmatter(markdown);
77
+ return { frontmatter: readFields(FIELDS, raw), body };
78
+ } catch (e) {
79
+ throw new Error(`${path}: ${(e as Error).message}`);
80
+ }
81
+ }
@@ -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,54 @@
1
+ import katex from 'katex';
2
+ import { Marked } 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
+ });
51
+
52
+ export function renderMarkdown(markdown: string): string {
53
+ return marked.parse(markdown) as string;
54
+ }
@@ -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
+ }