getfilepress 0.1.9 → 0.1.11

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 (34) hide show
  1. package/README.md +13 -4
  2. package/package.json +6 -3
  3. package/packages/app/src/lib/genie/GeniePanel.svelte +290 -21
  4. package/packages/app/src/lib/genie/ops.ts +11 -1
  5. package/packages/app/src/lib/genie/store.ts +54 -1
  6. package/packages/app/src/lib/theme-entry.ts +2 -1
  7. package/packages/app/src/routes/+error.svelte +38 -0
  8. package/packages/app/src/routes/posts/[slug]/+page.svelte +2 -1
  9. package/packages/app/src/site-theme.d.ts +3 -0
  10. package/packages/app/vite-plugin-genie.ts +45 -1
  11. package/packages/app/vite.config.ts +38 -7
  12. package/packages/core/src/lib/components/PostCard.svelte +2 -1
  13. package/packages/core/src/lib/config.ts +47 -1
  14. package/packages/core/src/lib/content/parse.ts +2 -0
  15. package/packages/core/src/lib/content/types.ts +2 -0
  16. package/packages/core/src/lib/format.ts +11 -0
  17. package/packages/core/src/lib/index.ts +7 -3
  18. package/packages/core/src/lib/redirects.ts +88 -0
  19. package/packages/core/src/lib/server.ts +9 -2
  20. package/packages/core/src/lib/styles/presets/essay.css +2 -0
  21. package/packages/core/src/lib/styles/presets/folio.css +27 -0
  22. package/packages/core/src/lib/styles/presets/ink.css +28 -0
  23. package/packages/core/src/lib/styles/theme.css +37 -0
  24. package/packages/import/src/cli.ts +1 -0
  25. package/packages/import/src/extract.ts +15 -2
  26. package/packages/import/src/ir.ts +2 -0
  27. package/packages/import/src/ollama.ts +332 -77
  28. package/packages/import/src/redirects.ts +17 -0
  29. package/packages/import/src/write-site.ts +25 -3
  30. package/scripts/copy-path-mounts.mjs +30 -1
  31. package/scripts/create-site.mjs +1 -0
  32. package/scripts/filepress.mjs +24 -14
  33. package/scripts/new-post.ts +125 -0
  34. package/scripts/preview.mjs +95 -0
package/README.md CHANGED
@@ -96,6 +96,7 @@ export default defineFilepressConfig({
96
96
  { label: 'RSS', href: '/rss.xml' },
97
97
  { label: 'GitHub', href: 'https://github.com/acme/site', icon: 'github' }
98
98
  ],
99
+ theme: 'essay', // or 'ink' | 'folio'; site theme.css still wins last
99
100
  newsletter: {
100
101
  url: 'https://buttondown.email/me',
101
102
  blurb: 'Occasional notes.',
@@ -124,8 +125,9 @@ paths: [
124
125
 
125
126
  ## Theming
126
127
 
127
- The engine ships a default Essay look. To restyle a site, add `theme.css` next to
128
- `filepress.config.ts`. It loads after the default theme, so you can override CSS
128
+ The engine ships a default Essay look, plus two token presets (`ink`, `folio`)
129
+ via config `theme`. To restyle a site, add `theme.css` next to
130
+ `filepress.config.ts`. It loads last, so you can override CSS
129
131
  variables and structural classes without forking the app.
130
132
 
131
133
  In local `filepress dev` / `pnpm dev`, **Genie Mode** (floating FAB) is a design cockpit:
@@ -147,7 +149,14 @@ Token and class reference: [`docs/THEME.md`](docs/THEME.md).
147
149
 
148
150
  ## Writing a post
149
151
 
150
- Add a file under the site's `posts/` directory:
152
+ From the site root (or with `--site` / `--root` in this monorepo):
153
+
154
+ ```bash
155
+ filepress new "My Post"
156
+ filepress new "My Post" --draft
157
+ ```
158
+
159
+ That writes `posts/YYYY-MM-DD-my-post.md`. Or add a file by hand:
151
160
 
152
161
  ```markdown
153
162
  ---
@@ -174,7 +183,7 @@ Body content in **Markdown**.
174
183
  | `draft` | no | Hidden from production listings/feeds/sitemap; still builds at its URL. Listed with a Draft label under `pnpm dev` (localhost). |
175
184
  | `updated` | no | Shown when different from `date`. |
176
185
 
177
- **Images:** put files in `static/images/posts/<slug>/` and reference them as `/images/posts/<slug>/photo.jpg`.
186
+ **Images:** put files in `static/images/posts/<slug>/` and reference them as `/images/posts/<slug>/photo.jpg`. The demo site’s [Images in posts](sites/demo/posts/images-in-posts.md) post is a worked example.
178
187
 
179
188
  **Captions:** an image alone on a line becomes a `<figure>`; the title attribute is the caption:
180
189
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "getfilepress",
3
- "version": "0.1.9",
3
+ "version": "0.1.11",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "FilePress — file-based Markdown blog engine. Link this package from a content-only site (config + posts), then run `filepress build`.",
@@ -34,6 +34,8 @@
34
34
  },
35
35
  "files": [
36
36
  "scripts/filepress.mjs",
37
+ "scripts/new-post.ts",
38
+ "scripts/preview.mjs",
37
39
  "scripts/copy-path-mounts.mjs",
38
40
  "scripts/create-site.mjs",
39
41
  "scripts/ensure-lease.mjs",
@@ -49,7 +51,7 @@
49
51
  "scripts": {
50
52
  "postinstall": "node scripts/postinstall.mjs",
51
53
  "filepress": "node scripts/filepress.mjs",
52
- "test": "pnpm --filter @filepress/core test && pnpm --filter @filepress/import test && pnpm --filter @filepress/app test && tsx --test scripts/sync-sibling-sites.test.ts",
54
+ "test": "pnpm --filter @filepress/core test && pnpm --filter @filepress/import test && pnpm --filter @filepress/app test && tsx --test scripts/sync-sibling-sites.test.ts scripts/new-post.test.ts scripts/preview.test.ts",
53
55
  "check": "pnpm filepress check --site demo",
54
56
  "build": "pnpm filepress build --site demo",
55
57
  "build:demo": "pnpm filepress build --site demo",
@@ -57,6 +59,7 @@
57
59
  "ship": "pnpm build:www && wrangler pages deploy sites/getfilepress/build --project-name getfilepress",
58
60
  "deploy:www": "pnpm ship",
59
61
  "dev": "node scripts/ensure-lease.mjs demo 5179 && pnpm filepress dev --site demo --host 0.0.0.0",
62
+ "serve": "node scripts/ensure-lease.mjs demo 5179 && pnpm filepress dev --site demo --host 0.0.0.0",
60
63
  "dev:demo": "node scripts/ensure-lease.mjs demo 5179 && pnpm filepress dev --site demo --host 0.0.0.0",
61
64
  "dev:www": "node scripts/ensure-lease.mjs getfilepress 5180 && pnpm filepress dev --site getfilepress --host 0.0.0.0",
62
65
  "create-site": "node scripts/create-site.mjs",
@@ -79,7 +82,7 @@
79
82
  "gray-matter": "^4.0.3",
80
83
  "highlight.js": "^11.11.1",
81
84
  "linkedom": "^0.18.12",
82
- "ollanet": "^0.4.0",
85
+ "ollanet": "^0.6.6",
83
86
  "rehype-autolink-headings": "^7.1.0",
84
87
  "rehype-highlight": "^7.0.2",
85
88
  "rehype-raw": "^7.0.0",
@@ -66,6 +66,33 @@
66
66
  let cfgLede = $state('');
67
67
  let cfgTagline = $state('');
68
68
  let cfgLogo = $state('');
69
+ let renamingId = $state<string | null>(null);
70
+ let renameDraft = $state('');
71
+ let jobNote = $state('');
72
+ let jobSecs = $state(0);
73
+ let jobTimer: ReturnType<typeof setInterval> | null = null;
74
+
75
+ function formatWait(secs: number): string {
76
+ const m = Math.floor(secs / 60);
77
+ const r = secs % 60;
78
+ return m ? `${m}m ${String(r).padStart(2, '0')}s` : `${r}s`;
79
+ }
80
+
81
+ function beginJob(note: string) {
82
+ jobNote = note;
83
+ jobSecs = 0;
84
+ if (jobTimer) clearInterval(jobTimer);
85
+ jobTimer = setInterval(() => {
86
+ jobSecs += 1;
87
+ }, 1000);
88
+ }
89
+
90
+ function endJob() {
91
+ if (jobTimer) clearInterval(jobTimer);
92
+ jobTimer = null;
93
+ jobNote = '';
94
+ jobSecs = 0;
95
+ }
69
96
 
70
97
  async function api(path: string, init?: RequestInit) {
71
98
  const res = await fetch(`/__filepress/genie${path}`, {
@@ -129,6 +156,89 @@
129
156
  }
130
157
  }
131
158
 
159
+ function versionWhen(v: VersionRow) {
160
+ if (v.id === 'baseline') return 'pre-Genie snapshot';
161
+ const d = new Date(v.createdAt);
162
+ if (Number.isNaN(d.getTime())) return v.createdAt;
163
+ return d.toLocaleString(undefined, {
164
+ month: 'short',
165
+ day: 'numeric',
166
+ hour: '2-digit',
167
+ minute: '2-digit'
168
+ });
169
+ }
170
+
171
+ function startRename(v: VersionRow) {
172
+ renamingId = v.id;
173
+ renameDraft = v.label;
174
+ }
175
+
176
+ async function toggleStar(v: VersionRow) {
177
+ if (v.id === 'baseline') return;
178
+ loading = true;
179
+ error = '';
180
+ try {
181
+ await api('/star', {
182
+ method: 'POST',
183
+ body: JSON.stringify({ versionId: v.id, starred: !v.starred })
184
+ });
185
+ await refresh();
186
+ } catch (e) {
187
+ error = e instanceof Error ? e.message : String(e);
188
+ loading = false;
189
+ }
190
+ }
191
+
192
+ async function saveLabel(v: VersionRow) {
193
+ const label = renameDraft.trim();
194
+ if (!label || label === v.label) {
195
+ renamingId = null;
196
+ return;
197
+ }
198
+ loading = true;
199
+ error = '';
200
+ try {
201
+ await api('/label', { method: 'POST', body: JSON.stringify({ versionId: v.id, label }) });
202
+ renamingId = null;
203
+ await refresh();
204
+ } catch (e) {
205
+ error = e instanceof Error ? e.message : String(e);
206
+ loading = false;
207
+ }
208
+ }
209
+
210
+ async function duplicate(v: VersionRow) {
211
+ loading = true;
212
+ error = '';
213
+ try {
214
+ await api('/duplicate', { method: 'POST', body: JSON.stringify({ versionId: v.id }) });
215
+ await refresh();
216
+ } catch (e) {
217
+ error = e instanceof Error ? e.message : String(e);
218
+ loading = false;
219
+ }
220
+ }
221
+
222
+ async function removeVersion(v: VersionRow) {
223
+ if (v.id === 'baseline') return;
224
+ if (
225
+ !confirm(
226
+ `Delete “${v.label}”? This only removes the snapshot under .filepress-genie/. The working tree stays as it is.`
227
+ )
228
+ ) {
229
+ return;
230
+ }
231
+ loading = true;
232
+ error = '';
233
+ try {
234
+ await api('/delete', { method: 'POST', body: JSON.stringify({ versionId: v.id }) });
235
+ await refresh();
236
+ } catch (e) {
237
+ error = e instanceof Error ? e.message : String(e);
238
+ loading = false;
239
+ }
240
+ }
241
+
132
242
  async function applyStock() {
133
243
  loading = true;
134
244
  error = '';
@@ -253,6 +363,11 @@
253
363
  async function runInspire() {
254
364
  loading = true;
255
365
  error = '';
366
+ beginJob(
367
+ useLlm
368
+ ? `Inspire + ${selectedModel || 'Ollama'} — first load of a 12B model can take several minutes`
369
+ : 'Crawling inspiration URLs'
370
+ );
256
371
  try {
257
372
  await api('/inspire', {
258
373
  method: 'POST',
@@ -270,12 +385,17 @@
270
385
  } catch (e) {
271
386
  error = e instanceof Error ? e.message : String(e);
272
387
  loading = false;
388
+ } finally {
389
+ endJob();
273
390
  }
274
391
  }
275
392
 
276
393
  async function runRefine() {
277
394
  loading = true;
278
395
  error = '';
396
+ beginJob(
397
+ `Asking ${selectedModel || 'Ollama'} — first load of a 12B model can take several minutes`
398
+ );
279
399
  try {
280
400
  await api('/refine', {
281
401
  method: 'POST',
@@ -289,6 +409,8 @@
289
409
  } catch (e) {
290
410
  error = e instanceof Error ? e.message : String(e);
291
411
  loading = false;
412
+ } finally {
413
+ endJob();
292
414
  }
293
415
  }
294
416
 
@@ -324,12 +446,18 @@
324
446
  <strong>Genie</strong>
325
447
  <p class="genie-tagline">Try a look → activate → commit baked files</p>
326
448
  </div>
327
- <button type="button" class="genie-x" onclick={() => (open = false)}>Close</button>
449
+ <button type="button" class="genie-x" onclick={() => { open = false; endJob(); }}>Close</button>
328
450
  </header>
329
451
 
330
452
  {#if error}
331
453
  <p class="genie-err">{error}</p>
332
454
  {/if}
455
+ {#if jobNote}
456
+ <p class="genie-progress" aria-live="polite">
457
+ {jobNote} · {formatWait(jobSecs)} elapsed. Watch the <code>filepress dev</code> terminal for
458
+ “still generating…”.
459
+ </p>
460
+ {/if}
333
461
 
334
462
  {#if health}
335
463
  <div class="genie-shell">
@@ -423,10 +551,17 @@
423
551
  disabled={loading || scanning || !hostIsReady() || !refinePrompt.trim()}
424
552
  onclick={runRefine}
425
553
  >
426
- {loading ? 'Working' : 'Refine & activate'}
554
+ {loading ? `Asking Ollama${formatWait(jobSecs)}` : 'Refine & activate'}
427
555
  </button>
428
556
  <p class="genie-muted">
429
- Tip: set <code>FILEPRESS_OLLAMA_MODEL</code> / <code>OLLAMA_HOST</code> for defaults;
557
+ The raw Ollama JSON is printed in the <code>filepress dev</code> terminal and saved to
558
+ <code>.filepress-genie/last-ollama.json</code>. “Icy / Antarctica / bright” means a
559
+ light page — activate <strong>baseline</strong> in History first if a prior dark look
560
+ is still the seed.
561
+ <code>gemma4:12b</code> often spends the first few minutes loading into VRAM — that is
562
+ normal. Progress prints in the filepress terminal every 15s. Raise the budget with
563
+ <code>FILEPRESS_OLLAMA_TIMEOUT_MS</code> (default 10 minutes). Tip: set
564
+ <code>FILEPRESS_OLLAMA_MODEL</code> / <code>OLLAMA_HOST</code> for defaults;
430
565
  <a href="https://ollanet.dev" target="_blank" rel="noreferrer">ollanet</a> finds other
431
566
  boxes. Finetuna can tune a named variant.
432
567
  </p>
@@ -605,25 +740,82 @@
605
740
  <h3>Versions</h3>
606
741
  <p class="genie-howto">
607
742
  Every Genie action saves a snapshot under <code>.filepress-genie/</code> (gitignored).
608
- Activate to bake into the working tree; commit when you like it. <code>baseline</code> is
609
- the pre-Genie look.
743
+ Star keepers, rename, duplicate, then activate to bake into the working tree.
744
+ <code>baseline</code> is the pre-Genie look and cannot be deleted.
610
745
  </p>
611
746
  <p class="genie-muted">
612
747
  Active: {health.active?.versionId ?? '(none)'}
748
+ {#if health.versions.length}
749
+ · {health.versions.length} saved
750
+ {/if}
613
751
  </p>
614
752
  <ul class="genie-versions">
615
753
  {#each health.versions as v (v.id)}
616
- <li class:active={health.active?.versionId === v.id}>
617
- <button
618
- type="button"
619
- disabled={loading || health.active?.versionId === v.id}
620
- onclick={() => activate(v.id)}
621
- >
622
- {v.label}
623
- </button>
624
- <span class="genie-muted"
625
- >{v.id === 'baseline' ? 'baseline' : v.createdAt.slice(11, 19)}</span
626
- >
754
+ {@const isActive = health.active?.versionId === v.id}
755
+ <li class:active={isActive} class:starred={v.starred}>
756
+ <div class="genie-ver-head">
757
+ <button
758
+ type="button"
759
+ class="genie-star"
760
+ class:on={v.starred}
761
+ disabled={loading || v.id === 'baseline'}
762
+ aria-pressed={v.starred}
763
+ aria-label={v.starred ? 'Unstar version' : 'Star version'}
764
+ onclick={() => toggleStar(v)}
765
+ >
766
+ {v.starred ? '★' : '☆'}
767
+ </button>
768
+ {#if renamingId === v.id}
769
+ <input
770
+ class="genie-rename"
771
+ type="text"
772
+ bind:value={renameDraft}
773
+ disabled={loading}
774
+ aria-label="Version label"
775
+ onkeydown={(e) => {
776
+ if (e.key === 'Enter') void saveLabel(v);
777
+ if (e.key === 'Escape') renamingId = null;
778
+ }}
779
+ />
780
+ {:else}
781
+ <strong>{v.label}</strong>
782
+ {/if}
783
+ {#if isActive}
784
+ <span class="genie-ver-badge">Active</span>
785
+ {/if}
786
+ </div>
787
+ <p class="genie-ver-when">{versionWhen(v)}</p>
788
+ <div class="genie-ver-actions">
789
+ {#if renamingId === v.id}
790
+ <button type="button" disabled={loading} onclick={() => saveLabel(v)}>
791
+ Save name
792
+ </button>
793
+ <button type="button" disabled={loading} onclick={() => (renamingId = null)}>
794
+ Cancel
795
+ </button>
796
+ {:else}
797
+ <button
798
+ type="button"
799
+ disabled={loading || isActive}
800
+ onclick={() => activate(v.id)}
801
+ >
802
+ Activate
803
+ </button>
804
+ <button type="button" disabled={loading} onclick={() => startRename(v)}>
805
+ Rename
806
+ </button>
807
+ <button type="button" disabled={loading} onclick={() => duplicate(v)}>
808
+ Duplicate
809
+ </button>
810
+ <button
811
+ type="button"
812
+ disabled={loading || v.id === 'baseline' || isActive}
813
+ onclick={() => removeVersion(v)}
814
+ >
815
+ Delete
816
+ </button>
817
+ {/if}
818
+ </div>
627
819
  </li>
628
820
  {/each}
629
821
  </ul>
@@ -816,6 +1008,16 @@
816
1008
  flex-shrink: 0;
817
1009
  }
818
1010
 
1011
+ .genie-progress {
1012
+ margin: 0 0.25rem 0.65rem;
1013
+ padding: 0.5rem 0.65rem;
1014
+ border-radius: 6px;
1015
+ border: 1px solid color-mix(in srgb, var(--accent, #f0c040) 40%, var(--rule, #444));
1016
+ font-size: 0.82rem;
1017
+ color: var(--ink-soft, #ccc);
1018
+ flex-shrink: 0;
1019
+ }
1020
+
819
1021
  .genie-row {
820
1022
  display: grid;
821
1023
  gap: 0.35rem;
@@ -898,19 +1100,86 @@
898
1100
  margin: 0.5rem 0;
899
1101
  padding: 0;
900
1102
  display: grid;
901
- gap: 0.35rem;
1103
+ gap: 0.55rem;
902
1104
  }
903
1105
 
904
1106
  .genie-versions li {
1107
+ display: grid;
1108
+ gap: 0.3rem;
1109
+ padding: 0.5rem 0.55rem;
1110
+ border: 1px solid var(--rule, #444);
1111
+ border-radius: 8px;
1112
+ }
1113
+
1114
+ .genie-versions li.active {
1115
+ border-color: color-mix(in srgb, var(--accent, #f0c040) 55%, var(--rule, #444));
1116
+ }
1117
+
1118
+ .genie-ver-head {
905
1119
  display: flex;
906
1120
  align-items: center;
907
- justify-content: space-between;
908
- gap: 0.5rem;
1121
+ gap: 0.4rem;
1122
+ min-width: 0;
909
1123
  }
910
1124
 
911
- .genie-versions li.active button {
912
- border-color: var(--accent, #f0c040);
1125
+ .genie-ver-head strong {
1126
+ flex: 1 1 auto;
1127
+ min-width: 0;
1128
+ overflow: hidden;
1129
+ text-overflow: ellipsis;
1130
+ white-space: nowrap;
1131
+ font-size: 0.84rem;
1132
+ }
1133
+
1134
+ .genie-star {
1135
+ flex: 0 0 auto;
1136
+ width: 1.7rem;
1137
+ padding: 0.15rem 0;
1138
+ border: 1px solid transparent;
1139
+ background: none;
1140
+ color: var(--ink-soft, #999);
1141
+ font-size: 0.95rem;
1142
+ line-height: 1;
1143
+ }
1144
+
1145
+ .genie-star.on {
1146
+ color: var(--accent, #f0c040);
1147
+ }
1148
+
1149
+ .genie-ver-badge {
1150
+ flex: 0 0 auto;
1151
+ font-size: 0.62rem;
1152
+ letter-spacing: 0.08em;
1153
+ text-transform: uppercase;
913
1154
  color: var(--accent, #f0c040);
1155
+ border: 1px solid color-mix(in srgb, var(--accent, #f0c040) 45%, var(--rule, #444));
1156
+ border-radius: 999px;
1157
+ padding: 0.08rem 0.4rem;
1158
+ }
1159
+
1160
+ .genie-rename {
1161
+ flex: 1 1 auto;
1162
+ min-width: 0;
1163
+ box-sizing: border-box;
1164
+ font: inherit;
1165
+ font-size: 0.82rem;
1166
+ }
1167
+
1168
+ .genie-ver-when {
1169
+ margin: 0;
1170
+ font-size: 0.72rem;
1171
+ color: var(--ink-soft, #999);
1172
+ }
1173
+
1174
+ .genie-ver-actions {
1175
+ display: flex;
1176
+ flex-wrap: wrap;
1177
+ gap: 0.3rem;
1178
+ }
1179
+
1180
+ .genie-ver-actions button {
1181
+ padding: 0.28rem 0.5rem;
1182
+ font-size: 0.72rem;
914
1183
  }
915
1184
 
916
1185
  .genie-linkish {
@@ -25,6 +25,7 @@ import type { DesignBrief, SiteIR } from '../../../../import/src/ir.ts';
25
25
  import {
26
26
  activateVersion,
27
27
  ensureBaseline,
28
+ genieRoot,
28
29
  getActive,
29
30
  listVersions,
30
31
  readVersionBrief,
@@ -313,6 +314,7 @@ function stubSiteIr(siteRoot: string): SiteIR {
313
314
  nav: [],
314
315
  topics: [],
315
316
  lede: null,
317
+ homeMarkdown: null,
316
318
  notes: [],
317
319
  assets: []
318
320
  };
@@ -405,13 +407,20 @@ export async function refineWithOllama(
405
407
  }
406
408
 
407
409
  const seed = loadWorkingBrief(siteRoot);
410
+ const logLabel = `filepress genie: refine ${model} @ ${host}`;
411
+ console.log(`${logLabel}: prompt “${prompt.slice(0, 80)}${prompt.length > 80 ? '…' : ''}”`);
408
412
  const brief = await generateDesignBrief({
409
413
  host,
410
414
  model,
411
415
  ir: stubSiteIr(siteRoot),
412
416
  inspireSummaries: [`Author steer: ${prompt}`],
413
417
  inspireSignals: [],
414
- seed
418
+ seed,
419
+ strictParse: true,
420
+ logLabel,
421
+ intent: 'steer',
422
+ steer: prompt,
423
+ dumpPath: join(genieRoot(siteRoot), 'last-ollama.json')
415
424
  });
416
425
  const themeCss = themeCssFromBrief(brief);
417
426
  const meta = writeSnapshot(siteRoot, {
@@ -423,6 +432,7 @@ export async function refineWithOllama(
423
432
  llm: { used: true, model, host },
424
433
  steers: [{ type: 'refine', prompt }]
425
434
  });
435
+ console.log(`${logLabel}: wrote ${meta.id}`);
426
436
  const active =
427
437
  opts.activate !== false ? activateVersion(siteRoot, meta.id) : getActive(siteRoot);
428
438
  return { meta, active, brief, llm: { used: true, model, host } };
@@ -1,5 +1,6 @@
1
1
  import {
2
2
  copyFileSync,
3
+ cpSync,
3
4
  existsSync,
4
5
  mkdirSync,
5
6
  readFileSync,
@@ -62,7 +63,10 @@ export function listVersions(siteRoot: string): GenieVersionMeta[] {
62
63
  const meta = readJson<GenieVersionMeta | null>(join(dir, id, 'meta.json'), null);
63
64
  if (meta) out.push(meta);
64
65
  }
65
- return out.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
66
+ return out.sort((a, b) => {
67
+ if (a.starred !== b.starred) return a.starred ? -1 : 1;
68
+ return b.createdAt.localeCompare(a.createdAt);
69
+ });
66
70
  }
67
71
 
68
72
  export function readVersionBrief(siteRoot: string, id: string): DesignBrief | null {
@@ -161,6 +165,55 @@ export function activateVersion(siteRoot: string, versionId: string): GenieActiv
161
165
  return active;
162
166
  }
163
167
 
168
+ export function readVersionMeta(siteRoot: string, versionId: string): GenieVersionMeta {
169
+ const meta = readJson<GenieVersionMeta | null>(join(versionPath(siteRoot, versionId), 'meta.json'), null);
170
+ if (!meta) throw new Error(`Unknown Genie version: ${versionId}`);
171
+ return meta;
172
+ }
173
+
174
+ export function updateVersionMeta(
175
+ siteRoot: string,
176
+ versionId: string,
177
+ patch: { label?: string; starred?: boolean }
178
+ ): GenieVersionMeta {
179
+ const meta = readVersionMeta(siteRoot, versionId);
180
+ if (typeof patch.label === 'string') {
181
+ const label = patch.label.trim();
182
+ if (!label) throw new Error('label cannot be empty');
183
+ meta.label = label.slice(0, 80);
184
+ }
185
+ if (typeof patch.starred === 'boolean') {
186
+ if (versionId === 'baseline' && patch.starred === false) {
187
+ throw new Error('Cannot unstar the baseline version');
188
+ }
189
+ meta.starred = patch.starred;
190
+ }
191
+ writeJson(join(versionPath(siteRoot, versionId), 'meta.json'), meta);
192
+ return meta;
193
+ }
194
+
195
+ /** Copy a snapshot folder to a new id. Does not activate. */
196
+ export function duplicateVersion(siteRoot: string, sourceId: string, label?: string): GenieVersionMeta {
197
+ const src = versionPath(siteRoot, sourceId);
198
+ if (!existsSync(join(src, 'theme.css'))) {
199
+ throw new Error(`Unknown Genie version: ${sourceId}`);
200
+ }
201
+ const source = readVersionMeta(siteRoot, sourceId);
202
+ const id = newVersionId();
203
+ const dest = versionPath(siteRoot, id);
204
+ cpSync(src, dest, { recursive: true });
205
+ const meta: GenieVersionMeta = {
206
+ ...source,
207
+ id,
208
+ createdAt: new Date().toISOString(),
209
+ parentId: sourceId,
210
+ label: (label?.trim() || `Copy of ${source.label}`).slice(0, 80),
211
+ starred: false
212
+ };
213
+ writeJson(join(dest, 'meta.json'), meta);
214
+ return meta;
215
+ }
216
+
164
217
  export function deleteVersion(siteRoot: string, versionId: string) {
165
218
  if (versionId === 'baseline') {
166
219
  throw new Error('Cannot delete the baseline version');
@@ -1,7 +1,8 @@
1
1
  /**
2
- * Essay chrome + fonts, then site theme.
2
+ * Essay chrome + fonts, then a named preset, then the site theme.
3
3
  * Site rules use higher-specificity selectors (`:root:root`, `header.site-header …`)
4
4
  * so they win even if Vite emits Essay CSS after the site sheet.
5
5
  */
6
6
  import '@filepress/core/theme';
7
+ import '$site-preset';
7
8
  import '$site-theme';
@@ -0,0 +1,38 @@
1
+ <script lang="ts">
2
+ import { page } from '$app/stores';
3
+ import { postsIndexPath } from '@filepress/core';
4
+ import config from '$site-config';
5
+
6
+ const homeHref = $derived(postsIndexPath(config));
7
+ const missing = $derived($page.status === 404);
8
+ const detail = $derived.by(() => {
9
+ const raw = $page.error?.message?.trim() ?? '';
10
+ if (!raw || /^not found$/i.test(raw)) return '';
11
+ return raw;
12
+ });
13
+ </script>
14
+
15
+ <svelte:head>
16
+ <title>{missing ? 'Not found' : 'Error'} — {config.title}</title>
17
+ <meta name="robots" content="noindex" />
18
+ </svelte:head>
19
+
20
+ <section class="empty-state error-page">
21
+ <p class="error-code">{$page.status}</p>
22
+ <h1>{missing ? 'This page is not here.' : 'Something went wrong.'}</h1>
23
+ <p>
24
+ {detail ||
25
+ (missing
26
+ ? 'The address may have changed, or the file was never published.'
27
+ : 'The build or request failed. Try the index, or check the terminal if you are in dev.')}
28
+ </p>
29
+ <p class="error-actions">
30
+ {#if config.homePage}
31
+ <a href="/">Home</a>
32
+ <span aria-hidden="true"> · </span>
33
+ <a href={homeHref}>Posts</a>
34
+ {:else}
35
+ <a href={homeHref}>Back to the index</a>
36
+ {/if}
37
+ </p>
38
+ </section>
@@ -1,6 +1,6 @@
1
1
  <script lang="ts">
2
2
  import type { PageData } from './$types';
3
- import { Newsletter, absoluteUrl, formatDate, ogImageUrl } from '@filepress/core';
3
+ import { Newsletter, absoluteUrl, formatDate, formatReadingTime, ogImageUrl } from '@filepress/core';
4
4
  import config from '$site-config';
5
5
 
6
6
  let { data }: { data: PageData } = $props();
@@ -51,6 +51,7 @@
51
51
  {#if showUpdated}
52
52
  &middot; updated <time datetime={post.updated}>{formatDate(post.updated!)}</time>
53
53
  {/if}
54
+ &middot; <span class="reading-time">{formatReadingTime(post.readingMinutes)}</span>
54
55
  </p>
55
56
  {#if data.tags.length}
56
57
  <ul class="tag-list" style="margin-top:0.75rem">
@@ -1,2 +1,5 @@
1
+ /** Aliased in vite.config.ts to a named token preset (`essay` | `ink` | `folio`). */
2
+ declare module '$site-preset';
3
+
1
4
  /** Aliased in vite.config.ts to the site's theme.css (created empty if missing). */
2
5
  declare module '$site-theme';