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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
251 changes: 147 additions & 104 deletions content-collections.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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<MDXContent>(`#/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<MDXContent>(`#/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<MDXContent>(`#/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<string, Set<number>>();
for (const document of documents) {
const seen = ordersBySeries.get(document.seriesSlug) ?? new Set<number>();
if (seen.has(document.order)) {
/** The subset of the collection context these transforms actually use. */
type TransformContext = Pick<CollectionContext, 'cache'>;

/**
* 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<MDXContent>(`#/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<TName extends string>(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<TName extends string>(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<TName extends string>(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<string, Set<number>>();
for (const document of documents) {
const seen = ordersBySeries.get(document.seriesSlug) ?? new Set<number>();
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
Expand All @@ -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],
},
Expand Down
51 changes: 50 additions & 1 deletion messages/en.json
Original file line number Diff line number Diff line change
@@ -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"
}
51 changes: 50 additions & 1 deletion messages/id.json
Original file line number Diff line number Diff line change
@@ -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"
}
Loading
Loading