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