Skip to content

Commit d375cc3

Browse files
authored
feat(FR-2716): F4 code-block syntax highlighting and copy button (#7010)
1 parent 8aa0358 commit d375cc3

11 files changed

Lines changed: 909 additions & 12 deletions

File tree

packages/backend.ai-docs-toolkit/ARCHITECTURE.md

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -659,3 +659,86 @@ The PDF pipeline reads `bookConfig.navigation[lang]` (the flat list). Since the
659659
- **CSS-only collapse** — sidebar groups use native `<details>`/`<summary>`; no runtime JS for collapse.
660660
- **PDF non-regression** — flat `navigation` shape preserved for the PDF pipeline; only the chapter ordering changes (intentionally).
661661
- **Backward compat** — flat `navigation` form still loads and renders identically to F1's sidebar.
662+
663+
## F4 — Reading UX (code-block syntax highlighting + Copy button)
664+
665+
F4 upgrades the web pipeline's code-block presentation:
666+
667+
1. **Build-time syntax highlighting** via [Shiki](https://shiki.style) — Shiki tokenizes source with TextMate grammars and returns inline-styled `<span>` rows. Zero runtime highlight JS is shipped (no Prism, no highlight.js).
668+
2. A **Copy button** per code block, served as a tiny vanilla JS asset `templates/assets/code-copy.js`.
669+
670+
### Shiki integration
671+
672+
`src/shiki-highlighter.ts` is the only place the toolkit talks to Shiki. It exposes a single `highlight({ code, lang, theme })` API used by `markdown-processor-web.ts`.
673+
674+
```text
675+
markdown-processor-web.ts
676+
precomputeShikiBlocks(markdown, theme) ← walks marked's lexer tokens
677+
↓ for each code token: shikiHighlight(...)
678+
↓ tokens cached in-memory: (theme, lang, sha1(code)) → innerHtml
679+
buildWebRenderer({ highlightedCode }) ← sync renderer
680+
↓ code() looks up `${lang}|||${node.text}` → wraps in <pre><code>
681+
```
682+
683+
**Why the pre-pass walks marked's lexer (not regex):** code blocks inside list items are dedented by marked's lexer before reaching the renderer. A regex over the raw markdown would store keys with the original indent, while the renderer looks up the dedented form — every list-nested code block would miss the cache. Walking the lexer guarantees the same `(lang, code)` tuple is used on both sides.
684+
685+
**Cache scope:** the in-memory map persists for the duration of one Node process (one `build:web` invocation). Building all four languages in sequence reuses the cache, so a snippet that appears in `en/foo.md` and again in `ko/foo.md` tokenizes once. The cache is not persisted to disk — Shiki's tokenization is fast enough on second-run that an in-memory cache covers the spec's "build wall-clock per language ≤ +50%" budget; for the WebUI docs the all-langs build runs in ~4s end-to-end.
686+
687+
**Defensive paths:** unknown languages render as plain `<span class="line">` rows (not highlighted, not error). Unknown themes warn once and fall back to `github-light`. Shiki errors during tokenization are caught and degrade to plain escaped text — never fail the build.
688+
689+
### Theme configuration
690+
691+
Operators control the syntax theme via `docs-toolkit.config.yaml`:
692+
693+
```yaml
694+
code:
695+
lightTheme: "github-light" # default; any bundled Shiki theme works
696+
```
697+
698+
Any [bundled Shiki theme](https://shiki.style/themes) is accepted (`github-light`, `vitesse-light`, `light-plus`, etc.). Unknown theme names warn once and fall back to `github-light`.
699+
700+
**Reserved namespace — `code.darkTheme`:** intentionally NOT wired in F4. The spec scopes F4 to light-theme only; a future dark-mode bucket will introduce `code.darkTheme` paired with Shiki's dual-theme rendering (`themes: { light, dark }`). Adding the key now would commit us to a CSS-variables vs inline-style output format before that work has chosen one. The slot is reserved at the type level via a code comment in `src/config.ts` only.
701+
702+
### Copy button (`templates/assets/code-copy.js`)
703+
704+
A vanilla DOM script (no framework, no deps; ~5 KB unminified, well under the per-page 25 KB JS budget). On `DOMContentLoaded`:
705+
706+
1. Wrap each `<pre><code>` in `.doc-code-block-wrapper` (idempotent).
707+
2. Inject a `.doc-code-copy-btn` button that reads `[data-copy-label]` etc. from `<body>` for localized strings.
708+
3. Click → `navigator.clipboard.writeText(<pre>.textContent)`. Falls back to a hidden-textarea + `document.execCommand("copy")` for non-secure-context previews.
709+
4. Flash "Copied!" / "Copy failed" states for 1.5s, then revert.
710+
711+
Localized labels (`copy`, `copied`, `copyFailed`) live in `WEBSITE_LABELS` (`src/config.ts`) and are injected as `data-*` attributes on `<body>`, so the script itself stays language-agnostic and gets content-hashed once across every language.
712+
713+
The script is automatically picked up by `website-generator.ts`'s asset pipeline (the slot was wired in F5) — drop the file at `templates/assets/code-copy.js` and the build hashes it and includes it via `PageAssets.codeCopy`.
714+
715+
### CSS additions (`styles-web.ts`)
716+
717+
- `.shiki-host > code .line { display: block }` — ensures Shiki's `.line` rows wrap correctly inside the `<pre>` frame instead of pushing it wider.
718+
- `.doc-code-block-wrapper` — positioning context for the absolutely-placed Copy button. The wrapper is invisible (no border / margin override).
719+
- `.doc-code-copy-btn` — top-right corner pill that fades in on hover/focus of the wrapper. Distinct color states: `idle` (default), `copied` (green tint), `failed` (red tint).
720+
721+
### Files touched (F4)
722+
723+
| File | Role |
724+
|---------------------------------------------------|--------------------------------------------------------------------------------|
725+
| `src/shiki-highlighter.ts` (new) | Lazy Shiki highlighter, in-memory `(theme, lang, sha1(code))` cache |
726+
| `src/markdown-processor-web.ts` | `precomputeShikiBlocks()` pre-pass; renderer reads pre-rendered HTML |
727+
| `src/config.ts` | `CodeConfig` / `ResolvedCodeConfig` types; `code.lightTheme` default; copy labels in WEBSITE_LABELS |
728+
| `src/styles-web.ts` | `.shiki-host`, `.doc-code-block-wrapper`, `.doc-code-copy-btn` styles |
729+
| `src/website-builder.ts` | `<body data-copy-label=…>` data attrs for the Copy script |
730+
| `src/index.ts` | Re-exports for `highlightCode`, `CodeConfig`, `DEFAULT_CODE_LIGHT_THEME` |
731+
| `templates/assets/code-copy.js` (new) | Vanilla JS Copy button (no deps, no CDN) |
732+
| `package.json` | `shiki@1.29.2` runtime dependency |
733+
734+
### PDF pipeline non-regression (F4)
735+
736+
F4 only touches `markdown-processor-web.ts`. The PDF pipeline (`markdown-processor.ts`) keeps its existing renderer — PDF code blocks remain unhighlighted (the spec does not require Shiki in PDFs). Verified by running `pnpm run pdf:en` on this commit (PDF generated successfully).
737+
738+
### Constraints honoured (F4)
739+
740+
- **Air-gapped** — Shiki bundles its grammars and themes as JSON inside the `shiki` npm package; no CDN, no network at build time. The Copy button uses `navigator.clipboard` (browser-native, no network).
741+
- **JS budget** — `code-copy.js` source is ~5 KB unminified, well under the per-page 25 KB budget. No runtime highlight JS is shipped (Shiki runs build-time only).
742+
- **Build wall-clock** — Shiki tokenization is amortized by the in-memory cache. The all-langs WebUI docs build runs in ~4s end-to-end (well within the "≤ +50% per language" budget).
743+
- **No dark-mode leakage** — F4 emits inline-styled spans for the configured light theme only. No `data-theme` attributes, no toggle UI, no `code.darkTheme` wiring (namespace reserved by comment only).
744+
- **Backward compat** — code blocks with `data-highlight="…"` (line-highlight feature) keep using the legacy `code-line.highlighted` renderer; mixing per-token Shiki colors with line-level overlays is left for a follow-up.

packages/backend.ai-docs-toolkit/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@
4646
"handlebars": "^4.7.8",
4747
"marked": "^12.0.2",
4848
"pdf-lib": "^1.17.1",
49+
"shiki": "1.29.2",
4950
"yaml": "^2.8.2"
5051
},
5152
"peerDependencies": {

packages/backend.ai-docs-toolkit/src/config.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,26 @@ export interface VersionEntry {
146146
latest?: boolean;
147147
}
148148

149+
/**
150+
* Code-block presentation config (F4 — reading UX).
151+
*
152+
* `lightTheme` controls Shiki's light syntax theme used during build-time
153+
* highlighting in the web pipeline. Any theme bundled with `shiki` is
154+
* accepted (e.g. `github-light`, `vitesse-light`, `light-plus`). Unknown
155+
* themes fall back to `github-light` with a warning.
156+
*
157+
* Reserved namespace — do not wire yet:
158+
* - `darkTheme`: a future dark-mode bucket will introduce this and pair
159+
* it with Shiki's dual-theme (`themes: { light, dark }`) rendering.
160+
* Adding the key here now would commit us to an output format before
161+
* that work has chosen one. Keep the slot reserved at the type level
162+
* only via this comment.
163+
*/
164+
export interface CodeConfig {
165+
/** Shiki light theme name. Default: `'github-light'`. */
166+
lightTheme?: string;
167+
}
168+
149169
/** Full toolkit config file shape (docs-toolkit.config.yaml) */
150170
export interface ToolkitConfig extends DocConfig {
151171
agents?: AgentConfig;
@@ -162,6 +182,8 @@ export interface ToolkitConfig extends DocConfig {
162182
* `sitemap.xml` and `robots.txt` generation. See `OgConfig`.
163183
*/
164184
og?: OgConfig;
185+
/** Code-block presentation (Shiki theme, F4). */
186+
code?: CodeConfig;
165187
}
166188

167189
// ── Defaults ──────────────────────────────────────────────────
@@ -237,6 +259,9 @@ export const WEBSITE_LABELS: Record<string, Record<string, string>> = {
237259
home: "Home",
238260
onThisPage: "On this page",
239261
tocToggle: "On this page",
262+
copy: "Copy",
263+
copied: "Copied!",
264+
copyFailed: "Copy failed",
240265
},
241266
ko: {
242267
previous: "이전",
@@ -248,6 +273,9 @@ export const WEBSITE_LABELS: Record<string, Record<string, string>> = {
248273
home: "홈",
249274
onThisPage: "이 페이지의 목차",
250275
tocToggle: "이 페이지의 목차",
276+
copy: "복사",
277+
copied: "복사됨!",
278+
copyFailed: "복사 실패",
251279
},
252280
ja: {
253281
previous: "前へ",
@@ -259,6 +287,9 @@ export const WEBSITE_LABELS: Record<string, Record<string, string>> = {
259287
home: "ホーム",
260288
onThisPage: "このページの目次",
261289
tocToggle: "このページの目次",
290+
copy: "コピー",
291+
copied: "コピーしました!",
292+
copyFailed: "コピーに失敗しました",
262293
},
263294
th: {
264295
previous: "ก่อนหน้า",
@@ -270,6 +301,9 @@ export const WEBSITE_LABELS: Record<string, Record<string, string>> = {
270301
home: "หน้าแรก",
271302
onThisPage: "หัวข้อในหน้านี้",
272303
tocToggle: "หัวข้อในหน้านี้",
304+
copy: "คัดลอก",
305+
copied: "คัดลอกแล้ว!",
306+
copyFailed: "คัดลอกไม่สำเร็จ",
273307
},
274308
};
275309

@@ -318,8 +352,21 @@ export interface ResolvedDocConfig {
318352
* SEO tag emitter, sitemap, and OG image renderer.
319353
*/
320354
og?: OgConfig;
355+
/**
356+
* Resolved code-block presentation. Always populated (defaults applied)
357+
* so downstream consumers don't have to null-check.
358+
*/
359+
code: ResolvedCodeConfig;
321360
}
322361

362+
/** Resolved code-block config — all defaults applied. */
363+
export interface ResolvedCodeConfig {
364+
lightTheme: string;
365+
}
366+
367+
/** Default Shiki light theme — readable, neutral, ships with shiki/themes. */
368+
export const DEFAULT_CODE_LIGHT_THEME = "github-light";
369+
323370
export function resolveConfig(config: ToolkitConfig): ResolvedDocConfig {
324371
const projectRoot = config.projectRoot;
325372
return {
@@ -370,6 +417,9 @@ export function resolveConfig(config: ToolkitConfig): ResolvedDocConfig {
370417
website: config.website,
371418
versions: config.versions,
372419
og: config.og,
420+
code: {
421+
lightTheme: config.code?.lightTheme ?? DEFAULT_CODE_LIGHT_THEME,
422+
},
373423
};
374424
}
375425

packages/backend.ai-docs-toolkit/src/index.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,27 @@ export type {
1111
DocConfig,
1212
AgentConfig,
1313
WebsiteConfig,
14+
CodeConfig,
1415
ToolkitConfig,
1516
ResolvedDocConfig,
1617
VersionEntry,
1718
VersionSource,
1819
OgConfig,
20+
ResolvedCodeConfig,
1921
} from "./config.js";
20-
export { resolveConfig, loadToolkitConfig, WEBSITE_LABELS } from "./config.js";
22+
export {
23+
resolveConfig,
24+
loadToolkitConfig,
25+
WEBSITE_LABELS,
26+
DEFAULT_CODE_LIGHT_THEME,
27+
} from "./config.js";
28+
29+
// ── Shiki Code Highlighting (F4) ────────────────────────────────
30+
export { highlight as highlightCode } from "./shiki-highlighter.js";
31+
export type {
32+
ShikiHighlightOptions,
33+
ShikiHighlightResult,
34+
} from "./shiki-highlighter.js";
2135

2236
// ── Versioned docs (F6) ─────────────────────────────────────────
2337
export type {

0 commit comments

Comments
 (0)