@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 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
+ ```
@@ -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/` (`/`, `/about`, `/pages`). For example, `pages/about.md` fails the build.
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.1.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/about.md ADDED
@@ -0,0 +1,5 @@
1
+ ---
2
+ title: About
3
+ ---
4
+
5
+ # About
@@ -14,7 +14,7 @@ print(A)
14
14
 
15
15
  Below are python editors created using \`\`\`python-editor ... \`\`\` code fence:
16
16
 
17
- ```python-editor
17
+ ```python-editor collapsed
18
18
  a = pie.point(1, 2, out='a')
19
19
  r = pie.scalar(1, out='r')
20
20
  c = pie.circle(a, r)
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
- /* ↺ draws small in most fonts. */
300
- .python-editor-reset {
301
- font-size: 1.125rem;
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
- padding: 0.5rem;
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
+ }
@@ -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
- /** An editable block of Python. Run draws its `pie.*` calls on the scope's canvas. */
31
- export function PythonEditor(props: { code: string; scope: PythonScope }) {
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');
@@ -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
- <PageTitle>{page.frontmatter.title}</PageTitle>
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
  );
@@ -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
- export default layout(import.meta.glob<LayoutModules[string]>("/layouts/index.tsx", { eager: true }), Home);
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 { PageTitle } from '~/components/PageTitle';
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
- <PageTitle>Pages</PageTitle>
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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
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
  }
@@ -1,10 +0,0 @@
1
- import { PageTitle } from "~/components/PageTitle";
2
-
3
- export default function About() {
4
- return (
5
- <main>
6
- <PageTitle>About</PageTitle>
7
- <h1>About</h1>
8
- </main>
9
- );
10
- }