From 8312dc6d8a9d0c0f183a5c9ca92b2c2d820d02d1 Mon Sep 17 00:00:00 2001 From: Neeldhara Misra Date: Thu, 25 Jun 2026 03:46:34 +0530 Subject: [PATCH] Improve imported blog post rendering --- package-lock.json | 2 + package.json | 2 + sites/art/astro.config.mjs | 11 +- sites/puzzles/astro.config.mjs | 11 +- sites/reflections/astro.config.mjs | 11 +- sites/research/astro.config.mjs | 11 +- sites/reviews/astro.config.mjs | 11 +- sites/vibes/astro.config.mjs | 11 +- src/remark-callouts.mjs | 13 ++ src/remark-code-fence-languages.mjs | 29 +++ src/remark-inline-footnotes.mjs | 289 ++++++++++++++++++++++++++++ src/styles/article.css | 100 +++++++++- 12 files changed, 486 insertions(+), 15 deletions(-) create mode 100644 src/remark-code-fence-languages.mjs create mode 100644 src/remark-inline-footnotes.mjs diff --git a/package-lock.json b/package-lock.json index 1a05a82..988bc01 100644 --- a/package-lock.json +++ b/package-lock.json @@ -38,12 +38,14 @@ "embla-carousel-react": "^8.5.2", "katex": "^0.17.0", "lucide-react": "^0.479.0", + "mdast-util-from-markdown": "^2.0.2", "react": "^19.0.0", "react-dom": "^19.0.0", "react-fast-marquee": "^1.6.5", "react-hook-form": "^7.54.2", "react-icons": "^5.4.0", "rehype-katex": "^7.0.1", + "remark-gfm": "^4.0.1", "remark-math": "^6.0.0", "tailwind-merge": "^3.0.2", "tailwindcss": "^4.0.12", diff --git a/package.json b/package.json index b27a5ac..f172b81 100644 --- a/package.json +++ b/package.json @@ -50,12 +50,14 @@ "embla-carousel-react": "^8.5.2", "katex": "^0.17.0", "lucide-react": "^0.479.0", + "mdast-util-from-markdown": "^2.0.2", "react": "^19.0.0", "react-dom": "^19.0.0", "react-fast-marquee": "^1.6.5", "react-hook-form": "^7.54.2", "react-icons": "^5.4.0", "rehype-katex": "^7.0.1", + "remark-gfm": "^4.0.1", "remark-math": "^6.0.0", "tailwind-merge": "^3.0.2", "tailwindcss": "^4.0.12", diff --git a/sites/art/astro.config.mjs b/sites/art/astro.config.mjs index 1f85fb3..9151a86 100644 --- a/sites/art/astro.config.mjs +++ b/sites/art/astro.config.mjs @@ -2,6 +2,7 @@ import { defineConfig } from "astro/config"; import mdx from "@astrojs/mdx"; import rehypeKatex from "rehype-katex"; +import remarkGfm from "remark-gfm"; import remarkMath from "remark-math"; import sitemap from "@astrojs/sitemap"; @@ -9,9 +10,17 @@ import react from "@astrojs/react"; // @ts-ignore - TailwindCSS v4 Vite plugin has type compatibility issues with Astro import tailwindcss from "@tailwindcss/vite"; import remarkCallouts from "../../src/remark-callouts.mjs"; +import remarkCodeFenceLanguages from "../../src/remark-code-fence-languages.mjs"; +import remarkInlineFootnotes from "../../src/remark-inline-footnotes.mjs"; import { ACTIVE_BLOG_SITE } from "./src/blog-sites.js"; -const remarkPlugins = [remarkMath, remarkCallouts]; +const remarkPlugins = [ + remarkGfm, + remarkInlineFootnotes, + remarkCodeFenceLanguages, + remarkMath, + remarkCallouts, +]; const rehypePlugins = [rehypeKatex]; // https://astro.build/config diff --git a/sites/puzzles/astro.config.mjs b/sites/puzzles/astro.config.mjs index 1f85fb3..9151a86 100644 --- a/sites/puzzles/astro.config.mjs +++ b/sites/puzzles/astro.config.mjs @@ -2,6 +2,7 @@ import { defineConfig } from "astro/config"; import mdx from "@astrojs/mdx"; import rehypeKatex from "rehype-katex"; +import remarkGfm from "remark-gfm"; import remarkMath from "remark-math"; import sitemap from "@astrojs/sitemap"; @@ -9,9 +10,17 @@ import react from "@astrojs/react"; // @ts-ignore - TailwindCSS v4 Vite plugin has type compatibility issues with Astro import tailwindcss from "@tailwindcss/vite"; import remarkCallouts from "../../src/remark-callouts.mjs"; +import remarkCodeFenceLanguages from "../../src/remark-code-fence-languages.mjs"; +import remarkInlineFootnotes from "../../src/remark-inline-footnotes.mjs"; import { ACTIVE_BLOG_SITE } from "./src/blog-sites.js"; -const remarkPlugins = [remarkMath, remarkCallouts]; +const remarkPlugins = [ + remarkGfm, + remarkInlineFootnotes, + remarkCodeFenceLanguages, + remarkMath, + remarkCallouts, +]; const rehypePlugins = [rehypeKatex]; // https://astro.build/config diff --git a/sites/reflections/astro.config.mjs b/sites/reflections/astro.config.mjs index 1f85fb3..9151a86 100644 --- a/sites/reflections/astro.config.mjs +++ b/sites/reflections/astro.config.mjs @@ -2,6 +2,7 @@ import { defineConfig } from "astro/config"; import mdx from "@astrojs/mdx"; import rehypeKatex from "rehype-katex"; +import remarkGfm from "remark-gfm"; import remarkMath from "remark-math"; import sitemap from "@astrojs/sitemap"; @@ -9,9 +10,17 @@ import react from "@astrojs/react"; // @ts-ignore - TailwindCSS v4 Vite plugin has type compatibility issues with Astro import tailwindcss from "@tailwindcss/vite"; import remarkCallouts from "../../src/remark-callouts.mjs"; +import remarkCodeFenceLanguages from "../../src/remark-code-fence-languages.mjs"; +import remarkInlineFootnotes from "../../src/remark-inline-footnotes.mjs"; import { ACTIVE_BLOG_SITE } from "./src/blog-sites.js"; -const remarkPlugins = [remarkMath, remarkCallouts]; +const remarkPlugins = [ + remarkGfm, + remarkInlineFootnotes, + remarkCodeFenceLanguages, + remarkMath, + remarkCallouts, +]; const rehypePlugins = [rehypeKatex]; // https://astro.build/config diff --git a/sites/research/astro.config.mjs b/sites/research/astro.config.mjs index 1f85fb3..9151a86 100644 --- a/sites/research/astro.config.mjs +++ b/sites/research/astro.config.mjs @@ -2,6 +2,7 @@ import { defineConfig } from "astro/config"; import mdx from "@astrojs/mdx"; import rehypeKatex from "rehype-katex"; +import remarkGfm from "remark-gfm"; import remarkMath from "remark-math"; import sitemap from "@astrojs/sitemap"; @@ -9,9 +10,17 @@ import react from "@astrojs/react"; // @ts-ignore - TailwindCSS v4 Vite plugin has type compatibility issues with Astro import tailwindcss from "@tailwindcss/vite"; import remarkCallouts from "../../src/remark-callouts.mjs"; +import remarkCodeFenceLanguages from "../../src/remark-code-fence-languages.mjs"; +import remarkInlineFootnotes from "../../src/remark-inline-footnotes.mjs"; import { ACTIVE_BLOG_SITE } from "./src/blog-sites.js"; -const remarkPlugins = [remarkMath, remarkCallouts]; +const remarkPlugins = [ + remarkGfm, + remarkInlineFootnotes, + remarkCodeFenceLanguages, + remarkMath, + remarkCallouts, +]; const rehypePlugins = [rehypeKatex]; // https://astro.build/config diff --git a/sites/reviews/astro.config.mjs b/sites/reviews/astro.config.mjs index 1f85fb3..9151a86 100644 --- a/sites/reviews/astro.config.mjs +++ b/sites/reviews/astro.config.mjs @@ -2,6 +2,7 @@ import { defineConfig } from "astro/config"; import mdx from "@astrojs/mdx"; import rehypeKatex from "rehype-katex"; +import remarkGfm from "remark-gfm"; import remarkMath from "remark-math"; import sitemap from "@astrojs/sitemap"; @@ -9,9 +10,17 @@ import react from "@astrojs/react"; // @ts-ignore - TailwindCSS v4 Vite plugin has type compatibility issues with Astro import tailwindcss from "@tailwindcss/vite"; import remarkCallouts from "../../src/remark-callouts.mjs"; +import remarkCodeFenceLanguages from "../../src/remark-code-fence-languages.mjs"; +import remarkInlineFootnotes from "../../src/remark-inline-footnotes.mjs"; import { ACTIVE_BLOG_SITE } from "./src/blog-sites.js"; -const remarkPlugins = [remarkMath, remarkCallouts]; +const remarkPlugins = [ + remarkGfm, + remarkInlineFootnotes, + remarkCodeFenceLanguages, + remarkMath, + remarkCallouts, +]; const rehypePlugins = [rehypeKatex]; // https://astro.build/config diff --git a/sites/vibes/astro.config.mjs b/sites/vibes/astro.config.mjs index 1f85fb3..9151a86 100644 --- a/sites/vibes/astro.config.mjs +++ b/sites/vibes/astro.config.mjs @@ -2,6 +2,7 @@ import { defineConfig } from "astro/config"; import mdx from "@astrojs/mdx"; import rehypeKatex from "rehype-katex"; +import remarkGfm from "remark-gfm"; import remarkMath from "remark-math"; import sitemap from "@astrojs/sitemap"; @@ -9,9 +10,17 @@ import react from "@astrojs/react"; // @ts-ignore - TailwindCSS v4 Vite plugin has type compatibility issues with Astro import tailwindcss from "@tailwindcss/vite"; import remarkCallouts from "../../src/remark-callouts.mjs"; +import remarkCodeFenceLanguages from "../../src/remark-code-fence-languages.mjs"; +import remarkInlineFootnotes from "../../src/remark-inline-footnotes.mjs"; import { ACTIVE_BLOG_SITE } from "./src/blog-sites.js"; -const remarkPlugins = [remarkMath, remarkCallouts]; +const remarkPlugins = [ + remarkGfm, + remarkInlineFootnotes, + remarkCodeFenceLanguages, + remarkMath, + remarkCallouts, +]; const rehypePlugins = [rehypeKatex]; // https://astro.build/config diff --git a/src/remark-callouts.mjs b/src/remark-callouts.mjs index c519661..56f6a1f 100644 --- a/src/remark-callouts.mjs +++ b/src/remark-callouts.mjs @@ -25,6 +25,13 @@ function paragraphCalloutLabel(paragraph) { if (paragraph?.type !== "paragraph") return null; const first = paragraph.children?.[0]; + if (first?.type === "text") { + const match = first.value.match(/^\s*\[!(aside|caution|note|tip|warning)\]\s*/i); + if (match) { + return CALLOUT_LABELS.get(normalizeLabel(match[1])); + } + } + if (first?.type !== "strong") return null; const label = CALLOUT_LABELS.get(normalizeLabel(textFromInline(first))); @@ -37,6 +44,12 @@ function removeLeadingLabel(paragraph, label) { if (paragraph?.type !== "paragraph") return; const first = paragraph.children?.[0]; + if (first?.type === "text") { + first.value = first.value.replace(/^\s*\[!(aside|caution|note|tip|warning)\]\s*/i, ""); + if (!first.value) paragraph.children.shift(); + return; + } + const firstText = normalizeLabel(textFromInline(first)); if (!first || first.type !== "strong" || firstText !== label.toLowerCase()) { return; diff --git a/src/remark-code-fence-languages.mjs b/src/remark-code-fence-languages.mjs new file mode 100644 index 0000000..240c7ec --- /dev/null +++ b/src/remark-code-fence-languages.mjs @@ -0,0 +1,29 @@ +const LANGUAGE_ALIASES = new Map([["ojs", "js"]]); + +const normalizeLanguage = (language) => { + if (!language) return language; + + const match = language.match(/^\{(.+)\}$/); + if (!match) return language; + + const normalized = match[1].trim().toLowerCase(); + return LANGUAGE_ALIASES.get(normalized) ?? normalized; +}; + +const visit = (node) => { + if (node.type === "code") { + node.lang = normalizeLanguage(node.lang); + } + + if (!Array.isArray(node.children)) return; + + for (const child of node.children) { + visit(child); + } +}; + +const remarkCodeFenceLanguages = () => (tree) => { + visit(tree); +}; + +export default remarkCodeFenceLanguages; diff --git a/src/remark-inline-footnotes.mjs b/src/remark-inline-footnotes.mjs new file mode 100644 index 0000000..ccdf9ee --- /dev/null +++ b/src/remark-inline-footnotes.mjs @@ -0,0 +1,289 @@ +import { fromMarkdown } from "mdast-util-from-markdown"; + +const INLINE_FOOTNOTE_PREFIX = "inline-footnote"; + +const findInlineFootnote = (value, fromIndex = 0) => { + const start = value.indexOf("^[", fromIndex); + + if (start === -1) { + return null; + } + + let depth = 1; + + for (let index = start + 2; index < value.length; index += 1) { + const character = value[index]; + + if (character === "\\" && index + 1 < value.length) { + index += 1; + continue; + } + + if (character === "[") { + depth += 1; + continue; + } + + if (character === "]") { + depth -= 1; + + if (depth === 0) { + return { + start, + end: index + 1, + value: value.slice(start + 2, index).trim(), + }; + } + } + } + + return null; +}; + +const createFootnoteChildren = (value) => { + const tree = fromMarkdown(value); + + if (tree.children.length > 0) { + return tree.children; + } + + return [ + { + type: "paragraph", + children: [ + { + type: "text", + value, + }, + ], + }, + ]; +}; + +const createFootnoteParagraph = (children) => ({ + type: "paragraph", + children: + children.length > 0 + ? children + : [ + { + type: "text", + value: "", + }, + ], +}); + +const createFootnoteDefinition = (identifier, label, value) => ({ + type: "footnoteDefinition", + identifier, + label, + children: createFootnoteChildren(value), +}); + +const createFootnoteDefinitionFromChildren = (identifier, label, children) => ({ + type: "footnoteDefinition", + identifier, + label, + children: [createFootnoteParagraph(children)], +}); + +const createFootnoteReference = (context) => { + context.count += 1; + + const label = String(context.count); + const identifier = `${INLINE_FOOTNOTE_PREFIX}-${label}`; + + return { + identifier, + label, + node: { + type: "footnoteReference", + identifier, + label, + }, + }; +}; + +const findClosingBracket = (value) => { + for (let index = 0; index < value.length; index += 1) { + const character = value[index]; + + if (character === "\\" && index + 1 < value.length) { + index += 1; + continue; + } + + if (character === "]") { + return index; + } + } + + return -1; +}; + +const cloneNode = (node) => JSON.parse(JSON.stringify(node)); + +const splitTextNode = (node, context) => { + const replacements = []; + let cursor = 0; + let match = findInlineFootnote(node.value, cursor); + + while (match) { + if (match.start > cursor) { + replacements.push({ + type: "text", + value: node.value.slice(cursor, match.start), + }); + } + + if (match.value) { + const reference = createFootnoteReference(context); + replacements.push(reference.node); + + context.definitions.push( + createFootnoteDefinition(reference.identifier, reference.label, match.value), + ); + } else { + replacements.push({ + type: "text", + value: node.value.slice(match.start, match.end), + }); + } + + cursor = match.end; + match = findInlineFootnote(node.value, cursor); + } + + if (cursor < node.value.length) { + replacements.push({ + type: "text", + value: node.value.slice(cursor), + }); + } + + return replacements; +}; + +const splitAcrossInlineNodes = (children, startIndex, context) => { + const startNode = children[startIndex]; + const start = startNode.value.indexOf("^["); + + if (start === -1) { + return null; + } + + if (findInlineFootnote(startNode.value, start)) { + return null; + } + + const footnoteChildren = []; + const replacements = []; + const before = startNode.value.slice(0, start); + const initialText = startNode.value.slice(start + 2); + + if (before) { + replacements.push({ + type: "text", + value: before, + }); + } + + if (initialText) { + footnoteChildren.push({ + type: "text", + value: initialText, + }); + } + + for (let index = startIndex + 1; index < children.length; index += 1) { + const child = children[index]; + + if (child.type === "text") { + const closingIndex = findClosingBracket(child.value); + + if (closingIndex !== -1) { + const footnoteText = child.value.slice(0, closingIndex); + const after = child.value.slice(closingIndex + 1); + + if (footnoteText) { + footnoteChildren.push({ + type: "text", + value: footnoteText, + }); + } + + const reference = createFootnoteReference(context); + replacements.push(reference.node); + + if (after) { + replacements.push({ + type: "text", + value: after, + }); + } + + context.definitions.push( + createFootnoteDefinitionFromChildren( + reference.identifier, + reference.label, + footnoteChildren, + ), + ); + + return { + consumed: index - startIndex + 1, + replacements, + }; + } + + footnoteChildren.push(cloneNode(child)); + continue; + } + + footnoteChildren.push(cloneNode(child)); + } + + return null; +}; + +const visitChildren = (node, context) => { + if (!Array.isArray(node.children)) { + return; + } + + for (let index = 0; index < node.children.length; index += 1) { + const child = node.children[index]; + + if (child.type === "text" && child.value.includes("^[")) { + const acrossNodes = splitAcrossInlineNodes(node.children, index, context); + + if (acrossNodes) { + node.children.splice(index, acrossNodes.consumed, ...acrossNodes.replacements); + index += acrossNodes.replacements.length - 1; + continue; + } + + const replacements = splitTextNode(child, context); + node.children.splice(index, 1, ...replacements); + index += replacements.length - 1; + continue; + } + + visitChildren(child, context); + } +}; + +const remarkInlineFootnotes = () => (tree) => { + const context = { + count: 0, + definitions: [], + }; + + visitChildren(tree, context); + + if (context.definitions.length > 0) { + tree.children.push(...context.definitions); + } +}; + +export default remarkInlineFootnotes; diff --git a/src/styles/article.css b/src/styles/article.css index 48d6441..18a8824 100644 --- a/src/styles/article.css +++ b/src/styles/article.css @@ -1,12 +1,13 @@ .blog-post-shell { + width: min(100% - 2rem, 94rem); margin-inline: auto; - max-width: 78rem; padding: 3rem 1.75rem 4.5rem; } .blog-article { + width: 100%; margin-inline: auto; - max-width: 68rem; + max-width: 82rem; } .blog-post-header { @@ -96,6 +97,12 @@ margin-bottom: 0; } +.article-content :where(hr) { + margin-block: 2rem; + border: 0; + border-top: 1px solid hsl(var(--border)); +} + .article-content :where(ul, ol) { padding-left: 1.35rem; } @@ -159,19 +166,29 @@ } .article-content :where(.callout) { - margin-block: 1.5rem; + margin-block: 1.6rem; border: 1px solid hsl(var(--border)); - border-left: 4px solid hsl(var(--primary)); - border-radius: 0.7rem; - background: color-mix(in srgb, hsl(var(--accent)) 54%, transparent); - padding: 1rem 1.15rem; + border-left: 3px solid hsl(var(--primary)); + border-radius: 0.55rem; + background: color-mix(in srgb, hsl(var(--accent)) 34%, transparent); + padding: 1.05rem 1.25rem; color: hsl(var(--foreground)); + font-size: 0.98em; + line-height: 1.68; } .article-content :where(.callout > *:first-child) { margin-top: 0; } +.article-content :where(.callout > *:last-child) { + margin-bottom: 0; +} + +.article-content :where(.callout > * + *) { + margin-top: 0.8rem; +} + .article-content :where(.callout h1) { font-size: 1.35rem; } @@ -184,12 +201,28 @@ font-size: 1.1rem; } +.article-content :where(.callout h1, .callout h2, .callout h3, .callout h4) { + margin-bottom: 0.35rem; +} + +.article-content :where(.callout hr) { + margin-block: 1rem; + border-top-color: color-mix(in srgb, hsl(var(--border)) 70%, transparent); +} + +.article-content :where(.callout img) { + max-width: min(100%, 34rem); +} + .article-content :where(aside:not(.callout)) { margin-block: 1.5rem; border: 1px solid hsl(var(--border)); - border-radius: 0.7rem; - background: hsl(var(--muted)); + border-left: 3px solid hsl(var(--primary)); + border-radius: 0.55rem; + background: color-mix(in srgb, hsl(var(--accent)) 28%, transparent); padding: 1rem 1.15rem; + font-size: 0.98em; + line-height: 1.68; } .article-content :where(img) { @@ -247,6 +280,55 @@ padding: 0.25rem 0; } +.article-content :where(sup) { + line-height: 0; +} + +.article-content :where(a[data-footnote-ref]) { + border-radius: 999px; + padding-inline: 0.16rem; + font-family: var(--font-mono); + font-size: 0.72em; + font-weight: 600; + text-decoration: none; +} + +.article-content :where([data-footnotes]) { + margin-top: 3rem; + border-top: 1px solid hsl(var(--border)); + padding-top: 1.15rem; + color: hsl(var(--muted-foreground)); + font-size: 0.92rem; + line-height: 1.62; +} + +.article-content :where([data-footnotes] h2) { + margin: 0 0 0.8rem; + color: hsl(var(--muted-foreground)); + font-family: var(--font-mono); + font-size: 0.72rem; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.article-content :where([data-footnotes] ol) { + padding-left: 1.25rem; +} + +.article-content :where([data-footnotes] li + li) { + margin-top: 0.55rem; +} + +.article-content :where([data-footnotes] p) { + margin: 0; +} + +.article-content :where(a[data-footnote-backref]) { + margin-left: 0.25rem; + text-decoration: none; +} + @media (min-width: 768px) { .blog-post-shell { padding-top: 4rem;