Skip to content

Commit 5675661

Browse files
enf0rc3claude
andcommitted
Render the code block shell at build time
The frame, header, label and language were being built by JavaScript after the page loaded. With scripting off, and in the window before hydration, a code block was bare text on the page background: the border, radius and padding used to sit on <pre> and now sit on the wrapper that script created. A Shiki transformer emits the whole shell instead, including the copy button. The copy handler is delegated at the document level, so it finds a statically rendered button by the same selector. Shiki, and not rehype, because plugins registered through `markdown.processor` never reach .mdx pages. rehypeWbr adds 18 <wbr> elements to the kubernetes-agent permissions page and none to kustomize.mdx, which has eight matches for it. code-blocks.js drops from 431 lines to 327: the wrapping, the copy button markup and the language table all go. It keeps copying, collapsing, and folding a <details data-group> set into one block with a language menu, which merges sibling blocks and so cannot be done per-block at build time. Two tests cover the shell with scripting disabled. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 55c24b1 commit 5675661

4 files changed

Lines changed: 252 additions & 206 deletions

File tree

astro.config.mjs

Lines changed: 4 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { attributeMarkdown, wrapTables } from '/src/themes/octopus/utilities/cus
77
import llmMdEmitter from './src/integrations/llm-md-emitter.ts';
88
import pruneDist from './src/integrations/prune-dist.ts';
99
import rehypeWbr from './src/plugins/rehype-wbr.js';
10+
import shikiCodeBlock from './src/plugins/shiki-code-block.js';
1011

1112
// https://astro.build/config
1213
export default defineConfig({
@@ -36,16 +37,9 @@ export default defineConfig({
3637
langAlias: {
3738
ocl: 'hcl'
3839
},
39-
transformers: [
40-
{
41-
// Shiki drops the fence's meta string, which the code block
42-
// header renders as the block's label
43-
pre(node) {
44-
const label = this.options.meta?.__raw?.trim();
45-
if (label) node.properties['data-label'] = label;
46-
}
47-
}
48-
]
40+
// A transformer, because rehype plugins registered through
41+
// `processor` below never reach .mdx pages
42+
transformers: [shikiCodeBlock()]
4943
},
5044
processor: unified({
5145
remarkPlugins: [

src/plugins/shiki-code-block.js

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
// Wraps every highlighted block in the code block shell at build time, so the
2+
// frame, header, label and language are on the page before any script runs.
3+
// code-blocks.js adds the behaviour: copying, collapsing, and folding a
4+
// <details data-group> set into one block with a language menu.
5+
6+
const REST = 'Copy to clipboard';
7+
8+
/** Display names for the fence languages used across the docs. */
9+
const LANGUAGE_NAMES = {
10+
bash: 'Bash',
11+
batch: 'Batch',
12+
'c#': 'C#',
13+
cs: 'C#',
14+
csharp: 'C#',
15+
docker: 'Docker',
16+
dockerfile: 'Dockerfile',
17+
fsharp: 'F#',
18+
go: 'Go',
19+
hcl: 'HCL',
20+
html: 'HTML',
21+
ini: 'INI',
22+
java: 'Java',
23+
javascript: 'JavaScript',
24+
js: 'JavaScript',
25+
json: 'JSON',
26+
log: 'Log',
27+
markdown: 'Markdown',
28+
nginx: 'nginx',
29+
ocl: 'OCL',
30+
plaintext: 'Text',
31+
powershell: 'PowerShell',
32+
ps: 'PowerShell',
33+
python: 'Python',
34+
ruby: 'Ruby',
35+
sh: 'Shell',
36+
shell: 'Shell',
37+
sql: 'SQL',
38+
text: 'Text',
39+
txt: 'Text',
40+
typescript: 'TypeScript',
41+
xml: 'XML',
42+
yaml: 'YAML',
43+
yml: 'YAML',
44+
};
45+
46+
function displayName(language) {
47+
const key = String(language ?? '')
48+
.trim()
49+
.toLowerCase();
50+
if (!key) return '';
51+
return LANGUAGE_NAMES[key] ?? key.charAt(0).toUpperCase() + key.slice(1);
52+
}
53+
54+
function h(tagName, properties, children = []) {
55+
return { type: 'element', tagName, properties, children };
56+
}
57+
58+
function text(value) {
59+
return { type: 'text', value };
60+
}
61+
62+
export default function shikiCodeBlock() {
63+
return {
64+
name: 'octopus:code-block',
65+
66+
root(node) {
67+
const pre = node.children.find(
68+
(child) => child.type === 'element' && child.tagName === 'pre'
69+
);
70+
if (!pre) return;
71+
72+
// langAlias rewrites what Shiki reports, so the attribute Astro set from
73+
// the fence wins when it is there. ```ocl has to stay OCL, not HCL.
74+
const language = displayName(
75+
pre.properties?.['data-language'] ?? this.options.lang
76+
);
77+
const label = this.options.meta?.__raw?.trim() ?? '';
78+
79+
// Focusable so an overflowing panel can be scrolled from the keyboard.
80+
pre.properties = { ...pre.properties, tabindex: '0' };
81+
82+
const header = h('div', { className: ['code-block__header'] }, [
83+
h(
84+
'p',
85+
{ className: ['code-block__label'], hidden: !label },
86+
label ? [text(label)] : []
87+
),
88+
h('div', { className: ['code-block__actions'] }, [
89+
h('span', { className: ['code-block__language'] }, [text(language)]),
90+
h(
91+
'button',
92+
{
93+
type: 'button',
94+
className: ['code-block__copy', 'btn', 'btn--small'],
95+
'data-tooltip': REST,
96+
'aria-label': 'Copy code to clipboard',
97+
},
98+
// Empty: the glyph is a CSS mask on the span itself.
99+
[
100+
h(
101+
'span',
102+
{ className: ['code-block__copy-icon', 'btn__icon'] },
103+
[]
104+
),
105+
]
106+
),
107+
]),
108+
]);
109+
110+
const body = h('div', { className: ['code-block__body'] }, [
111+
h('div', { className: ['code-block__panel'] }, [pre]),
112+
h('div', { className: ['code-block__fade'] }, []),
113+
]);
114+
115+
node.children = [h('div', { className: ['code-block'] }, [header, body])];
116+
},
117+
};
118+
}

0 commit comments

Comments
 (0)