feat: dynamic day-of-month favicon using subprocess pattern
- Create scripts/generateDayIcon.tsx for on-demand icon generation - Route handler calls script via execSync (avoids Next.js react-dom/server restriction) - Implement in-memory caching to minimize subprocess overhead - Show current day (01-31) on 2-column abacus in US Central Time - High-contrast design: blue/green beads, 2px strokes, gold border - Document SSR pattern in .claude/CLAUDE.md for future reference Fixes the Next.js limitation where route handlers cannot import react-dom/server by using subprocess pattern - scripts CAN use react-dom/server, route handlers call them via execSync and cache the results. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -194,6 +194,50 @@ When creating ANY new HTML/JSX element (div, button, section, etc.), add appropr
|
||||
- ✅ Use `useAbacusConfig` for abacus configuration
|
||||
- ✅ Use `useAbacusDisplay` for reading abacus state
|
||||
|
||||
**Server-Side Rendering (CRITICAL):**
|
||||
|
||||
`AbacusReact` already supports server-side rendering - it detects SSR and disables animations automatically.
|
||||
|
||||
**✅ CORRECT - Use in build scripts:**
|
||||
```typescript
|
||||
// scripts/generateAbacusIcons.tsx
|
||||
import React from 'react'
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { AbacusReact } from '@soroban/abacus-react'
|
||||
|
||||
const svg = renderToStaticMarkup(<AbacusReact value={5} columns={2} />)
|
||||
// This works! Scripts can use react-dom/server
|
||||
```
|
||||
|
||||
**❌ WRONG - Do NOT use in Next.js route handlers:**
|
||||
```typescript
|
||||
// src/app/icon/route.tsx - DON'T DO THIS!
|
||||
import { renderToStaticMarkup } from 'react-dom/server' // ❌ Next.js forbids this!
|
||||
import { AbacusReact } from '@soroban/abacus-react'
|
||||
|
||||
export async function GET() {
|
||||
const svg = renderToStaticMarkup(<AbacusReact ... />) // ❌ Will fail!
|
||||
}
|
||||
```
|
||||
|
||||
**✅ CORRECT - Pre-generate and read in route handlers:**
|
||||
```typescript
|
||||
// src/app/icon/route.tsx
|
||||
import { readFileSync } from 'fs'
|
||||
|
||||
export async function GET() {
|
||||
// Read pre-generated SVG from scripts/generateAbacusIcons.tsx
|
||||
const svg = readFileSync('public/icons/day-01.svg', 'utf-8')
|
||||
return new Response(svg, { headers: { 'Content-Type': 'image/svg+xml' } })
|
||||
}
|
||||
```
|
||||
|
||||
**Pattern to follow:**
|
||||
1. Generate static SVGs using `scripts/generateAbacusIcons.tsx` (uses renderToStaticMarkup)
|
||||
2. Commit generated SVGs to `public/icons/` or `public/`
|
||||
3. Route handlers read and serve the pre-generated files
|
||||
4. Regenerate icons when abacus styling changes
|
||||
|
||||
**MANDATORY: Read the Docs Before Customizing**
|
||||
|
||||
**ALWAYS read the full README documentation before customizing or styling AbacusReact:**
|
||||
|
||||
@@ -200,17 +200,14 @@ function generateOGImage(): string {
|
||||
const appDir = __dirname.replace('/scripts', '')
|
||||
|
||||
try {
|
||||
console.log('Generating favicon from AbacusReact...')
|
||||
const faviconSvg = generateFavicon()
|
||||
writeFileSync(join(appDir, 'src', 'app', 'icon.svg'), faviconSvg)
|
||||
console.log('✓ Generated src/app/icon.svg')
|
||||
|
||||
console.log('Generating Open Graph image from AbacusReact...')
|
||||
const ogImageSvg = generateOGImage()
|
||||
writeFileSync(join(appDir, 'public', 'og-image.svg'), ogImageSvg)
|
||||
console.log('✓ Generated public/og-image.svg')
|
||||
|
||||
console.log('\n✅ All icons generated successfully!')
|
||||
console.log('\n✅ Icon generated successfully!')
|
||||
console.log('\nNote: Day-of-month favicons are generated on-demand by src/app/icon/route.tsx')
|
||||
console.log('which calls scripts/generateDayIcon.tsx as a subprocess.')
|
||||
} catch (error) {
|
||||
console.error('❌ Error generating icons:', error)
|
||||
process.exit(1)
|
||||
|
||||
84
apps/web/scripts/generateDayIcon.tsx
Normal file
84
apps/web/scripts/generateDayIcon.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
#!/usr/bin/env tsx
|
||||
|
||||
/**
|
||||
* Generate a single day-of-month favicon
|
||||
* Usage: npx tsx scripts/generateDayIcon.tsx <day>
|
||||
* Example: npx tsx scripts/generateDayIcon.tsx 15
|
||||
*/
|
||||
|
||||
import React from 'react'
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { AbacusReact } from '@soroban/abacus-react'
|
||||
|
||||
// Extract just the SVG element content from rendered output
|
||||
function extractSvgContent(markup: string): string {
|
||||
const svgMatch = markup.match(/<svg[^>]*>([\s\S]*?)<\/svg>/)
|
||||
if (!svgMatch) {
|
||||
throw new Error('No SVG element found in rendered output')
|
||||
}
|
||||
return svgMatch[1]
|
||||
}
|
||||
|
||||
// Get day from command line argument
|
||||
const day = parseInt(process.argv[2], 10)
|
||||
|
||||
if (!day || day < 1 || day > 31) {
|
||||
console.error('Usage: npx tsx scripts/generateDayIcon.tsx <day>')
|
||||
console.error('Example: npx tsx scripts/generateDayIcon.tsx 15')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
// Render 2-column abacus showing day of month
|
||||
const abacusMarkup = renderToStaticMarkup(
|
||||
<AbacusReact
|
||||
value={day}
|
||||
columns={2}
|
||||
scaleFactor={1.0}
|
||||
animated={false}
|
||||
interactive={false}
|
||||
showNumbers={false}
|
||||
customStyles={{
|
||||
columnPosts: {
|
||||
fill: '#1c1917',
|
||||
stroke: '#0c0a09',
|
||||
strokeWidth: 2,
|
||||
},
|
||||
reckoningBar: {
|
||||
fill: '#1c1917',
|
||||
stroke: '#0c0a09',
|
||||
strokeWidth: 3,
|
||||
},
|
||||
columns: {
|
||||
0: {
|
||||
// Ones place - Bold Blue (high contrast)
|
||||
heavenBeads: { fill: '#2563eb', stroke: '#1e40af', strokeWidth: 2 },
|
||||
earthBeads: { fill: '#2563eb', stroke: '#1e40af', strokeWidth: 2 },
|
||||
},
|
||||
1: {
|
||||
// Tens place - Bold Green (high contrast)
|
||||
heavenBeads: { fill: '#16a34a', stroke: '#15803d', strokeWidth: 2 },
|
||||
earthBeads: { fill: '#16a34a', stroke: '#15803d', strokeWidth: 2 },
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
)
|
||||
|
||||
const svgContent = extractSvgContent(abacusMarkup)
|
||||
|
||||
// Wrap in SVG with proper viewBox for favicon sizing
|
||||
// AbacusReact with 2 columns + scaleFactor 1.0 = ~50×120px
|
||||
// Scale 0.7 = ~35×84px, centered in 100×100
|
||||
const svg = `<svg width="100" height="100" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- Background circle with border for definition -->
|
||||
<circle cx="50" cy="50" r="48" fill="#fef3c7" stroke="#d97706" stroke-width="2"/>
|
||||
|
||||
<!-- Abacus showing day ${day.toString().padStart(2, '0')} (US Central Time) -->
|
||||
<g transform="translate(33, 8) scale(0.7)">
|
||||
${svgContent}
|
||||
</g>
|
||||
</svg>
|
||||
`
|
||||
|
||||
// Output to stdout so parent process can capture it
|
||||
process.stdout.write(svg)
|
||||
55
apps/web/src/app/icon/route.tsx
Normal file
55
apps/web/src/app/icon/route.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import { execSync } from 'child_process'
|
||||
import { join } from 'path'
|
||||
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
// In-memory cache: { day: svg }
|
||||
const iconCache = new Map<number, string>()
|
||||
|
||||
// Get current day of month in US Central Time
|
||||
function getDayOfMonth(): number {
|
||||
const now = new Date()
|
||||
// Get date in America/Chicago timezone
|
||||
const centralDate = new Date(now.toLocaleString('en-US', { timeZone: 'America/Chicago' }))
|
||||
return centralDate.getDate()
|
||||
}
|
||||
|
||||
// Generate icon by calling script that uses react-dom/server
|
||||
function generateDayIcon(day: number): string {
|
||||
// Call the generation script as a subprocess
|
||||
// Scripts can use react-dom/server, route handlers cannot
|
||||
const scriptPath = join(process.cwd(), 'scripts', 'generateDayIcon.tsx')
|
||||
const svg = execSync(`npx tsx "${scriptPath}" ${day}`, {
|
||||
encoding: 'utf-8',
|
||||
cwd: process.cwd(),
|
||||
})
|
||||
return svg
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
const dayOfMonth = getDayOfMonth()
|
||||
|
||||
// Check cache first
|
||||
let svg = iconCache.get(dayOfMonth)
|
||||
|
||||
if (!svg) {
|
||||
// Generate and cache
|
||||
svg = generateDayIcon(dayOfMonth)
|
||||
iconCache.set(dayOfMonth, svg)
|
||||
|
||||
// Clear old cache entries (keep only current day)
|
||||
for (const [cachedDay] of iconCache) {
|
||||
if (cachedDay !== dayOfMonth) {
|
||||
iconCache.delete(cachedDay)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return new Response(svg, {
|
||||
headers: {
|
||||
'Content-Type': 'image/svg+xml',
|
||||
// Cache for 1 hour so it updates throughout the day
|
||||
'Cache-Control': 'public, max-age=3600, s-maxage=3600',
|
||||
},
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user