@sudajs/cli 0.10.1 → 0.10.3

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
@@ -265,6 +265,143 @@ var activateThemeOutputSchema = z.discriminatedUnion("status", [
265
265
  themeVersion: z.string().nullable()
266
266
  })
267
267
  ]);
268
+ var contactOptionSchema = z.object({ label: z.string().min(1).max(80), value: z.string().min(1).max(80) });
269
+ var contactFieldSchema = z.object({
270
+ id: z.string().trim().min(1).max(48).regex(/^[a-zA-Z][a-zA-Z0-9_-]*$/),
271
+ type: z.enum(["text", "textarea", "checkbox", "radio", "select"]),
272
+ label: z.string().trim().min(1).max(120),
273
+ placeholder: z.string().trim().max(160).optional(),
274
+ required: z.boolean().default(false),
275
+ options: z.array(contactOptionSchema).default([]),
276
+ validation: z.object({
277
+ format: z.enum(["email"]).optional(),
278
+ maxLength: z.number().int().min(1).max(5e3).optional()
279
+ }).default({})
280
+ });
281
+ function addUniqueContactIssue(items, collectionName, key, ctx, prefix = []) {
282
+ const seen = /* @__PURE__ */ new Set();
283
+ for (const [index, item] of items.entries()) {
284
+ const value = item[key];
285
+ if (value === void 0 || value === null || value === "") continue;
286
+ if (seen.has(value)) {
287
+ ctx.addIssue({
288
+ code: z.ZodIssueCode.custom,
289
+ message: `Duplicate ${String(key)} in ${collectionName}.`,
290
+ path: [...prefix, index, key]
291
+ });
292
+ continue;
293
+ }
294
+ seen.add(value);
295
+ }
296
+ }
297
+ var contactFieldsSchema = z.array(contactFieldSchema).superRefine((fields, ctx) => {
298
+ addUniqueContactIssue(fields, "fields", "id", ctx);
299
+ for (const [index, field] of fields.entries()) {
300
+ if ((field.type === "radio" || field.type === "select") && field.options.length === 0) {
301
+ ctx.addIssue({
302
+ code: z.ZodIssueCode.custom,
303
+ message: `${field.type} fields require at least one option.`,
304
+ path: [index, "options"]
305
+ });
306
+ }
307
+ addUniqueContactIssue(field.options, "options", "value", ctx, [index, "options"]);
308
+ }
309
+ });
310
+ var contactEmailNotificationUpdateSchema = z.object({
311
+ id: z.string().trim().min(1).max(48),
312
+ type: z.literal("email"),
313
+ enabled: z.boolean().default(true),
314
+ label: z.string().trim().min(1).max(80),
315
+ recipients: z.array(z.string().trim().email()).min(1)
316
+ });
317
+ var contactChatNotificationUpdateSchema = z.object({
318
+ id: z.string().trim().min(1).max(48),
319
+ type: z.enum(["slack", "wecom", "feishu", "dingtalk"]),
320
+ enabled: z.boolean().default(true),
321
+ label: z.string().trim().min(1).max(80),
322
+ webhookUrl: z.string().trim().url().optional()
323
+ });
324
+ var contactNotificationUpdateSchema = z.discriminatedUnion("type", [
325
+ contactEmailNotificationUpdateSchema,
326
+ contactChatNotificationUpdateSchema
327
+ ]);
328
+ var contactWebhookUpdateSchema = z.object({
329
+ id: z.string().trim().min(1).max(48),
330
+ enabled: z.boolean().default(true),
331
+ label: z.string().trim().min(1).max(80),
332
+ event: z.literal("contact.submitted").default("contact.submitted"),
333
+ url: z.string().trim().url().optional()
334
+ });
335
+ var updateContactFormInputSchema = z.object({
336
+ projectId: z.string().min(1),
337
+ enabled: z.boolean().optional(),
338
+ title: z.string().trim().min(1).max(120).optional(),
339
+ successMessage: z.string().trim().min(1).max(240).optional(),
340
+ submitLabel: z.string().trim().min(1).max(80).optional(),
341
+ fields: contactFieldsSchema.optional(),
342
+ notifications: z.array(contactNotificationUpdateSchema).optional(),
343
+ webhooks: z.array(contactWebhookUpdateSchema).optional(),
344
+ confirm: z.boolean().optional()
345
+ });
346
+ var contactFormViewSchema = z.object({
347
+ enabled: z.boolean(),
348
+ title: z.string(),
349
+ successMessage: z.string(),
350
+ submitLabel: z.string(),
351
+ fields: z.array(contactFieldSchema),
352
+ notifications: z.array(
353
+ z.discriminatedUnion("type", [
354
+ z.object({
355
+ id: z.string(),
356
+ type: z.literal("email"),
357
+ enabled: z.boolean(),
358
+ label: z.string(),
359
+ recipients: z.array(z.string())
360
+ }),
361
+ z.object({
362
+ id: z.string(),
363
+ type: z.enum(["slack", "wecom", "feishu", "dingtalk"]),
364
+ enabled: z.boolean(),
365
+ label: z.string(),
366
+ webhookUrlMasked: z.string().nullable()
367
+ })
368
+ ])
369
+ ),
370
+ webhooks: z.array(
371
+ z.object({
372
+ id: z.string(),
373
+ enabled: z.boolean(),
374
+ label: z.string(),
375
+ event: z.literal("contact.submitted"),
376
+ urlMasked: z.string().nullable()
377
+ })
378
+ ),
379
+ limits: z.object({
380
+ maxFields: z.number(),
381
+ maxNotificationChannels: z.number(),
382
+ maxWebhooks: z.number()
383
+ }),
384
+ features: z.object({
385
+ contactNotifications: z.boolean(),
386
+ contactWebhooks: z.boolean()
387
+ })
388
+ });
389
+ var contactFormOutputSchema = z.object({
390
+ contactForm: contactFormViewSchema
391
+ });
392
+ var updateContactFormOutputSchema = z.discriminatedUnion("status", [
393
+ z.object({
394
+ status: z.literal("needs_confirmation"),
395
+ projectId: z.string(),
396
+ impact: z.string(),
397
+ draft: updateContactFormInputSchema.omit({ confirm: true })
398
+ }),
399
+ z.object({
400
+ status: z.literal("updated"),
401
+ projectId: z.string(),
402
+ contactForm: contactFormViewSchema
403
+ })
404
+ ]);
268
405
  var PROJECT_NAME_MIN_LENGTH = 1;
269
406
  var PROJECT_NAME_MAX_LENGTH = 80;
270
407
  var PROJECT_DESCRIPTION_MIN_LENGTH = 1;
@@ -883,6 +1020,40 @@ var THEME_CHECK_ICP = {
883
1020
  text: "\u4EACICP\u590712345678\u53F7",
884
1021
  href: "https://beian.miit.gov.cn/"
885
1022
  };
1023
+ var PREVIEW_CONTACT_FORM = {
1024
+ enabled: true,
1025
+ endpoint: "/api/contact",
1026
+ honeypotField: "suda_contact_company",
1027
+ title: "Contact us",
1028
+ successMessage: "Thanks, we received your message.",
1029
+ submitLabel: "Submit",
1030
+ fields: [
1031
+ {
1032
+ id: "name",
1033
+ type: "text",
1034
+ label: "Name",
1035
+ required: true,
1036
+ options: [],
1037
+ validation: { maxLength: 120 }
1038
+ },
1039
+ {
1040
+ id: "email",
1041
+ type: "text",
1042
+ label: "Email",
1043
+ required: true,
1044
+ options: [],
1045
+ validation: { format: "email", maxLength: 200 }
1046
+ },
1047
+ {
1048
+ id: "message",
1049
+ type: "textarea",
1050
+ label: "Message",
1051
+ required: true,
1052
+ options: [],
1053
+ validation: { maxLength: 2e3 }
1054
+ }
1055
+ ]
1056
+ };
886
1057
  function stripHtmlTags(value) {
887
1058
  return value.replace(/<[^>]+>/g, "").replace(/\s+/g, " ").trim();
888
1059
  }
@@ -968,7 +1139,9 @@ var __testUtils = {
968
1139
  initThemeWithKey,
969
1140
  performActivateTheme,
970
1141
  performConfirmedPageOperation,
1142
+ performUpdateContactForm,
971
1143
  renderDevStarterPageHtml,
1144
+ resolveDevStarterSlug,
972
1145
  resolveScreenshotOptions,
973
1146
  slugifyThemeName,
974
1147
  validateThemeKey,
@@ -1065,24 +1238,25 @@ function createSudaPreviewVitePlugin(root) {
1065
1238
  next();
1066
1239
  return;
1067
1240
  }
1068
- if (url.pathname !== "/" && !url.pathname.startsWith("/pages/")) {
1241
+ const slug = resolveDevStarterSlug(url.pathname);
1242
+ if (slug === void 0) {
1069
1243
  next();
1070
1244
  return;
1071
1245
  }
1072
1246
  try {
1073
1247
  const theme = await loadViteDevTheme(root, server);
1074
1248
  const renderer = await loadThemeScopedRenderer(root);
1075
- if (url.pathname === "/") {
1076
- const fallbackSlug = pickStarterSlug(theme);
1077
- if (!fallbackSlug) {
1249
+ if (slug === null) {
1250
+ const homeSlug = pickStarterSlug(theme);
1251
+ if (!homeSlug) {
1078
1252
  response.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
1079
1253
  response.end("No starter pages declared by this theme.");
1080
1254
  return;
1081
1255
  }
1082
- const starter2 = findStarterPage(theme, fallbackSlug);
1256
+ const starter2 = findStarterPage(theme, homeSlug);
1083
1257
  if (!starter2) {
1084
1258
  response.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
1085
- response.end(`Unknown starter page: ${fallbackSlug}`);
1259
+ response.end(`Unknown starter page: ${homeSlug}`);
1086
1260
  return;
1087
1261
  }
1088
1262
  const html2 = await server.transformIndexHtml(
@@ -1093,7 +1267,6 @@ function createSudaPreviewVitePlugin(root) {
1093
1267
  response.end(html2);
1094
1268
  return;
1095
1269
  }
1096
- const slug = decodeURIComponent(url.pathname.slice("/pages/".length));
1097
1270
  const starter = findStarterPage(theme, slug);
1098
1271
  if (!starter) {
1099
1272
  response.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
@@ -1141,6 +1314,16 @@ function pickStarterSlug(theme) {
1141
1314
  function findStarterPage(theme, slug) {
1142
1315
  return theme.module.starterPages.find((page) => page.slug === slug) ?? null;
1143
1316
  }
1317
+ function resolveDevStarterSlug(pathname) {
1318
+ if (pathname === "/") {
1319
+ return null;
1320
+ }
1321
+ const normalized = pathname.replace(/^\/+/, "").replace(/\/+$/, "");
1322
+ if (!normalized || normalized.includes("/")) {
1323
+ return void 0;
1324
+ }
1325
+ return decodeURIComponent(normalized);
1326
+ }
1144
1327
  function escapeHtml(value) {
1145
1328
  return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
1146
1329
  }
@@ -1177,7 +1360,8 @@ async function loadThemeScopedRenderer(themeRoot) {
1177
1360
  function renderDevStarterPageHtml(theme, page, renderer) {
1178
1361
  const chrome = renderer.extractLayoutChrome(theme.module.defaultLayout);
1179
1362
  const metadata = {
1180
- resolveAssetUrl: createPreviewAssetResolver(renderer.resolveAssetPath)
1363
+ resolveAssetUrl: createPreviewAssetResolver(renderer.resolveAssetPath),
1364
+ contactForm: PREVIEW_CONTACT_FORM
1181
1365
  };
1182
1366
  const body = renderer.renderToString(
1183
1367
  renderer.createElement(renderer.ThemeRender, {
@@ -1643,6 +1827,58 @@ async function performActivateTheme(auth, input, fetcher = fetchJson) {
1643
1827
  themeVersion: response.themeVersion
1644
1828
  });
1645
1829
  }
1830
+ async function getRemoteContactForm(auth, projectId, fetcher = fetchJson) {
1831
+ const response = await fetcher(
1832
+ `${auth.baseUrl}/api/cli/agent/projects/${encodeURIComponent(projectId)}/contact-form`,
1833
+ { token: auth.token }
1834
+ );
1835
+ return contactFormViewSchema.parse(response.contactForm);
1836
+ }
1837
+ async function updateRemoteContactForm(auth, input, fetcher = fetchJson) {
1838
+ const body = {};
1839
+ if (input.enabled !== void 0) body.enabled = input.enabled;
1840
+ if (input.title !== void 0) body.title = input.title;
1841
+ if (input.successMessage !== void 0) body.successMessage = input.successMessage;
1842
+ if (input.submitLabel !== void 0) body.submitLabel = input.submitLabel;
1843
+ if (input.fields !== void 0) body.fields = input.fields;
1844
+ if (input.notifications !== void 0) body.notifications = input.notifications;
1845
+ if (input.webhooks !== void 0) body.webhooks = input.webhooks;
1846
+ const response = await fetcher(
1847
+ `${auth.baseUrl}/api/cli/agent/projects/${encodeURIComponent(input.projectId)}/contact-form`,
1848
+ {
1849
+ method: "PATCH",
1850
+ token: auth.token,
1851
+ headers: { "Content-Type": "application/json" },
1852
+ body: JSON.stringify(body)
1853
+ }
1854
+ );
1855
+ return contactFormViewSchema.parse(response.contactForm);
1856
+ }
1857
+ async function performUpdateContactForm(auth, input, fetcher = fetchJson) {
1858
+ const draft = updateContactFormInputSchema.omit({ confirm: true }).parse(input);
1859
+ const changes = [
1860
+ input.enabled !== void 0 ? "enabled state" : null,
1861
+ input.title !== void 0 || input.successMessage !== void 0 || input.submitLabel !== void 0 ? "form copy" : null,
1862
+ input.fields !== void 0 ? `${input.fields.length} field(s)` : null,
1863
+ input.notifications !== void 0 ? `${input.notifications.length} notification channel(s)` : null,
1864
+ input.webhooks !== void 0 ? `${input.webhooks.length} webhook(s)` : null
1865
+ ].filter((item) => typeof item === "string");
1866
+ const impact = `This will update the project contact form settings for "${input.projectId}": ${changes.join(", ") || "no explicit changes"}. Contact form fields affect what visitors submit publicly; notifications and webhooks may send future submissions to external systems and are subject to plan features and limits.`;
1867
+ if (input.confirm !== true) {
1868
+ return {
1869
+ status: "needs_confirmation",
1870
+ projectId: input.projectId,
1871
+ impact,
1872
+ draft
1873
+ };
1874
+ }
1875
+ const contactForm = await updateRemoteContactForm(auth, input, fetcher);
1876
+ return {
1877
+ status: "updated",
1878
+ projectId: input.projectId,
1879
+ contactForm
1880
+ };
1881
+ }
1646
1882
  async function listAgentProjects() {
1647
1883
  const auth = await requireCliBaseUrl();
1648
1884
  const result = await fetchJson(
@@ -1696,7 +1932,7 @@ async function startMcpServer() {
1696
1932
  server.registerTool(
1697
1933
  "create_project",
1698
1934
  {
1699
- description: "Create a new Suda project for the current user. Only call this after list_projects returned an empty list. Conversation flow before calling: (1) Ask the user for the website / brand name in one turn and capture it as `name`. (2) Ask the user for a business introduction; the user is allowed (and expected) to answer over multiple turns \u2014 keep asking follow-up questions and accumulating their answers until they confirm they are done, then concatenate the accumulated text into a single `siteDescription` (trimmed). Do not invent details the user did not provide. Do not call this tool until both fields are confirmed by the user. The project is created with the default theme and starter pages; no in-app AI site generation is triggered \u2014 drive page content afterwards via create_page_draft using the returned projectId.",
1935
+ description: "Create a new Suda project for the current user. Only call this after list_projects returned an empty list. Conversation flow before calling: (1) Ask the user for the website / brand name in one turn and capture it as `name`. (2) Ask the user for a business introduction; the user is allowed (and expected) to answer over multiple turns \u2014 keep asking follow-up questions and accumulating their answers until they confirm they are done, then concatenate the accumulated text into a single `siteDescription` (trimmed). Do not invent details the user did not provide. Do not call this tool until both fields are confirmed by the user. The project is created with the default theme, starter pages, and a default project contact form. After creation, if the business context suggests better contact fields, call get_contact_form_settings, propose the contact form plan to the user, and only call update_contact_form_settings with confirm:true after they agree. No in-app AI site generation is triggered \u2014 drive page content afterwards via create_page_draft using the returned projectId.",
1700
1936
  inputSchema: {
1701
1937
  name: createProjectInputSchema.shape.name.describe(
1702
1938
  "Website or brand name confirmed by the user in a dedicated turn. Used as the project name and site title."
@@ -1769,7 +2005,7 @@ async function startMcpServer() {
1769
2005
  server.registerTool(
1770
2006
  "get_page_schema",
1771
2007
  {
1772
- description: "Return the AI-first output schema for generating Suda page content, including all section schemas. Field schemas include dynamic visibility metadata for Suda visibleIf fields.",
2008
+ description: "Return the AI-first output schema for generating Suda page content, including all section schemas. Field schemas include dynamic visibility metadata for Suda visibleIf fields. If the theme has a contact section, generate only the section placement/content props; public form fields and delivery integrations are project contact form settings exposed to themes as metadata.contactForm. Use get_contact_form_settings and update_contact_form_settings for user-approved contact form changes.",
1773
2009
  inputSchema: {
1774
2010
  theme: z.string(),
1775
2011
  version: z.string().optional(),
@@ -1790,7 +2026,7 @@ async function startMcpServer() {
1790
2026
  server.registerTool(
1791
2027
  "get_section_schema",
1792
2028
  {
1793
- description: "Return the AI-first output schema for one section component in a theme. Field schemas include dynamic visibility metadata for Suda visibleIf fields.",
2029
+ description: "Return the AI-first output schema for one section component in a theme. Field schemas include dynamic visibility metadata for Suda visibleIf fields. For contact sections, use the schema for presentation props only and do not hardcode form field metadata, notification channels, webhook URLs, or recipients into page content. Use contact form tools for project-level contact settings.",
1794
2030
  inputSchema: {
1795
2031
  theme: z.string(),
1796
2032
  section: z.string(),
@@ -1827,6 +2063,48 @@ async function startMcpServer() {
1827
2063
  return mcpStructured("Suda page content validation result.", structuredContent);
1828
2064
  }
1829
2065
  );
2066
+ server.registerTool(
2067
+ "get_contact_form_settings",
2068
+ {
2069
+ description: "Read project contact form settings. Use this before planning contact form changes. The result includes editable field metadata, masked notification/webhook URLs, and plan features/limits. Secrets are never returned.",
2070
+ inputSchema: {
2071
+ projectId: z.string()
2072
+ },
2073
+ outputSchema: contactFormOutputSchema.shape
2074
+ },
2075
+ async ({ projectId }) => {
2076
+ const auth = await requireCliBaseUrl();
2077
+ const structuredContent = contactFormOutputSchema.parse({
2078
+ contactForm: await getRemoteContactForm(auth, projectId)
2079
+ });
2080
+ return mcpStructured("Suda contact form settings.", structuredContent);
2081
+ }
2082
+ );
2083
+ server.registerTool(
2084
+ "update_contact_form_settings",
2085
+ {
2086
+ description: "Plan or update project contact form settings. Use this when the user asks to change contact form fields, labels, validation, notification channels, or webhooks. Supported field types are text, textarea, checkbox, radio, and select. Conversation flow: first call get_contact_form_settings, then propose the exact draft to the user. Call this tool without confirm or with confirm:false to return the impact summary; only call again with confirm:true after the user explicitly agrees. Do not invent webhook URLs, notification recipients, or external delivery secrets. When updating an existing webhook, omit url to keep the stored secret; new webhooks need a user-provided url.",
2087
+ inputSchema: updateContactFormInputSchema.shape,
2088
+ outputSchema: {
2089
+ status: z.enum(["needs_confirmation", "updated"]),
2090
+ projectId: z.string(),
2091
+ impact: z.string().optional(),
2092
+ draft: updateContactFormInputSchema.omit({ confirm: true }).optional(),
2093
+ contactForm: contactFormViewSchema.optional()
2094
+ }
2095
+ },
2096
+ async (input) => {
2097
+ const parsed = updateContactFormInputSchema.parse(input);
2098
+ const auth = await requireCliBaseUrl();
2099
+ const structuredContent = updateContactFormOutputSchema.parse(
2100
+ await performUpdateContactForm(auth, parsed)
2101
+ );
2102
+ return mcpStructured(
2103
+ structuredContent.status === "needs_confirmation" ? "Updating this contact form requires explicit confirmation." : "Updated Suda contact form settings.",
2104
+ structuredContent
2105
+ );
2106
+ }
2107
+ );
1830
2108
  server.registerTool(
1831
2109
  "create_page_draft",
1832
2110
  {