Skip to content

Commit 23ffb81

Browse files
feat(public): homepage use-cases section + shared markdown renderer (#15)
Two changes that ship together: 1. Homepage use-cases section. /use-cases was missing from the marketing front door. Adds a "Use cases · 100+ shapes the platform fits" section between the how-it-works and pricing teasers. Renders 6 curated exemplar cards (one per major archetype: ephemeral test DB, one-afternoon MVP, agent memory, LangGraph fan-out, Devin-style PR bot, Stripe webhook handler) plus a "See all 104 use cases →" CTA. Cards link to /use-cases/<slug>; missing slugs simply skip rendering — no broken links. 2. Shared markdown renderer at src/lib/markdown.tsx. The three places that render body content (UseCaseDetailPage, BlogPostPage, DocsPage) all had near-identical inline renderMarkdown + inline functions with subtly different behaviours. Consolidated into one module with: - Heading levels h1–h6 (was h2–h6 before; needed for blog `#`) - Numbered lists `1. foo` → <ol> (was unsupported) - Markdown links `[text](url)` with href safety check (http/https/anchor/internal-route only; blocks javascript:) - Blockquotes `> ` → <blockquote> - ASCII tables `|` → <pre class="md-table"> (preserves /docs limits-table behaviour from the old DocsPage renderer) - Configurable baseHeading per call site so headings nest correctly under each page's existing h1/h2 wrappers - keyPrefix option to avoid React-key collisions when one parent renders multiple bodies BlogPostPage, DocsPage, UseCaseDetailPage now import from src/lib/markdown. The three sites of duplication (~80 lines each) are gone. Verified: npm run build → 115 static HTML files. Homepage shows 6 cards with correct slugs + "See all 104" CTA. /docs renders the tier-limits table via .md-table class. /blog/<slug> still renders its headings unchanged. /use-cases/<slug> detail body section remains empty (auto-generated detail still renders); next content commit will populate all 104 bodies via 4 parallel authoring agents. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent c214e98 commit 23ffb81

5 files changed

Lines changed: 304 additions & 163 deletions

File tree

src/lib/markdown.tsx

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
/* markdown.tsx — shared minimal markdown renderer.
2+
*
3+
* The dashboard's public pages (blog posts, /docs, /use-cases detail
4+
* pages) all render content authored in markdown from the
5+
* InstaNode-dev/content repo. They share this renderer to keep
6+
* behaviour identical across surfaces.
7+
*
8+
* SUPPORTED SYNTAX (intentionally minimal — anything not listed is
9+
* rendered as a plain paragraph):
10+
* ## Heading
11+
* ### Subheading
12+
* - bulleted item
13+
* 1. numbered item
14+
* > blockquote
15+
* ```fenced code block```
16+
* inline `code`
17+
* **bold**
18+
* [link text](https://example.com) — http/https/anchor/relative
19+
*
20+
* NOT SUPPORTED (deliberate, for XSS safety and to keep this tiny):
21+
* Raw HTML pass-through, images, tables, footnotes, definition lists,
22+
* strike-through, task lists. If the content repo needs any of these,
23+
* extend this file — don't sprinkle markdown libraries into individual
24+
* pages.
25+
*
26+
* SECURITY: hrefs are validated to start with http://, https://, /, or
27+
* # before being rendered. Anything else falls back to plain text — this
28+
* blocks `javascript:` URLs even though content comes from a (trusted)
29+
* public repo. */
30+
31+
import type { ReactNode } from 'react'
32+
33+
type Heading = 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6'
34+
35+
export type RenderOptions = {
36+
/* Base heading level for `## ` blocks. Use this when the body content
37+
* is rendered under an existing `<h2>` wrapper — pass 'h3' so the
38+
* body's top-level headings become h3 and `### ` becomes h4. */
39+
baseHeading?: Heading
40+
/* Stable prefix for React keys. Avoids collisions when multiple
41+
* renderMarkdown calls share a parent component. */
42+
keyPrefix?: string
43+
}
44+
45+
export function renderMarkdown(md: string, opts: RenderOptions = {}): ReactNode {
46+
const baseLevel = parseInt((opts.baseHeading ?? 'h3').slice(1), 10)
47+
const prefix = opts.keyPrefix ?? 'md'
48+
const blocks = md.trim().split(/\n\n+/)
49+
50+
return blocks.map((rawBlock, i) => {
51+
const block = rawBlock.trimEnd()
52+
const key = `${prefix}-${i}`
53+
54+
if (block.startsWith('# ')) return headingTag(baseLevel - 1, key, block.slice(2))
55+
if (block.startsWith('## ')) return headingTag(baseLevel, key, block.slice(3))
56+
if (block.startsWith('### ')) return headingTag(baseLevel + 1, key, block.slice(4))
57+
if (block.startsWith('#### ')) return headingTag(baseLevel + 2, key, block.slice(5))
58+
59+
if (block.startsWith('```')) {
60+
const inner = block.replace(/^```\w*\r?\n?/, '').replace(/\r?\n?```$/, '')
61+
return <pre key={key}><code>{inner}</code></pre>
62+
}
63+
64+
if (block.startsWith('> ')) {
65+
const text = block
66+
.split('\n')
67+
.map((l) => l.replace(/^>\s?/, ''))
68+
.join(' ')
69+
return <blockquote key={key}>{inline(text, key)}</blockquote>
70+
}
71+
72+
if (/^[-*]\s/.test(block)) {
73+
const items = block.split('\n').filter((l) => /^[-*]\s/.test(l))
74+
return (
75+
<ul key={key}>
76+
{items.map((item, j) => (
77+
<li key={`${key}-${j}`}>{inline(item.replace(/^[-*]\s+/, ''), `${key}-${j}`)}</li>
78+
))}
79+
</ul>
80+
)
81+
}
82+
83+
// ASCII tables (`| col | col |`) render as a styled pre block. The
84+
// shared renderer doesn't parse them into real <table> markup — the
85+
// minimal pre output is enough for everything currently shipped to
86+
// the marketing site (a tier-limits table on /docs). Upgrade to
87+
// <table> when a page actually needs sortable/wrappable rows.
88+
if (block.startsWith('|')) {
89+
return <pre key={key} className="md-table"><code>{block}</code></pre>
90+
}
91+
92+
if (/^\d+\.\s/.test(block)) {
93+
const items = block.split('\n').filter((l) => /^\d+\.\s/.test(l))
94+
return (
95+
<ol key={key}>
96+
{items.map((item, j) => (
97+
<li key={`${key}-${j}`}>{inline(item.replace(/^\d+\.\s+/, ''), `${key}-${j}`)}</li>
98+
))}
99+
</ol>
100+
)
101+
}
102+
103+
return <p key={key}>{inline(block, key)}</p>
104+
})
105+
}
106+
107+
function headingTag(level: number, key: string, content: string): ReactNode {
108+
const clamped = Math.min(Math.max(level, 1), 6)
109+
const Tag = `h${clamped}` as Heading
110+
return <Tag key={key}>{inline(content, key)}</Tag>
111+
}
112+
113+
/* inline — the per-block tokenizer for ` `code` `, **bold**, [text](url).
114+
*
115+
* It walks the string left-to-right, taking the earliest match among
116+
* the three patterns. Order matters only for overlapping patterns
117+
* (none in this small grammar) — picking the earliest match means
118+
* **a `b` c** renders the bold first, not the code inside the bold. */
119+
export function inline(text: string, keyPrefix = 'i'): ReactNode {
120+
const parts: ReactNode[] = []
121+
let rest = text
122+
let n = 0
123+
124+
while (rest.length > 0) {
125+
const matches = [
126+
findMatch(rest, /`([^`]+)`/),
127+
findMatch(rest, /\*\*([^*]+)\*\*/),
128+
findMatch(rest, /\[([^\]]+)\]\(([^)]+)\)/),
129+
]
130+
const valid = matches.filter((m): m is RegExpMatchArray => m !== null)
131+
if (valid.length === 0) {
132+
parts.push(rest)
133+
break
134+
}
135+
136+
// Pick the earliest-starting match
137+
valid.sort((a, b) => a.index! - b.index!)
138+
const m = valid[0]
139+
const idx = m.index!
140+
const matched = m[0]
141+
const k = `${keyPrefix}-${n++}`
142+
143+
if (idx > 0) parts.push(rest.slice(0, idx))
144+
145+
if (matched.startsWith('`')) {
146+
parts.push(<code key={k}>{m[1]}</code>)
147+
} else if (matched.startsWith('**')) {
148+
parts.push(<strong key={k}>{m[1]}</strong>)
149+
} else {
150+
const href = m[2]
151+
if (isSafeHref(href)) {
152+
parts.push(<a key={k} href={href}>{m[1]}</a>)
153+
} else {
154+
// Unsafe href (e.g. javascript:) — render as plain text
155+
parts.push(matched)
156+
}
157+
}
158+
159+
rest = rest.slice(idx + matched.length)
160+
}
161+
return parts
162+
}
163+
164+
function findMatch(s: string, re: RegExp): RegExpMatchArray | null {
165+
return s.match(re)
166+
}
167+
168+
/* isSafeHref — whitelist of href schemes we'll render as <a>.
169+
*
170+
* Allows http/https (external links), `/` (internal routes), and `#`
171+
* (anchors). Anything else — including `javascript:`, `data:`, `vbscript:`
172+
* — is rejected. Content is currently authored in a public repo we
173+
* trust; this check defends against future contributors and against
174+
* any future move to user-submitted content. */
175+
function isSafeHref(href: string): boolean {
176+
if (href.startsWith('/') || href.startsWith('#')) return true
177+
if (/^https?:\/\//i.test(href)) return true
178+
return false
179+
}

src/pages/BlogPostPage.tsx

Lines changed: 5 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@
1010

1111
import { useParams, Navigate } from 'react-router-dom'
1212
import { PublicShell } from '../layout/PublicShell'
13-
import { POSTS, type Post } from '../content/posts'
13+
import { POSTS } from '../content/posts'
14+
import { renderMarkdown } from '../lib/markdown'
1415

1516
export function BlogPostPage() {
1617
const { slug } = useParams<{ slug: string }>()
@@ -27,7 +28,9 @@ export function BlogPostPage() {
2728
<h1 className="post-title">{post.title}</h1>
2829
<p className="post-author">By {post.author}</p>
2930
</header>
30-
<div className="post-body">{renderMarkdown(post.body, post)}</div>
31+
<div className="post-body">
32+
{renderMarkdown(post.body, { baseHeading: 'h2', keyPrefix: post.slug })}
33+
</div>
3134
<footer className="post-foot">
3235
<a href="/blog" className="post-foot-link">More posts</a>
3336
<a href="/docs" className="post-foot-link">Read the docs →</a>
@@ -37,62 +40,6 @@ export function BlogPostPage() {
3740
)
3841
}
3942

40-
// renderMarkdown is intentionally minimal. It splits on blank lines into
41-
// block-level elements and pattern-matches each block. Inline formatting is
42-
// limited to `code`, **bold**, and naked URLs. No HTML is parsed; every
43-
// rendered node is a React element of a known type.
44-
function renderMarkdown(md: string, post: Post): React.ReactNode {
45-
const blocks = md.trim().split(/\n\n+/)
46-
return blocks.map((block, i) => {
47-
const key = `${post.slug}-${i}`
48-
if (block.startsWith('### ')) return <h3 key={key}>{inline(block.slice(4))}</h3>
49-
if (block.startsWith('## ')) return <h2 key={key}>{inline(block.slice(3))}</h2>
50-
if (block.startsWith('# ')) return <h1 key={key}>{inline(block.slice(2))}</h1>
51-
if (block.startsWith('```')) {
52-
const inner = block.replace(/^```\w*\n?/, '').replace(/\n?```$/, '')
53-
return <pre key={key}><code>{inner}</code></pre>
54-
}
55-
if (block.startsWith('- ') || block.startsWith('* ')) {
56-
const items = block.split('\n').filter((l) => l.startsWith('- ') || l.startsWith('* '))
57-
return (
58-
<ul key={key}>
59-
{items.map((item, j) => (
60-
<li key={`${key}-${j}`}>{inline(item.slice(2))}</li>
61-
))}
62-
</ul>
63-
)
64-
}
65-
return <p key={key}>{inline(block)}</p>
66-
})
67-
}
68-
69-
// inline handles `code`, **bold**, and plain text. Splits on tokens then
70-
// rebuilds with appropriate React elements.
71-
function inline(text: string): React.ReactNode {
72-
const parts: React.ReactNode[] = []
73-
let rest = text
74-
let key = 0
75-
while (rest.length > 0) {
76-
const code = rest.match(/^(.*?)`([^`]+)`(.*)$/)
77-
if (code) {
78-
if (code[1]) parts.push(code[1])
79-
parts.push(<code key={`c-${key++}`}>{code[2]}</code>)
80-
rest = code[3]
81-
continue
82-
}
83-
const bold = rest.match(/^(.*?)\*\*(.+?)\*\*(.*)$/)
84-
if (bold) {
85-
if (bold[1]) parts.push(bold[1])
86-
parts.push(<strong key={`b-${key++}`}>{bold[2]}</strong>)
87-
rest = bold[3]
88-
continue
89-
}
90-
parts.push(rest)
91-
break
92-
}
93-
return parts
94-
}
95-
9643
function formatDate(iso: string): string {
9744
const [y, m, d] = iso.split('-').map(Number)
9845
const date = new Date(Date.UTC(y, m - 1, d))

src/pages/DocsPage.tsx

Lines changed: 5 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
* public domain. */
1616

1717
import { PublicShell } from '../layout/PublicShell'
18+
import { renderMarkdown } from '../lib/markdown'
1819

1920
type Section = {
2021
id: string
@@ -94,7 +95,9 @@ export function DocsPage() {
9495
{s.title}
9596
</a>
9697
</h2>
97-
<div className="docs-section-body">{renderDocsMarkdown(s.body, s.id)}</div>
98+
<div className="docs-section-body">
99+
{renderMarkdown(s.body, { baseHeading: 'h3', keyPrefix: s.id })}
100+
</div>
98101
</section>
99102
))}
100103
</article>
@@ -103,59 +106,6 @@ export function DocsPage() {
103106
)
104107
}
105108

106-
// Same minimal markdown handling as BlogPostPage, kept self-contained so the
107-
// pages don't share a parser yet (premature abstraction; revisit if a third
108-
// page wants the same rendering).
109-
function renderDocsMarkdown(md: string, sectionID: string): React.ReactNode {
110-
const blocks = md.trim().split(/\n\n+/)
111-
return blocks.map((block, i) => {
112-
const key = `${sectionID}-${i}`
113-
if (block.startsWith('### ')) return <h3 key={key}>{inline(block.slice(4))}</h3>
114-
if (block.startsWith('## ')) return <h3 key={key}>{inline(block.slice(3))}</h3>
115-
if (block.startsWith('```')) {
116-
const inner = block.replace(/^```\w*\n?/, '').replace(/\n?```$/, '')
117-
return <pre key={key}><code>{inner}</code></pre>
118-
}
119-
if (block.startsWith('- ') || block.startsWith('* ')) {
120-
const items = block.split('\n').filter((l) => l.startsWith('- ') || l.startsWith('* '))
121-
return (
122-
<ul key={key}>
123-
{items.map((item, j) => (
124-
<li key={`${key}-${j}`}>{inline(item.slice(2))}</li>
125-
))}
126-
</ul>
127-
)
128-
}
129-
if (block.startsWith('|')) return <pre key={key} className="docs-table"><code>{block}</code></pre>
130-
return <p key={key}>{inline(block)}</p>
131-
})
132-
}
133-
134-
function inline(text: string): React.ReactNode {
135-
const parts: React.ReactNode[] = []
136-
let rest = text
137-
let key = 0
138-
while (rest.length > 0) {
139-
const code = rest.match(/^(.*?)`([^`]+)`(.*)$/)
140-
if (code) {
141-
if (code[1]) parts.push(code[1])
142-
parts.push(<code key={`c-${key++}`}>{code[2]}</code>)
143-
rest = code[3]
144-
continue
145-
}
146-
const bold = rest.match(/^(.*?)\*\*(.+?)\*\*(.*)$/)
147-
if (bold) {
148-
if (bold[1]) parts.push(bold[1])
149-
parts.push(<strong key={`b-${key++}`}>{bold[2]}</strong>)
150-
rest = bold[3]
151-
continue
152-
}
153-
parts.push(rest)
154-
break
155-
}
156-
return parts
157-
}
158-
159109
function DocsStyles() {
160110
return (
161111
<style>{`
@@ -183,7 +133,7 @@ function DocsStyles() {
183133
.docs-section-body code { background: var(--ink); border: 1px solid var(--border); color: var(--text); padding: 1px 6px; border-radius: 4px; font-size: 13.5px; font-family: var(--font-mono); }
184134
.docs-section-body pre { background: var(--code-bg); color: var(--text); border: 1px solid var(--border); padding: 16px 20px; border-radius: 8px; overflow-x: auto; font-size: 13px; line-height: 1.55; margin: 16px 0; }
185135
.docs-section-body pre code { background: transparent; padding: 0; color: inherit; }
186-
.docs-section-body pre.docs-table { background: transparent; color: inherit; padding: 0; font-size: 14px; }
136+
.docs-section-body pre.md-table { background: transparent; color: inherit; padding: 0; font-size: 14px; }
187137
.docs-section-body strong { font-weight: 600; }
188138
`}</style>
189139
)

0 commit comments

Comments
 (0)