import { html, nothing, type TemplateResult } from "lit"; import { decodeHTMLStrict as decodeEntities } from "entities"; import { Lexer, type MarkedToken, type Token, type Tokens } from "marked"; import { REFERENT_TOKEN_PATTERN } from "../../src/contracts/handle-table.ts"; /** The schemes a link in a rendered answer may point at. */ const LINK_SCHEMES = new Set(["http:", "https:"]); /** * `href` and the host it goes to, when a link may point at it, and * `undefined` otherwise. */ const safeHref = ( href: string, ): { href: string; host: string } | undefined => { try { const url = new URL(href); return LINK_SCHEMES.has(url.protocol) ? { href, host: url.host } : undefined; } catch { return undefined; } }; /** What a rendering knows beside the source. */ interface Context { /** The strings return referents stand for, by token. */ revealed: Readonly>; } /** The longest found string shown before it is cut. */ const MAX_FOUND_CHARS = 120; /** * `found` as one line the reader can see all of: each control character, line * or paragraph separator, and direction mark spelled as an escape. */ export const visibleFound = (found: string): string => found.replace( /[\p{Cc}\p{Zl}\p{Zp}\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]/gu, (character) => character === "\n" ? "\\n" : `\\u{${(character.codePointAt(0) ?? 0).toString(16).toUpperCase()}}`, ); /** `text` cut to `limit` characters, never inside one. */ export const cutFound = (text: string, limit = MAX_FOUND_CHARS): string => { const characters = Array.from(text); return characters.length <= limit ? text : `${characters.slice(0, limit).join("")}…`; }; /** * `tokens` as the tokens the lexer documents. The lexer's `Token` also admits * a generic token whose `type` is any string, which only an extension * produces; this renderer installs none, so every token is one of these, and * a switch on `type` then narrows to each one's fields. */ const known = (tokens: readonly Token[] | undefined): readonly MarkedToken[] => (tokens ?? []) as readonly MarkedToken[]; /** What a found string's chip says it is. */ const FOUND_BADGE = html`found`; /** * `found` as something an agent found: isolated, so its direction cannot run * into the text around it, and badged, so it never reads as the answer's own * words. One line, with no space around it, since it may sit in a code block; * cut short where it is long, with the whole of it on hover. */ const foundChip = (found: string): TemplateResult => { const visible = visibleFound(found); return html`${FOUND_BADGE}${ cutFound(visible) }`; }; /** * `text` split at each token `pattern` matches: the text between tokens * through `plain`, and each token through `marked`, or left in the text where * `marked` returns `undefined`. */ export const splitAtTokens = ( text: string, pattern: RegExp, plain: (text: string) => T, marked: (token: string) => T | undefined, ): T[] => { const parts: T[] = []; let from = 0; for (const match of text.matchAll(new RegExp(pattern))) { const part = marked(match[0]); if (part === undefined) continue; parts.push(plain(text.slice(from, match.index)), part); from = match.index + match[0].length; } parts.push(plain(text.slice(from))); return parts; }; /** * `text`, with each return referent the context reveals shown as the string * it stands for, marked as something an agent found rather than something the * answer's author wrote. */ const revealing = (text: string, context: Context): unknown[] => splitAtTokens( text, REFERENT_TOKEN_PATTERN, (plain) => plain, (token) => Object.hasOwn(context.revealed, token) ? foundChip(context.revealed[token]) : undefined, ); /** * Plain `text`, with each return referent `revealed` names shown as the * string it stands for, marked as found by an agent. */ export const revealedText = ( text: string, revealed: Readonly> = {}, ): unknown[] => revealing(text, { revealed }); const inline = (tokens: readonly Token[] | undefined, context: Context) => known(tokens).map((token) => inlineToken(token, context)); const inlineToken = (token: MarkedToken, context: Context): unknown => { switch (token.type) { case "text": return textToken(token, context); case "escape": return token.text; case "strong": return html`${inline(token.tokens, context)}`; case "em": return html`${inline(token.tokens, context)}`; case "del": return html`${inline(token.tokens, context)}`; // A code span is written as it reads, character references included. case "codespan": return html`${revealing(token.text, context)}`; case "br": return html`
`; case "checkbox": return checkbox(token); // A link's label shows no found string: the owner would read it as where // the link goes, and the parent wrote the destination. The host it goes // to is shown beside it, so a label cannot pass for somewhere else. case "link": { const target = safeHref(decodeEntities(token.href)); const label = inline(token.tokens, { revealed: {} }); return target === undefined ? label : html` ${label} (${target.host}) `; } // An image in an answer is shown as its description: the pane loads // nothing an answer names. case "image": return decodeEntities(token.text); // Raw markup in the source is not rendered. default: return nothing; } }; /** A task list's box, showing its state; the pane changes nothing. */ const checkbox = (token: Tokens.Checkbox): unknown => token.checked ? html`` : html``; const textToken = (token: Tokens.Text, context: Context): unknown => token.tokens === undefined ? revealing(decodeEntities(token.text), context) : inline(token.tokens, context); const blocks = (tokens: readonly Token[], context: Context) => known(tokens).map((token) => block(token, context)); const block = (token: MarkedToken, context: Context): unknown => { switch (token.type) { case "heading": { const content = inline(token.tokens, context); // A tag name cannot be bound, so each level names its own tag. An // answer's headings sit under the pane's own, so they start at h3. switch (token.depth) { case 1: return html`

${content}

`; case 2: return html`

${content}

`; case 3: return html`
${content}
`; default: return html`
${content}
`; } } case "paragraph": return html`

${inline(token.tokens, context)}

`; case "code": return html`
${revealing(token.text, context)}
`; case "blockquote": return html`
${blocks(token.tokens, context)}
`; case "list": { const items = token.items.map((item) => html`
  • ${blocks(item.tokens, context)}
  • ` ); return token.ordered ? html`
      ${items}
    ` : html`
      ${items}
    `; } case "table": return html` ${token.header.map((cell) => html`` )}${token.rows.map((row) => html`${ row.map((cell) => html``) }` )}
    ${inline(cell.tokens, context)}
    ${inline(cell.tokens, context)}
    `; case "hr": return html`
    `; case "checkbox": return checkbox(token); case "text": return textToken(token, context); // Raw markup in the source is not rendered, and what remains — a blank // line between blocks, a link reference definition — draws nothing. default: return nothing; } }; /** * Renders a Markdown document as a Lit template. The document never becomes * markup: the lexer reads it into tokens, each token becomes a template, and * text reaches the page as text. Raw HTML in the source is dropped, and a * link is kept only when it points at a web address, with its host shown * beside it. * * `revealed` maps return referents to the strings they stand for. Each one * the document names in its text is shown as its string, on one line, cut * short, and marked as found by an agent; a link's destination is left as * written, so a found string never becomes where a link goes. */ export const markdownTemplate = ( source: string, { revealed = {} }: { revealed?: Readonly> } = {}, ): TemplateResult => html`${blocks(new Lexer().lex(source), { revealed })}`;