getfilepress 0.1.8 → 0.1.10

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 (42) hide show
  1. package/README.md +21 -4
  2. package/package.json +110 -102
  3. package/packages/app/src/lib/genie/GeniePanel.svelte +286 -21
  4. package/packages/app/src/lib/genie/ops.ts +7 -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/+page.server.ts +12 -0
  9. package/packages/app/src/routes/posts/+page.svelte +38 -0
  10. package/packages/app/src/routes/posts/[slug]/+page.svelte +2 -1
  11. package/packages/app/src/routes/writing/+page.server.ts +2 -6
  12. package/packages/app/src/routes/writing/+page.svelte +1 -38
  13. package/packages/app/src/site-theme.d.ts +3 -0
  14. package/packages/app/vite-plugin-genie.ts +45 -1
  15. package/packages/app/vite.config.ts +38 -7
  16. package/packages/core/src/lib/components/PostCard.svelte +2 -1
  17. package/packages/core/src/lib/components/PostIndex.svelte +1 -1
  18. package/packages/core/src/lib/config.ts +51 -5
  19. package/packages/core/src/lib/content/feeds.ts +1 -1
  20. package/packages/core/src/lib/content/parse.ts +2 -0
  21. package/packages/core/src/lib/content/types.ts +2 -0
  22. package/packages/core/src/lib/format.ts +11 -0
  23. package/packages/core/src/lib/index.ts +7 -3
  24. package/packages/core/src/lib/redirects.ts +88 -0
  25. package/packages/core/src/lib/server.ts +9 -2
  26. package/packages/core/src/lib/styles/presets/essay.css +2 -0
  27. package/packages/core/src/lib/styles/presets/folio.css +27 -0
  28. package/packages/core/src/lib/styles/presets/ink.css +28 -0
  29. package/packages/core/src/lib/styles/theme.css +37 -0
  30. package/packages/import/src/cli.ts +1 -0
  31. package/packages/import/src/extract.ts +15 -2
  32. package/packages/import/src/ir.ts +2 -0
  33. package/packages/import/src/ollama.ts +119 -27
  34. package/packages/import/src/redirects.ts +17 -0
  35. package/packages/import/src/write-site.ts +25 -3
  36. package/scripts/copy-path-mounts.mjs +30 -1
  37. package/scripts/create-site.mjs +1 -0
  38. package/scripts/filepress.mjs +24 -14
  39. package/scripts/new-post.ts +125 -0
  40. package/scripts/preview.mjs +95 -0
  41. package/packages/app/README.md +0 -19
  42. package/packages/core/README.md +0 -29
@@ -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,13 @@
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
+ <code>gemma4:12b</code> often spends the first few minutes loading into VRAM — that is
558
+ normal. Progress prints in the filepress terminal every 15s. Raise the budget with
559
+ <code>FILEPRESS_OLLAMA_TIMEOUT_MS</code> (default 10 minutes). Tip: set
560
+ <code>FILEPRESS_OLLAMA_MODEL</code> / <code>OLLAMA_HOST</code> for defaults;
430
561
  <a href="https://ollanet.dev" target="_blank" rel="noreferrer">ollanet</a> finds other
431
562
  boxes. Finetuna can tune a named variant.
432
563
  </p>
@@ -605,25 +736,82 @@
605
736
  <h3>Versions</h3>
606
737
  <p class="genie-howto">
607
738
  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.
739
+ Star keepers, rename, duplicate, then activate to bake into the working tree.
740
+ <code>baseline</code> is the pre-Genie look and cannot be deleted.
610
741
  </p>
611
742
  <p class="genie-muted">
612
743
  Active: {health.active?.versionId ?? '(none)'}
744
+ {#if health.versions.length}
745
+ · {health.versions.length} saved
746
+ {/if}
613
747
  </p>
614
748
  <ul class="genie-versions">
615
749
  {#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
- >
750
+ {@const isActive = health.active?.versionId === v.id}
751
+ <li class:active={isActive} class:starred={v.starred}>
752
+ <div class="genie-ver-head">
753
+ <button
754
+ type="button"
755
+ class="genie-star"
756
+ class:on={v.starred}
757
+ disabled={loading || v.id === 'baseline'}
758
+ aria-pressed={v.starred}
759
+ aria-label={v.starred ? 'Unstar version' : 'Star version'}
760
+ onclick={() => toggleStar(v)}
761
+ >
762
+ {v.starred ? '★' : '☆'}
763
+ </button>
764
+ {#if renamingId === v.id}
765
+ <input
766
+ class="genie-rename"
767
+ type="text"
768
+ bind:value={renameDraft}
769
+ disabled={loading}
770
+ aria-label="Version label"
771
+ onkeydown={(e) => {
772
+ if (e.key === 'Enter') void saveLabel(v);
773
+ if (e.key === 'Escape') renamingId = null;
774
+ }}
775
+ />
776
+ {:else}
777
+ <strong>{v.label}</strong>
778
+ {/if}
779
+ {#if isActive}
780
+ <span class="genie-ver-badge">Active</span>
781
+ {/if}
782
+ </div>
783
+ <p class="genie-ver-when">{versionWhen(v)}</p>
784
+ <div class="genie-ver-actions">
785
+ {#if renamingId === v.id}
786
+ <button type="button" disabled={loading} onclick={() => saveLabel(v)}>
787
+ Save name
788
+ </button>
789
+ <button type="button" disabled={loading} onclick={() => (renamingId = null)}>
790
+ Cancel
791
+ </button>
792
+ {:else}
793
+ <button
794
+ type="button"
795
+ disabled={loading || isActive}
796
+ onclick={() => activate(v.id)}
797
+ >
798
+ Activate
799
+ </button>
800
+ <button type="button" disabled={loading} onclick={() => startRename(v)}>
801
+ Rename
802
+ </button>
803
+ <button type="button" disabled={loading} onclick={() => duplicate(v)}>
804
+ Duplicate
805
+ </button>
806
+ <button
807
+ type="button"
808
+ disabled={loading || v.id === 'baseline' || isActive}
809
+ onclick={() => removeVersion(v)}
810
+ >
811
+ Delete
812
+ </button>
813
+ {/if}
814
+ </div>
627
815
  </li>
628
816
  {/each}
629
817
  </ul>
@@ -816,6 +1004,16 @@
816
1004
  flex-shrink: 0;
817
1005
  }
818
1006
 
1007
+ .genie-progress {
1008
+ margin: 0 0.25rem 0.65rem;
1009
+ padding: 0.5rem 0.65rem;
1010
+ border-radius: 6px;
1011
+ border: 1px solid color-mix(in srgb, var(--accent, #f0c040) 40%, var(--rule, #444));
1012
+ font-size: 0.82rem;
1013
+ color: var(--ink-soft, #ccc);
1014
+ flex-shrink: 0;
1015
+ }
1016
+
819
1017
  .genie-row {
820
1018
  display: grid;
821
1019
  gap: 0.35rem;
@@ -898,19 +1096,86 @@
898
1096
  margin: 0.5rem 0;
899
1097
  padding: 0;
900
1098
  display: grid;
901
- gap: 0.35rem;
1099
+ gap: 0.55rem;
902
1100
  }
903
1101
 
904
1102
  .genie-versions li {
1103
+ display: grid;
1104
+ gap: 0.3rem;
1105
+ padding: 0.5rem 0.55rem;
1106
+ border: 1px solid var(--rule, #444);
1107
+ border-radius: 8px;
1108
+ }
1109
+
1110
+ .genie-versions li.active {
1111
+ border-color: color-mix(in srgb, var(--accent, #f0c040) 55%, var(--rule, #444));
1112
+ }
1113
+
1114
+ .genie-ver-head {
905
1115
  display: flex;
906
1116
  align-items: center;
907
- justify-content: space-between;
908
- gap: 0.5rem;
1117
+ gap: 0.4rem;
1118
+ min-width: 0;
909
1119
  }
910
1120
 
911
- .genie-versions li.active button {
912
- border-color: var(--accent, #f0c040);
1121
+ .genie-ver-head strong {
1122
+ flex: 1 1 auto;
1123
+ min-width: 0;
1124
+ overflow: hidden;
1125
+ text-overflow: ellipsis;
1126
+ white-space: nowrap;
1127
+ font-size: 0.84rem;
1128
+ }
1129
+
1130
+ .genie-star {
1131
+ flex: 0 0 auto;
1132
+ width: 1.7rem;
1133
+ padding: 0.15rem 0;
1134
+ border: 1px solid transparent;
1135
+ background: none;
1136
+ color: var(--ink-soft, #999);
1137
+ font-size: 0.95rem;
1138
+ line-height: 1;
1139
+ }
1140
+
1141
+ .genie-star.on {
1142
+ color: var(--accent, #f0c040);
1143
+ }
1144
+
1145
+ .genie-ver-badge {
1146
+ flex: 0 0 auto;
1147
+ font-size: 0.62rem;
1148
+ letter-spacing: 0.08em;
1149
+ text-transform: uppercase;
913
1150
  color: var(--accent, #f0c040);
1151
+ border: 1px solid color-mix(in srgb, var(--accent, #f0c040) 45%, var(--rule, #444));
1152
+ border-radius: 999px;
1153
+ padding: 0.08rem 0.4rem;
1154
+ }
1155
+
1156
+ .genie-rename {
1157
+ flex: 1 1 auto;
1158
+ min-width: 0;
1159
+ box-sizing: border-box;
1160
+ font: inherit;
1161
+ font-size: 0.82rem;
1162
+ }
1163
+
1164
+ .genie-ver-when {
1165
+ margin: 0;
1166
+ font-size: 0.72rem;
1167
+ color: var(--ink-soft, #999);
1168
+ }
1169
+
1170
+ .genie-ver-actions {
1171
+ display: flex;
1172
+ flex-wrap: wrap;
1173
+ gap: 0.3rem;
1174
+ }
1175
+
1176
+ .genie-ver-actions button {
1177
+ padding: 0.28rem 0.5rem;
1178
+ font-size: 0.72rem;
914
1179
  }
915
1180
 
916
1181
  .genie-linkish {
@@ -313,6 +313,7 @@ function stubSiteIr(siteRoot: string): SiteIR {
313
313
  nav: [],
314
314
  topics: [],
315
315
  lede: null,
316
+ homeMarkdown: null,
316
317
  notes: [],
317
318
  assets: []
318
319
  };
@@ -405,13 +406,17 @@ export async function refineWithOllama(
405
406
  }
406
407
 
407
408
  const seed = loadWorkingBrief(siteRoot);
409
+ const logLabel = `filepress genie: refine ${model} @ ${host}`;
410
+ console.log(`${logLabel}: prompt “${prompt.slice(0, 80)}${prompt.length > 80 ? '…' : ''}”`);
408
411
  const brief = await generateDesignBrief({
409
412
  host,
410
413
  model,
411
414
  ir: stubSiteIr(siteRoot),
412
415
  inspireSummaries: [`Author steer: ${prompt}`],
413
416
  inspireSignals: [],
414
- seed
417
+ seed,
418
+ strictParse: true,
419
+ logLabel
415
420
  });
416
421
  const themeCss = themeCssFromBrief(brief);
417
422
  const meta = writeSnapshot(siteRoot, {
@@ -423,6 +428,7 @@ export async function refineWithOllama(
423
428
  llm: { used: true, model, host },
424
429
  steers: [{ type: 'refine', prompt }]
425
430
  });
431
+ console.log(`${logLabel}: wrote ${meta.id}`);
426
432
  const active =
427
433
  opts.activate !== false ? activateVersion(siteRoot, meta.id) : getActive(siteRoot);
428
434
  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>
@@ -0,0 +1,12 @@
1
+ import { redirect } from '@sveltejs/kit';
2
+ import type { PageServerLoad } from './$types';
3
+ import { content } from '$lib/content.server';
4
+ import config from '$site-config';
5
+
6
+ export const load: PageServerLoad = () => {
7
+ // Without homePage, the post index stays at `/`.
8
+ if (!config.homePage) {
9
+ redirect(302, '/');
10
+ }
11
+ return content.getIndexPage(1, config.postsPerPage);
12
+ };
@@ -0,0 +1,38 @@
1
+ <script lang="ts">
2
+ import type { PageData } from './$types';
3
+ import { PostIndex, absoluteUrl, ogImageUrl, postsIndexPath } from '@filepress/core';
4
+ import config from '$site-config';
5
+
6
+ let { data }: { data: PageData } = $props();
7
+
8
+ const pageTitle = $derived(`Posts — ${config.title}`);
9
+ const canonical = $derived(absoluteUrl(config, postsIndexPath(config)));
10
+ const ogImage = $derived(ogImageUrl(config));
11
+ const description = $derived(
12
+ `Posts from ${config.title}. ${config.description}`.trim()
13
+ );
14
+ </script>
15
+
16
+ <svelte:head>
17
+ <title>{pageTitle}</title>
18
+ <meta name="description" content={description} />
19
+ <link rel="canonical" href={canonical} />
20
+ <meta property="og:type" content="website" />
21
+ <meta property="og:title" content="Posts — {config.title}" />
22
+ <meta property="og:description" content={description} />
23
+ <meta property="og:url" content={canonical} />
24
+ {#if ogImage}
25
+ <meta property="og:image" content={ogImage} />
26
+ <meta name="twitter:card" content="summary" />
27
+ <meta name="twitter:image" content={ogImage} />
28
+ {/if}
29
+ </svelte:head>
30
+
31
+ <PostIndex
32
+ site={config}
33
+ featured={data.featured}
34
+ posts={data.posts}
35
+ page={data.page}
36
+ totalPages={data.totalPages}
37
+ indexHref={postsIndexPath(config)}
38
+ />
@@ -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,12 +1,8 @@
1
1
  import { redirect } from '@sveltejs/kit';
2
2
  import type { PageServerLoad } from './$types';
3
- import { content } from '$lib/content.server';
3
+ import { postsIndexPath } from '@filepress/core';
4
4
  import config from '$site-config';
5
5
 
6
6
  export const load: PageServerLoad = () => {
7
- // Without homePage, the post index stays at `/` — keep /writing as an alias.
8
- if (!config.homePage) {
9
- redirect(302, '/');
10
- }
11
- return content.getIndexPage(1, config.postsPerPage);
7
+ redirect(308, postsIndexPath(config));
12
8
  };
@@ -1,38 +1 @@
1
- <script lang="ts">
2
- import type { PageData } from './$types';
3
- import { PostIndex, absoluteUrl, ogImageUrl, postsIndexPath } from '@filepress/core';
4
- import config from '$site-config';
5
-
6
- let { data }: { data: PageData } = $props();
7
-
8
- const pageTitle = $derived(`Posts — ${config.title}`);
9
- const canonical = $derived(absoluteUrl(config, postsIndexPath(config)));
10
- const ogImage = $derived(ogImageUrl(config));
11
- const description = $derived(
12
- `Posts from ${config.title}. ${config.description}`.trim()
13
- );
14
- </script>
15
-
16
- <svelte:head>
17
- <title>{pageTitle}</title>
18
- <meta name="description" content={description} />
19
- <link rel="canonical" href={canonical} />
20
- <meta property="og:type" content="website" />
21
- <meta property="og:title" content="Posts — {config.title}" />
22
- <meta property="og:description" content={description} />
23
- <meta property="og:url" content={canonical} />
24
- {#if ogImage}
25
- <meta property="og:image" content={ogImage} />
26
- <meta name="twitter:card" content="summary" />
27
- <meta name="twitter:image" content={ogImage} />
28
- {/if}
29
- </svelte:head>
30
-
31
- <PostIndex
32
- site={config}
33
- featured={data.featured}
34
- posts={data.posts}
35
- page={data.page}
36
- totalPages={data.totalPages}
37
- indexHref={postsIndexPath(config)}
38
- />
1
+ <p>Moved to <a href="/posts">/posts</a>.</p>
@@ -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';