From eb8eea79076ef582b431a2ac374677037f2539e0 Mon Sep 17 00:00:00 2001 From: Paras verma Date: Sun, 16 Aug 2026 14:31:33 +0530 Subject: [PATCH 1/4] feat(blog): add :::gallery directive for responsive image grids --- AGENTS.md | 205 ++++++++++++++++++++++++++ app/globals.css | 32 ++++ components/blog/MarkdownBody.test.tsx | 154 +++++++++++++++++++ components/blog/MarkdownBody.tsx | 37 ++++- lib/markdown/directives.test.ts | 176 ++++++++++++++++++++++ lib/markdown/directives.ts | 86 +++++++++++ package.json | 4 +- pnpm-lock.yaml | 48 ++++++ 8 files changed, 739 insertions(+), 3 deletions(-) create mode 100644 AGENTS.md create mode 100644 components/blog/MarkdownBody.test.tsx create mode 100644 lib/markdown/directives.test.ts create mode 100644 lib/markdown/directives.ts diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..aa498e3 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,205 @@ +# AGENTS.md + + + +# This is NOT the Next.js you know + +This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices. + +This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean. + + + +Instructions for AI coding agents working on this repository. + +## Project Overview + +**ossium-blog** — the official blog for [Ossium](https://ossium.in), a static-first Next.js site powered by Markdown posts. Live at [blog.ossium.in](https://blog.ossium.in). + +| Layer | Choice | +| --------------- | ---------------------------------------------- | +| Framework | Next.js 16 (App Router, static generation) | +| UI | React 19 + Tailwind CSS v4 | +| Language | TypeScript (strict) | +| Package Manager | pnpm 11.15.1 | +| Content | Markdown + YAML frontmatter (`content/posts/`) | +| Testing | Vitest + Testing Library | +| Linting | ESLint 9 (flat config) + Prettier | + +## Essential Commands + +Run from the project root. All must pass before committing. + +```bash +pnpm install # install dependencies +pnpm dev # dev server at http://localhost:3000 +pnpm build # production build (SSG) +pnpm lint # ESLint +pnpm typecheck # tsc --noEmit +pnpm format # format with Prettier +pnpm format:check # check formatting (CI) +pnpm test # run Vitest once +pnpm test:watch # Vitest in watch mode +``` + +Full verification before pushing: + +```bash +pnpm lint && pnpm typecheck && pnpm format:check && pnpm test && pnpm build +``` + +## Code Conventions + +- **TypeScript strict mode** — no `any`, no `@ts-ignore`. +- **Path alias** — use `@/` for all imports (maps to project root). +- **No `console.log`** in committed code. +- **No comments** unless explicitly requested. +- **No commented-out code** or unresolved `TODO`s — file an issue instead. +- **Prettier** handles formatting — double quotes, semicolons, 80 cols, 2-space indent, trailing commas. +- **Match surrounding code style** — mimic existing patterns in the file you're editing. + +## File Structure + +``` +app/ # Next.js App Router pages, layouts, API routes, SEO files + [slug]/page.tsx # Individual blog post pages (SSG) + BlogListing.tsx # Client component: search, filters, featured rail + layout.tsx # Root layout (Navbar, Footer, theme, metadata) + feed.xml/route.ts # RSS 2.0 feed + sitemap.ts # Auto-generated sitemap + robots.ts # Auto-generated robots.txt +components/ + blog/ # PostCard, MarkdownBody, ShareButton + landing/ # Navbar, Footer, DashedFrame + theme/ # ThemeToggle (light/dark, persisted) + ui/ # Container, SearchOverlay +content/posts/ # Markdown blog posts with YAML frontmatter +lib/ + content/ # Content pipeline: load, parse, related posts, types + markdown/ # Directives plugin: :::gallery + preprocessor (directives.ts) + constants.ts # APP_URL, APP_CONFIG, SOURCE_EDIT_BASE + utils.ts # cn() classname helper +public/ # Static assets (logos, demo images, llms.txt) +``` + +## Content Guidelines + +### Post Location + +All posts live in `content/posts/` as `.md` files. The filename slug becomes the URL path. + +### Frontmatter Schema + +```yaml +--- +title: "Clear, keyword-aware title" +description: "1-2 sentences for SEO (~150-160 chars)." +type: article | guide | howto | question +category: contribution | gsoc | github | career | tools | programs | beginners +tags: [tag-one, tag-two] +keywords: [seo keyword one, seo keyword two] +publishedAt: 2026-07-11 +updatedAt: 2026-07-11 +author: ossium +authorUrl: https://ossium.in +featured: false +draft: false +image: /demo/image.webp +answerSummary: "Only for type=question — short answer for FAQ rich results." +--- +``` + +### Post Types + +| Type | Use Case | +| ---------- | --------------------------------- | +| `article` | General blog posts | +| `guide` | Step-by-step guides | +| `howto` | How-to tutorials | +| `question` | Q&A posts (generates FAQ JSON-LD) | + +### Categories + +`contribution`, `gsoc`, `github`, `career`, `tools`, `programs`, `beginners` + +### Key Rules + +- Set `draft: true` until the post is ready to publish. +- Internal links use relative paths (`/other-post-slug`). +- Content in `content/posts/` is **not MIT licensed** — it is © Ossium, all rights reserved. + +### Images and Galleries + +Single image — the alt text renders as the caption: + +```md +![Description](/images/1.jpg) +``` + +Gallery (`columns` optional, defaults to 2; supported values: 2, 3, 4): + +```md +:::gallery columns=3 + +![](/images/1.jpg) +![Caption](/images/2.jpg) + +::: +``` + +- Images live in `public/`, referenced from the root (`/images/1.jpg`); remote URLs are supported. +- Galleries render as a responsive grid: 1 column on mobile, the requested count from 641px. +- Directive attributes may be written bare (`columns=3`) or braced (`{columns=3}`); `preprocessDirectives` in `lib/markdown/directives.ts` normalizes the bare form. +- Do not use raw HTML for layout — prefer directives (`:::gallery`), which are extensible for future `note`, `warning`, and `tabs` blocks. + +## Testing + +- Framework: **Vitest** with `@testing-library/react` and `jsdom`. +- Test files: `lib/**/*.test.ts` and `components/**/*.test.ts(x)`. +- Pure logic in `lib/` is the primary testing target. +- Run `pnpm test` to execute, `pnpm test:watch` for development. +- New code should include tests where practical. + +## Git Workflow + +### Branch Naming + +| Kind | Prefix | Example | +| ------- | ---------- | ---------------------------- | +| Feature | `feat/` | `feat/reading-progress-bar` | +| Bug fix | `fix/` | `fix/search-highlight-flash` | +| Content | `content/` | `content/gsoc-2027-timeline` | +| Chore | `chore/` | `chore/upgrade-deps` | +| Docs | `docs/` | `docs/contributing-typo` | + +### Commit Messages + +Use [Conventional Commits](https://www.conventionalcommits.org/): + +``` +(): +``` + +Types: `feat`, `fix`, `content`, `docs`, `refactor`, `chore`, `test`, `ci`, `perf`, `style`. + +- Subject ≤ 72 characters, imperative mood, lowercase (unless acronym). + +## Verification Checklist + +Before committing or pushing, confirm: + +- [ ] `pnpm lint` passes with no errors +- [ ] `pnpm typecheck` passes with no errors +- [ ] `pnpm format:check` passes (or run `pnpm format` to fix) +- [ ] `pnpm test` passes +- [ ] `pnpm build` succeeds +- [ ] No `console.log` statements in new code +- [ ] No secrets, API keys, or credentials committed +- [ ] Path alias `@/` used for imports (not relative `../../`) + +## Security + +- This is a **static site** — no database, no server-side state, no user input handling. +- Security headers are set in `next.config.ts` (CSP, X-Frame-Options: DENY, etc.). +- Never commit `.env` files — only `.env.example` is tracked. +- Report vulnerabilities via the process in `SECURITY.md`. diff --git a/app/globals.css b/app/globals.css index b1202f1..cc40873 100644 --- a/app/globals.css +++ b/app/globals.css @@ -209,3 +209,35 @@ summary, label[for] { cursor: pointer; } + +/* Galleries — responsive image grids from :::gallery directives */ +.blog-prose .gallery { + display: grid; + grid-template-columns: 1fr; + gap: 12px; + margin: 32px 0; +} +.blog-prose .gallery > p { + display: contents; +} +.blog-prose .gallery > p > span { + margin: 0; +} +.blog-prose .gallery img { + display: block; + width: 100%; + height: auto; + max-height: 300px; + object-fit: contain; +} +@media (min-width: 641px) { + .blog-prose .gallery[data-columns="2"] { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + .blog-prose .gallery[data-columns="3"] { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + .blog-prose .gallery[data-columns="4"] { + grid-template-columns: repeat(4, minmax(0, 1fr)); + } +} diff --git a/components/blog/MarkdownBody.test.tsx b/components/blog/MarkdownBody.test.tsx new file mode 100644 index 0000000..2669f16 --- /dev/null +++ b/components/blog/MarkdownBody.test.tsx @@ -0,0 +1,154 @@ +// @vitest-environment jsdom +import type { ComponentProps } from "react"; +import { render } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import MarkdownBody from "./MarkdownBody"; + +vi.mock("next/image", () => ({ + default: (props: ComponentProps<"img">) => , +})); + +vi.mock("next/link", () => ({ + default: ({ children, ...props }: ComponentProps<"a">) => ( + {children} + ), +})); + +describe("MarkdownBody galleries", () => { + it("renders a gallery directive as a grid with default columns", () => { + const { container } = render( + , + ); + + const grid = container.querySelector(".gallery"); + const images = container.querySelectorAll("img"); + + expect(grid).toBeInTheDocument(); + expect(grid).toHaveAttribute("data-columns", "2"); + expect(grid?.querySelectorAll("img")).toHaveLength(2); + expect(images).toHaveLength(2); + }); + + it("honors the columns attribute for 2, 3, and 4 columns", () => { + const { container } = render( + , + ); + + const grids = container.querySelectorAll(".gallery"); + expect(grids).toHaveLength(3); + expect(grids[0]).toHaveAttribute("data-columns", "2"); + expect(grids[1]).toHaveAttribute("data-columns", "3"); + expect(grids[2]).toHaveAttribute("data-columns", "4"); + }); + + it("falls back to 2 columns for invalid values", () => { + const { container } = render( + , + ); + + expect(container.querySelector(".gallery")).toHaveAttribute( + "data-columns", + "2", + ); + }); + + it("renders images inside galleries with the custom image component", () => { + const { container } = render( + , + ); + + const images = container.querySelectorAll(".gallery img"); + expect(images).toHaveLength(2); + expect(images[0]).toHaveAccessibleName("First"); + expect(images[1]).toHaveAccessibleName("Second"); + expect(images[1]).toHaveAttribute("loading", "lazy"); + }); + + it("keeps standalone images and multiple galleries separate", () => { + const { container } = render( + , + ); + + const grids = container.querySelectorAll(".gallery"); + const images = container.querySelectorAll("img"); + + expect(grids).toHaveLength(2); + expect(grids[0]?.querySelectorAll("img")).toHaveLength(1); + expect(grids[1]?.querySelectorAll("img")).toHaveLength(1); + expect(images).toHaveLength(3); + expect(images[1]).toHaveAccessibleName("Standalone"); + }); + + it("renders unhandled directives as plain blocks without breaking content", () => { + const { container } = render( + , + ); + + expect(container.textContent).toContain("Before."); + expect(container.textContent).toContain("Hidden."); + expect(container.textContent).toContain("After."); + expect(container.querySelector(".gallery")).toBeNull(); + }); +}); diff --git a/components/blog/MarkdownBody.tsx b/components/blog/MarkdownBody.tsx index c7fd180..733776b 100644 --- a/components/blog/MarkdownBody.tsx +++ b/components/blog/MarkdownBody.tsx @@ -4,8 +4,15 @@ import Image from "next/image"; import Link from "next/link"; import { cloneElement, isValidElement } from "react"; import ReactMarkdown from "react-markdown"; +import remarkDirective from "remark-directive"; import remarkGfm from "remark-gfm"; import rehypeHighlight from "rehype-highlight"; +import { + GALLERY_CLASS, + parseGalleryColumns, + preprocessDirectives, + remarkDirectives, +} from "@/lib/markdown/directives"; interface MarkdownBodyProps { content: string; @@ -21,9 +28,35 @@ export default function MarkdownBody({ content }: MarkdownBodyProps) { return (
{ + const className = node?.properties.className; + const isGallery = + typeof className === "string" && + className.split(/\s+/).includes(GALLERY_CLASS); + if (!isGallery) { + return ( +
+ {children} +
+ ); + } + const rawColumns = node?.properties["data-columns"]; + const columns = parseGalleryColumns( + typeof rawColumns === "string" ? rawColumns : undefined, + ); + return ( +
+ {children} +
+ ); + }, h1: ({ children }) => (

{children} @@ -200,7 +233,7 @@ export default function MarkdownBody({ content }: MarkdownBodyProps) { ), }} > - {content} + {preprocessDirectives(content)}

); diff --git a/lib/markdown/directives.test.ts b/lib/markdown/directives.test.ts new file mode 100644 index 0000000..f240475 --- /dev/null +++ b/lib/markdown/directives.test.ts @@ -0,0 +1,176 @@ +import { describe, expect, it } from "vitest"; +import rehypeStringify from "rehype-stringify"; +import remarkDirective from "remark-directive"; +import remarkParse from "remark-parse"; +import remarkRehype from "remark-rehype"; +import { unified } from "unified"; +import { + GALLERY_CLASS, + GALLERY_DEFAULT_COLUMNS, + parseGalleryColumns, + preprocessDirectives, + remarkDirectives, +} from "./directives"; + +interface DirectiveNode { + type: string; + name?: string; + attributes?: Record; + children: unknown[]; + data?: { + hName?: string; + hProperties?: Record; + }; +} + +function collectDirectives( + node: unknown, + out: DirectiveNode[] = [], +): DirectiveNode[] { + if (!node || typeof node !== "object") return out; + const candidate = node as { type?: unknown; children?: unknown }; + if (candidate.type === "containerDirective") { + out.push(node as DirectiveNode); + } + if (Array.isArray(candidate.children)) { + for (const child of candidate.children) collectDirectives(child, out); + } + return out; +} + +function parseWithDirectives(markdown: string): DirectiveNode[] { + const processor = unified() + .use(remarkParse) + .use(remarkDirective) + .use(remarkDirectives); + const tree = processor.runSync( + processor.parse(preprocessDirectives(markdown)), + ); + return collectDirectives(tree); +} + +describe("parseGalleryColumns", () => { + it("accepts 2, 3, and 4", () => { + expect(parseGalleryColumns("2")).toBe(2); + expect(parseGalleryColumns("3")).toBe(3); + expect(parseGalleryColumns("4")).toBe(4); + }); + + it("falls back to the default for missing or invalid values", () => { + expect(parseGalleryColumns(undefined)).toBe(GALLERY_DEFAULT_COLUMNS); + expect(parseGalleryColumns("1")).toBe(GALLERY_DEFAULT_COLUMNS); + expect(parseGalleryColumns("5")).toBe(GALLERY_DEFAULT_COLUMNS); + expect(parseGalleryColumns("2.5")).toBe(GALLERY_DEFAULT_COLUMNS); + expect(parseGalleryColumns("abc")).toBe(GALLERY_DEFAULT_COLUMNS); + }); +}); + +describe("preprocessDirectives", () => { + it("wraps bare attributes in braces", () => { + expect(preprocessDirectives(":::gallery columns=3\n\n:::")).toBe( + ":::gallery{columns=3}\n\n:::", + ); + }); + + it("leaves braced attributes, ids, and classes untouched", () => { + const input = ":::gallery{columns=3}\n\n:::\n\n:::note{.warning}\n\n:::"; + expect(preprocessDirectives(input)).toBe(input); + }); + + it("leaves directives without attributes untouched", () => { + const input = ":::gallery\n\n![](/a.jpg)\n\n:::"; + expect(preprocessDirectives(input)).toBe(input); + }); + + it("leaves lines inside fenced code blocks untouched", () => { + const input = + "```\n:::gallery columns=2\n```\n\n:::gallery columns=4\n\n:::"; + expect(preprocessDirectives(input)).toBe( + "```\n:::gallery columns=2\n```\n\n:::gallery{columns=4}\n\n:::", + ); + }); + + it("leaves closing fences and closing directives untouched", () => { + const input = ":::gallery columns=2\n\n:::\n\n```\n:::\n```"; + expect(preprocessDirectives(input)).toBe( + ":::gallery{columns=2}\n\n:::\n\n```\n:::\n```", + ); + }); +}); + +describe("remarkDirectives", () => { + it("defaults a gallery without columns to 2", () => { + const [directive] = parseWithDirectives(":::gallery\n\n![](/a.jpg)\n\n:::"); + expect(directive?.data).toEqual({ + hName: "div", + hProperties: { className: GALLERY_CLASS, "data-columns": "2" }, + }); + }); + + it("honors the columns attribute", () => { + const [directive] = parseWithDirectives( + ":::gallery columns=3\n\n![](/a.jpg)\n\n:::", + ); + expect(directive?.data?.hProperties).toEqual({ + className: GALLERY_CLASS, + "data-columns": "3", + }); + }); + + it("falls back to 2 columns for invalid values", () => { + const [directive] = parseWithDirectives( + ":::gallery columns=9\n\n![](/a.jpg)\n\n:::", + ); + expect(directive?.data?.hProperties?.["data-columns"]).toBe("2"); + }); + + it("transforms multiple galleries", () => { + const directives = parseWithDirectives( + ":::gallery\n\n![](/a.jpg)\n\n:::\n\n:::gallery columns=4\n\n![](/b.jpg)\n\n:::", + ); + expect(directives).toHaveLength(2); + expect(directives[0]?.data?.hProperties?.["data-columns"]).toBe("2"); + expect(directives[1]?.data?.hProperties?.["data-columns"]).toBe("4"); + }); + + it("leaves unhandled directives untouched", () => { + const [directive] = parseWithDirectives(":::note\n\nSome note.\n\n:::"); + expect(directive?.data).toBeUndefined(); + }); +}); + +describe("directive rendering pipeline", () => { + it("emits a gallery div with the requested columns", () => { + const html = String( + unified() + .use(remarkParse) + .use(remarkDirective) + .use(remarkDirectives) + .use(remarkRehype) + .use(rehypeStringify) + .processSync( + preprocessDirectives( + ":::gallery columns=3\n\n![](/a.jpg)\n![](/b.jpg)\n\n:::", + ), + ), + ); + expect(html).toContain(' )} - +
{post.tags.length > 0 && (
diff --git a/app/globals.css b/app/globals.css index cc40873..b26c464 100644 --- a/app/globals.css +++ b/app/globals.css @@ -81,6 +81,306 @@ body { transform: scaleX(1); } +/* Markdown post body (server-rendered HTML from lib/content/markdown.ts) */ +.blog-prose h1, +.blog-prose h2, +.blog-prose h3 { + font-family: var(--font-display); + font-weight: 600; + color: var(--color-neutral-900); +} +.dark .blog-prose h1, +.dark .blog-prose h2, +.dark .blog-prose h3 { + color: var(--color-neutral-100); +} +.blog-prose h1 { + font-size: 26px; + line-height: 1.25; + letter-spacing: -0.02em; + margin-top: 40px; + margin-bottom: 16px; +} +.blog-prose h2 { + font-size: 24px; + line-height: 1.3; + letter-spacing: -0.02em; + margin-top: 44px; + margin-bottom: 12px; + padding-bottom: 8px; + border-bottom: 1px solid var(--color-neutral-200); +} +.dark .blog-prose h2 { + border-bottom-color: var(--color-neutral-800); +} +.blog-prose h3 { + font-size: 20px; + line-height: 1.3; + letter-spacing: -0.015em; + margin-top: 32px; + margin-bottom: 8px; +} +.blog-prose p { + margin-bottom: 20px; +} +.blog-prose p:last-child { + margin-bottom: 0; +} +.blog-prose ul, +.blog-prose ol { + padding-left: 20px; + margin-bottom: 16px; +} +.blog-prose ul { + list-style: disc; +} +.blog-prose ol { + list-style: decimal; +} +.blog-prose ul li + li, +.blog-prose ol li + li { + margin-top: 8px; +} +.blog-prose ul > li::marker { + color: var(--color-neutral-400); +} +.dark .blog-prose ul > li::marker { + color: var(--color-neutral-600); +} +.blog-prose ol > li::marker { + color: var(--color-neutral-500); +} +.blog-prose li { + padding-left: 4px; +} +.blog-prose a { + color: var(--color-neutral-600); + text-decoration: underline; + text-underline-offset: 2px; + text-decoration-color: var(--color-neutral-400); + transition: + color 150ms ease, + text-decoration-color 150ms ease; +} +.blog-prose a:hover { + color: var(--color-neutral-900); + text-decoration-color: var(--color-neutral-500); +} +.dark .blog-prose a { + color: var(--color-neutral-300); + text-decoration-color: var(--color-neutral-600); +} +.dark .blog-prose a:hover { + color: var(--color-neutral-100); + text-decoration-color: var(--color-neutral-400); +} +.blog-prose strong { + font-weight: 600; + color: var(--color-neutral-900); +} +.dark .blog-prose strong { + color: var(--color-neutral-100); +} +.blog-prose em { + font-style: italic; + color: var(--color-neutral-800); +} +.dark .blog-prose em { + color: var(--color-neutral-200); +} +.blog-prose blockquote { + margin: 24px 0; + padding-left: 16px; + border-left: 2px solid var(--color-neutral-300); + font-style: italic; + color: var(--color-neutral-500); +} +.dark .blog-prose blockquote { + border-left-color: var(--color-neutral-600); + color: var(--color-neutral-400); +} +.blog-prose hr { + border: none; + border-top: 1px solid var(--color-neutral-200); + margin: 40px 0; +} +.dark .blog-prose hr { + border-top-color: var(--color-neutral-800); +} + +/* Code blocks — terminal window chrome + pre */ +.blog-prose .code-window { + margin: 24px 0; + overflow: hidden; + border-radius: 12px; + border: 1px solid var(--color-neutral-200); + background: #ffffff; +} +.dark .blog-prose .code-window { + border-color: var(--color-neutral-800); + background: #121314; +} +.blog-prose .code-window-bar { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 16px; + border-bottom: 1px solid var(--color-neutral-200); + background: var(--color-neutral-100); +} +.dark .blog-prose .code-window-bar { + border-bottom-color: color-mix( + in srgb, + var(--color-neutral-800) 70%, + transparent + ); + background: #0b0c0d; +} +.blog-prose .code-window-dots { + width: 33px; + height: 11px; + background-image: + radial-gradient(circle at 5.5px 5.5px, #ff5f57 0 4px, transparent 4.5px), + radial-gradient(circle at 16.5px 5.5px, #febc2e 0 4px, transparent 4.5px), + radial-gradient(circle at 27.5px 5.5px, #28c840 0 4px, transparent 4.5px); + background-size: 33px 11px; +} +.blog-prose .code-lang { + font-size: 11px; + font-weight: 500; + letter-spacing: 0.02em; + color: var(--color-neutral-400); +} +.dark .blog-prose .code-lang { + color: rgb(255 255 255 / 0.4); +} +.blog-prose .code-window pre { + margin: 0; + padding: 16px; + overflow-x: auto; + font-size: 13.5px; + font-family: var(--font-mono); + line-height: 1.625; + color: var(--color-neutral-800); + background: transparent; +} +.dark .blog-prose .code-window pre { + color: var(--color-neutral-200); +} +.blog-prose :not(pre) > code { + padding: 2px 6px; + border-radius: 4px; + border: 1px solid var(--color-neutral-300); + background: var(--color-neutral-200); + color: var(--color-neutral-800); + font-size: 14px; + font-family: var(--font-mono); +} +.dark .blog-prose :not(pre) > code { + border-color: rgb(63 63 70 / 0.5); + background: rgb(39 39 42 / 0.8); + color: var(--color-neutral-200); +} + +/* Images — captioned figures, plus single-row image grids */ +.blog-prose figure { + margin: 24px 0; + overflow: hidden; + border-radius: 8px; + border: 1px solid var(--color-neutral-200); + background: var(--color-neutral-100); +} +.dark .blog-prose figure { + border-color: var(--color-neutral-800); + background: #121314; +} +.blog-prose figure img { + display: block; + width: 100%; + height: auto; + max-height: 480px; + object-fit: cover; +} +.blog-prose figcaption { + padding: 8px 12px; + border-top: 1px solid var(--color-neutral-200); + font-size: 12px; + color: var(--color-neutral-500); +} +.dark .blog-prose figcaption { + border-top-color: var(--color-neutral-800); +} +.blog-prose .image-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; + margin: 32px 0; +} +.blog-prose .image-grid > p { + display: contents; +} +.blog-prose .image-grid > p > span { + margin: 0; +} +.blog-prose .image-grid img { + display: block; + width: 100%; + height: auto; + max-height: 300px; + object-fit: contain; +} +@media (max-width: 640px) { + .blog-prose .image-grid { + grid-template-columns: 1fr; + } +} + +/* Tables — wrapped in a horizontally scrollable container */ +.blog-prose .table-scroll { + margin: 24px 0; + overflow-x: auto; +} +.blog-prose table { + width: 100%; + border-collapse: collapse; + font-size: 14px; +} +.blog-prose thead { + border-bottom: 2px solid var(--color-neutral-300); +} +.dark .blog-prose thead { + border-bottom-color: var(--color-neutral-700); +} +.blog-prose tbody tr { + border-bottom: 1px solid var(--color-neutral-200); +} +.blog-prose tbody tr:last-child { + border-bottom: 0; +} +.dark .blog-prose tbody tr { + border-bottom-color: var(--color-neutral-800); +} +.blog-prose th, +.blog-prose td { + padding: 10px 12px; +} +.blog-prose th { + text-align: left; + white-space: nowrap; + font-weight: 600; + color: var(--color-neutral-900); +} +.dark .blog-prose th { + color: var(--color-neutral-100); +} +.blog-prose td { + vertical-align: top; + color: var(--color-neutral-700); +} +.dark .blog-prose td { + color: var(--color-neutral-300); +} + /* Syntax highlighting (rehype-highlight / highlight.js) — light + dark themes */ .hljs { color: #3f3f46; @@ -219,9 +519,21 @@ label[for] { } .blog-prose .gallery > p { display: contents; + margin: 0; } .blog-prose .gallery > p > span { - margin: 0; + display: flex; + flex-direction: column; + overflow: hidden; + border-radius: 0.5rem; + border: 1px solid #e5e7eb; + background: #f9fafb; +} +@media (prefers-color-scheme: dark) { + .blog-prose .gallery > p > span { + border-color: #262626; + background: #121314; + } } .blog-prose .gallery img { display: block; @@ -230,6 +542,19 @@ label[for] { max-height: 300px; object-fit: contain; } +.blog-prose .gallery > p > span > span { + padding: 0.5rem 0.75rem; + font-size: 0.75rem; + line-height: 1.5; + color: #737373; + border-top: 1px solid #e5e7eb; +} +@media (prefers-color-scheme: dark) { + .blog-prose .gallery > p > span > span { + color: #a3a3a3; + border-color: #262626; + } +} @media (min-width: 641px) { .blog-prose .gallery[data-columns="2"] { grid-template-columns: repeat(2, minmax(0, 1fr)); diff --git a/components/theme/ThemeToggle.tsx b/components/theme/ThemeToggle.tsx index 72e51f4..12013b4 100644 --- a/components/theme/ThemeToggle.tsx +++ b/components/theme/ThemeToggle.tsx @@ -31,7 +31,6 @@ export default function ThemeToggle() { const next = !isDark; document.documentElement.classList.toggle("dark", next); localStorage.setItem(STORAGE_KEY, next ? "dark" : "light"); - // Trigger re-render for other listeners window.dispatchEvent(new StorageEvent("storage", { key: STORAGE_KEY })); }; diff --git a/lib/content/markdown.ts b/lib/content/markdown.ts new file mode 100644 index 0000000..4b4c922 --- /dev/null +++ b/lib/content/markdown.ts @@ -0,0 +1,215 @@ +import rehypeHighlight from "rehype-highlight"; +import rehypeStringify from "rehype-stringify"; +import remarkDirective from "remark-directive"; +import remarkGfm from "remark-gfm"; +import remarkParse from "remark-parse"; +import remarkRehype from "remark-rehype"; +import { unified } from "unified"; +import { + GALLERY_CLASS, + preprocessDirectives, + remarkDirectives, +} from "@/lib/markdown/directives"; + +const IMAGE_GRID_CLASS = "image-grid"; + +interface HElement { + type: "element"; + tagName: string; + properties: Record; + children: HNode[]; +} + +interface HRoot { + type: "root"; + children: HNode[]; +} + +type HNode = + | HElement + | HRoot + | { type: "text"; value: string } + | { type: "comment"; value: string }; + +function getClasses(el: HElement): string[] { + const value = el.properties.className; + if (typeof value === "string") return value.split(/\s+/).filter(Boolean); + if (Array.isArray(value)) { + return value.flatMap((v) => + typeof v === "string" ? v.split(/\s+/).filter(Boolean) : [], + ); + } + return []; +} + +function getCodeLanguage(pre: HElement): string { + const code = pre.children.find( + (c): c is HElement => c.type === "element" && c.tagName === "code", + ); + const classes = code ? getClasses(code) : []; + const langClass = classes.find((c) => c.startsWith("language-")); + return langClass ? langClass.replace("language-", "") : "code"; +} + +function wrapCodeWindow(pre: HElement): HElement { + return { + type: "element", + tagName: "div", + properties: { className: "code-window" }, + children: [ + { + type: "element", + tagName: "div", + properties: { className: "code-window-bar" }, + children: [ + { + type: "element", + tagName: "span", + properties: { + className: "code-window-dots", + "aria-hidden": "true", + }, + children: [], + }, + { + type: "element", + tagName: "span", + properties: { className: "code-lang" }, + children: [{ type: "text", value: getCodeLanguage(pre) }], + }, + ], + }, + pre, + ], + }; +} + +function wrapTableScroll(table: HElement): HElement { + return { + type: "element", + tagName: "div", + properties: { className: "table-scroll" }, + children: [table], + }; +} + +function wrapInFigure(img: HElement): HElement { + const alt = typeof img.properties.alt === "string" ? img.properties.alt : ""; + const figure: HElement = { + type: "element", + tagName: "figure", + properties: {}, + children: [img], + }; + if (alt) { + figure.children.push({ + type: "element", + tagName: "figcaption", + properties: {}, + children: [{ type: "text", value: alt }], + }); + } + return figure; +} + +function wrapGalleryImage(img: HElement): HElement { + const alt = typeof img.properties.alt === "string" ? img.properties.alt : ""; + const wrapper: HElement = { + type: "element", + tagName: "span", + properties: { + className: + "block overflow-hidden rounded-lg border border-neutral-200 bg-neutral-100 dark:border-neutral-800 dark:bg-[#121314]", + }, + children: [img], + }; + if (alt) { + wrapper.children.push({ + type: "element", + tagName: "span", + properties: { + className: + "block px-3 py-2 text-xs text-neutral-500 border-t border-neutral-200 dark:border-neutral-800", + }, + children: [{ type: "text", value: alt }], + }); + } + return wrapper; +} + +function fixExternalLink(a: HElement): void { + const href = a.properties.href; + if (typeof href !== "string") return; + const isInternal = href.startsWith("/") && !href.startsWith("//"); + if (!isInternal) { + a.properties.target = "_blank"; + a.properties.rel = "noopener noreferrer"; + } +} + +function processChildren(parent: HNode, inGrid: boolean): void { + if (parent.type !== "element" && parent.type !== "root") return; + const children = parent.children; + for (let i = 0; i < children.length; i++) { + const child = children[i]; + if (child.type !== "element") continue; + const el = child; + const isGrid = + inGrid || + getClasses(el).includes(IMAGE_GRID_CLASS) || + getClasses(el).includes(GALLERY_CLASS); + switch (el.tagName) { + case "pre": + children[i] = wrapCodeWindow(el); + break; + case "table": { + children[i] = wrapTableScroll(el); + processChildren(el, isGrid); + break; + } + case "a": + fixExternalLink(el); + processChildren(el, isGrid); + break; + case "img": + if (isGrid) { + el.properties.loading = "lazy"; + children[i] = wrapGalleryImage(el); + } else { + children[i] = wrapInFigure(el); + } + break; + default: + processChildren(el, isGrid); + } + } +} + +function rehypePostProcess() { + return (tree: HNode) => { + processChildren(tree, false); + }; +} + +const processor = unified() + .use(remarkParse) + .use(remarkGfm) + .use(remarkDirective) + .use(remarkDirectives) + .use(remarkRehype, { allowDangerousHtml: true }) + .use(rehypeHighlight) + .use(rehypePostProcess) + .use(rehypeStringify, { allowDangerousHtml: true }); + +const htmlCache = new Map(); + +/** Render a Markdown post body to static HTML (server-side only). */ +export function renderMarkdownToHtml(markdown: string): string { + const source = preprocessDirectives(markdown); + const cached = htmlCache.get(source); + if (cached) return cached; + const file = processor.processSync(source); + const html = String(file); + htmlCache.set(source, html); + return html; +} diff --git a/package.json b/package.json index 0358f5d..8ba9b03 100644 --- a/package.json +++ b/package.json @@ -65,11 +65,16 @@ "react-dom": "^19.2.8", "react-markdown": "^10.1.0", "rehype-highlight": "^7.0.2", + "rehype-stringify": "^10.0.1", "remark-directive": "^4.0.0", "remark-gfm": "^4.0.1", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.1.2", + "unified": "^11.0.5", "unist-util-visit": "^5.1.0" }, "devDependencies": { + "@eslint/eslintrc": "^3.3.6", "@tailwindcss/postcss": "^4", "@testing-library/jest-dom": "6.9.1", "@testing-library/react": "^16", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e43ab42..2b9cf8e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -32,16 +32,31 @@ importers: rehype-highlight: specifier: ^7.0.2 version: 7.0.2 + rehype-stringify: + specifier: ^10.0.1 + version: 10.0.1 remark-directive: specifier: ^4.0.0 version: 4.0.0(supports-color@7.2.0) remark-gfm: specifier: ^4.0.1 version: 4.0.1(supports-color@7.2.0) + remark-parse: + specifier: ^11.0.0 + version: 11.0.0(supports-color@7.2.0) + remark-rehype: + specifier: ^11.1.2 + version: 11.1.2 + unified: + specifier: ^11.0.5 + version: 11.0.5 unist-util-visit: specifier: ^5.1.0 version: 5.1.0 devDependencies: + '@eslint/eslintrc': + specifier: ^3.3.6 + version: 3.3.6(supports-color@7.2.0) '@tailwindcss/postcss': specifier: ^4 version: 4.1.18 @@ -386,8 +401,8 @@ packages: resolution: {integrity: sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@eslint/eslintrc@3.3.3': - resolution: {integrity: sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==} + '@eslint/eslintrc@3.3.6': + resolution: {integrity: sha512-l2Ul9PrHsPCKcEY/ac7VgFj9D80C7S68sOKc618SyHDPK36s1XcFebXY0iTzUVn4Yq+YbwvSnDmCz9yxjX+QrA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@eslint/js@9.39.2': @@ -1228,6 +1243,9 @@ packages: ajv@6.12.6: resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} + ajv@6.15.0: + resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} + ansi-regex@5.0.1: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} @@ -1519,8 +1537,8 @@ packages: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} - electron-to-chromium@1.5.406: - resolution: {integrity: sha512-hWH5ORBi3d0IipnMh7BN5GDTaAmrSSSWmznwt2zltdiRNEWoEQyTwF0FFSBxzHO7hLSRT6loQu3IQGV0wg/Tvg==} + electron-to-chromium@1.5.407: + resolution: {integrity: sha512-4R8XgQOdfxexCd/u63lRm6wCHjECwI45MV9wxAs2ggtfWe2hwlo1ql97jKsju2IcJ+jFSTwBssyYoiWhh7mauQ==} emoji-regex@9.2.2: resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} @@ -1871,6 +1889,9 @@ packages: hast-util-is-element@3.0.0: resolution: {integrity: sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g==} + hast-util-to-html@9.0.5: + resolution: {integrity: sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==} + hast-util-to-jsx-runtime@2.3.6: resolution: {integrity: sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==} @@ -1897,6 +1918,9 @@ packages: html-url-attributes@3.0.1: resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==} + html-void-elements@3.0.0: + resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==} + http-proxy-agent@7.0.2: resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} engines: {node: '>= 14'} @@ -2078,8 +2102,8 @@ packages: js-tokens@9.0.1: resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} - js-yaml@4.1.1: - resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} + js-yaml@4.3.1: + resolution: {integrity: sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==} hasBin: true jsdom@25.0.1: @@ -2413,6 +2437,9 @@ packages: minimatch@3.1.2: resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} + minimatch@3.1.5: + resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} + minimist@1.2.8: resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} @@ -2626,6 +2653,9 @@ packages: rehype-highlight@7.0.2: resolution: {integrity: sha512-k158pK7wdC2qL3M5NcZROZ2tR/l7zOzjxXd5VGdcfIyoijjQqpHd3JKtYSBDpDZ38UI2WJWuFAtkMDxmx5kstA==} + rehype-stringify@10.0.1: + resolution: {integrity: sha512-k9ecfXHmIPuFVI61B9DeLPN0qFHfawM6RsuX48hoqlaKSF61RskNjSm1lI8PhBEM0MRdLxVVm4WmTqJQccH9mA==} + remark-directive@4.0.0: resolution: {integrity: sha512-7sxn4RfF1o3izevPV1DheyGDD6X4c9hrGpfdUpm7uC++dqrnJxIZVkk7CoKqcLm0VUMAuOol7Mno3m6g8cfMuA==} @@ -3404,16 +3434,16 @@ snapshots: dependencies: '@types/json-schema': 7.0.15 - '@eslint/eslintrc@3.3.3(supports-color@7.2.0)': + '@eslint/eslintrc@3.3.6(supports-color@7.2.0)': dependencies: - ajv: 6.12.6 + ajv: 6.15.0 debug: 4.4.3(supports-color@7.2.0) espree: 10.4.0 globals: 14.0.0 ignore: 5.3.2 import-fresh: 3.3.1 - js-yaml: 4.1.1 - minimatch: 3.1.2 + js-yaml: 4.3.1 + minimatch: 3.1.5 strip-json-comments: 3.1.1 transitivePeerDependencies: - supports-color @@ -4067,6 +4097,13 @@ snapshots: json-schema-traverse: 0.4.1 uri-js: 4.4.1 + ajv@6.15.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-json-stable-stringify: 2.1.0 + json-schema-traverse: 0.4.1 + uri-js: 4.4.1 + ansi-regex@5.0.1: {} ansi-styles@4.3.0: @@ -4191,7 +4228,7 @@ snapshots: dependencies: baseline-browser-mapping: 2.11.14 caniuse-lite: 1.0.30001809 - electron-to-chromium: 1.5.406 + electron-to-chromium: 1.5.407 node-releases: 2.0.53 update-browserslist-db: 1.3.1(browserslist@4.28.8) @@ -4363,7 +4400,7 @@ snapshots: es-errors: 1.3.0 gopd: 1.2.0 - electron-to-chromium@1.5.406: {} + electron-to-chromium@1.5.407: {} emoji-regex@9.2.2: {} @@ -4585,7 +4622,7 @@ snapshots: hasown: 2.0.4 is-core-module: 2.16.1 is-glob: 4.0.3 - minimatch: 3.1.2 + minimatch: 3.1.5 object.fromentries: 2.0.8 object.groupby: 1.0.3 object.values: 1.2.1 @@ -4613,7 +4650,7 @@ snapshots: hasown: 2.0.4 jsx-ast-utils: 3.3.5 language-tags: 1.0.9 - minimatch: 3.1.2 + minimatch: 3.1.5 object.fromentries: 2.0.8 safe-regex-test: 1.1.0 string.prototype.includes: 2.0.1 @@ -4641,7 +4678,7 @@ snapshots: estraverse: 5.3.0 hasown: 2.0.4 jsx-ast-utils: 3.3.5 - minimatch: 3.1.2 + minimatch: 3.1.5 object.entries: 1.1.9 object.fromentries: 2.0.8 object.values: 1.2.1 @@ -4669,7 +4706,7 @@ snapshots: '@eslint/config-array': 0.21.1(supports-color@7.2.0) '@eslint/config-helpers': 0.4.2 '@eslint/core': 0.17.0 - '@eslint/eslintrc': 3.3.3(supports-color@7.2.0) + '@eslint/eslintrc': 3.3.6(supports-color@7.2.0) '@eslint/js': 9.39.2 '@eslint/plugin-kit': 0.4.1 '@humanfs/node': 0.16.7 @@ -4880,6 +4917,20 @@ snapshots: dependencies: '@types/hast': 3.0.4 + hast-util-to-html@9.0.5: + dependencies: + '@types/hast': 3.0.4 + '@types/unist': 3.0.3 + ccount: 2.0.1 + comma-separated-tokens: 2.0.3 + hast-util-whitespace: 3.0.0 + html-void-elements: 3.0.0 + mdast-util-to-hast: 13.2.1 + property-information: 7.2.0 + space-separated-tokens: 2.0.2 + stringify-entities: 4.0.4 + zwitch: 2.0.4 + hast-util-to-jsx-runtime@2.3.6(supports-color@7.2.0): dependencies: '@types/estree': 1.0.8 @@ -4925,6 +4976,8 @@ snapshots: html-url-attributes@3.0.1: {} + html-void-elements@3.0.0: {} + http-proxy-agent@7.0.2(supports-color@7.2.0): dependencies: agent-base: 7.1.4 @@ -5110,7 +5163,7 @@ snapshots: js-tokens@9.0.1: {} - js-yaml@4.1.1: + js-yaml@4.3.1: dependencies: argparse: 2.0.1 @@ -5658,6 +5711,10 @@ snapshots: dependencies: brace-expansion: 1.1.12 + minimatch@3.1.5: + dependencies: + brace-expansion: 1.1.12 + minimist@1.2.8: {} ms@2.1.3: {} @@ -5896,6 +5953,12 @@ snapshots: unist-util-visit: 5.1.0 vfile: 6.0.3 + rehype-stringify@10.0.1: + dependencies: + '@types/hast': 3.0.4 + hast-util-to-html: 9.0.5 + unified: 11.0.5 + remark-directive@4.0.0(supports-color@7.2.0): dependencies: '@types/mdast': 4.0.4 From 26ddbd91caf8daeacb49e78af84f6895d95e70e4 Mon Sep 17 00:00:00 2001 From: Paras verma Date: Sun, 16 Aug 2026 17:47:38 +0530 Subject: [PATCH 3/4] docs: update AGENTS.md with server-side rendering architecture --- AGENTS.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index aa498e3..ce3b63f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -69,19 +69,24 @@ app/ # Next.js App Router pages, layouts, API routes, SEO files sitemap.ts # Auto-generated sitemap robots.ts # Auto-generated robots.txt components/ - blog/ # PostCard, MarkdownBody, ShareButton + blog/ # PostCard, MarkdownBody (client fallback), ShareButton landing/ # Navbar, Footer, DashedFrame theme/ # ThemeToggle (light/dark, persisted) ui/ # Container, SearchOverlay content/posts/ # Markdown blog posts with YAML frontmatter lib/ content/ # Content pipeline: load, parse, related posts, types + content/markdown.ts # Server-side markdown renderer (unified/remark/rehype) markdown/ # Directives plugin: :::gallery + preprocessor (directives.ts) constants.ts # APP_URL, APP_CONFIG, SOURCE_EDIT_BASE utils.ts # cn() classname helper public/ # Static assets (logos, demo images, llms.txt) ``` +### Rendering Pipeline + +Blog post markdown is rendered **server-side at build time** (SSG) using the unified/remark/rehype pipeline in `lib/content/markdown.ts`. The generated HTML is injected via `dangerouslySetInnerHTML` in `[slug]/page.tsx`. `components/blog/MarkdownBody.tsx` is kept as a client-side fallback but is not currently used by the main page. + ## Content Guidelines ### Post Location From c9c5bbf6ea17fca234188b121813163848eda4b6 Mon Sep 17 00:00:00 2001 From: Paras verma Date: Wed, 19 Aug 2026 14:03:33 +0530 Subject: [PATCH 4/4] feat: enhance JSON-LD safety, filter invalid posts, and improve markdown rendering --- app/[slug]/page.tsx | 10 +++++++--- app/feed.xml/route.ts | 1 + app/globals.css | 16 ++++++---------- components/ui/SearchOverlay.tsx | 3 ++- lib/content/markdown.ts | 10 ++++++++-- 5 files changed, 24 insertions(+), 16 deletions(-) diff --git a/app/[slug]/page.tsx b/app/[slug]/page.tsx index 3ac11d4..d6e1cef 100644 --- a/app/[slug]/page.tsx +++ b/app/[slug]/page.tsx @@ -92,6 +92,10 @@ export async function generateMetadata({ }; } +function safeJsonLd(obj: object): string { + return JSON.stringify(obj).replace(/