Skip to content

Commit 44750d4

Browse files
bogdanpricopclaude
andcommitted
fix(howto): render markdown guides as HTML instead of a wall of text
Reported: several How-To guides are unformatted text run together, everything with "Proxmox" in the name among them. Cause: the loader stores each guide body straight into howto_guides.content and the frontend injects that as HTML. Guides seeded from migrations are HTML, so they render. The 85 guides authored as markdown were injected verbatim, so their headings, lists, tables and code fences arrived as one undifferentiated block. Not a Proxmox problem — those were simply the ones noticed. - Add src/utils/markdown.js and render bodies at import time. Deliberately not a dependency: the corpus is ours and its surface is small, and a corpus test over all 154 shipped files proves the coverage is real. - Raw HTML passes through untouched, the way CommonMark treats HTML blocks. This is what makes the change safe rather than destructive: 133 of the 154 files contain HTML and 89 mix it with markdown. It is not a sanitizer, and guide content is administrator-authored and shipped, exactly as before. - Track fenced-code state, so the hundreds of `# comment` lines inside bash blocks stay comments instead of becoming headings. - Lift code spans out before emphasis, so `**x**` inside a snippet stays literal. The placeholder is NUL-delimited; an index-based one turned any standalone digit in the prose into a code span. - Leave underscores alone. They appear constantly in env-var names and paths, where emphasis is never what was meant. - Add styling for what markdown produces and the stylesheet never covered: tables, h1, h4-h6, horizontal rules, nested lists, images. - Update the two loader assertions that pinned the old raw-passthrough behaviour, since storing HTML is now the point. Verified against a running server: the three Proxmox guides come back with 10-14 headings, 8-20 lists, 2-5 code blocks and 1-2 tables each, and no markdown left over. Full suite: 330 suites, 4060 passing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent aacc064 commit 44750d4

6 files changed

Lines changed: 418 additions & 6 deletions

File tree

CHANGELOG.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,21 @@ operator what the tool actually does, and what their containers actually are.
3030
- Add a shared per-scan `docker info` cache to the posture context, English and
3131
Romanian labels, and 41 tests including call-count assertions on the cost gate.
3232

33+
### How-To guides render as formatted documents again
34+
35+
- Render markdown how-to bodies to HTML when the loader imports them. The
36+
frontend injects `content` as HTML, so the 85 guides authored as markdown had
37+
been arriving as one undifferentiated wall of text — headings, lists, tables
38+
and code fences all flattened. Everything with "Proxmox" in the name was in
39+
that set.
40+
- Raw HTML in a body passes through untouched, which is what makes the fix safe:
41+
133 of the 154 shipped files contain HTML and 89 mix it with markdown.
42+
- Add styling for the constructs markdown produces that had none — tables,
43+
`h1`/`h4`-`h6`, horizontal rules, nested lists and images.
44+
- Add a renderer with 343 tests, including a pass over every shipped guide
45+
asserting no markdown survives outside code blocks and no pre-existing HTML
46+
is lost.
47+
3348
### CLI transparency
3449

3550
- Add a pure derivation service that turns an action key plus typed parameters

public/css/app.css

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2943,6 +2943,21 @@ a:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-
29432943
.howto-content strong { color: var(--text-bright); }
29442944
.howto-content a { color: var(--accent); text-decoration: none; }
29452945
.howto-content a:hover { text-decoration: underline; }
2946+
/* v8.94.2 — markdown-authored guides also produce h1/h4, tables and rules,
2947+
which had no styling and rendered as unformatted runs of text. */
2948+
.howto-content h1 { font-size: 18px; color: var(--text-white); margin: 22px 0 10px; padding-bottom: 5px; border-bottom: 1px solid var(--border); }
2949+
.howto-content h1:first-child, .howto-content h2:first-child { margin-top: 0; }
2950+
.howto-content h4 { font-size: 13px; color: var(--text-bright); margin: 14px 0 5px; text-transform: uppercase; letter-spacing: 0.4px; }
2951+
.howto-content h5, .howto-content h6 { font-size: 12px; color: var(--text-bright); margin: 12px 0 4px; }
2952+
.howto-content hr { border: 0; border-top: 1px solid var(--border); margin: 18px 0; }
2953+
.howto-content ul ul, .howto-content ol ol, .howto-content ul ol, .howto-content ol ul { margin: 4px 0; }
2954+
.howto-content table, .howto-content .howto-table {
2955+
width: 100%; border-collapse: collapse; margin: 12px 0; font-size: 12px; display: block; overflow-x: auto;
2956+
}
2957+
.howto-content th, .howto-content td { border: 1px solid var(--border); padding: 6px 10px; text-align: left; vertical-align: top; }
2958+
.howto-content th { background: var(--surface2); color: var(--text-bright); font-weight: 600; white-space: nowrap; }
2959+
.howto-content tbody tr:nth-child(even) { background: rgba(255,255,255,0.02); }
2960+
.howto-content img { max-width: 100%; height: auto; }
29462961

29472962
/* ── Onboarding & Provisioning Wizard (v8.15.0) — components/wizard.js ── */
29482963
.wiz-overlay { position: fixed; inset: 0; z-index: 11000; background: rgba(0,0,0,0.7); display: flex; align-items: center; justify-content: center; padding: 16px; }

src/__tests__/howto-loader.test.js

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -241,8 +241,10 @@ describe('howto-loader — loadAll (DB integration)', () => {
241241
expect(row.title_ro).toBe('Ghid Nginx');
242242
expect(row.summary).toBe('EN summary');
243243
expect(row.summary_ro).toBe('Sumar RO');
244-
expect(row.content).toBe('EN body content');
245-
expect(row.content_ro).toBe('RO body content');
244+
// v8.94.2 — bodies are rendered to HTML on load. The frontend injects
245+
// `content` as HTML, so storing raw markdown produced a wall of text.
246+
expect(row.content).toBe('<p>EN body content</p>');
247+
expect(row.content_ro).toBe('<p>RO body content</p>');
246248
});
247249
});
248250

@@ -260,7 +262,7 @@ describe('howto-loader — loadAll (DB integration)', () => {
260262
expect(result.loaded).toBe(1);
261263
let row = db.prepare('SELECT * FROM howto_guides WHERE slug = ?').get(slug);
262264
expect(row.title).toBe('First');
263-
expect(row.content).toBe('v1 body');
265+
expect(row.content).toBe('<p>v1 body</p>');
264266

265267
// Second load: UPDATE (same slug, new content)
266268
fs.writeFileSync(
@@ -273,7 +275,7 @@ describe('howto-loader — loadAll (DB integration)', () => {
273275
expect(result.loaded).toBe(1);
274276
row = db.prepare('SELECT * FROM howto_guides WHERE slug = ?').get(slug);
275277
expect(row.title).toBe('Second');
276-
expect(row.content).toBe('v2 body');
278+
expect(row.content).toBe('<p>v2 body</p>');
277279

278280
// Still exactly one row
279281
const count = db.prepare('SELECT COUNT(*) as n FROM howto_guides').get().n;

src/__tests__/markdown.test.js

Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
'use strict';
2+
3+
// v8.94.2 — markdown renderer for how-to guides.
4+
//
5+
// Two kinds of coverage: focused cases for each construct, and a corpus pass
6+
// over all 154 shipped guides. The corpus pass is the one that matters — it is
7+
// the actual input, and it proves both that markdown is converted and that the
8+
// HTML already present in 133 of those files survives.
9+
10+
const fs = require('fs');
11+
const path = require('path');
12+
const md = require('../utils/markdown');
13+
const { _parseFrontMatter } = require('../services/howto-loader');
14+
15+
const CONTENT_DIR = path.join(__dirname, '..', 'db', 'howto-content');
16+
17+
describe('markdown — headings', () => {
18+
it('renders each level', () => {
19+
expect(md.render('# One')).toBe('<h1>One</h1>');
20+
expect(md.render('## Two')).toBe('<h2>Two</h2>');
21+
expect(md.render('###### Six')).toBe('<h6>Six</h6>');
22+
});
23+
24+
it('requires a space, so a bare hash is prose', () => {
25+
expect(md.render('#hashtag')).toBe('<p>#hashtag</p>');
26+
});
27+
28+
it('does not treat shell comments inside a fence as headings', () => {
29+
// This is the single most common false positive in the corpus: hundreds of
30+
// `# comment` lines live inside bash blocks.
31+
const out = md.render('```bash\n# install docker\napt install docker\n```');
32+
expect(out).toContain('<pre><code class="language-bash">');
33+
expect(out).toContain('# install docker');
34+
expect(out).not.toContain('<h1>');
35+
});
36+
});
37+
38+
describe('markdown — code', () => {
39+
it('escapes HTML inside fences', () => {
40+
const out = md.render('```\n<script>alert(1)</script>\n```');
41+
expect(out).toContain('&lt;script&gt;');
42+
expect(out).not.toContain('<script>');
43+
});
44+
45+
it('renders inline code and escapes it', () => {
46+
expect(md.render('use `docker ps` now')).toBe('<p>use <code>docker ps</code> now</p>');
47+
expect(md.render('`a < b`')).toContain('<code>a &lt; b</code>');
48+
});
49+
50+
it('leaves emphasis markers inside code spans alone', () => {
51+
expect(md.render('`**not bold**`')).toBe('<p><code>**not bold**</code></p>');
52+
});
53+
54+
it('does not turn a standalone number into a code span', () => {
55+
// Regression: an index-based placeholder made any bare digit a code span.
56+
expect(md.render('scale to 3 replicas')).toBe('<p>scale to 3 replicas</p>');
57+
expect(md.render('`x` and 0 and `y`')).toBe('<p><code>x</code> and 0 and <code>y</code></p>');
58+
});
59+
60+
it('handles an unterminated fence without hanging', () => {
61+
expect(md.render('```\nstuff')).toContain('<pre><code>stuff</code></pre>');
62+
});
63+
});
64+
65+
describe('markdown — emphasis and links', () => {
66+
it('renders bold and italic', () => {
67+
expect(md.render('**bold**')).toBe('<p><strong>bold</strong></p>');
68+
expect(md.render('*italic*')).toBe('<p><em>italic</em></p>');
69+
});
70+
71+
it('leaves underscores in identifiers alone', () => {
72+
// DD_PROVIDER_X and /var/log/some_file must not become emphasis.
73+
expect(md.render('set DD_SOME_VAR now')).toBe('<p>set DD_SOME_VAR now</p>');
74+
});
75+
76+
it('renders links as external', () => {
77+
const out = md.render('[docs](https://example.com/x)');
78+
expect(out).toContain('href="https://example.com/x"');
79+
expect(out).toContain('rel="noopener"');
80+
});
81+
});
82+
83+
describe('markdown — lists', () => {
84+
it('renders bullets and ordered lists', () => {
85+
expect(md.render('- a\n- b')).toBe('<ul>\n<li>a</li>\n<li>b</li>\n</ul>');
86+
expect(md.render('1. a\n2. b')).toBe('<ol>\n<li>a</li>\n<li>b</li>\n</ol>');
87+
});
88+
89+
it('nests by indentation and closes every level', () => {
90+
const out = md.render('- a\n - b\n- c');
91+
expect(out).toContain('<ul>');
92+
expect((out.match(/<ul>/g) || []).length).toBe(2);
93+
expect((out.match(/<\/ul>/g) || []).length).toBe(2);
94+
});
95+
96+
it('applies inline formatting inside items', () => {
97+
expect(md.render('- run `ls` **now**')).toContain('<li>run <code>ls</code> <strong>now</strong></li>');
98+
});
99+
});
100+
101+
describe('markdown — tables', () => {
102+
it('renders a header and body', () => {
103+
const out = md.render('| A | B |\n|---|---|\n| 1 | 2 |');
104+
expect(out).toContain('<th>A</th><th>B</th>');
105+
expect(out).toContain('<td>1</td><td>2</td>');
106+
});
107+
108+
it('needs a separator row, otherwise it is prose', () => {
109+
expect(md.render('| not | a table |')).toContain('<p>');
110+
});
111+
112+
it('supports alignment markers', () => {
113+
expect(md.render('| A | B |\n|:--|--:|\n| 1 | 2 |')).toContain('<th>A</th>');
114+
});
115+
});
116+
117+
describe('markdown — raw HTML passthrough', () => {
118+
it('leaves an HTML block untouched', () => {
119+
const html = '<div class="tip">Keep me</div>';
120+
expect(md.render(html)).toBe(html);
121+
});
122+
123+
it('preserves HTML mixed with markdown in one document', () => {
124+
const out = md.render('## Title\n\n<p class="lead">HTML here</p>\n\n- item');
125+
expect(out).toContain('<h2>Title</h2>');
126+
expect(out).toContain('<p class="lead">HTML here</p>');
127+
expect(out).toContain('<li>item</li>');
128+
});
129+
});
130+
131+
describe('markdown — robustness', () => {
132+
it('tolerates empty and nullish input', () => {
133+
expect(md.render('')).toBe('');
134+
expect(md.render(null)).toBe('');
135+
expect(md.render(undefined)).toBe('');
136+
});
137+
138+
it('normalises CRLF', () => {
139+
expect(md.render('# A\r\n\r\ntext')).toBe('<h1>A</h1>\n<p>text</p>');
140+
});
141+
});
142+
143+
describe('markdown — the shipped how-to corpus', () => {
144+
const files = fs.existsSync(CONTENT_DIR)
145+
? fs.readdirSync(CONTENT_DIR).filter(f => /^[a-z0-9-]+(\.[a-z]{2})?\.md$/.test(f))
146+
: [];
147+
148+
it('has guides to check', () => {
149+
expect(files.length).toBeGreaterThan(100);
150+
});
151+
152+
it.each(files)('%s renders with no markdown left over', (file) => {
153+
const parsed = _parseFrontMatter(fs.readFileSync(path.join(CONTENT_DIR, file), 'utf8'));
154+
expect(parsed).not.toBeNull();
155+
const html = md.render(parsed.body);
156+
// Code blocks legitimately contain markdown-looking text; everything else
157+
// must have been converted.
158+
const outside = html.replace(/<pre>[\s\S]*?<\/pre>/g, '');
159+
expect(outside).not.toMatch(/^#{1,6}\s+\S/m);
160+
expect(outside).not.toMatch(/\*\*[^*\n]+\*\*/);
161+
expect(outside).not.toMatch(/^\s*[-*+]\s+\S/m);
162+
expect(outside).not.toMatch(/^\s*\|.+\|\s*$/m);
163+
});
164+
165+
it.each(files)('%s keeps the HTML it already contained', (file) => {
166+
const parsed = _parseFrontMatter(fs.readFileSync(path.join(CONTENT_DIR, file), 'utf8'));
167+
const html = md.render(parsed.body);
168+
for (const tag of ['h2', 'h3', 'table', 'ul', 'div']) {
169+
const before = (parsed.body.match(new RegExp(`<${tag}\\b`, 'gi')) || []).length;
170+
const after = (html.match(new RegExp(`<${tag}\\b`, 'gi')) || []).length;
171+
expect(`${file}:${tag}:${after >= before}`).toBe(`${file}:${tag}:true`);
172+
}
173+
});
174+
});

src/services/howto-loader.js

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
const fs = require('fs');
2323
const path = require('path');
2424
const log = require('../utils/logger')('howto-loader');
25+
const markdown = require('../utils/markdown');
2526

2627
const CONTENT_DIR = path.join(__dirname, '..', 'db', 'howto-content');
2728

@@ -138,8 +139,12 @@ function loadAll(db) {
138139
enMeta.icon || '',
139140
enMeta.summary || '',
140141
roMeta.summary || '',
141-
enBody || '',
142-
roBody || '',
142+
// v8.94.2 — convert to HTML before storing. The frontend injects
143+
// `content` as HTML, so a markdown body used to arrive as one wall of
144+
// text. Raw HTML in a body passes through untouched, which matters:
145+
// most bodies mix the two.
146+
enBody ? markdown.render(enBody) : '',
147+
roBody ? markdown.render(roBody) : '',
143148
);
144149
loaded++;
145150
} catch (err) {

0 commit comments

Comments
 (0)