Skip to content

fix: use local TTF font and render logo in OG image#148

Merged
rsbh merged 7 commits into
mainfrom
fix/og-image-font-and-logo
Jun 4, 2026
Merged

fix: use local TTF font and render logo in OG image#148
rsbh merged 7 commits into
mainfrom
fix/og-image-font-and-logo

Conversation

@rsbh
Copy link
Copy Markdown
Member

@rsbh rsbh commented Jun 4, 2026

Summary

  • Replace remote WOFF2 font fetch with local Inter TTF (fixes Satori "Unsupported OpenType signature wOF2" error)
  • Render site logo from chronicle.yaml (logo.dark) next to site name in OG image
  • Extract OG utils (loadFont, loadLogo, getLogoDataUri) into testable module
  • Add __CHRONICLE_PACKAGE_ROOT__ build-time constant for font path resolution
  • Deduplicate og meta tags — RootHead only emits site-level jsonLd, page-level Head handles og/twitter
  • Add Head to LandingPage for og tags
  • Add dateModified to Article JSON-LD on doc pages

Closes #120
Closes #139

Test plan

  • 9 unit tests pass (bun test src/server/routes/og.test.ts) — no server needed
  • All 180 tests pass
  • Build succeeds
  • OG endpoint returns valid SVG with logo and correct font rendering
  • No duplicate og:image tags in HTML
  • Tested locally with Pixxel docs

🤖 Generated with Claude Code

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>
@vercel
Copy link
Copy Markdown

vercel Bot commented Jun 4, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
chronicle Ready Ready Preview, Comment Jun 4, 2026 6:03am

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Jun 4, 2026

Review Change Stack

📝 Walkthrough

Walkthrough

The PR extracts asset-loading utilities (getLogoDataUri, loadLogo, loadFont), injects a package-root compile-time constant, adds tests, and updates the OG route to lazy-load fonts and optionally render a dark-mode logo from the project's public/ directory.

Changes

OG Image Generation Refactoring

Layer / File(s) Summary
OG asset-loading utilities and tests
packages/chronicle/src/server/routes/og-utils.ts, packages/chronicle/src/server/routes/og.test.ts
getLogoDataUri generates base64 data URIs with MIME-type detection; loadLogo resolves and reads logos from public/; loadFont reads Inter font from src/fonts/ as ArrayBuffer. Tests verify MIME prefixes, base64 encoding, null returns on invalid paths, and font loading.
Build-time package root constant
packages/chronicle/src/server/vite-config.ts, packages/chronicle/src/types/globals.d.ts
Vite config injects __CHRONICLE_PACKAGE_ROOT__ as a compile-time constant; TypeScript globals declaration registers it for type checking.
OG route: font and logo integration
packages/chronicle/src/server/routes/og.tsx
Route imports asset utilities, replaces inline font fetching with cached lazy-loaded fontData, loads optional dark-mode logo from config, conditionally renders logo in SVG header alongside site name, and updates Satori font config to use cached buffer.

Estimated code review effort

🎯 2 (Simple) | ⏱️ ~12 minutes

Possibly related PRs

  • raystack/chronicle#54: Main PR's OG route now renders a logo loaded from config.logo.dark via the new loadLogo helper (reading from public/), which directly aligns with PR #54 adding logo.light/logo.dark config and ensuring static assets from public/ are served.

Suggested reviewers

  • rohanchkrabrty
  • rohilsurana
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the main changes: replacing a remote WOFF2 font with a local TTF font and adding logo rendering to the OG image, which are the primary objectives of the PR.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description check ✅ Passed The PR description is detailed and directly related to the changeset, clearly explaining the font replacement, logo rendering, utility extraction, and test coverage.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/og-image-font-and-logo

Warning

Review ran into problems

🔥 Problems

Stopped waiting for pipeline failures after 30000ms. One of your pipelines takes longer than our 30000ms fetch window to run, so review may not consider pipeline-failure results for inline comments if any failures occurred after the fetch window. Increase the timeout if you want to wait longer or run a @coderabbit review after the pipeline has finished.


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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🧹 Nitpick comments (1)
packages/chronicle/src/server/routes/og.test.ts (1)

6-6: ⚡ Quick win

Remove unused constant.

The FIXTURES constant is defined but never used in the test file.

♻️ Proposed fix
 const PACKAGE_ROOT = path.resolve(__dirname, '../../..');
-const FIXTURES = path.resolve(__dirname, '__fixtures__');
🤖 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 `@packages/chronicle/src/server/routes/og.test.ts` at line 6, Remove the unused
constant FIXTURES from the test file to eliminate dead code: locate the
declaration "const FIXTURES = path.resolve(__dirname, '__fixtures__');" in
og.test.ts and delete that line (and any import solely used for it, e.g., path
if it's only used here) so the test file no longer contains the unused FIXTURES
symbol.
🤖 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 `@packages/chronicle/src/server/routes/og-utils.ts`:
- Around line 4-8: getLogoDataUri currently maps only .svg and .png and falls
back to image/jpeg, which mislabels other formats (e.g., .webp, .gif); update
getLogoDataUri to explicitly map supported extensions (include .webp, .gif,
etc.) or use a reliable lookup (e.g., mime.getType) to derive the MIME from
filePath, and if the MIME cannot be determined or is unsupported, either throw a
clear error or return a safe default (do not silently assume image/jpeg); ensure
references to ext and mime inside getLogoDataUri are updated accordingly.
- Around line 20-28: The loadFont function currently swallows errors and returns
an empty ArrayBuffer which breaks Satori; change loadFont to surface the
underlying error instead of returning new ArrayBuffer(0): catch the thrown error
from fs.readFile in loadFont, log or include the error details, and rethrow (or
throw a new Error with context) so callers (OG route / Satori) fail fast; ensure
the thrown error message references loadFont and the fontPath so it's easy to
trace.

In `@packages/chronicle/src/server/routes/og.tsx`:
- Line 66: Check that the font data loaded by loadFont (fontData) is non-null
and not empty before passing it to Satori; locate the code that constructs the
font object { name: 'Inter', data: fontData, weight: 400, style: 'normal' } and
add a guard like if (!fontData || !(fontData as ArrayBuffer).byteLength) then
log the failure and return a safe fallback response (or use a bundled default
font) instead of calling Satori. Ensure the check references the same fontData
variable and that any early return uses the same response shape your handler
expects.
- Around line 16-18: The code currently calls loadLogo(...) per-request to
compute logoSrc; instead, cache the loaded logo at module scope similar to
fontData: introduce a module-level variable (e.g., cachedLogo or logoData) and
initialize it once when config.logo?.dark is present by calling
loadLogo(__CHRONICLE_PROJECT_ROOT__, config.logo.dark) (or lazily on first use)
and then use that cachedLogo in place of computing logoSrc inside the request
handler; update references to logoSrc and retain use of config.logo.dark and
loadLogo for the initial load.

---

Nitpick comments:
In `@packages/chronicle/src/server/routes/og.test.ts`:
- Line 6: Remove the unused constant FIXTURES from the test file to eliminate
dead code: locate the declaration "const FIXTURES = path.resolve(__dirname,
'__fixtures__');" in og.test.ts and delete that line (and any import solely used
for it, e.g., path if it's only used here) so the test file no longer contains
the unused FIXTURES symbol.
🪄 Autofix (Beta)

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: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 28b746ec-4be2-44c2-8421-fcb7e06430e2

📥 Commits

Reviewing files that changed from the base of the PR and between 86cbba0 and ba6d3b1.

⛔ Files ignored due to path filters (1)
  • packages/chronicle/src/fonts/Inter-Regular.ttf is excluded by !**/*.ttf
📒 Files selected for processing (5)
  • packages/chronicle/src/server/routes/og-utils.ts
  • packages/chronicle/src/server/routes/og.test.ts
  • packages/chronicle/src/server/routes/og.tsx
  • packages/chronicle/src/server/vite-config.ts
  • packages/chronicle/src/types/globals.d.ts

Comment thread packages/chronicle/src/server/routes/og-utils.ts Outdated
Comment thread packages/chronicle/src/server/routes/og-utils.ts
Comment thread packages/chronicle/src/server/routes/og.tsx Outdated
Comment thread packages/chronicle/src/server/routes/og.tsx
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>
- 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>
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 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 `@packages/chronicle/src/pages/LandingPage.tsx`:
- Around line 18-22: Replace the site-wide title prop passed into the Head
component from config.site.title with a page-specific title so Head can append
the site title correctly; in the LandingPage component change the title prop
(currently config.site.title) to a page-level value (e.g., a new
landingPageTitle variable, config.site.landingTitle, an empty string, or a title
that includes the current version) so Head (from
packages/chronicle/src/lib/head.tsx) produces correct document and social
metadata rather than duplicating the site title.

In `@packages/chronicle/src/server/App.tsx`:
- Around line 85-87: Replace raw JSON.stringify usage for JSON-LD injection with
a shared escaped serializer: implement and export a serializeJsonLd(value:
unknown) function that calls JSON.stringify(value, null, 2) and then escapes
problematic sequences (at minimum replace "</" with "<\/" and the Unicode
U+2028/U+2029 characters) to prevent breaking out of the <script
type="application/ld+json"> block; update the App component's siteJsonLd
injection (dangerouslySetInnerHTML) and the Head component's JSON-LD injection
to use serializeJsonLd(...) instead of JSON.stringify(...), and import the
shared helper where needed.
🪄 Autofix (Beta)

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: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: e175b1b2-aaae-4605-b2e1-aae25f537483

📥 Commits

Reviewing files that changed from the base of the PR and between ba6d3b1 and af39eac.

📒 Files selected for processing (3)
  • packages/chronicle/src/pages/LandingPage.tsx
  • packages/chronicle/src/server/App.tsx
  • packages/chronicle/src/server/routes/og.tsx
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/chronicle/src/server/routes/og.tsx

Comment thread packages/chronicle/src/pages/LandingPage.tsx
Comment on lines +85 to +87
<script
type='application/ld+json'
dangerouslySetInnerHTML={{ __html: JSON.stringify(siteJsonLd, null, 2) }}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

python - <<'PY'
import json
payload = {"name": "</script><script>alert(1)</script>"}
rendered = json.dumps(payload, indent=2)
print(rendered)
assert "</script>" in rendered, "Expected raw closing script tag in JSON.stringify-equivalent output"
PY

rg -n -C2 "dangerouslySetInnerHTML=\{\{ __html: JSON.stringify" packages/chronicle/src

Repository: raystack/chronicle

Length of output: 826


Security: escape JSON-LD output before injecting into the application/ld+json <script> tag.

dangerouslySetInnerHTML is populated with JSON.stringify(...) in both packages/chronicle/src/server/App.tsx and packages/chronicle/src/lib/head.tsx, so any config-derived value containing </script> (via unescaped <) can break out of the JSON-LD script block and enable script injection. Centralize an escaped serializer and use it in both places.

🔒 Suggested fix
+function serializeJsonLd(value: unknown) {
+  return JSON.stringify(value, null, 2)
+    .replace(/</g, '\\u003c')
+    .replace(/\u2028/g, '\\u2028')
+    .replace(/\u2029/g, '\\u2029');
+}
+
 function RootHead({ config }: { config: ChronicleConfig }) {
   const siteJsonLd = config.url
     ? {
         '`@context`': 'https://schema.org',
         '`@type`': 'WebSite',
         name: config.site.title,
         description: config.site.description,
         url: config.url,
       }
     : null;

   return (
     <>
       <title>{config.site.title}</title>
       {siteJsonLd && (
         <script
           type='application/ld+json'
-          dangerouslySetInnerHTML={{ __html: JSON.stringify(siteJsonLd, null, 2) }}
+          dangerouslySetInnerHTML={{ __html: serializeJsonLd(siteJsonLd) }}
         />
       )}
     </>
   );
 }

Apply the same serializeJsonLd(...) usage to packages/chronicle/src/lib/head.tsx (preferably via a shared helper used by both files).

🧰 Tools
🪛 ast-grep (0.43.0)

[warning] 86-86: Usage of dangerouslySetInnerHTML detected. This bypasses React's built-in XSS protection. Always sanitize HTML content using libraries like DOMPurify before injecting it into the DOM to prevent XSS attacks.
Context: dangerouslySetInnerHTML
Note: [CWE-79] Improper Neutralization of Input During Web Page Generation [REFERENCES]
- https://reactjs.org/docs/dom-elements.html#dangerouslysetinnerhtml
- https://cwe.mitre.org/data/definitions/79.html

(react-unsafe-html-injection)

🤖 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 `@packages/chronicle/src/server/App.tsx` around lines 85 - 87, Replace raw
JSON.stringify usage for JSON-LD injection with a shared escaped serializer:
implement and export a serializeJsonLd(value: unknown) function that calls
JSON.stringify(value, null, 2) and then escapes problematic sequences (at
minimum replace "</" with "<\/" and the Unicode U+2028/U+2029 characters) to
prevent breaking out of the <script type="application/ld+json"> block; update
the App component's siteJsonLd injection (dangerouslySetInnerHTML) and the Head
component's JSON-LD injection to use serializeJsonLd(...) instead of
JSON.stringify(...), and import the shared helper where needed.

Closes #139

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Avoids "Pixxel | Pixxel" duplication in document title and og:title.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@rsbh rsbh merged commit 4ceee50 into main Jun 4, 2026
9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat: add canonical URL and structured data to page head perf: bundle OG image font instead of fetching from Google on cold start

2 participants