feat(@projects/@magic-civilization): ✨ add markdown rendering support
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
parent
61b0105566
commit
2bce059326
3 changed files with 975 additions and 130 deletions
874
pnpm-lock.yaml
generated
874
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
`
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue