| 1 | /** |
| 2 | * g1t's additions to GitHub flavoured markdown. They run on the HTML tree |
| 3 | * after it has been sanitized, so what they add is trusted and what the |
| 4 | * author wrote never is. |
| 5 | */ |
| 6 | |
| 7 | import { type RefContext, parseRef, refHref } from "./refs.ts"; |
| 8 | |
| 9 | type Text = { type: "text"; value: string }; |
| 10 | type Element = { |
| 11 | type: "element"; |
| 12 | tagName: string; |
| 13 | properties: Record<string, unknown>; |
| 14 | children: Node[]; |
| 15 | position?: Node["position"]; |
| 16 | }; |
| 17 | /** A node of the HTML tree, loosely: only what these plugins read. */ |
| 18 | type Node = { |
| 19 | type: string; |
| 20 | value?: string; |
| 21 | tagName?: string; |
| 22 | properties?: Record<string, unknown>; |
| 23 | children?: Node[]; |
| 24 | /** Where in the text it was written; none for what a plugin made. */ |
| 25 | position?: { start: { line: number } }; |
| 26 | }; |
| 27 | |
| 28 | /** The repository a document belongs to, so `#12` is read in it. */ |
| 29 | export type MarkdownRepo = { namespace: string; name: string }; |
| 30 | |
| 31 | /** The repository a site path like `/acme/web` names. */ |
| 32 | export function repoAt(base?: string): MarkdownRepo | undefined { |
| 33 | const [namespace, name] = (base ?? "").split("/").filter(Boolean); |
| 34 | return namespace && name ? { namespace, name } : undefined; |
| 35 | } |
| 36 | |
| 37 | export const ALERTS = ["note", "tip", "important", "warning", "caution"] as const; |
| 38 | export type AlertKind = (typeof ALERTS)[number]; |
| 39 | |
| 40 | const ALERT_MARKER = /^\s*\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\]\s*\n?/i; |
| 41 | |
| 42 | function isElement(node: Node, tag?: string): node is Element { |
| 43 | return node.type === "element" && (tag == null || (node as Element).tagName === tag); |
| 44 | } |
| 45 | |
| 46 | /** |
| 47 | * GitHub's alerts: a quote that starts with `[!NOTE]`, `[!TIP]`, |
| 48 | * `[!IMPORTANT]`, `[!WARNING]` or `[!CAUTION]` becomes a callout. |
| 49 | */ |
| 50 | export function rehypeAlerts() { |
| 51 | return (tree: Node) => { |
| 52 | const walk = (node: Node) => { |
| 53 | for (const child of node.children ?? []) { |
| 54 | if (isElement(child, "blockquote")) { |
| 55 | const first = child.children.find((c): c is Element => isElement(c, "p")); |
| 56 | const text = first?.children[0]; |
| 57 | const match = text?.type === "text" ? ALERT_MARKER.exec((text as Text).value) : null; |
| 58 | if (first && text && match) { |
| 59 | (text as Text).value = (text as Text).value.slice(match[0].length); |
| 60 | // A marker on a line of its own leaves an empty paragraph behind. |
| 61 | if (first.children.length === 1 && !(text as Text).value.trim()) { |
| 62 | child.children = child.children.filter((c) => c !== first); |
| 63 | } |
| 64 | child.properties = { ...child.properties, dataAlert: match[1]!.toLowerCase() }; |
| 65 | } |
| 66 | } |
| 67 | walk(child); |
| 68 | } |
| 69 | }; |
| 70 | walk(tree); |
| 71 | }; |
| 72 | } |
| 73 | |
| 74 | /** |
| 75 | * Task list boxes: each box markdown made (not one written as HTML, which |
| 76 | * has a place of its own in the text) says the line of its list item, as |
| 77 | * `data-task-line`, so a click can tick the box on that line |
| 78 | * (`@g1t/contracts/tasks`). |
| 79 | */ |
| 80 | export function rehypeTaskLines() { |
| 81 | return (tree: Node) => { |
| 82 | const walk = (node: Node) => { |
| 83 | for (const child of node.children ?? []) { |
| 84 | if (isElement(child, "li") && child.position) { |
| 85 | const box = child.children.find((c): c is Element => isElement(c, "input")) ?? firstInput(child); |
| 86 | if (box && !box.position && box.properties.type === "checkbox") { |
| 87 | box.properties = { ...box.properties, dataTaskLine: child.position.start.line }; |
| 88 | } |
| 89 | } |
| 90 | walk(child); |
| 91 | } |
| 92 | }; |
| 93 | walk(tree); |
| 94 | }; |
| 95 | } |
| 96 | |
| 97 | /** The box at the start of a list item whose text is in a paragraph (a loose list's). */ |
| 98 | function firstInput(item: Element): Element | undefined { |
| 99 | const first = item.children.find((c) => c.type !== "text" || c.value?.trim()); |
| 100 | return first && isElement(first, "p") ? first.children.find((c): c is Element => isElement(c, "input")) : undefined; |
| 101 | } |
| 102 | |
| 103 | /** |
| 104 | * Where `@g1t` leads: g1t's own agent, which has no profile. Never `/g1t`, |
| 105 | * which staff made an alias of Flagon, Inc.'s workspace: mentioning g1t |
| 106 | * always means the agent. |
| 107 | */ |
| 108 | export const G1T_MENTION_HREF = "https://docs.g1t.sh/"; |
| 109 | |
| 110 | /** `owner/repo#12`, `workspace#12`, `#12`, `@workspace/team`, `@name` and commit hashes, in one pass. */ |
| 111 | const REFERENCE = |
| 112 | /(?<![\w/@#])(?:(?:([a-z0-9][a-z0-9-]*)(?:\/([a-z0-9._-]+))?)?#(\d+)|@([a-z0-9](?:[a-z0-9]|-(?=[a-z0-9])){0,38})\/([a-z0-9](?:[a-z0-9]|-(?=[a-z0-9])){0,59})|@([a-z0-9](?:[a-z0-9]|-(?=[a-z0-9])){0,38})|([0-9a-f]{7,40}))(?![\w-])/gi; |
| 113 | |
| 114 | function link(href: string, text: string, kind: string): Element { |
| 115 | return { |
| 116 | type: "element", |
| 117 | tagName: "a", |
| 118 | properties: { href, dataRef: kind }, |
| 119 | children: [{ type: "text", value: text }], |
| 120 | }; |
| 121 | } |
| 122 | |
| 123 | /** |
| 124 | * Links what a forge's text refers to: `#12`, `owner/repo#12` and |
| 125 | * `workspace#12` to the work item or pull request they name, read as |
| 126 | * lib/refs.ts says (in the document's repository, else its workspace), |
| 127 | * `@name` to a person or workspace (`@g1t` to how g1t's agent works), and |
| 128 | * a commit hash to its commit. Nothing inside code or a link changes. |
| 129 | */ |
| 130 | export function rehypeReferences(options: { repo?: MarkdownRepo; workspace?: string }) { |
| 131 | const { repo, workspace: here } = options; |
| 132 | const context: RefContext | null = repo ? { repo } : here ? { workspace: here } : null; |
| 133 | return (tree: Node) => { |
| 134 | const walk = (node: Node) => { |
| 135 | const children = node.children; |
| 136 | if (!children) return; |
| 137 | for (let index = 0; index < children.length; index++) { |
| 138 | const child = children[index]!; |
| 139 | if (isElement(child)) { |
| 140 | if (child.tagName === "a" || child.tagName === "code" || child.tagName === "pre") continue; |
| 141 | walk(child); |
| 142 | continue; |
| 143 | } |
| 144 | if (child.type !== "text") continue; |
| 145 | const value = (child as Text).value; |
| 146 | const parts: Node[] = []; |
| 147 | let last = 0; |
| 148 | for (const match of value.matchAll(REFERENCE)) { |
| 149 | const [whole, owner, otherName, number, workspace, team, name, hash] = match; |
| 150 | let replacement: Element | null = null; |
| 151 | if (number) { |
| 152 | const ref = parseRef(owner, otherName, number); |
| 153 | const href = ref ? refHref(ref, context) : null; |
| 154 | if (href) replacement = link(href, whole, "ref"); |
| 155 | } else if (workspace && team) { |
| 156 | replacement = link(`/${workspace.toLowerCase()}/-/teams/${team.toLowerCase()}`, whole, "team"); |
| 157 | } else if (name) { |
| 158 | const href = name.toLowerCase() === "g1t" ? G1T_MENTION_HREF : `/${name.toLowerCase()}`; |
| 159 | replacement = link(href, whole, "mention"); |
| 160 | } else if (hash && repo && /\d/.test(hash) && /[a-f]/i.test(hash)) { |
| 161 | replacement = link(`/${repo.namespace}/${repo.name}/commit/${hash}`, hash.slice(0, 7), "commit"); |
| 162 | } |
| 163 | if (!replacement) continue; |
| 164 | if (match.index! > last) parts.push({ type: "text", value: value.slice(last, match.index) }); |
| 165 | parts.push(replacement); |
| 166 | last = match.index! + whole.length; |
| 167 | } |
| 168 | if (parts.length === 0) continue; |
| 169 | if (last < value.length) parts.push({ type: "text", value: value.slice(last) }); |
| 170 | children.splice(index, 1, ...parts); |
| 171 | index += parts.length - 1; |
| 172 | } |
| 173 | }; |
| 174 | walk(tree); |
| 175 | }; |
| 176 | } |