diff --git a/apps/builder/app/builder/features/pages/page-settings.tsx b/apps/builder/app/builder/features/pages/page-settings.tsx index ea951a8f40cf..9cc9768c6216 100644 --- a/apps/builder/app/builder/features/pages/page-settings.tsx +++ b/apps/builder/app/builder/features/pages/page-settings.tsx @@ -112,6 +112,7 @@ import { } from "./page-utils"; import { Form } from "./form"; import { CustomMetadata } from "./custom-metadata"; +import { SchemaMarkup } from "./schema-markup"; const fieldDefaultValues = { name: "Untitled", @@ -128,6 +129,7 @@ const fieldDefaultValues = { redirect: `""`, documentType: "html" as (typeof documentTypes)[number], customMetas: [{ property: "", content: `""` }], + schemaMarkup: [] as Array<{ type: "application/ld+json"; content: string }>, marketplaceInclude: false, marketplaceCategory: "", marketplaceThumbnailAssetId: "", @@ -190,6 +192,14 @@ const SharedPageValues = z.object({ }) ) .optional(), + schemaMarkup: z + .array( + z.object({ + type: z.enum(["application/ld+json"]), + content: z.string(), + }) + ) + .optional(), }); const HomePageValues = SharedPageValues.extend({ @@ -284,6 +294,7 @@ const toFormValues = ( documentType: page.meta.documentType ?? fieldDefaultValues.documentType, isHomePage, customMetas: page.meta.custom ?? fieldDefaultValues.customMetas, + schemaMarkup: page.meta.schemaMarkup ?? fieldDefaultValues.schemaMarkup, marketplaceInclude: page.marketplace?.include ?? false, marketplaceCategory: page.marketplace?.category ?? "", marketplaceThumbnailAssetId: page.marketplace?.thumbnailAssetId ?? "", @@ -1147,6 +1158,31 @@ const FormFields = ({ /> + + + + { + onChange({ + field: "schemaMarkup", + value: schemaMarkup, + }); + }} + pageContext={{ + pageName: values.name, + pageTitle: title, + pageDescription: description || undefined, + siteName: pages?.meta?.siteName || undefined, + siteUrl: pageUrl, + pagePath: values.path, + contactEmail: pages?.meta?.contactEmail || undefined, + language: + String(computeExpression(values.language, variableValues)) || + undefined, + socialImageUrl: socialImageUrl || undefined, + }} + /> {(project?.marketplaceApprovalStatus === "PENDING" || @@ -1373,6 +1409,11 @@ const updatePage = (pageId: Page["id"], values: Partial) => { page.meta.custom = values.customMetas; } + if (values.schemaMarkup !== undefined) { + page.meta.schemaMarkup = + values.schemaMarkup.length > 0 ? values.schemaMarkup : undefined; + } + if (values.documentType !== undefined) { page.meta.documentType = values.documentType; } diff --git a/apps/builder/app/builder/features/pages/schema-markup.tsx b/apps/builder/app/builder/features/pages/schema-markup.tsx new file mode 100644 index 000000000000..1935557adcf3 --- /dev/null +++ b/apps/builder/app/builder/features/pages/schema-markup.tsx @@ -0,0 +1,407 @@ +import { useState } from "react"; +import { + Button, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + Flex, + Grid, + Label, + SmallIconButton, + Text, + theme, + Tooltip, +} from "@webstudio-is/design-system"; +import { + TrashIcon, + PlusIcon, + InfoCircleIcon, + AiIcon, + AiLoadingIcon, + ChevronDownIcon, + AlertIcon, +} from "@webstudio-is/icons"; +import { CodeEditor } from "~/builder/shared/code-editor"; +import { trpcClient } from "~/shared/trpc/trpc-client"; + +type Script = { + type: "application/ld+json"; + content: string; +}; + +type PageContext = { + pageName: string; + pageTitle: string; + pageDescription?: string; + siteName?: string; + siteUrl?: string; + pagePath?: string; + contactEmail?: string; + language?: string; + socialImageUrl?: string; +}; + +type SchemaMarkupProps = { + schemaMarkup: Script[]; + onChange: (value: Script[]) => void; + pageContext: PageContext; +}; + +const defaultJsonLd = `{ + "@context": "https://schema.org", + "@type": "Organization", + "name": "", + "url": "" +}`; + +const schemaTypes = [ + { value: "Organization", label: "Organization" }, + { value: "LocalBusiness", label: "Local Business" }, + { value: "Article", label: "Article" }, + { value: "BlogPosting", label: "Blog Posting" }, + { value: "Product", label: "Product" }, + { value: "Event", label: "Event" }, + { value: "FAQPage", label: "FAQ Page" }, + { value: "HowTo", label: "How To" }, + { value: "Person", label: "Person" }, + { value: "WebSite", label: "Web Site" }, + { value: "WebPage", label: "Web Page" }, + { value: "BreadcrumbList", label: "Breadcrumb List" }, + { value: "Review", label: "Review" }, + { value: "Recipe", label: "Recipe" }, + { value: "Course", label: "Course" }, + { value: "JobPosting", label: "Job Posting" }, + { value: "SoftwareApplication", label: "Software Application" }, + { value: "VideoObject", label: "Video Object" }, +] as const; + +type SchemaType = (typeof schemaTypes)[number]["value"]; + +const validateJsonLd = (content: string): string | undefined => { + if (content.trim() === "") { + return undefined; + } + try { + const parsed = JSON.parse(content); + if (typeof parsed !== "object" || parsed === null) { + return "JSON-LD must be an object"; + } + if (!parsed["@context"]) { + return "JSON-LD should include @context"; + } + if (!parsed["@type"]) { + return "JSON-LD should include @type"; + } + return undefined; + } catch { + return "Invalid JSON syntax"; + } +}; + +const getSchemaType = (content: string): string | undefined => { + try { + const parsed = JSON.parse(content); + return parsed["@type"]; + } catch { + return undefined; + } +}; + +// Schema types that should only appear once per page +// These represent singular entities that don't make sense to duplicate +const singletonSchemaTypes = new Set([ + "Organization", + "LocalBusiness", + "WebSite", + "WebPage", + "Person", + "FAQPage", + "BreadcrumbList", + "HowTo", + "Course", + "SoftwareApplication", +]); + +const findDuplicateSchemaTypes = (scripts: Script[]): Map => { + const typeCounts = new Map(); + + for (const script of scripts) { + const schemaType = getSchemaType(script.content); + if (schemaType) { + typeCounts.set(schemaType, (typeCounts.get(schemaType) ?? 0) + 1); + } + } + + // Return only singleton types that appear more than once + const duplicates = new Map(); + for (const [type, count] of typeCounts) { + if (count > 1 && singletonSchemaTypes.has(type)) { + duplicates.set(type, count); + } + } + return duplicates; +}; + +const ScriptItem = (props: { + content: string; + onDelete: () => void; + onChange: (content: string) => void; +}) => { + const [localContent, setLocalContent] = useState(props.content); + const validationError = validateJsonLd(localContent); + + return ( + + + + } + onClick={props.onDelete} + aria-label="Delete script" + /> + + + { + setLocalContent(value); + }} + onChangeComplete={(value) => { + setLocalContent(value); + props.onChange(value); + }} + /> + + {validationError && ( + + {validationError} + + )} + + ); +}; + +const useGenerateSchema = () => { + const { send, state, error } = trpcClient.ai.generateSchema.useMutation(); + const isLoading = state === "submitting"; + + const generate = ( + schemaType: SchemaType, + pageContext: PageContext, + onSuccess: (content: string) => void + ) => { + send( + { + schemaType, + pageContext: { + pageName: pageContext.pageName, + pageTitle: pageContext.pageTitle, + pageDescription: pageContext.pageDescription, + siteName: pageContext.siteName, + siteUrl: pageContext.siteUrl, + pagePath: pageContext.pagePath, + contactEmail: pageContext.contactEmail, + language: pageContext.language, + socialImageUrl: pageContext.socialImageUrl, + }, + }, + (generatedSchema: string) => { + onSuccess(generatedSchema); + } + ); + }; + + return { generate, isLoading, error }; +}; + +const GenerateWithAiButton = ({ + isLoading, + onSelectType, +}: { + isLoading: boolean; + onSelectType: (schemaType: SchemaType) => void; +}) => { + return ( + + + + + + {schemaTypes.map(({ value, label }) => ( + onSelectType(value)} + disabled={isLoading} + > + {label} + + ))} + + + ); +}; + +export const SchemaMarkup = (props: SchemaMarkupProps) => { + const { generate, isLoading, error } = useGenerateSchema(); + + const handleAiGenerate = (schemaType: SchemaType) => { + generate(schemaType, props.pageContext, (content) => { + const newSchemaMarkup = [ + ...props.schemaMarkup, + { type: "application/ld+json" as const, content }, + ]; + props.onChange(newSchemaMarkup); + }); + }; + + const duplicateTypes = findDuplicateSchemaTypes(props.schemaMarkup); + + return ( + + + + + Add JSON-LD structured data to help search engines understand your + page content. This can improve SEO and enable rich results in + search.{" "} + + Learn more + + + } + > + + + + + Add structured data using JSON-LD format to improve how search engines + and AI tools understand your page content. Use AI to generate schemas or + add them manually. + + + {duplicateTypes.size > 0 && ( + + + + + Duplicate schema types detected: + {" "} + {Array.from(duplicateTypes.entries()) + .map(([type, count]) => `${type} (${count}×)`) + .join(", ")} + . Having multiple schemas of the same type on one page may confuse + search engines. + + + )} + + {error && ( + + {error} + + )} + + + {props.schemaMarkup.map((script, index) => ( + { + const newSchemaMarkup = [...props.schemaMarkup]; + newSchemaMarkup[index] = { + type: "application/ld+json", + content, + }; + props.onChange(newSchemaMarkup); + }} + onDelete={() => { + const newSchemaMarkup = [...props.schemaMarkup]; + newSchemaMarkup.splice(index, 1); + props.onChange(newSchemaMarkup); + }} + /> + ))} + + + + + + + + ); +}; diff --git a/apps/builder/app/builder/shared/code-editor.tsx b/apps/builder/app/builder/shared/code-editor.tsx index b703c8f8b975..b727f5510136 100644 --- a/apps/builder/app/builder/shared/code-editor.tsx +++ b/apps/builder/app/builder/shared/code-editor.tsx @@ -26,6 +26,7 @@ import { } from "@codemirror/autocomplete"; import { html } from "@codemirror/lang-html"; import { markdown } from "@codemirror/lang-markdown"; +import { json } from "@codemirror/lang-json"; import { css } from "@webstudio-is/design-system"; import { EditorContent, @@ -115,10 +116,24 @@ const getCssPropertiesExtensions = () => [ autocompletion({ icons: false }), ]; +const getJsonExtensions = () => [ + highlightActiveLine(), + highlightSpecialChars(), + indentOnInput(), + json(), + bracketMatching(), + closeBrackets(), + // render autocomplete in body + // to prevent popover scroll overflow + tooltips({ parent: document.body }), + autocompletion({ icons: false }), + keymap.of([...closeBracketsKeymap, ...completionKeymap]), +]; + export const CodeEditor = forwardRef< HTMLDivElement, Omit, "extensions"> & { - lang?: "html" | "markdown" | "css-properties"; + lang?: "html" | "markdown" | "css-properties" | "json"; title?: ReactNode; size?: "default" | "small"; } @@ -136,6 +151,10 @@ export const CodeEditor = forwardRef< return getCssPropertiesExtensions(); } + if (lang === "json") { + return getJsonExtensions(); + } + if (lang === undefined) { return []; } diff --git a/apps/builder/app/env/env.server.ts b/apps/builder/app/env/env.server.ts index 5beff53a952b..0b7c7608f310 100644 --- a/apps/builder/app/env/env.server.ts +++ b/apps/builder/app/env/env.server.ts @@ -64,6 +64,10 @@ const env = { POSTGREST_URL: process.env.POSTGREST_URL ?? "http://localhost:3000", POSTGREST_API_KEY: process.env.POSTGREST_API_KEY ?? "", + // AI Features (OpenAI) + OPENAI_KEY: process.env.OPENAI_KEY, + OPENAI_ORG: process.env.OPENAI_ORG, + SECURE_COOKIE: true, // Used for project oauth login flow @todo remove ?? diff --git a/apps/builder/app/services/ai-router.server.ts b/apps/builder/app/services/ai-router.server.ts new file mode 100644 index 000000000000..7396f0aefacc --- /dev/null +++ b/apps/builder/app/services/ai-router.server.ts @@ -0,0 +1,181 @@ +import { z } from "zod"; +import { procedure, router } from "@webstudio-is/trpc-interface/index.server"; +import env from "~/env/env.server"; + +const schemaTypes = [ + "Organization", + "LocalBusiness", + "Article", + "BlogPosting", + "Product", + "Event", + "FAQPage", + "HowTo", + "Person", + "WebSite", + "WebPage", + "BreadcrumbList", + "Review", + "Recipe", + "Course", + "JobPosting", + "SoftwareApplication", + "VideoObject", +] as const; + +const generateSchemaPrompt = ( + schemaType: (typeof schemaTypes)[number], + pageContext: { + pageName: string; + pageTitle: string; + pageDescription?: string; + siteName?: string; + siteUrl?: string; + pagePath?: string; + contactEmail?: string; + language?: string; + socialImageUrl?: string; + } +) => { + const contextLines = [ + `- Page name: ${pageContext.pageName}`, + `- Page title: ${pageContext.pageTitle}`, + pageContext.pageDescription + ? `- Page description: ${pageContext.pageDescription}` + : null, + pageContext.siteName + ? `- Site/Company name: ${pageContext.siteName}` + : null, + pageContext.siteUrl ? `- Full page URL: ${pageContext.siteUrl}` : null, + pageContext.pagePath ? `- Page path: ${pageContext.pagePath}` : null, + pageContext.contactEmail + ? `- Contact email: ${pageContext.contactEmail}` + : null, + pageContext.language ? `- Language: ${pageContext.language}` : null, + pageContext.socialImageUrl + ? `- Social/Preview image URL: ${pageContext.socialImageUrl}` + : null, + ] + .filter(Boolean) + .join("\n"); + + return `Generate a valid JSON-LD structured data schema for a "${schemaType}" type. + +Context about the page and website: +${contextLines} + +Requirements: +1. Output ONLY valid JSON - no markdown code blocks, no explanations +2. Include "@context": "https://schema.org" and "@type": "${schemaType}" +3. Use the provided context data (site name, URL, email, image, etc.) in the schema where appropriate +4. For values that aren't provided in the context and need user customization, use descriptive placeholder text like "[Add your address here]" or "[Add phone number]" +5. Include all common/recommended properties for this schema type according to Google's guidelines +6. If generating LocalBusiness, Organization, or similar: use the contactEmail for email field if provided +7. If generating Article, BlogPosting: use appropriate datePublished and author fields +8. Always include the full URL (siteUrl) where URL fields are needed +9. Use the socialImageUrl for "image" fields when available + +Output the JSON object directly:`; +}; + +export const aiRouter = router({ + generateSchema: procedure + .input( + z.object({ + schemaType: z.enum(schemaTypes), + pageContext: z.object({ + pageName: z.string(), + pageTitle: z.string(), + pageDescription: z.string().optional(), + siteName: z.string().optional(), + siteUrl: z.string().optional(), + pagePath: z.string().optional(), + contactEmail: z.string().optional(), + language: z.string().optional(), + socialImageUrl: z.string().optional(), + }), + }) + ) + .mutation(async ({ input }) => { + if (!env.OPENAI_KEY) { + throw new Error( + "AI features require OPENAI_KEY to be configured. Please set the OPENAI_KEY environment variable." + ); + } + + const prompt = generateSchemaPrompt(input.schemaType, input.pageContext); + + const headers: Record = { + "Content-Type": "application/json", + Authorization: `Bearer ${env.OPENAI_KEY}`, + }; + + if (env.OPENAI_ORG) { + headers["OpenAI-Organization"] = env.OPENAI_ORG; + } + + const response = await fetch( + "https://api.openai.com/v1/chat/completions", + { + method: "POST", + headers, + body: JSON.stringify({ + model: "gpt-5.1", + messages: [ + { + role: "system", + content: + "You are a JSON-LD structured data expert. You generate valid schema.org JSON-LD markup for SEO. Always output raw JSON without markdown formatting.", + }, + { role: "user", content: prompt }, + ], + temperature: 0.7, + max_completion_tokens: 1000, + }), + } + ); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`OpenAI API error: ${response.status} - ${errorText}`); + } + + const data = (await response.json()) as { + choices: Array<{ message: { content: string } }>; + }; + + const content = data.choices[0]?.message?.content; + + if (!content) { + throw new Error("No response from AI"); + } + + // Clean up the response - remove markdown code blocks if present + let jsonContent = content.trim(); + if (jsonContent.startsWith("```json")) { + jsonContent = jsonContent.slice(7); + } else if (jsonContent.startsWith("```")) { + jsonContent = jsonContent.slice(3); + } + if (jsonContent.endsWith("```")) { + jsonContent = jsonContent.slice(0, -3); + } + jsonContent = jsonContent.trim(); + + // Validate JSON + try { + const parsed = JSON.parse(jsonContent); + // Pretty-print the JSON + return JSON.stringify(parsed, null, 2); + } catch { + throw new Error("AI generated invalid JSON. Please try again."); + } + }), + + getSchemaTypes: procedure.query(() => { + return schemaTypes.map((type) => ({ + value: type, + label: type.replace(/([A-Z])/g, " $1").trim(), + })); + }), +}); diff --git a/apps/builder/app/services/trcp-router.server.ts b/apps/builder/app/services/trcp-router.server.ts index 177b386ecfc7..7205c239e205 100644 --- a/apps/builder/app/services/trcp-router.server.ts +++ b/apps/builder/app/services/trcp-router.server.ts @@ -6,6 +6,7 @@ import { dashboardProjectRouter } from "@webstudio-is/dashboard/index.server"; import { marketplaceRouter } from "~/shared/marketplace/router.server"; import { userRouter } from "./user-router.server"; import { logoutRouter } from "./logout-router.server"; +import { aiRouter } from "./ai-router.server"; export const appRouter = router({ user: userRouter, @@ -15,6 +16,7 @@ export const appRouter = router({ authorizationToken: authorizationTokenRouter, dashboardProject: dashboardProjectRouter, logout: logoutRouter, + ai: aiRouter, }); export type AppRouter = typeof appRouter; diff --git a/apps/builder/package.json b/apps/builder/package.json index 59d665864cc2..eade80ad2dc5 100644 --- a/apps/builder/package.json +++ b/apps/builder/package.json @@ -25,6 +25,7 @@ "@codemirror/lang-css": "^6.3.1", "@codemirror/lang-html": "^6.4.9", "@codemirror/lang-javascript": "^6.2.3", + "@codemirror/lang-json": "^6.0.1", "@codemirror/lang-markdown": "^6.3.2", "@codemirror/language": "^6.11.0", "@codemirror/lint": "^6.8.5", diff --git a/packages/cli/templates/defaults/app/route-templates/html.tsx b/packages/cli/templates/defaults/app/route-templates/html.tsx index 7a3e0d6e371f..2fb84f29b4ed 100644 --- a/packages/cli/templates/defaults/app/route-templates/html.tsx +++ b/packages/cli/templates/defaults/app/route-templates/html.tsx @@ -22,6 +22,7 @@ import { PageSettingsMeta, PageSettingsTitle, PageSettingsCanonicalLink, + PageSettingsSchemaMarkup, } from "@webstudio-is/react-sdk/runtime"; import { projectId, @@ -310,6 +311,7 @@ const Outlet = () => { /> {pageMeta.title} + ); }; diff --git a/packages/cli/templates/react-router/app/route-templates/html.tsx b/packages/cli/templates/react-router/app/route-templates/html.tsx index 77ffc2f4b8c5..690037d3eab6 100644 --- a/packages/cli/templates/react-router/app/route-templates/html.tsx +++ b/packages/cli/templates/react-router/app/route-templates/html.tsx @@ -21,6 +21,7 @@ import { ReactSdkContext, PageSettingsMeta, PageSettingsTitle, + PageSettingsSchemaMarkup, } from "@webstudio-is/react-sdk/runtime"; import { projectId, @@ -308,6 +309,7 @@ const Outlet = () => { assetBaseUrl={constants.assetBaseUrl} /> {pageMeta.title} + ); }; diff --git a/packages/cli/templates/ssg/app/route-templates/html/+Head.tsx b/packages/cli/templates/ssg/app/route-templates/html/+Head.tsx index 88350e320ba3..64ed13e12571 100644 --- a/packages/cli/templates/ssg/app/route-templates/html/+Head.tsx +++ b/packages/cli/templates/ssg/app/route-templates/html/+Head.tsx @@ -63,6 +63,15 @@ export const Head = ({ data }: { data: PageContext["data"] }) => { isTwitterCardSizeDefined === false && ( )} + {pageMeta.schemaMarkup?.map((script, index) => ( +