Skip to content

feat: world-class MDX blog system — 10 posts, SVG illustrations, OG images, RSS, sitemap#54

Open
jorg-4 wants to merge 2 commits into
mainfrom
feature/blog-mdx-system
Open

feat: world-class MDX blog system — 10 posts, SVG illustrations, OG images, RSS, sitemap#54
jorg-4 wants to merge 2 commits into
mainfrom
feature/blog-mdx-system

Conversation

@jorg-4
Copy link
Copy Markdown
Collaborator

@jorg-4 jorg-4 commented May 15, 2026

Summary

  • MDX content system: File-based blog with Zod-validated frontmatter, import.meta.glob discovery, reading-time calculation — adding a new post = drop one .mdx file, zero code changes
  • 10 full blog posts: Intro, DevOps guide, AI/ML setup, security deep-dive, comparison vs Warp/Copilot, local vs cloud LLM, roadmap, multi-server fleet, how we built it, 5 Linux tasks — each 600–1200 words with real cx command examples
  • 6 custom SVG hero illustrations: Inline React components per post (circuit board, shield, terminal comparison, local/cloud diagram, roadmap timeline, architecture layers)
  • 4 Unsplash hero images: Permanent CDN URLs for photo-appropriate posts
  • Premium UI: 3-column article layout (sticky TOC + prose + sticky share bar), reading progress bar, animated CxDemo terminal, Callout components (note/warning/tip), CodeBlock with copy button + filename label, author card, related posts
  • Blog index: Tag filter chips + client-side search, featured hero card, 2-col grid, pagination
  • Tag archive pages: /blog/tag/:tag
  • OG images: Satori → PNG per post (1200×630, brand template) → public/og/*.png
  • RSS feed: 10 posts → public/blog/rss.xml
  • Blog sitemap: All posts + tag pages → public/sitemap.xml
  • Comprehensive SEO: Article + BreadcrumbList JSON-LD, full OpenGraph, Twitter cards, canonical URLs
  • Analytics: blog_article_viewed, blog_scroll_depth (25/50/75/100%), blog_copy_code_click, blog_outbound_clicked

Test plan

  • Browse /blog — hero section, tag chips, featured card, post grid all render
  • Click a post with SVG image — illustration renders inline, reading progress bar appears
  • Click a post with Unsplash image — photo loads, TOC highlights on scroll
  • Copy a code block — "✓ Copied!" feedback appears
  • CxDemo component — typing animation plays, blinking cursor
  • Share bar — Twitter link and copy-link work
  • Browse /blog/tag/DevOps — filtered list renders
  • Check public/og/*.png — 10 images, 1200×630
  • Check public/blog/rss.xml — valid XML with 10 items
  • TypeScript: npm run check — only pre-existing error in about.tsx

🤖 Generated with Claude Code

Summary by CodeRabbit

Release Notes

  • New Features
    • Launched comprehensive blog platform with published articles featuring metadata (publication date, author, tags, reading time estimates)
    • Added tag-based filtering and search functionality for easy article discovery
    • Included social sharing tools, reading progress bar, table of contents, and related article recommendations
    • Generated RSS feed subscription and sitemap for improved content discoverability

Review Change Stack

jorg-4 and others added 2 commits May 15, 2026 14:25
…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>
@cloudflare-workers-and-pages
Copy link
Copy Markdown

Deploying cx-web with  Cloudflare Pages  Cloudflare Pages

Latest commit: 7dd31ed
Status:🚫  Build failed.

View logs

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 15, 2026

📝 Walkthrough

Walkthrough

This 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.

Changes

Blog System: Complete MDX-based Content Platform

Layer / File(s) Summary
Blog post schema, parsing, and data queries
client/src/blog/schema.ts, client/src/blog/parse.ts
Zod schema validates post frontmatter (title, description, dates, tags, draft/featured flags); Vite glob loads MDX files from content/blog/, derives slugs, computes reading time and word count, caches posts sorted by publishedAt, and provides query functions: getAllPosts(), getPostBySlug(), getPostsByTag(), getAllTags(), getRelatedPosts() (scoring by tag overlap), and getMdxComponent().
Blog post SVG diagram components
client/src/blog/images/cx-*.tsx
Five reusable diagram components: CxLinuxRoadmapImage (timeline with milestone cards and progress), CxVsWarpImage (three-panel tool comparison), HowWeBuiltCxImage (layered architecture), LocalVsCloudLlmImage (LLM modes), SandboxedExecutionImage (protection rings), and WhatIsCxLinuxImage (AI layer illustration).
MDX integration, component registry, and TypeScript types
client/src/mdx.d.ts, client/src/blog/mdxComponents.tsx, vite.config.ts
Declares MDX module types; exports getMDXComponents(slug) to map custom Callout/CxDemo/CodeBlock overrides and external links (with analytics event firing); Vite config adds MDX rollup plugin with remark/rehype plugins (autolink-headings, slug, GFM).
Reusable blog UI components
client/src/components/blog/*
ArticleCard (post preview grid item), ArticleCardHero (featured hero with image), AuthorCard (bio + social), Callout (note/warning/tip boxes), CodeBlock (code + copy button), CxDemo (animated terminal), PostImage (SVG/img wrapper), ReadingProgress (scroll bar), ShareBar (X/copy/email), TableOfContents (auto-generated from headings with scroll tracking).
Blog pages: listing, single post, and tag archive
client/src/pages/blog/index.tsx, client/src/pages/blog/post.tsx, client/src/pages/blog/tag/index.tsx
Blog index fetches and filters posts by tag/search with pagination, shows featured hero on first page; single post page loads post and MDX, renders with TOC/share/author card/reading progress/related posts/prev-next nav; tag archive page lists posts for a tag with back link.
Build-time content generation
scripts/generate-og.ts, scripts/generate-rss.ts, scripts/generate-blog-sitemap.ts
OG image generator uses satori/@resvg/resvg-js to create PNG sharing images; RSS generator uses feed library to output Atom/RSS XML with HTML content and OG image enclosures; sitemap generator builds XML with posts and tag pages, merging into public/sitemap.xml.
Build configuration, analytics, and dependencies
package.json, vite.config.ts, tailwind.config.ts, client/src/lib/analytics.ts
Package.json adds build scripts (build:og, build:rss, build:sitemap) and 20+ dependencies (MDX, rehype/remark, satori, feed, gray-matter, reading-time); analytics module exports blogAnalytics with event helpers (articleViewed, scrollDepth, copyCodeClick, outboundClicked); Tailwind config reformatted as multi-line.
Blog content and route wiring
content/blog/*.mdx, content/authors.json, client/src/App.tsx
Adds 10 blog posts (AI/ML setup, roadmap, DevOps, architecture, LLM comparison, DevOps tasks, fleet management, safety, product comparison); author config with cx-team metadata; generated RSS feed and sitemap with all posts/tags; routes /blog/tag/:tag to BlogTagArchive.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Poem

📚 A blog system blooms,
MDX and images dance,
Schema guards the truth,
Components build the view,
Posts take their place. 🐰✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title accurately and concisely summarizes the main additions: an MDX blog system with 10 posts, SVG illustrations, OG images, RSS, and sitemap generation.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feature/blog-mdx-system

Warning

Review ran into problems

🔥 Problems

Git: Failed to clone repository. Please run the @coderabbitai full review command to re-trigger a full review. If the issue persists, set path_filters to include or exclude specific files.


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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread vite.config.ts
Comment on lines +14 to +21
...mdx({
remarkPlugins: [remarkGfm],
rehypePlugins: [
rehypeSlug,
[rehypeAutolinkHeadings, { behavior: "wrap" }],
],
providerImportSource: "@mdx-js/react",
}),
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

The MDX configuration is missing the necessary plugins to parse and export YAML frontmatter. Without remark-frontmatter and remark-mdx-frontmatter, the frontmatter export expected in client/src/blog/parse.ts will be undefined, which will cause the blog system to fail during frontmatter validation.

Comment thread client/src/blog/parse.ts
Comment on lines +25 to +46
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;
})
);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

This implementation triggers a full JavaScript module download for every blog post on the index page just to extract metadata. This will cause significant performance degradation as the blog grows. Consider using Vite's import query to only load the frontmatter at build time or via eager globbing.

Comment thread client/src/blog/parse.ts

if (import.meta.env.PROD && fm.draft) return null;

const bodyText = (mod.frontmatter as any).__rawBody ?? fm.description;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The __rawBody property is not provided by the standard MDX loader configuration. This causes the word count and reading time calculations to fall back to the short description, resulting in inaccurate metadata (e.g., "1 min read" and very low word counts) being displayed for all articles.

const featured = useMemo(() => posts.find((p) => p.frontmatter.featured), [posts]);

const filtered = useMemo(() => {
let result = posts.filter((p) => !p.frontmatter.featured || activeTag || query);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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.

Suggested change
let result = posts.filter((p) => !p.frontmatter.featured || activeTag || query);
let result = posts.filter((p) => p.slug !== featured?.slug || activeTag || query);

Comment thread scripts/generate-rss.ts
Comment on lines +46 to +50
const plainBody = body
.replace(/<[A-Z][^>]*>[\s\S]*?<\/[A-Z][^>]*>/g, "")
.replace(/<[A-Z][^/]*/g, "")
.replace(/^import .+$/gm, "")
.trim();
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The plainBody variable is defined using a complex regex but is never used in the feed generation. Additionally, the RSS feed currently only includes the article description; consider including the actual content to provide a better experience for RSS subscribers.

Comment thread vite.config.ts
Comment on lines 49 to 51
fs: {
strict: true,
deny: ["**/.*"],
strict: false,
},
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-medium medium

Removing the deny: ["**/.*"] restriction while setting strict: false for the file system server is a security risk. This configuration could allow the dev server to serve sensitive hidden files (such as .env or .git directories) if an attacker knows or guesses the path.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 16

🧹 Nitpick comments (2)
client/src/pages/blog/index.tsx (1)

31-31: ⚡ Quick win

Consider extracting hardcoded base URL to a constant.

The base URL "https://cxlinux.com/blog" is hardcoded here, but post.tsx defines const 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 win

Derive progress fill endpoint from the milestone data.

The progress line is hardcoded to x2="480", which can silently desync from done milestones 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

📥 Commits

Reviewing files that changed from the base of the PR and between ca74003 and 7dd31ed.

⛔ Files ignored due to path filters (11)
  • package-lock.json is excluded by !**/package-lock.json
  • public/og/ai-ml-setup-5-minutes.png is excluded by !**/*.png
  • public/og/cx-linux-2026-roadmap.png is excluded by !**/*.png
  • public/og/cx-vs-warp-copilot-cli.png is excluded by !**/*.png
  • public/og/devops-guide-cx-linux.png is excluded by !**/*.png
  • public/og/how-we-built-cx.png is excluded by !**/*.png
  • public/og/linux-tasks-30-seconds.png is excluded by !**/*.png
  • public/og/local-vs-cloud-llm.png is excluded by !**/*.png
  • public/og/multi-server-fleet-management.png is excluded by !**/*.png
  • public/og/sandboxed-execution-safety.png is excluded by !**/*.png
  • public/og/what-is-cx-linux.png is excluded by !**/*.png
📒 Files selected for processing (44)
  • client/src/App.tsx
  • client/src/blog/images/cx-linux-roadmap.tsx
  • client/src/blog/images/cx-vs-warp.tsx
  • client/src/blog/images/how-we-built-cx.tsx
  • client/src/blog/images/local-vs-cloud-llm.tsx
  • client/src/blog/images/sandboxed-execution.tsx
  • client/src/blog/images/what-is-cx-linux.tsx
  • client/src/blog/mdxComponents.tsx
  • client/src/blog/parse.ts
  • client/src/blog/schema.ts
  • client/src/components/blog/ArticleCard.tsx
  • client/src/components/blog/ArticleCardHero.tsx
  • client/src/components/blog/AuthorCard.tsx
  • client/src/components/blog/Callout.tsx
  • client/src/components/blog/CodeBlock.tsx
  • client/src/components/blog/CxDemo.tsx
  • client/src/components/blog/PostImage.tsx
  • client/src/components/blog/ReadingProgress.tsx
  • client/src/components/blog/ShareBar.tsx
  • client/src/components/blog/TableOfContents.tsx
  • client/src/lib/analytics.ts
  • client/src/mdx.d.ts
  • client/src/pages/blog/index.tsx
  • client/src/pages/blog/post.tsx
  • client/src/pages/blog/tag/index.tsx
  • content/authors.json
  • content/blog/ai-ml-setup-5-minutes.mdx
  • content/blog/cx-linux-2026-roadmap.mdx
  • content/blog/cx-vs-warp-copilot-cli.mdx
  • content/blog/devops-guide-cx-linux.mdx
  • content/blog/how-we-built-cx.mdx
  • content/blog/linux-tasks-30-seconds.mdx
  • content/blog/local-vs-cloud-llm.mdx
  • content/blog/multi-server-fleet-management.mdx
  • content/blog/sandboxed-execution-safety.mdx
  • content/blog/what-is-cx-linux.mdx
  • package.json
  • public/blog/rss.xml
  • public/sitemap.xml
  • scripts/generate-blog-sitemap.ts
  • scripts/generate-og.ts
  • scripts/generate-rss.ts
  • tailwind.config.ts
  • vite.config.ts

Comment on lines +26 to +38
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}` }),
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

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.

Suggested change
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.

Comment thread client/src/blog/parse.ts
Comment on lines +39 to +43
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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

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.

Suggested change
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.

Comment thread client/src/blog/parse.ts
Comment on lines +69 to +79
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);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

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.

Suggested change
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.

Comment thread client/src/blog/schema.ts
@@ -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)");
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

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.

Comment on lines +11 to +15
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>
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

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.

Suggested change
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
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

🧩 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.tsx

Repository: cxlinux-ai/cx-web

Length of output: 415


🏁 Script executed:

head -30 content/blog/devops-guide-cx-linux.mdx | cat -n

Repository: cxlinux-ai/cx-web

Length of output: 1931


🏁 Script executed:

rg -n 'CxDemo' content/blog/ -A2

Repository: 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:

  1. Backslash escape: command='cx "... Let\'s Encrypt ...'
  2. Double quotes: command="cx \"... Let's Encrypt ...\""
  3. Remove apostrophe: command='cx "... Lets Encrypt ...' (matches the pattern used in linux-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.

Comment thread public/blog/rss.xml
Comment on lines +45 to +46
<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>
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

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.

Comment thread scripts/generate-og.ts
Comment on lines +20 to +24
} catch {
// Fallback: use a system font path
console.warn("Inter font not found via @fontsource, using fallback");
fontBuffer = Buffer.alloc(0);
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

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.

Suggested change
} 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.

Comment thread scripts/generate-og.ts
console.log("OG images generated.");
}

main().catch(console.error);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify other scripts using the same non-failing catch pattern
rg -nP 'main\(\)\.catch\(console\.error\)' scripts

Repository: 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
done

Repository: 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 -40

Repository: 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.ts

Repository: 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.

Suggested change
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).

Comment thread vite.config.ts
Comment on lines 49 to +50
fs: {
strict: true,
deny: ["**/.*"],
strict: false,
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

🧩 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.ts

Repository: 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 -60

Repository: 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-list

Repository: 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 -20

Repository: 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=20

Repository: 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=30

Repository: 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 -20

Repository: 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=20

Repository: 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).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant