@sandboxsonny/ace-kit 0.2.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?)`.
@@ -0,0 +1,52 @@
1
+ # The command line
2
+
3
+ `ace-kit` is also a command. It signs you in to CMS Ace from your terminal, and writes types for your content.
4
+
5
+ ```sh
6
+ npx ace-kit login # sign in
7
+ npx ace-kit whoami # who am I signed in as?
8
+ npx ace-kit logout # sign out
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)
11
+ ```
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.
14
+
15
+ ## Signing in
16
+
17
+ ```sh
18
+ npx ace-kit login --api-url http://localhost:8080
19
+ ```
20
+
21
+ ```
22
+ Signing in to http://localhost:8080
23
+
24
+ 1. Go to http://localhost:5173/device
25
+ 2. Type BCDF-GHJK
26
+
27
+ Check the code on that page matches. Waiting for you to approve it (Ctrl-C to cancel)...
28
+ ```
29
+
30
+ Your browser opens the CMS admin app's approval page. **Type the code from your terminal** into it (it is deliberately not in the link), check that the tool and the address it shows are yours, and choose **Approve**. The terminal then says who you are signed in as. This is the OAuth device flow, the same shape as `gh auth login`: no password is ever typed into the terminal, and nothing secret is written in a file inside your project.
31
+
32
+ Only approve a login you started just now, from your own terminal. If the code on the page doesn't match the one in your terminal, or you didn't ask for a login, choose **Turn it down**.
33
+
34
+ ## Where the login is kept
35
+
36
+ - **On a Mac:** the macOS Keychain, as an item named `ace-kit` whose account is the API address. You can see it in Keychain Access. Ace Kit uses the `security` program that comes with macOS, so nothing native is installed, and the login is handed to it on standard input, never as an argument other users of the machine could read.
37
+ - **Anywhere else:** a file only you can read, `~/.config/ace-kit/credentials.json` (mode `0600`, in a folder of mode `0700`; `%APPDATA%\ace-kit` on Windows). Set `XDG_CONFIG_HOME` to move it.
38
+ - Set `ACE_KIT_CREDENTIALS=file` to use the file even on a Mac.
39
+
40
+ One login is kept per API address, so you can be signed in to a local CMS and a hosted one at once.
41
+
42
+ ## What a command-line login can do
43
+
44
+ Much less than you can in the browser. It can see which sites you can reach, read their content types, and read and sync their section types (the last only on a site where you are an owner, or a super admin). It cannot manage members, passwords or other logins, read or change entries and images, or approve another command-line login. It lasts 90 days at most and ends after 30 days unused. A super admin sees it on the Sessions screen, named after your machine, and can end it from there.
45
+
46
+ ## Build servers
47
+
48
+ A build server has nobody to approve a login in a browser. Set `ACE_TOKEN` to a token instead and Ace Kit uses it in place of a stored login. (Tokens for this are not made yet; for now sign in on the machine that needs it.)
49
+
50
+ ## Signing out
51
+
52
+ `npx ace-kit logout` ends the login at the CMS and forgets it on this machine. If the CMS can't be reached it still forgets it here, and says so.