feat: world-class MDX blog system — 10 posts, SVG illustrations, OG images, RSS, sitemap#54
feat: world-class MDX blog system — 10 posts, SVG illustrations, OG images, RSS, sitemap#54jorg-4 wants to merge 2 commits into
Conversation
…um UI - Install @mdx-js/rollup, shiki, reading-time, gray-matter, satori, feed, resvg - Add Zod-validated frontmatter schema (schema.ts) and parse utilities (parse.ts) - 10 full MDX blog posts covering intro, DevOps, AI/ML, security, roadmap, comparison - 6 custom SVG hero illustrations (what-is-cx-linux, sandboxed-execution, cx-vs-warp, local-vs-cloud-llm, roadmap, how-we-built-cx) - 9 premium blog UI components: ArticleCard, ArticleCardHero, ReadingProgress, TableOfContents, ShareBar, AuthorCard, CodeBlock, Callout, CxDemo - Full blog index rewrite: hero, tag chips, search, featured hero card, paginated grid - Full blog post rewrite: 3-col layout with sticky TOC + share bar, MDX rendering, reading progress bar, author card, prev/next nav, related posts, scroll-to-top - Tag archive page at /blog/tag/:tag - MDX components provider with analytics hooks for outbound clicks - Analytics extensions: blog_article_viewed, scroll_depth, copy_code_click, outbound_clicked - Update vite.config.ts with MDX plugin + fs.strict:false for content/ access - Update tailwind.config.ts to scan MDX files - Add /blog/tag/:tag route in App.tsx Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- scripts/generate-og.ts: Satori → PNG per post (1200×630, brand template) - scripts/generate-rss.ts: RSS 2.0 feed via `feed` library - scripts/generate-blog-sitemap.ts: sitemap entries with priorities - Generate all 10 OG images into public/og/ - Generate public/blog/rss.xml and public/sitemap.xml - Add @fontsource/inter and glob dependencies - Update package.json build scripts: build:og, build:rss, build:sitemap, build:blog Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
📝 WalkthroughWalkthroughThis PR implements a complete blog system using MDX for a web application. It adds schema validation for blog post metadata, dynamic post loading and filtering by tag/slug, a library of reusable blog UI components, a custom MDX rendering pipeline with syntax highlighting and code blocks, full-featured blog pages (index with filtering/pagination, single posts with TOC and related posts, tag archives), build-time generation of OG images/RSS feeds/sitemaps, and 10+ example blog posts with embedded demos and diagrams. ChangesBlog System: Complete MDX-based Content Platform
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Warning Review ran into problems🔥 ProblemsGit: Failed to clone repository. Please run the Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Code Review
This pull request introduces a comprehensive blog system built with MDX, including custom interactive components, automated RSS and sitemap generation, and dynamic Open Graph image creation. The review feedback identifies several critical issues: the MDX configuration lacks necessary plugins for frontmatter parsing, and the blog parser's metadata extraction method may cause performance degradation. Furthermore, a security vulnerability was noted in the Vite server configuration regarding hidden file access, and logic errors were found in the featured post filtering and RSS content generation.
| ...mdx({ | ||
| remarkPlugins: [remarkGfm], | ||
| rehypePlugins: [ | ||
| rehypeSlug, | ||
| [rehypeAutolinkHeadings, { behavior: "wrap" }], | ||
| ], | ||
| providerImportSource: "@mdx-js/react", | ||
| }), |
There was a problem hiding this comment.
| Object.entries(modules).map(async ([path, load]) => { | ||
| const mod = await load(); | ||
| const slug = slugFromPath(path); | ||
|
|
||
| const result = PostFrontmatterSchema.safeParse(mod.frontmatter); | ||
| if (!result.success) { | ||
| console.error(`[blog] Invalid frontmatter in ${path}:`, result.error.flatten()); | ||
| return null; | ||
| } | ||
|
|
||
| const fm = result.data; | ||
|
|
||
| if (import.meta.env.PROD && fm.draft) return null; | ||
|
|
||
| const bodyText = (mod.frontmatter as any).__rawBody ?? fm.description; | ||
| const rt = readingTime(bodyText); | ||
| const readingTimeMinutes = fm.readingTimeMinutes ?? Math.max(1, Math.ceil(rt.minutes)); | ||
| const wordCount = countWords(bodyText); | ||
|
|
||
| return { slug, frontmatter: fm, readingTimeMinutes, wordCount } satisfies BlogPost; | ||
| }) | ||
| ); |
There was a problem hiding this comment.
|
|
||
| if (import.meta.env.PROD && fm.draft) return null; | ||
|
|
||
| const bodyText = (mod.frontmatter as any).__rawBody ?? fm.description; |
There was a problem hiding this comment.
| const featured = useMemo(() => posts.find((p) => p.frontmatter.featured), [posts]); | ||
|
|
||
| const filtered = useMemo(() => { | ||
| let result = posts.filter((p) => !p.frontmatter.featured || activeTag || query); |
There was a problem hiding this comment.
The current filtering logic excludes all featured posts from the main grid when no search or tag filter is active. Since the hero section only displays the first featured post found, any additional posts marked as featured: true will be completely hidden from the blog index.
| let result = posts.filter((p) => !p.frontmatter.featured || activeTag || query); | |
| let result = posts.filter((p) => p.slug !== featured?.slug || activeTag || query); |
| const plainBody = body | ||
| .replace(/<[A-Z][^>]*>[\s\S]*?<\/[A-Z][^>]*>/g, "") | ||
| .replace(/<[A-Z][^/]*/g, "") | ||
| .replace(/^import .+$/gm, "") | ||
| .trim(); |
There was a problem hiding this comment.
| fs: { | ||
| strict: true, | ||
| deny: ["**/.*"], | ||
| strict: false, | ||
| }, |
There was a problem hiding this comment.
There was a problem hiding this comment.
Actionable comments posted: 16
🧹 Nitpick comments (2)
client/src/pages/blog/index.tsx (1)
31-31: ⚡ Quick winConsider extracting hardcoded base URL to a constant.
The base URL
"https://cxlinux.com/blog"is hardcoded here, butpost.tsxdefinesconst BASE_URL = "https://cxlinux.com"at the module level (line 19). Extracting this to a shared constant (e.g.,@/lib/constants) would ensure consistency and simplify future domain changes.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@client/src/pages/blog/index.tsx` at line 31, The string "https://cxlinux.com/blog" is hardcoded in the JSON-LD; extract a shared BASE_URL constant (post.tsx currently has const BASE_URL = "https://cxlinux.com") into a common module (e.g., export const BASE_URL from "@/lib/constants") and update blog/index.tsx to import BASE_URL and construct the blog URL as `${BASE_URL}/blog` (and update post.tsx to import the shared BASE_URL) so both files use the same canonical domain constant.client/src/blog/images/cx-linux-roadmap.tsx (1)
2-20: ⚡ Quick winDerive progress fill endpoint from the milestone data.
The progress line is hardcoded to
x2="480", which can silently desync fromdonemilestones during content updates.Suggested diff
export default function CxLinuxRoadmapImage({ className = "" }: { className?: string }) { const milestones = [ @@ { label: "v1.0", desc: "GA Release", done: false, x: 1020 }, ]; + const progressX = milestones.filter((m) => m.done).at(-1)?.x ?? 80; @@ - <line x1="80" y1="315" x2="480" y2="315" stroke="#00FF9F" strokeWidth="3"/> + <line x1="80" y1="315" x2={progressX} y2="315" stroke="#00FF9F" strokeWidth="3"/>🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@client/src/blog/images/cx-linux-roadmap.tsx` around lines 2 - 20, The progress fill x2 is hardcoded; compute it from the milestones array instead: find the maximum x among milestones where done === true (or fall back to the timeline start x if none are done) and use that value for the progress <line> x2 attribute; update the JSX that renders the progress fill (the <line> element) to reference this computed progressX so the visual progress always reflects the milestones data defined in the milestones constant.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@client/src/blog/mdxComponents.tsx`:
- Around line 26-38: The outbound link handler in the MDX anchor component emits
the full href to analytics and overwrites any existing onClick; change the
trackEvent label to a sanitized URL (e.g., only origin + pathname or a
hashed/safe identifier derived from href) instead of the full href, and compose
the tracking handler with any existing props.onClick so we preserve original
behavior; update the code around the a component, the isExternal check, and the
trackEvent call so it derives a safeLabel from href (strip query/hash) and calls
both props.onClick (if present) and the tracking function.
In `@client/src/blog/parse.ts`:
- Around line 39-43: The code uses (mod.frontmatter as any).__rawBody directly
into bodyText and then passes it to readingTime and countWords without ensuring
it's a string; guard by checking typeof (mod.frontmatter as any).__rawBody ===
"string" and fall back to fm.description or an empty string before computing
readingTime and word count so readingTimeMinutes (fm.readingTimeMinutes),
readingTime(bodyText), and countWords(bodyText) always receive a string input.
- Around line 69-79: getAllTags currently counts tags using their original
casing which splits mixed-case duplicates; normalize tags (e.g., to lowerCase()
and trim()) when iterating post.frontmatter.tags in getAllTags and use the
normalized value as the key for counts and as the returned tag field so
aggregation matches the case-insensitive query behavior; update references
inside getAllTags (posts, counts, and the map(([tag,count]) => ({ tag, count }))
step) to use the normalized tag string.
In `@client/src/blog/schema.ts`:
- Line 3: The current isoDate zod schema only checks format and allows invalid
calendar dates; update the isoDate validator (and any other uses at the same
places) to refine the string into a real calendar date: parse year/month/day
from the string, construct a Date (or use Date.UTC), and ensure the resulting
date components match the input (e.g., month/day rollover didn't occur) and that
the year/month/day are in valid ranges; implement this as a zod .refine() on the
existing isoDate schema so invalid dates (like 2023-02-30) are rejected while
keeping the YYYY-MM-DD format check.
In `@client/src/components/blog/PostImage.tsx`:
- Around line 11-15: Render the SVG with accessible alt semantics by exposing
the provided alt text to assistive tech: when SvgComponent is present, add
role="img" and aria-label={alt} to the wrapper (the div with className "w-full
rounded-xl ...") and/or pass the alt text into the SvgComponent via a title or
aria-label prop if the component supports it; also include a visually-hidden
caption (e.g., a sr-only element or figcaption) containing {alt} as a fallback
so screen readers get the image description. Ensure you reference the existing
SvgComponent and the alt prop when making the change.
In `@client/src/components/blog/ReadingProgress.tsx`:
- Around line 6-16: The useEffect in ReadingProgress registers the scroll
handler but never initializes state, so call update() once inside the effect
after defining the update function to setProgress immediately on mount (so it
reflects current window.scrollY and
document.documentElement.scrollHeight/clientHeight); keep the existing
window.addEventListener("scroll", update, { passive: true }) and the cleanup
that removes the listener with window.removeEventListener("scroll", update).
In `@client/src/components/blog/ShareBar.tsx`:
- Around line 13-21: The catch block in handleCopy currently does nothing;
implement a real fallback and user feedback: in the catch of
navigator.clipboard.writeText inside handleCopy, create/select a temporary
readonly input (or use an existing input) containing url, call
document.execCommand('copy') to attempt the legacy copy, then remove the temp
element, and setCopied(true) on success or show an error state (e.g.,
setCopied(false) plus trigger a user-visible error/toast) on failure; ensure you
reference handleCopy, setCopied, and navigator.clipboard.writeText so reviewers
can locate and verify the fallback and feedback behavior.
In `@client/src/lib/analytics.ts`:
- Around line 108-109: The outboundClicked analytics call currently sends the
full URL in the label which may leak query strings/PII; update the
outboundClicked function (the arrow function that calls trackEvent) to parse the
provided url and send only the origin+pathname or host instead (e.g., use
URL(url) to extract origin and pathname or only host), and pass that sanitized
value into trackEvent's label instead of the full url string.
In `@client/src/pages/blog/index.tsx`:
- Line 22: The page parsing can produce NaN (e.g., params.get("page") ===
"abc"), so update the page computation to validate parseInt's result and default
to 1: parse the value with parseInt(params.get("page") ?? "1", 10), check
Number.isNaN(parsed) (or isFinite) and if it is NaN set page = 1, otherwise set
page = Math.max(1, parsed); replace the existing const page = Math.max(1,
parseInt(...)) expression (referencing params.get("page") and the const page) so
the pagination slice later (the posts slice logic) never receives NaN.
In `@client/src/pages/blog/post.tsx`:
- Line 122: The scroll-depth calculation can divide by zero: update the pct
computation (the const pct line that uses window.scrollY and el.scrollHeight -
el.clientHeight) to guard the denominator by using a safe value (e.g. const
denom = Math.max(1, el.scrollHeight - el.clientHeight) or an explicit check) so
you never divide by zero and pct cannot become Infinity/NaN; then compute pct =
Math.round((window.scrollY / denom) * 100) and proceed as before.
In `@client/src/pages/blog/tag/index.tsx`:
- Around line 18-24: The effect currently returns early when tag is falsy and
leaves loading true; update the useEffect handling so that when tag is
empty/falsy you explicitly call setPosts([]) (or appropriate empty state) and
setLoading(false) before returning, otherwise proceed to call
getPostsByTag(tag). In short, inside the useEffect for tag, change the
early-return branch to reset posts and setLoading(false) and keep the existing
async fetch branch (getPostsByTag -> setPosts -> setLoading(false)).
In `@content/blog/devops-guide-cx-linux.mdx`:
- Line 20: The JSX attribute on the CxDemo component (attribute command)
contains shell-style quote escaping (`'"'"'`) which breaks JSX parsing; replace
the broken quoted string in the CxDemo command attribute with a valid JSX string
by either using backslash-escaped single quote inside a single-quoted attribute
(e.g., escape the apostrophe as \'), switching the attribute to double quotes
and escaping inner double quotes, or removing the apostrophe entirely to match
the pattern used elsewhere; update the CxDemo command value accordingly so the
attribute is a properly formed JS/JSX string.
In `@public/blog/rss.xml`:
- Around line 45-46: The RSS enclosure entries currently emit invalid MIME types
like type="image//photo-..." for Unsplash images; update the RSS generator
(where enclosures are created—e.g., the function that maps ogImage to
<enclosure> such as generateRss or renderRssEnclosure) to detect Unsplash URLs
and emit a valid MIME type (use "image/jpeg") for those enclosures; ensure the
logic only overrides the type when ogImage host matches unsplash.com (or
images.unsplash.com) and leave other image hosts to continue returning their
original MIME type.
In `@scripts/generate-og.ts`:
- Around line 20-24: The catch block in scripts/generate-og.ts currently sets
fontBuffer = Buffer.alloc(0) which is a no-op; instead, in the catch for loading
the Inter font, try to load a real fallback into fontBuffer (e.g., attempt
fs.readFileSync on common system font paths or read a bundled fallback asset)
and update the console.warn to mention which fallback was attempted; if no
fallback can be loaded, throw an error so OG generation fails fast. Locate the
catch that assigns to fontBuffer and replace the empty-buffer behavior with the
described real-fallback load (or an explicit throw) so fontBuffer always
contains a usable font or the process stops.
- Line 159: The catch handler for your top-level promise (shown as
main().catch(console.error)) currently only logs the error and leaves process
exit code as 0; update the catch to set process.exitCode = 1 when an error
occurs so CI fails on errors—i.e., replace or augment the call that uses
main().catch(console.error) to ensure errors both get logged and set
process.exitCode = 1 (apply the same change in scripts/generate-og.ts,
scripts/generate-rss.ts, and scripts/generate-blog-sitemap.ts).
In `@vite.config.ts`:
- Around line 49-50: The vite config currently disables Vite's FS guard
(fs.strict: false); re-enable strict mode and instead whitelist the content
directory so import.meta.glob() in client/src/blog/parse.ts and the authors.json
import in client/src/pages/blog/post.tsx can still read content/*; update the
Vite config's fs section to set strict: true and add an allow entry for the
project-level "content" directory (use the same fs config object referenced in
your vite.config.ts / defineConfig block).
---
Nitpick comments:
In `@client/src/blog/images/cx-linux-roadmap.tsx`:
- Around line 2-20: The progress fill x2 is hardcoded; compute it from the
milestones array instead: find the maximum x among milestones where done ===
true (or fall back to the timeline start x if none are done) and use that value
for the progress <line> x2 attribute; update the JSX that renders the progress
fill (the <line> element) to reference this computed progressX so the visual
progress always reflects the milestones data defined in the milestones constant.
In `@client/src/pages/blog/index.tsx`:
- Line 31: The string "https://cxlinux.com/blog" is hardcoded in the JSON-LD;
extract a shared BASE_URL constant (post.tsx currently has const BASE_URL =
"https://cxlinux.com") into a common module (e.g., export const BASE_URL from
"@/lib/constants") and update blog/index.tsx to import BASE_URL and construct
the blog URL as `${BASE_URL}/blog` (and update post.tsx to import the shared
BASE_URL) so both files use the same canonical domain constant.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: ad1e62d7-e564-4e3d-a502-01a5f185d32e
⛔ Files ignored due to path filters (11)
package-lock.jsonis excluded by!**/package-lock.jsonpublic/og/ai-ml-setup-5-minutes.pngis excluded by!**/*.pngpublic/og/cx-linux-2026-roadmap.pngis excluded by!**/*.pngpublic/og/cx-vs-warp-copilot-cli.pngis excluded by!**/*.pngpublic/og/devops-guide-cx-linux.pngis excluded by!**/*.pngpublic/og/how-we-built-cx.pngis excluded by!**/*.pngpublic/og/linux-tasks-30-seconds.pngis excluded by!**/*.pngpublic/og/local-vs-cloud-llm.pngis excluded by!**/*.pngpublic/og/multi-server-fleet-management.pngis excluded by!**/*.pngpublic/og/sandboxed-execution-safety.pngis excluded by!**/*.pngpublic/og/what-is-cx-linux.pngis excluded by!**/*.png
📒 Files selected for processing (44)
client/src/App.tsxclient/src/blog/images/cx-linux-roadmap.tsxclient/src/blog/images/cx-vs-warp.tsxclient/src/blog/images/how-we-built-cx.tsxclient/src/blog/images/local-vs-cloud-llm.tsxclient/src/blog/images/sandboxed-execution.tsxclient/src/blog/images/what-is-cx-linux.tsxclient/src/blog/mdxComponents.tsxclient/src/blog/parse.tsclient/src/blog/schema.tsclient/src/components/blog/ArticleCard.tsxclient/src/components/blog/ArticleCardHero.tsxclient/src/components/blog/AuthorCard.tsxclient/src/components/blog/Callout.tsxclient/src/components/blog/CodeBlock.tsxclient/src/components/blog/CxDemo.tsxclient/src/components/blog/PostImage.tsxclient/src/components/blog/ReadingProgress.tsxclient/src/components/blog/ShareBar.tsxclient/src/components/blog/TableOfContents.tsxclient/src/lib/analytics.tsclient/src/mdx.d.tsclient/src/pages/blog/index.tsxclient/src/pages/blog/post.tsxclient/src/pages/blog/tag/index.tsxcontent/authors.jsoncontent/blog/ai-ml-setup-5-minutes.mdxcontent/blog/cx-linux-2026-roadmap.mdxcontent/blog/cx-vs-warp-copilot-cli.mdxcontent/blog/devops-guide-cx-linux.mdxcontent/blog/how-we-built-cx.mdxcontent/blog/linux-tasks-30-seconds.mdxcontent/blog/local-vs-cloud-llm.mdxcontent/blog/multi-server-fleet-management.mdxcontent/blog/sandboxed-execution-safety.mdxcontent/blog/what-is-cx-linux.mdxpackage.jsonpublic/blog/rss.xmlpublic/sitemap.xmlscripts/generate-blog-sitemap.tsscripts/generate-og.tsscripts/generate-rss.tstailwind.config.tsvite.config.ts
| a: ({ href, children, ...props }: any) => { | ||
| const isExternal = href?.startsWith("http"); | ||
| return ( | ||
| <a | ||
| href={href} | ||
| {...props} | ||
| {...(isExternal | ||
| ? { | ||
| target: "_blank", | ||
| rel: "noopener noreferrer", | ||
| onClick: () => | ||
| trackEvent({ category: "engagement", action: "blog_outbound_clicked", label: `${slug}::${href}` }), | ||
| } |
There was a problem hiding this comment.
Avoid emitting full outbound URLs in analytics labels.
This currently sends raw href values to analytics, which may include query/hash data and user identifiers. Also preserve any existing link onClick behavior when tracking.
Suggested diff
// Anchor tags fire outbound click event for external links
a: ({ href, children, ...props }: any) => {
- const isExternal = href?.startsWith("http");
+ const isExternal = typeof href === "string" && /^https?:\/\//i.test(href);
+ const handleExternalClick = (event: any) => {
+ props.onClick?.(event);
+ if (event?.defaultPrevented || !href) return;
+ try {
+ const url = new URL(href, window.location.origin);
+ trackEvent({
+ category: "engagement",
+ action: "blog_outbound_clicked",
+ label: `${slug}::${url.origin}${url.pathname}`,
+ });
+ } catch {
+ trackEvent({
+ category: "engagement",
+ action: "blog_outbound_clicked",
+ label: `${slug}::invalid-url`,
+ });
+ }
+ };
return (
<a
href={href}
{...props}
{...(isExternal
? {
target: "_blank",
rel: "noopener noreferrer",
- onClick: () =>
- trackEvent({ category: "engagement", action: "blog_outbound_clicked", label: `${slug}::${href}` }),
+ onClick: handleExternalClick,
}
: {})}
>📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| a: ({ href, children, ...props }: any) => { | |
| const isExternal = href?.startsWith("http"); | |
| return ( | |
| <a | |
| href={href} | |
| {...props} | |
| {...(isExternal | |
| ? { | |
| target: "_blank", | |
| rel: "noopener noreferrer", | |
| onClick: () => | |
| trackEvent({ category: "engagement", action: "blog_outbound_clicked", label: `${slug}::${href}` }), | |
| } | |
| a: ({ href, children, ...props }: any) => { | |
| const isExternal = typeof href === "string" && /^https?:\/\//i.test(href); | |
| const handleExternalClick = (event: any) => { | |
| props.onClick?.(event); | |
| if (event?.defaultPrevented || !href) return; | |
| try { | |
| const url = new URL(href, window.location.origin); | |
| trackEvent({ | |
| category: "engagement", | |
| action: "blog_outbound_clicked", | |
| label: `${slug}::${url.origin}${url.pathname}`, | |
| }); | |
| } catch { | |
| trackEvent({ | |
| category: "engagement", | |
| action: "blog_outbound_clicked", | |
| label: `${slug}::invalid-url`, | |
| }); | |
| } | |
| }; | |
| return ( | |
| <a | |
| href={href} | |
| {...props} | |
| {...(isExternal | |
| ? { | |
| target: "_blank", | |
| rel: "noopener noreferrer", | |
| onClick: handleExternalClick, | |
| } | |
| : {})} | |
| > | |
| {children} | |
| </a> | |
| ); | |
| }, |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@client/src/blog/mdxComponents.tsx` around lines 26 - 38, The outbound link
handler in the MDX anchor component emits the full href to analytics and
overwrites any existing onClick; change the trackEvent label to a sanitized URL
(e.g., only origin + pathname or a hashed/safe identifier derived from href)
instead of the full href, and compose the tracking handler with any existing
props.onClick so we preserve original behavior; update the code around the a
component, the isExternal check, and the trackEvent call so it derives a
safeLabel from href (strip query/hash) and calls both props.onClick (if present)
and the tracking function.
| const bodyText = (mod.frontmatter as any).__rawBody ?? fm.description; | ||
| const rt = readingTime(bodyText); | ||
| const readingTimeMinutes = fm.readingTimeMinutes ?? Math.max(1, Math.ceil(rt.minutes)); | ||
| const wordCount = countWords(bodyText); | ||
|
|
There was a problem hiding this comment.
Guard bodyText type before text processing.
__rawBody is read from any; if it is non-string, parsing can throw when computing reading time/word count.
Suggested fix
- const bodyText = (mod.frontmatter as any).__rawBody ?? fm.description;
+ const rawBody = (mod.frontmatter as Record<string, unknown>).__rawBody;
+ const bodyText = typeof rawBody === "string" ? rawBody : fm.description;📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| const bodyText = (mod.frontmatter as any).__rawBody ?? fm.description; | |
| const rt = readingTime(bodyText); | |
| const readingTimeMinutes = fm.readingTimeMinutes ?? Math.max(1, Math.ceil(rt.minutes)); | |
| const wordCount = countWords(bodyText); | |
| const rawBody = (mod.frontmatter as Record<string, unknown>).__rawBody; | |
| const bodyText = typeof rawBody === "string" ? rawBody : fm.description; | |
| const rt = readingTime(bodyText); | |
| const readingTimeMinutes = fm.readingTimeMinutes ?? Math.max(1, Math.ceil(rt.minutes)); | |
| const wordCount = countWords(bodyText); |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@client/src/blog/parse.ts` around lines 39 - 43, The code uses
(mod.frontmatter as any).__rawBody directly into bodyText and then passes it to
readingTime and countWords without ensuring it's a string; guard by checking
typeof (mod.frontmatter as any).__rawBody === "string" and fall back to
fm.description or an empty string before computing readingTime and word count so
readingTimeMinutes (fm.readingTimeMinutes), readingTime(bodyText), and
countWords(bodyText) always receive a string input.
| export async function getAllTags(): Promise<{ tag: string; count: number }[]> { | ||
| const posts = await getAllPosts(); | ||
| const counts = new Map<string, number>(); | ||
| for (const post of posts) { | ||
| for (const tag of post.frontmatter.tags) { | ||
| counts.set(tag, (counts.get(tag) ?? 0) + 1); | ||
| } | ||
| } | ||
| return Array.from(counts.entries()) | ||
| .map(([tag, count]) => ({ tag, count })) | ||
| .sort((a, b) => b.count - a.count); |
There was a problem hiding this comment.
Normalize tag casing in aggregation to match query behavior.
Tag filtering is case-insensitive, but tag counting is case-sensitive, so mixed-case tags can split into duplicate entries.
Suggested fix
export async function getAllTags(): Promise<{ tag: string; count: number }[]> {
const posts = await getAllPosts();
const counts = new Map<string, number>();
for (const post of posts) {
for (const tag of post.frontmatter.tags) {
- counts.set(tag, (counts.get(tag) ?? 0) + 1);
+ const normalized = tag.toLowerCase();
+ counts.set(normalized, (counts.get(normalized) ?? 0) + 1);
}
}
return Array.from(counts.entries())
.map(([tag, count]) => ({ tag, count }))
.sort((a, b) => b.count - a.count);
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| export async function getAllTags(): Promise<{ tag: string; count: number }[]> { | |
| const posts = await getAllPosts(); | |
| const counts = new Map<string, number>(); | |
| for (const post of posts) { | |
| for (const tag of post.frontmatter.tags) { | |
| counts.set(tag, (counts.get(tag) ?? 0) + 1); | |
| } | |
| } | |
| return Array.from(counts.entries()) | |
| .map(([tag, count]) => ({ tag, count })) | |
| .sort((a, b) => b.count - a.count); | |
| export async function getAllTags(): Promise<{ tag: string; count: number }[]> { | |
| const posts = await getAllPosts(); | |
| const counts = new Map<string, number>(); | |
| for (const post of posts) { | |
| for (const tag of post.frontmatter.tags) { | |
| const normalized = tag.toLowerCase(); | |
| counts.set(normalized, (counts.get(normalized) ?? 0) + 1); | |
| } | |
| } | |
| return Array.from(counts.entries()) | |
| .map(([tag, count]) => ({ tag, count })) | |
| .sort((a, b) => b.count - a.count); | |
| } |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@client/src/blog/parse.ts` around lines 69 - 79, getAllTags currently counts
tags using their original casing which splits mixed-case duplicates; normalize
tags (e.g., to lowerCase() and trim()) when iterating post.frontmatter.tags in
getAllTags and use the normalized value as the key for counts and as the
returned tag field so aggregation matches the case-insensitive query behavior;
update references inside getAllTags (posts, counts, and the map(([tag,count]) =>
({ tag, count })) step) to use the normalized tag string.
| @@ -0,0 +1,29 @@ | |||
| import { z } from "zod"; | |||
|
|
|||
| const isoDate = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Must be ISO date (YYYY-MM-DD)"); | |||
There was a problem hiding this comment.
Strengthen date validation beyond format-only checks.
YYYY-MM-DD regex accepts invalid calendar dates, which can produce invalid timestamps later when posts are sorted by date.
Suggested fix
-const isoDate = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Must be ISO date (YYYY-MM-DD)");
+const isoDate = z
+ .string()
+ .regex(/^\d{4}-\d{2}-\d{2}$/, "Must be ISO date (YYYY-MM-DD)")
+ .refine((value) => {
+ const d = new Date(`${value}T00:00:00.000Z`);
+ return !Number.isNaN(d.getTime()) && d.toISOString().slice(0, 10) === value;
+ }, "Must be a valid calendar date");Also applies to: 11-12
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@client/src/blog/schema.ts` at line 3, The current isoDate zod schema only
checks format and allows invalid calendar dates; update the isoDate validator
(and any other uses at the same places) to refine the string into a real
calendar date: parse year/month/day from the string, construct a Date (or use
Date.UTC), and ensure the resulting date components match the input (e.g.,
month/day rollover didn't occur) and that the year/month/day are in valid
ranges; implement this as a zod .refine() on the existing isoDate schema so
invalid dates (like 2023-02-30) are rejected while keeping the YYYY-MM-DD format
check.
| if (SvgComponent) { | ||
| return ( | ||
| <div className="w-full rounded-xl overflow-hidden border border-white/8 mb-10 aspect-[1200/630]"> | ||
| <SvgComponent className="w-full h-full" /> | ||
| </div> |
There was a problem hiding this comment.
Preserve alt semantics for SVG-rendered post images.
Line 13 renders SVG diagrams without exposing the provided alt text, so screen readers lose image meaning.
Suggested accessibility fix
if (SvgComponent) {
return (
- <div className="w-full rounded-xl overflow-hidden border border-white/8 mb-10 aspect-[1200/630]">
+ <div
+ role="img"
+ aria-label={alt}
+ className="w-full rounded-xl overflow-hidden border border-white/8 mb-10 aspect-[1200/630]"
+ >
<SvgComponent className="w-full h-full" />
</div>
);
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| if (SvgComponent) { | |
| return ( | |
| <div className="w-full rounded-xl overflow-hidden border border-white/8 mb-10 aspect-[1200/630]"> | |
| <SvgComponent className="w-full h-full" /> | |
| </div> | |
| if (SvgComponent) { | |
| return ( | |
| <div | |
| role="img" | |
| aria-label={alt} | |
| className="w-full rounded-xl overflow-hidden border border-white/8 mb-10 aspect-[1200/630]" | |
| > | |
| <SvgComponent className="w-full h-full" /> | |
| </div> | |
| ); | |
| } |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@client/src/components/blog/PostImage.tsx` around lines 11 - 15, Render the
SVG with accessible alt semantics by exposing the provided alt text to assistive
tech: when SvgComponent is present, add role="img" and aria-label={alt} to the
wrapper (the div with className "w-full rounded-xl ...") and/or pass the alt
text into the SvgComponent via a title or aria-label prop if the component
supports it; also include a visually-hidden caption (e.g., a sr-only element or
figcaption) containing {alt} as a fallback so screen readers get the image
description. Ensure you reference the existing SvgComponent and the alt prop
when making the change.
|
|
||
| Provisioning a new server used to mean a playbook, an Ansible role, or a long runbook. With CX, you describe the desired end state: | ||
|
|
||
| <CxDemo command='cx "provision this server as a production Node.js host: nginx reverse proxy, PM2 process manager, Let'"'"'s Encrypt SSL, UFW firewall, fail2ban, daily backups to S3"' output="→ Detecting OS: Ubuntu 24.04 LTS |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Check CxDemo component implementation to see how it handles the command prop
rg -nP -A10 'export\s+(default\s+)?function\s+CxDemo' client/src/components/blog/CxDemo.tsxRepository: cxlinux-ai/cx-web
Length of output: 415
🏁 Script executed:
head -30 content/blog/devops-guide-cx-linux.mdx | cat -nRepository: cxlinux-ai/cx-web
Length of output: 1931
🏁 Script executed:
rg -n 'CxDemo' content/blog/ -A2Repository: cxlinux-ai/cx-web
Length of output: 4002
Fix quote escaping in JSX attribute.
Line 20 uses shell-style quote escaping ('"'"') which is invalid in JSX. The pattern breaks attribute parsing because command='...' ends at the first unescaped single quote. Replace with either:
- Backslash escape:
command='cx "... Let\'s Encrypt ...' - Double quotes:
command="cx \"... Let's Encrypt ...\"" - Remove apostrophe:
command='cx "... Lets Encrypt ...'(matches the pattern used inlinux-tasks-30-seconds.mdx)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@content/blog/devops-guide-cx-linux.mdx` at line 20, The JSX attribute on the
CxDemo component (attribute command) contains shell-style quote escaping
(`'"'"'`) which breaks JSX parsing; replace the broken quoted string in the
CxDemo command attribute with a valid JSX string by either using
backslash-escaped single quote inside a single-quoted attribute (e.g., escape
the apostrophe as \'), switching the attribute to double quotes and escaping
inner double quotes, or removing the apostrophe entirely to match the pattern
used elsewhere; update the CxDemo command value accordingly so the attribute is
a properly formed JS/JSX string.
| <enclosure url="https://images.unsplash.com/photo-1558494949-ef010cbdcc31?auto=format&fit=crop&w=1200&h=630&q=80" length="0" type="image//photo-1558494949-ef010cbdcc31"/> | ||
| </item> |
There was a problem hiding this comment.
Fix invalid RSS enclosure MIME types for Unsplash images.
On Line 45, Line 65, Line 75, and Line 95, type="image//photo-..." is not a valid MIME type. Feed consumers may ignore these enclosures. Please emit a real MIME type (e.g., image/jpeg) from the RSS generator when ogImage points to Unsplash URLs.
🔧 Suggested generated output fix
- <enclosure url="https://images.unsplash.com/photo-1558494949-ef010cbdcc31?auto=format&fit=crop&w=1200&h=630&q=80" length="0" type="image//photo-1558494949-ef010cbdcc31"/>
+ <enclosure url="https://images.unsplash.com/photo-1558494949-ef010cbdcc31?auto=format&fit=crop&w=1200&h=630&q=80" length="0" type="image/jpeg"/>Apply the same correction to the other Unsplash enclosure entries.
Also applies to: 65-66, 75-76, 95-96
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@public/blog/rss.xml` around lines 45 - 46, The RSS enclosure entries
currently emit invalid MIME types like type="image//photo-..." for Unsplash
images; update the RSS generator (where enclosures are created—e.g., the
function that maps ogImage to <enclosure> such as generateRss or
renderRssEnclosure) to detect Unsplash URLs and emit a valid MIME type (use
"image/jpeg") for those enclosures; ensure the logic only overrides the type
when ogImage host matches unsplash.com (or images.unsplash.com) and leave other
image hosts to continue returning their original MIME type.
| } catch { | ||
| // Fallback: use a system font path | ||
| console.warn("Inter font not found via @fontsource, using fallback"); | ||
| fontBuffer = Buffer.alloc(0); | ||
| } |
There was a problem hiding this comment.
Fallback path is a no-op instead of a real font fallback.
Line 21 says there is a system-font fallback, but Line 23 sets an empty buffer. If the Inter file is missing, OG generation is left in a brittle state instead of failing fast or loading an actual fallback font.
Suggested fix
-} catch {
- // Fallback: use a system font path
- console.warn("Inter font not found via `@fontsource`, using fallback");
- fontBuffer = Buffer.alloc(0);
+} catch (error) {
+ throw new Error(
+ `Inter font not found at ${interFontPath}. Install dependencies before running build:og.`,
+ );
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| } catch { | |
| // Fallback: use a system font path | |
| console.warn("Inter font not found via @fontsource, using fallback"); | |
| fontBuffer = Buffer.alloc(0); | |
| } | |
| } catch (error) { | |
| throw new Error( | |
| `Inter font not found at ${interFontPath}. Install dependencies before running build:og.`, | |
| ); | |
| } |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@scripts/generate-og.ts` around lines 20 - 24, The catch block in
scripts/generate-og.ts currently sets fontBuffer = Buffer.alloc(0) which is a
no-op; instead, in the catch for loading the Inter font, try to load a real
fallback into fontBuffer (e.g., attempt fs.readFileSync on common system font
paths or read a bundled fallback asset) and update the console.warn to mention
which fallback was attempted; if no fallback can be loaded, throw an error so OG
generation fails fast. Locate the catch that assigns to fontBuffer and replace
the empty-buffer behavior with the described real-fallback load (or an explicit
throw) so fontBuffer always contains a usable font or the process stops.
| console.log("OG images generated."); | ||
| } | ||
|
|
||
| main().catch(console.error); |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Verify other scripts using the same non-failing catch pattern
rg -nP 'main\(\)\.catch\(console\.error\)' scriptsRepository: cxlinux-ai/cx-web
Length of output: 238
🏁 Script executed:
# Check context around each occurrence to see if they're all similar
for file in scripts/generate-rss.ts scripts/generate-og.ts scripts/generate-blog-sitemap.ts; do
echo "=== $file ==="
if grep -n "main()" "$file" | head -5; then
echo "---"
tail -5 "$file"
fi
doneRepository: cxlinux-ai/cx-web
Length of output: 759
🏁 Script executed:
# Check if there are any other error handling patterns in the scripts directory
rg -n "catch\(" scripts/ -A 2 | head -40Repository: cxlinux-ai/cx-web
Length of output: 244
🏁 Script executed:
# Verify the exact line and context in generate-og.ts
sed -n '155,163p' scripts/generate-og.tsRepository: cxlinux-ai/cx-web
Length of output: 136
Unhandled errors in build scripts are logged but do not fail the process.
The catch block logs errors via console.error() but does not set a non-zero exit code. This allows failed script execution to pass CI/build systems undetected. This pattern appears in three scripts:
- scripts/generate-og.ts:159
- scripts/generate-rss.ts:69
- scripts/generate-blog-sitemap.ts:83
All three should set process.exitCode = 1 when an error occurs.
Suggested fix
-main().catch(console.error);
+main().catch((error) => {
+ console.error(error);
+ process.exitCode = 1;
+});Apply this fix to all three scripts.
📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| main().catch(console.error); | |
| main().catch((error) => { | |
| console.error(error); | |
| process.exitCode = 1; | |
| }); |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@scripts/generate-og.ts` at line 159, The catch handler for your top-level
promise (shown as main().catch(console.error)) currently only logs the error and
leaves process exit code as 0; update the catch to set process.exitCode = 1 when
an error occurs so CI fails on errors—i.e., replace or augment the call that
uses main().catch(console.error) to ensure errors both get logged and set
process.exitCode = 1 (apply the same change in scripts/generate-og.ts,
scripts/generate-rss.ts, and scripts/generate-blog-sitemap.ts).
| fs: { | ||
| strict: true, | ||
| deny: ["**/.*"], | ||
| strict: false, |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Verify where out-of-root content is actually referenced before setting fs.allow
rg -n --type=ts --type=tsx 'import\.meta\.glob\(|content/blog|content/' client vite.config.ts
rg -n 'server:\s*\{|fs:\s*\{|strict:\s*false|allow:\s*\[' vite.config.tsRepository: cxlinux-ai/cx-web
Length of output: 159
🏁 Script executed:
# Read the complete vite.config.ts to understand context and confirm fs configuration
cat -n vite.config.ts | head -60Repository: cxlinux-ai/cx-web
Length of output: 1849
🏁 Script executed:
# Search for glob patterns more broadly without type flags
rg -n 'import\.meta\.glob|content/blog|\.glob\(' --no-type-listRepository: cxlinux-ai/cx-web
Length of output: 505
🏁 Script executed:
# Check the directory structure to understand root configuration
find . -maxdepth 3 -type d -name 'content' -o -type d -name 'client' | head -20Repository: cxlinux-ai/cx-web
Length of output: 80
🏁 Script executed:
# Search for references to content paths in the entire codebase
rg -n 'content/' --max-count=20Repository: cxlinux-ai/cx-web
Length of output: 453
🏁 Script executed:
# Check if there are other files accessed outside the root directory
rg -n '\.\./\.\./\.\.|from.*content/|import.*content/' client --max-count=30Repository: cxlinux-ai/cx-web
Length of output: 258
🏁 Script executed:
# Verify what paths would need to be allowed
git ls-files | grep -E '^(content|shared)/' | head -20Repository: cxlinux-ai/cx-web
Length of output: 490
🏁 Script executed:
# Check for actual file imports from shared in the source code
rg 'from.*@shared|import.*@shared' client --max-count=20Repository: cxlinux-ai/cx-web
Length of output: 43
Re-enable filesystem guard and allow only the content directory.
Line 50 disables Vite's FS boundary checks globally. The codebase accesses content/blog/*.mdx via import.meta.glob() in client/src/blog/parse.ts and content/authors.json in client/src/pages/blog/post.tsx—both outside the client/ root. Instead of disabling strict mode entirely, keep it enabled and explicitly allow the content directory:
Suggested hardening patch
server: {
fs: {
- strict: false,
+ strict: true,
+ allow: [
+ path.resolve(import.meta.dirname, "content"),
+ ],
},
},🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@vite.config.ts` around lines 49 - 50, The vite config currently disables
Vite's FS guard (fs.strict: false); re-enable strict mode and instead whitelist
the content directory so import.meta.glob() in client/src/blog/parse.ts and the
authors.json import in client/src/pages/blog/post.tsx can still read content/*;
update the Vite config's fs section to set strict: true and add an allow entry
for the project-level "content" directory (use the same fs config object
referenced in your vite.config.ts / defineConfig block).
Summary
import.meta.globdiscovery, reading-time calculation — adding a new post = drop one.mdxfile, zero code changescxcommand examplesCxDemoterminal,Calloutcomponents (note/warning/tip),CodeBlockwith copy button + filename label, author card, related posts/blog/tag/:tagpublic/og/*.pngpublic/blog/rss.xmlpublic/sitemap.xmlArticle+BreadcrumbListJSON-LD, full OpenGraph, Twitter cards, canonical URLsblog_article_viewed,blog_scroll_depth(25/50/75/100%),blog_copy_code_click,blog_outbound_clickedTest plan
/blog— hero section, tag chips, featured card, post grid all render/blog/tag/DevOps— filtered list renderspublic/og/*.png— 10 images, 1200×630public/blog/rss.xml— valid XML with 10 itemsnpm run check— only pre-existing error in about.tsx🤖 Generated with Claude Code
Summary by CodeRabbit
Release Notes