Skip to content

Commit 99ece28

Browse files
chore(use-cases): drop Detail wrapper + add markdown renderer tests (#18)
Two related cleanups: 1. UseCaseDetailPage no longer wraps the body in a redundant "Detail" <h2> section. With every case now carrying its own ## Sample agent prompt / ## Steps to follow / ## Why this works / ## Related cases headings, the wrapper was forcing those to render as h3 — a misleading nesting level that pushed real semantic structure too deep. Body now renders at baseHeading=h2 so its sections are peers of the page's own h1 hero. The auto-generated "How to set it up" and "Why this is useful" sections move into an AutoDetail fallback component used only when a case has no body yet (currently no cases, kept for future resilience). 2. New unit tests at src/lib/markdown.test.tsx covering the shared markdown renderer — every block construct (## / ### / fenced code / -/* bullets / 1./2. ordered lists / > blockquote / | tables / paragraphs), every inline construct (**bold**, \`code\`, [text](url)), the href-safety check (rejects javascript:, data:, vbscript:; allows /, #, http(s)://), and configurable baseHeading. 26 tests, all pass in ~4ms. Closes the test-coverage gap the dashboard sweep agent flagged. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent f47e067 commit 99ece28

2 files changed

Lines changed: 228 additions & 69 deletions

File tree

src/lib/markdown.test.tsx

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
/* markdown.test.tsx — coverage for the shared markdown renderer.
2+
*
3+
* The renderer is used by /docs, /blog/:slug, and /use-cases/:slug. A bug
4+
* in it affects every public content surface. These tests exercise each
5+
* supported block + inline construct and a few security boundaries
6+
* (unsafe href schemes, HTML escaping). */
7+
8+
import { describe, it, expect } from 'vitest'
9+
import { renderToStaticMarkup } from 'react-dom/server'
10+
import { renderMarkdown, inline } from './markdown'
11+
12+
function html(md: string, opts?: Parameters<typeof renderMarkdown>[1]) {
13+
return renderToStaticMarkup(<>{renderMarkdown(md, opts)}</>)
14+
}
15+
16+
function htmlInline(text: string) {
17+
return renderToStaticMarkup(<>{inline(text)}</>)
18+
}
19+
20+
describe('renderMarkdown — block constructs', () => {
21+
it('renders ## as the configured base heading (default h3)', () => {
22+
expect(html('## Hello')).toBe('<h3>Hello</h3>')
23+
})
24+
25+
it('respects baseHeading=h2', () => {
26+
expect(html('## Hello', { baseHeading: 'h2' })).toBe('<h2>Hello</h2>')
27+
})
28+
29+
it('renders # one level above the base', () => {
30+
expect(html('# Hello', { baseHeading: 'h2' })).toBe('<h1>Hello</h1>')
31+
})
32+
33+
it('renders ### one level below ##', () => {
34+
expect(html('### Sub', { baseHeading: 'h2' })).toBe('<h3>Sub</h3>')
35+
})
36+
37+
it('clamps heading level into h1-h6', () => {
38+
// Past h6 should pin to h6, never overflow
39+
expect(html('### deep\n\n#### deeper\n\n##### deepest', { baseHeading: 'h6' }))
40+
.toContain('<h6>')
41+
})
42+
43+
it('renders fenced code blocks as <pre><code>', () => {
44+
expect(html('```\nfoo\nbar\n```')).toBe('<pre><code>foo\nbar</code></pre>')
45+
})
46+
47+
it('strips the language hint from the fence', () => {
48+
expect(html('```bash\nls\n```')).toBe('<pre><code>ls</code></pre>')
49+
})
50+
51+
it('renders unordered lists with - bullets', () => {
52+
expect(html('- one\n- two')).toBe('<ul><li>one</li><li>two</li></ul>')
53+
})
54+
55+
it('renders unordered lists with * bullets', () => {
56+
expect(html('* one\n* two')).toBe('<ul><li>one</li><li>two</li></ul>')
57+
})
58+
59+
it('renders ordered lists with 1. 2. notation as <ol>', () => {
60+
expect(html('1. first\n2. second')).toBe('<ol><li>first</li><li>second</li></ol>')
61+
})
62+
63+
it('renders > as <blockquote>', () => {
64+
expect(html('> quoted')).toBe('<blockquote>quoted</blockquote>')
65+
})
66+
67+
it('renders | as a styled pre table', () => {
68+
const out = html('| a | b |\n| - | - |\n| 1 | 2 |')
69+
expect(out).toContain('class="md-table"')
70+
expect(out).toContain('<pre')
71+
})
72+
73+
it('falls back to <p> for plain text', () => {
74+
expect(html('Just a paragraph.')).toBe('<p>Just a paragraph.</p>')
75+
})
76+
77+
it('separates blocks on blank lines', () => {
78+
expect(html('## H\n\nbody')).toBe('<h3>H</h3><p>body</p>')
79+
})
80+
})
81+
82+
describe('inline — token rendering', () => {
83+
it('renders **bold**', () => {
84+
expect(htmlInline('a **bold** b')).toBe('a <strong>bold</strong> b')
85+
})
86+
87+
it('renders `code`', () => {
88+
expect(htmlInline('use `npm test` to run')).toBe('use <code>npm test</code> to run')
89+
})
90+
91+
it('renders [text](url) as <a>', () => {
92+
expect(htmlInline('see [docs](/docs.md)')).toBe('see <a href="/docs.md">docs</a>')
93+
})
94+
95+
it('renders [text](https://...) as external <a>', () => {
96+
expect(htmlInline('on [GitHub](https://github.com)')).toBe('on <a href="https://github.com">GitHub</a>')
97+
})
98+
99+
it('picks the earliest token when multiple are present', () => {
100+
expect(htmlInline('**bold** and `code`'))
101+
.toBe('<strong>bold</strong> and <code>code</code>')
102+
})
103+
104+
it('passes plain text through unchanged', () => {
105+
expect(htmlInline('just words')).toBe('just words')
106+
})
107+
})
108+
109+
describe('inline — href safety', () => {
110+
it('rejects javascript: URLs (renders as literal text)', () => {
111+
const out = htmlInline('[click](javascript:alert(1))')
112+
expect(out).not.toContain('<a')
113+
expect(out).toContain('[click]')
114+
})
115+
116+
it('rejects data: URLs', () => {
117+
const out = htmlInline('[img](data:text/html,<script>alert(1)</script>)')
118+
expect(out).not.toContain('<a')
119+
})
120+
121+
it('rejects vbscript: URLs', () => {
122+
const out = htmlInline('[x](vbscript:msgbox)')
123+
expect(out).not.toContain('<a')
124+
})
125+
126+
it('allows /relative routes', () => {
127+
expect(htmlInline('[x](/foo)')).toBe('<a href="/foo">x</a>')
128+
})
129+
130+
it('allows # anchors', () => {
131+
expect(htmlInline('[x](#section)')).toBe('<a href="#section">x</a>')
132+
})
133+
})
134+
135+
describe('renderMarkdown — keyPrefix isolation', () => {
136+
it('produces stable keys per prefix (smoke test, no error from duplicate keys)', () => {
137+
// Two side-by-side renders with the same prefix shouldn't crash;
138+
// React only complains about dup keys among siblings, and these
139+
// are separate trees, so this is a "doesn't throw" assertion.
140+
const a = html('## Foo\n\nbody', { keyPrefix: 'a' })
141+
const b = html('## Foo\n\nbody', { keyPrefix: 'b' })
142+
expect(a).toBe('<h3>Foo</h3><p>body</p>')
143+
expect(b).toBe('<h3>Foo</h3><p>body</p>')
144+
})
145+
})

src/pages/UseCaseDetailPage.tsx

Lines changed: 83 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -90,72 +90,15 @@ function Detail({ useCase }: { useCase: UseCase }) {
9090
<p className="ucd-scenario">{useCase.scenario}</p>
9191
</header>
9292

93-
{useCase.services.length > 0 && (
94-
<section className="ucd-section">
95-
<h2>How to set it up</h2>
96-
<p className="ucd-section-lede">
97-
This scenario uses {useCase.services.length}{' '}
98-
{useCase.services.length === 1 ? 'service' : 'services'} from instanode.dev.
99-
Each is one HTTP call to provision — no signup, no Docker.
100-
</p>
101-
<ol className="ucd-steps">
102-
{useCase.services.map((s, i) => {
103-
const info = SERVICE_INFO[s]
104-
return (
105-
<li key={s} className="ucd-step">
106-
<p className="ucd-step-title">
107-
<span className="ucd-step-num">{i + 1}.</span> Provision {info.label}
108-
</p>
109-
<p className="ucd-step-gets">{info.gets}</p>
110-
<pre className="ucd-step-curl"><code>{info.curl}</code></pre>
111-
</li>
112-
)
113-
})}
114-
<li className="ucd-step">
115-
<p className="ucd-step-title">
116-
<span className="ucd-step-num">{useCase.services.length + 1}.</span> Wire your agent or app to the returned connection URLs
117-
</p>
118-
<p className="ucd-step-gets">
119-
Every response includes a <code>connection_url</code> (or <code>receive_url</code> for
120-
webhooks, <code>endpoint</code> for storage) plus an <code>upgrade_jwt</code> you can
121-
hand to <code>/claim</code> when you want to keep the resource past the 24-hour
122-
anonymous window.
123-
</p>
124-
</li>
125-
</ol>
126-
</section>
127-
)}
128-
129-
<section className="ucd-section">
130-
<h2>Why this is useful</h2>
131-
<ul className="ucd-bullets">
132-
<li>
133-
<strong>Zero ceremony.</strong> No signup, no API key, no Docker, no cloud account.
134-
The first call returns a real resource in under a second.
135-
</li>
136-
<li>
137-
<strong>Anonymous-first.</strong> The 24-hour anonymous tier is the trial — every
138-
resource expires unless you claim it. No credit card needed to try.
139-
</li>
140-
<li>
141-
<strong>Real infrastructure, not a sandbox.</strong> Every Postgres is a real
142-
Postgres, every Redis a real Redis. Your code that works on instanode.dev works
143-
against any standard hosted version when you migrate.
144-
</li>
145-
<li>
146-
<strong>Designed for agents.</strong> Single HTTP calls fit in muscle memory for
147-
LLM tool use. Predictable JSON responses, OpenAPI 3.1 spec at <code>/openapi.json</code>.
148-
</li>
149-
</ul>
150-
</section>
151-
152-
{useCase.body && (
153-
<section className="ucd-section ucd-section-custom">
154-
<h2>Detail</h2>
155-
<div className="ucd-body">
156-
{renderMarkdown(useCase.body, { baseHeading: 'h3', keyPrefix: useCase.slug })}
157-
</div>
158-
</section>
93+
{useCase.body ? (
94+
<div className="ucd-body">
95+
{renderMarkdown(useCase.body, { baseHeading: 'h2', keyPrefix: useCase.slug })}
96+
</div>
97+
) : (
98+
/* Fallback for cases without a hand-authored body. Today every
99+
* case has one; this branch exists for resilience if a new
100+
* case ships in the content repo before its body is written. */
101+
<AutoDetail services={useCase.services} />
159102
)}
160103

161104
<footer className="ucd-foot">
@@ -172,6 +115,76 @@ function Detail({ useCase }: { useCase: UseCase }) {
172115
)
173116
}
174117

118+
/* AutoDetail — fallback section block rendered only when a use case
119+
* ships without a hand-authored body. Lists the curls per service and
120+
* a generic value-prop bullet list — enough that the page is useful
121+
* even for an un-authored case. Once a body lands in the content repo
122+
* for the slug, this fallback is hidden and the body takes over. */
123+
function AutoDetail({ services }: { services: Service[] }) {
124+
return (
125+
<>
126+
{services.length > 0 && (
127+
<section className="ucd-section">
128+
<h2>How to set it up</h2>
129+
<p className="ucd-section-lede">
130+
This scenario uses {services.length}{' '}
131+
{services.length === 1 ? 'service' : 'services'} from instanode.dev.
132+
Each is one HTTP call to provision — no signup, no Docker.
133+
</p>
134+
<ol className="ucd-steps">
135+
{services.map((s, i) => {
136+
const info = SERVICE_INFO[s]
137+
return (
138+
<li key={s} className="ucd-step">
139+
<p className="ucd-step-title">
140+
<span className="ucd-step-num">{i + 1}.</span> Provision {info.label}
141+
</p>
142+
<p className="ucd-step-gets">{info.gets}</p>
143+
<pre className="ucd-step-curl"><code>{info.curl}</code></pre>
144+
</li>
145+
)
146+
})}
147+
<li className="ucd-step">
148+
<p className="ucd-step-title">
149+
<span className="ucd-step-num">{services.length + 1}.</span> Wire your agent or app to the returned connection URLs
150+
</p>
151+
<p className="ucd-step-gets">
152+
Every response includes a <code>connection_url</code> (or <code>receive_url</code> for
153+
webhooks, <code>endpoint</code> for storage) plus an <code>upgrade_jwt</code> you can
154+
hand to <code>/claim</code> when you want to keep the resource past the 24-hour
155+
anonymous window.
156+
</p>
157+
</li>
158+
</ol>
159+
</section>
160+
)}
161+
162+
<section className="ucd-section">
163+
<h2>Why this is useful</h2>
164+
<ul className="ucd-bullets">
165+
<li>
166+
<strong>Zero ceremony.</strong> No signup, no API key, no Docker, no cloud account.
167+
The first call returns a real resource in under a second.
168+
</li>
169+
<li>
170+
<strong>Anonymous-first.</strong> The 24-hour anonymous tier is the trial — every
171+
resource expires unless you claim it. No credit card needed to try.
172+
</li>
173+
<li>
174+
<strong>Real infrastructure, not a sandbox.</strong> Every Postgres is a real
175+
Postgres, every Redis a real Redis. Your code that works on instanode.dev works
176+
against any standard hosted version when you migrate.
177+
</li>
178+
<li>
179+
<strong>Designed for agents.</strong> Single HTTP calls fit in muscle memory for
180+
LLM tool use. Predictable JSON responses, OpenAPI 3.1 spec at <code>/openapi.json</code>.
181+
</li>
182+
</ul>
183+
</section>
184+
</>
185+
)
186+
}
187+
175188
function primaryCurl(services: Service[]): string {
176189
if (services.length === 0) return 'curl -X POST https://api.instanode.dev/db/new'
177190
return SERVICE_INFO[services[0]].curl
@@ -221,9 +234,10 @@ function DetailStyles() {
221234
.ucd-bullets li { margin: 0 0 10px; }
222235
.ucd-bullets strong { color: var(--text); font-weight: 600; }
223236
224-
.ucd-section-custom h3 { font-size: 18px; margin: 24px 0 8px; color: var(--text); }
225-
.ucd-section-custom h4 { font-size: 15px; margin: 18px 0 6px; color: var(--text); }
226-
.ucd-body { color: var(--text); font-size: 15px; line-height: 1.65; }
237+
.ucd-body { color: var(--text); font-size: 15px; line-height: 1.65; margin: 40px 0; }
238+
.ucd-body h2 { font-size: 22px; margin: 40px 0 14px; letter-spacing: -0.01em; color: var(--text); }
239+
.ucd-body h3 { font-size: 18px; margin: 24px 0 8px; color: var(--text); }
240+
.ucd-body h4 { font-size: 15px; margin: 18px 0 6px; color: var(--text); }
227241
.ucd-body p { margin: 0 0 14px; }
228242
.ucd-body code {
229243
background: var(--ink); border: 1px solid var(--border); color: var(--text);

0 commit comments

Comments
 (0)