getfilepress 0.0.0 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +178 -4
  3. package/package.json +89 -7
  4. package/packages/app/package.json +31 -0
  5. package/packages/app/src/app.d.ts +12 -0
  6. package/packages/app/src/app.html +12 -0
  7. package/packages/app/src/critical-theme.d.ts +4 -0
  8. package/packages/app/src/lib/content.server.ts +8 -0
  9. package/packages/app/src/lib/empty-theme.css +1 -0
  10. package/packages/app/src/lib/genie/GenieHost.svelte +16 -0
  11. package/packages/app/src/lib/genie/GeniePanel.svelte +429 -0
  12. package/packages/app/src/lib/genie/ops.ts +237 -0
  13. package/packages/app/src/lib/genie/store.ts +217 -0
  14. package/packages/app/src/lib/genie/types.ts +61 -0
  15. package/packages/app/src/lib/pages.server.ts +7 -0
  16. package/packages/app/src/lib/site.server.ts +52 -0
  17. package/packages/app/src/lib/theme-entry.ts +7 -0
  18. package/packages/app/src/routes/+layout.svelte +29 -0
  19. package/packages/app/src/routes/+layout.ts +10 -0
  20. package/packages/app/src/routes/+page.server.ts +28 -0
  21. package/packages/app/src/routes/+page.svelte +67 -0
  22. package/packages/app/src/routes/[slug]/+page.server.ts +20 -0
  23. package/packages/app/src/routes/[slug]/+page.svelte +47 -0
  24. package/packages/app/src/routes/page/[n]/+page.server.ts +21 -0
  25. package/packages/app/src/routes/page/[n]/+page.svelte +36 -0
  26. package/packages/app/src/routes/posts/[slug]/+page.server.ts +24 -0
  27. package/packages/app/src/routes/posts/[slug]/+page.svelte +95 -0
  28. package/packages/app/src/routes/robots.txt/+server.ts +11 -0
  29. package/packages/app/src/routes/rss.xml/+server.ts +13 -0
  30. package/packages/app/src/routes/sitemap.xml/+server.ts +19 -0
  31. package/packages/app/src/routes/tags/+page.server.ts +4 -0
  32. package/packages/app/src/routes/tags/+page.svelte +26 -0
  33. package/packages/app/src/routes/tags/[tag]/+page.server.ts +15 -0
  34. package/packages/app/src/routes/tags/[tag]/+page.svelte +23 -0
  35. package/packages/app/src/routes/topics/+page.server.ts +28 -0
  36. package/packages/app/src/routes/topics/+page.svelte +47 -0
  37. package/packages/app/src/routes/writing/+page.server.ts +12 -0
  38. package/packages/app/src/routes/writing/+page.svelte +38 -0
  39. package/packages/app/src/site-theme.d.ts +2 -0
  40. package/packages/app/static/.gitkeep +0 -0
  41. package/packages/app/tsconfig.json +15 -0
  42. package/packages/app/vite-plugin-critical-theme.ts +70 -0
  43. package/packages/app/vite-plugin-genie.ts +114 -0
  44. package/packages/app/vite.config.ts +141 -0
  45. package/packages/core/package.json +51 -0
  46. package/packages/core/src/lib/assets/favicon.svg +1 -0
  47. package/packages/core/src/lib/components/Newsletter.svelte +13 -0
  48. package/packages/core/src/lib/components/PostCard.svelte +34 -0
  49. package/packages/core/src/lib/components/PostIndex.svelte +86 -0
  50. package/packages/core/src/lib/components/SiteFooter.svelte +15 -0
  51. package/packages/core/src/lib/components/SiteHeader.svelte +28 -0
  52. package/packages/core/src/lib/config.ts +154 -0
  53. package/packages/core/src/lib/content/content.ts +193 -0
  54. package/packages/core/src/lib/content/feeds.ts +102 -0
  55. package/packages/core/src/lib/content/markdown.ts +78 -0
  56. package/packages/core/src/lib/content/pages.ts +103 -0
  57. package/packages/core/src/lib/content/parse.ts +214 -0
  58. package/packages/core/src/lib/content/rehype-figure.ts +79 -0
  59. package/packages/core/src/lib/content/types.ts +88 -0
  60. package/packages/core/src/lib/format.ts +25 -0
  61. package/packages/core/src/lib/index.ts +27 -0
  62. package/packages/core/src/lib/server.ts +33 -0
  63. package/packages/core/src/lib/styles/fonts.css +72 -0
  64. package/packages/core/src/lib/styles/theme.css +762 -0
  65. package/packages/core/src/lib/theme.ts +5 -0
  66. package/packages/import/package.json +27 -0
  67. package/packages/import/src/cli.ts +346 -0
  68. package/packages/import/src/discover.ts +190 -0
  69. package/packages/import/src/extract.ts +378 -0
  70. package/packages/import/src/fetch.ts +63 -0
  71. package/packages/import/src/html-to-md.ts +21 -0
  72. package/packages/import/src/images.ts +247 -0
  73. package/packages/import/src/inspire.ts +417 -0
  74. package/packages/import/src/ir.ts +109 -0
  75. package/packages/import/src/ollama.ts +145 -0
  76. package/packages/import/src/stock.ts +218 -0
  77. package/packages/import/src/theme.ts +478 -0
  78. package/packages/import/src/write-site.ts +371 -0
  79. package/packages/import/tsconfig.json +13 -0
  80. package/pnpm-workspace.yaml +2 -0
  81. package/scripts/create-site.mjs +260 -0
  82. package/scripts/filepress.mjs +273 -0
  83. package/scripts/link-embedded-packages.mjs +40 -0
  84. package/scripts/postinstall.mjs +56 -0
@@ -0,0 +1,371 @@
1
+ import {
2
+ copyFileSync,
3
+ existsSync,
4
+ mkdirSync,
5
+ readdirSync,
6
+ writeFileSync,
7
+ rmSync
8
+ } from 'node:fs';
9
+ import { dirname, join, resolve } from 'node:path';
10
+ import { fileURLToPath } from 'node:url';
11
+ import { spawnSync } from 'node:child_process';
12
+ import type { DesignBrief, ImportOptions, SiteIR } from './ir.ts';
13
+ import { fetchBuffer } from './fetch.ts';
14
+ import { fetchChosenImages } from './images.ts';
15
+ import { themeCssFromBrief } from './theme.ts';
16
+
17
+ function writeAttribution(metaDir: string, ir: SiteIR) {
18
+ const stockLines = ir.notes.filter(
19
+ (n) =>
20
+ n.includes('Openverse') ||
21
+ n.startsWith('- **') ||
22
+ n.startsWith('Stock attribution')
23
+ );
24
+ if (!stockLines.length) return;
25
+ writeFileSync(
26
+ join(metaDir, 'IMAGE_ATTRIBUTION.md'),
27
+ `# Image attribution
28
+
29
+ Chrome covers were fetched via [Openverse](https://openverse.org/) (Creative Commons).
30
+ Keep this file with the site if you redistribute.
31
+
32
+ ${stockLines.join('\n')}
33
+ `
34
+ );
35
+ }
36
+
37
+ const DEFAULT_FAVICON = resolve(
38
+ dirname(fileURLToPath(import.meta.url)),
39
+ '../../core/src/lib/assets/favicon.svg'
40
+ );
41
+
42
+ function ensureScaffold(out: string, opts: ImportOptions, title: string, url: string) {
43
+ const configPath = join(out, 'filepress.config.ts');
44
+ const needsScaffold = !existsSync(configPath) || !existsSync(join(out, 'package.json'));
45
+ if (!needsScaffold) {
46
+ mkdirSync(join(out, 'posts'), { recursive: true });
47
+ mkdirSync(join(out, 'pages'), { recursive: true });
48
+ mkdirSync(join(out, 'static'), { recursive: true });
49
+ return;
50
+ }
51
+
52
+ const createScript = join(opts.engineRoot, 'scripts', 'create-site.mjs');
53
+ const result = spawnSync(
54
+ process.execPath,
55
+ [
56
+ createScript,
57
+ opts.siteName,
58
+ '--external',
59
+ out,
60
+ '--title',
61
+ title,
62
+ '--url',
63
+ url,
64
+ ...(opts.force ? ['--force'] : [])
65
+ ],
66
+ { encoding: 'utf8' }
67
+ );
68
+ if (result.status !== 0) {
69
+ throw new Error(`create-site failed:\n${result.stdout}\n${result.stderr}`);
70
+ }
71
+ }
72
+
73
+ function yamlQuote(s: string): string {
74
+ return JSON.stringify(s);
75
+ }
76
+
77
+ function postFilename(date: string, slug: string): string {
78
+ return `${date}-${slug}.md`;
79
+ }
80
+
81
+ function rewriteImageUrls(markdown: string, mapping: Map<string, string>): string {
82
+ let out = markdown;
83
+ for (const [from, to] of mapping) {
84
+ out = out.split(from).join(to);
85
+ // also without origin if relative forms appeared
86
+ }
87
+ return out;
88
+ }
89
+
90
+ async function downloadImages(
91
+ urls: string[],
92
+ staticDir: string
93
+ ): Promise<Map<string, string>> {
94
+ const map = new Map<string, string>();
95
+ const imgDir = join(staticDir, 'images');
96
+ mkdirSync(imgDir, { recursive: true });
97
+ let i = 0;
98
+ for (const url of urls) {
99
+ try {
100
+ const buf = await fetchBuffer(url);
101
+ const ext = guessExt(url, buf);
102
+ const base = url.split('/').pop()?.split('?')[0] || `img-${++i}`;
103
+ const safe = base.replace(/[^\w.-]+/g, '-').slice(0, 80) || `img-${++i}`;
104
+ const name = safe.includes('.') ? safe : `${safe}${ext}`;
105
+ const dest = join(imgDir, name);
106
+ writeFileSync(dest, buf);
107
+ map.set(url, `/images/${name}`);
108
+ } catch (e) {
109
+ console.warn(`import: image skip ${url}: ${e instanceof Error ? e.message : e}`);
110
+ }
111
+ }
112
+ return map;
113
+ }
114
+
115
+ /** Copy favicons / touch icons to static/ root (preserve basename). */
116
+ async function downloadRootAssets(urls: string[], staticDir: string): Promise<void> {
117
+ mkdirSync(staticDir, { recursive: true });
118
+ for (const url of urls) {
119
+ try {
120
+ const buf = await fetchBuffer(url);
121
+ const name = new URL(url).pathname.split('/').filter(Boolean).pop();
122
+ if (!name || name.includes('..')) continue;
123
+ writeFileSync(join(staticDir, name), buf);
124
+ } catch {
125
+ /* optional asset */
126
+ }
127
+ }
128
+ }
129
+
130
+ function guessExt(url: string, buf: Uint8Array): string {
131
+ const path = new URL(url).pathname.toLowerCase();
132
+ if (path.endsWith('.png')) return '.png';
133
+ if (path.endsWith('.jpg') || path.endsWith('.jpeg')) return '.jpg';
134
+ if (path.endsWith('.webp')) return '.webp';
135
+ if (path.endsWith('.gif')) return '.gif';
136
+ if (path.endsWith('.svg')) return '.svg';
137
+ if (buf[0] === 0x89 && buf[1] === 0x50) return '.png';
138
+ if (buf[0] === 0xff && buf[1] === 0xd8) return '.jpg';
139
+ return '.bin';
140
+ }
141
+
142
+ function buildReport(ir: SiteIR, opts: ImportOptions, brief: DesignBrief | null): string {
143
+ const imgs = brief?.images;
144
+ const imageSection = imgs
145
+ ? `## Chrome images
146
+
147
+ ${imgs.hero ? `- Hero cover: \`${imgs.hero}\`` : '- Hero cover: (none — typographic lede)'}
148
+ ${imgs.header ? `- Header cover: \`${imgs.header}\`` : '- Header cover: (none)'}
149
+ ${imgs.background ? `- Page background: \`${imgs.background}\`` : '- Page background: (none)'}
150
+ ${imgs.logo ? `- Logo (config): \`${imgs.logo}\`` : '- Logo: (none — text title)'}
151
+ ${imgs.portrait ? `- Portrait file (not a CSS cover): \`${imgs.portrait}\`` : '- Portrait: (none)'}
152
+
153
+ Replace files under \`static/images/\` anytime; theme.css references cover paths only (not portrait).
154
+ `
155
+ : `## Chrome images
156
+
157
+ Not fetched. Re-run with \`--fetch-images\` to download suggested hero/header/background/logo into \`static/images/\`, or drop your own files there and set paths in \`theme.css\` / \`logo\` in config.
158
+ `;
159
+
160
+ return `# filepress import report
161
+
162
+ Source: ${ir.source.url}
163
+ Generator guess: ${ir.source.generator ?? 'unknown'}
164
+ Output: ${opts.out}
165
+ Generated: ${new Date().toISOString()}
166
+
167
+ ## Identity
168
+
169
+ - Title: ${ir.identity.title}
170
+ - Author: ${ir.identity.author}
171
+ - Canonical URL (config): ${opts.canonicalUrl || ir.identity.canonicalUrl}
172
+
173
+ ## Imported
174
+
175
+ - Posts: ${ir.posts.length}
176
+ - Pages: ${ir.pages.length}
177
+ - Topics: ${ir.topics.map((t) => t.tag).join(', ') || '(none)'}
178
+
179
+ ## URL remaps
180
+
181
+ Posts move to \`/posts/<slug>\` (filepress default). If you need old paths (e.g. \`/writing/…\`), add Cloudflare Pages redirects.
182
+
183
+ ${ir.posts.map((p) => `- \`${p.sourceUrl}\` → \`/posts/${p.slug}\``).join('\n')}
184
+
185
+ ${ir.pages.map((p) => `- \`${p.sourceUrl}\` → \`/${p.slug}\``).join('\n')}
186
+
187
+ ## Notes
188
+
189
+ ${ir.notes.map((n) => `- ${n}`).join('\n')}
190
+
191
+ ## Design
192
+
193
+ ${brief ? `Mood: ${brief.mood}\nDensity: ${brief.density}\nDo: ${brief.do.join('; ')}\nDon't: ${brief.dont.join('; ')}` : 'No LLM brief (token theme from defaults / source CSS).'}
194
+
195
+ ${imageSection}
196
+ ## Next
197
+
198
+ \`\`\`bash
199
+ cd ${opts.out}
200
+ pnpm install
201
+ pnpm filepress dev
202
+ \`\`\`
203
+ `;
204
+ }
205
+
206
+ export async function writeSite(
207
+ ir: SiteIR,
208
+ opts: ImportOptions,
209
+ brief: DesignBrief | null
210
+ ): Promise<{ reportPath: string }> {
211
+ const out = resolve(opts.out);
212
+ if (existsSync(out)) {
213
+ const entries = readdirSync(out).filter((e) => e !== '.git' && e !== 'artifacts');
214
+ if (entries.length && !opts.force) {
215
+ throw new Error(
216
+ `Refusing non-empty directory ${out} (pass --force to overwrite generated files carefully)`
217
+ );
218
+ }
219
+ if (opts.force && existsSync(join(out, 'filepress.config.ts'))) {
220
+ // wipe content dirs we own; keep .git
221
+ for (const d of ['posts', 'pages', 'static', '.filepress-import']) {
222
+ rmSync(join(out, d), { recursive: true, force: true });
223
+ }
224
+ }
225
+ }
226
+
227
+ const title = opts.title || ir.identity.title;
228
+ const url = opts.canonicalUrl || ir.identity.canonicalUrl;
229
+ const author = opts.author || ir.identity.author;
230
+
231
+ mkdirSync(out, { recursive: true });
232
+ ensureScaffold(out, opts, title, url);
233
+
234
+ // Remove scaffold sample content; we'll write imported files
235
+ rmSync(join(out, 'posts', 'hello-world.md'), { force: true });
236
+ rmSync(join(out, 'pages', 'about.md'), { force: true });
237
+ // Clear prior import posts/pages when re-running with --force
238
+ if (opts.force) {
239
+ for (const dir of ['posts', 'pages']) {
240
+ const abs = join(out, dir);
241
+ if (!existsSync(abs)) continue;
242
+ for (const f of readdirSync(abs)) {
243
+ if (f.endsWith('.md')) rmSync(join(abs, f), { force: true });
244
+ }
245
+ }
246
+ }
247
+
248
+ const staticDir = join(out, 'static');
249
+ mkdirSync(staticDir, { recursive: true });
250
+
251
+ let activeBrief = brief;
252
+ if (opts.fetchImages && brief?.images) {
253
+ console.log('import: fetching chrome images …');
254
+ const local = await fetchChosenImages(brief.images, staticDir);
255
+ activeBrief = { ...brief, images: local };
256
+ }
257
+
258
+ const allImages = [
259
+ ...new Set([...ir.posts.flatMap((p) => p.imageUrls), ...ir.pages.flatMap((p) => p.imageUrls)])
260
+ ];
261
+ const imageMap = await downloadImages(allImages, staticDir);
262
+ await downloadRootAssets(ir.assets ?? [], staticDir);
263
+ // Layout always links /favicon.svg — guarantee one exists for prerender.
264
+ const faviconDest = join(staticDir, 'favicon.svg');
265
+ if (!existsSync(faviconDest) && existsSync(DEFAULT_FAVICON)) {
266
+ copyFileSync(DEFAULT_FAVICON, faviconDest);
267
+ }
268
+
269
+ for (const post of ir.posts) {
270
+ const body = rewriteImageUrls(post.markdown, imageMap);
271
+ const tags =
272
+ post.tags.length > 0 ? `\ntags: [${post.tags.map((t) => yamlQuote(t)).join(', ')}]` : '';
273
+ const desc = post.description ? `\ndescription: ${yamlQuote(post.description)}` : '';
274
+ const md = `---
275
+ title: ${yamlQuote(post.title)}
276
+ date: ${post.date}
277
+ slug: ${yamlQuote(post.slug)}${desc}${tags}
278
+ ---
279
+
280
+ ${body}
281
+ `;
282
+ writeFileSync(join(out, 'posts', postFilename(post.date, post.slug)), md);
283
+ }
284
+
285
+ for (const page of ir.pages) {
286
+ const body = rewriteImageUrls(page.markdown, imageMap);
287
+ const desc = page.description ? `\ndescription: ${yamlQuote(page.description)}` : '';
288
+ const md = `---
289
+ title: ${yamlQuote(page.title)}${desc}
290
+ order: ${page.order}
291
+ ---
292
+
293
+ ${body}
294
+ `;
295
+ writeFileSync(join(out, 'pages', `${page.slug}.md`), md);
296
+ }
297
+
298
+ const topicsLit = ir.topics
299
+ .map((t) => `\t\t{ label: ${yamlQuote(t.label)}, tag: ${yamlQuote(t.tag)} }`)
300
+ .join(',\n');
301
+ const navLit = ir.nav
302
+ .map((n) => `\t\t{ label: ${yamlQuote(n.label)}, href: ${yamlQuote(n.href)} }`)
303
+ .join(',\n');
304
+ const ledeLine = ir.lede ? `\n\tlede: ${yamlQuote(ir.lede)},` : '';
305
+ const logo = activeBrief?.images?.logo;
306
+ const logoLine = logo ? `\n\tlogo: ${yamlQuote(logo)},` : '';
307
+
308
+ writeFileSync(
309
+ join(out, 'filepress.config.ts'),
310
+ `import { defineFilepressConfig } from 'getfilepress';
311
+
312
+ export default defineFilepressConfig({
313
+ title: ${yamlQuote(title)},
314
+ description: ${yamlQuote(ir.identity.description)},
315
+ url: ${yamlQuote(url)},
316
+ author: ${yamlQuote(author)},${ledeLine}${logoLine}
317
+ nav: [
318
+ ${navLit}
319
+ ],
320
+ topics: [
321
+ ${topicsLit}
322
+ ]
323
+ });
324
+ `
325
+ );
326
+
327
+ writeFileSync(join(out, 'theme.css'), themeCssFromBrief(activeBrief ?? undefined));
328
+
329
+ const metaDir = join(out, '.filepress-import');
330
+ mkdirSync(metaDir, { recursive: true });
331
+ writeFileSync(join(metaDir, 'site-ir.json'), JSON.stringify(ir, null, 2));
332
+ if (activeBrief)
333
+ writeFileSync(join(metaDir, 'design-brief.json'), JSON.stringify(activeBrief, null, 2));
334
+ writeAttribution(metaDir, ir);
335
+ const reportPath = join(metaDir, 'import-report.md');
336
+ writeFileSync(reportPath, buildReport(ir, opts, activeBrief));
337
+
338
+ // gitignore import cache
339
+ const gi = join(out, '.gitignore');
340
+ writeFileSync(
341
+ gi,
342
+ `/build
343
+ /node_modules
344
+ .DS_Store
345
+ Thumbs.db
346
+ .filepress/
347
+ .filepress-genie/
348
+ .filepress-import/crawl-cache/
349
+ `
350
+ );
351
+
352
+ return { reportPath };
353
+ }
354
+
355
+ export function defaultEngineRoot(): string {
356
+ // packages/import/src → repo root
357
+ return resolve(dirname(fileURLToPath(import.meta.url)), '../../..');
358
+ }
359
+
360
+ export function defaultOutPath(engineRoot: string, siteName: string): string {
361
+ return resolve(engineRoot, '..', siteName);
362
+ }
363
+
364
+ export function siteNameFromUrl(source: string): string {
365
+ try {
366
+ const host = new URL(source).hostname.replace(/^www\./, '');
367
+ return host.split('.')[0].toLowerCase().replace(/[^a-z0-9-]+/g, '-') || 'imported-site';
368
+ } catch {
369
+ return 'imported-site';
370
+ }
371
+ }
@@ -0,0 +1,13 @@
1
+ {
2
+ "compilerOptions": {
3
+ "module": "esnext",
4
+ "moduleResolution": "bundler",
5
+ "target": "esnext",
6
+ "strict": true,
7
+ "skipLibCheck": true,
8
+ "noEmit": true,
9
+ "lib": ["ESNext", "DOM"],
10
+ "types": ["node"]
11
+ },
12
+ "include": ["src/**/*.ts"]
13
+ }
@@ -0,0 +1,2 @@
1
+ packages:
2
+ - 'packages/*'
@@ -0,0 +1,260 @@
1
+ #!/usr/bin/env node
2
+ // Scaffold a content-only filepress site.
3
+ //
4
+ // Monorepo (default):
5
+ // node scripts/create-site.mjs my-site --title "My Site" --url https://my.site
6
+ // → sites/my-site/
7
+ //
8
+ // External sibling repo:
9
+ // node scripts/create-site.mjs example-site --external ../example-site \
10
+ // --title "…" --url https://…
11
+ // → writes package.json with "getfilepress": "link:<rel-to-engine>"
12
+ //
13
+ // Refuses a non-empty target (edge case 18), unless --force is passed (still
14
+ // refuses to overwrite filepress.config.ts / package.json if present).
15
+
16
+ import { copyFileSync, existsSync, mkdirSync, readdirSync, writeFileSync } from 'node:fs';
17
+ import { dirname, join, relative, resolve } from 'node:path';
18
+ import { fileURLToPath } from 'node:url';
19
+
20
+ const scriptDir = dirname(fileURLToPath(import.meta.url));
21
+ const repoRoot = resolve(scriptDir, '..');
22
+
23
+ function fail(msg) {
24
+ console.error(`create-site: ${msg}`);
25
+ process.exit(1);
26
+ }
27
+
28
+ function parseArgs(argv) {
29
+ const args = { _: [], force: false };
30
+ for (let i = 0; i < argv.length; i++) {
31
+ const a = argv[i];
32
+ if (a === '--title' || a === '--url' || a === '--external') args[a.slice(2)] = argv[++i];
33
+ else if (a === '--force') args.force = true;
34
+ else args._.push(a);
35
+ }
36
+ return args;
37
+ }
38
+
39
+ const args = parseArgs(process.argv.slice(2));
40
+ const name = args._[0];
41
+
42
+ if (!name) {
43
+ fail(
44
+ 'missing site name.\n' +
45
+ ' Usage: node scripts/create-site.mjs <name> [--title ..] [--url ..] [--external <path>]'
46
+ );
47
+ }
48
+ if (!/^[a-z0-9][a-z0-9-]*$/.test(name)) {
49
+ fail(`invalid site name "${name}". Use lowercase letters, numbers, and hyphens.`);
50
+ }
51
+
52
+ const external = Boolean(args.external);
53
+ const target = external ? resolve(repoRoot, args.external) : join(repoRoot, 'sites', name);
54
+
55
+ if (existsSync(target)) {
56
+ const entries = readdirSync(target).filter((e) => e !== '.git' && e !== 'artifacts');
57
+ if (entries.length > 0 && !args.force) {
58
+ fail(`refusing to overwrite non-empty directory: ${target} (pass --force to merge carefully)`);
59
+ }
60
+ if (existsSync(join(target, 'filepress.config.ts'))) {
61
+ fail(`filepress.config.ts already exists in ${target}`);
62
+ }
63
+ if (external && existsSync(join(target, 'package.json'))) {
64
+ fail(`package.json already exists in ${target}`);
65
+ }
66
+ }
67
+
68
+ const title = args.title || name;
69
+ const url = args.url || `https://${name}.example.com`;
70
+
71
+ mkdirSync(join(target, 'posts'), { recursive: true });
72
+ mkdirSync(join(target, 'pages'), { recursive: true });
73
+ mkdirSync(join(target, 'static'), { recursive: true });
74
+
75
+ const defaultFavicon = join(repoRoot, 'packages', 'core', 'src', 'lib', 'assets', 'favicon.svg');
76
+ if (existsSync(defaultFavicon)) {
77
+ copyFileSync(defaultFavicon, join(target, 'static', 'favicon.svg'));
78
+ }
79
+
80
+ const configImport = external
81
+ ? `import { defineFilepressConfig } from 'getfilepress';`
82
+ : `import { defineFilepressConfig } from 'getfilepress';`;
83
+
84
+ writeFileSync(
85
+ join(target, 'filepress.config.ts'),
86
+ `${configImport}
87
+
88
+ export default defineFilepressConfig({
89
+ title: ${JSON.stringify(title)},
90
+ description: 'A filepress site.',
91
+ url: ${JSON.stringify(url)},
92
+ author: ${JSON.stringify(title)},
93
+ topics: []
94
+ });
95
+ `
96
+ );
97
+
98
+ writeFileSync(
99
+ join(target, '.gitignore'),
100
+ `/build
101
+ /node_modules
102
+ .DS_Store
103
+ Thumbs.db
104
+ .filepress/
105
+ .filepress-genie/
106
+ .filepress-import/crawl-cache/
107
+ `
108
+ );
109
+
110
+ const today = new Date().toISOString().slice(0, 10);
111
+ writeFileSync(
112
+ join(target, 'posts', 'hello-world.md'),
113
+ `---
114
+ title: "Hello, world"
115
+ date: ${today}
116
+ description: The first post on ${title}.
117
+ tags: [meta]
118
+ ---
119
+
120
+ This is your first post. Edit or replace \`posts/hello-world.md\`, then push.
121
+ `
122
+ );
123
+
124
+ writeFileSync(
125
+ join(target, 'pages', 'about.md'),
126
+ `---
127
+ title: About
128
+ description: About ${title}.
129
+ order: 1
130
+ ---
131
+
132
+ Tell visitors who you are. This page lives at \`/about\` — edit \`pages/about.md\`.
133
+ `
134
+ );
135
+
136
+ if (external) {
137
+ const relToEngine = relative(target, repoRoot).replace(/\\/g, '/') || '..';
138
+ writeFileSync(
139
+ join(target, 'package.json'),
140
+ JSON.stringify(
141
+ {
142
+ name,
143
+ private: true,
144
+ version: '0.0.1',
145
+ type: 'module',
146
+ scripts: {
147
+ dev: 'filepress dev',
148
+ build: 'filepress build',
149
+ preview: 'filepress preview',
150
+ check: 'filepress check'
151
+ },
152
+ devDependencies: {
153
+ // link: uses the live engine tree (with its workspace node_modules).
154
+ // Alternatives after publish / push:
155
+ // "getfilepress": "^0.1.0"
156
+ // "getfilepress": "github:Catalyst-Forge-LLC/filepress#v0.1.0"
157
+ getfilepress: `link:${relToEngine}`
158
+ }
159
+ },
160
+ null,
161
+ '\t'
162
+ ) + '\n'
163
+ );
164
+
165
+ writeFileSync(
166
+ join(target, 'tsconfig.json'),
167
+ `{
168
+ "compilerOptions": {
169
+ "module": "esnext",
170
+ "moduleResolution": "bundler",
171
+ "target": "esnext",
172
+ "strict": true,
173
+ "skipLibCheck": true,
174
+ "noEmit": true,
175
+ "allowImportingTsExtensions": true
176
+ },
177
+ "include": ["filepress.config.ts"]
178
+ }
179
+ `
180
+ );
181
+
182
+ writeFileSync(
183
+ join(target, 'README.md'),
184
+ `# ${title}
185
+
186
+ Content-only filepress site. Local engine via \`link:${relToEngine}\`.
187
+
188
+ \`\`\`bash
189
+ # once in the engine repo
190
+ cd ${relToEngine} && pnpm install
191
+
192
+ # in this site
193
+ pnpm install
194
+ pnpm dev
195
+ pnpm build # → build/
196
+ \`\`\`
197
+
198
+ Optional: add \`theme.css\` next to \`filepress.config.ts\` to
199
+ override the default Essay theme.
200
+
201
+ ## Deploy (Cloudflare Pages)
202
+
203
+ \`link:\` only works on your machine. For CI/hosting, switch to npm or a git pin:
204
+
205
+ \`\`\`json
206
+ "getfilepress": "^0.1.0"
207
+ \`\`\`
208
+
209
+ or:
210
+
211
+ \`\`\`json
212
+ "getfilepress": "github:Catalyst-Forge-LLC/filepress#v0.1.0"
213
+ \`\`\`
214
+
215
+ | Setting | Value |
216
+ | --- | --- |
217
+ | Build command | \`pnpm install && pnpm build\` |
218
+ | Output directory | \`build\` |
219
+ `
220
+ );
221
+
222
+ console.log(`Created external site at ${target}`);
223
+ console.log(`Next:`);
224
+ console.log(` cd ${relToEngine} && pnpm install # if not already`);
225
+ console.log(` cd ${target} && pnpm install && pnpm dev`);
226
+ } else {
227
+ writeFileSync(
228
+ join(target, 'tsconfig.json'),
229
+ `{
230
+ "compilerOptions": {
231
+ "module": "esnext",
232
+ "moduleResolution": "bundler",
233
+ "target": "esnext",
234
+ "strict": true,
235
+ "skipLibCheck": true,
236
+ "noEmit": true,
237
+ "allowImportingTsExtensions": true
238
+ },
239
+ "include": ["filepress.config.ts"]
240
+ }
241
+ `
242
+ );
243
+
244
+ writeFileSync(
245
+ join(target, 'README.md'),
246
+ `# ${title}
247
+
248
+ Content-only filepress site (monorepo). Edit [\`filepress.config.ts\`](filepress.config.ts)
249
+ and [\`posts/\`](posts/). Optional [\`theme.css\`](theme.css) overrides the Essay theme.
250
+
251
+ \`\`\`bash
252
+ pnpm filepress dev --site ${name}
253
+ pnpm filepress build --site ${name} # → build/
254
+ \`\`\`
255
+ `
256
+ );
257
+
258
+ console.log(`Created sites/${name} (content-only)`);
259
+ console.log(`Next: pnpm filepress dev --site ${name}`);
260
+ }