feat(blogs): markdown content pipeline, token fixes, and a redesign - #17
Conversation
The blog was a single 59 KB client component with posts hardcoded in a BLOG_POSTS array and three fabricated authors carrying Unsplash stock photos. Nothing about it could be edited, scheduled, or moved to a database without editing TSX. Content now lives in content/posts/*.md with frontmatter, loaded through lib/posts.ts. The Post type is shaped as a database row on purpose -- slug as primary key, publishedAt as an ISO string so it sorts and indexes without parsing, body kept as raw markdown so the renderer can change without a migration. Pages call getAllPosts and getPost and know nothing about the filesystem, so moving to Postgres means reimplementing two functions rather than touching every page. Reading time is computed from the body, never written by hand, because a hand-written one is wrong the moment the post is edited. Frontmatter parsing and markdown rendering are written rather than pulled in. This app sits in a pnpm workspace, so a new dependency edits a lockfile shared by every app in the monorepo -- blast radius well beyond a blog. The supported subset is closed and under our control. Two token bugs fixed against DESIGN.md, both of which had been quietly flattening the brand: --color-bg-base #ffffff -> #fcfcfd canvas is "never pure" --color-text-secondary #3f3f36 -> #3f3f46 a typo The base layer also hardcoded pure white with !important, overriding the token entirely. It now reads from the token. Added the tokens the design source specifies but the stylesheet never encoded: hairline, grid line, display tracking (-0.03em), eyebrow tracking (0.16em). Plus utilities for the rules that were previously enforced by memory -- .signal-gradient exists as a named class precisely because DESIGN.md permits the blue-to-pink gradient on ONE line per view, and a named utility makes a second occurrence visible in review. Diagrams are React components rather than hand-authored SVG. Absolutely positioned SVG text does not reflow, does not use the site fonts, is not selectable or translatable, and needs re-authoring whenever a token changes. Components inherit Geist and restyle themselves. Where a raster is genuinely needed for an OG card, screenshot the component rather than maintaining a second artwork that will drift. Verified: typecheck clean, production build clean, all 7 posts prerendered as static HTML, series ordering and prev/next resolved from the `part` field.
Raw HTML in a post was already inert. Nothing in the renderer calls dangerouslySetInnerHTML, so every value reaches the DOM as a React child and gets escaped -- a post containing <script> renders those as visible characters in a paragraph. That is a property worth stating explicitly, since most markdown pipelines do the opposite, so it is now documented at the top of the module rather than left as an accident of implementation. Links were the hole that left. [text](javascript:...) is valid markdown and would have become a live javascript: URL; the same for data: and vbscript:, and for image sources. safeUrl allowlists http, https, mailto, tel and relative paths, and collapses anything else to "#" rather than throwing, because one bad link in a post should not blank the page. Verified by building a fixture full of payloads and inspecting the emitted HTML: no live script tag, no iframe, no event-handler attribute, no dangerous URL in any href or src, and the four hostile links all neutralised to "#" while the relative and https links survived intact. The payloads appear as escaped text, which is the correct outcome -- the post still renders, it just cannot execute. One note for whoever changes this next: introducing dangerouslySetInnerHTML to support embeds gives the guarantee up and needs sanitisation to replace it.
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
Reviewer's GuideReplaces the old client-side, hardcoded blog UI with a file-backed markdown content pipeline and new post layouts, while fixing design token mismatches and encoding several typography utilities; also introduces safe markdown rendering and reusable diagram/figure components aligned with DESIGN.md. Sequence diagram for serving a blog post from markdownsequenceDiagram
actor User
participant NextRouter as NextRouter
participant PostPage as PostPage
participant PostsRepo as lib_posts
participant MarkdownRenderer as markdown
User->>NextRouter: request /posts/:slug
NextRouter->>PostPage: render PostPage(params)
PostPage->>PostsRepo: getPost(slug)
PostsRepo-->>PostPage: Post
PostPage->>PostsRepo: getSeriesNeighbours(slug)
PostsRepo-->>PostPage: {prev,next}
PostPage->>MarkdownRenderer: renderMarkdown(Post.body)
MarkdownRenderer->>MarkdownRenderer: safeUrl(...) on links/images
MarkdownRenderer-->>PostPage: ReactNode[]
PostPage-->>NextRouter: HTML for article
NextRouter-->>User: rendered post page
File-Level Changes
Tips and commandsInteracting with Sourcery
Customizing Your ExperienceAccess your dashboard to:
Getting Help
|
|
Warning Review limit reached
Next review available in: 52 minutes You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository. How can I continue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews. How do review limits work?CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability. For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window. Please refer docs for additional details. Review details⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (4)
📝 WalkthroughWalkthroughThe blogs app now uses filesystem-backed Markdown posts. It renders a server-side index and static post routes with metadata, series navigation, safe Markdown handling, reusable figures and diagrams, and updated design tokens. ChangesBlogs publishing experience
Estimated code review effort: 4 (Complex) | ~60 minutes Sequence Diagram(s)sequenceDiagram
participant Visitor
participant Home
participant PostPage
participant PostStore
participant MarkdownRenderer
Visitor->>Home: Open blog index
Home->>PostStore: getAllPosts()
PostStore-->>Home: Return sorted posts
Home-->>Visitor: Render lead post and index
Visitor->>PostPage: Open post slug
PostPage->>PostStore: getPost() and getSeriesNeighbours()
PostPage->>MarkdownRenderer: renderMarkdown()
MarkdownRenderer-->>PostPage: Return React content
PostPage-->>Visitor: Render post page
Possibly related PRs
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches 💡 1📝 Generate docstrings 💡
🧪 Generate unit tests (beta)
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 |
The app shipped both the SVG files and the React diagram components,
with every post still pointing at the images -- so the components were
dead code and the incoherence would have survived review.
Posts now select a component with a fenced block:
```diagram
drift
```
A fence rather than new syntax, so the file stays valid markdown: it
still reads correctly on GitHub and in an editor, degrading to a short
code block rather than to noise. An unknown key renders a visible
message in place instead of nothing, so a typo cannot hide until someone
reads the whole post.
Three diagrams converted -- glyph-width drift, the encoding chain, and
the benchmark bars. Verified in the built HTML that they emit real
markup: the AFM widths 222 and 833 are live text nodes, so the figures
are selectable, translatable and sharp at any zoom, which is the point
of not shipping them as images.
Four diagrams still reference SVG and are listed in the PR rather than
silently left behind.
There was a problem hiding this comment.
Actionable comments posted: 9
🧹 Nitpick comments (3)
apps/blogs/tsconfig.tsbuildinfo (1)
1-1: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winMove
apps/blogs/tsconfig.tsbuildinfoout of source control.
apps/blogs/tsconfig.jsonenablesincremental, but this repo does not ignore*.tsbuildinfo. Move the file’stsBuildInfoFileto.turbo/or another ignored path, or ignore*.tsbuildinfo, then regenerate the build metadata instead of committingapps/blogs/tsconfig.tsbuildinfo. [apps/blogs/tsbuildinfo] is already tracked alongside the unignoredapps/web/tsconfig.tsbuildinfo`, so apply the same ignore policy consistently.🤖 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 `@apps/blogs/tsconfig.tsbuildinfo` at line 1, Remove the generated apps/blogs/tsconfig.tsbuildinfo artifact from source control and apply a consistent ignore policy for incremental TypeScript metadata, either by configuring tsBuildInfoFile under an ignored .turbo path or ignoring *.tsbuildinfo. Update the relevant tsconfig configuration and regenerate metadata without retaining tracked tsbuildinfo files, including the similarly tracked apps/web artifact.apps/blogs/lib/posts.ts (1)
105-122: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick winMemoize the post load to avoid repeated directory reads and parsing.
getPostandgetSeriesNeighbourseach callgetAllPosts, which re-reads and re-parses every Markdown file. The post route calls both, so one page render parses the whole corpus twice. Cache the parsed list in module scope.♻️ Proposed cache
+let cache: Post[] | undefined; + export function getAllPosts(): Post[] { + if (cache) return cache; if (!fs.existsSync(CONTENT_DIR)) return []; const posts = fs .readdirSync(CONTENT_DIR) .filter((f) => f.endsWith(".md")) .map(toPost); const everyPostIsPartOfASeries = posts.length > 0 && posts.every((p) => p.part != null); - return posts.sort((a, b) => + cache = posts.sort((a, b) => everyPostIsPartOfASeries ? (a.part ?? 0) - (b.part ?? 0) : b.publishedAt.localeCompare(a.publishedAt), ); + return cache; }🤖 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 `@apps/blogs/lib/posts.ts` around lines 105 - 122, Memoize the parsed posts in module scope so repeated calls do not re-read or re-parse the Markdown files. Update getAllPosts to populate and reuse the cache, while preserving the existing empty-directory handling, sorting, and behavior of getPost and getSeriesNeighbours.apps/blogs/app/page.tsx (1)
11-17: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win
formatDateis duplicated with a different month format.
apps/blogs/app/posts/[slug]/page.tsxlines 18-24 defines the same helper withmonth: "long". Move one implementation into a shared module, and pass the month style as an argument if both formats are intended.🤖 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 `@apps/blogs/app/page.tsx` around lines 11 - 17, Consolidate the duplicated formatDate helper from the blog page components into a shared module, then update both callers to import and use it. Preserve each page’s intended month formatting by making the month style configurable when necessary, including the existing "short" and "long" behaviors.
🤖 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 `@apps/blogs/app/page.tsx`:
- Around line 127-129: Update the post index rendering in the visible span to
return an empty cell when post.part is absent, rather than applying padStart to
an empty string; preserve the two-digit formatting for posts with a defined
part.
- Around line 90-94: Update the series label rendering in the page component so
its part count filters posts to those whose series matches the current series
before counting. Keep the existing conditional rendering and label format
unchanged, using the posts collection rather than the total posts.length.
In `@apps/blogs/components/Diagrams.tsx`:
- Around line 33-78: The fixed-width comparison rows in DriftDiagram are clipped
on narrow viewports because Figure hides overflow. Wrap the comparison content,
including the “word ends here” label and error marker, in an overflow-x-auto
container so all glyphs remain horizontally reachable without changing the
diagram’s scale.
- Around line 26-36: Update DriftDiagram’s drift measurement display to match
the defined TRUE_WIDTHS and guessTotal values: report the error as 0.221em, or
explicitly calculate and label the equivalent point value using a stated 12pt
font size. Remove the inconsistent “~0.3pt off” wording while preserving the
existing drift calculation.
In `@apps/blogs/content/posts/07-what-is-still-missing.md`:
- Line 102: Update the links in the post’s evaluation note so they no longer
point to unpublished relative routes; remove those links or replace them with
working canonical public URLs for the evaluations and benchmarks resources.
In `@apps/blogs/lib/markdown.tsx`:
- Around line 159-166: Update renderMarkdown’s Markdown image handling to
recognize an explicit diagram directive or source-to-component mapping for the
registered DIAGRAMS entries, rendering the resolved component directly instead
of wrapping it in Figure. Preserve Figure rendering for ordinary image sources,
and update Markdown posts to use the new diagram syntax for DriftDiagram,
EncodingChain, and BenchmarkBars.
- Around line 62-111: Update the token rendering branches in inline so emphasis,
strong, and link label content are passed back through inline using the existing
keyPrefix mechanism, enabling nested links and code spans; keep code span
contents rendered as plain text. Apply the recursive parsing to the label inside
the link branch while preserving safeUrl and external-link handling.
In `@apps/blogs/lib/posts.ts`:
- Around line 96-98: Update toPost in apps/blogs/lib/posts.ts at lines 96-98 to
validate frontmatter runtime types: normalize tags to an array using the
specified array/string/non-empty fallback behavior, and set part only when
data.part is a number. No direct change is needed in
apps/blogs/app/posts/[slug]/page.tsx lines 64-67; its existing tags length guard
and join are correct once Post.tags is guaranteed to be an array.
- Around line 124-130: Update getSeriesNeighbours to filter getAllPosts() to
posts sharing the target post’s series, then order that filtered collection by
part before selecting neighbours. Preserve the empty result for an unknown slug
and ensure prev/next are derived from the series-local ordering rather than the
global getAllPosts sort.
---
Nitpick comments:
In `@apps/blogs/app/page.tsx`:
- Around line 11-17: Consolidate the duplicated formatDate helper from the blog
page components into a shared module, then update both callers to import and use
it. Preserve each page’s intended month formatting by making the month style
configurable when necessary, including the existing "short" and "long"
behaviors.
In `@apps/blogs/lib/posts.ts`:
- Around line 105-122: Memoize the parsed posts in module scope so repeated
calls do not re-read or re-parse the Markdown files. Update getAllPosts to
populate and reuse the cache, while preserving the existing empty-directory
handling, sorting, and behavior of getPost and getSeriesNeighbours.
In `@apps/blogs/tsconfig.tsbuildinfo`:
- Line 1: Remove the generated apps/blogs/tsconfig.tsbuildinfo artifact from
source control and apply a consistent ignore policy for incremental TypeScript
metadata, either by configuring tsBuildInfoFile under an ignored .turbo path or
ignoring *.tsbuildinfo. Update the relevant tsconfig configuration and
regenerate metadata without retaining tracked tsbuildinfo files, including the
similarly tracked apps/web artifact.
🪄 Autofix
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 Plus
Run ID: 96070762-1e19-4dbb-9836-ca8c9971333d
⛔ Files ignored due to path filters (8)
apps/blogs/public/blog-assets/00-hero.svgis excluded by!**/*.svgapps/blogs/public/blog-assets/01-pdf-anatomy.svgis excluded by!**/*.svgapps/blogs/public/blog-assets/02-advance-drift.svgis excluded by!**/*.svgapps/blogs/public/blog-assets/03-encoding-chain.svgis excluded by!**/*.svgapps/blogs/public/blog-assets/04-table-strategies.svgis excluded by!**/*.svgapps/blogs/public/blog-assets/05-clipped-paren.svgis excluded by!**/*.svgapps/blogs/public/blog-assets/06-benchmark-ceiling.svgis excluded by!**/*.svgapps/blogs/public/blog-assets/07-hybrid-architecture.svgis excluded by!**/*.svg
📒 Files selected for processing (16)
apps/blogs/.claude/launch.jsonapps/blogs/app/globals.cssapps/blogs/app/page.tsxapps/blogs/app/posts/[slug]/page.tsxapps/blogs/components/Diagrams.tsxapps/blogs/components/Figure.tsxapps/blogs/content/posts/01-a-pdf-is-not-a-document.mdapps/blogs/content/posts/02-thinking-like-a-printer.mdapps/blogs/content/posts/03-where-tables-actually-live.mdapps/blogs/content/posts/04-building-pdftable.mdapps/blogs/content/posts/05-the-bugs-that-never-crashed.mdapps/blogs/content/posts/06-measuring-instead-of-believing.mdapps/blogs/content/posts/07-what-is-still-missing.mdapps/blogs/lib/markdown.tsxapps/blogs/lib/posts.tsapps/blogs/tsconfig.tsbuildinfo
| {series && ( | ||
| <div className="eyebrow mb-8"> | ||
| {series} · {posts.length} parts | ||
| </div> | ||
| </motion.div> | ||
| ) : ( | ||
| /* ━━━ MAGAZINE INDEX VIEW ━━━ */ | ||
| <motion.div | ||
| key="magazine-index" | ||
| initial={{ opacity: 0 }} | ||
| animate={{ opacity: 1 }} | ||
| exit={{ opacity: 0 }} | ||
| transition={{ duration: 0.3 }} | ||
| > | ||
| {/* ─── HERO SECTION ─── */} | ||
| <section className="relative min-h-[85vh] flex items-end overflow-hidden"> | ||
| {/* Gradient backdrop */} | ||
| <div className="absolute -top-40 left-1/2 -translate-x-1/2 w-[1200px] h-[700px] bg-[radial-gradient(ellipse_at_center,rgba(20,86,240,0.07)_0%,rgba(234,94,193,0.035)_40%,transparent_70%)] blur-[50px] pointer-events-none" /> | ||
| {/* Grid paper */} | ||
| <div className="absolute inset-0 grid-paper [mask-image:radial-gradient(ellipse_at_center,black_25%,transparent_70%)] pointer-events-none opacity-60" /> | ||
|
|
||
| <div className="relative z-10 w-full max-w-[1200px] mx-auto px-6 md:px-12 lg:px-16 pb-16 pt-36 md:pt-44"> | ||
| <div className="max-w-[920px]"> | ||
| {/* Chip */} | ||
| <motion.div | ||
| initial={{ opacity: 0, y: 12 }} | ||
| animate={{ opacity: 1, y: 0 }} | ||
| transition={{ duration: 0.6 }} | ||
| className="inline-flex items-center gap-2.5 px-3.5 py-1.5 rounded-full border border-border-gray bg-white/80 backdrop-blur-sm mb-8 shadow-sm" | ||
| > | ||
| <span className="relative flex h-1.5 w-1.5"> | ||
| <span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-brand-blue opacity-75" /> | ||
| <span className="relative inline-flex rounded-full h-1.5 w-1.5 bg-brand-blue" /> | ||
| </span> | ||
| <span className="font-data text-[10px] font-medium text-text-muted tracking-[0.16em] uppercase"> | ||
| Vectorless Engineering Journal | ||
| </span> | ||
| </motion.div> | ||
|
|
||
| {/* Headline */} | ||
| <motion.h1 | ||
| initial={{ opacity: 0, y: 20 }} | ||
| animate={{ opacity: 1, y: 0 }} | ||
| transition={{ duration: 0.7, delay: 0.1 }} | ||
| className="font-serif text-[48px] sm:text-[64px] md:text-[80px] lg:text-[92px] font-normal leading-[0.92] tracking-tight text-[#0A0A0A] mb-8" | ||
| > | ||
| Retrieval,{' '} | ||
| <span className="italic font-light text-transparent bg-clip-text bg-gradient-to-r from-brand-blue via-primary-500 to-brand-pink"> | ||
| rethought. | ||
| </span> | ||
| </motion.h1> | ||
|
|
||
| {/* Subtitle */} | ||
| <motion.p | ||
| initial={{ opacity: 0, y: 16 }} | ||
| animate={{ opacity: 1, y: 0 }} | ||
| transition={{ duration: 0.7, delay: 0.2 }} | ||
| className="text-lg md:text-xl font-light text-text-secondary leading-relaxed max-w-[640px]" | ||
| > | ||
| A technical journal on the design of structure-preserving retrieval architectures, no-chunking | ||
| models, and deterministic RAG systems for AI agents. | ||
| </motion.p> | ||
| </div> | ||
|
|
||
| {/* Decorative bottom border with date */} | ||
| <div className="mt-16 pt-6 border-t border-[#0A0A0A] flex items-center justify-between"> | ||
| <div className="font-data text-[10px] uppercase tracking-[0.2em] text-text-muted"> | ||
| Vol. 01 — {new Date().getFullYear()} | ||
| </div> | ||
| <div className="hidden sm:flex items-center gap-4 font-data text-[10px] uppercase tracking-[0.16em] text-text-muted"> | ||
| <span>{BLOG_POSTS.length} Articles Published</span> | ||
| <span className="w-1 h-1 rounded-full bg-brand-pink" /> | ||
| <span>Updated Weekly</span> | ||
| </div> | ||
| </div> | ||
| </div> | ||
| </section> | ||
|
|
||
| {/* ─── CATEGORIES + SANDBOX BAR ─── */} | ||
| <section className="sticky top-0 z-30 bg-white/90 backdrop-blur-lg border-b border-border-gray"> | ||
| <div className="max-w-[1200px] mx-auto px-6 md:px-12 lg:px-16 py-4 flex flex-col sm:flex-row sm:items-center justify-between gap-4"> | ||
| <div className="flex flex-wrap items-center gap-1"> | ||
| {categories.map((cat) => { | ||
| const isActive = selectedCategory === cat; | ||
| return ( | ||
| <button | ||
| key={cat} | ||
| onClick={() => setSelectedCategory(cat)} | ||
| className={`px-4 py-1.5 rounded-full text-xs font-data uppercase tracking-wider transition-all duration-200 cursor-pointer ${ | ||
| isActive | ||
| ? 'bg-[#0A0A0A] text-white font-semibold shadow-sm' | ||
| : 'text-text-muted hover:text-[#0A0A0A] hover:bg-black/[0.04]' | ||
| }`} | ||
| > | ||
| {cat} | ||
| </button> | ||
| ); | ||
| })} | ||
| </div> | ||
|
|
||
| <button | ||
| onClick={openSandbox} | ||
| className="group inline-flex items-center gap-2 bg-brand-blue hover:bg-primary-600 text-white px-5 py-2.5 rounded-full font-data text-[10px] uppercase tracking-wider transition-all duration-300 shadow-sm cursor-pointer self-start sm:self-auto" | ||
| > | ||
| <Terminal className="w-3.5 h-3.5" /> | ||
| <span>Try Retrieval</span> | ||
| <span className="w-1.5 h-1.5 rounded-full bg-brand-pink animate-pulse" /> | ||
| </button> | ||
| </div> | ||
| </section> | ||
|
|
||
| {/* ─── FEATURED ARTICLE (FULL WIDTH) ─── */} | ||
| <AnimatePresence mode="wait"> | ||
| {selectedCategory === 'All' && featuredPost && ( | ||
| <motion.section | ||
| key="featured-hero" | ||
| initial={{ opacity: 0, y: 10 }} | ||
| animate={{ opacity: 1, y: 0 }} | ||
| exit={{ opacity: 0 }} | ||
| transition={{ duration: 0.35 }} | ||
| className="border-b border-border-gray" | ||
| > | ||
| <div | ||
| onClick={() => setActiveArticle(featuredPost)} | ||
| className="group max-w-[1200px] mx-auto px-6 md:px-12 lg:px-16 py-16 md:py-20 cursor-pointer" | ||
| > | ||
| <div className="grid grid-cols-1 lg:grid-cols-12 gap-10 lg:gap-16 items-start"> | ||
| <div className="lg:col-span-7 flex flex-col justify-between min-h-[260px]"> | ||
| <div> | ||
| <div className="flex items-center gap-3 font-data text-[10px] uppercase tracking-[0.16em] text-text-muted mb-5"> | ||
| <span className="text-brand-blue font-semibold">{featuredPost.category}</span> | ||
| <span className="w-1 h-1 rounded-full bg-border-gray" /> | ||
| <span>{featuredPost.date}</span> | ||
| <span className="w-1 h-1 rounded-full bg-border-gray" /> | ||
| <span>{featuredPost.readTime}</span> | ||
| </div> | ||
|
|
||
| <h2 className="text-[32px] md:text-[44px] font-serif text-[#0A0A0A] group-hover:text-brand-blue transition-colors duration-300 leading-[1.06] tracking-tight mb-6"> | ||
| {featuredPost.title} | ||
| </h2> | ||
|
|
||
| <p className="text-base md:text-lg text-text-secondary font-light leading-relaxed max-w-[600px] mb-8"> | ||
| {featuredPost.snippet} | ||
| </p> | ||
| </div> | ||
|
|
||
| <div className="flex items-center justify-between"> | ||
| <div className="flex items-center gap-3"> | ||
| <div className="relative w-9 h-9 rounded-full overflow-hidden border-2 border-white shadow-sm"> | ||
| <img | ||
| src={featuredPost.author.avatarUrl} | ||
| alt={featuredPost.author.name} | ||
| className="object-cover w-full h-full" | ||
| referrerPolicy="no-referrer" | ||
| /> | ||
| </div> | ||
| <div className="flex flex-col"> | ||
| <span className="text-xs font-semibold text-[#0A0A0A]">{featuredPost.author.name}</span> | ||
| <span className="text-[9px] font-data text-text-muted uppercase tracking-wider"> | ||
| {featuredPost.author.role} | ||
| </span> | ||
| </div> | ||
| </div> | ||
|
|
||
| <div className="hidden sm:flex items-center gap-2 text-xs font-data uppercase tracking-[0.18em] font-semibold text-[#0A0A0A] group-hover:text-brand-blue transition-colors"> | ||
| <span>Read article</span> | ||
| <ArrowRight className="w-4 h-4 text-brand-pink group-hover:translate-x-1.5 transition-transform duration-300" /> | ||
| </div> | ||
| </div> | ||
| </div> | ||
|
|
||
| <div className="lg:col-span-5 w-full"> | ||
| <BlueprintIllustration type={featuredPost.imageType} large /> | ||
| </div> | ||
| </div> | ||
| </div> | ||
| </motion.section> | ||
| )} | ||
| </AnimatePresence> | ||
|
|
||
| {/* ─── ARTICLE GRID ─── */} | ||
| <section className="max-w-[1200px] mx-auto px-6 md:px-12 lg:px-16 py-12 md:py-16"> | ||
| {selectedCategory === 'All' && ( | ||
| <div className="flex items-center justify-between mb-8"> | ||
| <h2 className="font-data text-[11px] uppercase tracking-[0.2em] text-text-muted font-medium"> | ||
| Latest Articles | ||
| </h2> | ||
| <div className="h-px flex-1 bg-border-gray ml-6" /> | ||
| </div> | ||
| )} | ||
|
|
||
| <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-px bg-border-gray border border-border-gray rounded-xl overflow-hidden"> | ||
| <AnimatePresence mode="popLayout"> | ||
| {displayPosts.map((post, idx) => ( | ||
| <motion.article | ||
| key={post.id} | ||
| layout | ||
| initial={{ opacity: 0 }} | ||
| animate={{ opacity: 1 }} | ||
| exit={{ opacity: 0 }} | ||
| transition={{ duration: 0.3, delay: idx * 0.03 }} | ||
| onClick={() => setActiveArticle(post)} | ||
| className="group flex flex-col cursor-pointer bg-white hover:bg-[#FAFBFF] transition-colors duration-300" | ||
| > | ||
| <div className="flex flex-col h-full p-7 md:p-8"> | ||
| {/* Meta */} | ||
| <div className="flex items-center justify-between font-data text-[9px] uppercase tracking-[0.16em] text-text-muted mb-5"> | ||
| <span className="text-brand-blue font-semibold">{post.category}</span> | ||
| <span>{post.date}</span> | ||
| </div> | ||
|
|
||
| {/* Headline */} | ||
| <h3 className="text-xl md:text-2xl font-serif text-[#0A0A0A] group-hover:text-brand-blue transition-colors duration-300 leading-tight mb-4"> | ||
| {post.title} | ||
| </h3> | ||
|
|
||
| {/* Snippet */} | ||
| <p className="text-[13px] text-text-secondary leading-relaxed font-light mb-6 flex-grow"> | ||
| {post.snippet} | ||
| </p> | ||
|
|
||
| {/* Blueprint */} | ||
| <div className="mb-6"> | ||
| <BlueprintIllustration type={post.imageType} /> | ||
| </div> | ||
|
|
||
| {/* Author + Read link */} | ||
| <div className="flex items-center justify-between pt-5 border-t border-border-light"> | ||
| <div className="flex items-center gap-2.5"> | ||
| <div className="relative w-7 h-7 rounded-full overflow-hidden border border-border-gray"> | ||
| <img | ||
| src={post.author.avatarUrl} | ||
| alt={post.author.name} | ||
| className="object-cover w-full h-full" | ||
| referrerPolicy="no-referrer" | ||
| /> | ||
| </div> | ||
| <div className="flex flex-col"> | ||
| <span className="text-[11px] font-semibold text-[#0A0A0A]">{post.author.name}</span> | ||
| <span className="text-[8px] font-data text-text-muted uppercase tracking-wider"> | ||
| {post.readTime} | ||
| </span> | ||
| </div> | ||
| </div> | ||
|
|
||
| <ArrowUpRight className="w-4 h-4 text-text-muted group-hover:text-brand-blue group-hover:translate-x-0.5 group-hover:-translate-y-0.5 transition-all duration-300" /> | ||
| </div> | ||
| </div> | ||
| </motion.article> | ||
| ))} | ||
| </AnimatePresence> | ||
| </div> | ||
|
|
||
| {/* Empty State */} | ||
| {displayPosts.length === 0 && ( | ||
| <div className="py-24 text-center border border-dashed border-border-gray rounded-2xl flex flex-col items-center justify-center gap-4 bg-[#FCFCFD] mt-4"> | ||
| <Newspaper className="w-8 h-8 text-text-muted/40" /> | ||
| <span className="font-display font-medium text-text-secondary">No matching articles found</span> | ||
| <button | ||
| onClick={() => setSelectedCategory('All')} | ||
| className="text-xs font-data uppercase tracking-wider text-brand-blue hover:underline cursor-pointer" | ||
| > | ||
| Show all articles | ||
| </button> | ||
| </div> | ||
| )} | ||
| </section> | ||
|
|
||
| {/* ─── NEWSLETTER CTA ─── */} | ||
| <section className="border-t border-border-gray"> | ||
| <div className="max-w-[1200px] mx-auto px-6 md:px-12 lg:px-16 py-20 md:py-28"> | ||
| <div className="max-w-[600px] mx-auto text-center"> | ||
| <div className="inline-flex items-center gap-2 px-3 py-1 rounded-full border border-border-gray bg-white mb-6"> | ||
| <Sparkles className="w-3 h-3 text-brand-pink" /> | ||
| <span className="font-data text-[10px] uppercase tracking-[0.16em] text-text-muted font-medium"> | ||
| Stay in the loop | ||
| </span> | ||
| </div> | ||
|
|
||
| <h2 className="font-serif text-[32px] md:text-[42px] leading-[1.08] tracking-tight text-[#0A0A0A] mb-4"> | ||
| Engineering updates,{' '} | ||
| <span className="italic text-transparent bg-clip-text bg-gradient-to-r from-brand-blue to-brand-pink"> | ||
| delivered. | ||
| </span> | ||
| </h2> | ||
|
|
||
| <p className="text-base text-text-secondary font-light leading-relaxed mb-8 max-w-[440px] mx-auto"> | ||
| Get new posts on retrieval architectures, product releases, and the future of document AI — straight to | ||
| your inbox. | ||
| </p> | ||
|
|
||
| <div className="flex flex-col sm:flex-row items-stretch gap-3 max-w-[460px] mx-auto"> | ||
| <input | ||
| type="email" | ||
| placeholder="you@company.com" | ||
| className="flex-1 px-4 py-3 border border-border-gray rounded-full text-sm bg-[#FCFCFD] focus:outline-none focus:border-brand-blue focus:ring-1 focus:ring-brand-blue/20 transition-all placeholder:text-text-muted/60" | ||
| /> | ||
| <button className="bg-[#0A0A0A] text-white px-6 py-3 rounded-full text-sm font-medium hover:bg-brand-blue transition-colors duration-300 cursor-pointer whitespace-nowrap"> | ||
| Subscribe → | ||
| </button> | ||
| </div> | ||
|
|
||
| <p className="mt-4 text-[11px] text-text-muted font-data"> | ||
| No spam. Unsubscribe anytime. | ||
| </p> | ||
| </div> | ||
| )} |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
The part count includes posts outside the series.
posts.length counts every loaded post. If a post without series is added, the label reports a part count that is too high. Count only posts in the same series.
♻️ Proposed fix
{series && (
<div className="eyebrow mb-8">
- {series} · {posts.length} parts
+ {series} · {posts.filter((p) => p.series === series).length} parts
</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.
| {series && ( | |
| <div className="eyebrow mb-8"> | |
| {series} · {posts.length} parts | |
| </div> | |
| </motion.div> | |
| ) : ( | |
| /* ━━━ MAGAZINE INDEX VIEW ━━━ */ | |
| <motion.div | |
| key="magazine-index" | |
| initial={{ opacity: 0 }} | |
| animate={{ opacity: 1 }} | |
| exit={{ opacity: 0 }} | |
| transition={{ duration: 0.3 }} | |
| > | |
| {/* ─── HERO SECTION ─── */} | |
| <section className="relative min-h-[85vh] flex items-end overflow-hidden"> | |
| {/* Gradient backdrop */} | |
| <div className="absolute -top-40 left-1/2 -translate-x-1/2 w-[1200px] h-[700px] bg-[radial-gradient(ellipse_at_center,rgba(20,86,240,0.07)_0%,rgba(234,94,193,0.035)_40%,transparent_70%)] blur-[50px] pointer-events-none" /> | |
| {/* Grid paper */} | |
| <div className="absolute inset-0 grid-paper [mask-image:radial-gradient(ellipse_at_center,black_25%,transparent_70%)] pointer-events-none opacity-60" /> | |
| <div className="relative z-10 w-full max-w-[1200px] mx-auto px-6 md:px-12 lg:px-16 pb-16 pt-36 md:pt-44"> | |
| <div className="max-w-[920px]"> | |
| {/* Chip */} | |
| <motion.div | |
| initial={{ opacity: 0, y: 12 }} | |
| animate={{ opacity: 1, y: 0 }} | |
| transition={{ duration: 0.6 }} | |
| className="inline-flex items-center gap-2.5 px-3.5 py-1.5 rounded-full border border-border-gray bg-white/80 backdrop-blur-sm mb-8 shadow-sm" | |
| > | |
| <span className="relative flex h-1.5 w-1.5"> | |
| <span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-brand-blue opacity-75" /> | |
| <span className="relative inline-flex rounded-full h-1.5 w-1.5 bg-brand-blue" /> | |
| </span> | |
| <span className="font-data text-[10px] font-medium text-text-muted tracking-[0.16em] uppercase"> | |
| Vectorless Engineering Journal | |
| </span> | |
| </motion.div> | |
| {/* Headline */} | |
| <motion.h1 | |
| initial={{ opacity: 0, y: 20 }} | |
| animate={{ opacity: 1, y: 0 }} | |
| transition={{ duration: 0.7, delay: 0.1 }} | |
| className="font-serif text-[48px] sm:text-[64px] md:text-[80px] lg:text-[92px] font-normal leading-[0.92] tracking-tight text-[#0A0A0A] mb-8" | |
| > | |
| Retrieval,{' '} | |
| <span className="italic font-light text-transparent bg-clip-text bg-gradient-to-r from-brand-blue via-primary-500 to-brand-pink"> | |
| rethought. | |
| </span> | |
| </motion.h1> | |
| {/* Subtitle */} | |
| <motion.p | |
| initial={{ opacity: 0, y: 16 }} | |
| animate={{ opacity: 1, y: 0 }} | |
| transition={{ duration: 0.7, delay: 0.2 }} | |
| className="text-lg md:text-xl font-light text-text-secondary leading-relaxed max-w-[640px]" | |
| > | |
| A technical journal on the design of structure-preserving retrieval architectures, no-chunking | |
| models, and deterministic RAG systems for AI agents. | |
| </motion.p> | |
| </div> | |
| {/* Decorative bottom border with date */} | |
| <div className="mt-16 pt-6 border-t border-[#0A0A0A] flex items-center justify-between"> | |
| <div className="font-data text-[10px] uppercase tracking-[0.2em] text-text-muted"> | |
| Vol. 01 — {new Date().getFullYear()} | |
| </div> | |
| <div className="hidden sm:flex items-center gap-4 font-data text-[10px] uppercase tracking-[0.16em] text-text-muted"> | |
| <span>{BLOG_POSTS.length} Articles Published</span> | |
| <span className="w-1 h-1 rounded-full bg-brand-pink" /> | |
| <span>Updated Weekly</span> | |
| </div> | |
| </div> | |
| </div> | |
| </section> | |
| {/* ─── CATEGORIES + SANDBOX BAR ─── */} | |
| <section className="sticky top-0 z-30 bg-white/90 backdrop-blur-lg border-b border-border-gray"> | |
| <div className="max-w-[1200px] mx-auto px-6 md:px-12 lg:px-16 py-4 flex flex-col sm:flex-row sm:items-center justify-between gap-4"> | |
| <div className="flex flex-wrap items-center gap-1"> | |
| {categories.map((cat) => { | |
| const isActive = selectedCategory === cat; | |
| return ( | |
| <button | |
| key={cat} | |
| onClick={() => setSelectedCategory(cat)} | |
| className={`px-4 py-1.5 rounded-full text-xs font-data uppercase tracking-wider transition-all duration-200 cursor-pointer ${ | |
| isActive | |
| ? 'bg-[#0A0A0A] text-white font-semibold shadow-sm' | |
| : 'text-text-muted hover:text-[#0A0A0A] hover:bg-black/[0.04]' | |
| }`} | |
| > | |
| {cat} | |
| </button> | |
| ); | |
| })} | |
| </div> | |
| <button | |
| onClick={openSandbox} | |
| className="group inline-flex items-center gap-2 bg-brand-blue hover:bg-primary-600 text-white px-5 py-2.5 rounded-full font-data text-[10px] uppercase tracking-wider transition-all duration-300 shadow-sm cursor-pointer self-start sm:self-auto" | |
| > | |
| <Terminal className="w-3.5 h-3.5" /> | |
| <span>Try Retrieval</span> | |
| <span className="w-1.5 h-1.5 rounded-full bg-brand-pink animate-pulse" /> | |
| </button> | |
| </div> | |
| </section> | |
| {/* ─── FEATURED ARTICLE (FULL WIDTH) ─── */} | |
| <AnimatePresence mode="wait"> | |
| {selectedCategory === 'All' && featuredPost && ( | |
| <motion.section | |
| key="featured-hero" | |
| initial={{ opacity: 0, y: 10 }} | |
| animate={{ opacity: 1, y: 0 }} | |
| exit={{ opacity: 0 }} | |
| transition={{ duration: 0.35 }} | |
| className="border-b border-border-gray" | |
| > | |
| <div | |
| onClick={() => setActiveArticle(featuredPost)} | |
| className="group max-w-[1200px] mx-auto px-6 md:px-12 lg:px-16 py-16 md:py-20 cursor-pointer" | |
| > | |
| <div className="grid grid-cols-1 lg:grid-cols-12 gap-10 lg:gap-16 items-start"> | |
| <div className="lg:col-span-7 flex flex-col justify-between min-h-[260px]"> | |
| <div> | |
| <div className="flex items-center gap-3 font-data text-[10px] uppercase tracking-[0.16em] text-text-muted mb-5"> | |
| <span className="text-brand-blue font-semibold">{featuredPost.category}</span> | |
| <span className="w-1 h-1 rounded-full bg-border-gray" /> | |
| <span>{featuredPost.date}</span> | |
| <span className="w-1 h-1 rounded-full bg-border-gray" /> | |
| <span>{featuredPost.readTime}</span> | |
| </div> | |
| <h2 className="text-[32px] md:text-[44px] font-serif text-[#0A0A0A] group-hover:text-brand-blue transition-colors duration-300 leading-[1.06] tracking-tight mb-6"> | |
| {featuredPost.title} | |
| </h2> | |
| <p className="text-base md:text-lg text-text-secondary font-light leading-relaxed max-w-[600px] mb-8"> | |
| {featuredPost.snippet} | |
| </p> | |
| </div> | |
| <div className="flex items-center justify-between"> | |
| <div className="flex items-center gap-3"> | |
| <div className="relative w-9 h-9 rounded-full overflow-hidden border-2 border-white shadow-sm"> | |
| <img | |
| src={featuredPost.author.avatarUrl} | |
| alt={featuredPost.author.name} | |
| className="object-cover w-full h-full" | |
| referrerPolicy="no-referrer" | |
| /> | |
| </div> | |
| <div className="flex flex-col"> | |
| <span className="text-xs font-semibold text-[#0A0A0A]">{featuredPost.author.name}</span> | |
| <span className="text-[9px] font-data text-text-muted uppercase tracking-wider"> | |
| {featuredPost.author.role} | |
| </span> | |
| </div> | |
| </div> | |
| <div className="hidden sm:flex items-center gap-2 text-xs font-data uppercase tracking-[0.18em] font-semibold text-[#0A0A0A] group-hover:text-brand-blue transition-colors"> | |
| <span>Read article</span> | |
| <ArrowRight className="w-4 h-4 text-brand-pink group-hover:translate-x-1.5 transition-transform duration-300" /> | |
| </div> | |
| </div> | |
| </div> | |
| <div className="lg:col-span-5 w-full"> | |
| <BlueprintIllustration type={featuredPost.imageType} large /> | |
| </div> | |
| </div> | |
| </div> | |
| </motion.section> | |
| )} | |
| </AnimatePresence> | |
| {/* ─── ARTICLE GRID ─── */} | |
| <section className="max-w-[1200px] mx-auto px-6 md:px-12 lg:px-16 py-12 md:py-16"> | |
| {selectedCategory === 'All' && ( | |
| <div className="flex items-center justify-between mb-8"> | |
| <h2 className="font-data text-[11px] uppercase tracking-[0.2em] text-text-muted font-medium"> | |
| Latest Articles | |
| </h2> | |
| <div className="h-px flex-1 bg-border-gray ml-6" /> | |
| </div> | |
| )} | |
| <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-px bg-border-gray border border-border-gray rounded-xl overflow-hidden"> | |
| <AnimatePresence mode="popLayout"> | |
| {displayPosts.map((post, idx) => ( | |
| <motion.article | |
| key={post.id} | |
| layout | |
| initial={{ opacity: 0 }} | |
| animate={{ opacity: 1 }} | |
| exit={{ opacity: 0 }} | |
| transition={{ duration: 0.3, delay: idx * 0.03 }} | |
| onClick={() => setActiveArticle(post)} | |
| className="group flex flex-col cursor-pointer bg-white hover:bg-[#FAFBFF] transition-colors duration-300" | |
| > | |
| <div className="flex flex-col h-full p-7 md:p-8"> | |
| {/* Meta */} | |
| <div className="flex items-center justify-between font-data text-[9px] uppercase tracking-[0.16em] text-text-muted mb-5"> | |
| <span className="text-brand-blue font-semibold">{post.category}</span> | |
| <span>{post.date}</span> | |
| </div> | |
| {/* Headline */} | |
| <h3 className="text-xl md:text-2xl font-serif text-[#0A0A0A] group-hover:text-brand-blue transition-colors duration-300 leading-tight mb-4"> | |
| {post.title} | |
| </h3> | |
| {/* Snippet */} | |
| <p className="text-[13px] text-text-secondary leading-relaxed font-light mb-6 flex-grow"> | |
| {post.snippet} | |
| </p> | |
| {/* Blueprint */} | |
| <div className="mb-6"> | |
| <BlueprintIllustration type={post.imageType} /> | |
| </div> | |
| {/* Author + Read link */} | |
| <div className="flex items-center justify-between pt-5 border-t border-border-light"> | |
| <div className="flex items-center gap-2.5"> | |
| <div className="relative w-7 h-7 rounded-full overflow-hidden border border-border-gray"> | |
| <img | |
| src={post.author.avatarUrl} | |
| alt={post.author.name} | |
| className="object-cover w-full h-full" | |
| referrerPolicy="no-referrer" | |
| /> | |
| </div> | |
| <div className="flex flex-col"> | |
| <span className="text-[11px] font-semibold text-[#0A0A0A]">{post.author.name}</span> | |
| <span className="text-[8px] font-data text-text-muted uppercase tracking-wider"> | |
| {post.readTime} | |
| </span> | |
| </div> | |
| </div> | |
| <ArrowUpRight className="w-4 h-4 text-text-muted group-hover:text-brand-blue group-hover:translate-x-0.5 group-hover:-translate-y-0.5 transition-all duration-300" /> | |
| </div> | |
| </div> | |
| </motion.article> | |
| ))} | |
| </AnimatePresence> | |
| </div> | |
| {/* Empty State */} | |
| {displayPosts.length === 0 && ( | |
| <div className="py-24 text-center border border-dashed border-border-gray rounded-2xl flex flex-col items-center justify-center gap-4 bg-[#FCFCFD] mt-4"> | |
| <Newspaper className="w-8 h-8 text-text-muted/40" /> | |
| <span className="font-display font-medium text-text-secondary">No matching articles found</span> | |
| <button | |
| onClick={() => setSelectedCategory('All')} | |
| className="text-xs font-data uppercase tracking-wider text-brand-blue hover:underline cursor-pointer" | |
| > | |
| Show all articles | |
| </button> | |
| </div> | |
| )} | |
| </section> | |
| {/* ─── NEWSLETTER CTA ─── */} | |
| <section className="border-t border-border-gray"> | |
| <div className="max-w-[1200px] mx-auto px-6 md:px-12 lg:px-16 py-20 md:py-28"> | |
| <div className="max-w-[600px] mx-auto text-center"> | |
| <div className="inline-flex items-center gap-2 px-3 py-1 rounded-full border border-border-gray bg-white mb-6"> | |
| <Sparkles className="w-3 h-3 text-brand-pink" /> | |
| <span className="font-data text-[10px] uppercase tracking-[0.16em] text-text-muted font-medium"> | |
| Stay in the loop | |
| </span> | |
| </div> | |
| <h2 className="font-serif text-[32px] md:text-[42px] leading-[1.08] tracking-tight text-[#0A0A0A] mb-4"> | |
| Engineering updates,{' '} | |
| <span className="italic text-transparent bg-clip-text bg-gradient-to-r from-brand-blue to-brand-pink"> | |
| delivered. | |
| </span> | |
| </h2> | |
| <p className="text-base text-text-secondary font-light leading-relaxed mb-8 max-w-[440px] mx-auto"> | |
| Get new posts on retrieval architectures, product releases, and the future of document AI — straight to | |
| your inbox. | |
| </p> | |
| <div className="flex flex-col sm:flex-row items-stretch gap-3 max-w-[460px] mx-auto"> | |
| <input | |
| type="email" | |
| placeholder="you@company.com" | |
| className="flex-1 px-4 py-3 border border-border-gray rounded-full text-sm bg-[#FCFCFD] focus:outline-none focus:border-brand-blue focus:ring-1 focus:ring-brand-blue/20 transition-all placeholder:text-text-muted/60" | |
| /> | |
| <button className="bg-[#0A0A0A] text-white px-6 py-3 rounded-full text-sm font-medium hover:bg-brand-blue transition-colors duration-300 cursor-pointer whitespace-nowrap"> | |
| Subscribe → | |
| </button> | |
| </div> | |
| <p className="mt-4 text-[11px] text-text-muted font-data"> | |
| No spam. Unsubscribe anytime. | |
| </p> | |
| </div> | |
| )} | |
| {series && ( | |
| <div className="eyebrow mb-8"> | |
| {series} · {posts.filter((p) => p.series === series).length} parts | |
| </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 `@apps/blogs/app/page.tsx` around lines 90 - 94, Update the series label
rendering in the page component so its part count filters posts to those whose
series matches the current series before counting. Keep the existing conditional
rendering and label format unchanged, using the posts collection rather than the
total posts.length.
| <span className="stat text-[15px] text-text-muted md:w-8"> | ||
| {String(post.part ?? "").padStart(2, "0")} | ||
| </span> |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
A post without part renders as 00.
String(undefined ?? "") is "", and "".padStart(2, "0") is "00". The index then shows 00 as if the post were part zero. Render an empty cell instead.
♻️ Proposed fix
<span className="stat text-[15px] text-text-muted md:w-8">
- {String(post.part ?? "").padStart(2, "0")}
+ {post.part != null ? String(post.part).padStart(2, "0") : ""}
</span>📝 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.
| <span className="stat text-[15px] text-text-muted md:w-8"> | |
| {String(post.part ?? "").padStart(2, "0")} | |
| </span> | |
| <span className="stat text-[15px] text-text-muted md:w-8"> | |
| {post.part != null ? String(post.part).padStart(2, "0") : ""} | |
| </span> |
🤖 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 `@apps/blogs/app/page.tsx` around lines 127 - 129, Update the post index
rendering in the visible span to return an empty cell when post.part is absent,
rather than applying padStart to an empty string; preserve the two-digit
formatting for posts with a defined part.
| const TRUE_WIDTHS = [ | ||
| { g: "i", w: 222 }, | ||
| { g: "m", w: 833 }, | ||
| { g: "W", w: 944 }, | ||
| { g: "i", w: 222 }, | ||
| ]; | ||
|
|
||
| export function DriftDiagram() { | ||
| const scale = 0.28; // points-per-thousandth, chosen so the row fits | ||
| const trueTotal = TRUE_WIDTHS.reduce((a, b) => a + b.w, 0); | ||
| const guessTotal = TRUE_WIDTHS.length * 500; |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
Correct the four-glyph drift measurement.
The defined widths sum to 2,221 units. Four flat guesses sum to 2,000 units. The error is 0.221em, which is 2.652pt at 12pt. The displayed ~0.3pt off has no matching font size and conflicts with these inputs. Show 0.221em or name the font size used for a point value.
Also applies to: 81-89
🤖 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 `@apps/blogs/components/Diagrams.tsx` around lines 26 - 36, Update
DriftDiagram’s drift measurement display to match the defined TRUE_WIDTHS and
guessTotal values: report the error as 0.221em, or explicitly calculate and
label the equivalent point value using a stated 12pt font size. Remove the
inconsistent “~0.3pt off” wording while preserving the existing drift
calculation.
| export function DriftDiagram() { | ||
| const scale = 0.28; // points-per-thousandth, chosen so the row fits | ||
| const trueTotal = TRUE_WIDTHS.reduce((a, b) => a + b.w, 0); | ||
| const guessTotal = TRUE_WIDTHS.length * 500; | ||
|
|
||
| return ( | ||
| <Figure caption="Widths are stored in thousandths of an em. Guessing 500 for every glyph is not a small error on each one — it is a compounding error along the line."> | ||
| <div className="p-7"> | ||
| <Eyebrow tone="blue">True AFM widths</Eyebrow> | ||
| <div className="mt-3 flex items-end"> | ||
| {TRUE_WIDTHS.map((c, i) => ( | ||
| <div key={i} style={{ width: c.w * scale }} className="shrink-0"> | ||
| <div className="flex h-11 items-center justify-center rounded-sm border border-brand-blue/40 bg-brand-blue/[0.08] text-[17px] text-text-base"> | ||
| {c.g} | ||
| </div> | ||
| <div className="stat mt-1.5 text-center text-[11px] text-text-muted">{c.w}</div> | ||
| </div> | ||
| ))} | ||
| <div className="ml-3 self-center text-[12px] text-brand-blue">word ends here</div> | ||
| </div> | ||
|
|
||
| <div className="mt-8"> | ||
| <Eyebrow tone="pink">Flat 500 guess — what pdftable did</Eyebrow> | ||
| <div className="mt-3 flex items-end"> | ||
| {TRUE_WIDTHS.map((c, i) => ( | ||
| <div key={i} style={{ width: 500 * scale }} className="shrink-0"> | ||
| <div className="flex h-11 items-center justify-center rounded-sm border border-dashed border-brand-pink/50 bg-brand-pink/[0.06] text-[17px] text-text-base"> | ||
| {c.g} | ||
| </div> | ||
| <div className="stat mt-1.5 text-center text-[11px] text-brand-pink">500</div> | ||
| </div> | ||
| ))} | ||
| </div> | ||
| <div | ||
| className="relative mt-2 h-5" | ||
| style={{ width: Math.max(trueTotal, guessTotal) * scale }} | ||
| > | ||
| <div | ||
| className="absolute top-2 h-px bg-brand-pink" | ||
| style={{ | ||
| left: Math.min(trueTotal, guessTotal) * scale, | ||
| width: Math.abs(trueTotal - guessTotal) * scale, | ||
| }} | ||
| /> | ||
| </div> | ||
| <div className="text-[12px] text-brand-pink">error after only four glyphs</div> |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
Make DriftDiagram usable at narrow widths.
TRUE_WIDTHS produces a fixed 622px row before “word ends here” and padding. The flex row does not wrap. Figure at apps/blogs/components/Figure.tsx Line 25 clips overflow, so final glyphs and the error marker are unreachable on narrow viewports. Put the fixed-width comparison in an overflow-x-auto container, or use a responsive scale.
🤖 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 `@apps/blogs/components/Diagrams.tsx` around lines 33 - 78, The fixed-width
comparison rows in DriftDiagram are clipped on narrow viewports because Figure
hides overflow. Wrap the comparison content, including the “word ends here”
label and error marker, in an overflow-x-auto container so all glyphs remain
horizontally reachable without changing the diagram’s scale.
|
|
||
| --- | ||
|
|
||
| *Full evaluation reports, with commits, oracles and caveats, are in [`docs/evaluations/`](../evaluations/). Benchmarks that download their own datasets are in [`bench/`](../../bench/).* |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/usr/bin/env bash
set -euo pipefail
# List App Router pages. Verify routes for /evaluations and /bench are present.
fd -HI -t f '^page\.(ts|tsx|js|jsx)$' apps/blogs/app | sort
# Locate the source directories named by the prose.
fd -HI -t d . | rg '/(docs/evaluations|bench)$' || true
# Find site-local links or redirects that publish either path.
rg -n -C 3 -g '*.ts' -g '*.tsx' -g '*.md' '(?:/|\.{1,2}/)(evaluations|bench)/?' apps/blogs || trueRepository: hallelx2/vectorless
Length of output: 787
🏁 Script executed:
#!/usr/bin/env bash
set -euo pipefail
printf 'Tracked paths near apps/blogs/app:\n'
git ls-files apps/blogs/app | sort
printf '\nTracked paths named evaluations or bench:\n'
git ls-files | rg '(^|/)evaluations($|/)|(^|/)bench($|/)' || true
printf '\nAll markdown/TS/TSX references to evaluations or bench:\n'
rg -n -C 2 -g '*.md' -g '*.ts' -g '*.tsx' -g '*.js' -g '*.jsx' 'evaluations|bench|docs/evaluations' . || true
printf '\nCandidate app redirect/server config files:\n'
git ls-files apps/blogs app . | rg '(^|/)(middleware|redirects)\.(ts|tsx|js|jsx)$|(^|/)(layout|page)\.(ts|tsx|js|jsx)$|(^|/)next\.config\.(js|mjs|cjs|ts)$' || trueRepository: hallelx2/vectorless
Length of output: 6434
Replace these unresolvable relative links with canonical public URLs.
From the post slug route, ../evaluations/ resolves to /evaluations/ and ../../bench/ resolves to /bench/. These routes are not published in the blog app, so remove the links or replace them with working canonical URLs.
🤖 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 `@apps/blogs/content/posts/07-what-is-still-missing.md` at line 102, Update the
links in the post’s evaluation note so they no longer point to unpublished
relative routes; remove those links or replace them with working canonical
public URLs for the evaluations and benchmarks resources.
| const pattern = /(`[^`]+`)|(\*\*[^*]+\*\*)|(\*[^*]+\*)|(\[[^\]]+\]\([^)]+\))/g; | ||
| let last = 0; | ||
| let m: RegExpExecArray | null; | ||
| let i = 0; | ||
|
|
||
| while ((m = pattern.exec(text))) { | ||
| if (m.index > last) out.push(text.slice(last, m.index)); | ||
| const token = m[0]; | ||
| const key = `${keyPrefix}-i${i++}`; | ||
|
|
||
| if (token.startsWith("`")) { | ||
| out.push( | ||
| <code | ||
| key={key} | ||
| className="rounded bg-[color:var(--color-border-light)] px-1.5 py-0.5 font-data text-[0.85em] text-text-base" | ||
| > | ||
| {token.slice(1, -1)} | ||
| </code>, | ||
| ); | ||
| } else if (token.startsWith("**")) { | ||
| out.push( | ||
| <strong key={key} className="font-medium text-text-base"> | ||
| {token.slice(2, -2)} | ||
| </strong>, | ||
| ); | ||
| } else if (token.startsWith("[")) { | ||
| const link = /\[([^\]]+)\]\(([^)]+)\)/.exec(token)!; | ||
| const href = safeUrl(link[2]); | ||
| const external = /^https?:/i.test(href); | ||
| out.push( | ||
| <a | ||
| key={key} | ||
| href={href} | ||
| {...(external ? { target: "_blank", rel: "noopener noreferrer" } : {})} | ||
| className="text-brand-blue underline decoration-brand-blue/25 underline-offset-[3px] transition-colors hover:decoration-brand-blue" | ||
| > | ||
| {link[1]} | ||
| </a>, | ||
| ); | ||
| } else { | ||
| out.push( | ||
| <em key={key} className="italic"> | ||
| {token.slice(1, -1)} | ||
| </em>, | ||
| ); | ||
| } | ||
| last = m.index + token.length; | ||
| } | ||
| if (last < text.length) out.push(text.slice(last)); | ||
| return out; |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
Parse child inline syntax inside emphasis and link labels.
apps/blogs/content/posts/07-what-is-still-missing.md Line 102 starts and ends with *. The italic token consumes the full sentence. The branch then emits the nested links and code spans as text, so the document references appear literally and are not clickable. Recursively call inline for emphasis, strong text, and link labels. Keep code spans as text.
Proposed fix
- {token.slice(2, -2)}
+ {inline(token.slice(2, -2), `${key}-strong`)}
...
- {link[1]}
+ {inline(link[1], `${key}-link`)}
...
- {token.slice(1, -1)}
+ {inline(token.slice(1, -1), `${key}-em`)}📝 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 pattern = /(`[^`]+`)|(\*\*[^*]+\*\*)|(\*[^*]+\*)|(\[[^\]]+\]\([^)]+\))/g; | |
| let last = 0; | |
| let m: RegExpExecArray | null; | |
| let i = 0; | |
| while ((m = pattern.exec(text))) { | |
| if (m.index > last) out.push(text.slice(last, m.index)); | |
| const token = m[0]; | |
| const key = `${keyPrefix}-i${i++}`; | |
| if (token.startsWith("`")) { | |
| out.push( | |
| <code | |
| key={key} | |
| className="rounded bg-[color:var(--color-border-light)] px-1.5 py-0.5 font-data text-[0.85em] text-text-base" | |
| > | |
| {token.slice(1, -1)} | |
| </code>, | |
| ); | |
| } else if (token.startsWith("**")) { | |
| out.push( | |
| <strong key={key} className="font-medium text-text-base"> | |
| {token.slice(2, -2)} | |
| </strong>, | |
| ); | |
| } else if (token.startsWith("[")) { | |
| const link = /\[([^\]]+)\]\(([^)]+)\)/.exec(token)!; | |
| const href = safeUrl(link[2]); | |
| const external = /^https?:/i.test(href); | |
| out.push( | |
| <a | |
| key={key} | |
| href={href} | |
| {...(external ? { target: "_blank", rel: "noopener noreferrer" } : {})} | |
| className="text-brand-blue underline decoration-brand-blue/25 underline-offset-[3px] transition-colors hover:decoration-brand-blue" | |
| > | |
| {link[1]} | |
| </a>, | |
| ); | |
| } else { | |
| out.push( | |
| <em key={key} className="italic"> | |
| {token.slice(1, -1)} | |
| </em>, | |
| ); | |
| } | |
| last = m.index + token.length; | |
| } | |
| if (last < text.length) out.push(text.slice(last)); | |
| return out; | |
| const pattern = /(`[^`]+`)|(\*\*[^*]+\*\*)|(\*[^*]+\*)|(\[[^\]]+\]\([^)]+\))/g; | |
| let last = 0; | |
| let m: RegExpExecArray | null; | |
| let i = 0; | |
| while ((m = pattern.exec(text))) { | |
| if (m.index > last) out.push(text.slice(last, m.index)); | |
| const token = m[0]; | |
| const key = `${keyPrefix}-i${i++}`; | |
| if (token.startsWith("`")) { | |
| out.push( | |
| <code | |
| key={key} | |
| className="rounded bg-[color:var(--color-border-light)] px-1.5 py-0.5 font-data text-[0.85em] text-text-base" | |
| > | |
| {token.slice(1, -1)} | |
| </code>, | |
| ); | |
| } else if (token.startsWith("**")) { | |
| out.push( | |
| <strong key={key} className="font-medium text-text-base"> | |
| {inline(token.slice(2, -2), `${key}-strong`)} | |
| </strong>, | |
| ); | |
| } else if (token.startsWith("[")) { | |
| const link = /\[([^\]]+)\]\(([^)]+)\)/.exec(token)!; | |
| const href = safeUrl(link[2]); | |
| const external = /^https?:/i.test(href); | |
| out.push( | |
| <a | |
| key={key} | |
| href={href} | |
| {...(external ? { target: "_blank", rel: "noopener noreferrer" } : {})} | |
| className="text-brand-blue underline decoration-brand-blue/25 underline-offset-[3px] transition-colors hover:decoration-brand-blue" | |
| > | |
| {inline(link[1], `${key}-link`)} | |
| </a>, | |
| ); | |
| } else { | |
| out.push( | |
| <em key={key} className="italic"> | |
| {inline(token.slice(1, -1), `${key}-em`)} | |
| </em>, | |
| ); | |
| } | |
| last = m.index + token.length; | |
| } | |
| if (last < text.length) out.push(text.slice(last)); | |
| return out; |
🧰 Tools
🪛 OpenGrep (1.26.0)
[ERROR] 67-67: Dynamic command passed to child_process.exec/execSync. Use child_process.execFile or spawn with an argument array instead.
(coderabbit.command-injection.exec-js)
[ERROR] 88-88: Dynamic command passed to child_process.exec/execSync. Use child_process.execFile or spawn with an argument array instead.
(coderabbit.command-injection.exec-js)
🤖 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 `@apps/blogs/lib/markdown.tsx` around lines 62 - 111, Update the token
rendering branches in inline so emphasis, strong, and link label content are
passed back through inline using the existing keyPrefix mechanism, enabling
nested links and code spans; keep code span contents rendered as plain text.
Apply the recursive parsing to the label inside the link branch while preserving
safeUrl and external-link handling.
| part: data.part as number | undefined, | ||
| series: data.series as string | undefined, | ||
| tags: (data.tags as string[]) ?? [], |
There was a problem hiding this comment.
🩺 Stability & Availability | 🟠 Major | ⚡ Quick win
Unvalidated frontmatter casts let tags become a string, which breaks post rendering. toPost casts parsed frontmatter values without checking their runtime type. parseFrontmatter returns an array only when the value uses [...] syntax, so tags: go yields the string "go". The ?? fallback does not replace a non-nullish wrong type, so the string reaches the post route and post.tags.join(", ") throws a TypeError during static generation. The same gap lets part become a string.
apps/blogs/lib/posts.ts#L96-L98: normalize each field before returning the post. CoercetagswithArray.isArray(data.tags) ? data.tags : typeof data.tags === "string" && data.tags ? [data.tags] : [], and acceptpartonly whentypeof data.part === "number".apps/blogs/app/posts/[slug]/page.tsx#L64-L67: no change is needed oncePost.tagsis guaranteed to be an array; keep the existingpost.tags.lengthguard andjoin.
📍 Affects 2 files
apps/blogs/lib/posts.ts#L96-L98(this comment)apps/blogs/app/posts/[slug]/page.tsx#L64-L67
🤖 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 `@apps/blogs/lib/posts.ts` around lines 96 - 98, Update toPost in
apps/blogs/lib/posts.ts at lines 96-98 to validate frontmatter runtime types:
normalize tags to an array using the specified array/string/non-empty fallback
behavior, and set part only when data.part is a number. No direct change is
needed in apps/blogs/app/posts/[slug]/page.tsx lines 64-67; its existing tags
length guard and join are correct once Post.tags is guaranteed to be an array.
| /** Neighbours within a series, for prev/next links at the foot of a post. */ | ||
| export function getSeriesNeighbours(slug: string): { prev?: Post; next?: Post } { | ||
| const posts = getAllPosts(); | ||
| const i = posts.findIndex((p) => p.slug === slug); | ||
| if (i === -1) return {}; | ||
| return { prev: posts[i - 1], next: posts[i + 1] }; | ||
| } |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
getSeriesNeighbours does not scope to a series, and reverses prev/next outside series mode.
getAllPosts sorts ascending by part only when every post has a part. If one non-series post is added, the sort falls back to publishedAt descending. Then posts[i - 1] is the newer post, so the page labels it "Previous", and neighbours can come from a different series.
Filter by post.series and order by part inside this function, so the result does not depend on the global sort mode.
♻️ Proposed fix
export function getSeriesNeighbours(slug: string): { prev?: Post; next?: Post } {
- const posts = getAllPosts();
- const i = posts.findIndex((p) => p.slug === slug);
- if (i === -1) return {};
- return { prev: posts[i - 1], next: posts[i + 1] };
+ const all = getAllPosts();
+ const current = all.find((p) => p.slug === slug);
+ if (!current) return {};
+
+ const siblings = current.series
+ ? all
+ .filter((p) => p.series === current.series && p.part != null)
+ .sort((a, b) => (a.part ?? 0) - (b.part ?? 0))
+ : [...all].sort((a, b) => a.publishedAt.localeCompare(b.publishedAt));
+
+ const i = siblings.findIndex((p) => p.slug === slug);
+ if (i === -1) return {};
+ return { prev: siblings[i - 1], next: siblings[i + 1] };
}📝 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.
| /** Neighbours within a series, for prev/next links at the foot of a post. */ | |
| export function getSeriesNeighbours(slug: string): { prev?: Post; next?: Post } { | |
| const posts = getAllPosts(); | |
| const i = posts.findIndex((p) => p.slug === slug); | |
| if (i === -1) return {}; | |
| return { prev: posts[i - 1], next: posts[i + 1] }; | |
| } | |
| /** Neighbours within a series, for prev/next links at the foot of a post. */ | |
| export function getSeriesNeighbours(slug: string): { prev?: Post; next?: Post } { | |
| const all = getAllPosts(); | |
| const current = all.find((p) => p.slug === slug); | |
| if (!current) return {}; | |
| const siblings = current.series | |
| ? all | |
| .filter((p) => p.series === current.series && p.part != null) | |
| .sort((a, b) => (a.part ?? 0) - (b.part ?? 0)) | |
| : [...all].sort((a, b) => a.publishedAt.localeCompare(b.publishedAt)); | |
| const i = siblings.findIndex((p) => p.slug === slug); | |
| if (i === -1) return {}; | |
| return { prev: siblings[i - 1], next: siblings[i + 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 `@apps/blogs/lib/posts.ts` around lines 124 - 130, Update getSeriesNeighbours
to filter getAllPosts() to posts sharing the target post’s series, then order
that filtered collection by part before selecting neighbours. Preserve the empty
result for an unknown slug and ensure prev/next are derived from the
series-local ordering rather than the global getAllPosts sort.
apps/blogs/tsconfig.tsbuildinfo was committed by mistake in #17 -- a `git add apps/blogs` swept it in with the redesign. It is TypeScript incremental build state: machine-local, rewritten on every compile, and a source of pointless diffs and merge conflicts for anyone else building the app. apps/web had the same file already tracked, from before. Leaving it while adding *.tsbuildinfo to .gitignore would be incoherent, so both go. Adding the ignore pattern as well as removing the files, because .gitignore covered node_modules/ and .next/ but not this -- which is why nothing stopped it landing. Untracked with `update-index --force-remove` so the files stay on disk and nobody loses their incremental cache.
Replaces the hardcoded blog with a markdown content pipeline, and rebuilds the pages on the design tokens.
Content is markdown, shaped for a database
Posts lived in a
BLOG_POSTSarray inside a 59 KB client component, with three invented authors carrying Unsplash stock photos. Nothing could be edited, scheduled or migrated without editing TSX.They now live in
content/posts/*.mdwith frontmatter, loaded throughlib/posts.ts. ThePosttype is shaped as a database row deliberately —slugas primary key,publishedAtas an ISO string so it sorts and indexes without parsing,bodykept as raw markdown so the renderer can change without a migration.Pages call
getAllPosts/getPostand know nothing about the filesystem, so moving to Postgres means reimplementing two functions, not touching every page. Reading time is computed from the body — never hand-written, because a hand-written one is wrong the moment the post is edited.Frontmatter parsing and markdown rendering are written rather than pulled in: this app sits in a pnpm workspace, so a new dependency edits a lockfile shared by every app in the monorepo.
Raw HTML never renders — verified, not assumed
Nothing calls
dangerouslySetInnerHTML, so every value reaches the DOM as a React child and gets escaped. A post containing<script>renders it as visible characters.Links were the hole that left —
[text](javascript:…)is valid markdown.safeUrlallowlistshttp,https,mailto,teland relative paths; anything else collapses to#.I proved it by building a fixture full of payloads and inspecting the emitted HTML:
<script>tagonerrorattribute<img>issrc="#"<iframe>javascript:/data:/vbscript:in href or src#Two token bugs against DESIGN.md
The base layer also hardcoded
background-color: #ffffff !important, overriding the token entirely — so the near-white canvas the brand depends on was never rendering. Added the tokens DESIGN.md specifies but the stylesheet never encoded: hairline, grid line, display tracking (-0.03em), eyebrow tracking (0.16em)..signal-gradientis a named utility on purpose — DESIGN.md permits the blue→pink gradient on exactly one line per view, and a named class makes a second occurrence visible in review.Diagrams are components, not SVG
Hand-positioned SVG text does not reflow, does not use the site fonts, is not selectable or translatable, and needs re-authoring whenever a token changes.
components/Diagrams.tsxuses the same CSS as the page. For OG cards, screenshot the component rather than maintaining a second artwork that drifts.Verification
Typecheck clean, production build clean, all 7 posts prerendered as static HTML, series ordering and prev/next resolved from the
partfield.Not verified: the visual result. Screenshots require the browser pane to be displayed, so I checked structure and content programmatically only. Worth a look at
next devbefore merging.Summary by Sourcery
Replace the bespoke, client-side blog page with a markdown-backed content pipeline and redesigned article/index layouts grounded in shared design tokens.
New Features:
Bug Fixes:
Enhancements:
Build:
Documentation:
Chores:
Summary by CodeRabbit