Export as PDF, Core Web Vitals performance optimizations, Enhanced diff code block rendering and blog post example on codeblocks

This commit is contained in:
Wayne Sutton
2026-01-07 23:20:50 -08:00
parent 1257fa220f
commit cd696416d9
19 changed files with 1228 additions and 65 deletions

27
TASK.md
View File

@@ -4,10 +4,35 @@
## Current Status
v2.12.0 ready. Canonical URL fix for GitHub Issue #6 implemented.
v2.15.0 ready. Export as PDF feature added to CopyPageDropdown.
## Completed
- [x] Export as PDF option in CopyPageDropdown
- [x] Added browser print dialog for saving pages as PDF
- [x] Clean formatted output with markdown syntax stripped
- [x] Title as heading, metadata on single line, readable content
- [x] Uses Phosphor FilePdf icon (already installed)
- [x] Positioned at end of dropdown menu
- [x] Added formatForPrint function and handleExportPDF handler
- [x] Updated files.md, changelog.md, task.md documentation
- [x] Core Web Vitals performance optimizations
- [x] Fixed non-composited animations in visitor map (SVG r to transform: scale)
- [x] Removed 5 duplicate @keyframes spin definitions
- [x] Added will-change hints to animated elements
- [x] Inlined critical CSS in index.html for faster first paint
- [x] Added preconnect hints for convex.site
- [x] Enhanced diff code block rendering with @pierre/diffs
- [x] Added @pierre/diffs package for Shiki-based diff visualization
- [x] Created DiffCodeBlock component with unified/split view toggle
- [x] Updated BlogPost.tsx to route diff/patch blocks to new renderer
- [x] Added theme-aware CSS styles for diff blocks
- [x] Added vendor-diffs chunk to Vite config for code splitting
- [x] Created "How to Use Code Blocks" blog post with examples
- [x] Updated files.md with DiffCodeBlock documentation
- [x] Canonical URL mismatch fix (GitHub Issue #6)
- [x] Raw HTML was serving homepage canonical instead of page-specific canonical
- [x] Added SEARCH_ENGINE_BOTS array to botMeta.ts for search engine crawler detection

View File

@@ -4,6 +4,77 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
## [2.15.0] - 2026-01-07
### Added
- Export as PDF option in CopyPageDropdown
- Browser-based print dialog for saving pages as PDF
- Clean formatted output (no markdown syntax visible)
- Title displayed as proper heading
- Metadata shown as clean line (date, read time, tags)
- Content with markdown stripped for readable document
- Uses Phosphor FilePdf icon
- Positioned at end of dropdown menu
### Technical
- Added `formatForPrint` function to strip markdown syntax from content
- Added `handleExportPDF` handler with styled print window
- Imports `FilePdf` from `@phosphor-icons/react` (already installed)
## [2.14.0] - 2026-01-07
### Fixed
- Core Web Vitals performance optimizations
- Fixed non-composited animations in visitor map (SVG `r` attribute changed to `transform: scale()`)
- Removed 5 duplicate `@keyframes spin` definitions from global.css
- Added `will-change` hints to animated elements for GPU compositing
### Added
- Critical CSS inlined in index.html for faster first paint
- Theme variables (dark/light/tan/cloud)
- Reset and base body styles
- Layout skeleton and navigation styles
- Additional resource hints in index.html
- Preconnect to convex.site for faster API calls
### Technical
- Updated `src/styles/global.css`:
- Converted visitor-pulse animations from SVG `r` to `transform: scale()` (GPU-composited)
- Added `transform-origin`, `transform-box`, and `will-change` to pulse ring elements
- Added `will-change` to `.theme-toggle`, `.copy-page-menu`, `.search-modal-backdrop`, `.scroll-to-top`
- Removed duplicate `@keyframes spin` at lines 9194, 10091, 10243, 10651, 13726
- Updated `src/components/VisitorMap.tsx`:
- Changed pulse ring `r` values from 12/8 to base value 5 (scaling handled by CSS)
- Updated `index.html`:
- Added inline critical CSS (~2KB) for instant first contentful paint
- Added preconnect/dns-prefetch for convex.site
## [2.13.0] - 2026-01-07
### Added
- Enhanced diff code block rendering with @pierre/diffs library
- Diff and patch code blocks now render with Shiki-based syntax highlighting
- Unified and split (side-by-side) view modes with toggle button
- Theme-aware colors (dark/light/tan/cloud support)
- Copy button for diff content
- Automatic routing: ```diff and ```patch blocks use enhanced renderer
- New blog post: "How to Use Code Blocks" with syntax highlighting and diff examples
- DiffCodeBlock component (`src/components/DiffCodeBlock.tsx`)
### Technical
- Added `@pierre/diffs` package for enhanced diff visualization
- Updated `BlogPost.tsx` to route diff/patch language blocks to DiffCodeBlock
- Added diff block CSS styles to `global.css`
- Added `vendor-diffs` chunk to Vite config for code splitting
- Updated `files.md` with DiffCodeBlock documentation
## [2.12.0] - 2026-01-07
### Fixed

View File

@@ -0,0 +1,222 @@
---
title: "How to Use Code Blocks"
description: "A guide to syntax highlighting, diff rendering, and code formatting in your markdown posts."
date: "2026-01-07"
slug: "how-to-use-code-blocks"
published: true
tags: ["tutorial", "markdown", "code", "syntax-highlighting"]
readTime: "4 min read"
featured: false
authorName: "Markdown"
authorImage: "/images/authors/markdown.png"
excerpt: "Learn how to add syntax-highlighted code blocks and enhanced diff views to your posts."
docsSection: true
docsSectionGroup: "Publishing"
docsSectionOrder: 4
docsSectionGroupOrder: 2
---
# How to Use Code Blocks
Code blocks are essential for technical writing. This guide covers standard syntax highlighting and enhanced diff rendering.
## Basic code blocks
Wrap code in triple backticks with a language identifier:
````markdown
```javascript
function greet(name) {
return `Hello, ${name}!`;
}
```
````
This renders with syntax highlighting:
```javascript
function greet(name) {
return `Hello, ${name}!`;
}
```
## Supported languages
Common languages with syntax highlighting:
| Language | Identifier |
| ---------- | -------------------- |
| JavaScript | `javascript` or `js` |
| TypeScript | `typescript` or `ts` |
| Python | `python` or `py` |
| Bash | `bash` or `shell` |
| JSON | `json` |
| CSS | `css` |
| SQL | `sql` |
| Go | `go` |
| Rust | `rust` |
| Markdown | `markdown` or `md` |
## Code block features
Every code block includes:
- **Language label** in the top right corner
- **Copy button** that appears on hover
- **Theme-aware colors** matching your selected theme
## Diff code blocks
For showing code changes, use the `diff` or `patch` language identifier. These render with enhanced diff visualization powered by @pierre/diffs.
### Basic diff example
````markdown
```diff
--- a/config.js
+++ b/config.js
@@ -1,5 +1,5 @@
const config = {
- debug: true,
+ debug: false,
port: 3000
};
```
````
This renders as:
```diff
--- a/config.js
+++ b/config.js
@@ -1,5 +1,5 @@
const config = {
- debug: true,
+ debug: false,
port: 3000
};
```
### Multi-line changes
```diff
--- a/utils.ts
+++ b/utils.ts
@@ -10,12 +10,15 @@
export function formatDate(date: Date): string {
- return date.toLocaleDateString();
+ return date.toLocaleDateString('en-US', {
+ year: 'numeric',
+ month: 'long',
+ day: 'numeric'
+ });
}
export function parseDate(str: string): Date {
- return new Date(str);
+ const parsed = new Date(str);
+ if (isNaN(parsed.getTime())) {
+ throw new Error('Invalid date string');
+ }
+ return parsed;
}
```
### Diff view modes
Diff blocks include a view toggle button:
- **Unified view** (default): Shows changes in a single column with +/- indicators
- **Split view**: Shows old and new code side by side
Click the toggle button in the diff header to switch between views.
## Adding new functions
```diff
--- a/api.ts
+++ b/api.ts
@@ -5,6 +5,14 @@ export async function fetchUser(id: string) {
return response.json();
}
+export async function updateUser(id: string, data: UserUpdate) {
+ const response = await fetch(`/api/users/${id}`, {
+ method: 'PATCH',
+ body: JSON.stringify(data)
+ });
+ return response.json();
+}
+
export async function deleteUser(id: string) {
return fetch(`/api/users/${id}`, { method: 'DELETE' });
}
```
## Removing code
```diff
--- a/legacy.js
+++ b/legacy.js
@@ -1,15 +1,8 @@
const express = require('express');
const app = express();
-// Old middleware - no longer needed
-app.use((req, res, next) => {
- console.log('Request:', req.method, req.url);
- next();
-});
-
app.get('/', (req, res) => {
res.send('Hello World');
});
-// Deprecated route
-app.get('/old', (req, res) => res.redirect('/'));
-
app.listen(3000);
```
## Inline code
For inline code, use single backticks:
```markdown
Run `npm install` to install dependencies.
```
Renders as: Run `npm install` to install dependencies.
Inline code is detected automatically when the content is short (under 80 characters) and has no newlines.
## Plain text blocks
Code blocks without a language identifier render as plain text with word wrapping:
```
This is a plain text block. It wraps long lines automatically
instead of requiring horizontal scrolling. Useful for logs,
output, or any text that isn't code.
```
## Tips
**Choose the right language**: Use the correct language identifier for accurate highlighting. TypeScript files should use `typescript`, not `javascript`.
**Keep examples focused**: Show only the relevant code. Long blocks lose readers.
**Use diffs for changes**: When explaining modifications to existing code, diff blocks clearly show what changed.
**Test your blocks**: Preview your post to verify syntax highlighting works correctly.
## Summary
| Block type | Use case |
| ------------------ | ---------------------------------------------- |
| Regular code block | Showing code snippets with syntax highlighting |
| Diff block | Showing code changes with additions/deletions |
| Plain text block | Logs, output, or non-code text |
| Inline code | Commands, function names, short references |
Code blocks make technical content readable. Use the right format for your content type.

View File

@@ -11,6 +11,94 @@ docsSectionOrder: 4
All notable changes to this project.
## v2.15.0
Released January 7, 2026
**Export as PDF**
Added PDF export option to CopyPageDropdown. Users can now export any blog post or page as a clean, formatted PDF document using the browser's native print dialog.
**Features:**
- Export as PDF button in Copy page dropdown (positioned at end of menu)
- Clean formatted output without markdown syntax
- Title displayed as proper heading
- Metadata shown on single line (date, read time, tags)
- Content with markdown stripped for readable document
- Uses Phosphor FilePdf icon
**Technical:**
- Added `formatForPrint` function to strip markdown syntax (headings, bold, italic, code, links, blockquotes)
- Added `handleExportPDF` handler that opens styled print window
- Imports `FilePdf` from `@phosphor-icons/react` (already installed in project)
**Files changed:**
- `src/components/CopyPageDropdown.tsx` - New PDF export functionality
---
## v2.14.0
Released January 7, 2026
**Core Web Vitals performance optimizations**
Fixes for PageSpeed Insights failures on mobile and desktop. These changes improve Largest Contentful Paint (LCP) and eliminate non-composited animation warnings.
**Fixes:**
- Non-composited animations: Visitor map pulse animations now use GPU-composited `transform: scale()` instead of animating SVG `r` attribute
- Duplicate keyframes: Removed 5 redundant `@keyframes spin` definitions from CSS
- GPU compositing hints: Added `will-change` to animated elements (theme toggle, dropdown menus, modals, scroll-to-top button)
**Performance additions:**
- Critical CSS inlined in index.html (~2KB) for instant first paint
- Theme variables, reset styles, layout skeleton, and navigation pre-loaded
- Additional preconnect hints for Convex site endpoints
**Files changed:**
- `src/styles/global.css` - Animation fixes, will-change hints, removed duplicates
- `src/components/VisitorMap.tsx` - Updated SVG circle radius for transform-based animation
- `index.html` - Inline critical CSS, resource hints
---
## v2.13.0
Released January 7, 2026
**Enhanced diff code block rendering**
Diff and patch code blocks now render with enhanced visualization powered by @pierre/diffs. This brings Shiki-based syntax highlighting specifically designed for showing code changes.
**Features:**
- Unified view (default): Single column with +/- indicators
- Split view: Side-by-side comparison of old and new code
- View toggle button to switch between modes
- Theme-aware colors matching dark/light/tan/cloud themes
- Copy button for copying raw diff content
- Automatic routing: Use ```diff or ```patch in markdown
**New documentation:**
- Blog post: "How to Use Code Blocks" with examples of regular code blocks and diff rendering
**Technical:**
- Added `@pierre/diffs` package
- Created `DiffCodeBlock` component (`src/components/DiffCodeBlock.tsx`)
- Updated `BlogPost.tsx` to route diff/patch blocks to new renderer
- Added diff block CSS styles to `global.css`
- Added `vendor-diffs` chunk to Vite config
---
## v2.12.0
Released January 7, 2026

View File

@@ -9,7 +9,7 @@ A brief description of each file in the codebase.
| `package.json` | Dependencies and scripts for the blog |
| `tsconfig.json` | TypeScript configuration |
| `vite.config.ts` | Vite bundler configuration |
| `index.html` | Main HTML entry with SEO meta tags and JSON-LD |
| `index.html` | Main HTML entry with SEO meta tags, JSON-LD, critical CSS inline, and resource hints |
| `netlify.toml` | Netlify deployment and Convex HTTP redirects |
| `README.md` | Project documentation |
| `AGENTS.md` | AI coding agent instructions (agents.md spec) |
@@ -60,8 +60,9 @@ A brief description of each file in the codebase.
| `ThemeToggle.tsx` | Theme switcher (dark/light/tan/cloud) |
| `PostList.tsx` | Year-grouped blog post list or card grid (supports list/cards view modes, columns prop for 2/3 column grids, showExcerpts prop to control excerpt visibility) |
| `BlogHeroCard.tsx` | Hero card component for the first blogFeatured post on blog page. Displays landscape image, tags, date, title, excerpt, author info, and read more link |
| `BlogPost.tsx` | Markdown renderer with syntax highlighting, collapsible sections (details/summary), text wrapping for plain text code blocks, image lightbox support (click images to magnify in full-screen overlay), and iframe embed support with domain whitelisting (YouTube and Twitter/X only). SEO: H1 headings in markdown demoted to H2 (`.blog-h1-demoted` class) for single H1 per page compliance. |
| `CopyPageDropdown.tsx` | Share dropdown with Copy page (markdown to clipboard), View as Markdown (opens raw .md file), Download as SKILL.md (Anthropic Agent Skills format), and Open in AI links (ChatGPT, Claude, Perplexity) using local /raw URLs with simplified prompt |
| `BlogPost.tsx` | Markdown renderer with syntax highlighting, collapsible sections (details/summary), text wrapping for plain text code blocks, image lightbox support (click images to magnify in full-screen overlay), and iframe embed support with domain whitelisting (YouTube and Twitter/X only). Routes diff/patch code blocks to DiffCodeBlock for enhanced diff rendering. SEO: H1 headings in markdown demoted to H2 (`.blog-h1-demoted` class) for single H1 per page compliance. |
| `DiffCodeBlock.tsx` | Enhanced diff/patch code block renderer using @pierre/diffs library. Supports unified and split (side-by-side) view modes with toggle button. Theme-aware (dark/light) with copy button. Used automatically for ```diff and ```patch code blocks in markdown. |
| `CopyPageDropdown.tsx` | Share dropdown with Copy page (markdown to clipboard), View as Markdown (opens raw .md file), Download as SKILL.md (Anthropic Agent Skills format), Open in AI links (ChatGPT, Claude, Perplexity) using local /raw URLs, and Export as PDF (browser print with clean formatting) |
| `Footer.tsx` | Footer component that renders markdown content from frontmatter footer field or siteConfig.defaultContent. Can be enabled/disabled globally and per-page via frontmatter showFooter field. Renders inside article at bottom for posts/pages, and in current position on homepage. Supports images with size control via HTML attributes (width, height, style, class) |
| `SearchModal.tsx` | Full text search modal with keyboard navigation. Supports keyword and semantic search modes (toggle with Tab). Semantic mode conditionally shown when `siteConfig.semanticSearch.enabled: true`. When semantic disabled (default), shows keyword search only without mode toggle. |
| `FeaturedCards.tsx` | Card grid for featured posts/pages with excerpts |
@@ -69,7 +70,7 @@ A brief description of each file in the codebase.
| `MobileMenu.tsx` | Slide-out drawer menu for mobile navigation with hamburger button, includes sidebar table of contents when page has sidebar layout |
| `ScrollToTop.tsx` | Configurable scroll-to-top button with Phosphor ArrowUp icon |
| `GitHubContributions.tsx` | GitHub activity graph with theme-aware colors and year navigation |
| `VisitorMap.tsx` | Real-time visitor location map with dotted world display and theme-aware colors |
| `VisitorMap.tsx` | Real-time visitor location map with dotted world display, theme-aware colors, and GPU-composited pulse animations using transform: scale() |
| `PageSidebar.tsx` | Collapsible table of contents sidebar for pages/posts with sidebar layout, extracts headings (H1-H6), active heading highlighting, smooth scroll navigation, localStorage persistence for expanded/collapsed state |
| `RightSidebar.tsx` | Right sidebar component that displays CopyPageDropdown or AI chat on posts/pages at 1135px+ viewport width, controlled by siteConfig.rightSidebar.enabled and frontmatter rightSidebar/aiChat fields |
| `AIChatView.tsx` | AI chat interface component (Agent) using Anthropic Claude API. Supports per-page chat history, page content context, markdown rendering, and copy functionality. Used in Write page (replaces textarea when enabled) and optionally in RightSidebar. Requires ANTHROPIC_API_KEY environment variable in Convex. System prompt configurable via CLAUDE_PROMPT_STYLE, CLAUDE_PROMPT_COMMUNITY, CLAUDE_PROMPT_RULES, or CLAUDE_SYSTEM_PROMPT environment variables. Includes error handling for missing API keys. |
@@ -104,7 +105,7 @@ A brief description of each file in the codebase.
| File | Description |
| ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `global.css` | Global CSS with theme variables, centralized font-size CSS variables for all themes, sidebar styling with alternate background colors, hidden scrollbar, and consistent borders using box-shadow for docs-style layout. Left sidebar (`.post-sidebar-wrapper`) and right sidebar (`.post-sidebar-right`) have separate, independent styles. Footer image styles (`.site-footer-image-wrapper`, `.site-footer-image`, `.site-footer-image-caption`) for responsive image display. Write page layout uses viewport height constraints (100vh) with overflow hidden to prevent page scroll, and AI chat uses flexbox with min-height: 0 for proper scrollable message area. Image lightbox styles (`.image-lightbox-backdrop`, `.image-lightbox-img`, `.image-lightbox-close`, `.image-lightbox-caption`) for full-screen image magnification with backdrop, close button, and caption display. SEO: `.blog-h1-demoted` class for demoted H1s (semantic H2 with H1 styling), CSS `order` properties for article/sidebar DOM order optimization |
| `global.css` | Global CSS with theme variables, centralized font-size CSS variables for all themes, sidebar styling with alternate background colors, hidden scrollbar, and consistent borders using box-shadow for docs-style layout. Left sidebar (`.post-sidebar-wrapper`) and right sidebar (`.post-sidebar-right`) have separate, independent styles. Footer image styles (`.site-footer-image-wrapper`, `.site-footer-image`, `.site-footer-image-caption`) for responsive image display. Write page layout uses viewport height constraints (100vh) with overflow hidden to prevent page scroll, and AI chat uses flexbox with min-height: 0 for proper scrollable message area. Image lightbox styles (`.image-lightbox-backdrop`, `.image-lightbox-img`, `.image-lightbox-close`, `.image-lightbox-caption`) for full-screen image magnification with backdrop, close button, and caption display. SEO: `.blog-h1-demoted` class for demoted H1s (semantic H2 with H1 styling), CSS `order` properties for article/sidebar DOM order optimization. Core Web Vitals: GPU-composited visitor-pulse animations with `transform: scale()`, `will-change` hints on animated elements (theme-toggle, copy-page-menu, search-modal-backdrop, scroll-to-top) |
## Convex Backend (`convex/`)

View File

@@ -7,6 +7,9 @@
<!-- Preconnect for faster API calls -->
<link rel="preconnect" href="https://convex.cloud" crossorigin />
<link rel="dns-prefetch" href="https://convex.cloud" />
<!-- Preconnect for Convex site endpoints -->
<link rel="preconnect" href="https://convex.site" crossorigin />
<link rel="dns-prefetch" href="https://convex.site" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<!-- SEO Meta Tags -->
@@ -31,6 +34,25 @@
<!-- Theme -->
<meta name="theme-color" content="#faf8f5" />
<!-- Critical CSS - inlined for fast first paint -->
<style>
/* Theme variables */
:root[data-theme="dark"]{--bg-primary:#111;--bg-secondary:#1a1a1a;--bg-hover:#252525;--text-primary:#fafafa;--text-secondary:#a1a1a1;--text-muted:#6b6b6b;--border-color:#2a2a2a;--accent:#fafafa}
:root[data-theme="light"]{--bg-primary:#fff;--bg-secondary:#fafafa;--bg-hover:#f5f5f5;--text-primary:#111;--text-secondary:#6b6b6b;--text-muted:#a1a1a1;--border-color:#e5e5e5;--accent:#111}
:root[data-theme="tan"]{--bg-primary:#faf8f5;--bg-secondary:#f5f3f0;--bg-hover:#ebe9e6;--text-primary:#1a1a1a;--text-secondary:#6b6b6b;--text-muted:#999;--border-color:#e6e4e1;--accent:#8b7355}
:root[data-theme="cloud"]{--bg-primary:#f5f5f5;--bg-secondary:#ebebeb;--bg-hover:#e0e0e0;--text-primary:#171717;--text-secondary:#525252;--text-muted:#737373;--border-color:#d4d4d4;--accent:#171717}
/* Reset and base */
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
html{font-size:16px;-webkit-font-smoothing:antialiased}
body{font-family:"New York",-apple-system-ui-serif,ui-serif,Georgia,serif;background-color:var(--bg-primary);color:var(--text-primary);line-height:1.6}
/* Layout skeleton */
.layout{min-height:100vh;display:flex;flex-direction:column;padding-top:60px}
.main-content{flex:1;max-width:800px;width:100%;margin:0 auto}
/* Fixed nav */
.top-nav{position:fixed;top:0;left:13px;right:13px;z-index:100;display:flex;align-items:center;gap:16px;background-color:var(--bg-primary);padding:8px 12px;border-radius:8px}
@media(max-width:1024px){.layout{padding:60px 15px 15px}}
</style>
<!-- Theme initialization - runs before any rendering to prevent FOUC -->
<script>
(function() {

183
package-lock.json generated
View File

@@ -16,6 +16,7 @@
"@mendable/firecrawl-js": "^1.21.1",
"@modelcontextprotocol/sdk": "^1.0.0",
"@phosphor-icons/react": "^2.1.10",
"@pierre/diffs": "^1.0.4",
"@radix-ui/react-icons": "^1.3.2",
"@workos-inc/authkit-react": "^0.15.0",
"@workos-inc/widgets": "^1.6.1",
@@ -1355,6 +1356,25 @@
"react-dom": ">= 16.8"
}
},
"node_modules/@pierre/diffs": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@pierre/diffs/-/diffs-1.0.4.tgz",
"integrity": "sha512-jH0uzP3syMFcz0rp4uDDGTkV8sbMeh0W8Gq+e3P1rIiJ+gdGISfG+Qhwpe//W2W5Ac9G5vBAbMflywz+lLaJlQ==",
"license": "apache-2.0",
"dependencies": {
"@shikijs/core": "^3.0.0",
"@shikijs/engine-javascript": "^3.0.0",
"@shikijs/transformers": "^3.0.0",
"diff": "8.0.2",
"hast-util-to-html": "9.0.5",
"lru_map": "0.4.1",
"shiki": "^3.0.0"
},
"peerDependencies": {
"react": "^18.3.1 || ^19.0.0",
"react-dom": "^18.3.1 || ^19.0.0"
}
},
"node_modules/@pkgjs/parseargs": {
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
@@ -3415,6 +3435,83 @@
"win32"
]
},
"node_modules/@shikijs/core": {
"version": "3.21.0",
"resolved": "https://registry.npmjs.org/@shikijs/core/-/core-3.21.0.tgz",
"integrity": "sha512-AXSQu/2n1UIQekY8euBJlvFYZIw0PHY63jUzGbrOma4wPxzznJXTXkri+QcHeBNaFxiiOljKxxJkVSoB3PjbyA==",
"license": "MIT",
"dependencies": {
"@shikijs/types": "3.21.0",
"@shikijs/vscode-textmate": "^10.0.2",
"@types/hast": "^3.0.4",
"hast-util-to-html": "^9.0.5"
}
},
"node_modules/@shikijs/engine-javascript": {
"version": "3.21.0",
"resolved": "https://registry.npmjs.org/@shikijs/engine-javascript/-/engine-javascript-3.21.0.tgz",
"integrity": "sha512-ATwv86xlbmfD9n9gKRiwuPpWgPENAWCLwYCGz9ugTJlsO2kOzhOkvoyV/UD+tJ0uT7YRyD530x6ugNSffmvIiQ==",
"license": "MIT",
"dependencies": {
"@shikijs/types": "3.21.0",
"@shikijs/vscode-textmate": "^10.0.2",
"oniguruma-to-es": "^4.3.4"
}
},
"node_modules/@shikijs/engine-oniguruma": {
"version": "3.21.0",
"resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-3.21.0.tgz",
"integrity": "sha512-OYknTCct6qiwpQDqDdf3iedRdzj6hFlOPv5hMvI+hkWfCKs5mlJ4TXziBG9nyabLwGulrUjHiCq3xCspSzErYQ==",
"license": "MIT",
"dependencies": {
"@shikijs/types": "3.21.0",
"@shikijs/vscode-textmate": "^10.0.2"
}
},
"node_modules/@shikijs/langs": {
"version": "3.21.0",
"resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-3.21.0.tgz",
"integrity": "sha512-g6mn5m+Y6GBJ4wxmBYqalK9Sp0CFkUqfNzUy2pJglUginz6ZpWbaWjDB4fbQ/8SHzFjYbtU6Ddlp1pc+PPNDVA==",
"license": "MIT",
"dependencies": {
"@shikijs/types": "3.21.0"
}
},
"node_modules/@shikijs/themes": {
"version": "3.21.0",
"resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-3.21.0.tgz",
"integrity": "sha512-BAE4cr9EDiZyYzwIHEk7JTBJ9CzlPuM4PchfcA5ao1dWXb25nv6hYsoDiBq2aZK9E3dlt3WB78uI96UESD+8Mw==",
"license": "MIT",
"dependencies": {
"@shikijs/types": "3.21.0"
}
},
"node_modules/@shikijs/transformers": {
"version": "3.21.0",
"resolved": "https://registry.npmjs.org/@shikijs/transformers/-/transformers-3.21.0.tgz",
"integrity": "sha512-CZwvCWWIiRRiFk9/JKzdEooakAP8mQDtBOQ1TKiCaS2E1bYtyBCOkUzS8akO34/7ufICQ29oeSfkb3tT5KtrhA==",
"license": "MIT",
"dependencies": {
"@shikijs/core": "3.21.0",
"@shikijs/types": "3.21.0"
}
},
"node_modules/@shikijs/types": {
"version": "3.21.0",
"resolved": "https://registry.npmjs.org/@shikijs/types/-/types-3.21.0.tgz",
"integrity": "sha512-zGrWOxZ0/+0ovPY7PvBU2gIS9tmhSUUt30jAcNV0Bq0gb2S98gwfjIs1vxlmH5zM7/4YxLamT6ChlqqAJmPPjA==",
"license": "MIT",
"dependencies": {
"@shikijs/vscode-textmate": "^10.0.2",
"@types/hast": "^3.0.4"
}
},
"node_modules/@shikijs/vscode-textmate": {
"version": "10.0.2",
"resolved": "https://registry.npmjs.org/@shikijs/vscode-textmate/-/vscode-textmate-10.0.2.tgz",
"integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==",
"license": "MIT"
},
"node_modules/@tanstack/query-core": {
"version": "5.90.15",
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.15.tgz",
@@ -6500,6 +6597,29 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/hast-util-to-html": {
"version": "9.0.5",
"resolved": "https://registry.npmjs.org/hast-util-to-html/-/hast-util-to-html-9.0.5.tgz",
"integrity": "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==",
"license": "MIT",
"dependencies": {
"@types/hast": "^3.0.0",
"@types/unist": "^3.0.0",
"ccount": "^2.0.0",
"comma-separated-tokens": "^2.0.0",
"hast-util-whitespace": "^3.0.0",
"html-void-elements": "^3.0.0",
"mdast-util-to-hast": "^13.0.0",
"property-information": "^7.0.0",
"space-separated-tokens": "^2.0.0",
"stringify-entities": "^4.0.0",
"zwitch": "^2.0.4"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/hast-util-to-jsx-runtime": {
"version": "2.3.6",
"resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz",
@@ -7248,6 +7368,12 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/lru_map": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/lru_map/-/lru_map-0.4.1.tgz",
"integrity": "sha512-I+lBvqMMFfqaV8CJCISjI3wbjmwVu/VyOoU7+qtu9d7ioW5klMgsTTiUOUp+DJvfTTzKXoPbyC6YfgkNcyPSOg==",
"license": "MIT"
},
"node_modules/lru-cache": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
@@ -8396,6 +8522,23 @@
"wrappy": "1"
}
},
"node_modules/oniguruma-parser": {
"version": "0.12.1",
"resolved": "https://registry.npmjs.org/oniguruma-parser/-/oniguruma-parser-0.12.1.tgz",
"integrity": "sha512-8Unqkvk1RYc6yq2WBYRj4hdnsAxVze8i7iPfQr8e4uSP3tRv0rpZcbGUDvxfQQcdwHt/e9PrMvGCsa8OqG9X3w==",
"license": "MIT"
},
"node_modules/oniguruma-to-es": {
"version": "4.3.4",
"resolved": "https://registry.npmjs.org/oniguruma-to-es/-/oniguruma-to-es-4.3.4.tgz",
"integrity": "sha512-3VhUGN3w2eYxnTzHn+ikMI+fp/96KoRSVK9/kMTcFqj1NRDh2IhQCKvYxDnWePKRXY/AqH+Fuiyb7VHSzBjHfA==",
"license": "MIT",
"dependencies": {
"oniguruma-parser": "^0.12.1",
"regex": "^6.0.1",
"regex-recursion": "^6.0.2"
}
},
"node_modules/openai": {
"version": "4.104.0",
"resolved": "https://registry.npmjs.org/openai/-/openai-4.104.0.tgz",
@@ -9303,6 +9446,30 @@
"node": ">=6"
}
},
"node_modules/regex": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/regex/-/regex-6.1.0.tgz",
"integrity": "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==",
"license": "MIT",
"dependencies": {
"regex-utilities": "^2.3.0"
}
},
"node_modules/regex-recursion": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/regex-recursion/-/regex-recursion-6.0.2.tgz",
"integrity": "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==",
"license": "MIT",
"dependencies": {
"regex-utilities": "^2.3.0"
}
},
"node_modules/regex-utilities": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/regex-utilities/-/regex-utilities-2.3.0.tgz",
"integrity": "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==",
"license": "MIT"
},
"node_modules/regexp.prototype.flags": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz",
@@ -9833,6 +10000,22 @@
"node": ">=8"
}
},
"node_modules/shiki": {
"version": "3.21.0",
"resolved": "https://registry.npmjs.org/shiki/-/shiki-3.21.0.tgz",
"integrity": "sha512-N65B/3bqL/TI2crrXr+4UivctrAGEjmsib5rPMMPpFp1xAx/w03v8WZ9RDDFYteXoEgY7qZ4HGgl5KBIu1153w==",
"license": "MIT",
"dependencies": {
"@shikijs/core": "3.21.0",
"@shikijs/engine-javascript": "3.21.0",
"@shikijs/engine-oniguruma": "3.21.0",
"@shikijs/langs": "3.21.0",
"@shikijs/themes": "3.21.0",
"@shikijs/types": "3.21.0",
"@shikijs/vscode-textmate": "^10.0.2",
"@types/hast": "^3.0.4"
}
},
"node_modules/showdown": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/showdown/-/showdown-2.1.0.tgz",

View File

@@ -35,6 +35,7 @@
"@mendable/firecrawl-js": "^1.21.1",
"@modelcontextprotocol/sdk": "^1.0.0",
"@phosphor-icons/react": "^2.1.10",
"@pierre/diffs": "^1.0.4",
"@radix-ui/react-icons": "^1.3.2",
"@workos-inc/authkit-react": "^0.15.0",
"@workos-inc/widgets": "^1.6.1",

View File

@@ -7,6 +7,94 @@ Date: 2026-01-08
All notable changes to this project.
## v2.15.0
Released January 7, 2026
**Export as PDF**
Added PDF export option to CopyPageDropdown. Users can now export any blog post or page as a clean, formatted PDF document using the browser's native print dialog.
**Features:**
- Export as PDF button in Copy page dropdown (positioned at end of menu)
- Clean formatted output without markdown syntax
- Title displayed as proper heading
- Metadata shown on single line (date, read time, tags)
- Content with markdown stripped for readable document
- Uses Phosphor FilePdf icon
**Technical:**
- Added `formatForPrint` function to strip markdown syntax (headings, bold, italic, code, links, blockquotes)
- Added `handleExportPDF` handler that opens styled print window
- Imports `FilePdf` from `@phosphor-icons/react` (already installed in project)
**Files changed:**
- `src/components/CopyPageDropdown.tsx` - New PDF export functionality
---
## v2.14.0
Released January 7, 2026
**Core Web Vitals performance optimizations**
Fixes for PageSpeed Insights failures on mobile and desktop. These changes improve Largest Contentful Paint (LCP) and eliminate non-composited animation warnings.
**Fixes:**
- Non-composited animations: Visitor map pulse animations now use GPU-composited `transform: scale()` instead of animating SVG `r` attribute
- Duplicate keyframes: Removed 5 redundant `@keyframes spin` definitions from CSS
- GPU compositing hints: Added `will-change` to animated elements (theme toggle, dropdown menus, modals, scroll-to-top button)
**Performance additions:**
- Critical CSS inlined in index.html (~2KB) for instant first paint
- Theme variables, reset styles, layout skeleton, and navigation pre-loaded
- Additional preconnect hints for Convex site endpoints
**Files changed:**
- `src/styles/global.css` - Animation fixes, will-change hints, removed duplicates
- `src/components/VisitorMap.tsx` - Updated SVG circle radius for transform-based animation
- `index.html` - Inline critical CSS, resource hints
---
## v2.13.0
Released January 7, 2026
**Enhanced diff code block rendering**
Diff and patch code blocks now render with enhanced visualization powered by @pierre/diffs. This brings Shiki-based syntax highlighting specifically designed for showing code changes.
**Features:**
- Unified view (default): Single column with +/- indicators
- Split view: Side-by-side comparison of old and new code
- View toggle button to switch between modes
- Theme-aware colors matching dark/light/tan/cloud themes
- Copy button for copying raw diff content
- Automatic routing: Use ```diff or ```patch in markdown
**New documentation:**
- Blog post: "How to Use Code Blocks" with examples of regular code blocks and diff rendering
**Technical:**
- Added `@pierre/diffs` package
- Created `DiffCodeBlock` component (`src/components/DiffCodeBlock.tsx`)
- Updated `BlogPost.tsx` to route diff/patch blocks to new renderer
- Added diff block CSS styles to `global.css`
- Added `vendor-diffs` chunk to Vite config
---
## v2.12.0
Released January 7, 2026

View File

@@ -0,0 +1,215 @@
# How to Use Code Blocks
> A guide to syntax highlighting, diff rendering, and code formatting in your markdown posts.
---
Type: post
Date: 2026-01-07
Reading time: 4 min read
Tags: tutorial, markdown, code, syntax-highlighting
---
# How to Use Code Blocks
Code blocks are essential for technical writing. This guide covers standard syntax highlighting and enhanced diff rendering.
## Basic code blocks
Wrap code in triple backticks with a language identifier:
````markdown
```javascript
function greet(name) {
return `Hello, ${name}!`;
}
```
````
This renders with syntax highlighting:
```javascript
function greet(name) {
return `Hello, ${name}!`;
}
```
## Supported languages
Common languages with syntax highlighting:
| Language | Identifier |
| ---------- | -------------------- |
| JavaScript | `javascript` or `js` |
| TypeScript | `typescript` or `ts` |
| Python | `python` or `py` |
| Bash | `bash` or `shell` |
| JSON | `json` |
| CSS | `css` |
| SQL | `sql` |
| Go | `go` |
| Rust | `rust` |
| Markdown | `markdown` or `md` |
## Code block features
Every code block includes:
- **Language label** in the top right corner
- **Copy button** that appears on hover
- **Theme-aware colors** matching your selected theme
## Diff code blocks
For showing code changes, use the `diff` or `patch` language identifier. These render with enhanced diff visualization powered by @pierre/diffs.
### Basic diff example
````markdown
```diff
--- a/config.js
+++ b/config.js
@@ -1,5 +1,5 @@
const config = {
- debug: true,
+ debug: false,
port: 3000
};
```
````
This renders as:
```diff
--- a/config.js
+++ b/config.js
@@ -1,5 +1,5 @@
const config = {
- debug: true,
+ debug: false,
port: 3000
};
```
### Multi-line changes
```diff
--- a/utils.ts
+++ b/utils.ts
@@ -10,12 +10,15 @@
export function formatDate(date: Date): string {
- return date.toLocaleDateString();
+ return date.toLocaleDateString('en-US', {
+ year: 'numeric',
+ month: 'long',
+ day: 'numeric'
+ });
}
export function parseDate(str: string): Date {
- return new Date(str);
+ const parsed = new Date(str);
+ if (isNaN(parsed.getTime())) {
+ throw new Error('Invalid date string');
+ }
+ return parsed;
}
```
### Diff view modes
Diff blocks include a view toggle button:
- **Unified view** (default): Shows changes in a single column with +/- indicators
- **Split view**: Shows old and new code side by side
Click the toggle button in the diff header to switch between views.
## Adding new functions
```diff
--- a/api.ts
+++ b/api.ts
@@ -5,6 +5,14 @@ export async function fetchUser(id: string) {
return response.json();
}
+export async function updateUser(id: string, data: UserUpdate) {
+ const response = await fetch(`/api/users/${id}`, {
+ method: 'PATCH',
+ body: JSON.stringify(data)
+ });
+ return response.json();
+}
+
export async function deleteUser(id: string) {
return fetch(`/api/users/${id}`, { method: 'DELETE' });
}
```
## Removing code
```diff
--- a/legacy.js
+++ b/legacy.js
@@ -1,15 +1,8 @@
const express = require('express');
const app = express();
-// Old middleware - no longer needed
-app.use((req, res, next) => {
- console.log('Request:', req.method, req.url);
- next();
-});
-
app.get('/', (req, res) => {
res.send('Hello World');
});
-// Deprecated route
-app.get('/old', (req, res) => res.redirect('/'));
-
app.listen(3000);
```
## Inline code
For inline code, use single backticks:
```markdown
Run `npm install` to install dependencies.
```
Renders as: Run `npm install` to install dependencies.
Inline code is detected automatically when the content is short (under 80 characters) and has no newlines.
## Plain text blocks
Code blocks without a language identifier render as plain text with word wrapping:
```
This is a plain text block. It wraps long lines automatically
instead of requiring horizontal scrolling. Useful for logs,
output, or any text that isn't code.
```
## Tips
**Choose the right language**: Use the correct language identifier for accurate highlighting. TypeScript files should use `typescript`, not `javascript`.
**Keep examples focused**: Show only the relevant code. Long blocks lose readers.
**Use diffs for changes**: When explaining modifications to existing code, diff blocks clearly show what changed.
**Test your blocks**: Preview your post to verify syntax highlighting works correctly.
## Summary
| Block type | Use case |
| ------------------ | ---------------------------------------------- |
| Regular code block | Showing code snippets with syntax highlighting |
| Diff block | Showing code changes with additions/deletions |
| Plain text block | Logs, output, or non-code text |
| Inline code | Commands, function names, short references |
Code blocks make technical content readable. Use the right format for your content type.

View File

@@ -30,8 +30,10 @@ agents. -->
---
## Blog Posts (18)
## Blog Posts (19)
- **[How to Use Code Blocks](/raw/how-to-use-code-blocks.md)** - A guide to syntax highlighting, diff rendering, and code formatting in your markdown posts.
- Date: 2026-01-07 | Reading time: 4 min read | Tags: tutorial, markdown, code, syntax-highlighting
- **[How I added WorkOS to my Convex app with Cursor](/raw/workos-with-convex-cursor.md)** - A timeline of adding WorkOS AuthKit authentication to my markdown blog dashboard using Cursor, prompt engineering, and vibe coding. From PRD import to published feature.
- Date: 2025-12-30 | Reading time: 8 min read | Tags: cursor, workos, convex, prompt-engineering, ai-coding
- **[How to setup WorkOS with Markdown Sync](/raw/how-to-setup-workos.md)** - Step-by-step guide to configure WorkOS AuthKit authentication for your markdown blog dashboard. WorkOS is optional and can be enabled in siteConfig.ts.
@@ -90,7 +92,7 @@ agents. -->
---
**Total Content:** 18 posts, 16 pages
**Total Content:** 19 posts, 16 pages
All content is available as raw markdown files at `/raw/{slug}.md`

View File

@@ -47,6 +47,7 @@ import { Copy, Check, X } from "lucide-react";
import { useTheme } from "../context/ThemeContext";
import NewsletterSignup from "./NewsletterSignup";
import ContactForm from "./ContactForm";
import DiffCodeBlock from "./DiffCodeBlock";
import siteConfig from "../config/siteConfig";
import { useSearchHighlighting } from "../hooks/useSearchHighlighting";
@@ -611,6 +612,17 @@ export default function BlogPost({
const codeString = String(children).replace(/\n$/, "");
const language = match ? match[1] : "text";
// Route diff/patch to DiffCodeBlock for enhanced diff rendering
if (language === "diff" || language === "patch") {
return (
<DiffCodeBlock
code={codeString}
language={language as "diff" | "patch"}
/>
);
}
const isTextBlock = language === "text";
// Custom styles for text blocks to enable wrapping
@@ -899,6 +911,17 @@ export default function BlogPost({
const codeString = String(children).replace(/\n$/, "");
const language = match ? match[1] : "text";
// Route diff/patch to DiffCodeBlock for enhanced diff rendering
if (language === "diff" || language === "patch") {
return (
<DiffCodeBlock
code={codeString}
language={language as "diff" | "patch"}
/>
);
}
const isTextBlock = language === "text";
// Custom styles for text blocks to enable wrapping

View File

@@ -7,6 +7,7 @@ import {
Download,
ExternalLink,
} from "lucide-react";
import { FilePdf } from "@phosphor-icons/react";
// Maximum URL length for query parameters (conservative limit)
const MAX_URL_LENGTH = 6000;
@@ -110,6 +111,38 @@ function formatAsSkill(props: CopyPageDropdownProps): string {
return skill;
}
// Format content for print/PDF export (clean, readable document)
function formatForPrint(props: CopyPageDropdownProps): {
title: string;
metadata: string[];
description: string;
content: string;
} {
const { title, content, description, date, tags, readTime } = props;
const metadata: string[] = [];
if (date) metadata.push(date);
if (readTime) metadata.push(readTime);
if (tags && tags.length > 0) metadata.push(tags.join(", "));
// Strip common markdown syntax for cleaner display
const cleanContent = content
.replace(/^#{1,6}\s+/gm, "") // Remove heading markers
.replace(/\*\*([^*]+)\*\*/g, "$1") // Bold to plain
.replace(/\*([^*]+)\*/g, "$1") // Italic to plain
.replace(/`([^`]+)`/g, "$1") // Inline code to plain
.replace(/^\s*[-*+]\s+/gm, "- ") // Normalize list markers
.replace(/^\s*>\s+/gm, "") // Remove blockquote markers
.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1"); // Links to text only
return {
title,
metadata,
description: description || "",
content: cleanContent,
};
}
// Check if URL length exceeds safe limits
function isUrlTooLong(url: string): boolean {
return url.length > MAX_URL_LENGTH;
@@ -280,6 +313,67 @@ export default function CopyPageDropdown(props: CopyPageDropdownProps) {
setTimeout(() => setIsOpen(false), 1500);
};
// Handle export as PDF (browser print dialog)
const handleExportPDF = () => {
const printData = formatForPrint(props);
const printWindow = window.open("", "_blank");
if (printWindow) {
const escapeHtml = (str: string) =>
str.replace(/</g, "&lt;").replace(/>/g, "&gt;");
printWindow.document.write(`
<!DOCTYPE html>
<html>
<head>
<title>${escapeHtml(printData.title)}</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
max-width: 700px;
margin: 0 auto;
padding: 40px 20px;
line-height: 1.7;
color: #1a1a1a;
}
h1 {
font-size: 28px;
margin-bottom: 8px;
font-weight: 600;
}
.metadata {
color: #666;
font-size: 14px;
margin-bottom: 16px;
}
.description {
font-size: 18px;
color: #444;
margin-bottom: 24px;
font-style: italic;
}
.content {
white-space: pre-wrap;
font-size: 16px;
}
@media print {
body { padding: 20px; }
}
</style>
</head>
<body>
<h1>${escapeHtml(printData.title)}</h1>
${printData.metadata.length > 0 ? `<div class="metadata">${printData.metadata.join(" | ")}</div>` : ""}
${printData.description ? `<div class="description">${escapeHtml(printData.description)}</div>` : ""}
<div class="content">${escapeHtml(printData.content)}</div>
</body>
</html>
`);
printWindow.document.close();
printWindow.print();
}
setIsOpen(false);
};
// Get feedback icon
const getFeedbackIcon = () => {
switch (feedback) {
@@ -515,6 +609,22 @@ export default function CopyPageDropdown(props: CopyPageDropdownProps) {
</span>
</div>
</button>
{/* Export as PDF option */}
<button
className="copy-page-item"
onClick={handleExportPDF}
role="menuitem"
tabIndex={0}
>
<FilePdf size={16} className="copy-page-icon" aria-hidden="true" />
<div className="copy-page-item-content">
<span className="copy-page-item-title">Export as PDF</span>
<span className="copy-page-item-desc">
Print or save as PDF
</span>
</div>
</button>
</div>
)}
</div>

View File

@@ -0,0 +1,74 @@
import { useState } from "react";
import { PatchDiff } from "@pierre/diffs/react";
import { Copy, Check, Columns2, AlignJustify } from "lucide-react";
import { useTheme } from "../context/ThemeContext";
// Map app themes to @pierre/diffs themeType
const THEME_MAP: Record<string, "dark" | "light"> = {
dark: "dark",
light: "light",
tan: "light",
cloud: "light",
};
interface DiffCodeBlockProps {
code: string;
language: "diff" | "patch";
}
export default function DiffCodeBlock({ code, language }: DiffCodeBlockProps) {
const { theme } = useTheme();
const [viewMode, setViewMode] = useState<"split" | "unified">("unified");
const [copied, setCopied] = useState(false);
// Get theme type for @pierre/diffs
const themeType = THEME_MAP[theme] || "dark";
const handleCopy = async () => {
await navigator.clipboard.writeText(code);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
return (
<div className="diff-block-wrapper" data-theme-type={themeType}>
<div className="diff-block-header">
<span className="diff-language">{language}</span>
<div className="diff-block-controls">
<button
className="diff-view-toggle"
onClick={() =>
setViewMode(viewMode === "split" ? "unified" : "split")
}
title={
viewMode === "split"
? "Switch to unified view"
: "Switch to split view"
}
>
{viewMode === "split" ? (
<AlignJustify size={14} />
) : (
<Columns2 size={14} />
)}
</button>
<button
className="diff-copy-button"
onClick={handleCopy}
aria-label={copied ? "Copied!" : "Copy code"}
title={copied ? "Copied!" : "Copy code"}
>
{copied ? <Check size={14} /> : <Copy size={14} />}
</button>
</div>
</div>
<PatchDiff
patch={code}
options={{
themeType,
diffStyle: viewMode,
}}
/>
</div>
);
}

View File

@@ -116,21 +116,21 @@ export default function VisitorMap({ locations, title }: VisitorMapProps) {
{/* Visitor location dots with pulse animation */}
{visitorDots.map((dot, i) => (
<g key={`visitor-${i}`}>
{/* Outer pulse ring */}
{/* Outer pulse ring - base r=5, scaled via CSS transform */}
<circle
cx={dot.x}
cy={dot.y}
r="12"
r="5"
fill="var(--visitor-map-dot)"
opacity="0"
className="visitor-pulse-ring"
style={{ animationDelay: `${i * 0.2}s` }}
/>
{/* Middle pulse ring */}
{/* Middle pulse ring - base r=5, scaled via CSS transform */}
<circle
cx={dot.x}
cy={dot.y}
r="8"
r="5"
fill="var(--visitor-map-dot)"
opacity="0.2"
className="visitor-pulse-ring-mid"

View File

@@ -455,6 +455,7 @@ body {
transition:
color 0.2s ease,
background-color 0.2s ease;
will-change: color, background-color;
}
.theme-toggle:hover {
@@ -820,6 +821,7 @@ body {
z-index: 1000;
overflow: hidden;
animation: dropdownFadeIn 0.15s ease;
will-change: transform, opacity;
}
@keyframes spin {
@@ -1800,6 +1802,79 @@ body {
transform: scale(0.95);
}
/* Diff block styles for @pierre/diffs */
.diff-block-wrapper {
position: relative;
margin: 24px 0;
border-radius: 8px;
overflow: hidden;
border: 1px solid var(--border-color);
}
.diff-block-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border-color);
}
.diff-language {
font-size: var(--font-size-code-language);
font-weight: 500;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.diff-block-controls {
display: flex;
align-items: center;
gap: 8px;
}
.diff-view-toggle,
.diff-copy-button {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
padding: 0;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 6px;
color: var(--text-muted);
cursor: pointer;
transition:
background 0.15s ease,
color 0.15s ease;
}
.diff-view-toggle:hover,
.diff-copy-button:hover {
background: var(--bg-primary);
color: var(--text-primary);
border-color: var(--text-muted);
}
.diff-view-toggle:active,
.diff-copy-button:active {
transform: scale(0.95);
}
/* Theme-specific styles for @pierre/diffs */
.diff-block-wrapper[data-theme-type="dark"] {
--diffs-bg-color: #1e1e1e;
--diffs-fg-color: #d4d4d4;
}
.diff-block-wrapper[data-theme-type="light"] {
--diffs-bg-color: #ffffff;
--diffs-fg-color: #24292e;
}
/* Image styles */
.blog-image-wrapper {
display: block;
@@ -3422,6 +3497,7 @@ body {
justify-content: center;
padding-top: 15vh;
animation: backdropFadeIn 0.15s ease;
will-change: opacity;
}
@keyframes backdropFadeIn {
@@ -4376,44 +4452,50 @@ body {
max-height: 300px;
}
/* Pulse animation for visitor dots */
/* Pulse animation for visitor dots - using transform for GPU compositing */
@keyframes visitor-pulse {
0% {
r: 5;
transform: scale(1);
opacity: 0.6;
}
50% {
r: 18;
transform: scale(3.6);
opacity: 0;
}
100% {
r: 5;
transform: scale(1);
opacity: 0;
}
}
@keyframes visitor-pulse-mid {
0% {
r: 5;
transform: scale(1);
opacity: 0.3;
}
50% {
r: 12;
transform: scale(2.4);
opacity: 0;
}
100% {
r: 5;
transform: scale(1);
opacity: 0;
}
}
.visitor-pulse-ring {
animation: visitor-pulse 2.5s ease-out infinite;
transform-origin: center;
transform-box: fill-box;
will-change: transform, opacity;
}
.visitor-pulse-ring-mid {
animation: visitor-pulse-mid 2.5s ease-out infinite;
animation-delay: 0.3s;
transform-origin: center;
transform-box: fill-box;
will-change: transform, opacity;
}
.visitor-dot-center {
@@ -5266,6 +5348,7 @@ body {
z-index: 100;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
animation: scrollToTopFadeIn 0.2s ease;
will-change: transform, opacity;
}
@keyframes scrollToTopFadeIn {
@@ -9185,15 +9268,6 @@ body {
animation: spin 1s linear infinite;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.dashboard-editor-container {
display: flex;
flex: 1;
@@ -10082,15 +10156,6 @@ body {
animation: spin 1s linear infinite;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
/* AI Image Input Container */
.ai-image-input-container {
display: flex;
@@ -10234,15 +10299,6 @@ body {
animation: spin 1s linear infinite;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.dashboard-import-result {
padding: 1rem;
border-radius: 6px;
@@ -10641,16 +10697,6 @@ body {
border-color: var(--border-color);
}
/* Spinning animation for sync icon */
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.spinning {
animation: spin 1s linear infinite;
}
@@ -13717,15 +13763,6 @@ body {
animation: spin 1s linear infinite;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
/* Streaming cursor */
.ask-ai-cursor {
animation: blink 1s steps(1) infinite;

View File

@@ -26,6 +26,7 @@ export default defineConfig(({ mode }) => {
"rehype-sanitize",
],
"vendor-syntax": ["react-syntax-highlighter"],
"vendor-diffs": ["@pierre/diffs"],
},
},
},