Jaspers Hub

jaspers / markdown

pluginOfficialReviewed★ 0↓ 6

Markdown Document: A single-view plugin that renders GitHub-flavored Markdown written into a panel's state as formatted React elements on the grid.

6.2 KB
1import type { Definition, Nodes, Root, RootContent } from 'mdast'
2import { fromMarkdown } from 'mdast-util-from-markdown'
3import { gfmFromMarkdown } from 'mdast-util-gfm'
4import { gfm } from 'micromark-extension-gfm'
5import { 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. */
12export const MARKDOWN_MAX = 100_000
13/** The headings the outline lists, and how much of each heading, in characters. */
14const OUTLINE_MAX = 40
15const HEADING_MAX = 80
16/** How much of the title the map line carries, in characters. */
17const SUMMARY_MAX = 60
18/** The most main takes as one publish, in bytes of JSON. */
19const PUBLISH_MAX = 4096
20/** A <br> models put in a table cell, or anywhere, for a line break. */
21const LINE_BREAK = /^<br\s*\/?>$/i
22
23export 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
30export const OutputSchema = z.object({
31 title: z.string().nullable(),
32 characters: z.number().int().nonnegative(),
33 outline: z.array(z.string()),
34})
35
36export 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. */
39export 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. */
45export 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. */
50export 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
58export 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 */
66export 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. */
80export 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. */
92export 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. */
102export 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. */
107function 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. */
122function 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. */
133function 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
138function 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. */
143function thousands(value: number): string {
144 return String(value).replace(/\B(?=(\d{3})+(?!\d))/g, ',')
145}
146
147function byteLength(value: string): number {
148 return new TextEncoder().encode(value).length
149}
150
151function record(value: unknown): Record<string, unknown> | null {
152 return typeof value === 'object' && value !== null && !Array.isArray(value) ? (value as Record<string, unknown>) : null
153}