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.
- package/LICENSE +21 -0
- package/README.md +178 -4
- package/package.json +89 -7
- package/packages/app/package.json +31 -0
- package/packages/app/src/app.d.ts +12 -0
- package/packages/app/src/app.html +12 -0
- package/packages/app/src/critical-theme.d.ts +4 -0
- package/packages/app/src/lib/content.server.ts +8 -0
- package/packages/app/src/lib/empty-theme.css +1 -0
- package/packages/app/src/lib/genie/GenieHost.svelte +16 -0
- package/packages/app/src/lib/genie/GeniePanel.svelte +429 -0
- package/packages/app/src/lib/genie/ops.ts +237 -0
- package/packages/app/src/lib/genie/store.ts +217 -0
- package/packages/app/src/lib/genie/types.ts +61 -0
- package/packages/app/src/lib/pages.server.ts +7 -0
- package/packages/app/src/lib/site.server.ts +52 -0
- package/packages/app/src/lib/theme-entry.ts +7 -0
- package/packages/app/src/routes/+layout.svelte +29 -0
- package/packages/app/src/routes/+layout.ts +10 -0
- package/packages/app/src/routes/+page.server.ts +28 -0
- package/packages/app/src/routes/+page.svelte +67 -0
- package/packages/app/src/routes/[slug]/+page.server.ts +20 -0
- package/packages/app/src/routes/[slug]/+page.svelte +47 -0
- package/packages/app/src/routes/page/[n]/+page.server.ts +21 -0
- package/packages/app/src/routes/page/[n]/+page.svelte +36 -0
- package/packages/app/src/routes/posts/[slug]/+page.server.ts +24 -0
- package/packages/app/src/routes/posts/[slug]/+page.svelte +95 -0
- package/packages/app/src/routes/robots.txt/+server.ts +11 -0
- package/packages/app/src/routes/rss.xml/+server.ts +13 -0
- package/packages/app/src/routes/sitemap.xml/+server.ts +19 -0
- package/packages/app/src/routes/tags/+page.server.ts +4 -0
- package/packages/app/src/routes/tags/+page.svelte +26 -0
- package/packages/app/src/routes/tags/[tag]/+page.server.ts +15 -0
- package/packages/app/src/routes/tags/[tag]/+page.svelte +23 -0
- package/packages/app/src/routes/topics/+page.server.ts +28 -0
- package/packages/app/src/routes/topics/+page.svelte +47 -0
- package/packages/app/src/routes/writing/+page.server.ts +12 -0
- package/packages/app/src/routes/writing/+page.svelte +38 -0
- package/packages/app/src/site-theme.d.ts +2 -0
- package/packages/app/static/.gitkeep +0 -0
- package/packages/app/tsconfig.json +15 -0
- package/packages/app/vite-plugin-critical-theme.ts +70 -0
- package/packages/app/vite-plugin-genie.ts +114 -0
- package/packages/app/vite.config.ts +141 -0
- package/packages/core/package.json +51 -0
- package/packages/core/src/lib/assets/favicon.svg +1 -0
- package/packages/core/src/lib/components/Newsletter.svelte +13 -0
- package/packages/core/src/lib/components/PostCard.svelte +34 -0
- package/packages/core/src/lib/components/PostIndex.svelte +86 -0
- package/packages/core/src/lib/components/SiteFooter.svelte +15 -0
- package/packages/core/src/lib/components/SiteHeader.svelte +28 -0
- package/packages/core/src/lib/config.ts +154 -0
- package/packages/core/src/lib/content/content.ts +193 -0
- package/packages/core/src/lib/content/feeds.ts +102 -0
- package/packages/core/src/lib/content/markdown.ts +78 -0
- package/packages/core/src/lib/content/pages.ts +103 -0
- package/packages/core/src/lib/content/parse.ts +214 -0
- package/packages/core/src/lib/content/rehype-figure.ts +79 -0
- package/packages/core/src/lib/content/types.ts +88 -0
- package/packages/core/src/lib/format.ts +25 -0
- package/packages/core/src/lib/index.ts +27 -0
- package/packages/core/src/lib/server.ts +33 -0
- package/packages/core/src/lib/styles/fonts.css +72 -0
- package/packages/core/src/lib/styles/theme.css +762 -0
- package/packages/core/src/lib/theme.ts +5 -0
- package/packages/import/package.json +27 -0
- package/packages/import/src/cli.ts +346 -0
- package/packages/import/src/discover.ts +190 -0
- package/packages/import/src/extract.ts +378 -0
- package/packages/import/src/fetch.ts +63 -0
- package/packages/import/src/html-to-md.ts +21 -0
- package/packages/import/src/images.ts +247 -0
- package/packages/import/src/inspire.ts +417 -0
- package/packages/import/src/ir.ts +109 -0
- package/packages/import/src/ollama.ts +145 -0
- package/packages/import/src/stock.ts +218 -0
- package/packages/import/src/theme.ts +478 -0
- package/packages/import/src/write-site.ts +371 -0
- package/packages/import/tsconfig.json +13 -0
- package/pnpm-workspace.yaml +2 -0
- package/scripts/create-site.mjs +260 -0
- package/scripts/filepress.mjs +273 -0
- package/scripts/link-embedded-packages.mjs +40 -0
- 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, '&')
|
|
8
|
+
.replace(/</g, '<')
|
|
9
|
+
.replace(/>/g, '>')
|
|
10
|
+
.replace(/"/g, '"')
|
|
11
|
+
.replace(/'/g, ''');
|
|
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, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
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
|
+
}
|