Skip to content

Commit 4ceee50

Browse files
rsbhclaude
andauthored
fix: use local TTF font and render logo in OG image (#148)
* fix: use local TTF font and render logo in OG image Replace remote WOFF2 font fetch (unsupported by Satori) with local Inter TTF. Render site logo from chronicle.yaml next to site name in OG image when configured. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: deduplicate og meta tags and add Head to LandingPage RootHead now only emits site-level title and jsonLd WebSite schema. Page-level Head (DocsPage, ApiPage, LandingPage) is sole source of og:image, og:title, twitter tags with correct page title. Also bumps OG logo to 48px and site name font to 32px. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: address coderabbit review comments - Use explicit MIME map, return null for unsupported formats - Throw on font load failure instead of returning empty ArrayBuffer - Cache logo at module level to avoid repeated file I/O Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: add dateModified to Article JSON-LD on doc pages Closes #139 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: use dayjs to format dateModified as ISO 8601 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: use native Date for dateModified ISO formatting Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: use page-specific title in LandingPage Head Avoids "Pixxel | Pixxel" duplication in document title and og:title. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 86cbba0 commit 4ceee50

9 files changed

Lines changed: 147 additions & 37 deletions

File tree

335 KB
Binary file not shown.

packages/chronicle/src/pages/DocsPage.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,8 @@ export function DocsPage({ slug }: DocsPageProps) {
3838
'@type': 'Article',
3939
headline: page.frontmatter.title,
4040
description: page.frontmatter.description,
41-
...(pageUrl && { url: pageUrl })
41+
...(pageUrl && { url: pageUrl }),
42+
...(page.frontmatter.lastModified && { dateModified: new Date(page.frontmatter.lastModified).toISOString() }),
4243
}}
4344
/>
4445
<Page

packages/chronicle/src/pages/LandingPage.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { FolderIcon } from '@heroicons/react/24/outline';
22
import { Link as RouterLink } from 'react-router';
33
import { getLandingEntries } from '@/lib/config';
4+
import { Head } from '@/lib/head';
45
import { usePageContext } from '@/lib/page-context';
56
import styles from './LandingPage.module.css';
67

@@ -13,6 +14,12 @@ export function LandingPage() {
1314
: `${config.site.title}${versionLabel(config, version.dir)}`;
1415

1516
return (
17+
<>
18+
<Head
19+
title={version.dir ? `${config.site.title}${versionLabel(config, version.dir)}` : 'Documentation'}
20+
description={config.site.description}
21+
config={config}
22+
/>
1623
<div className={styles.root}>
1724
<div className={styles.header}>
1825
<h1 className={styles.title}>{heading}</h1>
@@ -42,6 +49,7 @@ export function LandingPage() {
4249
))}
4350
</div>
4451
</div>
52+
</>
4553
);
4654
}
4755

packages/chronicle/src/server/App.tsx

Lines changed: 19 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ import { ThemeProvider, Skeleton, Flex } from '@raystack/apsara';
44
import { lazy, Suspense } from 'react';
55
import { Navigate, useLocation } from 'react-router';
66
import { AnalyticsProvider } from '@/components/analytics/AnalyticsProvider';
7-
import { Head } from '@/lib/head';
87
import { usePageContext } from '@/lib/page-context';
98
import { resolveRoute, RouteType } from '@/lib/route-resolver';
109
import type { ChronicleConfig } from '@/types';
@@ -69,22 +68,25 @@ function PageFallback() {
6968
}
7069

7170
function RootHead({ config }: { config: ChronicleConfig }) {
72-
return (
73-
<Head
74-
title={config.site.title}
75-
description={config.site.description}
76-
config={config}
77-
jsonLd={
78-
config.url
79-
? {
80-
'@context': 'https://schema.org',
81-
'@type': 'WebSite',
82-
name: config.site.title,
83-
description: config.site.description,
84-
url: config.url
85-
}
86-
: undefined
71+
const siteJsonLd = config.url
72+
? {
73+
'@context': 'https://schema.org',
74+
'@type': 'WebSite',
75+
name: config.site.title,
76+
description: config.site.description,
77+
url: config.url,
8778
}
88-
/>
79+
: null;
80+
81+
return (
82+
<>
83+
<title>{config.site.title}</title>
84+
{siteJsonLd && (
85+
<script
86+
type='application/ld+json'
87+
dangerouslySetInnerHTML={{ __html: JSON.stringify(siteJsonLd, null, 2) }}
88+
/>
89+
)}
90+
</>
8991
);
9092
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import fs from 'node:fs/promises';
2+
import path from 'node:path';
3+
4+
const MIME_MAP: Record<string, string> = {
5+
'.svg': 'image/svg+xml',
6+
'.png': 'image/png',
7+
'.jpg': 'image/jpeg',
8+
'.jpeg': 'image/jpeg',
9+
};
10+
11+
export function getLogoDataUri(data: Buffer, filePath: string): string | null {
12+
const ext = path.extname(filePath).toLowerCase();
13+
const mime = MIME_MAP[ext];
14+
if (!mime) return null;
15+
return `data:${mime};base64,${data.toString('base64')}`;
16+
}
17+
18+
export async function loadLogo(projectRoot: string, logoPath: string): Promise<string | null> {
19+
try {
20+
const filePath = path.resolve(projectRoot, 'public', logoPath.replace(/^\//, ''));
21+
const data = await fs.readFile(filePath);
22+
return getLogoDataUri(data, filePath);
23+
} catch {
24+
return null;
25+
}
26+
}
27+
28+
export async function loadFont(packageRoot: string): Promise<ArrayBuffer> {
29+
const fontPath = path.resolve(packageRoot, 'src/fonts/Inter-Regular.ttf');
30+
const buffer = await fs.readFile(fontPath);
31+
return buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength);
32+
}
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import { describe, expect, test } from 'bun:test';
2+
import path from 'node:path';
3+
import { getLogoDataUri, loadLogo, loadFont } from './og-utils';
4+
5+
const PACKAGE_ROOT = path.resolve(__dirname, '../../..');
6+
const FIXTURES = path.resolve(__dirname, '__fixtures__');
7+
8+
describe('getLogoDataUri', () => {
9+
test('svg file returns svg mime type', () => {
10+
const data = Buffer.from('<svg></svg>');
11+
const result = getLogoDataUri(data, '/logo.svg');
12+
expect(result).toStartWith('data:image/svg+xml;base64,');
13+
});
14+
15+
test('png file returns png mime type', () => {
16+
const data = Buffer.from('fake-png');
17+
const result = getLogoDataUri(data, '/logo.png');
18+
expect(result).toStartWith('data:image/png;base64,');
19+
});
20+
21+
test('jpg file returns jpeg mime type', () => {
22+
const data = Buffer.from('fake-jpg');
23+
const result = getLogoDataUri(data, '/photo.jpg');
24+
expect(result).toStartWith('data:image/jpeg;base64,');
25+
});
26+
27+
test('returns null for unsupported format', () => {
28+
const data = Buffer.from('fake-webp');
29+
const result = getLogoDataUri(data, '/logo.webp');
30+
expect(result).toBeNull();
31+
});
32+
33+
test('encodes data as base64', () => {
34+
const content = '<svg xmlns="http://www.w3.org/2000/svg"></svg>';
35+
const data = Buffer.from(content);
36+
const result = getLogoDataUri(data, '/icon.svg');
37+
const base64 = result!.split(',')[1];
38+
expect(Buffer.from(base64, 'base64').toString()).toBe(content);
39+
});
40+
});
41+
42+
describe('loadLogo', () => {
43+
test('returns null for nonexistent file', async () => {
44+
const result = await loadLogo('/nonexistent', '/logo.svg');
45+
expect(result).toBeNull();
46+
});
47+
48+
test('strips leading slash from logo path', async () => {
49+
const result = await loadLogo('/nonexistent', '/nested/logo.svg');
50+
expect(result).toBeNull();
51+
});
52+
});
53+
54+
describe('loadFont', () => {
55+
test('loads Inter font from package', async () => {
56+
const font = await loadFont(PACKAGE_ROOT);
57+
expect(font.byteLength).toBeGreaterThan(0);
58+
});
59+
60+
test('throws for invalid path', async () => {
61+
expect(loadFont('/nonexistent')).rejects.toThrow();
62+
});
63+
});

packages/chronicle/src/server/routes/og.tsx

Lines changed: 21 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -2,31 +2,23 @@ import { defineHandler } from 'nitro';
22
import React from 'react';
33
import satori from 'satori';
44
import { loadConfig } from '@/lib/config';
5+
import { loadFont, loadLogo } from './og-utils';
56

67
let fontData: ArrayBuffer | null = null;
7-
8-
async function loadFont(): Promise<ArrayBuffer> {
9-
if (fontData) return fontData;
10-
11-
try {
12-
const response = await fetch(
13-
'https://fonts.gstatic.com/s/inter/v18/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuLyfAZ9hiA.woff2'
14-
);
15-
fontData = await response.arrayBuffer();
16-
} catch {
17-
fontData = new ArrayBuffer(0);
18-
}
19-
20-
return fontData;
21-
}
8+
let cachedLogo: string | null | undefined;
229

2310
export default defineHandler(async event => {
2411
const config = loadConfig();
2512
const title = event.url.searchParams.get('title') ?? config.site.title;
2613
const description = event.url.searchParams.get('description') ?? '';
2714
const siteName = config.site.title;
2815

29-
const font = await loadFont();
16+
if (!fontData) fontData = await loadFont(__CHRONICLE_PACKAGE_ROOT__);
17+
if (cachedLogo === undefined) {
18+
cachedLogo = config.logo?.dark
19+
? await loadLogo(__CHRONICLE_PROJECT_ROOT__, config.logo.dark)
20+
: null;
21+
}
3022

3123
const svg = await satori(
3224
<div
@@ -41,8 +33,18 @@ export default defineHandler(async event => {
4133
color: '#fafafa',
4234
}}
4335
>
44-
<div style={{ fontSize: 24, color: '#888', marginBottom: 16 }}>
45-
{siteName}
36+
<div style={{ display: 'flex', alignItems: 'center', marginBottom: 16 }}>
37+
{cachedLogo && (
38+
<img
39+
src={cachedLogo}
40+
width={48}
41+
height={48}
42+
style={{ marginRight: 16 }}
43+
/>
44+
)}
45+
<div style={{ fontSize: 32, color: '#888' }}>
46+
{siteName}
47+
</div>
4648
</div>
4749
<div
4850
style={{
@@ -64,7 +66,7 @@ export default defineHandler(async event => {
6466
width: 1200,
6567
height: 630,
6668
fonts: [
67-
{ name: 'Inter', data: font, weight: 400, style: 'normal' as const },
69+
{ name: 'Inter', data: fontData, weight: 400, style: 'normal' as const },
6870
],
6971
},
7072
);

packages/chronicle/src/server/vite-config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,7 @@ export async function createViteConfig(
131131
define: {
132132
__CHRONICLE_CONTENT_DIR__: JSON.stringify(contentMirror),
133133
__CHRONICLE_PROJECT_ROOT__: JSON.stringify(projectRoot),
134+
__CHRONICLE_PACKAGE_ROOT__: JSON.stringify(packageRoot),
134135
__CHRONICLE_CONFIG_RAW__: JSON.stringify(rawConfig),
135136
},
136137
css: {
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
// Vite build-time constants (injected via define in vite-config.ts)
22
declare const __CHRONICLE_CONTENT_DIR__: string
33
declare const __CHRONICLE_PROJECT_ROOT__: string
4+
declare const __CHRONICLE_PACKAGE_ROOT__: string
45
declare const __CHRONICLE_CONFIG_RAW__: string | null

0 commit comments

Comments
 (0)