Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions app/blog/[slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ export default async function BlogPost({
{post.date}
</time>
<h1
id="post-h"
className="hug font-semibold leading-[0.95] tracking-[-0.035em]"
style={{ fontSize: "var(--fs-h2)" }}
>
Expand Down
26 changes: 10 additions & 16 deletions app/blog/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,9 @@ import { listPosts } from "@/lib/blog";
const TITLE = "field notes";
const DESCRIPTION = "long-form posts on the deCDN protocol.";

// Inherit the root file-convention og image (app/opengraph-image.png).
// Next merges flat fields but shallow-replaces nested objects like
// `openGraph`, so declaring an `openGraph` here without re-including
// `images` strips the root image — which previously left every blog
// route sharing a blank card. We reuse `ogImages` for twitter too:
// the parent `twitter.images` field is empty here (no app/twitter-image
// convention exists) and Next's "fall back to og for twitter" only
// fires at the final resolution step, not via `parent`.
// openGraph shallow-replaces in Next 16, so re-pass `images` from parent
// or the root og image is dropped. Twitter `images` set explicitly because
// the og→twitter fallback fires at final resolution, not via ResolvingMetadata.
export async function generateMetadata(
_: unknown,
parent: ResolvingMetadata,
Expand Down Expand Up @@ -48,6 +43,7 @@ export default function BlogIndex() {
<header className="flex flex-col gap-6">
<span className="meta opacity-60">field notes</span>
<h1
id="blog-h"
className="hug font-semibold leading-[0.92] tracking-[-0.04em]"
style={{ fontSize: "var(--fs-h2)" }}
>
Expand Down Expand Up @@ -94,14 +90,12 @@ export default function BlogIndex() {
>
{post.title}
</h2>
{post.summary ? (
<p
className="max-w-[60ch] leading-[1.6] opacity-75"
style={{ fontSize: "var(--fs-body)" }}
>
{post.summary}
</p>
) : null}
<p
className="max-w-[60ch] leading-[1.6] opacity-75"
style={{ fontSize: "var(--fs-body)" }}
>
{post.summary}
</p>
</div>
</Link>
</li>
Expand Down
10 changes: 10 additions & 0 deletions app/sitemap-pages.xml/route.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,16 @@
import { SITE_URL } from "@/lib/links";
import { listPosts } from "@/lib/blog";

// SITE_URL is interpolated raw into <loc> bodies below. SLUG_RE guards
// the per-post slug; this guards the origin against a future accident
// (preview-URL with query string, embedded entity) that would emit
// invalid XML and have search engines silently reject the sitemap.
if (/[<>&"']/.test(SITE_URL)) {
throw new Error(
`[sitemap] SITE_URL contains XML-significant characters: ${SITE_URL}`,
);
}
Comment on lines +8 to +12

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-medium medium

The validation for SITE_URL is a good security measure to prevent XML injection. However, the error message includes the raw SITE_URL. If this error is ever displayed in an environment that renders HTML (like some CI logs or error monitoring tools), it could potentially lead to a secondary injection if SITE_URL itself was maliciously crafted. Consider sanitizing the output in the error message or just stating that the URL is invalid.


// Required by Next 16 under `output: "export"` for route handlers that emit
// static files at build time. GET-only is the only verb supported in export.
export const dynamic = "force-static";
Expand Down
8 changes: 3 additions & 5 deletions components/site/Chrome.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,9 @@ import Link from "next/link";
import { links } from "@/lib/links";

const SECTION_IDS = ["intro", "compare", "method", "faq", "contact"] as const;
// Hash anchors below are written `/#section`, not `#section`. The
// leading slash is load-bearing: this nav also renders on `/blog/*`,
// where a bare `#section` would resolve relative to the current path
// and land on `/blog/#section` (which doesn't exist) instead of
// `/#section` on the home page.
// Hash anchors are written `/#section`. Required because this nav also
// renders on /blog/*, where a bare `#section` resolves against the
// current URL (e.g. /blog/foo/#section) instead of the home page.
const NAV = [
{ id: "compare", label: "compare" },
{ id: "method", label: "method" },
Expand Down
34 changes: 34 additions & 0 deletions lib/blog.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { describe, expect, it } from "vitest";
import { getPost, parseSlug } from "./blog";

describe("parseSlug", () => {
it.each(["a", "a-b", "a-b-c", "abc123", "a1-b2"])("accepts %j", (input) => {
expect(parseSlug(input)).not.toBeNull();
});

it.each(["", "-a", "a-", "a--b", "A", "a_b", "a b", "a.b", "<script>"])(
"rejects %j",
(input) => {
expect(parseSlug(input)).toBeNull();
},
);

it("rejects non-string input", () => {
expect(parseSlug(42)).toBeNull();
expect(parseSlug(null)).toBeNull();
expect(parseSlug(undefined)).toBeNull();
expect(parseSlug({})).toBeNull();
expect(parseSlug([])).toBeNull();
});
});

describe("getPost", () => {
// Invalid slugs short-circuit on parseSlug before readEntries runs,
// so these assertions also exercise the "no filesystem read" path.
it.each(["A", "a--b", "-a", "a-", "", "a_b", "<script>"])(
"returns null for invalid slug %j without touching the filesystem",
(input) => {
expect(getPost(input)).toBeNull();
},
);
});
63 changes: 36 additions & 27 deletions lib/blog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,26 +4,34 @@ import matter from "gray-matter";

const POSTS_DIR = path.join(process.cwd(), "content", "blog");

// Constrain slug shape so file-system-sourced strings can never carry
// HTML or URL-significant characters into hrefs. This is the central
// guard against the stored-XSS class — see CodeQL js/stored-xss.
// Also rejects empty strings, leading/trailing hyphens, and consecutive
// hyphens so canonical kebab-case is enforced at the boundary.
// Central guard against stored-XSS via slug interpolation. Also enforces
// canonical kebab-case (no empty, leading/trailing/consecutive hyphens).
const SLUG_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}$/;

// Branded slug: once a `Slug` is produced, callers can rely on
// SLUG_RE having held. The only constructor sites are this module's
// own `parseEntry` (throws on failure) and `parseSlug` (returns null).
export type Slug = string & { readonly __brand: "Slug" };
// SLUG_RE having held. `unique symbol` makes the brand unfakeable
// across modules — no external caller can write `"foo" as Slug`
// because they can't reference this module-local symbol.
declare const __slug: unique symbol;
export type Slug = string & { readonly [__slug]: true };

export const parseSlug = (s: unknown): Slug | null =>
typeof s === "string" && SLUG_RE.test(s) ? (s as Slug) : null;

// IsoDate mirrors Slug — parseEntry validates against ISO_DATE_RE, and
// downstream consumers (RSS feed, archive page) can rely on the brand
// rather than re-testing the regex.
declare const __isoDate: unique symbol;
export type IsoDate = string & { readonly [__isoDate]: true };

const parseIsoDate = (s: string): IsoDate | null =>
ISO_DATE_RE.test(s) ? (s as IsoDate) : null;

export type PostMeta = {
slug: Slug;
title: string;
date: string;
date: IsoDate;
summary: string;
bucket?: string;
};
Expand Down Expand Up @@ -86,8 +94,8 @@ const parseEntry = (filename: string): PostSource | null => {
}
const title = data.title.trim();

const date = formatDate(data.date);
if (!ISO_DATE_RE.test(date)) {
const date = parseIsoDate(formatDate(data.date));
if (date === null) {
throw new Error(
data.date === undefined
? `[blog] ${filename}: frontmatter \`date\` is required`
Expand All @@ -112,33 +120,32 @@ const parseEntry = (filename: string): PostSource | null => {
return { slug, title, date, summary, bucket, body: content };
};

// Memoise: the build is single-process and source files are immutable
// mid-build. `readEntries` is hit from `listPosts` (BlogIndex + sitemap +
// generateStaticParams = 3 sites) and `getPost` (generateMetadata + the
// page render, 2× per slug), so without the cache we'd parse the
// directory 2N+3 times. Errors are thrown before `cache` is assigned,
// so a malformed post fails the build loud rather than poisoning the
// cache with a partial result.
// Single-process build with immutable source files; cache lets every
// call site share one parse. Errors throw before assignment so a
// malformed post can't poison the cache with a partial result.
let cache: PostSource[] | undefined;

const readEntries = (): PostSource[] => {
if (cache) return cache;
// The directory's presence is part of the project's contract — every
// statically wired blog route depends on it. Falling back to `[]`
// would silently regress to "nothing yet." in prod. If you really
// want zero posts, leave a `.gitkeep` in `content/blog/`.
if (!fs.existsSync(POSTS_DIR)) {
cache = [];
return cache;
throw new Error(
`[blog] posts directory not found at ${POSTS_DIR}. ` +
`If you intentionally have zero posts, create the directory with a .gitkeep.`,
);
}
cache = fs
.readdirSync(POSTS_DIR)
.filter((f) => /\.mdx?$/.test(f))
.map(parseEntry)
.filter((e): e is PostSource => e !== null)
.sort((a, b) => {
// Newest first; ISO-8601 dates compare lexically. Tie-break on
// slug because `fs.readdirSync` order isn't portable across
// filesystems (Linux returns inode order, macOS returns lex
// order on APFS but not on case-insensitive HFS+). Without the
// tie-break, ES stable sort would preserve readdir order for
// same-date posts and output would flip between machines.
// Newest first; tie-break on slug because readdir order isn't
// portable across filesystems and stable sort would otherwise
// flip same-date post order between machines.
if (a.date !== b.date) return b.date.localeCompare(a.date);
return a.slug.localeCompare(b.slug);
Comment on lines 149 to 150

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The addition of a tie-break on slug for sorting ensures consistent build output across different environments. However, since fs.readdirSync and subsequent file parsing are expensive operations, please implement module-level caching to prevent redundant work. Additionally, ensure that unused properties are filtered out during the initial parsing phase to simplify data retrieval and avoid 'no-unused-vars' warnings.

References
  1. Implement module-level caching for expensive file-system operations to prevent redundant parsing during build processes.
  2. Filter out unused properties during the initial parsing phase to simplify data retrieval and avoid eslint 'no-unused-vars' warnings.

});
Expand All @@ -158,5 +165,7 @@ export function listPosts(): PostMeta[] {
}

export function getPost(slug: string): PostSource | null {
return readEntries().find((e) => e.slug === slug) ?? null;
const valid = parseSlug(slug);
if (valid === null) return null;
return readEntries().find((e) => e.slug === valid) ?? null;
Comment thread
alpergundogdu marked this conversation as resolved.
}
6 changes: 4 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"version": "0.1.0",
"private": true,
"engines": {
"node": ">=20.9.0"
"node": ">=20.19.0"
},
"scripts": {
"dev": "next dev",
Expand All @@ -12,6 +12,7 @@
"lint": "eslint",
"format": "prettier --write .",
"format:check": "prettier --check .",
"test": "vitest run",
"prepare": "husky"
},
"lint-staged": {
Expand Down Expand Up @@ -55,6 +56,7 @@
"markdownlint-cli2": "^0.22.1",
"prettier": "^3.8.3",
"tailwindcss": "^4",
"typescript": "^6"
"typescript": "^6",
"vitest": "^4.1.6"
Comment thread
alpergundogdu marked this conversation as resolved.
}
}
Loading
Loading