@sandboxsonny/ace-kit 0.0.0-stage → 0.1.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.
Files changed (44) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/LICENSE +21 -0
  3. package/README.md +79 -2
  4. package/astro/AceImage.astro +29 -0
  5. package/astro/AceMenu.astro +24 -0
  6. package/astro/AceMenuList.astro +25 -0
  7. package/astro/AceNotice.astro +19 -0
  8. package/astro/AcePreviewRefresh.astro +15 -0
  9. package/astro/AceRichText.astro +12 -0
  10. package/astro/AceTextArea.astro +14 -0
  11. package/dist/astro-integration.d.ts +20 -0
  12. package/dist/astro-integration.js +32 -0
  13. package/dist/astro-middleware.js +10 -0
  14. package/dist/astro.d.ts +139 -0
  15. package/dist/astro.js +24 -0
  16. package/dist/chunk-KEV3YAWN.js +66 -0
  17. package/dist/chunk-KLRCWAE4.js +147 -0
  18. package/dist/chunk-M5WR5TCR.js +16 -0
  19. package/dist/chunk-URLKW2YS.js +107 -0
  20. package/dist/chunk-XPZY5R5A.js +16 -0
  21. package/dist/chunk-YLO6IAXF.js +45 -0
  22. package/dist/chunk-YN7LJWGI.js +36 -0
  23. package/dist/cli.js +211 -0
  24. package/dist/index.d.ts +310 -0
  25. package/dist/index.js +6 -0
  26. package/dist/preview-script.js +6 -0
  27. package/dist/react-router-server.d.ts +225 -0
  28. package/dist/react-router-server.js +89 -0
  29. package/dist/react-router.d.ts +155 -0
  30. package/dist/react-router.js +55 -0
  31. package/dist/react.d.ts +88 -0
  32. package/dist/react.js +5 -0
  33. package/dist/vite.d.ts +17 -0
  34. package/dist/vite.js +27 -0
  35. package/docs/api-reference.md +105 -0
  36. package/docs/astro.md +93 -0
  37. package/docs/getting-started.md +62 -0
  38. package/docs/live-preview.md +15 -0
  39. package/docs/react-router.md +116 -0
  40. package/docs/react.md +81 -0
  41. package/docs/releasing.md +53 -0
  42. package/docs/rich-text.md +54 -0
  43. package/docs/typed-content.md +50 -0
  44. package/package.json +158 -4
@@ -0,0 +1,147 @@
1
+ import { addressFromPath, isHomeAddress, visitablePath } from './chunk-M5WR5TCR.js';
2
+ import { previewTokenFrom, previewHeaders } from './chunk-YLO6IAXF.js';
3
+
4
+ // src/client/errors.ts
5
+ var AcePreviewExpiredError = class extends Error {
6
+ constructor() {
7
+ super("The preview link has expired.");
8
+ this.name = "AcePreviewExpiredError";
9
+ }
10
+ };
11
+ var AceUnavailableError = class extends Error {
12
+ constructor(message) {
13
+ super(message);
14
+ this.name = "AceUnavailableError";
15
+ }
16
+ };
17
+
18
+ // src/client/create-client.ts
19
+ var requestTimeoutMilliseconds = 5e3;
20
+ var maximumCachedAnswers = 500;
21
+ function createAceClient({
22
+ apiUrl,
23
+ siteDomain,
24
+ cacheSeconds = 5,
25
+ fetchImplementation = (input, init) => fetch(input, init),
26
+ now = Date.now
27
+ }) {
28
+ const cache = /* @__PURE__ */ new Map();
29
+ function forgetSomeAnswers() {
30
+ for (const [path, answer] of cache) if (answer.expiresAt <= now()) cache.delete(path);
31
+ for (const path of cache.keys()) {
32
+ if (cache.size < maximumCachedAnswers) break;
33
+ cache.delete(path);
34
+ }
35
+ }
36
+ async function get(path, options = { cached: true }) {
37
+ const cached = options.cached ? cache.get(path) : void 0;
38
+ if (cached && cached.expiresAt > now()) return cached.value;
39
+ let response;
40
+ try {
41
+ response = await fetchImplementation(`${apiUrl.replace(/\/+$/, "")}${path}`, {
42
+ headers: { Accept: "application/json", "X-Forwarded-Host": siteDomain },
43
+ signal: AbortSignal.timeout(requestTimeoutMilliseconds)
44
+ });
45
+ } catch {
46
+ throw new AceUnavailableError("Could not reach the CMS.");
47
+ }
48
+ let value = null;
49
+ if (response.status === 404) {
50
+ const reply = await response.json().catch(() => ({}));
51
+ if (reply.error === "no site for this domain") {
52
+ throw new AceUnavailableError(`The CMS has no site with the domain "${siteDomain}".`);
53
+ }
54
+ } else if (response.status === 403 && !options.cached) {
55
+ throw new AcePreviewExpiredError();
56
+ } else if (response.ok) {
57
+ value = await response.json().catch(() => {
58
+ throw new AceUnavailableError("The CMS sent a reply that could not be read.");
59
+ });
60
+ } else {
61
+ throw new AceUnavailableError(`The CMS answered with an error (${response.status}).`);
62
+ }
63
+ if (options.cached && cacheSeconds > 0) {
64
+ if (cache.size >= maximumCachedAnswers) forgetSomeAnswers();
65
+ cache.set(path, { expiresAt: now() + cacheSeconds * 1e3, value });
66
+ }
67
+ return value;
68
+ }
69
+ return {
70
+ /**
71
+ * One live entry of a content type, by its handle; null if the site has no such entry. With a preview token the
72
+ * entry the token was made for comes back with its draft applied (never cached); a token that has run out throws
73
+ * AcePreviewExpiredError.
74
+ */
75
+ async getEntry(contentTypeHandle, handle, options = {}) {
76
+ const path = `/public/entries/${encodeURIComponent(handle)}`;
77
+ const entry = options.previewToken ? await get(`${path}?preview=${encodeURIComponent(options.previewToken)}`, {
78
+ cached: false
79
+ }) : await get(path);
80
+ return entry && entry.content_type_handle === contentTypeHandle ? entry : null;
81
+ },
82
+ /** The live entries of a content type, newest first. */
83
+ async getEntries(contentTypeHandle, options = {}) {
84
+ const query = new URLSearchParams({ content_type: contentTypeHandle });
85
+ if (options.limit) query.set("limit", String(options.limit));
86
+ return await get(`/public/entries?${query}`) ?? [];
87
+ },
88
+ /** One of the site's navigation menus by its handle, with every link worked out; null if the site has none. */
89
+ getMenu(handle) {
90
+ return get(`/public/menus/${encodeURIComponent(handle)}`);
91
+ },
92
+ /** What the site's content types and fields are, which is what type generation reads. */
93
+ async getContentTypes() {
94
+ return await get("/public/content-types") ?? [];
95
+ }
96
+ };
97
+ }
98
+
99
+ // src/helpers/menus.ts
100
+ function linksFromMenu(menu) {
101
+ const convert = (items) => items.map((item) => {
102
+ const children = convert(item.children);
103
+ const to = visitablePath(item.url);
104
+ return children.length > 0 ? { label: item.label, to, children } : { label: item.label, to };
105
+ });
106
+ return convert(menu.items);
107
+ }
108
+ function isCurrentPage(link, pathname) {
109
+ const normalised = pathname.length > 1 ? pathname.replace(/\/$/, "") : pathname;
110
+ return link.to === normalised;
111
+ }
112
+ async function loadMenuLinks(client, handle, fallback = []) {
113
+ try {
114
+ const menu = await client.getMenu(handle);
115
+ return menu ? linksFromMenu(menu) : fallback;
116
+ } catch (error) {
117
+ if (!(error instanceof AceUnavailableError)) throw error;
118
+ console.error(error.message);
119
+ return fallback;
120
+ }
121
+ }
122
+
123
+ // src/helpers/page.ts
124
+ async function loadPage(client, { slug, url }) {
125
+ const address = addressFromPath(slug);
126
+ const previewToken = previewTokenFrom(url);
127
+ const headers = previewToken ? { ...previewHeaders } : {};
128
+ if (slug && address && isHomeAddress(address)) {
129
+ return { kind: "redirect", status: url.search ? 302 : 301, location: `/${url.search}`, headers };
130
+ }
131
+ if (!address) return { kind: "not-found", status: 404, headers };
132
+ try {
133
+ const entry = await client.getEntry(address.contentTypeHandle, address.entryHandle, {
134
+ previewToken
135
+ });
136
+ return entry ? { kind: "entry", status: 200, entry, headers } : { kind: "not-found", status: 404, headers };
137
+ } catch (error) {
138
+ if (error instanceof AcePreviewExpiredError) return { kind: "preview-expired", status: 403, headers };
139
+ if (error instanceof AceUnavailableError) {
140
+ console.error(error.message);
141
+ return { kind: "unavailable", status: 503, headers };
142
+ }
143
+ throw error;
144
+ }
145
+ }
146
+
147
+ export { AcePreviewExpiredError, AceUnavailableError, createAceClient, isCurrentPage, linksFromMenu, loadMenuLinks, loadPage };
@@ -0,0 +1,16 @@
1
+ // src/helpers/addressing.ts
2
+ var homeAddress = { contentTypeHandle: "pages", entryHandle: "home" };
3
+ var pathOfAddress = (address) => `/${address.contentTypeHandle}/${address.entryHandle}`;
4
+ var handlePattern = /^[a-z0-9]+(-[a-z0-9]+)*$/;
5
+ function addressFromPath(slug) {
6
+ const segments = (slug ?? "").split("/").filter((segment) => segment !== "");
7
+ if (segments.length === 0) return homeAddress;
8
+ if (segments.length !== 2 || !segments.every((segment) => handlePattern.test(segment))) return null;
9
+ return { contentTypeHandle: segments[0], entryHandle: segments[1] };
10
+ }
11
+ var isHomeAddress = (address) => address.contentTypeHandle === homeAddress.contentTypeHandle && address.entryHandle === homeAddress.entryHandle;
12
+ function visitablePath(path) {
13
+ return path === pathOfAddress(homeAddress) ? "/" : path;
14
+ }
15
+
16
+ export { addressFromPath, homeAddress, isHomeAddress, pathOfAddress, visitablePath };
@@ -0,0 +1,107 @@
1
+ import { visitablePath } from './chunk-M5WR5TCR.js';
2
+
3
+ // src/helpers/images.ts
4
+ function dimension(value) {
5
+ return typeof value === "number" && Number.isFinite(value) && value >= 1 ? Math.round(value) : void 0;
6
+ }
7
+ function imageAttributes(image, { sizes, lazy = true }) {
8
+ return {
9
+ src: image.src,
10
+ srcSet: image.srcset || void 0,
11
+ sizes: image.srcset ? sizes : void 0,
12
+ width: dimension(image.width),
13
+ height: dimension(image.height),
14
+ alt: image.alt_text,
15
+ loading: lazy ? "lazy" : "eager",
16
+ decoding: "async",
17
+ fetchPriority: lazy ? void 0 : "high"
18
+ };
19
+ }
20
+
21
+ // src/helpers/links.ts
22
+ function linkAddress(address) {
23
+ const allowed = /^(\/(?!\/)|https?:\/\/[^\s/]|mailto:\S|tel:\S)/.test(address) && !/\s/.test(address);
24
+ return allowed ? visitablePath(address) : null;
25
+ }
26
+ function isInternalPath(address) {
27
+ return address.startsWith("/") && !address.startsWith("//");
28
+ }
29
+
30
+ // src/helpers/text-area.ts
31
+ var escapes = { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" };
32
+ var escapeHtml = (text) => text.replace(/[&<>"']/g, (character) => escapes[character]);
33
+ function textAreaParagraphs(text) {
34
+ return (text ?? "").split(/\r?\n\s*\r?\n/).map((paragraph) => paragraph.trim()).filter((paragraph) => paragraph !== "");
35
+ }
36
+ var textAreaLines = (paragraph) => paragraph.split(/\r?\n/).map((line) => line.trim());
37
+ var textAreaSingleLine = (text) => (text ?? "").split(/\s*\r?\n\s*/).filter((part) => part !== "").join(" ").trim();
38
+ function renderTextArea(text, { multiline = true } = {}) {
39
+ if (!multiline) return escapeHtml(textAreaSingleLine(text));
40
+ return textAreaParagraphs(text).map((paragraph) => `<p>${textAreaLines(paragraph).map(escapeHtml).join("<br>")}</p>`).join("");
41
+ }
42
+
43
+ // src/helpers/rich-text.ts
44
+ var escapes2 = { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" };
45
+ var escapeHtml2 = (text) => text.replace(/[&<>"']/g, (character) => escapes2[character]);
46
+ var richTextImageSizes = "(min-width: 42rem) 42rem, 100vw";
47
+ var headingLevel = (node) => node.attrs?.level === 3 || node.attrs?.level === 4 ? node.attrs.level : 2;
48
+ var listStart = (node) => node.attrs?.start && node.attrs.start > 1 ? Math.trunc(node.attrs.start) : void 0;
49
+ function renderImage(node) {
50
+ if (!node.attrs?.image) return "";
51
+ const attributes = imageAttributes(node.attrs.image, { sizes: richTextImageSizes });
52
+ const parts = [
53
+ `src="${escapeHtml2(attributes.src)}"`,
54
+ attributes.srcSet ? `srcset="${escapeHtml2(attributes.srcSet)}" sizes="${escapeHtml2(attributes.sizes ?? "")}"` : "",
55
+ attributes.width ? `width="${attributes.width}"` : "",
56
+ attributes.height ? `height="${attributes.height}"` : "",
57
+ `alt="${escapeHtml2(attributes.alt)}"`,
58
+ 'loading="lazy" decoding="async"'
59
+ ];
60
+ return `<figure><img ${parts.filter(Boolean).join(" ")}></figure>`;
61
+ }
62
+ function renderText(node) {
63
+ const marks = node.marks ?? [];
64
+ return marks.reduce(
65
+ (html, mark) => {
66
+ if (mark.type === "bold") return `<strong>${html}</strong>`;
67
+ if (mark.type === "italic") return `<em>${html}</em>`;
68
+ const address = linkAddress(mark.attrs.href);
69
+ return address ? `<a href="${escapeHtml2(address)}">${html}</a>` : html;
70
+ },
71
+ escapeHtml2(node.text ?? "")
72
+ );
73
+ }
74
+ function renderNode(node) {
75
+ const inside = () => (node.content ?? []).map(renderNode).join("");
76
+ switch (node.type) {
77
+ case "text":
78
+ return renderText(node);
79
+ case "hardBreak":
80
+ return "<br>";
81
+ case "paragraph":
82
+ return `<p>${inside()}</p>`;
83
+ case "heading": {
84
+ const level = headingLevel(node);
85
+ return `<h${level}>${inside()}</h${level}>`;
86
+ }
87
+ case "bulletList":
88
+ return `<ul>${inside()}</ul>`;
89
+ case "orderedList": {
90
+ const start = listStart(node);
91
+ return `<ol${start ? ` start="${start}"` : ""}>${inside()}</ol>`;
92
+ }
93
+ case "listItem":
94
+ return `<li>${inside()}</li>`;
95
+ case "blockquote":
96
+ return `<blockquote>${inside()}</blockquote>`;
97
+ case "image":
98
+ return renderImage(node);
99
+ default:
100
+ return "";
101
+ }
102
+ }
103
+ function renderRichText(document) {
104
+ return (document.content ?? []).map(renderNode).join("");
105
+ }
106
+
107
+ export { headingLevel, imageAttributes, isInternalPath, linkAddress, listStart, renderRichText, renderTextArea, richTextImageSizes, textAreaLines, textAreaParagraphs, textAreaSingleLine };
@@ -0,0 +1,16 @@
1
+ // src/helpers/notices.ts
2
+ var pageNotices = {
3
+ redirect: { heading: "Redirecting", message: "This page has moved." },
4
+ "preview-expired": {
5
+ heading: "Preview expired",
6
+ message: "This preview link has run out. Reload the editor in the CMS to get a new one."
7
+ },
8
+ unavailable: {
9
+ heading: "Temporarily unavailable",
10
+ message: "This page can't be shown right now. Please try again shortly."
11
+ },
12
+ "not-found": { heading: "Page not found", message: "There's nothing at this address." },
13
+ entry: { heading: "", message: "" }
14
+ };
15
+
16
+ export { pageNotices };
@@ -0,0 +1,45 @@
1
+ // src/helpers/preview.ts
2
+ var previewParameter = "cms_preview";
3
+ var refreshMessageType = "cms-ace:refresh";
4
+ function originOf(address) {
5
+ try {
6
+ const parsed = new URL(address);
7
+ return parsed.protocol === "http:" || parsed.protocol === "https:" ? parsed.origin : null;
8
+ } catch {
9
+ return null;
10
+ }
11
+ }
12
+ function isRefreshRequest(event, adminOrigin) {
13
+ const expected = originOf(adminOrigin);
14
+ if (expected === null || event.origin !== expected) return false;
15
+ return typeof event.data === "object" && event.data !== null && event.data.type === refreshMessageType;
16
+ }
17
+ function previewTokenFrom(address) {
18
+ return address.searchParams.get(previewParameter) || null;
19
+ }
20
+ function framingPolicy(adminOrigin) {
21
+ const origin = originOf(adminOrigin);
22
+ if (origin === null) {
23
+ throw new Error(
24
+ `The CMS admin address must be a web address like https://admin.example.com (got "${adminOrigin}"). Check ACE_PREVIEW_ADMIN_ORIGIN.`
25
+ );
26
+ }
27
+ return `frame-ancestors 'self' ${origin}`;
28
+ }
29
+ function addFramingPolicy(headers, adminOrigin) {
30
+ const policy = framingPolicy(adminOrigin);
31
+ const existing = headers.get("Content-Security-Policy");
32
+ if (!existing) {
33
+ headers.set("Content-Security-Policy", policy);
34
+ return;
35
+ }
36
+ const others = existing.split(";").map((directive) => directive.trim()).filter((directive) => directive !== "" && !/^frame-ancestors(\s|$)/i.test(directive));
37
+ headers.set("Content-Security-Policy", [...others, policy].join("; "));
38
+ }
39
+ var previewHeaders = { "Cache-Control": "no-store", "X-Robots-Tag": "noindex" };
40
+ function swapPageContent(current, fresh) {
41
+ current.title = fresh.title;
42
+ current.body.replaceChildren(...Array.from(fresh.body.childNodes).map((node) => current.importNode(node, true)));
43
+ }
44
+
45
+ export { addFramingPolicy, framingPolicy, isRefreshRequest, originOf, previewHeaders, previewParameter, previewTokenFrom, refreshMessageType, swapPageContent };
@@ -0,0 +1,36 @@
1
+ import { swapPageContent, isRefreshRequest } from './chunk-YLO6IAXF.js';
2
+
3
+ // src/helpers/preview-listener.ts
4
+ async function refreshPageContent() {
5
+ const response = await fetch(location.href, { cache: "no-store" });
6
+ if (response.ok) swapPageContent(document, new DOMParser().parseFromString(await response.text(), "text/html"));
7
+ }
8
+ function listenForPreviewRefresh(adminOrigin, refresh = refreshPageContent) {
9
+ let refreshing = false;
10
+ let again = false;
11
+ async function run() {
12
+ if (refreshing) {
13
+ again = true;
14
+ return;
15
+ }
16
+ refreshing = true;
17
+ try {
18
+ await refresh();
19
+ } catch (error) {
20
+ console.error("The preview could not be refreshed.", error);
21
+ } finally {
22
+ refreshing = false;
23
+ if (again) {
24
+ again = false;
25
+ void run();
26
+ }
27
+ }
28
+ }
29
+ const onMessage = (event) => {
30
+ if (isRefreshRequest(event, adminOrigin)) void run();
31
+ };
32
+ window.addEventListener("message", onMessage);
33
+ return () => window.removeEventListener("message", onMessage);
34
+ }
35
+
36
+ export { listenForPreviewRefresh, refreshPageContent };
package/dist/cli.js ADDED
@@ -0,0 +1,211 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/generate/cli.ts
4
+ import { mkdir, writeFile } from "fs/promises";
5
+ import { dirname, resolve } from "path";
6
+ import { parseArgs } from "util";
7
+
8
+ // src/client/errors.ts
9
+ var AcePreviewExpiredError = class extends Error {
10
+ constructor() {
11
+ super("The preview link has expired.");
12
+ this.name = "AcePreviewExpiredError";
13
+ }
14
+ };
15
+ var AceUnavailableError = class extends Error {
16
+ constructor(message) {
17
+ super(message);
18
+ this.name = "AceUnavailableError";
19
+ }
20
+ };
21
+
22
+ // src/client/create-client.ts
23
+ var requestTimeoutMilliseconds = 5e3;
24
+ var maximumCachedAnswers = 500;
25
+ function createAceClient({
26
+ apiUrl,
27
+ siteDomain,
28
+ cacheSeconds = 5,
29
+ fetchImplementation = (input, init) => fetch(input, init),
30
+ now = Date.now
31
+ }) {
32
+ const cache = /* @__PURE__ */ new Map();
33
+ function forgetSomeAnswers() {
34
+ for (const [path, answer] of cache) if (answer.expiresAt <= now()) cache.delete(path);
35
+ for (const path of cache.keys()) {
36
+ if (cache.size < maximumCachedAnswers) break;
37
+ cache.delete(path);
38
+ }
39
+ }
40
+ async function get(path, options = { cached: true }) {
41
+ const cached = options.cached ? cache.get(path) : void 0;
42
+ if (cached && cached.expiresAt > now()) return cached.value;
43
+ let response;
44
+ try {
45
+ response = await fetchImplementation(`${apiUrl.replace(/\/+$/, "")}${path}`, {
46
+ headers: { Accept: "application/json", "X-Forwarded-Host": siteDomain },
47
+ signal: AbortSignal.timeout(requestTimeoutMilliseconds)
48
+ });
49
+ } catch {
50
+ throw new AceUnavailableError("Could not reach the CMS.");
51
+ }
52
+ let value = null;
53
+ if (response.status === 404) {
54
+ const reply = await response.json().catch(() => ({}));
55
+ if (reply.error === "no site for this domain") {
56
+ throw new AceUnavailableError(`The CMS has no site with the domain "${siteDomain}".`);
57
+ }
58
+ } else if (response.status === 403 && !options.cached) {
59
+ throw new AcePreviewExpiredError();
60
+ } else if (response.ok) {
61
+ value = await response.json().catch(() => {
62
+ throw new AceUnavailableError("The CMS sent a reply that could not be read.");
63
+ });
64
+ } else {
65
+ throw new AceUnavailableError(`The CMS answered with an error (${response.status}).`);
66
+ }
67
+ if (options.cached && cacheSeconds > 0) {
68
+ if (cache.size >= maximumCachedAnswers) forgetSomeAnswers();
69
+ cache.set(path, { expiresAt: now() + cacheSeconds * 1e3, value });
70
+ }
71
+ return value;
72
+ }
73
+ return {
74
+ /**
75
+ * One live entry of a content type, by its handle; null if the site has no such entry. With a preview token the
76
+ * entry the token was made for comes back with its draft applied (never cached); a token that has run out throws
77
+ * AcePreviewExpiredError.
78
+ */
79
+ async getEntry(contentTypeHandle, handle, options = {}) {
80
+ const path = `/public/entries/${encodeURIComponent(handle)}`;
81
+ const entry = options.previewToken ? await get(`${path}?preview=${encodeURIComponent(options.previewToken)}`, {
82
+ cached: false
83
+ }) : await get(path);
84
+ return entry && entry.content_type_handle === contentTypeHandle ? entry : null;
85
+ },
86
+ /** The live entries of a content type, newest first. */
87
+ async getEntries(contentTypeHandle, options = {}) {
88
+ const query = new URLSearchParams({ content_type: contentTypeHandle });
89
+ if (options.limit) query.set("limit", String(options.limit));
90
+ return await get(`/public/entries?${query}`) ?? [];
91
+ },
92
+ /** One of the site's navigation menus by its handle, with every link worked out; null if the site has none. */
93
+ getMenu(handle) {
94
+ return get(`/public/menus/${encodeURIComponent(handle)}`);
95
+ },
96
+ /** What the site's content types and fields are, which is what type generation reads. */
97
+ async getContentTypes() {
98
+ return await get("/public/content-types") ?? [];
99
+ }
100
+ };
101
+ }
102
+
103
+ // src/helpers/generate-types.ts
104
+ var quote = (text) => JSON.stringify(text);
105
+ function typeOfField(field) {
106
+ switch (field.component_key) {
107
+ case "text":
108
+ case "textarea":
109
+ return { type: "string" };
110
+ case "number":
111
+ return { type: "number" };
112
+ case "checkbox":
113
+ return { type: "boolean" };
114
+ case "select": {
115
+ const options = field.config?.options ?? [];
116
+ return { type: options.length > 0 ? options.map(quote).join(" | ") : "string" };
117
+ }
118
+ case "image":
119
+ return { type: "AceImageData", uses: "AceImageData" };
120
+ case "richtext":
121
+ return { type: "AceRichTextDocument", uses: "AceRichTextDocument" };
122
+ default:
123
+ return { type: "unknown" };
124
+ }
125
+ }
126
+ function generateTypes(contentTypes, packageName2, source) {
127
+ const used = /* @__PURE__ */ new Set();
128
+ const blocks = contentTypes.map((contentType) => {
129
+ const lines = contentType.fields.map((field) => {
130
+ const { type, uses } = typeOfField(field);
131
+ if (uses) used.add(uses);
132
+ const optional = field.config?.required === true ? "" : "?";
133
+ return ` /** ${field.label.replace(/\*\//g, "* /")} */
134
+ ${quote(field.handle)}${optional}: ${type};`;
135
+ });
136
+ const body = lines.length > 0 ? `{
137
+ ${lines.join("\n")}
138
+ }` : "Record<string, never>";
139
+ return ` /** ${contentType.name.replace(/\*\//g, "* /")} */
140
+ ${quote(contentType.handle)}: ${body};`;
141
+ });
142
+ const imports = used.size > 0 ? `import type { ${[...used].sort().join(", ")} } from ${quote(packageName2)};
143
+
144
+ ` : "";
145
+ return `// Generated by ace-kit from ${source}. Do not edit: run \`ace-kit generate\` to update it.
146
+ ${imports}declare module ${quote(packageName2)} {
147
+ interface AceContentTypes {
148
+ ${blocks.join("\n")}
149
+ }
150
+ }
151
+
152
+ export {};
153
+ `;
154
+ }
155
+
156
+ // src/helpers/package-name.ts
157
+ var packageName = "@sandboxsonny/ace-kit";
158
+
159
+ // src/generate/cli.ts
160
+ var usage = `ace-kit generate: write TypeScript types for your CMS Ace content types.
161
+
162
+ Usage:
163
+ ace-kit generate [--api-url <address>] [--domain <domain>] [--out <file>]
164
+
165
+ Options:
166
+ --api-url Where the CMS Ace API lives (or set ACE_API_URL, in the environment or a .env file).
167
+ --domain The domain your site has in the CMS (or set ACE_SITE_DOMAIN).
168
+ --out Where to write the types (default: src/ace-kit.generated.d.ts).
169
+ --help Show this message.
170
+ `;
171
+ async function main() {
172
+ try {
173
+ process.loadEnvFile(".env");
174
+ } catch {
175
+ }
176
+ const { positionals, values } = parseArgs({
177
+ allowPositionals: true,
178
+ options: {
179
+ "api-url": { type: "string" },
180
+ domain: { type: "string" },
181
+ out: { type: "string" },
182
+ help: { type: "boolean" }
183
+ }
184
+ });
185
+ if (values.help || positionals[0] !== "generate") {
186
+ console.log(usage);
187
+ return values.help ? 0 : 1;
188
+ }
189
+ const apiUrl = values["api-url"] ?? process.env.ACE_API_URL;
190
+ const siteDomain = values.domain ?? process.env.ACE_SITE_DOMAIN;
191
+ if (!apiUrl || !siteDomain) {
192
+ console.error(
193
+ "Give the API address and the site's domain: --api-url and --domain, or ACE_API_URL and ACE_SITE_DOMAIN."
194
+ );
195
+ return 1;
196
+ }
197
+ const client = createAceClient({ apiUrl, siteDomain, cacheSeconds: 0 });
198
+ const contentTypes = await client.getContentTypes();
199
+ const file = resolve(values.out ?? "src/ace-kit.generated.d.ts");
200
+ await mkdir(dirname(file), { recursive: true });
201
+ await writeFile(file, generateTypes(contentTypes, packageName, siteDomain));
202
+ console.log(`Wrote types for ${contentTypes.length} content type${contentTypes.length === 1 ? "" : "s"} to ${file}.`);
203
+ return 0;
204
+ }
205
+ main().then(
206
+ (code) => process.exit(code),
207
+ (error) => {
208
+ console.error(error instanceof Error ? error.message : error);
209
+ process.exit(1);
210
+ }
211
+ );