feat(pages): add blog section with i18n support#355
Conversation
Add a blog feature to the pages site including: - BlogPage component with list/detail views, tag filtering, search, and TOC - Blog content system with markdown posts and i18n (en/ja/zh) - Navbar integration with blog tab and improved active state detection - MarkdownRenderer image path handling for relative/absolute paths - Webpack CopyPlugin for serving static blog assets
|
🔍 OpenCodeReview found 10 issue(s) in this PR.
|
| const renderer = new Renderer(); | ||
| renderer.image = function ({ href, title, text }: { href: string; title?: string | null; text: string }) { | ||
| const escapeAttr = (s: string) => s.replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>'); | ||
| const src = href.startsWith('/') ? `${publicPath}${href}` : href; |
There was a problem hiding this comment.
Potential null pointer exception: In the marked library, the href parameter passed to the image renderer can be null or undefined at runtime (e.g., for malformed markdown like ![alt]()). Although the TypeScript type annotation declares it as string, this does not enforce runtime safety. Calling href.startsWith('/') when href is null/undefined will throw a TypeError.
Consider adding a null guard before accessing href.
Suggestion:
| const src = href.startsWith('/') ? `${publicPath}${href}` : href; | |
| const safeHref = href ?? ''; | |
| const src = safeHref.startsWith('/') ? `${publicPath}${safeHref}` : safeHref; |
| <div style={{ display: 'flex', alignItems: 'center', gap: 4 }}> | ||
| {navTabs.map((tab) => { | ||
| const isActive = currentPath === tab.path; | ||
| const isActive = tab.path === '/' ? currentPath === '/' : currentPath.startsWith(tab.path); |
There was a problem hiding this comment.
The startsWith check is fragile and could cause incorrect active state for tabs with overlapping prefixes. For example, if a future route like /docs-api or /benchmark-results is added, it would incorrectly highlight the /docs or /benchmark tab respectively.
A safer approach is to check that the current path either matches exactly or starts with the tab path followed by /:
Suggestion:
| const isActive = tab.path === '/' ? currentPath === '/' : currentPath.startsWith(tab.path); | |
| const isActive = currentPath === tab.path || currentPath.startsWith(tab.path + '/'); |
| .blog-card:hover { | ||
| background: rgba(255, 255, 255, 0.05); | ||
| border-color: rgba(255, 255, 255, 0.15); | ||
| } |
There was a problem hiding this comment.
The .blog-card has cursor: pointer indicating it's clickable, but no :focus or :focus-visible style is defined. In BlogPage.tsx, .blog-tag is rendered as a <button> element which is keyboard-focusable, but there's no visible focus indicator for it either. Consider adding focus styles for both interactive elements to support keyboard navigation.
For example:
.blog-tag:focus-visible {
outline: 2px solid #2BDE5E;
outline-offset: 2px;
}Note: .blog-card in BlogPage.tsx is a plain <div> with onClick — it won't receive keyboard focus at all. That component should use a <button> or add role="button" + tabIndex={0} + keyboard handlers.
| export function getAllBlogMetas(language: string): BlogMeta[] { | ||
| const slugs = Object.keys(blogMap.en) as BlogSlug[]; | ||
| return slugs | ||
| .map(slug => getBlogMeta(slug, language)) | ||
| .sort((a, b) => b.date.localeCompare(a.date)); | ||
| } |
There was a problem hiding this comment.
Inconsistent slug sources across functions: getAllBlogMetas always derives slugs from blogMap.en, while searchBlog derives slugs from blogMap[language]. This means:
- If a blog post exists only in a non-English language (e.g., zh), it will never appear in list views (
getAllBlogMetas) but could appear in search results (searchBlog). - Conversely, if a post exists in English but not in another language,
getAllBlogMetaswill list it for all languages, silently falling back to English content viagetRawContent.
Consider deriving slugs consistently — either always from the requested language's map, or by taking the union of all language maps — and documenting the fallback behavior.
| function stripFrontmatter(md: string): string { | ||
| if (md.startsWith('---')) { | ||
| const end = md.indexOf('---', 3); | ||
| if (end !== -1) { | ||
| return md.slice(end + 3).trim(); | ||
| } | ||
| } | ||
| return md; | ||
| } |
There was a problem hiding this comment.
Duplicated frontmatter boundary detection: Both stripFrontmatter and parseFrontmatter independently locate the frontmatter delimiters using the same indexOf('---', 3) logic. Consider extracting a shared helper (e.g., extractFrontmatter(raw): { frontmatter: string; body: string } | null) to avoid duplication and ensure both functions stay consistent if the parsing logic changes.
| const isDetailView = !!(slugParam && validSlugs.has(slugParam as BlogSlug)); | ||
| const activeSlug = isDetailView ? (slugParam as BlogSlug) : null; |
There was a problem hiding this comment.
Bug: No handling for invalid/missing blog slugs. When a user navigates to /blog/some-invalid-slug, isDetailView will be false (since the slug isn't in validSlugs), and the component silently falls through to the list view without any indication that the requested post was not found. This is confusing UX and masks routing issues.
Consider adding a 404/not-found state when slugParam is present but doesn't match a valid slug, or redirecting to /blog with an error message.
| const id = generateHeadingId(text); | ||
| headings.push({ id, text, level }); |
There was a problem hiding this comment.
Bug: Duplicate heading IDs possible. The generateHeadingId function does not handle duplicate heading texts. If a blog post has two headings with the same text (e.g., two ## Overview sections), both will get the same ID. This causes:
- The IntersectionObserver to track the wrong element.
- TOC click-to-scroll to jump to the first matching element instead of the intended one.
- Invalid HTML (duplicate
idattributes).
Consider appending a counter suffix for duplicates (e.g., overview-1, overview-2). Note that both extractHeadings here and the MarkdownRenderer heading renderer must use the same deduplication strategy to keep IDs consistent.
| > | ||
| <img src={searchIcon} alt="" style={{ width: 16, height: 16, opacity: 0.6 }} /> | ||
| <span>{t('blog.search.placeholder')}</span> | ||
| <span style={{ fontSize: 13, color: 'rgba(255,255,255,0.3)', fontFamily: '-apple-system, BlinkMacSystemFont, sans-serif', lineHeight: 1, marginLeft: 8 }}>{navigator.platform?.includes('Mac') ? '⌘K' : 'Ctrl+K'}</span> |
There was a problem hiding this comment.
Deprecated API: navigator.platform is deprecated. This property is unreliable on newer browsers/platforms (e.g., iPadOS reports as MacIntel, ChromeOS may return unexpected values). Consider using navigator.userAgentData?.platform (with fallback) or a simple userAgent check for more reliable platform detection.
| <button | ||
| onClick={() => navigate('/blog')} | ||
| style={{ | ||
| background: 'none', | ||
| border: 'none', | ||
| cursor: 'pointer', | ||
| padding: 0, | ||
| marginBottom: 24, | ||
| fontSize: 14, | ||
| color: 'rgba(255,255,255,0.5)', | ||
| fontFamily, | ||
| transition: 'color 0.2s', | ||
| }} |
There was a problem hiding this comment.
Inline styles overuse: Many of these inline styles are static (e.g., the back button, title, metadata container, prev/next buttons, TOC container). They should be extracted to CSS classes in blog.css for better maintainability, reusability, and consistency with the existing pattern used for .blog-card, .blog-tag, etc. Only truly dynamic styles (like conditional padding based on isMobile or active/hover color states) should remain inline.
| const handleKeyDown = (e: KeyboardEvent) => { | ||
| if ((e.metaKey || e.ctrlKey) && e.key === 'k') { | ||
| const activeEl = document.activeElement; | ||
| if (activeEl && (activeEl.tagName === 'INPUT' || activeEl.tagName === 'TEXTAREA') && !searchOpen) return; |
There was a problem hiding this comment.
Potential issue: Cmd+K toggle inconsistency. The guard condition !searchOpen in the INPUT/TEXTAREA check means that when the search modal IS open and focus is on the search input, pressing Cmd+K won't toggle it closed (because the early return fires). Users who expect Cmd+K to act as a toggle will need to use Escape instead. Consider removing the !searchOpen condition so Cmd+K consistently toggles the modal regardless of focus state.
Summary
Add a blog feature to the pages site:
pages/src/content/blog/index.tsstartsWithmatching for nested routescopy-webpack-pluginto serve static assets frompages/public/directoryIncludes an initial blog post "Introducing Open Code Review Blog" in all three languages.
Review notes
Code review flagged a few items worth addressing in follow-up:
publicPathcalculation in MarkdownRenderer may produce incorrect paths under multi-level routessearchBlogslug source inconsistency withgetAllBlogMetasnavigator.platformusage