Skip to content

Commit 68f41b5

Browse files
committed
WYSIWYG editor for email editor
1 parent 4d935a5 commit 68f41b5

20 files changed

Lines changed: 2307 additions & 1156 deletions

File tree

apps/backend/.env

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,4 +96,3 @@ STACK_TELEGRAM_CHAT_ID=# enter your telegram chat id
9696

9797
STACK_AI_PROVIDER=openrouter
9898
STACK_OPENROUTER_API_KEY=# enter your OpenRouter API key
99-
STACK_AI_MODEL=google/gemini-3-flash-preview

apps/backend/.env.development

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -55,10 +55,9 @@ STACK_OPENAI_API_KEY=mock_openai_api_key
5555
STACK_STRIPE_SECRET_KEY=sk_test_mockstripekey
5656
STACK_STRIPE_WEBHOOK_SECRET=mock_stripe_webhook_secret
5757

58-
STACK_AI_PROVIDER=openrouter
5958
# Set your OpenRouter API key
60-
STACK_OPENROUTER_API_KEY=
61-
STACK_AI_MODEL=google/gemini-3-flash-preview
59+
STACK_AI_PROVIDER=openrouter
60+
STACK_OPENROUTER_API_KEY=# empty by default, which means the codegen will fail
6261

6362

6463
# S3 Configuration for local development using s3mock

apps/backend/src/app/api/latest/emails/render-email/route.tsx

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
1-
import { getEmailThemeForThemeId, renderEmailWithTemplate } from "@/lib/email-rendering";
1+
import { getEmailThemeForThemeId, renderEmailWithTemplate, type EditableMetadata } from "@/lib/email-rendering";
22
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
33
import { KnownErrors } from "@stackframe/stack-shared/dist/known-errors";
4-
import { adaptSchema, templateThemeIdSchema, yupNumber, yupObject, yupString, yupUnion } from "@stackframe/stack-shared/dist/schema-fields";
4+
import { adaptSchema, templateThemeIdSchema, yupBoolean, yupMixed, yupNumber, yupObject, yupString, yupUnion } from "@stackframe/stack-shared/dist/schema-fields";
55
import { StatusError } from "@stackframe/stack-shared/dist/utils/errors";
66

77
export const POST = createSmartRouteHandler({
@@ -19,18 +19,26 @@ export const POST = createSmartRouteHandler({
1919
yupObject({
2020
template_id: yupString().uuid().defined(),
2121
theme_id: templateThemeIdSchema,
22+
editable_markers: yupBoolean().optional(),
23+
editable_source: yupString().oneOf(['template', 'theme', 'both']).optional(),
2224
}),
2325
yupObject({
2426
template_id: yupString().uuid().defined(),
2527
theme_tsx_source: yupString().defined(),
28+
editable_markers: yupBoolean().optional(),
29+
editable_source: yupString().oneOf(['template', 'theme', 'both']).optional(),
2630
}),
2731
yupObject({
2832
template_tsx_source: yupString().defined(),
2933
theme_id: templateThemeIdSchema,
34+
editable_markers: yupBoolean().optional(),
35+
editable_source: yupString().oneOf(['template', 'theme', 'both']).optional(),
3036
}),
3137
yupObject({
3238
template_tsx_source: yupString().defined(),
3339
theme_tsx_source: yupString().defined(),
40+
editable_markers: yupBoolean().optional(),
41+
editable_source: yupString().oneOf(['template', 'theme', 'both']).optional(),
3442
}),
3543
).defined(),
3644
}),
@@ -41,6 +49,7 @@ export const POST = createSmartRouteHandler({
4149
html: yupString().defined(),
4250
subject: yupString(),
4351
notification_category: yupString(),
52+
editable_regions: yupMixed<Record<string, EditableMetadata>>().optional(),
4453
}).defined(),
4554
}),
4655
async handler({ body, auth: { tenancy } }) {
@@ -69,12 +78,17 @@ export const POST = createSmartRouteHandler({
6978
throw new KnownErrors.SchemaError("Either template_id or template_tsx_source must be provided");
7079
}
7180

81+
const editableMarkers = 'editable_markers' in body && body.editable_markers === true;
82+
const editableSource = ('editable_source' in body ? body.editable_source : 'template') as 'template' | 'theme' | 'both';
83+
7284
const result = await renderEmailWithTemplate(
7385
contentSource,
7486
themeSource,
7587
{
7688
project: { displayName: tenancy.project.display_name },
7789
previewMode: true,
90+
editableMarkers,
91+
editableSource,
7892
themeProps: {
7993
projectLogos: {
8094
logoUrl: tenancy.project.logo_url ?? undefined,
@@ -95,6 +109,7 @@ export const POST = createSmartRouteHandler({
95109
html: result.data.html,
96110
subject: result.data.subject,
97111
notification_category: result.data.notificationCategory,
112+
editable_regions: result.data.editableRegions,
98113
},
99114
};
100115
},

apps/backend/src/app/api/latest/internal/ai-chat/[threadId]/route.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ export const POST = createSmartRouteHandler({
6262
}),
6363
async handler({ body, params, auth: { tenancy } }) {
6464
const adapter = getChatAdapter(body.context_type, tenancy, params.threadId);
65-
const modelName = getEnvVariable("STACK_AI_MODEL", getEnvVariable("STACK_OPENAI_MODEL", "gpt-4o"));
65+
const modelName = "google/gemini-3-flash-preview";
6666

6767
// Validate messages structure before passing to AI
6868
const validatedMessages = body.messages.map(msg => ({
Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
2+
import { createOpenAI } from "@ai-sdk/openai";
3+
import { adaptSchema, yupArray, yupMixed, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
4+
import { getEnvVariable } from "@stackframe/stack-shared/dist/utils/env";
5+
import { generateText } from "ai";
6+
7+
const aiProvider = getEnvVariable("STACK_AI_PROVIDER", "openai");
8+
const openai = createOpenAI({
9+
apiKey: aiProvider === "openrouter"
10+
? getEnvVariable("STACK_OPENROUTER_API_KEY", "MISSING_OPENROUTER_API_KEY")
11+
: getEnvVariable("STACK_OPENAI_API_KEY", "MISSING_OPENAI_API_KEY"),
12+
baseURL: aiProvider === "openrouter" ? "https://openrouter.ai/api/v1" : undefined,
13+
});
14+
15+
const WYSIWYG_SYSTEM_PROMPT = `You are an expert at editing React/JSX code. Your task is to update a specific text string in the source code.
16+
17+
RULES:
18+
1. You will be given the original source code and details about a text edit the user wants to make.
19+
2. Find the text at the specified location and replace it with the new text.
20+
3. If there are multiple occurrences of the same text, use the provided location info (line, column, occurrence index) to identify the correct one.
21+
4. The text you're given is given as plaintext, so you should escape it properly. Be smart about what the user's intent may have been; if it contains eg. an added newline character, that's because the user added a newline character, so depending on the context sometimes you should replace it with <br />, sometimes you should create a new <p>, and sometimes you should do something else. Change it in a good-faith interpretation of what the user may have wanted to do, not in perfect spec-compliance.
22+
5. If the text is part of a template literal or JSX expression, only change the static text portion.
23+
6. Return ONLY the complete updated source code, nothing else.
24+
7. Do NOT add any explanation, markdown formatting, or code fences - just the raw source code.
25+
8. Context: The user is editing the text in a WYSIWYG editor. They expect that the change they made will be reflected as-is, without massively the rest of the source code. However, in most cases, the user don't actually care about the rest of the source code, so in the rare cases where things are complex and you would have to change a bit more than just the text node, you should make the changes that sound reasonable from a UX perspective.
26+
9. If the user added whitespace padding at the very end or the very beginning of the text node, that was probably an accident and you can ignore it.
27+
28+
IMPORTANT:
29+
- The location info includes: line number, column, source context (lines before/after), JSX path, parent element.
30+
- Use all available information to find the exact text to replace.
31+
`;
32+
33+
const editMetadataSchema = yupObject({
34+
id: yupString().defined(),
35+
loc: yupObject({
36+
start: yupNumber().defined(),
37+
end: yupNumber().defined(),
38+
line: yupNumber().defined(),
39+
column: yupNumber().defined(),
40+
}).defined(),
41+
originalText: yupString().defined(),
42+
textHash: yupString().defined(),
43+
jsxPath: yupArray(yupString().defined()).defined(),
44+
parentElement: yupObject({
45+
tagName: yupString().defined(),
46+
props: yupMixed().defined(),
47+
}).defined(),
48+
sourceContext: yupObject({
49+
before: yupString().defined(),
50+
after: yupString().defined(),
51+
}).defined(),
52+
siblingIndex: yupNumber().defined(),
53+
occurrenceCount: yupNumber().defined(),
54+
occurrenceIndex: yupNumber().defined(),
55+
sourceFile: yupString().oneOf(["template", "theme"]).defined(),
56+
});
57+
58+
const domPathItemSchema = yupObject({
59+
tagName: yupString().defined(),
60+
index: yupNumber().defined(),
61+
});
62+
63+
export const POST = createSmartRouteHandler({
64+
metadata: {
65+
summary: "Apply WYSIWYG text edit",
66+
description: "Uses AI to update source code based on a WYSIWYG text edit",
67+
tags: ["Internal", "AI"],
68+
hidden: true,
69+
},
70+
request: yupObject({
71+
auth: yupObject({
72+
type: yupString().oneOf(["admin"]).defined(),
73+
tenancy: adaptSchema.defined(),
74+
}).defined(),
75+
body: yupObject({
76+
/** The type of source being edited */
77+
source_type: yupString().oneOf(["template", "theme", "draft"]).defined(),
78+
/** The current source code to edit */
79+
source_code: yupString().defined(),
80+
/** The original text that was in the editable region */
81+
old_text: yupString().defined(),
82+
/** The new text the user wants */
83+
new_text: yupString().defined(),
84+
/** Metadata from the editable region for locating the text */
85+
metadata: editMetadataSchema.defined(),
86+
/** DOM path from the iframe for additional context */
87+
dom_path: yupArray(domPathItemSchema.defined()).defined(),
88+
/** HTML context from the rendered output */
89+
html_context: yupString().defined(),
90+
}).defined(),
91+
}),
92+
response: yupObject({
93+
statusCode: yupNumber().oneOf([200]).defined(),
94+
bodyType: yupString().oneOf(["json"]).defined(),
95+
body: yupObject({
96+
updated_source: yupString().defined(),
97+
}).defined(),
98+
}),
99+
async handler({ body }) {
100+
const {
101+
source_code,
102+
old_text,
103+
new_text,
104+
metadata,
105+
dom_path,
106+
html_context,
107+
} = body;
108+
109+
// If no change, return original
110+
if (old_text === new_text) {
111+
return {
112+
statusCode: 200,
113+
bodyType: "json",
114+
body: { updated_source: source_code },
115+
};
116+
}
117+
118+
// Build the prompt for the AI
119+
const userPrompt = `
120+
## Source Code to Edit
121+
\`\`\`tsx
122+
${source_code}
123+
\`\`\`
124+
125+
## Edit Request
126+
- **Old text:** "${old_text}"
127+
- **New text:** "${new_text}"
128+
129+
## Location Information
130+
- **Line:** ${metadata.loc.line}
131+
- **Column:** ${metadata.loc.column}
132+
- **JSX Path:** ${metadata.jsxPath.join(" > ")}
133+
- **Parent Element:** <${metadata.parentElement.tagName}>
134+
- **Sibling Index:** ${metadata.siblingIndex}
135+
- **Occurrence:** ${metadata.occurrenceIndex} of ${metadata.occurrenceCount}
136+
137+
## Source Context (lines around the text)
138+
Before:
139+
\`\`\`
140+
${metadata.sourceContext.before}
141+
\`\`\`
142+
143+
After:
144+
\`\`\`
145+
${metadata.sourceContext.after}
146+
\`\`\`
147+
148+
## Runtime DOM Path (for disambiguation)
149+
${dom_path.map((p, i) => `${i + 1}. <${p.tagName}> (index: ${p.index})`).join("\n")}
150+
151+
## Rendered HTML Context
152+
\`\`\`html
153+
${html_context.slice(0, 500)}
154+
\`\`\`
155+
156+
Please update the source code to change "${old_text}" to "${new_text}" at the specified location. Return ONLY the complete updated source code.
157+
`;
158+
159+
const modelName = "google/gemini-3-flash-preview";
160+
161+
const result = await generateText({
162+
model: openai(modelName),
163+
system: WYSIWYG_SYSTEM_PROMPT,
164+
messages: [{ role: "user", content: userPrompt }],
165+
});
166+
167+
// Extract the updated source code from the response
168+
let updatedSource = result.text.trim();
169+
170+
// Remove any markdown code fences if the AI added them despite instructions
171+
if (updatedSource.startsWith("```")) {
172+
const lines = updatedSource.split("\n");
173+
// Remove first line (```tsx or similar)
174+
lines.shift();
175+
// Remove last line if it's ```
176+
if (lines[lines.length - 1]?.trim() === "```") {
177+
lines.pop();
178+
}
179+
updatedSource = lines.join("\n");
180+
}
181+
182+
return {
183+
statusCode: 200,
184+
bodyType: "json",
185+
body: { updated_source: updatedSource },
186+
};
187+
},
188+
});

apps/backend/src/lib/email-rendering.tsx

Lines changed: 62 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,18 @@ import { emptyEmailTheme } from '@stackframe/stack-shared/dist/helpers/emails';
33
import { getEnvVariable } from '@stackframe/stack-shared/dist/utils/env';
44
import { captureError, StackAssertionError } from '@stackframe/stack-shared/dist/utils/errors';
55
import { bundleJavaScript } from '@stackframe/stack-shared/dist/utils/esbuild';
6+
import {
7+
transpileJsxForEditing,
8+
convertSentinelTokensToComments,
9+
type EditableMetadata,
10+
} from '@stackframe/stack-shared/dist/utils/jsx-editable-transpiler';
611
import { get, has } from '@stackframe/stack-shared/dist/utils/objects';
712
import { Result } from "@stackframe/stack-shared/dist/utils/results";
813
import { deindent } from "@stackframe/stack-shared/dist/utils/strings";
914
import { Tenancy } from './tenancies';
1015

16+
export type { EditableMetadata };
17+
1118
export function getActiveEmailTheme(tenancy: Tenancy) {
1219
const themeList = tenancy.config.emails.themes;
1320
const currentActiveTheme = tenancy.config.emails.selectedThemeId;
@@ -47,6 +54,17 @@ export function createTemplateComponentFromHtml(html: string) {
4754
`;
4855
}
4956

57+
/**
58+
* Result type for rendering with editable markers.
59+
*/
60+
export type RenderWithEditableMarkersResult = {
61+
html: string,
62+
text: string,
63+
subject?: string,
64+
notificationCategory?: string,
65+
editableRegions: Record<string, EditableMetadata>,
66+
};
67+
5068
export async function renderEmailWithTemplate(
5169
templateOrDraftComponent: string,
5270
themeComponent: string,
@@ -64,10 +82,15 @@ export async function renderEmailWithTemplate(
6482
},
6583
},
6684
previewMode?: boolean,
85+
/** When true, transpiles JSX to include editable markers and returns editableRegions */
86+
editableMarkers?: boolean,
87+
/** Which source to make editable: 'template' (default), 'theme', or 'both' */
88+
editableSource?: 'template' | 'theme' | 'both',
6789
},
68-
): Promise<Result<{ html: string, text: string, subject?: string, notificationCategory?: string }, string>> {
90+
): Promise<Result<{ html: string, text: string, subject?: string, notificationCategory?: string, editableRegions?: Record<string, EditableMetadata> }, string>> {
6991
const variables = options.variables ?? {};
7092
const previewMode = options.previewMode ?? false;
93+
const editableMarkers = options.editableMarkers ?? false;
7194
const user = (previewMode && !options.user) ? { displayName: "John Doe" } : options.user;
7295
const project = (previewMode && !options.project) ? { displayName: "My Project" } : options.project;
7396
if (!user) {
@@ -77,10 +100,31 @@ export async function renderEmailWithTemplate(
77100
throw new StackAssertionError("Project is required when not in preview mode", { user, project, variables });
78101
}
79102

103+
// Optionally transpile sources to include editable markers
104+
let processedTemplate = templateOrDraftComponent;
105+
let processedTheme = themeComponent;
106+
let allEditableRegions: Record<string, EditableMetadata> = {};
107+
const editableSource = options.editableSource ?? 'template';
108+
109+
if (editableMarkers) {
110+
// Only transpile the source that should be editable
111+
if (editableSource === 'template' || editableSource === 'both') {
112+
const templateTranspiled = transpileJsxForEditing(templateOrDraftComponent, { sourceFile: 'template' });
113+
processedTemplate = templateTranspiled.code;
114+
allEditableRegions = { ...allEditableRegions, ...templateTranspiled.editableRegions };
115+
}
116+
117+
if (editableSource === 'theme' || editableSource === 'both') {
118+
const themeTranspiled = transpileJsxForEditing(themeComponent, { sourceFile: 'theme' });
119+
processedTheme = themeTranspiled.code;
120+
allEditableRegions = { ...allEditableRegions, ...themeTranspiled.editableRegions };
121+
}
122+
}
123+
80124
const result = await bundleJavaScript({
81125
"/utils.tsx": findComponentValueUtil,
82-
"/theme.tsx": themeComponent,
83-
"/template.tsx": templateOrDraftComponent,
126+
"/theme.tsx": processedTheme,
127+
"/template.tsx": processedTemplate,
84128
"/render.tsx": deindent`
85129
import { configure } from "arktype/config"
86130
configure({ onUndeclaredKey: "delete" })
@@ -150,7 +194,21 @@ export async function renderEmailWithTemplate(
150194
captureError("freestyle-no-result", noResultError);
151195
throw noResultError;
152196
}
153-
return Result.ok(executeResult.data.result as { html: string, text: string, subject: string, notificationCategory: string });
197+
198+
const renderResult = executeResult.data.result as { html: string, text: string, subject: string, notificationCategory: string };
199+
200+
// Post-process HTML to convert sentinel tokens to comments when editable markers are enabled
201+
if (editableMarkers) {
202+
return Result.ok({
203+
html: convertSentinelTokensToComments(renderResult.html),
204+
text: renderResult.text,
205+
subject: renderResult.subject,
206+
notificationCategory: renderResult.notificationCategory,
207+
editableRegions: allEditableRegions,
208+
});
209+
}
210+
211+
return Result.ok(renderResult);
154212
}
155213

156214
// unused, but kept for reference & in case we need it again

0 commit comments

Comments
 (0)