User-authored prose fields (Project.overview, Person.bio, ProjectUpdate.body, ProjectBuzz.summary, HelpWantedRole.description) are stored as markdown source and delivered to the client as pre-rendered, sanitized HTML alongside the source. Rendering happens server-side; clients never invoke a markdown library on user-provided content.
- All API responses with a
*Htmlfield paired with a markdown source field - Markdown editor widget on every authoring screen
- Plaintext excerpt generation (used for list previews and meta descriptions)
markdown source
│
▼
remark-parse → remark-gfm → remark-breaks
│
▼
remark-rehype (allowDangerousHtml: false)
│
▼
rehype-sanitize (schema: see below)
│
▼
rehype-stringify → rendered HTML
The same pipeline runs:
- On write — to validate that the source produces non-empty output if the field is required, and to surface any sanitizer warnings.
- On read — to populate the
*Htmlfield in API responses. The result is not cached on the record; it's computed on serialization. (If profiling shows this is a hot spot, cache*Htmlalongside*Sourcein-memory and invalidate on write. Don't pre-optimize.)
GFM features:
- Headings (h1–h6) — but the renderer demotes by 2 (h1 in source → h3 in output) so a project overview's
# Foodoesn't clash with the page's own H1 - Paragraphs, line breaks, soft breaks
- Strong, emphasis, strikethrough
- Inline code, fenced code blocks (no syntax highlighting in v1 — class is preserved for future client-side highlighting if desired)
- Ordered and unordered lists, nested
- Task lists (rendered with disabled checkboxes; not interactive in v1)
- Tables
- Links — only
http,https,mailtoschemes - Images — only
httpsscheme, withloading="lazy"andreferrerpolicy="no-referrer"added - Blockquotes
- Horizontal rules
- Autolinks (URLs that aren't markdown-linked become anchors)
- Raw HTML in source — entirely removed by
rehype-sanitize(no<script>, no<style>, no<iframe>, no<object>) - Any
on*attributes styleattributesjavascript:,data:,file:,vbscript:URLs- Unknown elements or attributes
| Transform | Rule |
|---|---|
| Heading demotion | h1 → h3, h2 → h4, etc. Min level h6. |
| External link target | All <a href> whose host differs from the site host get target="_blank" rel="noopener nofollow" |
| Image proxying | (v1: not implemented; planned: route through /img-proxy?u=... to dodge mixed-content and DDoS-the-source issues) |
@mention resolution |
@username in update bodies — if username matches an existing person's slug, link to /members/<slug>. Otherwise leave the literal text. |
For list previews and meta descriptions, we need plain text — not rendered HTML. A separate pipeline runs:
remark-parse → strip-markdown → trim whitespace → truncate
Truncation tries to break at a word boundary, then a sentence boundary if one is within 20% of the target length. Suffix … (single character) only if truncated.
Used for:
ProjectListItem.overviewExcerpt(600 chars)PersonListItem.bioExcerpt(200 chars)- HTML
<meta name="description">(≤ 160 chars) for project detail and person detail pages - OpenGraph
og:description(same)
Every markdown-authoring widget on the site uses the same component:
- Two-pane: source on the left, rendered preview on the right (using the same server pipeline, called on debounce)
- Toolbar with bold, italic, link, list, code, blockquote
- Drag-drop image upload is not in v1 — paste an external
https://URL - Character count visible
- Soft hint when source exceeds the field's max length, hard reject on submit
The shadcn-compatible component choice is an implementation detail; the spec is the behavior, not the library.