getfilepress 0.0.0 → 0.1.1

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 (84) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +178 -4
  3. package/package.json +89 -7
  4. package/packages/app/package.json +31 -0
  5. package/packages/app/src/app.d.ts +12 -0
  6. package/packages/app/src/app.html +12 -0
  7. package/packages/app/src/critical-theme.d.ts +4 -0
  8. package/packages/app/src/lib/content.server.ts +8 -0
  9. package/packages/app/src/lib/empty-theme.css +1 -0
  10. package/packages/app/src/lib/genie/GenieHost.svelte +16 -0
  11. package/packages/app/src/lib/genie/GeniePanel.svelte +429 -0
  12. package/packages/app/src/lib/genie/ops.ts +237 -0
  13. package/packages/app/src/lib/genie/store.ts +217 -0
  14. package/packages/app/src/lib/genie/types.ts +61 -0
  15. package/packages/app/src/lib/pages.server.ts +7 -0
  16. package/packages/app/src/lib/site.server.ts +52 -0
  17. package/packages/app/src/lib/theme-entry.ts +7 -0
  18. package/packages/app/src/routes/+layout.svelte +29 -0
  19. package/packages/app/src/routes/+layout.ts +10 -0
  20. package/packages/app/src/routes/+page.server.ts +28 -0
  21. package/packages/app/src/routes/+page.svelte +67 -0
  22. package/packages/app/src/routes/[slug]/+page.server.ts +20 -0
  23. package/packages/app/src/routes/[slug]/+page.svelte +47 -0
  24. package/packages/app/src/routes/page/[n]/+page.server.ts +21 -0
  25. package/packages/app/src/routes/page/[n]/+page.svelte +36 -0
  26. package/packages/app/src/routes/posts/[slug]/+page.server.ts +24 -0
  27. package/packages/app/src/routes/posts/[slug]/+page.svelte +95 -0
  28. package/packages/app/src/routes/robots.txt/+server.ts +11 -0
  29. package/packages/app/src/routes/rss.xml/+server.ts +13 -0
  30. package/packages/app/src/routes/sitemap.xml/+server.ts +19 -0
  31. package/packages/app/src/routes/tags/+page.server.ts +4 -0
  32. package/packages/app/src/routes/tags/+page.svelte +26 -0
  33. package/packages/app/src/routes/tags/[tag]/+page.server.ts +15 -0
  34. package/packages/app/src/routes/tags/[tag]/+page.svelte +23 -0
  35. package/packages/app/src/routes/topics/+page.server.ts +28 -0
  36. package/packages/app/src/routes/topics/+page.svelte +47 -0
  37. package/packages/app/src/routes/writing/+page.server.ts +12 -0
  38. package/packages/app/src/routes/writing/+page.svelte +38 -0
  39. package/packages/app/src/site-theme.d.ts +2 -0
  40. package/packages/app/static/.gitkeep +0 -0
  41. package/packages/app/tsconfig.json +15 -0
  42. package/packages/app/vite-plugin-critical-theme.ts +70 -0
  43. package/packages/app/vite-plugin-genie.ts +114 -0
  44. package/packages/app/vite.config.ts +141 -0
  45. package/packages/core/package.json +51 -0
  46. package/packages/core/src/lib/assets/favicon.svg +1 -0
  47. package/packages/core/src/lib/components/Newsletter.svelte +13 -0
  48. package/packages/core/src/lib/components/PostCard.svelte +34 -0
  49. package/packages/core/src/lib/components/PostIndex.svelte +86 -0
  50. package/packages/core/src/lib/components/SiteFooter.svelte +15 -0
  51. package/packages/core/src/lib/components/SiteHeader.svelte +28 -0
  52. package/packages/core/src/lib/config.ts +154 -0
  53. package/packages/core/src/lib/content/content.ts +193 -0
  54. package/packages/core/src/lib/content/feeds.ts +102 -0
  55. package/packages/core/src/lib/content/markdown.ts +78 -0
  56. package/packages/core/src/lib/content/pages.ts +103 -0
  57. package/packages/core/src/lib/content/parse.ts +214 -0
  58. package/packages/core/src/lib/content/rehype-figure.ts +79 -0
  59. package/packages/core/src/lib/content/types.ts +88 -0
  60. package/packages/core/src/lib/format.ts +25 -0
  61. package/packages/core/src/lib/index.ts +27 -0
  62. package/packages/core/src/lib/server.ts +33 -0
  63. package/packages/core/src/lib/styles/fonts.css +72 -0
  64. package/packages/core/src/lib/styles/theme.css +762 -0
  65. package/packages/core/src/lib/theme.ts +5 -0
  66. package/packages/import/package.json +27 -0
  67. package/packages/import/src/cli.ts +346 -0
  68. package/packages/import/src/discover.ts +190 -0
  69. package/packages/import/src/extract.ts +378 -0
  70. package/packages/import/src/fetch.ts +63 -0
  71. package/packages/import/src/html-to-md.ts +21 -0
  72. package/packages/import/src/images.ts +247 -0
  73. package/packages/import/src/inspire.ts +417 -0
  74. package/packages/import/src/ir.ts +109 -0
  75. package/packages/import/src/ollama.ts +145 -0
  76. package/packages/import/src/stock.ts +218 -0
  77. package/packages/import/src/theme.ts +478 -0
  78. package/packages/import/src/write-site.ts +371 -0
  79. package/packages/import/tsconfig.json +13 -0
  80. package/pnpm-workspace.yaml +2 -0
  81. package/scripts/create-site.mjs +260 -0
  82. package/scripts/filepress.mjs +273 -0
  83. package/scripts/link-embedded-packages.mjs +40 -0
  84. package/scripts/postinstall.mjs +56 -0
@@ -0,0 +1,141 @@
1
+ import adapter from '@sveltejs/adapter-static';
2
+ import { sveltekit } from '@sveltejs/kit/vite';
3
+ import { defineConfig } from 'vite';
4
+ import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
5
+ import { dirname, join, resolve } from 'node:path';
6
+ import { fileURLToPath } from 'node:url';
7
+ import {
8
+ criticalThemePlugin,
9
+ writeCriticalThemeModule
10
+ } from './vite-plugin-critical-theme.ts';
11
+ import { geniePlugin } from './vite-plugin-genie.ts';
12
+
13
+ const appRoot = dirname(fileURLToPath(import.meta.url));
14
+ const defaultSiteRoot = resolve(appRoot, '../../sites/demo');
15
+
16
+ function resolveSiteRoot(): string {
17
+ const raw = process.env.FILEPRESS_SITE_ROOT?.trim();
18
+ return raw ? resolve(raw) : defaultSiteRoot;
19
+ }
20
+
21
+ const siteRoot = resolveSiteRoot();
22
+ const siteConfig = join(siteRoot, 'filepress.config.ts');
23
+ const siteStatic = join(siteRoot, 'static');
24
+ const siteBuild = join(siteRoot, 'build');
25
+ const fallbackStatic = join(appRoot, 'static');
26
+
27
+ // SvelteKit requires an assets directory to exist. Prefer the site's static/;
28
+ // fall back to packages/app/static for sites that haven't added one yet.
29
+ const assetsDir = existsSync(siteStatic) ? siteStatic : fallbackStatic;
30
+ if (!existsSync(fallbackStatic)) mkdirSync(fallbackStatic, { recursive: true });
31
+
32
+ if (!existsSync(siteConfig)) {
33
+ throw new Error(`No filepress.config.ts at site root: ${siteConfig}`);
34
+ }
35
+
36
+ /**
37
+ * Prefer site-root theme.css so Genie activate can overwrite a real file Vite watches.
38
+ * Create an empty theme.css when absent (not the app stub path).
39
+ */
40
+ function resolveSiteTheme(): string {
41
+ const css = join(siteRoot, 'theme.css');
42
+ if (existsSync(css)) return css;
43
+ writeFileSync(css, '/* filepress site theme — edit freely or use Genie Mode in dev */\n');
44
+ return css;
45
+ }
46
+
47
+ const siteTheme = resolveSiteTheme();
48
+ const filepressCache = join(siteRoot, '.filepress');
49
+ if (!existsSync(filepressCache)) mkdirSync(filepressCache, { recursive: true });
50
+ const criticalThemeOut = join(filepressCache, 'critical-theme.generated.ts');
51
+ writeCriticalThemeModule(siteTheme, criticalThemeOut);
52
+
53
+ const coreEntry = join(appRoot, '../core/src/lib/index.ts');
54
+ const coreServer = join(appRoot, '../core/src/lib/server.ts');
55
+ const coreTheme = join(appRoot, '../core/src/lib/theme.ts');
56
+
57
+ /** Optional fixed port from `filepress dev|preview --port` (via FILEPRESS_PORT). */
58
+ function resolvePort(): number | undefined {
59
+ const raw = process.env.FILEPRESS_PORT?.trim();
60
+ if (!raw) return undefined;
61
+ const n = Number(raw);
62
+ return Number.isInteger(n) && n > 0 && n <= 65535 ? n : undefined;
63
+ }
64
+
65
+ const fixedPort = resolvePort();
66
+
67
+ export default defineConfig({
68
+ server: fixedPort
69
+ ? { port: fixedPort, strictPort: true }
70
+ : undefined,
71
+ preview: fixedPort
72
+ ? { port: fixedPort, strictPort: true }
73
+ : undefined,
74
+ // Regex aliases (exact) so npm installs don't need workspace links, and so a
75
+ // bare `@filepress/core` string alias can't steal `/server` + `/theme`.
76
+ resolve: {
77
+ alias: [
78
+ { find: /^@filepress\/core\/server$/, replacement: coreServer },
79
+ { find: /^@filepress\/core\/theme$/, replacement: coreTheme },
80
+ { find: /^@filepress\/core$/, replacement: coreEntry }
81
+ ]
82
+ },
83
+ plugins: [
84
+ criticalThemePlugin(siteTheme, criticalThemeOut),
85
+ geniePlugin(siteRoot),
86
+ sveltekit({
87
+ alias: {
88
+ // Site configs (monorepo or linked) import from `getfilepress`.
89
+ getfilepress: coreEntry,
90
+ 'getfilepress/server': coreServer,
91
+ 'getfilepress/theme': coreTheme,
92
+ '$site-config': siteConfig,
93
+ // Loaded after the core Essay theme so site rules win the cascade.
94
+ '$site-theme': siteTheme,
95
+ // Per-site critical tokens (written under site/.filepress/).
96
+ '$critical-theme': criticalThemeOut
97
+ },
98
+
99
+ // Absolute `/_app/...` asset URLs — more reliable on CDN/custom domains
100
+ // than `./_app/...` relative links (avoids unstyled flashes on deploy).
101
+ paths: {
102
+ relative: false
103
+ },
104
+
105
+ files: {
106
+ assets: assetsDir
107
+ },
108
+
109
+ compilerOptions: {
110
+ runes: ({ filename }) =>
111
+ filename.split(/[/\\]/).includes('node_modules') ? undefined : true
112
+ },
113
+
114
+ adapter: adapter({
115
+ pages: siteBuild,
116
+ assets: siteBuild,
117
+ fallback: '404.html',
118
+ strict: true
119
+ }),
120
+
121
+ prerender: {
122
+ entries: ['*'],
123
+
124
+ // Some parameterized routes legitimately have zero pages: /page/[n]
125
+ // when everything fits on page 1, /tags/[tag] when no listed post
126
+ // carries a tag, and /[slug] itself when every post is still
127
+ // draft:true (a site before its first published essay). Ignore
128
+ // those; stay strict for any other unseen route.
129
+ handleUnseenRoutes: ({ routes }) => {
130
+ const emptyOk = new Set(['/page/[n]', '/tags/[tag]', '/[slug]']);
131
+ const unexpected = routes.filter((r) => !emptyOk.has(r));
132
+ if (unexpected.length > 0) {
133
+ throw new Error(
134
+ `Routes marked prerenderable but not prerendered: ${unexpected.join(', ')}`
135
+ );
136
+ }
137
+ }
138
+ }
139
+ })
140
+ ]
141
+ });
@@ -0,0 +1,51 @@
1
+ {
2
+ "name": "@filepress/core",
3
+ "version": "0.1.1",
4
+ "private": true,
5
+ "type": "module",
6
+ "description": "Reusable engine for filepress sites: content loader, Markdown pipeline, feed/sitemap builders, config helper, Essay theme, and shared Svelte components.",
7
+ "svelte": "./src/lib/index.ts",
8
+ "types": "./src/lib/index.ts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./src/lib/index.ts",
12
+ "svelte": "./src/lib/index.ts",
13
+ "default": "./src/lib/index.ts"
14
+ },
15
+ "./server": {
16
+ "types": "./src/lib/server.ts",
17
+ "default": "./src/lib/server.ts"
18
+ },
19
+ "./theme": {
20
+ "default": "./src/lib/theme.ts"
21
+ },
22
+ "./assets/*": "./src/lib/assets/*"
23
+ },
24
+ "scripts": {
25
+ "test": "vitest run",
26
+ "test:watch": "vitest"
27
+ },
28
+ "dependencies": {
29
+ "@fontsource-variable/inter": "^5.2.8",
30
+ "@fontsource-variable/newsreader": "^5.2.10",
31
+ "gray-matter": "^4.0.3",
32
+ "highlight.js": "^11.11.1",
33
+ "rehype-autolink-headings": "^7.1.0",
34
+ "rehype-highlight": "^7.0.2",
35
+ "rehype-raw": "^7.0.0",
36
+ "rehype-slug": "^6.0.0",
37
+ "rehype-stringify": "^10.0.1",
38
+ "remark-gfm": "^4.0.1",
39
+ "remark-parse": "^11.0.0",
40
+ "remark-rehype": "^11.1.2",
41
+ "unified": "^11.0.5"
42
+ },
43
+ "peerDependencies": {
44
+ "svelte": "^5.0.0"
45
+ },
46
+ "devDependencies": {
47
+ "@types/node": "^26.1.0",
48
+ "svelte": "^5.56.1",
49
+ "vitest": "^4.1.9"
50
+ }
51
+ }
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="107" height="128" viewBox="0 0 107 128"><title>svelte-logo</title><path d="M94.157 22.819c-10.4-14.885-30.94-19.297-45.792-9.835L22.282 29.608A29.92 29.92 0 0 0 8.764 49.65a31.5 31.5 0 0 0 3.108 20.231 30 30 0 0 0-4.477 11.183 31.9 31.9 0 0 0 5.448 24.116c10.402 14.887 30.942 19.297 45.791 9.835l26.083-16.624A29.92 29.92 0 0 0 98.235 78.35a31.53 31.53 0 0 0-3.105-20.232 30 30 0 0 0 4.474-11.182 31.88 31.88 0 0 0-5.447-24.116" style="fill:#ff3e00"/><path d="M45.817 106.582a20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.503 18 18 0 0 1 .624-2.435l.49-1.498 1.337.981a33.6 33.6 0 0 0 10.203 5.098l.97.294-.09.968a5.85 5.85 0 0 0 1.052 3.878 6.24 6.24 0 0 0 6.695 2.485 5.8 5.8 0 0 0 1.603-.704L69.27 76.28a5.43 5.43 0 0 0 2.45-3.631 5.8 5.8 0 0 0-.987-4.371 6.24 6.24 0 0 0-6.698-2.487 5.7 5.7 0 0 0-1.6.704l-9.953 6.345a19 19 0 0 1-5.296 2.326 20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.502 17.99 17.99 0 0 1 8.13-12.052l26.081-16.623a19 19 0 0 1 5.3-2.329 20.72 20.72 0 0 1 22.237 8.243 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-.624 2.435l-.49 1.498-1.337-.98a33.6 33.6 0 0 0-10.203-5.1l-.97-.294.09-.968a5.86 5.86 0 0 0-1.052-3.878 6.24 6.24 0 0 0-6.696-2.485 5.8 5.8 0 0 0-1.602.704L37.73 51.72a5.42 5.42 0 0 0-2.449 3.63 5.79 5.79 0 0 0 .986 4.372 6.24 6.24 0 0 0 6.698 2.486 5.8 5.8 0 0 0 1.602-.704l9.952-6.342a19 19 0 0 1 5.295-2.328 20.72 20.72 0 0 1 22.237 8.242 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-8.13 12.053l-26.081 16.622a19 19 0 0 1-5.3 2.328" style="fill:#fff"/></svg>
@@ -0,0 +1,13 @@
1
+ <script lang="ts">
2
+ import type { NewsletterConfig } from '../config';
3
+
4
+ let { newsletter }: { newsletter: NewsletterConfig | null } = $props();
5
+ </script>
6
+
7
+ {#if newsletter}
8
+ <aside class="newsletter">
9
+ <h2>Subscribe</h2>
10
+ <p>{newsletter.blurb}</p>
11
+ <a class="button" href={newsletter.url} rel="noopener">{newsletter.cta}</a>
12
+ </aside>
13
+ {/if}
@@ -0,0 +1,34 @@
1
+ <script lang="ts">
2
+ import type { PostMeta } from '../content/types';
3
+ import { formatDate } from '../format';
4
+
5
+ let {
6
+ post,
7
+ featured = false,
8
+ showTags = true
9
+ }: { post: PostMeta; featured?: boolean; showTags?: boolean } = $props();
10
+ </script>
11
+
12
+ <article class="post-card" class:featured>
13
+ <p class="meta">
14
+ {#if post.draft}
15
+ <span class="draft-label">Draft</span>
16
+ {/if}
17
+ <time datetime={post.date}>{formatDate(post.date)}</time>
18
+ {#if post.author}
19
+ &middot; <span class="byline">{post.author}</span>
20
+ {/if}
21
+ </p>
22
+ <h2 class="post-title"><a href="/posts/{post.slug}">{post.title}</a></h2>
23
+ {#if post.description}
24
+ <p class="excerpt">{post.description}</p>
25
+ {/if}
26
+ <a class="read-more" href="/posts/{post.slug}">Read more &rarr;</a>
27
+ {#if showTags && post.tags.length}
28
+ <ul class="tag-list card-tags">
29
+ {#each post.tags as tag (tag)}
30
+ <li><a href="/tags/{tag}">{tag}</a></li>
31
+ {/each}
32
+ </ul>
33
+ {/if}
34
+ </article>
@@ -0,0 +1,86 @@
1
+ <script lang="ts">
2
+ import type { PostMeta } from '../content/types';
3
+ import type { SiteConfig } from '../config';
4
+ import PostCard from './PostCard.svelte';
5
+ import Newsletter from './Newsletter.svelte';
6
+
7
+ let {
8
+ site,
9
+ hero = false,
10
+ featured = null,
11
+ posts,
12
+ page = 1,
13
+ totalPages = 1,
14
+ /** Page-1 URL for the post index (`/` or `/writing`). */
15
+ indexHref = '/'
16
+ }: {
17
+ site: SiteConfig;
18
+ hero?: boolean;
19
+ featured?: PostMeta | null;
20
+ posts: PostMeta[];
21
+ page?: number;
22
+ totalPages?: number;
23
+ indexHref?: string;
24
+ } = $props();
25
+
26
+ const hasContent = $derived(featured !== null || posts.length > 0);
27
+ const prevHref = $derived(page <= 2 ? indexHref : `/page/${page - 1}`);
28
+ const nextHref = $derived(`/page/${page + 1}`);
29
+ </script>
30
+
31
+ {#if hero}
32
+ <!-- The masthead carries the site identity (title/logo plus tagline). The
33
+ index adds an optional one-line lede; a hidden h1 keeps semantics/SEO. -->
34
+ <header class="hero" class:visually-hidden={!site.lede}>
35
+ <h1 class="visually-hidden">{site.title}</h1>
36
+ {#if site.lede}
37
+ <p class="hero-lede">{site.lede}</p>
38
+ {/if}
39
+ </header>
40
+ {:else}
41
+ <header class="post-header">
42
+ {#if page <= 1}
43
+ <h1>Posts</h1>
44
+ {:else}
45
+ <p class="eyebrow">Posts</p>
46
+ <h1>Page {page}</h1>
47
+ {/if}
48
+ </header>
49
+ {/if}
50
+
51
+ {#if !hasContent}
52
+ <p class="empty-state">No posts yet. Add a Markdown file to <code>/posts/</code> and push.</p>
53
+ {:else}
54
+ {#if featured}
55
+ <div class="featured">
56
+ <p class="eyebrow">Latest</p>
57
+ <PostCard post={featured} featured />
58
+ </div>
59
+ {/if}
60
+
61
+ {#if posts.length}
62
+ <ul class="post-list">
63
+ {#each posts as post (post.slug)}
64
+ <li><PostCard {post} /></li>
65
+ {/each}
66
+ </ul>
67
+ {/if}
68
+
69
+ {#if totalPages > 1}
70
+ <nav class="pager" aria-label="Pagination">
71
+ {#if page > 1}
72
+ <a href={prevHref} rel="prev">&larr; Newer</a>
73
+ {:else}
74
+ <span></span>
75
+ {/if}
76
+ <span class="page-count">Page {page} of {totalPages}</span>
77
+ {#if page < totalPages}
78
+ <a href={nextHref} rel="next">Older &rarr;</a>
79
+ {:else}
80
+ <span></span>
81
+ {/if}
82
+ </nav>
83
+ {/if}
84
+ {/if}
85
+
86
+ <Newsletter newsletter={site.newsletter} />
@@ -0,0 +1,15 @@
1
+ <script lang="ts">
2
+ import type { SiteConfig } from '../config';
3
+
4
+ let { site, year = new Date().getFullYear() }: { site: SiteConfig; year?: number } = $props();
5
+ </script>
6
+
7
+ <footer class="site-footer">
8
+ <div class="wrap">
9
+ <span>&copy; {year} {site.author}</span>
10
+ <span class="footer-links">
11
+ <a href="/rss.xml">RSS</a>
12
+ <a href="/topics">Topics</a>
13
+ </span>
14
+ </div>
15
+ </footer>
@@ -0,0 +1,28 @@
1
+ <script lang="ts">
2
+ import type { SiteConfig } from '../config';
3
+
4
+ let { site }: { site: SiteConfig } = $props();
5
+ </script>
6
+
7
+ <header class="site-header">
8
+ <div class="wrap">
9
+ <div class="site-id">
10
+ <a class="site-title" href="/">
11
+ {#if site.logo}
12
+ <img class="site-logo" src={site.logo} alt="" />
13
+ {/if}
14
+ <span class="site-brand-copy">
15
+ <span class="site-wordmark">{site.title}</span>
16
+ {#if site.tagline}
17
+ <span class="site-tagline">{site.tagline}</span>
18
+ {/if}
19
+ </span>
20
+ </a>
21
+ </div>
22
+ <nav class="site-nav" aria-label="Primary">
23
+ {#each site.nav as item (item.href)}
24
+ <a href={item.href}>{item.label}</a>
25
+ {/each}
26
+ </nav>
27
+ </div>
28
+ </header>
@@ -0,0 +1,154 @@
1
+ /**
2
+ * filepress site configuration — the single per-site config surface.
3
+ *
4
+ * A site declares its identity in `filepress.config.ts` at the site root via
5
+ * `defineFilepressConfig({...})`. Core never reads environment variables for
6
+ * identity; the site owns that (and may read env in its own config file). This
7
+ * keeps `@filepress/core` app-agnostic and importable from both client and
8
+ * server code.
9
+ */
10
+ export interface NewsletterConfig {
11
+ /** Full URL to an external signup form (Buttondown, Substack, etc.). */
12
+ url: string;
13
+ /** One-line pitch shown above the button. */
14
+ blurb: string;
15
+ /** Button label. */
16
+ cta: string;
17
+ }
18
+
19
+ /** A curated topic surfaced in the Explore/Topics view, mapped to a tag. */
20
+ export interface Topic {
21
+ label: string;
22
+ tag: string;
23
+ }
24
+
25
+ export interface NavItem {
26
+ label: string;
27
+ href: string;
28
+ }
29
+
30
+ /** Fully-resolved site configuration (after defaults are applied). */
31
+ export interface SiteConfig {
32
+ title: string;
33
+ description: string;
34
+ tagline: string;
35
+ /** One-line lede shown on the index hero, or null for no visible hero. */
36
+ lede: string | null;
37
+ /** Path to a masthead logo image (site-relative, e.g. "/logo.png"), or null for text. */
38
+ logo: string | null;
39
+ /**
40
+ * Absolute or site-relative Open Graph image (e.g. "/logo.png").
41
+ * Defaults to `logo` when unset.
42
+ */
43
+ ogImage: string | null;
44
+ /** Canonical origin, no trailing slash, e.g. "https://example.com". */
45
+ url: string;
46
+ author: string;
47
+ /** Posts per page on the index (after the featured one). */
48
+ postsPerPage: number;
49
+ /**
50
+ * When set, `/` renders this static page slug (`pages/<slug>.md`) and the
51
+ * chronological post index moves to `/writing`.
52
+ */
53
+ homePage: string | null;
54
+ nav: NavItem[];
55
+ topics: Topic[];
56
+ newsletter: NewsletterConfig | null;
57
+ }
58
+
59
+ /** What a site author supplies; everything but `title` and `url` is optional. */
60
+ export interface SiteConfigInput {
61
+ title: string;
62
+ url: string;
63
+ description?: string;
64
+ tagline?: string;
65
+ lede?: string;
66
+ logo?: string;
67
+ /** Open Graph image path; defaults to `logo`. */
68
+ ogImage?: string;
69
+ author?: string;
70
+ postsPerPage?: number;
71
+ /** Static page slug to show at `/` (post index then lives at `/writing`). */
72
+ homePage?: string;
73
+ nav?: NavItem[];
74
+ topics?: Topic[];
75
+ newsletter?: NewsletterConfig | null;
76
+ }
77
+
78
+ /** Path to page 1 of the chronological post index. */
79
+ export function postsIndexPath(site: Pick<SiteConfig, 'homePage'>): string {
80
+ return site.homePage ? '/writing' : '/';
81
+ }
82
+
83
+ /**
84
+ * Validate and normalize a site's config, applying defaults. Fails loudly if a
85
+ * required field is missing (edge case 19) so a misconfigured site never builds
86
+ * a broken feed or canonical URL silently.
87
+ */
88
+ export function defineFilepressConfig(input: SiteConfigInput): SiteConfig {
89
+ if (!input || typeof input !== 'object') {
90
+ throw new Error('defineFilepressConfig: expected a config object.');
91
+ }
92
+ const title = (input.title ?? '').trim();
93
+ if (!title) throw new Error('filepress.config: `title` is required.');
94
+ const url = (input.url ?? '').trim();
95
+ if (!url) throw new Error('filepress.config: `url` is required.');
96
+ if (!/^https?:\/\//.test(url)) {
97
+ throw new Error(`filepress.config: \`url\` must start with http(s):// (got "${url}").`);
98
+ }
99
+
100
+ const description = (input.description ?? '').trim();
101
+ const homePage = (input.homePage ?? '').trim() || null;
102
+ if (homePage && !/^[a-z0-9]+(?:-[a-z0-9]+)*$/i.test(homePage)) {
103
+ throw new Error(
104
+ `filepress.config: \`homePage\` must be a simple slug (got "${homePage}").`
105
+ );
106
+ }
107
+
108
+ const defaultNav: NavItem[] = homePage
109
+ ? [
110
+ { label: 'Home', href: '/' },
111
+ { label: 'Posts', href: '/writing' },
112
+ { label: 'Topics', href: '/topics' }
113
+ ]
114
+ : [
115
+ { label: 'Posts', href: '/' },
116
+ { label: 'Topics', href: '/topics' }
117
+ ];
118
+
119
+ return {
120
+ title,
121
+ description,
122
+ tagline: (input.tagline ?? '').trim() || description || title,
123
+ lede: (input.lede ?? '').trim() || null,
124
+ logo: (input.logo ?? '').trim() || null,
125
+ ogImage:
126
+ (input.ogImage ?? '').trim() || (input.logo ?? '').trim() || null,
127
+ url: url.replace(/\/+$/, ''),
128
+ author: (input.author ?? '').trim() || title,
129
+ postsPerPage:
130
+ Number.isFinite(input.postsPerPage) && (input.postsPerPage as number) > 0
131
+ ? Math.floor(input.postsPerPage as number)
132
+ : 10,
133
+ homePage,
134
+ nav: input.nav && input.nav.length ? input.nav : defaultNav,
135
+ topics: input.topics ?? [],
136
+ newsletter: input.newsletter ?? null
137
+ };
138
+ }
139
+
140
+ /** Join the site origin with a path, guarding against double slashes. */
141
+ export function absoluteUrl(site: Pick<SiteConfig, 'url'>, path: string): string {
142
+ const base = site.url.replace(/\/+$/, '');
143
+ const suffix = path.startsWith('/') ? path : `/${path}`;
144
+ return `${base}${suffix}`;
145
+ }
146
+
147
+ /** Absolute URL for Open Graph / Twitter cards, or null when unset. */
148
+ export function ogImageUrl(
149
+ site: Pick<SiteConfig, 'url' | 'ogImage'>
150
+ ): string | null {
151
+ if (!site.ogImage) return null;
152
+ if (/^https?:\/\//i.test(site.ogImage)) return site.ogImage;
153
+ return absoluteUrl(site, site.ogImage);
154
+ }