@firedrill-tools/notion 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +402 -0
  3. package/firedrill/agent.target.json +17 -0
  4. package/firedrill/baseline.scenario.json +5 -0
  5. package/firedrill/bounded.scenario.json +19 -0
  6. package/firedrill/conformance.suite.json +23 -0
  7. package/firedrill/notion-bounded.drill.json +318 -0
  8. package/firedrill/notion-byte-budget.drill.json +116 -0
  9. package/firedrill/notion-mcp-aliases.drill.json +150 -0
  10. package/firedrill/notion-page-authoring.drill.json +254 -0
  11. package/firedrill/notion-rate-limited.drill.json +118 -0
  12. package/firedrill/notion-schema-growth.drill.json +88 -0
  13. package/firedrill/notion-scope-agent-only.drill.json +131 -0
  14. package/firedrill/notion-scope-auditor.drill.json +86 -0
  15. package/firedrill/notion-scope-board-bot.drill.json +128 -0
  16. package/firedrill/notion-scope-notes-bot.drill.json +303 -0
  17. package/firedrill/notion-scope-stranger.drill.json +773 -0
  18. package/firedrill/notion-task-triage.drill.json +277 -0
  19. package/firedrill/notion-trash-and-restore.drill.json +186 -0
  20. package/firedrill/notion-update-lost.drill.json +88 -0
  21. package/firedrill/notion-workspace-read.drill.json +258 -0
  22. package/firedrill/notion-write-unavailable.drill.json +161 -0
  23. package/firedrill/rate-limited.scenario.json +11 -0
  24. package/firedrill/tools/notion/app/assets/ATTRIBUTION.md +35 -0
  25. package/firedrill/tools/notion/app/assets/fonts/OFL.txt +93 -0
  26. package/firedrill/tools/notion/app/assets/fonts/inter-latin.woff2 +0 -0
  27. package/firedrill/tools/notion/app/assets/notion-wordmark.svg +1 -0
  28. package/firedrill/tools/notion/app/assets/notion.svg +1 -0
  29. package/firedrill/tools/notion/app/site/app.js +797 -0
  30. package/firedrill/tools/notion/app/site/assets/fonts/inter-latin.woff2 +0 -0
  31. package/firedrill/tools/notion/app/site/assets/notion-wordmark.svg +1 -0
  32. package/firedrill/tools/notion/app/site/assets/notion.svg +1 -0
  33. package/firedrill/tools/notion/app/site/chrome.js +104 -0
  34. package/firedrill/tools/notion/app/site/cover-picker.js +83 -0
  35. package/firedrill/tools/notion/app/site/database.js +648 -0
  36. package/firedrill/tools/notion/app/site/editors.js +320 -0
  37. package/firedrill/tools/notion/app/site/format-bar.js +97 -0
  38. package/firedrill/tools/notion/app/site/icons.js +131 -0
  39. package/firedrill/tools/notion/app/site/index.html +125 -0
  40. package/firedrill/tools/notion/app/site/page.js +826 -0
  41. package/firedrill/tools/notion/app/site/rich.js +159 -0
  42. package/firedrill/tools/notion/app/site/state.js +170 -0
  43. package/firedrill/tools/notion/app/site/styles.css +826 -0
  44. package/firedrill/tools/notion/app/site/ui.js +418 -0
  45. package/firedrill/tools/notion/behavior.mjs +1123 -0
  46. package/firedrill/tools/notion/lib/blocks.mjs +371 -0
  47. package/firedrill/tools/notion/lib/identity.mjs +123 -0
  48. package/firedrill/tools/notion/lib/ids.mjs +63 -0
  49. package/firedrill/tools/notion/lib/json-depth.mjs +26 -0
  50. package/firedrill/tools/notion/lib/markdown.mjs +381 -0
  51. package/firedrill/tools/notion/lib/properties.mjs +513 -0
  52. package/firedrill/tools/notion/lib/query.mjs +272 -0
  53. package/firedrill/tools/notion/lib/render.mjs +137 -0
  54. package/firedrill/tools/notion/lib/rich-text.mjs +134 -0
  55. package/firedrill/tools/notion/lib/size.mjs +44 -0
  56. package/firedrill/tools/notion/lib/state.mjs +192 -0
  57. package/firedrill/tools/notion/lib/wire.mjs +89 -0
  58. package/firedrill/tools/notion/notion.tool.json +9837 -0
  59. package/firedrill/update-lost.scenario.json +11 -0
  60. package/firedrill/world.json +7039 -0
  61. package/firedrill/write-unavailable.scenario.json +11 -0
  62. package/firedrill.json +5 -0
  63. package/package.json +63 -0
  64. package/starter.json +6482 -0
  65. package/test/conformance.mjs +1186 -0
@@ -0,0 +1,320 @@
1
+ // Popover editors for page property values (the same controls back the property panel and table cells),
2
+ // the emoji picker and the "/" block menu. Every editor resolves to the Notion property value to write.
3
+ import { icon } from "./icons.js";
4
+ import { EMOJIS, OPTION_COLORS, checkbox, pill, plain, textToRich } from "./rich.js";
5
+ import { state } from "./state.js";
6
+ import { $$, call, closePopovers, el, menuList, openPopover, textButton } from "./ui.js";
7
+
8
+ const TEXT_TYPES = ["rich_text", "url", "email", "phone_number", "number"];
9
+
10
+ /**
11
+ * Open the right editor for `definition` (data source property schema entry) anchored at `anchor`.
12
+ * `current` is the rendered property object; `onChange(valueObject)` receives e.g. `{ select: { name } }`.
13
+ */
14
+ export function openPropertyEditor(anchor, definition, current, onChange, options = {}) {
15
+ const type = definition.type;
16
+ const value = current?.[type];
17
+ if (TEXT_TYPES.includes(type)) return textEditor(anchor, definition, value, onChange);
18
+ if (type === "date") return dateEditor(anchor, value, onChange);
19
+ if (type === "select" || type === "status") return optionEditor(anchor, definition, value, onChange, { multi: false });
20
+ if (type === "multi_select") return optionEditor(anchor, definition, value ?? [], onChange, { multi: true });
21
+ if (type === "people") return peopleEditor(anchor, value ?? [], onChange);
22
+ if (type === "relation") return relationEditor(anchor, definition, value ?? [], onChange, options);
23
+ if (type === "checkbox") return onChange({ checkbox: !(value === true) });
24
+ return undefined;
25
+ }
26
+
27
+ function textEditor(anchor, definition, value, onChange) {
28
+ const type = definition.type;
29
+ const initial = type === "rich_text" ? plain(value) : value === null || value === undefined ? "" : String(value);
30
+ const box = el("div", { class: "editor-text" });
31
+ const input = type === "rich_text" ? el("textarea", { class: "editor-input", attrs: { rows: "3", "aria-label": definition.name } }) : el("input", { class: "editor-input", attrs: { type: type === "number" ? "number" : "text", "aria-label": definition.name, step: "any" } });
32
+ input.value = initial;
33
+ const commit = () => {
34
+ const raw = input.value.trim();
35
+ if (raw === initial.trim()) return closePopovers();
36
+ let next;
37
+ if (type === "rich_text") next = { rich_text: textToRich(raw) };
38
+ else if (type === "number") next = { number: raw === "" ? null : Number(raw) };
39
+ else next = { [type]: raw === "" ? null : raw };
40
+ closePopovers();
41
+ onChange(next);
42
+ };
43
+ input.addEventListener("keydown", (event) => {
44
+ if (event.key === "Enter" && !(type === "rich_text" && event.shiftKey)) {
45
+ event.preventDefault();
46
+ commit();
47
+ }
48
+ });
49
+ box.append(input, el("div", { class: "editor-hint", text: type === "rich_text" ? "Shift+Enter for a new line · Enter to save" : "Enter to save · Esc to cancel" }));
50
+ const popover = openPopover(anchor, box, { width: 300, onClose: undefined });
51
+ input.focus();
52
+ input.select?.();
53
+ return popover;
54
+ }
55
+
56
+ function dateEditor(anchor, value, onChange) {
57
+ const box = el("div", { class: "editor-date" });
58
+ const start = el("input", { class: "editor-input", attrs: { type: "date", "aria-label": "Start date" } });
59
+ const end = el("input", { class: "editor-input", attrs: { type: "date", "aria-label": "End date" } });
60
+ start.value = value?.start?.slice(0, 10) ?? "";
61
+ end.value = value?.end?.slice(0, 10) ?? "";
62
+ const endRow = el("label", { class: "editor-row" }, [el("span", { class: "editor-label", text: "End date" }), end]);
63
+ endRow.hidden = !value?.end;
64
+ const toggle = textButton(value?.end ? "Remove end date" : "Add end date", { class: "link", onClick: () => { endRow.hidden = !endRow.hidden; toggle.querySelector("span:last-child").textContent = endRow.hidden ? "Add end date" : "Remove end date"; if (endRow.hidden) end.value = ""; } });
65
+ const save = textButton("Save", { class: "primary", onClick: () => { closePopovers(); onChange({ date: start.value ? { start: start.value, end: end.value && !endRow.hidden ? end.value : null } : null }); } });
66
+ const clear = textButton("Clear", { onClick: () => { closePopovers(); onChange({ date: null }); } });
67
+ box.append(el("label", { class: "editor-row" }, [el("span", { class: "editor-label", text: "Date" }), start]), endRow, toggle, el("div", { class: "editor-actions" }, [clear, save]));
68
+ return openPopover(anchor, box, { width: 300 });
69
+ }
70
+
71
+ function optionEditor(anchor, definition, value, onChange, { multi }) {
72
+ const options = definition[definition.type]?.options ?? definition.config?.options ?? [];
73
+ const selectedIds = new Set(multi ? value.map((option) => option.id) : value ? [value.id] : []);
74
+ const box = el("div", { class: "editor-options" });
75
+ const chips = el("div", { class: "editor-chips" });
76
+ const search = el("input", { class: "editor-search", attrs: { type: "text", placeholder: multi ? "Search or create an option…" : "Search for an option…", "aria-label": "Search options" } });
77
+ const list = el("div", { class: "picker-list", attrs: { role: "listbox" } });
78
+ const renderChips = () => {
79
+ chips.replaceChildren();
80
+ for (const option of options.filter((candidate) => selectedIds.has(candidate.id))) {
81
+ const chip = pill(option, { status: definition.type === "status" });
82
+ const remove = el("button", { class: "chip-remove", attrs: { type: "button", "aria-label": `Remove ${option.name}` } }, icon("close"));
83
+ remove.addEventListener("click", () => { selectedIds.delete(option.id); commit(true); });
84
+ chip.append(remove);
85
+ chips.append(chip);
86
+ }
87
+ chips.hidden = chips.childElementCount === 0;
88
+ };
89
+ const commit = (keepOpen = false) => {
90
+ if (multi) onChange({ multi_select: options.filter((option) => selectedIds.has(option.id)).map((option) => ({ id: option.id })) });
91
+ else {
92
+ const chosen = options.find((option) => selectedIds.has(option.id));
93
+ onChange({ [definition.type]: chosen ? { id: chosen.id } : null });
94
+ }
95
+ if (!keepOpen) closePopovers();
96
+ else render();
97
+ };
98
+ const render = () => {
99
+ renderChips();
100
+ list.replaceChildren();
101
+ const needle = search.value.trim().toLowerCase();
102
+ const visible = options.filter((option) => option.name.toLowerCase().includes(needle));
103
+ list.append(el("div", { class: "picker-heading", text: "Select an option" }));
104
+ for (const option of visible) {
105
+ const row = el("button", { class: `picker-item ${selectedIds.has(option.id) ? "selected" : ""}`.trim(), attrs: { type: "button", role: "option", "aria-selected": String(selectedIds.has(option.id)) } });
106
+ row.append(pill(option, { status: definition.type === "status" }));
107
+ if (selectedIds.has(option.id)) row.append(icon("check", "picker-check"));
108
+ row.addEventListener("click", () => {
109
+ if (multi) {
110
+ if (selectedIds.has(option.id)) selectedIds.delete(option.id);
111
+ else selectedIds.add(option.id);
112
+ commit(true);
113
+ } else {
114
+ selectedIds.clear();
115
+ selectedIds.add(option.id);
116
+ commit();
117
+ }
118
+ });
119
+ list.append(row);
120
+ }
121
+ if (multi && needle.length > 0 && !options.some((option) => option.name.toLowerCase() === needle)) {
122
+ const create = el("button", { class: "picker-item create", attrs: { type: "button" } }, [el("span", { text: "Create " }), pill({ name: search.value.trim(), color: OPTION_COLORS[(search.value.length % (OPTION_COLORS.length - 1)) + 1] })]);
123
+ create.addEventListener("click", () => {
124
+ // A new option name is accepted by the Tool, which adds it to the schema exactly as the product does.
125
+ const names = options.filter((option) => selectedIds.has(option.id)).map((option) => ({ id: option.id }));
126
+ closePopovers();
127
+ onChange({ multi_select: [...names, { name: search.value.trim() }] });
128
+ });
129
+ list.append(create);
130
+ }
131
+ if (visible.length === 0 && !(multi && needle.length > 0)) list.append(el("div", { class: "picker-empty", text: "No options" }));
132
+ if (!multi && selectedIds.size > 0) {
133
+ const clear = el("button", { class: "picker-item clear", attrs: { type: "button" } }, [icon("close", "picker-icon"), el("span", { text: "Clear" })]);
134
+ clear.addEventListener("click", () => { selectedIds.clear(); commit(); });
135
+ list.append(clear);
136
+ }
137
+ };
138
+ search.addEventListener("input", render);
139
+ box.append(chips, search, list);
140
+ render();
141
+ return openPopover(anchor, box, { width: 300 });
142
+ }
143
+
144
+ function peopleEditor(anchor, value, onChange) {
145
+ const persons = [...state.users.values()].filter((user) => user.type === "person");
146
+ const selected = new Set(value.map((user) => user.id));
147
+ const box = el("div", { class: "editor-options" });
148
+ const search = el("input", { class: "editor-search", attrs: { type: "text", placeholder: "Search for a person…", "aria-label": "Search people" } });
149
+ const list = el("div", { class: "picker-list", attrs: { role: "listbox" } });
150
+ const commit = () => onChange({ people: [...selected].map((id) => ({ id })) });
151
+ const render = () => {
152
+ list.replaceChildren();
153
+ const needle = search.value.trim().toLowerCase();
154
+ if (state.usersDenied) {
155
+ list.append(el("div", { class: "picker-empty", text: "This integration can't list workspace users (no user-information capability)." }));
156
+ return;
157
+ }
158
+ if (state.usersTruncated) list.append(el("div", { class: "picker-empty", text: `Showing the first ${state.users.size} workspace members.` }));
159
+ list.append(el("div", { class: "picker-heading", text: "Select a person" }));
160
+ for (const user of persons.filter((user) => user.name.toLowerCase().includes(needle))) {
161
+ const row = el("button", { class: `picker-item ${selected.has(user.id) ? "selected" : ""}`.trim(), attrs: { type: "button", role: "option", "aria-selected": String(selected.has(user.id)) } });
162
+ const initial = el("span", { class: "avatar small", text: user.name.charAt(0).toUpperCase() });
163
+ row.append(initial, el("span", { class: "picker-label", text: user.name }), el("span", { class: "picker-sub", text: user.person?.email ?? (user.is_guest ? "Guest" : "") }));
164
+ if (selected.has(user.id)) row.append(icon("check", "picker-check"));
165
+ row.addEventListener("click", () => {
166
+ if (selected.has(user.id)) selected.delete(user.id);
167
+ else selected.add(user.id);
168
+ commit();
169
+ render();
170
+ });
171
+ list.append(row);
172
+ }
173
+ };
174
+ search.addEventListener("input", render);
175
+ box.append(search, list);
176
+ render();
177
+ return openPopover(anchor, box, { width: 300 });
178
+ }
179
+
180
+ function relationEditor(anchor, definition, value, onChange, options) {
181
+ const config = definition.relation ?? definition.config ?? {};
182
+ const sourceId = config.data_source_id;
183
+ const selected = new Set(value.map((ref) => ref.id));
184
+ const box = el("div", { class: "editor-options" });
185
+ const search = el("input", { class: "editor-search", attrs: { type: "text", placeholder: "Search for a page…", "aria-label": "Search pages" } });
186
+ const list = el("div", { class: "picker-list", attrs: { role: "listbox" } });
187
+ let candidates = [];
188
+ let loading = true;
189
+ let failure;
190
+ const titleOf = (page) => {
191
+ const property = Object.values(page.properties).find((entry) => entry.type === "title");
192
+ const text = property ? plain(property.title) : "";
193
+ return text || "Untitled";
194
+ };
195
+ const commit = () => onChange({ relation: [...selected].map((id) => ({ id })) });
196
+ const render = () => {
197
+ list.replaceChildren();
198
+ if (loading) {
199
+ list.append(el("div", { class: "picker-empty", text: "Loading…" }));
200
+ return;
201
+ }
202
+ if (failure) {
203
+ list.append(el("div", { class: "picker-empty", text: failure }));
204
+ return;
205
+ }
206
+ const needle = search.value.trim().toLowerCase();
207
+ list.append(el("div", { class: "picker-heading", text: "Link to page" }));
208
+ const visible = candidates.filter((page) => titleOf(page).toLowerCase().includes(needle));
209
+ for (const page of visible) {
210
+ const row = el("button", { class: `picker-item ${selected.has(page.id) ? "selected" : ""}`.trim(), attrs: { type: "button", role: "option", "aria-selected": String(selected.has(page.id)) } });
211
+ row.append(el("span", { class: "picker-emoji", text: page.icon?.type === "emoji" ? page.icon.emoji : "" }), el("span", { class: "picker-label", text: titleOf(page) }));
212
+ if (selected.has(page.id)) row.append(icon("check", "picker-check"));
213
+ row.addEventListener("click", () => {
214
+ if (selected.has(page.id)) selected.delete(page.id);
215
+ else selected.add(page.id);
216
+ commit();
217
+ render();
218
+ });
219
+ list.append(row);
220
+ }
221
+ if (visible.length === 0) list.append(el("div", { class: "picker-empty", text: "No pages found" }));
222
+ };
223
+ search.addEventListener("input", render);
224
+ box.append(search, list);
225
+ render();
226
+ const popover = openPopover(anchor, box, { width: 320 });
227
+ void (async () => {
228
+ try {
229
+ const result = await call("data-sources.query", { data_source_id: sourceId, page_size: 100, filter_properties: ["title"], sorts: [{ timestamp: "last_edited_time", direction: "descending" }] });
230
+ candidates = result.results;
231
+ if (result.has_more) options.onNotice?.(`Showing the ${result.results.length} most recently edited pages of the related database; search to narrow the list.`);
232
+ } catch (error) {
233
+ failure = error?.message ?? "Could not load the related pages.";
234
+ }
235
+ loading = false;
236
+ if (popover.isConnected) render();
237
+ })();
238
+ return popover;
239
+ }
240
+
241
+ // ---------------------------------------------------------------------------------------------
242
+ // Emoji picker (page / database icons)
243
+ // ---------------------------------------------------------------------------------------------
244
+
245
+ export function openEmojiPicker(anchor, current, onPick) {
246
+ const box = el("div", { class: "emoji-picker" });
247
+ const tabs = el("div", { class: "picker-tabs" }, [el("span", { class: "picker-tab active", text: "Emoji" }), el("span", { class: "picker-tab", text: "Icons" }), el("span", { class: "picker-tab", text: "Upload" })]);
248
+ const remove = textButton("Remove", { onClick: () => { closePopovers(); onPick(null); } });
249
+ remove.disabled = current === undefined;
250
+ const search = el("input", { class: "editor-search", attrs: { type: "text", placeholder: "Filter…", "aria-label": "Filter emoji" } });
251
+ const grid = el("div", { class: "emoji-grid", attrs: { role: "listbox", "aria-label": "Emoji" } });
252
+ const render = () => {
253
+ grid.replaceChildren();
254
+ for (const emoji of EMOJIS) {
255
+ const button = el("button", { class: `emoji-cell ${emoji === current ? "selected" : ""}`.trim(), text: emoji, attrs: { type: "button", role: "option", "aria-selected": String(emoji === current), "aria-label": emoji } });
256
+ button.addEventListener("click", () => { closePopovers(); onPick(emoji); });
257
+ grid.append(button);
258
+ }
259
+ };
260
+ render();
261
+ box.append(el("div", { class: "picker-head" }, [tabs, remove]), search, el("div", { class: "picker-heading", text: "Emoji" }), grid);
262
+ return openPopover(anchor, box, { width: 352 });
263
+ }
264
+
265
+ // ---------------------------------------------------------------------------------------------
266
+ // "/" block menu
267
+ // ---------------------------------------------------------------------------------------------
268
+
269
+ export const BLOCK_MENU = [
270
+ { type: "paragraph", label: "Text", description: "Just start writing with plain text.", icon: "block_text" },
271
+ { type: "heading_1", label: "Heading 1", description: "Big section heading.", icon: "block_h1" },
272
+ { type: "heading_2", label: "Heading 2", description: "Medium section heading.", icon: "block_h2" },
273
+ { type: "heading_3", label: "Heading 3", description: "Small section heading.", icon: "block_h3" },
274
+ { type: "bulleted_list_item", label: "Bulleted list", description: "Create a simple bulleted list.", icon: "block_bullet" },
275
+ { type: "numbered_list_item", label: "Numbered list", description: "Create a list with numbering.", icon: "block_numbered" },
276
+ { type: "to_do", label: "To-do list", description: "Track tasks with a to-do list.", icon: "block_todo" },
277
+ { type: "toggle", label: "Toggle list", description: "Toggles can hide and show content inside.", icon: "block_toggle" },
278
+ { type: "quote", label: "Quote", description: "Capture a quote.", icon: "block_quote" },
279
+ { type: "callout", label: "Callout", description: "Make writing stand out.", icon: "block_callout" },
280
+ { type: "code", label: "Code", description: "Capture a code snippet.", icon: "block_code" },
281
+ { type: "divider", label: "Divider", description: "Visually divide blocks.", icon: "block_divider" },
282
+ { type: "page", label: "Page", description: "Embed a sub-page inside this page.", icon: "block_page" },
283
+ ];
284
+
285
+ /** Block content object for a fresh block of `type` (the shape blocks.children.append accepts). */
286
+ export function newBlockInput(type, text = "") {
287
+ if (type === "divider") return { type, divider: {} };
288
+ if (type === "code") return { type, code: { rich_text: textToRich(text), language: "plain text" } };
289
+ if (type === "callout") return { type, callout: { rich_text: textToRich(text), icon: { type: "emoji", emoji: "💡" } } };
290
+ if (type === "to_do") return { type, to_do: { rich_text: textToRich(text), checked: false } };
291
+ return { type, [type]: { rich_text: textToRich(text) } };
292
+ }
293
+
294
+ export function openBlockMenu(anchor, onPick, { filter = "" } = {}) {
295
+ const box = el("div", { class: "block-menu" });
296
+ const render = (needle) => {
297
+ box.replaceChildren();
298
+ const items = BLOCK_MENU.filter((item) => item.label.toLowerCase().includes(needle) || item.type.includes(needle));
299
+ if (items.length === 0) {
300
+ box.append(el("div", { class: "picker-empty", text: "No results" }));
301
+ return;
302
+ }
303
+ box.append(el("div", { class: "picker-heading", text: "Basic blocks" }));
304
+ box.append(menuList(items.map((item) => ({ label: item.label, description: item.description, icon: item.icon, onSelect: () => onPick(item.type) }))));
305
+ };
306
+ render(filter.toLowerCase());
307
+ const popover = openPopover(anchor, box, { width: 320, focusFirst: false });
308
+ popover.filter = (needle) => render(needle.toLowerCase());
309
+ return popover;
310
+ }
311
+
312
+ /** Focus the first menu item of the open block menu (Enter from the block that triggered it). */
313
+ export function pickFirstMenuItem(popover) {
314
+ const first = $$(".menu-item:not(:disabled)", popover)[0];
315
+ first?.click();
316
+ }
317
+
318
+ export function checkboxControl(checked, onToggle, label) {
319
+ return checkbox(checked, { onToggle, label });
320
+ }
@@ -0,0 +1,97 @@
1
+ // Inline text toolbar shown over a text selection inside an editable block, as in the product.
2
+ // Editing writes one plain rich-text run (see README), so marks, links, colors, AI and turn-into are shown in place and
3
+ // open a "Not simulated by this Tool" panel. Comment opens the block's discussions, which the Tool serves.
4
+ import { el, notSimulated } from "./ui.js";
5
+ import { icon } from "./icons.js";
6
+
7
+ const handlers = new WeakMap();
8
+ let bar;
9
+ let current;
10
+
11
+ /** Register an editable block element with its in-scope actions ({ openComments }). */
12
+ export function registerFormatTarget(element, actions) {
13
+ handlers.set(element, actions);
14
+ install();
15
+ }
16
+
17
+ const MARKS_TEXT = "Bold, italic, underline, strikethrough, inline code and colors are rich-text annotations. This app writes an edited block back as one plain text run, so formatting from the toolbar is not simulated by this Tool.";
18
+
19
+ function glyphButton(label, text, className, shortcut, note) {
20
+ const button = el("button", { class: `format-btn ${className}`.trim(), title: shortcut ? `${label} ${shortcut}` : label, attrs: { type: "button", "aria-label": label } });
21
+ if (text) button.append(el("span", { class: "format-glyph", text }));
22
+ button.addEventListener("click", () => notSimulated(button, label, note ?? MARKS_TEXT));
23
+ return button;
24
+ }
25
+
26
+ function build() {
27
+ const element = el("div", { class: "format-bar", attrs: { role: "toolbar", "aria-label": "Text formatting" } });
28
+ const ai = glyphButton("Ask AI", "", "format-ai", "", "Notion AI editing is not part of the API subset this Tool serves.");
29
+ ai.append(icon("ai"), el("span", { class: "format-label", text: "Ask AI" }));
30
+ const turn = glyphButton("Turn into", "", "format-turn", "", "Changing a block's type in place is not part of the API subset this Tool serves; use / to insert a new block.");
31
+ turn.append(el("span", { class: "format-label", text: "Text" }), icon("chevron_down", "format-caret"));
32
+ const link = glyphButton("Link", "", "", "⌘K", "Links are rich-text annotations; this app writes edited blocks as plain text, so adding a link is not simulated by this Tool.");
33
+ link.append(icon("link"));
34
+ const comment = el("button", { class: "format-btn format-comment", title: "Comment ⌘⇧M", attrs: { type: "button", "aria-label": "Comment" } }, [icon("comment"), el("span", { class: "format-label", text: "Comment" })]);
35
+ comment.addEventListener("click", () => {
36
+ const actions = current && handlers.get(current);
37
+ hide();
38
+ actions?.openComments?.();
39
+ });
40
+ const color = glyphButton("Text color", "", "format-color", "⌘⇧H");
41
+ color.append(el("span", { class: "format-glyph", text: "A" }), icon("chevron_down", "format-caret"));
42
+ const more = glyphButton("More", "", "", "", "Mentions, equations and the rest of the selection menu are not simulated by this Tool.");
43
+ more.append(icon("more"));
44
+ const divider = () => el("span", { class: "format-divider", attrs: { "aria-hidden": "true" } });
45
+ element.append(
46
+ ai, divider(), turn, divider(), link, comment, divider(),
47
+ glyphButton("Bold", "B", "format-bold", "⌘B"),
48
+ glyphButton("Italicize", "i", "format-italic", "⌘I"),
49
+ glyphButton("Underline", "U", "format-underline", "⌘U"),
50
+ glyphButton("Strike-through", "S", "format-strike", "⌘⇧S"),
51
+ glyphButton("Mark as code", "<>", "format-code", "⌘E"),
52
+ color, divider(), more,
53
+ );
54
+ // Keep the text selection while the toolbar is used.
55
+ element.addEventListener("mousedown", (event) => event.preventDefault());
56
+ element.hidden = true;
57
+ document.body.append(element);
58
+ return element;
59
+ }
60
+
61
+ function hide() {
62
+ if (bar) bar.hidden = true;
63
+ current = undefined;
64
+ }
65
+
66
+ function update() {
67
+ const selection = document.getSelection();
68
+ if (!selection || selection.rangeCount === 0 || selection.isCollapsed) {
69
+ if (!bar?.contains(document.activeElement)) hide();
70
+ return;
71
+ }
72
+ const range = selection.getRangeAt(0);
73
+ const start = range.startContainer.parentElement?.closest?.(".block-text[contenteditable]") ?? (range.startContainer.nodeType === 1 ? range.startContainer.closest(".block-text[contenteditable]") : null);
74
+ if (!start || !handlers.has(start) || !start.contains(range.endContainer) || range.toString().trim() === "") {
75
+ hide();
76
+ return;
77
+ }
78
+ bar ??= build();
79
+ current = start;
80
+ bar.hidden = false;
81
+ const rect = range.getBoundingClientRect();
82
+ const width = bar.offsetWidth;
83
+ const left = Math.max(8, Math.min(window.innerWidth - width - 8, rect.left + rect.width / 2 - width / 2));
84
+ const top = rect.top - bar.offsetHeight - 8 < 8 ? rect.bottom + 8 : rect.top - bar.offsetHeight - 8;
85
+ bar.style.left = `${Math.round(left)}px`;
86
+ bar.style.top = `${Math.round(top)}px`;
87
+ }
88
+
89
+ let installed = false;
90
+ function install() {
91
+ if (installed) return;
92
+ installed = true;
93
+ document.addEventListener("selectionchange", update);
94
+ window.addEventListener("resize", hide);
95
+ document.addEventListener("scroll", hide, true);
96
+ document.addEventListener("keydown", (event) => { if (event.key === "Escape") hide(); });
97
+ }
@@ -0,0 +1,131 @@
1
+ // Monochrome SVG glyphs drawn in the product's icon language (20 px grid, 1.6 px strokes, round caps).
2
+ // Original paths for this package; rendered through the DOM API (never innerHTML with record data).
3
+ const SVG = "http://www.w3.org/2000/svg";
4
+
5
+ const STROKE = {
6
+ search: ["M9 3.5a5.5 5.5 0 1 1 0 11 5.5 5.5 0 0 1 0-11Z", "M13 13l3.5 3.5"],
7
+ home: ["M3.5 9.5 10 4l6.5 5.5V16a1 1 0 0 1-1 1h-3.5v-4.5h-4V17H4.5a1 1 0 0 1-1-1V9.5Z"],
8
+ inbox: ["M4 4.5h12l1.5 7v4a1 1 0 0 1-1 1h-13a1 1 0 0 1-1-1v-4l1.5-7Z", "M2.5 11.5H7l1 2h4l1-2h4.5"],
9
+ settings: ["M10 7.25a2.75 2.75 0 1 1 0 5.5 2.75 2.75 0 0 1 0-5.5Z", "M8.6 3h2.8l.4 1.9 1.6.9 1.9-.6 1.4 2.4-1.5 1.3v1.8l1.5 1.3-1.4 2.4-1.9-.6-1.6.9-.4 1.9H8.6l-.4-1.9-1.6-.9-1.9.6-1.4-2.4 1.5-1.3v-1.8L3.3 7.6l1.4-2.4 1.9.6 1.6-.9L8.6 3Z"],
10
+ templates: ["M4 5.5A1.5 1.5 0 0 1 5.5 4h9A1.5 1.5 0 0 1 16 5.5v9a1.5 1.5 0 0 1-1.5 1.5h-9A1.5 1.5 0 0 1 4 14.5v-9Z", "M4 8.5h12", "M8.5 8.5V16"],
11
+ trash: ["M4 5.5h12", "M8 5.5V4a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1v1.5", "M5.5 5.5 6.2 16a1 1 0 0 0 1 .9h5.6a1 1 0 0 0 1-.9l.7-10.5", "M8.5 8.5v5", "M11.5 8.5v5"],
12
+ invite: ["M8 9.5a2.75 2.75 0 1 0 0-5.5 2.75 2.75 0 0 0 0 5.5Z", "M3 16.5c.4-2.9 2.4-4.5 5-4.5s4.6 1.6 5 4.5", "M15 7v5", "M12.5 9.5h5"],
13
+ compose: ["M11.5 4H5.5A1.5 1.5 0 0 0 4 5.5v9A1.5 1.5 0 0 0 5.5 16h9a1.5 1.5 0 0 0 1.5-1.5V9", "M15.6 3.4a1.4 1.4 0 0 1 2 2L11 12l-2.8.8.8-2.8 6.6-6.6Z"],
14
+ plus: ["M10 4.5v11", "M4.5 10h11"],
15
+ close: ["M5.5 5.5l9 9", "M14.5 5.5l-9 9"],
16
+ chevron_down: ["M6 8l4 4 4-4"],
17
+ chevron_right: ["M8 6l4 4-4 4"],
18
+ chevron_left: ["M12 6l-4 4 4 4"],
19
+ double_chevron_left: ["M11 6l-4 4 4 4", "M16 6l-4 4 4 4"],
20
+ arrow_left: ["M16 10H4", "M9 5l-5 5 5 5"],
21
+ menu: ["M3.5 6h13", "M3.5 10h13", "M3.5 14h13"],
22
+ more: ["M5 10h.01", "M10 10h.01", "M15 10h.01"],
23
+ comment: ["M4 5.5A1.5 1.5 0 0 1 5.5 4h9A1.5 1.5 0 0 1 16 5.5v6a1.5 1.5 0 0 1-1.5 1.5H9l-3.5 3v-3H5.5A1.5 1.5 0 0 1 4 11.5v-6Z"],
24
+ star: ["M10 3.5l2 4.2 4.5.6-3.3 3.2.8 4.5-4-2.2-4 2.2.8-4.5L3.5 8.3 8 7.7l2-4.2Z"],
25
+ star_filled: ["M10 3.5l2 4.2 4.5.6-3.3 3.2.8 4.5-4-2.2-4 2.2.8-4.5L3.5 8.3 8 7.7l2-4.2Z"],
26
+ share: ["M14 7.5a2 2 0 1 0 0-4 2 2 0 0 0 0 4Z", "M6 12a2 2 0 1 0 0-4 2 2 0 0 0 0 4Z", "M14 16.5a2 2 0 1 0 0-4 2 2 0 0 0 0 4Z", "M7.8 9.1l4.4-2.6", "M7.8 10.9l4.4 2.6"],
27
+ clock: ["M10 3.5a6.5 6.5 0 1 1 0 13 6.5 6.5 0 0 1 0-13Z", "M10 6.5V10l2.5 1.5"],
28
+ help: ["M10 3.5a6.5 6.5 0 1 1 0 13 6.5 6.5 0 0 1 0-13Z", "M8 8a2 2 0 1 1 2.8 1.8c-.6.3-.8.6-.8 1.2v.5", "M10 14h.01"],
29
+ page: ["M6 3h5.5L15 6.5V16a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1Z", "M11 3v4h4", "M7.5 10h5", "M7.5 13h5"],
30
+ database: ["M4 5.5A1.5 1.5 0 0 1 5.5 4h9A1.5 1.5 0 0 1 16 5.5v9a1.5 1.5 0 0 1-1.5 1.5h-9A1.5 1.5 0 0 1 4 14.5v-9Z", "M4 8h12", "M4 12h12", "M8 8v8"],
31
+ table: ["M4 5.5A1.5 1.5 0 0 1 5.5 4h9A1.5 1.5 0 0 1 16 5.5v9a1.5 1.5 0 0 1-1.5 1.5h-9A1.5 1.5 0 0 1 4 14.5v-9Z", "M4 8h12", "M4 12h12", "M8 8v8"],
32
+ board: ["M3.5 4.5h3.5v11H3.5z", "M8.25 4.5h3.5v8h-3.5z", "M13 4.5h3.5v6H13z"],
33
+ filter: ["M3.5 5h13", "M6 10h8", "M8.5 15h3"],
34
+ sort: ["M7 4v12", "M4 7l3-3 3 3", "M13 16V4", "M10 13l3 3 3-3"],
35
+ expand: ["M11 4h5v5", "M9 9l7-7", "M9 16H4v-5", "M11 9l-7 7"],
36
+ open: ["M11 4h5v5", "M16 4l-7 7", "M9 5H5.5A1.5 1.5 0 0 0 4 6.5v8A1.5 1.5 0 0 0 5.5 16h8a1.5 1.5 0 0 0 1.5-1.5V11"],
37
+ check: ["M4 10.5l4 4 8-9"],
38
+ restore: ["M4.5 8.5A6 6 0 1 1 6 14", "M4 4.5v4h4"],
39
+ drag: ["M7.5 5h.01", "M7.5 10h.01", "M7.5 15h.01", "M12.5 5h.01", "M12.5 10h.01", "M12.5 15h.01"],
40
+ move: ["M4 5.5A1.5 1.5 0 0 1 5.5 4H9l1.5 2h4A1.5 1.5 0 0 1 16 7.5v7a1.5 1.5 0 0 1-1.5 1.5h-9A1.5 1.5 0 0 1 4 14.5v-9Z", "M10 9v5", "M7.5 11.5 10 14l2.5-2.5"],
41
+ markdown: ["M3.5 5h13v10h-13z", "M6 12.5v-5l2 2.5 2-2.5v5", "M13 7.5v5", "M11.5 11l1.5 1.5 1.5-1.5"],
42
+ link: ["M8.5 11.5a3 3 0 0 0 4.2 0l2.3-2.3a3 3 0 0 0-4.2-4.2L9.6 6.2", "M11.5 8.5a3 3 0 0 0-4.2 0L5 10.8a3 3 0 0 0 4.2 4.2l1.2-1.2"],
43
+ lock: ["M5.5 9h9a1 1 0 0 1 1 1v5.5a1 1 0 0 1-1 1h-9a1 1 0 0 1-1-1V10a1 1 0 0 1 1-1Z", "M7 9V6.5a3 3 0 0 1 6 0V9"],
44
+ warning: ["M10 3.5 17 16H3l7-12.5Z", "M10 8v3.5", "M10 13.8h.01"],
45
+ refresh: ["M15.5 8A6 6 0 0 0 4.7 6.4", "M4.5 3.5v3.5H8", "M4.5 12a6 6 0 0 0 10.8 1.6", "M15.5 16.5V13H12"],
46
+ send: ["M3.5 10 16.5 4l-3 12.5-4-4.5-6-2Z", "M9.5 12l7-8"],
47
+ ai: ["M10 3v3", "M10 14v3", "M3 10h3", "M14 10h3", "M5.2 5.2l2 2", "M12.8 12.8l2 2", "M14.8 5.2l-2 2", "M7.2 12.8l-2 2"],
48
+ image: ["M4 5.5A1.5 1.5 0 0 1 5.5 4h9A1.5 1.5 0 0 1 16 5.5v9a1.5 1.5 0 0 1-1.5 1.5h-9A1.5 1.5 0 0 1 4 14.5v-9Z", "M4.5 14l3.5-4 3 3 2-2 2.5 3", "M12.5 8h.01"],
49
+ learn: ["M3.5 6.5 10 3.5l6.5 3-6.5 3-6.5-3Z", "M6 8v4c0 1.2 1.8 2.5 4 2.5s4-1.3 4-2.5V8", "M16.5 6.5V11"],
50
+ text_color: ["M6 13.5 9.5 4.5h1l3.5 9", "M7.2 10.5h5.6", "M4 16.5h12"],
51
+ bolt: ["M11 2.5 4.5 11h5l-1 6.5L15.5 9h-5l.5-6.5Z"],
52
+ calendar: ["M4 5.5A1.5 1.5 0 0 1 5.5 4h9A1.5 1.5 0 0 1 16 5.5v9a1.5 1.5 0 0 1-1.5 1.5h-9A1.5 1.5 0 0 1 4 14.5v-9Z", "M4 8.5h12", "M7.5 3v2.5", "M12.5 3v2.5"],
53
+ list_view: ["M4 6h.01", "M4 10h.01", "M4 14h.01", "M7 6h9.5", "M7 10h9.5", "M7 14h9.5"],
54
+ gallery: ["M3.5 4h5.5v5.5H3.5z", "M11 4h5.5v5.5H11z", "M3.5 11.5h5.5V17H3.5z", "M11 11.5h5.5V17H11z"],
55
+ timeline: ["M3.5 5.5h7", "M6.5 10h8", "M9.5 14.5h7"],
56
+ chart: ["M4 16.5h12.5", "M6 13.5V9", "M10 13.5V5", "M14 13.5V11"],
57
+ feed: ["M4 4.5h12v4H4z", "M4 11.5h12v4H4z"],
58
+ map: ["M3.5 5.5 7.5 4l5 2 4-1.5v10l-4 1.5-5-2-4 1.5v-10Z", "M7.5 4v10", "M12.5 6v10"],
59
+ teamspace: ["M7 9a2.25 2.25 0 1 0 0-4.5A2.25 2.25 0 0 0 7 9Z", "M13.5 9a2 2 0 1 0 0-4 2 2 0 0 0 0 4Z", "M2.5 15.5c.4-2.4 2-3.8 4.5-3.8s4.1 1.4 4.5 3.8", "M12 11.8c.5-.2 1-.3 1.5-.3 2.1 0 3.4 1.3 3.8 3.5"],
60
+ // Property-type glyphs (the table header and property panel show one before each name).
61
+ prop_title: ["M4 15l3.5-9h1L12 15", "M5.3 12h5.4", "M14 8.5v6.5", "M12.5 12.7a1.6 1.6 0 1 0 1.5 1.5"],
62
+ prop_text: ["M3.5 6h13", "M3.5 10h13", "M3.5 14h8"],
63
+ prop_number: ["M7.5 3.5 6 16.5", "M13.5 3.5 12 16.5", "M4 7.5h13", "M3 12.5h13"],
64
+ prop_select: ["M10 3.5a6.5 6.5 0 1 1 0 13 6.5 6.5 0 0 1 0-13Z", "M7.5 10l1.8 1.8L13 8"],
65
+ prop_multi_select: ["M4 6h1.5", "M4 10h1.5", "M4 14h1.5", "M8 6h8.5", "M8 10h8.5", "M8 14h8.5"],
66
+ prop_status: ["M10 3.5a6.5 6.5 0 1 1 0 13 6.5 6.5 0 0 1 0-13Z", "M10 3.5a6.5 6.5 0 0 1 0 13Z"],
67
+ prop_date: ["M4 5.5A1.5 1.5 0 0 1 5.5 4h9A1.5 1.5 0 0 1 16 5.5v9a1.5 1.5 0 0 1-1.5 1.5h-9A1.5 1.5 0 0 1 4 14.5v-9Z", "M4 8.5h12", "M7.5 3v2.5", "M12.5 3v2.5"],
68
+ prop_people: ["M10 9.5a2.75 2.75 0 1 0 0-5.5 2.75 2.75 0 0 0 0 5.5Z", "M4.5 16.5c.5-2.9 2.6-4.5 5.5-4.5s5 1.6 5.5 4.5"],
69
+ prop_checkbox: ["M4 5.5A1.5 1.5 0 0 1 5.5 4h9A1.5 1.5 0 0 1 16 5.5v9a1.5 1.5 0 0 1-1.5 1.5h-9A1.5 1.5 0 0 1 4 14.5v-9Z", "M7 10.2l2 2 4-4.4"],
70
+ prop_url: ["M8.5 11.5a3 3 0 0 0 4.2 0l2.3-2.3a3 3 0 0 0-4.2-4.2L9.6 6.2", "M11.5 8.5a3 3 0 0 0-4.2 0L5 10.8a3 3 0 0 0 4.2 4.2l1.2-1.2"],
71
+ prop_email: ["M3.5 5.5h13v9h-13z", "M3.5 6l6.5 5 6.5-5"],
72
+ prop_phone: ["M5 3.5h3l1.5 3.5-2 1.5a8 8 0 0 0 4 4l1.5-2 3.5 1.5v3a1.5 1.5 0 0 1-1.5 1.5C8.6 16.5 3.5 11.4 3.5 5A1.5 1.5 0 0 1 5 3.5Z"],
73
+ prop_relation: ["M4 6h6", "M4 10h4", "M4 14h6", "M12 4l4 6-4 6"],
74
+ prop_created_time: ["M10 3.5a6.5 6.5 0 1 1 0 13 6.5 6.5 0 0 1 0-13Z", "M10 6.5V10l2.5 1.5"],
75
+ prop_last_edited_time: ["M10 3.5a6.5 6.5 0 1 1 0 13 6.5 6.5 0 0 1 0-13Z", "M10 6.5V10l2.5 1.5"],
76
+ prop_created_by: ["M10 9.5a2.75 2.75 0 1 0 0-5.5 2.75 2.75 0 0 0 0 5.5Z", "M4.5 16.5c.5-2.9 2.6-4.5 5.5-4.5s5 1.6 5.5 4.5"],
77
+ prop_last_edited_by: ["M10 9.5a2.75 2.75 0 1 0 0-5.5 2.75 2.75 0 0 0 0 5.5Z", "M4.5 16.5c.5-2.9 2.6-4.5 5.5-4.5s5 1.6 5.5 4.5"],
78
+ // Block-type glyphs for the slash menu.
79
+ block_text: ["M3.5 6h13", "M3.5 10h13", "M3.5 14h8"],
80
+ block_h1: ["M4 4.5v11", "M4 10h6", "M10 4.5v11", "M14.5 15.5v-7l-1.5 1"],
81
+ block_h2: ["M4 4.5v11", "M4 10h6", "M10 4.5v11", "M13 9.5c.3-1 1.2-1.3 2-1 1.2.4 1 1.8.3 2.5L13 13.5h3.5"],
82
+ block_h3: ["M4 4.5v11", "M4 10h6", "M10 4.5v11", "M13 8.5h3l-1.7 2.5c1.3 0 2 .7 2 1.6 0 1.3-1.5 2-3.3 1.3"],
83
+ block_bullet: ["M6 6h.01", "M6 10h.01", "M6 14h.01", "M9 6h7.5", "M9 10h7.5", "M9 14h7.5"],
84
+ block_numbered: ["M4 5.5h1.5v3", "M4 8.5h3", "M4 11.5c.4-.5 1.5-.7 2-.2.6.6-.2 1.3-2 3h2.5", "M9 6h7.5", "M9 10h7.5", "M9 14h7.5"],
85
+ block_todo: ["M4 5.5A1.5 1.5 0 0 1 5.5 4h9A1.5 1.5 0 0 1 16 5.5v9a1.5 1.5 0 0 1-1.5 1.5h-9A1.5 1.5 0 0 1 4 14.5v-9Z", "M7 10.2l2 2 4-4.4"],
86
+ block_toggle: ["M6 5l6 5-6 5"],
87
+ block_quote: ["M5 5.5v9", "M8.5 7h8", "M8.5 10h8", "M8.5 13h5"],
88
+ block_callout: ["M4 6.5A1.5 1.5 0 0 1 5.5 5h9A1.5 1.5 0 0 1 16 6.5v7a1.5 1.5 0 0 1-1.5 1.5h-9A1.5 1.5 0 0 1 4 13.5v-7Z", "M7.5 8v.01", "M10 8h3.5", "M7.5 11.5h6"],
89
+ block_code: ["M7 6.5 3.5 10 7 13.5", "M13 6.5l3.5 3.5-3.5 3.5", "M11.5 4.5l-3 11"],
90
+ block_divider: ["M3.5 10h13"],
91
+ block_page: ["M6 3h5.5L15 6.5V16a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1Z", "M11 3v4h4"],
92
+ };
93
+
94
+ const FILLED = new Set(["star_filled"]);
95
+
96
+ /** Build one icon element; `name` unknown → an empty box (so a typo is visible, never a crash). */
97
+ export function icon(name, className = "") {
98
+ const svg = document.createElementNS(SVG, "svg");
99
+ svg.setAttribute("viewBox", "0 0 20 20");
100
+ svg.setAttribute("width", "20");
101
+ svg.setAttribute("height", "20");
102
+ svg.setAttribute("aria-hidden", "true");
103
+ svg.setAttribute("focusable", "false");
104
+ svg.setAttribute("class", `icon icon-${name} ${className}`.trim());
105
+ const filled = FILLED.has(name);
106
+ svg.setAttribute("fill", filled ? "currentColor" : "none");
107
+ svg.setAttribute("stroke", "currentColor");
108
+ svg.setAttribute("stroke-width", "1.6");
109
+ svg.setAttribute("stroke-linecap", "round");
110
+ svg.setAttribute("stroke-linejoin", "round");
111
+ for (const d of STROKE[name] ?? []) {
112
+ const path = document.createElementNS(SVG, "path");
113
+ path.setAttribute("d", d);
114
+ svg.append(path);
115
+ }
116
+ return svg;
117
+ }
118
+
119
+ /** Property-type glyph name for a data source property type. */
120
+ export function propertyIcon(type) {
121
+ return STROKE[`prop_${type}`] ? `prop_${type}` : "prop_text";
122
+ }
123
+
124
+ /** Replace every `<span data-icon="…">` placeholder in `root` with its SVG (used for the static shell). */
125
+ export function hydrateIcons(root = document) {
126
+ for (const placeholder of root.querySelectorAll("[data-icon]")) {
127
+ const name = placeholder.getAttribute("data-icon");
128
+ const svg = icon(name, placeholder.className);
129
+ placeholder.replaceWith(svg);
130
+ }
131
+ }