@tinyvolt/pie 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/README.md +12 -0
- package/init/site-agents.md +5 -1
- package/package.json +5 -1
- package/pages/about.md +5 -0
- package/pages/python-editor.md +1 -1
- package/src/app.css +48 -4
- package/src/components/PageMeta.tsx +40 -0
- package/src/components/icons.tsx +72 -0
- package/src/lib/markdown/components/MarkdownBody.tsx +2 -1
- package/src/lib/markdown/components/PythonEditor.tsx +95 -7
- package/src/lib/markdown/frontmatter.ts +2 -0
- package/src/lib/markdown/render.ts +9 -1
- package/src/lib/site/vitePlugin.ts +17 -2
- package/src/routes/[...slug].tsx +2 -1
- package/src/routes/index.tsx +11 -1
- package/src/routes/pages/index.tsx +2 -2
- package/vite.ts +37 -2
- package/src/routes/about.tsx +0 -10
package/README.md
CHANGED
|
@@ -55,3 +55,15 @@ pnpm build # build to .output/
|
|
|
55
55
|
pnpm start # run the built server
|
|
56
56
|
pnpm preview # preview the build with vite
|
|
57
57
|
```
|
|
58
|
+
|
|
59
|
+
## Publishing
|
|
60
|
+
|
|
61
|
+
Bump `version` in `package.json`, then at the repo root:
|
|
62
|
+
|
|
63
|
+
```sh
|
|
64
|
+
npm whoami
|
|
65
|
+
npm login # if npm whoami fails
|
|
66
|
+
npm publish --dry-run # list the files without publishing
|
|
67
|
+
npm publish
|
|
68
|
+
git tag pie@<version> && git push origin pie@<version>
|
|
69
|
+
```
|
package/init/site-agents.md
CHANGED
|
@@ -32,6 +32,8 @@ Do the work in the folders above. Don't edit `node_modules/@tinyvolt/pie/`: inst
|
|
|
32
32
|
Flat `key: value` lines. Values may be quoted. A key may appear only once.
|
|
33
33
|
|
|
34
34
|
- `title` (required): shown in the header and in every browser tab title.
|
|
35
|
+
- `url`: where the site is deployed, e.g. `https://example.com`. Needed for `sitemap.xml`, `robots.txt`, canonical links and `og:url`; without it the build skips them and warns.
|
|
36
|
+
- `description`: shown in search results and link previews for a page with no `description` of its own.
|
|
35
37
|
|
|
36
38
|
## Pages
|
|
37
39
|
|
|
@@ -47,13 +49,14 @@ canvas: left
|
|
|
47
49
|
```
|
|
48
50
|
|
|
49
51
|
- `title`: the tab title. Empty means the tab shows the site title alone.
|
|
52
|
+
- `description`: shown in search results and link previews. Empty means `site.yml`'s `description`.
|
|
50
53
|
- `canvas`: `left`, `right`, `top`, `bottom` or `none` (default). Adds a canvas on that side of the page.
|
|
51
54
|
|
|
52
55
|
Other keys are ignored. A bad value fails the build, and shows an error screen in dev.
|
|
53
56
|
|
|
54
57
|
Math uses KaTeX: `$…$` inline, and `$$…$$` on its own lines for a display block.
|
|
55
58
|
|
|
56
|
-
A page's URL must not match a route in `node_modules/@tinyvolt/pie/src/routes/` (`/`, `/
|
|
59
|
+
A page's URL must not match a route in `node_modules/@tinyvolt/pie/src/routes/` (`/`, `/pages`). For example, `pages/pages.md` fails the build.
|
|
57
60
|
|
|
58
61
|
## Python editors
|
|
59
62
|
|
|
@@ -71,6 +74,7 @@ pie.circle(a, r)
|
|
|
71
74
|
- All editors on one page share the same Python variables and the same canvas.
|
|
72
75
|
- `import numpy`, `scipy`, `matplotlib` and other [Pyodide packages](https://pyodide.org/en/0.28.2/usage/packages-in-pyodide.html) load on first use.
|
|
73
76
|
- `plt.show()` shows matplotlib figures under the editor.
|
|
77
|
+
- `` ```python-editor collapsed `` starts the editor collapsed: only its toolbar shows until the reader expands it. Other words after `python-editor` are ignored.
|
|
74
78
|
|
|
75
79
|
## Drawing on the canvas: `pie.*`
|
|
76
80
|
|
package/package.json
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tinyvolt/pie",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "A Markdown-based SSG for interactive science explainers. Create a site with: npx @tinyvolt/pie init my-site",
|
|
5
5
|
"type": "module",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/TinyVolt/pie-ssg.git"
|
|
9
|
+
},
|
|
6
10
|
"exports": {
|
|
7
11
|
"./vite": "./vite.ts"
|
|
8
12
|
},
|
package/pages/python-editor.md
CHANGED
package/src/app.css
CHANGED
|
@@ -256,6 +256,8 @@ main .katex-display {
|
|
|
256
256
|
/* Hidden until MarkdownBody swaps it for the editor; keeps its space so the text below doesn't jump. */
|
|
257
257
|
pre:has(> code.language-python-editor) {
|
|
258
258
|
visibility: hidden;
|
|
259
|
+
max-height: 20rem;
|
|
260
|
+
overflow: hidden;
|
|
259
261
|
}
|
|
260
262
|
|
|
261
263
|
.python-editor {
|
|
@@ -265,8 +267,24 @@ pre:has(> code.language-python-editor) {
|
|
|
265
267
|
font-family: var(--font-ui, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif);
|
|
266
268
|
}
|
|
267
269
|
|
|
270
|
+
/* Default cap on the code's height. Dragging the resize bar replaces it with a fixed height. */
|
|
268
271
|
.python-editor .cm-editor {
|
|
269
272
|
font-size: 0.875rem;
|
|
273
|
+
max-height: 20rem;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
.python-editor .cm-scroller {
|
|
277
|
+
overflow-y: auto;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
.python-editor-resize {
|
|
281
|
+
height: 6px;
|
|
282
|
+
cursor: row-resize;
|
|
283
|
+
touch-action: none;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
.python-editor-resize:hover {
|
|
287
|
+
background: var(--color-border, #ddd);
|
|
270
288
|
}
|
|
271
289
|
|
|
272
290
|
.python-editor-toolbar {
|
|
@@ -278,6 +296,9 @@ pre:has(> code.language-python-editor) {
|
|
|
278
296
|
}
|
|
279
297
|
|
|
280
298
|
.python-editor-button {
|
|
299
|
+
display: inline-flex;
|
|
300
|
+
align-items: center;
|
|
301
|
+
justify-content: center;
|
|
281
302
|
width: 1.75rem;
|
|
282
303
|
height: 1.75rem;
|
|
283
304
|
padding: 0;
|
|
@@ -296,9 +317,23 @@ pre:has(> code.language-python-editor) {
|
|
|
296
317
|
font-size: 0.875rem;
|
|
297
318
|
}
|
|
298
319
|
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
320
|
+
.python-editor-result {
|
|
321
|
+
display: inline-flex;
|
|
322
|
+
align-items: center;
|
|
323
|
+
gap: 0.25rem;
|
|
324
|
+
font-size: 0.875rem;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
.python-editor-result-success {
|
|
328
|
+
color: var(--color-success, #1a7f37);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
.python-editor-result-error {
|
|
332
|
+
color: var(--color-error, #c00);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
.python-editor-result-stopped {
|
|
336
|
+
color: var(--color-warning, #9a6700);
|
|
302
337
|
}
|
|
303
338
|
|
|
304
339
|
.python-editor-collapse {
|
|
@@ -307,6 +342,7 @@ pre:has(> code.language-python-editor) {
|
|
|
307
342
|
|
|
308
343
|
/* Collapsed: only the toolbar shows. Hidden with CSS so the editor keeps its code. */
|
|
309
344
|
.python-editor-collapsed .python-editor-code,
|
|
345
|
+
.python-editor-collapsed .python-editor-resize,
|
|
310
346
|
.python-editor-collapsed .python-editor-output {
|
|
311
347
|
display: none;
|
|
312
348
|
}
|
|
@@ -315,12 +351,20 @@ pre:has(> code.language-python-editor) {
|
|
|
315
351
|
border-top: none;
|
|
316
352
|
}
|
|
317
353
|
|
|
354
|
+
/* Right padding leaves room for the copy button so it doesn't cover the text. */
|
|
318
355
|
.python-editor-output {
|
|
319
|
-
|
|
356
|
+
position: relative;
|
|
357
|
+
padding: 0.5rem 2.75rem 0.5rem 0.5rem;
|
|
320
358
|
border-top: 1px solid var(--color-border, #ddd);
|
|
321
359
|
font-size: 0.875rem;
|
|
322
360
|
}
|
|
323
361
|
|
|
362
|
+
.python-editor-copy {
|
|
363
|
+
position: absolute;
|
|
364
|
+
top: 0.5rem;
|
|
365
|
+
right: 0.5rem;
|
|
366
|
+
}
|
|
367
|
+
|
|
324
368
|
.python-editor-text {
|
|
325
369
|
margin: 0;
|
|
326
370
|
max-height: 20rem;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { Link, Meta } from "@solidjs/meta";
|
|
2
|
+
import { useLocation } from "@solidjs/router";
|
|
3
|
+
import { Show } from "solid-js";
|
|
4
|
+
import site from "/site.yml";
|
|
5
|
+
import { PageTitle } from "./PageTitle";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The tab title plus the tags for search results and link previews. Each route renders it once:
|
|
9
|
+
* `@solidjs/meta` replaces a `<meta>` only when its content matches too, so a page's tag can't override a default.
|
|
10
|
+
* Empty `title` or `description` falls back to `site.yml`. The canonical link and `og:url` need `site.yml`'s `url`.
|
|
11
|
+
*/
|
|
12
|
+
export function PageMeta(props: { title?: string; description?: string }) {
|
|
13
|
+
const location = useLocation();
|
|
14
|
+
const ogTitle = () => props.title || site.title;
|
|
15
|
+
const description = () => props.description || site.description;
|
|
16
|
+
return (
|
|
17
|
+
<>
|
|
18
|
+
<PageTitle>{props.title}</PageTitle>
|
|
19
|
+
<Meta property="og:type" content="website" />
|
|
20
|
+
<Meta property="og:site_name" content={site.title} />
|
|
21
|
+
<Meta property="og:title" content={ogTitle()} />
|
|
22
|
+
<Show when={description()}>
|
|
23
|
+
{description => (
|
|
24
|
+
<>
|
|
25
|
+
<Meta name="description" content={description()} />
|
|
26
|
+
<Meta property="og:description" content={description()} />
|
|
27
|
+
</>
|
|
28
|
+
)}
|
|
29
|
+
</Show>
|
|
30
|
+
<Show when={site.url}>
|
|
31
|
+
{url => (
|
|
32
|
+
<>
|
|
33
|
+
<Link rel="canonical" href={url() + location.pathname} />
|
|
34
|
+
<Meta property="og:url" content={url() + location.pathname} />
|
|
35
|
+
</>
|
|
36
|
+
)}
|
|
37
|
+
</Show>
|
|
38
|
+
</>
|
|
39
|
+
);
|
|
40
|
+
}
|
package/src/components/icons.tsx
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { JSX } from 'solid-js';
|
|
2
|
+
|
|
1
3
|
export type Direction = 'left' | 'right' | 'up' | 'down';
|
|
2
4
|
|
|
3
5
|
const CHEVRON_TURN: Record<Direction, number> = { left: 0, up: 90, right: 180, down: 270 };
|
|
@@ -20,3 +22,73 @@ export function Chevron(props: { direction: Direction }) {
|
|
|
20
22
|
</svg>
|
|
21
23
|
);
|
|
22
24
|
}
|
|
25
|
+
|
|
26
|
+
/** The shared frame for the icons below: 24×24 drawing, lines in the text color. */
|
|
27
|
+
function Icon(props: { children: JSX.Element }) {
|
|
28
|
+
return (
|
|
29
|
+
<svg
|
|
30
|
+
class="icon"
|
|
31
|
+
width="14"
|
|
32
|
+
height="14"
|
|
33
|
+
viewBox="0 0 24 24"
|
|
34
|
+
fill="none"
|
|
35
|
+
stroke="currentColor"
|
|
36
|
+
stroke-width="2"
|
|
37
|
+
stroke-linecap="round"
|
|
38
|
+
stroke-linejoin="round"
|
|
39
|
+
aria-hidden="true"
|
|
40
|
+
>
|
|
41
|
+
{props.children}
|
|
42
|
+
</svg>
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function PlayIcon() {
|
|
47
|
+
return (
|
|
48
|
+
<Icon>
|
|
49
|
+
<path d="M6 4l14 8-14 8z" fill="currentColor" />
|
|
50
|
+
</Icon>
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function StopIcon() {
|
|
55
|
+
return (
|
|
56
|
+
<Icon>
|
|
57
|
+
<rect x="5" y="5" width="14" height="14" rx="1" fill="currentColor" />
|
|
58
|
+
</Icon>
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function ResetIcon() {
|
|
63
|
+
return (
|
|
64
|
+
<Icon>
|
|
65
|
+
<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" />
|
|
66
|
+
<path d="M3 3v5h5" />
|
|
67
|
+
</Icon>
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function CopyIcon() {
|
|
72
|
+
return (
|
|
73
|
+
<Icon>
|
|
74
|
+
<rect x="8" y="8" width="14" height="14" rx="2" />
|
|
75
|
+
<path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2" />
|
|
76
|
+
</Icon>
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function CheckIcon() {
|
|
81
|
+
return (
|
|
82
|
+
<Icon>
|
|
83
|
+
<path d="M20 6L9 17l-5-5" />
|
|
84
|
+
</Icon>
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function CrossIcon() {
|
|
89
|
+
return (
|
|
90
|
+
<Icon>
|
|
91
|
+
<path d="M18 6L6 18M6 6l12 12" />
|
|
92
|
+
</Icon>
|
|
93
|
+
);
|
|
94
|
+
}
|
|
@@ -30,9 +30,10 @@ export function MarkdownBody(props: { html: string; store?: NodeStore; class?: s
|
|
|
30
30
|
const pre = codeEl.parentElement!;
|
|
31
31
|
if (!pre.isConnected) return;
|
|
32
32
|
const code = codeEl.textContent ?? '';
|
|
33
|
+
const collapsed = codeEl.hasAttribute('data-collapsed');
|
|
33
34
|
const host = document.createElement('div');
|
|
34
35
|
pre.replaceWith(host);
|
|
35
|
-
disposers.push(render(() => <PythonEditor code={code} scope={scope} />, host));
|
|
36
|
+
disposers.push(render(() => <PythonEditor code={code} collapsed={collapsed} scope={scope} />, host));
|
|
36
37
|
});
|
|
37
38
|
});
|
|
38
39
|
});
|
|
@@ -1,7 +1,9 @@
|
|
|
1
|
-
import { Accessor, createSignal, For, onCleanup, onMount, Setter, Show } from 'solid-js';
|
|
1
|
+
import { Accessor, Component, createSignal, For, onCleanup, onMount, Setter, Show } from 'solid-js';
|
|
2
|
+
import { Dynamic } from 'solid-js/web';
|
|
2
3
|
import { basicSetup, EditorView } from 'codemirror';
|
|
3
4
|
import { keymap } from '@codemirror/view';
|
|
4
5
|
import { python } from '@codemirror/lang-python';
|
|
6
|
+
import { CheckIcon, Chevron, CopyIcon, CrossIcon, PlayIcon, ResetIcon, StopIcon } from '~/components/icons';
|
|
5
7
|
import type { PythonScope, RunStatus } from '~/lib/python/runner';
|
|
6
8
|
|
|
7
9
|
const STATUS_TEXT: Record<RunStatus | 'idle', string> = {
|
|
@@ -19,6 +21,15 @@ const END_NOTE: Partial<Record<RunStatus, string>> = {
|
|
|
19
21
|
killed: 'Stopped. Python restarted, so variables from earlier blocks are gone.',
|
|
20
22
|
};
|
|
21
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
|
+
|
|
22
33
|
const isActive = (status: RunStatus | 'idle') =>
|
|
23
34
|
status === 'waiting' || status === 'loading' || status === 'running';
|
|
24
35
|
|
|
@@ -27,8 +38,11 @@ type OutputEntry =
|
|
|
27
38
|
| { type: 'text'; text: Accessor<string>; setText: Setter<string> }
|
|
28
39
|
| { type: 'plot'; png: string };
|
|
29
40
|
|
|
30
|
-
/**
|
|
31
|
-
|
|
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 }) {
|
|
32
46
|
let host!: HTMLDivElement;
|
|
33
47
|
let view: EditorView | undefined;
|
|
34
48
|
let stop: (() => void) | undefined;
|
|
@@ -37,8 +51,13 @@ export function PythonEditor(props: { code: string; scope: PythonScope }) {
|
|
|
37
51
|
const [output, setOutput] = createSignal<OutputEntry[]>([]);
|
|
38
52
|
const [error, setError] = createSignal('');
|
|
39
53
|
const [note, setNote] = createSignal('');
|
|
40
|
-
const [collapsed, setCollapsed] = createSignal(false);
|
|
54
|
+
const [collapsed, setCollapsed] = createSignal(props.collapsed ?? false);
|
|
55
|
+
const [copied, setCopied] = createSignal(false);
|
|
41
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
|
+
};
|
|
42
61
|
const initialCode = props.code.replace(/\n$/, '');
|
|
43
62
|
|
|
44
63
|
const addText = (text: string) => {
|
|
@@ -70,6 +89,28 @@ export function PythonEditor(props: { code: string; scope: PythonScope }) {
|
|
|
70
89
|
);
|
|
71
90
|
};
|
|
72
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
|
+
|
|
73
114
|
/** Puts the original code back. Ctrl/Cmd+Z undoes it. */
|
|
74
115
|
const reset = () => {
|
|
75
116
|
if (!view) return;
|
|
@@ -82,6 +123,32 @@ export function PythonEditor(props: { code: string; scope: PythonScope }) {
|
|
|
82
123
|
if (!collapsed()) view?.requestMeasure();
|
|
83
124
|
};
|
|
84
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
|
+
|
|
85
152
|
const runFromKeyboard = () => {
|
|
86
153
|
run();
|
|
87
154
|
return true;
|
|
@@ -105,12 +172,14 @@ export function PythonEditor(props: { code: string; scope: PythonScope }) {
|
|
|
105
172
|
|
|
106
173
|
onCleanup(() => {
|
|
107
174
|
stop?.();
|
|
175
|
+
clearTimeout(copiedTimer);
|
|
108
176
|
view?.destroy();
|
|
109
177
|
});
|
|
110
178
|
|
|
111
179
|
return (
|
|
112
180
|
<div class="python-editor" classList={{ 'python-editor-collapsed': collapsed() }}>
|
|
113
181
|
<div class="python-editor-code" ref={host} />
|
|
182
|
+
<div class="python-editor-resize" title="Drag to resize" onPointerDown={startResize} />
|
|
114
183
|
<div class="python-editor-toolbar">
|
|
115
184
|
<button
|
|
116
185
|
type="button"
|
|
@@ -119,7 +188,7 @@ export function PythonEditor(props: { code: string; scope: PythonScope }) {
|
|
|
119
188
|
aria-label={active() ? 'Stop' : 'Run'}
|
|
120
189
|
onClick={() => (active() ? stop?.() : run())}
|
|
121
190
|
>
|
|
122
|
-
{active() ?
|
|
191
|
+
{active() ? <StopIcon /> : <PlayIcon />}
|
|
123
192
|
</button>
|
|
124
193
|
<button
|
|
125
194
|
type="button"
|
|
@@ -128,9 +197,17 @@ export function PythonEditor(props: { code: string; scope: PythonScope }) {
|
|
|
128
197
|
aria-label="Reset code"
|
|
129
198
|
onClick={reset}
|
|
130
199
|
>
|
|
131
|
-
|
|
200
|
+
<ResetIcon />
|
|
132
201
|
</button>
|
|
133
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>
|
|
134
211
|
<button
|
|
135
212
|
type="button"
|
|
136
213
|
class="python-editor-button python-editor-collapse"
|
|
@@ -139,11 +216,22 @@ export function PythonEditor(props: { code: string; scope: PythonScope }) {
|
|
|
139
216
|
aria-expanded={!collapsed()}
|
|
140
217
|
onClick={toggleCollapsed}
|
|
141
218
|
>
|
|
142
|
-
{collapsed() ? '
|
|
219
|
+
<Chevron direction={collapsed() ? 'down' : 'up'} />
|
|
143
220
|
</button>
|
|
144
221
|
</div>
|
|
145
222
|
<Show when={output().length > 0 || error() || note()}>
|
|
146
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>
|
|
147
235
|
<For each={output()}>
|
|
148
236
|
{(entry) =>
|
|
149
237
|
entry.type === 'text' ? (
|
|
@@ -28,6 +28,8 @@ const FIELDS = {
|
|
|
28
28
|
canvas: oneOf(['left', 'right', 'top', 'bottom', 'none'], 'none'),
|
|
29
29
|
// Empty: the tab shows the site title alone.
|
|
30
30
|
title: text(''),
|
|
31
|
+
// Empty: `site.yml`'s `description`.
|
|
32
|
+
description: text(''),
|
|
31
33
|
};
|
|
32
34
|
|
|
33
35
|
export type Frontmatter = Values<typeof FIELDS>;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import katex from 'katex';
|
|
2
|
-
import { Marked } from 'marked';
|
|
2
|
+
import { Marked, Renderer } from 'marked';
|
|
3
3
|
|
|
4
4
|
function renderMath(formula: string, displayMode: boolean): string {
|
|
5
5
|
return katex.renderToString(formula, { displayMode, throwOnError: false });
|
|
@@ -47,6 +47,14 @@ const marked = new Marked({
|
|
|
47
47
|
},
|
|
48
48
|
},
|
|
49
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
|
+
},
|
|
50
58
|
});
|
|
51
59
|
|
|
52
60
|
export function renderMarkdown(markdown: string): string {
|
|
@@ -1,17 +1,32 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
2
|
import type { Plugin } from 'vite';
|
|
3
|
-
import { parseKeyValues, readFields, text, type Values } from '../markdown/frontmatter';
|
|
3
|
+
import { parseKeyValues, readFields, text, type Field, type Values } from '../markdown/frontmatter';
|
|
4
|
+
|
|
5
|
+
/** An `http://` or `https://` address, without a trailing `/`. */
|
|
6
|
+
function address(): Field<string> {
|
|
7
|
+
return {
|
|
8
|
+
default: '',
|
|
9
|
+
parse(raw) {
|
|
10
|
+
if (!/^https?:\/\/[^\s/]+/.test(raw)) throw new Error(`must start with http:// or https://, got ${JSON.stringify(raw)}`);
|
|
11
|
+
return raw.replace(/\/+$/, '');
|
|
12
|
+
},
|
|
13
|
+
};
|
|
14
|
+
}
|
|
4
15
|
|
|
5
16
|
/** Every field `site.yml` can set. Keys not listed here are ignored. */
|
|
6
17
|
const FIELDS = {
|
|
7
18
|
/** Shown in the header and in every browser tab title. */
|
|
8
19
|
title: text(''),
|
|
20
|
+
/** Where the site is deployed, e.g. `https://example.com`. Empty: no sitemap, robots.txt, canonical links or `og:url`. */
|
|
21
|
+
url: address(),
|
|
22
|
+
/** The search-result description of a page that has no `description` of its own. */
|
|
23
|
+
description: text(''),
|
|
9
24
|
};
|
|
10
25
|
|
|
11
26
|
export type Site = Values<typeof FIELDS>;
|
|
12
27
|
|
|
13
28
|
/** Reads `site.yml`. Throws, naming the file, on a bad line or a missing `title`. */
|
|
14
|
-
function parseSite(file: string, yml: string): Site {
|
|
29
|
+
export function parseSite(file: string, yml: string): Site {
|
|
15
30
|
try {
|
|
16
31
|
const site = readFields(FIELDS, parseKeyValues(yml));
|
|
17
32
|
if (!site.title) throw new Error('`title` is required');
|
package/src/routes/[...slug].tsx
CHANGED
|
@@ -2,6 +2,7 @@ import { createAsync, useParams } from '@solidjs/router';
|
|
|
2
2
|
import { HttpStatusCode } from '@solidjs/start';
|
|
3
3
|
import { Match, Switch } from 'solid-js';
|
|
4
4
|
import 'katex/dist/katex.min.css';
|
|
5
|
+
import { PageMeta } from '~/components/PageMeta';
|
|
5
6
|
import { PageTitle } from '~/components/PageTitle';
|
|
6
7
|
import { CanvasPage } from '~/lib/markdown/components/CanvasPage';
|
|
7
8
|
import { MarkdownBody } from '~/lib/markdown/components/MarkdownBody';
|
|
@@ -27,7 +28,7 @@ export default function Page() {
|
|
|
27
28
|
const canvas = page.frontmatter.canvas;
|
|
28
29
|
return (
|
|
29
30
|
<>
|
|
30
|
-
<
|
|
31
|
+
<PageMeta title={page.frontmatter.title} description={page.frontmatter.description} />
|
|
31
32
|
{canvas === 'none' ? <MarkdownBody html={page.html} /> : <CanvasPage side={canvas} html={page.html} />}
|
|
32
33
|
</>
|
|
33
34
|
);
|
package/src/routes/index.tsx
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { PageMeta } from "~/components/PageMeta";
|
|
1
2
|
import { layout, type LayoutModules } from "~/lib/layouts/layout";
|
|
2
3
|
|
|
3
4
|
// Shown when the site has no `layouts/index.tsx`.
|
|
@@ -11,4 +12,13 @@ function Home() {
|
|
|
11
12
|
);
|
|
12
13
|
}
|
|
13
14
|
|
|
14
|
-
|
|
15
|
+
const Layout = layout(import.meta.glob<LayoutModules[string]>("/layouts/index.tsx", { eager: true }), Home);
|
|
16
|
+
|
|
17
|
+
export default function Index() {
|
|
18
|
+
return (
|
|
19
|
+
<>
|
|
20
|
+
<PageMeta />
|
|
21
|
+
<Layout />
|
|
22
|
+
</>
|
|
23
|
+
);
|
|
24
|
+
}
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { For } from 'solid-js';
|
|
2
|
-
import {
|
|
2
|
+
import { PageMeta } from '~/components/PageMeta';
|
|
3
3
|
import { slugs } from '~/lib/markdown/pages';
|
|
4
4
|
|
|
5
5
|
export default function Pages() {
|
|
6
6
|
return (
|
|
7
7
|
<main>
|
|
8
|
-
<
|
|
8
|
+
<PageMeta title="Pages" />
|
|
9
9
|
<ul>
|
|
10
10
|
<For each={slugs}>
|
|
11
11
|
{slug => (
|
package/vite.ts
CHANGED
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
// `pie()`: every Vite plugin a pie-ssg site needs. A site's `vite.config.ts` is `plugins: [pie()]`.
|
|
2
|
+
import fs from 'node:fs';
|
|
2
3
|
import path from 'node:path';
|
|
3
4
|
import type { Plugin, PluginOption } from 'vite';
|
|
4
5
|
import { nitro } from 'nitro/vite';
|
|
6
|
+
import type { NitroModule } from 'nitro/types';
|
|
5
7
|
import { solidStart } from '@solidjs/start/config';
|
|
6
8
|
import { APP_DIR } from './src/lib/appDir';
|
|
7
9
|
import { checkPageRoutes, markdownPages } from './src/lib/markdown/vitePlugin';
|
|
8
10
|
import { checkFunctions } from './src/lib/nodes/functions/vitePlugin';
|
|
9
11
|
import { checkLayouts } from './src/lib/layouts/vitePlugin';
|
|
10
|
-
import { siteConfig } from './src/lib/site/vitePlugin';
|
|
12
|
+
import { parseSite, siteConfig } from './src/lib/site/vitePlugin';
|
|
11
13
|
|
|
12
14
|
/**
|
|
13
15
|
* `~/...` in a site's own files (`functions/`, `nodes/`, `layouts/`) means the app's `src/`.
|
|
@@ -50,12 +52,45 @@ function dedupeSolid(): Plugin {
|
|
|
50
52
|
};
|
|
51
53
|
}
|
|
52
54
|
|
|
55
|
+
/**
|
|
56
|
+
* After prerendering, writes `sitemap.xml` (every prerendered page) and `robots.txt` (pointing to it).
|
|
57
|
+
* Both need `site.yml`'s `url`; without it they are skipped with a warning. A site's own `public/` file wins.
|
|
58
|
+
*/
|
|
59
|
+
function seoFiles(): NitroModule {
|
|
60
|
+
return {
|
|
61
|
+
name: 'pie-seo-files',
|
|
62
|
+
setup(nitro) {
|
|
63
|
+
nitro.hooks.hook('prerender:done', ({ prerenderedRoutes }) => {
|
|
64
|
+
const { url } = parseSite('site.yml', fs.readFileSync(path.join(nitro.options.rootDir, 'site.yml'), 'utf8'));
|
|
65
|
+
if (!url) {
|
|
66
|
+
nitro.logger.warn('site.yml has no `url`: skipped sitemap.xml, robots.txt, canonical links and og:url. Add e.g. `url: https://example.com` before deploying.');
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
const pages = prerenderedRoutes.filter(route => route.fileName?.endsWith('.html')).map(route => route.route).sort();
|
|
70
|
+
const escape = (s: string) => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
71
|
+
const write = (name: string, contents: string) => {
|
|
72
|
+
const file = path.join(nitro.options.output.publicDir, name);
|
|
73
|
+
if (!fs.existsSync(file)) fs.writeFileSync(file, contents);
|
|
74
|
+
};
|
|
75
|
+
write('sitemap.xml', [
|
|
76
|
+
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
77
|
+
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">',
|
|
78
|
+
...pages.map(page => ` <url><loc>${escape(url + page)}</loc></url>`),
|
|
79
|
+
'</urlset>',
|
|
80
|
+
'',
|
|
81
|
+
].join('\n'));
|
|
82
|
+
write('robots.txt', `User-agent: *\nAllow: /\n\nSitemap: ${url}/sitemap.xml\n`);
|
|
83
|
+
});
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
53
88
|
export function pie(): PluginOption[] {
|
|
54
89
|
return [
|
|
55
90
|
siteAlias(), optimizeDeps(), dedupeSolid(),
|
|
56
91
|
markdownPages(), checkPageRoutes(), checkFunctions(), checkLayouts(), siteConfig(),
|
|
57
92
|
// SolidStart resolves `appRoot` against the working directory.
|
|
58
93
|
solidStart({ appRoot: path.relative(process.cwd(), APP_DIR) }),
|
|
59
|
-
nitro({ prerender: { crawlLinks: true, routes: ['/', '/pages'] } }),
|
|
94
|
+
nitro({ prerender: { crawlLinks: true, routes: ['/', '/pages'] }, modules: [seoFiles()] }),
|
|
60
95
|
];
|
|
61
96
|
}
|