@sandboxsonny/ace-kit 0.6.0 → 0.7.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.
@@ -182,7 +182,7 @@ var richText = (text) => ({
182
182
  type: "doc",
183
183
  content: text.split("\n\n").map((paragraph) => ({ type: "paragraph", content: [{ type: "text", text: paragraph }] }))
184
184
  });
185
- var frameworksWithSections = ["astro"];
185
+ var frameworksWithSections = ["astro", "react-router"];
186
186
  function pageFields(framework) {
187
187
  const fields = [
188
188
  { componentKey: "text", label: "Headline", handle: "headline", config: { required: true } },
@@ -822,10 +822,467 @@ Next:
822
822
  return seeded.kind === "failed" ? 1 : 0;
823
823
  }
824
824
 
825
+ // src/cli/doctor-command.ts
826
+ import { readFile as readFile3 } from "fs/promises";
827
+ import { join as join4 } from "path";
828
+ import { parseArgs as parseArgs3 } from "util";
829
+
830
+ // src/sections/find-schemas.ts
831
+ import { readdir as readdir2 } from "fs/promises";
832
+ import { join as join3, relative as relative2 } from "path";
833
+ var schemaFilePattern = /\.schema\.(?:ts|mts|js|mjs)$/;
834
+ var skippedFolders = /* @__PURE__ */ new Set([
835
+ "node_modules",
836
+ ".git",
837
+ "dist",
838
+ "build",
839
+ ".astro",
840
+ ".react-router",
841
+ ".output",
842
+ "coverage"
843
+ ]);
844
+ var defaultSchemaFolders = ["src", "app"];
845
+ async function findSchemaFiles(root, folder) {
846
+ const folders = folder === void 0 ? defaultSchemaFolders : [folder];
847
+ const found = [];
848
+ async function walk(directory) {
849
+ let entries;
850
+ try {
851
+ entries = await readdir2(directory, { withFileTypes: true });
852
+ } catch {
853
+ return;
854
+ }
855
+ for (const entry of entries) {
856
+ const path = join3(directory, entry.name);
857
+ if (entry.isDirectory()) {
858
+ if (!skippedFolders.has(entry.name)) await walk(path);
859
+ } else if (entry.isFile() && schemaFilePattern.test(entry.name) && !entry.name.endsWith(".d.ts")) {
860
+ found.push(path);
861
+ }
862
+ }
863
+ }
864
+ for (const name of folders) await walk(join3(root, name));
865
+ return found.sort((first, second) => {
866
+ const [firstName, secondName] = [relative2(root, first), relative2(root, second)];
867
+ return firstName < secondName ? -1 : firstName > secondName ? 1 : 0;
868
+ });
869
+ }
870
+
871
+ // src/sections/load-schemas.ts
872
+ import { relative as relative3 } from "path";
873
+
874
+ // src/sections/payload.ts
875
+ var handlePattern = /^[a-z0-9]+(-[a-z0-9]+)*$/;
876
+ var maximumHandleLength = 64;
877
+ var maximumNameLength = 200;
878
+ var fieldTypes = [
879
+ "text",
880
+ "textarea",
881
+ "number",
882
+ "checkbox",
883
+ "select",
884
+ "image",
885
+ "richtext",
886
+ "date",
887
+ "link"
888
+ ];
889
+ var isObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
890
+ var isHandle = (value) => typeof value === "string" && value.length <= maximumHandleLength && handlePattern.test(value);
891
+ var handleRule = 'lowercase letters, numbers and hyphens (such as "call-to-action")';
892
+ function checkFields(fields, where, problems) {
893
+ if (fields === void 0) return;
894
+ if (!Array.isArray(fields)) {
895
+ problems.push(`${where}: fields must be a list.`);
896
+ return;
897
+ }
898
+ const seen = /* @__PURE__ */ new Set();
899
+ fields.forEach((field, index) => {
900
+ if (!isObject(field)) {
901
+ problems.push(`${where}: field ${index + 1} must be an object with a handle, a label and a type.`);
902
+ return;
903
+ }
904
+ const name = isHandle(field.handle) ? `field "${field.handle}"` : `field ${index + 1}`;
905
+ if (!isHandle(field.handle)) problems.push(`${where}: ${name} needs a handle of ${handleRule}.`);
906
+ else if (seen.has(field.handle)) problems.push(`${where}: ${name} is defined twice.`);
907
+ else seen.add(field.handle);
908
+ if (typeof field.label !== "string" || field.label.trim() === "" || field.label.length > maximumNameLength) {
909
+ problems.push(`${where}: ${name} needs a label.`);
910
+ }
911
+ if (typeof field.type !== "string" || !fieldTypes.includes(field.type)) {
912
+ const given = typeof field.type === "string" ? ` "${field.type}"` : "";
913
+ problems.push(`${where}: ${name} has a type${given} that isn't one of ${fieldTypes.join(", ")}.`);
914
+ } else if (field.type === "select") {
915
+ const { options } = field;
916
+ if (!Array.isArray(options) || options.length === 0 || options.some((option) => typeof option !== "string" || option === "")) {
917
+ problems.push(`${where}: ${name} is a select, so it needs options: a list of the choices, as text.`);
918
+ }
919
+ }
920
+ if (field.required !== void 0 && typeof field.required !== "boolean") {
921
+ problems.push(`${where}: ${name} has a required that isn't true or false.`);
922
+ }
923
+ });
924
+ }
925
+ function checkSectionDefinition(definition, where = "this section") {
926
+ const problems = [];
927
+ if (!isObject(definition)) {
928
+ return [`${where}: the default export must be the result of defineSection({ ... }).`];
929
+ }
930
+ if (!isHandle(definition.handle)) {
931
+ problems.push(`${where}: needs a handle of ${handleRule}.`);
932
+ }
933
+ if (typeof definition.name !== "string" || definition.name.trim() === "" || definition.name.length > maximumNameLength) {
934
+ problems.push(`${where}: needs a name.`);
935
+ }
936
+ checkFields(definition.fields, where, problems);
937
+ if (definition.blocks !== void 0) {
938
+ if (!isObject(definition.blocks)) {
939
+ problems.push(`${where}: blocks must be an object of the kinds of block, by handle.`);
940
+ } else {
941
+ for (const [handle, block] of Object.entries(definition.blocks)) {
942
+ const blockWhere = `${where}, block "${handle}"`;
943
+ if (!isHandle(handle)) problems.push(`${blockWhere}: the handle must be ${handleRule}.`);
944
+ if (!isObject(block)) {
945
+ problems.push(`${blockWhere}: must be an object with a name and fields.`);
946
+ continue;
947
+ }
948
+ if (typeof block.name !== "string" || block.name.trim() === "") problems.push(`${blockWhere}: needs a name.`);
949
+ checkFields(block.fields, blockWhere, problems);
950
+ }
951
+ }
952
+ }
953
+ return problems;
954
+ }
955
+ function apiFields(fields) {
956
+ return (fields ?? []).map((field) => ({
957
+ handle: field.handle,
958
+ label: field.label,
959
+ component_key: field.type,
960
+ config: {
961
+ ...field.required ? { required: true } : {},
962
+ ...field.type === "select" ? { options: [...field.options] } : {}
963
+ }
964
+ }));
965
+ }
966
+ function sectionTypesPayload(definitions) {
967
+ return {
968
+ sections: definitions.map((definition) => ({
969
+ handle: definition.handle,
970
+ name: definition.name,
971
+ fields: apiFields(definition.fields),
972
+ blocks: Object.entries(definition.blocks ?? {}).map(([handle, block]) => ({
973
+ handle,
974
+ name: block.name,
975
+ fields: apiFields(block.fields)
976
+ }))
977
+ }))
978
+ };
979
+ }
980
+
981
+ // src/sections/load-schemas.ts
982
+ async function loadSchemas(files, root, importModule) {
983
+ const definitions = [];
984
+ const problems = [];
985
+ const handleToFile = /* @__PURE__ */ new Map();
986
+ for (const file of files) {
987
+ const name = relative3(root, file);
988
+ let exported;
989
+ try {
990
+ const module = await importModule(file);
991
+ exported = module.default;
992
+ } catch (error) {
993
+ problems.push(`${name}: could not be loaded: ${error instanceof Error ? error.message : String(error)}`);
994
+ continue;
995
+ }
996
+ const fileProblems = checkSectionDefinition(exported, name);
997
+ if (fileProblems.length > 0) {
998
+ problems.push(...fileProblems);
999
+ continue;
1000
+ }
1001
+ const definition = exported;
1002
+ const earlier = handleToFile.get(definition.handle);
1003
+ if (earlier) {
1004
+ problems.push(`${name}: the handle "${definition.handle}" is already used by ${earlier}.`);
1005
+ continue;
1006
+ }
1007
+ handleToFile.set(definition.handle, name);
1008
+ definitions.push({ file, definition });
1009
+ }
1010
+ return { definitions, problems };
1011
+ }
1012
+
1013
+ // src/cli/doctor-command.ts
1014
+ var doctorUsage = `ace-kit doctor: check that this site is set up to work with CMS Ace.
1015
+
1016
+ Usage:
1017
+ ace-kit doctor [--site <domain>] [--api-url <address>] [--dir <folder>]
1018
+
1019
+ Run it in your site's folder. It changes nothing and never prints your login. It checks your Node version, that Ace Kit
1020
+ is installed, which CMS and site the settings point at, that the CMS can be reached and knows the site, that you are
1021
+ signed in, that the site has its Pages content, and that the section types in your code match the CMS. Each problem
1022
+ says what to do. It exits with 1 if anything is wrong.
1023
+
1024
+ Options:
1025
+ --site The domain your site has in the CMS (default: ACE_SITE_DOMAIN, else Astro's \`site\` setting).
1026
+ --api-url Where the CMS Ace API lives (default: ACE_API_URL, else the hosted CMS Ace).
1027
+ --dir The folder holding your *.schema.ts files (default: src and app).
1028
+ --help Show this message.
1029
+ `;
1030
+ var symbols = { ok: "\u2713", warn: "!", fail: "\u2717", info: "-" };
1031
+ var requestTimeoutMilliseconds4 = 15e3;
1032
+ var plain = (text) => String(text).replace(/\p{Cc}/gu, "").slice(0, 200);
1033
+ var isOlderThan = (version, major, minor) => {
1034
+ const [actualMajor = 0, actualMinor = 0] = version.split(".").map(Number);
1035
+ return actualMajor < major || actualMajor === major && actualMinor < minor;
1036
+ };
1037
+ async function readText(path) {
1038
+ try {
1039
+ return await readFile3(path, "utf8");
1040
+ } catch {
1041
+ return null;
1042
+ }
1043
+ }
1044
+ async function domainFromAstroConfig(root) {
1045
+ for (const name of ["astro.config.mjs", "astro.config.ts", "astro.config.js", "astro.config.mts"]) {
1046
+ const text = await readText(join4(root, name));
1047
+ const found = text?.match(/\bsite\s*:\s*["']([^"']+)["']/);
1048
+ if (found?.[1]) return domainFromSite(found[1]);
1049
+ }
1050
+ return null;
1051
+ }
1052
+ async function doctorCommand(commandArguments, environment) {
1053
+ const { values } = parseArgs3({
1054
+ // eslint-disable-next-line id-denylist -- "args" is the name node's parseArgs gives this option.
1055
+ args: commandArguments,
1056
+ options: {
1057
+ "api-url": { type: "string" },
1058
+ site: { type: "string" },
1059
+ dir: { type: "string" },
1060
+ help: { type: "boolean" }
1061
+ }
1062
+ });
1063
+ if (values.help) {
1064
+ environment.out(doctorUsage);
1065
+ return 0;
1066
+ }
1067
+ const checks = [];
1068
+ const add = (status, label, hint) => checks.push({ status, label, hint });
1069
+ const root = environment.workingDirectory;
1070
+ if (isOlderThan(environment.nodeVersion, 22, 12))
1071
+ add(
1072
+ "fail",
1073
+ `Node ${environment.nodeVersion} is too old`,
1074
+ "Ace Kit needs Node 22.12 or newer. If you use mise, nvm or fnm, check which version it picks in this folder."
1075
+ );
1076
+ else add("ok", `Node ${environment.nodeVersion}`);
1077
+ const manifestText = await readText(join4(root, "package.json"));
1078
+ let framework = null;
1079
+ if (manifestText === null) {
1080
+ add("fail", "No package.json in this folder", "Run this in your site's folder.");
1081
+ } else {
1082
+ let manifest = {};
1083
+ try {
1084
+ manifest = JSON.parse(manifestText);
1085
+ } catch {
1086
+ add("fail", "package.json isn't valid JSON");
1087
+ }
1088
+ const dependencies = { ...manifest.devDependencies, ...manifest.dependencies };
1089
+ if ("astro" in dependencies) framework = "astro";
1090
+ else if ("react-router" in dependencies) framework = "react-router";
1091
+ add(
1092
+ framework ? "ok" : "info",
1093
+ framework ? framework === "astro" ? "An Astro site" : "A React Router site" : "Not an Astro or React Router site"
1094
+ );
1095
+ const installed = await readText(join4(root, "node_modules", "@sandboxsonny", "ace-kit", "package.json"));
1096
+ if (!("@sandboxsonny/ace-kit" in dependencies)) {
1097
+ add("fail", "Ace Kit isn't a dependency of this site", "Run: npm install @sandboxsonny/ace-kit");
1098
+ } else if (installed === null) {
1099
+ add("fail", "Ace Kit isn't installed", "Run: npm install");
1100
+ } else {
1101
+ let version = "unknown";
1102
+ try {
1103
+ version = String(JSON.parse(installed).version ?? "unknown");
1104
+ } catch {
1105
+ }
1106
+ add("ok", `Ace Kit ${version} is installed`);
1107
+ if (version !== environment.version && environment.version !== "development")
1108
+ add("info", `This command is Ace Kit ${environment.version}; the site uses ${version}`);
1109
+ }
1110
+ }
1111
+ let apiUrl = null;
1112
+ try {
1113
+ apiUrl = normaliseApiUrl(values["api-url"] ?? environment.variables.ACE_API_URL ?? hostedApiUrl);
1114
+ add("ok", `CMS: ${apiUrl}${apiUrl === hostedApiUrl ? " (the hosted CMS Ace)" : ""}`);
1115
+ } catch (error) {
1116
+ add("fail", `The CMS address is wrong: ${error instanceof Error ? error.message : String(error)}`);
1117
+ }
1118
+ const given = values.site ?? environment.variables.ACE_SITE_DOMAIN;
1119
+ const domain = given ? domainFromSite(given) : framework === "astro" ? await domainFromAstroConfig(root) : null;
1120
+ if (domain) add("ok", `Site: ${domain}`);
1121
+ else
1122
+ add(
1123
+ "fail",
1124
+ "Can't tell which site this is",
1125
+ framework === "astro" ? 'Set `site: "https://your-domain.com"` in astro.config.mjs, or ACE_SITE_DOMAIN in .env.' : "Set ACE_SITE_DOMAIN=your-domain.com in .env."
1126
+ );
1127
+ if (apiUrl) await checkTheCms(apiUrl, domain);
1128
+ return finish();
1129
+ async function request(path, headers = {}) {
1130
+ try {
1131
+ const response = await environment.fetchImplementation(`${apiUrl}${path}`, {
1132
+ headers: { Accept: "application/json", ...headers },
1133
+ signal: AbortSignal.timeout(requestTimeoutMilliseconds4)
1134
+ });
1135
+ return { status: response.status, body: await response.json().catch(() => null) };
1136
+ } catch {
1137
+ return null;
1138
+ }
1139
+ }
1140
+ async function checkTheCms(address, siteDomain) {
1141
+ const health = await request("/health");
1142
+ if (!health || health.status >= 500 || health.status === 404) {
1143
+ add(
1144
+ "fail",
1145
+ `Can't reach the CMS at ${address}`,
1146
+ isLocalApiUrl(address) ? "Is your local CMS running? (docker compose -p cms-backend up -d)" : "Check the address and your connection."
1147
+ );
1148
+ return;
1149
+ }
1150
+ add("ok", "The CMS answers");
1151
+ if (!siteDomain) return;
1152
+ const asSite = { "X-Ace-Site-Domain": siteDomain };
1153
+ const site = await request("/public/site", asSite);
1154
+ if (site?.status === 404) {
1155
+ add(
1156
+ "fail",
1157
+ `The CMS has no site with the domain ${siteDomain}`,
1158
+ "Make it with `npx @sandboxsonny/ace-kit create`, or in the admin app, or fix the domain."
1159
+ );
1160
+ } else if (site?.status === 200) {
1161
+ add("ok", `The CMS knows the site: "${plain(site.body?.name)}"`);
1162
+ await checkContent(asSite);
1163
+ } else {
1164
+ add("fail", `The CMS answered with an error (${site?.status ?? "no answer"}) when asked about the site`);
1165
+ }
1166
+ await checkLogin(address, siteDomain);
1167
+ }
1168
+ async function checkContent(asSite) {
1169
+ const types = await request("/public/content-types", asSite);
1170
+ const typeList = Array.isArray(types?.body) ? types.body : [];
1171
+ if (!typeList.some((type) => type.handle === "pages")) {
1172
+ add(
1173
+ "warn",
1174
+ "The site has no Pages content type",
1175
+ "The starter's pages read one. Add it in the admin app (Content types)."
1176
+ );
1177
+ return;
1178
+ }
1179
+ const entries = await request("/public/entries?content_type=pages&limit=100", asSite);
1180
+ const list = Array.isArray(entries?.body) ? entries.body : [];
1181
+ if (list.some((entry) => entry.handle === "home"))
1182
+ add("ok", `Pages has ${list.length} published ${list.length === 1 ? "entry" : "entries"}, including home`);
1183
+ else
1184
+ add(
1185
+ "warn",
1186
+ "Pages has no published entry called home",
1187
+ "The site's front page (/) reads it. Add one in the admin app and publish it."
1188
+ );
1189
+ }
1190
+ async function checkLogin(address, siteDomain) {
1191
+ const login = await resolveLogin({
1192
+ apiUrl: address,
1193
+ variables: environment.variables,
1194
+ store: environment.store,
1195
+ now: environment.now
1196
+ });
1197
+ if (!login) {
1198
+ add(
1199
+ "warn",
1200
+ "Not signed in",
1201
+ `Run: npx ace-kit login${isLocalApiUrl(address) ? ` --api-url ${address}` : ""}. Section sync and dev sites need it.`
1202
+ );
1203
+ return;
1204
+ }
1205
+ const authorised = { Authorization: `Bearer ${login.token}` };
1206
+ const me = await request("/auth/me", authorised);
1207
+ if (me?.status === 401) {
1208
+ add("warn", "Your login has ended", "Run: npx ace-kit login");
1209
+ return;
1210
+ }
1211
+ if (me?.status !== 200) {
1212
+ add("warn", `Couldn't check your login (${me?.status ?? "no answer"})`);
1213
+ return;
1214
+ }
1215
+ add("ok", `Signed in as ${plain(me.body?.email)}`);
1216
+ const sites = await request("/sites", authorised);
1217
+ const visible = Array.isArray(sites?.body) ? sites.body : [];
1218
+ const mine = visible.find((candidate) => candidate.kind !== "dev" && candidate.domain.toLowerCase() === siteDomain);
1219
+ if (!mine) {
1220
+ add("info", "This login can't see that site, so its section types and preview address weren't checked");
1221
+ return;
1222
+ }
1223
+ if (mine.preview_url) add("ok", `Preview address: ${plain(mine.preview_url)}`);
1224
+ else if (isLocalApiUrl(address))
1225
+ add(
1226
+ "warn",
1227
+ "The site has no Preview address",
1228
+ "Set it in the site's Settings (for example http://localhost:4321) so the customiser can show your site."
1229
+ );
1230
+ await checkSections(address, mine.id, authorised);
1231
+ }
1232
+ async function checkSections(address, siteId, authorised) {
1233
+ const files = await findSchemaFiles(root, values.dir);
1234
+ if (files.length === 0) {
1235
+ add("info", "No section schemas (*.schema.ts) in your code");
1236
+ return;
1237
+ }
1238
+ const loaded = await loadSchemas(files, root, environment.importModule);
1239
+ if (loaded.problems.length > 0) {
1240
+ for (const problem of loaded.problems) add("fail", `Section schema problem: ${plain(problem)}`);
1241
+ return;
1242
+ }
1243
+ const inCode = loaded.definitions.map(({ definition }) => definition.handle);
1244
+ add("ok", `${inCode.length} section type${inCode.length === 1 ? "" : "s"} in your code (${inCode.join(", ")})`);
1245
+ const cms = await request(`/sites/${siteId}/section-types`, authorised);
1246
+ if (!Array.isArray(cms?.body)) return;
1247
+ const active = new Set(
1248
+ cms.body.filter((type) => !type.retired_at).map((type) => type.handle)
1249
+ );
1250
+ const missing = inCode.filter((handle) => !active.has(handle));
1251
+ const unused = [...active].filter((handle) => !inCode.includes(handle));
1252
+ if (missing.length > 0)
1253
+ add(
1254
+ "warn",
1255
+ `Not in the CMS yet: ${missing.join(", ")}`,
1256
+ `Run: npx ace-kit sync${isLocalApiUrl(address) ? ` --api-url ${address}` : ""} (the dev server does this on save)`
1257
+ );
1258
+ else add("ok", "The CMS has all of them");
1259
+ if (unused.length > 0)
1260
+ add(
1261
+ "info",
1262
+ `In the CMS but not in your code: ${unused.join(", ")} (a sync would retire ${unused.length === 1 ? "it" : "them"})`
1263
+ );
1264
+ }
1265
+ function finish() {
1266
+ environment.out("ace-kit doctor\n");
1267
+ for (const check of checks) {
1268
+ environment.out(` ${symbols[check.status]} ${check.label}`);
1269
+ if (check.hint) environment.out(` ${check.hint}`);
1270
+ }
1271
+ const failed = checks.filter((check) => check.status === "fail").length;
1272
+ const warned = checks.filter((check) => check.status === "warn").length;
1273
+ environment.out(
1274
+ failed > 0 ? `
1275
+ ${failed} problem${failed === 1 ? "" : "s"} to fix${warned > 0 ? `, and ${warned} warning${warned === 1 ? "" : "s"}` : ""}.` : warned > 0 ? `
1276
+ Nothing is broken; ${warned} warning${warned === 1 ? "" : "s"}.` : "\nAll good."
1277
+ );
1278
+ return failed > 0 ? 1 : 0;
1279
+ }
1280
+ }
1281
+
825
1282
  // src/cli/generate-command.ts
826
1283
  import { mkdir as mkdir2, writeFile as writeFile2 } from "fs/promises";
827
1284
  import { dirname as dirname2, resolve as resolve3 } from "path";
828
- import { parseArgs as parseArgs3 } from "util";
1285
+ import { parseArgs as parseArgs4 } from "util";
829
1286
 
830
1287
  // src/client/errors.ts
831
1288
  var AcePreviewExpiredError = class extends Error {
@@ -842,7 +1299,7 @@ var AceUnavailableError = class extends Error {
842
1299
  };
843
1300
 
844
1301
  // src/client/create-client.ts
845
- var requestTimeoutMilliseconds4 = 5e3;
1302
+ var requestTimeoutMilliseconds5 = 5e3;
846
1303
  var maximumCachedAnswers = 500;
847
1304
  function createAceClient({
848
1305
  apiUrl,
@@ -866,7 +1323,7 @@ function createAceClient({
866
1323
  try {
867
1324
  response = await fetchImplementation(`${apiUrl.replace(/\/+$/, "")}${path}`, {
868
1325
  headers: { Accept: "application/json", "X-Ace-Site-Domain": siteDomain, "X-Forwarded-Host": siteDomain },
869
- signal: AbortSignal.timeout(requestTimeoutMilliseconds4)
1326
+ signal: AbortSignal.timeout(requestTimeoutMilliseconds5)
870
1327
  });
871
1328
  } catch {
872
1329
  throw new AceUnavailableError("Could not reach the CMS.");
@@ -994,7 +1451,7 @@ Options:
994
1451
  --help Show this message.
995
1452
  `;
996
1453
  async function generateCommand(commandArguments, environment) {
997
- const { values } = parseArgs3({
1454
+ const { values } = parseArgs4({
998
1455
  // eslint-disable-next-line id-denylist -- "args" is the name node's parseArgs gives this option.
999
1456
  args: commandArguments,
1000
1457
  options: {
@@ -1033,192 +1490,10 @@ async function generateCommand(commandArguments, environment) {
1033
1490
  }
1034
1491
 
1035
1492
  // src/cli/sync-command.ts
1036
- import { parseArgs as parseArgs4 } from "util";
1037
-
1038
- // src/sections/find-schemas.ts
1039
- import { readdir as readdir2 } from "fs/promises";
1040
- import { join as join3, relative as relative2 } from "path";
1041
- var schemaFilePattern = /\.schema\.(?:ts|mts|js|mjs)$/;
1042
- var skippedFolders = /* @__PURE__ */ new Set([
1043
- "node_modules",
1044
- ".git",
1045
- "dist",
1046
- "build",
1047
- ".astro",
1048
- ".react-router",
1049
- ".output",
1050
- "coverage"
1051
- ]);
1052
- async function findSchemaFiles(root, folder = "src") {
1053
- const start = join3(root, folder);
1054
- const found = [];
1055
- async function walk(directory) {
1056
- let entries;
1057
- try {
1058
- entries = await readdir2(directory, { withFileTypes: true });
1059
- } catch {
1060
- return;
1061
- }
1062
- for (const entry of entries) {
1063
- const path = join3(directory, entry.name);
1064
- if (entry.isDirectory()) {
1065
- if (!skippedFolders.has(entry.name)) await walk(path);
1066
- } else if (entry.isFile() && schemaFilePattern.test(entry.name) && !entry.name.endsWith(".d.ts")) {
1067
- found.push(path);
1068
- }
1069
- }
1070
- }
1071
- await walk(start);
1072
- return found.sort((first, second) => {
1073
- const [firstName, secondName] = [relative2(root, first), relative2(root, second)];
1074
- return firstName < secondName ? -1 : firstName > secondName ? 1 : 0;
1075
- });
1076
- }
1077
-
1078
- // src/sections/load-schemas.ts
1079
- import { relative as relative3 } from "path";
1080
-
1081
- // src/sections/payload.ts
1082
- var handlePattern = /^[a-z0-9]+(-[a-z0-9]+)*$/;
1083
- var maximumHandleLength = 64;
1084
- var maximumNameLength = 200;
1085
- var fieldTypes = [
1086
- "text",
1087
- "textarea",
1088
- "number",
1089
- "checkbox",
1090
- "select",
1091
- "image",
1092
- "richtext",
1093
- "date",
1094
- "link"
1095
- ];
1096
- var isObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
1097
- var isHandle = (value) => typeof value === "string" && value.length <= maximumHandleLength && handlePattern.test(value);
1098
- var handleRule = 'lowercase letters, numbers and hyphens (such as "call-to-action")';
1099
- function checkFields(fields, where, problems) {
1100
- if (fields === void 0) return;
1101
- if (!Array.isArray(fields)) {
1102
- problems.push(`${where}: fields must be a list.`);
1103
- return;
1104
- }
1105
- const seen = /* @__PURE__ */ new Set();
1106
- fields.forEach((field, index) => {
1107
- if (!isObject(field)) {
1108
- problems.push(`${where}: field ${index + 1} must be an object with a handle, a label and a type.`);
1109
- return;
1110
- }
1111
- const name = isHandle(field.handle) ? `field "${field.handle}"` : `field ${index + 1}`;
1112
- if (!isHandle(field.handle)) problems.push(`${where}: ${name} needs a handle of ${handleRule}.`);
1113
- else if (seen.has(field.handle)) problems.push(`${where}: ${name} is defined twice.`);
1114
- else seen.add(field.handle);
1115
- if (typeof field.label !== "string" || field.label.trim() === "" || field.label.length > maximumNameLength) {
1116
- problems.push(`${where}: ${name} needs a label.`);
1117
- }
1118
- if (typeof field.type !== "string" || !fieldTypes.includes(field.type)) {
1119
- const given = typeof field.type === "string" ? ` "${field.type}"` : "";
1120
- problems.push(`${where}: ${name} has a type${given} that isn't one of ${fieldTypes.join(", ")}.`);
1121
- } else if (field.type === "select") {
1122
- const { options } = field;
1123
- if (!Array.isArray(options) || options.length === 0 || options.some((option) => typeof option !== "string" || option === "")) {
1124
- problems.push(`${where}: ${name} is a select, so it needs options: a list of the choices, as text.`);
1125
- }
1126
- }
1127
- if (field.required !== void 0 && typeof field.required !== "boolean") {
1128
- problems.push(`${where}: ${name} has a required that isn't true or false.`);
1129
- }
1130
- });
1131
- }
1132
- function checkSectionDefinition(definition, where = "this section") {
1133
- const problems = [];
1134
- if (!isObject(definition)) {
1135
- return [`${where}: the default export must be the result of defineSection({ ... }).`];
1136
- }
1137
- if (!isHandle(definition.handle)) {
1138
- problems.push(`${where}: needs a handle of ${handleRule}.`);
1139
- }
1140
- if (typeof definition.name !== "string" || definition.name.trim() === "" || definition.name.length > maximumNameLength) {
1141
- problems.push(`${where}: needs a name.`);
1142
- }
1143
- checkFields(definition.fields, where, problems);
1144
- if (definition.blocks !== void 0) {
1145
- if (!isObject(definition.blocks)) {
1146
- problems.push(`${where}: blocks must be an object of the kinds of block, by handle.`);
1147
- } else {
1148
- for (const [handle, block] of Object.entries(definition.blocks)) {
1149
- const blockWhere = `${where}, block "${handle}"`;
1150
- if (!isHandle(handle)) problems.push(`${blockWhere}: the handle must be ${handleRule}.`);
1151
- if (!isObject(block)) {
1152
- problems.push(`${blockWhere}: must be an object with a name and fields.`);
1153
- continue;
1154
- }
1155
- if (typeof block.name !== "string" || block.name.trim() === "") problems.push(`${blockWhere}: needs a name.`);
1156
- checkFields(block.fields, blockWhere, problems);
1157
- }
1158
- }
1159
- }
1160
- return problems;
1161
- }
1162
- function apiFields(fields) {
1163
- return (fields ?? []).map((field) => ({
1164
- handle: field.handle,
1165
- label: field.label,
1166
- component_key: field.type,
1167
- config: {
1168
- ...field.required ? { required: true } : {},
1169
- ...field.type === "select" ? { options: [...field.options] } : {}
1170
- }
1171
- }));
1172
- }
1173
- function sectionTypesPayload(definitions) {
1174
- return {
1175
- sections: definitions.map((definition) => ({
1176
- handle: definition.handle,
1177
- name: definition.name,
1178
- fields: apiFields(definition.fields),
1179
- blocks: Object.entries(definition.blocks ?? {}).map(([handle, block]) => ({
1180
- handle,
1181
- name: block.name,
1182
- fields: apiFields(block.fields)
1183
- }))
1184
- }))
1185
- };
1186
- }
1187
-
1188
- // src/sections/load-schemas.ts
1189
- async function loadSchemas(files, root, importModule) {
1190
- const definitions = [];
1191
- const problems = [];
1192
- const handleToFile = /* @__PURE__ */ new Map();
1193
- for (const file of files) {
1194
- const name = relative3(root, file);
1195
- let exported;
1196
- try {
1197
- const module = await importModule(file);
1198
- exported = module.default;
1199
- } catch (error) {
1200
- problems.push(`${name}: could not be loaded: ${error instanceof Error ? error.message : String(error)}`);
1201
- continue;
1202
- }
1203
- const fileProblems = checkSectionDefinition(exported, name);
1204
- if (fileProblems.length > 0) {
1205
- problems.push(...fileProblems);
1206
- continue;
1207
- }
1208
- const definition = exported;
1209
- const earlier = handleToFile.get(definition.handle);
1210
- if (earlier) {
1211
- problems.push(`${name}: the handle "${definition.handle}" is already used by ${earlier}.`);
1212
- continue;
1213
- }
1214
- handleToFile.set(definition.handle, name);
1215
- definitions.push({ file, definition });
1216
- }
1217
- return { definitions, problems };
1218
- }
1493
+ import { parseArgs as parseArgs5 } from "util";
1219
1494
 
1220
1495
  // src/sections/sync.ts
1221
- var requestTimeoutMilliseconds5 = 2e4;
1496
+ var requestTimeoutMilliseconds6 = 2e4;
1222
1497
  async function readMessage2(response, fallback) {
1223
1498
  try {
1224
1499
  const body = await response.json();
@@ -1234,7 +1509,7 @@ async function syncSectionTypes(options) {
1234
1509
  try {
1235
1510
  sitesResponse = await fetchImplementation(`${apiUrl}/sites`, {
1236
1511
  headers,
1237
- signal: AbortSignal.timeout(requestTimeoutMilliseconds5)
1512
+ signal: AbortSignal.timeout(requestTimeoutMilliseconds6)
1238
1513
  });
1239
1514
  } catch {
1240
1515
  return { kind: "unreachable" };
@@ -1256,7 +1531,7 @@ async function syncSectionTypes(options) {
1256
1531
  method: "PUT",
1257
1532
  headers: { ...headers, "Content-Type": "application/json" },
1258
1533
  body: JSON.stringify(payload),
1259
- signal: AbortSignal.timeout(requestTimeoutMilliseconds5)
1534
+ signal: AbortSignal.timeout(requestTimeoutMilliseconds6)
1260
1535
  });
1261
1536
  } catch {
1262
1537
  return { kind: "unreachable" };
@@ -1292,7 +1567,7 @@ var syncUsage = `ace-kit sync: send the section types your code defines to CMS A
1292
1567
  Usage:
1293
1568
  ace-kit sync [--site <domain>] [--api-url <address>] [--dir <folder>]
1294
1569
 
1295
- It finds every *.schema.ts file in the folder (src by default), checks them, and makes the CMS's section types for your
1570
+ It finds every *.schema.ts file in the folder (src and app by default), checks them, and makes the CMS's section types for your
1296
1571
  site match. The dev server does this for you whenever a schema file is saved; run it yourself for a one-off sync, or on a
1297
1572
  build server (with ACE_TOKEN set).
1298
1573
 
@@ -1303,12 +1578,12 @@ Options:
1303
1578
  --site The domain your site has in the CMS, e.g. example.com (default: ACE_SITE_DOMAIN, else localhost).
1304
1579
  (--domain means the same.)
1305
1580
  --api-url Where the CMS Ace API lives (default: ACE_API_URL, else the hosted CMS Ace). Only for a CMS on your own machine.
1306
- --dir The folder to look in (default: src).
1581
+ --dir The folder to look in (default: src and app).
1307
1582
  --help Show this message.
1308
1583
  `;
1309
1584
  var isSwitchedOff = (value) => ["false", "off", "no", "0"].includes((value ?? "").trim().toLowerCase());
1310
1585
  async function syncCommand(commandArguments, environment) {
1311
- const { values } = parseArgs4({
1586
+ const { values } = parseArgs5({
1312
1587
  // eslint-disable-next-line id-denylist -- "args" is the name node's parseArgs gives this option.
1313
1588
  args: commandArguments,
1314
1589
  options: {
@@ -1344,11 +1619,11 @@ async function syncCommand(commandArguments, environment) {
1344
1619
  );
1345
1620
  return 0;
1346
1621
  }
1347
- const files = await findSchemaFiles(environment.workingDirectory, values.dir ?? "src");
1622
+ const files = await findSchemaFiles(environment.workingDirectory, values.dir);
1348
1623
  if (files.length === 0) {
1349
1624
  if (!automatic) {
1350
1625
  environment.out(
1351
- `No *.schema.ts files found in ${values.dir ?? "src"}, so nothing was synced (syncing none would retire every section).`
1626
+ `No *.schema.ts files found in ${values.dir ?? "src or app"}, so nothing was synced (syncing none would retire every section).`
1352
1627
  );
1353
1628
  }
1354
1629
  return 0;
@@ -1409,6 +1684,7 @@ var usage = `ace-kit: tools for sites built on CMS Ace.
1409
1684
 
1410
1685
  Commands:
1411
1686
  ace-kit create Make a new site from a starter.
1687
+ ace-kit doctor Check this site is set up to work with CMS Ace.
1412
1688
  ace-kit generate Write TypeScript types for your content types.
1413
1689
  ace-kit sync Send the section types your code defines to the CMS.
1414
1690
  ace-kit login Sign in from the command line (opens the admin app).
@@ -1422,6 +1698,8 @@ async function runCli(commandArguments, environment) {
1422
1698
  switch (command) {
1423
1699
  case "create":
1424
1700
  return createCommand(rest, environment);
1701
+ case "doctor":
1702
+ return doctorCommand(rest, environment);
1425
1703
  case "generate":
1426
1704
  return generateCommand(rest, environment);
1427
1705
  case "sync":
@@ -1447,6 +1725,7 @@ ${usage}`);
1447
1725
  }
1448
1726
  export {
1449
1727
  createUsage,
1728
+ doctorUsage,
1450
1729
  generateUsage,
1451
1730
  loginUsage,
1452
1731
  runCli,