feat(@projects/@magic-civilization): add markdown rendering support

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Natalie 2026-04-17 17:39:07 -07:00
parent 61b0105566
commit 2bce059326
3 changed files with 975 additions and 130 deletions

874
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff

View file

@ -18,6 +18,8 @@
"@magic-civ/engine-ts": "workspace:*",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-markdown": "^10.1.0",
"remark-gfm": "^4.0.1",
"styled-components": "^6.0.0",
"three": "^0.183.2"
},

View file

@ -1,25 +1,19 @@
import { type ReactElement, type ReactNode } from 'react'
import { type ReactElement } from 'react'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import styled from 'styled-components'
/**
* Small, dependency-free markdown renderer for the objective-summary flavor
* of markdown used in `.project/objectives/*.md` and other short prose
* rendered into the guide UI. Supports exactly the subset that actually
* appears in the source: paragraphs, unordered lists (`- ` / `* ` leader),
* inline code (``` `text` ```), bold (`**text**`), italic (`*text*`).
* Markdown renderer for prose sourced from `.project/objectives/*.md` and
* similar dev-facing content surfaced in the guide. Uses react-markdown
* with remark-gfm so the input understands the full GitHub-flavored
* markdown surface the source files actually use in particular tables,
* which show up in objective summaries as seed-batch result matrices and
* must render as actual tables, not collapsed pipe-delimited prose.
*
* Deliberately narrower than a general-purpose parser no headings, no
* links, no tables, no HTML passthrough because the content source is
* narrow too, and pulling in `react-markdown` would add ~50 kB for the
* dozen features we don't use.
*
* Parser structure (block inline):
* 1. Split text on blank-line-delimited blocks.
* 2. Each block is either a list (every line leads with `- ` / `* `) or a
* paragraph.
* 3. Inline formatting runs in precedence order:
* inline code bold italic. Code content is NOT further formatted
* (so "`**not bold**`" renders as literal). Bold may contain italic.
* Styling is scoped to a `<MarkdownRoot>` wrapper so the rendered HTML
* picks up the surrounding `styled-components` theme without bleeding
* into adjacent content.
*/
interface Props {
@ -28,122 +22,17 @@ interface Props {
}
export function Markdown({ children, className }: Props): ReactElement {
const blocks = parseBlocks(children)
return (
<MarkdownRoot className={className}>
{blocks.map((block, i) => renderBlock(block, `b${i}`))}
<ReactMarkdown remarkPlugins={[remarkGfm]}>{children}</ReactMarkdown>
</MarkdownRoot>
)
}
// ─── Block parsing ──────────────────────────────────────────────────────────
type Block =
| { readonly kind: 'paragraph'; readonly text: string }
| { readonly kind: 'list'; readonly items: readonly string[] }
const LIST_PREFIX_RE = /^[-*]\s+(.*)$/
function parseBlocks(input: string): readonly Block[] {
const raw = input.replace(/\r\n?/g, '\n').trim()
if (raw.length === 0) return []
const chunks = raw.split(/\n{2,}/)
return chunks.map(parseBlock)
}
function parseBlock(chunk: string): Block {
const lines = chunk.split('\n').map((l) => l.trimEnd())
const items: string[] = []
for (const line of lines) {
const match = line.match(LIST_PREFIX_RE)
if (match == null) {
// Mixed content — treat the whole chunk as a paragraph. Preserve
// intra-paragraph newlines as spaces so the rendered text flows
// instead of breaking mid-sentence.
return { kind: 'paragraph', text: lines.join(' ') }
}
items.push(match[1])
}
return { kind: 'list', items }
}
function renderBlock(block: Block, keyBase: string): ReactNode {
if (block.kind === 'list') {
return (
<ul key={keyBase}>
{block.items.map((item, i) => (
<li key={`${keyBase}-i${i}`}>{renderInline(item, `${keyBase}-i${i}`)}</li>
))}
</ul>
)
}
return <p key={keyBase}>{renderInline(block.text, keyBase)}</p>
}
// ─── Inline parsing (precedence: code → bold → italic) ──────────────────────
const CODE_RE = /`([^`]+)`/g
const BOLD_RE = /\*\*([^*]+?)\*\*/g
const ITALIC_RE = /\*([^*]+?)\*/g
function renderInline(text: string, keyBase: string): ReactNode[] {
return splitByRegex(text, CODE_RE, keyBase, {
wrap: (match, key) => <code key={key}>{match}</code>,
recurse: renderBoldItalic,
})
}
function renderBoldItalic(text: string, keyBase: string): ReactNode[] {
return splitByRegex(text, BOLD_RE, keyBase, {
wrap: (match, key) => <strong key={key}>{renderItalic(match, key)}</strong>,
recurse: renderItalic,
})
}
function renderItalic(text: string, keyBase: string): ReactNode[] {
return splitByRegex(text, ITALIC_RE, keyBase, {
wrap: (match, key) => <em key={key}>{match}</em>,
recurse: (t) => [t],
})
}
interface SplitOpts {
readonly wrap: (matched: string, key: string) => ReactNode
readonly recurse: (plain: string, keyBase: string) => ReactNode[]
}
/**
* Iterate matches of `pattern` against `text`; for each match wrap via
* `opts.wrap(groupOne, key)`, for the text between matches recurse into
* `opts.recurse(plain, keyBase)`. Uses `String.prototype.matchAll` so the
* regex is stateless across invocations and we avoid the sharp-edge of
* mutating a module-scoped /g regex's `lastIndex`.
*/
function splitByRegex(
text: string,
pattern: RegExp,
keyBase: string,
opts: SplitOpts,
): ReactNode[] {
const out: ReactNode[] = []
let last = 0
let idx = 0
for (const match of text.matchAll(pattern)) {
if (match.index === undefined) continue
if (match.index > last) {
out.push(...opts.recurse(text.slice(last, match.index), `${keyBase}-p${idx}`))
}
out.push(opts.wrap(match[1], `${keyBase}-w${idx}`))
last = match.index + match[0].length
idx += 1
}
if (last < text.length) {
out.push(...opts.recurse(text.slice(last), `${keyBase}-p${idx}`))
}
return out
}
// ─── Styled container ───────────────────────────────────────────────────────
// ─── Theme-aware styled container ───────────────────────────────────────────
// Keeps markdown output aligned with the surrounding guide visuals without
// requiring each consumer to re-author block-level CSS. Narrow enough that
// an overriding `className` can still fine-tune per-context spacing.
const MarkdownRoot = styled.div`
font-size: inherit;
@ -157,17 +46,28 @@ const MarkdownRoot = styled.div`
margin-bottom: 0;
}
ul {
ul, ol {
margin: 0 0 0.75rem;
padding-left: 1.25rem;
}
ul:last-child {
ul:last-child, ol:last-child {
margin-bottom: 0;
}
li {
margin: 0 0 0.25rem;
}
h1, h2, h3, h4, h5, h6 {
color: ${({ theme }) => theme.colors.text?.primary ?? 'inherit'};
font-weight: 600;
line-height: 1.3;
margin: 1.25rem 0 0.5rem;
}
h1 { font-size: 1.25rem; }
h2 { font-size: 1.125rem; }
h3 { font-size: 1rem; }
h4, h5, h6 { font-size: 0.9rem; }
code {
font-family: ${({ theme }) =>
theme.typography?.fontFamily?.mono ??
@ -178,6 +78,24 @@ const MarkdownRoot = styled.div`
background: ${({ theme }) => theme.colors.surface ?? 'rgba(255,255,255,0.06)'};
border: 1px solid ${({ theme }) => theme.colors.border?.default ?? 'rgba(255,255,255,0.12)'};
color: ${({ theme }) => theme.colors.text?.primary ?? 'inherit'};
word-break: break-word;
}
pre {
margin: 0 0 0.75rem;
padding: 0.75rem 0.875rem;
border-radius: 4px;
background: ${({ theme }) => theme.colors.surface ?? 'rgba(255,255,255,0.06)'};
border: 1px solid ${({ theme }) => theme.colors.border?.default ?? 'rgba(255,255,255,0.12)'};
overflow-x: auto;
font-size: 0.82em;
line-height: 1.5;
}
pre code {
padding: 0;
border: none;
background: transparent;
font-size: inherit;
}
strong {
@ -188,4 +106,55 @@ const MarkdownRoot = styled.div`
em {
font-style: italic;
}
a {
color: ${({ theme }) => theme.colors.primary?.dark ?? '#c9a84c'};
text-decoration: underline;
text-underline-offset: 2px;
}
table {
display: block;
width: 100%;
max-width: 100%;
overflow-x: auto;
border-collapse: collapse;
margin: 0 0 0.75rem;
font-size: 0.82em;
}
thead {
background: ${({ theme }) => theme.colors.surface ?? 'rgba(255,255,255,0.04)'};
}
th, td {
text-align: left;
padding: 0.375rem 0.625rem;
border: 1px solid ${({ theme }) => theme.colors.border?.default ?? 'rgba(255,255,255,0.12)'};
vertical-align: top;
}
th {
color: ${({ theme }) => theme.colors.text?.primary ?? 'inherit'};
font-weight: 600;
font-size: 0.7rem;
letter-spacing: 0.5px;
text-transform: uppercase;
}
blockquote {
margin: 0 0 0.75rem;
padding: 0.375rem 0.75rem;
border-left: 3px solid ${({ theme }) => theme.colors.primary?.dark ?? '#c9a84c'};
color: ${({ theme }) => theme.colors.text?.muted ?? 'inherit'};
font-style: italic;
}
hr {
border: none;
border-top: 1px solid ${({ theme }) => theme.colors.border?.default ?? 'rgba(255,255,255,0.12)'};
margin: 1rem 0;
}
/* GFM task list checkboxes */
input[type='checkbox'] {
margin-right: 0.375rem;
}
`