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.
Files changed (32) hide show
  1. package/README.md +36 -10
  2. package/package.json +99 -99
  3. package/packages/app/README.md +19 -0
  4. package/packages/app/package.json +1 -1
  5. package/packages/app/src/lib/genie/GeniePanel.svelte +672 -144
  6. package/packages/app/src/lib/genie/config-patch.ts +72 -0
  7. package/packages/app/src/lib/genie/ops.ts +231 -10
  8. package/packages/app/src/lib/genie/store.ts +12 -3
  9. package/packages/app/src/lib/pages.server.ts +4 -2
  10. package/packages/app/src/routes/sitemap.xml/+server.ts +5 -2
  11. package/packages/app/vite-plugin-genie.ts +64 -1
  12. package/packages/app/vite-plugin-path-mounts.ts +107 -0
  13. package/packages/app/vite.config.ts +112 -81
  14. package/packages/core/README.md +29 -0
  15. package/packages/core/package.json +1 -1
  16. package/packages/core/src/lib/config.ts +12 -1
  17. package/packages/core/src/lib/content/feeds.ts +5 -1
  18. package/packages/core/src/lib/content/pages.ts +11 -1
  19. package/packages/core/src/lib/content/parse.ts +12 -3
  20. package/packages/core/src/lib/index.ts +2 -1
  21. package/packages/core/src/lib/paths-shared.ts +89 -0
  22. package/packages/core/src/lib/paths.ts +84 -0
  23. package/packages/core/src/lib/server.ts +8 -1
  24. package/packages/core/src/lib/styles/theme.css +3 -2
  25. package/packages/import/package.json +2 -1
  26. package/packages/import/src/cli.ts +77 -3
  27. package/packages/import/src/ollama.ts +43 -1
  28. package/packages/import/src/ollanet-scan.ts +132 -0
  29. package/packages/import/tsconfig.json +1 -1
  30. package/scripts/copy-path-mounts.mjs +40 -0
  31. package/scripts/create-site.mjs +4 -11
  32. 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 { ollamaAvailable, ollamaSetupHint } from '../../../../import/src/ollama.ts';
11
- import type { DesignBrief } from '../../../../import/src/ir.ts';
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 = process.env.OLLAMA_HOST?.trim() || 'http://127.0.0.1:11434';
93
- const model = process.env.FILEPRESS_OLLAMA_MODEL?.trim() || 'gemma4:12b';
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
- ? `Ollama is reachable. For a GPU-tuned named variant, try Finetuna: https://github.com/Catalyst-Forge-LLC/finetuna — then set FILEPRESS_OLLAMA_MODEL.`
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(siteRoot: string, patch: GenieSteerPatch) {
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]: `/images/${destName}` } },
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
+ }