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,77 @@ export function ollamaSetupHint(host: string): string {
66
66
  ].join(' ');
67
67
  }
68
68
 
69
+ /** Wall-clock budget for `/api/chat`. 12B first-load often exceeds 3 minutes. */
70
+ export const DEFAULT_OLLAMA_CHAT_TIMEOUT_MS = 600_000;
71
+
72
+ export function ollamaChatTimeoutMs(env: NodeJS.ProcessEnv = process.env): number {
73
+ const raw = env.FILEPRESS_OLLAMA_TIMEOUT_MS?.trim();
74
+ if (!raw) return DEFAULT_OLLAMA_CHAT_TIMEOUT_MS;
75
+ const n = Number(raw);
76
+ if (!Number.isFinite(n) || n < 10_000) return DEFAULT_OLLAMA_CHAT_TIMEOUT_MS;
77
+ return Math.min(n, 3_600_000);
78
+ }
79
+
80
+ export function ollamaTimeoutMessage(opts: { host: string; model: string; timeoutMs: number }): string {
81
+ const secs = Math.round(opts.timeoutMs / 1000);
82
+ return (
83
+ `Ollama did not finish within ${secs}s (${opts.model} at ${opts.host}). ` +
84
+ `The model may still be loading — check \`ollama ps\`, then retry (a warm model is much faster). ` +
85
+ `Or raise FILEPRESS_OLLAMA_TIMEOUT_MS (milliseconds; default ${DEFAULT_OLLAMA_CHAT_TIMEOUT_MS}).`
86
+ );
87
+ }
88
+
89
+ export function isAbortLike(err: unknown): boolean {
90
+ if (!err || typeof err !== 'object') return false;
91
+ const name = 'name' in err ? String(err.name) : '';
92
+ const msg = 'message' in err ? String(err.message) : '';
93
+ return (
94
+ name === 'TimeoutError' ||
95
+ name === 'AbortError' ||
96
+ /aborted due to timeout/i.test(msg) ||
97
+ /The operation was aborted/i.test(msg)
98
+ );
99
+ }
100
+
101
+ /** Append one NDJSON line from Ollama `stream: true` `/api/chat`. */
102
+ export function appendOllamaChatDelta(line: string, acc: { content: string }): void {
103
+ const trimmed = line.trim();
104
+ if (!trimmed) return;
105
+ const ev = JSON.parse(trimmed) as { message?: { content?: string }; error?: string };
106
+ if (ev.error) throw new Error(ev.error);
107
+ if (ev.message?.content) acc.content += ev.message.content;
108
+ }
109
+
110
+ async function readOllamaChatStream(
111
+ res: Response,
112
+ logLabel: string,
113
+ started: number
114
+ ): Promise<string> {
115
+ if (!res.body) throw new Error('Ollama chat returned an empty body');
116
+ const reader = res.body.getReader();
117
+ const decoder = new TextDecoder();
118
+ let buf = '';
119
+ const acc = { content: '' };
120
+ let lastLog = 0;
121
+ for (;;) {
122
+ const { done, value } = await reader.read();
123
+ if (done) break;
124
+ buf += decoder.decode(value, { stream: true });
125
+ const lines = buf.split(/\r?\n/);
126
+ buf = lines.pop() ?? '';
127
+ for (const line of lines) appendOllamaChatDelta(line, acc);
128
+ const elapsed = Date.now() - started;
129
+ if (elapsed - lastLog >= 15_000) {
130
+ lastLog = elapsed;
131
+ console.log(
132
+ `${logLabel}: still generating… ${Math.round(elapsed / 1000)}s, ${acc.content.length} chars`
133
+ );
134
+ }
135
+ }
136
+ if (buf.trim()) appendOllamaChatDelta(buf, acc);
137
+ return acc.content;
138
+ }
139
+
69
140
  export async function generateDesignBrief(opts: {
70
141
  host: string;
71
142
  model: string;
@@ -73,8 +144,15 @@ export async function generateDesignBrief(opts: {
73
144
  inspireSummaries: string[];
74
145
  inspireSignals: InspirationSignals[];
75
146
  seed: DesignBrief;
147
+ /** When true, unparseable model JSON fails instead of silently using the seed. */
148
+ strictParse?: boolean;
149
+ logLabel?: string;
76
150
  }): Promise<DesignBrief> {
77
151
  const host = opts.host.replace(/\/+$/, '');
152
+ const timeoutMs = ollamaChatTimeoutMs();
153
+ const started = Date.now();
154
+ const logLabel = opts.logLabel ?? `filepress: Ollama ${opts.model} @ ${host}`;
155
+ console.log(`${logLabel}: starting chat (up to ${Math.round(timeoutMs / 1000)}s)`);
78
156
  const prompt = `You are a design director restyling a personal Markdown blog (filepress Essay chrome).
79
157
  The seed brief below was extracted from inspiration site CSS/fonts. Refine it — keep the punch.
80
158
  Return ONLY JSON (no fences) with this shape:
@@ -126,33 +204,41 @@ Inspiration text snippets:
126
204
  ${opts.inspireSummaries.map((s, i) => `(${i + 1}) ${s}`).join('\n') || '(none)'}
127
205
  `;
128
206
 
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
- });
148
-
149
- if (!res.ok) {
150
- const body = await res.text().catch(() => '');
151
- throw new Error(`Ollama chat failed (${res.status}): ${body.slice(0, 400)}`);
152
- }
207
+ let content = '';
208
+ try {
209
+ const res = await fetch(`${host}/api/chat`, {
210
+ method: 'POST',
211
+ headers: { 'content-type': 'application/json' },
212
+ body: JSON.stringify({
213
+ model: opts.model,
214
+ stream: true,
215
+ format: 'json',
216
+ options: { temperature: 0.35 },
217
+ messages: [
218
+ {
219
+ role: 'system',
220
+ content:
221
+ 'You output only valid JSON. Preserve dark inspiration palettes; do not flatten them into cream editorial.'
222
+ },
223
+ { role: 'user', content: prompt }
224
+ ]
225
+ }),
226
+ signal: AbortSignal.timeout(timeoutMs)
227
+ });
228
+
229
+ if (!res.ok) {
230
+ const body = await res.text().catch(() => '');
231
+ throw new Error(`Ollama chat failed (${res.status}): ${body.slice(0, 400)}`);
232
+ }
153
233
 
154
- const data = (await res.json()) as { message?: { content?: string } };
155
- const content = data.message?.content ?? '';
234
+ content = await readOllamaChatStream(res, logLabel, started);
235
+ console.log(`${logLabel}: done in ${Math.round((Date.now() - started) / 1000)}s (${content.length} chars)`);
236
+ } catch (e) {
237
+ if (isAbortLike(e)) {
238
+ throw new Error(ollamaTimeoutMessage({ host, model: opts.model, timeoutMs }));
239
+ }
240
+ throw e;
241
+ }
156
242
  try {
157
243
  const refined = parseBriefJson(content);
158
244
  // Never let the model drop fonts/googleHref from a rich seed
@@ -168,7 +254,13 @@ ${opts.inspireSummaries.map((s, i) => `(${i + 1}) ${s}`).join('\n') || '(none)'}
168
254
  elevatedCards: refined.elevatedCards ?? opts.seed.elevatedCards
169
255
  };
170
256
  } catch (e) {
171
- console.warn(`import: brief parse failed (${e}); using seed brief`);
257
+ const detail = e instanceof Error ? e.message : String(e);
258
+ if (opts.strictParse) {
259
+ throw new Error(
260
+ `Ollama returned a brief we could not parse (${detail}). First 200 chars: ${content.slice(0, 200)}`
261
+ );
262
+ }
263
+ console.warn(`import: brief parse failed (${detail}); using seed brief`);
172
264
  return opts.seed;
173
265
  }
174
266
  }
@@ -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
 
@@ -0,0 +1,125 @@
1
+ /**
2
+ * Stamp a dated Markdown post into a site's posts/ folder.
3
+ *
4
+ * filepress new "Post title"
5
+ * filepress new "Post title" --site demo
6
+ * filepress new "Post title" --draft
7
+ */
8
+ import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
9
+ import { dirname, join, resolve } from 'node:path';
10
+ import { fileURLToPath } from 'node:url';
11
+ import { slugify } from '../packages/core/src/lib/content/parse.ts';
12
+
13
+ const here = dirname(fileURLToPath(import.meta.url));
14
+ const packageRoot = resolve(here, '..');
15
+
16
+ export type NewPostArgs = {
17
+ title: string;
18
+ site: string | null;
19
+ root: string | null;
20
+ draft: boolean;
21
+ help: boolean;
22
+ };
23
+
24
+ export function parseNewArgs(argv: string[]): NewPostArgs {
25
+ const args: NewPostArgs = { title: '', site: null, root: null, draft: false, help: false };
26
+ const titleParts: string[] = [];
27
+ for (let i = 0; i < argv.length; i++) {
28
+ const a = argv[i];
29
+ if (a === '--help' || a === '-h') args.help = true;
30
+ else if (a === '--draft') args.draft = true;
31
+ else if (a === '--site') args.site = argv[++i] ?? '';
32
+ else if (a?.startsWith('--site=')) args.site = a.slice('--site='.length);
33
+ else if (a === '--root') args.root = argv[++i] ?? '';
34
+ else if (a?.startsWith('--root=')) args.root = a.slice('--root='.length);
35
+ else if (a.startsWith('-')) throw new Error(`unknown flag: ${a}`);
36
+ else titleParts.push(a);
37
+ }
38
+ args.title = titleParts.join(' ').trim().replace(/^["']|["']$/g, '');
39
+ return args;
40
+ }
41
+
42
+ export function resolveNewSiteRoot(args: Pick<NewPostArgs, 'site' | 'root'>, cwd = process.cwd()): string {
43
+ if (args.root) return resolve(cwd, args.root);
44
+ if (args.site) return join(packageRoot, 'sites', args.site);
45
+ return resolve(cwd);
46
+ }
47
+
48
+ export function isoDate(now = new Date()): string {
49
+ return now.toISOString().slice(0, 10);
50
+ }
51
+
52
+ export function newPostFilename(title: string, date: string): { slug: string; filename: string } {
53
+ const slug = slugify(title);
54
+ if (!slug) throw new Error('title did not produce a slug');
55
+ return { slug, filename: `${date}-${slug}.md` };
56
+ }
57
+
58
+ export function renderNewPost(title: string, date: string, draft: boolean): string {
59
+ const draftLine = draft ? 'draft: true\n' : '';
60
+ return `---
61
+ title: ${JSON.stringify(title)}
62
+ date: ${date}
63
+ ${draftLine}description: ""
64
+ tags: []
65
+ ---
66
+
67
+ `;
68
+ }
69
+
70
+ export function writeNewPost(
71
+ siteRoot: string,
72
+ title: string,
73
+ opts: { draft?: boolean; now?: Date } = {}
74
+ ): { path: string; filename: string } {
75
+ const date = isoDate(opts.now);
76
+ const { filename } = newPostFilename(title, date);
77
+ const postsDir = join(siteRoot, 'posts');
78
+ mkdirSync(postsDir, { recursive: true });
79
+ const dest = join(postsDir, filename);
80
+ if (existsSync(dest)) throw new Error(`already exists: posts/${filename}`);
81
+ writeFileSync(dest, renderNewPost(title, date, Boolean(opts.draft)));
82
+ return { path: dest, filename };
83
+ }
84
+
85
+ function usage(): string {
86
+ return `Usage: filepress new "Post title" [--draft] [--site name | --root path]
87
+
88
+ Writes posts/YYYY-MM-DD-slug.md next to filepress.config.ts.
89
+ Does not commit or deploy.`;
90
+ }
91
+
92
+ export function main(argv = process.argv.slice(2)): number {
93
+ let args: NewPostArgs;
94
+ try {
95
+ args = parseNewArgs(argv);
96
+ } catch (err) {
97
+ console.error(`filepress new: ${err instanceof Error ? err.message : err}`);
98
+ return 1;
99
+ }
100
+ if (args.help) {
101
+ console.log(usage());
102
+ return 0;
103
+ }
104
+ if (!args.title) {
105
+ console.error(usage());
106
+ return 1;
107
+ }
108
+ const siteRoot = resolveNewSiteRoot(args);
109
+ if (!existsSync(join(siteRoot, 'filepress.config.ts'))) {
110
+ console.error(`filepress new: no filepress.config.ts in ${siteRoot}`);
111
+ return 1;
112
+ }
113
+ try {
114
+ const written = writeNewPost(siteRoot, args.title, { draft: args.draft });
115
+ console.log(`Wrote posts/${written.filename}`);
116
+ return 0;
117
+ } catch (err) {
118
+ console.error(`filepress new: ${err instanceof Error ? err.message : err}`);
119
+ return 1;
120
+ }
121
+ }
122
+
123
+ if (process.argv[1] && resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url))) {
124
+ process.exitCode = main();
125
+ }
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Serve a FilePress `build/` folder the way a static host does:
3
+ * pretty URLs (`/about` → `about.html`) and `404.html` for unknown paths.
4
+ *
5
+ * Usage: node scripts/preview.mjs <buildDir> [port] [host]
6
+ */
7
+ import { createServer } from 'node:http';
8
+ import { existsSync, readFileSync, statSync } from 'node:fs';
9
+ import { extname, join, resolve, sep } from 'node:path';
10
+ import { fileURLToPath } from 'node:url';
11
+
12
+ const MIME = {
13
+ '.html': 'text/html; charset=utf-8',
14
+ '.css': 'text/css; charset=utf-8',
15
+ '.js': 'text/javascript; charset=utf-8',
16
+ '.mjs': 'text/javascript; charset=utf-8',
17
+ '.json': 'application/json',
18
+ '.svg': 'image/svg+xml',
19
+ '.xml': 'application/xml',
20
+ '.txt': 'text/plain; charset=utf-8',
21
+ '.woff2': 'font/woff2',
22
+ '.woff': 'font/woff',
23
+ '.png': 'image/png',
24
+ '.jpg': 'image/jpeg',
25
+ '.jpeg': 'image/jpeg',
26
+ '.webp': 'image/webp',
27
+ '.gif': 'image/gif',
28
+ '.ico': 'image/x-icon',
29
+ '.map': 'application/json'
30
+ };
31
+
32
+ const isMain =
33
+ Boolean(process.argv[1]) && resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url));
34
+
35
+ function underRoot(root, abs) {
36
+ return abs === root || abs.startsWith(root + sep);
37
+ }
38
+
39
+ /** Map a request path to a file under `root`, or null. */
40
+ export function resolveBuildFile(root, urlPath) {
41
+ const pathname = decodeURIComponent((urlPath.split('?')[0] || '/'));
42
+ const rel = pathname.replace(/^\/+/, '');
43
+ const abs = resolve(root, rel);
44
+ if (!underRoot(root, abs)) return null;
45
+ try {
46
+ const st = statSync(abs);
47
+ if (st.isFile()) return abs;
48
+ if (st.isDirectory()) {
49
+ const index = join(abs, 'index.html');
50
+ if (existsSync(index)) return index;
51
+ }
52
+ } catch {
53
+ /* pretty URL or sibling .html next to an empty adapter dir */
54
+ }
55
+ if (!extname(abs) && existsSync(`${abs}.html`)) return `${abs}.html`;
56
+ return null;
57
+ }
58
+
59
+ function send(res, file, status = 200) {
60
+ const type = MIME[extname(file).toLowerCase()] || 'application/octet-stream';
61
+ res.writeHead(status, { 'content-type': type, 'cache-control': 'no-store' });
62
+ res.end(readFileSync(file));
63
+ }
64
+
65
+ export function startPreview(root, port = 27777, host = '127.0.0.1') {
66
+ const site = resolve(root);
67
+ if (!existsSync(join(site, 'index.html'))) {
68
+ throw new Error(`filepress preview: no index.html in ${site}`);
69
+ }
70
+ const fallback = join(site, '404.html');
71
+ return createServer((req, res) => {
72
+ const file = resolveBuildFile(site, req.url || '/');
73
+ if (file && existsSync(file)) {
74
+ send(res, file);
75
+ return;
76
+ }
77
+ if (existsSync(fallback)) {
78
+ send(res, fallback, 404);
79
+ return;
80
+ }
81
+ res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' });
82
+ res.end('Not found');
83
+ }).listen(port, host, () => {
84
+ console.log(`filepress: preview http://${host}:${port} → ${site}`);
85
+ });
86
+ }
87
+
88
+ if (isMain) {
89
+ try {
90
+ startPreview(process.argv[2] ?? '', Number(process.argv[3] ?? 27777), process.argv[4] ?? '127.0.0.1');
91
+ } catch (err) {
92
+ console.error(err instanceof Error ? err.message : err);
93
+ process.exit(1);
94
+ }
95
+ }
@@ -1,19 +0,0 @@
1
- # @filepress/app
2
-
3
- The only SvelteKit application in the filepress monorepo. All routes, layouts, and
4
- Kit wiring live here. Content sites under [`../../sites`](../../sites) provide:
5
-
6
- - `filepress.config.ts` — identity
7
- - `posts/` — Markdown
8
- - `static/` — favicon, images (optional)
9
-
10
- ## Run against a site
11
-
12
- From the repo root (do not run vite directly without `FILEPRESS_SITE_ROOT`):
13
-
14
- ```bash
15
- pnpm filepress dev --site demo
16
- pnpm filepress build --site demo
17
- ```
18
-
19
- Build output is written to `sites/<name>/build/`.
@@ -1,29 +0,0 @@
1
- # @filepress/core
2
-
3
- The reusable filepress engine. Sites under [`../../sites`](../../sites) depend on
4
- it (via `workspace:*` in this monorepo) and provide their own SvelteKit routes,
5
- content, and `filepress.config.ts`.
6
-
7
- ## Entry points
8
-
9
- - `@filepress/core` — client-safe: `PostCard`, `PostIndex`, `Newsletter`,
10
- `SiteHeader`, `SiteFooter`, `defineFilepressConfig`, `absoluteUrl`,
11
- `formatDate`, and shared types.
12
- - `@filepress/core/server` — server-only (filesystem access): `createContent`,
13
- `renderMarkdown`, `buildRssXml` / `buildSitemapXml` / `buildRobotsTxt`, and the
14
- content-parsing primitives. Import only from `+page.server.ts`, `+server.ts`,
15
- or `*.server.ts` modules.
16
- - `@filepress/core/theme` — self-hosted fonts + the Essay theme CSS. Import once
17
- from a site's root layout.
18
-
19
- ## Why routes live in the site
20
-
21
- SvelteKit's router is per-project, so each site owns its `src/routes/`. Those
22
- route files stay thin: they call `createContent(...)` + core builders and render
23
- core components. `scripts/create-site.mjs` scaffolds them.
24
-
25
- ## Testing
26
-
27
- `pnpm --filter @filepress/core test` runs the unit tests over the pure parsing
28
- and figure-transform logic. Type-checking of the whole library happens through
29
- each site's `svelte-check`.