1
14
15import type { BlockType } from "@factory/shared/dto";
16
17
18export interface SlashCommandDef {
19
20 readonly id: string;
21
22 readonly label: string;
23
24 readonly keywords: string;
25
26 readonly blockType?: BlockType;
27
28 readonly special?: "database" | "synced" | "template";
29
30 readonly group: string;
31
32 readonly shortcut?: string;
33}
34
35
36export const SLASH_COMMANDS: readonly SlashCommandDef[] = [
37
38 { id: "paragraph", label: "Text", keywords: "text plain paragraph default", blockType: "paragraph", group: "Basic writing" },
39 { id: "heading_1", label: "Heading 1", keywords: "h1 title big large", blockType: "heading_1", group: "Basic writing" },
40 { id: "heading_2", label: "Heading 2", keywords: "h2 subtitle medium", blockType: "heading_2", group: "Basic writing" },
41 { id: "heading_3", label: "Heading 3", keywords: "h3 subheading small", blockType: "heading_3", group: "Basic writing" },
42 { id: "quote", label: "Quote", keywords: "quotation blockquote cite", blockType: "quote", group: "Basic writing" },
43 { id: "callout", label: "Callout", keywords: "note hint info banner", blockType: "callout", group: "Basic writing" },
44
45 { id: "bulleted_list", label: "Bulleted list", keywords: "bullet unordered list items", blockType: "bulleted_list", group: "Lists" },
46 { id: "numbered_list", label: "Numbered list", keywords: "ordered list steps numbers", blockType: "numbered_list", group: "Lists" },
47 { id: "todo", label: "To-do", keywords: "todo task checkbox check", blockType: "todo", group: "Lists" },
48 { id: "toggle", label: "Toggle", keywords: "collapse collapsible expand fold", blockType: "toggle", group: "Lists" },
49
50 { id: "image", label: "Image", keywords: "picture photo upload media", blockType: "image", group: "Media" },
51 { id: "video", label: "Video", keywords: "movie film clip media", blockType: "video", group: "Media" },
52 { id: "audio", label: "Audio", keywords: "sound music voice media", blockType: "audio", group: "Media" },
53 { id: "file", label: "File", keywords: "document attachment upload pdf", blockType: "file", group: "Media" },
54 { id: "bookmark", label: "Bookmark", keywords: "link url web page", blockType: "bookmark", group: "Media" },
55 { id: "embed", label: "Embed", keywords: "loom figma twitter youtube link url", blockType: "embed", group: "Media" },
56
57 { id: "code", label: "Code", keywords: "snippet programming codeblock", blockType: "code", group: "Code & math" },
58 { id: "equation", label: "Equation", keywords: "formula math latex", blockType: "equation", group: "Code & math" },
59
60 { id: "divider", label: "Divider", keywords: "separator hr line rule", blockType: "divider", group: "Layout" },
61 { id: "toc", label: "Table of contents", keywords: "toc contents headings outline navigation", blockType: "toc", group: "Layout" },
62 { id: "spacer", label: "Spacer", keywords: "space padding blank gap", blockType: "spacer", group: "Layout" },
63 { id: "column_list", label: "Columns", keywords: "grid layout two three", blockType: "column_list", group: "Layout" },
64
65 { id: "table", label: "Table", keywords: "rows columns grid static", blockType: "table", group: "Data" },
66 { id: "database", label: "Database", keywords: "db records tracker table", special: "database", group: "Data" },
67 { id: "linked_database", label: "Linked view", keywords: "linked database view embed", blockType: "linked_database", group: "Data" },
68 { id: "chart", label: "Chart", keywords: "bar line pie donut number graph", blockType: "chart", group: "Data" },
69 { id: "form", label: "Form", keywords: "survey input questionnaire", blockType: "form", group: "Data" },
70
71 { id: "child_page", label: "Sub-page", keywords: "page nested subpage child", blockType: "child_page", group: "Advanced" },
72 { id: "child_database", label: "Linked database block", keywords: "child database embed", blockType: "child_database", group: "Advanced" },
73 { id: "synced_block", label: "Synced block", keywords: "sync duplicate mirror", special: "synced", group: "Advanced" },
74];
75
76
77export interface SlashCommandHit {
78 readonly command: SlashCommandDef;
79 readonly score: number;
80}
81
82
87export function fuzzyScore(query: string, text: string): number | null {
88 if (query.length === 0) return 0;
89 const q = query.toLowerCase();
90 const t = text.toLowerCase();
91 let qi = 0;
92 let score = 0;
93 let lastMatch = -2;
94 for (let ti = 0; ti < t.length && qi < q.length; ti += 1) {
95 if (t[ti] === q[qi]) {
96
97 if (lastMatch >= 0 && ti !== lastMatch + 1) score += 4;
98 score += 1;
99 lastMatch = ti;
100 qi += 1;
101 }
102 }
103 if (qi < q.length) return null;
104
105 if (t.startsWith(q)) score = 1;
106 return score;
107}
108
109
114export function searchSlashCommands(query: string): readonly SlashCommandHit[] {
115 const trimmed = query.trim().toLowerCase();
116 if (trimmed.length === 0) {
117 return SLASH_COMMANDS.map((command) => ({ command, score: 0 }));
118 }
119 const hits: SlashCommandHit[] = [];
120 SLASH_COMMANDS.forEach((command, index) => {
121 const haystack = `${command.label} ${command.keywords}`;
122 const exact = fuzzyScore(trimmed, command.label);
123 const loose = exact !== null ? exact + 10 : fuzzyScore(trimmed, haystack);
124 if (loose !== null) {
125 hits.push({ command, score: loose * 1000 + index });
126 }
127 });
128 hits.sort((a, b) => a.score - b.score);
129 return hits;
130}
131