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,429 @@
1
+ <script lang="ts">
2
+ type VersionRow = {
3
+ id: string;
4
+ label: string;
5
+ createdAt: string;
6
+ starred?: boolean;
7
+ parentId?: string | null;
8
+ };
9
+
10
+ type Health = {
11
+ ollama: { available: boolean; hint: string; model: string; host: string };
12
+ active: { versionId: string; activatedAt: string } | null;
13
+ versions: VersionRow[];
14
+ brief: {
15
+ tokens: { accent: string; bg?: string; ink?: string };
16
+ paletteMode?: string;
17
+ density?: string;
18
+ hero?: string;
19
+ atmosphere?: string;
20
+ elevatedCards?: boolean;
21
+ navStyle?: string;
22
+ };
23
+ };
24
+
25
+ let open = $state(false);
26
+ let loading = $state(false);
27
+ let error = $state('');
28
+ let health = $state<Health | null>(null);
29
+ let stockQuery = $state('abstract dark texture');
30
+ let accent = $state('#1e4d6b');
31
+
32
+ async function api(path: string, init?: RequestInit) {
33
+ const res = await fetch(`/__filepress/genie${path}`, {
34
+ ...init,
35
+ headers: {
36
+ 'content-type': 'application/json',
37
+ ...(init?.headers || {})
38
+ }
39
+ });
40
+ const data = await res.json();
41
+ if (!res.ok) throw new Error(data.error || res.statusText);
42
+ return data;
43
+ }
44
+
45
+ async function refresh() {
46
+ loading = true;
47
+ error = '';
48
+ try {
49
+ health = await api('/health');
50
+ if (health?.brief?.tokens?.accent) accent = health.brief.tokens.accent;
51
+ } catch (e) {
52
+ error = e instanceof Error ? e.message : String(e);
53
+ } finally {
54
+ loading = false;
55
+ }
56
+ }
57
+
58
+ async function openPanel() {
59
+ open = true;
60
+ await refresh();
61
+ }
62
+
63
+ async function steer(brief: Record<string, unknown>, label: string) {
64
+ loading = true;
65
+ error = '';
66
+ try {
67
+ await api('/steer', { method: 'POST', body: JSON.stringify({ brief, label }) });
68
+ await refresh();
69
+ // Theme alias is a real file — full reload picks up CSS reliably.
70
+ location.reload();
71
+ } catch (e) {
72
+ error = e instanceof Error ? e.message : String(e);
73
+ loading = false;
74
+ }
75
+ }
76
+
77
+ async function activate(versionId: string) {
78
+ loading = true;
79
+ error = '';
80
+ try {
81
+ await api('/activate', { method: 'POST', body: JSON.stringify({ versionId }) });
82
+ location.reload();
83
+ } catch (e) {
84
+ error = e instanceof Error ? e.message : String(e);
85
+ loading = false;
86
+ }
87
+ }
88
+
89
+ async function applyStock() {
90
+ loading = true;
91
+ error = '';
92
+ try {
93
+ await api('/stock', {
94
+ method: 'POST',
95
+ body: JSON.stringify({ query: stockQuery, role: 'background' })
96
+ });
97
+ location.reload();
98
+ } catch (e) {
99
+ error = e instanceof Error ? e.message : String(e);
100
+ loading = false;
101
+ }
102
+ }
103
+
104
+ async function onUpload(ev: Event, role: 'hero' | 'background') {
105
+ const input = ev.currentTarget as HTMLInputElement;
106
+ const file = input.files?.[0];
107
+ if (!file) return;
108
+ loading = true;
109
+ error = '';
110
+ try {
111
+ const dataBase64 = await new Promise<string>((resolve, reject) => {
112
+ const reader = new FileReader();
113
+ reader.onload = () => resolve(String(reader.result || ''));
114
+ reader.onerror = () => reject(new Error('Failed to read file'));
115
+ reader.readAsDataURL(file);
116
+ });
117
+ await api('/upload', {
118
+ method: 'POST',
119
+ body: JSON.stringify({
120
+ role,
121
+ filename: file.name,
122
+ dataBase64
123
+ })
124
+ });
125
+ location.reload();
126
+ } catch (e) {
127
+ error = e instanceof Error ? e.message : String(e);
128
+ loading = false;
129
+ } finally {
130
+ input.value = '';
131
+ }
132
+ }
133
+ </script>
134
+
135
+ {#if !open}
136
+ <button type="button" class="genie-fab" onclick={openPanel} title="Open Genie Mode">
137
+ Genie
138
+ </button>
139
+ {:else}
140
+ <aside class="genie-panel" aria-label="Genie Mode">
141
+ <header class="genie-head">
142
+ <strong>Genie</strong>
143
+ <button type="button" class="genie-x" onclick={() => (open = false)}>Close</button>
144
+ </header>
145
+
146
+ {#if error}
147
+ <p class="genie-err">{error}</p>
148
+ {/if}
149
+
150
+ {#if health}
151
+ <section class="genie-sec">
152
+ <h3>Status</h3>
153
+ <p class="genie-muted">
154
+ Ollama: {health.ollama.available ? 'up' : 'down'} · {health.ollama.model}
155
+ </p>
156
+ <p class="genie-hint">{health.ollama.hint}</p>
157
+ </section>
158
+
159
+ <section class="genie-sec">
160
+ <h3>Steers</h3>
161
+ <label class="genie-row">
162
+ Accent
163
+ <input type="color" bind:value={accent} disabled={loading} />
164
+ <button
165
+ type="button"
166
+ disabled={loading}
167
+ onclick={() =>
168
+ steer(
169
+ { tokens: { accent, accentStrong: accent } },
170
+ `Accent ${accent}`
171
+ )}
172
+ >
173
+ Apply
174
+ </button>
175
+ </label>
176
+ <div class="genie-chips">
177
+ <button
178
+ type="button"
179
+ disabled={loading}
180
+ onclick={() =>
181
+ steer(
182
+ { paletteMode: 'dark', hero: 'bold', atmosphere: 'noise', elevatedCards: true, navStyle: 'uppercase-tracked', density: 'balanced' },
183
+ 'Dark punchy'
184
+ )}
185
+ >
186
+ Dark punchy
187
+ </button>
188
+ <button
189
+ type="button"
190
+ disabled={loading}
191
+ onclick={() =>
192
+ steer(
193
+ {
194
+ paletteMode: 'light',
195
+ hero: 'editorial',
196
+ atmosphere: 'none',
197
+ elevatedCards: false,
198
+ navStyle: 'soft',
199
+ density: 'sparse'
200
+ },
201
+ 'Light editorial'
202
+ )}
203
+ >
204
+ Light editorial
205
+ </button>
206
+ <button
207
+ type="button"
208
+ disabled={loading}
209
+ onclick={() => steer({ density: 'dense' }, 'Denser')}
210
+ >
211
+ Denser
212
+ </button>
213
+ <button
214
+ type="button"
215
+ disabled={loading}
216
+ onclick={() => steer({ hero: 'bold' }, 'Bold hero')}
217
+ >
218
+ Bold hero
219
+ </button>
220
+ </div>
221
+ </section>
222
+
223
+ <section class="genie-sec">
224
+ <h3>Background (Openverse)</h3>
225
+ <label class="genie-row">
226
+ Query
227
+ <input type="text" bind:value={stockQuery} disabled={loading} />
228
+ <button type="button" disabled={loading} onclick={applyStock}>Fetch</button>
229
+ </label>
230
+ </section>
231
+
232
+ <section class="genie-sec">
233
+ <h3>Upload</h3>
234
+ <label class="genie-row">
235
+ Hero background
236
+ <input
237
+ type="file"
238
+ accept="image/jpeg,image/png,image/webp,image/gif"
239
+ disabled={loading}
240
+ onchange={(e) => onUpload(e, 'hero')}
241
+ />
242
+ </label>
243
+ <label class="genie-row">
244
+ Page background
245
+ <input
246
+ type="file"
247
+ accept="image/jpeg,image/png,image/webp,image/gif"
248
+ disabled={loading}
249
+ onchange={(e) => onUpload(e, 'background')}
250
+ />
251
+ </label>
252
+ </section>
253
+
254
+ <section class="genie-sec">
255
+ <h3>Versions</h3>
256
+ <p class="genie-muted">
257
+ Active: {health.active?.versionId ?? '(none)'}
258
+ </p>
259
+ <ul class="genie-versions">
260
+ {#each health.versions as v (v.id)}
261
+ <li class:active={health.active?.versionId === v.id}>
262
+ <button
263
+ type="button"
264
+ disabled={loading || health.active?.versionId === v.id}
265
+ onclick={() => activate(v.id)}
266
+ >
267
+ {v.label}
268
+ </button>
269
+ <span class="genie-muted">{v.id === 'baseline' ? 'baseline' : v.createdAt.slice(11, 19)}</span>
270
+ </li>
271
+ {/each}
272
+ </ul>
273
+ <button type="button" class="genie-linkish" disabled={loading} onclick={refresh}>
274
+ Refresh list
275
+ </button>
276
+ </section>
277
+ {:else if loading}
278
+ <p class="genie-muted">Loading…</p>
279
+ {/if}
280
+ </aside>
281
+ {/if}
282
+
283
+ <style>
284
+ .genie-fab {
285
+ position: fixed;
286
+ right: 1.1rem;
287
+ bottom: 1.1rem;
288
+ z-index: 100000;
289
+ border: 1px solid color-mix(in srgb, var(--accent, #c9a227) 55%, #333);
290
+ background: color-mix(in srgb, var(--bg, #111) 88%, #000);
291
+ color: var(--accent, #f0c040);
292
+ font: 600 0.8rem/1 var(--font-sans, system-ui, sans-serif);
293
+ letter-spacing: 0.06em;
294
+ text-transform: uppercase;
295
+ padding: 0.7rem 0.95rem;
296
+ border-radius: 999px;
297
+ cursor: pointer;
298
+ box-shadow: 0 8px 28px color-mix(in srgb, #000 45%, transparent);
299
+ }
300
+
301
+ .genie-panel {
302
+ position: fixed;
303
+ top: 0;
304
+ right: 0;
305
+ bottom: 0;
306
+ z-index: 100000;
307
+ width: min(22rem, 100vw);
308
+ overflow: auto;
309
+ background: color-mix(in srgb, var(--bg, #12121a) 94%, #000);
310
+ color: var(--ink, #eee);
311
+ border-left: 1px solid var(--rule, #333);
312
+ padding: 1rem 1rem 2rem;
313
+ font: 0.9rem/1.45 var(--font-sans, system-ui, sans-serif);
314
+ box-shadow: -12px 0 40px color-mix(in srgb, #000 40%, transparent);
315
+ }
316
+
317
+ .genie-head {
318
+ display: flex;
319
+ align-items: center;
320
+ justify-content: space-between;
321
+ margin-bottom: 1rem;
322
+ }
323
+
324
+ .genie-x,
325
+ .genie-panel button {
326
+ cursor: pointer;
327
+ }
328
+
329
+ .genie-sec {
330
+ margin-bottom: 1.25rem;
331
+ padding-bottom: 1rem;
332
+ border-bottom: 1px solid var(--rule, #333);
333
+ }
334
+
335
+ .genie-sec h3 {
336
+ margin: 0 0 0.5rem;
337
+ font-size: 0.72rem;
338
+ letter-spacing: 0.1em;
339
+ text-transform: uppercase;
340
+ color: var(--ink-soft, #999);
341
+ font-weight: 600;
342
+ }
343
+
344
+ .genie-muted {
345
+ color: var(--ink-soft, #999);
346
+ font-size: 0.82rem;
347
+ margin: 0.25rem 0;
348
+ }
349
+
350
+ .genie-hint {
351
+ font-size: 0.78rem;
352
+ color: var(--ink-soft, #aaa);
353
+ line-height: 1.4;
354
+ margin: 0.4rem 0 0;
355
+ word-break: break-word;
356
+ }
357
+
358
+ .genie-err {
359
+ background: color-mix(in srgb, #c44 22%, transparent);
360
+ border: 1px solid #c44;
361
+ padding: 0.5rem 0.65rem;
362
+ border-radius: 6px;
363
+ font-size: 0.82rem;
364
+ }
365
+
366
+ .genie-row {
367
+ display: grid;
368
+ gap: 0.35rem;
369
+ margin-bottom: 0.65rem;
370
+ font-size: 0.82rem;
371
+ }
372
+
373
+ .genie-row input[type='text'],
374
+ .genie-row input[type='file'] {
375
+ width: 100%;
376
+ }
377
+
378
+ .genie-chips {
379
+ display: flex;
380
+ flex-wrap: wrap;
381
+ gap: 0.4rem;
382
+ }
383
+
384
+ .genie-chips button,
385
+ .genie-row button,
386
+ .genie-versions button {
387
+ border: 1px solid var(--rule, #444);
388
+ background: var(--surface, #1c1c22);
389
+ color: var(--ink, #eee);
390
+ border-radius: 6px;
391
+ padding: 0.35rem 0.55rem;
392
+ font-size: 0.78rem;
393
+ }
394
+
395
+ .genie-chips button:hover,
396
+ .genie-row button:hover,
397
+ .genie-versions button:hover {
398
+ border-color: var(--accent, #f0c040);
399
+ }
400
+
401
+ .genie-versions {
402
+ list-style: none;
403
+ margin: 0.5rem 0;
404
+ padding: 0;
405
+ display: grid;
406
+ gap: 0.35rem;
407
+ }
408
+
409
+ .genie-versions li {
410
+ display: flex;
411
+ align-items: center;
412
+ justify-content: space-between;
413
+ gap: 0.5rem;
414
+ }
415
+
416
+ .genie-versions li.active button {
417
+ border-color: var(--accent, #f0c040);
418
+ color: var(--accent, #f0c040);
419
+ }
420
+
421
+ .genie-linkish {
422
+ background: none;
423
+ border: none;
424
+ color: var(--accent, #f0c040);
425
+ padding: 0;
426
+ font-size: 0.8rem;
427
+ text-decoration: underline;
428
+ }
429
+ </style>
@@ -0,0 +1,237 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import {
4
+ DEFAULT_BRIEF,
5
+ themeCssFromBrief,
6
+ tokensFromSourceCss
7
+ } from '../../../../import/src/theme.ts';
8
+ import { formatAttributionMarkdown, searchStockImage } from '../../../../import/src/stock.ts';
9
+ import { fetchBuffer } from '../../../../import/src/fetch.ts';
10
+ import { ollamaAvailable, ollamaSetupHint } from '../../../../import/src/ollama.ts';
11
+ import type { DesignBrief } from '../../../../import/src/ir.ts';
12
+ import {
13
+ activateVersion,
14
+ ensureBaseline,
15
+ getActive,
16
+ listVersions,
17
+ readVersionBrief,
18
+ writeSnapshot
19
+ } from './store.ts';
20
+ import type { GenieSteerPatch } from './types.ts';
21
+
22
+ /** Build a starting brief from on-disk theme.css + optional prior genie brief. */
23
+ export function loadWorkingBrief(siteRoot: string): DesignBrief {
24
+ const active = getActive(siteRoot);
25
+ if (active) {
26
+ const fromVer = readVersionBrief(siteRoot, active.versionId);
27
+ if (fromVer) return structuredClone(fromVer) as DesignBrief;
28
+ }
29
+ const themePath = join(siteRoot, 'theme.css');
30
+ const css = existsSync(themePath) ? readFileSync(themePath, 'utf8') : '';
31
+ const tokens = tokensFromSourceCss(`<style>${css}</style>`);
32
+ const dark = Boolean(tokens.bg && /^#0|^#1[0-9a-f]/i.test(tokens.bg));
33
+ return {
34
+ ...DEFAULT_BRIEF,
35
+ tokens: { ...DEFAULT_BRIEF.tokens, ...tokens },
36
+ paletteMode: dark ? 'dark' : 'light',
37
+ hero: dark ? 'bold' : 'editorial',
38
+ atmosphere: dark ? 'noise' : 'none',
39
+ navStyle: dark ? 'uppercase-tracked' : 'soft',
40
+ elevatedCards: dark,
41
+ density: dark ? 'balanced' : 'sparse',
42
+ cssNotes: [...DEFAULT_BRIEF.cssNotes]
43
+ };
44
+ }
45
+
46
+ export function loadWorkingThemeCss(siteRoot: string, brief: DesignBrief): string {
47
+ const themePath = join(siteRoot, 'theme.css');
48
+ if (existsSync(themePath)) return readFileSync(themePath, 'utf8');
49
+ return themeCssFromBrief(brief);
50
+ }
51
+
52
+ export function ensureSiteThemeFile(siteRoot: string) {
53
+ const themePath = join(siteRoot, 'theme.css');
54
+ if (!existsSync(themePath)) {
55
+ const brief = loadWorkingBrief(siteRoot);
56
+ writeFileSync(themePath, themeCssFromBrief(brief));
57
+ }
58
+ mkdirSync(join(siteRoot, 'static', 'images'), { recursive: true });
59
+ }
60
+
61
+ function mergeBrief(base: DesignBrief, patch: Partial<DesignBrief>): DesignBrief {
62
+ return {
63
+ ...base,
64
+ ...patch,
65
+ tokens: { ...base.tokens, ...patch.tokens },
66
+ fonts: patch.fonts
67
+ ? {
68
+ serif: patch.fonts.serif ?? base.fonts?.serif ?? 'Georgia',
69
+ sans: patch.fonts.sans ?? base.fonts?.sans ?? 'system-ui',
70
+ mono: patch.fonts.mono ?? base.fonts?.mono ?? 'monospace',
71
+ googleHref:
72
+ patch.fonts.googleHref !== undefined
73
+ ? patch.fonts.googleHref
74
+ : (base.fonts?.googleHref ?? null)
75
+ }
76
+ : base.fonts,
77
+ images: { ...base.images, ...patch.images },
78
+ do: patch.do ?? base.do,
79
+ dont: patch.dont ?? base.dont,
80
+ cssNotes: patch.cssNotes ?? base.cssNotes
81
+ };
82
+ }
83
+
84
+ export function ensureBaselineIfNeeded(siteRoot: string) {
85
+ ensureSiteThemeFile(siteRoot);
86
+ const brief = loadWorkingBrief(siteRoot);
87
+ const themeCss = loadWorkingThemeCss(siteRoot, brief);
88
+ return ensureBaseline(siteRoot, { brief, themeCss });
89
+ }
90
+
91
+ export async function health(siteRoot: string) {
92
+ const host = process.env.OLLAMA_HOST?.trim() || 'http://127.0.0.1:11434';
93
+ const model = process.env.FILEPRESS_OLLAMA_MODEL?.trim() || 'gemma4:12b';
94
+ const up = await ollamaAvailable(host);
95
+ ensureBaselineIfNeeded(siteRoot);
96
+ return {
97
+ ok: true,
98
+ mode: 'genie',
99
+ siteRoot,
100
+ ollama: {
101
+ host,
102
+ model,
103
+ available: up,
104
+ hint: up
105
+ ? `Ollama is reachable. For a GPU-tuned named variant, try Finetuna: https://github.com/Catalyst-Forge-LLC/finetuna — then set FILEPRESS_OLLAMA_MODEL.`
106
+ : ollamaSetupHint(host)
107
+ },
108
+ active: getActive(siteRoot),
109
+ versions: listVersions(siteRoot).map((v) => ({
110
+ id: v.id,
111
+ label: v.label,
112
+ createdAt: v.createdAt,
113
+ starred: v.starred,
114
+ parentId: v.parentId
115
+ })),
116
+ brief: loadWorkingBrief(siteRoot)
117
+ };
118
+ }
119
+
120
+ function collectImageFiles(siteRoot: string, brief: DesignBrief) {
121
+ const imageFiles: Array<{ absPath: string; destName: string }> = [];
122
+ for (const slot of ['hero', 'header', 'background', 'logo', 'portrait'] as const) {
123
+ const rel = brief.images?.[slot];
124
+ if (!rel || !rel.startsWith('/images/')) continue;
125
+ const name = rel.slice('/images/'.length);
126
+ const abs = join(siteRoot, 'static', 'images', name);
127
+ if (existsSync(abs)) imageFiles.push({ absPath: abs, destName: name });
128
+ }
129
+ return imageFiles;
130
+ }
131
+
132
+ export function applySteer(siteRoot: string, patch: GenieSteerPatch) {
133
+ ensureBaselineIfNeeded(siteRoot);
134
+ const base = loadWorkingBrief(siteRoot);
135
+ const brief = mergeBrief(base, patch.brief || {});
136
+ const themeCss = themeCssFromBrief(brief);
137
+ const label =
138
+ patch.label ||
139
+ patch.prompt?.slice(0, 48) ||
140
+ `Steer ${new Date().toISOString().slice(11, 19)}`;
141
+
142
+ const meta = writeSnapshot(siteRoot, {
143
+ label,
144
+ prompt: patch.prompt || '',
145
+ brief,
146
+ themeCss,
147
+ imageFiles: collectImageFiles(siteRoot, brief),
148
+ steers: [{ type: 'steer', patch: patch.brief || {} }]
149
+ });
150
+
151
+ const shouldActivate = patch.activate !== false;
152
+ const active = shouldActivate ? activateVersion(siteRoot, meta.id) : getActive(siteRoot);
153
+ return { meta, active, brief };
154
+ }
155
+
156
+ export async function fetchStockCover(
157
+ siteRoot: string,
158
+ opts: { query: string; role?: 'background' | 'hero' | 'header'; activate?: boolean }
159
+ ) {
160
+ ensureBaselineIfNeeded(siteRoot);
161
+ const role = opts.role || 'background';
162
+ const query = opts.query.trim() || 'abstract dark texture';
163
+ const hit = await searchStockImage(query);
164
+ if (!hit) {
165
+ throw new Error(
166
+ `No Openverse hit for “${query}”. Try a shorter query like “abstract dark texture”.`
167
+ );
168
+ }
169
+
170
+ const buf = await fetchBuffer(hit.url);
171
+ const ext = hit.url.toLowerCase().includes('.png')
172
+ ? '.png'
173
+ : hit.url.toLowerCase().includes('.webp')
174
+ ? '.webp'
175
+ : '.jpg';
176
+ const destName = `${role}${ext}`;
177
+ const staticImg = join(siteRoot, 'static', 'images');
178
+ mkdirSync(staticImg, { recursive: true });
179
+ writeFileSync(join(staticImg, destName), buf);
180
+
181
+ const attribution = `# Image attribution\n\n${formatAttributionMarkdown({ [role]: hit })}\n`;
182
+ const base = loadWorkingBrief(siteRoot);
183
+ const brief = mergeBrief(base, { images: { ...base.images, [role]: `/images/${destName}` } });
184
+ const themeCss = themeCssFromBrief(brief);
185
+ const meta = writeSnapshot(siteRoot, {
186
+ label: `Stock ${role}: ${query.slice(0, 40)}`,
187
+ prompt: query,
188
+ brief,
189
+ themeCss,
190
+ imageFiles: collectImageFiles(siteRoot, brief),
191
+ attribution,
192
+ steers: [{ type: 'stock', role, query, title: hit.title }]
193
+ });
194
+ const active =
195
+ opts.activate !== false ? activateVersion(siteRoot, meta.id) : getActive(siteRoot);
196
+ return { meta, active, brief, hit };
197
+ }
198
+
199
+ const MAX_UPLOAD = 5 * 1024 * 1024;
200
+
201
+ export function receiveUpload(
202
+ siteRoot: string,
203
+ opts: {
204
+ role: 'hero' | 'background' | 'header' | 'logo';
205
+ filename: string;
206
+ dataBase64: string;
207
+ activate?: boolean;
208
+ }
209
+ ) {
210
+ ensureBaselineIfNeeded(siteRoot);
211
+ const raw = opts.dataBase64.replace(/^data:[^;]+;base64,/, '');
212
+ const buf = Buffer.from(raw, 'base64');
213
+ if (!buf.length) throw new Error('Empty upload');
214
+ if (buf.length > MAX_UPLOAD) throw new Error('Upload exceeds 5MB limit');
215
+
216
+ const safeBase = opts.filename.replace(/[^\w.-]+/g, '-').slice(0, 80) || opts.role;
217
+ const extMatch = safeBase.match(/\.(jpe?g|png|webp|gif)$/i);
218
+ let ext = extMatch ? extMatch[0].toLowerCase() : '.jpg';
219
+ if (ext === '.jpeg') ext = '.jpg';
220
+ const destName = `${opts.role}${ext}`;
221
+
222
+ const staticImg = join(siteRoot, 'static', 'images');
223
+ mkdirSync(staticImg, { recursive: true });
224
+ writeFileSync(join(staticImg, destName), buf);
225
+
226
+ return applySteer(siteRoot, {
227
+ label: `Upload ${opts.role}`,
228
+ prompt: `Local upload → ${opts.role}`,
229
+ brief: { images: { [opts.role]: `/images/${destName}` } },
230
+ activate: opts.activate !== false
231
+ });
232
+ }
233
+
234
+ export function doActivate(siteRoot: string, versionId: string) {
235
+ ensureBaselineIfNeeded(siteRoot);
236
+ return activateVersion(siteRoot, versionId);
237
+ }