fix: export text pages in static builds (#6513)

Fixes #6440

## Description

Static exports currently omit Text pages such as `/robots.txt`,
`/ads.txt`, and `/.well-known/security.txt`. This generates their
routes, evaluates content through the existing page-data loader, and
uses Vike's native `pageContext.content` to write the exact text at the
configured file path.

Internal route directories are separate from public URLs so dot-prefixed
paths work. No generated writer script, text-page manifest, HTML
wrapper, or post-build file rewrite is needed.

## Dependencies

- Pin Vike to `0.4.267-commit-feffb0f`: native non-HTML output is
published in this commit prerelease but is not yet in stable `0.4.267`.
- Use Vite `7.1.12` and the compatible React plugin for SSG. Build
through `vike build && vike prerender` so Vike uses its matching Vite
dependency.
- Update React Router's `vite-node` dependency to `3.2.4` for
compatibility with the CLI's Vite 7 test environment.

## Validation

- All 1,173 CLI tests pass locally across 72 files, including all 79
prebuild tests for normal HTML, dynamic SSG routes, and React Router
builds.
- Text export coverage checks exact output for `/robots.txt`,
resource-backed `/.well-known/security.txt`, empty text, Unicode, and
HTML-like content.
- CLI and SSG fixture type checks, lint, and formatting pass locally.
- SSG fixtures regenerated; the packaged SSG test passes locally.
- All executed GitHub CI checks pass at
`201fa971616bb607ca35ee24a970f107eddee754`, including all six browser
shards. Shard 4 passed on retry after an MDX template rename timeout.
Visual regression is skipped and preview deployment remains gated.
- Corrected the existing animation navigation test for fork CI: wait for
the rendered component with the public fallback and retain the animation
initialization assertion when the private module is present.

## Manual verification

1. Export a static project with Text pages at `/robots.txt` and
`/.well-known/security.txt`.
2. Run the generated project's build.
3. Confirm both files under `dist/client` contain exactly the configured
text, including resource expressions.
4. Confirm normal pages still render HTML.

- [ ] Original author tested the updated export and confirmed it works.
- [ ] Preview tested (deployment approval remains gated).

---------

Co-authored-by: Sviat Barbutsa <9897782+sviat-barbutsa@users.noreply.github.com>
Co-authored-by: Oleg Isonen <oleg008@gmail.com>
This commit is contained in:
authored and GitHub committed 2026-10-12 11:41:04 +02:00
1 parent 3c7dac7eb0
commit ff4a081f80
14 files changed
+1162 -262

No files matched your search

@@ -229,7 +229,7 @@ test.beforeAll(async ({ browser }, workerInfo) => {
});
});
test("Canvas switches away from text animation without reloading", async ({
test("Canvas switches away from an AnimateText page without reloading", async ({
browser,
page,
}, workerInfo) => {
@@ -303,7 +303,12 @@ test("Canvas switches away from text animation without reloading", async ({
if (canvas === undefined) {
throw new Error("Expected canvas frame");
}
await canvas.locator("[data-ws-text-animate]").first().waitFor();
await canvas
.locator(
'[data-ws-component="@webstudio-is/sdk-components-animation:AnimateText"]'
)
.getByText("Animated page text")
.waitFor();
await openPage({
page,
pageName: "Home",
+4 -4
View File
@@ -3,7 +3,7 @@
"version": "1.0.0",
"description": "",
"scripts": {
"build": "vite build && vike prerender",
"build": "vike build && vike prerender",
"build:debug": "pnpm build && cd dist && oxfmt '**/*.html'",
"dev": "vite dev",
"typecheck": "tsc --noEmit",
@@ -26,9 +26,9 @@
"devDependencies": {
"@types/react": "^18.2.70",
"@types/react-dom": "^18.2.25",
"@vitejs/plugin-react": "^4.4.1",
"@vitejs/plugin-react": "^4.7.0",
"typescript": "7.0.2",
"vite": "^6.3.4",
"vite": "7.1.12",
"webstudio": "workspace:*"
},
"dependencies": {
@@ -41,7 +41,7 @@
"@webstudio-is/sdk-components-react-remix": "workspace:*",
"react": "18.3.0-canary-14898b6a9-20240318",
"react-dom": "18.3.0-canary-14898b6a9-20240318",
"vike": "^0.4.229",
"vike": "0.4.267-commit-feffb0f",
"@webstudio-is/wsauth": "workspace:*",
"@shikijs/langs": "4.4.1",
"@shikijs/themes": "4.4.1",
@@ -3,7 +3,7 @@
"version": "1.0.0",
"description": "",
"scripts": {
"build": "vite build && vike prerender",
"build": "vike build && vike prerender",
"build:debug": "pnpm build && cd dist && oxfmt '**/*.html'",
"dev": "vite dev",
"typecheck": "tsc --noEmit",
@@ -26,9 +26,9 @@
"devDependencies": {
"@types/react": "^18.2.70",
"@types/react-dom": "^18.2.25",
"@vitejs/plugin-react": "^4.4.1",
"@vitejs/plugin-react": "^4.7.0",
"typescript": "7.0.2",
"vite": "^6.3.4",
"vite": "7.1.12",
"webstudio": "workspace:*"
},
"dependencies": {
@@ -41,7 +41,7 @@
"@webstudio-is/sdk-components-react-remix": "workspace:*",
"react": "18.3.0-canary-14898b6a9-20240318",
"react-dom": "18.3.0-canary-14898b6a9-20240318",
"vike": "^0.4.229",
"vike": "0.4.267-commit-feffb0f",
"@webstudio-is/wsauth": "workspace:*",
"@shikijs/langs": "4.4.1",
"@shikijs/themes": "4.4.1",
+2 -1
View File
@@ -97,7 +97,8 @@
"react-dom": "18.3.0-canary-14898b6a9-20240318",
"playwright": "1.50.1",
"@playwright/test": "1.50.1",
"vite@<7": "6.3.4"
"vite@<7": "6.3.4",
"@react-router/dev>vite-node": "3.2.4"
},
"patchedDependencies": {
"@stitches/react@1.3.1-1": "patches/@stitches__react@1.3.1-1.patch",
+3 -3
View File
@@ -95,7 +95,7 @@
"@types/which": "^3.0.4",
"@types/yargs": "^17.0.33",
"@vercel/react-router": "^1.1.0",
"@vitejs/plugin-react": "^4.4.1",
"@vitejs/plugin-react": "^4.7.0",
"@webstudio-is/css-engine": "workspace:*",
"@webstudio-is/content-engine": "workspace:*",
"@webstudio-is/expression": "workspace:*",
@@ -128,8 +128,8 @@
"shiki": "4.4.1",
"ts-expect": "^1.3.0",
"typescript": "7.0.2",
"vike": "^0.4.229",
"vite": "^6.3.4",
"vike": "0.4.267-commit-feffb0f",
"vite": "7.1.12",
"vitest": "^3.1.2",
"wrangler": "4.130.0"
}
+59 -38
View File
@@ -1,5 +1,6 @@
import { join } from "node:path";
import { readFile } from "node:fs/promises";
import { join, relative } from "node:path";
import { readFile, readdir } from "node:fs/promises";
import { createHash } from "node:crypto";
import { isPathnamePattern, matchPathnameParams } from "@webstudio-is/sdk";
import {
baseComponentImportSource,
@@ -28,23 +29,65 @@ const generateVikeRoute = (pagePath: string) => {
return route;
};
const generateVikeTextRoute = (pagePath: string) =>
`text-${createHash("sha256").update(pagePath).digest("hex")}`;
const readTemplates = async (directory: string) => {
const entries = await readdir(directory, {
recursive: true,
withFileTypes: true,
});
return Object.fromEntries(
await Promise.all(
entries
.filter((entry) => entry.isFile())
.map(async (entry) => {
const file = join(entry.parentPath, entry.name);
// Raw code placeholders use .template to stay out of typechecking.
return [
relative(directory, file).replace(/\.template$/, ""),
await readFile(file, "utf8"),
] as const;
})
)
);
};
export const createFramework = async (
options: FrameworkOptions = {}
): Promise<Framework> => {
const templatesDirectory = getFrameworkTemplatesDirectory(options);
const htmlPageTemplate = await readFile(
join(templatesDirectory, "html", "+Page.tsx"),
"utf8"
);
const htmlHeadTemplate = await readFile(
join(templatesDirectory, "html", "+Head.tsx"),
"utf8"
);
const htmlDataTemplate = await readFile(
join(templatesDirectory, "html", "+data.ts"),
"utf8"
const [sharedTemplates, htmlTemplates, textTemplates, dynamicTemplates] =
await Promise.all(
["shared", "html", "text", "dynamic"].map((directory) =>
readTemplates(join(templatesDirectory, directory))
)
);
const getRouteTemplates = (
type: "html" | "text",
pagePath: string,
prerenderPaths: readonly string[] = []
) => {
const route =
type === "html"
? generateVikeRoute(pagePath)
: generateVikeTextRoute(pagePath);
const templates = {
...sharedTemplates,
...(type === "html" ? htmlTemplates : textTemplates),
...(isPathnamePattern(pagePath) ? dynamicTemplates : {}),
};
return Object.entries(templates).map(([file, template]) => ({
file: join("pages", route, file),
template: template
.replaceAll("__PAGE_PATH__", () => JSON.stringify(pagePath))
.replaceAll("__PRERENDER_PATHS__", () =>
JSON.stringify(prerenderPaths)
),
}));
};
// cleanup route templates after reading to not bloat generated code
await cleanupFrameworkTemplates(options);
@@ -68,33 +111,11 @@ export const createFramework = async (
if (dynamic && prerenderPaths.length === 0) {
return [];
}
const route = generateVikeRoute(pagePath);
const entries = [
{
file: join("pages", route, "+Page.tsx"),
template: htmlPageTemplate,
},
{
file: join("pages", route, "+Head.tsx"),
template: htmlHeadTemplate,
},
{
file: join("pages", route, "+data.ts"),
template: htmlDataTemplate,
},
];
if (dynamic) {
entries.push({
file: join("pages", route, "+onBeforePrerenderStart.ts"),
template: `export const onBeforePrerenderStart = () => ${JSON.stringify(
prerenderPaths
)};\n`,
});
}
return entries;
return getRouteTemplates("html", pagePath, prerenderPaths);
},
xml: () => [],
text: () => [],
text: ({ pagePath }) =>
isPathnamePattern(pagePath) ? [] : getRouteTemplates("text", pagePath),
defaultSitemap: () => [],
};
};
+124 -34
View File
@@ -24,7 +24,7 @@ import {
import { build } from "esbuild";
import { loadConfigFromFile } from "vite";
import { bundleVersion } from "@webstudio-is/protocol";
import type { Asset, Instance, Prop } from "@webstudio-is/sdk";
import type { Asset, DataSource, Instance, Prop } from "@webstudio-is/sdk";
import {
createDocumentGraph,
type AssetFileDocument,
@@ -94,6 +94,11 @@ const runGeneratedCommand = async (
});
};
const prerenderSsg = async () => {
await runGeneratedCommand("vike", ["build"]);
await runGeneratedCommand("vike", ["prerender"]);
};
const linkPackagedPreviewDependencies = async () => {
const sourceNodeModules = join(originalCwd, "node_modules");
const targetNodeModules = join(tempDir, "node_modules");
@@ -243,6 +248,8 @@ const createSiteData = (
}>;
instances?: Array<[string, Omit<Instance, "type">]>;
props?: Array<[string, Prop]>;
dataSources?: Array<[string, DataSource]>;
resources?: Array<[string, Resource]>;
pageMeta?: Record<string, unknown>;
redirects?: Redirects;
} = {}
@@ -335,8 +342,8 @@ const createSiteData = (
],
]
).map(([id, instance]) => [id, { type: "instance", ...instance }]),
dataSources: [],
resources: [],
dataSources: overrides.dataSources ?? [],
resources: overrides.resources ?? [],
styleSources: [],
styleSourceSelections: [],
styles: [],
@@ -790,7 +797,7 @@ test("hydrates encoded filenames from an embedded SSG database", async () => {
describe("prebuild", () => {
test("rejects Assets queries without a content database without changing generated files", async () => {
const siteData = createSiteData();
siteData.build.resources = [["posts", createQueryResource()]] as never;
siteData.build.resources = [["posts", createQueryResource()]];
siteData.build.dataSources = [
[
"posts-data",
@@ -802,7 +809,7 @@ describe("prebuild", () => {
scopeInstanceId: "root",
},
],
] as never;
];
await writeSiteData(siteData);
const existingFiles = [
"app/routes/_index.tsx",
@@ -1266,7 +1273,7 @@ describe("prebuild", () => {
resourceId: "posts",
},
],
] as never;
];
siteData.build.resources = [
[
"posts",
@@ -1290,7 +1297,7 @@ describe("prebuild", () => {
}),
},
],
] as never;
];
siteData.build.styleSources = [
["dynamic-style", { type: "local", id: "dynamic-style" }],
] as never;
@@ -1439,7 +1446,7 @@ describe("prebuild", () => {
name,
resourceId: `${name}-resource`,
},
]) as never;
]);
siteData.build.resources = resourceNames.map((name) => [
`${name}-resource`,
{
@@ -1449,7 +1456,7 @@ describe("prebuild", () => {
method: "get",
headers: [],
},
]) as never;
]);
await writeSiteData(siteData);
await prebuild({
@@ -1586,8 +1593,7 @@ describe("prebuild", () => {
});
await symlink(join(originalCwd, "node_modules"), "node_modules", "dir");
await runGeneratedCommand("vite", ["build"]);
await runGeneratedCommand("vike", ["prerender"]);
await prerenderSsg();
const html = parseHtml(await readFile("dist/client/index.html", "utf8"));
const codeElements = findElementsByTagName(html, "code");
@@ -1651,8 +1657,7 @@ describe("prebuild", () => {
await prebuild({ assets: false, template: ["ssg"] });
await symlink(join(originalCwd, "node_modules"), "node_modules", "dir");
await runGeneratedCommand("vite", ["build"]);
await runGeneratedCommand("vike", ["prerender"]);
await prerenderSsg();
const htmlSource = await readFile("dist/client/index.html", "utf8");
expect(htmlSource).toMatch(/^<!DOCTYPE html><html/);
@@ -2346,9 +2351,7 @@ sitemap.map((page) => page.path);`
],
assetIndex: index,
};
siteData.build.resources = [
["posts", createQueryResource("full")],
] as never;
siteData.build.resources = [["posts", createQueryResource("full")]];
siteData.build.dataSources = [
[
"posts-data",
@@ -2360,7 +2363,7 @@ sitemap.map((page) => page.path);`
scopeInstanceId: "root",
},
],
] as never;
];
await writeSiteData(
siteData as unknown as ReturnType<typeof createSiteData>
);
@@ -2512,7 +2515,7 @@ sitemap.map((page) => page.path);`
}),
assetIndex: index,
};
siteData.build.resources = [["posts", createQueryResource()]] as never;
siteData.build.resources = [["posts", createQueryResource()]];
siteData.build.dataSources = [
[
"posts-data",
@@ -2524,7 +2527,7 @@ sitemap.map((page) => page.path);`
scopeInstanceId: "root",
},
],
] as never;
];
await writeSiteData(
siteData as unknown as ReturnType<typeof createSiteData>
);
@@ -2864,7 +2867,7 @@ sitemap.map((page) => page.path);`
scopeInstanceId: "root",
},
],
] as never;
];
siteData.build.resources = [
[
"author",
@@ -2887,7 +2890,7 @@ sitemap.map((page) => page.path);`
headers: [{ name: "Cache-Control", value: '"public, max-age=60"' }],
},
],
] as never;
];
await writeSiteData(siteData);
await prebuild({ assets: false, template: [template] });
await symlink(join(originalCwd, "node_modules"), "node_modules", "dir");
@@ -2998,13 +3001,12 @@ sitemap.map((page) => page.path);`
headers: [],
},
],
] as never;
];
await writeSiteData(siteData);
await prebuild({ assets: false, template: ["ssg"] });
await symlink(join(originalCwd, "node_modules"), "node_modules", "dir");
await runGeneratedCommand("vite", ["build"]);
await runGeneratedCommand("vike", ["prerender"]);
await prerenderSsg();
const html = parseHtml(await readFile("dist/client/index.html", "utf8"));
const [form] = findElementsByTagName(html, "form");
@@ -3133,7 +3135,7 @@ sitemap.map((page) => page.path);`
}),
},
],
] as never;
];
siteData.build.dataSources = [
[
"post-data",
@@ -3145,7 +3147,7 @@ sitemap.map((page) => page.path);`
scopeInstanceId: "post-root",
},
],
] as never;
];
await writeSiteData(siteData);
await prebuild({ assets: false, template: ["ssg"] });
@@ -3163,8 +3165,7 @@ sitemap.map((page) => page.path);`
expect(sitemap).not.toContain('"path": "/blog/hello-world"');
expect(sitemap).not.toContain('"path": "/blog/draft-post"');
await symlink(join(originalCwd, "node_modules"), "node_modules", "dir");
await runGeneratedCommand("vite", ["build"]);
await runGeneratedCommand("vike", ["prerender"]);
await prerenderSsg();
await expect(
readFile("dist/client/blog/hello-world/index.html", "utf8")
).resolves.toContain("<!DOCTYPE html>");
@@ -3652,7 +3653,7 @@ sitemap.map((page) => page.path);`
}),
},
],
] as never;
];
siteData.build.dataSources = [
[
"posts-data",
@@ -3664,7 +3665,7 @@ sitemap.map((page) => page.path);`
scopeInstanceId: "root",
},
],
] as never;
];
await writeSiteData(
siteData as unknown as ReturnType<typeof createSiteData>
);
@@ -3701,8 +3702,7 @@ sitemap.map((page) => page.path);`
meta: { totalCount: 1, hasMore: false },
},
]);
await runGeneratedCommand("vite", ["build"]);
await runGeneratedCommand("vike", ["prerender"]);
await prerenderSsg();
await expect(readFile("dist/client/index.html", "utf8")).resolves.toContain(
"<!DOCTYPE html>"
);
@@ -3806,7 +3806,7 @@ sitemap.map((page) => page.path);`
scopeInstanceId: "collection",
},
],
] as never;
];
siteData.build.resources = [
[
"sitemap-resource",
@@ -3819,7 +3819,7 @@ sitemap.map((page) => page.path);`
headers: [],
},
],
] as never;
];
await writeSiteData(siteData);
await prebuild({ assets: false, template: ["react-router"] });
@@ -3829,6 +3829,96 @@ sitemap.map((page) => page.path);`
).resolves.toContain('rootIds: [\n "sitemap-resource"');
});
test("emits static text pages at their configured paths", async () => {
const robots = "User-agent: *\nDisallow: /private";
const security = "Contact: mailto:security@example.com\n# <head> & café\n";
const siteData = createSiteData({
pages: [
{
id: "home",
name: "Home",
title: "Home",
path: "",
rootInstanceId: "root",
meta: {},
},
{
id: "robots",
name: "Robots",
title: "Robots",
path: "/robots.txt",
rootInstanceId: "root",
meta: {
documentType: "text",
content: JSON.stringify(robots),
},
},
{
id: "security",
name: "Security",
title: "Security",
path: "/.well-known/security.txt",
rootInstanceId: "root",
meta: {
documentType: "text",
content: `${encodeDataVariableId("security-data")}.data.content`,
},
},
{
id: "empty",
name: "Empty",
title: "Empty",
path: "/empty.txt",
rootInstanceId: "root",
meta: { documentType: "text", content: '""' },
},
],
dataSources: [
[
"security-data",
{
id: "security-data",
type: "resource",
name: "Security",
resourceId: "security-resource",
scopeInstanceId: "root",
},
],
],
resources: [
[
"security-resource",
{
id: "security-resource",
name: "Security",
control: "system",
method: "get",
url: JSON.stringify(
`data:application/json,${encodeURIComponent(JSON.stringify({ content: security }))}`
),
headers: [],
},
],
],
});
await writeSiteData(siteData);
await prebuild({ assets: false, template: ["ssg"] });
await symlink(join(originalCwd, "node_modules"), "node_modules", "dir");
await prerenderSsg();
await expect(readFile("dist/client/robots.txt", "utf8")).resolves.toBe(
robots
);
await expect(
readFile("dist/client/.well-known/security.txt", "utf8")
).resolves.toBe(security);
await expect(readFile("dist/client/empty.txt", "utf8")).resolves.toBe("");
await expect(readFile("dist/client/index.html", "utf8")).resolves.toContain(
"<!DOCTYPE html>"
);
}, 30_000);
test("generates html, xml, and text document routes", async () => {
await writeSiteData(
createSiteData({
@@ -0,0 +1 @@
export const onBeforePrerenderStart = () => __PRERENDER_PATHS__;
@@ -0,0 +1,5 @@
import type { Config } from "vike/types";
export default {
clientHooks: false,
} satisfies Config;
@@ -0,0 +1,5 @@
import type { OnRenderHtmlAsync } from "vike/types";
export const onRenderHtml: OnRenderHtmlAsync = async (pageContext) => {
pageContext.content = pageContext.data.pageMeta.content ?? "";
};
@@ -0,0 +1 @@
export default __PAGE_PATH__;
+4 -4
View File
@@ -3,7 +3,7 @@
"private": true,
"sideEffects": false,
"scripts": {
"build": "vite build && vike prerender",
"build": "vike build && vike prerender",
"dev": "vite dev",
"start": "vite preview",
"typecheck": "tsc --noEmit"
@@ -20,14 +20,14 @@
"react": "18.3.0-canary-14898b6a9-20240318",
"react-dom": "18.3.0-canary-14898b6a9-20240318",
"shiki": "4.4.1",
"vike": "^0.4.229"
"vike": "0.4.267-commit-feffb0f"
},
"devDependencies": {
"@types/react": "^18.2.70",
"@types/react-dom": "^18.2.25",
"@vitejs/plugin-react": "^4.4.1",
"@vitejs/plugin-react": "^4.7.0",
"typescript": "7.0.2",
"vite": "^6.3.4"
"vite": "7.1.12"
},
"engines": {
"node": ">=22.12.0"
+943 -172
View File
File diff suppressed because it is too large. Load diff