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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ node_modules/
# Output
dist/

# Content Collections (generated)
.content-collections/

# Deployment
.alchemy/
.wrangler/
Expand Down
123 changes: 123 additions & 0 deletions content-collections.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import { existsSync } from 'node:fs';
import path from 'node:path';

import type { WriterHook } from '@content-collections/core';
import { createDefaultImport, defineCollection, defineConfig } from '@content-collections/core';
import type { MDXContent } from 'mdx/types';

import { getLastModification } from '#/modules/markdown/utils';
import { postFrontmatterSchema } from '#/modules/post/post.schema';
import {
seriesFrontmatterSchema,
seriesPostFrontmatterSchema,
} from '#/modules/series/series.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 lastModification = await ctx.cache(
path.join(CONTENT_DIRECTORY, '/posts', filePath),
getLastModification,
);
const mdx = createDefaultImport<MDXContent>(`#/content/posts/${filePath}`);
return { ...document, slug, mdx, lastModification };
},
});

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 lastModification = await ctx.cache(
path.join(CONTENT_DIRECTORY, '/series', filePath),
getLastModification,
);
const mdx = createDefaultImport<MDXContent>(`#/content/series/${filePath}`);
return { ...document, slug, mdx, lastModification };
},
});

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 lastModification = await ctx.cache(
path.join(CONTENT_DIRECTORY, '/series', filePath),
getLastModification,
);
const mdx = createDefaultImport<MDXContent>(`#/content/series/${filePath}`);
return { ...document, slug, seriesSlug, order, mdx, lastModification };
},
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, '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.`,
);
}
}
},
});

// 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
// protection fails the build if the collection is ever pulled client-side.
// See https://tanstack.com/start/latest/docs/framework/react/guide/import-protection
const serverOnlyHook: WriterHook = async ({ fileType, content }) => {
if (fileType === 'typeDefinition') {
return { content };
}
return {
content: `import '@tanstack/react-start/server-only';\n\n${content}`,
};
};

export default defineConfig({
content: [posts, series, seriesPost],
hooks: {
writer: [serverOnlyHook],
},
});
9 changes: 9 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -46,18 +46,27 @@
"devDependencies": {
"@cloudflare/vite-plugin": "1.43.0",
"@cloudflare/workers-types": "5.20260705.1",
"@content-collections/core": "0.15.2",
"@content-collections/vite": "0.3.0",
"@inlang/paraglide-js": "^2.20.2",
"@mdx-js/rollup": "3.1.1",
"@rolldown/plugin-babel": "0.2.3",
"@tailwindcss/typography": "0.5.20",
"@tailwindcss/vite": "4.3.2",
"@tanstack/devtools-vite": "0.8.1",
"@tanstack/react-devtools": "0.10.8",
"@tanstack/react-router-devtools": "1.167.0",
"@types/mdx": "2.0.13",
"@types/node": "26.1.0",
"@types/react": "19.2.17",
"@types/react-dom": "19.2.3",
"@vitejs/plugin-react": "6.0.3",
"@vitejs/plugin-rsc": "0.5.27",
"alchemy": "0.93.12",
"babel-plugin-react-compiler": "1.0.0",
"remark-frontmatter": "5.0.0",
"remark-gfm": "4.0.1",
"remark-mdx-frontmatter": "4.0.0",
"shadcn": "4.13.0",
"tailwindcss": "4.3.2",
"tldts": "7.4.6",
Expand Down
Loading
Loading