Skip to content
297 linesCodeBlameRaw

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

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