Skip to content

feat(theme): highlight code blocks at build time with Shiki - #128

Merged
llbbl merged 3 commits into
mainfrom
feat/shiki-highlighting
Sep 10, 2026
Merged

llbbl merged 3 commits into
mainfrom
feat/shiki-highlighting

Conversation

@llbbl

@llbbl llbbl commented Sep 10, 2026

Copy link
Copy Markdown
Owner

Summary

  • marked emits <pre><code class="language-ts"> and the article stylesheet only set background, border and font — no highlighting at all, which is the most visible gap against Starlight, Docusaurus and VitePress.
  • Shiki now highlights fenced code blocks during prerendering, with no client JavaScript for the highlighting itself.
  • Syntax colors are added to each of the six themes and ride the existing derived token pipeline, so theme switching restyles already-highlighted markup without re-highlighting.

Changes

Highlighting Pipeline

  • src/lib/codeHighlight.ts: New module wrapping Shiki with createCssVariablesTheme(), which resolves every token to a small fixed set of var(--shiki-*) names. This makes a static style-to-class table possible with hand-authored CSS that Astro bundles normally.
  • src/lib/markdown.ts: Wires the highlighter into the markdown render path. Unknown languages and unfenced blocks fall back to plain rendering.
  • src/pages/content/[...slug].astro: Adds a copy button per code block; no other client JS is added for highlighting.

Why not transformerStyleToClass

@shikijs/transformers ships one, but its class names are content hashes discovered lazily as blocks are highlighted, so getCSS() only knows what it has seen. Astro renders each prerendered page independently with no "after all highlighting" hook, so the stylesheet would need per-page dynamic injection — exactly what a hash-based CSP cannot cover. createCssVariablesTheme() avoids this by resolving to a fixed variable set up front. @shikijs/transformers was dropped as a dependency after this design change made it unused.

CSP Compatibility

Verified zero style= attributes in built output. Combined with the CSP branch from PR #127, confirmed all five inline scripts — including the copy-button script — plus the one inline style are hashed by the emitted policy, zero unauthorized. The two PRs compose.

Themes

  • src/config/themes.ts: Twelve syntax tokens added to each of the six themes rather than borrowing one Shiki palette, so each theme keeps its own hue. They ride themeColorVariables, themePrepaintScript and ThemeSwitcher with no new plumbing because all three derive from Object.entries(theme.colors), and the Theme interface makes an omission a compile error.

Sanitizer

  • A pre-existing blanket '*': ['class'] allowing any class on any tag is replaced with allowedClasses scoped to div/pre/code/span with an explicit token list.

Styles

  • src/styles/global.css: Hand-authored stylesheet mapping Shiki's --shiki-* CSS variables to theme colors, plus copy-button styling.

Review findings applied

  • createCssVariablesTheme() emits twelve token variables, not the ten first assumed — the three omitted ones are the entire diff grammar, so diff blocks rendered completely unstyled with added and removed lines visually identical. Added those mappings, CSS rules and theme colors, plus a build-time warning when an unmapped variable appears so a future Shiki release is loud rather than silent.
  • Fixed a trailing-newline divergence from marked's default that put a blank line at the bottom of every indented code block and into the clipboard.
  • Dropped @shikijs/transformers (unused after the design change).
  • Gave each copy button an aria-label naming its language.

Test gaps closed

The drift test could not fail for the drift it claimed to guard, since the allowlist and the test's expectations both derive from the same table.

  • src/lib/codeHighlight.test.ts / src/lib/markdown.test.ts: Added an assertion that every sample language actually produces token classes — proven load-bearing, it fails with "diff produced no token classes" against the pre-fix table — and an assertion that the hand-authored stylesheet has a rule for every token class, that being the one half of the pipeline that does not derive from the shared source.

Build cost

+0.33s (~12%), one-time. The highlighter is a module-level singleton and grammars are bounded at 17 unique, not the full bundle.

Verification

  • 404 tests pass
  • Lint and tsc --noEmit clean
  • Credential-free build and smoke pass

Closes #107

Fenced code blocks previously rendered as plain monospace text. Shiki
now highlights them during prerendering, with no client JavaScript
for the highlighting itself.

Output uses CSS classes rather than Shiki's default inline style
attributes, so a strict style-src with no unsafe-inline can serve the
pages. Syntax colors are added to each of the six themes and ride the
existing derived token pipeline, so theme switching restyles
already-highlighted markup without re-highlighting.

Each block gets a copy button; unknown languages and unfenced blocks
fall back to plain rendering. The sanitizer's blanket class allowance
is replaced with a scoped allowlist.
@codecov

codecov Bot commented Sep 10, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 94.64286% with 3 lines in your changes missing coverage. Please review.
✅ Project coverage is 94.43%. Comparing base (32b8f49) to head (e56ca2b).
⚠️ Report is 1 commits behind head on main.

Files with missing lines Patch % Lines
src/lib/codeHighlight.ts 95.45% 0 Missing and 2 partials ⚠️
src/lib/markdown.ts 91.66% 0 Missing and 1 partial ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #128      +/-   ##
==========================================
+ Coverage   94.40%   94.43%   +0.02%     
==========================================
  Files          18       19       +1     
  Lines         483      539      +56     
  Branches      130      144      +14     
==========================================
+ Hits          456      509      +53     
  Misses         12       12              
- Partials       15       18       +3     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Body: codecov/patch and codecov/project flagged two uncovered error branches. The unmapped-token-variable path, which exists so a future Shiki release that adds a variable fails to unstyled text rather than leaking an inline style past a strict style-src, is now tested through an exported transformer factory. The highlighter-throws path, which must degrade to a plain code block rather than fail the build, is tested in an isolated file that mocks the Shiki core module, since the highlighter is a module-level singleton. File line coverage goes from 90.69% to 100%.
@llbbl
llbbl merged commit 9cd11b0 into main Sep 10, 2026
6 checks passed
@llbbl
llbbl deleted the feat/shiki-highlighting branch September 10, 2026 12:32
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(theme): build-time syntax highlighting with Shiki and copy button

1 participant