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/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..95e8072 --- /dev/null +++ b/apps/web/src/lib/components/editor/editor-toolbar.svelte @@ -0,0 +1,592 @@ + + + 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..3abb5f5 --- /dev/null +++ b/apps/web/src/lib/components/editor/editor-variable-popup.svelte @@ -0,0 +1,104 @@ + + +{#if items.length > 0} +
+ {#each grouped as { group, items: groupItems } (group)} +
+ {group} +
+ {#each groupItems as { item, flatIndex } (item.id)} + + {/each} + {/each} +
+{/if} diff --git a/apps/web/src/lib/components/editor/group-items-by-group.test.ts b/apps/web/src/lib/components/editor/group-items-by-group.test.ts new file mode 100644 index 0000000..f620aeb --- /dev/null +++ b/apps/web/src/lib/components/editor/group-items-by-group.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from "bun:test"; +import { groupItemsByGroup } from "./group-items-by-group"; + +describe("groupItemsByGroup", () => { + it("returns no groups when given no items", () => { + expect(groupItemsByGroup([])).toEqual([]); + }); + + it("preserves first-seen group order and item order within each group", () => { + const items = [ + { id: "1", group: "Lead fields" }, + { id: "2", group: "Custom fields" }, + { id: "3", group: "Lead fields" }, + { id: "4", group: "Meta" }, + { id: "5", group: "Custom fields" }, + ]; + + expect(groupItemsByGroup(items)).toEqual([ + { + group: "Lead fields", + items: [items[0], items[2]], + }, + { + group: "Custom fields", + items: [items[1], items[4]], + }, + { + group: "Meta", + items: [items[3]], + }, + ]); + }); + + it("supports group names that overlap with object property names", () => { + const items = [ + { id: "1", group: "__proto__" }, + { id: "2", group: "constructor" }, + { id: "3", group: "__proto__" }, + ]; + + expect(groupItemsByGroup(items)).toEqual([ + { + group: "__proto__", + items: [items[0], items[2]], + }, + { + group: "constructor", + items: [items[1]], + }, + ]); + }); +}); diff --git a/apps/web/src/lib/components/editor/group-items-by-group.ts b/apps/web/src/lib/components/editor/group-items-by-group.ts new file mode 100644 index 0000000..7d9c888 --- /dev/null +++ b/apps/web/src/lib/components/editor/group-items-by-group.ts @@ -0,0 +1,28 @@ +type GroupedItems = { + group: string; + items: T[]; +}; + +export function groupItemsByGroup( + items: readonly T[] +): GroupedItems[] { + const groupedItems = Object.create(null) as Record; + const groupOrder: string[] = []; + + for (const item of items) { + const group = groupedItems[item.group]; + + if (group) { + group.push(item); + continue; + } + + groupedItems[item.group] = [item]; + groupOrder.push(item.group); + } + + return groupOrder.map((group) => ({ + group, + items: groupedItems[group] ?? [], + })); +} 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/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/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/apps/web/src/lib/server/request-logging.test.ts b/apps/web/src/lib/server/request-logging.test.ts index ffd20f6..501fa7c 100644 --- a/apps/web/src/lib/server/request-logging.test.ts +++ b/apps/web/src/lib/server/request-logging.test.ts @@ -1,4 +1,8 @@ import { describe, it, expect, mock, beforeEach } from "bun:test"; +import { + createCommandMock, + createFormMock, +} from "../../test-helpers/sveltekit-mocks"; // Mock $app/server before importing the module under test const mockWideEvent: Record = {}; @@ -7,6 +11,9 @@ const mockGetRequestEvent = mock(() => ({ })); mock.module("$app/server", () => ({ + query: (fn: (...args: unknown[]) => unknown) => fn, + form: () => createFormMock(), + command: () => createCommandMock(), getRequestEvent: mockGetRequestEvent, })); diff --git a/apps/web/src/routes/(app)/lead-manual-create-form.svelte b/apps/web/src/routes/(app)/lead-manual-create-form.svelte index 6aeb98d..403d567 100644 --- a/apps/web/src/routes/(app)/lead-manual-create-form.svelte +++ b/apps/web/src/routes/(app)/lead-manual-create-form.svelte @@ -16,8 +16,18 @@ let { projectId, linkedLeadPlaceIds = [] }: LeadManualCreateFormProps = $props(); - const projects = $derived(await getProjects()); - const leads = $derived(await getLeads()); + // The dialog is closed by default, so use the query resources directly + // instead of top-level async deriveds that would trigger await_waterfall. + const projectsQuery = getProjects(); + const leadsQuery = getLeads(); + const projects = $derived( + (projectsQuery.current ?? []) as NonNullable< + typeof projectsQuery.current + > + ); + const leads = $derived( + (leadsQuery.current ?? []) as NonNullable + ); const existingAddLeadForm = addLeadsToProject.for( "project-existing-lead" ); diff --git a/apps/web/src/routes/(app)/lead-manual-create-form.test.ts b/apps/web/src/routes/(app)/lead-manual-create-form.test.ts new file mode 100644 index 0000000..a5fb38e --- /dev/null +++ b/apps/web/src/routes/(app)/lead-manual-create-form.test.ts @@ -0,0 +1,57 @@ +import { beforeEach, describe, expect, it, mock } from "bun:test"; +import { render } from "@testing-library/svelte"; +import { + createCommandMock, + createFormMock, + createQueryMock, +} from "../../test-helpers/sveltekit-mocks"; + +const mockGetProjects = createQueryMock([]); +const mockGetLeads = createQueryMock([]); + +mock.module("$env/dynamic/private", () => ({ env: {} })); + +mock.module("$app/server", () => ({ + query: (fn: (...args: unknown[]) => unknown) => fn, + form: () => createFormMock(), + command: () => createCommandMock(), + getRequestEvent: () => ({}), +})); + +const realProjectsRemote = await import("$lib/remote/projects.remote.js"); +const realLeadsRemote = await import("$lib/remote/leads.remote.js"); + +const mockedProjectsRemote = { + ...realProjectsRemote, + getProjects: mockGetProjects, + addLeadsToProject: createFormMock(), +}; + +const mockedLeadsRemote = { + ...realLeadsRemote, + getLeads: mockGetLeads, + createManualLead: createFormMock(), +}; + +mock.module("$lib/remote/projects.remote", () => mockedProjectsRemote); +mock.module("$lib/remote/projects.remote.js", () => mockedProjectsRemote); + +mock.module("$lib/remote/leads.remote", () => mockedLeadsRemote); +mock.module("$lib/remote/leads.remote.js", () => mockedLeadsRemote); + +const { default: LeadManualCreateForm } = + await import("./lead-manual-create-form.svelte"); + +describe("LeadManualCreateForm", () => { + beforeEach(() => { + mockGetProjects.mockClear(); + mockGetLeads.mockClear(); + }); + + it("creates both query resources when the form renders", () => { + render(LeadManualCreateForm); + + expect(mockGetProjects).toHaveBeenCalledTimes(1); + expect(mockGetLeads).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/web/src/routes/(app)/leads/[id]/lead-detail.test.ts b/apps/web/src/routes/(app)/leads/[id]/lead-detail.test.ts index 90c6274..5aa83b8 100644 --- a/apps/web/src/routes/(app)/leads/[id]/lead-detail.test.ts +++ b/apps/web/src/routes/(app)/leads/[id]/lead-detail.test.ts @@ -1,8 +1,10 @@ import { describe, it, expect, mock, beforeEach, spyOn } from "bun:test"; import { render, screen, waitFor } from "@testing-library/svelte"; import { + createCommandMock, createFormMock, createQueryMock, + createQueryResult, } from "../../../../test-helpers/sveltekit-mocks"; const mockLeadData = { @@ -62,6 +64,7 @@ mock.module("$app/paths", () => ({ mock.module("$app/server", () => ({ query: (fn: (...args: unknown[]) => unknown) => fn, form: () => createFormMock(), + command: () => createCommandMock(), getRequestEvent: () => ({}), })); @@ -74,7 +77,7 @@ describe("Lead detail page", () => { mockResolve.mockClear(); mockDeleteLead.mockClear(); mockGetLeadData.mockImplementation(() => - Promise.resolve(mockLeadData) + createQueryResult(mockLeadData) ); mockDeleteLead.mockImplementation(() => Promise.resolve({ ok: true })); mockResolve.mockImplementation((path: string) => path); diff --git a/apps/web/src/routes/(app)/leads/leads.test.ts b/apps/web/src/routes/(app)/leads/leads.test.ts index f436712..bc61960 100644 --- a/apps/web/src/routes/(app)/leads/leads.test.ts +++ b/apps/web/src/routes/(app)/leads/leads.test.ts @@ -1,8 +1,10 @@ import { describe, it, expect, mock, beforeEach } from "bun:test"; import { render, screen, waitFor } from "@testing-library/svelte"; import { + createCommandMock, createFormMock, createQueryMock, + createQueryResult, } from "../../../test-helpers/sveltekit-mocks"; const mockLeads = [ @@ -63,6 +65,7 @@ mock.module("$app/paths", () => ({ mock.module("$app/server", () => ({ query: (fn: (...args: unknown[]) => unknown) => fn, form: () => createFormMock(), + command: () => createCommandMock(), getRequestEvent: () => ({}), })); @@ -71,7 +74,7 @@ const { default: LeadsPage } = await import("./+page.svelte"); describe("Leads page", () => { beforeEach(() => { mockGetLeads.mockClear(); - mockGetLeads.mockImplementation(() => Promise.resolve(mockLeads)); + mockGetLeads.mockImplementation(() => createQueryResult(mockLeads)); }); it("renders the page heading", async () => { @@ -84,9 +87,7 @@ describe("Leads page", () => { it("renders the page description", async () => { render(LeadsPage); await waitFor(() => { - expect( - screen.getByText(/Review every saved lead/), - ).toBeTruthy(); + expect(screen.getByText(/Review every saved lead/)).toBeTruthy(); }); }); @@ -117,9 +118,7 @@ describe("Leads page", () => { it("shows no contact message for leads without details", async () => { render(LeadsPage); await waitFor(() => { - expect( - screen.getByText("No contact details yet"), - ).toBeTruthy(); + expect(screen.getByText("No contact details yet")).toBeTruthy(); }); }); @@ -132,13 +131,13 @@ describe("Leads page", () => { }); it("shows empty state when there are no leads", async () => { - mockGetLeads.mockImplementation(() => Promise.resolve([])); + mockGetLeads.mockImplementation(() => + createQueryResult([]) + ); render(LeadsPage); await waitFor(() => { expect(screen.getByText("No leads yet")).toBeTruthy(); - expect( - screen.getByText(/Discover leads and add them/), - ).toBeTruthy(); + expect(screen.getByText(/Discover leads and add them/)).toBeTruthy(); }); }); }); 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} diff --git a/apps/web/src/routes/(app)/projects/[id]/project.test.ts b/apps/web/src/routes/(app)/projects/[id]/project.test.ts index 2aa79a0..1c9123d 100644 --- a/apps/web/src/routes/(app)/projects/[id]/project.test.ts +++ b/apps/web/src/routes/(app)/projects/[id]/project.test.ts @@ -3,6 +3,7 @@ import { render, screen, waitFor } from "@testing-library/svelte"; import { createFormMock, createQueryMock, + createQueryResult, createCommandMock, mockSvelteKitModules, } from "../../../../test-helpers/sveltekit-mocks"; @@ -117,15 +118,19 @@ describe("Project page", () => { session: { activeOrganizationId: "org-1", }, - organizations: [{ id: "org-1", name: "Test Org", slug: "test-org" }] as { id: string; name: string; slug: string }[], + organizations: [ + { id: "org-1", name: "Test Org", slug: "test-org" }, + ] as { id: string; name: string; slug: string }[], } as const; beforeEach(() => { mockGetProjectData.mockClear(); mockGetProjectInitiatives.mockClear(); - mockGetProjectData.mockImplementation(() => Promise.resolve(mockProject)); + mockGetProjectData.mockImplementation(() => + createQueryResult(mockProject) + ); mockGetProjectInitiatives.mockImplementation(() => - Promise.resolve(mockInitiatives), + createQueryResult(mockInitiatives) ); }); @@ -136,7 +141,7 @@ describe("Project page", () => { }); await waitFor(() => { expect( - screen.getByRole("heading", { name: "Test Project" }), + screen.getByRole("heading", { name: "Test Project" }) ).toBeTruthy(); }); }); @@ -147,9 +152,7 @@ describe("Project page", () => { data: mockData, }); await waitFor(() => { - expect( - screen.getByText("A test project description"), - ).toBeTruthy(); + expect(screen.getByText("A test project description")).toBeTruthy(); }); }); diff --git a/apps/web/src/test-helpers/sveltekit-mocks.ts b/apps/web/src/test-helpers/sveltekit-mocks.ts index 68089df..c78d272 100644 --- a/apps/web/src/test-helpers/sveltekit-mocks.ts +++ b/apps/web/src/test-helpers/sveltekit-mocks.ts @@ -1,5 +1,14 @@ import { mock } from "bun:test"; +export type QueryResult = Promise & { + current: T; + loading: boolean; + error: null; + refresh: () => Promise; + set: (value: T) => Promise; + withOverride: (override: (value: T) => T) => QueryResult; +}; + /** * Creates a mock for SvelteKit remote `form()` functions. * Simulates .for(), .enhance(), .pending, and .fields properties. @@ -51,8 +60,25 @@ export function createFormMock(returnValue: unknown = { error: null }) { /** * Creates a mock for SvelteKit remote `query()` functions. */ -export function createQueryMock(returnValue: unknown = []) { - return mock(() => Promise.resolve(returnValue)); +export function createQueryMock(returnValue: T) { + return mock((): QueryResult => createQueryResult(returnValue)); +} + +export function createQueryResult(returnValue: T) { + const result = Promise.resolve(returnValue) as QueryResult; + + result.current = returnValue; + result.loading = false; + result.error = null; + result.refresh = () => Promise.resolve(result.current); + result.set = (value: T) => { + result.current = value; + return Promise.resolve(value); + }; + result.withOverride = (override: (value: T) => T) => + createQueryResult(override(result.current)); + + return result; } /** 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=="], 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" + } + ] }