@sudajs/cli 0.8.2 → 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.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, FileSystemThemeRegistry } from '@sudajs/theme-engine/server';
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 !== "dist/index.js") {
495
- throw new Error('manifest.entry must be artifact-local: "dist/index.js".');
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 !== "dist/runtime.client.js") {
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: "dist/runtime.client.js" when provided.'
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 copyThemeSourceAssets(root);
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 copyThemeSourceAssets(root) {
663
- const sourceAssets = path2.join(root, "src", "assets");
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 ok = result.ok && footerIssues.length === 0;
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
- console.log(`previewing Vite theme at ${url}`);
880
- await new Promise(() => void 0);
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 = `/src/assets/${assetPrefixMatch[1]}${url.search}`;
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 requested = url.searchParams.get("page");
910
- const activeSlug = requested && findStarterPage(theme, requested) ? requested : fallbackSlug;
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
- renderPreviewShellHtml(theme, activeSlug)
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
- function renderPreviewShellHtml(theme, activeSlug) {
1077
- const items = theme.module.starterPages.map((page) => {
1078
- const slug = page.slug;
1079
- const label = `${page.title}${page.isHome ? " (home)" : ""}`;
1080
- const active = slug === activeSlug;
1081
- const href = `/?page=${encodeURIComponent(slug)}`;
1082
- const className = active ? "suda-preview-tab suda-preview-tab--active" : "suda-preview-tab";
1083
- return `<a class="${className}" href="${escapeHtml(href)}" target="_self">${escapeHtml(label)}</a>`;
1084
- }).join("");
1085
- const frameSrc = `/pages/${encodeURIComponent(activeSlug)}`;
1086
- return [
1087
- "<!doctype html>",
1088
- '<html lang="en">',
1089
- "<head>",
1090
- '<meta charset="utf-8" />',
1091
- '<meta name="viewport" content="width=device-width, initial-scale=1" />',
1092
- `<title>${escapeHtml(theme.module.manifest.name)} \u2014 preview</title>`,
1093
- "<style>",
1094
- "html,body{margin:0;height:100%;font-family:system-ui,sans-serif;background:#0b0d12;color:#e6e8ee;}",
1095
- ".suda-preview-bar{display:flex;flex-wrap:wrap;gap:.25rem;padding:.5rem .75rem;background:#0b0d12;border-bottom:1px solid #1f2330;position:sticky;top:0;z-index:10;}",
1096
- ".suda-preview-tab{display:inline-flex;align-items:center;padding:.35rem .75rem;border-radius:.5rem;font-size:.875rem;color:#c9cdd6;text-decoration:none;border:1px solid transparent;}",
1097
- ".suda-preview-tab:hover{background:#1a1d27;}",
1098
- ".suda-preview-tab--active{background:#222633;color:#fff;border-color:#2c3142;}",
1099
- ".suda-preview-frame{display:block;border:0;width:100%;height:calc(100vh - 2.5rem);background:#fff;}",
1100
- "</style>",
1101
- "</head>",
1102
- "<body>",
1103
- `<nav class="suda-preview-bar">${items}</nav>`,
1104
- `<iframe class="suda-preview-frame" src="${escapeHtml(frameSrc)}" title="theme preview"></iframe>`,
1105
- "</body>",
1106
- "</html>"
1107
- ].join("");
1108
- }
1109
- async function startPreviewServer(theme, port) {
1110
- const { createServer } = await import('http');
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
- response.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
1193
- response.end("Not found");
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
- response.end(error instanceof Error ? error.message : "Internal error");
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
- outputPath: path2.resolve(root, options.output ?? DEFAULT_SCREENSHOT_RELATIVE),
1232
- viewport: {
1233
- width: parsePositiveInt(options.width, 1280),
1234
- height: parsePositiveInt(options.height, 800)
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
- skipBuild: options.skipBuild === true
1146
+ fullPage: options.fullPage === true
1238
1147
  };
1239
1148
  }
1240
- async function loadPlaywright() {
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 mod = await import(
1245
- /* @vite-ignore */
1246
- id
1247
- );
1248
- return mod;
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
- async function captureScreenshot(theme, options) {
1257
- const playwright = await loadPlaywright();
1258
- const handle = await startPreviewServer(theme, options.port);
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 context = await browser.newContext({
1264
- viewport: options.viewport,
1265
- deviceScaleFactor: 1
1266
- });
1267
- const page = await context.newPage();
1268
- await page.goto(handle.url, { waitUntil: "networkidle" });
1269
- await page.screenshot({ path: options.outputPath, fullPage: false });
1270
- await context.close();
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
- const theme = resolved.skipBuild ? await validateTheme(root) : await buildTheme(root);
1282
- await captureScreenshot(theme, resolved);
1271
+ await captureScreenshot(root, resolved);
1283
1272
  }
1284
1273
  async function loadLocalAgentManifest(root) {
1285
1274
  const built = await readJsonIfExists(
@@ -1965,10 +1954,11 @@ async function collectFiles(root, directory) {
1965
1954
  continue;
1966
1955
  }
1967
1956
  const relativePath = path2.relative(root, absolutePath).replaceAll(path2.sep, "/");
1957
+ const publishRelativePath = relativePath.startsWith("dist/") ? relativePath.slice("dist/".length) : relativePath;
1968
1958
  files.push({
1969
1959
  absolutePath,
1970
- relativePath,
1971
- contentType: contentTypeFor(relativePath)
1960
+ relativePath: publishRelativePath,
1961
+ contentType: contentTypeFor(publishRelativePath)
1972
1962
  });
1973
1963
  }
1974
1964
  return files;
@@ -1978,10 +1968,12 @@ async function collectThemeArtifactFiles(theme) {
1978
1968
  return files;
1979
1969
  }
1980
1970
  var SSR_REQUIRED_ARTIFACTS = [
1981
- "dist/index.js",
1982
- "dist/runtime.client.js",
1983
- "dist/manifest.json",
1984
- "dist/styles.css"
1971
+ "index.js",
1972
+ "runtime.client.js",
1973
+ "manifest.json",
1974
+ "styles.css",
1975
+ "agent-manifest.json",
1976
+ ...REQUIRED_PREVIEW_ARTIFACTS
1985
1977
  ];
1986
1978
  async function checksum(files) {
1987
1979
  const hash = createHash("sha256");
@@ -2017,13 +2009,13 @@ async function publishTheme(root, skipBuild, force) {
2017
2009
  }
2018
2010
  const files = await collectThemeArtifactFiles(theme);
2019
2011
  if (!theme.clientEntryPath) {
2020
- throw new Error("Missing dist/runtime.client.js. Run `suda theme build` first.");
2012
+ throw new Error("Missing runtime.client.js. Run `suda theme build` first.");
2021
2013
  }
2022
2014
  const collectedPaths = new Set(files.map((f) => f.relativePath));
2023
2015
  for (const required of SSR_REQUIRED_ARTIFACTS) {
2024
2016
  if (!collectedPaths.has(required)) {
2025
2017
  throw new Error(
2026
- `Missing required SSR artifact: ${required}. Run \`suda theme build\` and ensure ${required === "dist/styles.css" ? "`dist/styles.css` is generated" : `\`${required}\` is generated`}.`
2018
+ `Missing required theme artifact: ${required}. Run \`suda theme build\` and ensure \`${required}\` is generated.`
2027
2019
  );
2028
2020
  }
2029
2021
  }
@@ -2090,9 +2082,8 @@ async function publishTheme(root, skipBuild, force) {
2090
2082
  }
2091
2083
  console.log(`uploaded ${createThemeObjectKey(key, version, file.relativePath)}`);
2092
2084
  }
2093
- const bundleArtifactRelative = "dist/index.js";
2094
- const previewArtifactRelative = "dist/preview/desktop.png";
2095
- const previewArtifactExists = await pathExists(path2.join(theme.root, previewArtifactRelative));
2085
+ const bundleArtifactRelative = "index.js";
2086
+ const previewArtifactRelative = "assets/preview/desktop.png";
2096
2087
  const agentManifest = createThemeAgentManifest(theme.module);
2097
2088
  const completeRes = await fetch(`${baseUrl}/api/cli/themes/publish-complete`, {
2098
2089
  method: "POST",
@@ -2107,7 +2098,7 @@ async function publishTheme(root, skipBuild, force) {
2107
2098
  manifest: theme.module.manifest,
2108
2099
  agentManifest,
2109
2100
  bundleArtifactRelative,
2110
- previewArtifactRelative: previewArtifactExists ? previewArtifactRelative : void 0
2101
+ previewArtifactRelative
2111
2102
  })
2112
2103
  });
2113
2104
  if (!completeRes.ok) {
@@ -2304,7 +2295,7 @@ function buildProgram() {
2304
2295
  console.log(`valid ${result.module.manifest.key}@${result.module.manifest.version}`);
2305
2296
  if (!result.clientEntryPath) {
2306
2297
  console.warn(
2307
- "warning: dist/runtime.client.js is missing; editor runtime will not load until `suda theme build` runs."
2298
+ "warning: runtime.client.js is missing; editor runtime will not load until `suda theme build` runs."
2308
2299
  );
2309
2300
  }
2310
2301
  });
@@ -2333,7 +2324,12 @@ function buildProgram() {
2333
2324
  const port = Number(options.port ?? "4177");
2334
2325
  await watchTheme(resolveThemeRoot(options), Number.isFinite(port) ? port : 4177);
2335
2326
  });
2336
- theme.command("screenshot").description("Capture a desktop preview screenshot of the home starter page using Playwright.").option("--theme-root <path>", "Theme source/artifact root.").option("--output <path>", "Output PNG path relative to theme root.").option("--width <px>", "Viewport width in pixels.", "1280").option("--height <px>", "Viewport height in pixels.", "800").option("--port <port>", "Preview server port used during capture.", "4178").option("--skip-build", "Skip rebuilding the theme before capturing.").action(async (options) => {
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) => {
2337
2333
  await screenshotTheme(resolveThemeRoot(options), options);
2338
2334
  });
2339
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(