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
@@ -24,6 +24,7 @@ import {
24
24
  ollamaSetupHint,
25
25
  summarizeHtmlForBrief
26
26
  } from './ollama.ts';
27
+ import { pickDiscoveredServer, scanOllamaNetwork } from './ollanet-scan.ts';
27
28
  import { harvestImagesFromPage, planImages, type ImagePlan } from './images.ts';
28
29
  import { formatAttributionMarkdown, planStockCovers } from './stock.ts';
29
30
  import { DEFAULT_BRIEF, themeCssFromBrief, tokensFromSourceCss } from './theme.ts';
@@ -57,22 +58,30 @@ function parseArgs(argv: string[]) {
57
58
  author?: string;
58
59
  url?: string;
59
60
  ollama: string;
61
+ ollamaExplicit: boolean;
60
62
  model: string;
63
+ modelExplicit: boolean;
61
64
  noLlm: boolean;
62
65
  dryRun: boolean;
63
66
  force: boolean;
64
67
  yes: boolean;
65
68
  fetchImages: boolean;
69
+ scan: boolean;
70
+ lan: boolean;
66
71
  } = {
67
72
  _: [],
68
73
  inspire: [],
69
74
  ollama: process.env.OLLAMA_HOST?.trim() || 'http://127.0.0.1:11434',
75
+ ollamaExplicit: Boolean(process.env.OLLAMA_HOST?.trim()),
70
76
  model: process.env.FILEPRESS_OLLAMA_MODEL?.trim() || 'gemma4:12b',
77
+ modelExplicit: Boolean(process.env.FILEPRESS_OLLAMA_MODEL?.trim()),
71
78
  noLlm: false,
72
79
  dryRun: false,
73
80
  force: false,
74
81
  yes: false,
75
- fetchImages: false
82
+ fetchImages: false,
83
+ scan: false,
84
+ lan: false
76
85
  };
77
86
 
78
87
  for (let i = 0; i < argv.length; i++) {
@@ -89,13 +98,24 @@ function parseArgs(argv: string[]) {
89
98
  else if (a === '--title') out.title = next();
90
99
  else if (a === '--author') out.author = next();
91
100
  else if (a === '--url') out.url = next();
92
- else if (a === '--ollama') out.ollama = next();
93
- else if (a === '--model') out.model = next();
101
+ else if (a === '--ollama') {
102
+ out.ollama = next();
103
+ out.ollamaExplicit = true;
104
+ }
105
+ else if (a === '--model') {
106
+ out.model = next();
107
+ out.modelExplicit = true;
108
+ }
94
109
  else if (a === '--no-llm') out.noLlm = true;
95
110
  else if (a === '--dry-run') out.dryRun = true;
96
111
  else if (a === '--force') out.force = true;
97
112
  else if (a === '--yes' || a === '-y') out.yes = true;
98
113
  else if (a === '--fetch-images') out.fetchImages = true;
114
+ else if (a === '--scan') out.scan = true;
115
+ else if (a === '--lan') {
116
+ out.lan = true;
117
+ out.scan = true;
118
+ }
99
119
  else if (a === '--help' || a === '-h') out._.push('help');
100
120
  else out._.push(a);
101
121
  }
@@ -108,6 +128,55 @@ async function prompt(rl: ReturnType<typeof createInterface>, q: string, def?: s
108
128
  return ans || def || '';
109
129
  }
110
130
 
131
+ async function maybeScanOllama(
132
+ args: ReturnType<typeof parseArgs>,
133
+ rl: ReturnType<typeof createInterface> | null
134
+ ): Promise<{ host: string; model: string }> {
135
+ let host = args.ollama;
136
+ let model = args.model;
137
+ if (args.noLlm || !args.scan) return { host, model };
138
+
139
+ console.log(
140
+ args.lan
141
+ ? 'import: scanning for Ollama (localhost, config, Tailscale, LAN) …'
142
+ : 'import: scanning for Ollama (localhost, config, Tailscale) …'
143
+ );
144
+ const result = await scanOllamaNetwork({ lan: args.lan });
145
+ if (result.error) {
146
+ console.warn(`import: ollanet scan failed: ${result.error}`);
147
+ return { host, model };
148
+ }
149
+ if (!result.servers.length) {
150
+ console.warn(
151
+ 'import: no Ollama servers found. Add ~/.ollanet/config.json hosts, set OLLANET_HOSTS, or pass --lan.'
152
+ );
153
+ return { host, model };
154
+ }
155
+
156
+ for (const [i, s] of result.servers.entries()) {
157
+ const models = s.models.length ? s.models.join(', ') : '(no models)';
158
+ console.log(` ${i + 1}. ${s.label} ${s.endpoint} ${models}`);
159
+ }
160
+
161
+ let picked = pickDiscoveredServer(result.servers, args.ollamaExplicit ? host : undefined);
162
+ if (rl && result.servers.length > 1) {
163
+ const def = String((picked ? result.servers.indexOf(picked) : 0) + 1);
164
+ const ans = await prompt(rl, 'Ollama server number', def);
165
+ const idx = Number(ans) - 1;
166
+ if (Number.isInteger(idx) && result.servers[idx]) picked = result.servers[idx];
167
+ }
168
+ if (picked) {
169
+ host = picked.endpoint;
170
+ if (!args.modelExplicit) {
171
+ const preferred = process.env.FILEPRESS_OLLAMA_MODEL?.trim();
172
+ if (preferred && picked.models.includes(preferred)) model = preferred;
173
+ else if (picked.models[0]) model = picked.models[0];
174
+ }
175
+ console.log(`import: using ${host} · ${model}`);
176
+ }
177
+ return { host, model };
178
+ }
179
+
111
180
  async function main() {
112
181
  const args = parseArgs(process.argv.slice(2));
113
182
  if (args._.includes('help')) {
@@ -121,6 +190,8 @@ Options:
121
190
  --title / --author / --url
122
191
  --ollama <host> Default http://127.0.0.1:11434
123
192
  --model <name> Default gemma4:12b
193
+ --scan Discover Ollama hosts via ollanet (localhost, config, Tailscale)
194
+ --lan Also TCP-scan local LAN (implies --scan)
124
195
  --no-llm Skip Ollama; token theme from source CSS / defaults
125
196
  --dry-run Crawl + report only (no write)
126
197
  --force Overwrite generated content in --out
@@ -232,6 +303,9 @@ Options:
232
303
  };
233
304
 
234
305
  if (!opts.noLlm) {
306
+ const picked = await maybeScanOllama(args, rl);
307
+ opts.ollamaHost = picked.host;
308
+ opts.ollamaModel = picked.model;
235
309
  const up = await ollamaAvailable(opts.ollamaHost);
236
310
  if (!up) {
237
311
  console.warn(`import: ${ollamaSetupHint(opts.ollamaHost)}`);
@@ -2,6 +2,30 @@ import type { DesignBrief, SiteIR } from './ir.ts';
2
2
  import type { InspirationSignals } from './inspire.ts';
3
3
  import { DEFAULT_BRIEF, parseBriefJson } from './theme.ts';
4
4
 
5
+ /** Strip trailing slashes; used to compare Genie picker values with env. */
6
+ export function normalizeOllamaHost(host: string): string {
7
+ return host.trim().replace(/\/+$/, '');
8
+ }
9
+
10
+ /** Allow only http(s) Ollama URLs (no credentials). */
11
+ export function assertOllamaEndpoint(raw: string): string {
12
+ const trimmed = raw.trim();
13
+ if (!trimmed) throw new Error('Ollama host is empty');
14
+ let url: URL;
15
+ try {
16
+ url = new URL(trimmed);
17
+ } catch {
18
+ throw new Error(`Invalid Ollama host "${trimmed}"`);
19
+ }
20
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') {
21
+ throw new Error(`Ollama host must be http(s), got ${url.protocol}`);
22
+ }
23
+ if (url.username || url.password) {
24
+ throw new Error('Ollama host must not include credentials');
25
+ }
26
+ return `${url.protocol}//${url.host}`;
27
+ }
28
+
5
29
  export async function ollamaAvailable(host: string): Promise<boolean> {
6
30
  try {
7
31
  const res = await fetch(`${host.replace(/\/+$/, '')}/api/tags`, {
@@ -13,6 +37,23 @@ export async function ollamaAvailable(host: string): Promise<boolean> {
13
37
  }
14
38
  }
15
39
 
40
+ /** Model names from `GET /api/tags` (empty if unreachable). */
41
+ export async function listOllamaModels(host: string): Promise<string[]> {
42
+ try {
43
+ const res = await fetch(`${host.replace(/\/+$/, '')}/api/tags`, {
44
+ signal: AbortSignal.timeout(4000)
45
+ });
46
+ if (!res.ok) return [];
47
+ const data = (await res.json()) as { models?: Array<{ name?: string }> };
48
+ return (data.models || [])
49
+ .map((m) => (m.name || '').trim())
50
+ .filter(Boolean)
51
+ .sort((a, b) => a.localeCompare(b));
52
+ } catch {
53
+ return [];
54
+ }
55
+ }
56
+
16
57
  /** Shared copy for import + Genie Mode when Ollama is missing or unused. */
17
58
  export function ollamaSetupHint(host: string): string {
18
59
  const h = host.replace(/\/+$/, '') || 'http://127.0.0.1:11434';
@@ -20,7 +61,8 @@ export function ollamaSetupHint(host: string): string {
20
61
  `Ollama not reachable at ${h}.`,
21
62
  `Install from https://ollama.com then pull a model (e.g. ollama pull gemma4:12b).`,
22
63
  `For a GPU-tuned named variant, use Finetuna: https://github.com/Catalyst-Forge-LLC/finetuna`,
23
- `Then set FILEPRESS_OLLAMA_MODEL to that name (and OLLAMA_HOST if remote).`
64
+ `Then set FILEPRESS_OLLAMA_MODEL to that name (and OLLAMA_HOST if remote).`,
65
+ `To find other Ollama boxes (Tailscale, LAN, OLLANET_HOSTS), use Genie “Scan network” or filepress import --scan (ollanet).`
24
66
  ].join(' ');
25
67
  }
26
68
 
@@ -0,0 +1,132 @@
1
+ /**
2
+ * Optional network discovery for Ollama via ollanet (≥ 0.4.0).
3
+ * LAN TCP scan is opt-in (slow); default scan is localhost + config + Tailscale.
4
+ * Node-only — Genie middleware and the import CLI, never the Svelte client.
5
+ */
6
+ import { scanNetwork } from 'ollanet';
7
+
8
+ export type DiscoveredOllamaServer = {
9
+ label: string;
10
+ endpoint: string;
11
+ source: string;
12
+ self: boolean;
13
+ models: string[];
14
+ };
15
+
16
+ export type OllamaScanResult = {
17
+ ok: boolean;
18
+ network: string;
19
+ sources: string[];
20
+ scanned: number;
21
+ servers: DiscoveredOllamaServer[];
22
+ error?: string;
23
+ };
24
+
25
+ /** Loose scan JSON (ollanet ScanPayload plus incomplete fixtures). */
26
+ type ScanPayloadLike = {
27
+ network?: string;
28
+ sources?: string[];
29
+ scanned?: number;
30
+ servers?: Array<{
31
+ hostname?: string;
32
+ dnsName?: string;
33
+ ip?: string;
34
+ source?: string;
35
+ self?: boolean;
36
+ endpoint?: string;
37
+ models?: Array<{ name?: string }>;
38
+ }>;
39
+ };
40
+
41
+ export function normalizeEndpoint(host: string): string {
42
+ return host.trim().replace(/\/+$/, '').toLowerCase();
43
+ }
44
+
45
+ export function serverLabel(server: {
46
+ dnsName?: string;
47
+ hostname?: string;
48
+ ip?: string;
49
+ self?: boolean;
50
+ source?: string;
51
+ }): string {
52
+ const name = (server.dnsName || server.hostname || server.ip || 'ollama').trim();
53
+ const bits = [name];
54
+ if (server.self) bits.push('this device');
55
+ if (server.source && server.source !== 'localhost') bits.push(server.source);
56
+ return bits.join(' · ');
57
+ }
58
+
59
+ export function mapScanPayload(payload: ScanPayloadLike): OllamaScanResult {
60
+ const servers = (payload.servers ?? [])
61
+ .map((s) => {
62
+ const endpoint = (s.endpoint || '').trim().replace(/\/+$/, '');
63
+ if (!endpoint) return null;
64
+ const models = (s.models ?? [])
65
+ .map((m) => (m.name || '').trim())
66
+ .filter(Boolean)
67
+ .sort((a, b) => a.localeCompare(b));
68
+ return {
69
+ label: serverLabel(s),
70
+ endpoint,
71
+ source: s.source || 'unknown',
72
+ self: Boolean(s.self),
73
+ models
74
+ } satisfies DiscoveredOllamaServer;
75
+ })
76
+ .filter((s): s is DiscoveredOllamaServer => s != null);
77
+
78
+ const seen = new Set<string>();
79
+ const deduped: DiscoveredOllamaServer[] = [];
80
+ for (const s of servers) {
81
+ const key = normalizeEndpoint(s.endpoint);
82
+ if (seen.has(key)) continue;
83
+ seen.add(key);
84
+ deduped.push(s);
85
+ }
86
+
87
+ return {
88
+ ok: true,
89
+ network: payload.network || 'local',
90
+ sources: payload.sources ?? [],
91
+ scanned: payload.scanned ?? deduped.length,
92
+ servers: deduped
93
+ };
94
+ }
95
+
96
+ /** Prefer an explicit endpoint, then this-device with models, then any host with models. */
97
+ export function pickDiscoveredServer(
98
+ servers: DiscoveredOllamaServer[],
99
+ preferredEndpoint?: string
100
+ ): DiscoveredOllamaServer | undefined {
101
+ if (preferredEndpoint) {
102
+ const want = normalizeEndpoint(preferredEndpoint);
103
+ const hit = servers.find((s) => normalizeEndpoint(s.endpoint) === want);
104
+ if (hit) return hit;
105
+ }
106
+ const withModels = servers.filter((s) => s.models.length > 0);
107
+ const pool = withModels.length ? withModels : servers;
108
+ return pool.find((s) => s.self) ?? pool[0];
109
+ }
110
+
111
+ /**
112
+ * Discover reachable Ollama servers. Never throws — callers get `error` text.
113
+ * Pass `lan: true` to TCP-scan local /24s (can take several seconds).
114
+ */
115
+ export async function scanOllamaNetwork(
116
+ opts: { lan?: boolean } = {}
117
+ ): Promise<OllamaScanResult> {
118
+ try {
119
+ const payload = await scanNetwork({ lanScan: Boolean(opts.lan) });
120
+ return mapScanPayload(payload);
121
+ } catch (e) {
122
+ const message = e instanceof Error ? e.message : String(e);
123
+ return {
124
+ ok: false,
125
+ network: '',
126
+ sources: [],
127
+ scanned: 0,
128
+ servers: [],
129
+ error: message
130
+ };
131
+ }
132
+ }
@@ -9,5 +9,5 @@
9
9
  "lib": ["ESNext", "DOM"],
10
10
  "types": ["node"]
11
11
  },
12
- "include": ["src/**/*.ts"]
12
+ "include": ["src/**/*.ts", "src/**/*.d.ts"]
13
13
  }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Copy FilePress `paths` mounts into <site>/build after `vite build`.
3
+ * Reads `.filepress/path-mounts.json` written when vite.config loads the site config.
4
+ *
5
+ * Usage: node scripts/copy-path-mounts.mjs <siteRoot>
6
+ */
7
+ import { cpSync, existsSync, mkdirSync, readFileSync, statSync } from 'node:fs';
8
+ import { join, resolve } from 'node:path';
9
+
10
+ const siteRoot = resolve(process.argv[2] ?? '');
11
+ const buildDir = join(siteRoot, 'build');
12
+ const cachePath = join(siteRoot, '.filepress', 'path-mounts.json');
13
+
14
+ if (!existsSync(cachePath)) {
15
+ process.exit(0);
16
+ }
17
+ if (!existsSync(buildDir)) {
18
+ console.warn(`filepress: path mounts skipped — build dir missing (${buildDir})`);
19
+ process.exit(0);
20
+ }
21
+
22
+ /** @type {{ url: string; dir: string }[]} */
23
+ const mounts = JSON.parse(readFileSync(cachePath, 'utf8'));
24
+ if (!Array.isArray(mounts) || mounts.length === 0) process.exit(0);
25
+
26
+ for (const mount of mounts) {
27
+ const src = resolve(siteRoot, mount.dir);
28
+ const dest = join(buildDir, ...mount.url.replace(/^\//, '').split('/'));
29
+ if (!existsSync(src)) {
30
+ console.warn(`filepress: path mount ${mount.url} ← ${mount.dir} (missing; skipped)`);
31
+ continue;
32
+ }
33
+ if (!statSync(src).isDirectory()) {
34
+ console.error(`filepress: path mount dir "${mount.dir}" is not a directory`);
35
+ process.exit(1);
36
+ }
37
+ mkdirSync(dest, { recursive: true });
38
+ cpSync(src, dest, { recursive: true });
39
+ console.log(`filepress: mounted ${mount.url} ← ${mount.dir}`);
40
+ }
@@ -198,24 +198,17 @@ pnpm build # → build/
198
198
  Optional: add \`theme.css\` next to \`filepress.config.ts\` to
199
199
  override the default Essay theme.
200
200
 
201
- ## Deploy (Cloudflare Pages)
201
+ ## Deploy
202
202
 
203
- \`link:\` only works on your machine. For CI/hosting, switch to npm or a git pin:
203
+ \`link:\` only works on your machine. For CI/hosting, pin npm or a git tag:
204
204
 
205
205
  \`\`\`json
206
206
  "getfilepress": "^0.1.1"
207
207
  \`\`\`
208
208
 
209
- or:
209
+ **Cloudflare Pages (recommended):** build \`pnpm install && pnpm build\`, output \`build\`, Node 20+.
210
210
 
211
- \`\`\`json
212
- "getfilepress": "github:Catalyst-Forge-LLC/filepress#v0.1.1"
213
- \`\`\`
214
-
215
- | Setting | Value |
216
- | --- | --- |
217
- | Build command | \`pnpm install && pnpm build\` |
218
- | Output directory | \`build\` |
211
+ Any static host: publish the \`build/\` folder. Details: https://getfilepress.com/deploy
219
212
  `
220
213
  );
221
214
 
@@ -100,7 +100,7 @@ function findPackageBin(pkgName, binName, starts = [appDir, importDir, packageRo
100
100
  return null;
101
101
  }
102
102
 
103
- function runNodeBin(pkgName, binName, args, { cwd, env } = {}) {
103
+ function runNodeBin(pkgName, binName, args, { cwd, env, onSuccess } = {}) {
104
104
  const bin = findPackageBin(pkgName, binName);
105
105
  if (!bin) {
106
106
  fail(
@@ -116,7 +116,19 @@ function runNodeBin(pkgName, binName, args, { cwd, env } = {}) {
116
116
  });
117
117
  child.on('exit', (code, signal) => {
118
118
  if (signal) process.kill(process.pid, signal);
119
- process.exit(code ?? 1);
119
+ if (code) process.exit(code ?? 1);
120
+ if (typeof onSuccess === 'function') {
121
+ const result = onSuccess();
122
+ // If onSuccess returns a ChildProcess, wait for it instead of exiting now.
123
+ if (result && typeof result.on === 'function') {
124
+ result.on('exit', (c, s) => {
125
+ if (s) process.kill(process.pid, s);
126
+ process.exit(c ?? 1);
127
+ });
128
+ return;
129
+ }
130
+ }
131
+ process.exit(0);
120
132
  });
121
133
  return child;
122
134
  }
@@ -269,5 +281,21 @@ function runSiteCommand(argv) {
269
281
  const viteArgs = [command];
270
282
  if (args.host !== null) viteArgs.push('--host', args.host === 'true' ? 'true' : args.host);
271
283
  viteArgs.push(...args.extra);
272
- runNodeBin('vite', 'vite', viteArgs, { cwd: appDir, env });
284
+ runNodeBin('vite', 'vite', viteArgs, {
285
+ cwd: appDir,
286
+ env,
287
+ onSuccess:
288
+ command === 'build'
289
+ ? () => {
290
+ const copyScript = join(scriptDir, 'copy-path-mounts.mjs');
291
+ if (!existsSync(copyScript)) return;
292
+ return spawn(process.execPath, [copyScript, siteRoot], {
293
+ cwd: packageRoot,
294
+ env,
295
+ stdio: 'inherit',
296
+ shell: false
297
+ });
298
+ }
299
+ : undefined
300
+ });
273
301
  }