@sandboxsonny/ace-kit 0.0.0-stage → 0.1.1

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 +25 -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 +45 -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,155 @@
1
+ import * as react from 'react';
2
+ import { ImgHTMLAttributes } from 'react';
3
+
4
+ /** An image as the CMS Ace public API returns it. Use `src` and `srcset` in an `<img>`. */
5
+ type AceImageData = {
6
+ id: number;
7
+ /** The untouched original. */
8
+ url: string;
9
+ /** A web-sized copy, for browsers that ignore `srcset`. */
10
+ src: string;
11
+ /** Copies at several widths; empty when the image is too small to need them. */
12
+ srcset: string;
13
+ width: number | null;
14
+ height: number | null;
15
+ alt_text: string;
16
+ content_type: string;
17
+ };
18
+
19
+ type AceImageOptions = {
20
+ /** How wide the image is shown, e.g. "(min-width: 64rem) 64rem, 100vw". The browser uses it to pick a copy from srcset. */
21
+ sizes: string;
22
+ /** Leave on for images below the first screen; turn off for the one at the top of the page. */
23
+ lazy?: boolean;
24
+ };
25
+
26
+ type AceImageProps = AceImageOptions & {
27
+ image: AceImageData;
28
+ } & Omit<ImgHTMLAttributes<HTMLImageElement>, "src" | "srcSet" | "sizes" | "width" | "height" | "alt" | "loading">;
29
+ /** An image from the CMS as an `<img>` with a web-sized `src`, `srcset`, its size and its alt text. */
30
+ declare function AceImage({ image, sizes, lazy, ...rest }: AceImageProps): react.JSX.Element;
31
+
32
+ type AceTextAreaProps = {
33
+ /** The text area field's value. Missing or blank draws nothing. */
34
+ value: string | undefined;
35
+ /** True (the default): blank lines start new paragraphs and a single new line is a line break. False: one line of text. */
36
+ multiline?: boolean;
37
+ };
38
+ /**
39
+ * A text area field's text. Multi-line text becomes `<p>` paragraphs (a new line inside one is a `<br>`); with
40
+ * `multiline={false}` it is a single line of text with no element around it, to put inside your own. React escapes
41
+ * the text, so it is safe as it is.
42
+ */
43
+ declare function AceTextArea({ value, multiline }: AceTextAreaProps): react.JSX.Element;
44
+
45
+ /** A live entry from the public API. `Values` is the shape of its content type's fields. */
46
+ type AceEntry<Values = Record<string, unknown>> = {
47
+ id: number;
48
+ content_type_id: number;
49
+ /** The content type's handle, which is the first part of the entry's address. */
50
+ content_type_handle: string;
51
+ title: string;
52
+ handle: string;
53
+ description: string;
54
+ values: Values;
55
+ created_at: string;
56
+ updated_at: string;
57
+ /** Only on a preview: true when the entry has a draft and this is it. */
58
+ is_draft?: boolean;
59
+ };
60
+
61
+ /** What to do with a request for a page: show an entry, redirect, or explain. */
62
+ type AcePage<Values = Record<string, unknown>> =
63
+ | { kind: "entry"; status: 200; entry: AceEntry<Values>; headers: Record<string, string> }
64
+ | { kind: "redirect"; status: 301 | 302; location: string; headers: Record<string, string> }
65
+ | { kind: "not-found"; status: 404; headers: Record<string, string> }
66
+ | { kind: "unavailable"; status: 503; headers: Record<string, string> }
67
+ | { kind: "preview-expired"; status: 403; headers: Record<string, string> };
68
+
69
+ /**
70
+ * What to show for a page that isn't an entry (`page.kind` is `not-found`, `unavailable` or `preview-expired`): a heading
71
+ * and a message, and a link home unless the preview has expired. Unstyled; give it a `className`.
72
+ */
73
+ declare function AceNotice({ page, className }: {
74
+ page: Exclude<AcePage, {
75
+ kind: "entry";
76
+ }>;
77
+ className?: string;
78
+ }): react.JSX.Element;
79
+
80
+ /** A mark on a piece of text. A link's address is already worked out: an entry link comes as its path. */
81
+ type AceRichTextMark = { type: "bold" } | { type: "italic" } | { type: "link"; attrs: { href: string } };
82
+
83
+ /**
84
+ * One node of a rich text document: `paragraph`, `heading` (`attrs.level` 2 to 4), `bulletList`, `orderedList`
85
+ * (`attrs.start`), `listItem`, `blockquote`, `hardBreak`, `text`, or `image` (`attrs.image` is the picture).
86
+ */
87
+ type AceRichTextNode = {
88
+ type: string;
89
+ attrs?: { level?: number; start?: number; assetId?: number; image?: AceImageData };
90
+ content?: AceRichTextNode[];
91
+ text?: string;
92
+ marks?: AceRichTextMark[];
93
+ };
94
+
95
+ /** The value of a rich text field. */
96
+ type AceRichTextDocument = { type: "doc"; content?: AceRichTextNode[] };
97
+
98
+ /**
99
+ * A rich text field's document, drawn with React Router's `Link` for links to pages of this site. Everything else
100
+ * is as `AceRichText` from `@sandboxsonny/ace-kit/react`: safe by construction, bare elements to style yourself.
101
+ */
102
+ declare function AceRichText({ document }: {
103
+ document: AceRichTextDocument;
104
+ }): react.JSX.Element;
105
+
106
+ type MetaDescriptor = {
107
+ title: string;
108
+ } | {
109
+ name: string;
110
+ content: string;
111
+ };
112
+ /**
113
+ * The route's `meta`: the entry's title (followed by the site's name, if you give one), its description, and
114
+ * `noindex` for a preview so search engines ignore it.
115
+ *
116
+ * export const meta = createMeta({ siteName: "Example Site" });
117
+ */
118
+ declare function createMeta({ siteName }?: {
119
+ siteName?: string;
120
+ }): ({ data }: {
121
+ data: unknown;
122
+ }) => MetaDescriptor[];
123
+ /** `createMeta()` without a site name. */
124
+ declare const meta: ({ data }: {
125
+ data: unknown;
126
+ }) => MetaDescriptor[];
127
+
128
+ /** One link in a navigation bar, ready to draw. */
129
+ type AceNavigationLink = {
130
+ label: string;
131
+ to: string;
132
+ /** Links shown under this one. */
133
+ children?: AceNavigationLink[];
134
+ };
135
+
136
+ /** The header menu's links (the CMS menu, or the fallback), as `[{ label, to, children? }]`. Needs `createRootLoader`. */
137
+ declare function useAceMenu(): AceNavigationLink[];
138
+ /**
139
+ * Keeps live preview fresh. Call it once, in the root component. When this page is shown inside the CMS's editor and the
140
+ * editor saves a draft, the page's data is loaded again and redrawn without a reload. Elsewhere it does nothing.
141
+ * Needs `createRootLoader`, which knows whether the page is a preview.
142
+ */
143
+ declare function useAcePreview(): void;
144
+
145
+ /** What the root loader (`createRootLoader`) puts in the root route's data, for `useAceMenu` and `useAcePreview`. */
146
+ type AceRootData = {
147
+ ace: {
148
+ /** The header menu's links, or the fallback. */
149
+ menuLinks: AceNavigationLink[];
150
+ /** Where the CMS admin app runs, when this page is a preview shown inside its editor; otherwise null. */
151
+ previewAdminOrigin: string | null;
152
+ };
153
+ };
154
+
155
+ export { AceImage, AceNotice, AceRichText, type AceRootData, AceTextArea, createMeta, meta, useAceMenu, useAcePreview };
@@ -0,0 +1,55 @@
1
+ import { pageNotices } from './chunk-XPZY5R5A.js';
2
+ import { AceRichText, usePreviewRefresh } from './chunk-KEV3YAWN.js';
3
+ export { AceImage, AceTextArea } from './chunk-KEV3YAWN.js';
4
+ import { isInternalPath } from './chunk-URLKW2YS.js';
5
+ import './chunk-YN7LJWGI.js';
6
+ import './chunk-M5WR5TCR.js';
7
+ import './chunk-YLO6IAXF.js';
8
+ import { Link, useRevalidator, useRouteLoaderData } from 'react-router';
9
+ import { jsxs, jsx } from 'react/jsx-runtime';
10
+ import { useCallback } from 'react';
11
+
12
+ function AceNotice({ page, className }) {
13
+ const { heading, message } = pageNotices[page.kind];
14
+ return /* @__PURE__ */ jsxs("div", { className, children: [
15
+ /* @__PURE__ */ jsx("h1", { children: heading }),
16
+ /* @__PURE__ */ jsx("p", { children: message }),
17
+ page.kind === "preview-expired" ? null : /* @__PURE__ */ jsx(Link, { to: "/", children: "Back to the home page" })
18
+ ] });
19
+ }
20
+ var RouterLink = ({ href, children }) => isInternalPath(href) ? /* @__PURE__ */ jsx(Link, { to: href, children }) : /* @__PURE__ */ jsx("a", { href, children });
21
+ function AceRichText2({ document }) {
22
+ return /* @__PURE__ */ jsx(AceRichText, { document, link: RouterLink });
23
+ }
24
+
25
+ // src/react-router/meta.ts
26
+ function createMeta({ siteName } = {}) {
27
+ const suffix = siteName ? ` | ${siteName}` : "";
28
+ return function meta2({ data }) {
29
+ const page = data;
30
+ if (!page) return siteName ? [{ title: siteName }] : [];
31
+ const descriptors = [];
32
+ if (page.kind === "entry") {
33
+ descriptors.push({ title: `${page.entry.title}${suffix}` });
34
+ if (page.entry.description) descriptors.push({ name: "description", content: page.entry.description });
35
+ } else if (siteName) {
36
+ descriptors.push({ title: siteName });
37
+ }
38
+ if (page.headers["Cache-Control"]) descriptors.push({ name: "robots", content: "noindex" });
39
+ return descriptors;
40
+ };
41
+ }
42
+ var meta = createMeta();
43
+ function useAceRootData() {
44
+ return useRouteLoaderData("root")?.ace;
45
+ }
46
+ function useAceMenu() {
47
+ return useAceRootData()?.menuLinks ?? [];
48
+ }
49
+ function useAcePreview() {
50
+ const { revalidate } = useRevalidator();
51
+ const refresh = useCallback(() => revalidate(), [revalidate]);
52
+ usePreviewRefresh(useAceRootData()?.previewAdminOrigin, refresh);
53
+ }
54
+
55
+ export { AceNotice, AceRichText2 as AceRichText, createMeta, meta, useAceMenu, useAcePreview };
@@ -0,0 +1,88 @@
1
+ import * as react from 'react';
2
+ import { ImgHTMLAttributes, ComponentType, ReactNode } from 'react';
3
+
4
+ /** An image as the CMS Ace public API returns it. Use `src` and `srcset` in an `<img>`. */
5
+ type AceImageData = {
6
+ id: number;
7
+ /** The untouched original. */
8
+ url: string;
9
+ /** A web-sized copy, for browsers that ignore `srcset`. */
10
+ src: string;
11
+ /** Copies at several widths; empty when the image is too small to need them. */
12
+ srcset: string;
13
+ width: number | null;
14
+ height: number | null;
15
+ alt_text: string;
16
+ content_type: string;
17
+ };
18
+
19
+ type AceImageOptions = {
20
+ /** How wide the image is shown, e.g. "(min-width: 64rem) 64rem, 100vw". The browser uses it to pick a copy from srcset. */
21
+ sizes: string;
22
+ /** Leave on for images below the first screen; turn off for the one at the top of the page. */
23
+ lazy?: boolean;
24
+ };
25
+
26
+ type AceImageProps = AceImageOptions & {
27
+ image: AceImageData;
28
+ } & Omit<ImgHTMLAttributes<HTMLImageElement>, "src" | "srcSet" | "sizes" | "width" | "height" | "alt" | "loading">;
29
+ /** An image from the CMS as an `<img>` with a web-sized `src`, `srcset`, its size and its alt text. */
30
+ declare function AceImage({ image, sizes, lazy, ...rest }: AceImageProps): react.JSX.Element;
31
+
32
+ /** A mark on a piece of text. A link's address is already worked out: an entry link comes as its path. */
33
+ type AceRichTextMark = { type: "bold" } | { type: "italic" } | { type: "link"; attrs: { href: string } };
34
+
35
+ /**
36
+ * One node of a rich text document: `paragraph`, `heading` (`attrs.level` 2 to 4), `bulletList`, `orderedList`
37
+ * (`attrs.start`), `listItem`, `blockquote`, `hardBreak`, `text`, or `image` (`attrs.image` is the picture).
38
+ */
39
+ type AceRichTextNode = {
40
+ type: string;
41
+ attrs?: { level?: number; start?: number; assetId?: number; image?: AceImageData };
42
+ content?: AceRichTextNode[];
43
+ text?: string;
44
+ marks?: AceRichTextMark[];
45
+ };
46
+
47
+ /** The value of a rich text field. */
48
+ type AceRichTextDocument = { type: "doc"; content?: AceRichTextNode[] };
49
+
50
+ /** What a link in the text is drawn with. Give one that uses your router's link to keep navigation client-side. */
51
+ type AceRichTextLink = ComponentType<{
52
+ href: string;
53
+ children: ReactNode;
54
+ }>;
55
+ type AceRichTextProps = {
56
+ document: AceRichTextDocument;
57
+ /** Draws links; the default is a plain `<a>`. */
58
+ link?: AceRichTextLink;
59
+ };
60
+ /**
61
+ * A rich text field's document drawn as React elements. React escapes every piece of text, only the kinds of content
62
+ * the CMS allows are drawn, and links are checked again, so it is safe as it is. Wrap it in your own element to style it.
63
+ */
64
+ declare function AceRichText({ document, link }: AceRichTextProps): react.JSX.Element;
65
+
66
+ type AceTextAreaProps = {
67
+ /** The text area field's value. Missing or blank draws nothing. */
68
+ value: string | undefined;
69
+ /** True (the default): blank lines start new paragraphs and a single new line is a line break. False: one line of text. */
70
+ multiline?: boolean;
71
+ };
72
+ /**
73
+ * A text area field's text. Multi-line text becomes `<p>` paragraphs (a new line inside one is a `<br>`); with
74
+ * `multiline={false}` it is a single line of text with no element around it, to put inside your own. React escapes
75
+ * the text, so it is safe as it is.
76
+ */
77
+ declare function AceTextArea({ value, multiline }: AceTextAreaProps): react.JSX.Element;
78
+
79
+ /**
80
+ * Refreshes the page when the CMS's editor saves a draft, if the page is being shown inside its preview frame.
81
+ *
82
+ * - `adminOrigin`: where the CMS admin app runs. Pass null/undefined when the page isn't a preview and nothing happens.
83
+ * - `refresh`: how to get fresh content. Give your framework's own (React Router's `revalidator.revalidate`); the
84
+ * default fetches the page again and swaps its content in.
85
+ */
86
+ declare function usePreviewRefresh(adminOrigin: string | null | undefined, refresh?: () => Promise<void>): void;
87
+
88
+ export { AceImage, AceRichText, type AceRichTextLink, AceTextArea, usePreviewRefresh };
package/dist/react.js ADDED
@@ -0,0 +1,5 @@
1
+ export { AceImage, AceRichText, AceTextArea, usePreviewRefresh } from './chunk-KEV3YAWN.js';
2
+ import './chunk-URLKW2YS.js';
3
+ import './chunk-YN7LJWGI.js';
4
+ import './chunk-M5WR5TCR.js';
5
+ import './chunk-YLO6IAXF.js';
package/dist/vite.d.ts ADDED
@@ -0,0 +1,17 @@
1
+ import { Plugin } from 'vite';
2
+
3
+ /**
4
+ * A Vite plugin for sites whose server code reads Ace Kit's settings (`ACE_API_URL`, `ACE_SITE_DOMAIN`,
5
+ * `ACE_PREVIEW_ADMIN_ORIGIN`) from the environment, as the React Router adapter does. Vite only puts variables
6
+ * that start with `VITE_` into the code it runs, so without this a `.env` file would not reach the server in
7
+ * development. This reads the `ACE_` variables from `.env` files into `process.env`, never replacing one that is
8
+ * already set, and never exposing them to the browser. In production the host's environment is used as it is.
9
+ * It also makes sure the site uses a single copy of React and React Router, in the app and in its tests: add it to
10
+ * `vitest.config.ts` too if the site tests components that use the kit.
11
+ *
12
+ * // vite.config.ts
13
+ * export default defineConfig({ plugins: [ace(), reactRouter()] });
14
+ */
15
+ declare function ace(): Plugin;
16
+
17
+ export { ace as default };
package/dist/vite.js ADDED
@@ -0,0 +1,27 @@
1
+ import { resolve } from 'path';
2
+ import { loadEnv } from 'vite';
3
+
4
+ // src/vite/plugin.ts
5
+
6
+ // src/helpers/package-name.ts
7
+ var packageName = "@sandboxsonny/ace-kit";
8
+
9
+ // src/vite/plugin.ts
10
+ function ace() {
11
+ return {
12
+ name: "ace-kit",
13
+ config(config, { mode }) {
14
+ const root = config.root ? resolve(config.root) : process.cwd();
15
+ const fromFiles = loadEnv(mode, root, "ACE_");
16
+ for (const [name, value] of Object.entries(fromFiles)) {
17
+ if (process.env[name] === void 0) process.env[name] = value;
18
+ }
19
+ return {
20
+ resolve: { dedupe: ["react", "react-dom", "react-router"] },
21
+ test: { server: { deps: { inline: [packageName] } } }
22
+ };
23
+ }
24
+ };
25
+ }
26
+
27
+ export { ace as default };
@@ -0,0 +1,105 @@
1
+ # API reference
2
+
3
+ Everything below is exported from `@sandboxsonny/ace-kit` unless a path is given.
4
+
5
+ ## Client
6
+
7
+ | | |
8
+ | ------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------- |
9
+ | `createAceClient({ apiUrl, siteDomain, cacheSeconds?, fetchImplementation?, now? })` | A client. `fetchImplementation` and `now` are for tests. |
10
+ | `client.getEntry(contentType, handle, { previewToken? })` | One live entry, or `null`. With a token, the entry it was made for with its draft applied (never cached). |
11
+ | `client.getEntries(contentType, { limit? })` | A content type's live entries, newest first. |
12
+ | `client.getMenu(handle)` | A menu with every link worked out, or `null`. |
13
+ | `client.getContentTypes()` | What the content types look like (used by `ace-kit generate`). |
14
+ | `AceUnavailableError` | The CMS could not be reached, or answered with something unexpected, or the site isn't set up. |
15
+ | `AcePreviewExpiredError` | The preview token has run out. |
16
+
17
+ ## Pages and addresses
18
+
19
+ | | |
20
+ | ----------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
21
+ | `loadPage(client, { slug, url })` | What to do for a request: `AcePage`, one of `entry`, `redirect`, `not-found`, `unavailable`, `preview-expired`, each with `status` and `headers`. |
22
+ | `addressFromPath(slug)` | `{ contentTypeHandle, entryHandle }` for `/{content type}/{entry}`, the home entry for `/`, or `null`. |
23
+ | `homeAddress`, `isHomeAddress(address)`, `pathOfAddress(address)` | The `home` entry of `pages`, and helpers. |
24
+ | `visitablePath(path)` | `/pages/home` becomes `/` (it redirects there); anything else is unchanged. |
25
+
26
+ ## Images
27
+
28
+ | | |
29
+ | ------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------- |
30
+ | `imageAttributes(image, { sizes, lazy? })` | The attributes for an `<img>`: `src`, `srcSet`, `sizes`, `width`, `height`, `alt`, `loading`, `decoding`, `fetchPriority`. |
31
+
32
+ ## Menus
33
+
34
+ | | |
35
+ | ------------------------------------------ | -------------------------------------------------------------------------- |
36
+ | `linksFromMenu(menu)` | `[{ label, to, children? }]` with the home link at `/`. |
37
+ | `isCurrentPage(link, pathname)` | Whether a link is the page being shown. |
38
+ | `loadMenuLinks(client, handle, fallback?)` | A menu's links, or the fallback if the menu is missing or the CMS is down. |
39
+
40
+ ## Rich text
41
+
42
+ | | |
43
+ | -------------------------------------- | ------------------------------------------------------------------------------ |
44
+ | `renderRichText(document)` | The document as safe HTML. |
45
+ | `linkAddress(address)` | The address if a link may use it, otherwise `null`. |
46
+ | `renderTextArea(text, { multiline? })` | A textarea field's text as safe HTML: paragraphs and line breaks, or one line. |
47
+
48
+ ## Live preview
49
+
50
+ | | |
51
+ | ------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |
52
+ | `previewTokenFrom(url)`, `previewParameter` | The token in an address (`cms_preview`). |
53
+ | `previewHeaders` | `Cache-Control: no-store`, `X-Robots-Tag: noindex`. |
54
+ | `framingPolicy(adminOrigin)` | The `Content-Security-Policy` value that lets the admin app frame the site. |
55
+ | `addFramingPolicy(headers, adminOrigin)` | Adds that policy to a response's headers, keeping any policy the site already sends (only its `frame-ancestors` is replaced). Use this in your own middleware. |
56
+ | `originOf(address)` | The origin of a web address (`https://admin.example.com`), or `null`. |
57
+ | `isRefreshRequest(event, adminOrigin)`, `refreshMessageType` | Whether a message is the editor asking for a refresh, from the right origin. |
58
+ | `swapPageContent(current, fresh)` | Swaps a page's content for a fresh copy without reloading. |
59
+ | `listenForPreviewRefresh(adminOrigin, refresh?)`, `refreshPageContent()` | In the browser: refresh when the editor asks (only the admin origin counts); returns a function that stops listening. |
60
+
61
+ ## Types
62
+
63
+ `AceEntry<Values>`, `AceImageData`, `AceMenu`, `AceMenuItem`, `AceNavigationLink`, `AceRichTextDocument`, `AceRichTextNode`, `AceRichTextMark`, `AceContentTypeSchema`, `AceFieldSchema`, `AceAddress`, `AcePage`, `AceClient`, `AceClientOptions`, `AceImageAttributes`, `AceImageOptions`, and the ones generation fills in: `AceContentTypes`, `AceContentTypeHandle`, `AceValuesOf`.
64
+
65
+ ## Astro
66
+
67
+ `@sandboxsonny/ace-kit/astro/integration`: the integration (default export), `ace()`.
68
+
69
+ `@sandboxsonny/ace-kit/astro` (uses the integration's settings, so there is no client to create):
70
+
71
+ | | |
72
+ | ----------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
73
+ | `getPage(Astro)` | What to show for the page being requested; also sets the response's status and headers. A `redirect` is returned for you to act on with `Astro.redirect`. |
74
+ | `getEntry(contentType, handle)`, `getEntries(contentType, { limit? })`, `getMenu(handle)` | As on the client. |
75
+ | `getMenuLinks(handle, { fallback? })` | A menu's links, or the fallback if the menu is missing or the CMS is down. |
76
+ | `applyPage(response, page)` | Puts a page's status and headers on a response. |
77
+ | `ace` | The client these use. |
78
+
79
+ Components, by file: `AceImage.astro`, `AceRichText.astro`, `AceTextArea.astro`, `AceMenu.astro`, `AceNotice.astro`, `AcePreviewRefresh.astro`.
80
+
81
+ ## React Router 7
82
+
83
+ `@sandboxsonny/ace-kit/vite`: the plugin (default export), `ace()`.
84
+
85
+ `@sandboxsonny/ace-kit/react-router` (browser-safe):
86
+
87
+ | | |
88
+ | ----------------------------------------------------- | -------------------------------------------------------------------- |
89
+ | `AceRichText`, `AceTextArea`, `AceImage`, `AceNotice` | Components; rich text links are router links. |
90
+ | `createMeta({ siteName? })`, `meta` | The route's `meta`. |
91
+ | `useAceMenu()`, `useAcePreview()` | Read the header menu; keep a preview fresh. Need `createRootLoader`. |
92
+
93
+ `@sandboxsonny/ace-kit/react-router/server` (server only):
94
+
95
+ | | |
96
+ | ------------------------------------------------------------------- | ---------------------------------------------------------------- |
97
+ | `createPageLoader<Values>()`, `loader` | The loader for the catch-all route that draws every page. |
98
+ | `headers` | The route's `headers` export. |
99
+ | `createRootLoader({ menu, fallback? })` | The root loader: the menu's links and whether this is a preview. |
100
+ | `handleRequest`, `streamTimeout` | The server entry. |
101
+ | `getEntry`, `getEntries`, `getMenu`, `getMenuLinks`, `getAceClient` | Read content from your own loaders. |
102
+
103
+ ## React (`@sandboxsonny/ace-kit/react`)
104
+
105
+ `AceImage`, `AceRichText` (with `link`), `usePreviewRefresh(adminOrigin, refresh?)`.
package/docs/astro.md ADDED
@@ -0,0 +1,93 @@
1
+ # Astro
2
+
3
+ Needs `astro` 5 or newer, rendering on the server (`output: "server"`) so an edit published in the CMS shows on the next page load. There is nothing to wire up by hand: add the integration, and use the functions and components.
4
+
5
+ ## Set up
6
+
7
+ ```js
8
+ // astro.config.mjs
9
+ import { defineConfig } from "astro/config";
10
+ import ace from "@sandboxsonny/ace-kit/astro/integration";
11
+
12
+ export default defineConfig({
13
+ output: "server",
14
+ integrations: [ace()],
15
+ });
16
+ ```
17
+
18
+ The integration declares three settings, adds the middleware that lets the CMS's admin app (and only it) frame the site for [live preview](./live-preview.md), and makes Vite process the kit. The settings are read when the server starts, not baked into the build, so one build runs anywhere:
19
+
20
+ | Setting | Default | Meaning |
21
+ | -------------------------- | ----------------------- | --------------------------------------------------------- |
22
+ | `ACE_API_URL` | `http://localhost:8080` | where the CMS Ace API lives |
23
+ | `ACE_SITE_DOMAIN` | `localhost` | the domain this site has in the CMS (the port is ignored) |
24
+ | `ACE_PREVIEW_ADMIN_ORIGIN` | `http://localhost:5173` | where the CMS admin app runs |
25
+
26
+ Put them in `.env` for development and in the environment for production.
27
+
28
+ ## Pages
29
+
30
+ One catch-all route draws every page. `getPage` works out what to show from the address and sets the response's status and headers (a missing page is a 404, a preview is never cached or indexed):
31
+
32
+ ```astro
33
+ ---
34
+ // src/pages/[...slug].astro
35
+ import { getPage } from "@sandboxsonny/ace-kit/astro";
36
+ import AceNotice from "@sandboxsonny/ace-kit/astro/AceNotice.astro";
37
+
38
+ const page = await getPage(Astro);
39
+ if (page.kind === "redirect") return Astro.redirect(page.location, page.status);
40
+ ---
41
+
42
+ {page.kind === "entry" ? <h1>{page.entry.title}</h1> : <AceNotice page={page} />}
43
+ ```
44
+
45
+ Addresses are `/{content type}/{entry}` as on Shopify, `/` is the `home` entry of `pages`, and `/pages/home` redirects to `/`. `page.kind` is `entry`, `redirect`, `not-found`, `unavailable` or `preview-expired`. `AceNotice` draws a heading and a message for the last three; draw your own if you prefer.
46
+
47
+ ## Reading content
48
+
49
+ Anywhere in a page or component:
50
+
51
+ ```astro
52
+ ---
53
+ import { getEntry, getEntries, getMenuLinks } from "@sandboxsonny/ace-kit/astro";
54
+
55
+ const about = await getEntry("pages", "about"); // one entry, or null
56
+ const posts = await getEntries("posts", { limit: 10 }); // a content type's entries, newest first
57
+ const links = await getMenuLinks("main-menu", { fallback: [{ label: "Home", to: "/" }] });
58
+ ---
59
+ ```
60
+
61
+ `getMenuLinks` returns `[{ label, to, children? }]`, ready to draw. If the site has no such menu yet, or the CMS can't be reached, you get the `fallback`, so a missing menu never takes a page down. With [typed content](./typed-content.md), `getEntry("pages", "about")` knows the shape of a page's values.
62
+
63
+ There is a client behind these (`ace` from the same import, made with the three settings) if you want `getMenu`, `getContentTypes` or the raw client.
64
+
65
+ ## Components
66
+
67
+ Import them by file:
68
+
69
+ ```astro
70
+ ---
71
+ import AceImage from "@sandboxsonny/ace-kit/astro/AceImage.astro";
72
+ import AceRichText from "@sandboxsonny/ace-kit/astro/AceRichText.astro";
73
+ import AceTextArea from "@sandboxsonny/ace-kit/astro/AceTextArea.astro";
74
+ import AceMenu from "@sandboxsonny/ace-kit/astro/AceMenu.astro";
75
+ import AcePreviewRefresh from "@sandboxsonny/ace-kit/astro/AcePreviewRefresh.astro";
76
+ ---
77
+
78
+ <AceMenu handle="main-menu" />
79
+ <AceImage image={entry.values.hero} sizes="(min-width: 64rem) 64rem, 100vw" lazy={false} />
80
+ <AceRichText document={entry.values.body} />
81
+ <AcePreviewRefresh />
82
+ ```
83
+
84
+ - **AceMenu** draws a whole menu as a `<nav>` with nested lists, marking the current page with `aria-current="page"`. Props: `handle`, `fallback`, `label` (what a screen reader calls it) and `class`. It is deliberately unstyled (`.ace-menu`, `.ace-menu__list`, `.ace-menu__sublist` are the hooks); to control the markup, use `getMenuLinks` and draw it yourself.
85
+ - **AceImage** writes an `<img>` with `src`, `srcset`, `sizes`, `width`, `height`, `alt`, `loading` and `decoding`. Set `sizes` to how wide the image is shown, and `lazy={false}` for the image at the top of the page.
86
+ - **AceRichText** draws a rich text field as HTML. It outputs bare elements (`h2`, `p`, `ul`, `a`...); style them from a wrapping element. Astro scopes styles, so reach them with `:global`, e.g. `.rich-text :global(h2) { ... }`.
87
+ - **AceTextArea** draws a Textarea field (plain text): `multiline` (the default) makes paragraphs of blocks and a `<br>` of a new line; `multiline={false}` is one line of text with no element around it.
88
+ - **AcePreviewRefresh** goes once in your layout. It does nothing unless the page was opened by the CMS's editor.
89
+ - **AceNotice** is the message for a page that isn't an entry.
90
+
91
+ ## Without the integration
92
+
93
+ The integration is what makes the settings and the middleware automatic. If you can't use it, use the framework-free [client](./getting-started.md#the-client) and `loadPage` from the core, and call `addFramingPolicy(response.headers, origin)` in your own middleware.
@@ -0,0 +1,62 @@
1
+ # Getting started
2
+
3
+ Ace Kit is everything a website needs to use CMS Ace (see the [README](../README.md)): a typed client for the public API, addressing (`/{content type}/{entry}`), responsive images, menus, rich text, live preview support, and thin Astro and React adapters. It has no runtime dependencies.
4
+
5
+ ## Install
6
+
7
+ ```sh
8
+ npm install @sandboxsonny/ace-kit
9
+ ```
10
+
11
+ (pnpm, Yarn and Bun work the same way.) Needs Node 22.12 or newer. `astro` (5 or newer), `react` (18 or newer), `react-router` (7) and `vite` are optional peer dependencies: install whichever adapter you use.
12
+
13
+ ## What you import from where
14
+
15
+ | Import | What is in it |
16
+ | -------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
17
+ | `@sandboxsonny/ace-kit` | The framework-free core: the client, errors, `loadPage`, addressing, image attributes, menu helpers, rich text to HTML, preview helpers, all the types |
18
+ | `@sandboxsonny/ace-kit/astro/integration` | The Astro integration: add it to `astro.config.mjs` and an Astro site is wired up |
19
+ | `@sandboxsonny/ace-kit/astro` | What an Astro page calls: `getPage`, `getEntry`, `getEntries`, `getMenuLinks`, `getMenu`, `ace` (the client) |
20
+ | `@sandboxsonny/ace-kit/astro/AceImage.astro` and friends | Astro components: `AceImage`, `AceRichText`, `AceMenu`, `AceNotice`, `AcePreviewRefresh` |
21
+ | `@sandboxsonny/ace-kit/react` | `AceImage`, `AceRichText`, `usePreviewRefresh` |
22
+ | `@sandboxsonny/ace-kit/react-router` | React Router 7, browser-safe: `AceRichText` (router links), `AceImage`, `AceNotice`, `createMeta`, `useAceMenu`, `useAcePreview` |
23
+ | `@sandboxsonny/ace-kit/react-router/server` | React Router 7, server only: `createPageLoader`, `createRootLoader`, `handleRequest` (the server entry), `getEntry`, `getEntries`, `getMenuLinks`, `getAceClient` |
24
+ | `@sandboxsonny/ace-kit/vite` | A Vite plugin: reads `ACE_` settings from `.env` in development and keeps one copy of React |
25
+ | `ace-kit` (command) | `ace-kit generate`, which writes TypeScript types for your content |
26
+
27
+ ## The client
28
+
29
+ **On Astro and React Router 7 you can skip this section**: the [Astro integration](./astro.md) and the [React Router adapter](./react-router.md) make the client for you, and you call `getEntry`, `getMenuLinks` and the page loaders directly.
30
+
31
+ For anything else, create one client for the whole site, on the server. The browser never talks to the CMS API.
32
+
33
+ ```ts
34
+ import { createAceClient } from "@sandboxsonny/ace-kit";
35
+
36
+ export const cms = createAceClient({
37
+ apiUrl: "https://api.example.com", // where CMS Ace's API lives
38
+ siteDomain: "www.example.com", // the domain this site has in the CMS
39
+ });
40
+
41
+ const about = await cms.getEntry("pages", "about"); // one entry, or null
42
+ const posts = await cms.getEntries("posts", { limit: 10 }); // a content type's live entries, newest first
43
+ const menu = await cms.getMenu("main-menu"); // a navigation menu, or null
44
+ ```
45
+
46
+ - Answers are reused for 5 seconds (`cacheSeconds`, `0` turns it off), because every visitor looks like one IP address to the API's rate limit.
47
+ - A site that is not set up in the CMS, an unreachable CMS and an unexpected answer all throw `AceUnavailableError`, so a page can show "try again shortly" rather than "not found".
48
+ - Entry handles are unique across a site; `getEntry("pages", "about")` returns `null` if `about` belongs to another content type.
49
+
50
+ ## Drawing pages
51
+
52
+ `loadPage` works out what to do for a request, the same way for every framework:
53
+
54
+ ```ts
55
+ import { loadPage } from "@sandboxsonny/ace-kit";
56
+
57
+ const page = await loadPage(cms, { slug: "pages/about", url: new URL(request.url) });
58
+ // page.kind: "entry" | "redirect" | "not-found" | "unavailable" | "preview-expired"
59
+ // page.status, page.headers: what to send back
60
+ ```
61
+
62
+ It reads `/{content type}/{entry}` addresses (as on Shopify), treats `/` as the `home` entry of `pages`, redirects `/pages/home` to `/`, and handles live preview tokens. See [Astro](./astro.md), [React Router](./react-router.md) and [React](./react.md) for routes that use it. `loadMenuLinks(client, handle, fallback)` does for menus what `getMenuLinks` does in Astro: the links, or the fallback if the menu is missing or the CMS is down.
@@ -0,0 +1,15 @@
1
+ # Live preview
2
+
3
+ CMS Ace's editor shows your real site next to the form. It opens your site inside a frame at the entry's address with a preview token (`?cms_preview=<token>`), and every time the editor saves a draft it asks the page to refresh. For that to work a site must:
4
+
5
+ 1. **Read the token and pass it on.** `loadPage` does: it hands the token to `getEntry`, which asks the API for the entry with its draft applied. A token that has run out gives `page.kind === "preview-expired"`.
6
+ 2. **Never cache or index a preview.** `page.headers` has `Cache-Control: no-store` and `X-Robots-Tag: noindex` for such pages; send them (`getPage` does in Astro). Add `<meta name="robots" content="noindex">` too.
7
+ 3. **Allow the admin app to frame the site, and nobody else.** Send `Content-Security-Policy: frame-ancestors 'self' <admin origin>` on every response. The Astro integration and the React Router adapter do this for you; elsewhere call `addFramingPolicy(headers, origin)` on each response, which adds the directive to any policy the site already sends instead of replacing it.
8
+ 4. **Refresh when asked.** A small script listens for the editor's message, accepts it only from the admin origin, fetches the page again and swaps the content in without a reload (the scroll position stays).
9
+ - Astro: render `<AcePreviewRefresh />` once in the layout. It knows whether the page is a preview and where the admin app is (`ACE_PREVIEW_ADMIN_ORIGIN`).
10
+ - React: call `usePreviewRefresh(adminOrigin, refresh)` once.
11
+ - Anything else: `listenForPreviewRefresh(adminOrigin)` from the core, in the browser.
12
+
13
+ Then, in the CMS, set the site's **Preview address** (Settings) to where the site runs. The admin app and the site must both be `http` or both `https`: browsers refuse an `http` frame inside an `https` page.
14
+
15
+ The admin origin is a setting of the site (`ACE_PREVIEW_ADMIN_ORIGIN` in Astro), because it differs between development and production.