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 (
+
+
+ : }
+ suffix={}
+ >
+ {isLoading ? "Generating..." : "Generate with AI"}
+
+
+
+ {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);
+ }}
+ />
+ ))}
+
+
+
+ }
+ onClick={() => {
+ const newSchemaMarkup = [
+ ...props.schemaMarkup,
+ {
+ type: "application/ld+json" as const,
+ content: defaultJsonLd,
+ },
+ ];
+ props.onChange(newSchemaMarkup);
+ }}
+ >
+ Add manually
+
+
+
+
+ );
+};
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) => (
+
+ ))}
{favIconAsset && (
{
+ const [shouldRender, setShouldRender] = useState(false);
+
+ useEffect(() => {
+ // Check if this script was already rendered by the server
+ // We identify scripts by their content hash
+ const selector = `script[type="application/ld+json"]`;
+ const allScripts = document.querySelectorAll(selector);
+
+ for (const script of allScripts) {
+ if (
+ !isElementRenderedWithReact(script) &&
+ script.textContent === content
+ ) {
+ // Server-rendered duplicate found, don't render
+ return;
+ }
+ }
+
+ // No server-rendered duplicate, we should render
+ setShouldRender(true);
+ }, [content]);
+
+ if (isServer) {
+ return (
+
+ );
+ }
+
+ if (shouldRender === false) {
+ return null;
+ }
+
+ return (
+
+ );
+};
+
+export const PageSettingsSchemaMarkup = ({
+ pageMeta,
+}: {
+ pageMeta: PageMeta;
+}) => {
+ return (
+ <>
+ {pageMeta.schemaMarkup?.map((script, index) => (
+
+ ))}
+ >
+ );
+};
diff --git a/packages/react-sdk/src/runtime.ts b/packages/react-sdk/src/runtime.ts
index 2217cb8f1752..2052f6e8d39a 100644
--- a/packages/react-sdk/src/runtime.ts
+++ b/packages/react-sdk/src/runtime.ts
@@ -4,6 +4,7 @@ export * from "./variable-state";
export { PageSettingsMeta } from "./page-settings-meta";
export { PageSettingsTitle } from "./page-settings-title";
export { PageSettingsCanonicalLink } from "./page-settings-canonical-link";
+export { PageSettingsSchemaMarkup } from "./page-settings-schema-markup";
/**
* React has issues rendering certain elements, such as errors when a element has children.
diff --git a/packages/sdk/src/page-meta-generator.test.ts b/packages/sdk/src/page-meta-generator.test.ts
index fa27a1b69cad..508cc9afb1e2 100644
--- a/packages/sdk/src/page-meta-generator.test.ts
+++ b/packages/sdk/src/page-meta-generator.test.ts
@@ -22,28 +22,30 @@ test("generate minimal static page meta factory", () => {
assets: new Map(),
})
).toMatchInlineSnapshot(`
-"export const getPageMeta = ({
- system,
- resources,
-}: {
- system: System;
- resources: Record;
-}): PageMeta => {
- return {
- title: "Page title",
- description: undefined,
- excludePageFromSearch: undefined,
- language: undefined,
- socialImageAssetName: undefined,
- socialImageUrl: undefined,
- status: undefined,
- redirect: undefined,
- custom: [
- ],
- };
-};
-"
-`);
+ "export const getPageMeta = ({
+ system,
+ resources,
+ }: {
+ system: System;
+ resources: Record;
+ }): PageMeta => {
+ return {
+ title: "Page title",
+ description: undefined,
+ excludePageFromSearch: undefined,
+ language: undefined,
+ socialImageAssetName: undefined,
+ socialImageUrl: undefined,
+ status: undefined,
+ redirect: undefined,
+ custom: [
+ ],
+ schemaMarkup: [
+ ],
+ };
+ };
+ "
+ `);
});
test("generate complete static page meta factory", () => {
@@ -88,36 +90,38 @@ test("generate complete static page meta factory", () => {
]),
})
).toMatchInlineSnapshot(`
-"export const getPageMeta = ({
- system,
- resources,
-}: {
- system: System;
- resources: Record;
-}): PageMeta => {
- return {
- title: "Page title",
- description: "Page description",
- excludePageFromSearch: true,
- language: "en-US",
- socialImageAssetName: "social-image-name",
- socialImageUrl: undefined,
- status: 302,
- redirect: "/new-path",
- custom: [
- {
- property: "custom-property-1",
- content: "custom content 1",
- },
- {
- property: "custom-property-2",
- content: "custom content 2",
- },
- ],
- };
-};
-"
-`);
+ "export const getPageMeta = ({
+ system,
+ resources,
+ }: {
+ system: System;
+ resources: Record;
+ }): PageMeta => {
+ return {
+ title: "Page title",
+ description: "Page description",
+ excludePageFromSearch: true,
+ language: "en-US",
+ socialImageAssetName: "social-image-name",
+ socialImageUrl: undefined,
+ status: 302,
+ redirect: "/new-path",
+ custom: [
+ {
+ property: "custom-property-1",
+ content: "custom content 1",
+ },
+ {
+ property: "custom-property-2",
+ content: "custom content 2",
+ },
+ ],
+ schemaMarkup: [
+ ],
+ };
+ };
+ "
+ `);
});
test("generate asset url instead of id", () => {
@@ -138,28 +142,30 @@ test("generate asset url instead of id", () => {
assets: new Map(),
})
).toMatchInlineSnapshot(`
-"export const getPageMeta = ({
- system,
- resources,
-}: {
- system: System;
- resources: Record;
-}): PageMeta => {
- return {
- title: "Page title",
- description: undefined,
- excludePageFromSearch: undefined,
- language: undefined,
- socialImageAssetName: undefined,
- socialImageUrl: "https://my-image",
- status: undefined,
- redirect: undefined,
- custom: [
- ],
- };
-};
-"
-`);
+ "export const getPageMeta = ({
+ system,
+ resources,
+ }: {
+ system: System;
+ resources: Record;
+ }): PageMeta => {
+ return {
+ title: "Page title",
+ description: undefined,
+ excludePageFromSearch: undefined,
+ language: undefined,
+ socialImageAssetName: undefined,
+ socialImageUrl: "https://my-image",
+ status: undefined,
+ redirect: undefined,
+ custom: [
+ ],
+ schemaMarkup: [
+ ],
+ };
+ };
+ "
+ `);
});
test("generate custom meta ignoring empty property name", () => {
@@ -183,32 +189,34 @@ test("generate custom meta ignoring empty property name", () => {
assets: new Map(),
})
).toMatchInlineSnapshot(`
-"export const getPageMeta = ({
- system,
- resources,
-}: {
- system: System;
- resources: Record;
-}): PageMeta => {
- return {
- title: "Page title",
- description: undefined,
- excludePageFromSearch: undefined,
- language: undefined,
- socialImageAssetName: undefined,
- socialImageUrl: undefined,
- status: undefined,
- redirect: undefined,
- custom: [
- {
- property: "custom-property",
- content: "custom content 1",
- },
- ],
- };
-};
-"
-`);
+ "export const getPageMeta = ({
+ system,
+ resources,
+ }: {
+ system: System;
+ resources: Record;
+ }): PageMeta => {
+ return {
+ title: "Page title",
+ description: undefined,
+ excludePageFromSearch: undefined,
+ language: undefined,
+ socialImageAssetName: undefined,
+ socialImageUrl: undefined,
+ status: undefined,
+ redirect: undefined,
+ custom: [
+ {
+ property: "custom-property",
+ content: "custom content 1",
+ },
+ ],
+ schemaMarkup: [
+ ],
+ };
+ };
+ "
+ `);
});
test("generate page meta factory with variables", () => {
@@ -235,29 +243,31 @@ test("generate page meta factory with variables", () => {
assets: new Map(),
})
).toMatchInlineSnapshot(`
-"export const getPageMeta = ({
- system,
- resources,
-}: {
- system: System;
- resources: Record;
-}): PageMeta => {
- let VariableName = ""
- return {
- title: VariableName,
- description: undefined,
- excludePageFromSearch: undefined,
- language: undefined,
- socialImageAssetName: undefined,
- socialImageUrl: undefined,
- status: undefined,
- redirect: undefined,
- custom: [
- ],
- };
-};
-"
-`);
+ "export const getPageMeta = ({
+ system,
+ resources,
+ }: {
+ system: System;
+ resources: Record;
+ }): PageMeta => {
+ let VariableName = ""
+ return {
+ title: VariableName,
+ description: undefined,
+ excludePageFromSearch: undefined,
+ language: undefined,
+ socialImageAssetName: undefined,
+ socialImageUrl: undefined,
+ status: undefined,
+ redirect: undefined,
+ custom: [
+ ],
+ schemaMarkup: [
+ ],
+ };
+ };
+ "
+ `);
});
test("generate page meta factory with page system variable", () => {
@@ -284,29 +294,31 @@ test("generate page meta factory with page system variable", () => {
assets: new Map(),
})
).toMatchInlineSnapshot(`
-"export const getPageMeta = ({
- system,
- resources,
-}: {
- system: System;
- resources: Record;
-}): PageMeta => {
- let system_1 = system
- return {
- title: system_1?.params?.slug,
- description: undefined,
- excludePageFromSearch: undefined,
- language: undefined,
- socialImageAssetName: undefined,
- socialImageUrl: undefined,
- status: undefined,
- redirect: undefined,
- custom: [
- ],
- };
-};
-"
-`);
+ "export const getPageMeta = ({
+ system,
+ resources,
+ }: {
+ system: System;
+ resources: Record;
+ }): PageMeta => {
+ let system_1 = system
+ return {
+ title: system_1?.params?.slug,
+ description: undefined,
+ excludePageFromSearch: undefined,
+ language: undefined,
+ socialImageAssetName: undefined,
+ socialImageUrl: undefined,
+ status: undefined,
+ redirect: undefined,
+ custom: [
+ ],
+ schemaMarkup: [
+ ],
+ };
+ };
+ "
+ `);
});
test("generate page meta factory with global system variable", () => {
@@ -325,29 +337,31 @@ test("generate page meta factory with global system variable", () => {
assets: new Map(),
})
).toMatchInlineSnapshot(`
-"export const getPageMeta = ({
- system,
- resources,
-}: {
- system: System;
- resources: Record;
-}): PageMeta => {
- let system_1 = system
- return {
- title: system_1?.params?.slug,
- description: undefined,
- excludePageFromSearch: undefined,
- language: undefined,
- socialImageAssetName: undefined,
- socialImageUrl: undefined,
- status: undefined,
- redirect: undefined,
- custom: [
- ],
- };
-};
-"
-`);
+ "export const getPageMeta = ({
+ system,
+ resources,
+ }: {
+ system: System;
+ resources: Record;
+ }): PageMeta => {
+ let system_1 = system
+ return {
+ title: system_1?.params?.slug,
+ description: undefined,
+ excludePageFromSearch: undefined,
+ language: undefined,
+ socialImageAssetName: undefined,
+ socialImageUrl: undefined,
+ status: undefined,
+ redirect: undefined,
+ custom: [
+ ],
+ schemaMarkup: [
+ ],
+ };
+ };
+ "
+ `);
});
test("generate page meta factory with resources", () => {
@@ -374,29 +388,94 @@ test("generate page meta factory with resources", () => {
assets: new Map(),
})
).toMatchInlineSnapshot(`
-"export const getPageMeta = ({
- system,
- resources,
-}: {
- system: System;
- resources: Record;
-}): PageMeta => {
- let CmsPage = resources.CmsPage
- return {
- title: CmsPage?.data?.title,
- description: undefined,
- excludePageFromSearch: undefined,
- language: undefined,
- socialImageAssetName: undefined,
- socialImageUrl: undefined,
- status: undefined,
- redirect: undefined,
- custom: [
- ],
- };
-};
-"
-`);
+ "export const getPageMeta = ({
+ system,
+ resources,
+ }: {
+ system: System;
+ resources: Record;
+ }): PageMeta => {
+ let CmsPage = resources.CmsPage
+ return {
+ title: CmsPage?.data?.title,
+ description: undefined,
+ excludePageFromSearch: undefined,
+ language: undefined,
+ socialImageAssetName: undefined,
+ socialImageUrl: undefined,
+ status: undefined,
+ redirect: undefined,
+ custom: [
+ ],
+ schemaMarkup: [
+ ],
+ };
+ };
+ "
+ `);
+});
+
+test("generate page meta factory with schemaMarkup", () => {
+ expect(
+ generatePageMeta({
+ globalScope: createScope(),
+ page: {
+ id: "",
+ name: "",
+ path: "",
+ rootInstanceId: "",
+ title: `"Page title"`,
+ meta: {
+ schemaMarkup: [
+ {
+ type: "application/ld+json",
+ content:
+ '{"@context":"https://schema.org","@type":"Organization","name":"Test Org"}',
+ },
+ {
+ type: "application/ld+json",
+ content:
+ '{"@context":"https://schema.org","@type":"FAQPage","mainEntity":[]}',
+ },
+ ],
+ },
+ },
+ dataSources: new Map(),
+ assets: new Map(),
+ })
+ ).toMatchInlineSnapshot(`
+ "export const getPageMeta = ({
+ system,
+ resources,
+ }: {
+ system: System;
+ resources: Record;
+ }): PageMeta => {
+ return {
+ title: "Page title",
+ description: undefined,
+ excludePageFromSearch: undefined,
+ language: undefined,
+ socialImageAssetName: undefined,
+ socialImageUrl: undefined,
+ status: undefined,
+ redirect: undefined,
+ custom: [
+ ],
+ schemaMarkup: [
+ {
+ type: "application/ld+json",
+ content: "{\\"@context\\":\\"https://schema.org\\",\\"@type\\":\\"Organization\\",\\"name\\":\\"Test Org\\"}",
+ },
+ {
+ type: "application/ld+json",
+ content: "{\\"@context\\":\\"https://schema.org\\",\\"@type\\":\\"FAQPage\\",\\"mainEntity\\":[]}",
+ },
+ ],
+ };
+ };
+ "
+ `);
});
test("generate page meta factory without unused variables", () => {
@@ -444,27 +523,29 @@ test("generate page meta factory without unused variables", () => {
assets: new Map(),
})
).toMatchInlineSnapshot(`
-"export const getPageMeta = ({
- system,
- resources,
-}: {
- system: System;
- resources: Record;
-}): PageMeta => {
- let UsedName = ""
- return {
- title: UsedName,
- description: undefined,
- excludePageFromSearch: undefined,
- language: undefined,
- socialImageAssetName: undefined,
- socialImageUrl: undefined,
- status: undefined,
- redirect: undefined,
- custom: [
- ],
- };
-};
-"
-`);
+ "export const getPageMeta = ({
+ system,
+ resources,
+ }: {
+ system: System;
+ resources: Record;
+ }): PageMeta => {
+ let UsedName = ""
+ return {
+ title: UsedName,
+ description: undefined,
+ excludePageFromSearch: undefined,
+ language: undefined,
+ socialImageAssetName: undefined,
+ socialImageUrl: undefined,
+ status: undefined,
+ redirect: undefined,
+ custom: [
+ ],
+ schemaMarkup: [
+ ],
+ };
+ };
+ "
+ `);
});
diff --git a/packages/sdk/src/page-meta-generator.ts b/packages/sdk/src/page-meta-generator.ts
index 8f3f81dc4578..14c23b11dd94 100644
--- a/packages/sdk/src/page-meta-generator.ts
+++ b/packages/sdk/src/page-meta-generator.ts
@@ -14,6 +14,7 @@ export type PageMeta = {
status?: number;
redirect?: string;
custom: Array<{ property: string; content: string }>;
+ schemaMarkup?: Array<{ type: "application/ld+json"; content: string }>;
};
export const generatePageMeta = ({
@@ -96,6 +97,20 @@ export const generatePageMeta = ({
customExpression += ` },\n`;
}
customExpression += ` ]`;
+
+ // Generate schemaMarkup expression
+ let schemaMarkupExpression = "";
+ schemaMarkupExpression += `[\n`;
+ for (const schema of page.meta.schemaMarkup ?? []) {
+ const typeExpression = JSON.stringify(schema.type);
+ const contentExpression = JSON.stringify(schema.content);
+ schemaMarkupExpression += ` {\n`;
+ schemaMarkupExpression += ` type: ${typeExpression},\n`;
+ schemaMarkupExpression += ` content: ${contentExpression},\n`;
+ schemaMarkupExpression += ` },\n`;
+ }
+ schemaMarkupExpression += ` ]`;
+
let generated = "";
generated += `export const getPageMeta = ({\n`;
generated += ` system,\n`;
@@ -142,6 +157,7 @@ export const generatePageMeta = ({
generated += ` status: ${statusExpression},\n`;
generated += ` redirect: ${redirectExpression},\n`;
generated += ` custom: ${customExpression},\n`;
+ generated += ` schemaMarkup: ${schemaMarkupExpression},\n`;
generated += ` };\n`;
generated += `};\n`;
return generated;
diff --git a/packages/sdk/src/schema/pages.ts b/packages/sdk/src/schema/pages.ts
index 521ed21cf22c..efe5876ad861 100644
--- a/packages/sdk/src/schema/pages.ts
+++ b/packages/sdk/src/schema/pages.ts
@@ -70,6 +70,14 @@ const commonPageFields = {
})
)
.optional(),
+ schemaMarkup: z
+ .array(
+ z.object({
+ type: z.enum(["application/ld+json"]),
+ content: z.string(),
+ })
+ )
+ .optional(),
}),
marketplace: z.optional(
z.object({
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 138f96251ba9..07bb82b9caf8 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -127,6 +127,9 @@ importers:
'@codemirror/lang-javascript':
specifier: ^6.2.3
version: 6.2.3
+ '@codemirror/lang-json':
+ specifier: ^6.0.1
+ version: 6.0.2
'@codemirror/lang-markdown':
specifier: ^6.3.2
version: 6.3.2
@@ -2603,6 +2606,9 @@ packages:
'@codemirror/lang-javascript@6.2.3':
resolution: {integrity: sha512-8PR3vIWg7pSu7ur8A07pGiYHgy3hHj+mRYRCSG8q+mPIrl0F02rgpGv+DsQTHRTc30rydOsf5PZ7yjKFg2Ackw==}
+ '@codemirror/lang-json@6.0.2':
+ resolution: {integrity: sha512-x2OtO+AvwEHrEwR0FyyPtfDUiloG3rnVTSZV1W8UteaLL8/MajQd8DpvUb2YVzC+/T18aSDv0H9mu+xw0EStoQ==}
+
'@codemirror/lang-markdown@6.3.2':
resolution: {integrity: sha512-c/5MYinGbFxYl4itE9q/rgN/sMTjOr8XL5OWnC+EaRMLfCbVUmmubTJfdgpfcSS2SCaT7b+Q+xi3l6CgoE+BsA==}
@@ -3854,6 +3860,9 @@ packages:
'@lezer/javascript@1.4.19':
resolution: {integrity: sha512-j44kbR1QL26l6dMunZ1uhKBFteVGLVCBGNUD2sUaMnic+rbTviVuoK0CD1l9FTW31EueWvFFswCKMH7Z+M3JRA==}
+ '@lezer/json@1.0.3':
+ resolution: {integrity: sha512-BP9KzdF9Y35PDpv04r0VeSTKDeox5vVr3efE7eBbx3r4s3oNLfunchejZhjArmeieBH+nVOpgIiBJpEAv8ilqQ==}
+
'@lezer/lr@1.4.2':
resolution: {integrity: sha512-pu0K1jCIdnQ12aWNaAVU5bzi7Bd1w54J3ECgANPmYLtQKP0HBj2cE/5coBD66MT10xbtIuUr7tg0Shbsvk0mDA==}
@@ -10022,6 +10031,11 @@ snapshots:
'@lezer/common': 1.2.3
'@lezer/javascript': 1.4.19
+ '@codemirror/lang-json@6.0.2':
+ dependencies:
+ '@codemirror/language': 6.11.0
+ '@lezer/json': 1.0.3
+
'@codemirror/lang-markdown@6.3.2':
dependencies:
'@codemirror/autocomplete': 6.18.6
@@ -10934,6 +10948,12 @@ snapshots:
'@lezer/highlight': 1.2.1
'@lezer/lr': 1.4.2
+ '@lezer/json@1.0.3':
+ dependencies:
+ '@lezer/common': 1.2.3
+ '@lezer/highlight': 1.2.1
+ '@lezer/lr': 1.4.2
+
'@lezer/lr@1.4.2':
dependencies:
'@lezer/common': 1.2.3