getfilepress 0.1.8 → 0.1.10
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 +21 -4
- package/package.json +110 -102
- package/packages/app/src/lib/genie/GeniePanel.svelte +286 -21
- package/packages/app/src/lib/genie/ops.ts +7 -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/+page.server.ts +12 -0
- package/packages/app/src/routes/posts/+page.svelte +38 -0
- package/packages/app/src/routes/posts/[slug]/+page.svelte +2 -1
- package/packages/app/src/routes/writing/+page.server.ts +2 -6
- package/packages/app/src/routes/writing/+page.svelte +1 -38
- 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/components/PostIndex.svelte +1 -1
- package/packages/core/src/lib/config.ts +51 -5
- package/packages/core/src/lib/content/feeds.ts +1 -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 +119 -27
- 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
- package/packages/app/README.md +0 -19
- package/packages/core/README.md +0 -29
|
@@ -66,6 +66,77 @@ export function ollamaSetupHint(host: string): string {
|
|
|
66
66
|
].join(' ');
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
+
/** Wall-clock budget for `/api/chat`. 12B first-load often exceeds 3 minutes. */
|
|
70
|
+
export const DEFAULT_OLLAMA_CHAT_TIMEOUT_MS = 600_000;
|
|
71
|
+
|
|
72
|
+
export function ollamaChatTimeoutMs(env: NodeJS.ProcessEnv = process.env): number {
|
|
73
|
+
const raw = env.FILEPRESS_OLLAMA_TIMEOUT_MS?.trim();
|
|
74
|
+
if (!raw) return DEFAULT_OLLAMA_CHAT_TIMEOUT_MS;
|
|
75
|
+
const n = Number(raw);
|
|
76
|
+
if (!Number.isFinite(n) || n < 10_000) return DEFAULT_OLLAMA_CHAT_TIMEOUT_MS;
|
|
77
|
+
return Math.min(n, 3_600_000);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function ollamaTimeoutMessage(opts: { host: string; model: string; timeoutMs: number }): string {
|
|
81
|
+
const secs = Math.round(opts.timeoutMs / 1000);
|
|
82
|
+
return (
|
|
83
|
+
`Ollama did not finish within ${secs}s (${opts.model} at ${opts.host}). ` +
|
|
84
|
+
`The model may still be loading — check \`ollama ps\`, then retry (a warm model is much faster). ` +
|
|
85
|
+
`Or raise FILEPRESS_OLLAMA_TIMEOUT_MS (milliseconds; default ${DEFAULT_OLLAMA_CHAT_TIMEOUT_MS}).`
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function isAbortLike(err: unknown): boolean {
|
|
90
|
+
if (!err || typeof err !== 'object') return false;
|
|
91
|
+
const name = 'name' in err ? String(err.name) : '';
|
|
92
|
+
const msg = 'message' in err ? String(err.message) : '';
|
|
93
|
+
return (
|
|
94
|
+
name === 'TimeoutError' ||
|
|
95
|
+
name === 'AbortError' ||
|
|
96
|
+
/aborted due to timeout/i.test(msg) ||
|
|
97
|
+
/The operation was aborted/i.test(msg)
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Append one NDJSON line from Ollama `stream: true` `/api/chat`. */
|
|
102
|
+
export function appendOllamaChatDelta(line: string, acc: { content: string }): void {
|
|
103
|
+
const trimmed = line.trim();
|
|
104
|
+
if (!trimmed) return;
|
|
105
|
+
const ev = JSON.parse(trimmed) as { message?: { content?: string }; error?: string };
|
|
106
|
+
if (ev.error) throw new Error(ev.error);
|
|
107
|
+
if (ev.message?.content) acc.content += ev.message.content;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async function readOllamaChatStream(
|
|
111
|
+
res: Response,
|
|
112
|
+
logLabel: string,
|
|
113
|
+
started: number
|
|
114
|
+
): Promise<string> {
|
|
115
|
+
if (!res.body) throw new Error('Ollama chat returned an empty body');
|
|
116
|
+
const reader = res.body.getReader();
|
|
117
|
+
const decoder = new TextDecoder();
|
|
118
|
+
let buf = '';
|
|
119
|
+
const acc = { content: '' };
|
|
120
|
+
let lastLog = 0;
|
|
121
|
+
for (;;) {
|
|
122
|
+
const { done, value } = await reader.read();
|
|
123
|
+
if (done) break;
|
|
124
|
+
buf += decoder.decode(value, { stream: true });
|
|
125
|
+
const lines = buf.split(/\r?\n/);
|
|
126
|
+
buf = lines.pop() ?? '';
|
|
127
|
+
for (const line of lines) appendOllamaChatDelta(line, acc);
|
|
128
|
+
const elapsed = Date.now() - started;
|
|
129
|
+
if (elapsed - lastLog >= 15_000) {
|
|
130
|
+
lastLog = elapsed;
|
|
131
|
+
console.log(
|
|
132
|
+
`${logLabel}: still generating… ${Math.round(elapsed / 1000)}s, ${acc.content.length} chars`
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
if (buf.trim()) appendOllamaChatDelta(buf, acc);
|
|
137
|
+
return acc.content;
|
|
138
|
+
}
|
|
139
|
+
|
|
69
140
|
export async function generateDesignBrief(opts: {
|
|
70
141
|
host: string;
|
|
71
142
|
model: string;
|
|
@@ -73,8 +144,15 @@ export async function generateDesignBrief(opts: {
|
|
|
73
144
|
inspireSummaries: string[];
|
|
74
145
|
inspireSignals: InspirationSignals[];
|
|
75
146
|
seed: DesignBrief;
|
|
147
|
+
/** When true, unparseable model JSON fails instead of silently using the seed. */
|
|
148
|
+
strictParse?: boolean;
|
|
149
|
+
logLabel?: string;
|
|
76
150
|
}): Promise<DesignBrief> {
|
|
77
151
|
const host = opts.host.replace(/\/+$/, '');
|
|
152
|
+
const timeoutMs = ollamaChatTimeoutMs();
|
|
153
|
+
const started = Date.now();
|
|
154
|
+
const logLabel = opts.logLabel ?? `filepress: Ollama ${opts.model} @ ${host}`;
|
|
155
|
+
console.log(`${logLabel}: starting chat (up to ${Math.round(timeoutMs / 1000)}s)`);
|
|
78
156
|
const prompt = `You are a design director restyling a personal Markdown blog (filepress Essay chrome).
|
|
79
157
|
The seed brief below was extracted from inspiration site CSS/fonts. Refine it — keep the punch.
|
|
80
158
|
Return ONLY JSON (no fences) with this shape:
|
|
@@ -126,33 +204,41 @@ Inspiration text snippets:
|
|
|
126
204
|
${opts.inspireSummaries.map((s, i) => `(${i + 1}) ${s}`).join('\n') || '(none)'}
|
|
127
205
|
`;
|
|
128
206
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
{
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
207
|
+
let content = '';
|
|
208
|
+
try {
|
|
209
|
+
const res = await fetch(`${host}/api/chat`, {
|
|
210
|
+
method: 'POST',
|
|
211
|
+
headers: { 'content-type': 'application/json' },
|
|
212
|
+
body: JSON.stringify({
|
|
213
|
+
model: opts.model,
|
|
214
|
+
stream: true,
|
|
215
|
+
format: 'json',
|
|
216
|
+
options: { temperature: 0.35 },
|
|
217
|
+
messages: [
|
|
218
|
+
{
|
|
219
|
+
role: 'system',
|
|
220
|
+
content:
|
|
221
|
+
'You output only valid JSON. Preserve dark inspiration palettes; do not flatten them into cream editorial.'
|
|
222
|
+
},
|
|
223
|
+
{ role: 'user', content: prompt }
|
|
224
|
+
]
|
|
225
|
+
}),
|
|
226
|
+
signal: AbortSignal.timeout(timeoutMs)
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
if (!res.ok) {
|
|
230
|
+
const body = await res.text().catch(() => '');
|
|
231
|
+
throw new Error(`Ollama chat failed (${res.status}): ${body.slice(0, 400)}`);
|
|
232
|
+
}
|
|
153
233
|
|
|
154
|
-
|
|
155
|
-
|
|
234
|
+
content = await readOllamaChatStream(res, logLabel, started);
|
|
235
|
+
console.log(`${logLabel}: done in ${Math.round((Date.now() - started) / 1000)}s (${content.length} chars)`);
|
|
236
|
+
} catch (e) {
|
|
237
|
+
if (isAbortLike(e)) {
|
|
238
|
+
throw new Error(ollamaTimeoutMessage({ host, model: opts.model, timeoutMs }));
|
|
239
|
+
}
|
|
240
|
+
throw e;
|
|
241
|
+
}
|
|
156
242
|
try {
|
|
157
243
|
const refined = parseBriefJson(content);
|
|
158
244
|
// Never let the model drop fonts/googleHref from a rich seed
|
|
@@ -168,7 +254,13 @@ ${opts.inspireSummaries.map((s, i) => `(${i + 1}) ${s}`).join('\n') || '(none)'}
|
|
|
168
254
|
elevatedCards: refined.elevatedCards ?? opts.seed.elevatedCards
|
|
169
255
|
};
|
|
170
256
|
} catch (e) {
|
|
171
|
-
|
|
257
|
+
const detail = e instanceof Error ? e.message : String(e);
|
|
258
|
+
if (opts.strictParse) {
|
|
259
|
+
throw new Error(
|
|
260
|
+
`Ollama returned a brief we could not parse (${detail}). First 200 chars: ${content.slice(0, 200)}`
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
console.warn(`import: brief parse failed (${detail}); using seed brief`);
|
|
172
264
|
return opts.seed;
|
|
173
265
|
}
|
|
174
266
|
}
|
|
@@ -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
|
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stamp a dated Markdown post into a site's posts/ folder.
|
|
3
|
+
*
|
|
4
|
+
* filepress new "Post title"
|
|
5
|
+
* filepress new "Post title" --site demo
|
|
6
|
+
* filepress new "Post title" --draft
|
|
7
|
+
*/
|
|
8
|
+
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
9
|
+
import { dirname, join, resolve } from 'node:path';
|
|
10
|
+
import { fileURLToPath } from 'node:url';
|
|
11
|
+
import { slugify } from '../packages/core/src/lib/content/parse.ts';
|
|
12
|
+
|
|
13
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
14
|
+
const packageRoot = resolve(here, '..');
|
|
15
|
+
|
|
16
|
+
export type NewPostArgs = {
|
|
17
|
+
title: string;
|
|
18
|
+
site: string | null;
|
|
19
|
+
root: string | null;
|
|
20
|
+
draft: boolean;
|
|
21
|
+
help: boolean;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export function parseNewArgs(argv: string[]): NewPostArgs {
|
|
25
|
+
const args: NewPostArgs = { title: '', site: null, root: null, draft: false, help: false };
|
|
26
|
+
const titleParts: string[] = [];
|
|
27
|
+
for (let i = 0; i < argv.length; i++) {
|
|
28
|
+
const a = argv[i];
|
|
29
|
+
if (a === '--help' || a === '-h') args.help = true;
|
|
30
|
+
else if (a === '--draft') args.draft = true;
|
|
31
|
+
else if (a === '--site') args.site = argv[++i] ?? '';
|
|
32
|
+
else if (a?.startsWith('--site=')) args.site = a.slice('--site='.length);
|
|
33
|
+
else if (a === '--root') args.root = argv[++i] ?? '';
|
|
34
|
+
else if (a?.startsWith('--root=')) args.root = a.slice('--root='.length);
|
|
35
|
+
else if (a.startsWith('-')) throw new Error(`unknown flag: ${a}`);
|
|
36
|
+
else titleParts.push(a);
|
|
37
|
+
}
|
|
38
|
+
args.title = titleParts.join(' ').trim().replace(/^["']|["']$/g, '');
|
|
39
|
+
return args;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function resolveNewSiteRoot(args: Pick<NewPostArgs, 'site' | 'root'>, cwd = process.cwd()): string {
|
|
43
|
+
if (args.root) return resolve(cwd, args.root);
|
|
44
|
+
if (args.site) return join(packageRoot, 'sites', args.site);
|
|
45
|
+
return resolve(cwd);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function isoDate(now = new Date()): string {
|
|
49
|
+
return now.toISOString().slice(0, 10);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function newPostFilename(title: string, date: string): { slug: string; filename: string } {
|
|
53
|
+
const slug = slugify(title);
|
|
54
|
+
if (!slug) throw new Error('title did not produce a slug');
|
|
55
|
+
return { slug, filename: `${date}-${slug}.md` };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function renderNewPost(title: string, date: string, draft: boolean): string {
|
|
59
|
+
const draftLine = draft ? 'draft: true\n' : '';
|
|
60
|
+
return `---
|
|
61
|
+
title: ${JSON.stringify(title)}
|
|
62
|
+
date: ${date}
|
|
63
|
+
${draftLine}description: ""
|
|
64
|
+
tags: []
|
|
65
|
+
---
|
|
66
|
+
|
|
67
|
+
`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function writeNewPost(
|
|
71
|
+
siteRoot: string,
|
|
72
|
+
title: string,
|
|
73
|
+
opts: { draft?: boolean; now?: Date } = {}
|
|
74
|
+
): { path: string; filename: string } {
|
|
75
|
+
const date = isoDate(opts.now);
|
|
76
|
+
const { filename } = newPostFilename(title, date);
|
|
77
|
+
const postsDir = join(siteRoot, 'posts');
|
|
78
|
+
mkdirSync(postsDir, { recursive: true });
|
|
79
|
+
const dest = join(postsDir, filename);
|
|
80
|
+
if (existsSync(dest)) throw new Error(`already exists: posts/${filename}`);
|
|
81
|
+
writeFileSync(dest, renderNewPost(title, date, Boolean(opts.draft)));
|
|
82
|
+
return { path: dest, filename };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function usage(): string {
|
|
86
|
+
return `Usage: filepress new "Post title" [--draft] [--site name | --root path]
|
|
87
|
+
|
|
88
|
+
Writes posts/YYYY-MM-DD-slug.md next to filepress.config.ts.
|
|
89
|
+
Does not commit or deploy.`;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function main(argv = process.argv.slice(2)): number {
|
|
93
|
+
let args: NewPostArgs;
|
|
94
|
+
try {
|
|
95
|
+
args = parseNewArgs(argv);
|
|
96
|
+
} catch (err) {
|
|
97
|
+
console.error(`filepress new: ${err instanceof Error ? err.message : err}`);
|
|
98
|
+
return 1;
|
|
99
|
+
}
|
|
100
|
+
if (args.help) {
|
|
101
|
+
console.log(usage());
|
|
102
|
+
return 0;
|
|
103
|
+
}
|
|
104
|
+
if (!args.title) {
|
|
105
|
+
console.error(usage());
|
|
106
|
+
return 1;
|
|
107
|
+
}
|
|
108
|
+
const siteRoot = resolveNewSiteRoot(args);
|
|
109
|
+
if (!existsSync(join(siteRoot, 'filepress.config.ts'))) {
|
|
110
|
+
console.error(`filepress new: no filepress.config.ts in ${siteRoot}`);
|
|
111
|
+
return 1;
|
|
112
|
+
}
|
|
113
|
+
try {
|
|
114
|
+
const written = writeNewPost(siteRoot, args.title, { draft: args.draft });
|
|
115
|
+
console.log(`Wrote posts/${written.filename}`);
|
|
116
|
+
return 0;
|
|
117
|
+
} catch (err) {
|
|
118
|
+
console.error(`filepress new: ${err instanceof Error ? err.message : err}`);
|
|
119
|
+
return 1;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (process.argv[1] && resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url))) {
|
|
124
|
+
process.exitCode = main();
|
|
125
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Serve a FilePress `build/` folder the way a static host does:
|
|
3
|
+
* pretty URLs (`/about` → `about.html`) and `404.html` for unknown paths.
|
|
4
|
+
*
|
|
5
|
+
* Usage: node scripts/preview.mjs <buildDir> [port] [host]
|
|
6
|
+
*/
|
|
7
|
+
import { createServer } from 'node:http';
|
|
8
|
+
import { existsSync, readFileSync, statSync } from 'node:fs';
|
|
9
|
+
import { extname, join, resolve, sep } from 'node:path';
|
|
10
|
+
import { fileURLToPath } from 'node:url';
|
|
11
|
+
|
|
12
|
+
const MIME = {
|
|
13
|
+
'.html': 'text/html; charset=utf-8',
|
|
14
|
+
'.css': 'text/css; charset=utf-8',
|
|
15
|
+
'.js': 'text/javascript; charset=utf-8',
|
|
16
|
+
'.mjs': 'text/javascript; charset=utf-8',
|
|
17
|
+
'.json': 'application/json',
|
|
18
|
+
'.svg': 'image/svg+xml',
|
|
19
|
+
'.xml': 'application/xml',
|
|
20
|
+
'.txt': 'text/plain; charset=utf-8',
|
|
21
|
+
'.woff2': 'font/woff2',
|
|
22
|
+
'.woff': 'font/woff',
|
|
23
|
+
'.png': 'image/png',
|
|
24
|
+
'.jpg': 'image/jpeg',
|
|
25
|
+
'.jpeg': 'image/jpeg',
|
|
26
|
+
'.webp': 'image/webp',
|
|
27
|
+
'.gif': 'image/gif',
|
|
28
|
+
'.ico': 'image/x-icon',
|
|
29
|
+
'.map': 'application/json'
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const isMain =
|
|
33
|
+
Boolean(process.argv[1]) && resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url));
|
|
34
|
+
|
|
35
|
+
function underRoot(root, abs) {
|
|
36
|
+
return abs === root || abs.startsWith(root + sep);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Map a request path to a file under `root`, or null. */
|
|
40
|
+
export function resolveBuildFile(root, urlPath) {
|
|
41
|
+
const pathname = decodeURIComponent((urlPath.split('?')[0] || '/'));
|
|
42
|
+
const rel = pathname.replace(/^\/+/, '');
|
|
43
|
+
const abs = resolve(root, rel);
|
|
44
|
+
if (!underRoot(root, abs)) return null;
|
|
45
|
+
try {
|
|
46
|
+
const st = statSync(abs);
|
|
47
|
+
if (st.isFile()) return abs;
|
|
48
|
+
if (st.isDirectory()) {
|
|
49
|
+
const index = join(abs, 'index.html');
|
|
50
|
+
if (existsSync(index)) return index;
|
|
51
|
+
}
|
|
52
|
+
} catch {
|
|
53
|
+
/* pretty URL or sibling .html next to an empty adapter dir */
|
|
54
|
+
}
|
|
55
|
+
if (!extname(abs) && existsSync(`${abs}.html`)) return `${abs}.html`;
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function send(res, file, status = 200) {
|
|
60
|
+
const type = MIME[extname(file).toLowerCase()] || 'application/octet-stream';
|
|
61
|
+
res.writeHead(status, { 'content-type': type, 'cache-control': 'no-store' });
|
|
62
|
+
res.end(readFileSync(file));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function startPreview(root, port = 27777, host = '127.0.0.1') {
|
|
66
|
+
const site = resolve(root);
|
|
67
|
+
if (!existsSync(join(site, 'index.html'))) {
|
|
68
|
+
throw new Error(`filepress preview: no index.html in ${site}`);
|
|
69
|
+
}
|
|
70
|
+
const fallback = join(site, '404.html');
|
|
71
|
+
return createServer((req, res) => {
|
|
72
|
+
const file = resolveBuildFile(site, req.url || '/');
|
|
73
|
+
if (file && existsSync(file)) {
|
|
74
|
+
send(res, file);
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
if (existsSync(fallback)) {
|
|
78
|
+
send(res, fallback, 404);
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' });
|
|
82
|
+
res.end('Not found');
|
|
83
|
+
}).listen(port, host, () => {
|
|
84
|
+
console.log(`filepress: preview http://${host}:${port} → ${site}`);
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (isMain) {
|
|
89
|
+
try {
|
|
90
|
+
startPreview(process.argv[2] ?? '', Number(process.argv[3] ?? 27777), process.argv[4] ?? '127.0.0.1');
|
|
91
|
+
} catch (err) {
|
|
92
|
+
console.error(err instanceof Error ? err.message : err);
|
|
93
|
+
process.exit(1);
|
|
94
|
+
}
|
|
95
|
+
}
|
package/packages/app/README.md
DELETED
|
@@ -1,19 +0,0 @@
|
|
|
1
|
-
# @filepress/app
|
|
2
|
-
|
|
3
|
-
The only SvelteKit application in the filepress monorepo. All routes, layouts, and
|
|
4
|
-
Kit wiring live here. Content sites under [`../../sites`](../../sites) provide:
|
|
5
|
-
|
|
6
|
-
- `filepress.config.ts` — identity
|
|
7
|
-
- `posts/` — Markdown
|
|
8
|
-
- `static/` — favicon, images (optional)
|
|
9
|
-
|
|
10
|
-
## Run against a site
|
|
11
|
-
|
|
12
|
-
From the repo root (do not run vite directly without `FILEPRESS_SITE_ROOT`):
|
|
13
|
-
|
|
14
|
-
```bash
|
|
15
|
-
pnpm filepress dev --site demo
|
|
16
|
-
pnpm filepress build --site demo
|
|
17
|
-
```
|
|
18
|
-
|
|
19
|
-
Build output is written to `sites/<name>/build/`.
|
package/packages/core/README.md
DELETED
|
@@ -1,29 +0,0 @@
|
|
|
1
|
-
# @filepress/core
|
|
2
|
-
|
|
3
|
-
The reusable filepress engine. Sites under [`../../sites`](../../sites) depend on
|
|
4
|
-
it (via `workspace:*` in this monorepo) and provide their own SvelteKit routes,
|
|
5
|
-
content, and `filepress.config.ts`.
|
|
6
|
-
|
|
7
|
-
## Entry points
|
|
8
|
-
|
|
9
|
-
- `@filepress/core` — client-safe: `PostCard`, `PostIndex`, `Newsletter`,
|
|
10
|
-
`SiteHeader`, `SiteFooter`, `defineFilepressConfig`, `absoluteUrl`,
|
|
11
|
-
`formatDate`, and shared types.
|
|
12
|
-
- `@filepress/core/server` — server-only (filesystem access): `createContent`,
|
|
13
|
-
`renderMarkdown`, `buildRssXml` / `buildSitemapXml` / `buildRobotsTxt`, and the
|
|
14
|
-
content-parsing primitives. Import only from `+page.server.ts`, `+server.ts`,
|
|
15
|
-
or `*.server.ts` modules.
|
|
16
|
-
- `@filepress/core/theme` — self-hosted fonts + the Essay theme CSS. Import once
|
|
17
|
-
from a site's root layout.
|
|
18
|
-
|
|
19
|
-
## Why routes live in the site
|
|
20
|
-
|
|
21
|
-
SvelteKit's router is per-project, so each site owns its `src/routes/`. Those
|
|
22
|
-
route files stay thin: they call `createContent(...)` + core builders and render
|
|
23
|
-
core components. `scripts/create-site.mjs` scaffolds them.
|
|
24
|
-
|
|
25
|
-
## Testing
|
|
26
|
-
|
|
27
|
-
`pnpm --filter @filepress/core test` runs the unit tests over the pure parsing
|
|
28
|
-
and figure-transform logic. Type-checking of the whole library happens through
|
|
29
|
-
each site's `svelte-check`.
|