import type { Definition, Nodes, Root, RootContent } from 'mdast' import { fromMarkdown } from 'mdast-util-from-markdown' import { gfmFromMarkdown } from 'mdast-util-gfm' import { gfm } from 'micromark-extension-gfm' import { z } from 'zod' // The document's pure half: the state read the forgiving way, the Markdown read into a tree the view // renders as React elements (never as HTML), which links may open, and what the view publishes about // it. Pure, so all of it can be checked in a test. /** The longest document the state takes, in characters. The whole tree goes to every window on every change. */ export const MARKDOWN_MAX = 100_000 /** The headings the outline lists, and how much of each heading, in characters. */ const OUTLINE_MAX = 40 const HEADING_MAX = 80 /** How much of the title the map line carries, in characters. */ const SUMMARY_MAX = 60 /** The most main takes as one publish, in bytes of JSON. */ const PUBLISH_MAX = 4096 /** A
models put in a table cell, or anywhere, for a line break. */ const LINE_BREAK = /^$/i export const StateSchema = z.object({ markdown: z .string() .max(MARKDOWN_MAX) .describe('The whole document in GitHub-flavored Markdown. Setting it replaces what is shown.'), }) export const OutputSchema = z.object({ title: z.string().nullable(), characters: z.number().int().nonnegative(), outline: z.array(z.string()), }) export type DocOutput = z.infer /** The Markdown in a panel's state as main stores it, as written, with no defaults applied. */ export function readMarkdown(state: unknown): string { const markdown = record(state)?.['markdown'] return typeof markdown === 'string' ? markdown : '' } /** The Markdown as GitHub reads it: CommonMark plus tables, task lists, strikethrough, footnotes, and bare links. */ export function parseDoc(source: string): Root { return fromMarkdown(source, { extensions: [gfm()], mdastExtensions: [gfmFromMarkdown()] }) } /** The address a link opens, or null: main opens https links only, so nothing else is a link here. */ export function linkHref(url: string): string | null { try { return new URL(url).protocol === 'https:' ? url : null } catch { return null } } export function isLineBreak(html: string): boolean { return LINE_BREAK.test(html) } /** * The definitions reference links and images point at, by the normalized label mdast gives both. A * definition inside a quote or a list item counts for the whole document. */ export function definitionsOf(tree: Root): Map { const found = new Map() const visit = (nodes: RootContent[]): void => { for (const node of nodes) { if (node.type === 'definition') { if (!found.has(node.identifier)) found.set(node.identifier, node) } else if ('children' in node) visit(node.children) } } visit(tree.children) return found } /** What the output says about the document: its title, how long it is, and its headings, in order. */ export function docOutput(tree: Root, source: string): DocOutput { const headings = headingsOf(tree) const output: DocOutput = { title: headings[0] ? cut(headings[0].text, HEADING_MAX) : null, characters: source.length, outline: headings.slice(0, OUTLINE_MAX).map((heading) => `${'#'.repeat(heading.depth)} ${cut(heading.text, HEADING_MAX)}`), } while (output.outline.length > 0 && byteLength(JSON.stringify(output)) > PUBLISH_MAX) output.outline.pop() return output } /** The line the model's map carries: the document's title, or its first line with words, and its length. */ export function summarize(state: unknown, output: unknown): string { const markdown = readMarkdown(state) if (markdown.trim() === '') return 'Empty document' const published = record(output) // An output the view published for an earlier document says nothing about this one. const title = published?.['characters'] === markdown.length && typeof published['title'] === 'string' ? published['title'] : null return `${cut(title ?? firstLine(markdown), SUMMARY_MAX)} (${thousands(markdown.length)} chars)` } /** The panel's text: a line naming the document, which a citation of it takes as its title, then the Markdown as written. */ export function docText(markdown: string, title: string | null): string | null { return markdown.trim() === '' ? null : `${title ?? 'Document'}\n\n${markdown}` } /** Every heading with words in it, in document order, quotes and list items included. */ function headingsOf(tree: Root): { depth: number; text: string }[] { const found: { depth: number; text: string }[] = [] const visit = (nodes: RootContent[]): void => { for (const node of nodes) { if (node.type === 'heading') { const text = plainText(node) if (text) found.push({ depth: node.depth, text }) } else if ('children' in node) visit(node.children) } } visit(tree.children) return found } /** A node's words as a reader sees them: marks dropped, code and image descriptions kept, spacing collapsed. */ function plainText(node: Nodes): string { const words = (current: Nodes): string => { if (current.type === 'text' || current.type === 'inlineCode') return current.value if (current.type === 'image' || current.type === 'imageReference') return current.alt ?? '' if (current.type === 'break') return ' ' return 'children' in current ? current.children.map(words).join('') : '' } return words(node).replace(/\s+/g, ' ').trim() } /** At most max characters, the last one an ellipsis when anything was cut. Characters, not UTF-16 halves. */ function cut(text: string, max: number): string { const characters = Array.from(text) return characters.length > max ? `${characters.slice(0, max - 1).join('')}…` : text } function firstLine(markdown: string): string { return markdown.split('\n').map((line) => line.trim()).find((line) => line !== '') ?? '' } /** 100000 as 100,000, the same in any locale. */ function thousands(value: number): string { return String(value).replace(/\B(?=(\d{3})+(?!\d))/g, ',') } function byteLength(value: string): number { return new TextEncoder().encode(value).length } function record(value: unknown): Record | null { return typeof value === 'object' && value !== null && !Array.isArray(value) ? (value as Record) : null }