Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

176 linesCodeBlameRaw
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
7import { type RefContext, parseRef, refHref } from "./refs.ts";
8
9type Text = { type: "text"; value: string };
10type 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. */
18type 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. */
29export type MarkdownRepo = { namespace: string; name: string };
30
31/** The repository a site path like `/acme/web` names. */
32export function repoAt(base?: string): MarkdownRepo | undefined {
33 const [namespace, name] = (base ?? "").split("/").filter(Boolean);
34 return namespace && name ? { namespace, name } : undefined;
35}
36
37export const ALERTS = ["note", "tip", "important", "warning", "caution"] as const;
38export type AlertKind = (typeof ALERTS)[number];
39
40const ALERT_MARKER = /^\s*\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\]\s*\n?/i;
41
42function 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 */
50export 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 */
80export 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). */
98function 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 */
108export 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. */
111const 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
114function 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 */
130export 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}