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
@@ -1,7 +1,170 @@
1
+ import { mkdirSync, writeFileSync } from 'node:fs';
2
+ import { dirname } from 'node:path';
1
3
  import type { DesignBrief, SiteIR } from './ir.ts';
2
4
  import type { InspirationSignals } from './inspire.ts';
3
5
  import { DEFAULT_BRIEF, parseBriefJson } from './theme.ts';
4
6
 
7
+ export type BriefIntent = 'import' | 'steer';
8
+
9
+ const BRIEF_SHAPE = `{
10
+ "mood": "short phrase",
11
+ "do": ["..."],
12
+ "dont": ["..."],
13
+ "tokens": {
14
+ "accent": "#rrggbb",
15
+ "accentStrong": "#rrggbb",
16
+ "bg": "#rrggbb",
17
+ "ink": "#rrggbb",
18
+ "inkSoft": "#rrggbb",
19
+ "surface": "#rrggbb",
20
+ "rule": "#rrggbb",
21
+ "ruleStrong": "#rrggbb"
22
+ },
23
+ "density": "sparse" | "balanced" | "dense",
24
+ "paletteMode": "dark" | "light",
25
+ "fonts": {
26
+ "serif": "Font Name",
27
+ "sans": "Font Name",
28
+ "mono": "Font Name",
29
+ "googleHref": "https://fonts.googleapis.com/css2?..." or null
30
+ },
31
+ "hero": "bold" | "editorial",
32
+ "atmosphere": "noise" | "none",
33
+ "navStyle": "uppercase-tracked" | "soft",
34
+ "elevatedCards": true | false,
35
+ "cssNotes": ["..."]
36
+ }`;
37
+
38
+ /** Paper / ice defaults when the author asked for light and the model still returns a cave. */
39
+ export const LIGHT_FLOOR = {
40
+ bg: '#f3f7fb',
41
+ surface: '#ffffff',
42
+ ink: '#16324a',
43
+ inkSoft: '#3d5a70',
44
+ rule: '#c5d6e4',
45
+ ruleStrong: '#8eafc8',
46
+ accent: '#1a6fa8',
47
+ accentStrong: '#0e4d7a'
48
+ };
49
+
50
+ export function paletteHintFromSteer(text: string): 'light' | 'dark' | null {
51
+ const t = text.toLowerCase();
52
+ const wantsLight =
53
+ /\b(light|bright|white|snow|ice|icy|antarctica|antarctic|arctic|glacier|frost|daylight|crisp|parchment|paper)\b/.test(
54
+ t
55
+ );
56
+ const wantsDark = /\b(dark|night|black|midnight|noir|charcoal|obsidian)\b/.test(t);
57
+ if (wantsLight && !wantsDark) return 'light';
58
+ if (wantsDark && !wantsLight) return 'dark';
59
+ if (wantsLight) return 'light';
60
+ if (wantsDark) return 'dark';
61
+ return null;
62
+ }
63
+
64
+ export function hexRelativeLuminance(hex: string | undefined): number | null {
65
+ if (!hex) return null;
66
+ const m = hex.trim().match(/^#([0-9a-f]{3}|[0-9a-f]{6})$/i);
67
+ if (!m) return null;
68
+ let h = m[1];
69
+ if (h.length === 3) h = [...h].map((c) => `${c}${c}`).join('');
70
+ const n = parseInt(h, 16);
71
+ const r = (n >> 16) & 255;
72
+ const g = (n >> 8) & 255;
73
+ const b = n & 255;
74
+ return (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255;
75
+ }
76
+
77
+ export function applyPaletteHint(brief: DesignBrief, hint: 'light' | 'dark' | null): DesignBrief {
78
+ if (!hint) return brief;
79
+ const tokens = { ...brief.tokens };
80
+ if (hint === 'light') {
81
+ const bgLum = hexRelativeLuminance(tokens.bg);
82
+ const inkLum = hexRelativeLuminance(tokens.ink);
83
+ if (bgLum === null || bgLum < 0.75) {
84
+ tokens.bg = LIGHT_FLOOR.bg;
85
+ tokens.surface = LIGHT_FLOOR.surface;
86
+ tokens.rule = tokens.rule && (hexRelativeLuminance(tokens.rule) ?? 0) > 0.55 ? tokens.rule : LIGHT_FLOOR.rule;
87
+ tokens.ruleStrong =
88
+ tokens.ruleStrong && (hexRelativeLuminance(tokens.ruleStrong) ?? 0) > 0.4
89
+ ? tokens.ruleStrong
90
+ : LIGHT_FLOOR.ruleStrong;
91
+ }
92
+ if (inkLum === null || inkLum > 0.45) {
93
+ tokens.ink = LIGHT_FLOOR.ink;
94
+ tokens.inkSoft = LIGHT_FLOOR.inkSoft;
95
+ }
96
+ return { ...brief, paletteMode: 'light', tokens };
97
+ }
98
+ return { ...brief, paletteMode: 'dark' };
99
+ }
100
+
101
+ export function buildDesignBriefPrompt(opts: {
102
+ intent: BriefIntent;
103
+ seed: DesignBrief;
104
+ ir: SiteIR;
105
+ inspireSummaries: string[];
106
+ inspireSignals: InspirationSignals[];
107
+ steer?: string;
108
+ }): { system: string; user: string } {
109
+ const notes = opts.inspireSignals.flatMap((s) => s.notes).join('; ') || '(none)';
110
+ const snippets =
111
+ opts.inspireSummaries.map((s, i) => `(${i + 1}) ${s}`).join('\n') || '(none)';
112
+ if (opts.intent === 'steer') {
113
+ return {
114
+ system:
115
+ 'You output only valid JSON. Honor the author\'s written palette. Light, ice, snow, or Antarctica means a bright page — never a black one unless they also say dark or night.',
116
+ user: `You are restyling a filepress Essay site from the author's written direction.
117
+ The seed is only a starting point. When the direction conflicts with the seed, the direction wins.
118
+ Return ONLY JSON (no fences) with this shape:
119
+ ${BRIEF_SHAPE}
120
+
121
+ Hard rules:
122
+ - Follow the author's palette. Light / bright / white / snow / ice / Antarctica / daylight → paletteMode "light", background #e8 or lighter (paper, snow, ice), dark readable ink. Do not use near-black backgrounds.
123
+ - "Cold" or "icy" is color temperature (blue, white, crisp) — not a night scene unless they also say dark/night/black.
124
+ - Dark/night/black only when the author asks for that.
125
+ - Do not invent marketing section layouts.
126
+ - Avoid purple-on-white clichés.
127
+
128
+ Author direction:
129
+ ${opts.steer?.trim() || '(none)'}
130
+
131
+ Seed brief (override freely when the direction conflicts):
132
+ ${JSON.stringify(opts.seed, null, 2)}
133
+
134
+ Site identity (content only — do not force its old palette):
135
+ ${JSON.stringify(opts.ir.identity, null, 2)}
136
+ `
137
+ };
138
+ }
139
+ return {
140
+ system:
141
+ 'You output only valid JSON. Preserve dark inspiration palettes; do not flatten them into cream editorial.',
142
+ user: `You are a design director restyling a personal Markdown blog (filepress Essay chrome).
143
+ The seed brief below was extracted from inspiration site CSS/fonts. Refine it — keep the punch.
144
+ Return ONLY JSON (no fences) with this shape:
145
+ ${BRIEF_SHAPE}
146
+
147
+ Hard rules:
148
+ - If inspiration is dark/modern, paletteMode MUST stay "dark" with bold hero + noise + tracked nav.
149
+ - Personal essay site: do NOT invent marketing section layouts.
150
+ - Prefer inspiration fonts/colors over the source site's cream-editorial look.
151
+ - Avoid purple-on-white clichés.
152
+
153
+ Seed brief (from inspiration extraction):
154
+ ${JSON.stringify(opts.seed, null, 2)}
155
+
156
+ Site identity (content only — do not force its old palette):
157
+ ${JSON.stringify(opts.ir.identity, null, 2)}
158
+
159
+ Inspiration notes:
160
+ ${notes}
161
+
162
+ Inspiration text snippets:
163
+ ${snippets}
164
+ `
165
+ };
166
+ }
167
+
5
168
  /** Strip trailing slashes; used to compare Genie picker values with env. */
6
169
  export function normalizeOllamaHost(host: string): string {
7
170
  return host.trim().replace(/\/+$/, '');
@@ -66,6 +229,77 @@ export function ollamaSetupHint(host: string): string {
66
229
  ].join(' ');
67
230
  }
68
231
 
232
+ /** Wall-clock budget for `/api/chat`. 12B first-load often exceeds 3 minutes. */
233
+ export const DEFAULT_OLLAMA_CHAT_TIMEOUT_MS = 600_000;
234
+
235
+ export function ollamaChatTimeoutMs(env: NodeJS.ProcessEnv = process.env): number {
236
+ const raw = env.FILEPRESS_OLLAMA_TIMEOUT_MS?.trim();
237
+ if (!raw) return DEFAULT_OLLAMA_CHAT_TIMEOUT_MS;
238
+ const n = Number(raw);
239
+ if (!Number.isFinite(n) || n < 10_000) return DEFAULT_OLLAMA_CHAT_TIMEOUT_MS;
240
+ return Math.min(n, 3_600_000);
241
+ }
242
+
243
+ export function ollamaTimeoutMessage(opts: { host: string; model: string; timeoutMs: number }): string {
244
+ const secs = Math.round(opts.timeoutMs / 1000);
245
+ return (
246
+ `Ollama did not finish within ${secs}s (${opts.model} at ${opts.host}). ` +
247
+ `The model may still be loading — check \`ollama ps\`, then retry (a warm model is much faster). ` +
248
+ `Or raise FILEPRESS_OLLAMA_TIMEOUT_MS (milliseconds; default ${DEFAULT_OLLAMA_CHAT_TIMEOUT_MS}).`
249
+ );
250
+ }
251
+
252
+ export function isAbortLike(err: unknown): boolean {
253
+ if (!err || typeof err !== 'object') return false;
254
+ const name = 'name' in err ? String(err.name) : '';
255
+ const msg = 'message' in err ? String(err.message) : '';
256
+ return (
257
+ name === 'TimeoutError' ||
258
+ name === 'AbortError' ||
259
+ /aborted due to timeout/i.test(msg) ||
260
+ /The operation was aborted/i.test(msg)
261
+ );
262
+ }
263
+
264
+ /** Append one NDJSON line from Ollama `stream: true` `/api/chat`. */
265
+ export function appendOllamaChatDelta(line: string, acc: { content: string }): void {
266
+ const trimmed = line.trim();
267
+ if (!trimmed) return;
268
+ const ev = JSON.parse(trimmed) as { message?: { content?: string }; error?: string };
269
+ if (ev.error) throw new Error(ev.error);
270
+ if (ev.message?.content) acc.content += ev.message.content;
271
+ }
272
+
273
+ async function readOllamaChatStream(
274
+ res: Response,
275
+ logLabel: string,
276
+ started: number
277
+ ): Promise<string> {
278
+ if (!res.body) throw new Error('Ollama chat returned an empty body');
279
+ const reader = res.body.getReader();
280
+ const decoder = new TextDecoder();
281
+ let buf = '';
282
+ const acc = { content: '' };
283
+ let lastLog = 0;
284
+ for (;;) {
285
+ const { done, value } = await reader.read();
286
+ if (done) break;
287
+ buf += decoder.decode(value, { stream: true });
288
+ const lines = buf.split(/\r?\n/);
289
+ buf = lines.pop() ?? '';
290
+ for (const line of lines) appendOllamaChatDelta(line, acc);
291
+ const elapsed = Date.now() - started;
292
+ if (elapsed - lastLog >= 15_000) {
293
+ lastLog = elapsed;
294
+ console.log(
295
+ `${logLabel}: still generating… ${Math.round(elapsed / 1000)}s, ${acc.content.length} chars`
296
+ );
297
+ }
298
+ }
299
+ if (buf.trim()) appendOllamaChatDelta(buf, acc);
300
+ return acc.content;
301
+ }
302
+
69
303
  export async function generateDesignBrief(opts: {
70
304
  host: string;
71
305
  model: string;
@@ -73,102 +307,123 @@ export async function generateDesignBrief(opts: {
73
307
  inspireSummaries: string[];
74
308
  inspireSignals: InspirationSignals[];
75
309
  seed: DesignBrief;
310
+ /** When true, unparseable model JSON fails instead of silently using the seed. */
311
+ strictParse?: boolean;
312
+ logLabel?: string;
313
+ /** `steer` uses the author-direction prompt; default is the import/inspire prompt. */
314
+ intent?: BriefIntent;
315
+ steer?: string;
316
+ /** Write raw + applied brief JSON here (Genie: `.filepress-genie/last-ollama.json`). */
317
+ dumpPath?: string;
76
318
  }): Promise<DesignBrief> {
77
319
  const host = opts.host.replace(/\/+$/, '');
78
- const prompt = `You are a design director restyling a personal Markdown blog (filepress Essay chrome).
79
- The seed brief below was extracted from inspiration site CSS/fonts. Refine it — keep the punch.
80
- Return ONLY JSON (no fences) with this shape:
81
- {
82
- "mood": "short phrase",
83
- "do": ["..."],
84
- "dont": ["..."],
85
- "tokens": {
86
- "accent": "#rrggbb",
87
- "accentStrong": "#rrggbb",
88
- "bg": "#rrggbb",
89
- "ink": "#rrggbb",
90
- "inkSoft": "#rrggbb",
91
- "surface": "#rrggbb",
92
- "rule": "#rrggbb",
93
- "ruleStrong": "#rrggbb"
94
- },
95
- "density": "sparse" | "balanced" | "dense",
96
- "paletteMode": "dark" | "light",
97
- "fonts": {
98
- "serif": "Font Name",
99
- "sans": "Font Name",
100
- "mono": "Font Name",
101
- "googleHref": "https://fonts.googleapis.com/css2?..." or null
102
- },
103
- "hero": "bold" | "editorial",
104
- "atmosphere": "noise" | "none",
105
- "navStyle": "uppercase-tracked" | "soft",
106
- "elevatedCards": true | false,
107
- "cssNotes": ["..."]
108
- }
109
-
110
- Hard rules:
111
- - If inspiration is dark/modern, paletteMode MUST stay "dark" with bold hero + noise + tracked nav.
112
- - Personal essay site: do NOT invent marketing section layouts.
113
- - Prefer inspiration fonts/colors over the source site's cream-editorial look.
114
- - Avoid purple-on-white clichés.
115
-
116
- Seed brief (from inspiration extraction):
117
- ${JSON.stringify(opts.seed, null, 2)}
118
-
119
- Site identity (content only — do not force its old palette):
120
- ${JSON.stringify(opts.ir.identity, null, 2)}
320
+ const timeoutMs = ollamaChatTimeoutMs();
321
+ const started = Date.now();
322
+ const logLabel = opts.logLabel ?? `filepress: Ollama ${opts.model} @ ${host}`;
323
+ const intent = opts.intent ?? 'import';
324
+ const hint = intent === 'steer' ? paletteHintFromSteer(opts.steer ?? '') : null;
325
+ const messages = buildDesignBriefPrompt({
326
+ intent,
327
+ seed: opts.seed,
328
+ ir: opts.ir,
329
+ inspireSummaries: opts.inspireSummaries,
330
+ inspireSignals: opts.inspireSignals,
331
+ steer: opts.steer
332
+ });
333
+ console.log(`${logLabel}: starting chat (up to ${Math.round(timeoutMs / 1000)}s) intent=${intent}`);
334
+ if (hint) console.log(`${logLabel}: author palette hint = ${hint}`);
121
335
 
122
- Inspiration notes:
123
- ${opts.inspireSignals.flatMap((s) => s.notes).join('; ') || '(none)'}
336
+ let content = '';
337
+ try {
338
+ const res = await fetch(`${host}/api/chat`, {
339
+ method: 'POST',
340
+ headers: { 'content-type': 'application/json' },
341
+ body: JSON.stringify({
342
+ model: opts.model,
343
+ stream: true,
344
+ format: 'json',
345
+ options: { temperature: 0.35 },
346
+ messages: [
347
+ { role: 'system', content: messages.system },
348
+ { role: 'user', content: messages.user }
349
+ ]
350
+ }),
351
+ signal: AbortSignal.timeout(timeoutMs)
352
+ });
124
353
 
125
- Inspiration text snippets:
126
- ${opts.inspireSummaries.map((s, i) => `(${i + 1}) ${s}`).join('\n') || '(none)'}
127
- `;
128
-
129
- const res = await fetch(`${host}/api/chat`, {
130
- method: 'POST',
131
- headers: { 'content-type': 'application/json' },
132
- body: JSON.stringify({
133
- model: opts.model,
134
- stream: false,
135
- format: 'json',
136
- options: { temperature: 0.35 },
137
- messages: [
138
- {
139
- role: 'system',
140
- content:
141
- 'You output only valid JSON. Preserve dark inspiration palettes; do not flatten them into cream editorial.'
142
- },
143
- { role: 'user', content: prompt }
144
- ]
145
- }),
146
- signal: AbortSignal.timeout(180_000)
147
- });
354
+ if (!res.ok) {
355
+ const body = await res.text().catch(() => '');
356
+ throw new Error(`Ollama chat failed (${res.status}): ${body.slice(0, 400)}`);
357
+ }
148
358
 
149
- if (!res.ok) {
150
- const body = await res.text().catch(() => '');
151
- throw new Error(`Ollama chat failed (${res.status}): ${body.slice(0, 400)}`);
359
+ content = await readOllamaChatStream(res, logLabel, started);
360
+ console.log(`${logLabel}: done in ${Math.round((Date.now() - started) / 1000)}s (${content.length} chars)`);
361
+ console.log(`${logLabel}: raw JSON\n${content}`);
362
+ } catch (e) {
363
+ if (isAbortLike(e)) {
364
+ throw new Error(ollamaTimeoutMessage({ host, model: opts.model, timeoutMs }));
365
+ }
366
+ throw e;
152
367
  }
153
-
154
- const data = (await res.json()) as { message?: { content?: string } };
155
- const content = data.message?.content ?? '';
156
368
  try {
157
369
  const refined = parseBriefJson(content);
158
370
  // Never let the model drop fonts/googleHref from a rich seed
159
- return {
371
+ let brief: DesignBrief = {
160
372
  ...opts.seed,
161
373
  ...refined,
162
374
  tokens: { ...opts.seed.tokens, ...refined.tokens },
163
375
  fonts: refined.fonts || opts.seed.fonts,
164
- paletteMode: refined.paletteMode || opts.seed.paletteMode,
376
+ paletteMode: refined.paletteMode || (intent === 'steer' ? undefined : opts.seed.paletteMode),
165
377
  hero: refined.hero || opts.seed.hero,
166
378
  atmosphere: refined.atmosphere || opts.seed.atmosphere,
167
379
  navStyle: refined.navStyle || opts.seed.navStyle,
168
380
  elevatedCards: refined.elevatedCards ?? opts.seed.elevatedCards
169
381
  };
382
+ brief = applyPaletteHint(brief, hint);
383
+ console.log(
384
+ `${logLabel}: applied paletteMode=${brief.paletteMode ?? '(unset)'} bg=${brief.tokens.bg ?? '(unset)'} ink=${brief.tokens.ink ?? '(unset)'} accent=${brief.tokens.accent}`
385
+ );
386
+ if (opts.dumpPath) {
387
+ mkdirSync(dirname(opts.dumpPath), { recursive: true });
388
+ writeFileSync(
389
+ opts.dumpPath,
390
+ `${JSON.stringify(
391
+ {
392
+ intent,
393
+ steer: opts.steer ?? null,
394
+ hint,
395
+ model: opts.model,
396
+ host,
397
+ raw: content,
398
+ applied: {
399
+ paletteMode: brief.paletteMode,
400
+ tokens: brief.tokens,
401
+ mood: brief.mood
402
+ }
403
+ },
404
+ null,
405
+ 2
406
+ )}\n`
407
+ );
408
+ console.log(`${logLabel}: dumped ${opts.dumpPath}`);
409
+ }
410
+ return brief;
170
411
  } catch (e) {
171
- console.warn(`import: brief parse failed (${e}); using seed brief`);
412
+ const detail = e instanceof Error ? e.message : String(e);
413
+ if (opts.dumpPath && content) {
414
+ mkdirSync(dirname(opts.dumpPath), { recursive: true });
415
+ writeFileSync(
416
+ opts.dumpPath,
417
+ `${JSON.stringify({ intent, steer: opts.steer ?? null, parseError: detail, raw: content }, null, 2)}\n`
418
+ );
419
+ console.log(`${logLabel}: dumped unparsed response to ${opts.dumpPath}`);
420
+ }
421
+ if (opts.strictParse) {
422
+ throw new Error(
423
+ `Ollama returned a brief we could not parse (${detail}). First 200 chars: ${content.slice(0, 200)}`
424
+ );
425
+ }
426
+ console.warn(`import: brief parse failed (${detail}); using seed brief`);
172
427
  return opts.seed;
173
428
  }
174
429
  }
@@ -0,0 +1,17 @@
1
+ import {
2
+ redirectsFromSourceUrls,
3
+ writingPostRedirects,
4
+ type RedirectRule
5
+ } from '../../core/src/lib/redirects.ts';
6
+ import type { SiteIR } from './ir.ts';
7
+
8
+ /** Old source paths → FilePress URLs, plus `/writing` when home becomes a page. */
9
+ export function importRedirectRules(
10
+ ir: Pick<SiteIR, 'posts' | 'pages' | 'homeMarkdown'>
11
+ ): RedirectRule[] {
12
+ const pairs = [
13
+ ...ir.posts.map((p) => ({ sourceUrl: p.sourceUrl, destPath: `/posts/${p.slug}` })),
14
+ ...ir.pages.map((p) => ({ sourceUrl: p.sourceUrl, destPath: `/${p.slug}` }))
15
+ ];
16
+ return [...(ir.homeMarkdown ? writingPostRedirects() : []), ...redirectsFromSourceUrls(pairs)];
17
+ }
@@ -9,9 +9,11 @@ import {
9
9
  import { dirname, join, resolve } from 'node:path';
10
10
  import { fileURLToPath } from 'node:url';
11
11
  import { spawnSync } from 'node:child_process';
12
+ import { serializeRedirects } from '../../core/src/lib/redirects.ts';
12
13
  import type { DesignBrief, ImportOptions, SiteIR } from './ir.ts';
13
14
  import { fetchBuffer } from './fetch.ts';
14
15
  import { fetchChosenImages } from './images.ts';
16
+ import { importRedirectRules } from './redirects.ts';
15
17
  import { themeCssFromBrief } from './theme.ts';
16
18
 
17
19
  function writeAttribution(metaDir: string, ir: SiteIR) {
@@ -178,8 +180,8 @@ Generated: ${new Date().toISOString()}
178
180
 
179
181
  ## URL remaps
180
182
 
181
- Posts move to \`/posts/<slug>\` (filepress default). If you need old paths (e.g. \`/writing/…\`), add Cloudflare Pages redirects.
182
-
183
+ Posts live at \`/posts/<slug>\`. Old paths are written to \`static/_redirects\` (Cloudflare Pages / Netlify).
184
+ ${ir.homeMarkdown ? '\nLong home bio became `pages/home.md`; the post index is `/posts`.\n' : ''}
183
185
  ${ir.posts.map((p) => `- \`${p.sourceUrl}\` → \`/posts/${p.slug}\``).join('\n')}
184
186
 
185
187
  ${ir.pages.map((p) => `- \`${p.sourceUrl}\` → \`/${p.slug}\``).join('\n')}
@@ -295,6 +297,24 @@ ${body}
295
297
  writeFileSync(join(out, 'pages', `${page.slug}.md`), md);
296
298
  }
297
299
 
300
+ if (ir.homeMarkdown && !ir.pages.some((p) => p.slug === 'home')) {
301
+ writeFileSync(
302
+ join(out, 'pages', 'home.md'),
303
+ `---
304
+ title: ${yamlQuote(ir.identity.title)}
305
+ order: 0
306
+ ---
307
+
308
+ ${ir.homeMarkdown}
309
+ `
310
+ );
311
+ }
312
+
313
+ const redirectRules = importRedirectRules(ir);
314
+ if (redirectRules.length) {
315
+ writeFileSync(join(staticDir, '_redirects'), serializeRedirects(redirectRules));
316
+ }
317
+
298
318
  const topicsLit = ir.topics
299
319
  .map((t) => `\t\t{ label: ${yamlQuote(t.label)}, tag: ${yamlQuote(t.tag)} }`)
300
320
  .join(',\n');
@@ -302,6 +322,8 @@ ${body}
302
322
  .map((n) => `\t\t{ label: ${yamlQuote(n.label)}, href: ${yamlQuote(n.href)} }`)
303
323
  .join(',\n');
304
324
  const ledeLine = ir.lede ? `\n\tlede: ${yamlQuote(ir.lede)},` : '';
325
+ const useHomePage = Boolean(ir.homeMarkdown) || ir.pages.some((p) => p.slug === 'home');
326
+ const homePageLine = useHomePage ? `\n\thomePage: 'home',` : '';
305
327
  const logo = activeBrief?.images?.logo;
306
328
  const logoLine = logo ? `\n\tlogo: ${yamlQuote(logo)},` : '';
307
329
 
@@ -313,7 +335,7 @@ export default defineFilepressConfig({
313
335
  title: ${yamlQuote(title)},
314
336
  description: ${yamlQuote(ir.identity.description)},
315
337
  url: ${yamlQuote(url)},
316
- author: ${yamlQuote(author)},${ledeLine}${logoLine}
338
+ author: ${yamlQuote(author)},${ledeLine}${homePageLine}${logoLine}
317
339
  nav: [
318
340
  ${navLit}
319
341
  ],
@@ -1,7 +1,8 @@
1
1
  /**
2
2
  * Post-`vite build` steps for a FilePress site:
3
3
  * 1. Write default `build/_headers` unless the site already provided one
4
- * 2. Copy `paths` mounts (from `.filepress/path-mounts.json`)
4
+ * 2. Merge engine `_redirects` (from `.filepress/redirects.txt`) into `build/_redirects`
5
+ * 3. Copy `paths` mounts (from `.filepress/path-mounts.json`)
5
6
  *
6
7
  * Usage: node scripts/copy-path-mounts.mjs <siteRoot>
7
8
  */
@@ -30,6 +31,34 @@ if (existsSync(headersDest)) {
30
31
  console.log('filepress: wrote default _headers');
31
32
  }
32
33
 
34
+ const redirectsPlan = join(siteRoot, '.filepress', 'redirects.txt');
35
+ const redirectsDest = join(buildDir, '_redirects');
36
+ if (existsSync(redirectsPlan)) {
37
+ const planned = readFileSync(redirectsPlan, 'utf8').trim();
38
+ if (planned) {
39
+ const existing = existsSync(redirectsDest) ? readFileSync(redirectsDest, 'utf8') : '';
40
+ const have = new Set(
41
+ existing
42
+ .split(/\r?\n/)
43
+ .map((line) => line.replace(/#.*$/, '').trim())
44
+ .filter(Boolean)
45
+ .map((line) => line.split(/\s+/).slice(0, 2).join('\0'))
46
+ );
47
+ const add = planned
48
+ .split(/\r?\n/)
49
+ .map((line) => line.trim())
50
+ .filter(Boolean)
51
+ .filter((line) => !have.has(line.split(/\s+/).slice(0, 2).join('\0')));
52
+ if (add.length) {
53
+ const prefix = existing.trimEnd();
54
+ writeFileSync(redirectsDest, prefix ? `${prefix}\n${add.join('\n')}\n` : `${add.join('\n')}\n`);
55
+ console.log(`filepress: wrote ${add.length} _redirects rule(s)`);
56
+ } else {
57
+ console.log('filepress: kept site _redirects');
58
+ }
59
+ }
60
+ }
61
+
33
62
  if (!existsSync(cachePath)) {
34
63
  process.exit(0);
35
64
  }
@@ -216,6 +216,7 @@ Any static host: publish the \`build/\` folder. Details: https://getfilepress.co
216
216
  console.log(`Next:`);
217
217
  console.log(` cd ${relToEngine} && pnpm install # if not already`);
218
218
  console.log(` cd ${target} && pnpm install && pnpm dev`);
219
+ console.log(` filepress new "My Post"`);
219
220
  } else {
220
221
  writeFileSync(
221
222
  join(target, 'tsconfig.json'),
@@ -145,6 +145,13 @@ function runImport(argv) {
145
145
  runNodeBin('tsx', 'tsx', [importCli, ...argv], { cwd: packageRoot });
146
146
  }
147
147
 
148
+ /** `filepress new "Title"` → dated post skeleton next to filepress.config.ts. */
149
+ function runNew(argv) {
150
+ const cli = join(scriptDir, 'new-post.ts');
151
+ if (!existsSync(cli)) fail(`new-post CLI missing at ${cli}`);
152
+ runNodeBin('tsx', 'tsx', [cli, ...argv], { cwd: process.cwd() });
153
+ }
154
+
148
155
  function listSites() {
149
156
  if (!existsSync(sitesDir)) return [];
150
157
  return readdirSync(sitesDir).filter((name) => {
@@ -173,6 +180,8 @@ const argv = process.argv.slice(2);
173
180
  await ensureEmbeddedLinks();
174
181
  if (argv[0] === 'import') {
175
182
  runImport(argv.slice(1));
183
+ } else if (argv[0] === 'new') {
184
+ runNew(argv.slice(1));
176
185
  } else {
177
186
  runSiteCommand(argv);
178
187
  }
@@ -190,6 +199,7 @@ function runSiteCommand(argv) {
190
199
  ` filepress preview [--port 27777] # serves <site>/build (default 27777)\n` +
191
200
  ` filepress dev --port <n> [--host] # vite dev; optional fixed port / LAN\n` +
192
201
  ` filepress import --source <url> [--inspire <url>] …\n` +
202
+ ` filepress new "Post title" [--draft] [--site name | --root path]\n` +
193
203
  `monorepo sites: ${listSites().join(', ') || '(none)'}`
194
204
  );
195
205
  }
@@ -238,20 +248,20 @@ function runSiteCommand(argv) {
238
248
  );
239
249
  }
240
250
  const port = args.port || process.env.FILEPRESS_PORT || '27777';
241
- console.log(`filepress: preview http://localhost:${port} → ${buildDir}`);
242
- runNodeBin(
243
- 'sirv-cli',
244
- 'sirv',
245
- [
246
- buildDir,
247
- '--dev',
248
- '--port',
249
- String(port),
250
- '--host',
251
- args.host && args.host !== 'true' ? args.host : args.host === 'true' ? '0.0.0.0' : '127.0.0.1'
252
- ],
253
- { cwd: appDir, env }
254
- );
251
+ const host =
252
+ args.host && args.host !== 'true' ? args.host : args.host === 'true' ? '0.0.0.0' : '127.0.0.1';
253
+ const previewScript = join(scriptDir, 'preview.mjs');
254
+ if (!existsSync(previewScript)) fail(`preview script missing at ${previewScript}`);
255
+ const child = spawn(process.execPath, [previewScript, buildDir, String(port), host], {
256
+ cwd: packageRoot,
257
+ env,
258
+ stdio: 'inherit',
259
+ shell: false
260
+ });
261
+ child.on('exit', (code, signal) => {
262
+ if (signal) process.kill(process.pid, signal);
263
+ process.exit(code ?? 1);
264
+ });
255
265
  return;
256
266
  }
257
267