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