From b1b078cfb31ccebd5352ececdb705999f1461ccf Mon Sep 17 00:00:00 2001 From: Edwin Tantawi Date: Sat, 1 Aug 2026 20:35:53 +0700 Subject: [PATCH 01/11] feat(markdown): estimate reading time for every document MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Counts words in the raw body at 200 wpm, memoized on file content beside the table-of-contents parse. Code samples are counted with prose rather than excluded: readers do spend time on them, usually more per line, so dropping them understates a code-heavy post more than counting them overstates a prose one — and one uniform rule keeps the number predictable for authors. Also lifts frontmatter stripping into a shared helper, which the table-of-contents parser had been doing inline. --- src/modules/markdown/markdown.utils.ts | 40 ++++++++++++++++++++++---- src/modules/post/post.types.ts | 2 ++ src/modules/series/series.types.ts | 4 +++ 3 files changed, 40 insertions(+), 6 deletions(-) diff --git a/src/modules/markdown/markdown.utils.ts b/src/modules/markdown/markdown.utils.ts index 1baaa5a..7a18d0a 100644 --- a/src/modules/markdown/markdown.utils.ts +++ b/src/modules/markdown/markdown.utils.ts @@ -54,6 +54,39 @@ export function resolveAsset(src: string, contentSubDir: string) { return createDefaultImport(`#/content/${assetPath}`); } +/** Average adult reading speed for prose, in words per minute. */ +const WORDS_PER_MINUTE = 200; + +/** + * Estimates how long a document takes to read, in whole minutes (minimum one). + * + * Counts every word in the body, code samples included. Excluding them would be + * defensible — nobody reads a 60-line snippet at prose speed — but people do + * *spend* time on code, usually more per line than on prose, so dropping it + * understates a code-heavy post more than counting it overstates one. A single + * uniform rule also keeps the number predictable for authors. + * + * Takes the raw `.mdx` source, since the `frontmatter-only` parser never + * exposes the body; the caller reads it off disk and memoizes this alongside + * the other derived fields. + */ +export function estimateReadingTime(content: string): number { + const body = stripFrontmatter(content); + const words = body.split(/\s+/).filter(Boolean).length; + return Math.max(1, Math.round(words / WORDS_PER_MINUTE)); +} + +/** + * Drops a document's leading `---` … `---` block. + * + * Content Collections strips frontmatter through its own parser, but helpers + * here are handed the raw file. Left in place the closing fence parses as a + * setext underline and turns the last YAML line into a phantom heading. + */ +function stripFrontmatter(content: string): string { + return content.replace(/^\uFEFF?---\r?\n[\s\S]*?\r?\n---\r?\n/, ''); +} + /** Concatenates a hast subtree's text — the same content `rehype-slug` slugs. */ function textContent(node: Nodes): string { if (node.type === 'text') return node.value; @@ -79,12 +112,7 @@ function textContent(node: Nodes): string { * (this module is reached only from the config's Node-side `transform`). */ export async function extractTableOfContents(content: string): Promise { - // Content Collections strips the frontmatter through its own parser, but the - // caller hands us the raw file, so drop the leading `---` … `---` block first — - // otherwise its closing fence parses as a setext underline and turns the last - // YAML line into a phantom heading. - const body = content.replace(/^\uFEFF?---\r?\n[\s\S]*?\r?\n---\r?\n/, ''); - const tree = toHast(fromMarkdown(body)) as Nodes; + const tree = toHast(fromMarkdown(stripFrontmatter(content))) as Nodes; // Stamp heading `id`s in place, exactly as the render pipeline does. rehypeSlug()(tree as never); diff --git a/src/modules/post/post.types.ts b/src/modules/post/post.types.ts index 7721607..73a5a14 100644 --- a/src/modules/post/post.types.ts +++ b/src/modules/post/post.types.ts @@ -10,6 +10,8 @@ export type PostFrontmatter = z.infer; export interface PostItem extends PostFrontmatter { slug: string; lastModification: string; + /** Estimated read duration in whole minutes. */ + readingTime: number; } export interface PostContent extends PostItem { diff --git a/src/modules/series/series.types.ts b/src/modules/series/series.types.ts index 0bd3155..e149c26 100644 --- a/src/modules/series/series.types.ts +++ b/src/modules/series/series.types.ts @@ -14,6 +14,8 @@ export type SeriesPostFrontmatter = z.infer; export interface SeriesItem extends SeriesFrontmatter { slug: string; lastModification: string; + /** Estimated read duration of the series' own introduction, in whole minutes. */ + readingTime: number; } export interface SeriesContent extends SeriesItem { @@ -29,6 +31,8 @@ export interface SeriesPostItem extends SeriesPostFrontmatter { }; order: number; lastModification: string; + /** Estimated read duration in whole minutes. */ + readingTime: number; } export interface SeriesPostContent extends SeriesPostItem { From 0b6cd8a97a193686e6a88384b5aa590a10f55377 Mon Sep 17 00:00:00 2001 From: Edwin Tantawi Date: Sat, 1 Aug 2026 20:36:08 +0700 Subject: [PATCH 02/11] feat(content): add dev-gated fixture content MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Index pages rendering one or two items hide most layout problems, so the design needs dense content to be built and verified against. Fixtures are committed rather than local-only, so a fresh clone reproduces the same environment and regressions at volume stay reproducible. They must never ship. Rather than defining the fixture collections only in development — which would change the generated module's exports between modes and break type-checking against it — the collections always exist and only their include glob changes, becoming one no file can match in production. Fixture arrays are then simply empty there, so content.source can concatenate them unconditionally and no consumer needs an environment check. Also factors the three near-identical collection transforms into shared builders, rather than copy-pasting them a second time for the fixtures. --- content-collections.ts | 249 ++++++++++-------- src/content/_fixtures/README.md | 27 ++ .../container-queries-made-cards-portable.mdx | 63 +++++ ...iscriminated-unions-beat-boolean-flags.mdx | 72 +++++ .../posts/focus-management-nobody-sees.mdx | 66 +++++ .../narrowing-unions-without-type-guards.mdx | 84 ++++++ .../react-compiler-changed-how-i-memo.mdx | 67 +++++ .../reading-vite-dependency-prebundling.mdx | 72 +++++ .../posts/the-cost-of-a-dependency.mdx | 56 ++++ .../what-prerendering-actually-buys-you.mdx | 60 +++++ .../01_why-the-edge-changes-your-code.mdx | 59 +++++ .../02_data-at-the-edge.mdx | 63 +++++ .../03_caching-that-actually-works.mdx | 63 +++++ .../04_observability-without-a-server.mdx | 89 +++++++ .../series/shipping-on-the-edge/_index.mdx | 33 +++ src/modules/content/content.source.ts | 31 +++ 16 files changed, 1050 insertions(+), 104 deletions(-) create mode 100644 src/content/_fixtures/README.md create mode 100644 src/content/_fixtures/posts/container-queries-made-cards-portable.mdx create mode 100644 src/content/_fixtures/posts/discriminated-unions-beat-boolean-flags.mdx create mode 100644 src/content/_fixtures/posts/focus-management-nobody-sees.mdx create mode 100644 src/content/_fixtures/posts/narrowing-unions-without-type-guards.mdx create mode 100644 src/content/_fixtures/posts/react-compiler-changed-how-i-memo.mdx create mode 100644 src/content/_fixtures/posts/reading-vite-dependency-prebundling.mdx create mode 100644 src/content/_fixtures/posts/the-cost-of-a-dependency.mdx create mode 100644 src/content/_fixtures/posts/what-prerendering-actually-buys-you.mdx create mode 100644 src/content/_fixtures/series/shipping-on-the-edge/01_why-the-edge-changes-your-code.mdx create mode 100644 src/content/_fixtures/series/shipping-on-the-edge/02_data-at-the-edge.mdx create mode 100644 src/content/_fixtures/series/shipping-on-the-edge/03_caching-that-actually-works.mdx create mode 100644 src/content/_fixtures/series/shipping-on-the-edge/04_observability-without-a-server.mdx create mode 100644 src/content/_fixtures/series/shipping-on-the-edge/_index.mdx create mode 100644 src/modules/content/content.source.ts diff --git a/content-collections.ts b/content-collections.ts index 38e5cb3..389efd8 100644 --- a/content-collections.ts +++ b/content-collections.ts @@ -2,11 +2,12 @@ import { existsSync } from 'node:fs'; import { readFile } from 'node:fs/promises'; import path from 'node:path'; -import type { WriterHook } from '@content-collections/core'; +import type { CollectionContext, Meta, WriterHook } from '@content-collections/core'; import { createDefaultImport, defineCollection, defineConfig } from '@content-collections/core'; import type { MDXContent } from 'mdx/types'; import { + estimateReadingTime, extractTableOfContents, getLastModification, resolveAsset, @@ -16,117 +17,157 @@ import { seriesFrontmatterSchema, seriesPostFrontmatterSchema, } from '#/modules/series/series.schema'; +import type { Thumbnail } from '#/modules/thumbnail/thumbnail.schema'; const CONTENT_DIRECTORY = 'src/content'; -const posts = defineCollection({ - name: 'posts', - directory: `${CONTENT_DIRECTORY}/posts`, - include: '*.mdx', - parser: 'frontmatter-only', - schema: postFrontmatterSchema, - transform: async (document, ctx) => { - const slug = document._meta.path; - const filePath = document._meta.filePath; - const contentPath = path.join(CONTENT_DIRECTORY, '/posts', filePath); - const lastModification = await ctx.cache(contentPath, getLastModification); - const raw = await readFile(contentPath, 'utf-8'); - // Cache the parse on the file's content (a distinct `key` from the - // path-keyed `getLastModification` above), so it recomputes when the body's - // headings change and never collides with that sibling cache entry. - const toc = await ctx.cache(raw, extractTableOfContents, { key: 'toc' }); - const mdx = createDefaultImport(`#/content/posts/${filePath}`); - const contentSubDir = path.posix.join('posts', path.posix.dirname(filePath)); - const thumbnail = document.thumbnail - ? { ...document.thumbnail, src: resolveAsset(document.thumbnail.src, contentSubDir) } - : null; - return { ...document, slug, mdx, lastModification, thumbnail, toc }; - }, -}); +/** + * Fixture content is committed so a fresh clone reproduces the dense layouts + * the design was built and verified against — but it must never ship. + * + * Rather than define the fixture collections only in development, which would + * change the generated module's exports between modes and break type-checking + * against it, the collections always exist and only their `include` glob + * changes. In production it becomes a pattern no file can match, so no fixture + * MDX is compiled, none of it reaches the bundle, and every fixture array is + * simply empty — letting consumers concatenate them unconditionally rather than + * branching on the environment at runtime. + */ +const NEVER_MATCHES = '__fixtures-are-excluded-from-production__/*.mdx'; -const series = defineCollection({ - name: 'series', - directory: `${CONTENT_DIRECTORY}/series`, - include: '*/_index.mdx', - parser: 'frontmatter-only', - schema: seriesFrontmatterSchema, - transform: async (document, ctx) => { - const filePath = document._meta.filePath; - const slug = document._meta.directory; - const contentPath = path.join(CONTENT_DIRECTORY, '/series', filePath); - const lastModification = await ctx.cache(contentPath, getLastModification); - const raw = await readFile(contentPath, 'utf-8'); - // Cache the parse on the file's content (a distinct `key` from the - // path-keyed `getLastModification` above), so it recomputes when the body's - // headings change and never collides with that sibling cache entry. - const toc = await ctx.cache(raw, extractTableOfContents, { key: 'toc' }); - const mdx = createDefaultImport(`#/content/series/${filePath}`); - const contentSubDir = path.posix.join('series', path.posix.dirname(filePath)); - const thumbnail = document.thumbnail - ? { ...document.thumbnail, src: resolveAsset(document.thumbnail.src, contentSubDir) } - : null; - return { ...document, slug, mdx, lastModification, thumbnail, toc }; - }, -}); +function fixtureInclude(pattern: string) { + return process.env.NODE_ENV === 'production' ? NEVER_MATCHES : pattern; +} -const seriesPost = defineCollection({ - name: 'seriesPost', - directory: `${CONTENT_DIRECTORY}/series`, - include: ['*/*.mdx', '!*/_index.mdx'], - parser: 'frontmatter-only', - schema: seriesPostFrontmatterSchema, - transform: async (document, ctx) => { - const filePath = document._meta.filePath; - const seriesSlug = document._meta.directory; - const fileName = document._meta.fileName.replace(`.${document._meta.extension}`, ''); - const match = fileName.match(/^(\d+)_(.+)$/); - if (!match) { - throw new Error( - `Series post "${filePath}" must be prefixed with an order number, e.g. "00_${fileName}.mdx".`, - ); - } - const [_, orderPrefix, slug] = match; - const order = Number(orderPrefix); - const contentPath = path.join(CONTENT_DIRECTORY, '/series', filePath); - const lastModification = await ctx.cache(contentPath, getLastModification); - const raw = await readFile(contentPath, 'utf-8'); - // Cache the parse on the file's content (a distinct `key` from the - // path-keyed `getLastModification` above), so it recomputes when the body's - // headings change and never collides with that sibling cache entry. - const toc = await ctx.cache(raw, extractTableOfContents, { key: 'toc' }); - const mdx = createDefaultImport(`#/content/series/${filePath}`); - const contentSubDir = path.posix.join('series', path.posix.dirname(filePath)); - const thumbnail = document.thumbnail - ? { ...document.thumbnail, src: resolveAsset(document.thumbnail.src, contentSubDir) } - : null; - return { ...document, slug, seriesSlug, order, mdx, lastModification, thumbnail, toc }; - }, - onSuccess: (documents) => { - const ordersBySeries = new Map>(); - for (const document of documents) { - const seen = ordersBySeries.get(document.seriesSlug) ?? new Set(); - if (seen.has(document.order)) { +/** The subset of the collection context these transforms actually use. */ +type TransformContext = Pick; + +/** + * Derives everything the three document types compute identically: the git + * last-modified stamp, the table of contents, the reading-time estimate, the + * compiled-MDX import, and the thumbnail's asset-resolved `src`. + * + * `segment` is the collection's directory beneath `src/content` (`posts`, or + * `_fixtures/posts`), which rebases both the on-disk read and the `#/content` + * import so a fixture collection resolves against its own tree. + */ +async function deriveDocument( + meta: Meta, + thumbnail: Thumbnail, + segment: string, + ctx: TransformContext, +) { + const filePath = meta.filePath; + const contentPath = path.join(CONTENT_DIRECTORY, segment, filePath); + const lastModification = await ctx.cache(contentPath, getLastModification); + const raw = await readFile(contentPath, 'utf-8'); + // Cache the parse on the file's content (a distinct `key` from the + // path-keyed `getLastModification` above), so it recomputes when the body's + // headings change and never collides with that sibling cache entry. + const toc = await ctx.cache(raw, extractTableOfContents, { key: 'toc' }); + // Same content key, its own `key` namespace — see the note above. + const readingTime = await ctx.cache(raw, estimateReadingTime, { key: 'readingTime' }); + const mdx = createDefaultImport(`#/content/${segment}/${filePath}`); + const contentSubDir = path.posix.join(segment, path.posix.dirname(filePath)); + + return { + lastModification, + toc, + readingTime, + mdx, + thumbnail: thumbnail ? { ...thumbnail, src: resolveAsset(thumbnail.src, contentSubDir) } : null, + }; +} + +/** Standalone posts: one flat `*.mdx` per document, slugged by file name. */ +function definePostsCollection(name: TName, segment: string) { + return defineCollection({ + name, + directory: `${CONTENT_DIRECTORY}/${segment}`, + include: segment.startsWith('_fixtures') ? fixtureInclude('*.mdx') : '*.mdx', + parser: 'frontmatter-only', + schema: postFrontmatterSchema, + transform: async (document, ctx) => { + const derived = await deriveDocument(document._meta, document.thumbnail, segment, ctx); + return { ...document, slug: document._meta.path, ...derived }; + }, + }); +} + +/** Series roots: the `_index.mdx` inside each series directory. */ +function defineSeriesCollection(name: TName, segment: string) { + return defineCollection({ + name, + directory: `${CONTENT_DIRECTORY}/${segment}`, + include: segment.startsWith('_fixtures') ? fixtureInclude('*/_index.mdx') : '*/_index.mdx', + parser: 'frontmatter-only', + schema: seriesFrontmatterSchema, + transform: async (document, ctx) => { + const derived = await deriveDocument(document._meta, document.thumbnail, segment, ctx); + return { ...document, slug: document._meta.directory, ...derived }; + }, + }); +} + +/** Series parts: every `*.mdx` in a series directory except its `_index.mdx`. */ +function defineSeriesPostCollection(name: TName, segment: string) { + return defineCollection({ + name, + directory: `${CONTENT_DIRECTORY}/${segment}`, + include: [ + segment.startsWith('_fixtures') ? fixtureInclude('*/*.mdx') : '*/*.mdx', + '!*/_index.mdx', + ], + parser: 'frontmatter-only', + schema: seriesPostFrontmatterSchema, + transform: async (document, ctx) => { + const { filePath, directory, fileName, extension } = document._meta; + const documentName = fileName.replace(`.${extension}`, ''); + const match = documentName.match(/^(\d+)_(.+)$/); + if (!match) { throw new Error( - `Series "${document.seriesSlug}" has two posts with order ${document.order}. Each post needs a unique numeric prefix.`, + `Series post "${filePath}" must be prefixed with an order number, e.g. "00_${documentName}.mdx".`, ); } - seen.add(document.order); - ordersBySeries.set(document.seriesSlug, seen); - } - - // Every series directory with posts must also ship an `_index.mdx`; without - // it the series is absent from `allSeries`, so its detail page 404s while - // the posts beneath it stay reachable — an easy-to-miss orphan state. - for (const seriesSlug of ordersBySeries.keys()) { - const indexPath = path.join(CONTENT_DIRECTORY, 'series', seriesSlug, '_index.mdx'); - if (!existsSync(indexPath)) { - throw new Error( - `Series "${seriesSlug}" has posts but no "_index.mdx". Add ${indexPath} so the series is listed and its detail page resolves.`, - ); + const [_, orderPrefix, slug] = match; + const derived = await deriveDocument(document._meta, document.thumbnail, segment, ctx); + return { ...document, slug, seriesSlug: directory, order: Number(orderPrefix), ...derived }; + }, + onSuccess: (documents) => { + const ordersBySeries = new Map>(); + for (const document of documents) { + const seen = ordersBySeries.get(document.seriesSlug) ?? new Set(); + if (seen.has(document.order)) { + throw new Error( + `Series "${document.seriesSlug}" has two posts with order ${document.order}. Each post needs a unique numeric prefix.`, + ); + } + seen.add(document.order); + ordersBySeries.set(document.seriesSlug, seen); } - } - }, -}); + + // Every series directory with posts must also ship an `_index.mdx`; without + // it the series is absent from `allSeries`, so its detail page 404s while + // the posts beneath it stay reachable — an easy-to-miss orphan state. + for (const seriesSlug of ordersBySeries.keys()) { + const indexPath = path.join(CONTENT_DIRECTORY, segment, seriesSlug, '_index.mdx'); + if (!existsSync(indexPath)) { + throw new Error( + `Series "${seriesSlug}" has posts but no "_index.mdx". Add ${indexPath} so the series is listed and its detail page resolves.`, + ); + } + } + }, + }); +} + +const posts = definePostsCollection('posts', 'posts'); +const series = defineSeriesCollection('series', 'series'); +const seriesPost = defineSeriesPostCollection('seriesPost', 'series'); + +const fixturePosts = definePostsCollection('fixturePosts', '_fixtures/posts'); +const fixtureSeries = defineSeriesCollection('fixtureSeries', '_fixtures/series'); +const fixtureSeriesPost = defineSeriesPostCollection('fixtureSeriesPost', '_fixtures/series'); // Keep the generated collection (and the compiled MDX it imports) out of the // client bundle: prepend a `server-only` marker so TanStack Start's import @@ -142,7 +183,7 @@ const serverOnlyHook: WriterHook = async ({ fileType, content }) => { }; export default defineConfig({ - content: [posts, series, seriesPost], + content: [posts, series, seriesPost, fixturePosts, fixtureSeries, fixtureSeriesPost], hooks: { writer: [serverOnlyHook], }, diff --git a/src/content/_fixtures/README.md b/src/content/_fixtures/README.md new file mode 100644 index 0000000..11320b6 --- /dev/null +++ b/src/content/_fixtures/README.md @@ -0,0 +1,27 @@ +# Fixture content — never ships + +Everything under `_fixtures/` is **placeholder content**, committed on purpose. + +It exists so that a fresh clone reproduces the dense content the layouts were +designed and verified against: multi-item lists, overlapping tags, related-post +matches, search-index ranking, long tables of contents, and multi-part series +navigation. Without it every index page renders with one or two items and +layout regressions at volume go unnoticed until production. + +## How it is excluded from production + +`content-collections.ts` defines the `fixture*` collections unconditionally so +the generated module's exports stay identical in every mode — only their +`include` glob changes. When `NODE_ENV=production` the glob is swapped for one +no file can match, so no fixture MDX is compiled, none of it enters the bundle, +and `allFixturePosts` / `allFixtureSeries` / `allFixtureSeriesPosts` are empty +arrays. Consumers concatenate them unconditionally; production simply gets +nothing. + +## Deleting it + +Delete this directory and remove the three `fixture*` collections from +`content-collections.ts`. Nothing else references it — the module layer +concatenates the fixture arrays and does not care whether they are empty. + +The names, prose, and opinions in these files are invented. diff --git a/src/content/_fixtures/posts/container-queries-made-cards-portable.mdx b/src/content/_fixtures/posts/container-queries-made-cards-portable.mdx new file mode 100644 index 0000000..9b080bb --- /dev/null +++ b/src/content/_fixtures/posts/container-queries-made-cards-portable.mdx @@ -0,0 +1,63 @@ +--- +title: Container queries finally made my cards portable +description: A card that renders correctly in a sidebar, a three-column grid, and a full-width list without any of them knowing about each other. Viewport breakpoints could never do this, and I had stopped noticing how much that cost. +date: 2026-07-15 +thumbnail: null +author: Ayu Lestari +tags: + - css + - accessibility +--- + +Every component library eventually grows a `variant` prop that exists purely to +describe how much horizontal room the component has. `compact`, `dense`, +`inline`, `sidebar`. None of those are really variants — they are the component +asking the caller a question that the layout already knows the answer to. + +## The problem, stated precisely + +A viewport media query answers "how wide is the window". A component almost +never wants that. It wants "how wide am I", and those two diverge the moment +the component appears anywhere other than the main column. + +So you thread a prop down, and now the card's appearance is a function of where +the caller thinks it is, which drifts the first time someone moves it. + +## What replaced it + +```css +.card { + container-type: inline-size; +} + +@container (width >= 24rem) { + .card__body { + display: grid; + grid-template-columns: 8rem 1fr; + gap: 1rem; + } +} +``` + +The card now answers its own question. Drop it in a 20rem sidebar and it +stacks; drop it in a 60rem column and it goes side-by-side. The caller passes +nothing. + +## Where it changed my markup + +The interesting part was not the queries — it was that I deleted props. One +component lost four boolean props and the branching that went with them. The +grid page and the sidebar now render the _same_ element with the same +attributes. + +## The accessibility angle I did not expect + +Reflow at 320px (WCAG 1.4.10) gets easier, because you are no longer reasoning +about viewport width in one place and actual available width in another. If the +container is narrow, the component is narrow. There is no combination of nested +layouts that produces a surprise. + +One caveat worth knowing: `container-type: inline-size` establishes containment, +so the element can no longer be sized by its own contents in the inline +direction. That bit me once on a element that was relying on intrinsic width, +and the fix was simply to move the container one level out. diff --git a/src/content/_fixtures/posts/discriminated-unions-beat-boolean-flags.mdx b/src/content/_fixtures/posts/discriminated-unions-beat-boolean-flags.mdx new file mode 100644 index 0000000..5111d28 --- /dev/null +++ b/src/content/_fixtures/posts/discriminated-unions-beat-boolean-flags.mdx @@ -0,0 +1,72 @@ +--- +title: Discriminated unions beat boolean flags +description: Four booleans describe sixteen states, and your component probably only handles four of them. Modelling the states you actually have makes the impossible ones unrepresentable. +date: 2026-06-19 +thumbnail: null +author: Rina Prakoso +tags: + - typescript + - react +--- + +Here is a props interface I have written, and seen, many times: + +```ts +interface Props { + isLoading: boolean; + isError: boolean; + isEmpty: boolean; + data?: Item[]; +} +``` + +Four fields, sixteen combinations. How many are real? Four. The other twelve — +loading _and_ error, empty _with_ data, error _with_ data — are states the +component will happily be handed and has no defined behaviour for. + +## Model the states, not the flags + +```ts +type Props = + | { status: 'loading' } + | { status: 'error'; error: Error } + | { status: 'empty' } + | { status: 'ready'; data: Item[] }; +``` + +Now the impossible states cannot be constructed. `data` exists only where it is +meaningful, so the optional-chaining disappears from the render body: + +```tsx +function List(props: Props) { + switch (props.status) { + case 'loading': + return ; + case 'error': + return {props.error.message}; + case 'empty': + return ; + case 'ready': + return ; // no `?.`, no `?? []` + } +} +``` + +## The part that pays off later + +Add a `refreshing` state — data on screen, fetch in flight. With booleans you +add a fifth flag and every existing branch silently keeps compiling while +quietly being wrong. + +With a union, you add a member and the compiler walks you through every place +that needs a decision. Pair it with an `assertNever` default and the walk is +mandatory. + +## Where it does not pay + +Genuinely independent booleans. `isDisabled` and `isRequired` on an input are +orthogonal — all four combinations are meaningful, and forcing them into a union +produces names like `disabled-and-required` that carry no extra information. + +The test is simple: write out the combinations. If some of them are nonsense, +you have a union pretending to be flags. diff --git a/src/content/_fixtures/posts/focus-management-nobody-sees.mdx b/src/content/_fixtures/posts/focus-management-nobody-sees.mdx new file mode 100644 index 0000000..25b8cd1 --- /dev/null +++ b/src/content/_fixtures/posts/focus-management-nobody-sees.mdx @@ -0,0 +1,66 @@ +--- +title: Focus management is the accessibility work nobody sees +description: Colour contrast gets audited and alt text gets reviewed, but focus is where single-page apps quietly fall apart — and it is invisible unless you unplug your mouse. +date: 2026-07-04 +thumbnail: null +author: Ayu Lestari +tags: + - accessibility + - react +--- + +Run an automated accessibility checker over a typical single-page app and it +will pass. Then navigate it with the keyboard alone and discover that after +every route change, focus is sitting on `` and the next Tab starts from +the top of the page. + +Automated tools check a snapshot. Focus is a property of a _sequence_. + +## What breaks, and why it is invisible + +A full page load resets focus to the document and screen readers announce the +new page. Client-side navigation does neither: the URL changes, the DOM swaps, +and as far as assistive technology is concerned nothing happened at all. + +The user hears silence and lands nowhere. + +## The minimum that fixes it + +Three things, in order of how often they are missing. + +**Move focus deliberately after navigation.** Not to the top of the document — +to the heading of the new content: + +```tsx +const headingRef = useRef(null); + +useEffect(() => { + headingRef.current?.focus(); +}, [pathname]); + +return ( +

+ {title} +

+); +``` + +`tabIndex={-1}` makes it programmatically focusable without adding it to the Tab +order. This is the whole trick and it is two lines. + +**Announce the change.** A polite live region that receives the new page title +covers screen reader users who are not tracking focus. + +**Return focus when a layer closes.** Anything that opens over the page — a +drawer, a dialog, a command palette — must send focus back to whatever opened +it. Otherwise dismissing a menu drops the user at the start of the document. + +## The test that costs nothing + +Unplug the mouse. Navigate to an article, open the menu, close it with Escape, +follow a link, come back. If at any point you cannot tell where you are, a +keyboard user cannot either. + +I have never done this on a site — mine or anyone else's — without finding +something. The bar is not "no violations reported". The bar is that the journey +makes sense. diff --git a/src/content/_fixtures/posts/narrowing-unions-without-type-guards.mdx b/src/content/_fixtures/posts/narrowing-unions-without-type-guards.mdx new file mode 100644 index 0000000..5ec8029 --- /dev/null +++ b/src/content/_fixtures/posts/narrowing-unions-without-type-guards.mdx @@ -0,0 +1,84 @@ +--- +title: Narrowing unions without writing type guards +description: Custom type predicates are the tool everyone reaches for first, and most of the time they are unnecessary. Discriminants, the `in` operator, and exhaustive switches usually get you there with less code to keep honest. +date: 2026-07-28 +thumbnail: null +author: Rina Prakoso +tags: + - typescript +--- + +A custom type guard is a promise the compiler cannot check. When you write +`function isCat(a: Animal): a is Cat`, TypeScript takes your word for it — the +body could return `true` unconditionally and nothing would complain. That makes +predicates the one place in a well-typed codebase where a refactor can silently +go wrong. + +Most of the time you do not need one. + +## Discriminants do the work for free + +If a union member carries a literal-typed field, narrowing is automatic: + +```ts +type Result = { status: 'ok'; value: string } | { status: 'error'; message: string }; + +function render(result: Result) { + if (result.status === 'ok') { + return result.value; // narrowed, no predicate involved + } + return result.message; +} +``` + +The compiler verifies the relationship rather than trusting it. Rename `status` +and every branch fails loudly. + +## The `in` operator for shapes you do not control + +Third-party unions often lack a discriminant. `in` narrows on property presence +without you asserting anything: + +```ts +function describe(input: { file: File } | { url: string }) { + return 'file' in input ? input.file.name : input.url; +} +``` + +## Exhaustiveness as a build-time alarm + +The pattern worth adopting everywhere is the `never` fallthrough. Add a union +member and every switch that forgot about it fails to compile: + +```ts +function assertNever(value: never): never { + throw new Error(`Unhandled variant: ${JSON.stringify(value)}`); +} + +function toLabel(result: Result) { + switch (result.status) { + case 'ok': + return 'Succeeded'; + case 'error': + return 'Failed'; + default: + return assertNever(result); + } +} +``` + +This is the single highest-leverage line in the file. It converts "someone will +remember to update this" into a compiler error. + +## When a predicate is genuinely right + +Two cases justify one: narrowing from `unknown` at a trust boundary, and +narrowing arrays via `.filter`. For the first, do not hand-write it — derive it +from a schema so the check and the type share one source: + +```ts +const isPost = (input: unknown): input is Post => postSchema.safeParse(input).success; +``` + +Now the predicate cannot drift from the type, because the type is inferred from +the same schema doing the checking. diff --git a/src/content/_fixtures/posts/react-compiler-changed-how-i-memo.mdx b/src/content/_fixtures/posts/react-compiler-changed-how-i-memo.mdx new file mode 100644 index 0000000..5e4b86f --- /dev/null +++ b/src/content/_fixtures/posts/react-compiler-changed-how-i-memo.mdx @@ -0,0 +1,67 @@ +--- +title: The React Compiler changed how I reach for memo +description: After a month on the compiler, the useMemo calls I used to write reflexively are mostly gone — but a few categories survived, and understanding which ones taught me more about memoization than years of adding it by hand. +date: 2026-07-22 +thumbnail: null +author: Bayu Santoso +tags: + - react + - performance +--- + +The pitch for the React Compiler is that you stop writing `useMemo`, +`useCallback`, and `memo` by hand. That is broadly true. What the pitch skips is +that a handful of memoizations are not about rendering at all, and those stay. + +## What the compiler took over + +Everything whose only job was referential stability across renders. This is the +bulk of it: + +```tsx +// Before — hand-written, load-bearing only because of the dependency array +const sorted = useMemo(() => items.sort(byDate), [items]); +const onSelect = useCallback((id: string) => setSelected(id), []); +``` + +The compiler now derives both. Deleting them made components meaningfully +easier to read, because a dependency array is a second, parallel description of +the data flow that has to be kept in sync with the first. + +## What survived + +Three categories, and they have something in common: the memo is not an +optimization, it is a correctness or lifetime concern. + +### External resources with a lifecycle + +```tsx +const observer = useMemo(() => new IntersectionObserver(onIntersect), [onIntersect]); +``` + +Constructing an observer on every render is not merely slow — it leaks +subscriptions. The compiler will not reason about that for you. + +### Genuinely expensive pure work + +The compiler memoizes based on render-scope analysis, not cost. If a function +takes 40ms it is worth being explicit, because the intent belongs in the code: + +```tsx +const index = useMemo(() => buildSearchIndex(documents), [documents]); +``` + +### Identity consumed outside React + +Anything handed to a non-React system — a map instance, a WebGL context, a +third-party chart that diffs by reference — needs a stability guarantee you can +point at. + +## The rule I settled on + +If removing the memo would only make rendering slower, delete it and let the +compiler work. If removing it would change behaviour, leak, or break something +outside React, keep it and write a comment saying which. + +That distinction was always the real rule. The compiler just stopped letting me +paper over it with reflexive `useMemo`. diff --git a/src/content/_fixtures/posts/reading-vite-dependency-prebundling.mdx b/src/content/_fixtures/posts/reading-vite-dependency-prebundling.mdx new file mode 100644 index 0000000..4eca1ad --- /dev/null +++ b/src/content/_fixtures/posts/reading-vite-dependency-prebundling.mdx @@ -0,0 +1,72 @@ +--- +title: Reading Vite's dependency pre-bundling +description: Why the dev server converts your node_modules to ESM before serving anything, what actually triggers a re-bundle, and how to read the optimizer's mind when it decides to reload the page underneath you. +date: 2026-06-02 +thumbnail: null +author: Bayu Santoso +tags: + - tooling + - performance +--- + +The first time a Vite dev server tells you it is "re-optimizing dependencies" +and reloads the page mid-edit, it feels arbitrary. It is not. The rule is +learnable and knowing it removes a whole category of confusing dev-server +behaviour. + +## Why pre-bundling exists at all + +Two reasons, and only the second is about speed. + +**Correctness.** Plenty of packages still ship CommonJS or UMD. Browsers cannot +`import` those. The optimizer converts them to ESM so the browser can. + +**Request count.** A package that internally splits into 600 modules would mean +600 requests on first load. Pre-bundling collapses each dependency into one. + +Your own source is never pre-bundled — it is served as-is, transformed on +demand. That asymmetry is the whole design: source changes constantly and is +already ESM; dependencies change rarely and often are not. + +## What triggers a re-bundle + +The optimizer keys its cache on a hash of inputs that includes your lockfile, +the relevant `vite.config` fields, and the set of discovered imports. Change any +of them and it re-runs. + +The one that surprises people is the third. Vite scans your entry points at +startup to discover which dependencies you use. Import something that was not +reachable at scan time — inside a lazily loaded route, say, or behind a +condition — and it is found only when the browser requests it. That is a +discovery _after_ optimization, so the optimizer re-runs and the page reloads. + +## Making it stop + +If a dependency is reached only through a path the scanner cannot see, name it: + +```ts +export default defineConfig({ + optimizeDeps: { + include: ['some-lazily-reached-package'], + }, +}); +``` + +The reverse also comes up. A package shipping a `"use client"` directive, or one +you want left alone for boundary reasons, gets excluded: + +```ts +export default defineConfig({ + optimizeDeps: { + exclude: ['lucide-react'], + }, +}); +``` + +## When the cache is genuinely stale + +Linked local packages are the common case — their contents change without the +lockfile changing, so the hash does not move. Deleting `node_modules/.vite` +forces a rebuild. If you find yourself doing that routinely, that is the signal +to reach for `include`/`exclude` instead, because routine cache-clearing means +the optimizer's model of your graph is wrong. diff --git a/src/content/_fixtures/posts/the-cost-of-a-dependency.mdx b/src/content/_fixtures/posts/the-cost-of-a-dependency.mdx new file mode 100644 index 0000000..fba63d5 --- /dev/null +++ b/src/content/_fixtures/posts/the-cost-of-a-dependency.mdx @@ -0,0 +1,56 @@ +--- +title: The cost of a dependency is not its bundle size +description: Bundlephobia tells you the cheapest thing to measure. The expensive costs — API surface you did not choose, upgrade coupling, and the behaviour you now cannot change — never show up in kilobytes. +date: 2026-04-30 +thumbnail: null +author: Devsantara Team +tags: + - tooling + - performance +--- + +The standard way to evaluate a package is to look up its minified-and-gzipped +size, decide whether the number feels acceptable, and install it. That check is +not wrong. It is just measuring the one cost that is easiest to measure and, in +most cases, the one that matters least. + +## Costs that do not appear in kilobytes + +**API surface you did not choose.** A date library with 200 functions does not +constrain you at 12KB — it constrains you because five different developers will +reach for five different functions to do the same thing, and consistency now +requires a lint rule you have to write and maintain. + +**Upgrade coupling.** Every dependency is a scheduling dependency. When it drops +support for the runtime you are on, or takes a major that your other +dependencies have not adopted yet, its timeline becomes yours. + +**Behaviour you can no longer change.** This is the expensive one. A dependency +that renders UI has made accessibility decisions, focus decisions, and +animation decisions on your behalf. If one of them is wrong for you, your +options are to fork it, patch it, or work around it. All three cost more than +the code would have. + +## The questions worth asking instead + +Before installing, three things: + +1. **How much of it will we use?** Using 5% means carrying 95% of the + maintenance surface for none of the benefit. +2. **What happens when it is unmaintained?** Not "if". Can you vendor the part + you use in an afternoon, or is it load-bearing across the app? +3. **Does it make a decision we care about?** Formatting a date is not a + decision. Managing focus, trapping keyboards, and animating layers very much + are. + +## When installing is obviously right + +Correctness-critical work with real edge cases — timezone arithmetic, +internationalisation, cryptography, accessible interaction primitives. In each +of these, hand-rolling means reimplementing years of bug fixes you have not +heard of yet. + +The heuristic I use: **depend on other people's edge cases, own your own +decisions.** A library that knows about the 1582 calendar cutover is doing work +you should not repeat. A library that decides how your menu behaves on Escape is +making a call you should be making. diff --git a/src/content/_fixtures/posts/what-prerendering-actually-buys-you.mdx b/src/content/_fixtures/posts/what-prerendering-actually-buys-you.mdx new file mode 100644 index 0000000..4bedf82 --- /dev/null +++ b/src/content/_fixtures/posts/what-prerendering-actually-buys-you.mdx @@ -0,0 +1,60 @@ +--- +title: What prerendering actually buys you +description: Everyone frames static generation as a speed optimization. The latency win is real but modest — the durability win is the one that changed how I think about deploying content sites. +date: 2026-05-21 +thumbnail: null +author: Devsantara Team +tags: + - cloudflare + - performance + - tooling +--- + +Prerendering a content site is usually justified with a number: time to first +byte drops from something like 180ms to something like 20ms. That number is +real. It is also not the reason to do it. + +## The latency argument, honestly + +Server-rendering a Markdown page is not slow. The content is already parsed at +build time, the render is a few milliseconds, and a CDN in front of it absorbs +almost everything. If you put `s-maxage` on an SSR route you recover most of the +gap without prerendering anything. + +So if you are choosing purely on latency, the honest answer is that it is a +modest win. + +## The argument that actually matters + +A prerendered page has no failure modes at request time. + +There is no runtime to cold-start, no origin to be unreachable, no code path +that can throw on a request that happened to arrive during a bad deploy. The +page was either produced correctly at build time — where a failure blocks the +deploy and you find out immediately — or it was not produced at all. + +That moves an entire class of production incidents to build time, which is the +only place you can afford to have them. + +## The second-order effects + +Once every page exists as a file at build time, other things get easier: + +| Task | Server-rendered | Prerendered | +| ----------------- | -------------------------------------------- | -------------------------------- | +| Search index | Query at runtime, or build a second pipeline | Read the output you already have | +| Broken-link check | Crawl a running server | Walk the output directory | +| Sitemap | Maintain a route list by hand | Derive from what was emitted | +| Regression review | Diff behaviour | Diff files | + +That last one is underrated. A build that emits files means a content change +produces a reviewable diff of rendered output. + +## What it costs + +Build time scales with content. At a few hundred pages this is irrelevant; at +tens of thousands you will want incremental builds and that is a real project. + +And every publish requires a deploy. For a git-backed blog this is not a cost — +publishing _was_ a git push already. For anything with a CMS and non-technical +authors, it very much is, and that is the case where the calculus flips. diff --git a/src/content/_fixtures/series/shipping-on-the-edge/01_why-the-edge-changes-your-code.mdx b/src/content/_fixtures/series/shipping-on-the-edge/01_why-the-edge-changes-your-code.mdx new file mode 100644 index 0000000..72aedfd --- /dev/null +++ b/src/content/_fixtures/series/shipping-on-the-edge/01_why-the-edge-changes-your-code.mdx @@ -0,0 +1,59 @@ +--- +title: Why the edge changes the shape of your code +description: The constraints of an edge runtime look like a list of things you cannot do. Most of them are things you should not have been doing, and the list is shorter than it first appears. +date: 2026-06-25 +thumbnail: null +author: Devsantara Team +tags: + - cloudflare + - performance +--- + +The first encounter with an edge runtime is a list of absences. No `fs`. No +long-lived process. No native modules. Tight CPU limits per request. + +It reads as a downgrade. It mostly is not. + +## The constraint that matters: no process memory + +In a Node server, module scope survives between requests. That is why the +in-memory cache you wrote works, and why the connection pool exists. + +At the edge, your code may run in an isolate that was created for this request +and disposed after it. Anything you stored in module scope may or may not be +there next time, and you cannot tell which. + +```ts +// Works on one server. Silently useless across hundreds of isolates. +const cache = new Map(); + +export async function getUser(id: string) { + if (cache.has(id)) return cache.get(id); + const user = await db.users.find(id); + cache.set(id, user); + return user; +} +``` + +The bug is not that this crashes. It is that it appears to work — a hit rate +near zero looks exactly like a cold cache. + +## Why this is an improvement + +Process-local state was always a lie in any horizontally scaled system. Two Node +instances behind a load balancer have the same problem; the edge just makes it +impossible to ignore. + +Being forced to name where state lives — a KV store, a durable object, a cache +API, the request itself — produces a system you can reason about. + +## The CPU limit is a design brief + +Per-request CPU budgets sound alarming until you notice what they exclude: +waiting on I/O usually does not count. What counts is _your_ computation. + +If you are inside the limit, you are doing per-request work proportional to the +response. If you are outside it, you are doing something that belongs at build +time or in a queue — and that was true before you moved to the edge. + +Next: where data lives when your compute is everywhere and your database is not. diff --git a/src/content/_fixtures/series/shipping-on-the-edge/02_data-at-the-edge.mdx b/src/content/_fixtures/series/shipping-on-the-edge/02_data-at-the-edge.mdx new file mode 100644 index 0000000..27810b7 --- /dev/null +++ b/src/content/_fixtures/series/shipping-on-the-edge/02_data-at-the-edge.mdx @@ -0,0 +1,63 @@ +--- +title: Data at the edge +description: Compute in three hundred locations and a database in one is a recipe for slower responses than you started with. The three ways out, and how to tell which one your workload needs. +date: 2026-06-26 +thumbnail: null +author: Bayu Santoso +tags: + - cloudflare + - performance +--- + +Here is the failure mode nobody warns you about: you move your app to the edge, +measure, and it is _slower_. + +The reason is arithmetic. Your compute moved next to the user. Your database did +not. A request that used to make four sequential queries over a 1ms link inside +one datacentre now makes four sequential queries over a 140ms link. + +You did not speed anything up. You moved the compute away from the data. + +## Rule one: count the round trips + +Before anything else, count sequential dependent queries in your hottest path. +Four round trips at 140ms is 560ms of pure waiting, and no amount of edge +proximity recovers it. + +Two fixes apply before any architectural change: + +- **Parallelise what is independent.** `Promise.all` is free latency. +- **Collapse dependent chains into one query.** One join beats three lookups by + more than the join costs. + +Do these first. They often make the rest unnecessary. + +## The three shapes of solution + +**Move the data to the edge.** Replicated key-value stores put a copy in every +location. Reads become local and fast. The cost is consistency — a write is +visible everywhere _eventually_, on the order of seconds. Correct for content, +configuration, and feature flags. Wrong for anything a user expects to see +immediately after writing it. + +**Pin the compute to the data.** Keep the request handler at the edge for static +and cached work, and route the parts that need the database to a location beside +it. You pay one long trip instead of four. + +**Give each entity its own home.** Durable objects and similar primitives place a +single-threaded, consistent object somewhere specific, and route all traffic for +that entity to it. Excellent for per-document or per-room state. Not a +general-purpose database. + +## Choosing + +Ask what the data's read-to-write ratio is and how stale a read may be. + +Overwhelmingly read-heavy and tolerant of seconds of staleness — replicate it. +Write-heavy or read-your-writes — pin the compute, or give the entity a home. + +Most applications need more than one of these, and the mistake is picking one +globally instead of per data type. + +Next: caching, which is the cheapest version of "move the data to the edge" and +the one most people get subtly wrong. diff --git a/src/content/_fixtures/series/shipping-on-the-edge/03_caching-that-actually-works.mdx b/src/content/_fixtures/series/shipping-on-the-edge/03_caching-that-actually-works.mdx new file mode 100644 index 0000000..88e998e --- /dev/null +++ b/src/content/_fixtures/series/shipping-on-the-edge/03_caching-that-actually-works.mdx @@ -0,0 +1,63 @@ +--- +title: Caching that actually works +description: One Cache-Control header is really two policies — the browser's and the CDN's — and conflating them is why a deploy sometimes takes a week to reach half your users. +date: 2026-06-27 +thumbnail: null +author: Rina Prakoso +tags: + - cloudflare + - performance + - tooling +--- + +Most caching bugs are not subtle. They come from writing one header and +expecting it to mean the same thing in two very different caches. + +## Two caches, two lifetimes + +A browser cache is private, small, and — critically — **you cannot purge it**. +Once a response is in a user's browser with `max-age=86400`, it is there for a +day. No deploy reaches it. + +A CDN cache is shared, large, and purgeable. You can invalidate it at will. + +These want opposite settings. The CDN should hold things for a long time, since +you can always clear it. The browser should hold things briefly, since you +cannot. + +``` +Cache-Control: public, max-age=300, stale-while-revalidate=60 +CDN-Cache-Control: s-maxage=604800, stale-while-revalidate=86400 +``` + +Five minutes in the browser, a week at the edge. A deploy plus a purge reaches +everyone within five minutes, worst case. Set `max-age=604800` and the same +deploy reaches some users next week. + +## `stale-while-revalidate` is the good part + +It says: serve the stale copy immediately, and refresh in the background. The +user waits for nothing and the next user gets fresh content. + +For anything where a few seconds of staleness is invisible — which is most +content — this converts a cache miss from "user waits for the origin" into "user +waits for nothing". + +## The one that is not a duration + +`immutable` promises the content at this URL will never change. It is only safe +with a content hash in the filename, and there it is exactly right: a year of +caching with no revalidation requests at all. + +Applied to a URL that _can_ change, it is the least recoverable caching mistake +available, because there is no purge for browsers. + +## The rule + +Before setting any cache header, answer: _if this is wrong, how do I fix it?_ + +If the answer is "purge the CDN", be generous. If the answer is "wait for it to +expire in every browser that has it", be conservative. + +Next: what to do when a cached response is wrong and you cannot reproduce it +locally. diff --git a/src/content/_fixtures/series/shipping-on-the-edge/04_observability-without-a-server.mdx b/src/content/_fixtures/series/shipping-on-the-edge/04_observability-without-a-server.mdx new file mode 100644 index 0000000..ca7a748 --- /dev/null +++ b/src/content/_fixtures/series/shipping-on-the-edge/04_observability-without-a-server.mdx @@ -0,0 +1,89 @@ +--- +title: Observability without a server +description: There is nothing to SSH into, the failure happened in a city you have never been to, and it has not happened since. What you instrument before that day decides whether you can answer it. +date: 2026-06-28 +thumbnail: null +author: Ayu Lestari +tags: + - cloudflare + - accessibility + - tooling +--- + +The last thing the edge takes away is the debugging workflow you have relied on +for your whole career: connect to the machine, read the logs, reproduce it +locally. + +There is no machine. The isolate that served the failing request stopped +existing when it returned. It ran in a location you cannot reach and there were +three hundred others running concurrently. + +Everything you will ever know about that request is what you decided to record +before it happened. + +## Log the request, not the line + +Scattered `console.log` calls are close to useless when you cannot correlate +them. One structured record per request is worth more than fifty loose lines: + +```ts +const start = performance.now(); +const requestId = crypto.randomUUID(); + +try { + const response = await handle(request); + log({ + requestId, + colo: request.cf?.colo, + path: new URL(request.url).pathname, + status: response.status, + cache: response.headers.get('cf-cache-status'), + ms: Math.round(performance.now() - start), + }); + return response; +} catch (error) { + log({ requestId, level: 'error', message: String(error) }); + throw error; +} +``` + +The `colo` field is the one people leave out and then wish they had. "Only in +Singapore" is a diagnosis; "sometimes" is not. + +## Sample, but never sample errors + +At volume you cannot keep everything. Sample successes aggressively — one in a +hundred is plenty to see a latency distribution. + +Keep every error. Errors are rare by definition, and the one you dropped is the +one you needed. + +## Return the request ID + +Put it in a response header. When someone reports a problem, you get a key +straight into your logs instead of a timestamp and a guess. + +```ts +response.headers.set('X-Request-Id', requestId); +``` + +This is a two-line change and it turns most "cannot reproduce" reports into a +single lookup. + +## Alert on rates, not counts + +At the edge, some absolute number of errors per minute is meaningless — it moves +with traffic. Alert on error _rate_ and on latency percentiles. + +A p50 that looks fine while p99 has tripled is the normal shape of an edge +problem: one region degraded, everywhere else healthy, averages hiding it +completely. + +## Where the series lands + +The edge does not remove complexity, it relocates it. State becomes explicit, +caching becomes two policies instead of one, and debugging becomes something you +design rather than something you do afterwards. + +All three of those are things you should have been doing on a single server. +The edge just stops making it optional. diff --git a/src/content/_fixtures/series/shipping-on-the-edge/_index.mdx b/src/content/_fixtures/series/shipping-on-the-edge/_index.mdx new file mode 100644 index 0000000..0482687 --- /dev/null +++ b/src/content/_fixtures/series/shipping-on-the-edge/_index.mdx @@ -0,0 +1,33 @@ +--- +title: Shipping on the Edge +description: A four-part walk through what actually changes when your application runs in hundreds of places at once — the shape of your code, where your data lives, what caching means, and how you debug something with no server to log into. +date: 2026-06-25 +thumbnail: null +--- + +Edge runtimes are usually introduced as "the same thing, but closer to users". +That framing gets you through a hello-world and then quietly stops being true. + +Running in three hundred locations simultaneously is not a deployment detail. It +changes what a request costs, where state can live, which APIs exist, and how +you find out what went wrong. This series works through those consequences in +the order you actually hit them. + +## What this covers + +Four parts, each building on the one before: + +1. **Why the edge changes the shape of your code** — the constraints that come + from a runtime that is not Node, and why they are mostly good for you. +2. **Data at the edge** — the round-trip problem, and the three shapes of + solution. +3. **Caching that actually works** — why `Cache-Control` is two headers on a + CDN, and how to reason about invalidation. +4. **Observability without a server** — finding failures you cannot reproduce + locally. + +## Who it is for + +You have deployed something to an edge platform and it worked, and now you are +trying to decide whether to move something real there. It assumes you are +comfortable with HTTP caching semantics and have written a backend before. diff --git a/src/modules/content/content.source.ts b/src/modules/content/content.source.ts new file mode 100644 index 0000000..e786cb3 --- /dev/null +++ b/src/modules/content/content.source.ts @@ -0,0 +1,31 @@ +import { + allFixturePosts, + allFixtureSeries, + allFixtureSeriesPosts, + allPosts, + allSeries, + allSeriesPosts, +} from 'content-collections'; + +/** + * The site's documents, with fixture content folded in. + * + * Fixtures (`src/content/_fixtures`) exist so development renders against + * dense, realistic content rather than the handful of real documents — index + * pages with one item hide most layout problems. They are compiled only in + * development: the production build collects them with a glob that matches + * nothing, so `allFixture*` is empty there and these arrays are identical to + * the real collections once deployed. + * + * That is why nothing here branches on the environment, and why every consumer + * can read these instead of the raw collections without caring that fixtures + * exist at all. Deleting the fixtures is a matter of deleting the directory and + * the `fixture*` collections; this module keeps working. + */ +export const postDocuments = [...allPosts, ...allFixturePosts]; + +/** Series roots. See {@link postDocuments} for how fixtures are folded in. */ +export const seriesDocuments = [...allSeries, ...allFixtureSeries]; + +/** Series parts. See {@link postDocuments} for how fixtures are folded in. */ +export const seriesPostDocuments = [...allSeriesPosts, ...allFixtureSeriesPosts]; From 4e88c470f7b79f054d29e4db5df0b63b30fe1195 Mon Sep 17 00:00:00 2001 From: Edwin Tantawi Date: Sat, 1 Aug 2026 20:36:27 +0700 Subject: [PATCH 03/11] feat(content): read documents through the fixture-aware source Routes their collection access through content.source so development renders against fixture content, and carries the new reading-time estimate onto every returned item. --- src/modules/post/post.fn.tsx | 8 +++++--- src/modules/series/series.fn.tsx | 16 ++++++++++------ 2 files changed, 15 insertions(+), 9 deletions(-) diff --git a/src/modules/post/post.fn.tsx b/src/modules/post/post.fn.tsx index 4ec08dd..8bdda09 100644 --- a/src/modules/post/post.fn.tsx +++ b/src/modules/post/post.fn.tsx @@ -1,15 +1,15 @@ import { notFound } from '@tanstack/react-router'; import { createServerFn } from '@tanstack/react-start'; import { renderServerComponent } from '@tanstack/react-start/rsc'; -import { allPosts } from 'content-collections'; import * as z from 'zod/v4'; +import { postDocuments } from '#/modules/content/content.source'; import { MarkdownRender } from '#/modules/markdown'; import type { PostContent, PostItem } from '#/modules/post/post.types'; import { parseThumbnail } from '#/modules/thumbnail/thumbnail.utils'; export const getAllPostsFn = createServerFn({ method: 'GET' }).handler((): PostItem[] => { - return [...allPosts] + return [...postDocuments] .sort((a, b) => b.date.localeCompare(a.date)) .map((post) => { return { @@ -21,6 +21,7 @@ export const getAllPostsFn = createServerFn({ method: 'GET' }).handler((): PostI tags: post.tags, thumbnail: post.thumbnail, lastModification: post.lastModification, + readingTime: post.readingTime, }; }); }); @@ -28,7 +29,7 @@ export const getAllPostsFn = createServerFn({ method: 'GET' }).handler((): PostI export const getPostBySlugFn = createServerFn({ method: 'GET' }) .validator(z.object({ slug: z.string() })) .handler(async ({ data }): Promise => { - const post = allPosts.find((post) => post.slug === data.slug); + const post = postDocuments.find((post) => post.slug === data.slug); if (!post) throw notFound(); return { @@ -40,6 +41,7 @@ export const getPostBySlugFn = createServerFn({ method: 'GET' }) tags: post.tags, thumbnail: parseThumbnail(post.thumbnail), lastModification: post.lastModification, + readingTime: post.readingTime, toc: post.toc, mdx: await renderServerComponent(), }; diff --git a/src/modules/series/series.fn.tsx b/src/modules/series/series.fn.tsx index f56bc3f..1678755 100644 --- a/src/modules/series/series.fn.tsx +++ b/src/modules/series/series.fn.tsx @@ -1,9 +1,9 @@ import { notFound } from '@tanstack/react-router'; import { createServerFn } from '@tanstack/react-start'; import { renderServerComponent } from '@tanstack/react-start/rsc'; -import { allSeries, allSeriesPosts } from 'content-collections'; import * as z from 'zod/v4'; +import { seriesDocuments, seriesPostDocuments } from '#/modules/content/content.source'; import { MarkdownRender } from '#/modules/markdown'; import type { SeriesContent, @@ -14,7 +14,7 @@ import type { import { parseThumbnail } from '#/modules/thumbnail/thumbnail.utils'; export const getAllSeriesFn = createServerFn({ method: 'GET' }).handler((): SeriesItem[] => { - return [...allSeries] + return [...seriesDocuments] .sort((a, b) => b.date.localeCompare(a.date)) .map((series) => { return { @@ -24,6 +24,7 @@ export const getAllSeriesFn = createServerFn({ method: 'GET' }).handler((): Seri date: series.date, thumbnail: series.thumbnail, lastModification: series.lastModification, + readingTime: series.readingTime, }; }); }); @@ -31,10 +32,10 @@ export const getAllSeriesFn = createServerFn({ method: 'GET' }).handler((): Seri export const getSeriesBySlugFn = createServerFn({ method: 'GET' }) .validator(z.object({ slug: z.string() })) .handler(async ({ data }): Promise => { - const series = allSeries.find((series) => series.slug === data.slug); + const series = seriesDocuments.find((series) => series.slug === data.slug); if (!series) throw notFound(); - const posts: SeriesPostItem[] = allSeriesPosts + const posts: SeriesPostItem[] = seriesPostDocuments .filter((post) => post.seriesSlug === data.slug) .sort((a, b) => a.order - b.order) .map((post) => { @@ -49,6 +50,7 @@ export const getSeriesBySlugFn = createServerFn({ method: 'GET' }) // A post without its own thumbnail inherits the series' one. thumbnail: post.thumbnail ?? series.thumbnail, lastModification: post.lastModification, + readingTime: post.readingTime, series: { slug: post.seriesSlug }, }; }); @@ -60,6 +62,7 @@ export const getSeriesBySlugFn = createServerFn({ method: 'GET' }) date: series.date, thumbnail: parseThumbnail(series.thumbnail), lastModification: series.lastModification, + readingTime: series.readingTime, posts, toc: series.toc, mdx: await renderServerComponent(), @@ -69,13 +72,13 @@ export const getSeriesBySlugFn = createServerFn({ method: 'GET' }) export const getSeriesPostFn = createServerFn({ method: 'GET' }) .validator(z.object({ slug: z.string(), postSlug: z.string() })) .handler(async ({ data }): Promise => { - const post = allSeriesPosts.find( + const post = seriesPostDocuments.find( (post) => post.seriesSlug === data.slug && post.slug === data.postSlug, ); if (!post) throw notFound(); // A post without its own thumbnail inherits the series' one. - const series = allSeries.find((series) => series.slug === post.seriesSlug); + const series = seriesDocuments.find((series) => series.slug === post.seriesSlug); return { slug: post.slug, @@ -87,6 +90,7 @@ export const getSeriesPostFn = createServerFn({ method: 'GET' }) tags: post.tags, thumbnail: parseThumbnail(post.thumbnail ?? series?.thumbnail ?? null), lastModification: post.lastModification, + readingTime: post.readingTime, series: { slug: post.seriesSlug }, toc: post.toc, mdx: await renderServerComponent(), From 34f51f9aee40d1784ae39411000eb213838479a2 Mon Sep 17 00:00:00 2001 From: Edwin Tantawi Date: Sat, 1 Aug 2026 20:36:36 +0700 Subject: [PATCH 04/11] feat(content): add unified feed spanning posts and series parts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Standalone posts and series parts are separate collections with separate routes, but readers discover them together: a series part tagged react is invisible under that tag unless the two are unioned, which effectively unpublishes most of a series. FeedItem is the shape both flatten to, discriminated by kind so consumers branch on it rather than assembling hrefs from strings — that keeps link building inside the router's type checking. Series roots are excluded on purpose: a root is a table of contents for its parts, not something read on its own, so including it would surface the same material twice. Sorting falls back to title on equal dates, so adding a file cannot reshuffle a list and make prerendered output differ between builds. --- src/modules/content/content.fn.ts | 17 +++++++ src/modules/content/content.types.ts | 44 ++++++++++++++++++ src/modules/content/content.utils.ts | 69 ++++++++++++++++++++++++++++ 3 files changed, 130 insertions(+) create mode 100644 src/modules/content/content.fn.ts create mode 100644 src/modules/content/content.types.ts create mode 100644 src/modules/content/content.utils.ts diff --git a/src/modules/content/content.fn.ts b/src/modules/content/content.fn.ts new file mode 100644 index 0000000..dac3f8f --- /dev/null +++ b/src/modules/content/content.fn.ts @@ -0,0 +1,17 @@ +import { createServerFn } from '@tanstack/react-start'; + +import type { FeedItem } from '#/modules/content/content.types'; +import { collectFeedItems } from '#/modules/content/content.utils'; + +/** + * The unified feed: every standalone post and series part, newest first. + * + * `limit` caps the result for surfaces that show a slice — the home page shows + * the latest handful — while omitting it returns everything. + */ +export const getFeedFn = createServerFn({ method: 'GET' }) + .validator((limit: number | undefined) => limit) + .handler(({ data: limit }): FeedItem[] => { + const items = collectFeedItems(); + return limit === undefined ? items : items.slice(0, limit); + }); diff --git a/src/modules/content/content.types.ts b/src/modules/content/content.types.ts new file mode 100644 index 0000000..ab7bc95 --- /dev/null +++ b/src/modules/content/content.types.ts @@ -0,0 +1,44 @@ +/** + * A single entry in any list that spans both collections — the home feed, tag + * pages, search results, and "read next". + * + * Standalone posts and series parts are separate collections with separate + * routes, but readers discover them together: a series part tagged `react` has + * to appear under that tag or it is effectively unpublished. This is the shape + * both flatten to. + * + * `kind` is the discriminant. It decides which route the entry links to, so + * consumers branch on it rather than reconstructing an href from strings — + * which keeps link building inside TanStack Router's type checking. + */ +export type FeedItem = FeedPostItem | FeedSeriesPostItem; + +interface FeedItemBase { + slug: string; + title: string; + description: string; + /** ISO date (`YYYY-MM-DD`) from frontmatter. */ + date: string; + author: string; + tags: string[]; + /** Estimated read duration in whole minutes. */ + readingTime: number; +} + +/** A standalone post, living at `/posts/$slug`. */ +export interface FeedPostItem extends FeedItemBase { + kind: 'post'; +} + +/** One part of a series, living at `/series/$seriesSlug/$slug`. */ +export interface FeedSeriesPostItem extends FeedItemBase { + kind: 'series-post'; + series: { + slug: string; + title: string; + /** 1-based position within the series, from the file's numeric prefix. */ + order: number; + /** Total parts, so a card can say "Part 2 of 5" without a second query. */ + total: number; + }; +} diff --git a/src/modules/content/content.utils.ts b/src/modules/content/content.utils.ts new file mode 100644 index 0000000..d0b6788 --- /dev/null +++ b/src/modules/content/content.utils.ts @@ -0,0 +1,69 @@ +import { + postDocuments, + seriesDocuments, + seriesPostDocuments, +} from '#/modules/content/content.source'; +import type { FeedItem } from '#/modules/content/content.types'; + +/** + * Every document that a reader can land on, flattened to {@link FeedItem} and + * sorted newest first. + * + * Server-only: it reads the generated collections. Route loaders reach it + * through a server function rather than importing it directly. + * + * Series *roots* are deliberately absent. A root is a table of contents for its + * parts, not something you read on its own, so including it in a tag page or + * search results would surface the same material twice. + */ +export function collectFeedItems(): FeedItem[] { + const seriesTitleBySlug = new Map(seriesDocuments.map((series) => [series.slug, series.title])); + const partCountBySeries = new Map(); + for (const part of seriesPostDocuments) { + partCountBySeries.set(part.seriesSlug, (partCountBySeries.get(part.seriesSlug) ?? 0) + 1); + } + + const posts: FeedItem[] = postDocuments.map((post) => ({ + kind: 'post', + slug: post.slug, + title: post.title, + description: post.description, + date: post.date, + author: post.author, + tags: post.tags, + readingTime: post.readingTime, + })); + + const parts: FeedItem[] = seriesPostDocuments.map((part) => ({ + kind: 'series-post', + slug: part.slug, + title: part.title, + description: part.description, + date: part.date, + author: part.author, + tags: part.tags, + readingTime: part.readingTime, + series: { + slug: part.seriesSlug, + // A part cannot exist without its `_index.mdx` — the collection's + // `onSuccess` fails the build otherwise — so the lookup always hits. + title: seriesTitleBySlug.get(part.seriesSlug) ?? part.seriesSlug, + order: part.order, + total: partCountBySeries.get(part.seriesSlug) ?? 1, + }, + })); + + return [...posts, ...parts].sort(byNewestFirst); +} + +/** + * Sorts newest first, falling back to title so the order is total. + * + * Without the tiebreak, two documents sharing a date order by however the + * collections happened to be concatenated, which reshuffles lists whenever a + * file is added — invisible in review, and enough to make prerendered output + * differ between builds for no reason. + */ +export function byNewestFirst(a: FeedItem, b: FeedItem): number { + return b.date.localeCompare(a.date) || a.title.localeCompare(b.title); +} From 8636221cc771835f18ff6f98d7eb7627cc2c2354 Mon Sep 17 00:00:00 2001 From: Edwin Tantawi Date: Sat, 1 Aug 2026 20:36:52 +0700 Subject: [PATCH 05/11] feat(ui): add layout width tokens, page container, and site constants Three named widths drive every page: measure is the reading column at roughly 68 characters, breakout is what code, tables, and figures widen to inside an article, and shell frames the page with room for a table-of-contents rail from xl up. Tailwind derives max-w-* utilities from each, so pages reference the intent rather than a magic number. site.ts holds the constants that are neither translated nor per-environment, plus the absolute-URL and GitHub-edit helpers that canonicals, feeds, and article footers will each need. --- src/lib/site.ts | 34 +++++++++++++++++++++++++++++++++ src/ui/components/container.tsx | 15 +++++++++++++++ src/ui/styles/app.css | 13 +++++++++++++ 3 files changed, 62 insertions(+) create mode 100644 src/lib/site.ts create mode 100644 src/ui/components/container.tsx diff --git a/src/lib/site.ts b/src/lib/site.ts new file mode 100644 index 0000000..bf229fc --- /dev/null +++ b/src/lib/site.ts @@ -0,0 +1,34 @@ +import { clientEnv } from '#/lib/env/client'; + +/** + * Site-wide constants that are neither translated nor environment-specific. + * + * The brand name is deliberately not a Paraglide message: it is a proper noun + * and reads identically in every locale, so routing it through translation + * would only create an opportunity for the two to drift. + */ +export const site = { + name: 'devsantara', + repository: 'https://github.com/devsantara/website', + /** Branch that "edit this page" links target. */ + repositoryBranch: 'main', +} as const; + +/** + * Absolute URL for an app-relative path. + * + * Canonical links, Open Graph tags, the feed, and the sitemap are all invalid + * with relative URLs, so each of them resolves through here rather than + * concatenating the base URL by hand. + */ +export function absoluteUrl(path: string): string { + return new URL(path, clientEnv.VITE_BASE_URL).href; +} + +/** + * GitHub edit URL for a repository-relative file — the `.mdx` behind a post or + * series part — so a reader who spots a typo is one click from fixing it. + */ +export function sourceEditUrl(repositoryPath: string): string { + return `${site.repository}/edit/${site.repositoryBranch}/${repositoryPath}`; +} diff --git a/src/ui/components/container.tsx b/src/ui/components/container.tsx new file mode 100644 index 0000000..91c55fb --- /dev/null +++ b/src/ui/components/container.tsx @@ -0,0 +1,15 @@ +import type * as React from 'react'; + +import { cn } from '#/ui/utils'; + +/** + * The page's horizontal frame: caps width at the shell and supplies the gutter. + * + * Every full-bleed band — the header's inner row, the footer, a page's content — + * wraps its contents in one, which is what keeps them optically aligned down + * the page. Bands that need their own background stay full-width and put this + * inside, rather than being constrained themselves. + */ +export function Container({ className, ...props }: React.ComponentProps<'div'>) { + return
; +} diff --git a/src/ui/styles/app.css b/src/ui/styles/app.css index 1fc39fb..efa75f4 100644 --- a/src/ui/styles/app.css +++ b/src/ui/styles/app.css @@ -11,6 +11,19 @@ --font-heading: var(--font-sans); --font-sans: 'Geist Variable', sans-serif; --font-mono: 'Geist Mono Variable', monospace; + + /* + * Layout widths. Tailwind derives `max-w-*` / `w-*` utilities from each. + * + * `measure` is the reading column — roughly 68 characters at the body size, + * which is where prose stays comfortable to scan. `breakout` is what code + * blocks, tables, and figures widen to inside an article, since those want + * room that prose does not. `shell` frames the whole page and is sized to + * hold the measure plus one table-of-contents rail from `xl` up. + */ + --container-measure: 42rem; + --container-breakout: 56rem; + --container-shell: 80rem; --color-sidebar-ring: var(--sidebar-ring); --color-sidebar-border: var(--sidebar-border); --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); From 50637276f4869849143628fdd3b9f72616c2f601 Mon Sep 17 00:00:00 2001 From: Edwin Tantawi Date: Sat, 1 Aug 2026 20:37:10 +0700 Subject: [PATCH 06/11] feat(layout): add site shell with responsive nav, theme, and locale controls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every page now renders inside a shell: skip link, sticky masthead, a single main landmark, and a footer. Pages drop their own main element, since two would make the landmark ambiguous. Navigation collapses into a bottom drawer below md and sits inline above it. Both variants are always in the markup and switched with utility classes, never a media-query hook — the site is prerendered, so a JavaScript-chosen variant would bake one width's chrome into the HTML that every other width then corrects on hydration. The drawer's contents mount only while open, so the links never exist twice in the accessibility tree. The footer repeats the primary links on purpose. On small screens the header's copy lives inside a drawer that cannot open without JavaScript, so without them the site would have no reachable navigation in that case. The locale switcher is two plain anchors rather than a dropdown: a dropdown cannot open without JavaScript, and a client-side navigation would change the address bar while leaving the page in the previous language, since every string is resolved during the server render. Interface copy is translated into both locales. --- messages/en.json | 36 ++++++++- messages/id.json | 36 ++++++++- .../layout/components/locale-switcher.tsx | 60 ++++++++++++++ src/modules/layout/components/nav-drawer.tsx | 81 +++++++++++++++++++ src/modules/layout/components/site-footer.tsx | 77 ++++++++++++++++++ src/modules/layout/components/site-header.tsx | 58 +++++++++++++ src/modules/layout/components/site-shell.tsx | 30 +++++++ src/modules/layout/components/skip-link.tsx | 25 ++++++ .../layout/components/theme-toggle.tsx | 58 +++++++++++++ src/modules/layout/layout.nav.ts | 23 ++++++ src/routes/__root.tsx | 3 +- src/routes/index.tsx | 4 +- src/routes/posts/$slug.tsx | 4 +- src/routes/posts/index.tsx | 4 +- src/routes/series/$slug/$postSlug.tsx | 4 +- src/routes/series/$slug/index.tsx | 4 +- src/routes/series/index.tsx | 4 +- 17 files changed, 496 insertions(+), 15 deletions(-) create mode 100644 src/modules/layout/components/locale-switcher.tsx create mode 100644 src/modules/layout/components/nav-drawer.tsx create mode 100644 src/modules/layout/components/site-footer.tsx create mode 100644 src/modules/layout/components/site-header.tsx create mode 100644 src/modules/layout/components/site-shell.tsx create mode 100644 src/modules/layout/components/skip-link.tsx create mode 100644 src/modules/layout/components/theme-toggle.tsx create mode 100644 src/modules/layout/layout.nav.ts diff --git a/messages/en.json b/messages/en.json index 006f618..d904d45 100644 --- a/messages/en.json +++ b/messages/en.json @@ -1,3 +1,37 @@ { - "$schema": "https://inlang.com/schema/inlang-message-format" + "$schema": "https://inlang.com/schema/inlang-message-format", + "skip_to_content": "Skip to content", + "brand_home_label": "devsantara, back to home", + "nav_primary_label": "Main", + "nav_footer_label": "Footer", + "nav_posts": "Posts", + "nav_series": "Series", + "nav_tags": "Tags", + "nav_about": "About", + "menu_open": "Open menu", + "menu_title": "Menu", + "menu_description": "Navigate the site and change your preferences.", + "menu_close": "Close", + "theme_label": "Theme", + "theme_light": "Light", + "theme_dark": "Dark", + "theme_system": "System", + "locale_label": "Language", + "locale_name_en": "English", + "locale_name_id": "Bahasa Indonesia", + "footer_description": "A community blog for developers.", + "footer_feed": "RSS feed", + "footer_source": "Source on GitHub", + "footer_copyright": "© {year} devsantara", + "page_tags_title": "Tags", + "page_tags_description": "Every topic written about here, most covered first.", + "page_tag_heading": "Tagged “{tag}”", + "page_tag_back": "All tags", + "page_about_title": "About", + "page_about_intro": "devsantara is a community blog. Developers here write about the problems they actually hit — the debugging, the trade-offs, and the decisions that only make sense once you have made them.", + "page_about_contribute_heading": "Write with us", + "page_about_contribute_body": "Every post is a Markdown file in a public repository. Open a pull request with your draft and we will review it together.", + "page_about_contribute_cta": "Open the repository", + "series_part_label": "Part {order} of {total}", + "empty_no_content": "Nothing here yet." } diff --git a/messages/id.json b/messages/id.json index 006f618..51d5fba 100644 --- a/messages/id.json +++ b/messages/id.json @@ -1,3 +1,37 @@ { - "$schema": "https://inlang.com/schema/inlang-message-format" + "$schema": "https://inlang.com/schema/inlang-message-format", + "skip_to_content": "Lewati ke konten", + "brand_home_label": "devsantara, kembali ke beranda", + "nav_primary_label": "Utama", + "nav_footer_label": "Footer", + "nav_posts": "Artikel", + "nav_series": "Seri", + "nav_tags": "Tag", + "nav_about": "Tentang", + "menu_open": "Buka menu", + "menu_title": "Menu", + "menu_description": "Jelajahi situs dan ubah preferensi Anda.", + "menu_close": "Tutup", + "theme_label": "Tema", + "theme_light": "Terang", + "theme_dark": "Gelap", + "theme_system": "Sistem", + "locale_label": "Bahasa", + "locale_name_en": "English", + "locale_name_id": "Bahasa Indonesia", + "footer_description": "Blog komunitas untuk pengembang.", + "footer_feed": "Umpan RSS", + "footer_source": "Kode sumber di GitHub", + "footer_copyright": "© {year} devsantara", + "page_tags_title": "Tag", + "page_tags_description": "Semua topik yang dibahas di sini, dari yang paling banyak ditulis.", + "page_tag_heading": "Bertag “{tag}”", + "page_tag_back": "Semua tag", + "page_about_title": "Tentang", + "page_about_intro": "devsantara adalah blog komunitas. Para pengembang di sini menulis tentang masalah yang benar-benar mereka hadapi — proses debugging, pertimbangan teknis, dan keputusan yang baru masuk akal setelah dijalani.", + "page_about_contribute_heading": "Menulis bersama kami", + "page_about_contribute_body": "Setiap artikel adalah berkas Markdown di repositori publik. Buka pull request berisi draf Anda dan kami akan meninjaunya bersama.", + "page_about_contribute_cta": "Buka repositori", + "series_part_label": "Bagian {order} dari {total}", + "empty_no_content": "Belum ada apa pun di sini." } diff --git a/src/modules/layout/components/locale-switcher.tsx b/src/modules/layout/components/locale-switcher.tsx new file mode 100644 index 0000000..49c1d69 --- /dev/null +++ b/src/modules/layout/components/locale-switcher.tsx @@ -0,0 +1,60 @@ +import { useLocation } from '@tanstack/react-router'; + +import { m } from '#/lib/i18n/paraglide/messages.js'; +import { deLocalizeHref, getLocale, locales, localizeHref } from '#/lib/i18n/paraglide/runtime'; +import { cn } from '#/ui/utils'; + +const LOCALE_NAMES: Record string> = { + en: () => m.locale_name_en(), + id: () => m.locale_name_id(), +}; + +/** + * Switches the interface language. + * + * Rendered as two plain anchors rather than a dropdown, for two reasons. With + * only a handful of locales a dropdown hides the choice behind an interaction + * that buys nothing, and — more importantly — a dropdown cannot open without + * JavaScript, which would make language the one preference a reader could not + * change on an otherwise fully static site. + * + * They are deliberately `` and not router links. The locale lives in the URL + * prefix, which the router does not model, and every translated string is + * resolved during the server render; a client-side navigation would change the + * address bar while leaving the page in the previous language. A document load + * is the correct behaviour here, not a missed optimisation. + */ +export function LocaleSwitcher({ className }: { className?: string }) { + const location = useLocation(); + const activeLocale = getLocale(); + const path = deLocalizeHref(location.href); + + return ( + + ); +} diff --git a/src/modules/layout/components/nav-drawer.tsx b/src/modules/layout/components/nav-drawer.tsx new file mode 100644 index 0000000..654cdbd --- /dev/null +++ b/src/modules/layout/components/nav-drawer.tsx @@ -0,0 +1,81 @@ +import { Link } from '@tanstack/react-router'; +import { Menu } from 'lucide-react'; +import * as React from 'react'; + +import { m } from '#/lib/i18n/paraglide/messages.js'; +import { LocaleSwitcher } from '#/modules/layout/components/locale-switcher'; +import { ThemeToggle } from '#/modules/layout/components/theme-toggle'; +import { primaryNav } from '#/modules/layout/layout.nav'; +import { Button } from '#/ui/components/core/button'; +import { + Drawer, + DrawerContent, + DrawerDescription, + DrawerHeader, + DrawerTitle, + DrawerTrigger, +} from '#/ui/components/core/drawer'; +import { Separator } from '#/ui/components/core/separator'; + +/** + * Primary navigation on small screens, as a bottom sheet. + * + * A bottom sheet rather than a side panel because it opens within reach of a + * thumb, and because the table of contents already uses one — a reader learns + * one gesture for "there is more here" instead of two. + * + * The whole component is hidden from `md` up, where the same links render + * inline in the header. That switch is CSS-only, so the server-rendered HTML is + * correct at every width and nothing shifts on hydration. The drawer's contents + * only mount while it is open, so the links never exist twice in the + * accessibility tree. + * + * Closing on navigation is explicit: the router swaps the page underneath an + * open drawer without unmounting it, which would otherwise leave the reader + * looking at a menu covering the page they just asked for. + */ +export function NavDrawer() { + const [open, setOpen] = React.useState(false); + + return ( + + + + + } + /> + + + {m.menu_title()} + {m.menu_description()} + + + + + + +
+ + +
+
+ + ); +} diff --git a/src/modules/layout/components/site-footer.tsx b/src/modules/layout/components/site-footer.tsx new file mode 100644 index 0000000..48fd34a --- /dev/null +++ b/src/modules/layout/components/site-footer.tsx @@ -0,0 +1,77 @@ +import { Link } from '@tanstack/react-router'; +import { Rss } from 'lucide-react'; + +import { m } from '#/lib/i18n/paraglide/messages.js'; +import { site } from '#/lib/site'; +import { primaryNav } from '#/modules/layout/layout.nav'; +import { Container } from '#/ui/components/container'; + +/** + * Site footer, and the navigation of last resort. + * + * It repeats the primary links on purpose. On small screens the header's copy + * lives inside a drawer that cannot open without JavaScript, so without these + * the site would have no reachable navigation at all in that case. They are + * real anchors in the initial HTML, which is what makes the drawer an + * enhancement rather than a dependency. + * + * The year is read at render time. Every page is prerendered, so it is fixed at + * build — correct on the day it deploys, and stale only if nothing is published + * for a year, at which point the copyright line is not the problem. + */ +export function SiteFooter() { + const year = new Date().getFullYear(); + + return ( + + ); +} diff --git a/src/modules/layout/components/site-header.tsx b/src/modules/layout/components/site-header.tsx new file mode 100644 index 0000000..e1fbe4f --- /dev/null +++ b/src/modules/layout/components/site-header.tsx @@ -0,0 +1,58 @@ +import { Link } from '@tanstack/react-router'; + +import { m } from '#/lib/i18n/paraglide/messages.js'; +import { site } from '#/lib/site'; +import { LocaleSwitcher } from '#/modules/layout/components/locale-switcher'; +import { NavDrawer } from '#/modules/layout/components/nav-drawer'; +import { ThemeToggle } from '#/modules/layout/components/theme-toggle'; +import { primaryNav } from '#/modules/layout/layout.nav'; +import { Container } from '#/ui/components/container'; + +/** + * The site's masthead: brand, primary navigation, and preferences. + * + * Below `md` the navigation and preferences collapse into {@link NavDrawer}; + * from `md` up they sit inline. Both variants are always in the markup and + * switched with utility classes, never with a media-query hook — the site is + * prerendered, so a JavaScript-chosen variant would bake one width's chrome + * into the HTML that every other width then has to correct on hydration. + */ +export function SiteHeader() { + return ( +
+ + + {site.name} + + + + +
+
+ + +
+ +
+
+
+ ); +} diff --git a/src/modules/layout/components/site-shell.tsx b/src/modules/layout/components/site-shell.tsx new file mode 100644 index 0000000..8ddf606 --- /dev/null +++ b/src/modules/layout/components/site-shell.tsx @@ -0,0 +1,30 @@ +import type * as React from 'react'; + +import { SiteFooter } from '#/modules/layout/components/site-footer'; +import { SiteHeader } from '#/modules/layout/components/site-header'; +import { SkipLink } from '#/modules/layout/components/skip-link'; + +/** + * The frame every page renders inside: skip link, masthead, content, footer. + * + * `#content` is the skip link's target and the page's single `
` landmark, + * so pages render their content directly and never declare a `
` of their + * own — two would make the landmark ambiguous to a screen reader. + * + * `tabIndex={-1}` makes the target programmatically focusable without adding it + * to the tab order, so following the skip link actually moves focus rather than + * only scrolling. The footer is pushed to the bottom on short pages by the + * column layout, not by a sticky-footer hack. + */ +export function SiteShell({ children }: { children: React.ReactNode }) { + return ( +
+ + +
+ {children} +
+ +
+ ); +} diff --git a/src/modules/layout/components/skip-link.tsx b/src/modules/layout/components/skip-link.tsx new file mode 100644 index 0000000..3e3ea86 --- /dev/null +++ b/src/modules/layout/components/skip-link.tsx @@ -0,0 +1,25 @@ +import { m } from '#/lib/i18n/paraglide/messages.js'; + +/** + * Lets keyboard and screen-reader users jump past the header straight to the + * page's content, instead of tabbing through the whole navigation on every + * page (WCAG 2.4.1). + * + * It is visually hidden until focused rather than hidden outright: `display: + * none` and `visibility: hidden` remove an element from the focus order + * entirely, which would make the link unreachable by the very users it exists + * for. Clipping it to a zero-size box keeps it focusable and silent. + * + * Must be the first focusable element in the document, so it is rendered at the + * top of the shell, before the header. + */ +export function SkipLink() { + return ( + + {m.skip_to_content()} + + ); +} diff --git a/src/modules/layout/components/theme-toggle.tsx b/src/modules/layout/components/theme-toggle.tsx new file mode 100644 index 0000000..ac481c7 --- /dev/null +++ b/src/modules/layout/components/theme-toggle.tsx @@ -0,0 +1,58 @@ +import { Monitor, Moon, Sun } from 'lucide-react'; + +import { m } from '#/lib/i18n/paraglide/messages.js'; +import { Button } from '#/ui/components/core/button'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuTrigger, +} from '#/ui/components/core/dropdown-menu'; +import { useTheme } from '#/ui/theme'; + +const THEMES = [ + { value: 'light', icon: Sun, label: () => m.theme_light() }, + { value: 'dark', icon: Moon, label: () => m.theme_dark() }, + { value: 'system', icon: Monitor, label: () => m.theme_system() }, +] as const; + +/** + * Switches between light, dark, and following the operating system. + * + * The trigger's icon is chosen in CSS from the `dark` class on `` rather + * than from the `theme` state, because that state is only correct after the + * provider has read `localStorage` on mount. Driving the icon from React would + * render the wrong one during SSR and flip it on hydration — the exact flash + * the inline theme script exists to prevent. + * + * This control needs JavaScript. Without it the site still renders in the + * reader's preferred scheme, because the tokens key off `prefers-color-scheme`; + * only the override is unavailable. + */ +export function ThemeToggle() { + const { theme, setTheme } = useTheme(); + + return ( + + + + + + } + /> + + setTheme(value as never)}> + {THEMES.map(({ value, icon: Icon, label }) => ( + + + {label()} + + ))} + + + + ); +} diff --git a/src/modules/layout/layout.nav.ts b/src/modules/layout/layout.nav.ts new file mode 100644 index 0000000..35db82b --- /dev/null +++ b/src/modules/layout/layout.nav.ts @@ -0,0 +1,23 @@ +import { m } from '#/lib/i18n/paraglide/messages.js'; +import type { FileRouteTypes } from '#/routeTree.gen'; + +/** A primary navigation destination. */ +export interface NavItem { + /** Type-checked against the generated route tree, so a renamed route fails the build. */ + to: FileRouteTypes['to']; + /** Read at render time rather than stored as a string, so it follows the active locale. */ + label: () => string; +} + +/** + * The site's primary navigation, in one place because it renders three times: + * inline in the header from `md` up, inside the mobile drawer, and in the + * footer — where it doubles as the navigation that survives with JavaScript + * disabled, since the drawer cannot open without it. + */ +export const primaryNav: readonly NavItem[] = [ + { to: '/posts', label: () => m.nav_posts() }, + { to: '/series', label: () => m.nav_series() }, + { to: '/tags', label: () => m.nav_tags() }, + { to: '/about', label: () => m.nav_about() }, +]; diff --git a/src/routes/__root.tsx b/src/routes/__root.tsx index 50fb910..3d0a441 100644 --- a/src/routes/__root.tsx +++ b/src/routes/__root.tsx @@ -6,6 +6,7 @@ import { preload } from 'react-dom'; import { tanstackRouterDevtools } from '#/devtools/router-devtools'; import { getLocale, getTextDirection } from '#/lib/i18n/paraglide/runtime'; +import { SiteShell } from '#/modules/layout/components/site-shell'; import { Toaster } from '#/ui/components/core/sonner'; import { TooltipProvider } from '#/ui/components/core/tooltip'; import { ThemeProvider } from '#/ui/theme'; @@ -43,7 +44,7 @@ function RootDocument({ children }: { children: React.ReactNode }) { - {children} + {children} diff --git a/src/routes/index.tsx b/src/routes/index.tsx index a240174..f5206df 100644 --- a/src/routes/index.tsx +++ b/src/routes/index.tsx @@ -6,7 +6,7 @@ export const Route = createFileRoute('/')({ function HomePage() { return ( -
+

@devsantara/website

-
+
); } diff --git a/src/routes/posts/$slug.tsx b/src/routes/posts/$slug.tsx index f16efcb..0d6b0a6 100644 --- a/src/routes/posts/$slug.tsx +++ b/src/routes/posts/$slug.tsx @@ -15,7 +15,7 @@ function PostPage() { const post = Route.useLoaderData(); return ( -
+
{post.thumbnail && }
@@ -38,6 +38,6 @@ function PostPage() {
{post.mdx}
-
+ ); } diff --git a/src/routes/posts/index.tsx b/src/routes/posts/index.tsx index e3f94eb..22d04dd 100644 --- a/src/routes/posts/index.tsx +++ b/src/routes/posts/index.tsx @@ -11,7 +11,7 @@ function PostsPage() { const posts = Route.useLoaderData(); return ( -
+

Posts

    {posts.map((post) => ( @@ -31,6 +31,6 @@ function PostsPage() { ))}
-
+ ); } diff --git a/src/routes/series/$slug/$postSlug.tsx b/src/routes/series/$slug/$postSlug.tsx index 88ad9d4..c8476d9 100644 --- a/src/routes/series/$slug/$postSlug.tsx +++ b/src/routes/series/$slug/$postSlug.tsx @@ -16,7 +16,7 @@ function SeriesPostPage() { const { slug } = Route.useParams(); return ( -
+
{post.mdx}
-
+ ); } diff --git a/src/routes/series/$slug/index.tsx b/src/routes/series/$slug/index.tsx index 10a9dfb..c6414ed 100644 --- a/src/routes/series/$slug/index.tsx +++ b/src/routes/series/$slug/index.tsx @@ -13,7 +13,7 @@ function SeriesDetailPage() { const series = Route.useLoaderData(); return ( -
+
{series.thumbnail && }
@@ -49,6 +49,6 @@ function SeriesDetailPage() {

No posts in this series yet.

)} -
+ ); } diff --git a/src/routes/series/index.tsx b/src/routes/series/index.tsx index 22b1fd4..4660e05 100644 --- a/src/routes/series/index.tsx +++ b/src/routes/series/index.tsx @@ -11,7 +11,7 @@ function SeriesPage() { const series = Route.useLoaderData(); return ( -
+

Series

    {series.map((item) => ( @@ -31,6 +31,6 @@ function SeriesPage() { ))}
-
+ ); } From 8a18da384524b3cb095f6d6da7e216551edb45d2 Mon Sep 17 00:00:00 2001 From: Edwin Tantawi Date: Sat, 1 Aug 2026 20:37:26 +0700 Subject: [PATCH 07/11] feat(tag): add tag hub, per-tag browsing, and an about page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tags were rendered as badges linking nowhere. They now resolve to real pages spanning both collections, so a series part is discoverable by its topic rather than only through its series. Slugging is what merges Cloudflare and cloudflare into one page instead of two half-empty ones. A slug nobody uses is a 404 rather than an empty page: the tag does not exist, and rendering no-results for it would invite crawlers to index an unbounded set of empty URLs. Both pages are plain — they get their design in a later pass — but they are wired to real data, so the header's navigation has no dead ends. --- src/modules/tag/tag.fn.ts | 29 ++++++++++++++++ src/modules/tag/tag.types.ts | 9 +++++ src/modules/tag/tag.utils.ts | 49 ++++++++++++++++++++++++++ src/routeTree.gen.ts | 63 +++++++++++++++++++++++++++++++++ src/routes/about.tsx | 31 +++++++++++++++++ src/routes/tags/$tag.tsx | 67 ++++++++++++++++++++++++++++++++++++ src/routes/tags/index.tsx | 39 +++++++++++++++++++++ 7 files changed, 287 insertions(+) create mode 100644 src/modules/tag/tag.fn.ts create mode 100644 src/modules/tag/tag.types.ts create mode 100644 src/modules/tag/tag.utils.ts create mode 100644 src/routes/about.tsx create mode 100644 src/routes/tags/$tag.tsx create mode 100644 src/routes/tags/index.tsx diff --git a/src/modules/tag/tag.fn.ts b/src/modules/tag/tag.fn.ts new file mode 100644 index 0000000..e2dc21f --- /dev/null +++ b/src/modules/tag/tag.fn.ts @@ -0,0 +1,29 @@ +import { notFound } from '@tanstack/react-router'; +import { createServerFn } from '@tanstack/react-start'; +import * as z from 'zod/v4'; + +import type { FeedItem } from '#/modules/content/content.types'; +import { collectFeedItems } from '#/modules/content/content.utils'; +import type { TagSummary } from '#/modules/tag/tag.types'; +import { collectTags, filterByTag } from '#/modules/tag/tag.utils'; + +/** Every tag in use, most-used first. */ +export const getAllTagsFn = createServerFn({ method: 'GET' }).handler((): TagSummary[] => { + return collectTags(collectFeedItems()); +}); + +/** + * One tag and the content carrying it. + * + * A slug nobody uses is a 404 rather than an empty page: the tag genuinely does + * not exist, and rendering "no results" for it would invite crawlers to index + * an unbounded set of empty URLs. + */ +export const getTagFn = createServerFn({ method: 'GET' }) + .validator(z.object({ slug: z.string() })) + .handler(({ data }): { tag: TagSummary; items: FeedItem[] } => { + const items = collectFeedItems(); + const tag = collectTags(items).find((tag) => tag.slug === data.slug); + if (!tag) throw notFound(); + return { tag, items: filterByTag(items, data.slug) }; + }); diff --git a/src/modules/tag/tag.types.ts b/src/modules/tag/tag.types.ts new file mode 100644 index 0000000..2e61720 --- /dev/null +++ b/src/modules/tag/tag.types.ts @@ -0,0 +1,9 @@ +/** One tag, with how much content carries it. */ +export interface TagSummary { + /** URL segment, e.g. `type-safety`. */ + slug: string; + /** As authored in frontmatter, e.g. `type safety`. */ + name: string; + /** How many documents carry this tag, across posts and series parts. */ + count: number; +} diff --git a/src/modules/tag/tag.utils.ts b/src/modules/tag/tag.utils.ts new file mode 100644 index 0000000..2aba57a --- /dev/null +++ b/src/modules/tag/tag.utils.ts @@ -0,0 +1,49 @@ +import type { FeedItem } from '#/modules/content/content.types'; +import type { TagSummary } from '#/modules/tag/tag.types'; + +/** + * URL segment for a tag as authored in frontmatter. + * + * Authors write tags as prose (`type safety`, `Cloudflare`), so the same tag + * arrives in several spellings. Slugging is what makes `Cloudflare` and + * `cloudflare` one page instead of two half-empty ones. + */ +export function toTagSlug(name: string): string { + return name + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, ''); +} + +/** + * Aggregates every tag across the given items, most-used first. + * + * Where spellings differ for one slug the first occurrence in document order + * wins the display name, which is stable because the caller sorts before + * calling — otherwise the label on a tag page could change between builds + * purely from a new post being added. + */ +export function collectTags(items: FeedItem[]): TagSummary[] { + const tags = new Map(); + + for (const item of items) { + for (const name of item.tags) { + const slug = toTagSlug(name); + if (!slug) continue; + const existing = tags.get(slug); + if (existing) { + existing.count += 1; + } else { + tags.set(slug, { slug, name, count: 1 }); + } + } + } + + return [...tags.values()].sort((a, b) => b.count - a.count || a.name.localeCompare(b.name)); +} + +/** The items carrying `slug`, preserving the order they were given in. */ +export function filterByTag(items: FeedItem[], slug: string): FeedItem[] { + return items.filter((item) => item.tags.some((name) => toTagSlug(name) === slug)); +} diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index 5b58992..110dac6 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -9,18 +9,31 @@ // Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. import { Route as rootRouteImport } from './routes/__root' +import { Route as AboutRouteImport } from './routes/about' import { Route as IndexRouteImport } from './routes/index' +import { Route as TagsIndexRouteImport } from './routes/tags/index' import { Route as SeriesIndexRouteImport } from './routes/series/index' import { Route as PostsIndexRouteImport } from './routes/posts/index' +import { Route as TagsTagRouteImport } from './routes/tags/$tag' import { Route as PostsSlugRouteImport } from './routes/posts/$slug' import { Route as SeriesSlugIndexRouteImport } from './routes/series/$slug/index' import { Route as SeriesSlugPostSlugRouteImport } from './routes/series/$slug/$postSlug' +const AboutRoute = AboutRouteImport.update({ + id: '/about', + path: '/about', + getParentRoute: () => rootRouteImport, +} as any) const IndexRoute = IndexRouteImport.update({ id: '/', path: '/', getParentRoute: () => rootRouteImport, } as any) +const TagsIndexRoute = TagsIndexRouteImport.update({ + id: '/tags/', + path: '/tags/', + getParentRoute: () => rootRouteImport, +} as any) const SeriesIndexRoute = SeriesIndexRouteImport.update({ id: '/series/', path: '/series/', @@ -31,6 +44,11 @@ const PostsIndexRoute = PostsIndexRouteImport.update({ path: '/posts/', getParentRoute: () => rootRouteImport, } as any) +const TagsTagRoute = TagsTagRouteImport.update({ + id: '/tags/$tag', + path: '/tags/$tag', + getParentRoute: () => rootRouteImport, +} as any) const PostsSlugRoute = PostsSlugRouteImport.update({ id: '/posts/$slug', path: '/posts/$slug', @@ -49,26 +67,35 @@ const SeriesSlugPostSlugRoute = SeriesSlugPostSlugRouteImport.update({ export interface FileRoutesByFullPath { '/': typeof IndexRoute + '/about': typeof AboutRoute '/posts/$slug': typeof PostsSlugRoute + '/tags/$tag': typeof TagsTagRoute '/posts/': typeof PostsIndexRoute '/series/': typeof SeriesIndexRoute + '/tags/': typeof TagsIndexRoute '/series/$slug/$postSlug': typeof SeriesSlugPostSlugRoute '/series/$slug/': typeof SeriesSlugIndexRoute } export interface FileRoutesByTo { '/': typeof IndexRoute + '/about': typeof AboutRoute '/posts/$slug': typeof PostsSlugRoute + '/tags/$tag': typeof TagsTagRoute '/posts': typeof PostsIndexRoute '/series': typeof SeriesIndexRoute + '/tags': typeof TagsIndexRoute '/series/$slug/$postSlug': typeof SeriesSlugPostSlugRoute '/series/$slug': typeof SeriesSlugIndexRoute } export interface FileRoutesById { __root__: typeof rootRouteImport '/': typeof IndexRoute + '/about': typeof AboutRoute '/posts/$slug': typeof PostsSlugRoute + '/tags/$tag': typeof TagsTagRoute '/posts/': typeof PostsIndexRoute '/series/': typeof SeriesIndexRoute + '/tags/': typeof TagsIndexRoute '/series/$slug/$postSlug': typeof SeriesSlugPostSlugRoute '/series/$slug/': typeof SeriesSlugIndexRoute } @@ -76,40 +103,59 @@ export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath fullPaths: | '/' + | '/about' | '/posts/$slug' + | '/tags/$tag' | '/posts/' | '/series/' + | '/tags/' | '/series/$slug/$postSlug' | '/series/$slug/' fileRoutesByTo: FileRoutesByTo to: | '/' + | '/about' | '/posts/$slug' + | '/tags/$tag' | '/posts' | '/series' + | '/tags' | '/series/$slug/$postSlug' | '/series/$slug' id: | '__root__' | '/' + | '/about' | '/posts/$slug' + | '/tags/$tag' | '/posts/' | '/series/' + | '/tags/' | '/series/$slug/$postSlug' | '/series/$slug/' fileRoutesById: FileRoutesById } export interface RootRouteChildren { IndexRoute: typeof IndexRoute + AboutRoute: typeof AboutRoute PostsSlugRoute: typeof PostsSlugRoute + TagsTagRoute: typeof TagsTagRoute PostsIndexRoute: typeof PostsIndexRoute SeriesIndexRoute: typeof SeriesIndexRoute + TagsIndexRoute: typeof TagsIndexRoute SeriesSlugPostSlugRoute: typeof SeriesSlugPostSlugRoute SeriesSlugIndexRoute: typeof SeriesSlugIndexRoute } declare module '@tanstack/react-router' { interface FileRoutesByPath { + '/about': { + id: '/about' + path: '/about' + fullPath: '/about' + preLoaderRoute: typeof AboutRouteImport + parentRoute: typeof rootRouteImport + } '/': { id: '/' path: '/' @@ -117,6 +163,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof IndexRouteImport parentRoute: typeof rootRouteImport } + '/tags/': { + id: '/tags/' + path: '/tags' + fullPath: '/tags/' + preLoaderRoute: typeof TagsIndexRouteImport + parentRoute: typeof rootRouteImport + } '/series/': { id: '/series/' path: '/series' @@ -131,6 +184,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof PostsIndexRouteImport parentRoute: typeof rootRouteImport } + '/tags/$tag': { + id: '/tags/$tag' + path: '/tags/$tag' + fullPath: '/tags/$tag' + preLoaderRoute: typeof TagsTagRouteImport + parentRoute: typeof rootRouteImport + } '/posts/$slug': { id: '/posts/$slug' path: '/posts/$slug' @@ -157,9 +217,12 @@ declare module '@tanstack/react-router' { const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, + AboutRoute: AboutRoute, PostsSlugRoute: PostsSlugRoute, + TagsTagRoute: TagsTagRoute, PostsIndexRoute: PostsIndexRoute, SeriesIndexRoute: SeriesIndexRoute, + TagsIndexRoute: TagsIndexRoute, SeriesSlugPostSlugRoute: SeriesSlugPostSlugRoute, SeriesSlugIndexRoute: SeriesSlugIndexRoute, } diff --git a/src/routes/about.tsx b/src/routes/about.tsx new file mode 100644 index 0000000..9d0405c --- /dev/null +++ b/src/routes/about.tsx @@ -0,0 +1,31 @@ +import { createFileRoute } from '@tanstack/react-router'; + +import { m } from '#/lib/i18n/paraglide/messages.js'; +import { site } from '#/lib/site'; + +export const Route = createFileRoute('/about')({ + component: AboutPage, +}); + +function AboutPage() { + return ( +
+

{m.page_about_title()}

+

{m.page_about_intro()}

+ +

+ {m.page_about_contribute_heading()} +

+

{m.page_about_contribute_body()}

+

+ + {m.page_about_contribute_cta()} + +

+
+ ); +} diff --git a/src/routes/tags/$tag.tsx b/src/routes/tags/$tag.tsx new file mode 100644 index 0000000..ed10462 --- /dev/null +++ b/src/routes/tags/$tag.tsx @@ -0,0 +1,67 @@ +import { createFileRoute, Link } from '@tanstack/react-router'; + +import { m } from '#/lib/i18n/paraglide/messages.js'; +import { getTagFn } from '#/modules/tag/tag.fn'; + +export const Route = createFileRoute('/tags/$tag')({ + loader: ({ params: { tag } }) => getTagFn({ data: { slug: tag } }), + component: TagPage, +}); + +function TagPage() { + const { tag, items } = Route.useLoaderData(); + + return ( +
+ + {m.page_tag_back()} + +

+ {m.page_tag_heading({ tag: tag.name })} +

+ +
    + {items.map((item) => ( +
  • +
    +

    + {item.kind === 'post' ? ( + + {item.title} + + ) : ( + + {item.title} + + )} +

    + {item.kind === 'series-post' && ( +

    + {item.series.title} ·{' '} + {m.series_part_label({ + order: String(item.series.order), + total: String(item.series.total), + })} +

    + )} +

    {item.description}

    +
    +
  • + ))} +
+
+ ); +} diff --git a/src/routes/tags/index.tsx b/src/routes/tags/index.tsx new file mode 100644 index 0000000..257d1cf --- /dev/null +++ b/src/routes/tags/index.tsx @@ -0,0 +1,39 @@ +import { createFileRoute, Link } from '@tanstack/react-router'; + +import { m } from '#/lib/i18n/paraglide/messages.js'; +import { getAllTagsFn } from '#/modules/tag/tag.fn'; + +export const Route = createFileRoute('/tags/')({ + loader: () => getAllTagsFn(), + component: TagsPage, +}); + +function TagsPage() { + const tags = Route.useLoaderData(); + + return ( +
+

{m.page_tags_title()}

+

{m.page_tags_description()}

+ + {tags.length === 0 ? ( +

{m.empty_no_content()}

+ ) : ( +
    + {tags.map((tag) => ( +
  • + + {tag.name} + {tag.count} + +
  • + ))} +
+ )} +
+ ); +} From 8b25d0df29d6dea8060989dbe4b304981d07fc98 Mon Sep 17 00:00:00 2001 From: Edwin Tantawi Date: Sat, 1 Aug 2026 21:07:02 +0700 Subject: [PATCH 08/11] refactor(toc): rebuild the table of contents as a plain list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the animated rail — morphing thumb, boundary dots, scroll-driven edge fades, separate tablet and mobile presentations — with a flat list of anchors and an active highlight. About a fifth of the code, and far less surface to prove WCAG conformance against. The list is flat rather than nested: screen readers announce nesting depth for every item, which is noise when the visual indent already says the same thing and the hierarchy is two levels deep at most. Active tracking observes headings against a band across the top of the viewport rather than the whole of it, which would mark every heading on screen as current at once. When nothing is in the band the reader is inside a section, so the last match is held instead of cleared — otherwise the highlight would be blank for most of the time spent reading. The entry parser is kept as-is; it was the data transform, not the rail. --- src/modules/toc/components/article-toc.tsx | 160 -------------- src/modules/toc/components/toc-list.tsx | 62 ++++++ src/modules/toc/components/toc.tsx | 240 --------------------- src/modules/toc/toc.active.ts | 60 ++++++ src/modules/toc/toc.rail.ts | 197 ----------------- src/modules/toc/toc.scroll.ts | 190 ---------------- src/ui/styles/app.css | 98 ++++----- 7 files changed, 167 insertions(+), 840 deletions(-) delete mode 100644 src/modules/toc/components/article-toc.tsx create mode 100644 src/modules/toc/components/toc-list.tsx delete mode 100644 src/modules/toc/components/toc.tsx create mode 100644 src/modules/toc/toc.active.ts delete mode 100644 src/modules/toc/toc.rail.ts delete mode 100644 src/modules/toc/toc.scroll.ts diff --git a/src/modules/toc/components/article-toc.tsx b/src/modules/toc/components/article-toc.tsx deleted file mode 100644 index 85b5f9a..0000000 --- a/src/modules/toc/components/article-toc.tsx +++ /dev/null @@ -1,160 +0,0 @@ -import { ListIcon } from 'lucide-react'; -import * as React from 'react'; - -import type { TableOfContents } from '#/modules/markdown/markdown.types'; -import { TocLink, TocList, TocRail, TocRoot, useToc } from '#/modules/toc/components/toc'; -import { parseTocEntries } from '#/modules/toc/toc.entries'; -import { Button } from '#/ui/components/core/button'; -import { - Drawer, - DrawerClose, - DrawerContent, - DrawerFooter, - DrawerHeader, - DrawerTitle, - DrawerTrigger, -} from '#/ui/components/core/drawer'; -import { - Sheet, - SheetContent, - SheetHeader, - SheetTitle, - SheetTrigger, -} from '#/ui/components/core/sheet'; -import { cn } from '#/ui/utils'; - -/** - * The article's "On this page" navigation, placed once per article and adapting - * to three widths: - * - * - **Desktop** (`xl` and up): fixed in the gutter just right of the centered - * article, always visible ({@link GutterToc}). - * - **Tablet** (`md`–`xl`, where that gutter collapses): a floating button - * opening the list in a right-side sheet ({@link SheetToc}). - * - **Mobile** (below `md`): a full-width bar pinned to the very bottom like a - * bottom navigation bar, opening the list in a drawer ({@link DrawerToc}). - * - * All three tiers share one {@link TocRoot}, so the reading position is - * tracked once no matter which tier is visible. Renders nothing when the - * article has no h2/h3 headings. - */ -export function ArticleToc({ headings }: { headings: TableOfContents }) { - const entries = React.useMemo(() => parseTocEntries(headings), [headings]); - if (entries.length === 0) return null; - - return ( - - -
- -
-
- -
-
- ); -} - -const TOC_TITLE = 'On this page'; - -/** The icon-and-text title shared by every tier's heading. */ -function TocTitle() { - return ( - <> - - {TOC_TITLE} - - ); -} - -/** - * The scrollable list every tier shows: rail, links, and the fading - * `.toc-scroll` viewport (see app.css) that hides its scrollbar and fades the - * edges while there's more to scroll. - */ -function TocPanel({ className, onNavigate }: { className?: string; onNavigate?: () => void }) { - const { entries } = useToc(); - - return ( -
- - - {entries.map((entry) => ( - - ))} - -
- ); -} - -/** Desktop tier: always visible in the gutter right of the centered article. */ -function GutterToc() { - return ( - - ); -} - -/** - * Tablet tier: a floating outline button in the bottom-right corner opening - * the TOC in a sheet that slides in from the right, echoing the desktop - * gutter's position. Closes as soon as a link scrolls the page. - */ -function SheetToc() { - const [open, setOpen] = React.useState(false); - - return ( - - - {TOC_TITLE} - - } - /> - - - - - - - setOpen(false)} /> - - - ); -} - -/** - * Mobile tier: a full-width bar fixed to the very bottom — styled like a - * mobile bottom navigation bar, with a safe-area inset so it clears the home - * indicator — opening the TOC in a bottom drawer the reader can swipe down to - * dismiss. Closes as soon as a link scrolls the page. - */ -function DrawerToc() { - const [open, setOpen] = React.useState(false); - - return ( - - - - {TOC_TITLE} - - - - - - - - setOpen(false)} /> - - }>Close - - - - ); -} diff --git a/src/modules/toc/components/toc-list.tsx b/src/modules/toc/components/toc-list.tsx new file mode 100644 index 0000000..0448d82 --- /dev/null +++ b/src/modules/toc/components/toc-list.tsx @@ -0,0 +1,62 @@ +import type { TableOfContents } from '#/modules/markdown/markdown.types'; +import { useActiveHeading } from '#/modules/toc/toc.active'; +import { parseTocEntries } from '#/modules/toc/toc.entries'; +import { cn } from '#/ui/utils'; + +/** + * The table of contents itself: one flat list, indented by heading depth. + * + * Flat rather than nested `
    `s on purpose. Screen readers announce nesting + * depth for every item in a nested list ("list, 3 items, list, 2 items…"), + * which is noise when the visual indent already conveys the same thing and the + * hierarchy is at most two levels deep. + * + * Every entry is a real anchor to a heading id stamped at build time, so the + * whole control works with JavaScript disabled; the highlight is the only part + * that needs it. Jumps inherit the document's `scroll-behavior`, which app.css + * turns off under `prefers-reduced-motion`. + */ +export function TocList({ + headings, + onNavigate, + className, +}: { + headings: TableOfContents; + /** Called after following an entry, so a disclosure can close itself. */ + onNavigate?: () => void; + className?: string; +}) { + const entries = parseTocEntries(headings); + const activeId = useActiveHeading(entries.map((entry) => entry.id)); + + if (entries.length === 0) return null; + + return ( +
      + {entries.map((entry) => { + const isActive = entry.id === activeId; + return ( +
    • + + {entry.label} + +
    • + ); + })} +
    + ); +} diff --git a/src/modules/toc/components/toc.tsx b/src/modules/toc/components/toc.tsx deleted file mode 100644 index 62c60e7..0000000 --- a/src/modules/toc/components/toc.tsx +++ /dev/null @@ -1,240 +0,0 @@ -import * as React from 'react'; - -import type { TocEntry } from '#/modules/toc/toc.entries'; -import type { RailGeometry } from '#/modules/toc/toc.rail'; -import { DOT_RADIUS, highlightSpan, linkIndent, useRailGeometry } from '#/modules/toc/toc.rail'; -import { scrollToHeading, useKeepActiveInView, useScrollSpy } from '#/modules/toc/toc.scroll'; -import { cn } from '#/ui/utils'; - -/** - * Composable "On this page" primitives. `TocRoot` owns the shared reading - * state (one scroll spy, however many lists render under it); each `TocList` - * renders one navigable list and measures its own layout for the `TocRail` - * highlight. Compose them per surface: - * - * ```tsx - * const entries = parseTocEntries(post.toc); - * - * - * - * - * {entries.map((entry) => ( - * - * ))} - * - * - * ``` - * - * `ArticleToc` assembles these into the standard responsive shell. - */ - -interface TocContextValue { - /** The entries this TOC navigates, in document order. */ - entries: TocEntry[]; - /** Ids of the headings whose section is on screen, in document order. */ - activeIds: string[]; - /** The reading position is still above the first heading. */ - atStart: boolean; - /** The end of the article's content has scrolled into view. */ - atEnd: boolean; -} - -const TocContext = React.createContext(null); - -/** The TOC's entries and live reading state, provided by {@link TocRoot}. */ -export function useToc(): TocContextValue { - const context = React.useContext(TocContext); - if (!context) throw new Error('useToc must be used within '); - return context; -} - -interface TocRootProps { - /** - * Parsed entries (see `parseTocEntries`), referentially stable across - * renders — memoize at the call site; they key the scroll spy. - */ - entries: TocEntry[]; - children: React.ReactNode; -} - -/** - * Provides the entries and their scroll-derived reading state to every TOC - * piece below it. Renders no DOM of its own, so one root can feed several - * lists (the responsive shell mounts three) from a single scroll listener. - */ -export function TocRoot({ entries, children }: TocRootProps) { - const spy = useScrollSpy(entries); - const value = React.useMemo(() => ({ entries, ...spy }), [entries, spy]); - return {children}; -} - -interface TocListContextValue { - /** Measured layout of this list, or `null` until it has one (see `useRailGeometry`). */ - geometry: RailGeometry | null; - /** Registers a link's `
  • ` under its entry id, for measurement and follow-scroll. */ - registerLink: (id: string, element: HTMLLIElement | null) => void; - /** Click handler shared by every link: smooth-scrolls and reports navigation. */ - onLinkClick: (event: React.MouseEvent, id: string) => void; -} - -const TocListContext = React.createContext(null); - -function useTocList(): TocListContextValue { - const context = React.useContext(TocListContext); - if (!context) throw new Error('TOC list pieces must be used within '); - return context; -} - -interface TocListProps { - className?: string; - /** Called after a link scrolls the page — lets a sheet or drawer close. */ - onNavigate?: () => void; - /** `TocLink`s (and optionally a `TocRail`). */ - children: React.ReactNode; -} - -/** - * One rendered TOC list: a `