Markdown Document: A single-view plugin that renders GitHub-flavored Markdown written into a panel's state as formatted React elements on the grid.
| 1 | import type { Definition, Nodes, Root, RootContent } from 'mdast' |
| 2 | import { fromMarkdown } from 'mdast-util-from-markdown' |
| 3 | import { gfmFromMarkdown } from 'mdast-util-gfm' |
| 4 | import { gfm } from 'micromark-extension-gfm' |
| 5 | import { z } from 'zod' |
| 6 | |
| 7 | // The document's pure half: the state read the forgiving way, the Markdown read into a tree the view |
| 8 | // renders as React elements (never as HTML), which links may open, and what the view publishes about |
| 9 | // it. Pure, so all of it can be checked in a test. |
| 10 | |
| 11 | /** The longest document the state takes, in characters. The whole tree goes to every window on every change. */ |
| 12 | export const MARKDOWN_MAX = 100_000 |
| 13 | /** The headings the outline lists, and how much of each heading, in characters. */ |
| 14 | const OUTLINE_MAX = 40 |
| 15 | const HEADING_MAX = 80 |
| 16 | /** How much of the title the map line carries, in characters. */ |
| 17 | const SUMMARY_MAX = 60 |
| 18 | /** The most main takes as one publish, in bytes of JSON. */ |
| 19 | const PUBLISH_MAX = 4096 |
| 20 | /** A <br> models put in a table cell, or anywhere, for a line break. */ |
| 21 | const LINE_BREAK = /^<br\s*\/?>$/i |
| 22 | |
| 23 | export const StateSchema = z.object({ |
| 24 | markdown: z |
| 25 | .string() |
| 26 | .max(MARKDOWN_MAX) |
| 27 | .describe('The whole document in GitHub-flavored Markdown. Setting it replaces what is shown.'), |
| 28 | }) |
| 29 | |
| 30 | export const OutputSchema = z.object({ |
| 31 | title: z.string().nullable(), |
| 32 | characters: z.number().int().nonnegative(), |
| 33 | outline: z.array(z.string()), |
| 34 | }) |
| 35 | |
| 36 | export type DocOutput = z.infer<typeof OutputSchema> |
| 37 | |
| 38 | /** The Markdown in a panel's state as main stores it, as written, with no defaults applied. */ |
| 39 | export function readMarkdown(state: unknown): string { |
| 40 | const markdown = record(state)?.['markdown'] |
| 41 | return typeof markdown === 'string' ? markdown : '' |
| 42 | } |
| 43 | |
| 44 | /** The Markdown as GitHub reads it: CommonMark plus tables, task lists, strikethrough, footnotes, and bare links. */ |
| 45 | export function parseDoc(source: string): Root { |
| 46 | return fromMarkdown(source, { extensions: [gfm()], mdastExtensions: [gfmFromMarkdown()] }) |
| 47 | } |
| 48 | |
| 49 | /** The address a link opens, or null: main opens https links only, so nothing else is a link here. */ |
| 50 | export function linkHref(url: string): string | null { |
| 51 | try { |
| 52 | return new URL(url).protocol === 'https:' ? url : null |
| 53 | } catch { |
| 54 | return null |
| 55 | } |
| 56 | } |
| 57 | |
| 58 | export function isLineBreak(html: string): boolean { |
| 59 | return LINE_BREAK.test(html) |
| 60 | } |
| 61 | |
| 62 | /** |
| 63 | * The definitions reference links and images point at, by the normalized label mdast gives both. A |
| 64 | * definition inside a quote or a list item counts for the whole document. |
| 65 | */ |
| 66 | export function definitionsOf(tree: Root): Map<string, Definition> { |
| 67 | const found = new Map<string, Definition>() |
| 68 | const visit = (nodes: RootContent[]): void => { |
| 69 | for (const node of nodes) { |
| 70 | if (node.type === 'definition') { |
| 71 | if (!found.has(node.identifier)) found.set(node.identifier, node) |
| 72 | } else if ('children' in node) visit(node.children) |
| 73 | } |
| 74 | } |
| 75 | visit(tree.children) |
| 76 | return found |
| 77 | } |
| 78 | |
| 79 | /** What the output says about the document: its title, how long it is, and its headings, in order. */ |
| 80 | export function docOutput(tree: Root, source: string): DocOutput { |
| 81 | const headings = headingsOf(tree) |
| 82 | const output: DocOutput = { |
| 83 | title: headings[0] ? cut(headings[0].text, HEADING_MAX) : null, |
| 84 | characters: source.length, |
| 85 | outline: headings.slice(0, OUTLINE_MAX).map((heading) => `${'#'.repeat(heading.depth)} ${cut(heading.text, HEADING_MAX)}`), |
| 86 | } |
| 87 | while (output.outline.length > 0 && byteLength(JSON.stringify(output)) > PUBLISH_MAX) output.outline.pop() |
| 88 | return output |
| 89 | } |
| 90 | |
| 91 | /** The line the model's map carries: the document's title, or its first line with words, and its length. */ |
| 92 | export function summarize(state: unknown, output: unknown): string { |
| 93 | const markdown = readMarkdown(state) |
| 94 | if (markdown.trim() === '') return 'Empty document' |
| 95 | const published = record(output) |
| 96 | // An output the view published for an earlier document says nothing about this one. |
| 97 | const title = published?.['characters'] === markdown.length && typeof published['title'] === 'string' ? published['title'] : null |
| 98 | return `${cut(title ?? firstLine(markdown), SUMMARY_MAX)} (${thousands(markdown.length)} chars)` |
| 99 | } |
| 100 | |
| 101 | /** The panel's text: a line naming the document, which a citation of it takes as its title, then the Markdown as written. */ |
| 102 | export function docText(markdown: string, title: string | null): string | null { |
| 103 | return markdown.trim() === '' ? null : `${title ?? 'Document'}\n\n${markdown}` |
| 104 | } |
| 105 | |
| 106 | /** Every heading with words in it, in document order, quotes and list items included. */ |
| 107 | function headingsOf(tree: Root): { depth: number; text: string }[] { |
| 108 | const found: { depth: number; text: string }[] = [] |
| 109 | const visit = (nodes: RootContent[]): void => { |
| 110 | for (const node of nodes) { |
| 111 | if (node.type === 'heading') { |
| 112 | const text = plainText(node) |
| 113 | if (text) found.push({ depth: node.depth, text }) |
| 114 | } else if ('children' in node) visit(node.children) |
| 115 | } |
| 116 | } |
| 117 | visit(tree.children) |
| 118 | return found |
| 119 | } |
| 120 | |
| 121 | /** A node's words as a reader sees them: marks dropped, code and image descriptions kept, spacing collapsed. */ |
| 122 | function plainText(node: Nodes): string { |
| 123 | const words = (current: Nodes): string => { |
| 124 | if (current.type === 'text' || current.type === 'inlineCode') return current.value |
| 125 | if (current.type === 'image' || current.type === 'imageReference') return current.alt ?? '' |
| 126 | if (current.type === 'break') return ' ' |
| 127 | return 'children' in current ? current.children.map(words).join('') : '' |
| 128 | } |
| 129 | return words(node).replace(/\s+/g, ' ').trim() |
| 130 | } |
| 131 | |
| 132 | /** At most max characters, the last one an ellipsis when anything was cut. Characters, not UTF-16 halves. */ |
| 133 | function cut(text: string, max: number): string { |
| 134 | const characters = Array.from(text) |
| 135 | return characters.length > max ? `${characters.slice(0, max - 1).join('')}…` : text |
| 136 | } |
| 137 | |
| 138 | function firstLine(markdown: string): string { |
| 139 | return markdown.split('\n').map((line) => line.trim()).find((line) => line !== '') ?? '' |
| 140 | } |
| 141 | |
| 142 | /** 100000 as 100,000, the same in any locale. */ |
| 143 | function thousands(value: number): string { |
| 144 | return String(value).replace(/\B(?=(\d{3})+(?!\d))/g, ',') |
| 145 | } |
| 146 | |
| 147 | function byteLength(value: string): number { |
| 148 | return new TextEncoder().encode(value).length |
| 149 | } |
| 150 | |
| 151 | function record(value: unknown): Record<string, unknown> | null { |
| 152 | return typeof value === 'object' && value !== null && !Array.isArray(value) ? (value as Record<string, unknown>) : null |
| 153 | } |