getfilepress 0.1.2 → 0.1.4
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/README.md +36 -10
- package/package.json +99 -99
- package/packages/app/README.md +19 -0
- package/packages/app/package.json +1 -1
- package/packages/app/src/lib/genie/GeniePanel.svelte +672 -144
- package/packages/app/src/lib/genie/config-patch.ts +72 -0
- package/packages/app/src/lib/genie/ops.ts +231 -10
- package/packages/app/src/lib/genie/store.ts +12 -3
- package/packages/app/src/lib/pages.server.ts +4 -2
- package/packages/app/src/routes/sitemap.xml/+server.ts +5 -2
- package/packages/app/vite-plugin-genie.ts +64 -1
- package/packages/app/vite-plugin-path-mounts.ts +107 -0
- package/packages/app/vite.config.ts +112 -81
- package/packages/core/README.md +29 -0
- package/packages/core/package.json +1 -1
- package/packages/core/src/lib/config.ts +12 -1
- package/packages/core/src/lib/content/feeds.ts +5 -1
- package/packages/core/src/lib/content/pages.ts +11 -1
- package/packages/core/src/lib/content/parse.ts +12 -3
- package/packages/core/src/lib/index.ts +2 -1
- package/packages/core/src/lib/paths-shared.ts +89 -0
- package/packages/core/src/lib/paths.ts +84 -0
- package/packages/core/src/lib/server.ts +8 -1
- package/packages/core/src/lib/styles/theme.css +3 -2
- package/packages/import/package.json +2 -1
- package/packages/import/src/cli.ts +77 -3
- package/packages/import/src/ollama.ts +43 -1
- package/packages/import/src/ollanet-scan.ts +132 -0
- package/packages/import/tsconfig.json +1 -1
- package/scripts/copy-path-mounts.mjs +40 -0
- package/scripts/create-site.mjs +4 -11
- package/scripts/filepress.mjs +31 -3
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
|
|
4
|
+
/** Limited chrome keys Genie may bake into `filepress.config.ts` (Q8). */
|
|
5
|
+
export type GenieConfigPatch = {
|
|
6
|
+
lede?: string | null;
|
|
7
|
+
tagline?: string | null;
|
|
8
|
+
logo?: string | null;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
const KEYS = ['lede', 'tagline', 'logo'] as const;
|
|
12
|
+
|
|
13
|
+
function upsertStringField(src: string, key: string, value: string | null | undefined): string {
|
|
14
|
+
if (value === undefined) return src;
|
|
15
|
+
const fieldRe = new RegExp(`(\\n\\t)${key}:\\s*(['\`"])([\\s\\S]*?)\\2,?`, 'm');
|
|
16
|
+
if (value === null || value === '') {
|
|
17
|
+
return src.replace(fieldRe, '');
|
|
18
|
+
}
|
|
19
|
+
const quoted = JSON.stringify(value);
|
|
20
|
+
if (fieldRe.test(src)) {
|
|
21
|
+
return src.replace(fieldRe, `$1${key}: ${quoted},`);
|
|
22
|
+
}
|
|
23
|
+
// Insert after defineFilepressConfig({
|
|
24
|
+
return src.replace(
|
|
25
|
+
/(defineFilepressConfig\(\{\s*)/,
|
|
26
|
+
`$1\n\t${key}: ${quoted},`
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Apply a config patch to the site's `filepress.config.ts` (string fields only). */
|
|
31
|
+
export function applyConfigPatch(siteRoot: string, patch: GenieConfigPatch): void {
|
|
32
|
+
const path = join(siteRoot, 'filepress.config.ts');
|
|
33
|
+
if (!existsSync(path)) {
|
|
34
|
+
throw new Error('No filepress.config.ts at site root — cannot patch config');
|
|
35
|
+
}
|
|
36
|
+
let src = readFileSync(path, 'utf8');
|
|
37
|
+
const before = src;
|
|
38
|
+
for (const key of KEYS) {
|
|
39
|
+
if (key in patch) src = upsertStringField(src, key, patch[key]);
|
|
40
|
+
}
|
|
41
|
+
if (src !== before) writeFileSync(path, src);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function readConfigPatchFile(path: string): GenieConfigPatch {
|
|
45
|
+
if (!existsSync(path)) return {};
|
|
46
|
+
try {
|
|
47
|
+
return JSON.parse(readFileSync(path, 'utf8')) as GenieConfigPatch;
|
|
48
|
+
} catch {
|
|
49
|
+
return {};
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Best-effort identity fields for Ollama prompts (no TS evaluate). */
|
|
54
|
+
export function stubIdentityFromConfig(siteRoot: string): {
|
|
55
|
+
title: string;
|
|
56
|
+
description: string;
|
|
57
|
+
author: string;
|
|
58
|
+
canonicalUrl: string;
|
|
59
|
+
} {
|
|
60
|
+
const path = join(siteRoot, 'filepress.config.ts');
|
|
61
|
+
const src = existsSync(path) ? readFileSync(path, 'utf8') : '';
|
|
62
|
+
const pick = (key: string, fallback: string) => {
|
|
63
|
+
const m = src.match(new RegExp(`${key}:\\s*['\`"]([^'\`"]+)['\`"]`));
|
|
64
|
+
return m?.[1]?.trim() || fallback;
|
|
65
|
+
};
|
|
66
|
+
return {
|
|
67
|
+
title: pick('title', 'Site'),
|
|
68
|
+
description: pick('description', ''),
|
|
69
|
+
author: pick('author', pick('title', 'Author')),
|
|
70
|
+
canonicalUrl: pick('url', 'https://example.com')
|
|
71
|
+
};
|
|
72
|
+
}
|
|
@@ -6,9 +6,22 @@ import {
|
|
|
6
6
|
tokensFromSourceCss
|
|
7
7
|
} from '../../../../import/src/theme.ts';
|
|
8
8
|
import { formatAttributionMarkdown, searchStockImage } from '../../../../import/src/stock.ts';
|
|
9
|
-
import { fetchBuffer } from '../../../../import/src/fetch.ts';
|
|
10
|
-
import {
|
|
11
|
-
|
|
9
|
+
import { fetchBuffer, fetchText } from '../../../../import/src/fetch.ts';
|
|
10
|
+
import {
|
|
11
|
+
briefFromInspiration,
|
|
12
|
+
extractInspirationSignals,
|
|
13
|
+
type InspirationSignals
|
|
14
|
+
} from '../../../../import/src/inspire.ts';
|
|
15
|
+
import {
|
|
16
|
+
assertOllamaEndpoint,
|
|
17
|
+
generateDesignBrief,
|
|
18
|
+
listOllamaModels,
|
|
19
|
+
ollamaAvailable,
|
|
20
|
+
ollamaSetupHint,
|
|
21
|
+
summarizeHtmlForBrief
|
|
22
|
+
} from '../../../../import/src/ollama.ts';
|
|
23
|
+
import { scanOllamaNetwork } from '../../../../import/src/ollanet-scan.ts';
|
|
24
|
+
import type { DesignBrief, SiteIR } from '../../../../import/src/ir.ts';
|
|
12
25
|
import {
|
|
13
26
|
activateVersion,
|
|
14
27
|
ensureBaseline,
|
|
@@ -17,6 +30,7 @@ import {
|
|
|
17
30
|
readVersionBrief,
|
|
18
31
|
writeSnapshot
|
|
19
32
|
} from './store.ts';
|
|
33
|
+
import { stubIdentityFromConfig, type GenieConfigPatch } from './config-patch.ts';
|
|
20
34
|
import type { GenieSteerPatch } from './types.ts';
|
|
21
35
|
|
|
22
36
|
/** Build a starting brief from on-disk theme.css + optional prior genie brief. */
|
|
@@ -88,10 +102,19 @@ export function ensureBaselineIfNeeded(siteRoot: string) {
|
|
|
88
102
|
return ensureBaseline(siteRoot, { brief, themeCss });
|
|
89
103
|
}
|
|
90
104
|
|
|
105
|
+
function ollamaHost() {
|
|
106
|
+
return assertOllamaEndpoint(process.env.OLLAMA_HOST?.trim() || 'http://127.0.0.1:11434');
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function defaultOllamaModel() {
|
|
110
|
+
return process.env.FILEPRESS_OLLAMA_MODEL?.trim() || 'gemma4:12b';
|
|
111
|
+
}
|
|
112
|
+
|
|
91
113
|
export async function health(siteRoot: string) {
|
|
92
|
-
const host =
|
|
93
|
-
const model =
|
|
114
|
+
const host = ollamaHost();
|
|
115
|
+
const model = defaultOllamaModel();
|
|
94
116
|
const up = await ollamaAvailable(host);
|
|
117
|
+
const models = up ? await listOllamaModels(host) : [];
|
|
95
118
|
ensureBaselineIfNeeded(siteRoot);
|
|
96
119
|
return {
|
|
97
120
|
ok: true,
|
|
@@ -100,9 +123,12 @@ export async function health(siteRoot: string) {
|
|
|
100
123
|
ollama: {
|
|
101
124
|
host,
|
|
102
125
|
model,
|
|
126
|
+
models,
|
|
103
127
|
available: up,
|
|
104
128
|
hint: up
|
|
105
|
-
?
|
|
129
|
+
? models.length
|
|
130
|
+
? `Ollama is reachable (${models.length} model${models.length === 1 ? '' : 's'}). Pick a server/model below, or tune with Finetuna: https://github.com/Catalyst-Forge-LLC/finetuna`
|
|
131
|
+
: `Ollama is up but no models listed. Pull one (e.g. ollama pull ${model}) or use Finetuna: https://github.com/Catalyst-Forge-LLC/finetuna`
|
|
106
132
|
: ollamaSetupHint(host)
|
|
107
133
|
},
|
|
108
134
|
active: getActive(siteRoot),
|
|
@@ -117,6 +143,20 @@ export async function health(siteRoot: string) {
|
|
|
117
143
|
};
|
|
118
144
|
}
|
|
119
145
|
|
|
146
|
+
function resolveRequestHost(raw?: string) {
|
|
147
|
+
return raw?.trim() ? assertOllamaEndpoint(raw) : ollamaHost();
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Optional ollanet discovery (localhost / config / Tailscale; LAN only when requested). */
|
|
151
|
+
export async function scanOllamaHosts(opts: { lan?: boolean } = {}) {
|
|
152
|
+
const result = await scanOllamaNetwork({ lan: Boolean(opts.lan) });
|
|
153
|
+
return {
|
|
154
|
+
...result,
|
|
155
|
+
defaultHost: ollamaHost(),
|
|
156
|
+
defaultModel: defaultOllamaModel()
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
120
160
|
function collectImageFiles(siteRoot: string, brief: DesignBrief) {
|
|
121
161
|
const imageFiles: Array<{ absPath: string; destName: string }> = [];
|
|
122
162
|
for (const slot of ['hero', 'header', 'background', 'logo', 'portrait'] as const) {
|
|
@@ -129,7 +169,10 @@ function collectImageFiles(siteRoot: string, brief: DesignBrief) {
|
|
|
129
169
|
return imageFiles;
|
|
130
170
|
}
|
|
131
171
|
|
|
132
|
-
export function applySteer(
|
|
172
|
+
export function applySteer(
|
|
173
|
+
siteRoot: string,
|
|
174
|
+
patch: GenieSteerPatch & { configPatch?: GenieConfigPatch }
|
|
175
|
+
) {
|
|
133
176
|
ensureBaselineIfNeeded(siteRoot);
|
|
134
177
|
const base = loadWorkingBrief(siteRoot);
|
|
135
178
|
const brief = mergeBrief(base, patch.brief || {});
|
|
@@ -145,7 +188,8 @@ export function applySteer(siteRoot: string, patch: GenieSteerPatch) {
|
|
|
145
188
|
brief,
|
|
146
189
|
themeCss,
|
|
147
190
|
imageFiles: collectImageFiles(siteRoot, brief),
|
|
148
|
-
steers: [{ type: 'steer', patch: patch.brief || {} }]
|
|
191
|
+
steers: [{ type: 'steer', patch: patch.brief || {} }],
|
|
192
|
+
configPatch: patch.configPatch
|
|
149
193
|
});
|
|
150
194
|
|
|
151
195
|
const shouldActivate = patch.activate !== false;
|
|
@@ -214,7 +258,7 @@ export function receiveUpload(
|
|
|
214
258
|
if (buf.length > MAX_UPLOAD) throw new Error('Upload exceeds 5MB limit');
|
|
215
259
|
|
|
216
260
|
const safeBase = opts.filename.replace(/[^\w.-]+/g, '-').slice(0, 80) || opts.role;
|
|
217
|
-
const extMatch = safeBase.match(/\.(jpe?g|png|webp|gif)$/i);
|
|
261
|
+
const extMatch = safeBase.match(/\.(jpe?g|png|webp|gif|svg)$/i);
|
|
218
262
|
let ext = extMatch ? extMatch[0].toLowerCase() : '.jpg';
|
|
219
263
|
if (ext === '.jpeg') ext = '.jpg';
|
|
220
264
|
const destName = `${opts.role}${ext}`;
|
|
@@ -223,10 +267,12 @@ export function receiveUpload(
|
|
|
223
267
|
mkdirSync(staticImg, { recursive: true });
|
|
224
268
|
writeFileSync(join(staticImg, destName), buf);
|
|
225
269
|
|
|
270
|
+
const imagePath = `/images/${destName}`;
|
|
226
271
|
return applySteer(siteRoot, {
|
|
227
272
|
label: `Upload ${opts.role}`,
|
|
228
273
|
prompt: `Local upload → ${opts.role}`,
|
|
229
|
-
brief: { images: { [opts.role]:
|
|
274
|
+
brief: { images: { [opts.role]: imagePath } },
|
|
275
|
+
configPatch: opts.role === 'logo' ? { logo: imagePath } : undefined,
|
|
230
276
|
activate: opts.activate !== false
|
|
231
277
|
});
|
|
232
278
|
}
|
|
@@ -235,3 +281,178 @@ export function doActivate(siteRoot: string, versionId: string) {
|
|
|
235
281
|
ensureBaselineIfNeeded(siteRoot);
|
|
236
282
|
return activateVersion(siteRoot, versionId);
|
|
237
283
|
}
|
|
284
|
+
|
|
285
|
+
function assertHttpUrls(urls: string[]): string[] {
|
|
286
|
+
const out: string[] = [];
|
|
287
|
+
for (const raw of urls) {
|
|
288
|
+
const u = raw.trim();
|
|
289
|
+
if (!u) continue;
|
|
290
|
+
let parsed: URL;
|
|
291
|
+
try {
|
|
292
|
+
parsed = new URL(u);
|
|
293
|
+
} catch {
|
|
294
|
+
throw new Error(`Invalid URL: ${u}`);
|
|
295
|
+
}
|
|
296
|
+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
|
297
|
+
throw new Error(`Only http(s) inspire URLs allowed: ${u}`);
|
|
298
|
+
}
|
|
299
|
+
out.push(parsed.href);
|
|
300
|
+
}
|
|
301
|
+
if (!out.length) throw new Error('Provide 1–3 inspire URLs');
|
|
302
|
+
if (out.length > 3) throw new Error('At most 3 inspire URLs');
|
|
303
|
+
return out;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function stubSiteIr(siteRoot: string): SiteIR {
|
|
307
|
+
const identity = stubIdentityFromConfig(siteRoot);
|
|
308
|
+
return {
|
|
309
|
+
source: { url: identity.canonicalUrl, generator: null },
|
|
310
|
+
identity,
|
|
311
|
+
posts: [],
|
|
312
|
+
pages: [],
|
|
313
|
+
nav: [],
|
|
314
|
+
topics: [],
|
|
315
|
+
lede: null,
|
|
316
|
+
notes: [],
|
|
317
|
+
assets: []
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/** Live inspire crawl → DesignBrief snapshot (optional Ollama refine). */
|
|
322
|
+
export async function runInspire(
|
|
323
|
+
siteRoot: string,
|
|
324
|
+
opts: {
|
|
325
|
+
urls: string[];
|
|
326
|
+
useLlm?: boolean;
|
|
327
|
+
model?: string;
|
|
328
|
+
host?: string;
|
|
329
|
+
activate?: boolean;
|
|
330
|
+
label?: string;
|
|
331
|
+
}
|
|
332
|
+
) {
|
|
333
|
+
ensureBaselineIfNeeded(siteRoot);
|
|
334
|
+
const urls = assertHttpUrls(opts.urls);
|
|
335
|
+
const signals: InspirationSignals[] = [];
|
|
336
|
+
const summaries: string[] = [];
|
|
337
|
+
const notes: string[] = [];
|
|
338
|
+
|
|
339
|
+
for (const u of urls) {
|
|
340
|
+
try {
|
|
341
|
+
const sig = await extractInspirationSignals(u);
|
|
342
|
+
signals.push(sig);
|
|
343
|
+
const { text } = await fetchText(u);
|
|
344
|
+
summaries.push(`${u}: ${summarizeHtmlForBrief(text)}`);
|
|
345
|
+
notes.push(`${u} → ${sig.paletteMode}`);
|
|
346
|
+
} catch (e) {
|
|
347
|
+
throw new Error(
|
|
348
|
+
`Inspire failed for ${u}: ${e instanceof Error ? e.message : String(e)}`
|
|
349
|
+
);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
let brief = mergeBrief(loadWorkingBrief(siteRoot), briefFromInspiration(signals));
|
|
354
|
+
// Prefer inspire look for chrome; keep any already-chosen image paths unless inspire cleared them
|
|
355
|
+
const host = resolveRequestHost(opts.host);
|
|
356
|
+
const model = (opts.model || defaultOllamaModel()).trim();
|
|
357
|
+
let llm = { used: false, model: null as string | null, host: null as string | null };
|
|
358
|
+
|
|
359
|
+
if (opts.useLlm !== false) {
|
|
360
|
+
const up = await ollamaAvailable(host);
|
|
361
|
+
if (up) {
|
|
362
|
+
brief = await generateDesignBrief({
|
|
363
|
+
host,
|
|
364
|
+
model,
|
|
365
|
+
ir: stubSiteIr(siteRoot),
|
|
366
|
+
inspireSummaries: summaries,
|
|
367
|
+
inspireSignals: signals,
|
|
368
|
+
seed: brief
|
|
369
|
+
});
|
|
370
|
+
llm = { used: true, model, host };
|
|
371
|
+
} else {
|
|
372
|
+
notes.push('Ollama unavailable — used deterministic inspire brief');
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
const themeCss = themeCssFromBrief(brief);
|
|
377
|
+
const meta = writeSnapshot(siteRoot, {
|
|
378
|
+
label: opts.label || `Inspire: ${urls.map((u) => new URL(u).hostname).join(', ')}`.slice(0, 72),
|
|
379
|
+
prompt: urls.join('\n'),
|
|
380
|
+
brief,
|
|
381
|
+
themeCss,
|
|
382
|
+
imageFiles: collectImageFiles(siteRoot, brief),
|
|
383
|
+
inspireUrls: urls,
|
|
384
|
+
llm,
|
|
385
|
+
steers: [{ type: 'inspire', urls, notes }]
|
|
386
|
+
});
|
|
387
|
+
const active =
|
|
388
|
+
opts.activate !== false ? activateVersion(siteRoot, meta.id) : getActive(siteRoot);
|
|
389
|
+
return { meta, active, brief, notes, llm };
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/** Ollama refine of the working brief from a short natural-language prompt. */
|
|
393
|
+
export async function refineWithOllama(
|
|
394
|
+
siteRoot: string,
|
|
395
|
+
opts: { prompt: string; model?: string; host?: string; activate?: boolean }
|
|
396
|
+
) {
|
|
397
|
+
ensureBaselineIfNeeded(siteRoot);
|
|
398
|
+
const prompt = opts.prompt.trim();
|
|
399
|
+
if (!prompt) throw new Error('`prompt` is required');
|
|
400
|
+
|
|
401
|
+
const host = resolveRequestHost(opts.host);
|
|
402
|
+
const model = (opts.model || defaultOllamaModel()).trim();
|
|
403
|
+
if (!(await ollamaAvailable(host))) {
|
|
404
|
+
throw new Error(ollamaSetupHint(host));
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
const seed = loadWorkingBrief(siteRoot);
|
|
408
|
+
const brief = await generateDesignBrief({
|
|
409
|
+
host,
|
|
410
|
+
model,
|
|
411
|
+
ir: stubSiteIr(siteRoot),
|
|
412
|
+
inspireSummaries: [`Author steer: ${prompt}`],
|
|
413
|
+
inspireSignals: [],
|
|
414
|
+
seed
|
|
415
|
+
});
|
|
416
|
+
const themeCss = themeCssFromBrief(brief);
|
|
417
|
+
const meta = writeSnapshot(siteRoot, {
|
|
418
|
+
label: `Refine: ${prompt.slice(0, 40)}`,
|
|
419
|
+
prompt,
|
|
420
|
+
brief,
|
|
421
|
+
themeCss,
|
|
422
|
+
imageFiles: collectImageFiles(siteRoot, brief),
|
|
423
|
+
llm: { used: true, model, host },
|
|
424
|
+
steers: [{ type: 'refine', prompt }]
|
|
425
|
+
});
|
|
426
|
+
const active =
|
|
427
|
+
opts.activate !== false ? activateVersion(siteRoot, meta.id) : getActive(siteRoot);
|
|
428
|
+
return { meta, active, brief, llm: { used: true, model, host } };
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/** Snapshot a config-only chrome patch (lede / tagline / logo). */
|
|
432
|
+
export function applyConfigOnly(
|
|
433
|
+
siteRoot: string,
|
|
434
|
+
opts: { patch: GenieConfigPatch; label?: string; activate?: boolean }
|
|
435
|
+
) {
|
|
436
|
+
ensureBaselineIfNeeded(siteRoot);
|
|
437
|
+
const keys = Object.keys(opts.patch).filter(
|
|
438
|
+
(k) => opts.patch[k as keyof GenieConfigPatch] !== undefined
|
|
439
|
+
);
|
|
440
|
+
if (!keys.length) throw new Error('No config fields to patch');
|
|
441
|
+
|
|
442
|
+
const brief = opts.patch.logo
|
|
443
|
+
? mergeBrief(loadWorkingBrief(siteRoot), { images: { logo: opts.patch.logo } })
|
|
444
|
+
: loadWorkingBrief(siteRoot);
|
|
445
|
+
const themeCss = loadWorkingThemeCss(siteRoot, brief);
|
|
446
|
+
const meta = writeSnapshot(siteRoot, {
|
|
447
|
+
label: opts.label || `Config: ${keys.join(', ')}`,
|
|
448
|
+
prompt: JSON.stringify(opts.patch),
|
|
449
|
+
brief,
|
|
450
|
+
themeCss,
|
|
451
|
+
imageFiles: collectImageFiles(siteRoot, brief),
|
|
452
|
+
configPatch: opts.patch,
|
|
453
|
+
steers: [{ type: 'config', patch: opts.patch }]
|
|
454
|
+
});
|
|
455
|
+
const active =
|
|
456
|
+
opts.activate !== false ? activateVersion(siteRoot, meta.id) : getActive(siteRoot);
|
|
457
|
+
return { meta, active, brief };
|
|
458
|
+
}
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
import { join } from 'node:path';
|
|
11
11
|
import { randomBytes } from 'node:crypto';
|
|
12
12
|
import type { DesignBrief, GenieActive, GenieVersionMeta } from './types.ts';
|
|
13
|
+
import { applyConfigPatch, readConfigPatchFile, type GenieConfigPatch } from './config-patch.ts';
|
|
13
14
|
|
|
14
15
|
const GENIE_DIR = '.filepress-genie';
|
|
15
16
|
|
|
@@ -85,6 +86,9 @@ export type SnapshotInput = {
|
|
|
85
86
|
/** Absolute or site-relative files to copy into version images/ as basename */
|
|
86
87
|
imageFiles?: Array<{ absPath: string; destName: string }>;
|
|
87
88
|
attribution?: string;
|
|
89
|
+
configPatch?: GenieConfigPatch;
|
|
90
|
+
inspireUrls?: string[];
|
|
91
|
+
llm?: { used: boolean; model: string | null; host: string | null };
|
|
88
92
|
};
|
|
89
93
|
|
|
90
94
|
export function writeSnapshot(siteRoot: string, input: SnapshotInput): GenieVersionMeta {
|
|
@@ -101,13 +105,13 @@ export function writeSnapshot(siteRoot: string, input: SnapshotInput): GenieVers
|
|
|
101
105
|
starred: false,
|
|
102
106
|
prompt: input.prompt || '',
|
|
103
107
|
steers: input.steers || [],
|
|
104
|
-
inspireUrls: [],
|
|
105
|
-
llm: { used: false, model: null, host: null }
|
|
108
|
+
inspireUrls: input.inspireUrls || [],
|
|
109
|
+
llm: input.llm || { used: false, model: null, host: null }
|
|
106
110
|
};
|
|
107
111
|
|
|
108
112
|
writeJson(join(dir, 'meta.json'), meta);
|
|
109
113
|
writeJson(join(dir, 'design-brief.json'), input.brief);
|
|
110
|
-
writeJson(join(dir, 'config-patch.json'), {});
|
|
114
|
+
writeJson(join(dir, 'config-patch.json'), input.configPatch || {});
|
|
111
115
|
writeFileSync(join(dir, 'theme.css'), input.themeCss);
|
|
112
116
|
if (input.attribution) {
|
|
113
117
|
writeFileSync(join(dir, 'attribution.md'), input.attribution);
|
|
@@ -144,6 +148,11 @@ export function activateVersion(siteRoot: string, versionId: string): GenieActiv
|
|
|
144
148
|
copyFileSync(attr, join(siteRoot, '.filepress-import', 'IMAGE_ATTRIBUTION.md'));
|
|
145
149
|
}
|
|
146
150
|
|
|
151
|
+
const patch = readConfigPatchFile(join(dir, 'config-patch.json'));
|
|
152
|
+
if (Object.keys(patch).length > 0) {
|
|
153
|
+
applyConfigPatch(siteRoot, patch);
|
|
154
|
+
}
|
|
155
|
+
|
|
147
156
|
const active: GenieActive = {
|
|
148
157
|
versionId,
|
|
149
158
|
activatedAt: new Date().toISOString()
|
|
@@ -1,7 +1,9 @@
|
|
|
1
|
-
import { createPages } from '@filepress/core/server';
|
|
1
|
+
import { createPages, pathMountReservedSlugs } from '@filepress/core/server';
|
|
2
2
|
import { getPagesDir } from './site.server';
|
|
3
|
+
import config from '$site-config';
|
|
3
4
|
|
|
4
5
|
/** Bound to the active site's `pages/` (missing dir → empty). */
|
|
5
6
|
export const pages = createPages({
|
|
6
|
-
pagesDir: getPagesDir()
|
|
7
|
+
pagesDir: getPagesDir(),
|
|
8
|
+
extraReservedSlugs: pathMountReservedSlugs(config.paths ?? [])
|
|
7
9
|
});
|
|
@@ -1,17 +1,20 @@
|
|
|
1
1
|
import type { RequestHandler } from './$types';
|
|
2
|
-
import { buildSitemapXml } from '@filepress/core/server';
|
|
2
|
+
import { buildSitemapXml, listPathMountHtmlUrls } from '@filepress/core/server';
|
|
3
3
|
import { content } from '$lib/content.server';
|
|
4
4
|
import { pages } from '$lib/pages.server';
|
|
5
|
+
import { getSiteRoot } from '$lib/site.server';
|
|
5
6
|
import config from '$site-config';
|
|
6
7
|
|
|
7
8
|
export const prerender = true;
|
|
8
9
|
|
|
9
10
|
export const GET: RequestHandler = () => {
|
|
11
|
+
const mountUrls = listPathMountHtmlUrls(getSiteRoot(), config.paths ?? []);
|
|
10
12
|
const xml = buildSitemapXml(config, {
|
|
11
13
|
posts: content.getPublishedPosts(),
|
|
12
14
|
tags: content.getAllTags(),
|
|
13
15
|
pageCount: content.getIndexPageCount(config.postsPerPage),
|
|
14
|
-
pages: pages.getPublishedPages()
|
|
16
|
+
pages: pages.getPublishedPages(),
|
|
17
|
+
mountUrls
|
|
15
18
|
});
|
|
16
19
|
return new Response(xml, {
|
|
17
20
|
headers: { 'Content-Type': 'application/xml; charset=utf-8' }
|
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
2
2
|
import type { Plugin } from 'vite';
|
|
3
3
|
import {
|
|
4
|
+
applyConfigOnly,
|
|
4
5
|
applySteer,
|
|
5
6
|
doActivate,
|
|
6
7
|
fetchStockCover,
|
|
7
8
|
health,
|
|
8
|
-
receiveUpload
|
|
9
|
+
receiveUpload,
|
|
10
|
+
refineWithOllama,
|
|
11
|
+
runInspire,
|
|
12
|
+
scanOllamaHosts
|
|
9
13
|
} from './src/lib/genie/ops.ts';
|
|
10
14
|
import { deleteVersion, listVersions } from './src/lib/genie/store.ts';
|
|
11
15
|
|
|
@@ -97,6 +101,65 @@ export function geniePlugin(siteRoot: string): Plugin {
|
|
|
97
101
|
});
|
|
98
102
|
}
|
|
99
103
|
|
|
104
|
+
if (method === 'POST' && path === '/__filepress/genie/scan') {
|
|
105
|
+
const body = JSON.parse((await readBody(req)) || '{}');
|
|
106
|
+
return sendJson(res, 200, await scanOllamaHosts({ lan: Boolean(body.lan) }));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (method === 'POST' && path === '/__filepress/genie/inspire') {
|
|
110
|
+
const body = JSON.parse(await readBody(req));
|
|
111
|
+
const urls = Array.isArray(body.urls)
|
|
112
|
+
? body.urls
|
|
113
|
+
: typeof body.urls === 'string'
|
|
114
|
+
? body.urls.split(/\n+/).map((s: string) => s.trim())
|
|
115
|
+
: [];
|
|
116
|
+
return sendJson(
|
|
117
|
+
res,
|
|
118
|
+
200,
|
|
119
|
+
await runInspire(siteRoot, {
|
|
120
|
+
urls,
|
|
121
|
+
useLlm: body.useLlm,
|
|
122
|
+
model: body.model,
|
|
123
|
+
host: body.host,
|
|
124
|
+
activate: body.activate,
|
|
125
|
+
label: body.label
|
|
126
|
+
})
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (method === 'POST' && path === '/__filepress/genie/refine') {
|
|
131
|
+
const body = JSON.parse(await readBody(req));
|
|
132
|
+
if (!body.prompt || typeof body.prompt !== 'string') {
|
|
133
|
+
return sendJson(res, 400, { error: '`prompt` string required' });
|
|
134
|
+
}
|
|
135
|
+
return sendJson(
|
|
136
|
+
res,
|
|
137
|
+
200,
|
|
138
|
+
await refineWithOllama(siteRoot, {
|
|
139
|
+
prompt: body.prompt,
|
|
140
|
+
model: body.model,
|
|
141
|
+
host: body.host,
|
|
142
|
+
activate: body.activate
|
|
143
|
+
})
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (method === 'POST' && path === '/__filepress/genie/config') {
|
|
148
|
+
const body = JSON.parse(await readBody(req));
|
|
149
|
+
if (!body.patch || typeof body.patch !== 'object') {
|
|
150
|
+
return sendJson(res, 400, { error: '`patch` object required' });
|
|
151
|
+
}
|
|
152
|
+
return sendJson(
|
|
153
|
+
res,
|
|
154
|
+
200,
|
|
155
|
+
applyConfigOnly(siteRoot, {
|
|
156
|
+
patch: body.patch,
|
|
157
|
+
label: body.label,
|
|
158
|
+
activate: body.activate
|
|
159
|
+
})
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
|
|
100
163
|
if (method === 'POST' && path === '/__filepress/genie/delete') {
|
|
101
164
|
const body = JSON.parse(await readBody(req));
|
|
102
165
|
deleteVersion(siteRoot, body.versionId);
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
2
|
+
import {
|
|
3
|
+
createReadStream,
|
|
4
|
+
existsSync,
|
|
5
|
+
statSync,
|
|
6
|
+
} from 'node:fs';
|
|
7
|
+
import { extname, join, normalize, relative, resolve, sep } from 'node:path';
|
|
8
|
+
import type { Plugin } from 'vite';
|
|
9
|
+
import type { PathMount } from '../core/src/lib/paths-shared.ts';
|
|
10
|
+
|
|
11
|
+
const MIME: Record<string, string> = {
|
|
12
|
+
'.html': 'text/html; charset=utf-8',
|
|
13
|
+
'.css': 'text/css; charset=utf-8',
|
|
14
|
+
'.js': 'text/javascript; charset=utf-8',
|
|
15
|
+
'.mjs': 'text/javascript; charset=utf-8',
|
|
16
|
+
'.json': 'application/json; charset=utf-8',
|
|
17
|
+
'.svg': 'image/svg+xml',
|
|
18
|
+
'.png': 'image/png',
|
|
19
|
+
'.jpg': 'image/jpeg',
|
|
20
|
+
'.jpeg': 'image/jpeg',
|
|
21
|
+
'.gif': 'image/gif',
|
|
22
|
+
'.webp': 'image/webp',
|
|
23
|
+
'.ico': 'image/x-icon',
|
|
24
|
+
'.woff': 'font/woff',
|
|
25
|
+
'.woff2': 'font/woff2',
|
|
26
|
+
'.txt': 'text/plain; charset=utf-8',
|
|
27
|
+
'.xml': 'application/xml; charset=utf-8',
|
|
28
|
+
'.map': 'application/json; charset=utf-8',
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
function contentType(filePath: string): string {
|
|
32
|
+
return MIME[extname(filePath).toLowerCase()] ?? 'application/octet-stream';
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Resolve a request path under a mount to a file on disk.
|
|
37
|
+
* Returns null when the path escapes the mount root or does not exist.
|
|
38
|
+
*/
|
|
39
|
+
function resolveMountFile(mountRoot: string, urlPath: string, mountUrl: string): string | null {
|
|
40
|
+
const prefix = mountUrl.endsWith('/') ? mountUrl.slice(0, -1) : mountUrl;
|
|
41
|
+
let rest = urlPath === prefix ? '' : urlPath.slice(prefix.length);
|
|
42
|
+
if (rest.startsWith('/')) rest = rest.slice(1);
|
|
43
|
+
rest = decodeURIComponent(rest.split('?')[0] ?? '');
|
|
44
|
+
|
|
45
|
+
const candidates = rest
|
|
46
|
+
? [rest, join(rest, 'index.html'), `${rest}.html`]
|
|
47
|
+
: ['index.html'];
|
|
48
|
+
|
|
49
|
+
for (const rel of candidates) {
|
|
50
|
+
const abs = normalize(resolve(mountRoot, rel));
|
|
51
|
+
const rootNorm = normalize(mountRoot) + sep;
|
|
52
|
+
if (abs !== normalize(mountRoot) && !abs.startsWith(rootNorm)) continue;
|
|
53
|
+
if (existsSync(abs) && statSync(abs).isFile()) return abs;
|
|
54
|
+
}
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function sendFile(res: ServerResponse, filePath: string): void {
|
|
59
|
+
res.statusCode = 200;
|
|
60
|
+
res.setHeader('Content-Type', contentType(filePath));
|
|
61
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
62
|
+
createReadStream(filePath).pipe(res);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface PathMountsPluginOptions {
|
|
66
|
+
siteRoot: string;
|
|
67
|
+
/** Resolved mounts from site config (may be empty). */
|
|
68
|
+
mounts: PathMount[];
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Serve `paths` mounts in `filepress dev` and copy them into `build/` after
|
|
73
|
+
* `filepress build`. Mount contents are opaque to FilePress (site-owned HTML/CSS/JS).
|
|
74
|
+
*/
|
|
75
|
+
export function pathMountsPlugin(opts: PathMountsPluginOptions): Plugin {
|
|
76
|
+
const { siteRoot, mounts } = opts;
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
name: 'filepress-path-mounts',
|
|
80
|
+
configureServer(server) {
|
|
81
|
+
if (mounts.length === 0) return;
|
|
82
|
+
|
|
83
|
+
server.middlewares.use((req: IncomingMessage, res: ServerResponse, next) => {
|
|
84
|
+
const raw = req.url || '';
|
|
85
|
+
const pathname = raw.split('?')[0] ?? '';
|
|
86
|
+
const mount = mounts.find(
|
|
87
|
+
(m) => pathname === m.url || pathname.startsWith(`${m.url}/`),
|
|
88
|
+
);
|
|
89
|
+
if (!mount) return next();
|
|
90
|
+
|
|
91
|
+
const root = resolve(siteRoot, mount.dir);
|
|
92
|
+
if (!existsSync(root)) return next();
|
|
93
|
+
|
|
94
|
+
const file = resolveMountFile(root, pathname, mount.url);
|
|
95
|
+
if (!file) return next();
|
|
96
|
+
sendFile(res, file);
|
|
97
|
+
});
|
|
98
|
+
},
|
|
99
|
+
// Copy happens in scripts/filepress.mjs after `vite build` so adapter-static
|
|
100
|
+
// has already written `build/` (closeBundle runs too early / wrong tree).
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Dev helper: relative path for logging. */
|
|
105
|
+
export function describeMount(siteRoot: string, mount: PathMount): string {
|
|
106
|
+
return `${mount.url} ← ${relative(siteRoot, resolve(siteRoot, mount.dir)) || mount.dir}`;
|
|
107
|
+
}
|