Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions console/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@
"@tailwindcss/vite": "^4.1.18",
"@tanstack/react-hotkeys": "^0.4.1",
"@tanstack/react-table": "^8.21.3",
"@templatical/editor": "0.19.0",
"@templatical/types": "0.19.0",
"@textea/json-viewer": "^4.0.1",
"@tiptap/core": "3.20.1",
"@tiptap/extension-blockquote": "3.20.1",
Expand Down Expand Up @@ -83,6 +85,7 @@
"html-react-parser": "^5.2.16",
"i18next": "^23.16.8",
"i18next-http-backend": "^2.7.3",
"liquidjs": "^10.27.2",
"lucide-react": "^0.563.0",
"maplibre-gl": "^5.19.0",
"md5": "^2.3.0",
Expand Down
208 changes: 146 additions & 62 deletions console/pnpm-lock.yaml

Large diffs are not rendered by default.

6 changes: 6 additions & 0 deletions console/pnpm-workspace.yaml
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
allowBuilds:
'@clerk/shared': true
esbuild: true
minimumReleaseAgeExclude:
- '@templatical/editor@0.19.0'
- '@templatical/types@0.19.0'
onlyBuiltDependencies:
- '@clerk/shared'
- esbuild
16 changes: 15 additions & 1 deletion console/src/components/iframe.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,21 @@ export default function Iframe({
frame.contentDocument.documentElement.style.overflow = allowScroll ? "" : "hidden"
}
if (fullHeight) {
frame.style.minHeight = `${frame.contentWindow?.document.documentElement.scrollHeight}px`
// Collapse before measuring. A document can never report a
// scrollHeight smaller than the frame rendering it, so measuring
// in place only ever grows the frame — a short document inherits
// the height of whatever was shown before it, and the first
// measurement is pinned to the 300x150 default an iframe gets
// when no size is set.
const root = frame.contentDocument?.documentElement
if (root) {
const previous = frame.style.height
frame.style.height = "0px"
const contentHeight = root.scrollHeight
// Keep the previous height rather than collapsing to nothing
// if the document is not measurable yet.
frame.style.height = contentHeight > 0 ? `${contentHeight}px` : previous
}
}
}
}, [allowScroll, content, fullHeight])
Expand Down
12 changes: 11 additions & 1 deletion console/src/components/preview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { useTranslation } from "react-i18next"
import { ProjectContext } from "../contexts"
import clsx from "clsx"
import { compileEmail } from "@/views/campaign/template/mail/editor/codeEditor/compileEmail"
import { templaticalPreviewHtml } from "@/lib/templatical-preview"
import { getSystemPreviewProps } from "@/views/campaign/template/mail/editor/variableScope"
import { EmailFrame } from "@/components/preview/EmailFrame"
import { PhoneFrame } from "@/components/preview/PhoneFrame"
Expand Down Expand Up @@ -52,6 +53,15 @@ function EmailPreviewContent({
const abortRef = useRef<AbortController | null>(null)

useEffect(() => {
// A visually authored template is rendered by the backend at save time,
// and the resulting HTML is what the bundle holds. Compiling code.source
// here instead would show the JSX the template carried before it was
// switched, which is kept only so the switch stays reversible.
if (data?.type === "templatical") {
setCompiledHtml(templaticalPreviewHtml(data?.code?.bundle))
return
}

const source = data?.code?.source
if (!source) {
setCompiledHtml("")
Expand Down Expand Up @@ -80,7 +90,7 @@ function EmailPreviewContent({
return () => {
abortController.abort()
}
}, [data?.code?.source])
}, [data?.type, data?.code?.bundle, data?.code?.source])

// In the small thumbnail (e.g. the Journey Send node) the Gmail-style
// header chrome doesn't fit and just looks broken — show the rendered
Expand Down
124 changes: 67 additions & 57 deletions console/src/lib/path-suggestions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,82 +2,92 @@ import { oapiClient } from "@/oapi/client"
import type { UUID } from "@/types/common"
import type { VariableSuggestions } from "@/types"

/**
* Swallow a failure from an endpoint that not every deployment serves, so one
* missing schema source cannot take the whole aggregate down.
*/
function optional<T>(request: Promise<T>, what: string): Promise<T | undefined> {
return request.catch((error) => {
console.debug(`Failed to fetch ${what}:`, error)
return undefined
})
}

/**
* Fetches variable path suggestions for a project by aggregating the various
* subject schema endpoints. Organization-related schemas are optional and fall
* back to empty arrays when the endpoints are unavailable.
*
* The six endpoints are independent, so they go out together: requested one
* after another this cost six round trips, which editors that must wait for a
* complete variable list before mounting pay in full.
*
* Replaces the legacy `api.projects.pathSuggestions` aggregator; uses the typed
* OpenAPI client throughout.
*/
export async function fetchPathSuggestions(projectId: UUID): Promise<VariableSuggestions> {
const path = { projectID: projectId }

const { data: userEvents } = await oapiClient.GET(
"/api/admin/projects/{projectID}/subjects/user/events/schema",
{ params: { path } },
)
const eventPaths = (userEvents?.results ?? []).map((event) => ({
const [userEvents, users, scheduled, organizationEvents, organizationUsers, organizations] =
await Promise.all([
oapiClient.GET("/api/admin/projects/{projectID}/subjects/user/events/schema", {
params: { path },
}),
oapiClient.GET("/api/admin/projects/{projectID}/subjects/users/schema", {
params: { path },
}),
optional(
oapiClient.GET("/api/admin/projects/{projectID}/subjects/user/scheduled/schema", {
params: { path },
}),
"scheduled schemas",
),
optional(
oapiClient.GET(
"/api/admin/projects/{projectID}/subjects/organization/events/schema",
{
params: { path },
},
),
"organization event schemas",
),
optional(
oapiClient.GET(
"/api/admin/projects/{projectID}/subjects/organizations/users/schema",
{ params: { path } },
),
"organization user schemas",
),
optional(
oapiClient.GET("/api/admin/projects/{projectID}/subjects/organizations/schema", {
params: { path },
}),
"organization schemas",
),
])

const eventPaths = (userEvents.data?.results ?? []).map((event) => ({
...event,
schema: event.schema ?? [],
})) as VariableSuggestions["eventPaths"]

const { data: users } = await oapiClient.GET(
"/api/admin/projects/{projectID}/subjects/users/schema",
{ params: { path } },
)
const userPaths = (users?.results ?? []) as VariableSuggestions["userPaths"]
const userPaths = (users.data?.results ?? []) as VariableSuggestions["userPaths"]

let scheduledPaths: VariableSuggestions["scheduledPaths"] = []
try {
const { data } = await oapiClient.GET(
"/api/admin/projects/{projectID}/subjects/user/scheduled/schema",
{ params: { path } },
)
scheduledPaths = (data?.results ?? []).map((s) => ({
...s,
schema: s.schema ?? [],
})) as VariableSuggestions["scheduledPaths"]
} catch (error) {
console.debug("Failed to fetch scheduled schemas:", error)
}
const scheduledPaths = (scheduled?.data?.results ?? []).map((s) => ({
...s,
schema: s.schema ?? [],
})) as VariableSuggestions["scheduledPaths"]

let organizationEventPaths: VariableSuggestions["organizationEventPaths"] = []
try {
const { data } = await oapiClient.GET(
"/api/admin/projects/{projectID}/subjects/organization/events/schema",
{ params: { path } },
)
organizationEventPaths = (data?.results ?? []).map((event) => ({
...event,
schema: event.schema ?? [],
})) as VariableSuggestions["organizationEventPaths"]
} catch (error) {
console.debug("Failed to fetch organization event schemas:", error)
}
const organizationEventPaths = (organizationEvents?.data?.results ?? []).map((event) => ({
...event,
schema: event.schema ?? [],
})) as VariableSuggestions["organizationEventPaths"]

let organizationUserPaths: VariableSuggestions["organizationUserPaths"] = []
try {
const { data } = await oapiClient.GET(
"/api/admin/projects/{projectID}/subjects/organizations/users/schema",
{ params: { path } },
)
organizationUserPaths = (data?.results ??
[]) as VariableSuggestions["organizationUserPaths"]
} catch (error) {
console.debug("Failed to fetch organization user schemas:", error)
}
const organizationUserPaths = (organizationUsers?.data?.results ??
[]) as VariableSuggestions["organizationUserPaths"]

let organizationPaths: VariableSuggestions["organizationPaths"] = []
try {
const { data } = await oapiClient.GET(
"/api/admin/projects/{projectID}/subjects/organizations/schema",
{ params: { path } },
)
organizationPaths = (data?.results ?? []) as VariableSuggestions["organizationPaths"]
} catch (error) {
console.debug("Failed to fetch organization schemas:", error)
}
const organizationPaths = (organizations?.data?.results ??
[]) as VariableSuggestions["organizationPaths"]

return {
eventPaths,
Expand Down
77 changes: 77 additions & 0 deletions console/src/lib/templatical-preview.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { describe, expect, it } from "vitest"
import {
resolveMergeTags,
templaticalPlainText,
templaticalPreviewHtml,
} from "./templatical-preview"

const context = {
user: { email: "admin@localhost", data: { admin: true } },
unsubscribe_url: "https://lunogram.com/unsubscribe",
now: "2026-07-26T00:00:00.000Z",
}

describe("resolveMergeTags", () => {
it("resolves a nested user path", () => {
expect(resolveMergeTags("Hello {{ user.email }}", context)).toBe("Hello admin@localhost")
expect(resolveMergeTags("{{ user.data.admin }}", context)).toBe("true")
})

it("resolves tags outside the user scope", () => {
// These are offered by the merge-tag picker but were previously
// unresolvable, because only `user` was in the preview context.
expect(resolveMergeTags("{{ unsubscribe_url }}", context)).toBe(
"https://lunogram.com/unsubscribe",
)
})

it("resolves filtered tags", () => {
// The picker's "Current Year". Handlebars could not parse the `|`, and
// because the pass covers the whole document its parse error took every
// other tag down with it.
expect(resolveMergeTags("{{ now | date: '%Y' }}", context)).toBe("2026")
})

it("renders an unknown path empty without affecting its neighbours", () => {
expect(
resolveMergeTags("a={{ user.email }} b={{ nope }} c={{ now | date: '%Y' }}", context),
).toBe("a=admin@localhost b= c=2026")
})

it("leaves rendered email scaffolding intact", () => {
const html = [
"<style>@media only screen and (min-width:480px) { .col { width:100% !important; } }</style>",
"<!--[if mso | IE]><table><tr><td><![endif]-->",
"<p>Hi {{ user.email }}</p>",
].join("\n")

const out = resolveMergeTags(html, context)

expect(out).toContain("@media only screen and (min-width:480px)")
expect(out).toContain("<!--[if mso | IE]>")
expect(out).toContain("Hi admin@localhost")
})

it("returns the input unchanged when there is nothing to render", () => {
expect(resolveMergeTags("", context)).toBe("")
})
})

describe("bundle readers", () => {
it("read the html and plain text written by the backend", () => {
const bundle = JSON.stringify({
kind: "templatical",
html: "<p>hi</p>",
plainText: "hi",
})

expect(templaticalPreviewHtml(bundle)).toBe("<p>hi</p>")
expect(templaticalPlainText(bundle)).toBe("hi")
})

it("preview as blank for a template that has not been saved yet", () => {
expect(templaticalPreviewHtml(undefined)).toBe("")
expect(templaticalPlainText(null)).toBe("")
expect(templaticalPreviewHtml("not json")).toBe("")
})
})
72 changes: 72 additions & 0 deletions console/src/lib/templatical-preview.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { Liquid } from "liquidjs"

/**
* Preview HTML for a visually authored (Templatical) email template.
*
* These templates are rendered by the backend when the template is saved, and
* the resulting HTML is stored in the bundle. The console must not fall back to
* compiling `code.source` for them: that field holds whatever JSX the template
* carried before it was switched to the visual editor, kept only so the switch
* stays reversible, and rendering it shows the wrong email entirely.
*
* Returns an empty string when the template has not been saved yet, which
* previews as blank — the same as a code template that has not compiled.
*/
export function templaticalPreviewHtml(bundle: string | undefined | null): string {
return readBundle(bundle).html ?? ""
}

/**
* Plain-text alternative for a visually authored template.
*
* Derived by the backend from the rendered HTML, so like the HTML it reflects
* the last save rather than unsaved edits.
*/
export function templaticalPlainText(bundle: string | undefined | null): string {
return readBundle(bundle).plainText ?? ""
}

/**
* Liquid, matching the merge-tag syntax the block editor is configured with and
* the engine the send pipeline uses server-side. Non-strict so an unknown path
* renders empty instead of throwing, the same as a real send.
*/
const liquid = new Liquid({ strictVariables: false, strictFilters: false })

/**
* Substitute merge tags in a rendered email against a preview context.
*
* The backend renders a visually authored template once, at save time, leaving
* merge tags as literal `{{ … }}` for the Liquid pass that runs per recipient.
* Previewing therefore means running that same pass in the console.
*
* It must be Liquid rather than Handlebars: the merge-tag picker offers
* filtered tags such as `{{ now | date: '%Y' }}`, and Handlebars cannot parse
* the `|`. Because the substitution covers the whole document, a parse error
* takes every other tag down with it, not just the offending one.
*
* Falls back to the unsubstituted HTML if rendering fails, so a preview never
* goes blank.
*/
export function resolveMergeTags(html: string, context: Record<string, unknown>): string {
if (!html) return html
try {
return liquid.parseAndRenderSync(html, context)
} catch (error) {
console.warn("Merge tag preview failed:", error)
return html
}
}

function readBundle(bundle: string | undefined | null): {
kind?: string
html?: string
plainText?: string
} {
if (!bundle) return {}
try {
return JSON.parse(bundle) as { kind?: string; html?: string; plainText?: string }
} catch {
return {}
}
}
Loading
Loading