Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
33b1b5b
perf(fonts): preload Geist fonts via dedicated stylesheet
edwintantawi Jul 9, 2026
a6536e2
feat(security): enable CSRF protection for server functions
edwintantawi Jul 9, 2026
70ccffd
feat(blog): add MDX-powered post module with content collections
edwintantawi Jul 9, 2026
523fa0a
feat(series): add MDX-powered series module with grouped multi-part p…
edwintantawi Jul 9, 2026
6dc5796
chore(deps): add Shiki and remark-directive tooling for MDX
edwintantawi Jul 12, 2026
49880a4
feat(markdown): add MDX component overrides for links, code, and tabs
edwintantawi Jul 12, 2026
0edce81
feat(markdown): highlight code and render :::tabs in the MDX pipeline
edwintantawi Jul 12, 2026
00f3297
docs(series): expand the Markdown style guide with code and tabs
edwintantawi Jul 12, 2026
3818bea
chore(deps): add hast/mdast and image-size tooling for content images
edwintantawi Jul 12, 2026
919276b
feat(thumbnail): add frontmatter thumbnails with rendered captions
edwintantawi Jul 12, 2026
9c59a48
feat(markdown): render local images with intrinsic dimensions and cap…
edwintantawi Jul 12, 2026
a12045c
docs(series): expand the Markdown style guide and add thumbnails
edwintantawi Jul 12, 2026
7c97e52
chore(content): remove demo frontend-development series and journey post
edwintantawi Jul 12, 2026
d1c03e0
refactor(markdown): rename module files to markdown.* convention
edwintantawi Jul 12, 2026
5606126
docs(markdown): document the MarkdownRender component
edwintantawi Jul 12, 2026
59ab9c2
style(markdown): add thin code-block scrollbar and refine copy button
edwintantawi Jul 13, 2026
46650ee
style(markdown): truncate long code-block filenames with ellipsis
edwintantawi Jul 13, 2026
69aa3ac
feat(markdown): colorize matching brackets in code blocks
edwintantawi Jul 13, 2026
ba40638
feat(markdown): add heading permalinks and responsive table scroll
edwintantawi Jul 13, 2026
d3f683c
style(markdown): offset footnote jump targets
edwintantawi Jul 13, 2026
fa7f7e2
feat(markdown): add GitHub-style alert callouts
edwintantawi Jul 13, 2026
7657d6f
feat(markdown): derive table of contents from headings
edwintantawi Jul 13, 2026
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
149 changes: 149 additions & 0 deletions content-collections.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
import { existsSync } from 'node:fs';
import { readFile } from 'node:fs/promises';
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 {
extractTableOfContents,
getLastModification,
resolveAsset,
} from '#/modules/markdown/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 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 };
},
});

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 };
},
});

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)) {
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],
},
});
29 changes: 29 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@
"react-dom": "19.2.7",
"react-resizable-panels": "4.12.1",
"recharts": "3.9.2",
"rehype-autolink-headings": "7.1.0",
"rehype-slug": "6.0.0",
"sonner": "2.0.7",
"tailwind-merge": "3.6.0",
"tw-animate-css": "1.4.0",
Expand All @@ -46,22 +48,49 @@
"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",
"@shikijs/colorized-brackets": "4.3.1",
"@shikijs/transformers": "4.3.1",
"@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/hast": "3.0.5",
"@types/mdast": "4.0.4",
"@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",
"hast-util-raw": "9.1.0",
"hast-util-to-html": "9.0.5",
"hast-util-to-jsx-runtime": "2.3.6",
"image-size": "2.0.2",
"mdast-util-directive": "3.1.0",
"mdast-util-from-markdown": "2.0.3",
"mdast-util-mdx-jsx": "3.2.0",
"mdast-util-to-hast": "13.2.1",
"rehype-mdx-import-media": "1.4.0",
"rehype-pretty-code": "0.14.4",
"remark-directive": "4.0.0",
"remark-frontmatter": "5.0.0",
"remark-gfm": "4.0.1",
"remark-mdx-frontmatter": "4.0.0",
"shadcn": "4.13.0",
"shiki": "4.3.1",
"tailwindcss": "4.3.2",
"tldts": "7.4.6",
"typescript": "6.0.3",
"unist-util-visit": "5.1.0",
"vfile": "6.0.3",
"vite": "catalog:",
"vite-plus": "catalog:"
},
Expand Down
Loading
Loading