barakopress 0.2.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 (69) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +274 -0
  3. package/dist/cms.d.ts +47 -0
  4. package/dist/cms.d.ts.map +1 -0
  5. package/dist/cms.js +131 -0
  6. package/dist/cms.js.map +1 -0
  7. package/dist/config.d.ts +91 -0
  8. package/dist/config.d.ts.map +1 -0
  9. package/dist/config.js +75 -0
  10. package/dist/config.js.map +1 -0
  11. package/dist/delivery.d.ts +45 -0
  12. package/dist/delivery.d.ts.map +1 -0
  13. package/dist/delivery.js +62 -0
  14. package/dist/delivery.js.map +1 -0
  15. package/dist/index.d.ts +17 -0
  16. package/dist/index.d.ts.map +1 -0
  17. package/dist/index.js +39 -0
  18. package/dist/index.js.map +1 -0
  19. package/dist/markdown.d.ts +5 -0
  20. package/dist/markdown.d.ts.map +1 -0
  21. package/dist/markdown.js +91 -0
  22. package/dist/markdown.js.map +1 -0
  23. package/dist/routes/feed.d.ts +3 -0
  24. package/dist/routes/feed.d.ts.map +1 -0
  25. package/dist/routes/feed.js +73 -0
  26. package/dist/routes/feed.js.map +1 -0
  27. package/dist/routes/revalidate.d.ts +25 -0
  28. package/dist/routes/revalidate.d.ts.map +1 -0
  29. package/dist/routes/revalidate.js +136 -0
  30. package/dist/routes/revalidate.js.map +1 -0
  31. package/dist/routes/robots.d.ts +4 -0
  32. package/dist/routes/robots.d.ts.map +1 -0
  33. package/dist/routes/robots.js +18 -0
  34. package/dist/routes/robots.js.map +1 -0
  35. package/dist/routes/sitemap.d.ts +4 -0
  36. package/dist/routes/sitemap.d.ts.map +1 -0
  37. package/dist/routes/sitemap.js +33 -0
  38. package/dist/routes/sitemap.js.map +1 -0
  39. package/dist/screens/archive.d.ts +13 -0
  40. package/dist/screens/archive.d.ts.map +1 -0
  41. package/dist/screens/archive.js +47 -0
  42. package/dist/screens/archive.js.map +1 -0
  43. package/dist/screens/blog-index.d.ts +9 -0
  44. package/dist/screens/blog-index.d.ts.map +1 -0
  45. package/dist/screens/blog-index.js +38 -0
  46. package/dist/screens/blog-index.js.map +1 -0
  47. package/dist/screens/blog-post.d.ts +20 -0
  48. package/dist/screens/blog-post.d.ts.map +1 -0
  49. package/dist/screens/blog-post.js +103 -0
  50. package/dist/screens/blog-post.js.map +1 -0
  51. package/dist/screens/post-view.d.ts +8 -0
  52. package/dist/screens/post-view.d.ts.map +1 -0
  53. package/dist/screens/post-view.js +19 -0
  54. package/dist/screens/post-view.js.map +1 -0
  55. package/package.json +76 -0
  56. package/src/cms.ts +196 -0
  57. package/src/config.ts +159 -0
  58. package/src/delivery.ts +134 -0
  59. package/src/index.ts +69 -0
  60. package/src/markdown.ts +96 -0
  61. package/src/routes/feed.ts +79 -0
  62. package/src/routes/revalidate.ts +157 -0
  63. package/src/routes/robots.ts +20 -0
  64. package/src/routes/sitemap.ts +35 -0
  65. package/src/screens/archive.tsx +79 -0
  66. package/src/screens/blog-index.tsx +122 -0
  67. package/src/screens/blog-post.tsx +110 -0
  68. package/src/screens/post-view.tsx +84 -0
  69. package/src/styles.css +314 -0
@@ -0,0 +1,157 @@
1
+ import { createHmac, timingSafeEqual } from "node:crypto";
2
+ import { revalidateTag } from "next/cache";
3
+ import { NextResponse, type NextRequest } from "next/server";
4
+ import type { PressConfig } from "../config.js";
5
+
6
+ /*
7
+ * The endpoint that makes the cache correct.
8
+ *
9
+ * barakoCMS fires a signed webhook when content changes and this drops the cache tag, so the next
10
+ * request renders fresh. It is the only writer to the cache and it is reachable by anyone who
11
+ * finds the URL, so the order of the checks matters as much as the checks.
12
+ *
13
+ * The signing recipe is barakoCMS docs/webhooks.md:
14
+ *
15
+ * X-Barako-Timestamp unix seconds when it was signed
16
+ * X-Barako-Signature "sha256=" + lowercase hex HMAC-SHA256 over "<timestamp>.<raw body>"
17
+ * X-Barako-Delivery the delivery log row id
18
+ *
19
+ * Four things are load-bearing:
20
+ *
21
+ * 1. The signature covers the RAW body bytes. Parse the JSON first and you have re-serialised
22
+ * it into a different string that will never verify.
23
+ * 2. Constant-time compare, or a few thousand requests recover the expected signature.
24
+ * 3. An old timestamp is refused, so a captured delivery cannot be replayed forever.
25
+ * 4. Cheap checks come first. Reading an unbounded body into memory before authenticating lets
26
+ * an anonymous caller spend this server's memory, so the length is checked first and the
27
+ * read is capped.
28
+ */
29
+
30
+ const TOLERANCE_SECONDS = 300;
31
+
32
+ /** A signed delivery is small. Anything larger did not come from the CMS. */
33
+ const MAX_BODY_BYTES = 64 * 1024;
34
+
35
+ export interface RevalidateOptions {
36
+ /** Defaults to REVALIDATE_SECRET. */
37
+ secret?: string;
38
+ /** Seconds a signature stays valid. Shorter is safer; the sender's clock has to be close. */
39
+ toleranceSeconds?: number;
40
+ maxBodyBytes?: number;
41
+ }
42
+
43
+ /** Constant-time compare that does not leak length through an early return either. */
44
+ function sameSignature(a: string, b: string): boolean {
45
+ const left = Buffer.from(a, "utf8");
46
+ const right = Buffer.from(b, "utf8");
47
+ if (left.length !== right.length) return false;
48
+ return timingSafeEqual(left, right);
49
+ }
50
+
51
+ /*
52
+ * A replayed delivery is honoured once.
53
+ *
54
+ * A captured signature stays valid for the whole tolerance window, and each accepted purge costs
55
+ * a re-render of every cached page. Remembering what has already been honoured turns "unlimited
56
+ * work for five minutes" into "one". In-process, which is the same scope as the cache it
57
+ * protects: another instance has its own cache and would do its own single purge anyway.
58
+ */
59
+ function makeReplayGuard(windowSeconds: number) {
60
+ const seen = new Map<string, number>();
61
+ return function alreadyHonoured(signature: string): boolean {
62
+ const now = Date.now() / 1000;
63
+ for (const [sig, at] of seen) if (now - at > windowSeconds) seen.delete(sig);
64
+ if (seen.has(signature)) return true;
65
+ seen.set(signature, now);
66
+ return false;
67
+ };
68
+ }
69
+
70
+ export function createRevalidateRoute(config: PressConfig, options: RevalidateOptions = {}) {
71
+ const tolerance = options.toleranceSeconds ?? TOLERANCE_SECONDS;
72
+ const maxBody = options.maxBodyBytes ?? MAX_BODY_BYTES;
73
+ const alreadyHonoured = makeReplayGuard(tolerance);
74
+
75
+ async function POST(request: NextRequest) {
76
+ const secret = options.secret ?? process.env.REVALIDATE_SECRET;
77
+ if (!secret) {
78
+ // Refuse rather than accept unsigned. An unconfigured deployment that quietly accepts
79
+ // anything is an open cache-purge endpoint, which is a free denial of service.
80
+ console.error("revalidate: no secret configured, refusing every delivery");
81
+ return NextResponse.json({ error: "not configured" }, { status: 503 });
82
+ }
83
+
84
+ const timestamp = request.headers.get("x-barako-timestamp");
85
+ const signature = request.headers.get("x-barako-signature");
86
+ if (!timestamp || !signature) {
87
+ return NextResponse.json({ error: "unsigned" }, { status: 401 });
88
+ }
89
+
90
+ const sent = Number(timestamp);
91
+ if (!Number.isFinite(sent)) {
92
+ return NextResponse.json({ error: "bad timestamp" }, { status: 401 });
93
+ }
94
+ if (Math.abs(Date.now() / 1000 - sent) > tolerance) {
95
+ // Also catches a receiver whose clock has drifted, which looks identical from here.
96
+ return NextResponse.json({ error: "stale" }, { status: 401 });
97
+ }
98
+
99
+ // Length before content: the last check that costs nothing.
100
+ const declared = Number(request.headers.get("content-length") ?? "0");
101
+ if (Number.isFinite(declared) && declared > maxBody) {
102
+ return NextResponse.json({ error: "too large" }, { status: 413 });
103
+ }
104
+
105
+ const raw = Buffer.from(await request.arrayBuffer());
106
+ if (raw.byteLength > maxBody) {
107
+ // A chunked request declares no length, so the read is bounded again here.
108
+ return NextResponse.json({ error: "too large" }, { status: 413 });
109
+ }
110
+
111
+ const material = Buffer.concat([Buffer.from(`${timestamp}.`, "utf8"), raw]);
112
+ const expected = "sha256=" + createHmac("sha256", secret).update(material).digest("hex");
113
+ if (!sameSignature(expected, signature)) {
114
+ return NextResponse.json({ error: "bad signature" }, { status: 401 });
115
+ }
116
+
117
+ if (alreadyHonoured(signature)) {
118
+ // Honest 200: the purge this delivery asked for has already happened, so the CMS has
119
+ // no reason to retry. A 401 here would make a legitimate retry look like an attack.
120
+ return NextResponse.json({ revalidated: true, repeated: true });
121
+ }
122
+
123
+ /*
124
+ * No cache warming here, deliberately.
125
+ *
126
+ * An earlier version fetched the main pages after purging, on the theory that Next serves
127
+ * one stale response after a purge and this server should absorb it. Measured against a
128
+ * CMS that logs every read, those fetches caused zero reads and re-rendered nothing:
129
+ * `{ expire: 0 }` already makes the next request a blocking miss, and a page that was
130
+ * never in the warm list was equally fresh for its first reader.
131
+ *
132
+ * It could not have worked in the shipped stack anyway. Caddy sets X-Forwarded-Proto, so
133
+ * the origin resolved to https against the plain HTTP server inside this container, and
134
+ * the failure was swallowed without a log line. Three requests per delivery, latency on
135
+ * the response, and triple the work an attacker gets from one captured signature, for
136
+ * nothing.
137
+ */
138
+ revalidateTag(config.cacheTag, { expire: 0 });
139
+
140
+ // The delivery id is echoed only after the signature verified, so an anonymous caller
141
+ // cannot put text of their choosing into this server's log.
142
+ const delivery = request.headers.get("x-barako-delivery") ?? "unknown";
143
+ console.log(`revalidate: dropped tag "${config.cacheTag}" for delivery ${delivery}`);
144
+ return NextResponse.json({ revalidated: true, tag: config.cacheTag, delivery });
145
+ }
146
+
147
+ /*
148
+ * GET answers "is this wired up" without a signed request, and reveals nothing: not whether a
149
+ * secret is set, not the tag, not the last delivery. A health check that leaks configuration
150
+ * is how someone learns what to forge.
151
+ */
152
+ async function GET() {
153
+ return NextResponse.json({ ok: true });
154
+ }
155
+
156
+ return { POST, GET };
157
+ }
@@ -0,0 +1,20 @@
1
+ import type { MetadataRoute } from "next";
2
+ import type { PressConfig } from "../config.js";
3
+
4
+ /*
5
+ * Generated, because the Sitemap line has to be an absolute URL and the domain is per deployment.
6
+ * A relative Sitemap: is ignored.
7
+ *
8
+ * The URL comes from config rather than process.env read at module scope. That distinction is the
9
+ * whole bug it fixes: this route is prerendered at build, so an env read baked the build
10
+ * machine's value in permanently, and production advertised a localhost sitemap that no cache
11
+ * purge could ever correct.
12
+ */
13
+ export function createRobots(config: PressConfig) {
14
+ return function robots(): MetadataRoute.Robots {
15
+ return {
16
+ rules: { userAgent: "*", allow: "/" },
17
+ sitemap: `${config.site.url}/sitemap.xml`,
18
+ };
19
+ };
20
+ }
@@ -0,0 +1,35 @@
1
+ import type { MetadataRoute } from "next";
2
+ import type { PressConfig } from "../config.js";
3
+ import { listPosts } from "../cms.js";
4
+
5
+ /*
6
+ * Built from the same cached read as every other page, so it costs nothing in the steady state
7
+ * and cannot disagree with what the site serves.
8
+ *
9
+ * A post whose SEO block says noIndex is left out: the CMS resolves that flag per entry and its
10
+ * own sitemap honours it, so a site that ignored it here would contradict the CMS on the one
11
+ * signal an editor set deliberately.
12
+ */
13
+ export function createSitemap(config: PressConfig) {
14
+ return async function sitemap(): Promise<MetadataRoute.Sitemap> {
15
+ let indexable: Awaited<ReturnType<typeof listPosts>>["posts"] = [];
16
+ try {
17
+ const { posts } = await listPosts(config, { pageSize: config.pageSizes.sitemap });
18
+ indexable = posts.filter((p) => !p.seo?.noIndex);
19
+ } catch {
20
+ // A sitemap that throws is worse than a short one: it fails the build when
21
+ // prerendered, and serves a crawler a 500 at runtime.
22
+ indexable = [];
23
+ }
24
+
25
+ return [
26
+ { url: config.site.url, changeFrequency: "daily", priority: 1 },
27
+ ...indexable.map((p) => ({
28
+ url: `${config.site.url}${config.routes.post}/${p.slug}`,
29
+ lastModified: p.publishedAt ? new Date(p.publishedAt) : undefined,
30
+ changeFrequency: "monthly" as const,
31
+ priority: 0.7,
32
+ })),
33
+ ];
34
+ };
35
+ }
@@ -0,0 +1,79 @@
1
+ import Link from "next/link";
2
+ import { notFound } from "next/navigation";
3
+ import type { PressConfig } from "../config.js";
4
+ import { getTerm, listPostsBy, type Post } from "../cms.js";
5
+ import { renderMarkdown } from "../markdown.js";
6
+ import { Card } from "./blog-index.js";
7
+
8
+ type SlugParams = { params: Promise<{ slug: string }> };
9
+
10
+ /*
11
+ * Posts by author, or by category.
12
+ *
13
+ * One screen for both, because they differ only in which type is resolved and which reference is
14
+ * filtered on, and both of those are in the config. A site with no author type simply never
15
+ * mounts the route; the factory still refuses cleanly if it is mounted anyway.
16
+ */
17
+ export function createArchive(config: PressConfig, which: "author" | "category") {
18
+ return async function ArchivePage({ params }: SlugParams) {
19
+ if (!config.types[which]) notFound();
20
+
21
+ const { slug } = await params;
22
+ const [term, posts] = await Promise.all([
23
+ getTerm(config, which, slug),
24
+ listPostsBy(config, which, slug),
25
+ ]);
26
+ if (!term || !posts) notFound();
27
+
28
+ return (
29
+ <>
30
+ <p className="meta">
31
+ <Link href="/">Back</Link>
32
+ </p>
33
+ <h1>{term.name}</h1>
34
+
35
+ {term.description && (
36
+ <div
37
+ className="prose"
38
+ dangerouslySetInnerHTML={{ __html: renderMarkdown(term.description) }}
39
+ />
40
+ )}
41
+
42
+ {which === "author" && term.website && (
43
+ <p className="meta">
44
+ <a href={term.website} rel="noopener noreferrer">
45
+ {term.website}
46
+ </a>
47
+ </p>
48
+ )}
49
+
50
+ <h2 style={{ marginTop: "2.5rem" }}>
51
+ {posts.length} {posts.length === 1 ? "post" : "posts"}
52
+ </h2>
53
+
54
+ {posts.map((p: Post) => (
55
+ <Card key={p.id} config={config} post={p} />
56
+ ))}
57
+ </>
58
+ );
59
+ };
60
+ }
61
+
62
+ /** Slugs for a static export of an archive route. */
63
+ export function createArchiveStaticParams(config: PressConfig, which: "author" | "category") {
64
+ return async function generateStaticParams(): Promise<{ slug: string }[]> {
65
+ const type = config.types[which];
66
+ if (!type) return [];
67
+
68
+ const { list } = await import("../delivery.js");
69
+ try {
70
+ const res = await list(config, type, { pageSize: 100 });
71
+ return res.items
72
+ .map((c) => c.slug ?? "")
73
+ .filter(Boolean)
74
+ .map((slug) => ({ slug }));
75
+ } catch {
76
+ return [];
77
+ }
78
+ };
79
+ }
@@ -0,0 +1,122 @@
1
+ import Link from "next/link";
2
+ import type { PressConfig } from "../config.js";
3
+ import { formatDate, listPosts, type Post } from "../cms.js";
4
+
5
+ /*
6
+ * The post list.
7
+ *
8
+ * A factory rather than a component, because a Next route is a file and a package cannot write
9
+ * files into someone else's app. The consumer's page.tsx is:
10
+ *
11
+ * import { createBlogIndex } from "barakopress";
12
+ * import { config } from "@/press.config";
13
+ * export default createBlogIndex(config);
14
+ * export const revalidate = 300;
15
+ *
16
+ * `Card` is exported too, so a site that wants its own list but the engine's card can have one
17
+ * without copying the markup.
18
+ */
19
+
20
+ export function Card({
21
+ config,
22
+ post,
23
+ featured = false,
24
+ }: {
25
+ config: PressConfig;
26
+ post: Post;
27
+ featured?: boolean;
28
+ }) {
29
+ return (
30
+ <article className={featured ? "card featured-card" : "card"}>
31
+ {featured && <span className="chip">Featured</span>}
32
+ <h2>
33
+ <Link href={`${config.routes.post}/${post.slug}`}>{post.title}</Link>
34
+ </h2>
35
+ <p className="meta">
36
+ {post.publishedAt && (
37
+ <time dateTime={post.publishedAt}>{formatDate(config, post.publishedAt)}</time>
38
+ )}
39
+ {post.author && config.routes.author && (
40
+ <>
41
+ {" by "}
42
+ <Link href={`${config.routes.author}/${post.author.slug}`}>
43
+ {post.author.name}
44
+ </Link>
45
+ </>
46
+ )}
47
+ {post.category && config.routes.category && (
48
+ <>
49
+ {" in "}
50
+ <Link href={`${config.routes.category}/${post.category.slug}`}>
51
+ {post.category.name}
52
+ </Link>
53
+ </>
54
+ )}
55
+ </p>
56
+ {post.excerpt && <p className="excerpt">{post.excerpt}</p>}
57
+ {post.tags.length > 0 && (
58
+ <p className="tags">
59
+ {post.tags.map((t) => (
60
+ <span key={t} className="tag">
61
+ {t}
62
+ </span>
63
+ ))}
64
+ </p>
65
+ )}
66
+ </article>
67
+ );
68
+ }
69
+
70
+ export function createBlogIndex(config: PressConfig) {
71
+ return async function BlogIndex() {
72
+ let posts: Post[] = [];
73
+ let failure: string | null = null;
74
+
75
+ try {
76
+ ({ posts } = await listPosts(config));
77
+ } catch (e) {
78
+ // An unreachable CMS is the likeliest thing to be wrong, so it gets a readable page
79
+ // rather than a stack trace. This render is not cached, so the next request retries.
80
+ failure = e instanceof Error ? e.message : String(e);
81
+ }
82
+
83
+ const featured = posts.filter((p) => p.featured);
84
+ const rest = posts.filter((p) => !p.featured);
85
+
86
+ return (
87
+ <>
88
+ <header className="masthead">
89
+ <h1>{config.site.name}</h1>
90
+ {config.site.tagline && <p className="tagline">{config.site.tagline}</p>}
91
+ </header>
92
+
93
+ {failure && (
94
+ <div className="notice error">
95
+ <p>
96
+ <strong>This page could not be loaded.</strong>
97
+ </p>
98
+ <p>Please try again shortly.</p>
99
+ </div>
100
+ )}
101
+
102
+ {!failure && posts.length === 0 && (
103
+ <div className="notice">
104
+ <p>
105
+ <strong>Nothing published yet.</strong>
106
+ </p>
107
+ <p>
108
+ Only published entries of a type opted into public delivery appear here.
109
+ </p>
110
+ </div>
111
+ )}
112
+
113
+ {featured.map((p) => (
114
+ <Card key={p.id} config={config} post={p} featured />
115
+ ))}
116
+ {rest.map((p) => (
117
+ <Card key={p.id} config={config} post={p} />
118
+ ))}
119
+ </>
120
+ );
121
+ };
122
+ }
@@ -0,0 +1,110 @@
1
+ import { notFound } from "next/navigation";
2
+ import type { Metadata } from "next";
3
+ import type { PressConfig } from "../config.js";
4
+ import { getPost, getPostPreview, listPosts } from "../cms.js";
5
+ import { PostView } from "./post-view.js";
6
+
7
+ type SlugParams = { params: Promise<{ slug: string }> };
8
+ type PreviewParams = SlugParams & { searchParams: Promise<{ preview?: string }> };
9
+
10
+ /*
11
+ * The post page, in two shapes.
12
+ *
13
+ * `createBlogPost` reads only `params`, so it can be prerendered and works under
14
+ * `output: "export"`. `createBlogPostPreview` also reads `searchParams` for a preview token,
15
+ * which forces dynamic rendering: a static export refuses it outright with "couldn't be rendered
16
+ * statically because it used searchParams". A static site takes the first and gives up draft
17
+ * preview, which is the honest trade and the reason these are two exports rather than a flag.
18
+ */
19
+
20
+ export function createBlogPost(config: PressConfig) {
21
+ return async function PostPage({ params }: SlugParams) {
22
+ const { slug } = await params;
23
+ const post = await getPost(config, slug);
24
+ if (!post) notFound();
25
+ return <PostView config={config} post={post} />;
26
+ };
27
+ }
28
+
29
+ export function createBlogPostPreview(config: PressConfig) {
30
+ return async function PostPreviewPage({ params, searchParams }: PreviewParams) {
31
+ const { slug } = await params;
32
+ const { preview } = await searchParams;
33
+
34
+ // A token routes to the uncached read, so a draft never enters the shared cache.
35
+ const post = preview ? await getPostPreview(config, slug, preview) : await getPost(config, slug);
36
+ if (!post) notFound();
37
+
38
+ return <PostView config={config} post={post} preview={Boolean(preview)} />;
39
+ };
40
+ }
41
+
42
+ /*
43
+ * Metadata from the API's resolved `seo` block rather than assembled here.
44
+ *
45
+ * A type opted into SEO fields returns title, description, canonical, social image and noIndex on
46
+ * every public response, with the title already falling back to the entry's title. So this maps,
47
+ * and an editor changing the meta description in the console changes the page.
48
+ */
49
+ export function createPostMetadata(config: PressConfig) {
50
+ return async function generateMetadata({ params }: SlugParams): Promise<Metadata> {
51
+ const { slug } = await params;
52
+ const post = await getPost(config, slug);
53
+ if (!post) return { title: "Not found" };
54
+
55
+ const seo = post.seo;
56
+ const title = seo?.title ?? post.title;
57
+ const description = seo?.description ?? post.excerpt;
58
+ const image = seo?.imageUrl ?? post.coverImage;
59
+
60
+ return {
61
+ title,
62
+ description,
63
+ alternates: seo?.canonicalUrl ? { canonical: seo.canonicalUrl } : undefined,
64
+ robots: seo?.noIndex ? { index: false, follow: false } : undefined,
65
+ openGraph: {
66
+ title,
67
+ description,
68
+ type: "article",
69
+ publishedTime: post.publishedAt,
70
+ images: image ? [image] : undefined,
71
+ },
72
+ twitter: {
73
+ card: image ? "summary_large_image" : "summary",
74
+ title,
75
+ description,
76
+ images: image ? [image] : undefined,
77
+ },
78
+ };
79
+ };
80
+ }
81
+
82
+ /*
83
+ * Slugs for a static export, paged to the end rather than to one hardcoded limit.
84
+ *
85
+ * Every consumer of an export-mode site needs this, and every one that hand-writes it caps the
86
+ * page size and silently drops the posts past it. Doing it once here is the difference between an
87
+ * engine and a snippet in a README.
88
+ */
89
+ export function createPostStaticParams(config: PressConfig) {
90
+ return async function generateStaticParams(): Promise<{ slug: string }[]> {
91
+ const slugs: { slug: string }[] = [];
92
+ const pageSize = 100;
93
+
94
+ for (let page = 1; page <= 200; page++) {
95
+ let batch;
96
+ try {
97
+ batch = await listPosts(config, { page, pageSize });
98
+ } catch {
99
+ // A build with no CMS reachable produces no routes. Under `output: "export"` Next
100
+ // then refuses the build, which is the correct outcome: a static site with no
101
+ // content is not something to ship quietly.
102
+ break;
103
+ }
104
+ for (const p of batch.posts) if (p.slug) slugs.push({ slug: p.slug });
105
+ if (!batch.hasNextPage) break;
106
+ }
107
+
108
+ return slugs;
109
+ };
110
+ }
@@ -0,0 +1,84 @@
1
+ import Link from "next/link";
2
+ import type { PressConfig } from "../config.js";
3
+ import { formatDate, type Post } from "../cms.js";
4
+ import { renderMarkdown } from "../markdown.js";
5
+
6
+ /*
7
+ * The rendering, shared by the static screen and the preview screen.
8
+ *
9
+ * It is its own component because reading `searchParams` forces a route to render dynamically,
10
+ * and a site built with `output: "export"` is refused outright for it. Preview needs the query
11
+ * string; a published post does not. Splitting the fetch from the render is what lets one set of
12
+ * markup serve a static site and a server-rendered one.
13
+ *
14
+ * Every link is built from `config.routes`, so a site that mounts posts at /writing gets /writing
15
+ * links here, in the feed and in the sitemap, instead of three files disagreeing.
16
+ */
17
+ export function PostView({
18
+ config,
19
+ post,
20
+ preview = false,
21
+ }: {
22
+ config: PressConfig;
23
+ post: Post;
24
+ preview?: boolean;
25
+ }) {
26
+ return (
27
+ <article>
28
+ {preview && (
29
+ <div className="notice preview-banner">
30
+ Preview. This is how the post will look. It is not published, and it is served
31
+ uncached so nothing here reaches another reader.
32
+ </div>
33
+ )}
34
+
35
+ <p className="meta">
36
+ <Link href="/">Back</Link>
37
+ </p>
38
+
39
+ <h1>{post.title}</h1>
40
+
41
+ <p className="meta">
42
+ {post.publishedAt && (
43
+ <time dateTime={post.publishedAt}>{formatDate(config, post.publishedAt)}</time>
44
+ )}
45
+ {post.author && config.routes.author && (
46
+ <>
47
+ {" by "}
48
+ <Link href={`${config.routes.author}/${post.author.slug}`}>
49
+ {post.author.name}
50
+ </Link>
51
+ </>
52
+ )}
53
+ {post.author && !config.routes.author && <>{` by ${post.author.name}`}</>}
54
+ {post.category && config.routes.category && (
55
+ <>
56
+ {" in "}
57
+ <Link href={`${config.routes.category}/${post.category.slug}`}>
58
+ {post.category.name}
59
+ </Link>
60
+ </>
61
+ )}
62
+ </p>
63
+
64
+ {post.coverImage && (
65
+ // Not next/image: the CMS resizes on request with ?w=, so the optimiser would be a
66
+ // second resizer in front of the first.
67
+ // eslint-disable-next-line @next/next/no-img-element
68
+ <img className="cover" src={post.coverImage} alt={post.coverImageAlt ?? ""} />
69
+ )}
70
+
71
+ <div className="prose" dangerouslySetInnerHTML={{ __html: renderMarkdown(post.body) }} />
72
+
73
+ {post.tags.length > 0 && (
74
+ <p className="tags">
75
+ {post.tags.map((t) => (
76
+ <span key={t} className="tag">
77
+ {t}
78
+ </span>
79
+ ))}
80
+ </p>
81
+ )}
82
+ </article>
83
+ );
84
+ }