@sudajs/cli 0.18.10 → 0.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +2569 -69
- package/dist/index.js +609 -469
- package/dist/index.js.map +1 -1
- package/package.json +2 -3
- package/templates/theme/AGENTS.md +91 -11
- package/templates/theme/README.md +14 -0
- package/templates/theme/docs/agent-guides/component-authoring.md +11 -2
- package/templates/theme/docs/agent-guides/design-and-runtime.md +23 -8
- package/templates/theme/src/manifest.ts +6 -0
- package/templates/theme/src/sections.tsx +16 -11
- package/templates/theme/src/styles.css +40 -20
package/dist/index.js
CHANGED
|
@@ -6,13 +6,11 @@ import { createRequire } from 'module';
|
|
|
6
6
|
import path2 from 'path';
|
|
7
7
|
import { createInterface } from 'readline/promises';
|
|
8
8
|
import { fileURLToPath, pathToFileURL } from 'url';
|
|
9
|
-
import {
|
|
10
|
-
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
11
|
-
import { themeDesignSystemSchema, createThemeDesignCssVariables, createThemeAgentManifest, createSudaPageAgentRuntime, checkThemeModule, formatThemeCheckResult, agentValidationResultSchema, agentPageSchemaOutputSchema, agentComponentOutputSchema } from '@sudajs/theme-engine';
|
|
9
|
+
import { themeDesignSystemSchema, createThemeDesignCssVariables, createThemeAgentManifest, createSudaPageAgentRuntime, checkThemeModule, formatThemeCheckResult, agentValidationResultSchema } from '@sudajs/theme-engine';
|
|
12
10
|
import { themeManifestSchema, runWithThemePreviewLocale } from '@sudajs/theme-engine/server';
|
|
13
11
|
import { Command } from 'commander';
|
|
14
12
|
import { build } from 'esbuild';
|
|
15
|
-
import { CircleCheck } from 'lucide-react';
|
|
13
|
+
import { CircleCheck, Pencil } from 'lucide-react';
|
|
16
14
|
import { createElement } from 'react';
|
|
17
15
|
import { renderToStaticMarkup } from 'react-dom/server';
|
|
18
16
|
import { z } from 'zod';
|
|
@@ -1185,17 +1183,18 @@ async function validateThemeCssTokenContract(root) {
|
|
|
1185
1183
|
}
|
|
1186
1184
|
}
|
|
1187
1185
|
}
|
|
1188
|
-
const
|
|
1186
|
+
const platformTokenPattern = /var\(\s*--(?:background|foreground|primary(?:-foreground)?|secondary(?:-foreground)?|accent(?:-foreground)?|muted(?:-foreground)?|radius(?:-(?:card|button|input))?)(?:\s*[,)])/;
|
|
1187
|
+
const platformAliases = /* @__PURE__ */ new Set();
|
|
1189
1188
|
let changed = true;
|
|
1190
1189
|
while (changed) {
|
|
1191
1190
|
changed = false;
|
|
1192
1191
|
for (const [name, value] of declarations) {
|
|
1193
|
-
const
|
|
1194
|
-
const dependsOnAlias = [...
|
|
1192
|
+
const dependsOnPlatformToken = platformTokenPattern.test(value);
|
|
1193
|
+
const dependsOnAlias = [...platformAliases].some(
|
|
1195
1194
|
(alias) => new RegExp(`var\\(\\s*${escapeRegExp(alias)}(?:\\s*[,\\)])`).test(value)
|
|
1196
1195
|
);
|
|
1197
|
-
if ((
|
|
1198
|
-
|
|
1196
|
+
if ((dependsOnPlatformToken || dependsOnAlias) && !platformAliases.has(name)) {
|
|
1197
|
+
platformAliases.add(name);
|
|
1199
1198
|
changed = true;
|
|
1200
1199
|
}
|
|
1201
1200
|
}
|
|
@@ -1211,7 +1210,7 @@ async function validateThemeCssTokenContract(root) {
|
|
|
1211
1210
|
}
|
|
1212
1211
|
}
|
|
1213
1212
|
const searchable = handwrittenStyles.join("");
|
|
1214
|
-
for (const alias of
|
|
1213
|
+
for (const alias of platformAliases) {
|
|
1215
1214
|
const reference = new RegExp(`var\\(\\s*${escapeRegExp(alias)}(?:\\s*[,\\)])`, "g");
|
|
1216
1215
|
for (const match of searchable.matchAll(reference)) {
|
|
1217
1216
|
const line = searchable.slice(0, match.index ?? 0).split("\n").length;
|
|
@@ -1224,7 +1223,7 @@ async function validateThemeCssTokenContract(root) {
|
|
|
1224
1223
|
[
|
|
1225
1224
|
"Hand-written theme CSS must not consume Suda-backed Tailwind aliases from `@theme inline`.",
|
|
1226
1225
|
...issues.map((issue) => ` - ${issue}`),
|
|
1227
|
-
"Use `var(--
|
|
1226
|
+
"Use an unprefixed platform token such as `var(--primary)` directly, or define and consume a theme-prefixed variable on the theme root. Tailwind utilities such as `bg-primary` remain supported."
|
|
1228
1227
|
].join("\n")
|
|
1229
1228
|
);
|
|
1230
1229
|
}
|
|
@@ -1724,8 +1723,14 @@ function runFooterContractCheck(theme, renderer) {
|
|
|
1724
1723
|
return issues;
|
|
1725
1724
|
}
|
|
1726
1725
|
var __testUtils = {
|
|
1726
|
+
activateThemeOutputSchema,
|
|
1727
|
+
contactFormOutputSchema,
|
|
1728
|
+
createPageDraftOutputSchema,
|
|
1729
|
+
createProjectOutputSchema,
|
|
1727
1730
|
createThemeProjectRequire,
|
|
1731
|
+
createVisualAuditPage,
|
|
1728
1732
|
createRemotePageDraft,
|
|
1733
|
+
destructivePageOperationOutputSchema,
|
|
1729
1734
|
findAnchorTagByHref,
|
|
1730
1735
|
formatMissingSharpWarning,
|
|
1731
1736
|
loadThemeScopedRenderer,
|
|
@@ -1736,11 +1741,18 @@ var __testUtils = {
|
|
|
1736
1741
|
performConfirmedPageOperation,
|
|
1737
1742
|
performUpdateContactForm,
|
|
1738
1743
|
performUpdateThemeSettings,
|
|
1744
|
+
getRemoteContactForm,
|
|
1739
1745
|
getRemoteThemeSettings,
|
|
1746
|
+
listProjectsOutputSchema,
|
|
1740
1747
|
renderDevStarterPageHtml,
|
|
1748
|
+
renderDevEditorPageHtml,
|
|
1741
1749
|
writeVitePreviewEntries,
|
|
1750
|
+
resolveDevEditorSlug,
|
|
1742
1751
|
resolveDevStarterSlug,
|
|
1752
|
+
resolveDevVisualAuditComponent,
|
|
1753
|
+
getThemeVisualAuditComponentTypes,
|
|
1743
1754
|
resolveScreenshotOptions,
|
|
1755
|
+
resolveVisualCheckOptions,
|
|
1744
1756
|
slugifyThemeName,
|
|
1745
1757
|
validateThemeKey,
|
|
1746
1758
|
validateThemeName,
|
|
@@ -1751,7 +1763,11 @@ var __testUtils = {
|
|
|
1751
1763
|
resolveThemePreviewLocale,
|
|
1752
1764
|
withSerializedVitePreviewLocaleLoad,
|
|
1753
1765
|
toViteModuleUrl,
|
|
1754
|
-
|
|
1766
|
+
themeSettingsOutputSchema,
|
|
1767
|
+
updateContactFormOutputSchema,
|
|
1768
|
+
updatePageDraftOutputSchema,
|
|
1769
|
+
updateRemotePageDraft,
|
|
1770
|
+
updateThemeSettingsOutputSchema
|
|
1755
1771
|
};
|
|
1756
1772
|
async function runThemeCheck(theme) {
|
|
1757
1773
|
const result = checkThemeModule(theme.module);
|
|
@@ -1807,7 +1823,7 @@ async function watchTheme(root, port) {
|
|
|
1807
1823
|
console.log(`previewing Vite theme at ${style.url(handle.url)}`);
|
|
1808
1824
|
await new Promise(() => void 0);
|
|
1809
1825
|
}
|
|
1810
|
-
async function startViteDevPreviewServer(root, port) {
|
|
1826
|
+
async function startViteDevPreviewServer(root, port, options = {}) {
|
|
1811
1827
|
const viteConfig = await findViteConfig(root);
|
|
1812
1828
|
const vite = await loadThemeVite(root);
|
|
1813
1829
|
await writeVitePreviewEntries(root);
|
|
@@ -1819,16 +1835,17 @@ async function startViteDevPreviewServer(root, port) {
|
|
|
1819
1835
|
cacheDir: path2.join(root, ".suda-build", "node_modules", ".vite"),
|
|
1820
1836
|
appType: "custom",
|
|
1821
1837
|
optimizeDeps: {
|
|
1822
|
-
entries: [".suda-build/preview-client.ts"]
|
|
1838
|
+
entries: [".suda-build/preview-client.ts", ".suda-build/editor-client.ts"]
|
|
1823
1839
|
},
|
|
1824
1840
|
server: { host, port },
|
|
1825
|
-
plugins: [createSudaPreviewVitePlugin(root)]
|
|
1841
|
+
plugins: [createSudaPreviewVitePlugin(root, options)]
|
|
1826
1842
|
});
|
|
1827
1843
|
await server.listen(port);
|
|
1828
1844
|
const url = server.resolvedUrls?.local.find((candidate) => candidate.includes("127.0.0.1")) ?? server.resolvedUrls?.local[0] ?? `http://${host}:${port}/`;
|
|
1829
1845
|
return {
|
|
1830
1846
|
url: url.replace(/\/$/, ""),
|
|
1831
1847
|
port,
|
|
1848
|
+
server,
|
|
1832
1849
|
close: () => server.close()
|
|
1833
1850
|
};
|
|
1834
1851
|
}
|
|
@@ -1838,8 +1855,8 @@ async function writeVitePreviewEntries(root) {
|
|
|
1838
1855
|
const buildDir = path2.join(root, ".suda-build");
|
|
1839
1856
|
await mkdir(buildDir, { recursive: true });
|
|
1840
1857
|
await writeFile(
|
|
1841
|
-
path2.join(buildDir, "runtime.dev.
|
|
1842
|
-
createClientRuntimeSource(clientHooksImport, false),
|
|
1858
|
+
path2.join(buildDir, "runtime.dev.js"),
|
|
1859
|
+
createClientRuntimeSource(clientHooksImport, false, false),
|
|
1843
1860
|
"utf8"
|
|
1844
1861
|
);
|
|
1845
1862
|
await writeFile(
|
|
@@ -1851,15 +1868,58 @@ async function writeVitePreviewEntries(root) {
|
|
|
1851
1868
|
'if(!payloadElement){throw new Error("Missing Suda theme hydration payload.")}',
|
|
1852
1869
|
"const payload=JSON.parse(payloadElement.textContent ?? '{}');",
|
|
1853
1870
|
"if(payload.previewLocaleMessages){installThemePreviewLocale(payload.previewLocaleMessages)}",
|
|
1854
|
-
'const runtime=(await loadThemeRuntime("/.suda-build/runtime.dev.
|
|
1871
|
+
'const runtime=(await loadThemeRuntime("/.suda-build/runtime.dev.js")).default;',
|
|
1855
1872
|
'const metadata={contactForm:payload.contactForm,resolveAssetUrl:(value: string | undefined)=>resolveAssetPath(value,{legacyResolve:(key)=>key.startsWith("themes/")?`/api/themes/${key.slice("themes/".length)}`:undefined})};',
|
|
1856
1873
|
'await hydrateThemePage({container:document.querySelector("[data-theme-hydration-root]")!,runtime,pageData:payload.pageData,layoutData:payload.layoutData,metadata,onRecoverableError:(error)=>console.error("[theme-dev] theme hydration recovered from an error",error)});',
|
|
1857
1874
|
""
|
|
1858
1875
|
].join("\n"),
|
|
1859
1876
|
"utf8"
|
|
1860
1877
|
);
|
|
1878
|
+
await writeFile(
|
|
1879
|
+
path2.join(buildDir, "editor-client.ts"),
|
|
1880
|
+
[
|
|
1881
|
+
'import "@puckeditor/core/puck.css";',
|
|
1882
|
+
'import { composeUnifiedThemeEditorData, createUnifiedThemeEditorConfig, installThemePreviewLocale, resolveAssetPath, ThemeEditor } from "@sudajs/theme-engine";',
|
|
1883
|
+
'import { createElement } from "react";',
|
|
1884
|
+
'import { createRoot } from "react-dom/client";',
|
|
1885
|
+
'import runtime from "./runtime.dev";',
|
|
1886
|
+
'const payloadElement=document.querySelector("#suda-theme-editor-data");',
|
|
1887
|
+
'if(!payloadElement){throw new Error("Missing Suda theme editor payload.")}',
|
|
1888
|
+
"const payload=JSON.parse(payloadElement.textContent ?? '{}');",
|
|
1889
|
+
"if(payload.previewLocaleMessages){installThemePreviewLocale(payload.previewLocaleMessages)}",
|
|
1890
|
+
'const resolveAssetUrl=(value: string | undefined)=>resolveAssetPath(value,{legacyResolve:(key)=>key.startsWith("themes/")?`/api/themes/${key.slice("themes/".length)}`:undefined});',
|
|
1891
|
+
'const config=createUnifiedThemeEditorConfig(runtime.theme,undefined,{labels:{pageContent:"Page content",themeSettings:"Theme settings"}});',
|
|
1892
|
+
"const data=composeUnifiedThemeEditorData(payload.layoutData,payload.pageData,{layoutConfig:runtime.theme.layoutConfig,pageConfig:runtime.theme.pageConfig});",
|
|
1893
|
+
'const editorContext={assets:[],resolveAssetUrl,currentPageId:payload.currentPageId,backHref:payload.backHref,editorPages:payload.editorPages,editorLocale:payload.locale,chromeMessages:{backToPages:"Back to preview",pageMenuLabel:"Switch starter page",themeSettings:"Theme settings",outline:"Outline",blocks:"Blocks",switchViewport:"Switch viewport"}};',
|
|
1894
|
+
"const metadata={contactForm:payload.contactForm,isEditor:true,resolveAssetUrl};",
|
|
1895
|
+
'createRoot(document.querySelector("#suda-theme-dev-editor-root")!).render(createElement(ThemeEditor,{config,data,editorContext,metadata,iframe:false,height:"100%",publishActionVisible:false}));',
|
|
1896
|
+
""
|
|
1897
|
+
].join("\n"),
|
|
1898
|
+
"utf8"
|
|
1899
|
+
);
|
|
1861
1900
|
}
|
|
1862
|
-
function createClientRuntimeSource(clientHooksImport, useClientDirective) {
|
|
1901
|
+
function createClientRuntimeSource(clientHooksImport, useClientDirective, typescript = true) {
|
|
1902
|
+
if (!typescript) {
|
|
1903
|
+
return [
|
|
1904
|
+
...useClientDirective ? ['"use client";'] : [],
|
|
1905
|
+
'import * as themeConfig from "../src/config.js";',
|
|
1906
|
+
clientHooksImport,
|
|
1907
|
+
"const theme = {",
|
|
1908
|
+
" pageConfig: themeConfig.pageConfig,",
|
|
1909
|
+
" layoutConfig: themeConfig.layoutConfig,",
|
|
1910
|
+
"};",
|
|
1911
|
+
'if ("rendering" in themeConfig && themeConfig.rendering !== undefined) {',
|
|
1912
|
+
" theme.rendering = themeConfig.rendering;",
|
|
1913
|
+
"}",
|
|
1914
|
+
"const hooks = clientHooks;",
|
|
1915
|
+
"const runtime = { theme };",
|
|
1916
|
+
"if (hooks.initialize !== undefined) {",
|
|
1917
|
+
" runtime.initialize = hooks.initialize;",
|
|
1918
|
+
"}",
|
|
1919
|
+
"export default runtime;",
|
|
1920
|
+
""
|
|
1921
|
+
].join("\n");
|
|
1922
|
+
}
|
|
1863
1923
|
return [
|
|
1864
1924
|
...useClientDirective ? ['"use client";'] : [],
|
|
1865
1925
|
'import type { ThemeClientHooks, ThemeClientRuntime, ThemeRenderModule } from "@sudajs/theme-engine/runtime";',
|
|
@@ -1881,24 +1941,54 @@ function createClientRuntimeSource(clientHooksImport, useClientDirective) {
|
|
|
1881
1941
|
""
|
|
1882
1942
|
].join("\n");
|
|
1883
1943
|
}
|
|
1884
|
-
function createSudaPreviewVitePlugin(root) {
|
|
1944
|
+
function createSudaPreviewVitePlugin(root, options = {}) {
|
|
1885
1945
|
return {
|
|
1886
1946
|
name: "suda-theme-preview",
|
|
1947
|
+
resolveId(source, importer) {
|
|
1948
|
+
if (importer === void 0 || !source.endsWith(".js") || !source.startsWith(".")) {
|
|
1949
|
+
return void 0;
|
|
1950
|
+
}
|
|
1951
|
+
const requestedPath = path2.resolve(path2.dirname(importer), source);
|
|
1952
|
+
if (!requestedPath.startsWith(`${root}${path2.sep}`)) {
|
|
1953
|
+
return void 0;
|
|
1954
|
+
}
|
|
1955
|
+
for (const extension of [".ts", ".tsx"]) {
|
|
1956
|
+
const candidate = `${requestedPath.slice(0, -3)}${extension}`;
|
|
1957
|
+
if (existsSync(candidate)) {
|
|
1958
|
+
return candidate;
|
|
1959
|
+
}
|
|
1960
|
+
}
|
|
1961
|
+
return void 0;
|
|
1962
|
+
},
|
|
1887
1963
|
configureServer(server) {
|
|
1888
1964
|
server.middlewares.use((request, response, next) => {
|
|
1889
1965
|
void (async () => {
|
|
1890
1966
|
const url = new URL(request.url ?? "/", "http://localhost");
|
|
1967
|
+
if (url.pathname.startsWith("/src/") && url.pathname.endsWith(".js")) {
|
|
1968
|
+
const sourcePath = path2.join(root, url.pathname.slice(1));
|
|
1969
|
+
for (const extension of [".ts", ".tsx"]) {
|
|
1970
|
+
const candidate = `${sourcePath.slice(0, -3)}${extension}`;
|
|
1971
|
+
if (existsSync(candidate)) {
|
|
1972
|
+
request.url = `${url.pathname.slice(0, -3)}${extension}${url.search}`;
|
|
1973
|
+
break;
|
|
1974
|
+
}
|
|
1975
|
+
}
|
|
1976
|
+
}
|
|
1891
1977
|
const assetPrefixMatch = url.pathname.match(/^\/api\/themes\/[^/]+\/[^/]+\/assets\/(.+)$/);
|
|
1892
1978
|
if (assetPrefixMatch?.[1]) {
|
|
1893
1979
|
request.url = `/assets/${assetPrefixMatch[1]}${url.search}`;
|
|
1894
1980
|
next();
|
|
1895
1981
|
return;
|
|
1896
1982
|
}
|
|
1897
|
-
const
|
|
1898
|
-
|
|
1983
|
+
const visualAuditComponent = resolveDevVisualAuditComponent(url.pathname);
|
|
1984
|
+
const editorSlug = resolveDevEditorSlug(url.pathname);
|
|
1985
|
+
const previewSlug = resolveDevStarterSlug(url.pathname);
|
|
1986
|
+
if (visualAuditComponent === void 0 && editorSlug === void 0 && previewSlug === void 0) {
|
|
1899
1987
|
next();
|
|
1900
1988
|
return;
|
|
1901
1989
|
}
|
|
1990
|
+
const isEditor = editorSlug !== void 0;
|
|
1991
|
+
const slug = isEditor ? editorSlug : previewSlug;
|
|
1902
1992
|
try {
|
|
1903
1993
|
const acceptLanguageHeader = request.headers["accept-language"];
|
|
1904
1994
|
const acceptLanguage = Array.isArray(acceptLanguageHeader) ? acceptLanguageHeader.join(",") : acceptLanguageHeader;
|
|
@@ -1911,25 +2001,32 @@ function createSudaPreviewVitePlugin(root) {
|
|
|
1911
2001
|
);
|
|
1912
2002
|
const renderer = await loadThemeScopedRenderer(root);
|
|
1913
2003
|
const starterSlug = slug ?? pickStarterSlug(localized.theme);
|
|
1914
|
-
const starter = starterSlug ? findStarterPage(localized.theme, starterSlug) : null;
|
|
2004
|
+
const starter = visualAuditComponent ? createVisualAuditPage(localized.theme, visualAuditComponent) : starterSlug ? findStarterPage(localized.theme, starterSlug) : null;
|
|
1915
2005
|
if (!starter) {
|
|
1916
2006
|
response.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
|
|
1917
2007
|
response.end(
|
|
1918
|
-
starterSlug ? `Unknown starter page: ${starterSlug}` : "No starter pages declared by this theme."
|
|
2008
|
+
starterSlug ? `Unknown starter page: ${starterSlug}` : visualAuditComponent ? `Unknown theme page component: ${visualAuditComponent}` : "No starter pages declared by this theme."
|
|
1919
2009
|
);
|
|
1920
2010
|
return;
|
|
1921
2011
|
}
|
|
1922
|
-
const
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
|
|
1928
|
-
|
|
1929
|
-
|
|
1930
|
-
|
|
1931
|
-
|
|
2012
|
+
const htmlSource = isEditor ? renderDevEditorPageHtml(
|
|
2013
|
+
localized.theme,
|
|
2014
|
+
starter,
|
|
2015
|
+
renderer,
|
|
2016
|
+
localized.locale,
|
|
2017
|
+
url.searchParams,
|
|
2018
|
+
localized.previewLocaleMessages
|
|
2019
|
+
) : renderDevStarterPageHtml(
|
|
2020
|
+
localized.theme,
|
|
2021
|
+
starter,
|
|
2022
|
+
renderer,
|
|
2023
|
+
localized.locale,
|
|
2024
|
+
canonicalLocale(explicitLang) !== null,
|
|
2025
|
+
localized.previewLocaleMessages,
|
|
2026
|
+
url.searchParams,
|
|
2027
|
+
{ showDevTools: options.showDevTools !== false }
|
|
1932
2028
|
);
|
|
2029
|
+
const html = await server.transformIndexHtml(url.pathname, htmlSource);
|
|
1933
2030
|
response.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
1934
2031
|
response.end(html);
|
|
1935
2032
|
} catch (error2) {
|
|
@@ -2009,6 +2106,56 @@ function pickStarterSlug(theme) {
|
|
|
2009
2106
|
function findStarterPage(theme, slug) {
|
|
2010
2107
|
return theme.module.starterPages.find((page) => page.slug === slug) ?? null;
|
|
2011
2108
|
}
|
|
2109
|
+
var DEV_EDITOR_PATH = "/__suda/editor";
|
|
2110
|
+
var DEV_VISUAL_AUDIT_PATH = "/__suda/visual-audit";
|
|
2111
|
+
function resolveDevVisualAuditComponent(pathname) {
|
|
2112
|
+
if (!pathname.startsWith(`${DEV_VISUAL_AUDIT_PATH}/`)) {
|
|
2113
|
+
return void 0;
|
|
2114
|
+
}
|
|
2115
|
+
const encodedComponent = pathname.slice(DEV_VISUAL_AUDIT_PATH.length + 1);
|
|
2116
|
+
if (!encodedComponent || encodedComponent.includes("/")) {
|
|
2117
|
+
return void 0;
|
|
2118
|
+
}
|
|
2119
|
+
try {
|
|
2120
|
+
return decodeURIComponent(encodedComponent);
|
|
2121
|
+
} catch {
|
|
2122
|
+
return void 0;
|
|
2123
|
+
}
|
|
2124
|
+
}
|
|
2125
|
+
function getThemeVisualAuditComponentTypes(theme) {
|
|
2126
|
+
return Object.keys(theme.module.pageConfig.components);
|
|
2127
|
+
}
|
|
2128
|
+
function createVisualAuditPage(theme, type) {
|
|
2129
|
+
const themeComponent = theme.module.pageConfig.components[type];
|
|
2130
|
+
if (!themeComponent) {
|
|
2131
|
+
return null;
|
|
2132
|
+
}
|
|
2133
|
+
const props = {
|
|
2134
|
+
...themeComponent.defaultProps ?? {},
|
|
2135
|
+
id: `suda-visual-audit-${safeObjectKeyPart(type)}`
|
|
2136
|
+
};
|
|
2137
|
+
return {
|
|
2138
|
+
slug: `visual-audit-${safeObjectKeyPart(type).toLowerCase()}`,
|
|
2139
|
+
title: `Visual audit: ${type}`,
|
|
2140
|
+
data: {
|
|
2141
|
+
root: { props: {} },
|
|
2142
|
+
content: [{ type, props }]
|
|
2143
|
+
}
|
|
2144
|
+
};
|
|
2145
|
+
}
|
|
2146
|
+
function resolveDevEditorSlug(pathname) {
|
|
2147
|
+
if (pathname === DEV_EDITOR_PATH || pathname === `${DEV_EDITOR_PATH}/`) {
|
|
2148
|
+
return null;
|
|
2149
|
+
}
|
|
2150
|
+
if (!pathname.startsWith(`${DEV_EDITOR_PATH}/`)) {
|
|
2151
|
+
return void 0;
|
|
2152
|
+
}
|
|
2153
|
+
const normalized = pathname.slice(DEV_EDITOR_PATH.length + 1).replace(/\/+$/, "");
|
|
2154
|
+
if (!normalized || normalized.includes("/")) {
|
|
2155
|
+
return void 0;
|
|
2156
|
+
}
|
|
2157
|
+
return decodeURIComponent(normalized);
|
|
2158
|
+
}
|
|
2012
2159
|
function resolveDevStarterSlug(pathname) {
|
|
2013
2160
|
if (pathname === "/") {
|
|
2014
2161
|
return null;
|
|
@@ -2122,8 +2269,9 @@ async function loadThemeScopedRenderer(themeRoot) {
|
|
|
2122
2269
|
renderToString: reactDomServerModule.renderToString
|
|
2123
2270
|
};
|
|
2124
2271
|
}
|
|
2125
|
-
function renderDevStarterPageHtml(theme, page, renderer, locale = "en", preserveLang = false, previewLocaleMessages) {
|
|
2126
|
-
const
|
|
2272
|
+
function renderDevStarterPageHtml(theme, page, renderer, locale = "en", preserveLang = false, previewLocaleMessages, searchParams = new URLSearchParams(), options = {}) {
|
|
2273
|
+
const layoutData = createDevPreviewLayout(theme, searchParams);
|
|
2274
|
+
const chrome = renderer.extractLayoutChrome(layoutData);
|
|
2127
2275
|
const metadata = {
|
|
2128
2276
|
resolveAssetUrl: createPreviewAssetResolver(renderer.resolveAssetPath),
|
|
2129
2277
|
contactForm: PREVIEW_CONTACT_FORM
|
|
@@ -2132,17 +2280,21 @@ function renderDevStarterPageHtml(theme, page, renderer, locale = "en", preserve
|
|
|
2132
2280
|
renderer.createElement(renderer.ThemeRender, {
|
|
2133
2281
|
theme: theme.module,
|
|
2134
2282
|
pageData: page.data,
|
|
2135
|
-
layoutData
|
|
2283
|
+
layoutData,
|
|
2136
2284
|
metadata
|
|
2137
2285
|
})
|
|
2138
2286
|
);
|
|
2139
2287
|
const cssVarStyle = Object.entries(chrome.cssVariables).map(([key, value]) => `${key}: ${value};`).join(" ");
|
|
2140
2288
|
const customHead = chrome.customHeadCode ?? "";
|
|
2141
2289
|
const customBody = chrome.customBodyCode ?? "";
|
|
2142
|
-
const
|
|
2290
|
+
const showDevTools = options.showDevTools !== false;
|
|
2291
|
+
const designPalette = showDevTools ? renderDevDesignPalette(
|
|
2292
|
+
theme.module.manifest.designSystem,
|
|
2293
|
+
createDevEditorHref(page.slug, searchParams)
|
|
2294
|
+
) : null;
|
|
2143
2295
|
const hydrationPayload = serializeInlineScriptValue({
|
|
2144
2296
|
pageData: page.data,
|
|
2145
|
-
layoutData
|
|
2297
|
+
layoutData,
|
|
2146
2298
|
contactForm: PREVIEW_CONTACT_FORM,
|
|
2147
2299
|
previewLocaleMessages
|
|
2148
2300
|
});
|
|
@@ -2158,12 +2310,12 @@ function renderDevStarterPageHtml(theme, page, renderer, locale = "en", preserve
|
|
|
2158
2310
|
'<meta name="viewport" content="width=device-width, initial-scale=1" />',
|
|
2159
2311
|
`<title>${escapeHtml(`${theme.module.manifest.name} \u2014 ${page.title}`)}</title>`,
|
|
2160
2312
|
'<link rel="stylesheet" href="/src/styles.css" />',
|
|
2161
|
-
designPalette.style,
|
|
2162
2313
|
customHead,
|
|
2314
|
+
designPalette?.style ?? "",
|
|
2163
2315
|
"</head>",
|
|
2164
2316
|
`<body${cssVarStyle ? ` style="${cssVarStyle}"` : ""}>`,
|
|
2165
2317
|
`<div data-theme-hydration-root="" style="display:contents">${body}</div>`,
|
|
2166
|
-
designPalette
|
|
2318
|
+
designPalette?.html ?? "",
|
|
2167
2319
|
customBody,
|
|
2168
2320
|
hydrationScript,
|
|
2169
2321
|
...preserveLang ? [
|
|
@@ -2173,13 +2325,95 @@ function renderDevStarterPageHtml(theme, page, renderer, locale = "en", preserve
|
|
|
2173
2325
|
"</html>"
|
|
2174
2326
|
].join("");
|
|
2175
2327
|
}
|
|
2328
|
+
function copyDevPreviewSearchParams(searchParams) {
|
|
2329
|
+
const copied = new URLSearchParams();
|
|
2330
|
+
for (const key of ["lang", "designPreset"]) {
|
|
2331
|
+
const value = searchParams.get(key);
|
|
2332
|
+
if (value) {
|
|
2333
|
+
copied.set(key, value);
|
|
2334
|
+
}
|
|
2335
|
+
}
|
|
2336
|
+
return copied;
|
|
2337
|
+
}
|
|
2338
|
+
function createDevEditorHref(slug, searchParams) {
|
|
2339
|
+
const query = copyDevPreviewSearchParams(searchParams).toString();
|
|
2340
|
+
return `${DEV_EDITOR_PATH}/${encodeURIComponent(slug)}${query ? `?${query}` : ""}`;
|
|
2341
|
+
}
|
|
2342
|
+
function createDevPreviewHref(page, searchParams) {
|
|
2343
|
+
const pathname = page.isHome ? "/" : `/${encodeURIComponent(page.slug)}`;
|
|
2344
|
+
const query = copyDevPreviewSearchParams(searchParams).toString();
|
|
2345
|
+
return `${pathname}${query ? `?${query}` : ""}`;
|
|
2346
|
+
}
|
|
2347
|
+
function createDevPreviewLayout(theme, searchParams) {
|
|
2348
|
+
const requestedPresetId = searchParams.get("designPreset");
|
|
2349
|
+
const requestedPreset = requestedPresetId ? theme.module.manifest.designSystem.presets.find(
|
|
2350
|
+
(preset) => preset.id === requestedPresetId
|
|
2351
|
+
) : void 0;
|
|
2352
|
+
if (!requestedPreset) {
|
|
2353
|
+
return theme.module.defaultLayout;
|
|
2354
|
+
}
|
|
2355
|
+
const rootFields = theme.module.layoutConfig.root?.fields ?? {};
|
|
2356
|
+
const designFieldName = Object.entries(rootFields).find(
|
|
2357
|
+
([, field]) => field.metadata?.sudaField === "designSystem"
|
|
2358
|
+
)?.[0];
|
|
2359
|
+
if (!designFieldName) {
|
|
2360
|
+
return theme.module.defaultLayout;
|
|
2361
|
+
}
|
|
2362
|
+
const layoutData = structuredClone(theme.module.defaultLayout);
|
|
2363
|
+
layoutData.root = {
|
|
2364
|
+
...layoutData.root ?? {},
|
|
2365
|
+
props: {
|
|
2366
|
+
...layoutData.root?.props ?? {},
|
|
2367
|
+
[designFieldName]: { presetId: requestedPreset.id }
|
|
2368
|
+
}
|
|
2369
|
+
};
|
|
2370
|
+
return layoutData;
|
|
2371
|
+
}
|
|
2372
|
+
function renderDevEditorPageHtml(theme, page, renderer, locale = "en", searchParams = new URLSearchParams(), previewLocaleMessages) {
|
|
2373
|
+
const layoutData = createDevPreviewLayout(theme, searchParams);
|
|
2374
|
+
const chrome = renderer.extractLayoutChrome(layoutData);
|
|
2375
|
+
const cssVariables = { ...chrome.cssVariables };
|
|
2376
|
+
const cssVarStyle = Object.entries(cssVariables).map(([key, value]) => `${key}: ${value};`).join(" ");
|
|
2377
|
+
const editorPages = theme.module.starterPages.map((starterPage) => ({
|
|
2378
|
+
id: starterPage.slug,
|
|
2379
|
+
handle: starterPage.slug,
|
|
2380
|
+
label: starterPage.title,
|
|
2381
|
+
group: starterPage.isHome ? "home" : "static",
|
|
2382
|
+
editorHref: createDevEditorHref(starterPage.slug, searchParams)
|
|
2383
|
+
}));
|
|
2384
|
+
const payload = serializeInlineScriptValue({
|
|
2385
|
+
pageData: page.data,
|
|
2386
|
+
layoutData,
|
|
2387
|
+
contactForm: PREVIEW_CONTACT_FORM,
|
|
2388
|
+
previewLocaleMessages,
|
|
2389
|
+
locale,
|
|
2390
|
+
currentPageId: page.slug,
|
|
2391
|
+
backHref: createDevPreviewHref(page, searchParams),
|
|
2392
|
+
editorPages
|
|
2393
|
+
});
|
|
2394
|
+
return [
|
|
2395
|
+
"<!doctype html>",
|
|
2396
|
+
`<html lang="${escapeHtml(locale)}">`,
|
|
2397
|
+
"<head>",
|
|
2398
|
+
'<meta charset="utf-8" />',
|
|
2399
|
+
'<meta name="viewport" content="width=device-width, initial-scale=1" />',
|
|
2400
|
+
`<title>${escapeHtml(`Edit ${theme.module.manifest.name} \u2014 ${page.title}`)}</title>`,
|
|
2401
|
+
'<link rel="stylesheet" href="/src/styles.css" />',
|
|
2402
|
+
"<style>html,body,#suda-theme-dev-editor-root{height:100%;margin:0}body{overflow:hidden}</style>",
|
|
2403
|
+
"</head>",
|
|
2404
|
+
`<body${cssVarStyle ? ` style="${cssVarStyle}"` : ""}>`,
|
|
2405
|
+
'<div id="suda-theme-dev-editor-root"></div>',
|
|
2406
|
+
`<script id="suda-theme-editor-data" type="application/json">${payload}</script>`,
|
|
2407
|
+
'<script type="module">void import("/.suda-build/editor-client.ts").catch((error)=>console.error("[theme-dev] theme editor failed to load",error))</script>',
|
|
2408
|
+
"</body>",
|
|
2409
|
+
"</html>"
|
|
2410
|
+
].join("");
|
|
2411
|
+
}
|
|
2176
2412
|
function serializeInlineScriptValue(value) {
|
|
2177
2413
|
return JSON.stringify(value).replaceAll("<", "\\u003c").replaceAll("\u2028", "\\u2028").replaceAll("\u2029", "\\u2029");
|
|
2178
2414
|
}
|
|
2179
|
-
function renderDevDesignPalette(designSystem) {
|
|
2180
|
-
|
|
2181
|
-
return { style: "", html: "" };
|
|
2182
|
-
}
|
|
2415
|
+
function renderDevDesignPalette(designSystem, editorHref) {
|
|
2416
|
+
const hasPresetSwitcher = designSystem.presets.length >= 2;
|
|
2183
2417
|
const presets = designSystem.presets.map((preset) => ({
|
|
2184
2418
|
id: preset.id,
|
|
2185
2419
|
variables: createThemeDesignCssVariables(preset.tokens)
|
|
@@ -2193,20 +2427,28 @@ function renderDevDesignPalette(designSystem) {
|
|
|
2193
2427
|
focusable: false
|
|
2194
2428
|
})
|
|
2195
2429
|
);
|
|
2196
|
-
const
|
|
2430
|
+
const editIcon = renderToStaticMarkup(
|
|
2431
|
+
createElement(Pencil, {
|
|
2432
|
+
width: 20,
|
|
2433
|
+
height: 20,
|
|
2434
|
+
"aria-hidden": true,
|
|
2435
|
+
focusable: false
|
|
2436
|
+
})
|
|
2437
|
+
);
|
|
2438
|
+
const buttons = hasPresetSwitcher ? designSystem.presets.map(
|
|
2197
2439
|
(preset) => `<button type="button" data-suda-dev-preset="${escapeHtml(preset.id)}" title="${escapeHtml(
|
|
2198
2440
|
preset.description ? `${preset.label} \u2014 ${preset.description}` : preset.label
|
|
2199
2441
|
)}" aria-label="${escapeHtml(preset.label)}"><span style="background:${escapeHtml(
|
|
2200
2442
|
preset.tokens.colors.primary
|
|
2201
2443
|
)}"></span>${selectedIcon}</button>`
|
|
2202
|
-
).join("");
|
|
2444
|
+
).join("") : "";
|
|
2203
2445
|
const config = serializeInlineScriptValue({
|
|
2204
2446
|
defaultPresetId: designSystem.defaultPresetId,
|
|
2205
2447
|
presets
|
|
2206
2448
|
});
|
|
2207
2449
|
return {
|
|
2208
|
-
style: '<style data-suda-dev-palette-style>\n#suda-dev-palette{all:initial;position:fixed;z-index:2147483000;top:50%;right:16px;width:64px;max-height:calc(100svh - 32px);padding:8px 9px;overflow-x:hidden;overflow-y:auto;box-sizing:border-box;background:#fff;border:1px solid #d1d5db;border-radius:999px;box-shadow:0 8px 24px rgb(15 23 42 / 12%);transform:translateY(-50%);color-scheme:light;scrollbar-width:none;-ms-overflow-style:none}\n#suda-dev-palette::-webkit-scrollbar{display:none}\n#suda-dev-palette button{all:initial;position:relative;display:grid;width:44px;height:44px;box-sizing:border-box;cursor:pointer;border-radius:999px;place-items:center}\n#suda-dev-palette button span{display:block;width:32px;height:32px;border-radius:999px;transition:transform 120ms ease}\n#suda-dev-palette button:hover span{transform:scale(1.08)}\n#suda-dev-palette button[aria-pressed="true"] span{visibility:hidden}\n#suda-dev-palette .suda-dev-preset-selected-icon{position:absolute;display:block;width:32px;height:32px;color:var(--suda-dev-preset-primary);visibility:hidden;opacity:0;transform:scale(.85);transition:opacity 120ms ease,transform 120ms ease}\n#suda-dev-palette button[aria-pressed="true"] .suda-dev-preset-selected-icon{visibility:visible;opacity:1;transform:scale(1)}\n#suda-dev-palette button:focus-visible{outline:2px solid #94a3b8;outline-offset:2px}\n@media(max-width:640px){#suda-dev-palette{right:8px;max-height:calc(100svh - 16px)}}\n@media(prefers-reduced-motion:reduce){#suda-dev-palette button span,#suda-dev-palette .suda-dev-preset-selected-icon{transition:none}}\n</style>',
|
|
2209
|
-
html: `<aside id="suda-dev-palette" aria-label="
|
|
2450
|
+
style: '<style data-suda-dev-palette-style>\n#suda-dev-palette{all:initial;position:fixed;z-index:2147483000;top:50%;right:16px;width:64px;max-height:calc(100svh - 32px);padding:8px 9px;overflow-x:hidden;overflow-y:auto;box-sizing:border-box;background:#fff;border:1px solid #d1d5db;border-radius:999px;box-shadow:0 8px 24px rgb(15 23 42 / 12%);transform:translateY(-50%);color-scheme:light;scrollbar-width:none;-ms-overflow-style:none}\n#suda-dev-palette::-webkit-scrollbar{display:none}\n#suda-dev-palette button{all:initial;position:relative;display:grid;width:44px;height:44px;box-sizing:border-box;cursor:pointer;border-radius:999px;place-items:center}\n#suda-dev-palette button span{display:block;width:32px;height:32px;border-radius:999px;transition:transform 120ms ease}\n#suda-dev-palette button:hover span{transform:scale(1.08)}\n#suda-dev-palette button[aria-pressed="true"] span{visibility:hidden}\n#suda-dev-palette .suda-dev-preset-selected-icon{position:absolute;display:block;width:32px;height:32px;color:var(--suda-dev-preset-primary);visibility:hidden;opacity:0;transform:scale(.85);transition:opacity 120ms ease,transform 120ms ease}\n#suda-dev-palette button[aria-pressed="true"] .suda-dev-preset-selected-icon{visibility:visible;opacity:1;transform:scale(1)}\n#suda-dev-palette button:focus-visible{outline:2px solid #94a3b8;outline-offset:2px}\n#suda-dev-palette .suda-dev-edit-divider{display:block;width:28px;height:1px;margin:4px auto;background:#e2e8f0}\n#suda-dev-palette .suda-dev-edit-link{all:initial;display:grid;width:44px;height:44px;box-sizing:border-box;color:#334155;cursor:pointer;border-radius:999px;place-items:center;transition:background-color 120ms ease,color 120ms ease}\n#suda-dev-palette .suda-dev-edit-link:hover{color:#0f172a;background:#f1f5f9}\n#suda-dev-palette .suda-dev-edit-link:focus-visible{outline:2px solid #94a3b8;outline-offset:2px}\n@media(max-width:640px){#suda-dev-palette{right:8px;max-height:calc(100svh - 16px)}}\n@media(prefers-reduced-motion:reduce){#suda-dev-palette button span,#suda-dev-palette .suda-dev-preset-selected-icon,#suda-dev-palette .suda-dev-edit-link{transition:none}}\n</style>',
|
|
2451
|
+
html: `<aside id="suda-dev-palette" aria-label="Theme development tools">${buttons}${hasPresetSwitcher ? '<span class="suda-dev-edit-divider" aria-hidden="true"></span>' : ""}<a class="suda-dev-edit-link" data-suda-dev-editor-link="" href="${escapeHtml(editorHref)}" title="Edit starter page" aria-label="Edit starter page">${editIcon}</a></aside>` + (hasPresetSwitcher ? `<script data-suda-dev-palette-script>(function(){const config=${config};const palette=document.getElementById("suda-dev-palette");if(!palette)return;const byId=new Map(config.presets.map((preset)=>[preset.id,preset]));const params=new URL(location.href).searchParams;const requested=params.get("designPreset");const selected=byId.has(requested)?requested:config.defaultPresetId;function apply(id,updateUrl){const preset=byId.get(id);if(!preset)return;const properties=Object.keys(preset.variables);const targets=[document.documentElement,document.body,...document.querySelectorAll("[style]")].filter((element,index,items)=>items.indexOf(element)===index&&(element===document.documentElement||element===document.body||properties.some((property)=>element.style.getPropertyValue(property)!=="")));for(const target of targets){for(const [property,value] of Object.entries(preset.variables))target.style.setProperty(property,value)}for(const button of palette.querySelectorAll("[data-suda-dev-preset]")){const active=button.dataset.sudaDevPreset===id;button.setAttribute("aria-pressed",String(active));button.style.setProperty("--suda-dev-preset-primary",byId.get(button.dataset.sudaDevPreset).variables["--primary"])}const editorLink=palette.querySelector("[data-suda-dev-editor-link]");if(editorLink){const editorUrl=new URL(editorLink.href,location.href);editorUrl.searchParams.set("designPreset",id);editorLink.href=editorUrl.pathname+editorUrl.search+editorUrl.hash}if(updateUrl){const url=new URL(location.href);url.searchParams.set("designPreset",id);history.replaceState(history.state,"",url)}}if(requested&&byId.has(requested))apply(selected,false);else for(const button of palette.querySelectorAll("[data-suda-dev-preset]")){button.setAttribute("aria-pressed",String(button.dataset.sudaDevPreset===selected));button.style.setProperty("--suda-dev-preset-primary",byId.get(button.dataset.sudaDevPreset).variables["--primary"])}palette.addEventListener("click",(event)=>{const button=event.target.closest("[data-suda-dev-preset]");if(button)apply(button.dataset.sudaDevPreset,true)})})();</script>` : "")
|
|
2210
2452
|
};
|
|
2211
2453
|
}
|
|
2212
2454
|
var SCREENSHOT_DEVICES = ["desktop", "tablet", "mobile"];
|
|
@@ -2281,6 +2523,33 @@ function resolveScreenshotOptions(root, options) {
|
|
|
2281
2523
|
fullPage: options.fullPage === true
|
|
2282
2524
|
};
|
|
2283
2525
|
}
|
|
2526
|
+
function parseVisualCheckPresetIds(theme, value) {
|
|
2527
|
+
const available = theme.module.manifest.designSystem.presets.map((preset) => preset.id);
|
|
2528
|
+
if (value === void 0) {
|
|
2529
|
+
return available;
|
|
2530
|
+
}
|
|
2531
|
+
const requested = (Array.isArray(value) ? value : [value]).flatMap((item) => item.split(",")).map((item) => item.trim()).filter(Boolean);
|
|
2532
|
+
if (requested.length === 0) {
|
|
2533
|
+
return available;
|
|
2534
|
+
}
|
|
2535
|
+
const unknown = requested.filter((preset) => !available.includes(preset));
|
|
2536
|
+
if (unknown.length > 0) {
|
|
2537
|
+
throw new Error(
|
|
2538
|
+
`Unknown design preset ${unknown.map((preset) => `"${preset}"`).join(", ")}. Available presets: ${available.join(", ")}.`
|
|
2539
|
+
);
|
|
2540
|
+
}
|
|
2541
|
+
return Array.from(new Set(requested));
|
|
2542
|
+
}
|
|
2543
|
+
function resolveVisualCheckOptions(root, theme, options) {
|
|
2544
|
+
const outputDir = path2.resolve(root, options.output ?? path2.join(".suda-build", "visual-check"));
|
|
2545
|
+
return {
|
|
2546
|
+
devices: parseScreenshotDevices(options.device),
|
|
2547
|
+
fullPage: options.fullPage === true,
|
|
2548
|
+
outputDir,
|
|
2549
|
+
port: parsePositiveInt(options.port, 4179),
|
|
2550
|
+
presetIds: parseVisualCheckPresetIds(theme, options.preset)
|
|
2551
|
+
};
|
|
2552
|
+
}
|
|
2284
2553
|
function createThemeProjectRequire(themeRoot) {
|
|
2285
2554
|
return createRequire(path2.join(themeRoot, "package.json"));
|
|
2286
2555
|
}
|
|
@@ -2375,7 +2644,7 @@ async function optimizePngIfAvailable(themeRoot, filePath) {
|
|
|
2375
2644
|
}
|
|
2376
2645
|
async function captureScreenshot(root, options) {
|
|
2377
2646
|
const playwright = await loadPlaywright(root);
|
|
2378
|
-
const handle = await startViteDevPreviewServer(root, options.port);
|
|
2647
|
+
const handle = await startViteDevPreviewServer(root, options.port, { showDevTools: false });
|
|
2379
2648
|
try {
|
|
2380
2649
|
const browser = await playwright.chromium.launch({ headless: true });
|
|
2381
2650
|
try {
|
|
@@ -2408,6 +2677,278 @@ async function screenshotTheme(root, options) {
|
|
|
2408
2677
|
const resolved = resolveScreenshotOptions(root, options);
|
|
2409
2678
|
await captureScreenshot(root, resolved);
|
|
2410
2679
|
}
|
|
2680
|
+
function visualAuditUrl(baseUrl, component, preset) {
|
|
2681
|
+
const url = new URL(`${DEV_VISUAL_AUDIT_PATH}/${encodeURIComponent(component)}`, `${baseUrl}/`);
|
|
2682
|
+
url.searchParams.set("designPreset", preset);
|
|
2683
|
+
return url.href;
|
|
2684
|
+
}
|
|
2685
|
+
async function runThemeVisualCheck(root, options) {
|
|
2686
|
+
const playwright = await loadPlaywright(root);
|
|
2687
|
+
const handle = await startViteDevPreviewServer(root, parsePositiveInt(options.port, 4179));
|
|
2688
|
+
let theme;
|
|
2689
|
+
try {
|
|
2690
|
+
theme = await loadViteDevTheme(root, handle.server);
|
|
2691
|
+
} catch (error2) {
|
|
2692
|
+
await handle.close();
|
|
2693
|
+
throw error2;
|
|
2694
|
+
}
|
|
2695
|
+
const resolved = resolveVisualCheckOptions(root, theme, options);
|
|
2696
|
+
const components = getThemeVisualAuditComponentTypes(theme);
|
|
2697
|
+
const report = {
|
|
2698
|
+
components,
|
|
2699
|
+
issues: [],
|
|
2700
|
+
outputDir: resolved.outputDir,
|
|
2701
|
+
presets: resolved.presetIds
|
|
2702
|
+
};
|
|
2703
|
+
try {
|
|
2704
|
+
const browser = await playwright.chromium.launch({ headless: true });
|
|
2705
|
+
try {
|
|
2706
|
+
for (const preset of resolved.presetIds) {
|
|
2707
|
+
for (const device of resolved.devices) {
|
|
2708
|
+
const viewport = SCREENSHOT_DEVICE_CONFIG[device].viewport;
|
|
2709
|
+
const context = await browser.newContext({ viewport, deviceScaleFactor: 1 });
|
|
2710
|
+
try {
|
|
2711
|
+
for (const component of components) {
|
|
2712
|
+
const page = await context.newPage();
|
|
2713
|
+
const pageErrors = [];
|
|
2714
|
+
page.on("pageerror", (error2) => pageErrors.push(error2.message));
|
|
2715
|
+
try {
|
|
2716
|
+
await page.goto(visualAuditUrl(handle.url, component, preset), {
|
|
2717
|
+
waitUntil: "networkidle"
|
|
2718
|
+
});
|
|
2719
|
+
const audit = await page.evaluate(() => {
|
|
2720
|
+
const issues = [];
|
|
2721
|
+
const section = document.querySelector("[data-section]");
|
|
2722
|
+
if (!section) {
|
|
2723
|
+
return {
|
|
2724
|
+
issues: [
|
|
2725
|
+
{
|
|
2726
|
+
code: "section-missing",
|
|
2727
|
+
message: "The visual audit page did not render the theme component.",
|
|
2728
|
+
severity: "error"
|
|
2729
|
+
}
|
|
2730
|
+
]
|
|
2731
|
+
};
|
|
2732
|
+
}
|
|
2733
|
+
const colorForToken = (token) => {
|
|
2734
|
+
const probe = document.createElement("span");
|
|
2735
|
+
probe.style.color = `var(${token})`;
|
|
2736
|
+
probe.style.display = "none";
|
|
2737
|
+
section.append(probe);
|
|
2738
|
+
const color = getComputedStyle(probe).color;
|
|
2739
|
+
probe.remove();
|
|
2740
|
+
return color;
|
|
2741
|
+
};
|
|
2742
|
+
const contrastRatio = (first, second) => {
|
|
2743
|
+
const channels = (value) => {
|
|
2744
|
+
const match = value.match(/^rgba?\\(([^)]+)\\)$/);
|
|
2745
|
+
if (!match?.[1]) return null;
|
|
2746
|
+
const parsed = match[1].split(",").slice(0, 3).map((channel) => Number.parseFloat(channel.trim()));
|
|
2747
|
+
if (parsed.length !== 3 || !parsed.every(Number.isFinite)) return null;
|
|
2748
|
+
const [red, green, blue] = parsed;
|
|
2749
|
+
if (red === void 0 || green === void 0 || blue === void 0) return null;
|
|
2750
|
+
return [red, green, blue];
|
|
2751
|
+
};
|
|
2752
|
+
const firstRgb = channels(first);
|
|
2753
|
+
const secondRgb = channels(second);
|
|
2754
|
+
if (!firstRgb || !secondRgb) return null;
|
|
2755
|
+
const luminance = (values) => {
|
|
2756
|
+
const [red, green, blue] = values.map((channel) => {
|
|
2757
|
+
const normalized = channel / 255;
|
|
2758
|
+
return normalized <= 0.04045 ? normalized / 12.92 : ((normalized + 0.055) / 1.055) ** 2.4;
|
|
2759
|
+
});
|
|
2760
|
+
return 0.2126 * red + 0.7152 * green + 0.0722 * blue;
|
|
2761
|
+
};
|
|
2762
|
+
const firstLuminance = luminance(firstRgb);
|
|
2763
|
+
const secondLuminance = luminance(secondRgb);
|
|
2764
|
+
return (Math.max(firstLuminance, secondLuminance) + 0.05) / (Math.min(firstLuminance, secondLuminance) + 0.05);
|
|
2765
|
+
};
|
|
2766
|
+
const tokenPairs = [
|
|
2767
|
+
["--background", "--foreground"],
|
|
2768
|
+
["--primary", "--primary-foreground"],
|
|
2769
|
+
["--secondary", "--secondary-foreground"],
|
|
2770
|
+
["--accent", "--accent-foreground"],
|
|
2771
|
+
["--muted", "--muted-foreground"]
|
|
2772
|
+
];
|
|
2773
|
+
for (const [surfaceToken, foregroundToken] of tokenPairs) {
|
|
2774
|
+
const ratio = contrastRatio(colorForToken(surfaceToken), colorForToken(foregroundToken));
|
|
2775
|
+
if (ratio === null) {
|
|
2776
|
+
issues.push({
|
|
2777
|
+
code: "token-color-unresolved",
|
|
2778
|
+
message: `${surfaceToken} or ${foregroundToken} did not resolve to a CSS color.`,
|
|
2779
|
+
severity: "error"
|
|
2780
|
+
});
|
|
2781
|
+
} else if (ratio < 4.5) {
|
|
2782
|
+
issues.push({
|
|
2783
|
+
code: "token-contrast",
|
|
2784
|
+
message: `${surfaceToken} and ${foregroundToken} have ${ratio.toFixed(2)}:1 contrast; 4.5:1 is required.`,
|
|
2785
|
+
severity: "error"
|
|
2786
|
+
});
|
|
2787
|
+
}
|
|
2788
|
+
}
|
|
2789
|
+
const semanticTokenPairs = [
|
|
2790
|
+
["--background", "--foreground"],
|
|
2791
|
+
["--primary", "--primary-foreground"],
|
|
2792
|
+
["--secondary", "--secondary-foreground"],
|
|
2793
|
+
["--accent", "--accent-foreground"],
|
|
2794
|
+
["--muted", "--muted-foreground"]
|
|
2795
|
+
];
|
|
2796
|
+
const semanticSurfaces = semanticTokenPairs.map(([surface, foreground]) => ({
|
|
2797
|
+
foreground,
|
|
2798
|
+
foregroundColor: colorForToken(foreground),
|
|
2799
|
+
surface,
|
|
2800
|
+
surfaceColor: colorForToken(surface)
|
|
2801
|
+
}));
|
|
2802
|
+
const semanticSurfacesByElement = /* @__PURE__ */ new Map();
|
|
2803
|
+
for (const element of [section, ...section.querySelectorAll("*")]) {
|
|
2804
|
+
const backgroundColor = getComputedStyle(element).backgroundColor;
|
|
2805
|
+
const surface = semanticSurfaces.find(
|
|
2806
|
+
(candidate) => candidate.surfaceColor === backgroundColor
|
|
2807
|
+
);
|
|
2808
|
+
if (surface) semanticSurfacesByElement.set(element, surface);
|
|
2809
|
+
}
|
|
2810
|
+
const visible = (element) => {
|
|
2811
|
+
const style2 = getComputedStyle(element);
|
|
2812
|
+
const rect = element.getBoundingClientRect();
|
|
2813
|
+
return style2.display !== "none" && style2.visibility !== "hidden" && Number.parseFloat(style2.opacity) > 0 && rect.width > 0 && rect.height > 0;
|
|
2814
|
+
};
|
|
2815
|
+
const closestSurface = (element) => {
|
|
2816
|
+
let current = element;
|
|
2817
|
+
while (current && section.contains(current)) {
|
|
2818
|
+
const match = semanticSurfacesByElement.get(current);
|
|
2819
|
+
if (match) return match;
|
|
2820
|
+
current = current.parentElement;
|
|
2821
|
+
}
|
|
2822
|
+
return null;
|
|
2823
|
+
};
|
|
2824
|
+
const reportedTextChecks = /* @__PURE__ */ new Set();
|
|
2825
|
+
const textWalker = document.createTreeWalker(section, NodeFilter.SHOW_TEXT);
|
|
2826
|
+
let textNode = textWalker.nextNode();
|
|
2827
|
+
while (textNode) {
|
|
2828
|
+
if (!textNode.textContent?.trim()) {
|
|
2829
|
+
textNode = textWalker.nextNode();
|
|
2830
|
+
continue;
|
|
2831
|
+
}
|
|
2832
|
+
const parent = textNode.parentElement;
|
|
2833
|
+
if (!parent || !visible(parent)) {
|
|
2834
|
+
textNode = textWalker.nextNode();
|
|
2835
|
+
continue;
|
|
2836
|
+
}
|
|
2837
|
+
const surface = closestSurface(parent);
|
|
2838
|
+
if (surface) {
|
|
2839
|
+
const actualForeground = getComputedStyle(parent).color;
|
|
2840
|
+
const ratio = contrastRatio(surface.surfaceColor, actualForeground);
|
|
2841
|
+
const key = `${surface.surface}/${actualForeground}`;
|
|
2842
|
+
if (ratio !== null && ratio < 4.5 && !reportedTextChecks.has(`contrast/${key}`)) {
|
|
2843
|
+
issues.push({
|
|
2844
|
+
code: "surface-text-contrast",
|
|
2845
|
+
message: `Text on ${surface.surface} has ${ratio.toFixed(2)}:1 contrast; 4.5:1 is required.`,
|
|
2846
|
+
severity: "error"
|
|
2847
|
+
});
|
|
2848
|
+
reportedTextChecks.add(`contrast/${key}`);
|
|
2849
|
+
}
|
|
2850
|
+
if (actualForeground !== surface.foregroundColor && !reportedTextChecks.has(`foreground/${key}`)) {
|
|
2851
|
+
issues.push({
|
|
2852
|
+
code: "surface-foreground-token-mismatch",
|
|
2853
|
+
message: `Text on ${surface.surface} uses ${actualForeground} instead of ${surface.foreground}; verify the custom foreground remains readable.`,
|
|
2854
|
+
severity: "warning"
|
|
2855
|
+
});
|
|
2856
|
+
reportedTextChecks.add(`foreground/${key}`);
|
|
2857
|
+
}
|
|
2858
|
+
}
|
|
2859
|
+
textNode = textWalker.nextNode();
|
|
2860
|
+
}
|
|
2861
|
+
const sectionRect = section.getBoundingClientRect();
|
|
2862
|
+
if (document.documentElement.scrollWidth > window.innerWidth + 1 || sectionRect.left < -1 || sectionRect.right > window.innerWidth + 1) {
|
|
2863
|
+
issues.push({
|
|
2864
|
+
code: "horizontal-overflow",
|
|
2865
|
+
message: "The page or audited section overflows the viewport horizontally.",
|
|
2866
|
+
severity: "error"
|
|
2867
|
+
});
|
|
2868
|
+
}
|
|
2869
|
+
const firstText = section.querySelector("h1, h2, h3, p");
|
|
2870
|
+
if (firstText) {
|
|
2871
|
+
const textRect = firstText.getBoundingClientRect();
|
|
2872
|
+
const overlays = Array.from(document.querySelectorAll("body *")).filter(
|
|
2873
|
+
(element) => {
|
|
2874
|
+
if (element === section || section.contains(element)) return false;
|
|
2875
|
+
const style2 = getComputedStyle(element);
|
|
2876
|
+
if (style2.position !== "fixed" || style2.visibility === "hidden") return false;
|
|
2877
|
+
const rect = element.getBoundingClientRect();
|
|
2878
|
+
return rect.bottom > textRect.top && rect.top < textRect.bottom && rect.right > textRect.left && rect.left < textRect.right;
|
|
2879
|
+
}
|
|
2880
|
+
);
|
|
2881
|
+
if (overlays.length > 0) {
|
|
2882
|
+
issues.push({
|
|
2883
|
+
code: "layout-overlay",
|
|
2884
|
+
message: "A fixed layout element overlaps the first section text. Add spacing in the theme layout if this is unintended.",
|
|
2885
|
+
severity: "warning"
|
|
2886
|
+
});
|
|
2887
|
+
}
|
|
2888
|
+
}
|
|
2889
|
+
return { issues };
|
|
2890
|
+
});
|
|
2891
|
+
for (const issue of audit.issues) {
|
|
2892
|
+
report.issues.push({ ...issue, component, device, preset });
|
|
2893
|
+
}
|
|
2894
|
+
for (const message of pageErrors) {
|
|
2895
|
+
report.issues.push({
|
|
2896
|
+
component,
|
|
2897
|
+
code: "page-error",
|
|
2898
|
+
device,
|
|
2899
|
+
message,
|
|
2900
|
+
preset,
|
|
2901
|
+
severity: "error"
|
|
2902
|
+
});
|
|
2903
|
+
}
|
|
2904
|
+
const outputPath = path2.join(
|
|
2905
|
+
resolved.outputDir,
|
|
2906
|
+
safeObjectKeyPart(preset),
|
|
2907
|
+
device,
|
|
2908
|
+
`${safeObjectKeyPart(component)}.png`
|
|
2909
|
+
);
|
|
2910
|
+
await mkdir(path2.dirname(outputPath), { recursive: true });
|
|
2911
|
+
await page.screenshot({ path: outputPath, fullPage: resolved.fullPage });
|
|
2912
|
+
await optimizePngIfAvailable(root, outputPath);
|
|
2913
|
+
} finally {
|
|
2914
|
+
await page.close();
|
|
2915
|
+
}
|
|
2916
|
+
}
|
|
2917
|
+
} finally {
|
|
2918
|
+
await context.close();
|
|
2919
|
+
}
|
|
2920
|
+
}
|
|
2921
|
+
}
|
|
2922
|
+
} finally {
|
|
2923
|
+
await browser.close();
|
|
2924
|
+
}
|
|
2925
|
+
} finally {
|
|
2926
|
+
await handle.close();
|
|
2927
|
+
}
|
|
2928
|
+
await mkdir(resolved.outputDir, { recursive: true });
|
|
2929
|
+
await writeFile(
|
|
2930
|
+
path2.join(resolved.outputDir, "report.json"),
|
|
2931
|
+
`${JSON.stringify(report, null, 2)}
|
|
2932
|
+
`
|
|
2933
|
+
);
|
|
2934
|
+
const errors = report.issues.filter((issue) => issue.severity === "error");
|
|
2935
|
+
const warnings = report.issues.filter((issue) => issue.severity === "warning");
|
|
2936
|
+
if (errors.length === 0) {
|
|
2937
|
+
console.log(success(`visual check passed; screenshots: ${style.path(report.outputDir)}`));
|
|
2938
|
+
} else {
|
|
2939
|
+
console.error(error(`visual check failed with ${errors.length} error(s).`));
|
|
2940
|
+
}
|
|
2941
|
+
for (const issue of report.issues) {
|
|
2942
|
+
const formatter = issue.severity === "error" ? error : warning;
|
|
2943
|
+
console.log(
|
|
2944
|
+
` ${formatter(issue.severity)} ${issue.preset}/${issue.device}/${issue.component}: ${issue.message}`
|
|
2945
|
+
);
|
|
2946
|
+
}
|
|
2947
|
+
if (warnings.length > 0) {
|
|
2948
|
+
console.log(warning(`visual check completed with ${warnings.length} warning(s).`));
|
|
2949
|
+
}
|
|
2950
|
+
return errors.length === 0;
|
|
2951
|
+
}
|
|
2411
2952
|
async function loadLocalAgentManifest(root) {
|
|
2412
2953
|
const built = await readJsonIfExists(
|
|
2413
2954
|
path2.join(root, "dist", "agent-manifest.json")
|
|
@@ -2769,422 +3310,6 @@ async function createAgentProject(options) {
|
|
|
2769
3310
|
})
|
|
2770
3311
|
});
|
|
2771
3312
|
}
|
|
2772
|
-
function mcpStructured(summary, value) {
|
|
2773
|
-
return {
|
|
2774
|
-
content: [
|
|
2775
|
-
{
|
|
2776
|
-
type: "text",
|
|
2777
|
-
text: summary
|
|
2778
|
-
}
|
|
2779
|
-
],
|
|
2780
|
-
structuredContent: value
|
|
2781
|
-
};
|
|
2782
|
-
}
|
|
2783
|
-
async function startMcpServer() {
|
|
2784
|
-
const server = new McpServer({
|
|
2785
|
-
name: "suda",
|
|
2786
|
-
version: "0.1.0"
|
|
2787
|
-
});
|
|
2788
|
-
server.registerTool(
|
|
2789
|
-
"list_projects",
|
|
2790
|
-
{
|
|
2791
|
-
description: "List Suda projects the current CLI user can access. ALWAYS call this first when the user asks to generate or edit a website. Flow: (1) If `projects` is empty, do NOT call this tool again; instead skip directly to gathering inputs and calling create_project (see its description). (2) If `projects` has one or more entries, you MUST ask the user which project to use before any further action \u2014 even if there is only a single project, present it and ask the user to confirm or pick a different one. Never auto-pick a project on the user's behalf.",
|
|
2792
|
-
inputSchema: {},
|
|
2793
|
-
outputSchema: listProjectsOutputSchema.shape
|
|
2794
|
-
},
|
|
2795
|
-
async () => {
|
|
2796
|
-
const structuredContent = listProjectsOutputSchema.parse({
|
|
2797
|
-
projects: await listAgentProjects()
|
|
2798
|
-
});
|
|
2799
|
-
return mcpStructured("Suda projects accessible to the current user.", structuredContent);
|
|
2800
|
-
}
|
|
2801
|
-
);
|
|
2802
|
-
server.registerTool(
|
|
2803
|
-
"create_project",
|
|
2804
|
-
{
|
|
2805
|
-
description: "Create a new Suda project for the current user. Only call this after list_projects returned an empty list. Conversation flow before calling: (1) Ask the user for the website / brand name in one turn and capture it as `name`. (2) Ask the user for a business introduction; the user is allowed (and expected) to answer over multiple turns \u2014 keep asking follow-up questions and accumulating their answers until they confirm they are done, then concatenate the accumulated text into a single `siteDescription` (trimmed). Do not invent details the user did not provide. Do not call this tool until both fields are confirmed by the user. The project is created with the default theme, starter pages, and a default project contact form. After creation, if the business context suggests better contact fields, call get_contact_form_settings, propose the contact form plan to the user, and only call update_contact_form_settings with confirm:true after they agree. No in-app AI site generation is triggered \u2014 drive page content afterwards via create_page_draft using the returned projectId.",
|
|
2806
|
-
inputSchema: {
|
|
2807
|
-
name: createProjectInputSchema.shape.name.describe(
|
|
2808
|
-
"Website or brand name confirmed by the user in a dedicated turn. Used as the project name and site title."
|
|
2809
|
-
),
|
|
2810
|
-
siteDescription: createProjectInputSchema.shape.siteDescription.describe(
|
|
2811
|
-
"Business introduction the user provided, possibly across multiple turns, then concatenated and trimmed. Captures what the site is about and who it serves. Used to seed default site metadata."
|
|
2812
|
-
)
|
|
2813
|
-
},
|
|
2814
|
-
outputSchema: createProjectOutputSchema.shape
|
|
2815
|
-
},
|
|
2816
|
-
async ({ name, siteDescription }) => {
|
|
2817
|
-
const result = await createAgentProject({ name, siteDescription });
|
|
2818
|
-
const structuredContent = createProjectOutputSchema.parse({
|
|
2819
|
-
projectId: result.projectId,
|
|
2820
|
-
name: result.name,
|
|
2821
|
-
defaultDomain: result.defaultDomain,
|
|
2822
|
-
dashboardUrl: result.dashboardUrl
|
|
2823
|
-
});
|
|
2824
|
-
return mcpStructured("Created Suda project.", structuredContent);
|
|
2825
|
-
}
|
|
2826
|
-
);
|
|
2827
|
-
server.registerTool(
|
|
2828
|
-
"list_themes",
|
|
2829
|
-
{
|
|
2830
|
-
description: "List themes visible to the current Suda CLI user.",
|
|
2831
|
-
inputSchema: {
|
|
2832
|
-
projectId: z.string().optional(),
|
|
2833
|
-
themeRoot: z.string().optional()
|
|
2834
|
-
},
|
|
2835
|
-
outputSchema: {
|
|
2836
|
-
themes: z.array(
|
|
2837
|
-
z.object({
|
|
2838
|
-
key: z.string(),
|
|
2839
|
-
name: z.string(),
|
|
2840
|
-
version: z.string(),
|
|
2841
|
-
description: z.string().nullable().optional(),
|
|
2842
|
-
categories: z.array(z.string()).optional(),
|
|
2843
|
-
preview: z.string().optional(),
|
|
2844
|
-
active: z.boolean().optional()
|
|
2845
|
-
})
|
|
2846
|
-
)
|
|
2847
|
-
}
|
|
2848
|
-
},
|
|
2849
|
-
async ({ projectId, themeRoot }) => {
|
|
2850
|
-
const structuredContent = { themes: await listAgentThemes({ projectId, themeRoot }) };
|
|
2851
|
-
return mcpStructured("Available Suda themes.", structuredContent);
|
|
2852
|
-
}
|
|
2853
|
-
);
|
|
2854
|
-
server.registerTool(
|
|
2855
|
-
"describe_theme",
|
|
2856
|
-
{
|
|
2857
|
-
description: "Return the raw Suda agent manifest for a theme.",
|
|
2858
|
-
inputSchema: {
|
|
2859
|
-
theme: z.string(),
|
|
2860
|
-
version: z.string().optional(),
|
|
2861
|
-
projectId: z.string().optional(),
|
|
2862
|
-
themeRoot: z.string().optional()
|
|
2863
|
-
},
|
|
2864
|
-
outputSchema: {
|
|
2865
|
-
agentManifest: z.unknown()
|
|
2866
|
-
}
|
|
2867
|
-
},
|
|
2868
|
-
async ({ theme, version, projectId, themeRoot }) => {
|
|
2869
|
-
const structuredContent = {
|
|
2870
|
-
agentManifest: await fetchAgentManifest(theme, { version, projectId, themeRoot })
|
|
2871
|
-
};
|
|
2872
|
-
return mcpStructured("Raw Suda theme agent manifest.", structuredContent);
|
|
2873
|
-
}
|
|
2874
|
-
);
|
|
2875
|
-
server.registerTool(
|
|
2876
|
-
"get_page_schema",
|
|
2877
|
-
{
|
|
2878
|
-
description: "Return the schema for configuring existing Suda theme sections as page content. Use only the returned section types, fields, defaultProps, and slot allowedComponents; never create theme source code or new component types. If the theme has a contact section, generate only the section placement/content props; public form fields and delivery integrations are project contact form settings exposed to themes as metadata.contactForm. Use get_contact_form_settings and update_contact_form_settings for user-approved contact form changes.",
|
|
2879
|
-
inputSchema: {
|
|
2880
|
-
theme: z.string(),
|
|
2881
|
-
version: z.string().optional(),
|
|
2882
|
-
projectId: z.string().optional(),
|
|
2883
|
-
themeRoot: z.string().optional(),
|
|
2884
|
-
includeExample: z.boolean().optional()
|
|
2885
|
-
},
|
|
2886
|
-
outputSchema: agentPageSchemaOutputSchema.shape
|
|
2887
|
-
},
|
|
2888
|
-
async ({ theme, version, projectId, themeRoot, includeExample }) => {
|
|
2889
|
-
const manifest = await fetchAgentManifest(theme, { version, projectId, themeRoot });
|
|
2890
|
-
const structuredContent = createSudaPageAgentRuntime(manifest).getPageSchemaOutput({
|
|
2891
|
-
includeExample: includeExample === true
|
|
2892
|
-
});
|
|
2893
|
-
return mcpStructured("Suda page content configuration schema.", structuredContent);
|
|
2894
|
-
}
|
|
2895
|
-
);
|
|
2896
|
-
server.registerTool(
|
|
2897
|
-
"get_section_schema",
|
|
2898
|
-
{
|
|
2899
|
-
description: "Return the schema for configuring one existing section in a theme. Use the returned fields, defaultProps, and slot allowedComponents to set props; do not design or add theme components. For contact sections, use the schema for presentation props only and do not hardcode form field metadata, notification channels, webhook URLs, or recipients into page content. Use contact form tools for project-level contact settings.",
|
|
2900
|
-
inputSchema: {
|
|
2901
|
-
theme: z.string(),
|
|
2902
|
-
section: z.string(),
|
|
2903
|
-
version: z.string().optional(),
|
|
2904
|
-
projectId: z.string().optional(),
|
|
2905
|
-
themeRoot: z.string().optional()
|
|
2906
|
-
},
|
|
2907
|
-
outputSchema: agentComponentOutputSchema.shape
|
|
2908
|
-
},
|
|
2909
|
-
async ({ theme, section, version, projectId, themeRoot }) => {
|
|
2910
|
-
const manifest = await fetchAgentManifest(theme, { version, projectId, themeRoot });
|
|
2911
|
-
const structuredContent = createSectionSchema(manifest, section);
|
|
2912
|
-
return mcpStructured("Suda section configuration schema.", structuredContent);
|
|
2913
|
-
}
|
|
2914
|
-
);
|
|
2915
|
-
server.registerTool(
|
|
2916
|
-
"validate_page_config",
|
|
2917
|
-
{
|
|
2918
|
-
description: "Validate Suda page content JSON against an existing theme schema.",
|
|
2919
|
-
inputSchema: {
|
|
2920
|
-
theme: z.string(),
|
|
2921
|
-
data: z.unknown(),
|
|
2922
|
-
version: z.string().optional(),
|
|
2923
|
-
projectId: z.string().optional(),
|
|
2924
|
-
themeRoot: z.string().optional()
|
|
2925
|
-
},
|
|
2926
|
-
outputSchema: agentValidationResultSchema.shape
|
|
2927
|
-
},
|
|
2928
|
-
async ({ theme, data, version, projectId, themeRoot }) => {
|
|
2929
|
-
const manifest = await fetchAgentManifest(theme, { version, projectId, themeRoot });
|
|
2930
|
-
const structuredContent = agentValidationResultSchema.parse(
|
|
2931
|
-
createSudaPageAgentRuntime(manifest).validatePageContent(data)
|
|
2932
|
-
);
|
|
2933
|
-
return mcpStructured("Suda page content validation result.", structuredContent);
|
|
2934
|
-
}
|
|
2935
|
-
);
|
|
2936
|
-
server.registerTool(
|
|
2937
|
-
"get_contact_form_settings",
|
|
2938
|
-
{
|
|
2939
|
-
description: "Read project contact form settings. Use this before planning contact form changes. The result includes editable field metadata, masked notification/webhook URLs, and plan features/limits. Secrets are never returned.",
|
|
2940
|
-
inputSchema: {
|
|
2941
|
-
projectId: z.string()
|
|
2942
|
-
},
|
|
2943
|
-
outputSchema: contactFormOutputSchema.shape
|
|
2944
|
-
},
|
|
2945
|
-
async ({ projectId }) => {
|
|
2946
|
-
const auth = await requireCliBaseUrl();
|
|
2947
|
-
const structuredContent = contactFormOutputSchema.parse({
|
|
2948
|
-
contactForm: await getRemoteContactForm(auth, projectId)
|
|
2949
|
-
});
|
|
2950
|
-
return mcpStructured("Suda contact form settings.", structuredContent);
|
|
2951
|
-
}
|
|
2952
|
-
);
|
|
2953
|
-
server.registerTool(
|
|
2954
|
-
"update_contact_form_settings",
|
|
2955
|
-
{
|
|
2956
|
-
description: "Plan or update project contact form settings. Use this when the user asks to change contact form fields, labels, validation, notification channels, or webhooks. Supported field types are text, textarea, checkbox, radio, and select. Conversation flow: first call get_contact_form_settings, then propose the exact draft to the user. Call this tool without confirm or with confirm:false to return the impact summary; only call again with confirm:true after the user explicitly agrees. Do not invent webhook URLs, notification recipients, or external delivery secrets. When updating an existing webhook, omit url to keep the stored secret; new webhooks need a user-provided url.",
|
|
2957
|
-
inputSchema: updateContactFormInputSchema.shape,
|
|
2958
|
-
outputSchema: {
|
|
2959
|
-
status: z.enum(["needs_confirmation", "updated"]),
|
|
2960
|
-
projectId: z.string(),
|
|
2961
|
-
impact: z.string().optional(),
|
|
2962
|
-
draft: updateContactFormInputSchema.omit({ confirm: true }).optional(),
|
|
2963
|
-
contactForm: contactFormViewSchema.optional()
|
|
2964
|
-
}
|
|
2965
|
-
},
|
|
2966
|
-
async (input) => {
|
|
2967
|
-
const parsed = updateContactFormInputSchema.parse(input);
|
|
2968
|
-
const auth = await requireCliBaseUrl();
|
|
2969
|
-
const structuredContent = updateContactFormOutputSchema.parse(
|
|
2970
|
-
await performUpdateContactForm(auth, parsed)
|
|
2971
|
-
);
|
|
2972
|
-
return mcpStructured(
|
|
2973
|
-
structuredContent.status === "needs_confirmation" ? "Updating this contact form requires explicit confirmation." : "Updated Suda contact form settings.",
|
|
2974
|
-
structuredContent
|
|
2975
|
-
);
|
|
2976
|
-
}
|
|
2977
|
-
);
|
|
2978
|
-
server.registerTool(
|
|
2979
|
-
"get_theme_settings",
|
|
2980
|
-
{
|
|
2981
|
-
description: 'Read project theme layout root settings. Use this before planning theme style changes. The result includes manifest.designSystem presets, current rootProps, and the layout root schema. To use a preset, set rootProps.designSystem to { presetId }. To fully customize colors/radius, set rootProps.designSystem to { presetId: "custom", tokens: { colors, radius } } using every token from designSystem.',
|
|
2982
|
-
inputSchema: {
|
|
2983
|
-
projectId: z.string()
|
|
2984
|
-
},
|
|
2985
|
-
outputSchema: themeSettingsOutputSchema.shape
|
|
2986
|
-
},
|
|
2987
|
-
async ({ projectId }) => {
|
|
2988
|
-
const auth = await requireCliBaseUrl();
|
|
2989
|
-
const structuredContent = themeSettingsOutputSchema.parse({
|
|
2990
|
-
themeSettings: await getRemoteThemeSettings(auth, projectId)
|
|
2991
|
-
});
|
|
2992
|
-
return mcpStructured("Suda theme settings.", structuredContent);
|
|
2993
|
-
}
|
|
2994
|
-
);
|
|
2995
|
-
server.registerTool(
|
|
2996
|
-
"update_theme_settings",
|
|
2997
|
-
{
|
|
2998
|
-
description: "Plan or update project theme layout root settings. Use this for choosing a designSystem preset or saving custom design tokens for colors and radius. Conversation flow: first call get_theme_settings, then propose the exact rootProps changes to the user. Call this tool without confirm or with confirm:false to return the impact summary; only call again with confirm:true after the user explicitly agrees. Do not create theme source code or introduce a second token system.",
|
|
2999
|
-
inputSchema: updateThemeSettingsInputSchema.shape,
|
|
3000
|
-
outputSchema: {
|
|
3001
|
-
status: z.enum(["needs_confirmation", "updated"]),
|
|
3002
|
-
projectId: z.string(),
|
|
3003
|
-
impact: z.string().optional(),
|
|
3004
|
-
draft: updateThemeSettingsInputSchema.omit({ confirm: true }).optional(),
|
|
3005
|
-
themeSettings: themeSettingsViewSchema.optional()
|
|
3006
|
-
}
|
|
3007
|
-
},
|
|
3008
|
-
async (input) => {
|
|
3009
|
-
const parsed = updateThemeSettingsInputSchema.parse(input);
|
|
3010
|
-
const auth = await requireCliBaseUrl();
|
|
3011
|
-
const structuredContent = updateThemeSettingsOutputSchema.parse(
|
|
3012
|
-
await performUpdateThemeSettings(auth, parsed)
|
|
3013
|
-
);
|
|
3014
|
-
return mcpStructured(
|
|
3015
|
-
structuredContent.status === "needs_confirmation" ? "Updating these theme settings requires explicit confirmation." : "Updated Suda theme settings.",
|
|
3016
|
-
structuredContent
|
|
3017
|
-
);
|
|
3018
|
-
}
|
|
3019
|
-
);
|
|
3020
|
-
server.registerTool(
|
|
3021
|
-
"create_page_draft",
|
|
3022
|
-
{
|
|
3023
|
-
description: "Create a new workspace page draft from Suda page content JSON that configures existing theme sections. When creating a complete website, mark exactly one primary landing page with isHome: true so it becomes the public homepage at `/`. After this tool succeeds, tell the user the page is only a draft, explain that they can review it in the SudaCloud workspace by navigating to Pages, opening the created page, previewing it, then clicking Publish, and ask whether they want you to publish it now. Do not call publish_page until the user explicitly confirms.",
|
|
3024
|
-
inputSchema: {
|
|
3025
|
-
projectId: z.string(),
|
|
3026
|
-
title: z.string(),
|
|
3027
|
-
slug: z.string(),
|
|
3028
|
-
isHome: z.boolean().optional().describe("Set true for exactly one page that should serve as the public homepage at `/`."),
|
|
3029
|
-
theme: z.string(),
|
|
3030
|
-
data: z.unknown(),
|
|
3031
|
-
version: z.string().optional()
|
|
3032
|
-
},
|
|
3033
|
-
outputSchema: createPageDraftOutputSchema
|
|
3034
|
-
},
|
|
3035
|
-
async ({ projectId, title, slug, isHome, theme, data, version }) => {
|
|
3036
|
-
const manifest = await fetchAgentManifest(theme, { version, projectId });
|
|
3037
|
-
const prepared = createSudaPageAgentRuntime(manifest).preparePageData(data);
|
|
3038
|
-
if (!prepared.validation.valid) {
|
|
3039
|
-
return mcpStructured(
|
|
3040
|
-
"Suda page content validation failed. Fix issues before creating a draft.",
|
|
3041
|
-
createPageDraftOutputSchema.parse({
|
|
3042
|
-
status: "invalid",
|
|
3043
|
-
valid: false,
|
|
3044
|
-
issues: prepared.validation.issues
|
|
3045
|
-
})
|
|
3046
|
-
);
|
|
3047
|
-
}
|
|
3048
|
-
const auth = await requireCliBaseUrl();
|
|
3049
|
-
const response = await createRemotePageDraft(auth, {
|
|
3050
|
-
projectId,
|
|
3051
|
-
title,
|
|
3052
|
-
slug,
|
|
3053
|
-
isHome,
|
|
3054
|
-
themeKey: manifest.manifest.key,
|
|
3055
|
-
themeVersion: manifest.manifest.version,
|
|
3056
|
-
data
|
|
3057
|
-
});
|
|
3058
|
-
return mcpStructured(
|
|
3059
|
-
"Created Suda page draft. Tell the user they can review it in the SudaCloud workspace under Pages by opening the created page, previewing it, then clicking Publish. Ask whether they want you to publish it now before calling publish_page.",
|
|
3060
|
-
createPageDraftOutputSchema.parse({ status: "created", ...response })
|
|
3061
|
-
);
|
|
3062
|
-
}
|
|
3063
|
-
);
|
|
3064
|
-
server.registerTool(
|
|
3065
|
-
"update_page_draft",
|
|
3066
|
-
{
|
|
3067
|
-
description: "Update an existing workspace page draft by slug from Suda page content JSON that configures existing theme sections.",
|
|
3068
|
-
inputSchema: {
|
|
3069
|
-
projectId: z.string(),
|
|
3070
|
-
slug: z.string(),
|
|
3071
|
-
theme: z.string(),
|
|
3072
|
-
data: z.unknown(),
|
|
3073
|
-
version: z.string().optional()
|
|
3074
|
-
},
|
|
3075
|
-
outputSchema: updatePageDraftOutputSchema
|
|
3076
|
-
},
|
|
3077
|
-
async ({ projectId, slug, theme, data, version }) => {
|
|
3078
|
-
const manifest = await fetchAgentManifest(theme, { version, projectId });
|
|
3079
|
-
const prepared = createSudaPageAgentRuntime(manifest).preparePageData(data);
|
|
3080
|
-
if (!prepared.validation.valid) {
|
|
3081
|
-
return mcpStructured(
|
|
3082
|
-
"Suda page content validation failed. Fix issues before updating a draft.",
|
|
3083
|
-
updatePageDraftOutputSchema.parse({
|
|
3084
|
-
status: "invalid",
|
|
3085
|
-
valid: false,
|
|
3086
|
-
issues: prepared.validation.issues
|
|
3087
|
-
})
|
|
3088
|
-
);
|
|
3089
|
-
}
|
|
3090
|
-
const auth = await requireCliBaseUrl();
|
|
3091
|
-
const response = await updateRemotePageDraft(auth, {
|
|
3092
|
-
projectId,
|
|
3093
|
-
slug,
|
|
3094
|
-
themeKey: manifest.manifest.key,
|
|
3095
|
-
themeVersion: manifest.manifest.version,
|
|
3096
|
-
data
|
|
3097
|
-
});
|
|
3098
|
-
return mcpStructured(
|
|
3099
|
-
"Updated Suda page draft.",
|
|
3100
|
-
updatePageDraftOutputSchema.parse({ status: "updated", ...response })
|
|
3101
|
-
);
|
|
3102
|
-
}
|
|
3103
|
-
);
|
|
3104
|
-
server.registerTool(
|
|
3105
|
-
"delete_page",
|
|
3106
|
-
{
|
|
3107
|
-
description: "Delete a Suda page by slug. This is destructive: first call without confirm to get the exact project, slug, and impact; only call again with confirm: true after the user explicitly agrees.",
|
|
3108
|
-
inputSchema: {
|
|
3109
|
-
projectId: z.string(),
|
|
3110
|
-
slug: z.string(),
|
|
3111
|
-
confirm: z.boolean().optional()
|
|
3112
|
-
},
|
|
3113
|
-
outputSchema: destructivePageOperationOutputSchema
|
|
3114
|
-
},
|
|
3115
|
-
async ({ projectId, slug, confirm }) => {
|
|
3116
|
-
const auth = await requireCliBaseUrl();
|
|
3117
|
-
const structuredContent = destructivePageOperationOutputSchema.parse(
|
|
3118
|
-
await performConfirmedPageOperation(auth, {
|
|
3119
|
-
operation: "delete",
|
|
3120
|
-
projectId,
|
|
3121
|
-
slug,
|
|
3122
|
-
confirm
|
|
3123
|
-
})
|
|
3124
|
-
);
|
|
3125
|
-
return mcpStructured(
|
|
3126
|
-
structuredContent.status === "needs_confirmation" ? "Deleting this Suda page requires explicit confirmation." : "Deleted Suda page.",
|
|
3127
|
-
structuredContent
|
|
3128
|
-
);
|
|
3129
|
-
}
|
|
3130
|
-
);
|
|
3131
|
-
server.registerTool(
|
|
3132
|
-
"publish_page",
|
|
3133
|
-
{
|
|
3134
|
-
description: "Publish the current saved draft for a Suda page by slug. This affects the public site: first call without confirm to get the exact project, slug, and impact; only call again with confirm: true after the user explicitly agrees. If the user wants to review manually first, tell them to open the SudaCloud workspace, navigate to Pages, open the created page, preview it, then click Publish.",
|
|
3135
|
-
inputSchema: {
|
|
3136
|
-
projectId: z.string(),
|
|
3137
|
-
slug: z.string(),
|
|
3138
|
-
confirm: z.boolean().optional()
|
|
3139
|
-
},
|
|
3140
|
-
outputSchema: destructivePageOperationOutputSchema
|
|
3141
|
-
},
|
|
3142
|
-
async ({ projectId, slug, confirm }) => {
|
|
3143
|
-
const auth = await requireCliBaseUrl();
|
|
3144
|
-
const structuredContent = destructivePageOperationOutputSchema.parse(
|
|
3145
|
-
await performConfirmedPageOperation(auth, {
|
|
3146
|
-
operation: "publish",
|
|
3147
|
-
projectId,
|
|
3148
|
-
slug,
|
|
3149
|
-
confirm
|
|
3150
|
-
})
|
|
3151
|
-
);
|
|
3152
|
-
return mcpStructured(
|
|
3153
|
-
structuredContent.status === "needs_confirmation" ? "Publishing this Suda page requires explicit confirmation." : "Published Suda page.",
|
|
3154
|
-
structuredContent
|
|
3155
|
-
);
|
|
3156
|
-
}
|
|
3157
|
-
);
|
|
3158
|
-
server.registerTool(
|
|
3159
|
-
"activate_theme",
|
|
3160
|
-
{
|
|
3161
|
-
description: "Activate a theme for a Suda project. This affects how public pages render and may seed starter content on first activation: first call without confirm to get the exact project, theme, and impact; only call again with confirm: true after the user explicitly agrees.",
|
|
3162
|
-
inputSchema: {
|
|
3163
|
-
projectId: z.string(),
|
|
3164
|
-
themeKey: z.string(),
|
|
3165
|
-
themeVersion: z.string().optional(),
|
|
3166
|
-
confirm: z.boolean().optional()
|
|
3167
|
-
},
|
|
3168
|
-
outputSchema: activateThemeOutputSchema
|
|
3169
|
-
},
|
|
3170
|
-
async ({ projectId, themeKey, themeVersion, confirm }) => {
|
|
3171
|
-
const auth = await requireCliBaseUrl();
|
|
3172
|
-
const structuredContent = activateThemeOutputSchema.parse(
|
|
3173
|
-
await performActivateTheme(auth, {
|
|
3174
|
-
projectId,
|
|
3175
|
-
themeKey,
|
|
3176
|
-
themeVersion,
|
|
3177
|
-
confirm
|
|
3178
|
-
})
|
|
3179
|
-
);
|
|
3180
|
-
return mcpStructured(
|
|
3181
|
-
structuredContent.status === "needs_confirmation" ? "Activating this Suda theme requires explicit confirmation." : "Activated Suda theme.",
|
|
3182
|
-
structuredContent
|
|
3183
|
-
);
|
|
3184
|
-
}
|
|
3185
|
-
);
|
|
3186
|
-
await server.connect(new StdioServerTransport());
|
|
3187
|
-
}
|
|
3188
3313
|
async function writeThemeArtifacts(theme) {
|
|
3189
3314
|
const dist = path2.join(theme.root, "dist");
|
|
3190
3315
|
const agentManifest = createThemeAgentManifest(theme.module);
|
|
@@ -3675,6 +3800,24 @@ function buildProgram() {
|
|
|
3675
3800
|
).option("--output <path>", "Output PNG path relative to theme root. Requires exactly one device.").option("--width <px>", "Override viewport width in pixels.").option("--height <px>", "Override viewport height in pixels.").option("--full-page", "Capture the full page instead of the viewport.").option("--port <port>", "Preview server port used during capture.", "4178").action(async (options) => {
|
|
3676
3801
|
await screenshotTheme(resolveThemeRoot(options), options);
|
|
3677
3802
|
});
|
|
3803
|
+
theme.command("visual-check").description(
|
|
3804
|
+
"Render every theme page component across presets and viewports, then report token contrast, semantic surface colors, overflow, and layout overlays."
|
|
3805
|
+
).option("--theme-root <path>", "Theme source/artifact root.").option(
|
|
3806
|
+
"--device <device>",
|
|
3807
|
+
"Device to audit: desktop, tablet, mobile. Repeat or use comma-separated values.",
|
|
3808
|
+
collectOption,
|
|
3809
|
+
[]
|
|
3810
|
+
).option(
|
|
3811
|
+
"--preset <id>",
|
|
3812
|
+
"Design preset to audit. Repeat or use comma-separated values; defaults to every theme preset.",
|
|
3813
|
+
collectOption,
|
|
3814
|
+
[]
|
|
3815
|
+
).option("--output <path>", "Output directory relative to theme root.").option("--full-page", "Capture full-page screenshots instead of viewport screenshots.").option("--port <port>", "Preview server port used during visual checking.", "4179").action(async (options) => {
|
|
3816
|
+
const ok = await runThemeVisualCheck(resolveThemeRoot(options), options);
|
|
3817
|
+
if (!ok) {
|
|
3818
|
+
process.exitCode = 1;
|
|
3819
|
+
}
|
|
3820
|
+
});
|
|
3678
3821
|
theme.command("publish").description("Upload artifact to S3 and upsert ThemePackage/ThemeVersion.").option("--theme-root <path>", "Theme source/artifact root.").option("--skip-build", "Publish existing dist files without rebuilding.").option("--host <host>", "Publish to this authenticated SudaCloud workspace host.").option(
|
|
3679
3822
|
"--force",
|
|
3680
3823
|
"Development recovery only: clear the existing themes/<key>/<version>/ prefix and ThemeVersion row before republishing. Re-published clients pinned to this version are unavailable until the new publish completes."
|
|
@@ -3746,9 +3889,6 @@ function buildProgram() {
|
|
|
3746
3889
|
const manifest = await fetchAgentManifest(options.theme, options);
|
|
3747
3890
|
printJson(createSectionSchema(manifest, options.section));
|
|
3748
3891
|
});
|
|
3749
|
-
program.command("mcp").description("Run the Suda local MCP server over stdio.").action(async () => {
|
|
3750
|
-
await startMcpServer();
|
|
3751
|
-
});
|
|
3752
3892
|
const hostCmd = program.command("host").description("Manage the current SudaCloud workspace host.");
|
|
3753
3893
|
hostCmd.command("current").description("Show the current authenticated workspace host.").action(async () => {
|
|
3754
3894
|
await showCurrentHost();
|