diff --git a/content-collections.ts b/content-collections.ts index 38e5cb3..b296f1d 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,159 @@ 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, + // Repository-relative, which is exactly what a GitHub edit URL needs. + contentPath, + 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 +185,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/messages/en.json b/messages/en.json index 006f618..24eca91 100644 --- a/messages/en.json +++ b/messages/en.json @@ -1,3 +1,52 @@ { - "$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.", + "nav_home": "Home", + "breadcrumb_label": "Breadcrumb", + "toc_title": "On this page", + "series_contents_title": "In this series", + "article_by": "By {author}", + "article_reading_time": "{minutes} min read", + "article_updated": "Updated {date}", + "article_tags_label": "Topics", + "article_edit": "Edit this page", + "article_copy_link": "Copy link", + "article_link_copied": "Link copied", + "article_previous": "Previous", + "article_next": "Next", + "article_related_title": "Read next", + "article_pagination_label": "More articles" } diff --git a/messages/id.json b/messages/id.json index 006f618..89a5ec9 100644 --- a/messages/id.json +++ b/messages/id.json @@ -1,3 +1,52 @@ { - "$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.", + "nav_home": "Beranda", + "breadcrumb_label": "Navigasi bertingkat", + "toc_title": "Di halaman ini", + "series_contents_title": "Dalam seri ini", + "article_by": "Oleh {author}", + "article_reading_time": "{minutes} menit baca", + "article_updated": "Diperbarui {date}", + "article_tags_label": "Topik", + "article_edit": "Sunting halaman ini", + "article_copy_link": "Salin tautan", + "article_link_copied": "Tautan disalin", + "article_previous": "Sebelumnya", + "article_next": "Berikutnya", + "article_related_title": "Baca berikutnya", + "article_pagination_label": "Artikel lainnya" } 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/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/modules/content/components/article-adjacent-nav.tsx b/src/modules/content/components/article-adjacent-nav.tsx new file mode 100644 index 0000000..6147a8e --- /dev/null +++ b/src/modules/content/components/article-adjacent-nav.tsx @@ -0,0 +1,71 @@ +import { ArrowLeft, ArrowRight } from 'lucide-react'; + +import { m } from '#/lib/i18n/paraglide/messages.js'; +import { FeedItemLink } from '#/modules/content/components/feed-item-link'; +import type { FeedItem } from '#/modules/content/content.types'; +import { cn } from '#/ui/utils'; + +/** + * Links to the articles either side of this one. + * + * For a series part that is the author's ordering, which is the entire reason + * the series exists; for a standalone post it is publication date. + * + * When only one side exists the other keeps its grid cell rather than + * collapsing, so "next" stays on the right at the end of a series instead of + * sliding under the "previous" label. + */ +export function ArticleAdjacentNav({ + previous, + next, + label, +}: { + previous: FeedItem | null; + next: FeedItem | null; + /** Names this navigation region, since a page can hold several. */ + label: string; +}) { + if (!previous && !next) return null; + + return ( + + ); +} + +function AdjacentCard({ item, direction }: { item: FeedItem; direction: 'previous' | 'next' }) { + const isNext = direction === 'next'; + const Icon = isNext ? ArrowRight : ArrowLeft; + + return ( +
+

+ {!isNext && } + {isNext ? m.article_next() : m.article_previous()} + {isNext && } +

+

+ +

+
+ ); +} diff --git a/src/modules/content/components/article-footer.tsx b/src/modules/content/components/article-footer.tsx new file mode 100644 index 0000000..fb5cc1d --- /dev/null +++ b/src/modules/content/components/article-footer.tsx @@ -0,0 +1,51 @@ +import { Pencil } from 'lucide-react'; + +import { m } from '#/lib/i18n/paraglide/messages.js'; +import { sourceEditUrl } from '#/lib/site'; +import { CopyLinkButton } from '#/modules/content/components/copy-link-button'; +import { formatTimestamp } from '#/modules/content/content.format'; +import { Button } from '#/ui/components/core/button'; +import { Separator } from '#/ui/components/core/separator'; + +/** + * Closes an article: when it last changed, and the two things a reader might + * want to do with it. + * + * The edit link is the point of the block. Every article is a Markdown file in + * a public repository, and putting that one click away is what turns a reader + * who spotted a typo into a contributor — which matters more here than on a + * blog with a single author. + */ +export function ArticleFooter({ + lastModification, + contentPath, +}: { + /** ISO timestamp of the last commit touching the source file. */ + lastModification: string; + /** Repository-relative path to the source `.mdx`. */ + contentPath: string; +}) { + return ( +
+ +
+

+ +

+
+ + +
+
+
+ ); +} diff --git a/src/modules/content/components/article-header.tsx b/src/modules/content/components/article-header.tsx new file mode 100644 index 0000000..0b2889f --- /dev/null +++ b/src/modules/content/components/article-header.tsx @@ -0,0 +1,73 @@ +import { Link } from '@tanstack/react-router'; + +import { m } from '#/lib/i18n/paraglide/messages.js'; +import { formatDate } from '#/modules/content/content.format'; +import { toTagSlug } from '#/modules/tag/tag.utils'; +import { ThumbnailFigure } from '#/modules/thumbnail/components/thumbnail-figure'; +import type { Thumbnail } from '#/modules/thumbnail/thumbnail.schema'; + +/** + * An article's title block: thumbnail, heading, standfirst, byline, and topics. + * + * The heading is the page's only `h1`, so the body's own headings start at `h2` + * and the outline stays a single well-formed tree — which is what a screen + * reader's heading navigation relies on. + * + * Author is plain text rather than a link: there are no author pages, and a + * link that goes nowhere is worse than none. + */ +export function ArticleHeader({ + title, + description, + author, + date, + readingTime, + tags, + thumbnail, +}: { + title: string; + description: string; + author: string; + date: string; + readingTime: number; + tags: string[]; + thumbnail: Thumbnail; +}) { + return ( +
+ {thumbnail && } + +

+ {title} +

+ +

{description}

+ +

+ {m.article_by({ author })} + · + + · + {m.article_reading_time({ minutes: String(readingTime) })} +

+ + {tags.length > 0 && ( + + )} +
+ ); +} diff --git a/src/modules/content/components/article-related.tsx b/src/modules/content/components/article-related.tsx new file mode 100644 index 0000000..b0c63ea --- /dev/null +++ b/src/modules/content/components/article-related.tsx @@ -0,0 +1,35 @@ +import { m } from '#/lib/i18n/paraglide/messages.js'; +import { PostCard } from '#/modules/content/components/post-card'; +import type { FeedItem } from '#/modules/content/content.types'; + +/** + * "Read next": other articles sharing tags with this one. + * + * Renders nothing when there is no overlap, rather than padding the slot with + * whatever was newest. An empty section is a smaller cost than a suggestion the + * reader can see has nothing to do with what they just read — and on a young + * blog that would be most of them. + */ +export function ArticleRelated({ items }: { items: FeedItem[] }) { + if (items.length === 0) return null; + + return ( +
+ +
    + {items.map((item) => ( +
  • + +
  • + ))} +
+
+ ); +} diff --git a/src/modules/content/components/copy-link-button.tsx b/src/modules/content/components/copy-link-button.tsx new file mode 100644 index 0000000..792ad51 --- /dev/null +++ b/src/modules/content/components/copy-link-button.tsx @@ -0,0 +1,39 @@ +'use client'; + +import { Link2 } from 'lucide-react'; +import { toast } from 'sonner'; + +import { m } from '#/lib/i18n/paraglide/messages.js'; +import { Button } from '#/ui/components/core/button'; + +/** + * Copies the current page's URL to the clipboard. + * + * The confirmation is a toast rather than a label swap on the button: the + * toaster is a live region, so the result is announced instead of only being + * visible, and the button's accessible name stays stable. + * + * Clipboard access needs JavaScript and a secure context. The address bar is + * always the fallback, so this is an affordance rather than the only way to + * take a link — it simply does nothing on the rare failure. + */ +export function CopyLinkButton() { + return ( + + ); +} diff --git a/src/modules/content/components/feed-item-link.tsx b/src/modules/content/components/feed-item-link.tsx new file mode 100644 index 0000000..3ee2d16 --- /dev/null +++ b/src/modules/content/components/feed-item-link.tsx @@ -0,0 +1,43 @@ +import { Link } from '@tanstack/react-router'; +import type * as React from 'react'; + +import type { FeedItem } from '#/modules/content/content.types'; + +/** + * Links to a feed entry, whichever kind it is. + * + * The single place that maps `kind` to a route. Everything that lists mixed + * content — tag pages, related, previous/next, search — goes through here, so + * no caller assembles an href from strings and every link stays inside the + * router's type checking. Changing where series parts live is then one edit. + */ +export function FeedItemLink({ + item, + className, + children, +}: { + item: FeedItem; + className?: string; + /** Defaults to the entry's title. */ + children?: React.ReactNode; +}) { + const label = children ?? item.title; + + if (item.kind === 'post') { + return ( + + {label} + + ); + } + + return ( + + {label} + + ); +} diff --git a/src/modules/content/components/post-card.tsx b/src/modules/content/components/post-card.tsx new file mode 100644 index 0000000..e870e43 --- /dev/null +++ b/src/modules/content/components/post-card.tsx @@ -0,0 +1,87 @@ +import { cva, type VariantProps } from 'class-variance-authority'; + +import { m } from '#/lib/i18n/paraglide/messages.js'; +import { FeedItemLink } from '#/modules/content/components/feed-item-link'; +import { formatDate } from '#/modules/content/content.format'; +import type { FeedItem } from '#/modules/content/content.types'; +import { cn } from '#/ui/utils'; + +const postCardVariants = cva('group relative', { + variants: { + variant: { + /** Index lists: room for the description and full metadata. */ + default: '', + /** Rails and "read next": title and one line of context. */ + compact: '', + }, + }, + defaultVariants: { variant: 'default' }, +}); + +/** + * One entry in any list of content. + * + * Text-first by design — no thumbnail. Lists are for scanning, and a column of + * images both slows that down and makes the layout depend on every author + * shipping a good one, which `thumbnail` being nullable says they will not. + * + * The title's link carries a full-card overlay, so the whole card is the target + * while the accessible name stays just the title. Metadata sits above that + * overlay so tag links and the series link remain independently clickable. + */ +export function PostCard({ + item, + variant = 'default', + className, +}: { + item: FeedItem; + className?: string; +} & VariantProps) { + const isCompact = variant === 'compact'; + + return ( +
+ {item.kind === 'series-post' && ( +

+ +

+ )} + +

+ +

+ + {!isCompact && ( +

{item.description}

+ )} + +

+ + · + {m.article_reading_time({ minutes: String(item.readingTime) })} +

+
+ ); +} + +/** The "Series name · Part 2 of 5" line above a series part's title. */ +function FeedItemLinkSeries({ item }: { item: Extract }) { + return ( + <> + {item.series.title} + · + {m.series_part_label({ + order: String(item.series.order), + total: String(item.series.total), + })} + + ); +} 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.format.ts b/src/modules/content/content.format.ts new file mode 100644 index 0000000..eaa5313 --- /dev/null +++ b/src/modules/content/content.format.ts @@ -0,0 +1,31 @@ +import { format } from 'date-fns'; +import { enUS, id } from 'date-fns/locale'; + +import { getLocale } from '#/lib/i18n/paraglide/runtime'; + +const DATE_LOCALES = { en: enUS, id } as const; + +/** + * Formats an ISO date (`YYYY-MM-DD`) for reading, in the active locale. + * + * Parsed as a plain calendar date rather than through `new Date(iso)`, which + * reads a bare `YYYY-MM-DD` as UTC midnight and then renders it in the viewer's + * zone — turning a post dated the 1st into the 31st for anyone west of + * Greenwich. Frontmatter dates are calendar dates with no time or zone, so they + * should not move. + */ +export function formatDate(iso: string): string { + const [year, month, day] = iso.split('-').map(Number); + const date = new Date(year, (month ?? 1) - 1, day ?? 1); + const locale = DATE_LOCALES[getLocale() as keyof typeof DATE_LOCALES] ?? enUS; + return format(date, 'd MMMM yyyy', { locale }); +} + +/** + * Formats a full ISO timestamp — the git last-modified stamp, which unlike a + * frontmatter date is a real instant and is correctly shown in local time. + */ +export function formatTimestamp(iso: string): string { + const locale = DATE_LOCALES[getLocale() as keyof typeof DATE_LOCALES] ?? enUS; + return format(new Date(iso), 'd MMMM yyyy', { locale }); +} 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]; diff --git a/src/modules/content/content.types.ts b/src/modules/content/content.types.ts new file mode 100644 index 0000000..5c30b5c --- /dev/null +++ b/src/modules/content/content.types.ts @@ -0,0 +1,60 @@ +/** + * 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; +} + +/** + * What surrounds an article: where it sits in a sequence, and what to read next. + * + * Kept separate from the document itself because it is derived from the whole + * corpus rather than from the file — the same post has different neighbours + * once something else is published. + */ +export interface ArticleContext { + /** The one to read before this. Older for a standalone post, lower `order` within a series. */ + previous: FeedItem | null; + /** The one to read after this. Newer for a standalone post, higher `order` within a series. */ + next: FeedItem | null; + /** Others sharing tags with this one, most overlap first. */ + related: FeedItem[]; +} + +/** 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..8cca6e4 --- /dev/null +++ b/src/modules/content/content.utils.ts @@ -0,0 +1,93 @@ +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); +} + +/** + * The standalone posts published either side of `slug`, chronologically. + * + * Series parts are excluded from the sequence. A standalone post's neighbours + * are other standalone posts; dropping the reader into the middle of an + * unrelated series would be a worse suggestion than none. + * + * `items` is newest-first, so the *newer* neighbour sits at the lower index — + * `previous` means the one published before this, which is the one further down + * the list. + */ +export function findAdjacentPosts( + items: FeedItem[], + slug: string, +): { previous: FeedItem | null; next: FeedItem | null } { + const posts = items.filter((item) => item.kind === 'post'); + const index = posts.findIndex((post) => post.slug === slug); + if (index === -1) return { previous: null, next: null }; + return { + previous: posts[index + 1] ?? null, + next: posts[index - 1] ?? null, + }; +} diff --git a/src/modules/layout/components/article-layout.tsx b/src/modules/layout/components/article-layout.tsx new file mode 100644 index 0000000..b8d2bf2 --- /dev/null +++ b/src/modules/layout/components/article-layout.tsx @@ -0,0 +1,177 @@ +import { List, PanelLeft } from 'lucide-react'; +import * as React from 'react'; + +import { Button } from '#/ui/components/core/button'; +import { + Drawer, + DrawerContent, + DrawerDescription, + DrawerHeader, + DrawerTitle, + DrawerTrigger, +} from '#/ui/components/core/drawer'; +import { cn } from '#/ui/utils'; + +interface ArticleLayoutProps { + children: React.ReactNode; + /** Table of contents for this article. Right rail from `xl`, disclosure below. */ + toc?: React.ReactNode; + /** Label for the table of contents, used as both the rail heading and the drawer title. */ + tocLabel: string; + /** Optional context list, e.g. a series' parts. Left rail from `2xl`, disclosure below. */ + aside?: React.ReactNode; + /** Label for the aside, used as both the rail heading and the drawer title. */ + asideLabel?: string; +} + +/** + * The frame an article renders in: body column, plus rails when there is room. + * + * The body column is the full breakout width, and `.article-body` caps text + * back to the reading measure inside it — so code and tables widen by simply + * not being capped, with no negative margins to clamp. + * + * Rails appear where the arithmetic allows rather than at one shared + * breakpoint. The table of contents needs 15rem beside a 56rem column, which + * fits from `xl`; a series part wants a second rail on the other side, and + * 15 + 56 + 15rem plus gaps only fits from `2xl`. Below those widths each + * becomes a disclosure in a bar above the article. + * + * Both placements are always in the markup and switched with utility classes, + * so prerendered HTML is right at every width. The rails are `display:none` + * when collapsed, which takes them out of the accessibility tree, and drawer + * contents mount only while open — so the same navigation is never exposed + * twice. + */ +export function ArticleLayout({ children, toc, tocLabel, aside, asideLabel }: ArticleLayoutProps) { + return ( +
+ {aside && asideLabel && ( + + {aside} + + )} + + {/* `.article-column` centres every child on the reading measure — see + app.css. The column stays at the full breakout width so code and + tables can opt out of that cap and use it. */} +
+
+ {aside && asideLabel && ( + } className="2xl:hidden"> + {aside} + + )} + {toc && ( + } className="xl:hidden"> + {toc} + + )} +
+ + {children} +
+ + {toc && ( + + {toc} + + )} +
+ ); +} + +/** + * A sticky column beside the article. + * + * Offset below the masthead so it never sits underneath it, and capped to the + * remaining viewport height so a long list scrolls within the rail instead of + * running off the bottom of the screen. + */ +function Rail({ + label, + className, + children, +}: { + label: string; + className?: string; + children: React.ReactNode; +}) { + return ( + // No explicit grid placement: a definite row would make the browser place + // this rail before the auto-placed article and steal the wide column. + + ); +} + +/** + * The rail's collapsed form: a button opening a bottom drawer. + * + * Closing on navigation is handled by one delegated click handler rather than + * threading a dismiss callback into the slot. Every control inside is an + * anchor, so any click that lands on one has navigated and the drawer should + * go — which keeps the slot a plain node instead of a render prop. + */ +function Disclosure({ + label, + icon, + className, + children, +}: { + label: string; + icon: React.ReactNode; + className?: string; + children: React.ReactNode; +}) { + const [open, setOpen] = React.useState(false); + + return ( + + + {icon} + {label} + + } + /> + + + {label} + {label} + +
{ + if ((event.target as HTMLElement).closest('a')) setOpen(false); + }} + > + {children} +
+
+
+ ); +} 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..1dad9a9 --- /dev/null +++ b/src/modules/layout/components/nav-drawer.tsx @@ -0,0 +1,78 @@ +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 { NavLink } from '#/modules/layout/components/nav-link'; +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/nav-link.tsx b/src/modules/layout/components/nav-link.tsx new file mode 100644 index 0000000..cf8d81f --- /dev/null +++ b/src/modules/layout/components/nav-link.tsx @@ -0,0 +1,50 @@ +import { Link, useLocation } from '@tanstack/react-router'; + +import { deLocalizeHref } from '#/lib/i18n/paraglide/runtime'; +import type { NavItem } from '#/modules/layout/layout.nav'; + +/** + * A primary navigation link that distinguishes "this is the page you are on" + * from "this is the section you are in". + * + * The router treats a prefix match as active and then stamps + * `aria-current="page"` on the result — and it does so *after* spreading + * `activeProps`, so that value cannot be overridden, only avoided. Left alone, + * reading `/series/x/y` announces the header's Series item, both `/series` + * breadcrumb crumbs, and the footer's Series link as the current page, + * alongside the crumb that genuinely is one. + * + * So matching is pinned to `exact`, which limits the router's marker to the one + * link that really is the current page, and the section highlight is derived + * separately and exposed as `data-section` for callers to style. The highlight + * is presentational, which is the right register for it: "you are somewhere + * under here" is a visual hint, not a claim about the current page. + */ +export function NavLink({ + item, + className, + onNavigate, +}: { + item: NavItem; + className?: string; + /** Called after following the link, so a drawer can close itself. */ + onNavigate?: () => void; +}) { + const location = useLocation(); + // Normalised because the locale prefix is part of the address but not of the + // route paths these items are declared with. + const pathname = deLocalizeHref(location.pathname); + const isSection = pathname === item.to || pathname.startsWith(`${item.to}/`); + + return ( + + {item.label()} + + ); +} diff --git a/src/modules/layout/components/site-footer.tsx b/src/modules/layout/components/site-footer.tsx new file mode 100644 index 0000000..36b8b25 --- /dev/null +++ b/src/modules/layout/components/site-footer.tsx @@ -0,0 +1,81 @@ +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 { MATCH_EXACT_PATH, 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..3b0dd38 --- /dev/null +++ b/src/modules/layout/components/site-header.tsx @@ -0,0 +1,56 @@ +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 { NavLink } from '#/modules/layout/components/nav-link'; +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..146f399 --- /dev/null +++ b/src/modules/layout/components/site-shell.tsx @@ -0,0 +1,72 @@ +import { useLocation } from '@tanstack/react-router'; +import * 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 it programmatically focusable without adding it to the + * tab order, which both the skip link and the route-change handler below rely + * on. The footer is pushed down on short pages by the column layout rather than + * a sticky-footer hack. + */ +export function SiteShell({ children }: { children: React.ReactNode }) { + const contentRef = useRouteChangeFocus(); + + return ( +
+ + +
+ {children} +
+ +
+ ); +} + +/** + * Moves focus to the content landmark whenever the route changes. + * + * A full page load resets focus to the document and screen readers announce the + * new page. A client-side navigation does neither: the address changes and the + * DOM swaps, but focus stays wherever the reader left it — usually on a link + * that no longer exists — so nothing is announced and the next Tab starts from + * the top of the document. Following "next article" in a series would be silent + * (WCAG 2.4.3). + * + * Focusing the landmark makes assistive technology announce it and begin + * reading the new content from there. The first render is skipped: a fresh page + * load already puts focus in the right place, and stealing it would scroll + * anyone arriving at a `#heading` deep link back to the top. + */ +function useRouteChangeFocus() { + const ref = React.useRef(null); + const { pathname } = useLocation(); + const isInitialRender = React.useRef(true); + + React.useEffect( + function focusContentOnNavigation() { + if (isInitialRender.current) { + isInitialRender.current = false; + return; + } + ref.current?.focus(); + }, + [pathname], + ); + + return ref; +} 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..02fbaf5 --- /dev/null +++ b/src/modules/layout/layout.nav.ts @@ -0,0 +1,37 @@ +import { m } from '#/lib/i18n/paraglide/messages.js'; +import type { FileRouteTypes } from '#/routeTree.gen'; + +/** + * Restricts the router's active matching to an exact URL match. + * + * By default a prefix match counts as active, and the router then stamps + * `aria-current="page"` on it — after spreading `activeProps`, so the value + * cannot be overridden, only avoided. Without this, reading `/series/x/y` + * announces the breadcrumb's `/series` crumbs and the footer's Series link as + * the current page alongside the crumb that genuinely is one. + * + * Apply to any link that is a destination rather than a statement about where + * the reader is. + */ +export const MATCH_EXACT_PATH = { exact: true } as const; + +/** 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/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.fn.tsx b/src/modules/post/post.fn.tsx index 4ec08dd..1f1227d 100644 --- a/src/modules/post/post.fn.tsx +++ b/src/modules/post/post.fn.tsx @@ -1,15 +1,17 @@ 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 { collectFeedItems, findAdjacentPosts } from '#/modules/content/content.utils'; import { MarkdownRender } from '#/modules/markdown'; -import type { PostContent, PostItem } from '#/modules/post/post.types'; +import type { PostItem, PostPageData } from '#/modules/post/post.types'; +import { findRelatedByTags } from '#/modules/tag/tag.utils'; 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,26 +23,40 @@ export const getAllPostsFn = createServerFn({ method: 'GET' }).handler((): PostI tags: post.tags, thumbnail: post.thumbnail, lastModification: post.lastModification, + readingTime: post.readingTime, }; }); }); 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); + .handler(async ({ data }): Promise => { + const post = postDocuments.find((post) => post.slug === data.slug); if (!post) throw notFound(); + const feed = collectFeedItems(); + const self = feed.find((item) => item.kind === 'post' && item.slug === post.slug); + return { - slug: post.slug, - title: post.title, - description: post.description, - date: post.date, - author: post.author, - tags: post.tags, - thumbnail: parseThumbnail(post.thumbnail), - lastModification: post.lastModification, - toc: post.toc, - mdx: await renderServerComponent(), + post: { + slug: post.slug, + title: post.title, + description: post.description, + date: post.date, + author: post.author, + tags: post.tags, + thumbnail: parseThumbnail(post.thumbnail), + lastModification: post.lastModification, + readingTime: post.readingTime, + toc: post.toc, + contentPath: post.contentPath, + mdx: await renderServerComponent(), + }, + context: { + ...findAdjacentPosts(feed, post.slug), + // `self` is always found — it was just located in the same collections + // the feed is built from — but relatedness needs the flattened shape. + related: self ? findRelatedByTags(feed, self) : [], + }, }; }); diff --git a/src/modules/post/post.types.ts b/src/modules/post/post.types.ts index 7721607..394b6e8 100644 --- a/src/modules/post/post.types.ts +++ b/src/modules/post/post.types.ts @@ -2,6 +2,7 @@ import type { RenderableServerComponent } from '@tanstack/react-start/rsc'; import type { JSX } from 'react/jsx-runtime'; import * as z from 'zod/v4'; +import type { ArticleContext } from '#/modules/content/content.types'; import type { TableOfContents } from '#/modules/markdown/markdown.types'; import type { postFrontmatterSchema } from '#/modules/post/post.schema'; @@ -10,9 +11,19 @@ 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 { toc: TableOfContents; + /** Repository-relative path to the source `.mdx`, for the "edit this page" link. */ + contentPath: string; mdx: RenderableServerComponent; } + +/** Everything `/posts/$slug` renders: the document, plus what surrounds it. */ +export interface PostPageData { + post: PostContent; + context: ArticleContext; +} diff --git a/src/modules/series/components/series-parts-list.tsx b/src/modules/series/components/series-parts-list.tsx new file mode 100644 index 0000000..b7554b3 --- /dev/null +++ b/src/modules/series/components/series-parts-list.tsx @@ -0,0 +1,56 @@ +import { Link } from '@tanstack/react-router'; + +import type { SeriesPostItem } from '#/modules/series/series.types'; +import { cn } from '#/ui/utils'; + +/** + * A series' parts in reading order, for the rail beside a part. + * + * An ordered list, because the order carries meaning here — a screen reader + * announces the position, which is the same information the visible numbers + * give everyone else. + * + * The part being read is marked `aria-current="page"` rather than only styled, + * so "where am I in this series" is answerable without seeing the highlight. + */ +export function SeriesPartsList({ + seriesSlug, + parts, + activeSlug, + className, +}: { + seriesSlug: string; + parts: SeriesPostItem[]; + /** Slug of the part currently being read, if any. */ + activeSlug?: string; + className?: string; +}) { + return ( +
    + {parts.map((part) => { + const isActive = part.slug === activeSlug; + return ( +
  1. + + + {String(part.order).padStart(2, '0')} + + {part.title} + +
  2. + ); + })} +
+ ); +} diff --git a/src/modules/series/series.fn.tsx b/src/modules/series/series.fn.tsx index f56bc3f..1ea51d2 100644 --- a/src/modules/series/series.fn.tsx +++ b/src/modules/series/series.fn.tsx @@ -1,20 +1,23 @@ 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 type { FeedItem } from '#/modules/content/content.types'; +import { collectFeedItems } from '#/modules/content/content.utils'; import { MarkdownRender } from '#/modules/markdown'; import type { SeriesContent, SeriesItem, - SeriesPostContent, SeriesPostItem, + SeriesPostPageData, } from '#/modules/series/series.types'; +import { findRelatedByTags } from '#/modules/tag/tag.utils'; 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 +27,7 @@ export const getAllSeriesFn = createServerFn({ method: 'GET' }).handler((): Seri date: series.date, thumbnail: series.thumbnail, lastModification: series.lastModification, + readingTime: series.readingTime, }; }); }); @@ -31,28 +35,9 @@ 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 - .filter((post) => post.seriesSlug === data.slug) - .sort((a, b) => a.order - b.order) - .map((post) => { - return { - slug: post.slug, - order: post.order, - title: post.title, - description: post.description, - date: post.date, - author: post.author, - tags: post.tags, - // A post without its own thumbnail inherits the series' one. - thumbnail: post.thumbnail ?? series.thumbnail, - lastModification: post.lastModification, - series: { slug: post.seriesSlug }, - }; - }); - return { slug: series.slug, title: series.title, @@ -60,24 +45,28 @@ export const getSeriesBySlugFn = createServerFn({ method: 'GET' }) date: series.date, thumbnail: parseThumbnail(series.thumbnail), lastModification: series.lastModification, - posts, + readingTime: series.readingTime, + posts: collectSeriesParts(series.slug, series.thumbnail), toc: series.toc, + contentPath: series.contentPath, mdx: await renderServerComponent(), }; }); -export const getSeriesPostFn = createServerFn({ method: 'GET' }) - .validator(z.object({ slug: z.string(), postSlug: z.string() })) - .handler(async ({ data }): Promise => { - const post = allSeriesPosts.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); - - return { +/** + * A series' parts in reading order. + * + * `seriesThumbnail` is the fallback a part inherits when it has none of its own, + * so a series reads as one visual set rather than a run of gaps. + */ +function collectSeriesParts( + seriesSlug: string, + seriesThumbnail: SeriesItem['thumbnail'], +): SeriesPostItem[] { + return seriesPostDocuments + .filter((post) => post.seriesSlug === seriesSlug) + .sort((a, b) => a.order - b.order) + .map((post) => ({ slug: post.slug, order: post.order, title: post.title, @@ -85,10 +74,85 @@ export const getSeriesPostFn = createServerFn({ method: 'GET' }) date: post.date, author: post.author, tags: post.tags, - thumbnail: parseThumbnail(post.thumbnail ?? series?.thumbnail ?? null), + thumbnail: post.thumbnail ?? seriesThumbnail, lastModification: post.lastModification, + readingTime: post.readingTime, series: { slug: post.seriesSlug }, - toc: post.toc, - mdx: await renderServerComponent(), + })); +} + +export const getSeriesPostFn = createServerFn({ method: 'GET' }) + .validator(z.object({ slug: z.string(), postSlug: z.string() })) + .handler(async ({ data }): Promise => { + const post = seriesPostDocuments.find( + (post) => post.seriesSlug === data.slug && post.slug === data.postSlug, + ); + if (!post) throw notFound(); + + // Guaranteed to exist: the collection's `onSuccess` fails the build for any + // series directory holding parts without an `_index.mdx`. + const series = seriesDocuments.find((series) => series.slug === post.seriesSlug); + if (!series) throw notFound(); + + const parts = collectSeriesParts(series.slug, series.thumbnail); + const index = parts.findIndex((part) => part.slug === post.slug); + const feed = collectFeedItems(); + const self = feed.find( + (item) => + item.kind === 'series-post' && + item.slug === post.slug && + item.series.slug === post.seriesSlug, + ); + + return { + post: { + slug: post.slug, + order: post.order, + title: post.title, + description: post.description, + date: post.date, + author: post.author, + tags: post.tags, + // A part without its own thumbnail inherits the series' one. + thumbnail: parseThumbnail(post.thumbnail ?? series.thumbnail), + lastModification: post.lastModification, + readingTime: post.readingTime, + series: { slug: post.seriesSlug }, + toc: post.toc, + contentPath: post.contentPath, + mdx: await renderServerComponent(), + }, + series: { slug: series.slug, title: series.title, parts }, + context: { + // Within a series, sequence is the author's ordering rather than + // publication date — that ordering is the whole point of a series. + previous: toFeedItem(parts[index - 1], series), + next: toFeedItem(parts[index + 1], series), + related: self ? findRelatedByTags(feed, self) : [], + }, }; }); + +/** Flattens a neighbouring part to the shape the shared link components take. */ +function toFeedItem( + part: SeriesPostItem | undefined, + series: { slug: string; title: string }, +): FeedItem | null { + if (!part) return null; + return { + 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: series.slug, + title: series.title, + order: part.order, + total: seriesPostDocuments.filter((post) => post.seriesSlug === series.slug).length, + }, + }; +} diff --git a/src/modules/series/series.types.ts b/src/modules/series/series.types.ts index 0bd3155..a32bd32 100644 --- a/src/modules/series/series.types.ts +++ b/src/modules/series/series.types.ts @@ -2,6 +2,7 @@ import type { RenderableServerComponent } from '@tanstack/react-start/rsc'; import type { JSX } from 'react/jsx-runtime'; import * as z from 'zod/v4'; +import type { ArticleContext } from '#/modules/content/content.types'; import type { TableOfContents } from '#/modules/markdown/markdown.types'; import type { seriesFrontmatterSchema, @@ -14,11 +15,15 @@ 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 { posts: SeriesPostItem[]; toc: TableOfContents; + /** Repository-relative path to the source `.mdx`, for the "edit this page" link. */ + contentPath: string; mdx: RenderableServerComponent; } @@ -29,9 +34,31 @@ export interface SeriesPostItem extends SeriesPostFrontmatter { }; order: number; lastModification: string; + /** Estimated read duration in whole minutes. */ + readingTime: number; } export interface SeriesPostContent extends SeriesPostItem { toc: TableOfContents; + /** Repository-relative path to the source `.mdx`, for the "edit this page" link. */ + contentPath: string; mdx: RenderableServerComponent; } + +/** + * Everything `/series/$slug/$postSlug` renders. + * + * The full part list travels with the part because the page shows the whole + * series alongside it — a rail from `xl` up, a disclosure below — and because + * "Part 3 of 7" and the previous/next links are all read off the same ordering. + */ +export interface SeriesPostPageData { + post: SeriesPostContent; + series: { + slug: SeriesItem['slug']; + title: SeriesItem['title']; + /** Every part, in reading order. */ + parts: SeriesPostItem[]; + }; + context: ArticleContext; +} 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..b1f710f --- /dev/null +++ b/src/modules/tag/tag.utils.ts @@ -0,0 +1,89 @@ +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)); +} + +/** + * Other content sharing tags with `target`, most overlap first. + * + * Shared-tag count is a blunt relevance signal, but it is one a reader can + * verify at a glance — the suggestion always visibly has something in common + * with what they just read. Items sharing nothing are omitted rather than + * padded in, so an article with unusual tags shows fewer suggestions or none, + * which is more honest than filling the slot with whatever was newest. + * + * Ties break by the order `items` arrives in, which the caller has already + * sorted, so the result is stable between builds. + */ +export function findRelatedByTags(items: FeedItem[], target: FeedItem, limit = 3): FeedItem[] { + const targetTags = new Set(target.tags.map(toTagSlug)); + if (targetTags.size === 0) return []; + + return items + .filter((item) => !isSameDocument(item, target)) + .map((item) => ({ + item, + shared: item.tags.filter((tag) => targetTags.has(toTagSlug(tag))).length, + })) + .filter(({ shared }) => shared > 0) + .sort((a, b) => b.shared - a.shared) + .slice(0, limit) + .map(({ item }) => item); +} + +/** + * Whether two entries are the same document. + * + * Slug alone is not enough: two series can each have a part slugged + * `introduction`, so a part must match on its series too. + */ +function isSameDocument(a: FeedItem, b: FeedItem): boolean { + if (a.kind !== b.kind) return false; + if (a.kind === 'post') return a.slug === b.slug; + return b.kind === 'series-post' && a.slug === b.slug && a.series.slug === b.series.slug; +} 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 `