Skip to content

Commit 4c3d438

Browse files
authored
feat(markdown): render heading levels four through six (#82)
renderBlock only matched '#', '##' and '###', so a '#### Title' block fell through to the paragraph branch and rendered its hashes as literal text. One ATX regex now covers levels one through six; '##' still owns TOC anchoring. Adds runtime and shadcn CSS for h4-h6, and a heading section to the component tour so the preview exercises them.
1 parent 7f8c8d3 commit 4c3d438

5 files changed

Lines changed: 119 additions & 8 deletions

File tree

examples/component-tour.html

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -231,6 +231,16 @@
231231
1. A numbered list nested under a bullet
232232
2. Its second entry
233233

234+
## Headings
235+
236+
### Level three sets up a topic
237+
238+
#### Level four splits it into steps
239+
240+
##### Level five carries a detail
241+
242+
###### Level six closes it out
243+
234244
## Markdown and HTML images
235245

236246
![Mountain landscape](https://images.unsplash.com/photo-1535025183041-0991a977e25b?w=800 "Rendered from Markdown")

packages/htmdx/src/components/shadcn/shared/theme.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,22 @@ htmdx-code {
129129
font-weight: 600;
130130
margin: 1rem 0 0.5rem;
131131
}
132+
.htmdx-doc-section-card h4:not([data-slot]) {
133+
font-size: 1rem;
134+
font-weight: 600;
135+
margin: 1rem 0 0.375rem;
136+
}
137+
.htmdx-doc-section-card h5:not([data-slot]) {
138+
font-size: 0.9375rem;
139+
font-weight: 600;
140+
margin: 0.875rem 0 0.375rem;
141+
}
142+
.htmdx-doc-section-card h6:not([data-slot]) {
143+
font-size: 0.875rem;
144+
font-weight: 600;
145+
color: var(--muted-foreground);
146+
margin: 0.875rem 0 0.375rem;
147+
}
132148
.htmdx-doc-section-card p:not([data-slot]) {
133149
font-size: 0.875rem;
134150
line-height: 1.6;

packages/htmdx/src/index.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1475,6 +1475,30 @@ const RUNTIME_CSS = `
14751475
.htmdx-doc-section-card > h3:first-child:not([data-slot]) {
14761476
margin-top: 0;
14771477
}
1478+
.htmdx-doc-section-card h4:not([data-slot]) {
1479+
font-family: var(--md-ref-typeface-brand);
1480+
font-size: 1.25rem;
1481+
line-height: 1.5rem;
1482+
font-weight: 500;
1483+
color: var(--md-sys-color-on-surface);
1484+
margin: 18px 0 8px;
1485+
}
1486+
.htmdx-doc-section-card h5:not([data-slot]) {
1487+
font-family: var(--md-ref-typeface-brand);
1488+
font-size: 1.05rem;
1489+
line-height: 1.4rem;
1490+
font-weight: 500;
1491+
color: var(--md-sys-color-on-surface);
1492+
margin: 16px 0 6px;
1493+
}
1494+
.htmdx-doc-section-card h6:not([data-slot]) {
1495+
font-family: var(--md-ref-typeface-brand);
1496+
font-size: 0.95rem;
1497+
line-height: 1.35rem;
1498+
font-weight: 500;
1499+
color: var(--md-sys-color-on-surface-variant);
1500+
margin: 16px 0 6px;
1501+
}
14781502
.htmdx-doc-section-card > p,
14791503
.htmdx-doc-section-card > ul,
14801504
.htmdx-doc-section-card > div > p,

packages/htmdx/src/react/markdown.tsx

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { MermaidDiagram } from './mermaid';
1717

1818
const INLINE = /\*\*([^*]+)\*\*|`([^`]+)`|\[([^\]]+)\]\(([^)]+)\)/g;
1919
const HTML_TAG = /<\/?([A-Za-z][A-Za-z0-9]*)(?:\s[^>]*)?\/?>/g;
20+
const ATX_HEADING = /^(#{1,6}) /;
2021
const LIST_BLOCK = /^(?:-|\d{1,9}[.)])\s/;
2122
const LIST_ITEM = /^(\s*)(?:-|(\d{1,9})[.)])\s+(.*)$/;
2223
// Indentation is author-controlled, so a document with runaway indentation would
@@ -102,20 +103,20 @@ function renderBlock(
102103
if (fencedCode) {
103104
return fencedCode;
104105
}
105-
if (block.startsWith('### ')) {
106-
return createElement('h3', { key }, renderInline(block.slice(4), html));
107-
}
108-
if (block.startsWith('## ')) {
109-
const label = block.slice(3);
106+
const heading = ATX_HEADING.exec(block);
107+
if (heading) {
108+
const level = heading[1].length;
109+
const label = block.slice(level + 1);
110+
// Only `##` anchors the table of contents; deeper levels are body structure.
111+
if (level !== 2) {
112+
return createElement(`h${level}`, { key }, renderInline(label, html));
113+
}
110114
const id = context ? uniqueSlug(label, context) : slugify(label);
111115
if (context) {
112116
context.headings.push({ id, label });
113117
}
114118
return createElement('h2', { key, id }, renderInline(label, html));
115119
}
116-
if (block.startsWith('# ')) {
117-
return createElement('h1', { key }, renderInline(block.slice(2), html));
118-
}
119120
if (isListBlock(block)) {
120121
return createElement(Fragment, { key }, ...renderLists(parseList(block), 'list', html));
121122
}
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import { renderToStaticMarkup } from 'react-dom/server';
2+
import { describe, expect, test } from 'vitest';
3+
import { compileToReact } from '../src/react';
4+
5+
function article(source: string) {
6+
const container = document.createElement('div');
7+
container.innerHTML = renderToStaticMarkup(compileToReact(source));
8+
return container;
9+
}
10+
11+
describe('atx headings', () => {
12+
test.each([
13+
['#', 'h1'],
14+
['##', 'h2'],
15+
['###', 'h3'],
16+
['####', 'h4'],
17+
['#####', 'h5'],
18+
['######', 'h6'],
19+
])('renders %s as %s', (hashes, tag) => {
20+
const container = article(`${hashes} Interaction`);
21+
const heading = container.querySelector(tag);
22+
23+
expect(heading?.textContent).toBe('Interaction');
24+
expect(container.textContent).not.toContain('#');
25+
});
26+
27+
test('renders inline markup inside a deep heading', () => {
28+
const container = article('#### 1. `capture` **native** selection');
29+
30+
expect(container.querySelector('h4 code')?.textContent).toBe('capture');
31+
expect(container.querySelector('h4 strong')?.textContent).toBe('native');
32+
});
33+
34+
test('anchors h2 for the table of contents and leaves deeper levels unanchored', () => {
35+
const container = article('## Case\n\n#### Deep\n\n##### Deeper');
36+
37+
expect(container.querySelector('h2')?.getAttribute('id')).toBe('case');
38+
expect(container.querySelector('h4')?.hasAttribute('id')).toBe(false);
39+
expect(container.querySelector('h5')?.hasAttribute('id')).toBe(false);
40+
});
41+
42+
test('leaves a seventh level as prose, the way CommonMark does', () => {
43+
const container = article('####### Too deep');
44+
45+
expect(container.querySelector('p')?.textContent).toBe('####### Too deep');
46+
});
47+
48+
test('leaves a hash without a space as prose', () => {
49+
const container = article('####Tight');
50+
51+
expect(container.querySelector('p')?.textContent).toBe('####Tight');
52+
});
53+
54+
test('keeps a hash line inside a fence literal', () => {
55+
const container = article('```text\n#### Not a heading\n```');
56+
57+
expect(container.querySelector('h4')).toBeNull();
58+
expect(container.querySelector('pre')?.textContent).toBe('#### Not a heading');
59+
});
60+
});

0 commit comments

Comments
 (0)