@sandboxsonny/ace-kit 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -13,7 +13,7 @@ type AceClientOptions = {
13
13
  type AceFieldSchema = {
14
14
  handle: string;
15
15
  label: string;
16
- /** `text`, `textarea`, `select`, `number`, `checkbox`, `image`, `richtext`, `date` or `link`. */
16
+ /** `text`, `textarea`, `select`, `number`, `checkbox`, `image`, `richtext`, `date`, `link` or `sections`. */
17
17
  component_key: string;
18
18
  config: { required?: boolean; options?: string[] } | null;
19
19
  };
@@ -178,6 +178,164 @@ type AceImageAttributes = {
178
178
  /** What to put on an `<img>`: a web-sized `src`, `srcset` when there are copies, and the size so the page doesn't jump. */
179
179
  declare function imageAttributes(image: AceImageData, { sizes, lazy }: AceImageOptions): AceImageAttributes;
180
180
 
181
+ /**
182
+ * What a link field holds in an entry's values, as visitors get it: where it goes, already worked out (a link to a
183
+ * page of the site is its address, such as "/pages/about"), and the words it is shown with, if the editor gave any.
184
+ */
185
+ type AceLinkData = {
186
+ href: string;
187
+ label?: string;
188
+ };
189
+
190
+ /** A mark on a piece of text. A link's address is already worked out: an entry link comes as its path. */
191
+ type AceRichTextMark = { type: "bold" } | { type: "italic" } | { type: "link"; attrs: { href: string } };
192
+
193
+ /**
194
+ * One node of a rich text document: `paragraph`, `heading` (`attrs.level` 2 to 4), `bulletList`, `orderedList`
195
+ * (`attrs.start`), `listItem`, `blockquote`, `hardBreak`, `text`, or `image` (`attrs.image` is the picture).
196
+ */
197
+ type AceRichTextNode = {
198
+ type: string;
199
+ attrs?: { level?: number; start?: number; assetId?: number; image?: AceImageData };
200
+ content?: AceRichTextNode[];
201
+ text?: string;
202
+ marks?: AceRichTextMark[];
203
+ };
204
+
205
+ /** The value of a rich text field. */
206
+ type AceRichTextDocument = { type: "doc"; content?: AceRichTextNode[] };
207
+
208
+ /** The kinds of field a section or block can hold. */
209
+ type SectionFieldType = "text" | "textarea" | "number" | "checkbox" | "select" | "image" | "richtext" | "date" | "link";
210
+ type FieldBase = {
211
+ /** What the field is called in the section's values: lowercase letters, numbers and hyphens. */
212
+ handle: string;
213
+ /** What the editor sees it called. */
214
+ label: string;
215
+ /** If true, the editor has to fill it in. */
216
+ required?: boolean;
217
+ };
218
+ /** One field of a section or block. A `select` field also lists its `options`. */
219
+ type SectionFieldDefinition = (FieldBase & {
220
+ type: Exclude<SectionFieldType, "select">;
221
+ }) | (FieldBase & {
222
+ type: "select";
223
+ options: readonly string[];
224
+ });
225
+ /** A kind of block a section can hold: one item of a list inside the section, such as one question of an FAQ. */
226
+ type BlockDefinition = {
227
+ name: string;
228
+ fields?: readonly SectionFieldDefinition[];
229
+ };
230
+ /** What `defineSection` takes: a kind of section a site's code defines. */
231
+ type SectionDefinition = {
232
+ /** What the section is called in the CMS and in code (`type`): lowercase letters, numbers and hyphens. */
233
+ handle: string;
234
+ /** What the editor sees it called. */
235
+ name: string;
236
+ fields?: readonly SectionFieldDefinition[];
237
+ /** The kinds of block it can hold, by handle. */
238
+ blocks?: Readonly<Record<string, BlockDefinition>>;
239
+ };
240
+ /**
241
+ * Describes a kind of section: its fields, and the blocks it can hold. Put one in a `*.schema.ts` file next to the
242
+ * component that draws it, as its default export. Ace Kit syncs them to the CMS while the dev server runs (and with
243
+ * `ace-kit sync`), and the component's props can be typed from it with `SectionOf`.
244
+ *
245
+ * // Faq.schema.ts
246
+ * export default defineSection({
247
+ * handle: "faq",
248
+ * name: "FAQ",
249
+ * fields: [{ handle: "heading", label: "Heading", type: "text" }],
250
+ * blocks: { question: { name: "Question", fields: [{ handle: "question", label: "Question", type: "text", required: true }] } },
251
+ * });
252
+ *
253
+ * It returns the definition unchanged: its job is to check it as you write it, and to keep the exact handles and
254
+ * types that `SectionOf` reads.
255
+ */
256
+ declare function defineSection<const Definition extends SectionDefinition>(definition: Definition): Definition;
257
+ type ValueOfField<Field> = Field extends {
258
+ type: "text" | "textarea" | "date";
259
+ } ? string : Field extends {
260
+ type: "number";
261
+ } ? number : Field extends {
262
+ type: "checkbox";
263
+ } ? boolean : Field extends {
264
+ type: "select";
265
+ options: readonly (infer Option)[];
266
+ } ? Option : Field extends {
267
+ type: "image";
268
+ } ? AceImageData : Field extends {
269
+ type: "richtext";
270
+ } ? AceRichTextDocument : Field extends {
271
+ type: "link";
272
+ } ? AceLinkData : unknown;
273
+ type FieldsOf<Fields> = Fields extends readonly (infer Field extends SectionFieldDefinition)[] ? Field : never;
274
+ /** The values of a list of fields: a required field is always there, any other may be missing. */
275
+ type ValuesOfFields<Fields> = {
276
+ [Field in FieldsOf<Fields> as Field extends {
277
+ required: true;
278
+ } ? Field["handle"] : never]: ValueOfField<Field>;
279
+ } & {
280
+ [Field in FieldsOf<Fields> as Field extends {
281
+ required: true;
282
+ } ? never : Field["handle"]]?: ValueOfField<Field>;
283
+ };
284
+ type BlocksOf<Definition> = Definition extends {
285
+ blocks: infer Blocks extends Readonly<Record<string, BlockDefinition>>;
286
+ } ? {
287
+ [Handle in keyof Blocks & string]: {
288
+ id: string;
289
+ type: Handle;
290
+ values: ValuesOfFields<Blocks[Handle]["fields"]>;
291
+ };
292
+ }[keyof Blocks & string][] : never[];
293
+ /**
294
+ * A section of this kind, as a website gets it from the CMS, worked out from its definition: type the props of the
295
+ * component that draws it with this.
296
+ *
297
+ * import type { SectionOf } from "@sandboxsonny/ace-kit";
298
+ * import type faq from "./Faq.schema";
299
+ * type Props = { section: SectionOf<typeof faq> };
300
+ */
301
+ type SectionOf<Definition extends SectionDefinition> = {
302
+ id: string;
303
+ type: Definition["handle"];
304
+ values: ValuesOfFields<Definition["fields"]>;
305
+ blocks: BlocksOf<Definition>;
306
+ };
307
+
308
+ /**
309
+ * Checks a section definition and returns what is wrong with it, written for the person who wrote it (an empty list
310
+ * means it is fine). The CMS checks everything again; this is so the mistake is reported against the file, at once,
311
+ * before anything is sent.
312
+ */
313
+ declare function checkSectionDefinition(definition: unknown, where?: string): string[];
314
+ type ApiField = {
315
+ handle: string;
316
+ label: string;
317
+ component_key: string;
318
+ config: {
319
+ required?: boolean;
320
+ options?: string[];
321
+ };
322
+ };
323
+ /** The body of `PUT /sites/{id}/section-types`: every section type the site's code defines. */
324
+ type SectionTypesPayload = {
325
+ sections: {
326
+ handle: string;
327
+ name: string;
328
+ fields: ApiField[];
329
+ blocks: {
330
+ handle: string;
331
+ name: string;
332
+ fields: ApiField[];
333
+ }[];
334
+ }[];
335
+ };
336
+ /** Turns section definitions (already checked) into what the CMS takes. */
337
+ declare function sectionTypesPayload(definitions: readonly SectionDefinition[]): SectionTypesPayload;
338
+
181
339
  type AceDateFormat = Intl.DateTimeFormatOptions & {
182
340
  /** A language such as "en-GB". The default is the one the server or browser runs in. */
183
341
  locale?: string;
@@ -188,15 +346,6 @@ type AceDateFormat = Intl.DateTimeFormatOptions & {
188
346
  */
189
347
  declare function formatAceDate(date: string, format?: AceDateFormat): string | null;
190
348
 
191
- /**
192
- * What a link field holds in an entry's values, as visitors get it: where it goes, already worked out (a link to a
193
- * page of the site is its address, such as "/pages/about"), and the words it is shown with, if the editor gave any.
194
- */
195
- type AceLinkData = {
196
- href: string;
197
- label?: string;
198
- };
199
-
200
349
  /**
201
350
  * Where a link in rich text goes, or null if it can't be trusted. The CMS only allows paths, web addresses,
202
351
  * mailto: and tel:; this checks again so the page stays safe even if something else ever gets into the text.
@@ -307,24 +456,6 @@ declare function renderTextArea(text: string | undefined, { multiline }?: {
307
456
  multiline?: boolean;
308
457
  }): string;
309
458
 
310
- /** A mark on a piece of text. A link's address is already worked out: an entry link comes as its path. */
311
- type AceRichTextMark = { type: "bold" } | { type: "italic" } | { type: "link"; attrs: { href: string } };
312
-
313
- /**
314
- * One node of a rich text document: `paragraph`, `heading` (`attrs.level` 2 to 4), `bulletList`, `orderedList`
315
- * (`attrs.start`), `listItem`, `blockquote`, `hardBreak`, `text`, or `image` (`attrs.image` is the picture).
316
- */
317
- type AceRichTextNode = {
318
- type: string;
319
- attrs?: { level?: number; start?: number; assetId?: number; image?: AceImageData };
320
- content?: AceRichTextNode[];
321
- text?: string;
322
- marks?: AceRichTextMark[];
323
- };
324
-
325
- /** The value of a rich text field. */
326
- type AceRichTextDocument = { type: "doc"; content?: AceRichTextNode[] };
327
-
328
459
  /** How wide an image in the text is shown, so the browser picks a suitable copy from srcset. */
329
460
  declare const richTextImageSizes = "(min-width: 42rem) 42rem, 100vw";
330
461
  /** The heading level to draw: 2 to 4, whatever the document says. */
@@ -337,4 +468,24 @@ declare const listStart: (node: AceRichTextNode) => number | undefined;
337
468
  */
338
469
  declare function renderRichText(document: AceRichTextDocument): string;
339
470
 
340
- export { type AceAddress, type AceClient, type AceClientOptions, type AceContentTypeHandle, type AceContentTypeSchema, type AceContentTypes, type AceDateFormat, type AceEntry, type AceFieldSchema, type AceImageAttributes, type AceImageData, type AceImageOptions, type AceLinkAttributes, type AceLinkData, type AceMenu, type AceMenuItem, type AceNavigationLink, type AcePage, AcePreviewExpiredError, type AceRichTextDocument, type AceRichTextMark, type AceRichTextNode, AceUnavailableError, type AceValuesOf, addFramingPolicy, addressFromPath, createAceClient, formatAceDate, framingPolicy, headingLevel, homeAddress, imageAttributes, isCurrentPage, isHomeAddress, isInternalPath, isRefreshRequest, linkAddress, linkAttributes, linksFromMenu, listStart, listenForPreviewRefresh, loadMenuLinks, loadPage, originOf, pageNotices, pathOfAddress, previewHeaders, previewParameter, previewTokenFrom, refreshMessageType, refreshPageContent, renderRichText, renderTextArea, richTextImageSizes, swapPageContent, visitablePath };
471
+ /** One block of a section, as a website gets it from the CMS, without knowing which kind it is. */
472
+ type AceSectionBlock = {
473
+ id: string;
474
+ type: string;
475
+ values: Record<string, unknown>;
476
+ };
477
+
478
+ /**
479
+ * One section of an entry's sections field, as a website gets it from the CMS, without knowing which kind it is.
480
+ * A component that draws one kind of section types its prop with `SectionOf<typeof schema>` instead.
481
+ */
482
+ type AceSection = {
483
+ /** Steady for as long as the section exists, so a list can be keyed by it. */
484
+ id: string;
485
+ /** Which kind of section: the `handle` of its definition. */
486
+ type: string;
487
+ values: Record<string, unknown>;
488
+ blocks: AceSectionBlock[];
489
+ };
490
+
491
+ export { type AceAddress, type AceClient, type AceClientOptions, type AceContentTypeHandle, type AceContentTypeSchema, type AceContentTypes, type AceDateFormat, type AceEntry, type AceFieldSchema, type AceImageAttributes, type AceImageData, type AceImageOptions, type AceLinkAttributes, type AceLinkData, type AceMenu, type AceMenuItem, type AceNavigationLink, type AcePage, AcePreviewExpiredError, type AceRichTextDocument, type AceRichTextMark, type AceRichTextNode, type AceSection, type AceSectionBlock, AceUnavailableError, type AceValuesOf, type BlockDefinition, type SectionDefinition, type SectionFieldDefinition, type SectionFieldType, type SectionOf, type SectionTypesPayload, type ValuesOfFields, addFramingPolicy, addressFromPath, checkSectionDefinition, createAceClient, defineSection, formatAceDate, framingPolicy, headingLevel, homeAddress, imageAttributes, isCurrentPage, isHomeAddress, isInternalPath, isRefreshRequest, linkAddress, linkAttributes, linksFromMenu, listStart, listenForPreviewRefresh, loadMenuLinks, loadPage, originOf, pageNotices, pathOfAddress, previewHeaders, previewParameter, previewTokenFrom, refreshMessageType, refreshPageContent, renderRichText, renderTextArea, richTextImageSizes, sectionTypesPayload, swapPageContent, visitablePath };
package/dist/index.js CHANGED
@@ -5,6 +5,118 @@ export { AcePreviewExpiredError, AceUnavailableError, createAceClient, isCurrent
5
5
  export { addressFromPath, homeAddress, isHomeAddress, pathOfAddress, visitablePath } from './chunk-M5WR5TCR.js';
6
6
  export { addFramingPolicy, framingPolicy, isRefreshRequest, originOf, previewHeaders, previewParameter, previewTokenFrom, refreshMessageType, swapPageContent } from './chunk-YLO6IAXF.js';
7
7
 
8
+ // src/sections/define-section.ts
9
+ function defineSection(definition) {
10
+ return definition;
11
+ }
12
+
13
+ // src/sections/payload.ts
14
+ var handlePattern = /^[a-z0-9]+(-[a-z0-9]+)*$/;
15
+ var maximumHandleLength = 64;
16
+ var maximumNameLength = 200;
17
+ var fieldTypes = [
18
+ "text",
19
+ "textarea",
20
+ "number",
21
+ "checkbox",
22
+ "select",
23
+ "image",
24
+ "richtext",
25
+ "date",
26
+ "link"
27
+ ];
28
+ var isObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
29
+ var isHandle = (value) => typeof value === "string" && value.length <= maximumHandleLength && handlePattern.test(value);
30
+ var handleRule = 'lowercase letters, numbers and hyphens (such as "call-to-action")';
31
+ function checkFields(fields, where, problems) {
32
+ if (fields === void 0) return;
33
+ if (!Array.isArray(fields)) {
34
+ problems.push(`${where}: fields must be a list.`);
35
+ return;
36
+ }
37
+ const seen = /* @__PURE__ */ new Set();
38
+ fields.forEach((field, index) => {
39
+ if (!isObject(field)) {
40
+ problems.push(`${where}: field ${index + 1} must be an object with a handle, a label and a type.`);
41
+ return;
42
+ }
43
+ const name = isHandle(field.handle) ? `field "${field.handle}"` : `field ${index + 1}`;
44
+ if (!isHandle(field.handle)) problems.push(`${where}: ${name} needs a handle of ${handleRule}.`);
45
+ else if (seen.has(field.handle)) problems.push(`${where}: ${name} is defined twice.`);
46
+ else seen.add(field.handle);
47
+ if (typeof field.label !== "string" || field.label.trim() === "" || field.label.length > maximumNameLength) {
48
+ problems.push(`${where}: ${name} needs a label.`);
49
+ }
50
+ if (typeof field.type !== "string" || !fieldTypes.includes(field.type)) {
51
+ const given = typeof field.type === "string" ? ` "${field.type}"` : "";
52
+ problems.push(`${where}: ${name} has a type${given} that isn't one of ${fieldTypes.join(", ")}.`);
53
+ } else if (field.type === "select") {
54
+ const { options } = field;
55
+ if (!Array.isArray(options) || options.length === 0 || options.some((option) => typeof option !== "string" || option === "")) {
56
+ problems.push(`${where}: ${name} is a select, so it needs options: a list of the choices, as text.`);
57
+ }
58
+ }
59
+ if (field.required !== void 0 && typeof field.required !== "boolean") {
60
+ problems.push(`${where}: ${name} has a required that isn't true or false.`);
61
+ }
62
+ });
63
+ }
64
+ function checkSectionDefinition(definition, where = "this section") {
65
+ const problems = [];
66
+ if (!isObject(definition)) {
67
+ return [`${where}: the default export must be the result of defineSection({ ... }).`];
68
+ }
69
+ if (!isHandle(definition.handle)) {
70
+ problems.push(`${where}: needs a handle of ${handleRule}.`);
71
+ }
72
+ if (typeof definition.name !== "string" || definition.name.trim() === "" || definition.name.length > maximumNameLength) {
73
+ problems.push(`${where}: needs a name.`);
74
+ }
75
+ checkFields(definition.fields, where, problems);
76
+ if (definition.blocks !== void 0) {
77
+ if (!isObject(definition.blocks)) {
78
+ problems.push(`${where}: blocks must be an object of the kinds of block, by handle.`);
79
+ } else {
80
+ for (const [handle, block] of Object.entries(definition.blocks)) {
81
+ const blockWhere = `${where}, block "${handle}"`;
82
+ if (!isHandle(handle)) problems.push(`${blockWhere}: the handle must be ${handleRule}.`);
83
+ if (!isObject(block)) {
84
+ problems.push(`${blockWhere}: must be an object with a name and fields.`);
85
+ continue;
86
+ }
87
+ if (typeof block.name !== "string" || block.name.trim() === "") problems.push(`${blockWhere}: needs a name.`);
88
+ checkFields(block.fields, blockWhere, problems);
89
+ }
90
+ }
91
+ }
92
+ return problems;
93
+ }
94
+ function apiFields(fields) {
95
+ return (fields ?? []).map((field) => ({
96
+ handle: field.handle,
97
+ label: field.label,
98
+ component_key: field.type,
99
+ config: {
100
+ ...field.required ? { required: true } : {},
101
+ ...field.type === "select" ? { options: [...field.options] } : {}
102
+ }
103
+ }));
104
+ }
105
+ function sectionTypesPayload(definitions) {
106
+ return {
107
+ sections: definitions.map((definition) => ({
108
+ handle: definition.handle,
109
+ name: definition.name,
110
+ fields: apiFields(definition.fields),
111
+ blocks: Object.entries(definition.blocks ?? {}).map(([handle, block]) => ({
112
+ handle,
113
+ name: block.name,
114
+ fields: apiFields(block.fields)
115
+ }))
116
+ }))
117
+ };
118
+ }
119
+
8
120
  // src/helpers/dates.ts
9
121
  var defaultFormat = { dateStyle: "long" };
10
122
  function formatAceDate(date, format = {}) {
@@ -21,4 +133,4 @@ function formatAceDate(date, format = {}) {
21
133
  }).format(moment);
22
134
  }
23
135
 
24
- export { formatAceDate };
136
+ export { checkSectionDefinition, defineSection, formatAceDate, sectionTypesPayload };
@@ -75,7 +75,7 @@ type AceClientOptions = {
75
75
  type AceFieldSchema = {
76
76
  handle: string;
77
77
  label: string;
78
- /** `text`, `textarea`, `select`, `number`, `checkbox`, `image`, `richtext`, `date` or `link`. */
78
+ /** `text`, `textarea`, `select`, `number`, `checkbox`, `image`, `richtext`, `date`, `link` or `sections`. */
79
79
  component_key: string;
80
80
  config: { required?: boolean; options?: string[] } | null;
81
81
  };
@@ -1,5 +1,5 @@
1
1
  import * as react from 'react';
2
- import { ImgHTMLAttributes } from 'react';
2
+ import { ImgHTMLAttributes, ComponentType } from 'react';
3
3
 
4
4
  /** An image as the CMS Ace public API returns it. Use `src` and `srcset` in an `<img>`. */
5
5
  type AceImageData = {
@@ -122,6 +122,51 @@ declare function AceRichText({ document }: {
122
122
  document: AceRichTextDocument;
123
123
  }): react.JSX.Element;
124
124
 
125
+ /** One block of a section, as a website gets it from the CMS, without knowing which kind it is. */
126
+ type AceSectionBlock = {
127
+ id: string;
128
+ type: string;
129
+ values: Record<string, unknown>;
130
+ };
131
+
132
+ /**
133
+ * One section of an entry's sections field, as a website gets it from the CMS, without knowing which kind it is.
134
+ * A component that draws one kind of section types its prop with `SectionOf<typeof schema>` instead.
135
+ */
136
+ type AceSection = {
137
+ /** Steady for as long as the section exists, so a list can be keyed by it. */
138
+ id: string;
139
+ /** Which kind of section: the `handle` of its definition. */
140
+ type: string;
141
+ values: Record<string, unknown>;
142
+ blocks: AceSectionBlock[];
143
+ };
144
+
145
+ type AceSectionsProps = {
146
+ /** An entry's sections field, as the CMS gives it: a list of sections, or nothing. */
147
+ sections: readonly AceSection[] | null | undefined;
148
+ /**
149
+ * The component that draws each kind of section, by the section's `type` (the handle in its schema). Each one is
150
+ * given the section as its `section` prop. A kind with no component is left out.
151
+ */
152
+ components: Readonly<Record<string, ComponentType<{
153
+ section: never;
154
+ }>>>;
155
+ /** Drawn for a section whose kind has no component, instead of leaving it out. */
156
+ fallback?: ComponentType<{
157
+ section: AceSection;
158
+ }>;
159
+ };
160
+ /**
161
+ * Draws an entry's sections field: each section by the component for its kind, in the order the editor put them.
162
+ *
163
+ * <AceSections sections={entry.values.content} components={{ hero: Hero, faq: Faq }} />
164
+ *
165
+ * `Hero` is typed from its own schema (`SectionOf<typeof heroSchema>`), and that is kept: the map only has to
166
+ * say which component goes with which `type`.
167
+ */
168
+ declare function AceSections({ sections, components, fallback: Fallback }: AceSectionsProps): react.JSX.Element | null;
169
+
125
170
  type MetaDescriptor = {
126
171
  title: string;
127
172
  } | {
@@ -171,4 +216,4 @@ type AceRootData = {
171
216
  };
172
217
  };
173
218
 
174
- export { AceImage, AceLink, AceNotice, AceRichText, type AceRootData, AceTextArea, createMeta, meta, useAceMenu, useAcePreview };
219
+ export { AceImage, AceLink, AceNotice, AceRichText, type AceRootData, AceSections, AceTextArea, createMeta, meta, useAceMenu, useAcePreview };
@@ -1,6 +1,6 @@
1
1
  import { pageNotices } from './chunk-XPZY5R5A.js';
2
- import { AceLink, AceRichText, usePreviewRefresh } from './chunk-2MXND4HG.js';
3
- export { AceImage, AceTextArea } from './chunk-2MXND4HG.js';
2
+ import { AceLink, AceRichText, usePreviewRefresh } from './chunk-XS6Z7BMM.js';
3
+ export { AceImage, AceSections, AceTextArea } from './chunk-XS6Z7BMM.js';
4
4
  import { isInternalPath } from './chunk-FVR55GIU.js';
5
5
  import './chunk-YN7LJWGI.js';
6
6
  import './chunk-M5WR5TCR.js';
package/dist/react.d.ts CHANGED
@@ -85,6 +85,51 @@ type AceLinkProps = {
85
85
  /** A link field, drawn as a link. Nothing is drawn if the address can't be trusted. */
86
86
  declare function AceLink({ link, children, className, as: Link }: AceLinkProps): react.JSX.Element | null;
87
87
 
88
+ /** One block of a section, as a website gets it from the CMS, without knowing which kind it is. */
89
+ type AceSectionBlock = {
90
+ id: string;
91
+ type: string;
92
+ values: Record<string, unknown>;
93
+ };
94
+
95
+ /**
96
+ * One section of an entry's sections field, as a website gets it from the CMS, without knowing which kind it is.
97
+ * A component that draws one kind of section types its prop with `SectionOf<typeof schema>` instead.
98
+ */
99
+ type AceSection = {
100
+ /** Steady for as long as the section exists, so a list can be keyed by it. */
101
+ id: string;
102
+ /** Which kind of section: the `handle` of its definition. */
103
+ type: string;
104
+ values: Record<string, unknown>;
105
+ blocks: AceSectionBlock[];
106
+ };
107
+
108
+ type AceSectionsProps = {
109
+ /** An entry's sections field, as the CMS gives it: a list of sections, or nothing. */
110
+ sections: readonly AceSection[] | null | undefined;
111
+ /**
112
+ * The component that draws each kind of section, by the section's `type` (the handle in its schema). Each one is
113
+ * given the section as its `section` prop. A kind with no component is left out.
114
+ */
115
+ components: Readonly<Record<string, ComponentType<{
116
+ section: never;
117
+ }>>>;
118
+ /** Drawn for a section whose kind has no component, instead of leaving it out. */
119
+ fallback?: ComponentType<{
120
+ section: AceSection;
121
+ }>;
122
+ };
123
+ /**
124
+ * Draws an entry's sections field: each section by the component for its kind, in the order the editor put them.
125
+ *
126
+ * <AceSections sections={entry.values.content} components={{ hero: Hero, faq: Faq }} />
127
+ *
128
+ * `Hero` is typed from its own schema (`SectionOf<typeof heroSchema>`), and that is kept: the map only has to
129
+ * say which component goes with which `type`.
130
+ */
131
+ declare function AceSections({ sections, components, fallback: Fallback }: AceSectionsProps): react.JSX.Element | null;
132
+
88
133
  type AceTextAreaProps = {
89
134
  /** The text area field's value. Missing or blank draws nothing. */
90
135
  value: string | undefined;
@@ -107,4 +152,4 @@ declare function AceTextArea({ value, multiline }: AceTextAreaProps): react.JSX.
107
152
  */
108
153
  declare function usePreviewRefresh(adminOrigin: string | null | undefined, refresh?: () => Promise<void>): void;
109
154
 
110
- export { AceImage, AceLink, AceRichText, type AceRichTextLink, AceTextArea, usePreviewRefresh };
155
+ export { AceImage, AceLink, AceRichText, type AceRichTextLink, AceSections, AceTextArea, usePreviewRefresh };
package/dist/react.js CHANGED
@@ -1,4 +1,4 @@
1
- export { AceImage, AceLink, AceRichText, AceTextArea, usePreviewRefresh } from './chunk-2MXND4HG.js';
1
+ export { AceImage, AceLink, AceRichText, AceSections, AceTextArea, usePreviewRefresh } from './chunk-XS6Z7BMM.js';
2
2
  import './chunk-FVR55GIU.js';
3
3
  import './chunk-YN7LJWGI.js';
4
4
  import './chunk-M5WR5TCR.js';
package/dist/vite.d.ts CHANGED
@@ -7,7 +7,8 @@ import { Plugin } from 'vite';
7
7
  * development. This reads the `ACE_` variables from `.env` files into `process.env`, never replacing one that is
8
8
  * already set, and never exposing them to the browser. In production the host's environment is used as it is.
9
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.
10
+ * `vitest.config.ts` too if the site tests components that use the kit. And while the dev server runs it syncs the
11
+ * site's section types (its `*.schema.ts` files) to the CMS whenever they change; see `ace-kit sync`.
11
12
  *
12
13
  * // vite.config.ts
13
14
  * export default defineConfig({ plugins: [ace(), reactRouter()] });
package/dist/vite.js CHANGED
@@ -1,8 +1,7 @@
1
+ import { startDevSite, startSectionSync } from './chunk-O3NAJNUN.js';
1
2
  import { resolve } from 'path';
2
3
  import { loadEnv } from 'vite';
3
4
 
4
- // src/vite/plugin.ts
5
-
6
5
  // src/helpers/package-name.ts
7
6
  var packageName = "@sandboxsonny/ace-kit";
8
7
 
@@ -10,6 +9,26 @@ var packageName = "@sandboxsonny/ace-kit";
10
9
  function ace() {
11
10
  return {
12
11
  name: "ace-kit",
12
+ async configureServer(server) {
13
+ const logger = server.config.logger;
14
+ const devSite = await startDevSite({
15
+ root: server.config.root,
16
+ mode: server.config.mode,
17
+ log: {
18
+ info: (text) => logger.info(`[ace-kit] ${text}`, { timestamp: true }),
19
+ warn: (text) => logger.warn(`[ace-kit] ${text}`, { timestamp: true })
20
+ }
21
+ });
22
+ if (devSite) {
23
+ const tellCms = () => {
24
+ const address = server.httpServer?.address();
25
+ if (address && typeof address === "object") void devSite.setPreviewUrl(`http://localhost:${address.port}`);
26
+ };
27
+ if (server.httpServer && !server.httpServer.listening) server.httpServer.once("listening", tellCms);
28
+ else tellCms();
29
+ }
30
+ startSectionSync(server);
31
+ },
13
32
  config(config, { mode }) {
14
33
  const root = config.root ? resolve(config.root) : process.cwd();
15
34
  const fromFiles = loadEnv(mode, root, "ACE_");
@@ -36,6 +36,17 @@ Everything below is exported from `@sandboxsonny/ace-kit` unless a path is given
36
36
  | `linkAttributes(link)` | What to draw for a link field: `{ href, label }` (`label` is `null` when the editor gave no words), or `null` if the address can't be trusted. |
37
37
  | `formatAceDate(date, format?)` | A date field's day (`"2026-10-13"`) as words, in the language asked for (`{ locale: "en-GB" }`), or `null` if it isn't a real day. Takes the options of `Intl.DateTimeFormat`; the default is `dateStyle: "long"`. A day has no time of day, so it never shifts with a time zone. |
38
38
 
39
+ ## Sections
40
+
41
+ | | |
42
+ | --------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- |
43
+ | `defineSection({ handle, name, fields?, blocks? })` | Describes a kind of section in a `*.schema.ts` file (see [Sections](./sections.md)). Returns it unchanged, for `SectionOf`. |
44
+ | `SectionOf<typeof schema>` | The type of a section of that kind, as a website gets it: use it for the props of the component that draws it. |
45
+ | `checkSectionDefinition(definition, where?)` | What is wrong with a definition, as a list of messages (empty if fine). |
46
+ | `sectionTypesPayload(definitions)` | The body `PUT /sites/{id}/section-types` takes. |
47
+
48
+ Types: `AceSection`, `AceSectionBlock`, `SectionDefinition`, `SectionFieldDefinition`, `SectionFieldType`, `BlockDefinition`, `ValuesOfFields`. Components: `AceSections.astro`, and `AceSections` from `/react` and `/react-router`.
49
+
39
50
  ## Menus
40
51
 
41
52
  | | |
@@ -83,7 +94,7 @@ Everything below is exported from `@sandboxsonny/ace-kit` unless a path is given
83
94
  | `applyPage(response, page)` | Puts a page's status and headers on a response. |
84
95
  | `ace` | The client these use. |
85
96
 
86
- Components, by file: `AceImage.astro`, `AceLink.astro`, `AceRichText.astro`, `AceTextArea.astro`, `AceMenu.astro`, `AceNotice.astro`, `AcePreviewRefresh.astro`.
97
+ Components, by file: `AceImage.astro`, `AceLink.astro`, `AceSections.astro`, `AceRichText.astro`, `AceTextArea.astro`, `AceMenu.astro`, `AceNotice.astro`, `AcePreviewRefresh.astro`.
87
98
 
88
99
  ## React Router 7
89
100
 
@@ -109,4 +120,4 @@ Components, by file: `AceImage.astro`, `AceLink.astro`, `AceRichText.astro`, `Ac
109
120
 
110
121
  ## React (`@sandboxsonny/ace-kit/react`)
111
122
 
112
- `AceImage`, `AceLink` (with `as`), `AceRichText` (with `link`), `usePreviewRefresh(adminOrigin, refresh?)`.
123
+ `AceImage`, `AceLink` (with `as`), `AceSections` (with `fallback`), `AceRichText` (with `link`), `usePreviewRefresh(adminOrigin, refresh?)`.
@@ -7,6 +7,7 @@ npx ace-kit login # sign in
7
7
  npx ace-kit whoami # who am I signed in as?
8
8
  npx ace-kit logout # sign out
9
9
  npx ace-kit generate # write TypeScript types (see Typed content)
10
+ npx ace-kit sync # send your section types to the CMS (see Sections)
10
11
  ```
11
12
 
12
13
  Every command takes `--api-url`, or reads `ACE_API_URL` from the environment or a `.env` file in the folder you run it in. Failing both, it uses the address you last signed in to (kept in a small file beside your logins), so after `ace-kit login --api-url …` you can simply run `ace-kit whoami` from anywhere.
@@ -0,0 +1,41 @@
1
+ # Dev sites
2
+
3
+ When your site's dev server works against a hosted CMS, it doesn't touch the live site. It works on a **dev site**: a private copy of the site that only you can see. You can change section types, try new fields and edit test content freely; the live site, and everyone editing it, never notice.
4
+
5
+ ## How it works
6
+
7
+ On start, the dev server (the Astro integration, or the `ace()` Vite plugin on React Router):
8
+
9
+ 1. checks you are signed in (`npx ace-kit login`), and finds the site `ACE_SITE_DOMAIN` names;
10
+ 2. asks the CMS for your dev site of it, which the CMS makes the first time as a copy of the live site: its content types, section types, menus and the **text** of its entries, with every link between entries pointing at the copy. Images are not copied yet, so image fields start empty;
11
+ 3. points the site at it: `ACE_SITE_DOMAIN` becomes the dev site's domain for as long as the dev server runs (your `.env` is not changed), so the site reads its content from the dev site, and section sync goes there;
12
+ 4. tells the CMS where the dev server is running, so the live preview in the admin app shows your dev server.
13
+
14
+ ```
15
+ [ace-kit] Made you a dev site, "Acme (dev)", as a copy of the live site (dev-3.acme.com). This dev server reads and syncs to it; the live site is not touched.
16
+ ```
17
+
18
+ In the admin app the dev site appears in your Sites list with a **Dev** badge. Its Settings page has **Reset dev site**, which throws away what is in it and copies the live site again, for when you want to go back after trying something.
19
+
20
+ ## Who can have one
21
+
22
+ Partner accounts and super admins, for sites they own. Ask for a partner account if the dev server says your account can't have a dev site.
23
+
24
+ ## Going to the live site
25
+
26
+ A dev site is never merged into the live site. When your change is ready, deploy your site, then run `npx ace-kit sync` against the live site (`ACE_SITE_DOMAIN` set to the live domain, as in your `.env`). Deploy first: the live CMS then has the section types the deployed code expects.
27
+
28
+ ## Settings
29
+
30
+ | Setting | Meaning |
31
+ | ------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
32
+ | `ACE_DEV_SITE` | `false` stops the dev server using a dev site. `true` uses one even against a CMS on your own machine. Default: on for a hosted CMS only. |
33
+ | `ACE_SYNC_SECTIONS` | See [sections](./sections.md). |
34
+
35
+ ## If something stops it
36
+
37
+ The dev server says so once, and carries on showing the **live site**, read-only, with section sync off: you aren't signed in (run `npx ace-kit login --api-url <address>`), your account can't have a dev site, the site isn't one you can see, or the CMS can't be reached.
38
+
39
+ ## Housekeeping
40
+
41
+ A dev site nobody has used for 30 days (no one working in it, no website reading from it) is removed, with everything in it. It is also removed with the live site it was copied from. Dev sites don't count toward a partner's site limit.