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