From 3593b5b41cbd2cb913c26f4a6ad204f54064cd80 Mon Sep 17 00:00:00 2001 From: Simon Prosen <38320875+simpros@users.noreply.github.com> Date: Mon, 6 Apr 2026 09:36:44 +0200 Subject: [PATCH 1/6] feat: add TipTap email editor primitives Introduce reusable TipTap nodes, variable suggestions, and test coverage so initiative emails can support structured placeholders and CTA buttons without changing stored template syntax. --- apps/web/package.json | 11 +- .../editor/template-variables-helper.test.ts | 134 ++++++++++++++ .../editor/tiptap-editor-commands.test.ts | 82 ++++++++ .../components/editor/tiptap-email-button.ts | 175 ++++++++++++++++++ .../editor/tiptap-template-variable.test.ts | 133 +++++++++++++ .../editor/tiptap-template-variable.ts | 146 +++++++++++++++ .../editor/tiptap-variable-suggestion.test.ts | 60 ++++++ .../editor/tiptap-variable-suggestion.ts | 171 +++++++++++++++++ .../src/lib/components/template-variables.ts | 107 +++++++---- bun.lock | 134 ++++++++++++++ 10 files changed, 1114 insertions(+), 39 deletions(-) create mode 100644 apps/web/src/lib/components/editor/template-variables-helper.test.ts create mode 100644 apps/web/src/lib/components/editor/tiptap-editor-commands.test.ts create mode 100644 apps/web/src/lib/components/editor/tiptap-email-button.ts create mode 100644 apps/web/src/lib/components/editor/tiptap-template-variable.test.ts create mode 100644 apps/web/src/lib/components/editor/tiptap-template-variable.ts create mode 100644 apps/web/src/lib/components/editor/tiptap-variable-suggestion.test.ts create mode 100644 apps/web/src/lib/components/editor/tiptap-variable-suggestion.ts diff --git a/apps/web/package.json b/apps/web/package.json index 8697ee6..d28f368 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -52,5 +52,14 @@ "valibot": "^1.2.0", "vite": "^8.0.0" }, - "dependencies": {} + "dependencies": { + "@tiptap/core": "^3.22.2", + "@tiptap/extension-link": "^3.22.2", + "@tiptap/extension-placeholder": "^3.22.2", + "@tiptap/extension-text-align": "^3.22.2", + "@tiptap/extension-underline": "^3.22.2", + "@tiptap/pm": "^3.22.2", + "@tiptap/starter-kit": "^3.22.2", + "@tiptap/suggestion": "^3.22.2" + } } diff --git a/apps/web/src/lib/components/editor/template-variables-helper.test.ts b/apps/web/src/lib/components/editor/template-variables-helper.test.ts new file mode 100644 index 0000000..8dcd208 --- /dev/null +++ b/apps/web/src/lib/components/editor/template-variables-helper.test.ts @@ -0,0 +1,134 @@ +import { describe, it, expect } from "bun:test"; +import { + getAllTemplateVariables, + CORE_LEAD_VARIABLES, + getTemplateVariablePreview, + highlightResolved, +} from "../template-variables"; + +describe("getAllTemplateVariables", () => { + it("returns core variables when no custom fields", () => { + const result = getAllTemplateVariables([]); + expect(result).toEqual(CORE_LEAD_VARIABLES); + }); + + it("includes custom fields after core variables", () => { + const customFields = [{ name: "industry" }, { name: "revenue" }]; + const result = getAllTemplateVariables(customFields); + + expect(result.length).toBe(CORE_LEAD_VARIABLES.length + 2); + + const customItems = result.slice(CORE_LEAD_VARIABLES.length); + expect(customItems[0]).toEqual({ + token: "{{custom.industry}}", + label: "custom.industry", + group: "Custom fields", + }); + expect(customItems[1]).toEqual({ + token: "{{custom.revenue}}", + label: "custom.revenue", + group: "Custom fields", + }); + }); + + it("preserves core variable structure", () => { + const result = getAllTemplateVariables([]); + const leadName = result.find((v) => v.token === "{{lead.name}}"); + expect(leadName).toBeDefined(); + expect(leadName!.label).toBe("lead.name"); + expect(leadName!.group).toBe("Lead fields"); + expect(leadName!.description).toBe("Business name"); + }); + + it("all core variables have group 'Lead fields'", () => { + const result = getAllTemplateVariables([]); + for (const v of result) { + expect(v.group).toBe("Lead fields"); + } + }); +}); + +describe("highlightResolved", () => { + it("replaces lead placeholders with highlighted values", () => { + const result = highlightResolved( + "

Hello {{lead.name}}

", + { + placeId: "place-1", + name: "Acme Corp", + }, + [] + ); + + expect(result).toContain(" { + const result = highlightResolved( + "

Hello {{lead.email}}

", + { + placeId: "place-1", + name: "Acme Corp", + email: null, + }, + [] + ); + + expect(result).toContain("{{lead.email}}"); + expect(result).toContain("#fee2e2"); + }); +}); + +describe("getTemplateVariablePreview", () => { + it("returns the raw token when no lead is selected", () => { + expect(getTemplateVariablePreview("lead.name", null)).toEqual({ + text: "{{lead.name}}", + state: "raw", + }); + }); + + it("returns the raw token when preview is explicitly cleared", () => { + expect(getTemplateVariablePreview("lead.email", null)).toEqual({ + text: "{{lead.email}}", + state: "raw", + }); + }); + + it("returns the resolved lead value when present", () => { + expect( + getTemplateVariablePreview("lead.name", { + placeId: "place-1", + name: "Acme Corp", + }) + ).toEqual({ + text: "Acme Corp", + state: "resolved", + }); + }); + + it("marks known lead variables as missing when empty", () => { + expect( + getTemplateVariablePreview("lead.email", { + placeId: "place-1", + name: "Acme Corp", + email: null, + }) + ).toEqual({ + text: "{{lead.email}}", + state: "missing", + }); + }); + + it("keeps custom variables as raw tokens", () => { + expect( + getTemplateVariablePreview("custom.industry", { + placeId: "place-1", + name: "Acme Corp", + }) + ).toEqual({ + text: "{{custom.industry}}", + state: "raw", + }); + }); +}); diff --git a/apps/web/src/lib/components/editor/tiptap-editor-commands.test.ts b/apps/web/src/lib/components/editor/tiptap-editor-commands.test.ts new file mode 100644 index 0000000..cd0507f --- /dev/null +++ b/apps/web/src/lib/components/editor/tiptap-editor-commands.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from "bun:test"; +import { Editor } from "@tiptap/core"; +import StarterKit from "@tiptap/starter-kit"; +import Link from "@tiptap/extension-link"; +import Underline from "@tiptap/extension-underline"; +import TextAlign from "@tiptap/extension-text-align"; +import Placeholder from "@tiptap/extension-placeholder"; +import { TemplateVariable } from "./tiptap-template-variable"; +import { EmailButton } from "./tiptap-email-button"; + +function createEditor(content = "

Hello

") { + const element = document.createElement("div"); + document.body.appendChild(element); + + return new Editor({ + element, + extensions: [ + StarterKit.configure({ + heading: { levels: [1, 2, 3] }, + link: false, + underline: false, + }), + Underline, + Link, + TextAlign.configure({ + types: ["heading", "paragraph"], + }), + Placeholder, + TemplateVariable, + EmailButton, + ], + content, + }); +} + +describe("TipTap editor commands", () => { + it("inserts a template variable inline", () => { + const editor = createEditor(); + + editor.commands.focus("end"); + const inserted = editor.commands.insertTemplateVariable("lead.name"); + + expect(inserted).toBe(true); + expect(editor.getHTML()).toContain('data-type="template-variable"'); + expect(editor.getHTML()).toContain("{{lead.name}}"); + + editor.destroy(); + }); + + it("inserts an email button block", () => { + const editor = createEditor(); + + editor.commands.focus("end"); + const inserted = editor.commands.insertEmailButton({ + text: "Book a Call", + url: "https://example.com", + backgroundColor: "#c4520a", + textColor: "#ffffff", + }); + + expect(inserted).toBe(true); + expect(editor.getHTML()).toContain('data-type="email-button"'); + expect(editor.getHTML()).toContain("Book a Call"); + expect(editor.getHTML()).toContain("https://example.com"); + + editor.destroy(); + }); + + it("applies a link to selected text", () => { + const editor = createEditor("

Hello world

"); + + editor.commands.setTextSelection({ from: 1, to: 6 }); + const inserted = editor.commands.setLink({ + href: "https://example.com", + }); + + expect(inserted).toBe(true); + expect(editor.getHTML()).toContain('href="https://example.com"'); + + editor.destroy(); + }); +}); diff --git a/apps/web/src/lib/components/editor/tiptap-email-button.ts b/apps/web/src/lib/components/editor/tiptap-email-button.ts new file mode 100644 index 0000000..adc0b7e --- /dev/null +++ b/apps/web/src/lib/components/editor/tiptap-email-button.ts @@ -0,0 +1,175 @@ +import { Node, mergeAttributes } from "@tiptap/core"; + +export type EmailButtonOptions = { + /** Extra HTML attributes added to every rendered node. */ + HTMLAttributes: Record; +}; + +export type EmailButtonAttrs = { + text: string; + url: string; + backgroundColor: string; + textColor: string; +}; + +declare module "@tiptap/core" { + interface Commands { + emailButton: { + /** Insert an email CTA button at the current cursor position. */ + insertEmailButton: (attrs: EmailButtonAttrs) => ReturnType; + /** Update the attributes of the currently selected email button. */ + updateEmailButton: (attrs: Partial) => ReturnType; + }; + } +} + +/** + * Default inline styles for the email button `` tag. + * Uses inline styles exclusively for email client compatibility. + */ +function buildEmailButtonStyles( + backgroundColor: string, + textColor: string +): string { + return [ + `display: inline-block`, + `padding: 12px 24px`, + `background-color: ${backgroundColor}`, + `color: ${textColor}`, + `text-decoration: none`, + `font-weight: 700`, + `font-size: 14px`, + `text-transform: uppercase`, + `letter-spacing: 0.05em`, + `border: 2px solid ${backgroundColor}`, + `text-align: center`, + ].join("; "); +} + +/** + * A TipTap node representing an email CTA button. + * + * - Block-level node (renders as a centered `
` wrapper with an `` inside). + * - Stores text, url, backgroundColor, textColor as attributes. + * - Renders in the editor as a styled button preview. + * - Serializes to email-compatible HTML with inline styles (no CSS classes). + * - Parses matching `` tags from incoming HTML back into button nodes. + */ +export const EmailButton = Node.create({ + name: "emailButton", + + group: "block", + atom: true, + selectable: true, + draggable: false, + + addOptions() { + return { + HTMLAttributes: {}, + }; + }, + + addAttributes() { + return { + text: { + default: "Click Here", + parseHTML: (element) => { + const anchor = element.querySelector("a") ?? element; + return anchor.textContent?.trim() ?? "Click Here"; + }, + }, + url: { + default: "#", + parseHTML: (element) => { + const anchor = element.querySelector("a") ?? element; + return anchor.getAttribute("href") ?? "#"; + }, + }, + backgroundColor: { + default: "#c4520a", + parseHTML: (element) => { + const anchor = element.querySelector("a") ?? element; + const style = anchor.getAttribute("style") ?? ""; + const match = style.match(/background-color:\s*([^;]+)/); + return match?.[1]?.trim() ?? "#c4520a"; + }, + }, + textColor: { + default: "#ffffff", + parseHTML: (element) => { + const anchor = element.querySelector("a") ?? element; + const style = anchor.getAttribute("style") ?? ""; + // Match "color:" that is NOT "background-color:" + const match = style.match(/(? a with inline styles containing display: inline-block) + tag: "div", + getAttrs: (element) => { + if (typeof element === "string") return false; + const anchor = element.querySelector("a"); + if (!anchor) return false; + const style = anchor.getAttribute("style") ?? ""; + if (!style.includes("display: inline-block")) return false; + if (!style.includes("background-color")) return false; + return null; + }, + }, + ]; + }, + + renderHTML({ node, HTMLAttributes }) { + const { text, url, backgroundColor, textColor } = + node.attrs as EmailButtonAttrs; + const buttonStyle = buildEmailButtonStyles(backgroundColor, textColor); + + return [ + "div", + mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, { + "data-type": "email-button", + style: "text-align: center; padding: 16px 0;", + }), + [ + "a", + { + href: url, + style: buttonStyle, + target: "_blank", + rel: "noopener noreferrer", + }, + text, + ], + ]; + }, + + addCommands() { + return { + insertEmailButton: + (attrs: EmailButtonAttrs) => + ({ commands }) => { + return commands.insertContent({ + type: this.name, + attrs, + }); + }, + updateEmailButton: + (attrs: Partial) => + ({ commands, state }) => { + const { from } = state.selection; + const node = state.doc.nodeAt(from); + if (node?.type.name !== this.name) return false; + return commands.updateAttributes(this.name, attrs); + }, + }; + }, +}); diff --git a/apps/web/src/lib/components/editor/tiptap-template-variable.test.ts b/apps/web/src/lib/components/editor/tiptap-template-variable.test.ts new file mode 100644 index 0000000..39bed45 --- /dev/null +++ b/apps/web/src/lib/components/editor/tiptap-template-variable.test.ts @@ -0,0 +1,133 @@ +import { describe, it, expect } from "bun:test"; +import { + normalizeTemplateEditorHtml, + preprocessTemplateHtml, + postprocessTemplateHtml, +} from "./tiptap-template-variable"; + +describe("preprocessTemplateHtml", () => { + it("wraps a single template variable in a span", () => { + const input = "

Hello {{lead.name}}

"; + const result = preprocessTemplateHtml(input); + expect(result).toBe( + '

Hello {{lead.name}}

' + ); + }); + + it("wraps multiple template variables", () => { + const input = "

{{lead.name}} — {{lead.email}}

"; + const result = preprocessTemplateHtml(input); + expect(result).toContain( + 'data-variable-id="lead.name">{{lead.name}}' + ); + expect(result).toContain( + 'data-variable-id="lead.email">{{lead.email}}' + ); + }); + + it("wraps custom field variables", () => { + const input = "{{custom.industry}}"; + const result = preprocessTemplateHtml(input); + expect(result).toBe( + '{{custom.industry}}' + ); + }); + + it("handles text with no variables", () => { + const input = "

Hello world

"; + const result = preprocessTemplateHtml(input); + expect(result).toBe("

Hello world

"); + }); + + it("handles empty string", () => { + expect(preprocessTemplateHtml("")).toBe(""); + }); + + it("handles variables with underscores", () => { + const input = "{{lead.google_maps_url}}"; + const result = preprocessTemplateHtml(input); + expect(result).toContain('data-variable-id="lead.google_maps_url"'); + }); + + it("does not wrap incomplete braces", () => { + const input = "{{incomplete"; + const result = preprocessTemplateHtml(input); + expect(result).toBe("{{incomplete"); + }); +}); + +describe("postprocessTemplateHtml", () => { + it("converts a span back to raw template token", () => { + const input = + '

Hello {{lead.name}}

'; + const result = postprocessTemplateHtml(input); + expect(result).toBe("

Hello {{lead.name}}

"); + }); + + it("converts multiple spans back", () => { + const input = + '{{lead.name}} and {{lead.email}}'; + const result = postprocessTemplateHtml(input); + expect(result).toBe("{{lead.name}} and {{lead.email}}"); + }); + + it("handles HTML with no template variable spans", () => { + const input = "

Hello world

"; + const result = postprocessTemplateHtml(input); + expect(result).toBe("

Hello world

"); + }); + + it("handles empty string", () => { + expect(postprocessTemplateHtml("")).toBe(""); + }); + + it("handles custom field spans", () => { + const input = + '{{custom.industry}}'; + const result = postprocessTemplateHtml(input); + expect(result).toBe("{{custom.industry}}"); + }); +}); + +describe("round-trip: preprocess -> postprocess", () => { + it("preserves raw template HTML through round-trip", () => { + const original = + "

Hello {{lead.name}}, your email is {{lead.email}}

"; + const preprocessed = preprocessTemplateHtml(original); + const postprocessed = postprocessTemplateHtml(preprocessed); + expect(postprocessed).toBe(original); + }); + + it("preserves HTML with no variables through round-trip", () => { + const original = "

No variables here

"; + const preprocessed = preprocessTemplateHtml(original); + const postprocessed = postprocessTemplateHtml(preprocessed); + expect(postprocessed).toBe(original); + }); + + it("preserves mixed content through round-trip", () => { + const original = + '

Welcome

Hi {{lead.name}},

Visit our site

{{custom.offer}}

'; + const preprocessed = preprocessTemplateHtml(original); + const postprocessed = postprocessTemplateHtml(preprocessed); + expect(postprocessed).toBe(original); + }); +}); + +describe("normalizeTemplateEditorHtml", () => { + it("treats empty string as empty content", () => { + expect(normalizeTemplateEditorHtml("")).toBe(""); + expect(normalizeTemplateEditorHtml(" ")).toBe(""); + }); + + it("normalizes TipTap empty paragraphs to empty string", () => { + expect(normalizeTemplateEditorHtml("

")).toBe(""); + expect(normalizeTemplateEditorHtml("


")).toBe(""); + }); + + it("preserves non-empty content while trimming outer whitespace", () => { + expect(normalizeTemplateEditorHtml("

Hello

\n")).toBe( + "

Hello

" + ); + }); +}); diff --git a/apps/web/src/lib/components/editor/tiptap-template-variable.ts b/apps/web/src/lib/components/editor/tiptap-template-variable.ts new file mode 100644 index 0000000..c852c0e --- /dev/null +++ b/apps/web/src/lib/components/editor/tiptap-template-variable.ts @@ -0,0 +1,146 @@ +import { Node, mergeAttributes } from "@tiptap/core"; + +export type TemplateVariableOptions = { + /** Extra HTML attributes added to every rendered node. */ + HTMLAttributes: Record; +}; + +declare module "@tiptap/core" { + interface Commands { + templateVariable: { + /** Insert a template variable node at the current cursor position. */ + insertTemplateVariable: (variableId: string) => ReturnType; + }; + } +} + +/** + * A TipTap node representing a `{{variable}}` template placeholder. + * + * - Inline, atomic (non-editable within the editor). + * - Stores the variable identifier (e.g. `lead.name`) in the `variableId` attr. + * - Renders as a styled `` pill in the editor. + * - Serializes to `{{lead.name}}` in HTML output so the existing + * `resolveTemplate()` backend logic works unchanged. + * - Parses `{{...}}` text back into nodes when loading saved content. + */ +export const TemplateVariable = Node.create({ + name: "templateVariable", + + group: "inline", + inline: true, + atom: true, + selectable: true, + draggable: false, + + addOptions() { + return { + HTMLAttributes: {}, + }; + }, + + addAttributes() { + return { + variableId: { + default: null, + parseHTML: (element) => element.getAttribute("data-variable-id"), + renderHTML: (attributes) => ({ + "data-variable-id": attributes.variableId as string, + }), + }, + }; + }, + + parseHTML() { + return [ + { + tag: 'span[data-type="template-variable"]', + }, + ]; + }, + + renderHTML({ node, HTMLAttributes }) { + const variableId = node.attrs.variableId as string; + + return [ + "span", + mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, { + "data-type": "template-variable", + "data-variable-id": variableId, + class: "tiptap-template-variable", + contenteditable: "false", + }), + `{{${variableId}}}`, + ]; + }, + + addCommands() { + return { + insertTemplateVariable: + (variableId: string) => + ({ commands }) => { + return commands.insertContent({ + type: this.name, + attrs: { variableId }, + }); + }, + }; + }, +}); + +/** + * Regex that matches `{{variableId}}` tokens in plain text. + * Used to convert raw HTML containing template tokens into + * ProseMirror nodes on content load. + */ +const TEMPLATE_TOKEN_REGEX = /\{\{([^}]+)\}\}/g; + +/** + * Pre-processes HTML to convert raw `{{variable}}` text into + * `{{variable}}` + * so TipTap's `parseHTML` can pick them up. + * + * This is necessary because saved/AI-generated content contains raw + * `{{lead.name}}` strings rather than the `` wrapper format. + */ +export function preprocessTemplateHtml(html: string): string { + return html.replace( + TEMPLATE_TOKEN_REGEX, + (_match, variableId: string) => { + return `{{${variableId}}}`; + } + ); +} + +/** + * Post-processes HTML output from TipTap to convert + * `{{var}}` back to + * raw `{{var}}` strings for storage and email sending. + */ +export function postprocessTemplateHtml(html: string): string { + return html.replace( + /]*data-type="template-variable"[^>]*data-variable-id="([^"]*)"[^>]*>[^<]*<\/span>/g, + (_match, variableId: string) => `{{${variableId}}}` + ); +} + +/** + * Normalizes TipTap's empty document HTML to the empty string used by forms/storage. + * + * TipTap represents an empty document as a blank paragraph, which is equivalent + * to no content for this editor. Normalizing these shapes prevents sync loops + * between the external bound value and the internal editor document. + */ +export function normalizeTemplateEditorHtml(html: string): string { + const trimmed = html.trim(); + + if ( + trimmed === "" || + trimmed === "

" || + trimmed === "


" + ) { + return ""; + } + + return trimmed; +} diff --git a/apps/web/src/lib/components/editor/tiptap-variable-suggestion.test.ts b/apps/web/src/lib/components/editor/tiptap-variable-suggestion.test.ts new file mode 100644 index 0000000..89efaf7 --- /dev/null +++ b/apps/web/src/lib/components/editor/tiptap-variable-suggestion.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from "bun:test"; +import { EditorState } from "@tiptap/pm/state"; +import { schema } from "@tiptap/pm/schema-basic"; +import { findVariableSuggestionMatch } from "./tiptap-variable-suggestion"; + +function createTrigger(text: string) { + const doc = schema.node("doc", undefined, [ + schema.node("paragraph", undefined, [schema.text(text)]), + ]); + const state = EditorState.create({ schema, doc }); + const paragraph = state.doc.firstChild; + const textNode = paragraph?.firstChild; + + if (!paragraph || !textNode) { + throw new Error("Failed to create test document"); + } + + return { + char: "{{", + allowSpaces: false, + allowToIncludeChar: false, + allowedPrefixes: null, + startOfLine: false, + $position: state.doc.resolve(paragraph.nodeSize - 1), + } as const; +} + +describe("findVariableSuggestionMatch", () => { + it("matches a fresh double-brace trigger", () => { + const match = findVariableSuggestionMatch(createTrigger("Hello {{")); + + expect(match).not.toBeNull(); + expect(match?.text).toBe("{{"); + expect(match?.query).toBe(""); + }); + + it("matches a typed variable query after double braces", () => { + const match = findVariableSuggestionMatch( + createTrigger("Hello {{lead.na") + ); + + expect(match).not.toBeNull(); + expect(match?.text).toBe("{{lead.na"); + expect(match?.query).toBe("lead.na"); + }); + + it("does not match a single opening brace", () => { + const match = findVariableSuggestionMatch(createTrigger("Hello {")); + + expect(match).toBeNull(); + }); + + it("does not match if whitespace appears after the trigger", () => { + const match = findVariableSuggestionMatch( + createTrigger("Hello {{ lead") + ); + + expect(match).toBeNull(); + }); +}); diff --git a/apps/web/src/lib/components/editor/tiptap-variable-suggestion.ts b/apps/web/src/lib/components/editor/tiptap-variable-suggestion.ts new file mode 100644 index 0000000..9637230 --- /dev/null +++ b/apps/web/src/lib/components/editor/tiptap-variable-suggestion.ts @@ -0,0 +1,171 @@ +import { PluginKey } from "@tiptap/pm/state"; +import Suggestion, { + type SuggestionMatch, + type Trigger, +} from "@tiptap/suggestion"; +import { TemplateVariable } from "./tiptap-template-variable.js"; +import type { TemplateVariable as TemplateVariableType } from "../template-variables.js"; +import type { Editor } from "@tiptap/core"; + +export type SuggestionItem = { + id: string; + label: string; + group: string; + description?: string; +}; + +export type VariableSuggestionOptions = { + /** Called when the suggestion popup should be shown. */ + onStart: (props: SuggestionCallbackProps) => void; + /** Called when the suggestion query changes (e.g. user types more). */ + onUpdate: (props: SuggestionCallbackProps) => void; + /** Called when the suggestion popup should be hidden. */ + onExit: () => void; + /** Called when the user presses keyboard navigation keys. */ + onKeyDown: (event: KeyboardEvent) => boolean; + /** Returns the list of available template variables. */ + getVariables: () => TemplateVariableType[]; +}; + +export type SuggestionCallbackProps = { + query: string; + items: SuggestionItem[]; + command: (item: SuggestionItem) => void; + clientRect: (() => DOMRect | null) | null; + editor: Editor; +}; + +const DOUBLE_BRACE_QUERY_REGEX = /\{\{[^\s{}]*$/; + +export function findVariableSuggestionMatch({ + $position, + allowedPrefixes, +}: Trigger): SuggestionMatch { + const text = $position.nodeBefore?.isText + ? $position.nodeBefore.text + : null; + + if (!text) { + return null; + } + + const match = DOUBLE_BRACE_QUERY_REGEX.exec(text); + + if (!match || match.index === undefined) { + return null; + } + + if (allowedPrefixes !== null) { + const prefix = match.index > 0 ? (text[match.index - 1] ?? "") : ""; + + if (prefix !== "" && !allowedPrefixes.includes(prefix)) { + return null; + } + } + + const textFrom = $position.pos - text.length; + const from = textFrom + match.index; + const to = from + match[0].length; + + if (!(from < $position.pos && to >= $position.pos)) { + return null; + } + + return { + range: { from, to }, + query: match[0].slice(2), + text: match[0], + }; +} + +/** + * Creates a TipTap plugin that handles `{{` trigger for template variable + * autocompletion. Uses TipTap's Suggestion utility under the hood. + * + * The suggestion inserts a `templateVariable` node when the user + * selects an item from the autocomplete popup. + */ +export function createVariableSuggestion( + options: VariableSuggestionOptions +) { + return TemplateVariable.extend({ + addProseMirrorPlugins() { + return [ + ...(this.parent?.() ?? []), + Suggestion({ + editor: this.editor, + char: "{{", + pluginKey: new PluginKey("templateVariableSuggestion"), + allowSpaces: false, + allowedPrefixes: null, + findSuggestionMatch: findVariableSuggestionMatch, + + items: ({ query }) => { + const variables = options.getVariables(); + const items: SuggestionItem[] = variables.map((v) => ({ + id: v.token.slice(2, -2), // Remove {{ and }} + label: v.label, + group: v.group, + description: v.description, + })); + + if (!query) return items; + + const lower = query.toLowerCase(); + return items.filter( + (item) => + item.label.toLowerCase().includes(lower) || + item.id.toLowerCase().includes(lower) || + (item.description?.toLowerCase().includes(lower) ?? false) + ); + }, + + command: ({ editor, range, props }) => { + const item = props as unknown as SuggestionItem; + editor + .chain() + .focus() + .deleteRange(range) + .insertTemplateVariable(item.id) + .run(); + }, + + render: () => ({ + onStart: (props) => { + const items = props.items as SuggestionItem[]; + const command = (item: SuggestionItem) => { + props.command(item as Record); + }; + options.onStart({ + query: props.query, + items, + command, + clientRect: props.clientRect ?? null, + editor: props.editor, + }); + }, + onUpdate: (props) => { + const items = props.items as SuggestionItem[]; + const command = (item: SuggestionItem) => { + props.command(item as Record); + }; + options.onUpdate({ + query: props.query, + items, + command, + clientRect: props.clientRect ?? null, + editor: props.editor, + }); + }, + onKeyDown: ({ event }) => { + return options.onKeyDown(event); + }, + onExit: () => { + options.onExit(); + }, + }), + }), + ]; + }, + }); +} diff --git a/apps/web/src/lib/components/template-variables.ts b/apps/web/src/lib/components/template-variables.ts index 6583773..e5c32b1 100644 --- a/apps/web/src/lib/components/template-variables.ts +++ b/apps/web/src/lib/components/template-variables.ts @@ -54,29 +54,66 @@ export const CORE_LEAD_VARIABLES: TemplateVariable[] = [ export type CustomFieldValue = { name: string; value: string | null }; +export type TemplateVariablePreview = { + text: string; + state: "raw" | "resolved" | "missing"; +}; + export function customFieldToken(fieldName: string): string { return `{{custom.${fieldName}}}`; } +const CORE_LEAD_VALUE_ACCESSORS: Record< + string, + (lead: Lead) => string | null | undefined +> = { + "{{lead.name}}": (lead) => lead.name, + "{{lead.email}}": (lead) => lead.email, + "{{lead.phone}}": (lead) => lead.phone, + "{{lead.website}}": (lead) => lead.website, + "{{lead.address}}": (lead) => lead.address, + "{{lead.rating}}": (lead) => + lead.rating != null ? String(lead.rating) : null, + "{{lead.google_maps_url}}": (lead) => lead.googleMapsUrl, +}; + +export function getTemplateVariablePreview( + variableId: string, + lead: Lead | null +): TemplateVariablePreview { + const token = `{{${variableId}}}`; + + if (!lead) { + return { text: token, state: "raw" }; + } + + const value = CORE_LEAD_VALUE_ACCESSORS[token]?.(lead) + ?.toString() + .trim(); + + if (value) { + return { text: value, state: "resolved" }; + } + + if (token in CORE_LEAD_VALUE_ACCESSORS) { + return { text: token, state: "missing" }; + } + + return { text: token, state: "raw" }; +} + export function resolveTemplate( html: string, lead: Lead, customFields: CustomFieldValue[] ): string { - const coreMap: Record = { - "{{lead.name}}": lead.name, - "{{lead.email}}": lead.email, - "{{lead.phone}}": lead.phone, - "{{lead.website}}": lead.website, - "{{lead.address}}": lead.address, - "{{lead.rating}}": lead.rating != null ? String(lead.rating) : null, - "{{lead.google_maps_url}}": lead.googleMapsUrl, - }; - let result = html; - for (const [token, value] of Object.entries(coreMap)) { - result = result.replaceAll(token, value ?? ""); + for (const { token } of CORE_LEAD_VARIABLES) { + result = result.replaceAll( + token, + CORE_LEAD_VALUE_ACCESSORS[token](lead) ?? "" + ); } for (const field of customFields) { @@ -111,28 +148,15 @@ export function findMissingPlaceholders( const tokens = extractPlaceholders(text); if (tokens.length === 0) return []; - const coreAccessors: Record< - string, - (lead: Lead) => string | null | undefined - > = { - "{{lead.name}}": (l) => l.name, - "{{lead.email}}": (l) => l.email, - "{{lead.phone}}": (l) => l.phone, - "{{lead.website}}": (l) => l.website, - "{{lead.address}}": (l) => l.address, - "{{lead.rating}}": (l) => (l.rating != null ? String(l.rating) : null), - "{{lead.google_maps_url}}": (l) => l.googleMapsUrl, - }; - const knownTokens = new Set([ - ...Object.keys(coreAccessors), + ...Object.keys(CORE_LEAD_VALUE_ACCESSORS), ...customFields.map((f) => customFieldToken(f.name)), ]); const results: { token: string; missingCount: number }[] = []; for (const token of tokens) { - const accessor = coreAccessors[token]; + const accessor = CORE_LEAD_VALUE_ACCESSORS[token]; if (accessor) { const missing = leads.filter( (l) => !accessor(l)?.toString().trim() @@ -149,6 +173,23 @@ export function findMissingPlaceholders( return results; } +/** + * Returns all available template variables for a project, + * combining core lead fields and custom fields into a single list. + * Used by the editor toolbar dropdown and suggestion plugin. + */ +export function getAllTemplateVariables( + customFields: { name: string }[] +): TemplateVariable[] { + const customItems: TemplateVariable[] = customFields.map((f) => ({ + token: customFieldToken(f.name), + label: `custom.${f.name}`, + group: "Custom fields", + })); + + return [...CORE_LEAD_VARIABLES, ...customItems]; +} + export function highlightResolved( originalHtml: string, lead: Lead, @@ -159,16 +200,6 @@ export function highlightResolved( ...customFields.map((f) => customFieldToken(f.name)), ]; - const coreMap: Record = { - "{{lead.name}}": lead.name, - "{{lead.email}}": lead.email, - "{{lead.phone}}": lead.phone, - "{{lead.website}}": lead.website, - "{{lead.address}}": lead.address, - "{{lead.rating}}": lead.rating != null ? String(lead.rating) : null, - "{{lead.google_maps_url}}": lead.googleMapsUrl, - }; - let result = originalHtml; for (const token of allTokens) { @@ -176,7 +207,7 @@ export function highlightResolved( let value: string; if (token.startsWith("{{lead.")) { - value = coreMap[token] ?? ""; + value = CORE_LEAD_VALUE_ACCESSORS[token]?.(lead) ?? ""; } else { const fieldName = token.slice("{{custom.".length, -2); const field = customFields.find((f) => f.name === fieldName); diff --git a/bun.lock b/bun.lock index 3afd451..fcd03ca 100644 --- a/bun.lock +++ b/bun.lock @@ -27,6 +27,16 @@ "apps/web": { "name": "@leader/web", "version": "0.2.4", + "dependencies": { + "@tiptap/core": "^3.22.2", + "@tiptap/extension-link": "^3.22.2", + "@tiptap/extension-placeholder": "^3.22.2", + "@tiptap/extension-text-align": "^3.22.2", + "@tiptap/extension-underline": "^3.22.2", + "@tiptap/pm": "^3.22.2", + "@tiptap/starter-kit": "^3.22.2", + "@tiptap/suggestion": "^3.22.2", + }, "devDependencies": { "@leader/auth": "workspace:*", "@leader/db": "workspace:*", @@ -451,6 +461,8 @@ "@protobufjs/utf8": ["@protobufjs/utf8@1.1.0", "", {}, "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw=="], + "@remirror/core-constants": ["@remirror/core-constants@3.0.0", "", {}, "sha512-42aWfPrimMfDKDi4YegyS7x+/0tlzaqwPQCULLanv3DMIlu96KTJR0fM5isWX2UViOqlGnX6YFgqWepcX+XMNg=="], + "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.0-rc.9", "", { "os": "android", "cpu": "arm64" }, "sha512-lcJL0bN5hpgJfSIz/8PIf02irmyL43P+j1pTCfbD1DbLkmGRuFIA4DD3B3ZOvGqG0XiVvRznbKtN0COQVaKUTg=="], "@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.0-rc.9", "", { "os": "darwin", "cpu": "arm64" }, "sha512-J7Zk3kLYFsLtuH6U+F4pS2sYVzac0qkjcO5QxHS7OS7yZu2LRs+IXo+uvJ/mvpyUljDJ3LROZPoQfgBIpCMhdQ=="], @@ -595,6 +607,62 @@ "@testing-library/svelte-core": ["@testing-library/svelte-core@1.0.0", "", { "peerDependencies": { "svelte": "^3 || ^4 || ^5 || ^5.0.0-next.0" } }, "sha512-VkUePoLV6oOYwSUvX6ShA8KLnJqZiYMIbP2JW2t0GLWLkJxKGvuH5qrrZBV/X7cXFnLGuFQEC7RheYiZOW68KQ=="], + "@tiptap/core": ["@tiptap/core@3.22.2", "", { "peerDependencies": { "@tiptap/pm": "^3.22.2" } }, "sha512-atq35NkpeEphH6vNYJ0pTLLBA73FAbvTV9Ovd3AaTC5s99/KF5Q86zVJXvml8xPRcMGM6dLp+eSSd06oTscMSA=="], + + "@tiptap/extension-blockquote": ["@tiptap/extension-blockquote@3.22.2", "", { "peerDependencies": { "@tiptap/core": "^3.22.2" } }, "sha512-iTdlmGFcgxi4LKaOW2Rc9/yD83qTXgRm5BN3vCHWy5+TbEnReYxYqU5qKsbtTbKy30sO8TJTdAXTZ29uomShQQ=="], + + "@tiptap/extension-bold": ["@tiptap/extension-bold@3.22.2", "", { "peerDependencies": { "@tiptap/core": "^3.22.2" } }, "sha512-bqsPJyKcT/RWse4e16U2EKhraR8a2+98TUuk1amG3yCyFJZStoO/j+pN0IqZdZZjr3WtxFyvwWp7Kc59UN+jUA=="], + + "@tiptap/extension-bullet-list": ["@tiptap/extension-bullet-list@3.22.2", "", { "peerDependencies": { "@tiptap/extension-list": "^3.22.2" } }, "sha512-llrTJnA72RGcWLLO+ro0QN4sjHynhaCerhpV+GZE/ATd8BqV/ekQFdBLJrvC/09My2XQfCwLsyCh92NPXUdELA=="], + + "@tiptap/extension-code": ["@tiptap/extension-code@3.22.2", "", { "peerDependencies": { "@tiptap/core": "^3.22.2" } }, "sha512-iYFY+yzfYA9MKt7nupyW/PzqL9XC2D0mC8l1z2Y10i0/fGL8NbqIYjhNUAyXGqH3QWcI+DirI66842y2OadPOg=="], + + "@tiptap/extension-code-block": ["@tiptap/extension-code-block@3.22.2", "", { "peerDependencies": { "@tiptap/core": "^3.22.2", "@tiptap/pm": "^3.22.2" } }, "sha512-PEwFlDyvtKF19WCrOFg77qJV9WqhvjCY4ZoXlHP9Hx0KTcOA8W39mtw8d4NWU5pLRK94yHKF1DVVL8UUkEOnww=="], + + "@tiptap/extension-document": ["@tiptap/extension-document@3.22.2", "", { "peerDependencies": { "@tiptap/core": "^3.22.2" } }, "sha512-yPw9pQeVC4QDh86TuyKCZxxM4g0NAw7mEtGnAo6EpxaBQr1wyBr9yFpys+QTsQpRTmyTf1VHp4iTTLuWHMljIw=="], + + "@tiptap/extension-dropcursor": ["@tiptap/extension-dropcursor@3.22.2", "", { "peerDependencies": { "@tiptap/extensions": "^3.22.2" } }, "sha512-sDv3fv4LtX0X4nqwh9Gn3C/aZXT+C2JlK7tJovPOpaYP/a6hr03Sn35X5moAfgMCSiWFygEvlTriqwmCsJuxog=="], + + "@tiptap/extension-gapcursor": ["@tiptap/extension-gapcursor@3.22.2", "", { "peerDependencies": { "@tiptap/extensions": "^3.22.2" } }, "sha512-rR2OLrl/k2kj7xehaZHq0Y7T+1wy2DOTabir9LsTrktTFEcklrh9qY1KC6rEBkwMKaWrmignR1l39kS6RlKFNw=="], + + "@tiptap/extension-hard-break": ["@tiptap/extension-hard-break@3.22.2", "", { "peerDependencies": { "@tiptap/core": "^3.22.2" } }, "sha512-ChsoqF4XRp6EWatTRlXL4LMFh/ggwRVCyt09brSfjJV5knFaXlECSa5/+rKLMLMULaj6dVlJqoAD15exgu2HHA=="], + + "@tiptap/extension-heading": ["@tiptap/extension-heading@3.22.2", "", { "peerDependencies": { "@tiptap/core": "^3.22.2" } }, "sha512-QPHLef+ikAyf7RVc4EdGeKxH4OEGb3ueCEwJ41RcYPtZ1BX9ueei7FC936guTdL1U7w3vQ65qfy86HznzkYgvw=="], + + "@tiptap/extension-horizontal-rule": ["@tiptap/extension-horizontal-rule@3.22.2", "", { "peerDependencies": { "@tiptap/core": "^3.22.2", "@tiptap/pm": "^3.22.2" } }, "sha512-Oz8KN5KJAWV1mFNE9UIWXdMD6xa5zPf/0yLsT8V4sgaRm+VsdFKllN58BY9qCZf/kIZbaOez5KkaoeAcm0MAZg=="], + + "@tiptap/extension-italic": ["@tiptap/extension-italic@3.22.2", "", { "peerDependencies": { "@tiptap/core": "^3.22.2" } }, "sha512-fmtQu2HDnV3sOZPdz0+1lOLI7UtrIhusohJj2UwOLQxG8qqhLwbvWx2OQTlfblgY0z+CjLRr6ANbNDxOTIblfg=="], + + "@tiptap/extension-link": ["@tiptap/extension-link@3.22.2", "", { "dependencies": { "linkifyjs": "^4.3.2" }, "peerDependencies": { "@tiptap/core": "^3.22.2", "@tiptap/pm": "^3.22.2" } }, "sha512-TXfSoKmng5pecvQUZqdsx6ICeob5V5hhYOj2vCEtjfcjWsyCndqFIl1w+Nt/yI5ehrFNOVPyj3ZvcELuuAW6pw=="], + + "@tiptap/extension-list": ["@tiptap/extension-list@3.22.2", "", { "peerDependencies": { "@tiptap/core": "^3.22.2", "@tiptap/pm": "^3.22.2" } }, "sha512-Vq9xScgkA2A3Zj9dQ4WUBKK7u7UCzeSFRz9FcKTQVZHRPbZoqFGnlRUVngqsE7JXrCOthXQ1dXxgk40nAsBFRw=="], + + "@tiptap/extension-list-item": ["@tiptap/extension-list-item@3.22.2", "", { "peerDependencies": { "@tiptap/extension-list": "^3.22.2" } }, "sha512-Mk+iiLIFh8Pfuarr6mWfTO7QJbd2ZQd0nGNhNWXlGAO7DJCb4BP9nj4bEIJ17SbcykGRjsi4WMqY50z4MHXqKQ=="], + + "@tiptap/extension-list-keymap": ["@tiptap/extension-list-keymap@3.22.2", "", { "peerDependencies": { "@tiptap/extension-list": "^3.22.2" } }, "sha512-TozU9V2vldMUPpTXnfLCO33EO06jLxn7uEJTMBnN4iX/dLV3cBVCbE4kHyDKS0sLd7joUeekS06vYP9uQb1hFw=="], + + "@tiptap/extension-ordered-list": ["@tiptap/extension-ordered-list@3.22.2", "", { "peerDependencies": { "@tiptap/extension-list": "^3.22.2" } }, "sha512-K7qxoBKmsVkAd3kW64ZRCUPFrDcNGpXRDUBx9YgAO/bTfsfxtH2oil+igsUWGXPczpP4yoHPKjTfhpBpLjGl6Q=="], + + "@tiptap/extension-paragraph": ["@tiptap/extension-paragraph@3.22.2", "", { "peerDependencies": { "@tiptap/core": "^3.22.2" } }, "sha512-EHZZzxVhvzEPDPWtRBF1YKhB+WCUjd1C2NhjHfL3Dl71PBqM3ZWA6qN7NDGPyNyGGWauui/NR/4X+5AfPqlHyA=="], + + "@tiptap/extension-placeholder": ["@tiptap/extension-placeholder@3.22.2", "", { "peerDependencies": { "@tiptap/extensions": "^3.22.2" } }, "sha512-xYw733CmSeG7MyYBDdV5NFiwlBdXXzw4Mvjb2t4QRXagkDbHeNY/LtKTcrtcMNfO4Jx0mwivGQZUIEC8oAfvxg=="], + + "@tiptap/extension-strike": ["@tiptap/extension-strike@3.22.2", "", { "peerDependencies": { "@tiptap/core": "^3.22.2" } }, "sha512-YFC3elKU1L8PiGbcB6tqd/7vWPF5IbydJz0POJpHzSjstX+VfT8VsvS7ubxVuSIWQ11kGkH3mzX6LX8JHsHZxg=="], + + "@tiptap/extension-text": ["@tiptap/extension-text@3.22.2", "", { "peerDependencies": { "@tiptap/core": "^3.22.2" } }, "sha512-J1w7JwijfSD7ah0WfiwZ/DVWCIGT9x369RM4RJc57i44mIBElj7tl1dh+N5KPGOXKUup4gr7sSJAE38lgeaDMg=="], + + "@tiptap/extension-text-align": ["@tiptap/extension-text-align@3.22.2", "", { "peerDependencies": { "@tiptap/core": "^3.22.2" } }, "sha512-pgqyXzVHo4WmDhK26rDwhK2lxQwnjl/9DP816C2k3To/fZRK1eW7q0pSAYteHWmKkaYAxwj/0UvCU0nXKlPujw=="], + + "@tiptap/extension-underline": ["@tiptap/extension-underline@3.22.2", "", { "peerDependencies": { "@tiptap/core": "^3.22.2" } }, "sha512-BaV6WOowxdkGTLWiU7DdZ3Twh633O4RGqwUM5dDas5LvaqL8AMWGTO8Wg9yAaaKXzd9MtKI1ZCqS/+MtzusgkQ=="], + + "@tiptap/extensions": ["@tiptap/extensions@3.22.2", "", { "peerDependencies": { "@tiptap/core": "^3.22.2", "@tiptap/pm": "^3.22.2" } }, "sha512-s7MZmm2Xdq+8feIXgY3v7gVpQ5ClqBZi20KheouS7KSbBlrY4fu2irYR1EGc6r1UUVaHMxEa+cx5knhx+mIPUw=="], + + "@tiptap/pm": ["@tiptap/pm@3.22.2", "", { "dependencies": { "prosemirror-changeset": "^2.3.0", "prosemirror-collab": "^1.3.1", "prosemirror-commands": "^1.6.2", "prosemirror-dropcursor": "^1.8.1", "prosemirror-gapcursor": "^1.3.2", "prosemirror-history": "^1.4.1", "prosemirror-inputrules": "^1.4.0", "prosemirror-keymap": "^1.2.2", "prosemirror-markdown": "^1.13.1", "prosemirror-menu": "^1.2.4", "prosemirror-model": "^1.24.1", "prosemirror-schema-basic": "^1.2.3", "prosemirror-schema-list": "^1.5.0", "prosemirror-state": "^1.4.3", "prosemirror-tables": "^1.6.4", "prosemirror-trailing-node": "^3.0.0", "prosemirror-transform": "^1.10.2", "prosemirror-view": "^1.38.1" } }, "sha512-G2ENwIazoSKkAnN5MN5yN91TIZNFm6TxB74kPf3Empr2k9W51Hkcier70jHGpArhgcEaL4BVreuU1PRDRwCeGw=="], + + "@tiptap/starter-kit": ["@tiptap/starter-kit@3.22.2", "", { "dependencies": { "@tiptap/core": "^3.22.2", "@tiptap/extension-blockquote": "^3.22.2", "@tiptap/extension-bold": "^3.22.2", "@tiptap/extension-bullet-list": "^3.22.2", "@tiptap/extension-code": "^3.22.2", "@tiptap/extension-code-block": "^3.22.2", "@tiptap/extension-document": "^3.22.2", "@tiptap/extension-dropcursor": "^3.22.2", "@tiptap/extension-gapcursor": "^3.22.2", "@tiptap/extension-hard-break": "^3.22.2", "@tiptap/extension-heading": "^3.22.2", "@tiptap/extension-horizontal-rule": "^3.22.2", "@tiptap/extension-italic": "^3.22.2", "@tiptap/extension-link": "^3.22.2", "@tiptap/extension-list": "^3.22.2", "@tiptap/extension-list-item": "^3.22.2", "@tiptap/extension-list-keymap": "^3.22.2", "@tiptap/extension-ordered-list": "^3.22.2", "@tiptap/extension-paragraph": "^3.22.2", "@tiptap/extension-strike": "^3.22.2", "@tiptap/extension-text": "^3.22.2", "@tiptap/extension-underline": "^3.22.2", "@tiptap/extensions": "^3.22.2", "@tiptap/pm": "^3.22.2" } }, "sha512-+CCKX8tOQ/ZPb2k/z6em4AQCFYAcdd8+0TOzPWiuLxRyCHRPBBVhnPsXOKgKwE4OO3E8BsezquuYRYRwsyzCqg=="], + + "@tiptap/suggestion": ["@tiptap/suggestion@3.22.2", "", { "peerDependencies": { "@tiptap/core": "^3.22.2", "@tiptap/pm": "^3.22.2" } }, "sha512-t2GQSrF4eQyPb+KqXVfcC2cokYIDNfpLLq7B0ELlnWBJURnLOVJ2ssJ6ASI247scu9ZKPG1g5bFP4IXdBhyPgg=="], + "@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="], "@types/aria-query": ["@types/aria-query@5.0.4", "", {}, "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw=="], @@ -617,6 +685,12 @@ "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], + "@types/linkify-it": ["@types/linkify-it@5.0.0", "", {}, "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q=="], + + "@types/markdown-it": ["@types/markdown-it@14.1.2", "", { "dependencies": { "@types/linkify-it": "^5", "@types/mdurl": "^2" } }, "sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog=="], + + "@types/mdurl": ["@types/mdurl@2.0.0", "", {}, "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg=="], + "@types/mssql": ["@types/mssql@9.1.9", "", { "dependencies": { "@types/node": "*", "tarn": "^3.0.1", "tedious": "*" } }, "sha512-P0nCgw6vzY23UxZMnbI4N7fnLGANt4LI4yvxze1paPj+LuN28cFv5EI+QidP8udnId/BKhkcRhm/BleNsjK65A=="], "@types/node": ["@types/node@25.3.2", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-RpV6r/ij22zRRdyBPcxDeKAzH43phWVKEjL2iksqo1Vz3CuBUrgmPpPhALKiRfU7OMCmeeO9vECBMsV0hMTG8Q=="], @@ -693,6 +767,8 @@ "archiver-utils": ["archiver-utils@5.0.2", "", { "dependencies": { "glob": "^10.0.0", "graceful-fs": "^4.2.0", "is-stream": "^2.0.1", "lazystream": "^1.0.0", "lodash": "^4.17.15", "normalize-path": "^3.0.0", "readable-stream": "^4.0.0" } }, "sha512-wuLJMmIBQYCsGZgYLTy5FIB2pF6Lfb6cXMSF8Qywwk3t20zWnAi7zLcQFdKQmIB8wyZpY5ER38x08GbwtR2cLA=="], + "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], + "aria-query": ["aria-query@5.3.2", "", {}, "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw=="], "asn1": ["asn1@0.2.6", "", { "dependencies": { "safer-buffer": "~2.1.0" } }, "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ=="], @@ -781,6 +857,8 @@ "crc32-stream": ["crc32-stream@6.0.0", "", { "dependencies": { "crc-32": "^1.2.0", "readable-stream": "^4.0.0" } }, "sha512-piICUB6ei4IlTv1+653yq5+KoqfBYmj9bw6LqXoOneTMDXk5nM1qt12mFW1caG3LlJXEKW1Bp0WggEmIfQB34g=="], + "crelt": ["crelt@1.0.6", "", {}, "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g=="], + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], "css.escape": ["css.escape@1.5.1", "", {}, "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg=="], @@ -1027,6 +1105,10 @@ "lilconfig": ["lilconfig@2.1.0", "", {}, "sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ=="], + "linkify-it": ["linkify-it@5.0.0", "", { "dependencies": { "uc.micro": "^2.0.0" } }, "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ=="], + + "linkifyjs": ["linkifyjs@4.3.2", "", {}, "sha512-NT1CJtq3hHIreOianA8aSXn6Cw0JzYOuDQbOrSPe7gqFnCpKP++MQe3ODgO3oh2GJFORkAAdqredOa60z63GbA=="], + "locate-character": ["locate-character@3.0.0", "", {}, "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA=="], "locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="], @@ -1057,6 +1139,10 @@ "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], + "markdown-it": ["markdown-it@14.1.1", "", { "dependencies": { "argparse": "^2.0.1", "entities": "^4.4.0", "linkify-it": "^5.0.0", "mdurl": "^2.0.0", "punycode.js": "^2.3.1", "uc.micro": "^2.1.0" }, "bin": { "markdown-it": "bin/markdown-it.mjs" } }, "sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA=="], + + "mdurl": ["mdurl@2.0.0", "", {}, "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w=="], + "min-indent": ["min-indent@1.0.1", "", {}, "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg=="], "minimatch": ["minimatch@10.2.4", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg=="], @@ -1099,6 +1185,8 @@ "optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="], + "orderedmap": ["orderedmap@2.1.1", "", {}, "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g=="], + "p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="], "p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="], @@ -1153,12 +1241,50 @@ "properties-reader": ["properties-reader@3.0.1", "", { "dependencies": { "@kwsites/file-exists": "^1.1.1", "mkdirp": "^3.0.1" } }, "sha512-WPn+h9RGEExOKdu4bsF4HksG/uzd3cFq3MFtq8PsFeExPse5Ha/VOjQNyHhjboBFwGXGev6muJYTSPAOkROq2g=="], + "prosemirror-changeset": ["prosemirror-changeset@2.4.0", "", { "dependencies": { "prosemirror-transform": "^1.0.0" } }, "sha512-LvqH2v7Q2SF6yxatuPP2e8vSUKS/L+xAU7dPDC4RMyHMhZoGDfBC74mYuyYF4gLqOEG758wajtyhNnsTkuhvng=="], + + "prosemirror-collab": ["prosemirror-collab@1.3.1", "", { "dependencies": { "prosemirror-state": "^1.0.0" } }, "sha512-4SnynYR9TTYaQVXd/ieUvsVV4PDMBzrq2xPUWutHivDuOshZXqQ5rGbZM84HEaXKbLdItse7weMGOUdDVcLKEQ=="], + + "prosemirror-commands": ["prosemirror-commands@1.7.1", "", { "dependencies": { "prosemirror-model": "^1.0.0", "prosemirror-state": "^1.0.0", "prosemirror-transform": "^1.10.2" } }, "sha512-rT7qZnQtx5c0/y/KlYaGvtG411S97UaL6gdp6RIZ23DLHanMYLyfGBV5DtSnZdthQql7W+lEVbpSfwtO8T+L2w=="], + + "prosemirror-dropcursor": ["prosemirror-dropcursor@1.8.2", "", { "dependencies": { "prosemirror-state": "^1.0.0", "prosemirror-transform": "^1.1.0", "prosemirror-view": "^1.1.0" } }, "sha512-CCk6Gyx9+Tt2sbYk5NK0nB1ukHi2ryaRgadV/LvyNuO3ena1payM2z6Cg0vO1ebK8cxbzo41ku2DE5Axj1Zuiw=="], + + "prosemirror-gapcursor": ["prosemirror-gapcursor@1.4.1", "", { "dependencies": { "prosemirror-keymap": "^1.0.0", "prosemirror-model": "^1.0.0", "prosemirror-state": "^1.0.0", "prosemirror-view": "^1.0.0" } }, "sha512-pMdYaEnjNMSwl11yjEGtgTmLkR08m/Vl+Jj443167p9eB3HVQKhYCc4gmHVDsLPODfZfjr/MmirsdyZziXbQKw=="], + + "prosemirror-history": ["prosemirror-history@1.5.0", "", { "dependencies": { "prosemirror-state": "^1.2.2", "prosemirror-transform": "^1.0.0", "prosemirror-view": "^1.31.0", "rope-sequence": "^1.3.0" } }, "sha512-zlzTiH01eKA55UAf1MEjtssJeHnGxO0j4K4Dpx+gnmX9n+SHNlDqI2oO1Kv1iPN5B1dm5fsljCfqKF9nFL6HRg=="], + + "prosemirror-inputrules": ["prosemirror-inputrules@1.5.1", "", { "dependencies": { "prosemirror-state": "^1.0.0", "prosemirror-transform": "^1.0.0" } }, "sha512-7wj4uMjKaXWAQ1CDgxNzNtR9AlsuwzHfdFH1ygEHA2KHF2DOEaXl1CJfNPAKCg9qNEh4rum975QLaCiQPyY6Fw=="], + + "prosemirror-keymap": ["prosemirror-keymap@1.2.3", "", { "dependencies": { "prosemirror-state": "^1.0.0", "w3c-keyname": "^2.2.0" } }, "sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw=="], + + "prosemirror-markdown": ["prosemirror-markdown@1.13.4", "", { "dependencies": { "@types/markdown-it": "^14.0.0", "markdown-it": "^14.0.0", "prosemirror-model": "^1.25.0" } }, "sha512-D98dm4cQ3Hs6EmjK500TdAOew4Z03EV71ajEFiWra3Upr7diytJsjF4mPV2dW+eK5uNectiRj0xFxYI9NLXDbw=="], + + "prosemirror-menu": ["prosemirror-menu@1.3.0", "", { "dependencies": { "crelt": "^1.0.0", "prosemirror-commands": "^1.0.0", "prosemirror-history": "^1.0.0", "prosemirror-state": "^1.0.0" } }, "sha512-TImyPXCHPcDsSka2/lwJ6WjTASr4re/qWq1yoTTuLOqfXucwF6VcRa2LWCkM/EyTD1UO3CUwiH8qURJoWJRxwg=="], + + "prosemirror-model": ["prosemirror-model@1.25.4", "", { "dependencies": { "orderedmap": "^2.0.0" } }, "sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA=="], + + "prosemirror-schema-basic": ["prosemirror-schema-basic@1.2.4", "", { "dependencies": { "prosemirror-model": "^1.25.0" } }, "sha512-ELxP4TlX3yr2v5rM7Sb70SqStq5NvI15c0j9j/gjsrO5vaw+fnnpovCLEGIcpeGfifkuqJwl4fon6b+KdrODYQ=="], + + "prosemirror-schema-list": ["prosemirror-schema-list@1.5.1", "", { "dependencies": { "prosemirror-model": "^1.0.0", "prosemirror-state": "^1.0.0", "prosemirror-transform": "^1.7.3" } }, "sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q=="], + + "prosemirror-state": ["prosemirror-state@1.4.4", "", { "dependencies": { "prosemirror-model": "^1.0.0", "prosemirror-transform": "^1.0.0", "prosemirror-view": "^1.27.0" } }, "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw=="], + + "prosemirror-tables": ["prosemirror-tables@1.8.5", "", { "dependencies": { "prosemirror-keymap": "^1.2.3", "prosemirror-model": "^1.25.4", "prosemirror-state": "^1.4.4", "prosemirror-transform": "^1.10.5", "prosemirror-view": "^1.41.4" } }, "sha512-V/0cDCsHKHe/tfWkeCmthNUcEp1IVO3p6vwN8XtwE9PZQLAZJigbw3QoraAdfJPir4NKJtNvOB8oYGKRl+t0Dw=="], + + "prosemirror-trailing-node": ["prosemirror-trailing-node@3.0.0", "", { "dependencies": { "@remirror/core-constants": "3.0.0", "escape-string-regexp": "^4.0.0" }, "peerDependencies": { "prosemirror-model": "^1.22.1", "prosemirror-state": "^1.4.2", "prosemirror-view": "^1.33.8" } }, "sha512-xiun5/3q0w5eRnGYfNlW1uU9W6x5MoFKWwq/0TIRgt09lv7Hcser2QYV8t4muXbEr+Fwo0geYn79Xs4GKywrRQ=="], + + "prosemirror-transform": ["prosemirror-transform@1.12.0", "", { "dependencies": { "prosemirror-model": "^1.21.0" } }, "sha512-GxboyN4AMIsoHNtz5uf2r2Ru551i5hWeCMD6E2Ib4Eogqoub0NflniaBPVQ4MrGE5yZ8JV9tUHg9qcZTTrcN4w=="], + + "prosemirror-view": ["prosemirror-view@1.41.8", "", { "dependencies": { "prosemirror-model": "^1.20.0", "prosemirror-state": "^1.0.0", "prosemirror-transform": "^1.1.0" } }, "sha512-TnKDdohEatgyZNGCDWIdccOHXhYloJwbwU+phw/a23KBvJIR9lWQWW7WHHK3vBdOLDNuF7TaX98GObUZOWkOnA=="], + "protobufjs": ["protobufjs@7.5.4", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.4", "@protobufjs/eventemitter": "^1.1.0", "@protobufjs/fetch": "^1.1.0", "@protobufjs/float": "^1.0.2", "@protobufjs/inquire": "^1.1.0", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.0", "@types/node": ">=13.7.0", "long": "^5.0.0" } }, "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg=="], "pump": ["pump@3.0.4", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA=="], "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], + "punycode.js": ["punycode.js@2.3.1", "", {}, "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA=="], + "react-is": ["react-is@17.0.2", "", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="], "readable-stream": ["readable-stream@4.7.0", "", { "dependencies": { "abort-controller": "^3.0.0", "buffer": "^6.0.3", "events": "^3.3.0", "process": "^0.11.10", "string_decoder": "^1.3.0" } }, "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg=="], @@ -1185,6 +1311,8 @@ "rollup": ["rollup@4.59.0", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.59.0", "@rollup/rollup-android-arm64": "4.59.0", "@rollup/rollup-darwin-arm64": "4.59.0", "@rollup/rollup-darwin-x64": "4.59.0", "@rollup/rollup-freebsd-arm64": "4.59.0", "@rollup/rollup-freebsd-x64": "4.59.0", "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", "@rollup/rollup-linux-arm-musleabihf": "4.59.0", "@rollup/rollup-linux-arm64-gnu": "4.59.0", "@rollup/rollup-linux-arm64-musl": "4.59.0", "@rollup/rollup-linux-loong64-gnu": "4.59.0", "@rollup/rollup-linux-loong64-musl": "4.59.0", "@rollup/rollup-linux-ppc64-gnu": "4.59.0", "@rollup/rollup-linux-ppc64-musl": "4.59.0", "@rollup/rollup-linux-riscv64-gnu": "4.59.0", "@rollup/rollup-linux-riscv64-musl": "4.59.0", "@rollup/rollup-linux-s390x-gnu": "4.59.0", "@rollup/rollup-linux-x64-gnu": "4.59.0", "@rollup/rollup-linux-x64-musl": "4.59.0", "@rollup/rollup-openbsd-x64": "4.59.0", "@rollup/rollup-openharmony-arm64": "4.59.0", "@rollup/rollup-win32-arm64-msvc": "4.59.0", "@rollup/rollup-win32-ia32-msvc": "4.59.0", "@rollup/rollup-win32-x64-gnu": "4.59.0", "@rollup/rollup-win32-x64-msvc": "4.59.0", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg=="], + "rope-sequence": ["rope-sequence@1.3.4", "", {}, "sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ=="], + "rou3": ["rou3@0.7.12", "", {}, "sha512-iFE4hLDuloSWcD7mjdCDhx2bKcIsYbtOTpfH5MHHLSKMOUyjqQXTeZVa289uuwEGEKFoE/BAPbhaU4B774nceg=="], "run-applescript": ["run-applescript@7.1.0", "", {}, "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q=="], @@ -1315,6 +1443,8 @@ "typescript-eslint": ["typescript-eslint@8.56.1", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.56.1", "@typescript-eslint/parser": "8.56.1", "@typescript-eslint/typescript-estree": "8.56.1", "@typescript-eslint/utils": "8.56.1" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-U4lM6pjmBX7J5wk4szltF7I1cGBHXZopnAXCMXb3+fZ3B/0Z3hq3wS/CCUB2NZBNAExK92mCU2tEohWuwVMsDQ=="], + "uc.micro": ["uc.micro@2.1.0", "", {}, "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A=="], + "undici": ["undici@7.22.0", "", {}, "sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg=="], "undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], @@ -1333,6 +1463,8 @@ "vitest": ["vitest@4.0.18", "", { "dependencies": { "@vitest/expect": "4.0.18", "@vitest/mocker": "4.0.18", "@vitest/pretty-format": "4.0.18", "@vitest/runner": "4.0.18", "@vitest/snapshot": "4.0.18", "@vitest/spy": "4.0.18", "@vitest/utils": "4.0.18", "es-module-lexer": "^1.7.0", "expect-type": "^1.2.2", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^3.10.0", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.0.3", "vite": "^6.0.0 || ^7.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.0.18", "@vitest/browser-preview": "4.0.18", "@vitest/browser-webdriverio": "4.0.18", "@vitest/ui": "4.0.18", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ=="], + "w3c-keyname": ["w3c-keyname@2.2.8", "", {}, "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ=="], + "whatwg-mimetype": ["whatwg-mimetype@3.0.0", "", {}, "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q=="], "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], @@ -1499,6 +1631,8 @@ "lazystream/readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="], + "markdown-it/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], + "playwright/fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="], "postcss/nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], From 46a5dc861ab6feb81a01fe33f7150f7a83f5ced7 Mon Sep 17 00:00:00 2001 From: Simon Prosen <38320875+simpros@users.noreply.github.com> Date: Mon, 6 Apr 2026 09:36:49 +0200 Subject: [PATCH 2/6] feat: add TipTap email editor UI Extract the editor toolbar, suggestion popup, and shared styles into dedicated UI pieces so rich email formatting stays reviewable and reusable across initiative forms. --- apps/web/src/app.css | 165 +++++ .../components/editor/editor-toolbar.svelte | 608 ++++++++++++++++++ .../editor/editor-variable-popup.svelte | 108 ++++ 3 files changed, 881 insertions(+) create mode 100644 apps/web/src/lib/components/editor/editor-toolbar.svelte create mode 100644 apps/web/src/lib/components/editor/editor-variable-popup.svelte diff --git a/apps/web/src/app.css b/apps/web/src/app.css index 77fa5e6..f9e81be 100644 --- a/apps/web/src/app.css +++ b/apps/web/src/app.css @@ -168,3 +168,168 @@ body { @apply transition-all duration-100 ease-out; } } + +/* ─────────────────────────────────────────────────── + TipTap Email Editor + ─────────────────────────────────────────────────── */ + +.tiptap-editor .tiptap { + min-height: 24rem; + padding: 1.5rem; + outline: none; + font-family: var(--font-sans); + font-size: 0.9375rem; + line-height: 1.7; + color: var(--color-foreground); +} + +.tiptap-editor .tiptap:focus-visible { + outline: none; +} + +/* Paragraphs */ +.tiptap-editor .tiptap p { + margin-top: 0; + margin-bottom: 0.75em; +} + +.tiptap-editor .tiptap > :first-child { + margin-top: 0; +} + +/* Headings — match the app's uppercase bold style */ +.tiptap-editor .tiptap h1, +.tiptap-editor .tiptap h2, +.tiptap-editor .tiptap h3 { + font-family: var(--font-sans); + font-weight: 700; + text-transform: uppercase; + letter-spacing: -0.02em; + margin-bottom: 0.5em; + margin-top: 1em; + color: var(--color-foreground); +} + +.tiptap-editor .tiptap h1 { + font-size: 1.75rem; + line-height: 1.2; +} + +.tiptap-editor .tiptap h2 { + font-size: 1.375rem; + line-height: 1.25; +} + +.tiptap-editor .tiptap h3 { + font-size: 1.125rem; + line-height: 1.3; +} + +/* Lists */ +.tiptap-editor .tiptap ul { + list-style-type: disc; + padding-left: 1.5em; + margin-bottom: 0.75em; +} + +.tiptap-editor .tiptap ol { + list-style-type: decimal; + padding-left: 1.5em; + margin-bottom: 0.75em; +} + +.tiptap-editor .tiptap li { + margin-bottom: 0.25em; +} + +/* Links */ +.tiptap-editor .tiptap a { + color: var(--color-primary-600); + text-decoration: underline; + text-underline-offset: 2px; +} + +/* Horizontal rule */ +.tiptap-editor .tiptap hr { + border: none; + border-top: 2px solid var(--color-border); + margin: 1.5em 0; +} + +/* Blockquote */ +.tiptap-editor .tiptap blockquote { + border-left: 3px solid var(--color-secondary-400); + padding-left: 1em; + margin-left: 0; + margin-bottom: 0.75em; + color: var(--color-neutral-600); + font-style: italic; +} + +/* Template variable pills */ +.tiptap-template-variable { + display: inline; + background-color: var(--color-secondary-100); + color: var(--color-secondary-800); + border: 1px solid var(--color-secondary-300); + padding: 1px 6px; + font-family: var(--font-mono); + font-size: 0.8125rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.03em; + white-space: nowrap; + user-select: none; +} + +.tiptap-template-variable[data-preview-state="resolved"] { + background-color: #fef08a; + color: var(--color-neutral-900); + border-color: #facc15; + text-transform: none; + letter-spacing: 0; +} + +.tiptap-template-variable[data-preview-state="missing"] { + background-color: #fee2e2; + color: #991b1b; + border-color: #fca5a5; +} + +.tiptap-template-variable[data-preview-state="raw"] { + background-color: var(--color-secondary-100); + color: var(--color-secondary-800); + border-color: var(--color-secondary-300); +} + +/* Email button blocks in editor */ +.tiptap-editor .tiptap [data-type="email-button"] { + text-align: center; + padding: 1em 0; +} + +.tiptap-editor .tiptap [data-type="email-button"] a { + text-decoration: none; +} + +.tiptap-editor + .tiptap + [data-type="email-button"].ProseMirror-selectednode { + outline: 2px solid var(--color-primary-500); + outline-offset: 4px; +} + +/* Selected node outline */ +.tiptap-editor .tiptap .ProseMirror-selectednode { + outline: 2px solid var(--color-primary-400); + outline-offset: 2px; +} + +/* Placeholder */ +.tiptap-editor .tiptap p.is-editor-empty:first-child::before { + content: attr(data-placeholder); + color: var(--color-neutral-400); + pointer-events: none; + float: left; + height: 0; +} diff --git a/apps/web/src/lib/components/editor/editor-toolbar.svelte b/apps/web/src/lib/components/editor/editor-toolbar.svelte new file mode 100644 index 0000000..d1abd4c --- /dev/null +++ b/apps/web/src/lib/components/editor/editor-toolbar.svelte @@ -0,0 +1,608 @@ + + + diff --git a/apps/web/src/lib/components/editor/editor-variable-popup.svelte b/apps/web/src/lib/components/editor/editor-variable-popup.svelte new file mode 100644 index 0000000..b6de0ff --- /dev/null +++ b/apps/web/src/lib/components/editor/editor-variable-popup.svelte @@ -0,0 +1,108 @@ + + +{#if items.length > 0} +
+ {#each grouped as { group, items: groupItems } (group)} +
+ {group} +
+ {#each groupItems as { item, flatIndex } (item.id)} + + {/each} + {/each} +
+{/if} From 8209f385336520d86fb7e50e281a0275b6e2b1bb Mon Sep 17 00:00:00 2001 From: Simon Prosen <38320875+simpros@users.noreply.github.com> Date: Mon, 6 Apr 2026 09:36:55 +0200 Subject: [PATCH 3/6] feat: switch initiative emails to the TipTap editor Replace the split HTML/preview workflow with a richer body editor while keeping subject token insertion and existing form submission behavior intact for initiative create and edit flows. --- .../components/email-template-editor.svelte | 467 +++++++++++------- .../edit/initiative-edit-form.svelte | 6 +- .../new/initiative-email-form.svelte | 6 +- 3 files changed, 281 insertions(+), 198 deletions(-) diff --git a/apps/web/src/lib/components/email-template-editor.svelte b/apps/web/src/lib/components/email-template-editor.svelte index b5b6908..38e0013 100644 --- a/apps/web/src/lib/components/email-template-editor.svelte +++ b/apps/web/src/lib/components/email-template-editor.svelte @@ -1,11 +1,31 @@ - - + + +{#if suggestionOpen && suggestionCommand} + +{/if} +
+ + + {#if subjectName !== undefined}
{/if}
-
- - - {#if leads.length > 0} -
- + Preview lead -
+ {/if} -
-
- {value.length} characters +
+ {value.length} characters +
-
- {#if viewMode === "edit" || viewMode === "split"} -
- - HTML Editor - - -
+
+ + {#if editor} + {/if} - {#if viewMode === "preview" || viewMode === "split"} -
- - Email Preview - -
-
- {#if previewHtml.trim()} - - - {@html previewHtml} - {:else} -

- Preview will appear here as you type... -

- {/if} -
-
-
- {/if} +
{#if missingPlaceholders.length > 0} -
-

- ⚠ Some placeholders have missing data +

+

+ Missing placeholder data

    {#each missingPlaceholders as { token, missingCount } (token)}
  • - {token} + {token} — empty for {missingCount} of {leads.length} lead{leads.length === 1 ? "" @@ -352,8 +440,11 @@ {/if}

    - Tip: type {{ in the subject or body to insert a variable (e.g. lead name, address). + > + in the body to insert a variable, or use the + {{ + toolbar button.

diff --git a/apps/web/src/routes/(app)/projects/[id]/initiatives/[initiativeId]/edit/initiative-edit-form.svelte b/apps/web/src/routes/(app)/projects/[id]/initiatives/[initiativeId]/edit/initiative-edit-form.svelte index 506658f..0b3e5f8 100644 --- a/apps/web/src/routes/(app)/projects/[id]/initiatives/[initiativeId]/edit/initiative-edit-form.svelte +++ b/apps/web/src/routes/(app)/projects/[id]/initiatives/[initiativeId]/edit/initiative-edit-form.svelte @@ -106,11 +106,7 @@ subjectName={updateInitiativeEmail.fields.subject.as("text").name} {projectId} {leads} - placeholder="

Hello,

- -

Your message here...

- -

Best regards

" + placeholder="Start writing your email..." /> {#if errorMessage} diff --git a/apps/web/src/routes/(app)/projects/[id]/initiatives/new/initiative-email-form.svelte b/apps/web/src/routes/(app)/projects/[id]/initiatives/new/initiative-email-form.svelte index 8eac7e0..0e8ab31 100644 --- a/apps/web/src/routes/(app)/projects/[id]/initiatives/new/initiative-email-form.svelte +++ b/apps/web/src/routes/(app)/projects/[id]/initiatives/new/initiative-email-form.svelte @@ -85,11 +85,7 @@ subjectName={createInitiativeEmail.fields.subject.as("text").name} {projectId} {leads} - placeholder="

Hello,

- -

Your message here...

- -

Best regards

" + placeholder="Start writing your email..." /> {#if errorMessage} From bdc18b7e6b37a9ba9c21d5e0c1193c97fc67b061 Mon Sep 17 00:00:00 2001 From: Simon Prosen <38320875+simpros@users.noreply.github.com> Date: Mon, 6 Apr 2026 09:36:58 +0200 Subject: [PATCH 4/6] chore: add Jean app port metadata Record the web app port in Jean config so local tooling can discover the running app without bundling that setup change into the editor feature commits. --- jean.json | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/jean.json b/jean.json index 7d45c59..7a06f23 100644 --- a/jean.json +++ b/jean.json @@ -3,5 +3,11 @@ "setup": "bun install", "teardown": null, "run": "bun dev" - } + }, + "ports": [ + { + "port": 5173, + "label": "app" + } + ] } From 466495be33ba1ef1e3be592841836037dd1f931a Mon Sep 17 00:00:00 2001 From: Simon Prosen <38320875+simpros@users.noreply.github.com> Date: Mon, 6 Apr 2026 19:40:20 +0200 Subject: [PATCH 5/6] fix: remove non-reactive maps from editor menus Keep the toolbar and variable popup grouping logic compatible with Svelte's reactivity lint rules without changing menu ordering. --- .../components/editor/editor-toolbar.svelte | 20 +------ .../editor/editor-variable-popup.svelte | 34 ++++++------ .../editor/group-items-by-group.test.ts | 52 +++++++++++++++++++ .../components/editor/group-items-by-group.ts | 28 ++++++++++ 4 files changed, 97 insertions(+), 37 deletions(-) create mode 100644 apps/web/src/lib/components/editor/group-items-by-group.test.ts create mode 100644 apps/web/src/lib/components/editor/group-items-by-group.ts diff --git a/apps/web/src/lib/components/editor/editor-toolbar.svelte b/apps/web/src/lib/components/editor/editor-toolbar.svelte index d1abd4c..95e8072 100644 --- a/apps/web/src/lib/components/editor/editor-toolbar.svelte +++ b/apps/web/src/lib/components/editor/editor-toolbar.svelte @@ -3,6 +3,7 @@ import type { Selection } from "@tiptap/pm/state"; import type { TemplateVariable } from "../template-variables.js"; import type { EmailButtonAttrs } from "./tiptap-email-button.js"; + import { groupItemsByGroup } from "./group-items-by-group.js"; // Side-effect imports to register TipTap command types import "@tiptap/starter-kit"; @@ -62,24 +63,7 @@ }; }); - // Group variables by group - type GroupEntry = { - group: string; - items: TemplateVariable[]; - }; - - const grouped = $derived.by((): GroupEntry[] => { - const groupMap = new Map(); - for (const v of variables) { - const group = groupMap.get(v.group) ?? []; - group.push(v); - groupMap.set(v.group, group); - } - return [...groupMap.entries()].map(([group, items]) => ({ - group, - items, - })); - }); + const grouped = $derived.by(() => groupItemsByGroup(variables)); function rememberSelection() { preservedSelection = editor.state.selection; diff --git a/apps/web/src/lib/components/editor/editor-variable-popup.svelte b/apps/web/src/lib/components/editor/editor-variable-popup.svelte index b6de0ff..3abb5f5 100644 --- a/apps/web/src/lib/components/editor/editor-variable-popup.svelte +++ b/apps/web/src/lib/components/editor/editor-variable-popup.svelte @@ -1,5 +1,6 @@