From 09c3ccea9df6e439af12fb5b359d21fbab498b38 Mon Sep 17 00:00:00 2001 From: Tim Disney Date: Sat, 25 Jul 2026 20:14:45 -0700 Subject: [PATCH] render markdown --- packages/ui/package.json | 3 +- packages/ui/src/app.css | 55 ++++ packages/ui/src/lib/components/Blocks.svelte | 78 ++++++ packages/ui/src/lib/components/Prose.svelte | 15 +- packages/ui/src/lib/components/Runs.svelte | 20 ++ packages/ui/src/lib/components/Thread.svelte | 5 +- .../ui/src/lib/components/UnitDetail.svelte | 5 +- packages/ui/src/lib/format.test.ts | 7 + packages/ui/src/lib/format.ts | 24 +- packages/ui/src/lib/prose.test.ts | 138 ++++++++-- packages/ui/src/lib/prose.ts | 240 ++++++++++++++---- pnpm-lock.yaml | 10 + 12 files changed, 509 insertions(+), 91 deletions(-) create mode 100644 packages/ui/src/lib/components/Blocks.svelte create mode 100644 packages/ui/src/lib/components/Runs.svelte diff --git a/packages/ui/package.json b/packages/ui/package.json index 3467487..44c01ab 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -22,7 +22,8 @@ "@radial/atproto": "workspace:*", "@radial/core": "workspace:*", "@radial/ingest": "workspace:*", - "@radial/sidecar": "workspace:*" + "@radial/sidecar": "workspace:*", + "marked": "^18.0.7" }, "devDependencies": { "@sveltejs/adapter-static": "^3.0.10", diff --git a/packages/ui/src/app.css b/packages/ui/src/app.css index f7380ae..c365326 100644 --- a/packages/ui/src/app.css +++ b/packages/ui/src/app.css @@ -389,10 +389,65 @@ a { color: var(--accent); text-underline-offset: 2px; } .body p { margin: 0 0 0.9em; } .body p:last-child { margin-bottom: 0; } .body .bh { font-size: 13px; font-weight: 700; letter-spacing: 0.001em; margin: 21px 0 7px; color: var(--ink); } +/* A body's own `#` title sits above its sections; below `##` the levels stop growing apart and + only the weight carries the step, so a deep outline never shouts. */ +.body .bh[data-level='1'] { font-size: 14px; margin-top: 24px; } +.body .bh[data-level='4'], .body .bh[data-level='5'], .body .bh[data-level='6'] { + font-weight: 600; color: var(--ink-2); margin-top: 17px; +} .body > .bh:first-child { margin-top: 2px; } .body .plain { white-space: pre-wrap; } .body code, code.inl { font: 12.5px/1.4 var(--mono); background: var(--sunk); padding: 1px 4px; border-radius: 4px; } .unit.open .body code, .unit.open code.inl { background: var(--hover); } +.body strong { font-weight: 600; color: var(--ink); } +.body em { font-style: italic; } +.body del { text-decoration: line-through; color: var(--ink-2); } +.body .lk { color: var(--accent); text-decoration: none; border-bottom: 1px solid var(--line); } +.body .lk:hover { border-bottom-color: var(--accent); } +/* An image is shown at the prose measure and never wider, so a screenshot pasted into a plan sits + in the column the text is read in rather than stretching the pane. */ +.body .ig { + display: block; max-width: 100%; height: auto; margin: 0.9em 0; + border: 1px solid var(--line-soft); border-radius: 7px; background: var(--sunk); +} +/* One whose address we will not fetch from — a relative path, or a scheme that is not http(s) — + is named instead, so the reader knows the body meant to show something here. */ +.body .im { color: var(--ink-2); } +.body .im span { margin-right: 4px; color: var(--stroke); } + +/* A fenced block is a quotation of code: the mono column runs the measure, and a long line scrolls + inside the block rather than widening the pane. */ +.body .cb { + margin: 0 0 0.9em; padding: 9px 11px; + background: var(--sunk); border: 1px solid var(--line-soft); border-radius: 7px; + overflow-x: auto; +} +.body .cb code { display: block; font: 12px/1.55 var(--mono); background: none; padding: 0; border-radius: 0; white-space: pre; } +.unit.open .body .cb { background: var(--hover); } + +.body .bl { margin: 0 0 0.9em; padding-left: 19px; } +.body .bl li { margin: 0 0 0.2em; } +.body .bl li::marker { color: var(--stroke); } +.body .bl .bl { margin: 0.2em 0 0; } +.body .bl p { margin: 0 0 0.4em; } +.body .bl li.task { list-style: none; margin-left: -19px; padding-left: 19px; text-indent: -19px; } +.body .bl .tk { display: inline-block; width: 19px; text-indent: 0; color: var(--stroke); } +.body .bq { + margin: 0 0 0.9em; padding-left: 12px; + border-left: 1px solid var(--line); color: var(--ink-2); +} +.body .br { border: 0; border-top: 1px solid var(--line-soft); margin: 18px 0; } + +/* A table is the one block allowed past the measure — its own scroller, so a wide one never widens + the pane the prose is read in. */ +.body .tw { margin: 0 0 0.9em; overflow-x: auto; } +.body .tb { border-collapse: collapse; font-size: 13px; } +.body .tb th, .body .tb td { + text-align: left; padding: 5px 12px 5px 0; vertical-align: top; + border-bottom: 1px solid var(--line-soft); white-space: nowrap; +} +.body .tb th { font-weight: 600; color: var(--ink-2); font-size: 11.5px; letter-spacing: 0.02em; text-transform: uppercase; } +.body .tb tr:last-child td { border-bottom: 0; } .crit { list-style: none; margin: 0 0 14px; padding: 0; max-width: 64ch; } .crit li { display: flex; gap: 8px; font-size: 13.5px; line-height: 1.5; padding: 3px 0; color: var(--ink); } diff --git a/packages/ui/src/lib/components/Blocks.svelte b/packages/ui/src/lib/components/Blocks.svelte new file mode 100644 index 0000000..ee66621 --- /dev/null +++ b/packages/ui/src/lib/components/Blocks.svelte @@ -0,0 +1,78 @@ + + +{#each blocks as block, index (index)} + {#if block.type === 'heading'} + + + + {:else if block.type === 'code'} +
{textOf(block)}
+ {:else if block.type === 'list'} + {@const list = listOf(block)} + + {#each list.items as item, itemIndex (itemIndex)} +
  • + {#if item.task}{/if} +
  • + {/each} +
    + {:else if block.type === 'blockquote'} +
    + {:else if block.type === 'hr'} +
    + {:else if block.type === 'table'} + {@const table = tableOf(block)} +
    + + + + {#each table.header as cell, cellIndex (cellIndex)} + + {/each} + + + + {#each table.rows as row, rowIndex (rowIndex)} + + {#each row as cell, cellIndex (cellIndex)} + + {/each} + + {/each} + +
    +
    + {:else if block.type === 'paragraph'} +

    + {:else if block.type === 'space'} + + {:else if kids(block).length > 0} + + + {:else} + +

    {textOf(block)}

    + {/if} +{/each} diff --git a/packages/ui/src/lib/components/Prose.svelte b/packages/ui/src/lib/components/Prose.svelte index 31d5095..5a887b3 100644 --- a/packages/ui/src/lib/components/Prose.svelte +++ b/packages/ui/src/lib/components/Prose.svelte @@ -1,10 +1,11 @@
    - {#each blocks as block, index (index)} - {#if block.kind === 'heading'} -

    {block.text}

    - {:else} -

    {#each block.segments as segment, segmentIndex (segmentIndex)}{#if segment.code}{segment.text}{:else}{segment.text}{/if}{/each}

    - {/if} - {/each} +
    diff --git a/packages/ui/src/lib/components/Runs.svelte b/packages/ui/src/lib/components/Runs.svelte new file mode 100644 index 0000000..751e3fe --- /dev/null +++ b/packages/ui/src/lib/components/Runs.svelte @@ -0,0 +1,20 @@ + + +{#each runs as run, index (index)}{#if run.type === 'escape' || run.type === 'codespan'}{#if run.type === 'codespan'}{textOf(run)}{:else}{textOf(run)}{/if}{:else if run.type === 'strong'}{:else if run.type === 'em'}{:else if run.type === 'del'}{:else if run.type === 'br'}
    {:else if run.type === 'link'}{#if link(run)}{:else if isAtUri(hrefOf(run))}{:else}{/if}{:else if run.type === 'image'}{#if safeImage(hrefOf(run))}{textOf(run)}{:else}{label(run)}{/if}{:else if kids(run).length > 0}{:else}{textOf(run)}{/if}{/each} diff --git a/packages/ui/src/lib/components/Thread.svelte b/packages/ui/src/lib/components/Thread.svelte index abfa8e2..f450a05 100644 --- a/packages/ui/src/lib/components/Thread.svelte +++ b/packages/ui/src/lib/components/Thread.svelte @@ -2,8 +2,9 @@ import type { GoalView } from '@radial/core' import { stamp } from '$lib/format.js' import type { Space } from '$lib/space.js' - import { segments } from '$lib/prose.js' + import { inline } from '$lib/prose.js' import Badge from './Badge.svelte' + import Runs from './Runs.svelte' import Disc from './Disc.svelte' // The goal's thread. Messages are agent-authored too, so they render through the same @@ -48,7 +49,7 @@ {:else} -

    {#each segments(message.value.body) as segment, index (index)}{#if segment.code}{segment.text}{:else}{segment.text}{/if}{/each}

    +

    {/if} {/each} diff --git a/packages/ui/src/lib/components/UnitDetail.svelte b/packages/ui/src/lib/components/UnitDetail.svelte index 970ae7d..3faf67e 100644 --- a/packages/ui/src/lib/components/UnitDetail.svelte +++ b/packages/ui/src/lib/components/UnitDetail.svelte @@ -4,7 +4,7 @@ import { account } from '$lib/auth.svelte.js' import { openDraft } from '$lib/compose.svelte.js' import { shortCommit, stamp } from '$lib/format.js' - import { segments } from '$lib/prose.js' + import { inline } from '$lib/prose.js' import { buildableTypes, captureDraft, @@ -34,6 +34,7 @@ import AskReview from './AskReview.svelte' import Badge from './Badge.svelte' import Prose from './Prose.svelte' + import Runs from './Runs.svelte' import UriChip from './UriChip.svelte' import VerdictCard from './Verdict.svelte' @@ -288,7 +289,7 @@ {stamp(question.value.createdAt)} -

    {#each segments(question.value.body) as segment, position (position)}{#if segment.code}{segment.text}{:else}{segment.text}{/if}{/each}

    +

    {/if} diff --git a/packages/ui/src/lib/format.test.ts b/packages/ui/src/lib/format.test.ts index c0c237f..1d8135b 100644 --- a/packages/ui/src/lib/format.test.ts +++ b/packages/ui/src/lib/format.test.ts @@ -60,4 +60,11 @@ describe('summarize', () => { it('returns nothing for a body that is only headings', () => { expect(summarize('## Only a heading')).toBe('') }) + + // A plan opens with its own `# ` title, and the row already shows the artifact's type. + it("skips a body's own title and the fence under it", () => { + expect(summarize('# Plan: implement multiply\n\n```js\nconst x = 1\n```\n\nReplace the stub.')).toBe( + 'Replace the stub.', + ) + }) }) diff --git a/packages/ui/src/lib/format.ts b/packages/ui/src/lib/format.ts index dbd57f4..485b835 100644 --- a/packages/ui/src/lib/format.ts +++ b/packages/ui/src/lib/format.ts @@ -4,6 +4,8 @@ // ("now" is passed in, never read from the clock): the fixture draws itself at `FIXTURE_NOW`, so // "6m ago" means six minutes in the comp and six minutes against a live space, from the same code. +import { plain, prose } from './prose.js' + const MINUTE = 60_000 const HOUR = 60 * MINUTE const DAY = 24 * HOUR @@ -79,17 +81,23 @@ export const shortCommit = (commit: string, length = 10): string => commit.slice * The line a row shows for a landed artifact. * * Artifacts carry a body, not a title — so the row's one line has to come out of the prose. Take - * the first paragraph that is neither a `## ` heading nor the `Status:` convention an ADR opens - * with (phase6-ui-plan §3.4 keeps that prose rather than making it a field), and cut it at the - * first sentence. + * the first block of running text: not a heading (the body's own title is not a summary of it), + * not a fence or a rule, and not the `Status:` convention an ADR opens with (phase6-ui-plan §3.4 + * keeps that prose rather than making it a field). Then cut it at the first sentence. */ export function summarize(body: string, limit = 140): string { - const paragraph = body - .split(/\n\n+/) - .map((block) => block.trim()) - .find((block) => block && !block.startsWith('## ') && !/^status:/i.test(block)) + let paragraph = '' + for (const block of prose(body)) { + if (block.type === 'heading' || block.type === 'code' || block.type === 'hr') continue + // A list stands for its first item, which is the closest thing it has to a line. + const first = block.type === 'list' ? block.items[0] : block + const text = first ? plain([first]) : '' + if (!text.trim() || /^status:/i.test(text.trim())) continue + paragraph = text + break + } if (!paragraph) return '' - const line = paragraph.replace(/\s+/g, ' ').replace(/`/g, '') + const line = paragraph.replace(/\s+/g, ' ') const sentence = /^(.+?[.?!])(\s|$)/.exec(line) const text = sentence?.[1] ?? line return text.length > limit ? `${text.slice(0, limit - 1).trimEnd()}…` : text diff --git a/packages/ui/src/lib/prose.test.ts b/packages/ui/src/lib/prose.test.ts index 730c308..a3a374b 100644 --- a/packages/ui/src/lib/prose.test.ts +++ b/packages/ui/src/lib/prose.test.ts @@ -1,42 +1,130 @@ import { describe, expect, it } from 'vitest' -import { prose, segments } from './prose.js' +import { decode, inline, isAtUri, plain, prose, safeHref, safeImage } from './prose.js' + +const types = (body: string) => prose(body).map((token) => token.type) describe('prose', () => { - it('opens a section on `## ` and keeps everything else a paragraph', () => { - expect(prose('## Approach\n\nAdd a route.\n\n## What this is not\n\nNo diff view.')).toEqual([ - { kind: 'heading', text: 'Approach' }, - { kind: 'paragraph', segments: [{ text: 'Add a route.', code: false }] }, - { kind: 'heading', text: 'What this is not' }, - { kind: 'paragraph', segments: [{ text: 'No diff view.', code: false }] }, + it('reads the blocks an agent-authored body is actually written in', () => { + const body = [ + '# Plan: implement `multiply`', + '', + '**Context**', + '', + '```js', + 'export function add(a, b) { return a + b }', + '', + 'export const x = 1', + '```', + '', + '- one', + '- two', + '', + '> quoted', + '', + '---', + '', + '| a | b |', + '| --- | --- |', + '| 1 | 2 |', + ].join('\n') + expect(types(body)).toEqual([ + 'heading', + 'paragraph', + 'code', + 'list', + 'blockquote', + 'hr', + 'table', + ]) + }) + + it('keeps a fenced block verbatim, blank lines and markers and all', () => { + const [fence] = prose('```js\n# not a heading\n\n- not a list\n```') + expect(fence).toMatchObject({ + type: 'code', + lang: 'js', + text: '# not a heading\n\n- not a list', + }) + }) + + it('carries the heading level, so a body title can sit above its sections', () => { + expect(prose('# Title\n\n## Section')).toMatchObject([ + { type: 'heading', depth: 1 }, + { type: 'heading', depth: 2 }, ]) }) it('marks backticked runs as machine identifiers', () => { - expect(segments('resolve the exact `cid`, never the `uri`')).toEqual([ - { text: 'resolve the exact ', code: false }, - { text: 'cid', code: true }, - { text: ', never the ', code: false }, - { text: 'uri', code: true }, + expect(inline('resolve the exact `cid`, never the `uri`')).toMatchObject([ + { type: 'text', text: 'resolve the exact ' }, + { type: 'codespan', text: 'cid' }, + { type: 'text', text: ', never the ' }, + { type: 'codespan', text: 'uri' }, ]) }) - it('leaves an unclosed backtick as literal text rather than swallowing the rest', () => { - expect(segments('a `b')).toEqual([{ text: 'a `b', code: false }]) + it('marks bold, italic and struck runs', () => { + expect(inline('**Context**, *why*, ~~was~~').map((token) => token.type)).toEqual([ + 'strong', + 'text', + 'em', + 'text', + 'del', + ]) }) - // Artifact bodies come from another member's repo. The renderer never produces markup, so the - // only defence needed is that this stays a data structure — a body full of tags parses to text. + it('leaves an identifier that happens to carry underscores alone', () => { + expect(plain(inline('call snake_case_name twice'))).toBe('call snake_case_name twice') + }) + + // A body comes from another member's repo. Nothing on this path produces markup, so the defence + // is that a token tree is data: a body full of tags renders as its own source. it('treats markup in an agent-authored body as text', () => { - const blocks = prose(' and ') - expect(blocks).toEqual([ - { - kind: 'paragraph', - segments: [{ text: ' and ', code: false }], - }, - ]) + const body = ' and ' + expect(plain(prose(body))).toBe(body) + expect(prose(body).every((token) => token.type === 'html' || token.type === 'paragraph')).toBe(true) + }) + + it('resolves the entities the renderer would otherwise show literally', () => { + expect(decode('a & b <c> A B &unknown;')).toBe('a & b A B &unknown;') + }) + + it('leaves entities inside code alone, the way markdown does', () => { + expect(prose('```\n&\n```')).toMatchObject([{ type: 'code', text: '&' }]) + expect(inline('`&`')).toMatchObject([{ type: 'codespan', text: '&' }]) + }) + + describe('a link is only a link if we would send someone there', () => { + it('allows the schemes a reader can follow', () => { + expect(safeHref('https://example.com/a?b=1')).toBe('https://example.com/a?b=1') + expect(safeHref('mailto:someone@example.com')).toBe('mailto:someone@example.com') + }) + + it('refuses a script URL, a data URL, and anything relative', () => { + expect(safeHref('javascript:alert(1)')).toBeNull() + expect(safeHref('JaVaScRiPt:alert(1)')).toBeNull() + expect(safeHref('data:text/html,')).toBeNull() + expect(safeHref('./docs/plan.md')).toBeNull() + expect(safeHref('//example.com')).toBeNull() + }) + + it('loads an image only from an address a picture can come from', () => { + expect(safeImage('https://example.com/shot.png')).toBe('https://example.com/shot.png') + expect(safeImage('mailto:someone@example.com')).toBeNull() + expect(safeImage('data:image/svg+xml,')).toBeNull() + expect(safeImage('./shot.png')).toBeNull() + }) + + it('knows an at:// URI, which the app shows as a chip rather than navigates to', () => { + expect(isAtUri('at://did:plc:qv7hjr2mzk4x/com.disnetdev.radial.goal/3lbq2xk7wf22h')).toBe(true) + expect(safeHref('at://did:plc:qv7hjr2mzk4x/com.disnetdev.radial.goal/3lb')).toBeNull() + expect(isAtUri('https://example.com')).toBe(false) + }) }) - it('drops blank blocks so a doubled newline cannot open an empty paragraph', () => { - expect(prose('one\n\n\n\ntwo')).toHaveLength(2) + it('reads back the plain text of a token tree', () => { + expect(plain(inline('the `cid`, **not** the [uri](https://example.com)'))).toBe( + 'the cid, not the uri', + ) }) }) diff --git a/packages/ui/src/lib/prose.ts b/packages/ui/src/lib/prose.ts index 85522c2..57571de 100644 --- a/packages/ui/src/lib/prose.ts +++ b/packages/ui/src/lib/prose.ts @@ -1,48 +1,202 @@ -// The comp's deliberately tiny prose renderer: `## ` opens a section, backticks mark machine -// identifiers, blank lines separate paragraphs. That is the whole grammar, and it stays the whole -// grammar (phase6-ui-plan §2). +// The front half of the prose renderer: markdown in, tokens out. // // Artifact and message bodies are agent-authored — untrusted text arriving from another member's -// PDS. So this parses to a *structure* and the component renders it with ordinary Svelte -// interpolation. Nothing here produces markup, there is no `{@html}` anywhere on the path, and no -// markdown library is reachable from it. - -export interface ProseSegment { - text: string - /** A backticked run: a machine identifier, rendered in mono. */ - code: boolean -} - -export type ProseBlock = - | { kind: 'heading'; text: string } - | { kind: 'paragraph'; segments: ProseSegment[] } - -/** Split one paragraph into plain and backticked runs. An unclosed backtick stays literal text. */ -export function segments(text: string): ProseSegment[] { - const out: ProseSegment[] = [] - let rest = text - while (rest.length > 0) { - const open = rest.indexOf('`') - const close = open === -1 ? -1 : rest.indexOf('`', open + 1) - if (open === -1 || close === -1) { - out.push({ text: rest, code: false }) - break +// PDS. So `marked` is used for its *tokenizer only*: `Lexer` gives back a token tree and the +// components walk that tree with ordinary Svelte interpolation. `marked.parse()`, the half that +// produces HTML, is never called. There is no `{@html}` anywhere on the path and no sanitizer to +// get wrong, because no markup is ever produced from a body — the worst a hostile body can do is +// render as its own source. +// +// Two things a tokenizer will not decide for us, decided here: an entity is decoded to the +// character it names (the renderer interpolates, so `&` would otherwise reach the screen +// literally), and a link is only a link if its scheme is one we are willing to send someone to. + +import { Lexer, type Token, type Tokens } from 'marked' + +export type { Token, Tokens } + +const OPTIONS = { gfm: true, breaks: false, pedantic: false } + +/** The block tokens of a body. `space` is dropped: it is separation, not content. */ +export function prose(body: string): Token[] { + return new Lexer(OPTIONS).lex(body).filter((token) => token.type !== 'space') +} + +/** The inline tokens of one run of text — a thread message, a question, a table cell. */ +export function inline(text: string): Token[] { + return Lexer.lexInline(text, OPTIONS) +} + +const NAMED: Record = { + amp: '&', + lt: '<', + gt: '>', + quot: '"', + apos: "'", + nbsp: ' ', + copy: '©', + reg: '®', + trade: '™', + deg: '°', + middot: '·', + bull: '•', + hellip: '…', + mdash: '—', + ndash: '–', + lsquo: '‘', + rsquo: '’', + ldquo: '“', + rdquo: '”', + laquo: '«', + raquo: '»', + times: '×', + divide: '÷', + plusmn: '±', + larr: '←', + rarr: '→', + harr: '↔', + darr: '↓', + uarr: '↑', +} + +/** + * Resolve the entities in a run of text. Unknown names are left alone — a body that says `&foo;` + * meant to say `&foo;`, and CommonMark leaves it standing too. + */ +export function decode(text: string): string { + return text.replace(/&(#\d{1,7}|#[xX][0-9a-fA-F]{1,6}|[a-zA-Z][a-zA-Z0-9]{1,31});/g, (whole, name: string) => { + if (name.startsWith('#')) { + const point = name[1] === 'x' || name[1] === 'X' ? parseInt(name.slice(2), 16) : Number(name.slice(1)) + if (!Number.isInteger(point) || point <= 0 || point > 0x10ffff) return whole + try { + return String.fromCodePoint(point) + } catch { + return whole + } } - if (open > 0) out.push({ text: rest.slice(0, open), code: false }) - out.push({ text: rest.slice(open + 1, close), code: true }) - rest = rest.slice(close + 1) + return NAMED[name] ?? whole + }) +} + +/** + * The address a link may navigate to or an image may load from, or null for one that stays text. + * + * A body comes from another member's repo, so the scheme is an allowlist rather than a blocklist: + * `javascript:` and `data:` are the obvious ones, but a relative link is refused too — it would + * resolve against this app's origin, which is not where the body's author was pointing. + */ +export function safeHref(href: string): string | null { + try { + const url = new URL(href.trim()) + return url.protocol === 'http:' || url.protocol === 'https:' || url.protocol === 'mailto:' + ? url.href + : null + } catch { + return null + } +} + +/** + * The address an image may load from. Narrower than a link's: `mailto:` is somewhere to send a + * reader, not somewhere to fetch from, and a body that names it meant something other than a + * picture. + */ +export function safeImage(href: string): string | null { + const url = safeHref(href) + return url !== null && /^https?:\/\//.test(url) ? url : null +} + +/** An `at://` URI, which the app shows as a chip rather than as somewhere to navigate. */ +export const isAtUri = (href: string): boolean => /^at:\/\//i.test(href.trim()) + +// ── reading a token ─────────────────────────────────────────────────────────────────────────── +// `Token` is a union with a `Generic` member, so narrowing it by `type` inside a template gives +// back something with `any` on it. These accessors are where that is dealt with once: the parser's +// shapes stop here, and the components below get plain, typed values. + +/** The children of a token — the runs inside a heading, the blocks inside a quote. */ +export const kids = (token: Token): Token[] => + 'tokens' in token && Array.isArray(token.tokens) ? (token.tokens as Token[]) : [] + +/** The literal text of a token, entities resolved. Code carries its own text, never decoded. */ +export function textOf(token: Token): string { + const text = 'text' in token && typeof token.text === 'string' ? token.text : '' + return token.type === 'code' || token.type === 'codespan' || token.type === 'escape' + ? text + : decode(text) +} + +export const hrefOf = (token: Token): string => + 'href' in token && typeof token.href === 'string' ? token.href : '' + +export const titleOf = (token: Token): string | null => + 'title' in token && typeof token.title === 'string' ? token.title : null + +export const depthOf = (token: Token): number => + 'depth' in token && typeof token.depth === 'number' ? token.depth : 1 + +export const langOf = (token: Token): string => + 'lang' in token && typeof token.lang === 'string' ? token.lang : '' + +export interface ProseItem { + blocks: Token[] + /** A GFM task item, which carries its own box rather than a bullet. */ + task: boolean + checked: boolean +} + +export interface ProseList { + ordered: boolean + start: number + items: ProseItem[] +} + +export function listOf(token: Token): ProseList { + const list = token as Tokens.List + return { + ordered: list.ordered === true, + start: typeof list.start === 'number' && list.start > 0 ? list.start : 1, + items: (list.items ?? []).map((item) => ({ + // The checkbox is a token of its own; the box is drawn from `task`, so drop it from the run. + blocks: (item.tokens ?? []).filter((child) => child.type !== 'checkbox'), + task: item.task === true, + checked: item.checked === true, + })), + } +} + +export interface ProseCell { + runs: Token[] + align: 'center' | 'left' | 'right' | null +} + +export interface ProseTable { + header: ProseCell[] + rows: ProseCell[][] +} + +export function tableOf(token: Token): ProseTable { + const table = token as Tokens.Table + const cell = (source: Tokens.TableCell): ProseCell => ({ + runs: source.tokens ?? [], + align: source.align ?? null, + }) + return { + header: (table.header ?? []).map(cell), + rows: (table.rows ?? []).map((row) => row.map(cell)), } - return out.filter((segment) => segment.text.length > 0) -} - -export function prose(body: string): ProseBlock[] { - return body - .split(/\n\n+/) - .map((block) => block.trim()) - .filter((block) => block.length > 0) - .map((block) => - block.startsWith('## ') - ? { kind: 'heading' as const, text: block.slice(3).trim() } - : { kind: 'paragraph' as const, segments: segments(block) }, - ) +} + +/** The plain text of a token tree — what a row's one line and a title attribute want. */ +export function plain(tokens: Token[]): string { + return tokens + .map((token) => { + if (token.type === 'br') return ' ' + if (token.type === 'list') return listOf(token).items.map((item) => plain(item.blocks)).join(' ') + // An image stands for its alt text; a link for its label, not its address. + if (token.type === 'image') return textOf(token) + const children = kids(token) + return children.length > 0 ? plain(children) : textOf(token) + }) + .join('') } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7d9ab51..e34a040 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -88,6 +88,9 @@ importers: '@radial/sidecar': specifier: workspace:* version: link:../sidecar + marked: + specifier: ^18.0.7 + version: 18.0.7 devDependencies: '@sveltejs/adapter-static': specifier: ^3.0.10 @@ -760,6 +763,11 @@ packages: magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + marked@18.0.7: + resolution: {integrity: sha512-iDVQ5ldaiKXn6b2JroX5kgRfmwgqolW7NpaEzTl1k/2Zh1njIEN9yniyLV/mOvWwtsE8OGgkjsCYvijuPk1dtA==} + engines: {node: '>= 20'} + hasBin: true + mri@1.2.0: resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} engines: {node: '>=4'} @@ -1517,6 +1525,8 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + marked@18.0.7: {} + mri@1.2.0: {} mrmime@2.0.1: {} -- 2.51.2