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.

409 linesCodeBlameRaw
1/**
2 * g1t's own blocks in the page editor, beside BlockNote's: callouts,
3 * Mermaid diagrams, math, and cards for g1t things (a work item, a pull
4 * request, a channel, a project, another page); and inline mentions of
5 * people, agents and pages, dates, and citations of code (a file, folder
6 * or pattern in a repository, and the symbol, endpoint or environment
7 * variable there the page describes). The artifacts service reads the same
8 * names and attributes when it writes Markdown (services/artifacts
9 * src/markdown.ts), so keep the two in step. Browser-only: loaded with
10 * the editor.
11 */
12import { BlockNoteSchema, createCodeBlockSpec, defaultBlockSpecs, defaultInlineContentSpecs } from "@blocknote/core";
13import { createReactBlockSpec, createReactInlineContentSpec } from "@blocknote/react";
14import { codeBlockOptions } from "@blocknote/code-block";
15import {
16 AlertTriangle,
17 Braces,
18 CheckCircle2,
19 CircleDot,
20 FileCode2,
21 FileText,
22 GitPullRequest,
23 Hash,
24 Info,
25 KeyRound,
26 OctagonAlert,
27 Package,
28 Route,
29} from "lucide-react";
30import { useEffect, useId, useState, type ReactNode } from "react";
31
32import { citationHref } from "../../../lib/folios";
33import { useDrawnTheme } from "../../../lib/theme";
34import { Button } from "../../ui/button";
35import { Card } from "../../ui/card";
36
37/** What an embed shows, from the site (`-/artifacts/api?embed=`). */
38export type EmbedCard = {
39 kind: "item" | "pull" | "channel" | "project" | "page" | "link";
40 title: string;
41 subtitle: string | null;
42 state: string | null;
43 href: string;
44};
45
46const CALLOUTS = {
47 info: { icon: <Info size={16} />, ring: "border-info/30 bg-info/8", text: "text-info" },
48 warning: { icon: <AlertTriangle size={16} />, ring: "border-warn/30 bg-warn/8", text: "text-warn" },
49 success: { icon: <CheckCircle2 size={16} />, ring: "border-success/30 bg-success/8", text: "text-success" },
50 danger: { icon: <OctagonAlert size={16} />, ring: "border-danger/30 bg-danger/8", text: "text-danger" },
51} as const;
52
53export const Callout = createReactBlockSpec(
54 {
55 type: "callout",
56 propSchema: {
57 kind: { default: "info", values: ["info", "warning", "success", "danger"] },
58 backgroundColor: { default: "default" },
59 textColor: { default: "default" },
60 },
61 content: "inline",
62 },
63 {
64 render: ({ block, editor, contentRef }) => {
65 const look = CALLOUTS[block.props.kind as keyof typeof CALLOUTS] ?? CALLOUTS.info;
66 const next = { info: "warning", warning: "success", success: "danger", danger: "info" } as const;
67 return (
68 <div className={`my-1 flex w-full gap-2.5 rounded-lg border px-3.5 py-2.5 ${look.ring}`}>
69 <button
70 type="button"
71 contentEditable={false}
72 aria-label="Change the callout's kind"
73 disabled={!editor.isEditable}
74 onClick={() => editor.updateBlock(block, { props: { kind: next[block.props.kind as keyof typeof next] ?? "info" } })}
75 className={`mt-0.5 shrink-0 ${look.text}`}
76 >
77 {look.icon}
78 </button>
79 <div ref={contentRef} className="min-w-0 grow" />
80 </div>
81 );
82 },
83 },
84);
85
86/** A diagram drawn from its Mermaid source, live as it is typed. */
87function MermaidView({ code }: { code: string }) {
88 const id = useId().replace(/[^a-zA-Z0-9]/g, "");
89 const [svg, setSvg] = useState<string | null>(null);
90 const [error, setError] = useState<string | null>(null);
91 // Drawn again in the other theme's colours when the page switches.
92 const theme = useDrawnTheme();
93 useEffect(() => {
94 let cancelled = false;
95 const timer = setTimeout(async () => {
96 try {
97 const mermaid = (await import("mermaid")).default;
98 mermaid.initialize({ startOnLoad: false, theme: theme === "light" ? "default" : "dark", securityLevel: "strict", fontFamily: "inherit" });
99 const { svg } = await mermaid.render(`mermaid-${id}-${Date.now()}`, code || "flowchart LR\n A --> B");
100 if (!cancelled) {
101 setSvg(svg);
102 setError(null);
103 }
104 } catch (e) {
105 if (!cancelled) setError(e instanceof Error ? e.message.split("\n")[0]! : "This diagram has an error.");
106 }
107 }, 250);
108 return () => {
109 cancelled = true;
110 clearTimeout(timer);
111 };
112 }, [code, id, theme]);
113 if (error) return <p className="text-xs text-danger">{error}</p>;
114 if (!svg) return <p className="text-xs text-faint">Drawing…</p>;
115 // Mermaid sanitizes what it draws (securityLevel "strict").
116 return <div className="flex justify-center overflow-x-auto [&_svg]:max-w-full" dangerouslySetInnerHTML={{ __html: svg }} />;
117}
118
119function SourceEditor({
120 value,
121 onChange,
122 placeholder,
123 mono = true,
124 editable,
125}: {
126 value: string;
127 onChange: (v: string) => void;
128 placeholder: string;
129 mono?: boolean;
130 editable: boolean;
131}) {
132 const [draft, setDraft] = useState(value);
133 useEffect(() => setDraft(value), [value]);
134 if (!editable) return null;
135 return (
136 <textarea
137 value={draft}
138 placeholder={placeholder}
139 spellCheck={false}
140 rows={Math.min(14, Math.max(2, draft.split("\n").length))}
141 onChange={(e) => setDraft(e.target.value)}
142 onBlur={() => draft !== value && onChange(draft)}
143 onKeyDown={(e) => e.stopPropagation()}
144 className={`w-full resize-y rounded-md border border-line bg-bg px-2.5 py-2 text-[0.8125rem] leading-relaxed text-fg outline-none focus:border-(--color-accent) ${mono ? "font-mono" : ""}`}
145 />
146 );
147}
148
149export const Mermaid = createReactBlockSpec(
150 { type: "mermaid", propSchema: { code: { default: "flowchart LR\n A[Idea] --> B[Spec] --> C[Shipped]" } }, content: "none" },
151 {
152 render: ({ block, editor }) => {
153 const [editing, setEditing] = useState(false);
154 return (
155 <Card radius="lg" className="my-1 w-full p-3" contentEditable={false}>
156 <div className="mb-2 flex items-center justify-between text-[0.6875rem] font-medium tracking-wide text-faint uppercase">
157 <span>Diagram</span>
158 {editor.isEditable && (
159 <Button
160 type="button"
161 onClick={() => setEditing(!editing)}
162 variant="ghost"
163 size="inline"
164 className="rounded px-1.5 py-0.5 font-normal normal-case text-faint"
165 >
166 {editing ? "Done" : "Edit source"}
167 </Button>
168 )}
169 </div>
170 {editing && (
171 <SourceEditor
172 editable
173 value={block.props.code}
174 placeholder="flowchart LR&#10; A --> B"
175 onChange={(code) => editor.updateBlock(block, { props: { code } })}
176 />
177 )}
178 <div className={editing ? "mt-3" : ""}>
179 <MermaidView code={block.props.code} />
180 </div>
181 </Card>
182 );
183 },
184 },
185);
186
187/** A formula, typeset with KaTeX. */
188function MathView({ expression, inline = false }: { expression: string; inline?: boolean }) {
189 const [html, setHtml] = useState<string | null>(null);
190 useEffect(() => {
191 let cancelled = false;
192 void (async () => {
193 const [{ default: katex }] = await Promise.all([import("katex"), import("katex/dist/katex.min.css")]);
194 const out = katex.renderToString(expression || "\\;", { throwOnError: false, displayMode: !inline, output: "html", trust: false, strict: "ignore" });
195 if (!cancelled) setHtml(out);
196 })();
197 return () => {
198 cancelled = true;
199 };
200 }, [expression, inline]);
201 if (html === null) return <code className="text-xs text-faint">{expression}</code>;
202 // KaTeX escapes what it is given and allows no commands that run anything (trust: false).
203 return <div className="overflow-x-auto text-fg" dangerouslySetInnerHTML={{ __html: html }} />;
204}
205
206export const MathBlock = createReactBlockSpec(
207 { type: "math", propSchema: { expression: { default: "e^{i\\pi} + 1 = 0" } }, content: "none" },
208 {
209 render: ({ block, editor }) => {
210 const [editing, setEditing] = useState(false);
211 return (
212 <div className="group/math my-1 w-full rounded-lg px-3 py-2 hover:bg-surface" contentEditable={false}>
213 {editing ? (
214 <SourceEditor
215 editable
216 value={block.props.expression}
217 placeholder="\int_0^1 x^2\,dx"
218 onChange={(expression) => editor.updateBlock(block, { props: { expression } })}
219 />
220 ) : null}
221 <button
222 type="button"
223 className="block w-full text-left"
224 disabled={!editor.isEditable}
225 onClick={() => setEditing(!editing)}
226 aria-label={editing ? "Done editing the formula" : "Edit the formula"}
227 >
228 <MathView expression={block.props.expression} />
229 </button>
230 </div>
231 );
232 },
233 },
234);
235
236const EMBED_ICONS: Record<EmbedCard["kind"], ReactNode> = {
237 item: <CircleDot size={16} />,
238 pull: <GitPullRequest size={16} />,
239 channel: <Hash size={16} />,
240 project: <Package size={16} />,
241 page: <FileText size={16} />,
242 link: <FileText size={16} />,
243};
244
245/** The card for a g1t address, asked of the site as the reader (so it shows only what they may see). */
246function EmbedView({ url, title }: { url: string; title: string }) {
247 const [card, setCard] = useState<EmbedCard | null | "missing">(null);
248 useEffect(() => {
249 let cancelled = false;
250 const workspace = window.location.pathname.split("/")[1] ?? "";
251 fetch(`/${workspace}/-/artifacts/api?embed=${encodeURIComponent(url)}`, { headers: { accept: "application/json" } })
252 .then((r) => r.json() as Promise<{ ok: boolean; value?: EmbedCard }>)
253 .then((r) => !cancelled && setCard(r.ok && r.value ? r.value : "missing"))
254 .catch(() => !cancelled && setCard("missing"));
255 return () => {
256 cancelled = true;
257 };
258 }, [url]);
259 const shown = card && card !== "missing" ? card : null;
260 const state = shown?.state ?? null;
261 const tone = state === "open" ? "text-success" : state === "merged" ? "text-merged" : state === "closed" ? "text-danger" : "text-(--g1t-muted)";
262 return (
263 <a
264 href={shown?.href ?? url}
265 contentEditable={false}
266 className="my-1 flex w-full items-center gap-3 rounded-lg border border-line bg-surface px-3.5 py-2.5 no-underline transition-colors hover:border-line-strong hover:bg-raised"
267 >
268 <span className={`shrink-0 ${tone}`}>{EMBED_ICONS[shown?.kind ?? "link"]}</span>
269 <span className="min-w-0 grow">
270 <span className="block truncate text-sm font-medium text-fg">{shown?.title ?? (title || url)}</span>
271 <span className="block truncate text-xs text-faint">
272 {card === null ? "Loading…" : card === "missing" ? "You can't see this, or it's gone." : shown?.subtitle}
273 </span>
274 </span>
275 {state && <span className={`shrink-0 rounded-full px-2 py-0.5 text-[0.6875rem] font-medium capitalize ring-1 ring-current/30 ${tone}`}>{state}</span>}
276 </a>
277 );
278}
279
280export const Embed = createReactBlockSpec(
281 { type: "embed", propSchema: { url: { default: "" }, title: { default: "" }, kind: { default: "link" } }, content: "none" },
282 {
283 render: ({ block, editor }) => {
284 const [draft, setDraft] = useState("");
285 if (!block.props.url) {
286 if (!editor.isEditable) return <div contentEditable={false} />;
287 return (
288 <form
289 contentEditable={false}
290 className="my-1 flex w-full gap-2 rounded-lg border border-dashed border-line p-2"
291 onSubmit={(e) => {
292 e.preventDefault();
293 const url = draft.trim();
294 if (url) editor.updateBlock(block, { props: { url } });
295 }}
296 >
297 <input
298 autoFocus
299 value={draft}
300 onChange={(e) => setDraft(e.target.value)}
301 onKeyDown={(e) => e.stopPropagation()}
302 placeholder="Paste the address of a work item, pull request, channel, project or artifact"
303 className="min-w-0 grow bg-transparent px-1.5 text-sm text-fg outline-none placeholder:text-faint"
304 />
305 <Button type="submit" variant="secondary" size="inline" className="px-2.5 py-1 text-xs">
306 Embed
307 </Button>
308 </form>
309 );
310 }
311 return <EmbedView url={block.props.url} title={block.props.title} />;
312 },
313 },
314);
315
316/** A person, agent or page, inline. */
317export const Mention = createReactInlineContentSpec(
318 {
319 type: "mention",
320 propSchema: { kind: { default: "user" }, id: { default: "" }, name: { default: "" }, href: { default: "" } },
321 content: "none",
322 },
323 {
324 render: ({ inlineContent }) => {
325 const p = inlineContent.props;
326 if (p.kind === "page") {
327 return (
328 <a href={p.href || "#"} className="rounded px-0.5 font-medium text-(--color-accent) no-underline hover:underline">
329 <FileText size={13} className="mr-0.5 inline -translate-y-px" aria-hidden="true" />
330 {p.name || "Untitled"}
331 </a>
332 );
333 }
334 return (
335 <span
336 className={`rounded px-1 py-px font-medium ${p.kind === "agent" ? "bg-[color-mix(in_srgb,var(--color-accent)_15%,transparent)] text-(--color-accent)" : "bg-info/12 text-info"}`}
337 >
338 @{p.name}
339 </span>
340 );
341 },
342 },
343);
344
345/** A date, inline. */
346export const DateChip = createReactInlineContentSpec(
347 { type: "date", propSchema: { date: { default: "" } }, content: "none" },
348 {
349 render: ({ inlineContent }) => {
350 const at = new Date(`${inlineContent.props.date}T12:00:00`);
351 const label = Number.isNaN(at.getTime())
352 ? inlineContent.props.date
353 : at.toLocaleDateString(undefined, { weekday: "short", month: "short", day: "numeric", year: "numeric" });
354 return <span className="rounded bg-raised px-1 py-px text-[0.9em] text-fg-soft">{label}</span>;
355 },
356 },
357);
358
359const CITATION_ICONS: Record<string, ReactNode> = {
360 path: <FileCode2 size={12} aria-hidden="true" />,
361 symbol: <Braces size={12} aria-hidden="true" />,
362 endpoint: <Route size={12} aria-hidden="true" />,
363 env: <KeyRound size={12} aria-hidden="true" />,
364};
365
366/**
367 * Code the page cites, inline: what it names (the path, or the symbol,
368 * endpoint or variable there), linking to it in Code at the commit it was
369 * cited at. When a merge changes it, the page is marked possibly out of
370 * date (services/artifacts src/staleness.ts).
371 */
372export const Citation = createReactInlineContentSpec(
373 {
374 type: "citation",
375 propSchema: { repo: { default: "" }, path: { default: "" }, kind: { default: "path" }, label: { default: "" }, ref: { default: "" } },
376 content: "none",
377 },
378 {
379 render: ({ inlineContent }) => {
380 const p = inlineContent.props;
381 const shown = p.kind !== "path" && p.label ? p.label : p.path;
382 return (
383 <a
384 href={citationHref({ repo: p.repo, path: p.path, ref: p.ref || null })}
385 aria-label={`${shown} in ${p.repo}${p.kind !== "path" ? `, ${p.path}` : ""}`}
386 className="inline-flex items-center gap-1 rounded border border-line bg-raised px-1 py-px align-baseline font-mono text-[0.85em] text-fg no-underline hover:border-line-strong"
387 >
388 <span className="text-faint">{CITATION_ICONS[p.kind] ?? CITATION_ICONS.path}</span>
389 {shown}
390 </a>
391 );
392 },
393 },
394);
395
396export const schema = BlockNoteSchema.create({
397 blockSpecs: {
398 ...defaultBlockSpecs,
399 codeBlock: createCodeBlockSpec(codeBlockOptions),
400 callout: Callout(),
401 mermaid: Mermaid(),
402 math: MathBlock(),
403 embed: Embed(),
404 },
405 inlineContentSpecs: { ...defaultInlineContentSpecs, mention: Mention, date: DateChip, citation: Citation },
406});
407
408export type DocSchema = typeof schema;
409export type DocEditorInstance = typeof schema.BlockNoteEditor;