getfilepress 0.1.9 → 0.1.11
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 +13 -4
- package/package.json +6 -3
- package/packages/app/src/lib/genie/GeniePanel.svelte +290 -21
- package/packages/app/src/lib/genie/ops.ts +11 -1
- package/packages/app/src/lib/genie/store.ts +54 -1
- package/packages/app/src/lib/theme-entry.ts +2 -1
- package/packages/app/src/routes/+error.svelte +38 -0
- package/packages/app/src/routes/posts/[slug]/+page.svelte +2 -1
- package/packages/app/src/site-theme.d.ts +3 -0
- package/packages/app/vite-plugin-genie.ts +45 -1
- package/packages/app/vite.config.ts +38 -7
- package/packages/core/src/lib/components/PostCard.svelte +2 -1
- package/packages/core/src/lib/config.ts +47 -1
- package/packages/core/src/lib/content/parse.ts +2 -0
- package/packages/core/src/lib/content/types.ts +2 -0
- package/packages/core/src/lib/format.ts +11 -0
- package/packages/core/src/lib/index.ts +7 -3
- package/packages/core/src/lib/redirects.ts +88 -0
- package/packages/core/src/lib/server.ts +9 -2
- package/packages/core/src/lib/styles/presets/essay.css +2 -0
- package/packages/core/src/lib/styles/presets/folio.css +27 -0
- package/packages/core/src/lib/styles/presets/ink.css +28 -0
- package/packages/core/src/lib/styles/theme.css +37 -0
- package/packages/import/src/cli.ts +1 -0
- package/packages/import/src/extract.ts +15 -2
- package/packages/import/src/ir.ts +2 -0
- package/packages/import/src/ollama.ts +332 -77
- package/packages/import/src/redirects.ts +17 -0
- package/packages/import/src/write-site.ts +25 -3
- package/scripts/copy-path-mounts.mjs +30 -1
- package/scripts/create-site.mjs +1 -0
- package/scripts/filepress.mjs +24 -14
- package/scripts/new-post.ts +125 -0
- package/scripts/preview.mjs +95 -0
|
@@ -1,7 +1,170 @@
|
|
|
1
|
+
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { dirname } from 'node:path';
|
|
1
3
|
import type { DesignBrief, SiteIR } from './ir.ts';
|
|
2
4
|
import type { InspirationSignals } from './inspire.ts';
|
|
3
5
|
import { DEFAULT_BRIEF, parseBriefJson } from './theme.ts';
|
|
4
6
|
|
|
7
|
+
export type BriefIntent = 'import' | 'steer';
|
|
8
|
+
|
|
9
|
+
const BRIEF_SHAPE = `{
|
|
10
|
+
"mood": "short phrase",
|
|
11
|
+
"do": ["..."],
|
|
12
|
+
"dont": ["..."],
|
|
13
|
+
"tokens": {
|
|
14
|
+
"accent": "#rrggbb",
|
|
15
|
+
"accentStrong": "#rrggbb",
|
|
16
|
+
"bg": "#rrggbb",
|
|
17
|
+
"ink": "#rrggbb",
|
|
18
|
+
"inkSoft": "#rrggbb",
|
|
19
|
+
"surface": "#rrggbb",
|
|
20
|
+
"rule": "#rrggbb",
|
|
21
|
+
"ruleStrong": "#rrggbb"
|
|
22
|
+
},
|
|
23
|
+
"density": "sparse" | "balanced" | "dense",
|
|
24
|
+
"paletteMode": "dark" | "light",
|
|
25
|
+
"fonts": {
|
|
26
|
+
"serif": "Font Name",
|
|
27
|
+
"sans": "Font Name",
|
|
28
|
+
"mono": "Font Name",
|
|
29
|
+
"googleHref": "https://fonts.googleapis.com/css2?..." or null
|
|
30
|
+
},
|
|
31
|
+
"hero": "bold" | "editorial",
|
|
32
|
+
"atmosphere": "noise" | "none",
|
|
33
|
+
"navStyle": "uppercase-tracked" | "soft",
|
|
34
|
+
"elevatedCards": true | false,
|
|
35
|
+
"cssNotes": ["..."]
|
|
36
|
+
}`;
|
|
37
|
+
|
|
38
|
+
/** Paper / ice defaults when the author asked for light and the model still returns a cave. */
|
|
39
|
+
export const LIGHT_FLOOR = {
|
|
40
|
+
bg: '#f3f7fb',
|
|
41
|
+
surface: '#ffffff',
|
|
42
|
+
ink: '#16324a',
|
|
43
|
+
inkSoft: '#3d5a70',
|
|
44
|
+
rule: '#c5d6e4',
|
|
45
|
+
ruleStrong: '#8eafc8',
|
|
46
|
+
accent: '#1a6fa8',
|
|
47
|
+
accentStrong: '#0e4d7a'
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
export function paletteHintFromSteer(text: string): 'light' | 'dark' | null {
|
|
51
|
+
const t = text.toLowerCase();
|
|
52
|
+
const wantsLight =
|
|
53
|
+
/\b(light|bright|white|snow|ice|icy|antarctica|antarctic|arctic|glacier|frost|daylight|crisp|parchment|paper)\b/.test(
|
|
54
|
+
t
|
|
55
|
+
);
|
|
56
|
+
const wantsDark = /\b(dark|night|black|midnight|noir|charcoal|obsidian)\b/.test(t);
|
|
57
|
+
if (wantsLight && !wantsDark) return 'light';
|
|
58
|
+
if (wantsDark && !wantsLight) return 'dark';
|
|
59
|
+
if (wantsLight) return 'light';
|
|
60
|
+
if (wantsDark) return 'dark';
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function hexRelativeLuminance(hex: string | undefined): number | null {
|
|
65
|
+
if (!hex) return null;
|
|
66
|
+
const m = hex.trim().match(/^#([0-9a-f]{3}|[0-9a-f]{6})$/i);
|
|
67
|
+
if (!m) return null;
|
|
68
|
+
let h = m[1];
|
|
69
|
+
if (h.length === 3) h = [...h].map((c) => `${c}${c}`).join('');
|
|
70
|
+
const n = parseInt(h, 16);
|
|
71
|
+
const r = (n >> 16) & 255;
|
|
72
|
+
const g = (n >> 8) & 255;
|
|
73
|
+
const b = n & 255;
|
|
74
|
+
return (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function applyPaletteHint(brief: DesignBrief, hint: 'light' | 'dark' | null): DesignBrief {
|
|
78
|
+
if (!hint) return brief;
|
|
79
|
+
const tokens = { ...brief.tokens };
|
|
80
|
+
if (hint === 'light') {
|
|
81
|
+
const bgLum = hexRelativeLuminance(tokens.bg);
|
|
82
|
+
const inkLum = hexRelativeLuminance(tokens.ink);
|
|
83
|
+
if (bgLum === null || bgLum < 0.75) {
|
|
84
|
+
tokens.bg = LIGHT_FLOOR.bg;
|
|
85
|
+
tokens.surface = LIGHT_FLOOR.surface;
|
|
86
|
+
tokens.rule = tokens.rule && (hexRelativeLuminance(tokens.rule) ?? 0) > 0.55 ? tokens.rule : LIGHT_FLOOR.rule;
|
|
87
|
+
tokens.ruleStrong =
|
|
88
|
+
tokens.ruleStrong && (hexRelativeLuminance(tokens.ruleStrong) ?? 0) > 0.4
|
|
89
|
+
? tokens.ruleStrong
|
|
90
|
+
: LIGHT_FLOOR.ruleStrong;
|
|
91
|
+
}
|
|
92
|
+
if (inkLum === null || inkLum > 0.45) {
|
|
93
|
+
tokens.ink = LIGHT_FLOOR.ink;
|
|
94
|
+
tokens.inkSoft = LIGHT_FLOOR.inkSoft;
|
|
95
|
+
}
|
|
96
|
+
return { ...brief, paletteMode: 'light', tokens };
|
|
97
|
+
}
|
|
98
|
+
return { ...brief, paletteMode: 'dark' };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function buildDesignBriefPrompt(opts: {
|
|
102
|
+
intent: BriefIntent;
|
|
103
|
+
seed: DesignBrief;
|
|
104
|
+
ir: SiteIR;
|
|
105
|
+
inspireSummaries: string[];
|
|
106
|
+
inspireSignals: InspirationSignals[];
|
|
107
|
+
steer?: string;
|
|
108
|
+
}): { system: string; user: string } {
|
|
109
|
+
const notes = opts.inspireSignals.flatMap((s) => s.notes).join('; ') || '(none)';
|
|
110
|
+
const snippets =
|
|
111
|
+
opts.inspireSummaries.map((s, i) => `(${i + 1}) ${s}`).join('\n') || '(none)';
|
|
112
|
+
if (opts.intent === 'steer') {
|
|
113
|
+
return {
|
|
114
|
+
system:
|
|
115
|
+
'You output only valid JSON. Honor the author\'s written palette. Light, ice, snow, or Antarctica means a bright page — never a black one unless they also say dark or night.',
|
|
116
|
+
user: `You are restyling a filepress Essay site from the author's written direction.
|
|
117
|
+
The seed is only a starting point. When the direction conflicts with the seed, the direction wins.
|
|
118
|
+
Return ONLY JSON (no fences) with this shape:
|
|
119
|
+
${BRIEF_SHAPE}
|
|
120
|
+
|
|
121
|
+
Hard rules:
|
|
122
|
+
- Follow the author's palette. Light / bright / white / snow / ice / Antarctica / daylight → paletteMode "light", background #e8 or lighter (paper, snow, ice), dark readable ink. Do not use near-black backgrounds.
|
|
123
|
+
- "Cold" or "icy" is color temperature (blue, white, crisp) — not a night scene unless they also say dark/night/black.
|
|
124
|
+
- Dark/night/black only when the author asks for that.
|
|
125
|
+
- Do not invent marketing section layouts.
|
|
126
|
+
- Avoid purple-on-white clichés.
|
|
127
|
+
|
|
128
|
+
Author direction:
|
|
129
|
+
${opts.steer?.trim() || '(none)'}
|
|
130
|
+
|
|
131
|
+
Seed brief (override freely when the direction conflicts):
|
|
132
|
+
${JSON.stringify(opts.seed, null, 2)}
|
|
133
|
+
|
|
134
|
+
Site identity (content only — do not force its old palette):
|
|
135
|
+
${JSON.stringify(opts.ir.identity, null, 2)}
|
|
136
|
+
`
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
return {
|
|
140
|
+
system:
|
|
141
|
+
'You output only valid JSON. Preserve dark inspiration palettes; do not flatten them into cream editorial.',
|
|
142
|
+
user: `You are a design director restyling a personal Markdown blog (filepress Essay chrome).
|
|
143
|
+
The seed brief below was extracted from inspiration site CSS/fonts. Refine it — keep the punch.
|
|
144
|
+
Return ONLY JSON (no fences) with this shape:
|
|
145
|
+
${BRIEF_SHAPE}
|
|
146
|
+
|
|
147
|
+
Hard rules:
|
|
148
|
+
- If inspiration is dark/modern, paletteMode MUST stay "dark" with bold hero + noise + tracked nav.
|
|
149
|
+
- Personal essay site: do NOT invent marketing section layouts.
|
|
150
|
+
- Prefer inspiration fonts/colors over the source site's cream-editorial look.
|
|
151
|
+
- Avoid purple-on-white clichés.
|
|
152
|
+
|
|
153
|
+
Seed brief (from inspiration extraction):
|
|
154
|
+
${JSON.stringify(opts.seed, null, 2)}
|
|
155
|
+
|
|
156
|
+
Site identity (content only — do not force its old palette):
|
|
157
|
+
${JSON.stringify(opts.ir.identity, null, 2)}
|
|
158
|
+
|
|
159
|
+
Inspiration notes:
|
|
160
|
+
${notes}
|
|
161
|
+
|
|
162
|
+
Inspiration text snippets:
|
|
163
|
+
${snippets}
|
|
164
|
+
`
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
5
168
|
/** Strip trailing slashes; used to compare Genie picker values with env. */
|
|
6
169
|
export function normalizeOllamaHost(host: string): string {
|
|
7
170
|
return host.trim().replace(/\/+$/, '');
|
|
@@ -66,6 +229,77 @@ export function ollamaSetupHint(host: string): string {
|
|
|
66
229
|
].join(' ');
|
|
67
230
|
}
|
|
68
231
|
|
|
232
|
+
/** Wall-clock budget for `/api/chat`. 12B first-load often exceeds 3 minutes. */
|
|
233
|
+
export const DEFAULT_OLLAMA_CHAT_TIMEOUT_MS = 600_000;
|
|
234
|
+
|
|
235
|
+
export function ollamaChatTimeoutMs(env: NodeJS.ProcessEnv = process.env): number {
|
|
236
|
+
const raw = env.FILEPRESS_OLLAMA_TIMEOUT_MS?.trim();
|
|
237
|
+
if (!raw) return DEFAULT_OLLAMA_CHAT_TIMEOUT_MS;
|
|
238
|
+
const n = Number(raw);
|
|
239
|
+
if (!Number.isFinite(n) || n < 10_000) return DEFAULT_OLLAMA_CHAT_TIMEOUT_MS;
|
|
240
|
+
return Math.min(n, 3_600_000);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
export function ollamaTimeoutMessage(opts: { host: string; model: string; timeoutMs: number }): string {
|
|
244
|
+
const secs = Math.round(opts.timeoutMs / 1000);
|
|
245
|
+
return (
|
|
246
|
+
`Ollama did not finish within ${secs}s (${opts.model} at ${opts.host}). ` +
|
|
247
|
+
`The model may still be loading — check \`ollama ps\`, then retry (a warm model is much faster). ` +
|
|
248
|
+
`Or raise FILEPRESS_OLLAMA_TIMEOUT_MS (milliseconds; default ${DEFAULT_OLLAMA_CHAT_TIMEOUT_MS}).`
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
export function isAbortLike(err: unknown): boolean {
|
|
253
|
+
if (!err || typeof err !== 'object') return false;
|
|
254
|
+
const name = 'name' in err ? String(err.name) : '';
|
|
255
|
+
const msg = 'message' in err ? String(err.message) : '';
|
|
256
|
+
return (
|
|
257
|
+
name === 'TimeoutError' ||
|
|
258
|
+
name === 'AbortError' ||
|
|
259
|
+
/aborted due to timeout/i.test(msg) ||
|
|
260
|
+
/The operation was aborted/i.test(msg)
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** Append one NDJSON line from Ollama `stream: true` `/api/chat`. */
|
|
265
|
+
export function appendOllamaChatDelta(line: string, acc: { content: string }): void {
|
|
266
|
+
const trimmed = line.trim();
|
|
267
|
+
if (!trimmed) return;
|
|
268
|
+
const ev = JSON.parse(trimmed) as { message?: { content?: string }; error?: string };
|
|
269
|
+
if (ev.error) throw new Error(ev.error);
|
|
270
|
+
if (ev.message?.content) acc.content += ev.message.content;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
async function readOllamaChatStream(
|
|
274
|
+
res: Response,
|
|
275
|
+
logLabel: string,
|
|
276
|
+
started: number
|
|
277
|
+
): Promise<string> {
|
|
278
|
+
if (!res.body) throw new Error('Ollama chat returned an empty body');
|
|
279
|
+
const reader = res.body.getReader();
|
|
280
|
+
const decoder = new TextDecoder();
|
|
281
|
+
let buf = '';
|
|
282
|
+
const acc = { content: '' };
|
|
283
|
+
let lastLog = 0;
|
|
284
|
+
for (;;) {
|
|
285
|
+
const { done, value } = await reader.read();
|
|
286
|
+
if (done) break;
|
|
287
|
+
buf += decoder.decode(value, { stream: true });
|
|
288
|
+
const lines = buf.split(/\r?\n/);
|
|
289
|
+
buf = lines.pop() ?? '';
|
|
290
|
+
for (const line of lines) appendOllamaChatDelta(line, acc);
|
|
291
|
+
const elapsed = Date.now() - started;
|
|
292
|
+
if (elapsed - lastLog >= 15_000) {
|
|
293
|
+
lastLog = elapsed;
|
|
294
|
+
console.log(
|
|
295
|
+
`${logLabel}: still generating… ${Math.round(elapsed / 1000)}s, ${acc.content.length} chars`
|
|
296
|
+
);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
if (buf.trim()) appendOllamaChatDelta(buf, acc);
|
|
300
|
+
return acc.content;
|
|
301
|
+
}
|
|
302
|
+
|
|
69
303
|
export async function generateDesignBrief(opts: {
|
|
70
304
|
host: string;
|
|
71
305
|
model: string;
|
|
@@ -73,102 +307,123 @@ export async function generateDesignBrief(opts: {
|
|
|
73
307
|
inspireSummaries: string[];
|
|
74
308
|
inspireSignals: InspirationSignals[];
|
|
75
309
|
seed: DesignBrief;
|
|
310
|
+
/** When true, unparseable model JSON fails instead of silently using the seed. */
|
|
311
|
+
strictParse?: boolean;
|
|
312
|
+
logLabel?: string;
|
|
313
|
+
/** `steer` uses the author-direction prompt; default is the import/inspire prompt. */
|
|
314
|
+
intent?: BriefIntent;
|
|
315
|
+
steer?: string;
|
|
316
|
+
/** Write raw + applied brief JSON here (Genie: `.filepress-genie/last-ollama.json`). */
|
|
317
|
+
dumpPath?: string;
|
|
76
318
|
}): Promise<DesignBrief> {
|
|
77
319
|
const host = opts.host.replace(/\/+$/, '');
|
|
78
|
-
const
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
"ruleStrong": "#rrggbb"
|
|
94
|
-
},
|
|
95
|
-
"density": "sparse" | "balanced" | "dense",
|
|
96
|
-
"paletteMode": "dark" | "light",
|
|
97
|
-
"fonts": {
|
|
98
|
-
"serif": "Font Name",
|
|
99
|
-
"sans": "Font Name",
|
|
100
|
-
"mono": "Font Name",
|
|
101
|
-
"googleHref": "https://fonts.googleapis.com/css2?..." or null
|
|
102
|
-
},
|
|
103
|
-
"hero": "bold" | "editorial",
|
|
104
|
-
"atmosphere": "noise" | "none",
|
|
105
|
-
"navStyle": "uppercase-tracked" | "soft",
|
|
106
|
-
"elevatedCards": true | false,
|
|
107
|
-
"cssNotes": ["..."]
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
Hard rules:
|
|
111
|
-
- If inspiration is dark/modern, paletteMode MUST stay "dark" with bold hero + noise + tracked nav.
|
|
112
|
-
- Personal essay site: do NOT invent marketing section layouts.
|
|
113
|
-
- Prefer inspiration fonts/colors over the source site's cream-editorial look.
|
|
114
|
-
- Avoid purple-on-white clichés.
|
|
115
|
-
|
|
116
|
-
Seed brief (from inspiration extraction):
|
|
117
|
-
${JSON.stringify(opts.seed, null, 2)}
|
|
118
|
-
|
|
119
|
-
Site identity (content only — do not force its old palette):
|
|
120
|
-
${JSON.stringify(opts.ir.identity, null, 2)}
|
|
320
|
+
const timeoutMs = ollamaChatTimeoutMs();
|
|
321
|
+
const started = Date.now();
|
|
322
|
+
const logLabel = opts.logLabel ?? `filepress: Ollama ${opts.model} @ ${host}`;
|
|
323
|
+
const intent = opts.intent ?? 'import';
|
|
324
|
+
const hint = intent === 'steer' ? paletteHintFromSteer(opts.steer ?? '') : null;
|
|
325
|
+
const messages = buildDesignBriefPrompt({
|
|
326
|
+
intent,
|
|
327
|
+
seed: opts.seed,
|
|
328
|
+
ir: opts.ir,
|
|
329
|
+
inspireSummaries: opts.inspireSummaries,
|
|
330
|
+
inspireSignals: opts.inspireSignals,
|
|
331
|
+
steer: opts.steer
|
|
332
|
+
});
|
|
333
|
+
console.log(`${logLabel}: starting chat (up to ${Math.round(timeoutMs / 1000)}s) intent=${intent}`);
|
|
334
|
+
if (hint) console.log(`${logLabel}: author palette hint = ${hint}`);
|
|
121
335
|
|
|
122
|
-
|
|
123
|
-
|
|
336
|
+
let content = '';
|
|
337
|
+
try {
|
|
338
|
+
const res = await fetch(`${host}/api/chat`, {
|
|
339
|
+
method: 'POST',
|
|
340
|
+
headers: { 'content-type': 'application/json' },
|
|
341
|
+
body: JSON.stringify({
|
|
342
|
+
model: opts.model,
|
|
343
|
+
stream: true,
|
|
344
|
+
format: 'json',
|
|
345
|
+
options: { temperature: 0.35 },
|
|
346
|
+
messages: [
|
|
347
|
+
{ role: 'system', content: messages.system },
|
|
348
|
+
{ role: 'user', content: messages.user }
|
|
349
|
+
]
|
|
350
|
+
}),
|
|
351
|
+
signal: AbortSignal.timeout(timeoutMs)
|
|
352
|
+
});
|
|
124
353
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
const res = await fetch(`${host}/api/chat`, {
|
|
130
|
-
method: 'POST',
|
|
131
|
-
headers: { 'content-type': 'application/json' },
|
|
132
|
-
body: JSON.stringify({
|
|
133
|
-
model: opts.model,
|
|
134
|
-
stream: false,
|
|
135
|
-
format: 'json',
|
|
136
|
-
options: { temperature: 0.35 },
|
|
137
|
-
messages: [
|
|
138
|
-
{
|
|
139
|
-
role: 'system',
|
|
140
|
-
content:
|
|
141
|
-
'You output only valid JSON. Preserve dark inspiration palettes; do not flatten them into cream editorial.'
|
|
142
|
-
},
|
|
143
|
-
{ role: 'user', content: prompt }
|
|
144
|
-
]
|
|
145
|
-
}),
|
|
146
|
-
signal: AbortSignal.timeout(180_000)
|
|
147
|
-
});
|
|
354
|
+
if (!res.ok) {
|
|
355
|
+
const body = await res.text().catch(() => '');
|
|
356
|
+
throw new Error(`Ollama chat failed (${res.status}): ${body.slice(0, 400)}`);
|
|
357
|
+
}
|
|
148
358
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
359
|
+
content = await readOllamaChatStream(res, logLabel, started);
|
|
360
|
+
console.log(`${logLabel}: done in ${Math.round((Date.now() - started) / 1000)}s (${content.length} chars)`);
|
|
361
|
+
console.log(`${logLabel}: raw JSON\n${content}`);
|
|
362
|
+
} catch (e) {
|
|
363
|
+
if (isAbortLike(e)) {
|
|
364
|
+
throw new Error(ollamaTimeoutMessage({ host, model: opts.model, timeoutMs }));
|
|
365
|
+
}
|
|
366
|
+
throw e;
|
|
152
367
|
}
|
|
153
|
-
|
|
154
|
-
const data = (await res.json()) as { message?: { content?: string } };
|
|
155
|
-
const content = data.message?.content ?? '';
|
|
156
368
|
try {
|
|
157
369
|
const refined = parseBriefJson(content);
|
|
158
370
|
// Never let the model drop fonts/googleHref from a rich seed
|
|
159
|
-
|
|
371
|
+
let brief: DesignBrief = {
|
|
160
372
|
...opts.seed,
|
|
161
373
|
...refined,
|
|
162
374
|
tokens: { ...opts.seed.tokens, ...refined.tokens },
|
|
163
375
|
fonts: refined.fonts || opts.seed.fonts,
|
|
164
|
-
paletteMode: refined.paletteMode || opts.seed.paletteMode,
|
|
376
|
+
paletteMode: refined.paletteMode || (intent === 'steer' ? undefined : opts.seed.paletteMode),
|
|
165
377
|
hero: refined.hero || opts.seed.hero,
|
|
166
378
|
atmosphere: refined.atmosphere || opts.seed.atmosphere,
|
|
167
379
|
navStyle: refined.navStyle || opts.seed.navStyle,
|
|
168
380
|
elevatedCards: refined.elevatedCards ?? opts.seed.elevatedCards
|
|
169
381
|
};
|
|
382
|
+
brief = applyPaletteHint(brief, hint);
|
|
383
|
+
console.log(
|
|
384
|
+
`${logLabel}: applied paletteMode=${brief.paletteMode ?? '(unset)'} bg=${brief.tokens.bg ?? '(unset)'} ink=${brief.tokens.ink ?? '(unset)'} accent=${brief.tokens.accent}`
|
|
385
|
+
);
|
|
386
|
+
if (opts.dumpPath) {
|
|
387
|
+
mkdirSync(dirname(opts.dumpPath), { recursive: true });
|
|
388
|
+
writeFileSync(
|
|
389
|
+
opts.dumpPath,
|
|
390
|
+
`${JSON.stringify(
|
|
391
|
+
{
|
|
392
|
+
intent,
|
|
393
|
+
steer: opts.steer ?? null,
|
|
394
|
+
hint,
|
|
395
|
+
model: opts.model,
|
|
396
|
+
host,
|
|
397
|
+
raw: content,
|
|
398
|
+
applied: {
|
|
399
|
+
paletteMode: brief.paletteMode,
|
|
400
|
+
tokens: brief.tokens,
|
|
401
|
+
mood: brief.mood
|
|
402
|
+
}
|
|
403
|
+
},
|
|
404
|
+
null,
|
|
405
|
+
2
|
|
406
|
+
)}\n`
|
|
407
|
+
);
|
|
408
|
+
console.log(`${logLabel}: dumped ${opts.dumpPath}`);
|
|
409
|
+
}
|
|
410
|
+
return brief;
|
|
170
411
|
} catch (e) {
|
|
171
|
-
|
|
412
|
+
const detail = e instanceof Error ? e.message : String(e);
|
|
413
|
+
if (opts.dumpPath && content) {
|
|
414
|
+
mkdirSync(dirname(opts.dumpPath), { recursive: true });
|
|
415
|
+
writeFileSync(
|
|
416
|
+
opts.dumpPath,
|
|
417
|
+
`${JSON.stringify({ intent, steer: opts.steer ?? null, parseError: detail, raw: content }, null, 2)}\n`
|
|
418
|
+
);
|
|
419
|
+
console.log(`${logLabel}: dumped unparsed response to ${opts.dumpPath}`);
|
|
420
|
+
}
|
|
421
|
+
if (opts.strictParse) {
|
|
422
|
+
throw new Error(
|
|
423
|
+
`Ollama returned a brief we could not parse (${detail}). First 200 chars: ${content.slice(0, 200)}`
|
|
424
|
+
);
|
|
425
|
+
}
|
|
426
|
+
console.warn(`import: brief parse failed (${detail}); using seed brief`);
|
|
172
427
|
return opts.seed;
|
|
173
428
|
}
|
|
174
429
|
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import {
|
|
2
|
+
redirectsFromSourceUrls,
|
|
3
|
+
writingPostRedirects,
|
|
4
|
+
type RedirectRule
|
|
5
|
+
} from '../../core/src/lib/redirects.ts';
|
|
6
|
+
import type { SiteIR } from './ir.ts';
|
|
7
|
+
|
|
8
|
+
/** Old source paths → FilePress URLs, plus `/writing` when home becomes a page. */
|
|
9
|
+
export function importRedirectRules(
|
|
10
|
+
ir: Pick<SiteIR, 'posts' | 'pages' | 'homeMarkdown'>
|
|
11
|
+
): RedirectRule[] {
|
|
12
|
+
const pairs = [
|
|
13
|
+
...ir.posts.map((p) => ({ sourceUrl: p.sourceUrl, destPath: `/posts/${p.slug}` })),
|
|
14
|
+
...ir.pages.map((p) => ({ sourceUrl: p.sourceUrl, destPath: `/${p.slug}` }))
|
|
15
|
+
];
|
|
16
|
+
return [...(ir.homeMarkdown ? writingPostRedirects() : []), ...redirectsFromSourceUrls(pairs)];
|
|
17
|
+
}
|
|
@@ -9,9 +9,11 @@ import {
|
|
|
9
9
|
import { dirname, join, resolve } from 'node:path';
|
|
10
10
|
import { fileURLToPath } from 'node:url';
|
|
11
11
|
import { spawnSync } from 'node:child_process';
|
|
12
|
+
import { serializeRedirects } from '../../core/src/lib/redirects.ts';
|
|
12
13
|
import type { DesignBrief, ImportOptions, SiteIR } from './ir.ts';
|
|
13
14
|
import { fetchBuffer } from './fetch.ts';
|
|
14
15
|
import { fetchChosenImages } from './images.ts';
|
|
16
|
+
import { importRedirectRules } from './redirects.ts';
|
|
15
17
|
import { themeCssFromBrief } from './theme.ts';
|
|
16
18
|
|
|
17
19
|
function writeAttribution(metaDir: string, ir: SiteIR) {
|
|
@@ -178,8 +180,8 @@ Generated: ${new Date().toISOString()}
|
|
|
178
180
|
|
|
179
181
|
## URL remaps
|
|
180
182
|
|
|
181
|
-
Posts
|
|
182
|
-
|
|
183
|
+
Posts live at \`/posts/<slug>\`. Old paths are written to \`static/_redirects\` (Cloudflare Pages / Netlify).
|
|
184
|
+
${ir.homeMarkdown ? '\nLong home bio became `pages/home.md`; the post index is `/posts`.\n' : ''}
|
|
183
185
|
${ir.posts.map((p) => `- \`${p.sourceUrl}\` → \`/posts/${p.slug}\``).join('\n')}
|
|
184
186
|
|
|
185
187
|
${ir.pages.map((p) => `- \`${p.sourceUrl}\` → \`/${p.slug}\``).join('\n')}
|
|
@@ -295,6 +297,24 @@ ${body}
|
|
|
295
297
|
writeFileSync(join(out, 'pages', `${page.slug}.md`), md);
|
|
296
298
|
}
|
|
297
299
|
|
|
300
|
+
if (ir.homeMarkdown && !ir.pages.some((p) => p.slug === 'home')) {
|
|
301
|
+
writeFileSync(
|
|
302
|
+
join(out, 'pages', 'home.md'),
|
|
303
|
+
`---
|
|
304
|
+
title: ${yamlQuote(ir.identity.title)}
|
|
305
|
+
order: 0
|
|
306
|
+
---
|
|
307
|
+
|
|
308
|
+
${ir.homeMarkdown}
|
|
309
|
+
`
|
|
310
|
+
);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const redirectRules = importRedirectRules(ir);
|
|
314
|
+
if (redirectRules.length) {
|
|
315
|
+
writeFileSync(join(staticDir, '_redirects'), serializeRedirects(redirectRules));
|
|
316
|
+
}
|
|
317
|
+
|
|
298
318
|
const topicsLit = ir.topics
|
|
299
319
|
.map((t) => `\t\t{ label: ${yamlQuote(t.label)}, tag: ${yamlQuote(t.tag)} }`)
|
|
300
320
|
.join(',\n');
|
|
@@ -302,6 +322,8 @@ ${body}
|
|
|
302
322
|
.map((n) => `\t\t{ label: ${yamlQuote(n.label)}, href: ${yamlQuote(n.href)} }`)
|
|
303
323
|
.join(',\n');
|
|
304
324
|
const ledeLine = ir.lede ? `\n\tlede: ${yamlQuote(ir.lede)},` : '';
|
|
325
|
+
const useHomePage = Boolean(ir.homeMarkdown) || ir.pages.some((p) => p.slug === 'home');
|
|
326
|
+
const homePageLine = useHomePage ? `\n\thomePage: 'home',` : '';
|
|
305
327
|
const logo = activeBrief?.images?.logo;
|
|
306
328
|
const logoLine = logo ? `\n\tlogo: ${yamlQuote(logo)},` : '';
|
|
307
329
|
|
|
@@ -313,7 +335,7 @@ export default defineFilepressConfig({
|
|
|
313
335
|
title: ${yamlQuote(title)},
|
|
314
336
|
description: ${yamlQuote(ir.identity.description)},
|
|
315
337
|
url: ${yamlQuote(url)},
|
|
316
|
-
author: ${yamlQuote(author)},${ledeLine}${logoLine}
|
|
338
|
+
author: ${yamlQuote(author)},${ledeLine}${homePageLine}${logoLine}
|
|
317
339
|
nav: [
|
|
318
340
|
${navLit}
|
|
319
341
|
],
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Post-`vite build` steps for a FilePress site:
|
|
3
3
|
* 1. Write default `build/_headers` unless the site already provided one
|
|
4
|
-
* 2.
|
|
4
|
+
* 2. Merge engine `_redirects` (from `.filepress/redirects.txt`) into `build/_redirects`
|
|
5
|
+
* 3. Copy `paths` mounts (from `.filepress/path-mounts.json`)
|
|
5
6
|
*
|
|
6
7
|
* Usage: node scripts/copy-path-mounts.mjs <siteRoot>
|
|
7
8
|
*/
|
|
@@ -30,6 +31,34 @@ if (existsSync(headersDest)) {
|
|
|
30
31
|
console.log('filepress: wrote default _headers');
|
|
31
32
|
}
|
|
32
33
|
|
|
34
|
+
const redirectsPlan = join(siteRoot, '.filepress', 'redirects.txt');
|
|
35
|
+
const redirectsDest = join(buildDir, '_redirects');
|
|
36
|
+
if (existsSync(redirectsPlan)) {
|
|
37
|
+
const planned = readFileSync(redirectsPlan, 'utf8').trim();
|
|
38
|
+
if (planned) {
|
|
39
|
+
const existing = existsSync(redirectsDest) ? readFileSync(redirectsDest, 'utf8') : '';
|
|
40
|
+
const have = new Set(
|
|
41
|
+
existing
|
|
42
|
+
.split(/\r?\n/)
|
|
43
|
+
.map((line) => line.replace(/#.*$/, '').trim())
|
|
44
|
+
.filter(Boolean)
|
|
45
|
+
.map((line) => line.split(/\s+/).slice(0, 2).join('\0'))
|
|
46
|
+
);
|
|
47
|
+
const add = planned
|
|
48
|
+
.split(/\r?\n/)
|
|
49
|
+
.map((line) => line.trim())
|
|
50
|
+
.filter(Boolean)
|
|
51
|
+
.filter((line) => !have.has(line.split(/\s+/).slice(0, 2).join('\0')));
|
|
52
|
+
if (add.length) {
|
|
53
|
+
const prefix = existing.trimEnd();
|
|
54
|
+
writeFileSync(redirectsDest, prefix ? `${prefix}\n${add.join('\n')}\n` : `${add.join('\n')}\n`);
|
|
55
|
+
console.log(`filepress: wrote ${add.length} _redirects rule(s)`);
|
|
56
|
+
} else {
|
|
57
|
+
console.log('filepress: kept site _redirects');
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
33
62
|
if (!existsSync(cachePath)) {
|
|
34
63
|
process.exit(0);
|
|
35
64
|
}
|
package/scripts/create-site.mjs
CHANGED
|
@@ -216,6 +216,7 @@ Any static host: publish the \`build/\` folder. Details: https://getfilepress.co
|
|
|
216
216
|
console.log(`Next:`);
|
|
217
217
|
console.log(` cd ${relToEngine} && pnpm install # if not already`);
|
|
218
218
|
console.log(` cd ${target} && pnpm install && pnpm dev`);
|
|
219
|
+
console.log(` filepress new "My Post"`);
|
|
219
220
|
} else {
|
|
220
221
|
writeFileSync(
|
|
221
222
|
join(target, 'tsconfig.json'),
|
package/scripts/filepress.mjs
CHANGED
|
@@ -145,6 +145,13 @@ function runImport(argv) {
|
|
|
145
145
|
runNodeBin('tsx', 'tsx', [importCli, ...argv], { cwd: packageRoot });
|
|
146
146
|
}
|
|
147
147
|
|
|
148
|
+
/** `filepress new "Title"` → dated post skeleton next to filepress.config.ts. */
|
|
149
|
+
function runNew(argv) {
|
|
150
|
+
const cli = join(scriptDir, 'new-post.ts');
|
|
151
|
+
if (!existsSync(cli)) fail(`new-post CLI missing at ${cli}`);
|
|
152
|
+
runNodeBin('tsx', 'tsx', [cli, ...argv], { cwd: process.cwd() });
|
|
153
|
+
}
|
|
154
|
+
|
|
148
155
|
function listSites() {
|
|
149
156
|
if (!existsSync(sitesDir)) return [];
|
|
150
157
|
return readdirSync(sitesDir).filter((name) => {
|
|
@@ -173,6 +180,8 @@ const argv = process.argv.slice(2);
|
|
|
173
180
|
await ensureEmbeddedLinks();
|
|
174
181
|
if (argv[0] === 'import') {
|
|
175
182
|
runImport(argv.slice(1));
|
|
183
|
+
} else if (argv[0] === 'new') {
|
|
184
|
+
runNew(argv.slice(1));
|
|
176
185
|
} else {
|
|
177
186
|
runSiteCommand(argv);
|
|
178
187
|
}
|
|
@@ -190,6 +199,7 @@ function runSiteCommand(argv) {
|
|
|
190
199
|
` filepress preview [--port 27777] # serves <site>/build (default 27777)\n` +
|
|
191
200
|
` filepress dev --port <n> [--host] # vite dev; optional fixed port / LAN\n` +
|
|
192
201
|
` filepress import --source <url> [--inspire <url>] …\n` +
|
|
202
|
+
` filepress new "Post title" [--draft] [--site name | --root path]\n` +
|
|
193
203
|
`monorepo sites: ${listSites().join(', ') || '(none)'}`
|
|
194
204
|
);
|
|
195
205
|
}
|
|
@@ -238,20 +248,20 @@ function runSiteCommand(argv) {
|
|
|
238
248
|
);
|
|
239
249
|
}
|
|
240
250
|
const port = args.port || process.env.FILEPRESS_PORT || '27777';
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
);
|
|
251
|
+
const host =
|
|
252
|
+
args.host && args.host !== 'true' ? args.host : args.host === 'true' ? '0.0.0.0' : '127.0.0.1';
|
|
253
|
+
const previewScript = join(scriptDir, 'preview.mjs');
|
|
254
|
+
if (!existsSync(previewScript)) fail(`preview script missing at ${previewScript}`);
|
|
255
|
+
const child = spawn(process.execPath, [previewScript, buildDir, String(port), host], {
|
|
256
|
+
cwd: packageRoot,
|
|
257
|
+
env,
|
|
258
|
+
stdio: 'inherit',
|
|
259
|
+
shell: false
|
|
260
|
+
});
|
|
261
|
+
child.on('exit', (code, signal) => {
|
|
262
|
+
if (signal) process.kill(process.pid, signal);
|
|
263
|
+
process.exit(code ?? 1);
|
|
264
|
+
});
|
|
255
265
|
return;
|
|
256
266
|
}
|
|
257
267
|
|