@sudajs/cli 0.5.5 → 0.6.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
@@ -1,15 +1,15 @@
1
1
  #!/usr/bin/env node
2
2
  import { createHash } from 'crypto';
3
- import fs, { mkdir, writeFile, readFile, stat, readdir } from 'fs/promises';
3
+ import fs, { copyFile, mkdir, readdir, writeFile, stat, rm, readFile } from 'fs/promises';
4
4
  import { createRequire } from 'module';
5
5
  import path2 from 'path';
6
6
  import { fileURLToPath, pathToFileURL } from 'url';
7
7
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
8
8
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
9
- import { createAgentPageSchemaOutput, checkThemeModule, formatThemeCheckResult, createThemeAgentManifest, validateAgentPageContentWithManifest, agentValidationResultSchema, createPageDataFromAgentContent, findAgentSection, createAgentComponentSchemaOutput, agentPageSchemaOutputSchema, agentComponentOutputSchema } from '@sudajs/theme-engine';
9
+ import { createThemeAgentManifest, createAgentPageSchemaOutput, checkThemeModule, formatThemeCheckResult, validateAgentPageContentWithManifest, agentValidationResultSchema, createPageDataFromAgentContent, findAgentSection, createAgentComponentSchemaOutput, agentPageSchemaOutputSchema, agentComponentOutputSchema } from '@sudajs/theme-engine';
10
10
  import { themeManifestSchema, FileSystemThemeRegistry } from '@sudajs/theme-engine/server';
11
11
  import { Command } from 'commander';
12
- import { build, context } from 'esbuild';
12
+ import { build } from 'esbuild';
13
13
  import { z } from 'zod';
14
14
  import os from 'os';
15
15
 
@@ -213,6 +213,8 @@ var createProjectOutputSchema = z.object({
213
213
  defaultDomain: z.string().nullable(),
214
214
  dashboardUrl: z.string()
215
215
  });
216
+ var packageRoot = path2.resolve(path2.dirname(fileURLToPath(import.meta.url)), "..");
217
+ var themeTemplateRoot = path2.join(packageRoot, "templates", "theme");
216
218
  function createHostReactShimPlugin() {
217
219
  return {
218
220
  name: "suda-host-react-shim",
@@ -472,29 +474,34 @@ async function validateTheme(root) {
472
474
  }
473
475
  return result;
474
476
  }
475
- async function runCommand(command, args, cwd) {
476
- const { spawn } = await import('child_process');
477
- await new Promise((resolve, reject) => {
478
- const child = spawn(command, args, { cwd, stdio: "inherit" });
479
- child.on("error", reject);
480
- child.on("exit", (code) => {
481
- if (code === 0) {
482
- resolve();
483
- } else {
484
- reject(new Error(`${command} ${args.join(" ")} exited with ${code ?? "unknown"}.`));
485
- }
486
- });
487
- });
488
- }
489
- async function runThemePackageBuild(root) {
490
- const packageJson = await readJson(
491
- path2.join(root, "package.json")
477
+ var VITE_CONFIG_FILES = [
478
+ "vite.config.ts",
479
+ "vite.config.mts",
480
+ "vite.config.js",
481
+ "vite.config.mjs"
482
+ ];
483
+ async function findViteConfig(root) {
484
+ for (const file of VITE_CONFIG_FILES) {
485
+ const candidate = path2.join(root, file);
486
+ if (await pathExists(candidate)) {
487
+ return candidate;
488
+ }
489
+ }
490
+ throw new Error(
491
+ `Missing vite.config.* in ${root}. Suda theme projects must use the Vite template; run \`suda theme init\` or migrate this theme to Vite.`
492
492
  );
493
- const buildScript = packageJson.scripts?.build;
494
- if (!buildScript || buildScript.includes("suda theme") || buildScript.includes("suda-theme")) {
495
- return;
493
+ }
494
+ async function loadThemeVite(root) {
495
+ const themeRequire = createRequire(path2.join(root, "package.json"));
496
+ let viteEntry;
497
+ try {
498
+ viteEntry = themeRequire.resolve("vite");
499
+ } catch {
500
+ throw new Error(
501
+ `Vite is required for Suda theme projects. Install it in ${root}: \`pnpm add -D vite @vitejs/plugin-react tailwindcss @tailwindcss/postcss postcss\`.`
502
+ );
496
503
  }
497
- await runCommand("pnpm", ["--dir", root, "run", "build"], process.cwd());
504
+ return await import(pathToFileURL(viteEntry).href);
498
505
  }
499
506
  async function findClientEntry(root) {
500
507
  const candidates = [
@@ -517,14 +524,74 @@ async function findServerEntry(root) {
517
524
  }
518
525
  throw new Error(`Missing server entry. Add src/index.ts(x) in ${root}.`);
519
526
  }
520
- async function buildServerBundle(root) {
521
- const entryPoint = await findServerEntry(root);
527
+ function isThemeExternal(id) {
528
+ return id === "react" || id.startsWith("react/") || id === "react-dom" || id.startsWith("react-dom/") || id === "@puckeditor/core" || id.startsWith("@puckeditor/core/") || id === "@sudajs/theme-engine" || id.startsWith("@sudajs/theme-engine/");
529
+ }
530
+ async function buildViteTheme(root) {
531
+ const viteConfig = await findViteConfig(root);
532
+ const vite = await loadThemeVite(root);
533
+ const serverEntry = await findServerEntry(root);
534
+ const stylesEntry = path2.join(root, "src", "styles.css");
535
+ if (!await pathExists(stylesEntry)) {
536
+ throw new Error(`Missing ${stylesEntry}. Vite Suda themes must define src/styles.css.`);
537
+ }
538
+ await rm(path2.join(root, "dist"), { recursive: true, force: true });
539
+ await rm(path2.join(root, ".suda-build"), { recursive: true, force: true });
540
+ await vite.build({
541
+ root,
542
+ configFile: viteConfig,
543
+ configLoader: "runner",
544
+ cacheDir: path2.join(root, ".suda-build", "vite-cache"),
545
+ publicDir: false,
546
+ build: {
547
+ emptyOutDir: false,
548
+ outDir: path2.join(root, "dist"),
549
+ sourcemap: false,
550
+ ssr: serverEntry,
551
+ rollupOptions: {
552
+ external: isThemeExternal,
553
+ output: {
554
+ entryFileNames: "index.js",
555
+ chunkFileNames: "chunks/[name]-[hash].js",
556
+ assetFileNames: "assets/[name]-[hash][extname]"
557
+ }
558
+ }
559
+ }
560
+ });
561
+ await vite.build({
562
+ root,
563
+ configFile: viteConfig,
564
+ configLoader: "runner",
565
+ cacheDir: path2.join(root, ".suda-build", "vite-cache"),
566
+ publicDir: false,
567
+ build: {
568
+ emptyOutDir: false,
569
+ outDir: path2.join(root, ".suda-build", "styles"),
570
+ sourcemap: false,
571
+ rollupOptions: {
572
+ input: stylesEntry,
573
+ output: {
574
+ entryFileNames: "style-entry.js",
575
+ chunkFileNames: "chunks/[name]-[hash].js",
576
+ assetFileNames: "assets/[name]-[hash][extname]"
577
+ }
578
+ }
579
+ }
580
+ });
581
+ }
582
+ async function buildClientRuntime(root, minify) {
583
+ const entryPoint = await findClientEntry(root);
584
+ if (!await pathExists(entryPoint)) {
585
+ throw new Error(`Missing client entry: ${entryPoint}`);
586
+ }
587
+ await mkdir(path2.join(root, "dist"), { recursive: true });
588
+ const hostReactShimPlugin = createHostReactShimPlugin();
522
589
  await build({
523
590
  bundle: true,
524
591
  entryPoints: [entryPoint],
525
592
  external: [
526
593
  "react",
527
- "react/jsx-runtime",
594
+ "react/*",
528
595
  "react-dom",
529
596
  "react-dom/*",
530
597
  "@puckeditor/core",
@@ -534,31 +601,7 @@ async function buildServerBundle(root) {
534
601
  ],
535
602
  format: "esm",
536
603
  jsx: "automatic",
537
- minify: false,
538
- outfile: path2.join(root, "dist", "index.js"),
539
- platform: "node",
540
- sourcemap: false,
541
- target: "es2022",
542
- treeShaking: true
543
- });
544
- }
545
- async function buildTheme(root, skipThemeBuild) {
546
- if (!skipThemeBuild) {
547
- await runThemePackageBuild(root);
548
- }
549
- await buildServerBundle(root);
550
- const entryPoint = await findClientEntry(root);
551
- if (!await pathExists(entryPoint)) {
552
- throw new Error(`Missing client entry: ${entryPoint}`);
553
- }
554
- await mkdir(path2.join(root, "dist"), { recursive: true });
555
- const hostReactShimPlugin = createHostReactShimPlugin();
556
- await build({
557
- bundle: true,
558
- entryPoints: [entryPoint],
559
- format: "esm",
560
- jsx: "automatic",
561
- minify: true,
604
+ minify,
562
605
  outfile: path2.join(root, "dist", "runtime.client.js"),
563
606
  platform: "browser",
564
607
  plugins: [hostReactShimPlugin],
@@ -566,71 +609,219 @@ async function buildTheme(root, skipThemeBuild) {
566
609
  target: "es2022",
567
610
  treeShaking: true
568
611
  });
612
+ }
613
+ async function buildTheme(root) {
614
+ await buildViteTheme(root);
615
+ await buildClientRuntime(root, true);
616
+ return finalizeTheme(root);
617
+ }
618
+ async function finalizeTheme(root) {
619
+ await findViteConfig(root);
620
+ await finalizeStylesheet(root);
569
621
  const validated = await validateTheme(root);
570
622
  await writeThemeArtifacts(validated);
571
623
  return validateTheme(root);
572
624
  }
573
- function runThemeCheck(theme) {
625
+ async function finalizeStylesheet(root) {
626
+ const builtStylesDir = path2.join(root, ".suda-build", "styles");
627
+ const cssFiles = [
628
+ ...await collectFiles(root, builtStylesDir),
629
+ ...await collectFiles(root, path2.join(root, "dist"))
630
+ ];
631
+ const cssFile = cssFiles.find((file) => file.relativePath.endsWith(".css"));
632
+ if (!cssFile) {
633
+ throw new Error(
634
+ `Vite did not emit a stylesheet from src/styles.css. Check ${path2.join(root, "vite.config.ts")} and Tailwind/PostCSS configuration.`
635
+ );
636
+ }
637
+ await copyFile(cssFile.absolutePath, path2.join(root, "styles.css"));
638
+ const assetsDir = path2.join(builtStylesDir, "assets");
639
+ if (await pathExists(assetsDir)) {
640
+ await mkdir(path2.join(root, "assets"), { recursive: true });
641
+ await copyDirectory(assetsDir, path2.join(root, "assets"));
642
+ }
643
+ }
644
+ async function copyDirectory(source, destination) {
645
+ const children = await readdir(source, { withFileTypes: true });
646
+ await mkdir(destination, { recursive: true });
647
+ for (const child of children) {
648
+ const sourcePath = path2.join(source, child.name);
649
+ const destinationPath = path2.join(destination, child.name);
650
+ if (child.isDirectory()) {
651
+ await copyDirectory(sourcePath, destinationPath);
652
+ continue;
653
+ }
654
+ await copyFile(sourcePath, destinationPath);
655
+ }
656
+ }
657
+ var THEME_CHECK_WHITE_LABEL = {
658
+ visible: true,
659
+ text: "Powered by <strong>\u901F\u642D\u4E91</strong>",
660
+ href: "https://www.sudayun.cn"
661
+ };
662
+ var THEME_CHECK_ICP = {
663
+ text: "\u4EACICP\u590712345678\u53F7",
664
+ href: "https://beian.miit.gov.cn/"
665
+ };
666
+ function escapeRegExp(value) {
667
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
668
+ }
669
+ function findAnchorTagByHref(html, href) {
670
+ const pattern = new RegExp(
671
+ `<a\\b[^>]*href=(['"])${escapeRegExp(href)}\\1[^>]*>`,
672
+ "i"
673
+ );
674
+ return html.match(pattern)?.[0] ?? null;
675
+ }
676
+ function runFooterContractCheck(theme, renderer) {
677
+ const pageData = theme.module.starterPages[0]?.data ?? { root: { props: {} }, content: [] };
678
+ const metadata = {
679
+ resolveAssetUrl: createPreviewAssetResolver(renderer.resolveAssetPath),
680
+ whiteLabel: THEME_CHECK_WHITE_LABEL,
681
+ icp: THEME_CHECK_ICP
682
+ };
683
+ const html = renderer.renderToStaticMarkup(
684
+ renderer.createElement(renderer.ThemeRender, {
685
+ theme: theme.module,
686
+ pageData,
687
+ layoutData: theme.module.defaultLayout,
688
+ metadata
689
+ })
690
+ );
691
+ const issues = [];
692
+ if (!html.includes("Powered by") || !html.includes("\u901F\u642D\u4E91")) {
693
+ issues.push("Footer must render metadata.whiteLabel.text when whiteLabel.visible is true.");
694
+ }
695
+ if (!html.includes(THEME_CHECK_ICP.text)) {
696
+ issues.push("Footer must render metadata.icp.text when ICP metadata is present.");
697
+ }
698
+ for (const href of [THEME_CHECK_WHITE_LABEL.href, THEME_CHECK_ICP.href]) {
699
+ const anchorTag = findAnchorTagByHref(html, href);
700
+ if (!anchorTag) {
701
+ issues.push(`Footer must render an anchor tag for ${href}.`);
702
+ continue;
703
+ }
704
+ if (!/\btarget=(['"])_blank\1/i.test(anchorTag)) {
705
+ issues.push(`Footer link ${href} must render with target="_blank".`);
706
+ }
707
+ }
708
+ return issues;
709
+ }
710
+ var __testUtils = {
711
+ findAnchorTagByHref
712
+ };
713
+ async function runThemeCheck(theme) {
574
714
  const result = checkThemeModule(theme.module);
575
- const text = formatThemeCheckResult(result);
576
- if (result.ok) {
715
+ const footerIssues = result.ok ? runFooterContractCheck(theme, await loadThemeScopedRenderer(theme.root)) : [];
716
+ const ok = result.ok && footerIssues.length === 0;
717
+ const lines = [formatThemeCheckResult(result)];
718
+ if (footerIssues.length === 0 && result.ok) {
719
+ lines.push("Footer contract check passed: whiteLabel and ICP metadata render correctly.");
720
+ } else if (footerIssues.length > 0) {
721
+ for (const issue of footerIssues) {
722
+ lines.push(` error layoutConfig.footerContract: ${issue}`);
723
+ }
724
+ lines.push(`Footer contract check: ${footerIssues.length} error(s).`);
725
+ }
726
+ const text = lines.join("\n");
727
+ if (ok) {
577
728
  console.log(text);
578
729
  } else {
579
730
  console.error(text);
580
731
  }
581
- return result.ok;
732
+ return ok;
582
733
  }
583
- async function watchTheme(root, skipThemeBuild, port) {
584
- if (!skipThemeBuild) {
585
- await runThemePackageBuild(root);
586
- }
587
- await buildServerBundle(root);
588
- const theme = await validateTheme(root);
589
- const entryPoint = await findClientEntry(root);
590
- await mkdir(path2.join(root, "dist"), { recursive: true });
591
- const hostReactShimPlugin = createHostReactShimPlugin();
592
- const watchLogPlugin = createWatchLogPlugin("runtime.client.js");
593
- const context$1 = await context({
594
- bundle: true,
595
- entryPoints: [entryPoint],
596
- format: "esm",
597
- jsx: "automatic",
598
- minify: false,
599
- outfile: path2.join(root, "dist", "runtime.client.js"),
600
- platform: "browser",
601
- plugins: [hostReactShimPlugin, watchLogPlugin],
602
- sourcemap: true,
603
- target: "es2022",
604
- treeShaking: true
734
+ async function watchTheme(root, port) {
735
+ const viteConfig = await findViteConfig(root);
736
+ const vite = await loadThemeVite(root);
737
+ const host = "127.0.0.1";
738
+ const server = await vite.createServer({
739
+ root,
740
+ configFile: viteConfig,
741
+ configLoader: "runner",
742
+ cacheDir: path2.join(root, ".suda-build", "vite-cache"),
743
+ appType: "custom",
744
+ server: { host, port },
745
+ plugins: [createSudaPreviewVitePlugin(root)]
605
746
  });
606
- await context$1.watch();
607
- const handle = await startPreviewServer(theme, port);
608
- console.log(`watching theme runtime; previewing at ${handle.url}`);
747
+ await server.listen(port);
748
+ const url = server.resolvedUrls?.local.find((candidate) => candidate.includes("127.0.0.1")) ?? server.resolvedUrls?.local[0] ?? `http://${host}:${port}/`;
749
+ console.log(`previewing Vite theme at ${url}`);
609
750
  await new Promise(() => void 0);
610
751
  }
611
- function createWatchLogPlugin(label) {
752
+ function createSudaPreviewVitePlugin(root) {
612
753
  return {
613
- name: "suda-theme-watch-log",
614
- setup(build) {
615
- let firstBuild = true;
616
- build.onEnd((result) => {
617
- if (firstBuild) {
618
- firstBuild = false;
619
- return;
620
- }
621
- const errorCount = result.errors.length;
622
- if (errorCount > 0) {
623
- console.log(
624
- `rebuild failed (${errorCount} error${errorCount === 1 ? "" : "s"}): ${label}`
625
- );
626
- return;
627
- }
628
- const stamp = (/* @__PURE__ */ new Date()).toLocaleTimeString();
629
- console.log(`[${stamp}] rebuilt ${label}`);
754
+ name: "suda-theme-preview",
755
+ configureServer(server) {
756
+ server.middlewares.use((request, response, next) => {
757
+ void (async () => {
758
+ const url = new URL(request.url ?? "/", "http://localhost");
759
+ const assetPrefixMatch = url.pathname.match(/^\/api\/themes\/[^/]+\/[^/]+\/assets\/(.+)$/);
760
+ if (assetPrefixMatch?.[1]) {
761
+ request.url = `/${assetPrefixMatch[1]}${url.search}`;
762
+ next();
763
+ return;
764
+ }
765
+ if (url.pathname !== "/" && !url.pathname.startsWith("/pages/")) {
766
+ next();
767
+ return;
768
+ }
769
+ try {
770
+ const theme = await loadViteDevTheme(root, server);
771
+ const renderer = await loadThemeScopedRenderer(root);
772
+ if (url.pathname === "/") {
773
+ const fallbackSlug = pickStarterSlug(theme);
774
+ if (!fallbackSlug) {
775
+ response.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
776
+ response.end("No starter pages declared by this theme.");
777
+ return;
778
+ }
779
+ const requested = url.searchParams.get("page");
780
+ const activeSlug = requested && findStarterPage(theme, requested) ? requested : fallbackSlug;
781
+ const html2 = await server.transformIndexHtml(
782
+ url.pathname,
783
+ renderPreviewShellHtml(theme, activeSlug)
784
+ );
785
+ response.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
786
+ response.end(html2);
787
+ return;
788
+ }
789
+ const slug = decodeURIComponent(url.pathname.slice("/pages/".length));
790
+ const starter = findStarterPage(theme, slug);
791
+ if (!starter) {
792
+ response.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
793
+ response.end(`Unknown starter page: ${slug}`);
794
+ return;
795
+ }
796
+ const html = await server.transformIndexHtml(
797
+ url.pathname,
798
+ renderDevStarterPageHtml(theme, starter, renderer)
799
+ );
800
+ response.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
801
+ response.end(html);
802
+ } catch (error) {
803
+ response.writeHead(500, { "Content-Type": "text/plain; charset=utf-8" });
804
+ response.end(error instanceof Error ? error.stack : String(error));
805
+ }
806
+ })();
630
807
  });
631
808
  }
632
809
  };
633
810
  }
811
+ async function loadViteDevTheme(root, server) {
812
+ const imported = await server.ssrLoadModule("/src/index.tsx");
813
+ if (!imported.default) {
814
+ throw new Error("src/index.tsx must export a default ThemeModule.");
815
+ }
816
+ validateThemeModule(imported.default);
817
+ return {
818
+ root,
819
+ module: imported.default,
820
+ serverEntryPath: path2.join(root, "src", "index.tsx"),
821
+ clientEntryPath: null,
822
+ stylesheetPath: path2.join(root, "src", "styles.css")
823
+ };
824
+ }
634
825
  var PREVIEW_PUBLIC_BASE_PATH = "/api/themes";
635
826
  function pickStarterSlug(theme) {
636
827
  const pages = theme.module.starterPages;
@@ -718,6 +909,51 @@ function renderStarterPageHtml(theme, resolved, page, renderer) {
718
909
  "</html>"
719
910
  ].join("");
720
911
  }
912
+ function renderDevStarterPageHtml(theme, page, renderer) {
913
+ const chrome = renderer.extractLayoutChrome(theme.module.defaultLayout);
914
+ const metadata = {
915
+ resolveAssetUrl: (value) => {
916
+ if (!value) {
917
+ return value;
918
+ }
919
+ if (/^[a-z][a-z0-9+.-]*:/i.test(value) || value.startsWith("/")) {
920
+ return value;
921
+ }
922
+ if (value.startsWith("themes/")) {
923
+ const parts = value.split("/");
924
+ return `/${parts.slice(3).join("/")}`;
925
+ }
926
+ return value;
927
+ }
928
+ };
929
+ const body = renderer.renderToStaticMarkup(
930
+ renderer.createElement(renderer.ThemeRender, {
931
+ theme: theme.module,
932
+ pageData: page.data,
933
+ layoutData: theme.module.defaultLayout,
934
+ metadata
935
+ })
936
+ );
937
+ const cssVarStyle = Object.entries(chrome.cssVariables).map(([key, value]) => `${key}: ${value};`).join(" ");
938
+ const customHead = chrome.customHeadCode ?? "";
939
+ const customBody = chrome.customBodyCode ?? "";
940
+ return [
941
+ "<!doctype html>",
942
+ '<html lang="en">',
943
+ "<head>",
944
+ '<meta charset="utf-8" />',
945
+ '<meta name="viewport" content="width=device-width, initial-scale=1" />',
946
+ `<title>${escapeHtml(`${theme.module.manifest.name} \u2014 ${page.title}`)}</title>`,
947
+ '<link rel="stylesheet" href="/src/styles.css" />',
948
+ customHead,
949
+ "</head>",
950
+ `<body${cssVarStyle ? ` style="${cssVarStyle}"` : ""}>`,
951
+ body,
952
+ customBody,
953
+ "</body>",
954
+ "</html>"
955
+ ].join("");
956
+ }
721
957
  function renderPreviewShellHtml(theme, activeSlug) {
722
958
  const items = theme.module.starterPages.map((page) => {
723
959
  const slug = page.slug;
@@ -923,7 +1159,7 @@ async function captureScreenshot(theme, options) {
923
1159
  }
924
1160
  async function screenshotTheme(root, options) {
925
1161
  const resolved = resolveScreenshotOptions(root, options);
926
- const theme = resolved.skipBuild ? await validateTheme(root) : await buildTheme(root, false);
1162
+ const theme = resolved.skipBuild ? await validateTheme(root) : await buildTheme(root);
927
1163
  await captureScreenshot(theme, resolved);
928
1164
  }
929
1165
  async function loadLocalAgentManifest(root) {
@@ -1460,14 +1696,11 @@ ${lines.join("\n")}`;
1460
1696
  }
1461
1697
  return message;
1462
1698
  }
1463
- async function publishTheme(root, skipBuild, skipCheck, force) {
1464
- const theme = skipBuild ? await validateTheme(root) : await buildTheme(root, false);
1465
- await writeThemeArtifacts(theme);
1466
- if (!skipCheck) {
1467
- const ok = runThemeCheck(theme);
1468
- if (!ok) {
1469
- throw new Error("AI metadata check failed. Fix the errors above or rerun with --skip-check.");
1470
- }
1699
+ async function publishTheme(root, skipBuild, force) {
1700
+ const theme = skipBuild ? await finalizeTheme(root) : await buildTheme(root);
1701
+ const ok = await runThemeCheck(theme);
1702
+ if (!ok) {
1703
+ throw new Error("AI metadata check failed. Fix the errors above.");
1471
1704
  }
1472
1705
  const files = await collectThemeArtifactFiles(theme);
1473
1706
  if (!theme.clientEntryPath) {
@@ -1600,6 +1833,14 @@ async function readCliPackageVersions() {
1600
1833
  resolved = `^${engineVersion}`;
1601
1834
  }
1602
1835
  } catch {
1836
+ const monorepoEnginePkgPath = path2.resolve(path2.dirname(here), "..", "..", "theme-engine", "package.json");
1837
+ const engineRaw = await readFile(monorepoEnginePkgPath, "utf8").catch(() => null);
1838
+ if (engineRaw) {
1839
+ const engineVersion = JSON.parse(engineRaw).version;
1840
+ if (typeof engineVersion === "string" && engineVersion.length > 0) {
1841
+ resolved = `^${engineVersion}`;
1842
+ }
1843
+ }
1603
1844
  }
1604
1845
  if (!resolved) {
1605
1846
  throw new Error(
@@ -1614,720 +1855,43 @@ async function readCliPackageVersions() {
1614
1855
  };
1615
1856
  return cachedCliPackageVersions;
1616
1857
  }
1858
+ async function replaceTemplateTokens(filePath, replacements) {
1859
+ let contents = await readFile(filePath, "utf8");
1860
+ for (const [token, value] of Object.entries(replacements)) {
1861
+ contents = contents.split(token).join(value);
1862
+ }
1863
+ await writeFile(filePath, contents);
1864
+ }
1865
+ async function loadThemeTemplateFile(relativePath) {
1866
+ return readFile(path2.join(themeTemplateRoot, relativePath), "utf8");
1867
+ }
1617
1868
  async function initTheme(target) {
1618
1869
  const key = path2.basename(target).replace(/[^a-zA-Z0-9._-]/g, "-").toLowerCase();
1619
1870
  const cliPackageVersions = await readCliPackageVersions();
1620
- await mkdir(path2.join(target, "src"), { recursive: true });
1621
- await writeFile(
1622
- path2.join(target, "package.json"),
1623
- `${JSON.stringify(
1624
- {
1625
- name: `@suda-themes/${key}`,
1626
- version: "0.1.0",
1627
- private: true,
1628
- packageManager: "pnpm@11.6.0",
1629
- type: "module",
1630
- main: "./dist/index.js",
1631
- types: "./dist/index.d.ts",
1632
- scripts: {
1633
- // `build:src` compiles TypeScript sources to dist/. `build` then runs
1634
- // `suda theme build --skip-theme-build` so the CLI does not recurse
1635
- // back into this script (it would otherwise see scripts.build and
1636
- // call `pnpm run build` again, causing infinite recursion).
1637
- "build:src": "tsc -p tsconfig.json",
1638
- build: "pnpm run build:src && suda theme build --theme-root . --skip-theme-build",
1639
- dev: "suda theme dev --theme-root . --skip-theme-build",
1640
- typecheck: "tsc -p tsconfig.json --noEmit",
1641
- validate: "suda theme validate --theme-root ."
1642
- },
1643
- devDependencies: {
1644
- "@puckeditor/core": "^0.21.2",
1645
- "@sudajs/cli": cliPackageVersions.cli,
1646
- "@sudajs/theme-engine": cliPackageVersions.themeEngine,
1647
- "@types/node": "^22.0.0",
1648
- "@types/react": "^19.0.0",
1649
- "@types/react-dom": "^19.0.0",
1650
- react: "^19.0.0",
1651
- "react-dom": "^19.0.0",
1652
- typescript: "^5.6.0"
1653
- },
1654
- peerDependencies: {
1655
- "@puckeditor/core": "^0.21.2",
1656
- // Themes are consumed by hosts (workspace/site/worker) that already
1657
- // ship a copy of `@sudajs/theme-engine`. Declaring it as a peer means
1658
- // hosts can patch/minor-bump the engine without forcing every theme
1659
- // to re-publish, while CLI/dev installs still get a working version
1660
- // via devDependencies.
1661
- "@sudajs/theme-engine": cliPackageVersions.themeEngine,
1662
- react: "^19.0.0",
1663
- "react-dom": "^19.0.0"
1664
- }
1665
- },
1666
- null,
1667
- 2
1668
- )}
1669
- `
1670
- );
1671
- await writeFile(
1672
- path2.join(target, "pnpm-workspace.yaml"),
1673
- `# This is NOT a monorepo marker \u2014 it only exists because pnpm 11 moved
1674
- # project-level settings such as \`allowBuilds\` out of package.json. Without
1675
- # a \`packages:\` field this workspace contains just the current package, so
1676
- # the project remains a standalone single-package theme.
1677
- # See https://pnpm.io/settings and https://pnpm.io/pnpm-workspace_yaml.
1678
- allowBuilds:
1679
- esbuild: true
1680
- `
1681
- );
1682
- await writeFile(
1683
- path2.join(target, "tsconfig.json"),
1684
- `${JSON.stringify(
1685
- {
1686
- compilerOptions: {
1687
- target: "ES2022",
1688
- lib: ["DOM", "DOM.Iterable", "ES2022"],
1689
- module: "NodeNext",
1690
- moduleResolution: "NodeNext",
1691
- jsx: "react-jsx",
1692
- rootDir: "src",
1693
- outDir: "dist",
1694
- declaration: true,
1695
- declarationMap: true,
1696
- sourceMap: true,
1697
- strict: true,
1698
- noUncheckedIndexedAccess: true,
1699
- esModuleInterop: true,
1700
- skipLibCheck: true,
1701
- forceConsistentCasingInFileNames: true,
1702
- resolveJsonModule: true,
1703
- types: ["node"]
1704
- },
1705
- // `package.json` is included so `src/index.tsx` can read its `version`
1706
- // via `import pkg from "../package.json"`. tsc tolerates this with
1707
- // rootDir pinned because the JSON is resolved as input but not emitted.
1708
- include: ["src", "package.json"]
1709
- },
1710
- null,
1711
- 2
1712
- )}
1713
- `
1714
- );
1715
- await writeFile(
1716
- path2.join(target, "src", "manifest.ts"),
1717
- `import type { ThemeSourceManifest } from "@sudajs/theme-engine";
1718
-
1719
- // Source-side manifest \u2014 version is intentionally omitted because the
1720
- // authoritative theme version lives in package.json. \`src/index.tsx\` reads
1721
- // package.json#version and merges it into the published ThemeManifest, so a
1722
- // single \`pnpm changeset version\` (or \`npm version\`) bumps everything.
1723
- export const sourceManifest: ThemeSourceManifest = {
1724
- key: "${key}",
1725
- name: "${key}",
1726
- categories: ["other"],
1727
- minEngineVersion: "0.0.0",
1728
- entry: "dist/index.js",
1729
- clientEntry: "dist/runtime.client.js",
1730
- // Required. SudaCloud currently hosts SSR themes only; CSR/Hybrid postures
1731
- // are on the roadmap. Authoring \`renderMode\` is the contract a theme uses
1732
- // to declare which posture it expects from the host.
1733
- renderMode: "ssr",
1734
- };
1735
- `
1736
- );
1737
- await writeFile(
1738
- path2.join(target, "src", "theme-asset.ts"),
1739
- `import { createThemeAssetResolver } from "@sudajs/theme-engine/runtime";
1740
-
1741
- import pkg from "../package.json" with { type: "json" };
1742
- import { sourceManifest } from "./manifest.js";
1743
-
1744
- export const themeAsset = createThemeAssetResolver(sourceManifest.key, pkg.version);
1745
- `
1746
- );
1747
- await writeFile(
1748
- path2.join(target, "src", "sections.tsx"),
1749
- `import type { SudaComponentConfig } from "@sudajs/theme-engine";
1750
-
1751
- export const Hero: SudaComponentConfig = {
1752
- label: "Hero",
1753
- ai: {
1754
- instructions:
1755
- "Primary page introduction used to communicate the main value proposition. " +
1756
- "Place near the beginning of landing, product, service, or campaign pages. " +
1757
- "Include one concise headline, supporting copy, and one primary call to action. " +
1758
- "Use at most once per page. Do not use for ordinary section headings or article content.",
1759
- },
1760
- fields: {
1761
- eyebrow: { type: "text", label: "Eyebrow" },
1762
- title: { type: "text", label: "Title" },
1763
- description: { type: "textarea", label: "Description" },
1764
- primaryLabel: { type: "text", label: "Primary button label" },
1765
- primaryHref: { type: "text", label: "Primary button link" },
1766
- },
1767
- defaultProps: {
1768
- eyebrow: "New theme",
1769
- title: "Build with SudaCloud",
1770
- description: "Edit this starter section in the visual editor.",
1771
- primaryLabel: "Get started",
1772
- primaryHref: "#contact",
1773
- },
1774
- render: ({ eyebrow, title, description, primaryLabel, primaryHref }) => (
1775
- <section className="${key}-section ${key}-hero">
1776
- <p className="${key}-eyebrow">{eyebrow}</p>
1777
- <h1>{title}</h1>
1778
- <p>{description}</p>
1779
- <a className="${key}-button" href={primaryHref}>{primaryLabel}</a>
1780
- </section>
1781
- ),
1782
- };
1783
-
1784
- export const FeatureGrid: SudaComponentConfig = {
1785
- label: "Feature grid",
1786
- ai: {
1787
- instructions:
1788
- "Section that lists 3\u20136 short feature or benefit cards explaining what the product or service offers. " +
1789
- "Place after the hero / introduction and before the final call-to-action. " +
1790
- "Use when the page needs to communicate multiple distinct value points; do not use for testimonials, FAQs, or step-by-step processes.",
1791
- },
1792
- fields: {
1793
- title: { type: "text", label: "Title" },
1794
- description: { type: "textarea", label: "Description" },
1795
- features: {
1796
- type: "array",
1797
- label: "Features",
1798
- arrayFields: {
1799
- title: { type: "text", label: "Title" },
1800
- description: { type: "textarea", label: "Description" },
1801
- },
1802
- },
1803
- },
1804
- defaultProps: {
1805
- title: "Everything you need to launch",
1806
- description: "Use this section to explain the core value of the project.",
1807
- features: [
1808
- { title: "Fast setup", description: "Start from a clean theme contract." },
1809
- { title: "Visual editing", description: "Expose content fields through Puck." },
1810
- { title: "Publish ready", description: "Build and publish with the Suda CLI." },
1811
- ],
1812
- },
1813
- render: ({ title, description, features = [] }) => (
1814
- <section className="${key}-section">
1815
- <h2>{title}</h2>
1816
- <p>{description}</p>
1817
- <div className="${key}-grid">
1818
- {features.map((feature: { title?: string; description?: string }, index: number) => (
1819
- <article className="${key}-card" key={index}>
1820
- <h3>{feature.title}</h3>
1821
- <p>{feature.description}</p>
1822
- </article>
1823
- ))}
1824
- </div>
1825
- </section>
1826
- ),
1827
- };
1828
-
1829
- export const Testimonial: SudaComponentConfig = {
1830
- label: "Testimonial",
1831
- ai: {
1832
- instructions:
1833
- "Section that quotes a single customer or expert as social proof. " +
1834
- "Place mid-page after a feature or value section, or near the end before the final CTA. " +
1835
- "Do not invent quotes, names, or roles; only use content the user provides.",
1836
- },
1837
- fields: {
1838
- quote: { type: "textarea", label: "Quote" },
1839
- author: { type: "text", label: "Author" },
1840
- role: { type: "text", label: "Role" },
1841
- },
1842
- defaultProps: {
1843
- quote: "SudaCloud gives our team a practical editing workflow without giving up theme control.",
1844
- author: "Alex Chen",
1845
- role: "Founder",
1846
- },
1847
- render: ({ quote, author, role }) => (
1848
- <section className="${key}-section ${key}-quote">
1849
- <blockquote>{quote}</blockquote>
1850
- <p>{author} \xB7 {role}</p>
1851
- </section>
1852
- ),
1853
- };
1854
-
1855
- export const CallToAction: SudaComponentConfig = {
1856
- label: "Call to action",
1857
- ai: {
1858
- instructions:
1859
- "Closing conversion section that invites the visitor to take a specific action (sign up, contact, buy, etc.). " +
1860
- "Place near the end of the page, after supporting content. " +
1861
- "Use at most once per page. Do not use as the page's first introduction \u2014 the Hero component fills that role.",
1862
- },
1863
- fields: {
1864
- title: { type: "text", label: "Title" },
1865
- description: { type: "textarea", label: "Description" },
1866
- buttonLabel: { type: "text", label: "Button label" },
1867
- buttonHref: { type: "text", label: "Button link" },
1868
- },
1869
- defaultProps: {
1870
- title: "Ready to build your next page?",
1871
- description: "Use this section as the final conversion block.",
1872
- buttonLabel: "Contact us",
1873
- buttonHref: "#contact",
1874
- },
1875
- render: ({ title, description, buttonLabel, buttonHref }) => (
1876
- <section className="${key}-section ${key}-cta" id="contact">
1877
- <h2>{title}</h2>
1878
- <p>{description}</p>
1879
- <a className="${key}-button" href={buttonHref}>{buttonLabel}</a>
1880
- </section>
1881
- ),
1882
- };
1883
-
1884
- export const SECTION_COMPONENTS = { Hero, FeatureGrid, Testimonial, CallToAction };
1885
- `
1886
- );
1887
- await writeFile(
1888
- path2.join(target, "src", "layout.tsx"),
1889
- `import { getPageSlot } from "@sudajs/theme-engine/runtime";
1890
- import type { Config } from "@puckeditor/core";
1891
- import type { SudaComponentConfig } from "@sudajs/theme-engine";
1892
- import type { ReactElement, ReactNode } from "react";
1893
-
1894
- type PuckExtras = { puck?: { metadata?: Record<string, unknown> } };
1895
-
1896
- export const rootConfig: NonNullable<Config["root"]> = {
1897
- fields: {
1898
- siteName: { type: "text", label: "Site name" },
1899
- },
1900
- defaultProps: { siteName: "${key}" },
1901
- render: ({ children, siteName }: { children?: ReactNode; siteName?: string }) => (
1902
- <div className="${key}-root" data-site-name={siteName}>{children}</div>
1903
- ),
1904
- };
1905
-
1906
- export const Header: SudaComponentConfig = {
1907
- label: "Header",
1908
- ai: {
1909
- instructions:
1910
- "Site-wide header rendered at the top of every page. " +
1911
- "Place once at the top of the layout (not inside page content). " +
1912
- "Used for branding and primary navigation, not for promotional content.",
1913
- },
1914
- fields: { siteName: { type: "text", label: "Site name" } },
1915
- defaultProps: { siteName: "${key}" },
1916
- render: ({ siteName }) => <header className="${key}-header">{siteName}</header>,
1917
- };
1918
-
1919
- export const PageOutlet: SudaComponentConfig = {
1920
- label: "Page outlet",
1921
- ai: {
1922
- exclude: true,
1923
- instructions:
1924
- "Structural slot where each page's content is injected. " +
1925
- "Managed by the layout, never created or placed by AI.",
1926
- },
1927
- fields: {},
1928
- defaultProps: {},
1929
- render: (props: PuckExtras): ReactElement => <>{getPageSlot(props.puck?.metadata)}</>,
1930
- };
1931
-
1932
- export const Footer: SudaComponentConfig = {
1933
- label: "Footer",
1934
- ai: {
1935
- instructions:
1936
- "Site-wide footer rendered at the bottom of every page. " +
1937
- "Place once at the end of the layout. " +
1938
- "Used for legal text, copyright, and secondary links \u2014 not for primary CTAs.",
1939
- },
1940
- fields: { text: { type: "text", label: "Text" } },
1941
- defaultProps: { text: "\xA9 ${key}" },
1942
- render: ({ text }) => <footer className="${key}-footer">{text}</footer>,
1943
- };
1944
-
1945
- export const LAYOUT_COMPONENTS = { Header, PageOutlet, Footer };
1946
- `
1947
- );
1948
- await writeFile(
1949
- path2.join(target, "src", "config.ts"),
1950
- `import type { Config, Data } from "@puckeditor/core";
1951
-
1952
- import { LAYOUT_COMPONENTS, rootConfig } from "./layout.js";
1953
- import { SECTION_COMPONENTS } from "./sections.js";
1954
-
1955
- export const pageConfig: Config = {
1956
- components: SECTION_COMPONENTS,
1957
- };
1958
-
1959
- export const layoutConfig: Config = {
1960
- root: rootConfig,
1961
- components: LAYOUT_COMPONENTS,
1962
- };
1963
-
1964
- export const defaultLayout: Data = {
1965
- root: { props: { siteName: "${key}" } },
1966
- content: [
1967
- { type: "Header", props: { id: "Header-1", siteName: "${key}" } },
1968
- { type: "PageOutlet", props: { id: "PageOutlet-1" } },
1969
- { type: "Footer", props: { id: "Footer-1", text: "\xA9 ${key}" } },
1970
- ],
1971
- };
1972
- `
1973
- );
1974
- await writeFile(
1975
- path2.join(target, "src", "templates.ts"),
1976
- `import type { ThemeStarterPage } from "@sudajs/theme-engine";
1977
-
1978
- export const starterPages: ThemeStarterPage[] = [
1979
- {
1980
- slug: "home",
1981
- title: "Home",
1982
- isHome: true,
1983
- data: {
1984
- root: { props: {} },
1985
- content: [
1986
- {
1987
- type: "Hero",
1988
- props: {
1989
- id: "Hero-1",
1990
- eyebrow: "Starter page",
1991
- title: "Welcome to ${key}",
1992
- description: "This page was generated by suda theme init.",
1993
- primaryLabel: "Explore features",
1994
- primaryHref: "#features",
1995
- },
1996
- },
1997
- {
1998
- type: "FeatureGrid",
1999
- props: {
2000
- id: "FeatureGrid-1",
2001
- title: "Designed for editable sites",
2002
- description: "Starter sections show agents and editors how this theme is structured.",
2003
- features: [
2004
- { title: "Typed fields", description: "Each section exposes a clear field schema." },
2005
- { title: "Starter pages", description: "Templates show realistic section composition." },
2006
- { title: "CLI workflow", description: "Build, validate, preview, and publish from one tool." },
2007
- ],
2008
- },
2009
- },
2010
- {
2011
- type: "CallToAction",
2012
- props: {
2013
- id: "CallToAction-1",
2014
- title: "Launch your first page",
2015
- description: "Customize this starter template or let an agent generate a new draft.",
2016
- buttonLabel: "Get in touch",
2017
- buttonHref: "/contact",
2018
- },
2019
- },
2020
- ],
2021
- },
2022
- },
2023
- {
2024
- slug: "about",
2025
- title: "About",
2026
- data: {
2027
- root: { props: {} },
2028
- content: [
2029
- {
2030
- type: "Hero",
2031
- props: {
2032
- id: "Hero-About",
2033
- eyebrow: "About",
2034
- title: "A clean starting point for your story",
2035
- description: "Use this page to introduce the project, audience, and promise.",
2036
- primaryLabel: "Contact us",
2037
- primaryHref: "/contact",
2038
- },
2039
- },
2040
- {
2041
- type: "Testimonial",
2042
- props: {
2043
- id: "Testimonial-About",
2044
- quote: "This starter theme keeps the editable surface focused and predictable.",
2045
- author: "SudaCloud",
2046
- role: "Theme team",
2047
- },
2048
- },
2049
- ],
2050
- },
2051
- },
2052
- {
2053
- slug: "contact",
2054
- title: "Contact",
2055
- data: {
2056
- root: { props: {} },
2057
- content: [
2058
- {
2059
- type: "Hero",
2060
- props: {
2061
- id: "Hero-Contact",
2062
- eyebrow: "Contact",
2063
- title: "Let's talk",
2064
- description: "Tell visitors how to reach you and what happens next.",
2065
- primaryLabel: "Email us",
2066
- primaryHref: "mailto:hello@example.com",
2067
- },
2068
- },
2069
- {
2070
- type: "CallToAction",
2071
- props: {
2072
- id: "CallToAction-Contact",
2073
- title: "Start the conversation",
2074
- description: "Replace this copy with your preferred contact details or form link.",
2075
- buttonLabel: "Send an email",
2076
- buttonHref: "mailto:hello@example.com",
2077
- },
2078
- },
2079
- ],
2080
- },
2081
- },
2082
- ];
2083
- `
2084
- );
2085
- await writeFile(
2086
- path2.join(target, "src", "index.tsx"),
2087
- `import type { ThemeManifest, ThemeModule } from "@sudajs/theme-engine";
2088
-
2089
- import pkg from "../package.json" with { type: "json" };
2090
- import { defaultLayout, layoutConfig, pageConfig } from "./config.js";
2091
- import { sourceManifest } from "./manifest.js";
2092
- import { starterPages } from "./templates.js";
2093
-
2094
- // Single source of truth: theme version comes from package.json#version,
2095
- // merged into the source-side manifest. esbuild inlines the JSON import at
2096
- // build time, so the published \`dist/index.js\` is fully self-contained.
2097
- export const manifest: ThemeManifest = { ...sourceManifest, version: pkg.version };
2098
-
2099
- const theme: ThemeModule = {
2100
- manifest,
2101
- pageConfig,
2102
- layoutConfig,
2103
- defaultLayout,
2104
- starterPages,
2105
- };
2106
-
2107
- export default theme;
2108
- export { pageConfig, layoutConfig, defaultLayout, starterPages };
2109
- `
2110
- );
2111
- await writeFile(
2112
- path2.join(target, "src", "runtime.client.ts"),
2113
- '"use client";\n\nimport theme from "./index.js";\n\nexport default theme;\n'
2114
- );
2115
- await writeFile(
2116
- path2.join(target, "styles.css"),
2117
- `.${key}-root { font-family: Inter, ui-sans-serif, system-ui, sans-serif; color: #111827; background: #ffffff; }
2118
- .${key}-header, .${key}-footer { padding: 20px clamp(20px, 5vw, 64px); border-bottom: 1px solid #e5e7eb; }
2119
- .${key}-footer { border-top: 1px solid #e5e7eb; border-bottom: 0; color: #6b7280; }
2120
- .${key}-section { padding: 64px clamp(20px, 5vw, 64px); }
2121
- .${key}-hero { background: #f8fafc; }
2122
- .${key}-eyebrow { text-transform: uppercase; letter-spacing: 0.08em; font-size: 12px; color: #2563eb; font-weight: 700; }
2123
- .${key}-section h1 { max-width: 780px; font-size: clamp(40px, 7vw, 72px); line-height: 0.95; margin: 0 0 20px; }
2124
- .${key}-section h2 { max-width: 720px; font-size: 36px; line-height: 1.05; margin: 0 0 16px; }
2125
- .${key}-section p { max-width: 680px; line-height: 1.7; color: #4b5563; }
2126
- .${key}-button { display: inline-flex; align-items: center; min-height: 42px; padding: 0 18px; border-radius: 8px; background: #111827; color: #ffffff; text-decoration: none; font-weight: 700; }
2127
- .${key}-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 16px; margin-top: 28px; }
2128
- .${key}-card { border: 1px solid #e5e7eb; border-radius: 8px; padding: 20px; }
2129
- .${key}-quote blockquote { max-width: 780px; font-size: 28px; line-height: 1.25; margin: 0 0 16px; }
2130
- .${key}-cta { background: #111827; color: #ffffff; }
2131
- .${key}-cta p { color: #d1d5db; }
2132
- .${key}-cta .${key}-button { background: #ffffff; color: #111827; }
2133
- `
2134
- );
2135
- await writeFile(path2.join(target, ".gitignore"), "node_modules\ndist\n*.tsbuildinfo\n");
2136
- await writeFile(
2137
- path2.join(target, "AGENTS.md"),
2138
- `# Suda Theme Agent Guide
2139
-
2140
- This directory is a Suda theme source project generated by \`suda theme init\`.
2141
- It is a **standalone** npm package \u2014 it does not depend on the SudaCloud monorepo.
2142
-
2143
- ## Render mode
2144
-
2145
- This theme contract requires \`renderMode: "ssr"\` in the source manifest
2146
- (\`src/manifest.ts\`). SudaCloud's site runtime currently only supports SSR
2147
- themes; CSR / Hybrid postures are on the roadmap. **Do not** introduce
2148
- client-only entry points or assume browser-only globals at module scope \u2014
2149
- the theme bundle must execute in a Node SSR context.
2150
-
2151
- ## What to edit
2152
-
2153
- - Add page sections in \`src/sections.tsx\` and register them in \`SECTION_COMPONENTS\`.
2154
- - Add starter pages in \`src/templates.ts\` to show realistic section combinations.
2155
- - Keep shared site chrome in \`src/layout.tsx\`; \`PageOutlet\` is where page content renders.
2156
- - Keep \`src/index.tsx\` exporting the source \`ThemeModule\`. The final artifact is produced by \`suda theme build\`.
2157
- - All relative imports under \`src/\` MUST include the \`.js\` extension (NodeNext ESM resolution).
2158
-
2159
- ## Page content rules
2160
-
2161
- - Agent page files passed to \`suda agent page validate/create\` contain \`content\` and optional \`zones\` only.
2162
- - Every \`content[]\` item must use a section type from \`SECTION_COMPONENTS\`.
2163
- - Every \`content[]\` item must include \`props.id\`.
2164
- - Prefer editing section props over changing render code when generating pages.
2165
- - Do not edit files under \`dist/\`; they are generated.
2166
-
2167
- ## AI metadata (required)
2168
-
2169
- Every component (and zone-level layout component) **must** declare \`ai.instructions\`.
2170
- Without it the page-generation agent has no way to know what the component is for,
2171
- and \`suda theme check\` (run automatically before publish) will fail.
2172
-
2173
- ### Component-level \`ai\`
2174
-
2175
- Each \`ComponentConfig\` must include:
2176
-
2177
- \`\`\`ts
2178
- import type { SudaComponentConfig } from "@sudajs/theme-engine";
2179
-
2180
- export const Hero: SudaComponentConfig = {
2181
- label: "Hero",
2182
- ai: {
2183
- instructions:
2184
- "Primary page introduction used to communicate the main value proposition. " +
2185
- "Place near the beginning of landing or product pages. " +
2186
- "Include one primary CTA. Use at most once per page. " +
2187
- "Do not use for ordinary section headings or article content.",
2188
- },
2189
- fields: { /* ... */ },
2190
- defaultProps: { /* ... */ },
2191
- render: (props) => /* ... */,
2192
- };
2193
- \`\`\`
2194
-
2195
- A good \`instructions\` covers, in plain English:
2196
-
2197
- 1. **Purpose** \u2014 what role this component plays on a page.
2198
- 2. **Use when** \u2014 page types or scenarios where it should be selected.
2199
- 3. **Avoid when** \u2014 situations that look similar but call for a different component.
2200
- 4. **Placement** \u2014 where in the page it normally belongs (top, after features, near end, etc.).
2201
- 5. **Frequency / composition** \u2014 how many times it can appear, what it must contain, and any adjacency rules.
2202
-
2203
- Write executable, specific sentences (\`Use when...\`, \`Place after...\`, \`Use at most once per page.\`).
2204
- Do **not** describe styling (colors, fonts, spacing) \u2014 those belong to the design system.
2205
- Do **not** write vague filler like "modern", "engaging", "beautiful".
2206
-
2207
- If two components look similar (e.g. Hero vs. PageHeader vs. SectionHeader),
2208
- their \`instructions\` must explicitly differentiate them, otherwise the agent
2209
- will mix them up.
2210
-
2211
- ### Hiding a component from AI
2212
-
2213
- Use \`ai.exclude: true\` for internal/debug components or anything the agent
2214
- should never auto-create. Such components remain editable by humans:
2215
-
2216
- \`\`\`ts
2217
- ai: { exclude: true, instructions: "Internal debug block; AI must not create it." }
2218
- \`\`\`
2219
-
2220
- ### Field-level \`ai\` (recommended, optional)
2221
-
2222
- Field-level metadata sharpens the generated content but is **not required**.
2223
- Use it when a field's name and type alone don't fully convey the constraints:
2224
-
2225
- \`\`\`ts
2226
- fields: {
2227
- title: {
2228
- type: "text",
2229
- ai: {
2230
- instructions:
2231
- "Main value-proposition headline. 4\u201312 words, focus on the visitor benefit, " +
2232
- "do not repeat the eyebrow or description.",
2233
- required: true,
2234
- },
2235
- },
2236
- imageUrl: {
2237
- type: "text",
2238
- ai: {
2239
- // Atomic values that cannot render correctly while half-streamed.
2240
- stream: false,
2241
- instructions: "Image that supports the section topic. Avoid logos, screenshots, or unrelated decoration.",
2242
- },
2243
- },
2244
- }
2245
- \`\`\`
2246
-
2247
- Supported field-level keys: \`instructions\`, \`required\`, \`exclude\`, \`stream\`,
2248
- \`bind\` (delegate to a tool), and \`schema\` (only needed for \`custom\` / \`external\` /
2249
- \`user\` fields whose runtime shape can't be inferred).
2250
-
2251
- ### Verifying
2252
-
2253
- \`\`\`bash
2254
- pnpm build # also runs the AI metadata check
2255
- suda theme check # run the check on its own
2256
- \`\`\`
2257
-
2258
- Missing component-level \`ai.instructions\` is reported as an **error** and blocks
2259
- the build. Very short or label-repeating instructions are reported as
2260
- **warnings**.
2261
-
2262
- ## Useful commands
2263
-
2264
- \`\`\`bash
2265
- pnpm install
2266
- pnpm typecheck
2267
- pnpm build # tsc + suda theme build --skip-theme-build (runs AI check)
2268
- pnpm validate
2269
- pnpm dev # watch and serve local theme artifact files
2270
- suda theme check # AI metadata check only
2271
- suda agent theme describe local --theme-root .
2272
- suda agent section schema --theme local --section Hero --theme-root .
2273
- suda agent page validate --theme local --input ./page.json --theme-root .
2274
- \`\`\`
2275
- `
2276
- );
2277
- await writeFile(
2278
- path2.join(target, "CLAUDE.md"),
2279
- `# Claude Code
2280
-
2281
- Read \`AGENTS.md\` first. It is the canonical guide for this Suda theme project.
2282
- `
2283
- );
2284
- await writeFile(
2285
- path2.join(target, "README.md"),
2286
- `# ${key}
2287
-
2288
- A Suda theme scaffolded with \`suda theme init\`. This is a standalone
2289
- package; it does not need to live inside the SudaCloud monorepo.
2290
-
2291
- This theme targets SudaCloud's SSR runtime (\`renderMode: "ssr"\` in
2292
- \`src/manifest.ts\`); CSR / Hybrid postures are not yet supported by the
2293
- host.
2294
-
2295
- ## Setup
2296
-
2297
- \`\`\`bash
2298
- pnpm install
2299
- \`\`\`
2300
-
2301
- ## Develop
2302
-
2303
- \`\`\`bash
2304
- pnpm typecheck # type-check sources
2305
- pnpm build # tsc + suda theme build (server bundle + client runtime + manifest)
2306
- pnpm dev # watch the browser runtime and start preview server
2307
- pnpm validate # validate the dist artifact
2308
- \`\`\`
2309
-
2310
- > \`renderMode\` currently only accepts \`"ssr"\`. \`suda theme validate\` and
2311
- > \`suda theme check\` will reject manifests that omit it or set a different
2312
- > value.
2313
-
2314
- ## Agent tooling
2315
-
2316
- \`\`\`bash
2317
- suda agent theme describe local --theme-root .
2318
- suda agent page schema local --theme-root .
2319
- suda agent section schema --theme local --section Hero --theme-root .
2320
- \`\`\`
2321
-
2322
- ## Publish
2323
-
2324
- \`\`\`bash
2325
- pnpm build
2326
- suda theme screenshot # optional: capture dist/preview/desktop.png
2327
- suda theme publish
2328
- \`\`\`
2329
- `
2330
- );
1871
+ await copyDirectory(themeTemplateRoot, target);
1872
+ const packageJson = JSON.parse(await loadThemeTemplateFile("package.json"));
1873
+ packageJson.name = `@suda-themes/${key}`;
1874
+ packageJson.devDependencies["@sudajs/cli"] = cliPackageVersions.cli;
1875
+ packageJson.devDependencies["@sudajs/theme-engine"] = cliPackageVersions.themeEngine;
1876
+ packageJson.peerDependencies["@sudajs/theme-engine"] = cliPackageVersions.themeEngine;
1877
+ await writeFile(path2.join(target, "package.json"), `${JSON.stringify(packageJson, null, 2)}
1878
+ `);
1879
+ const replacements = {
1880
+ "__SUDA_THEME_KEY__": key
1881
+ };
1882
+ const filesToReplace = [
1883
+ "README.md",
1884
+ "src/manifest.ts",
1885
+ "src/theme-asset.ts",
1886
+ "src/sections.tsx",
1887
+ "src/layout.tsx",
1888
+ "src/config.ts",
1889
+ "src/templates.ts",
1890
+ "src/styles.css"
1891
+ ];
1892
+ for (const relativePath of filesToReplace) {
1893
+ await replaceTemplateTokens(path2.join(target, relativePath), replacements);
1894
+ }
2331
1895
  console.log(`created theme scaffold at ${target}`);
2332
1896
  }
2333
1897
  function buildProgram() {
@@ -2346,46 +1910,41 @@ function buildProgram() {
2346
1910
  );
2347
1911
  }
2348
1912
  });
2349
- theme.command("build").description("Build server dist (via package script) and browser runtime.").option("--theme-root <path>", "Theme source/artifact root.").option("--skip-theme-build", "Skip the theme package build script.").option("--skip-check", "Skip the AI metadata check that normally runs after build.").action(async (options) => {
2350
- const result = await buildTheme(resolveThemeRoot(options), options.skipThemeBuild === true);
1913
+ theme.command("build").description("Build a Vite Suda theme artifact.").option("--theme-root <path>", "Theme source/artifact root.").action(async (options) => {
1914
+ const result = await buildTheme(resolveThemeRoot(options));
2351
1915
  console.log(`built ${result.module.manifest.key}@${result.module.manifest.version}`);
2352
- if (options.skipCheck !== true) {
2353
- const ok = runThemeCheck(result);
2354
- if (!ok) {
2355
- throw new Error(
2356
- "AI metadata check failed. Fix the errors above or rerun with --skip-check."
2357
- );
2358
- }
1916
+ const ok = await runThemeCheck(result);
1917
+ if (!ok) {
1918
+ throw new Error("AI metadata check failed. Fix the errors above.");
2359
1919
  }
2360
1920
  });
1921
+ theme.command("finalize").description("Finalize an existing Vite build into the Suda theme artifact contract.").option("--theme-root <path>", "Theme source/artifact root.").action(async (options) => {
1922
+ const result = await finalizeTheme(resolveThemeRoot(options));
1923
+ console.log(`finalized ${result.module.manifest.key}@${result.module.manifest.version}`);
1924
+ });
2361
1925
  theme.command("check").description(
2362
1926
  "Run AI metadata checks on a built theme without rebuilding. Reports missing component-level ai.instructions as errors and weak content as warnings."
2363
1927
  ).option("--theme-root <path>", "Theme source/artifact root.").action(async (options) => {
2364
1928
  const theme2 = await validateTheme(resolveThemeRoot(options));
2365
- const ok = runThemeCheck(theme2);
1929
+ const ok = await runThemeCheck(theme2);
2366
1930
  if (!ok) {
2367
1931
  process.exitCode = 1;
2368
1932
  }
2369
1933
  });
2370
- theme.command("dev").description("Watch and rebuild the browser runtime, and serve the local theme artifact files.").option("--theme-root <path>", "Theme source/artifact root.").option("--skip-theme-build", "Skip the theme package build script.").option("--port <port>", "Preview server port.", "4177").action(async (options) => {
1934
+ theme.command("dev").description("Start the Vite-powered local theme preview.").option("--theme-root <path>", "Theme source/artifact root.").option("--port <port>", "Preview server port.", "4177").action(async (options) => {
2371
1935
  const port = Number(options.port ?? "4177");
2372
- await watchTheme(
2373
- resolveThemeRoot(options),
2374
- options.skipThemeBuild === true,
2375
- Number.isFinite(port) ? port : 4177
2376
- );
1936
+ await watchTheme(resolveThemeRoot(options), Number.isFinite(port) ? port : 4177);
2377
1937
  });
2378
1938
  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) => {
2379
1939
  await screenshotTheme(resolveThemeRoot(options), options);
2380
1940
  });
2381
- 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("--skip-check", "Skip the AI metadata check that normally runs before upload.").option(
1941
+ 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(
2382
1942
  "--force",
2383
1943
  "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."
2384
1944
  ).action(async (options) => {
2385
1945
  await publishTheme(
2386
1946
  resolveThemeRoot(options),
2387
1947
  options.skipBuild === true,
2388
- options.skipCheck === true,
2389
1948
  options.force === true
2390
1949
  );
2391
1950
  });
@@ -2469,6 +2028,6 @@ async function main() {
2469
2028
  await program.parseAsync(argv);
2470
2029
  }
2471
2030
 
2472
- export { buildProgram, main, parseThemeRef };
2031
+ export { __testUtils, buildProgram, buildTheme, finalizeTheme, initTheme, main, parseThemeRef, validateTheme, watchTheme };
2473
2032
  //# sourceMappingURL=index.js.map
2474
2033
  //# sourceMappingURL=index.js.map