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.
Files changed (34) hide show
  1. package/README.md +13 -4
  2. package/package.json +6 -3
  3. package/packages/app/src/lib/genie/GeniePanel.svelte +290 -21
  4. package/packages/app/src/lib/genie/ops.ts +11 -1
  5. package/packages/app/src/lib/genie/store.ts +54 -1
  6. package/packages/app/src/lib/theme-entry.ts +2 -1
  7. package/packages/app/src/routes/+error.svelte +38 -0
  8. package/packages/app/src/routes/posts/[slug]/+page.svelte +2 -1
  9. package/packages/app/src/site-theme.d.ts +3 -0
  10. package/packages/app/vite-plugin-genie.ts +45 -1
  11. package/packages/app/vite.config.ts +38 -7
  12. package/packages/core/src/lib/components/PostCard.svelte +2 -1
  13. package/packages/core/src/lib/config.ts +47 -1
  14. package/packages/core/src/lib/content/parse.ts +2 -0
  15. package/packages/core/src/lib/content/types.ts +2 -0
  16. package/packages/core/src/lib/format.ts +11 -0
  17. package/packages/core/src/lib/index.ts +7 -3
  18. package/packages/core/src/lib/redirects.ts +88 -0
  19. package/packages/core/src/lib/server.ts +9 -2
  20. package/packages/core/src/lib/styles/presets/essay.css +2 -0
  21. package/packages/core/src/lib/styles/presets/folio.css +27 -0
  22. package/packages/core/src/lib/styles/presets/ink.css +28 -0
  23. package/packages/core/src/lib/styles/theme.css +37 -0
  24. package/packages/import/src/cli.ts +1 -0
  25. package/packages/import/src/extract.ts +15 -2
  26. package/packages/import/src/ir.ts +2 -0
  27. package/packages/import/src/ollama.ts +332 -77
  28. package/packages/import/src/redirects.ts +17 -0
  29. package/packages/import/src/write-site.ts +25 -3
  30. package/scripts/copy-path-mounts.mjs +30 -1
  31. package/scripts/create-site.mjs +1 -0
  32. package/scripts/filepress.mjs +24 -14
  33. package/scripts/new-post.ts +125 -0
  34. package/scripts/preview.mjs +95 -0
@@ -11,7 +11,7 @@ import {
11
11
  runInspire,
12
12
  scanOllamaHosts
13
13
  } from './src/lib/genie/ops.ts';
14
- import { deleteVersion, listVersions } from './src/lib/genie/store.ts';
14
+ import { deleteVersion, duplicateVersion, listVersions, updateVersionMeta } from './src/lib/genie/store.ts';
15
15
 
16
16
  function readBody(req: IncomingMessage): Promise<string> {
17
17
  return new Promise((resolve, reject) => {
@@ -160,8 +160,51 @@ export function geniePlugin(siteRoot: string): Plugin {
160
160
  );
161
161
  }
162
162
 
163
+ if (method === 'POST' && path === '/__filepress/genie/star') {
164
+ const body = JSON.parse(await readBody(req));
165
+ if (!body.versionId) {
166
+ return sendJson(res, 400, { error: '`versionId` required' });
167
+ }
168
+ if (typeof body.starred !== 'boolean') {
169
+ return sendJson(res, 400, { error: '`starred` boolean required' });
170
+ }
171
+ return sendJson(res, 200, {
172
+ version: updateVersionMeta(siteRoot, body.versionId, { starred: body.starred })
173
+ });
174
+ }
175
+
176
+ if (method === 'POST' && path === '/__filepress/genie/label') {
177
+ const body = JSON.parse(await readBody(req));
178
+ if (!body.versionId) {
179
+ return sendJson(res, 400, { error: '`versionId` required' });
180
+ }
181
+ if (typeof body.label !== 'string') {
182
+ return sendJson(res, 400, { error: '`label` string required' });
183
+ }
184
+ return sendJson(res, 200, {
185
+ version: updateVersionMeta(siteRoot, body.versionId, { label: body.label })
186
+ });
187
+ }
188
+
189
+ if (method === 'POST' && path === '/__filepress/genie/duplicate') {
190
+ const body = JSON.parse(await readBody(req));
191
+ if (!body.versionId) {
192
+ return sendJson(res, 400, { error: '`versionId` required' });
193
+ }
194
+ return sendJson(res, 200, {
195
+ version: duplicateVersion(
196
+ siteRoot,
197
+ body.versionId,
198
+ typeof body.label === 'string' ? body.label : undefined
199
+ )
200
+ });
201
+ }
202
+
163
203
  if (method === 'POST' && path === '/__filepress/genie/delete') {
164
204
  const body = JSON.parse(await readBody(req));
205
+ if (!body.versionId) {
206
+ return sendJson(res, 400, { error: '`versionId` required' });
207
+ }
165
208
  deleteVersion(siteRoot, body.versionId);
166
209
  return sendJson(res, 200, { ok: true });
167
210
  }
@@ -169,6 +212,7 @@ export function geniePlugin(siteRoot: string): Plugin {
169
212
  return sendJson(res, 404, { error: `Unknown Genie route: ${method} ${path}` });
170
213
  } catch (e) {
171
214
  const message = e instanceof Error ? e.message : String(e);
215
+ console.error(`filepress genie: ${method} ${path} failed: ${message}`);
172
216
  return sendJson(res, 500, { error: message });
173
217
  }
174
218
  });
@@ -13,6 +13,8 @@ import { pathMountsPlugin } from './vite-plugin-path-mounts.ts';
13
13
  import { filepressLeaseName, localberthGet } from './localberth-port.ts';
14
14
  import type { PathMount } from '../core/src/lib/paths-shared.ts';
15
15
  import { unexpectedUnseenPrerenderRoutes } from './src/lib/prerender-empty-ok.ts';
16
+ import { buildRedirectRules, THEME_PRESETS, type ThemePreset } from '../core/src/lib/config.ts';
17
+ import { serializeRedirects, type RedirectRule } from '../core/src/lib/redirects.ts';
16
18
 
17
19
  const appRoot = dirname(fileURLToPath(import.meta.url));
18
20
  const defaultSiteRoot = resolve(appRoot, '../../sites/demo');
@@ -53,8 +55,15 @@ const coreConfig = join(appRoot, '../core/src/lib/config.ts');
53
55
  const coreServer = join(appRoot, '../core/src/lib/server.ts');
54
56
  const coreTheme = join(appRoot, '../core/src/lib/theme.ts');
55
57
 
56
- /** Load `paths` via a short-lived Vite SSR graph (config-only getfilepress alias). */
57
- async function loadPathMounts(): Promise<PathMount[]> {
58
+ type SiteHints = {
59
+ paths: PathMount[];
60
+ theme: ThemePreset;
61
+ redirects: RedirectRule[];
62
+ };
63
+
64
+ /** Load config via a short-lived Vite SSR graph (config-only getfilepress alias). */
65
+ async function loadSiteHints(): Promise<SiteHints> {
66
+ const fallback: SiteHints = { paths: [], theme: 'essay', redirects: [] };
58
67
  const temp = await createServer({
59
68
  configFile: false,
60
69
  root: siteRoot,
@@ -73,17 +82,35 @@ async function loadPathMounts(): Promise<PathMount[]> {
73
82
  });
74
83
  try {
75
84
  const mod = await temp.ssrLoadModule(pathToFileURL(siteConfig).href);
76
- const paths = mod.default?.paths;
77
- return Array.isArray(paths) ? (paths as PathMount[]) : [];
85
+ const cfg = mod.default as {
86
+ paths?: PathMount[];
87
+ theme?: ThemePreset;
88
+ homePage?: string | null;
89
+ redirects?: RedirectRule[];
90
+ } | null;
91
+ const theme = cfg?.theme && THEME_PRESETS.includes(cfg.theme) ? cfg.theme : 'essay';
92
+ return {
93
+ paths: Array.isArray(cfg?.paths) ? cfg.paths : [],
94
+ theme,
95
+ redirects: buildRedirectRules({
96
+ homePage: cfg?.homePage ?? null,
97
+ redirects: Array.isArray(cfg?.redirects) ? cfg.redirects : []
98
+ })
99
+ };
78
100
  } catch (err) {
79
101
  const detail = err instanceof Error ? err.message : String(err);
80
- console.warn(`filepress: could not load path mounts (${detail}); continuing with none.`);
81
- return [];
102
+ console.warn(`filepress: could not load site config (${detail}); continuing with defaults.`);
103
+ return fallback;
82
104
  } finally {
83
105
  await temp.close();
84
106
  }
85
107
  }
86
108
 
109
+ function resolveSitePreset(theme: ThemePreset): string {
110
+ const file = join(appRoot, '../core/src/lib/styles/presets', `${theme}.css`);
111
+ return existsSync(file) ? file : join(appRoot, '../core/src/lib/styles/presets/essay.css');
112
+ }
113
+
87
114
  function resolvePort(): number | undefined {
88
115
  const raw = process.env.FILEPRESS_PORT?.trim();
89
116
  if (raw) {
@@ -96,11 +123,14 @@ function resolvePort(): number | undefined {
96
123
  const fixedPort = resolvePort();
97
124
 
98
125
  export default defineConfig(async () => {
99
- const pathMounts = await loadPathMounts();
126
+ const hints = await loadSiteHints();
127
+ const pathMounts = hints.paths;
128
+ const sitePreset = resolveSitePreset(hints.theme);
100
129
  writeFileSync(
101
130
  join(filepressCache, 'path-mounts.json'),
102
131
  `${JSON.stringify(pathMounts, null, '\t')}\n`
103
132
  );
133
+ writeFileSync(join(filepressCache, 'redirects.txt'), serializeRedirects(hints.redirects));
104
134
 
105
135
  const host = process.env.HOST?.trim() || '127.0.0.1';
106
136
  return {
@@ -134,6 +164,7 @@ export default defineConfig(async () => {
134
164
  'getfilepress/theme': coreTheme,
135
165
  '$site-config': siteConfig,
136
166
  '$site-theme': siteTheme,
167
+ '$site-preset': sitePreset,
137
168
  '$critical-theme': criticalThemeOut
138
169
  },
139
170
  paths: {
@@ -1,6 +1,6 @@
1
1
  <script lang="ts">
2
2
  import type { PostMeta } from '../content/types';
3
- import { formatDate } from '../format';
3
+ import { formatDate, formatReadingTime } from '../format';
4
4
 
5
5
  let {
6
6
  post,
@@ -18,6 +18,7 @@
18
18
  {#if post.author}
19
19
  &middot; <span class="byline">{post.author}</span>
20
20
  {/if}
21
+ &middot; <span class="reading-time">{formatReadingTime(post.readingMinutes)}</span>
21
22
  </p>
22
23
  <h2 class="post-title"><a href="/posts/{post.slug}">{post.title}</a></h2>
23
24
  {#if post.description}
@@ -8,7 +8,12 @@
8
8
  * server code.
9
9
  */
10
10
  import { normalizePathMounts, type PathMount } from './paths-shared';
11
+ import { writingPostRedirects, type RedirectRule } from './redirects';
11
12
  export type { PathMount } from './paths-shared';
13
+ export type { RedirectRule } from './redirects';
14
+
15
+ export const THEME_PRESETS = ['essay', 'ink', 'folio'] as const;
16
+ export type ThemePreset = (typeof THEME_PRESETS)[number];
12
17
  export interface NewsletterConfig {
13
18
  /** Full URL to an external signup form (Buttondown, Substack, etc.). */
14
19
  url: string;
@@ -72,6 +77,10 @@ export interface SiteConfig {
72
77
  * FilePress does not parse or theme mount contents.
73
78
  */
74
79
  paths: PathMount[];
80
+ /** Named token sheet loaded after Essay, before the site `theme.css`. */
81
+ theme: ThemePreset;
82
+ /** Extra Cloudflare/Netlify `_redirects` lines merged at build. */
83
+ redirects: RedirectRule[];
75
84
  }
76
85
 
77
86
  /** What a site author supplies; everything but `title` and `url` is optional. */
@@ -95,6 +104,10 @@ export interface SiteConfigInput {
95
104
  newsletter?: NewsletterConfig | null;
96
105
  /** Mount site-relative dirs at URL prefixes (docs shells, etc.). */
97
106
  paths?: PathMount[];
107
+ /** Built-in token preset. Default `essay`. Site `theme.css` still wins last. */
108
+ theme?: ThemePreset;
109
+ /** Extra `_redirects` rules (from → to). */
110
+ redirects?: RedirectRule[];
98
111
  }
99
112
 
100
113
  const defaultFooterLinks: NavItem[] = [
@@ -178,10 +191,43 @@ export function defineFilepressConfig(input: SiteConfigInput): SiteConfig {
178
191
  footerLinks: normalizeNavItems(input.footerLinks) ?? [...defaultFooterLinks],
179
192
  topics: input.topics ?? [],
180
193
  newsletter: input.newsletter ?? null,
181
- paths: normalizePathMounts(input.paths)
194
+ paths: normalizePathMounts(input.paths),
195
+ theme: normalizeTheme(input.theme),
196
+ redirects: normalizeRedirects(input.redirects)
182
197
  };
183
198
  }
184
199
 
200
+ /** Engine + site rules to write into `build/_redirects`. */
201
+ export function buildRedirectRules(site: Pick<SiteConfig, 'homePage' | 'redirects'>): RedirectRule[] {
202
+ return [...(site.homePage ? writingPostRedirects() : []), ...site.redirects];
203
+ }
204
+
205
+ function normalizeTheme(theme: ThemePreset | undefined): ThemePreset {
206
+ const name = (theme ?? 'essay').trim().toLowerCase();
207
+ if (!THEME_PRESETS.includes(name as ThemePreset)) {
208
+ throw new Error(
209
+ `filepress.config: \`theme\` must be ${THEME_PRESETS.join(', ')} (got "${theme}").`
210
+ );
211
+ }
212
+ return name as ThemePreset;
213
+ }
214
+
215
+ function normalizeRedirects(rules: RedirectRule[] | undefined): RedirectRule[] {
216
+ if (!rules?.length) return [];
217
+ return rules.map((rule) => {
218
+ const from = (rule.from ?? '').trim();
219
+ const to = (rule.to ?? '').trim();
220
+ if (!from || !to) {
221
+ throw new Error('filepress.config: redirects need non-empty `from` and `to`.');
222
+ }
223
+ const status = rule.status ?? 301;
224
+ if (status !== 301 && status !== 302 && status !== 308) {
225
+ throw new Error(`filepress.config: redirect status must be 301, 302, or 308 (got ${status}).`);
226
+ }
227
+ return { from, to, status };
228
+ });
229
+ }
230
+
185
231
  /** Join the site origin with a path, guarding against double slashes. */
186
232
  export function absoluteUrl(site: Pick<SiteConfig, 'url'>, path: string): string {
187
233
  const base = site.url.replace(/\/+$/, '');
@@ -1,4 +1,5 @@
1
1
  import matter from 'gray-matter';
2
+ import { readingMinutes } from '../format';
2
3
  import type { PageSource, PostSource, RawFrontmatter, RawPageFrontmatter } from './types';
3
4
  import { RESERVED_PAGE_SLUGS } from './types';
4
5
 
@@ -135,6 +136,7 @@ export function parsePost(path: string, raw: string): PostSource {
135
136
  author,
136
137
  draft,
137
138
  sourcePath: path,
139
+ readingMinutes: readingMinutes(parsed.content),
138
140
  body: parsed.content
139
141
  };
140
142
  }
@@ -29,6 +29,8 @@ export interface PostMeta {
29
29
  draft: boolean;
30
30
  /** Source file path relative to the repo root, e.g. "/posts/foo.md". */
31
31
  sourcePath: string;
32
+ /** Whole minutes at ~228 wpm, computed from the Markdown body. */
33
+ readingMinutes: number;
32
34
  }
33
35
 
34
36
  /** A post's metadata plus its raw (uncompiled) Markdown body. */
@@ -23,3 +23,14 @@ export function formatDate(iso: string): string {
23
23
  const [y, m, d] = iso.split('-').map(Number);
24
24
  return `${d} ${MONTHS[m - 1]} ${y}`;
25
25
  }
26
+
27
+ /** ~228 wpm (adult silent reading). Always at least one minute for a published post. */
28
+ export function readingMinutes(body: string, wordsPerMinute = 228): number {
29
+ const words = body.trim().split(/\s+/).filter(Boolean).length;
30
+ if (words === 0) return 1;
31
+ return Math.max(1, Math.round(words / wordsPerMinute));
32
+ }
33
+
34
+ export function formatReadingTime(minutes: number): string {
35
+ return minutes === 1 ? '1 min read' : `${minutes} min read`;
36
+ }
@@ -12,7 +12,9 @@ export {
12
12
  defineFilepressConfig,
13
13
  absoluteUrl,
14
14
  ogImageUrl,
15
- postsIndexPath
15
+ postsIndexPath,
16
+ buildRedirectRules,
17
+ THEME_PRESETS
16
18
  } from './config';
17
19
  export type {
18
20
  SiteConfig,
@@ -21,11 +23,13 @@ export type {
21
23
  Topic,
22
24
  NavItem,
23
25
  NavIconName,
24
- PathMount
26
+ PathMount,
27
+ ThemePreset,
28
+ RedirectRule
25
29
  } from './config';
26
30
 
27
31
  export { isPathMountHref } from './paths-shared';
28
32
 
29
- export { formatDate } from './format';
33
+ export { formatDate, formatReadingTime, readingMinutes } from './format';
30
34
 
31
35
  export type { PostMeta, PostSource, RenderedPost, RawFrontmatter } from './content/types';
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Cloudflare Pages / Netlify `_redirects` lines.
3
+ * Code owns the file shape; sites and import only supply from/to pairs.
4
+ */
5
+ export type RedirectStatus = 301 | 302 | 308;
6
+
7
+ export type RedirectRule = {
8
+ from: string;
9
+ to: string;
10
+ status: RedirectStatus;
11
+ };
12
+
13
+ const STATUS = new Set<RedirectStatus>([301, 302, 308]);
14
+
15
+ export function normalizeRedirectPath(path: string): string {
16
+ const trimmed = path.trim();
17
+ if (!trimmed) throw new Error('redirect path cannot be empty');
18
+ if (trimmed.startsWith('http://') || trimmed.startsWith('https://')) {
19
+ const url = new URL(trimmed);
20
+ const out = url.pathname || '/';
21
+ return out === '/' ? '/' : out.replace(/\/+$/, '') || '/';
22
+ }
23
+ const withSlash = trimmed.startsWith('/') ? trimmed : `/${trimmed}`;
24
+ if (withSlash.includes('*') || withSlash.includes(':splat')) return withSlash;
25
+ return withSlash === '/' ? '/' : withSlash.replace(/\/+$/, '') || '/';
26
+ }
27
+
28
+ export function serializeRedirects(rules: RedirectRule[]): string {
29
+ const lines = rules.map((r) => `${r.from} ${r.to} ${r.status}`);
30
+ return `${lines.join('\n')}\n`;
31
+ }
32
+
33
+ export function parseRedirectsFile(text: string): RedirectRule[] {
34
+ const rules: RedirectRule[] = [];
35
+ for (const raw of text.split(/\r?\n/)) {
36
+ const line = raw.replace(/#.*$/, '').trim();
37
+ if (!line) continue;
38
+ const parts = line.split(/\s+/);
39
+ if (parts.length < 2) continue;
40
+ const status = Number(parts[2] ?? 301);
41
+ if (!STATUS.has(status as RedirectStatus)) continue;
42
+ rules.push({
43
+ from: parts[0],
44
+ to: parts[1],
45
+ status: status as RedirectStatus
46
+ });
47
+ }
48
+ return rules;
49
+ }
50
+
51
+ export function mergeRedirects(existing: string, extra: RedirectRule[]): string {
52
+ const have = new Set(parseRedirectsFile(existing).map((r) => `${r.from}\0${r.to}`));
53
+ const add = extra.filter((r) => !have.has(`${r.from}\0${r.to}`));
54
+ if (add.length === 0) return existing.endsWith('\n') ? existing : `${existing}\n`;
55
+ const prefix = existing.trimEnd();
56
+ const block = serializeRedirects(add);
57
+ return prefix ? `${prefix}\n${block}` : block;
58
+ }
59
+
60
+ /** When `/` is a landing page, old `/writing` indexes used to hold the post list. */
61
+ export function writingPostRedirects(): RedirectRule[] {
62
+ return [
63
+ { from: '/writing', to: '/posts', status: 308 },
64
+ { from: '/writing/*', to: '/posts/:splat', status: 301 }
65
+ ];
66
+ }
67
+
68
+ export function redirectsFromSourceUrls(
69
+ pairs: Array<{ sourceUrl: string; destPath: string }>
70
+ ): RedirectRule[] {
71
+ const rules: RedirectRule[] = [];
72
+ const seen = new Set<string>();
73
+ for (const pair of pairs) {
74
+ let from: string;
75
+ try {
76
+ from = normalizeRedirectPath(pair.sourceUrl);
77
+ } catch {
78
+ continue;
79
+ }
80
+ const to = pair.destPath.startsWith('/') ? pair.destPath : `/${pair.destPath}`;
81
+ if (from === to || from === '/') continue;
82
+ const key = `${from}\0${to}`;
83
+ if (seen.has(key)) continue;
84
+ seen.add(key);
85
+ rules.push({ from, to, status: 301 });
86
+ }
87
+ return rules;
88
+ }
@@ -20,8 +20,15 @@ export {
20
20
  filenameOf
21
21
  } from './content/parse';
22
22
 
23
- export { absoluteUrl, ogImageUrl } from './config';
24
- export type { SiteConfig, PathMount } from './config';
23
+ export { absoluteUrl, ogImageUrl, buildRedirectRules } from './config';
24
+ export type { SiteConfig, PathMount, ThemePreset, RedirectRule } from './config';
25
+ export {
26
+ serializeRedirects,
27
+ parseRedirectsFile,
28
+ mergeRedirects,
29
+ writingPostRedirects,
30
+ redirectsFromSourceUrls
31
+ } from './redirects';
25
32
  export { defaultSecurityHeaders, writeBuildHeaders, mergeSecurityHeaders } from './headers';
26
33
  export type { MergeSecurityHeadersResult } from './headers';
27
34
  export {
@@ -0,0 +1,2 @@
1
+ /* Essay is the default FilePress look. This sheet is a no-op placeholder so
2
+ `$site-preset` always resolves. */
@@ -0,0 +1,27 @@
1
+ /* Warmer folio: more margin, amber accent. Tokens only. */
2
+ :root {
3
+ --bg: #f7f1e6;
4
+ --surface: #fffdf8;
5
+ --ink: #1c1812;
6
+ --ink-soft: #5a5146;
7
+ --ink-faint: #8a7f70;
8
+ --rule: #e6dcc8;
9
+ --rule-strong: #d2c4a8;
10
+ --accent: #8a4b12;
11
+ --accent-strong: #6b3910;
12
+ --gap: clamp(1.5rem, 5vw, 3rem);
13
+ }
14
+
15
+ @media (prefers-color-scheme: dark) {
16
+ :root {
17
+ --bg: #16130e;
18
+ --surface: #1f1b14;
19
+ --ink: #f3ead8;
20
+ --ink-soft: #c4b59a;
21
+ --ink-faint: #8e826c;
22
+ --rule: #322c22;
23
+ --rule-strong: #443b2d;
24
+ --accent: #e0a05a;
25
+ --accent-strong: #f0c08a;
26
+ }
27
+ }
@@ -0,0 +1,28 @@
1
+ /* Cooler, slightly tighter cousin of Essay. Tokens only — HTML stays the same. */
2
+ :root {
3
+ --bg: #f3f4f2;
4
+ --surface: #ffffff;
5
+ --ink: #141513;
6
+ --ink-soft: #454843;
7
+ --ink-faint: #767a73;
8
+ --rule: #dce0d8;
9
+ --rule-strong: #c5cbbf;
10
+ --accent: #1c3d5a;
11
+ --accent-strong: #132b40;
12
+ --measure: 38rem;
13
+ --measure-wide: 44rem;
14
+ }
15
+
16
+ @media (prefers-color-scheme: dark) {
17
+ :root {
18
+ --bg: #101210;
19
+ --surface: #181a17;
20
+ --ink: #e8ebe4;
21
+ --ink-soft: #a8ada4;
22
+ --ink-faint: #7a7f76;
23
+ --rule: #272a25;
24
+ --rule-strong: #353930;
25
+ --accent: #8eb6d4;
26
+ --accent-strong: #b0cce3;
27
+ }
28
+ }
@@ -59,6 +59,7 @@
59
59
 
60
60
  html {
61
61
  -webkit-text-size-adjust: 100%;
62
+ overflow-x: clip;
62
63
  }
63
64
 
64
65
  body {
@@ -773,6 +774,20 @@ hr {
773
774
  }
774
775
 
775
776
  @media (max-width: 34rem) {
777
+ body {
778
+ font-size: 1.08rem;
779
+ line-height: 1.65;
780
+ }
781
+ .site-logo {
782
+ height: 3.1rem;
783
+ }
784
+ .site-title {
785
+ font-size: 1.28rem;
786
+ }
787
+ .site-nav {
788
+ flex-wrap: wrap;
789
+ gap: 0.75rem 1.1rem;
790
+ }
776
791
  .post-nav {
777
792
  grid-template-columns: 1fr;
778
793
  }
@@ -780,3 +795,25 @@ hr {
780
795
  text-align: left;
781
796
  }
782
797
  }
798
+
799
+ .error-page {
800
+ text-align: center;
801
+ font-style: normal;
802
+ padding: 4rem 0;
803
+ }
804
+ .error-page h1 {
805
+ font-size: 1.65rem;
806
+ margin: 0.35rem 0 0.75rem;
807
+ }
808
+ .error-code {
809
+ font-family: var(--font-sans);
810
+ font-size: 0.72rem;
811
+ letter-spacing: 0.16em;
812
+ text-transform: uppercase;
813
+ color: var(--ink-faint);
814
+ margin: 0;
815
+ }
816
+ .error-actions {
817
+ margin-top: 1.5rem;
818
+ font-style: normal;
819
+ }
@@ -359,6 +359,7 @@ Options:
359
359
  nav: ir.nav,
360
360
  topics: ir.topics,
361
361
  lede: ir.lede,
362
+ homeMarkdown: Boolean(ir.homeMarkdown),
362
363
  notes: ir.notes,
363
364
  images: imagePlan.chosen,
364
365
  unsplashQueries: imagePlan.unsplashQueries
@@ -199,6 +199,7 @@ export async function extractSite(discovered: DiscoverResult): Promise<SiteIR> {
199
199
  let description = '';
200
200
  let author = title;
201
201
  let lede: string | null = null;
202
+ let homeMarkdown: string | null = null;
202
203
  let generator: string | null = null;
203
204
 
204
205
  if (homeDoc) {
@@ -230,7 +231,13 @@ export async function extractSite(discovered: DiscoverResult): Promise<SiteIR> {
230
231
  lede = sentence && sentence.length > 80 ? sentence : `${cut.replace(/\s+\S*$/, '').trim()}…`;
231
232
  }
232
233
  }
233
- notes.push('Home bio mapped to config `lede` (posts remain the index). Long bio → pages/about.md when present.');
234
+ const homeMd = htmlToMarkdown(clone.innerHTML).trim();
235
+ if (homeMd.length > 200 && paragraphs.length >= 2) {
236
+ homeMarkdown = homeMd;
237
+ notes.push('Home bio is long enough for pages/home.md; the post index moves to /posts.');
238
+ } else {
239
+ notes.push('Home bio mapped to config `lede` (posts remain the index).');
240
+ }
234
241
  }
235
242
 
236
243
  const posts: SiteIRPost[] = [];
@@ -334,7 +341,12 @@ export async function extractSite(discovered: DiscoverResult): Promise<SiteIR> {
334
341
  tag
335
342
  }));
336
343
 
337
- const nav: Array<{ label: string; href: string }> = [{ label: 'Posts', href: '/' }];
344
+ const nav: Array<{ label: string; href: string }> = homeMarkdown
345
+ ? [
346
+ { label: 'Home', href: '/' },
347
+ { label: 'Posts', href: '/posts' }
348
+ ]
349
+ : [{ label: 'Posts', href: '/' }];
338
350
  for (const page of pages) {
339
351
  nav.push({
340
352
  label: page.title,
@@ -372,6 +384,7 @@ export async function extractSite(discovered: DiscoverResult): Promise<SiteIR> {
372
384
  nav,
373
385
  topics,
374
386
  lede,
387
+ homeMarkdown,
375
388
  notes,
376
389
  assets: [...new Set(assets)]
377
390
  };
@@ -36,6 +36,8 @@ export type SiteIR = {
36
36
  topics: Array<{ label: string; tag: string }>;
37
37
  /** Suggested lede for the filepress index (from home bio), or null. */
38
38
  lede: string | null;
39
+ /** Full home bio as Markdown when it is long enough to be `pages/home.md`. */
40
+ homeMarkdown: string | null;
39
41
  /** Notes for the import report (URL remaps, skips). */
40
42
  notes: string[];
41
43
  /** Same-origin chrome assets to copy into static/ (favicons, etc.). */