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.
- package/LICENSE +21 -0
- package/README.md +178 -4
- package/package.json +89 -7
- package/packages/app/package.json +31 -0
- package/packages/app/src/app.d.ts +12 -0
- package/packages/app/src/app.html +12 -0
- package/packages/app/src/critical-theme.d.ts +4 -0
- package/packages/app/src/lib/content.server.ts +8 -0
- package/packages/app/src/lib/empty-theme.css +1 -0
- package/packages/app/src/lib/genie/GenieHost.svelte +16 -0
- package/packages/app/src/lib/genie/GeniePanel.svelte +429 -0
- package/packages/app/src/lib/genie/ops.ts +237 -0
- package/packages/app/src/lib/genie/store.ts +217 -0
- package/packages/app/src/lib/genie/types.ts +61 -0
- package/packages/app/src/lib/pages.server.ts +7 -0
- package/packages/app/src/lib/site.server.ts +52 -0
- package/packages/app/src/lib/theme-entry.ts +7 -0
- package/packages/app/src/routes/+layout.svelte +29 -0
- package/packages/app/src/routes/+layout.ts +10 -0
- package/packages/app/src/routes/+page.server.ts +28 -0
- package/packages/app/src/routes/+page.svelte +67 -0
- package/packages/app/src/routes/[slug]/+page.server.ts +20 -0
- package/packages/app/src/routes/[slug]/+page.svelte +47 -0
- package/packages/app/src/routes/page/[n]/+page.server.ts +21 -0
- package/packages/app/src/routes/page/[n]/+page.svelte +36 -0
- package/packages/app/src/routes/posts/[slug]/+page.server.ts +24 -0
- package/packages/app/src/routes/posts/[slug]/+page.svelte +95 -0
- package/packages/app/src/routes/robots.txt/+server.ts +11 -0
- package/packages/app/src/routes/rss.xml/+server.ts +13 -0
- package/packages/app/src/routes/sitemap.xml/+server.ts +19 -0
- package/packages/app/src/routes/tags/+page.server.ts +4 -0
- package/packages/app/src/routes/tags/+page.svelte +26 -0
- package/packages/app/src/routes/tags/[tag]/+page.server.ts +15 -0
- package/packages/app/src/routes/tags/[tag]/+page.svelte +23 -0
- package/packages/app/src/routes/topics/+page.server.ts +28 -0
- package/packages/app/src/routes/topics/+page.svelte +47 -0
- package/packages/app/src/routes/writing/+page.server.ts +12 -0
- package/packages/app/src/routes/writing/+page.svelte +38 -0
- package/packages/app/src/site-theme.d.ts +2 -0
- package/packages/app/static/.gitkeep +0 -0
- package/packages/app/tsconfig.json +15 -0
- package/packages/app/vite-plugin-critical-theme.ts +70 -0
- package/packages/app/vite-plugin-genie.ts +114 -0
- package/packages/app/vite.config.ts +141 -0
- package/packages/core/package.json +51 -0
- package/packages/core/src/lib/assets/favicon.svg +1 -0
- package/packages/core/src/lib/components/Newsletter.svelte +13 -0
- package/packages/core/src/lib/components/PostCard.svelte +34 -0
- package/packages/core/src/lib/components/PostIndex.svelte +86 -0
- package/packages/core/src/lib/components/SiteFooter.svelte +15 -0
- package/packages/core/src/lib/components/SiteHeader.svelte +28 -0
- package/packages/core/src/lib/config.ts +154 -0
- package/packages/core/src/lib/content/content.ts +193 -0
- package/packages/core/src/lib/content/feeds.ts +102 -0
- package/packages/core/src/lib/content/markdown.ts +78 -0
- package/packages/core/src/lib/content/pages.ts +103 -0
- package/packages/core/src/lib/content/parse.ts +214 -0
- package/packages/core/src/lib/content/rehype-figure.ts +79 -0
- package/packages/core/src/lib/content/types.ts +88 -0
- package/packages/core/src/lib/format.ts +25 -0
- package/packages/core/src/lib/index.ts +27 -0
- package/packages/core/src/lib/server.ts +33 -0
- package/packages/core/src/lib/styles/fonts.css +72 -0
- package/packages/core/src/lib/styles/theme.css +762 -0
- package/packages/core/src/lib/theme.ts +5 -0
- package/packages/import/package.json +27 -0
- package/packages/import/src/cli.ts +346 -0
- package/packages/import/src/discover.ts +190 -0
- package/packages/import/src/extract.ts +378 -0
- package/packages/import/src/fetch.ts +63 -0
- package/packages/import/src/html-to-md.ts +21 -0
- package/packages/import/src/images.ts +247 -0
- package/packages/import/src/inspire.ts +417 -0
- package/packages/import/src/ir.ts +109 -0
- package/packages/import/src/ollama.ts +145 -0
- package/packages/import/src/stock.ts +218 -0
- package/packages/import/src/theme.ts +478 -0
- package/packages/import/src/write-site.ts +371 -0
- package/packages/import/tsconfig.json +13 -0
- package/pnpm-workspace.yaml +2 -0
- package/scripts/create-site.mjs +260 -0
- package/scripts/filepress.mjs +273 -0
- package/scripts/link-embedded-packages.mjs +40 -0
- 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
|
+
}
|