getfilepress 0.0.0 → 0.1.0

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,193 @@
1
+ import { readdirSync, readFileSync } from 'node:fs';
2
+ import { isAbsolute, join, resolve } from 'node:path';
3
+ import type { PostMeta, PostSource, RenderedPost } from './types';
4
+ import { assertUniqueSlugs, ContentError, normalizeTag, parsePost } from './parse';
5
+ import { renderMarkdown } from './markdown';
6
+
7
+ export interface ContentApi {
8
+ loadPostSources(): PostSource[];
9
+ /** Production truth: non-draft, date ≤ today. Feeds and sitemap use this. */
10
+ getPublishedPosts(): PostMeta[];
11
+ /**
12
+ * What the index / tags / topics show. Same as published in production;
13
+ * includes drafts under `pnpm dev` (or `FILEPRESS_SHOW_DRAFTS=1`).
14
+ */
15
+ getListedPosts(): PostMeta[];
16
+ listsDrafts(): boolean;
17
+ getIndexPage(
18
+ page: number,
19
+ perPage: number
20
+ ): { featured: PostMeta | null; posts: PostMeta[]; page: number; totalPages: number };
21
+ getIndexPageCount(perPage: number): number;
22
+ getBuildableSlugs(): string[];
23
+ getRenderedPost(slug: string): Promise<RenderedPost | null>;
24
+ getAllTags(): { tag: string; count: number }[];
25
+ getPostsByTag(tag: string): PostMeta[];
26
+ getAdjacentPosts(slug: string): { older: PostMeta | null; newer: PostMeta | null };
27
+ }
28
+
29
+ export interface CreateContentOptions {
30
+ contentDir: string;
31
+ /** Override draft listing. Default: on in Vite DEV, or when FILEPRESS_SHOW_DRAFTS is 1/true. */
32
+ listDrafts?: boolean;
33
+ }
34
+
35
+ /**
36
+ * Whether listings should include `draft: true` posts. Production builds stay
37
+ * closed unless FILEPRESS_SHOW_DRAFTS forces them open (rare; mainly for checks).
38
+ */
39
+ export function resolveListDrafts(override?: boolean): boolean {
40
+ if (override !== undefined) return override;
41
+ const env = process.env.FILEPRESS_SHOW_DRAFTS?.trim();
42
+ if (env === '1' || env === 'true') return true;
43
+ if (env === '0' || env === 'false') return false;
44
+ return Boolean(import.meta.env.DEV);
45
+ }
46
+
47
+ /**
48
+ * Build a content API bound to one content directory. This is the seam a site
49
+ * wires up in a server-only module: `createContent({ contentDir: 'posts' })`.
50
+ *
51
+ * Reads the filesystem, so it must only be imported from server code
52
+ * (`+page.server.ts`, `+server.ts`, or a `*.server.ts` lib module). The pure
53
+ * parsing/validation logic lives in `parse.ts` and is safe to import anywhere.
54
+ */
55
+ export function createContent(opts: CreateContentOptions): ContentApi {
56
+ const dir = isAbsolute(opts.contentDir)
57
+ ? opts.contentDir
58
+ : resolve(process.cwd(), opts.contentDir);
59
+ const listDrafts = resolveListDrafts(opts.listDrafts);
60
+
61
+ // One prerender pass per build, so cache parsed posts in production; re-read
62
+ // each time in dev so edits show on refresh.
63
+ let cache: PostSource[] | null = null;
64
+
65
+ function loadPostSources(): PostSource[] {
66
+ if (cache && import.meta.env.PROD) return cache;
67
+
68
+ let filenames: string[];
69
+ try {
70
+ filenames = readdirSync(dir).filter((f) => f.toLowerCase().endsWith('.md'));
71
+ } catch (e: unknown) {
72
+ const detail = e instanceof Error ? e.message : String(e);
73
+ throw new ContentError(`Could not read content directory "${dir}": ${detail}`);
74
+ }
75
+
76
+ const posts = filenames
77
+ .sort((a, b) => a.localeCompare(b))
78
+ .map((name) => parsePost(`/${name}`, readFileSync(join(dir, name), 'utf8')));
79
+
80
+ assertUniqueSlugs(posts);
81
+ cache = posts;
82
+ return posts;
83
+ }
84
+
85
+ const today = () => new Date().toISOString().slice(0, 10);
86
+ const isPublished = (post: PostSource, now: string) => !post.draft && post.date <= now;
87
+ const byDateDesc = (a: PostMeta, b: PostMeta) =>
88
+ a.date < b.date ? 1 : a.date > b.date ? -1 : a.slug.localeCompare(b.slug);
89
+ const toMeta = (post: PostSource): PostMeta => {
90
+ const { body: _body, ...meta } = post;
91
+ return meta;
92
+ };
93
+
94
+ function getPublishedPosts(): PostMeta[] {
95
+ const now = today();
96
+ return loadPostSources()
97
+ .filter((p) => isPublished(p, now))
98
+ .sort(byDateDesc)
99
+ .map(toMeta);
100
+ }
101
+
102
+ function getListedPosts(): PostMeta[] {
103
+ const now = today();
104
+ return loadPostSources()
105
+ .filter((p) => {
106
+ if (isPublished(p, now)) return true;
107
+ // Drafts show in local listings regardless of date so future-dated
108
+ // work-in-progress is previewable. Future *published* posts stay hidden.
109
+ return listDrafts && p.draft;
110
+ })
111
+ .sort(byDateDesc)
112
+ .map(toMeta);
113
+ }
114
+
115
+ function getIndexPage(page: number, perPage: number) {
116
+ const listed = getListedPosts();
117
+ const featured = listed[0] ?? null;
118
+ const rest = listed.slice(1);
119
+ const size = Math.max(1, Math.floor(perPage));
120
+ const totalPages = Math.max(1, Math.ceil(rest.length / size));
121
+ const current = Math.min(Math.max(1, Math.floor(page)), totalPages);
122
+ const start = (current - 1) * size;
123
+ return {
124
+ featured: current === 1 ? featured : null,
125
+ posts: rest.slice(start, start + size),
126
+ page: current,
127
+ totalPages
128
+ };
129
+ }
130
+
131
+ function getIndexPageCount(perPage: number): number {
132
+ const rest = Math.max(0, getListedPosts().length - 1);
133
+ return Math.max(1, Math.ceil(rest / Math.max(1, Math.floor(perPage))));
134
+ }
135
+
136
+ function getBuildableSlugs(): string[] {
137
+ const now = today();
138
+ return loadPostSources()
139
+ .filter((p) => p.draft || p.date <= now)
140
+ .map((p) => p.slug);
141
+ }
142
+
143
+ function findBuildable(slug: string): PostSource | null {
144
+ const now = today();
145
+ return loadPostSources().find((p) => p.slug === slug && (p.draft || p.date <= now)) ?? null;
146
+ }
147
+
148
+ async function getRenderedPost(slug: string): Promise<RenderedPost | null> {
149
+ const post = findBuildable(slug);
150
+ if (!post) return null;
151
+ const html = await renderMarkdown(post.body);
152
+ return { ...toMeta(post), html };
153
+ }
154
+
155
+ function getAllTags(): { tag: string; count: number }[] {
156
+ const counts = new Map<string, number>();
157
+ for (const post of getListedPosts()) {
158
+ for (const tag of post.tags) counts.set(tag, (counts.get(tag) ?? 0) + 1);
159
+ }
160
+ return [...counts.entries()]
161
+ .map(([tag, count]) => ({ tag, count }))
162
+ .sort((a, b) => a.tag.localeCompare(b.tag));
163
+ }
164
+
165
+ function getPostsByTag(tag: string): PostMeta[] {
166
+ const normalized = normalizeTag(tag);
167
+ return getListedPosts().filter((p) => p.tags.includes(normalized));
168
+ }
169
+
170
+ function getAdjacentPosts(slug: string): { older: PostMeta | null; newer: PostMeta | null } {
171
+ const listed = getListedPosts();
172
+ const i = listed.findIndex((p) => p.slug === slug);
173
+ if (i === -1) return { older: null, newer: null };
174
+ return {
175
+ newer: i > 0 ? listed[i - 1] : null,
176
+ older: i < listed.length - 1 ? listed[i + 1] : null
177
+ };
178
+ }
179
+
180
+ return {
181
+ loadPostSources,
182
+ getPublishedPosts,
183
+ getListedPosts,
184
+ listsDrafts: () => listDrafts,
185
+ getIndexPage,
186
+ getIndexPageCount,
187
+ getBuildableSlugs,
188
+ getRenderedPost,
189
+ getAllTags,
190
+ getPostsByTag,
191
+ getAdjacentPosts
192
+ };
193
+ }
@@ -0,0 +1,102 @@
1
+ import type { SiteConfig } from '../config';
2
+ import { absoluteUrl } from '../config';
3
+ import type { PageMeta, PostMeta } from './types';
4
+
5
+ function escapeXml(value: string): string {
6
+ return value
7
+ .replace(/&/g, '&amp;')
8
+ .replace(/</g, '&lt;')
9
+ .replace(/>/g, '&gt;')
10
+ .replace(/"/g, '&quot;')
11
+ .replace(/'/g, '&apos;');
12
+ }
13
+
14
+ /** RFC-822 date at midnight UTC for a YYYY-MM-DD string. */
15
+ function rfc822(date: string): string {
16
+ const [y, m, d] = date.split('-').map(Number);
17
+ return new Date(Date.UTC(y, m - 1, d)).toUTCString();
18
+ }
19
+
20
+ /** Build an RSS 2.0 feed from published posts. */
21
+ export function buildRssXml(site: SiteConfig, posts: PostMeta[]): string {
22
+ const items = posts
23
+ .map((post) => {
24
+ const url = absoluteUrl(site, `/posts/${post.slug}`);
25
+ return ` <item>
26
+ <title>${escapeXml(post.title)}</title>
27
+ <link>${escapeXml(url)}</link>
28
+ <guid isPermaLink="true">${escapeXml(url)}</guid>
29
+ <pubDate>${rfc822(post.date)}</pubDate>
30
+ ${post.description ? `<description>${escapeXml(post.description)}</description>` : ''}
31
+ ${post.tags.map((t) => `<category>${escapeXml(t)}</category>`).join('\n\t\t\t')}
32
+ </item>`;
33
+ })
34
+ .join('\n');
35
+
36
+ return `<?xml version="1.0" encoding="UTF-8"?>
37
+ <rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
38
+ <channel>
39
+ <title>${escapeXml(site.title)}</title>
40
+ <link>${escapeXml(site.url)}</link>
41
+ <description>${escapeXml(site.description)}</description>
42
+ <atom:link href="${escapeXml(absoluteUrl(site, '/rss.xml'))}" rel="self" type="application/rss+xml" />
43
+ ${items}
44
+ </channel>
45
+ </rss>
46
+ `;
47
+ }
48
+
49
+ /** Build a sitemap covering the index, static pages, paginated pages, topics, tags, and posts. */
50
+ export function buildSitemapXml(
51
+ site: SiteConfig,
52
+ data: {
53
+ posts: PostMeta[];
54
+ tags: { tag: string }[];
55
+ pageCount: number;
56
+ /** Published static pages (`pages/*.md`). */
57
+ pages?: PageMeta[];
58
+ }
59
+ ): string {
60
+ const esc = (v: string) => v.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
61
+
62
+ const extraPages: { loc: string }[] = [];
63
+ for (let n = 2; n <= data.pageCount; n++) extraPages.push({ loc: absoluteUrl(site, `/page/${n}`) });
64
+
65
+ const staticPages = data.pages ?? [];
66
+
67
+ const urls: { loc: string; lastmod?: string }[] = [
68
+ { loc: absoluteUrl(site, '/') },
69
+ ...(site.homePage ? [{ loc: absoluteUrl(site, '/writing') }] : []),
70
+ { loc: absoluteUrl(site, '/topics') },
71
+ { loc: absoluteUrl(site, '/tags') },
72
+ ...extraPages,
73
+ ...staticPages.map((p) => ({ loc: absoluteUrl(site, `/${p.slug}`) })),
74
+ ...data.posts.map((p) => ({
75
+ loc: absoluteUrl(site, `/posts/${p.slug}`),
76
+ lastmod: p.updated ?? p.date
77
+ })),
78
+ ...data.tags.map(({ tag }) => ({ loc: absoluteUrl(site, `/tags/${tag}`) }))
79
+ ];
80
+
81
+ const body = urls
82
+ .map(
83
+ ({ loc, lastmod }) =>
84
+ ` <url><loc>${esc(loc)}</loc>${lastmod ? `<lastmod>${lastmod}</lastmod>` : ''}</url>`
85
+ )
86
+ .join('\n');
87
+
88
+ return `<?xml version="1.0" encoding="UTF-8"?>
89
+ <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
90
+ ${body}
91
+ </urlset>
92
+ `;
93
+ }
94
+
95
+ /** Build robots.txt referencing the sitemap. */
96
+ export function buildRobotsTxt(site: SiteConfig): string {
97
+ return `User-agent: *
98
+ Allow: /
99
+
100
+ Sitemap: ${absoluteUrl(site, '/sitemap.xml')}
101
+ `;
102
+ }
@@ -0,0 +1,78 @@
1
+ import { unified } from 'unified';
2
+ import remarkParse from 'remark-parse';
3
+ import remarkGfm from 'remark-gfm';
4
+ import remarkRehype from 'remark-rehype';
5
+ import rehypeRaw from 'rehype-raw';
6
+ import rehypeSlug from 'rehype-slug';
7
+ import rehypeAutolinkHeadings from 'rehype-autolink-headings';
8
+ import rehypeHighlight from 'rehype-highlight';
9
+ import rehypeStringify from 'rehype-stringify';
10
+ import bash from 'highlight.js/lib/languages/bash';
11
+ import css from 'highlight.js/lib/languages/css';
12
+ import go from 'highlight.js/lib/languages/go';
13
+ import javascript from 'highlight.js/lib/languages/javascript';
14
+ import json from 'highlight.js/lib/languages/json';
15
+ import markdown from 'highlight.js/lib/languages/markdown';
16
+ import python from 'highlight.js/lib/languages/python';
17
+ import rust from 'highlight.js/lib/languages/rust';
18
+ import sql from 'highlight.js/lib/languages/sql';
19
+ import typescript from 'highlight.js/lib/languages/typescript';
20
+ import xml from 'highlight.js/lib/languages/xml';
21
+ import yaml from 'highlight.js/lib/languages/yaml';
22
+ import { rehypeFigure } from './rehype-figure';
23
+
24
+ /**
25
+ * Markdown → HTML pipeline (build-time only).
26
+ *
27
+ * Decisions baked in here:
28
+ * - GFM enabled (tables, strikethrough, task lists, autolinks).
29
+ * - Raw HTML in post bodies is passed through (`allowDangerousHtml` + `rehype-raw`).
30
+ * The content trust boundary is "whoever can push to the repo", so this is the
31
+ * owner's own HTML.
32
+ * - Headings get stable slug ids (`rehype-slug`) plus a self-anchor link
33
+ * (`rehype-autolink-headings`) so deep links work.
34
+ * - Code blocks are syntax-highlighted at build time with a small language
35
+ * allowlist (not the full highlight.js registry).
36
+ * - Image-only paragraphs become `<figure>` with a `<figcaption>` from the
37
+ * image title/alt (`rehype-figure`), for captioned editorial images.
38
+ */
39
+ const processor = unified()
40
+ .use(remarkParse)
41
+ .use(remarkGfm)
42
+ .use(remarkRehype, { allowDangerousHtml: true })
43
+ .use(rehypeRaw)
44
+ .use(rehypeSlug)
45
+ .use(rehypeAutolinkHeadings, {
46
+ behavior: 'wrap',
47
+ properties: { className: ['heading-anchor'] }
48
+ })
49
+ .use(rehypeFigure)
50
+ .use(rehypeHighlight, {
51
+ detect: false,
52
+ ignoreMissing: true,
53
+ languages: {
54
+ bash,
55
+ css,
56
+ go,
57
+ javascript,
58
+ js: javascript,
59
+ json,
60
+ markdown,
61
+ md: markdown,
62
+ python,
63
+ rust,
64
+ sql,
65
+ typescript,
66
+ ts: typescript,
67
+ xml,
68
+ html: xml,
69
+ yaml,
70
+ yml: yaml
71
+ }
72
+ })
73
+ .use(rehypeStringify, { allowDangerousHtml: true });
74
+
75
+ export async function renderMarkdown(markdown: string): Promise<string> {
76
+ const file = await processor.process(markdown);
77
+ return String(file);
78
+ }
@@ -0,0 +1,103 @@
1
+ import { existsSync, readdirSync, readFileSync } from 'node:fs';
2
+ import { isAbsolute, join, resolve } from 'node:path';
3
+ import type { PageMeta, PageSource, RenderedPage } from './types';
4
+ import { ContentError, assertUniqueSlugs, parsePage } from './parse';
5
+ import { renderMarkdown } from './markdown';
6
+ import { resolveListDrafts } from './content';
7
+
8
+ export interface PagesApi {
9
+ loadPageSources(): PageSource[];
10
+ /** Non-draft pages (sitemap, public nav helpers). */
11
+ getPublishedPages(): PageMeta[];
12
+ /** Listed pages: published, plus drafts under `pnpm dev`. */
13
+ getListedPages(): PageMeta[];
14
+ listsDrafts(): boolean;
15
+ getBuildableSlugs(): string[];
16
+ getRenderedPage(slug: string): Promise<RenderedPage | null>;
17
+ }
18
+
19
+ export interface CreatePagesOptions {
20
+ /** Absolute or cwd-relative path to `pages/`. Missing dir → empty site (ok). */
21
+ pagesDir: string;
22
+ listDrafts?: boolean;
23
+ }
24
+
25
+ /**
26
+ * Content API for static Markdown pages (`pages/*.md` → `/<slug>`).
27
+ * Absent `pages/` directory is fine — returns empty lists.
28
+ */
29
+ export function createPages(opts: CreatePagesOptions): PagesApi {
30
+ const dir = isAbsolute(opts.pagesDir)
31
+ ? opts.pagesDir
32
+ : resolve(process.cwd(), opts.pagesDir);
33
+ const listDrafts = resolveListDrafts(opts.listDrafts);
34
+
35
+ let cache: PageSource[] | null = null;
36
+
37
+ function loadPageSources(): PageSource[] {
38
+ if (cache && import.meta.env.PROD) return cache;
39
+
40
+ if (!existsSync(dir)) {
41
+ cache = [];
42
+ return cache;
43
+ }
44
+
45
+ let filenames: string[];
46
+ try {
47
+ filenames = readdirSync(dir).filter((f) => f.toLowerCase().endsWith('.md'));
48
+ } catch (e: unknown) {
49
+ const detail = e instanceof Error ? e.message : String(e);
50
+ throw new ContentError(`Could not read pages directory "${dir}": ${detail}`);
51
+ }
52
+
53
+ const pages = filenames
54
+ .sort((a, b) => a.localeCompare(b))
55
+ .map((name) => parsePage(`/pages/${name}`, readFileSync(join(dir, name), 'utf8')));
56
+
57
+ assertUniqueSlugs(pages);
58
+ cache = pages;
59
+ return pages;
60
+ }
61
+
62
+ const toMeta = (page: PageSource): PageMeta => {
63
+ const { body: _body, ...meta } = page;
64
+ return meta;
65
+ };
66
+
67
+ const byOrder = (a: PageMeta, b: PageMeta) =>
68
+ a.order !== b.order ? a.order - b.order : a.slug.localeCompare(b.slug);
69
+
70
+ function getPublishedPages(): PageMeta[] {
71
+ return loadPageSources()
72
+ .filter((p) => !p.draft)
73
+ .sort(byOrder)
74
+ .map(toMeta);
75
+ }
76
+
77
+ function getListedPages(): PageMeta[] {
78
+ return loadPageSources()
79
+ .filter((p) => !p.draft || listDrafts)
80
+ .sort(byOrder)
81
+ .map(toMeta);
82
+ }
83
+
84
+ function getBuildableSlugs(): string[] {
85
+ return loadPageSources().map((p) => p.slug);
86
+ }
87
+
88
+ async function getRenderedPage(slug: string): Promise<RenderedPage | null> {
89
+ const page = loadPageSources().find((p) => p.slug === slug);
90
+ if (!page) return null;
91
+ const html = await renderMarkdown(page.body);
92
+ return { ...toMeta(page), html };
93
+ }
94
+
95
+ return {
96
+ loadPageSources,
97
+ getPublishedPages,
98
+ getListedPages,
99
+ listsDrafts: () => listDrafts,
100
+ getBuildableSlugs,
101
+ getRenderedPage
102
+ };
103
+ }
@@ -0,0 +1,214 @@
1
+ import matter from 'gray-matter';
2
+ import type { PageSource, PostSource, RawFrontmatter, RawPageFrontmatter } from './types';
3
+ import { RESERVED_PAGE_SLUGS } from './types';
4
+
5
+ /**
6
+ * A content error that names the offending file. The build must fail loudly and
7
+ * attributably (per PHASE_1_BRIEF §3 and GENESIS edge cases 1–3), never crash
8
+ * with a generic stack trace or silently drop a post.
9
+ *
10
+ * This module is deliberately free of `import.meta.glob` / Vite specifics so the
11
+ * parsing + validation logic can be unit-tested in isolation.
12
+ */
13
+ export class ContentError extends Error {
14
+ constructor(message: string) {
15
+ super(message);
16
+ this.name = 'ContentError';
17
+ }
18
+ }
19
+
20
+ const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
21
+
22
+ /** Validate a strict YYYY-MM-DD date string and confirm it's a real calendar date. */
23
+ export function assertValidDate(value: string, field: string, file: string): string {
24
+ if (!DATE_RE.test(value)) {
25
+ throw new ContentError(
26
+ `${file}: invalid \`${field}\` "${value}" — dates must be strictly YYYY-MM-DD (e.g. 2026-07-04).`
27
+ );
28
+ }
29
+ const [y, m, d] = value.split('-').map(Number);
30
+ const dt = new Date(Date.UTC(y, m - 1, d));
31
+ if (dt.getUTCFullYear() !== y || dt.getUTCMonth() !== m - 1 || dt.getUTCDate() !== d) {
32
+ throw new ContentError(`${file}: \`${field}\` "${value}" is not a real calendar date.`);
33
+ }
34
+ return value;
35
+ }
36
+
37
+ /**
38
+ * Derive a URL-safe slug from a string. Lowercases, trims, turns whitespace and
39
+ * underscores into hyphens, and drops any character that isn't a Unicode letter,
40
+ * number, or hyphen (emoji and punctuation are stripped; accented letters kept).
41
+ */
42
+ export function slugify(input: string): string {
43
+ return input
44
+ .normalize('NFKC')
45
+ .toLowerCase()
46
+ .trim()
47
+ .replace(/[\s_]+/g, '-')
48
+ .replace(/[^\p{L}\p{N}-]+/gu, '')
49
+ .replace(/-{2,}/g, '-')
50
+ .replace(/^-+|-+$/g, '');
51
+ }
52
+
53
+ /** Normalize a tag: coerce to string, trim, lowercase (edge case 13). */
54
+ export function normalizeTag(tag: unknown): string {
55
+ return String(tag).trim().toLowerCase();
56
+ }
57
+
58
+ /** Filename (no extension, no directory) from a "/posts/foo.md" style path. */
59
+ export function filenameOf(path: string): string {
60
+ return path.slice(path.lastIndexOf('/') + 1).replace(/\.md$/, '');
61
+ }
62
+
63
+ function normalizeDateInput(value: unknown): string {
64
+ // YAML auto-parses unquoted dates into Date objects; a phone may also type a
65
+ // string. Normalize both to the YYYY-MM-DD text before strict validation.
66
+ return value instanceof Date ? value.toISOString().slice(0, 10) : String(value).trim();
67
+ }
68
+
69
+ /** Parse and validate one raw Markdown file into a PostSource. Throws ContentError. */
70
+ export function parsePost(path: string, raw: string): PostSource {
71
+ let parsed: matter.GrayMatterFile<string>;
72
+ try {
73
+ // gray-matter tolerates trailing whitespace after the closing `---` (edge case 8).
74
+ parsed = matter(raw);
75
+ } catch (e: unknown) {
76
+ const detail = e instanceof Error ? e.message : String(e);
77
+ throw new ContentError(`${path}: could not parse YAML frontmatter — ${detail}`);
78
+ }
79
+
80
+ const fm = parsed.data as RawFrontmatter;
81
+
82
+ if (fm.title === undefined || fm.title === null || String(fm.title).trim() === '') {
83
+ throw new ContentError(`${path}: missing required frontmatter field \`title\`.`);
84
+ }
85
+ if (fm.date === undefined || fm.date === null || String(fm.date).trim() === '') {
86
+ throw new ContentError(`${path}: missing required frontmatter field \`date\`.`);
87
+ }
88
+
89
+ const title = String(fm.title).trim();
90
+ const date = assertValidDate(normalizeDateInput(fm.date), 'date', path);
91
+
92
+ let updated: string | null = null;
93
+ if (fm.updated !== undefined && fm.updated !== null && String(fm.updated).trim() !== '') {
94
+ updated = assertValidDate(normalizeDateInput(fm.updated), 'updated', path);
95
+ }
96
+
97
+ const explicitSlug = fm.slug !== undefined && fm.slug !== null && String(fm.slug).trim() !== '';
98
+ const slug = slugify(explicitSlug ? String(fm.slug) : filenameOf(path));
99
+ if (slug === '') {
100
+ throw new ContentError(
101
+ `${path}: could not derive a non-empty slug from ${explicitSlug ? 'the `slug` field' : 'the filename'}.`
102
+ );
103
+ }
104
+
105
+ const descriptionRaw = fm.description ?? fm.excerpt;
106
+ const description =
107
+ descriptionRaw !== undefined && descriptionRaw !== null && String(descriptionRaw).trim() !== ''
108
+ ? String(descriptionRaw).trim()
109
+ : null;
110
+
111
+ let tags: string[] = [];
112
+ if (fm.tags !== undefined && fm.tags !== null) {
113
+ if (!Array.isArray(fm.tags)) {
114
+ throw new ContentError(
115
+ `${path}: \`tags\` must be a YAML list (e.g. tags: [notes, sveltekit]), got ${typeof fm.tags}.`
116
+ );
117
+ }
118
+ tags = [...new Set(fm.tags.map(normalizeTag).filter((t) => t !== ''))];
119
+ }
120
+
121
+ const author =
122
+ fm.author !== undefined && fm.author !== null && String(fm.author).trim() !== ''
123
+ ? String(fm.author).trim()
124
+ : null;
125
+
126
+ const draft = fm.draft === true;
127
+
128
+ return {
129
+ slug,
130
+ title,
131
+ date,
132
+ updated,
133
+ description,
134
+ tags,
135
+ author,
136
+ draft,
137
+ sourcePath: path,
138
+ body: parsed.content
139
+ };
140
+ }
141
+
142
+ /** Throw if any two sources resolve to the same slug, naming both files (edge case 3). */
143
+ export function assertUniqueSlugs(sources: { slug: string; sourcePath: string }[]): void {
144
+ const bySlug = new Map<string, string>();
145
+ for (const item of sources) {
146
+ const existing = bySlug.get(item.slug);
147
+ if (existing) {
148
+ throw new ContentError(
149
+ `Duplicate slug "${item.slug}" produced by two files: ${existing} and ${item.sourcePath}. ` +
150
+ `Set a distinct \`slug\` in one file's frontmatter or rename it.`
151
+ );
152
+ }
153
+ bySlug.set(item.slug, item.sourcePath);
154
+ }
155
+ }
156
+
157
+ const RESERVED = new Set<string>(RESERVED_PAGE_SLUGS);
158
+
159
+ /** Parse and validate one static page Markdown file. Throws ContentError. */
160
+ export function parsePage(path: string, raw: string): PageSource {
161
+ let parsed: matter.GrayMatterFile<string>;
162
+ try {
163
+ parsed = matter(raw);
164
+ } catch (e: unknown) {
165
+ const detail = e instanceof Error ? e.message : String(e);
166
+ throw new ContentError(`${path}: could not parse YAML frontmatter — ${detail}`);
167
+ }
168
+
169
+ const fm = parsed.data as RawPageFrontmatter;
170
+
171
+ if (fm.title === undefined || fm.title === null || String(fm.title).trim() === '') {
172
+ throw new ContentError(`${path}: missing required frontmatter field \`title\`.`);
173
+ }
174
+
175
+ const title = String(fm.title).trim();
176
+ const explicitSlug = fm.slug !== undefined && fm.slug !== null && String(fm.slug).trim() !== '';
177
+ const slug = slugify(explicitSlug ? String(fm.slug) : filenameOf(path));
178
+ if (slug === '') {
179
+ throw new ContentError(
180
+ `${path}: could not derive a non-empty slug from ${explicitSlug ? 'the `slug` field' : 'the filename'}.`
181
+ );
182
+ }
183
+ if (RESERVED.has(slug)) {
184
+ throw new ContentError(
185
+ `${path}: slug "${slug}" is reserved by the engine (${RESERVED_PAGE_SLUGS.join(', ')}). ` +
186
+ `Rename the file or set a different \`slug\` in frontmatter.`
187
+ );
188
+ }
189
+
190
+ const descriptionRaw = fm.description ?? fm.excerpt;
191
+ const description =
192
+ descriptionRaw !== undefined && descriptionRaw !== null && String(descriptionRaw).trim() !== ''
193
+ ? String(descriptionRaw).trim()
194
+ : null;
195
+
196
+ let order = 0;
197
+ if (fm.order !== undefined && fm.order !== null && String(fm.order).trim() !== '') {
198
+ const n = Number(fm.order);
199
+ if (!Number.isFinite(n)) {
200
+ throw new ContentError(`${path}: \`order\` must be a number (got ${JSON.stringify(fm.order)}).`);
201
+ }
202
+ order = n;
203
+ }
204
+
205
+ return {
206
+ slug,
207
+ title,
208
+ description,
209
+ draft: fm.draft === true,
210
+ order,
211
+ sourcePath: path,
212
+ body: parsed.content
213
+ };
214
+ }