Skip to content

Commit e55b949

Browse files
committed
Fix code tabls only handling two grouped blocks
1 parent cbb028f commit e55b949

4 files changed

Lines changed: 222 additions & 8 deletions

File tree

‎_TODO.md‎

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -147,18 +147,12 @@ See comp images in Breadcrumbs component directory
147147

148148
## Code Block Problems
149149

150-
- I aliased 'promql' to 'go'. When a code fence using the alias is rendered with the language set to 'promql', it shows as 'go' incorrectly because of the alias.
151-
152-
- We need custom handling for all language names that are displayed:
153-
1. `html` should be displayed uppercase as `HTML`, `yaml` as `YAML`
154-
2. `typescript` and its alias `ts` as `TypeScript`, `javascript` and its alias `js` as `JavaScript`
155-
3. The alias `md` as `Markdown
156-
4. All languages with their first names uppercased
157-
158150
- The "go" language code block in `src/content/articles/api-versioning-deprecation-sunset-headers-migration/index.mdx` is not being combined with the "typescript", "python", and "ruby" tabbed code block. Same with "python" block in "### Admission Control Strategies" section of `src/content/articles/backpressure-load-shedding-admission-control-overload/index.mdx`, plus the line numbering is weird in the code block.
159151

160152
- The "❌" symbol is not showing in sql code block, it's rendering as a plain "X". But "✅" renders okay. `src/content/articles/database-schema-migrations-continuous-deployment-zero-downtime/index.mdx`
161153

154+
- Need to be able to highlight the title in a code block to copy it, or at least have a "copy" wrapper on it
155+
162156
- Language "haproxy" is not highlighting Shiki, need to adjust allowed languages in config
163157

164158
- add "cel" language to code blocks

‎src/lib/config/shiki.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { ShikiTransformer } from '@shikijs/types'
22
import type { Element } from 'hast'
3+
import { builtinLanguages } from 'shiki'
34
import { createCssVariablesTheme } from 'shiki/core'
45
import {
56
parseMetaHighlightString,
@@ -124,6 +125,7 @@ export const shikiConfigOptions = {
124125
fontStyle: true,
125126
}),
126127
],
128+
/** MathJax handles math code block rendering, MermaidJS handles mermaid code block rendering */
127129
excludeLangs: ['mermaid', 'math'],
128130
langAlias,
129131
wrap: true,
Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
/**
2+
* Diagnostic test to reproduce the 3-block code-tabs grouping bug.
3+
* Run with: npx vitest run src/lib/markdown/plugins/rehype-code-tabs/__tests__/three-block-repro.spec.ts
4+
*/
5+
import { describe, it, expect } from 'vitest'
6+
import type { Root, Element } from 'hast'
7+
import { remark } from 'remark'
8+
import remarkRehype from 'remark-rehype'
9+
import rehypeStringify from 'rehype-stringify'
10+
import remarkCodeTabs from '@lib/markdown/plugins/remark-code-tabs'
11+
import rehypeCodeTabs from '@lib/markdown/plugins/rehype-code-tabs'
12+
import { unified } from 'unified'
13+
14+
describe('3-block code-tabs grouping', () => {
15+
it('groups 3 consecutive same-group blocks (remark pipeline)', async () => {
16+
const md = [
17+
'```typescript [g1:TypeScript]',
18+
'const x = 1',
19+
'```',
20+
'```php [g1:PHP]',
21+
'<?php echo 1;',
22+
'```',
23+
'```python [g1:Python]',
24+
'print(1)',
25+
'```',
26+
].join('\n')
27+
28+
const html = String(
29+
await remark()
30+
.use(remarkCodeTabs)
31+
.use(remarkRehype)
32+
.use(rehypeCodeTabs)
33+
.use(rehypeStringify)
34+
.process(md)
35+
)
36+
37+
console.log('HTML output:', html)
38+
39+
// All 3 should be inside a single <code-tabs>
40+
const codeTabsCount = (html.match(/<code-tabs/g) || []).length
41+
expect(codeTabsCount, 'Should have exactly 1 <code-tabs> wrapper').toBe(1)
42+
})
43+
44+
it('groups 3 consecutive same-group HAST blocks directly', async () => {
45+
const makePre = (tab: string): Element => ({
46+
type: 'element',
47+
tagName: 'pre',
48+
properties: {},
49+
children: [{
50+
type: 'element',
51+
tagName: 'code',
52+
properties: {
53+
'data-code-tabs-group': 'g1',
54+
'data-code-tabs-tab': tab,
55+
},
56+
children: [{ type: 'text', value: `code for ${tab}` }],
57+
}],
58+
})
59+
60+
const tree: Root = {
61+
type: 'root',
62+
children: [
63+
makePre('TypeScript'),
64+
{ type: 'text', value: '\n' },
65+
makePre('PHP'),
66+
{ type: 'text', value: '\n' },
67+
makePre('Python'),
68+
],
69+
}
70+
71+
const processor = unified().use(rehypeCodeTabs)
72+
await processor.run(tree as never)
73+
74+
const elements = tree.children.filter(
75+
c => (c as Element).type === 'element'
76+
) as Element[]
77+
78+
console.log('Elements:', elements.map(e => `${e.tagName}(${e.children?.length})`))
79+
80+
expect(elements.length, 'Should have exactly 1 top-level element').toBe(1)
81+
expect(elements[0].tagName).toBe('code-tabs')
82+
expect(elements[0].children.length, 'code-tabs should contain all 3 pre blocks').toBe(3)
83+
})
84+
85+
it('unwraps figure-wrapped grouped code blocks and groups them', async () => {
86+
const makePre = (tab: string, lang: string): Element => ({
87+
type: 'element',
88+
tagName: 'pre',
89+
properties: {},
90+
children: [{
91+
type: 'element',
92+
tagName: 'code',
93+
properties: {
94+
className: [`language-${lang}`],
95+
'data-code-tabs-group': 'g1',
96+
'data-code-tabs-tab': tab,
97+
},
98+
children: [{ type: 'text', value: `code for ${tab}` }],
99+
}],
100+
})
101+
102+
// Simulate remark-captions wrapping the 3rd code block in <figure>
103+
const tree: Root = {
104+
type: 'root',
105+
children: [
106+
makePre('TypeScript', 'typescript'),
107+
{ type: 'text', value: '\n' },
108+
makePre('PHP', 'php'),
109+
{ type: 'text', value: '\n' },
110+
{
111+
type: 'element',
112+
tagName: 'figure',
113+
properties: {},
114+
children: [
115+
makePre('Python', 'python'),
116+
{
117+
type: 'element',
118+
tagName: 'figcaption',
119+
properties: {},
120+
children: [{ type: 'text', value: 'Different admission control strategies.' }],
121+
},
122+
],
123+
} as Element,
124+
],
125+
}
126+
127+
const processor = unified().use(rehypeCodeTabs)
128+
await processor.run(tree as never)
129+
130+
const elements = tree.children.filter(
131+
c => (c as Element).type === 'element'
132+
) as Element[]
133+
134+
expect(elements.length, 'Should have exactly 1 top-level element').toBe(1)
135+
expect(elements[0].tagName).toBe('code-tabs')
136+
expect(elements[0].children.length, 'code-tabs should contain all 3 pre blocks').toBe(3)
137+
// Verify all children are <pre> (not figure)
138+
expect(elements[0].children.every(
139+
(c: Element) => c.tagName === 'pre'
140+
)).toBe(true)
141+
})
142+
143+
it('does NOT unwrap figures containing non-grouped code blocks', async () => {
144+
const tree: Root = {
145+
type: 'root',
146+
children: [
147+
{
148+
type: 'element',
149+
tagName: 'figure',
150+
properties: {},
151+
children: [
152+
{
153+
type: 'element',
154+
tagName: 'pre',
155+
properties: {},
156+
children: [{
157+
type: 'element',
158+
tagName: 'code',
159+
properties: { className: ['language-python'] },
160+
children: [{ type: 'text', value: 'print(1)' }],
161+
}],
162+
},
163+
{
164+
type: 'element',
165+
tagName: 'figcaption',
166+
properties: {},
167+
children: [{ type: 'text', value: 'A standalone caption.' }],
168+
},
169+
],
170+
} as Element,
171+
],
172+
}
173+
174+
const processor = unified().use(rehypeCodeTabs)
175+
await processor.run(tree as never)
176+
177+
const elements = tree.children.filter(
178+
c => (c as Element).type === 'element'
179+
) as Element[]
180+
181+
// The figure should be left alone (not unwrapped)
182+
// The pre inside gets standalone-wrapped in code-tabs
183+
expect(elements.length).toBe(1)
184+
// The figure stays as-is, and the standalone wrapping happens inside it
185+
expect(elements[0].tagName).toBe('figure')
186+
})
187+
})

‎src/lib/markdown/plugins/rehype-code-tabs/index.ts‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,35 @@ function shouldSkipSingleWrap(pre: Element): boolean {
133133
return EXCLUDED_SINGLE_WRAP_LANGS.has(lang.toLowerCase())
134134
}
135135

136+
/**
137+
* Replace <figure> wrappers around grouped code blocks with just the <pre> child.
138+
*
139+
* `remark-captions` wraps code blocks followed by "Code: ..." paragraphs in
140+
* `<figure>` elements. When a grouped code block (with `data-code-tabs-group`)
141+
* is inside a figure, it breaks adjacency detection in `wrapCodeTabRuns`.
142+
* This pre-processing step extracts grouped `<pre>` elements from their figure
143+
* wrappers so they can be properly combined into tab groups.
144+
*/
145+
function unwrapGroupedFigures(parent: Parent): void {
146+
const children = parent.children
147+
148+
for (let i = 0; i < children.length; i += 1) {
149+
const node = children[i]
150+
if (!isElement(node) || node.tagName !== 'figure') continue
151+
152+
const pre = node.children.find(
153+
(child): child is Element => isElement(child) && child.tagName === 'pre'
154+
)
155+
if (!pre) continue
156+
157+
// Only unwrap if the <pre> belongs to a tab group
158+
const info = getTabInfoFromPre(pre)
159+
if (!info) continue
160+
161+
children[i] = pre
162+
}
163+
}
164+
136165
function wrapCodeTabRuns(parent: Parent): void {
137166
if (isElement(parent) && parent.tagName === 'code-tabs') return
138167

@@ -208,12 +237,14 @@ function wrapStandaloneCodeBlocks(parent: Parent): void {
208237

209238
const rehypeCodeTabs: Plugin<[], Root> = () => {
210239
return tree => {
240+
unwrapGroupedFigures(tree as unknown as Parent)
211241
wrapCodeTabRuns(tree as unknown as Parent)
212242
wrapStandaloneCodeBlocks(tree as unknown as Parent)
213243

214244
visit(tree, 'element', (node: Element): typeof SKIP | void => {
215245
if (node.tagName === 'code-tabs') return SKIP
216246
if (!isParent(node)) return
247+
unwrapGroupedFigures(node)
217248
wrapCodeTabRuns(node)
218249
wrapStandaloneCodeBlocks(node)
219250
})

0 commit comments

Comments
 (0)