From dc707e6559c93fd7d401bcb202f5251d850d3ad9 Mon Sep 17 00:00:00 2001 From: shreyanshkotak Date: Wed, 6 May 2026 22:26:08 -0400 Subject: [PATCH] feat: add plain text page support Add text as a supported document type with page content metadata, Builder page settings UI, and generated text/plain routes for Remix and React Router templates. Also restrict HTML mutation commands on non-HTML pages, exclude text/XML pages from static sitemap output, and update CLI prebuild/link handling for local generated projects. --- .../command-panel/groups/components-group.tsx | 7 +- .../command-panel/groups/convert-group.tsx | 9 +- .../command-panel/groups/tags-group.tsx | 9 +- .../command-panel/groups/wrap-group.tsx | 9 +- .../command-panel/shared/document-utils.ts | 10 + .../features/components/components.tsx | 3 +- .../builder/features/pages/page-settings.tsx | 603 ++++++++++-------- .../app/builder/sidebar-left/sidebar-left.tsx | 31 +- .../app/builder/sidebar-left/sidebar-tabs.tsx | 4 + packages/cli/src/commands/link.ts | 11 +- packages/cli/src/framework-react-router.ts | 10 + packages/cli/src/framework-remix.ts | 10 + packages/cli/src/framework-vike-ssg.ts | 1 + packages/cli/src/framework.ts | 1 + packages/cli/src/prebuild.ts | 17 +- .../defaults/app/route-templates/text.tsx | 80 +++ .../react-router/app/route-templates/text.tsx | 80 +++ packages/sdk/src/page-meta-generator.ts | 8 + packages/sdk/src/page-utils.test.ts | 38 ++ packages/sdk/src/schema/component-meta.ts | 1 + packages/sdk/src/schema/pages.test.ts | 29 +- packages/sdk/src/schema/pages.ts | 3 +- packages/template/src/template.ts | 1 + 23 files changed, 676 insertions(+), 299 deletions(-) create mode 100644 apps/builder/app/builder/features/command-panel/shared/document-utils.ts create mode 100644 packages/cli/templates/defaults/app/route-templates/text.tsx create mode 100644 packages/cli/templates/react-router/app/route-templates/text.tsx diff --git a/apps/builder/app/builder/features/command-panel/groups/components-group.tsx b/apps/builder/app/builder/features/command-panel/groups/components-group.tsx index 4a4a007f7c0b..1a1e16b554f3 100644 --- a/apps/builder/app/builder/features/command-panel/groups/components-group.tsx +++ b/apps/builder/app/builder/features/command-panel/groups/components-group.tsx @@ -69,8 +69,13 @@ export const $componentOptions = computed( order?: number; firstInstance: { component: string }; }) => { + const documentType = selectedPage?.meta.documentType; + // text pages serve plain text content and accept no insertions + if (documentType === "text") { + return; + } // show only xml category and collection component in xml documents - if (selectedPage?.meta.documentType === "xml") { + if (documentType === "xml") { if (category !== "xml" && name !== collectionComponent) { return; } diff --git a/apps/builder/app/builder/features/command-panel/groups/convert-group.tsx b/apps/builder/app/builder/features/command-panel/groups/convert-group.tsx index 3e7480b0c686..3d5f74bc1e5f 100644 --- a/apps/builder/app/builder/features/command-panel/groups/convert-group.tsx +++ b/apps/builder/app/builder/features/command-panel/groups/convert-group.tsx @@ -16,7 +16,7 @@ import { elementComponent, tags } from "@webstudio-is/sdk"; import { $registeredComponentMetas } from "~/shared/nano-states"; import { $instances } from "~/shared/sync/data-stores"; import { $props } from "~/shared/sync/data-stores"; -import { $selectedInstancePath } from "~/shared/nano-states"; +import { $selectedInstancePath, $selectedPage } from "~/shared/nano-states"; import { getInstanceLabel, InstanceIcon, @@ -28,6 +28,7 @@ import { closeCommandPanel, openCommandPanel, } from "../command-state"; +import { allowsHtmlMutations } from "../shared/document-utils"; import { useState } from "react"; import { convertInstance } from "~/shared/instance-utils"; @@ -46,8 +47,9 @@ const $convertOptions = computed( $instances, $props, $registeredComponentMetas, + $selectedPage, ], - (isOpen, instancePath, instances, props, metas) => { + (isOpen, instancePath, instances, props, metas, selectedPage) => { const convertOptions: ConvertOption[] = []; if (!isOpen) { return convertOptions; @@ -55,6 +57,9 @@ const $convertOptions = computed( if (instancePath === undefined || instancePath.length === 1) { return convertOptions; } + if (!allowsHtmlMutations(selectedPage)) { + return convertOptions; + } const [selectedItem] = instancePath; // Test all registered components diff --git a/apps/builder/app/builder/features/command-panel/groups/tags-group.tsx b/apps/builder/app/builder/features/command-panel/groups/tags-group.tsx index f6aad5936cd2..b5350b4a753e 100644 --- a/apps/builder/app/builder/features/command-panel/groups/tags-group.tsx +++ b/apps/builder/app/builder/features/command-panel/groups/tags-group.tsx @@ -13,11 +13,12 @@ import { $registeredComponentMetas } from "~/shared/nano-states"; import { $instances } from "~/shared/sync/data-stores"; import { $props } from "~/shared/sync/data-stores"; import { insertWebstudioFragmentAt } from "~/shared/instance-utils"; -import { $selectedInstancePath } from "~/shared/nano-states"; +import { $selectedInstancePath, $selectedPage } from "~/shared/nano-states"; import { InstanceIcon } from "~/builder/shared/instance-label"; import { isTreeSatisfyingContentModel } from "~/shared/content-model"; import { closeCommandPanel, $isCommandPanelOpen } from "../command-state"; import type { BaseOption } from "../shared/types"; +import { allowsHtmlMutations } from "../shared/document-utils"; export type TagOption = BaseOption & { type: "tag"; @@ -31,8 +32,9 @@ export const $tagOptions = computed( $instances, $props, $registeredComponentMetas, + $selectedPage, ], - (isOpen, instancePath, instances, props, metas) => { + (isOpen, instancePath, instances, props, metas, selectedPage) => { const tagOptions: TagOption[] = []; if (!isOpen) { return tagOptions; @@ -40,6 +42,9 @@ export const $tagOptions = computed( if (instancePath === undefined) { return tagOptions; } + if (!allowsHtmlMutations(selectedPage)) { + return tagOptions; + } const [{ instance, instanceSelector }] = instancePath; const childInstance: Instance = { type: "instance", diff --git a/apps/builder/app/builder/features/command-panel/groups/wrap-group.tsx b/apps/builder/app/builder/features/command-panel/groups/wrap-group.tsx index ff3ff139511e..d846492c7d25 100644 --- a/apps/builder/app/builder/features/command-panel/groups/wrap-group.tsx +++ b/apps/builder/app/builder/features/command-panel/groups/wrap-group.tsx @@ -22,7 +22,7 @@ import type { import { $registeredComponentMetas } from "~/shared/nano-states"; import { $instances } from "~/shared/sync/data-stores"; import { $props } from "~/shared/sync/data-stores"; -import { $selectedInstancePath } from "~/shared/nano-states"; +import { $selectedInstancePath, $selectedPage } from "~/shared/nano-states"; import { getInstanceLabel, InstanceIcon, @@ -36,6 +36,7 @@ import { } from "../command-state"; import { useState } from "react"; import { wrapInstance } from "~/shared/instance-utils"; +import { allowsHtmlMutations } from "../shared/document-utils"; type WrapOption = { component: string; @@ -146,8 +147,9 @@ const $wrapOptions = computed( $instances, $props, $registeredComponentMetas, + $selectedPage, ], - (isOpen, instancePath, instances, props, metas) => { + (isOpen, instancePath, instances, props, metas, selectedPage) => { const wrapOptions: WrapOption[] = []; if (!isOpen) { return wrapOptions; @@ -155,6 +157,9 @@ const $wrapOptions = computed( if (instancePath === undefined || instancePath.length === 1) { return wrapOptions; } + if (!allowsHtmlMutations(selectedPage)) { + return wrapOptions; + } const [selectedItem, parentItem] = instancePath; // Build list of allowed wrappers from registered metas diff --git a/apps/builder/app/builder/features/command-panel/shared/document-utils.ts b/apps/builder/app/builder/features/command-panel/shared/document-utils.ts new file mode 100644 index 000000000000..a07625428e08 --- /dev/null +++ b/apps/builder/app/builder/features/command-panel/shared/document-utils.ts @@ -0,0 +1,10 @@ +import type { Page } from "@webstudio-is/sdk"; + +// Only html documents accept arbitrary html element/component mutations +// (insertion, wrapping, conversion). xml documents allow only specific xml +// components (handled per-item where applicable) and text documents serve +// plain text content with no insertions. +export const allowsHtmlMutations = (page: Page | undefined) => { + const documentType = page?.meta.documentType; + return documentType === undefined || documentType === "html"; +}; diff --git a/apps/builder/app/builder/features/components/components.tsx b/apps/builder/app/builder/features/components/components.tsx index 6696c5032a14..3d949e545eaf 100644 --- a/apps/builder/app/builder/features/components/components.tsx +++ b/apps/builder/app/builder/features/components/components.tsx @@ -7,6 +7,7 @@ import { type WsComponentMeta, componentCategories, collectionComponent, + documentTypes, elementComponent, } from "@webstudio-is/sdk"; import { @@ -110,7 +111,7 @@ const filterAndGroupComponents = ({ metasByCategory, search, }: { - documentType?: "html" | "xml"; + documentType?: (typeof documentTypes)[number]; metasByCategory: Map>; search: string; }): Groups => { diff --git a/apps/builder/app/builder/features/pages/page-settings.tsx b/apps/builder/app/builder/features/pages/page-settings.tsx index b7b4cfcea752..37a9fed4d7f5 100644 --- a/apps/builder/app/builder/features/pages/page-settings.tsx +++ b/apps/builder/app/builder/features/pages/page-settings.tsx @@ -50,7 +50,6 @@ import { Link, buttonStyle, PanelBanner, - css, Switch, TitleSuffixSpacer, ProBadge, @@ -126,6 +125,7 @@ const fieldDefaultValues = { status: undefined as string | undefined, redirect: `""`, documentType: "html" as (typeof documentTypes)[number], + content: `""`, customMetas: [{ property: "", content: `""` }], marketplaceInclude: false, marketplaceCategory: "", @@ -181,6 +181,7 @@ const SharedPageValues = z.object({ status: Status.optional(), redirect: z.optional(ProjectNewRedirectPath.or(EmptyString)), documentType: z.optional(z.enum(documentTypes)), + content: z.string().optional(), customMetas: z .array( z.object({ @@ -225,6 +226,7 @@ const validateValues = ( socialImageUrl: computeExpression(values.socialImageUrl, variableValues), status: computeExpression(values.status ?? `undefined`, variableValues), redirect: computeExpression(values.redirect, variableValues), + content: computeExpression(values.content, variableValues), customMetas: values.customMetas.map((item) => ({ property: item.property, content: computeExpression(item.content, variableValues), @@ -299,6 +301,7 @@ const toFormValues = ( status: page.meta.status ?? fieldDefaultValues.status, redirect: page.meta.redirect ?? fieldDefaultValues.redirect, documentType: page.meta.documentType ?? fieldDefaultValues.documentType, + content: page.meta.content ?? fieldDefaultValues.content, isHomePage, customMetas: page.meta.custom ?? fieldDefaultValues.customMetas, marketplaceInclude: page.marketplace?.include ?? false, @@ -573,14 +576,6 @@ const usePageUrl = (values: Values) => { return `${publishedOrigin}${compiledPath}`; }; -const fieldsetStyle = css({ - all: "unset", - display: "block", - "&:disabled": { - opacity: 0.4, - }, -}); - const MarketplaceSection = ({ values, onChange, @@ -793,7 +788,7 @@ const FormFields = ({ page - ) : values.documentType === "xml" ? ( + ) : values.documentType !== "html" ? ( <> - XML pages cannot be set as the home page + {values.documentType.toUpperCase()} pages cannot be set as + the home page ) : ( @@ -877,7 +873,7 @@ const FormFields = ({ options={documentTypes} getValue={(docType: (typeof documentTypes)[number]) => docType} getLabel={(docType: (typeof documentTypes)[number]) => - docType.toLocaleUpperCase() + docType.toUpperCase() } value={values.documentType} disabled={values.isHomePage} @@ -891,325 +887,376 @@ const FormFields = ({ - - - {/** - * ----------------------========<<>>>========---------------------- - */} -
- - - + {values.documentType === "text" && ( + <> + + + - Optimize the way this page appears in search engine results - pages. + The plain text content served for this page. - - - Text + + { + onChange({ field: "content", value }); }} - > - - - - - - - - - - { - onChange({ - field: "title", - value, - }); - }} - onRemove={(evaluatedValue) => { - onChange({ - field: "title", - value: JSON.stringify(evaluatedValue ?? ""), - }); - }} - /> - - { + onRemove={(evaluatedValue) => { onChange({ - field: "title", - value: JSON.stringify(event.target.value), + field: "content", + value: JSON.stringify(evaluatedValue ?? ""), }); }} /> - - - - - - - - { - onChange({ - field: "description", - value, - }); - }} - onRemove={(evaluatedValue) => { - onChange({ - field: "description", - value: JSON.stringify(evaluatedValue ?? ""), - }); - }} - /> -