@sudajs/cli 0.8.1 → 0.9.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 +73 -2
- package/dist/index.js +270 -266
- package/dist/index.js.map +1 -1
- package/package.json +4 -4
- package/templates/theme/AGENTS.md +2 -1
- package/templates/theme/README.md +11 -2
- package/templates/theme/src/manifest.ts +2 -2
- package/templates/theme/src/sections.tsx +1 -1
- package/templates/theme/src/templates.ts +1 -1
- /package/templates/theme/{src/assets → assets/brand}/suda-logo.svg +0 -0
package/dist/index.js
CHANGED
|
@@ -8,7 +8,7 @@ import { fileURLToPath, pathToFileURL } from 'url';
|
|
|
8
8
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
9
9
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
10
10
|
import { createThemeAgentManifest, createAgentPageSchemaOutput, checkThemeModule, formatThemeCheckResult, validateAgentPageContentWithManifest, agentValidationResultSchema, findAgentSection, createAgentComponentSchemaOutput, agentPageSchemaOutputSchema, agentComponentOutputSchema } from '@sudajs/theme-engine';
|
|
11
|
-
import { themeManifestSchema
|
|
11
|
+
import { themeManifestSchema } from '@sudajs/theme-engine/server';
|
|
12
12
|
import { Command } from 'commander';
|
|
13
13
|
import { build } from 'esbuild';
|
|
14
14
|
import { z } from 'zod';
|
|
@@ -409,6 +409,10 @@ function createThemeObjectKey(themeKey, version, relativePath) {
|
|
|
409
409
|
function resolveThemeRoot(options) {
|
|
410
410
|
return path2.resolve(options.themeRoot ?? process.cwd());
|
|
411
411
|
}
|
|
412
|
+
function collectOption(value, previous) {
|
|
413
|
+
previous.push(value);
|
|
414
|
+
return previous;
|
|
415
|
+
}
|
|
412
416
|
async function pathExists(filePath) {
|
|
413
417
|
try {
|
|
414
418
|
await stat(filePath);
|
|
@@ -491,12 +495,12 @@ ${issues}`);
|
|
|
491
495
|
if (!module.manifest.key || !module.manifest.version || !module.manifest.name) {
|
|
492
496
|
throw new Error("manifest.key, manifest.version and manifest.name are required.");
|
|
493
497
|
}
|
|
494
|
-
if (module.manifest.entry !== "
|
|
495
|
-
throw new Error('manifest.entry must be artifact-local: "
|
|
498
|
+
if (module.manifest.entry !== "index.js") {
|
|
499
|
+
throw new Error('manifest.entry must be artifact-local: "index.js".');
|
|
496
500
|
}
|
|
497
|
-
if (module.manifest.clientEntry !== void 0 && module.manifest.clientEntry !== "
|
|
501
|
+
if (module.manifest.clientEntry !== void 0 && module.manifest.clientEntry !== "runtime.client.js") {
|
|
498
502
|
throw new Error(
|
|
499
|
-
'manifest.clientEntry must be artifact-local: "
|
|
503
|
+
'manifest.clientEntry must be artifact-local: "runtime.client.js" when provided.'
|
|
500
504
|
);
|
|
501
505
|
}
|
|
502
506
|
assertRecord(module.pageConfig?.components, "pageConfig.components");
|
|
@@ -631,7 +635,7 @@ async function buildViteTheme(root) {
|
|
|
631
635
|
}
|
|
632
636
|
}
|
|
633
637
|
});
|
|
634
|
-
await
|
|
638
|
+
await copyThemeAssets(root);
|
|
635
639
|
const stylesheetEntry = path2.join(root, ".suda-build", "styles-entry.ts");
|
|
636
640
|
await mkdir(path2.dirname(stylesheetEntry), { recursive: true });
|
|
637
641
|
await writeFile(stylesheetEntry, 'import "../src/styles.css";\n', "utf8");
|
|
@@ -659,8 +663,8 @@ async function buildViteTheme(root) {
|
|
|
659
663
|
});
|
|
660
664
|
await rm(path2.join(root, "dist", "chunks", "theme-styles.js"), { force: true });
|
|
661
665
|
}
|
|
662
|
-
async function
|
|
663
|
-
const sourceAssets = path2.join(root, "
|
|
666
|
+
async function copyThemeAssets(root) {
|
|
667
|
+
const sourceAssets = path2.join(root, "assets");
|
|
664
668
|
if (!await pathExists(sourceAssets)) {
|
|
665
669
|
return;
|
|
666
670
|
}
|
|
@@ -827,13 +831,17 @@ function runFooterContractCheck(theme, renderer) {
|
|
|
827
831
|
return issues;
|
|
828
832
|
}
|
|
829
833
|
var __testUtils = {
|
|
834
|
+
createThemeProjectRequire,
|
|
830
835
|
createRemotePageDraft,
|
|
831
836
|
findAnchorTagByHref,
|
|
832
837
|
loadThemeScopedRenderer,
|
|
838
|
+
normalizePlaywrightModule,
|
|
839
|
+
normalizeSharpModule,
|
|
833
840
|
initThemeWithKey,
|
|
834
841
|
performActivateTheme,
|
|
835
842
|
performConfirmedPageOperation,
|
|
836
843
|
renderDevStarterPageHtml,
|
|
844
|
+
resolveScreenshotOptions,
|
|
837
845
|
slugifyThemeName,
|
|
838
846
|
validateThemeKey,
|
|
839
847
|
validateThemeName,
|
|
@@ -843,7 +851,8 @@ var __testUtils = {
|
|
|
843
851
|
async function runThemeCheck(theme) {
|
|
844
852
|
const result = checkThemeModule(theme.module);
|
|
845
853
|
const footerIssues = result.ok ? runFooterContractCheck(theme, await loadThemeScopedRenderer(theme.root)) : [];
|
|
846
|
-
const
|
|
854
|
+
const previewIssues = await runPreviewContractCheck(theme);
|
|
855
|
+
const ok = result.ok && footerIssues.length === 0 && previewIssues.length === 0;
|
|
847
856
|
const lines = [formatThemeCheckResult(result)];
|
|
848
857
|
if (footerIssues.length === 0 && result.ok) {
|
|
849
858
|
lines.push("Footer contract check passed: whiteLabel and ICP metadata render correctly.");
|
|
@@ -853,6 +862,14 @@ async function runThemeCheck(theme) {
|
|
|
853
862
|
}
|
|
854
863
|
lines.push(`Footer contract check: ${footerIssues.length} error(s).`);
|
|
855
864
|
}
|
|
865
|
+
if (previewIssues.length === 0) {
|
|
866
|
+
lines.push("Preview contract check passed: desktop, tablet, and mobile screenshots exist.");
|
|
867
|
+
} else {
|
|
868
|
+
for (const issue of previewIssues) {
|
|
869
|
+
lines.push(` error assets.preview: ${issue}`);
|
|
870
|
+
}
|
|
871
|
+
lines.push(`Preview contract check: ${previewIssues.length} error(s).`);
|
|
872
|
+
}
|
|
856
873
|
const text = lines.join("\n");
|
|
857
874
|
if (ok) {
|
|
858
875
|
console.log(text);
|
|
@@ -861,7 +878,27 @@ async function runThemeCheck(theme) {
|
|
|
861
878
|
}
|
|
862
879
|
return ok;
|
|
863
880
|
}
|
|
881
|
+
async function runPreviewContractCheck(theme) {
|
|
882
|
+
const issues = [];
|
|
883
|
+
for (const relativePath of REQUIRED_PREVIEW_ARTIFACTS) {
|
|
884
|
+
const filePath = path2.join(theme.root, "dist", relativePath);
|
|
885
|
+
try {
|
|
886
|
+
const info = await stat(filePath);
|
|
887
|
+
if (info.size <= 0) {
|
|
888
|
+
issues.push(`${relativePath} is empty. Run \`suda theme capture\` and rebuild.`);
|
|
889
|
+
}
|
|
890
|
+
} catch {
|
|
891
|
+
issues.push(`Missing ${relativePath}. Run \`suda theme capture\`, then \`suda theme build\`.`);
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
return issues;
|
|
895
|
+
}
|
|
864
896
|
async function watchTheme(root, port) {
|
|
897
|
+
const handle = await startViteDevPreviewServer(root, port);
|
|
898
|
+
console.log(`previewing Vite theme at ${handle.url}`);
|
|
899
|
+
await new Promise(() => void 0);
|
|
900
|
+
}
|
|
901
|
+
async function startViteDevPreviewServer(root, port) {
|
|
865
902
|
const viteConfig = await findViteConfig(root);
|
|
866
903
|
const vite = await loadThemeVite(root);
|
|
867
904
|
const host = "127.0.0.1";
|
|
@@ -876,8 +913,11 @@ async function watchTheme(root, port) {
|
|
|
876
913
|
});
|
|
877
914
|
await server.listen(port);
|
|
878
915
|
const url = server.resolvedUrls?.local.find((candidate) => candidate.includes("127.0.0.1")) ?? server.resolvedUrls?.local[0] ?? `http://${host}:${port}/`;
|
|
879
|
-
|
|
880
|
-
|
|
916
|
+
return {
|
|
917
|
+
url: url.replace(/\/$/, ""),
|
|
918
|
+
port,
|
|
919
|
+
close: () => server.close()
|
|
920
|
+
};
|
|
881
921
|
}
|
|
882
922
|
function createSudaPreviewVitePlugin(root) {
|
|
883
923
|
return {
|
|
@@ -888,7 +928,7 @@ function createSudaPreviewVitePlugin(root) {
|
|
|
888
928
|
const url = new URL(request.url ?? "/", "http://localhost");
|
|
889
929
|
const assetPrefixMatch = url.pathname.match(/^\/api\/themes\/[^/]+\/[^/]+\/assets\/(.+)$/);
|
|
890
930
|
if (assetPrefixMatch?.[1]) {
|
|
891
|
-
request.url = `/
|
|
931
|
+
request.url = `/assets/${assetPrefixMatch[1]}${url.search}`;
|
|
892
932
|
next();
|
|
893
933
|
return;
|
|
894
934
|
}
|
|
@@ -906,11 +946,15 @@ function createSudaPreviewVitePlugin(root) {
|
|
|
906
946
|
response.end("No starter pages declared by this theme.");
|
|
907
947
|
return;
|
|
908
948
|
}
|
|
909
|
-
const
|
|
910
|
-
|
|
949
|
+
const starter2 = findStarterPage(theme, fallbackSlug);
|
|
950
|
+
if (!starter2) {
|
|
951
|
+
response.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
|
|
952
|
+
response.end(`Unknown starter page: ${fallbackSlug}`);
|
|
953
|
+
return;
|
|
954
|
+
}
|
|
911
955
|
const html2 = await server.transformIndexHtml(
|
|
912
956
|
url.pathname,
|
|
913
|
-
|
|
957
|
+
renderDevStarterPageHtml(theme, starter2, renderer)
|
|
914
958
|
);
|
|
915
959
|
response.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
916
960
|
response.end(html2);
|
|
@@ -979,10 +1023,6 @@ function createPreviewAssetResolver(resolveAssetPath) {
|
|
|
979
1023
|
});
|
|
980
1024
|
};
|
|
981
1025
|
}
|
|
982
|
-
function createPreviewThemeAssetUrl(themeKey, themeVersion, relativePath) {
|
|
983
|
-
const normalizedRelativePath = relativePath.replace(/^\/+/, "");
|
|
984
|
-
return `${PREVIEW_PUBLIC_BASE_PATH}/${themeKey}/${themeVersion}/assets/${normalizedRelativePath}?v=${encodeURIComponent(themeVersion)}`;
|
|
985
|
-
}
|
|
986
1026
|
async function loadThemeScopedRenderer(themeRoot) {
|
|
987
1027
|
const themeRequire = createRequire(path2.join(themeRoot, "package.json"));
|
|
988
1028
|
const reactPath = themeRequire.resolve("react");
|
|
@@ -1001,45 +1041,6 @@ async function loadThemeScopedRenderer(themeRoot) {
|
|
|
1001
1041
|
renderToString: reactDomServerModule.renderToString
|
|
1002
1042
|
};
|
|
1003
1043
|
}
|
|
1004
|
-
function renderStarterPageHtml(theme, resolved, page, renderer) {
|
|
1005
|
-
const chrome = renderer.extractLayoutChrome(theme.module.defaultLayout);
|
|
1006
|
-
const metadata = {
|
|
1007
|
-
resolveAssetUrl: createPreviewAssetResolver(renderer.resolveAssetPath)
|
|
1008
|
-
};
|
|
1009
|
-
const body = renderer.renderToString(
|
|
1010
|
-
renderer.createElement(renderer.ThemeRender, {
|
|
1011
|
-
theme: theme.module,
|
|
1012
|
-
pageData: page.data,
|
|
1013
|
-
layoutData: theme.module.defaultLayout,
|
|
1014
|
-
metadata
|
|
1015
|
-
})
|
|
1016
|
-
);
|
|
1017
|
-
const cssVarStyle = Object.entries(chrome.cssVariables).map(([key, value]) => `${key}: ${value};`).join(" ");
|
|
1018
|
-
const stylesheetUrl = resolved.runtime?.stylesheet ? createPreviewThemeAssetUrl(
|
|
1019
|
-
theme.module.manifest.key,
|
|
1020
|
-
theme.module.manifest.version,
|
|
1021
|
-
resolved.runtime.stylesheet
|
|
1022
|
-
) : null;
|
|
1023
|
-
const stylesheetTag = stylesheetUrl ? `<link rel="stylesheet" href="${escapeHtml(stylesheetUrl)}" />` : "";
|
|
1024
|
-
const customHead = chrome.customHeadCode ?? "";
|
|
1025
|
-
const customBody = chrome.customBodyCode ?? "";
|
|
1026
|
-
return [
|
|
1027
|
-
"<!doctype html>",
|
|
1028
|
-
'<html lang="en">',
|
|
1029
|
-
"<head>",
|
|
1030
|
-
'<meta charset="utf-8" />',
|
|
1031
|
-
'<meta name="viewport" content="width=device-width, initial-scale=1" />',
|
|
1032
|
-
`<title>${escapeHtml(`${theme.module.manifest.name} \u2014 ${page.title}`)}</title>`,
|
|
1033
|
-
stylesheetTag,
|
|
1034
|
-
customHead,
|
|
1035
|
-
"</head>",
|
|
1036
|
-
`<body${cssVarStyle ? ` style="${cssVarStyle}"` : ""}>`,
|
|
1037
|
-
body,
|
|
1038
|
-
customBody,
|
|
1039
|
-
"</body>",
|
|
1040
|
-
"</html>"
|
|
1041
|
-
].join("");
|
|
1042
|
-
}
|
|
1043
1044
|
function renderDevStarterPageHtml(theme, page, renderer) {
|
|
1044
1045
|
const chrome = renderer.extractLayoutChrome(theme.module.defaultLayout);
|
|
1045
1046
|
const metadata = {
|
|
@@ -1073,152 +1074,49 @@ function renderDevStarterPageHtml(theme, page, renderer) {
|
|
|
1073
1074
|
"</html>"
|
|
1074
1075
|
].join("");
|
|
1075
1076
|
}
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
const renderer = await loadThemeScopedRenderer(theme.root);
|
|
1112
|
-
const registry = new FileSystemThemeRegistry({
|
|
1113
|
-
artifacts: [
|
|
1114
|
-
{
|
|
1115
|
-
rootPath: theme.root,
|
|
1116
|
-
serverEntryPath: theme.serverEntryPath,
|
|
1117
|
-
module: theme.module
|
|
1118
|
-
}
|
|
1119
|
-
]
|
|
1120
|
-
});
|
|
1121
|
-
const themeKey = theme.module.manifest.key;
|
|
1122
|
-
const themeVersion = theme.module.manifest.version;
|
|
1123
|
-
const assetPathPrefix = `${PREVIEW_PUBLIC_BASE_PATH}/${themeKey}/${themeVersion}/assets/`;
|
|
1124
|
-
const clientRuntimePrefix = `${PREVIEW_PUBLIC_BASE_PATH}/${themeKey}/${themeVersion}/runtime/client`;
|
|
1125
|
-
const server = createServer((request, response) => {
|
|
1126
|
-
void (async () => {
|
|
1127
|
-
const url = new URL(request.url ?? "/", `http://localhost:${port}`);
|
|
1128
|
-
const pathname = url.pathname;
|
|
1129
|
-
if (pathname === "/" || pathname === "/index.html") {
|
|
1130
|
-
const fallbackSlug = pickStarterSlug(theme);
|
|
1131
|
-
if (!fallbackSlug) {
|
|
1132
|
-
response.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
|
|
1133
|
-
response.end("No starter pages declared by this theme.");
|
|
1134
|
-
return;
|
|
1135
|
-
}
|
|
1136
|
-
const requested = url.searchParams.get("page");
|
|
1137
|
-
const activeSlug = requested && findStarterPage(theme, requested) ? requested : fallbackSlug;
|
|
1138
|
-
response.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
1139
|
-
response.end(renderPreviewShellHtml(theme, activeSlug));
|
|
1140
|
-
return;
|
|
1141
|
-
}
|
|
1142
|
-
if (pathname.startsWith("/pages/")) {
|
|
1143
|
-
const slug = decodeURIComponent(pathname.slice("/pages/".length));
|
|
1144
|
-
const starter = findStarterPage(theme, slug);
|
|
1145
|
-
if (!starter) {
|
|
1146
|
-
response.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
|
|
1147
|
-
response.end(`Unknown starter page: ${slug}`);
|
|
1148
|
-
return;
|
|
1149
|
-
}
|
|
1150
|
-
const resolved = await registry.resolve(themeKey, themeVersion);
|
|
1151
|
-
if (!resolved) {
|
|
1152
|
-
response.writeHead(500, { "Content-Type": "text/plain; charset=utf-8" });
|
|
1153
|
-
response.end("Failed to resolve theme via FileSystemThemeRegistry.");
|
|
1154
|
-
return;
|
|
1155
|
-
}
|
|
1156
|
-
const html = renderStarterPageHtml(theme, resolved, starter, renderer);
|
|
1157
|
-
response.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
1158
|
-
response.end(html);
|
|
1159
|
-
return;
|
|
1160
|
-
}
|
|
1161
|
-
if (pathname.startsWith(assetPathPrefix)) {
|
|
1162
|
-
const relativePath = decodeURIComponent(pathname.slice(assetPathPrefix.length));
|
|
1163
|
-
const filePath = await registry.resolveAssetPath(themeKey, themeVersion, relativePath);
|
|
1164
|
-
if (!filePath) {
|
|
1165
|
-
response.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
|
|
1166
|
-
response.end("Not found");
|
|
1167
|
-
return;
|
|
1168
|
-
}
|
|
1169
|
-
const body = await readFile(filePath);
|
|
1170
|
-
response.writeHead(200, {
|
|
1171
|
-
"Content-Type": contentTypeFor(relativePath),
|
|
1172
|
-
"Cache-Control": "no-cache"
|
|
1173
|
-
});
|
|
1174
|
-
response.end(body);
|
|
1175
|
-
return;
|
|
1176
|
-
}
|
|
1177
|
-
if (pathname === clientRuntimePrefix) {
|
|
1178
|
-
const filePath = await registry.resolveClientEntryPath(themeKey, themeVersion);
|
|
1179
|
-
if (!filePath) {
|
|
1180
|
-
response.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
|
|
1181
|
-
response.end("Theme has no client runtime bundle.");
|
|
1182
|
-
return;
|
|
1183
|
-
}
|
|
1184
|
-
const body = await readFile(filePath);
|
|
1185
|
-
response.writeHead(200, {
|
|
1186
|
-
"Content-Type": "application/javascript; charset=utf-8",
|
|
1187
|
-
"Cache-Control": "no-cache"
|
|
1188
|
-
});
|
|
1189
|
-
response.end(body);
|
|
1190
|
-
return;
|
|
1077
|
+
var SCREENSHOT_DEVICES = ["desktop", "tablet", "mobile"];
|
|
1078
|
+
var SCREENSHOT_DEVICE_CONFIG = {
|
|
1079
|
+
desktop: {
|
|
1080
|
+
viewport: { width: 1440, height: 900 },
|
|
1081
|
+
outputRelativePath: path2.join("assets", "preview", "desktop.png")
|
|
1082
|
+
},
|
|
1083
|
+
tablet: {
|
|
1084
|
+
viewport: { width: 834, height: 1112 },
|
|
1085
|
+
outputRelativePath: path2.join("assets", "preview", "tablet.png")
|
|
1086
|
+
},
|
|
1087
|
+
mobile: {
|
|
1088
|
+
viewport: { width: 390, height: 844 },
|
|
1089
|
+
outputRelativePath: path2.join("assets", "preview", "mobile.png")
|
|
1090
|
+
}
|
|
1091
|
+
};
|
|
1092
|
+
var REQUIRED_PREVIEW_ARTIFACTS = SCREENSHOT_DEVICES.map(
|
|
1093
|
+
(device) => `assets/preview/${device}.png`
|
|
1094
|
+
);
|
|
1095
|
+
function isScreenshotDevice(value) {
|
|
1096
|
+
return SCREENSHOT_DEVICES.includes(value);
|
|
1097
|
+
}
|
|
1098
|
+
function parseScreenshotDevices(value) {
|
|
1099
|
+
if (value === void 0) {
|
|
1100
|
+
return [...SCREENSHOT_DEVICES];
|
|
1101
|
+
}
|
|
1102
|
+
const rawValues = Array.isArray(value) ? value : [value];
|
|
1103
|
+
const devices = [];
|
|
1104
|
+
for (const raw of rawValues) {
|
|
1105
|
+
for (const part of raw.split(",")) {
|
|
1106
|
+
const device = part.trim();
|
|
1107
|
+
if (!device) continue;
|
|
1108
|
+
if (!isScreenshotDevice(device)) {
|
|
1109
|
+
throw new Error(
|
|
1110
|
+
`Unknown screenshot device "${device}". Use one of: ${SCREENSHOT_DEVICES.join(", ")}.`
|
|
1111
|
+
);
|
|
1191
1112
|
}
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
})().catch((error) => {
|
|
1195
|
-
if (!response.headersSent) {
|
|
1196
|
-
response.writeHead(500, { "Content-Type": "text/plain; charset=utf-8" });
|
|
1113
|
+
if (!devices.includes(device)) {
|
|
1114
|
+
devices.push(device);
|
|
1197
1115
|
}
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
await new Promise((resolve, reject) => {
|
|
1202
|
-
const onError = (err) => {
|
|
1203
|
-
reject(err);
|
|
1204
|
-
};
|
|
1205
|
-
server.once("error", onError);
|
|
1206
|
-
server.listen(port, () => {
|
|
1207
|
-
server.off("error", onError);
|
|
1208
|
-
resolve();
|
|
1209
|
-
});
|
|
1210
|
-
});
|
|
1211
|
-
return {
|
|
1212
|
-
url: `http://localhost:${port}`,
|
|
1213
|
-
port,
|
|
1214
|
-
close: () => new Promise((resolve) => {
|
|
1215
|
-
server.close(() => {
|
|
1216
|
-
resolve();
|
|
1217
|
-
});
|
|
1218
|
-
})
|
|
1219
|
-
};
|
|
1116
|
+
}
|
|
1117
|
+
}
|
|
1118
|
+
return devices.length > 0 ? devices : [...SCREENSHOT_DEVICES];
|
|
1220
1119
|
}
|
|
1221
|
-
var DEFAULT_SCREENSHOT_RELATIVE = path2.join("dist", "preview", "desktop.png");
|
|
1222
1120
|
function parsePositiveInt(value, fallback) {
|
|
1223
1121
|
if (!value) {
|
|
1224
1122
|
return fallback;
|
|
@@ -1227,25 +1125,68 @@ function parsePositiveInt(value, fallback) {
|
|
|
1227
1125
|
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
|
1228
1126
|
}
|
|
1229
1127
|
function resolveScreenshotOptions(root, options) {
|
|
1128
|
+
const devices = parseScreenshotDevices(options.device);
|
|
1129
|
+
if (options.output && devices.length !== 1) {
|
|
1130
|
+
throw new Error("`--output` can only be used when exactly one `--device` is selected.");
|
|
1131
|
+
}
|
|
1230
1132
|
return {
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1133
|
+
targets: devices.map((device) => {
|
|
1134
|
+
const config = SCREENSHOT_DEVICE_CONFIG[device];
|
|
1135
|
+
const viewport = {
|
|
1136
|
+
width: parsePositiveInt(options.width, config.viewport.width),
|
|
1137
|
+
height: parsePositiveInt(options.height, config.viewport.height)
|
|
1138
|
+
};
|
|
1139
|
+
return {
|
|
1140
|
+
device,
|
|
1141
|
+
outputPath: path2.resolve(root, options.output ?? config.outputRelativePath),
|
|
1142
|
+
viewport
|
|
1143
|
+
};
|
|
1144
|
+
}),
|
|
1236
1145
|
port: parsePositiveInt(options.port, 4178),
|
|
1237
|
-
|
|
1146
|
+
fullPage: options.fullPage === true
|
|
1238
1147
|
};
|
|
1239
1148
|
}
|
|
1240
|
-
|
|
1149
|
+
function createThemeProjectRequire(themeRoot) {
|
|
1150
|
+
return createRequire(path2.join(themeRoot, "package.json"));
|
|
1151
|
+
}
|
|
1152
|
+
function normalizePlaywrightModule(moduleValue) {
|
|
1153
|
+
if (typeof moduleValue !== "object" || moduleValue === null) {
|
|
1154
|
+
return null;
|
|
1155
|
+
}
|
|
1156
|
+
if ("chromium" in moduleValue) {
|
|
1157
|
+
const chromium = moduleValue.chromium;
|
|
1158
|
+
if (typeof chromium === "object" && chromium !== null && "launch" in chromium) {
|
|
1159
|
+
return moduleValue;
|
|
1160
|
+
}
|
|
1161
|
+
}
|
|
1162
|
+
if ("default" in moduleValue) {
|
|
1163
|
+
return normalizePlaywrightModule(moduleValue.default);
|
|
1164
|
+
}
|
|
1165
|
+
return null;
|
|
1166
|
+
}
|
|
1167
|
+
function normalizeSharpModule(moduleValue) {
|
|
1168
|
+
if (typeof moduleValue === "function") {
|
|
1169
|
+
return moduleValue;
|
|
1170
|
+
}
|
|
1171
|
+
if (typeof moduleValue !== "object" || moduleValue === null) {
|
|
1172
|
+
return null;
|
|
1173
|
+
}
|
|
1174
|
+
if ("default" in moduleValue) {
|
|
1175
|
+
return normalizeSharpModule(moduleValue.default);
|
|
1176
|
+
}
|
|
1177
|
+
return null;
|
|
1178
|
+
}
|
|
1179
|
+
async function loadPlaywright(themeRoot) {
|
|
1180
|
+
const themeRequire = createThemeProjectRequire(themeRoot);
|
|
1241
1181
|
const candidates = ["playwright", "@playwright/test"];
|
|
1242
1182
|
for (const id of candidates) {
|
|
1243
1183
|
try {
|
|
1244
|
-
const
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
)
|
|
1248
|
-
|
|
1184
|
+
const resolvedId = themeRequire.resolve(id);
|
|
1185
|
+
const mod = await import(pathToFileURL(resolvedId).href);
|
|
1186
|
+
const playwright = normalizePlaywrightModule(mod);
|
|
1187
|
+
if (playwright) {
|
|
1188
|
+
return playwright;
|
|
1189
|
+
}
|
|
1249
1190
|
} catch {
|
|
1250
1191
|
}
|
|
1251
1192
|
}
|
|
@@ -1253,33 +1194,81 @@ async function loadPlaywright() {
|
|
|
1253
1194
|
"Playwright is required for screenshot capture. Install it as a dev dependency: `pnpm add -D playwright && pnpm exec playwright install chromium`."
|
|
1254
1195
|
);
|
|
1255
1196
|
}
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1197
|
+
var warnedMissingSharp = false;
|
|
1198
|
+
var warnedSharpOptimizeFailure = false;
|
|
1199
|
+
async function loadOptionalSharp(themeRoot) {
|
|
1200
|
+
const resolvers = [
|
|
1201
|
+
createThemeProjectRequire(themeRoot),
|
|
1202
|
+
createRequire(path2.join(packageRoot, "package.json"))
|
|
1203
|
+
];
|
|
1204
|
+
for (const resolver of resolvers) {
|
|
1205
|
+
try {
|
|
1206
|
+
const sharpModulePath = resolver.resolve("sharp");
|
|
1207
|
+
const mod = await import(pathToFileURL(sharpModulePath).href);
|
|
1208
|
+
const sharp = normalizeSharpModule(mod);
|
|
1209
|
+
if (sharp) {
|
|
1210
|
+
return sharp;
|
|
1211
|
+
}
|
|
1212
|
+
} catch {
|
|
1213
|
+
}
|
|
1214
|
+
}
|
|
1215
|
+
return null;
|
|
1216
|
+
}
|
|
1217
|
+
async function optimizePngIfAvailable(themeRoot, filePath) {
|
|
1218
|
+
const sharp = await loadOptionalSharp(themeRoot);
|
|
1219
|
+
if (!sharp) {
|
|
1220
|
+
if (!warnedMissingSharp) {
|
|
1221
|
+
console.warn(
|
|
1222
|
+
"warning: PNG optimization skipped because optional dependency `sharp` is not installed. Install it in the theme project to enable preview compression."
|
|
1223
|
+
);
|
|
1224
|
+
warnedMissingSharp = true;
|
|
1225
|
+
}
|
|
1226
|
+
return;
|
|
1227
|
+
}
|
|
1228
|
+
try {
|
|
1229
|
+
const optimized = await sharp(filePath).png({ compressionLevel: 9, palette: true }).toBuffer();
|
|
1230
|
+
await writeFile(filePath, optimized);
|
|
1231
|
+
} catch {
|
|
1232
|
+
if (!warnedSharpOptimizeFailure) {
|
|
1233
|
+
console.warn(
|
|
1234
|
+
"warning: PNG optimization failed. The screenshot was saved uncompressed."
|
|
1235
|
+
);
|
|
1236
|
+
warnedSharpOptimizeFailure = true;
|
|
1237
|
+
}
|
|
1238
|
+
}
|
|
1239
|
+
}
|
|
1240
|
+
async function captureScreenshot(root, options) {
|
|
1241
|
+
const playwright = await loadPlaywright(root);
|
|
1242
|
+
const handle = await startViteDevPreviewServer(root, options.port);
|
|
1259
1243
|
try {
|
|
1260
|
-
await mkdir(path2.dirname(options.outputPath), { recursive: true });
|
|
1261
1244
|
const browser = await playwright.chromium.launch({ headless: true });
|
|
1262
1245
|
try {
|
|
1263
|
-
const
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1246
|
+
for (const target of options.targets) {
|
|
1247
|
+
await mkdir(path2.dirname(target.outputPath), { recursive: true });
|
|
1248
|
+
const context = await browser.newContext({
|
|
1249
|
+
viewport: target.viewport,
|
|
1250
|
+
deviceScaleFactor: 1
|
|
1251
|
+
});
|
|
1252
|
+
try {
|
|
1253
|
+
const page = await context.newPage();
|
|
1254
|
+
await page.goto(handle.url, { waitUntil: "networkidle" });
|
|
1255
|
+
await page.screenshot({ path: target.outputPath, fullPage: options.fullPage });
|
|
1256
|
+
} finally {
|
|
1257
|
+
await context.close();
|
|
1258
|
+
}
|
|
1259
|
+
await optimizePngIfAvailable(root, target.outputPath);
|
|
1260
|
+
console.log(`captured ${target.device} ${path2.relative(root, target.outputPath)}`);
|
|
1261
|
+
}
|
|
1271
1262
|
} finally {
|
|
1272
1263
|
await browser.close();
|
|
1273
1264
|
}
|
|
1274
1265
|
} finally {
|
|
1275
1266
|
await handle.close();
|
|
1276
1267
|
}
|
|
1277
|
-
console.log(`captured ${path2.relative(theme.root, options.outputPath)}`);
|
|
1278
1268
|
}
|
|
1279
1269
|
async function screenshotTheme(root, options) {
|
|
1280
1270
|
const resolved = resolveScreenshotOptions(root, options);
|
|
1281
|
-
|
|
1282
|
-
await captureScreenshot(theme, resolved);
|
|
1271
|
+
await captureScreenshot(root, resolved);
|
|
1283
1272
|
}
|
|
1284
1273
|
async function loadLocalAgentManifest(root) {
|
|
1285
1274
|
const built = await readJsonIfExists(
|
|
@@ -1405,34 +1394,38 @@ async function createAgentPage(options) {
|
|
|
1405
1394
|
return;
|
|
1406
1395
|
}
|
|
1407
1396
|
const auth = await requireCliBaseUrl();
|
|
1408
|
-
const
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
);
|
|
1397
|
+
const request = {
|
|
1398
|
+
projectId: options.projectId,
|
|
1399
|
+
title: options.title,
|
|
1400
|
+
slug: options.slug,
|
|
1401
|
+
themeKey: manifest.manifest.key,
|
|
1402
|
+
themeVersion: manifest.manifest.version,
|
|
1403
|
+
data: pageContent
|
|
1404
|
+
};
|
|
1405
|
+
if (options.home === true) {
|
|
1406
|
+
request.isHome = true;
|
|
1407
|
+
}
|
|
1408
|
+
const response = await createRemotePageDraft(auth, request, fetchJson);
|
|
1420
1409
|
printJson({ success: true, ...response });
|
|
1421
1410
|
}
|
|
1422
1411
|
async function createRemotePageDraft(auth, input, fetcher = fetchJson) {
|
|
1412
|
+
const body = {
|
|
1413
|
+
title: input.title,
|
|
1414
|
+
slug: input.slug,
|
|
1415
|
+
themeKey: input.themeKey,
|
|
1416
|
+
themeVersion: input.themeVersion,
|
|
1417
|
+
data: input.data
|
|
1418
|
+
};
|
|
1419
|
+
if (input.isHome !== void 0) {
|
|
1420
|
+
body.isHome = input.isHome;
|
|
1421
|
+
}
|
|
1423
1422
|
return fetcher(
|
|
1424
1423
|
`${auth.baseUrl}/api/cli/agent/projects/${encodeURIComponent(input.projectId)}/pages`,
|
|
1425
1424
|
{
|
|
1426
1425
|
method: "POST",
|
|
1427
1426
|
token: auth.token,
|
|
1428
1427
|
headers: { "Content-Type": "application/json" },
|
|
1429
|
-
body: JSON.stringify(
|
|
1430
|
-
title: input.title,
|
|
1431
|
-
slug: input.slug,
|
|
1432
|
-
themeKey: input.themeKey,
|
|
1433
|
-
themeVersion: input.themeVersion,
|
|
1434
|
-
data: input.data
|
|
1435
|
-
})
|
|
1428
|
+
body: JSON.stringify(body)
|
|
1436
1429
|
}
|
|
1437
1430
|
);
|
|
1438
1431
|
}
|
|
@@ -1701,18 +1694,19 @@ async function startMcpServer() {
|
|
|
1701
1694
|
server.registerTool(
|
|
1702
1695
|
"create_page_draft",
|
|
1703
1696
|
{
|
|
1704
|
-
description: "Create a new workspace page draft from AI-generated Suda page content.",
|
|
1697
|
+
description: "Create a new workspace page draft from AI-generated Suda page content. 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.",
|
|
1705
1698
|
inputSchema: {
|
|
1706
1699
|
projectId: z.string(),
|
|
1707
1700
|
title: z.string(),
|
|
1708
1701
|
slug: z.string(),
|
|
1702
|
+
isHome: z.boolean().optional().describe("Set true for exactly one page that should serve as the public homepage at `/`."),
|
|
1709
1703
|
theme: z.string(),
|
|
1710
1704
|
data: z.unknown(),
|
|
1711
1705
|
version: z.string().optional()
|
|
1712
1706
|
},
|
|
1713
1707
|
outputSchema: createPageDraftOutputSchema
|
|
1714
1708
|
},
|
|
1715
|
-
async ({ projectId, title, slug, theme, data, version }) => {
|
|
1709
|
+
async ({ projectId, title, slug, isHome, theme, data, version }) => {
|
|
1716
1710
|
const manifest = await fetchAgentManifest(theme, { version, projectId });
|
|
1717
1711
|
const validation = validateAgentPageContentWithManifest(data, manifest);
|
|
1718
1712
|
if (!validation.valid) {
|
|
@@ -1730,12 +1724,13 @@ async function startMcpServer() {
|
|
|
1730
1724
|
projectId,
|
|
1731
1725
|
title,
|
|
1732
1726
|
slug,
|
|
1727
|
+
isHome,
|
|
1733
1728
|
themeKey: manifest.manifest.key,
|
|
1734
1729
|
themeVersion: manifest.manifest.version,
|
|
1735
1730
|
data
|
|
1736
1731
|
});
|
|
1737
1732
|
return mcpStructured(
|
|
1738
|
-
"Created Suda page draft.",
|
|
1733
|
+
"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.",
|
|
1739
1734
|
createPageDraftOutputSchema.parse({ status: "created", ...response })
|
|
1740
1735
|
);
|
|
1741
1736
|
}
|
|
@@ -1810,7 +1805,7 @@ async function startMcpServer() {
|
|
|
1810
1805
|
server.registerTool(
|
|
1811
1806
|
"publish_page",
|
|
1812
1807
|
{
|
|
1813
|
-
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.",
|
|
1808
|
+
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.",
|
|
1814
1809
|
inputSchema: {
|
|
1815
1810
|
projectId: z.string(),
|
|
1816
1811
|
slug: z.string(),
|
|
@@ -1959,10 +1954,11 @@ async function collectFiles(root, directory) {
|
|
|
1959
1954
|
continue;
|
|
1960
1955
|
}
|
|
1961
1956
|
const relativePath = path2.relative(root, absolutePath).replaceAll(path2.sep, "/");
|
|
1957
|
+
const publishRelativePath = relativePath.startsWith("dist/") ? relativePath.slice("dist/".length) : relativePath;
|
|
1962
1958
|
files.push({
|
|
1963
1959
|
absolutePath,
|
|
1964
|
-
relativePath,
|
|
1965
|
-
contentType: contentTypeFor(
|
|
1960
|
+
relativePath: publishRelativePath,
|
|
1961
|
+
contentType: contentTypeFor(publishRelativePath)
|
|
1966
1962
|
});
|
|
1967
1963
|
}
|
|
1968
1964
|
return files;
|
|
@@ -1972,10 +1968,12 @@ async function collectThemeArtifactFiles(theme) {
|
|
|
1972
1968
|
return files;
|
|
1973
1969
|
}
|
|
1974
1970
|
var SSR_REQUIRED_ARTIFACTS = [
|
|
1975
|
-
"
|
|
1976
|
-
"
|
|
1977
|
-
"
|
|
1978
|
-
"
|
|
1971
|
+
"index.js",
|
|
1972
|
+
"runtime.client.js",
|
|
1973
|
+
"manifest.json",
|
|
1974
|
+
"styles.css",
|
|
1975
|
+
"agent-manifest.json",
|
|
1976
|
+
...REQUIRED_PREVIEW_ARTIFACTS
|
|
1979
1977
|
];
|
|
1980
1978
|
async function checksum(files) {
|
|
1981
1979
|
const hash = createHash("sha256");
|
|
@@ -2011,13 +2009,13 @@ async function publishTheme(root, skipBuild, force) {
|
|
|
2011
2009
|
}
|
|
2012
2010
|
const files = await collectThemeArtifactFiles(theme);
|
|
2013
2011
|
if (!theme.clientEntryPath) {
|
|
2014
|
-
throw new Error("Missing
|
|
2012
|
+
throw new Error("Missing runtime.client.js. Run `suda theme build` first.");
|
|
2015
2013
|
}
|
|
2016
2014
|
const collectedPaths = new Set(files.map((f) => f.relativePath));
|
|
2017
2015
|
for (const required of SSR_REQUIRED_ARTIFACTS) {
|
|
2018
2016
|
if (!collectedPaths.has(required)) {
|
|
2019
2017
|
throw new Error(
|
|
2020
|
-
`Missing required
|
|
2018
|
+
`Missing required theme artifact: ${required}. Run \`suda theme build\` and ensure \`${required}\` is generated.`
|
|
2021
2019
|
);
|
|
2022
2020
|
}
|
|
2023
2021
|
}
|
|
@@ -2084,9 +2082,8 @@ async function publishTheme(root, skipBuild, force) {
|
|
|
2084
2082
|
}
|
|
2085
2083
|
console.log(`uploaded ${createThemeObjectKey(key, version, file.relativePath)}`);
|
|
2086
2084
|
}
|
|
2087
|
-
const bundleArtifactRelative = "
|
|
2088
|
-
const previewArtifactRelative = "
|
|
2089
|
-
const previewArtifactExists = await pathExists(path2.join(theme.root, previewArtifactRelative));
|
|
2085
|
+
const bundleArtifactRelative = "index.js";
|
|
2086
|
+
const previewArtifactRelative = "assets/preview/desktop.png";
|
|
2090
2087
|
const agentManifest = createThemeAgentManifest(theme.module);
|
|
2091
2088
|
const completeRes = await fetch(`${baseUrl}/api/cli/themes/publish-complete`, {
|
|
2092
2089
|
method: "POST",
|
|
@@ -2101,7 +2098,7 @@ async function publishTheme(root, skipBuild, force) {
|
|
|
2101
2098
|
manifest: theme.module.manifest,
|
|
2102
2099
|
agentManifest,
|
|
2103
2100
|
bundleArtifactRelative,
|
|
2104
|
-
previewArtifactRelative
|
|
2101
|
+
previewArtifactRelative
|
|
2105
2102
|
})
|
|
2106
2103
|
});
|
|
2107
2104
|
if (!completeRes.ok) {
|
|
@@ -2298,7 +2295,7 @@ function buildProgram() {
|
|
|
2298
2295
|
console.log(`valid ${result.module.manifest.key}@${result.module.manifest.version}`);
|
|
2299
2296
|
if (!result.clientEntryPath) {
|
|
2300
2297
|
console.warn(
|
|
2301
|
-
"warning:
|
|
2298
|
+
"warning: runtime.client.js is missing; editor runtime will not load until `suda theme build` runs."
|
|
2302
2299
|
);
|
|
2303
2300
|
}
|
|
2304
2301
|
});
|
|
@@ -2327,7 +2324,12 @@ function buildProgram() {
|
|
|
2327
2324
|
const port = Number(options.port ?? "4177");
|
|
2328
2325
|
await watchTheme(resolveThemeRoot(options), Number.isFinite(port) ? port : 4177);
|
|
2329
2326
|
});
|
|
2330
|
-
theme.command("screenshot").description("Capture
|
|
2327
|
+
theme.command("screenshot").alias("capture").description("Capture preview screenshots of the home starter page using Playwright.").option("--theme-root <path>", "Theme source/artifact root.").option(
|
|
2328
|
+
"--device <device>",
|
|
2329
|
+
"Device to capture: desktop, tablet, mobile. Repeat or use comma-separated values.",
|
|
2330
|
+
collectOption,
|
|
2331
|
+
[]
|
|
2332
|
+
).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) => {
|
|
2331
2333
|
await screenshotTheme(resolveThemeRoot(options), options);
|
|
2332
2334
|
});
|
|
2333
2335
|
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(
|
|
@@ -2386,7 +2388,9 @@ function buildProgram() {
|
|
|
2386
2388
|
agentPage.command("validate").description("Validate an AI-generated Suda page content JSON document.").requiredOption("--theme <theme>", "Theme key.").requiredOption("--input <file>", "JSON file containing Suda page content.").option("--version <version>", "Theme version.").option("--project-id <projectId>", "Project scope.").option("--theme-root <path>", "Read a local theme.").action(async (options) => {
|
|
2387
2389
|
await validateAgentPage(options.theme, options.input, options);
|
|
2388
2390
|
});
|
|
2389
|
-
agentPage.command("create").description(
|
|
2391
|
+
agentPage.command("create").description(
|
|
2392
|
+
"Create a workspace page draft from AI-generated Suda page content JSON. Review it in Pages, then preview and publish from the workspace."
|
|
2393
|
+
).requiredOption("--project-id <projectId>", "Project id.").requiredOption("--title <title>", "Page title.").requiredOption("--slug <slug>", "Page slug.").requiredOption("--theme <theme>", "Theme key.").requiredOption("--input <file>", "JSON file containing Suda page content.").option("--version <version>", "Theme version.").option("--home", "Mark this page draft as the project's homepage.").action(async (options) => {
|
|
2390
2394
|
await createAgentPage(options);
|
|
2391
2395
|
});
|
|
2392
2396
|
const agentSection = agent.command("section").description("Inspect a single section.");
|