@sandboxsonny/ace-kit 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +19 -0
- package/LICENSE +21 -0
- package/README.md +79 -2
- package/astro/AceImage.astro +29 -0
- package/astro/AceMenu.astro +24 -0
- package/astro/AceMenuList.astro +25 -0
- package/astro/AceNotice.astro +19 -0
- package/astro/AcePreviewRefresh.astro +15 -0
- package/astro/AceRichText.astro +12 -0
- package/astro/AceTextArea.astro +14 -0
- package/dist/astro-integration.d.ts +20 -0
- package/dist/astro-integration.js +32 -0
- package/dist/astro-middleware.js +10 -0
- package/dist/astro.d.ts +139 -0
- package/dist/astro.js +24 -0
- package/dist/chunk-KEV3YAWN.js +66 -0
- package/dist/chunk-KLRCWAE4.js +147 -0
- package/dist/chunk-M5WR5TCR.js +16 -0
- package/dist/chunk-URLKW2YS.js +107 -0
- package/dist/chunk-XPZY5R5A.js +16 -0
- package/dist/chunk-YLO6IAXF.js +45 -0
- package/dist/chunk-YN7LJWGI.js +36 -0
- package/dist/cli.js +211 -0
- package/dist/index.d.ts +310 -0
- package/dist/index.js +6 -0
- package/dist/preview-script.js +6 -0
- package/dist/react-router-server.d.ts +225 -0
- package/dist/react-router-server.js +89 -0
- package/dist/react-router.d.ts +155 -0
- package/dist/react-router.js +55 -0
- package/dist/react.d.ts +88 -0
- package/dist/react.js +5 -0
- package/dist/vite.d.ts +17 -0
- package/dist/vite.js +27 -0
- package/docs/api-reference.md +105 -0
- package/docs/astro.md +93 -0
- package/docs/getting-started.md +62 -0
- package/docs/live-preview.md +15 -0
- package/docs/react-router.md +116 -0
- package/docs/react.md +81 -0
- package/docs/releasing.md +53 -0
- package/docs/rich-text.md +54 -0
- package/docs/typed-content.md +50 -0
- package/package.json +158 -4
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
type AceClientOptions = {
|
|
2
|
+
/** Where the CMS Ace API lives, e.g. `https://api.example.com`. */
|
|
3
|
+
apiUrl: string;
|
|
4
|
+
/** The domain this site has in the CMS. */
|
|
5
|
+
siteDomain: string;
|
|
6
|
+
/** How long an answer is reused, in seconds, so busy pages don't hit the API's per-IP rate limit. 0 turns caching off. */
|
|
7
|
+
cacheSeconds?: number;
|
|
8
|
+
fetchImplementation?: typeof fetch;
|
|
9
|
+
now?: () => number;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
/** A field of a content type as `GET /public/content-types` describes it. */
|
|
13
|
+
type AceFieldSchema = {
|
|
14
|
+
handle: string;
|
|
15
|
+
label: string;
|
|
16
|
+
/** `text`, `textarea`, `select`, `number`, `checkbox`, `image` or `richtext`. */
|
|
17
|
+
component_key: string;
|
|
18
|
+
config: { required?: boolean; options?: string[] } | null;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/** A content type as `GET /public/content-types` describes it. */
|
|
22
|
+
type AceContentTypeSchema = {
|
|
23
|
+
handle: string;
|
|
24
|
+
name: string;
|
|
25
|
+
fields: AceFieldSchema[];
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/** A live entry from the public API. `Values` is the shape of its content type's fields. */
|
|
29
|
+
type AceEntry<Values = Record<string, unknown>> = {
|
|
30
|
+
id: number;
|
|
31
|
+
content_type_id: number;
|
|
32
|
+
/** The content type's handle, which is the first part of the entry's address. */
|
|
33
|
+
content_type_handle: string;
|
|
34
|
+
title: string;
|
|
35
|
+
handle: string;
|
|
36
|
+
description: string;
|
|
37
|
+
values: Values;
|
|
38
|
+
created_at: string;
|
|
39
|
+
updated_at: string;
|
|
40
|
+
/** Only on a preview: true when the entry has a draft and this is it. */
|
|
41
|
+
is_draft?: boolean;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The shape of each content type's values, keyed by the content type's handle. It starts empty; running
|
|
46
|
+
* `ace-kit generate` fills it in for your site (by declaration merging), and the client then returns the right
|
|
47
|
+
* type for each content type.
|
|
48
|
+
*/
|
|
49
|
+
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
|
|
50
|
+
interface AceContentTypes {}
|
|
51
|
+
|
|
52
|
+
/** The handles of your content types once types are generated; any text before that. */
|
|
53
|
+
type AceContentTypeHandle = keyof AceContentTypes extends never
|
|
54
|
+
? string
|
|
55
|
+
: Extract<keyof AceContentTypes, string>;
|
|
56
|
+
|
|
57
|
+
/** The values of a content type's entries: what was generated for it, or loose before that. */
|
|
58
|
+
type AceValuesOf<Handle extends string> = Handle extends keyof AceContentTypes
|
|
59
|
+
? AceContentTypes[Handle]
|
|
60
|
+
: Record<string, unknown>;
|
|
61
|
+
|
|
62
|
+
/** One link of a menu as the public API returns it, with its address already worked out. */
|
|
63
|
+
type AceMenuItem = {
|
|
64
|
+
label: string;
|
|
65
|
+
/** A path on this site for a link to an entry, or the address as typed. */
|
|
66
|
+
url: string;
|
|
67
|
+
kind: "entry" | "url";
|
|
68
|
+
/** The entry an entry link goes to; null for a typed address. */
|
|
69
|
+
entry: { id: number; title: string; handle: string; content_type_handle: string } | null;
|
|
70
|
+
children: AceMenuItem[];
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
type AceMenu = {
|
|
74
|
+
name: string;
|
|
75
|
+
handle: string;
|
|
76
|
+
items: AceMenuItem[];
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
/** One link in a navigation bar, ready to draw. */
|
|
80
|
+
type AceNavigationLink = {
|
|
81
|
+
label: string;
|
|
82
|
+
to: string;
|
|
83
|
+
/** Links shown under this one. */
|
|
84
|
+
children?: AceNavigationLink[];
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* A client for the CMS Ace public API. It runs on the server only, so the visitor's browser never talks to the
|
|
89
|
+
* API. The API finds the site from X-Forwarded-Host, which this client sets to the site's domain.
|
|
90
|
+
*/
|
|
91
|
+
declare function createAceClient({ apiUrl, siteDomain, cacheSeconds, fetchImplementation, now, }: AceClientOptions): {
|
|
92
|
+
/**
|
|
93
|
+
* One live entry of a content type, by its handle; null if the site has no such entry. With a preview token the
|
|
94
|
+
* entry the token was made for comes back with its draft applied (never cached); a token that has run out throws
|
|
95
|
+
* AcePreviewExpiredError.
|
|
96
|
+
*/
|
|
97
|
+
getEntry<Handle extends AceContentTypeHandle>(contentTypeHandle: Handle, handle: string, options?: {
|
|
98
|
+
previewToken?: string | null;
|
|
99
|
+
}): Promise<AceEntry<AceValuesOf<Handle>> | null>;
|
|
100
|
+
/** The live entries of a content type, newest first. */
|
|
101
|
+
getEntries<Handle extends AceContentTypeHandle>(contentTypeHandle: Handle, options?: {
|
|
102
|
+
limit?: number;
|
|
103
|
+
}): Promise<AceEntry<AceValuesOf<Handle>>[]>;
|
|
104
|
+
/** One of the site's navigation menus by its handle, with every link worked out; null if the site has none. */
|
|
105
|
+
getMenu(handle: string): Promise<AceMenu | null>;
|
|
106
|
+
/** What the site's content types and fields are, which is what type generation reads. */
|
|
107
|
+
getContentTypes(): Promise<AceContentTypeSchema[]>;
|
|
108
|
+
};
|
|
109
|
+
/** The client `createAceClient` returns. */
|
|
110
|
+
type AceClient = ReturnType<typeof createAceClient>;
|
|
111
|
+
|
|
112
|
+
/** The preview link the editor opened has run out. Reload the editor for a new one. */
|
|
113
|
+
declare class AcePreviewExpiredError extends Error {
|
|
114
|
+
constructor();
|
|
115
|
+
}
|
|
116
|
+
/** The CMS could not be reached, or answered with something unexpected. */
|
|
117
|
+
declare class AceUnavailableError extends Error {
|
|
118
|
+
constructor(message: string);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Which entry an address is for: the content type's handle, then the entry's. */
|
|
122
|
+
type AceAddress = {
|
|
123
|
+
contentTypeHandle: string;
|
|
124
|
+
entryHandle: string;
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
/** The content type and entry shown at "/". */
|
|
128
|
+
declare const homeAddress: AceAddress;
|
|
129
|
+
/** The path of an entry: `/{content type}/{entry}`. */
|
|
130
|
+
declare const pathOfAddress: (address: AceAddress) => string;
|
|
131
|
+
/**
|
|
132
|
+
* Reads an address the way Shopify does: "/{content type}/{entry}", so "/pages/about" is the entry "about" of the
|
|
133
|
+
* content type "pages". "/" is the home entry. Anything else is not a page.
|
|
134
|
+
*/
|
|
135
|
+
declare function addressFromPath(slug: string | undefined): AceAddress | null;
|
|
136
|
+
/** Whether an address is the home entry's own address, which redirects to "/". */
|
|
137
|
+
declare const isHomeAddress: (address: AceAddress) => boolean;
|
|
138
|
+
/**
|
|
139
|
+
* Where a path from the CMS should really go: the home entry's own address, which redirects, is replaced by
|
|
140
|
+
* the "/" it redirects to.
|
|
141
|
+
*/
|
|
142
|
+
declare function visitablePath(path: string): string;
|
|
143
|
+
|
|
144
|
+
/** An image as the CMS Ace public API returns it. Use `src` and `srcset` in an `<img>`. */
|
|
145
|
+
type AceImageData = {
|
|
146
|
+
id: number;
|
|
147
|
+
/** The untouched original. */
|
|
148
|
+
url: string;
|
|
149
|
+
/** A web-sized copy, for browsers that ignore `srcset`. */
|
|
150
|
+
src: string;
|
|
151
|
+
/** Copies at several widths; empty when the image is too small to need them. */
|
|
152
|
+
srcset: string;
|
|
153
|
+
width: number | null;
|
|
154
|
+
height: number | null;
|
|
155
|
+
alt_text: string;
|
|
156
|
+
content_type: string;
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
type AceImageOptions = {
|
|
160
|
+
/** How wide the image is shown, e.g. "(min-width: 64rem) 64rem, 100vw". The browser uses it to pick a copy from srcset. */
|
|
161
|
+
sizes: string;
|
|
162
|
+
/** Leave on for images below the first screen; turn off for the one at the top of the page. */
|
|
163
|
+
lazy?: boolean;
|
|
164
|
+
};
|
|
165
|
+
/** The attributes of an `<img>` for an image: the same for every framework. */
|
|
166
|
+
type AceImageAttributes = {
|
|
167
|
+
src: string;
|
|
168
|
+
srcSet: string | undefined;
|
|
169
|
+
sizes: string | undefined;
|
|
170
|
+
width: number | undefined;
|
|
171
|
+
height: number | undefined;
|
|
172
|
+
alt: string;
|
|
173
|
+
loading: "lazy" | "eager";
|
|
174
|
+
decoding: "async";
|
|
175
|
+
fetchPriority: "high" | undefined;
|
|
176
|
+
};
|
|
177
|
+
/** What to put on an `<img>`: a web-sized `src`, `srcset` when there are copies, and the size so the page doesn't jump. */
|
|
178
|
+
declare function imageAttributes(image: AceImageData, { sizes, lazy }: AceImageOptions): AceImageAttributes;
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Where a link in rich text goes, or null if it can't be trusted. The CMS only allows paths, web addresses,
|
|
182
|
+
* mailto: and tel:; this checks again so the page stays safe even if something else ever gets into the text.
|
|
183
|
+
*/
|
|
184
|
+
declare function linkAddress(address: string): string | null;
|
|
185
|
+
/** Whether a link stays on this site: a path such as "/pages/about", not "//elsewhere", "https://..." or "mailto:...". */
|
|
186
|
+
declare function isInternalPath(address: string): boolean;
|
|
187
|
+
|
|
188
|
+
/** A CMS menu as links ready to draw. An entry link to the home entry goes to "/" so it isn't a redirect. */
|
|
189
|
+
declare function linksFromMenu(menu: AceMenu): AceNavigationLink[];
|
|
190
|
+
/** Whether a link is for the page being shown. "/" matches only the home page; a trailing slash is ignored. */
|
|
191
|
+
declare function isCurrentPage(link: AceNavigationLink, pathname: string): boolean;
|
|
192
|
+
/**
|
|
193
|
+
* A menu's links, ready to draw. If the site has no such menu, or the CMS can't be reached, `fallback` is returned
|
|
194
|
+
* instead (a missing menu must never take a whole page down); anything else that goes wrong is a bug and is thrown.
|
|
195
|
+
*/
|
|
196
|
+
declare function loadMenuLinks(client: AceClient, handle: string, fallback?: AceNavigationLink[]): Promise<AceNavigationLink[]>;
|
|
197
|
+
|
|
198
|
+
/** What to do with a request for a page: show an entry, redirect, or explain. */
|
|
199
|
+
type AcePage<Values = Record<string, unknown>> =
|
|
200
|
+
| { kind: "entry"; status: 200; entry: AceEntry<Values>; headers: Record<string, string> }
|
|
201
|
+
| { kind: "redirect"; status: 301 | 302; location: string; headers: Record<string, string> }
|
|
202
|
+
| { kind: "not-found"; status: 404; headers: Record<string, string> }
|
|
203
|
+
| { kind: "unavailable"; status: 503; headers: Record<string, string> }
|
|
204
|
+
| { kind: "preview-expired"; status: 403; headers: Record<string, string> };
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Works out what to do for a request: which entry to show, where to redirect, or which message to give. It does
|
|
208
|
+
* the same for every framework, so a route only has to act on the answer.
|
|
209
|
+
*
|
|
210
|
+
* - `slug` is the part of the path after the site's root, e.g. "pages/about" ("" or undefined for the home page).
|
|
211
|
+
* - `url` is the full request address; it carries the preview token the CMS puts in its preview frame.
|
|
212
|
+
*
|
|
213
|
+
* Addresses are `/{content type}/{entry}` as on Shopify, and `/` is the home entry. A page shown with a preview
|
|
214
|
+
* token is never cached or indexed (the answer's `headers` say so).
|
|
215
|
+
*/
|
|
216
|
+
declare function loadPage<Values = Record<string, unknown>>(client: AceClient, { slug, url }: {
|
|
217
|
+
slug: string | undefined;
|
|
218
|
+
url: URL;
|
|
219
|
+
}): Promise<AcePage<Values>>;
|
|
220
|
+
|
|
221
|
+
/** What a page that isn't an entry says: a heading and a message. Shared by every framework's notice component. */
|
|
222
|
+
declare const pageNotices: Record<AcePage["kind"], {
|
|
223
|
+
heading: string;
|
|
224
|
+
message: string;
|
|
225
|
+
}>;
|
|
226
|
+
|
|
227
|
+
/** The address parameter the CMS puts in the preview frame's address. */
|
|
228
|
+
declare const previewParameter = "cms_preview";
|
|
229
|
+
/** What the CMS sends the page when the draft has changed. */
|
|
230
|
+
declare const refreshMessageType = "cms-ace:refresh";
|
|
231
|
+
/**
|
|
232
|
+
* The origin (`https://admin.example.com`, with no path or trailing slash) of a web address, or null if it isn't a
|
|
233
|
+
* web address. Browsers report a message's origin in exactly this form, so origins are compared in it.
|
|
234
|
+
*/
|
|
235
|
+
declare function originOf(address: string): string | null;
|
|
236
|
+
/** Whether a message is the CMS asking the page to refresh, from the admin origin we expect and no one else. */
|
|
237
|
+
declare function isRefreshRequest(event: {
|
|
238
|
+
origin: string;
|
|
239
|
+
data: unknown;
|
|
240
|
+
}, adminOrigin: string): boolean;
|
|
241
|
+
/** The preview token in an address, if any. */
|
|
242
|
+
declare function previewTokenFrom(address: URL): string | null;
|
|
243
|
+
/**
|
|
244
|
+
* Who may show this site inside a frame: itself and the CMS admin app, so the editor can preview
|
|
245
|
+
* it and nobody else can frame it (clickjacking).
|
|
246
|
+
*/
|
|
247
|
+
declare function framingPolicy(adminOrigin: string): string;
|
|
248
|
+
/**
|
|
249
|
+
* Adds the framing policy to a response's headers without losing a Content-Security-Policy the site already sends:
|
|
250
|
+
* the policy is added to it, replacing only a `frame-ancestors` directive it already has (which would otherwise
|
|
251
|
+
* stop the CMS's editor showing the site).
|
|
252
|
+
*/
|
|
253
|
+
declare function addFramingPolicy(headers: Headers, adminOrigin: string): void;
|
|
254
|
+
/** The headers for a page shown with a preview token: never cached, never indexed. */
|
|
255
|
+
declare const previewHeaders: Record<string, string>;
|
|
256
|
+
/**
|
|
257
|
+
* Swaps the page's content for the same page fetched again. Only the body's children and the title change, so
|
|
258
|
+
* the scroll position, the scripts and the styles stay as they are and nothing flashes.
|
|
259
|
+
*/
|
|
260
|
+
declare function swapPageContent(current: Document, fresh: Document): void;
|
|
261
|
+
|
|
262
|
+
/** Fetches this page again and swaps its content in; the preview token is still in the address. */
|
|
263
|
+
declare function refreshPageContent(): Promise<void>;
|
|
264
|
+
/**
|
|
265
|
+
* Listens, in the browser, for the CMS asking the page to refresh (it does when the editor saves a draft) and calls
|
|
266
|
+
* `refresh`. Only messages from the admin origin count. Messages that arrive while a refresh is under way are folded
|
|
267
|
+
* into one more refresh afterwards. Returns a function that stops listening.
|
|
268
|
+
*/
|
|
269
|
+
declare function listenForPreviewRefresh(adminOrigin: string, refresh?: () => Promise<void>): () => void;
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* A text area field's text as HTML. With `multiline` (the default) blank lines start new paragraphs (`<p>`) and a
|
|
273
|
+
* single new line is a line break; without it the text is one line, with no element around it. Every piece of text
|
|
274
|
+
* is escaped, so the result is safe to put on a page as it is.
|
|
275
|
+
*/
|
|
276
|
+
declare function renderTextArea(text: string | undefined, { multiline }?: {
|
|
277
|
+
multiline?: boolean;
|
|
278
|
+
}): string;
|
|
279
|
+
|
|
280
|
+
/** A mark on a piece of text. A link's address is already worked out: an entry link comes as its path. */
|
|
281
|
+
type AceRichTextMark = { type: "bold" } | { type: "italic" } | { type: "link"; attrs: { href: string } };
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* One node of a rich text document: `paragraph`, `heading` (`attrs.level` 2 to 4), `bulletList`, `orderedList`
|
|
285
|
+
* (`attrs.start`), `listItem`, `blockquote`, `hardBreak`, `text`, or `image` (`attrs.image` is the picture).
|
|
286
|
+
*/
|
|
287
|
+
type AceRichTextNode = {
|
|
288
|
+
type: string;
|
|
289
|
+
attrs?: { level?: number; start?: number; assetId?: number; image?: AceImageData };
|
|
290
|
+
content?: AceRichTextNode[];
|
|
291
|
+
text?: string;
|
|
292
|
+
marks?: AceRichTextMark[];
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
/** The value of a rich text field. */
|
|
296
|
+
type AceRichTextDocument = { type: "doc"; content?: AceRichTextNode[] };
|
|
297
|
+
|
|
298
|
+
/** How wide an image in the text is shown, so the browser picks a suitable copy from srcset. */
|
|
299
|
+
declare const richTextImageSizes = "(min-width: 42rem) 42rem, 100vw";
|
|
300
|
+
/** The heading level to draw: 2 to 4, whatever the document says. */
|
|
301
|
+
declare const headingLevel: (node: AceRichTextNode) => 2 | 3 | 4;
|
|
302
|
+
/** The number an ordered list starts at, when it isn't 1. */
|
|
303
|
+
declare const listStart: (node: AceRichTextNode) => number | undefined;
|
|
304
|
+
/**
|
|
305
|
+
* A rich text document as HTML. Every piece of text and every attribute is escaped, and only the kinds of
|
|
306
|
+
* content the CMS allows are drawn, so the result is safe to put on a page as it is.
|
|
307
|
+
*/
|
|
308
|
+
declare function renderRichText(document: AceRichTextDocument): string;
|
|
309
|
+
|
|
310
|
+
export { type AceAddress, type AceClient, type AceClientOptions, type AceContentTypeHandle, type AceContentTypeSchema, type AceContentTypes, type AceEntry, type AceFieldSchema, type AceImageAttributes, type AceImageData, type AceImageOptions, type AceMenu, type AceMenuItem, type AceNavigationLink, type AcePage, AcePreviewExpiredError, type AceRichTextDocument, type AceRichTextMark, type AceRichTextNode, AceUnavailableError, type AceValuesOf, addFramingPolicy, addressFromPath, createAceClient, framingPolicy, headingLevel, homeAddress, imageAttributes, isCurrentPage, isHomeAddress, isInternalPath, isRefreshRequest, linkAddress, linksFromMenu, listStart, listenForPreviewRefresh, loadMenuLinks, loadPage, originOf, pageNotices, pathOfAddress, previewHeaders, previewParameter, previewTokenFrom, refreshMessageType, refreshPageContent, renderRichText, renderTextArea, richTextImageSizes, swapPageContent, visitablePath };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { pageNotices } from './chunk-XPZY5R5A.js';
|
|
2
|
+
export { headingLevel, imageAttributes, isInternalPath, linkAddress, listStart, renderRichText, renderTextArea, richTextImageSizes } from './chunk-URLKW2YS.js';
|
|
3
|
+
export { listenForPreviewRefresh, refreshPageContent } from './chunk-YN7LJWGI.js';
|
|
4
|
+
export { AcePreviewExpiredError, AceUnavailableError, createAceClient, isCurrentPage, linksFromMenu, loadMenuLinks, loadPage } from './chunk-KLRCWAE4.js';
|
|
5
|
+
export { addressFromPath, homeAddress, isHomeAddress, pathOfAddress, visitablePath } from './chunk-M5WR5TCR.js';
|
|
6
|
+
export { addFramingPolicy, framingPolicy, isRefreshRequest, originOf, previewHeaders, previewParameter, previewTokenFrom, refreshMessageType, swapPageContent } from './chunk-YLO6IAXF.js';
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { listenForPreviewRefresh } from './chunk-YN7LJWGI.js';
|
|
2
|
+
import './chunk-YLO6IAXF.js';
|
|
3
|
+
|
|
4
|
+
// src/preview/refresh-script.ts
|
|
5
|
+
var adminOrigin = document.querySelector("[data-ace-preview-origin]")?.dataset.acePreviewOrigin;
|
|
6
|
+
if (adminOrigin) listenForPreviewRefresh(adminOrigin);
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import * as react_router from 'react-router';
|
|
2
|
+
import { EntryContext, AppLoadContext } from 'react-router';
|
|
3
|
+
|
|
4
|
+
/** A live entry from the public API. `Values` is the shape of its content type's fields. */
|
|
5
|
+
type AceEntry<Values = Record<string, unknown>> = {
|
|
6
|
+
id: number;
|
|
7
|
+
content_type_id: number;
|
|
8
|
+
/** The content type's handle, which is the first part of the entry's address. */
|
|
9
|
+
content_type_handle: string;
|
|
10
|
+
title: string;
|
|
11
|
+
handle: string;
|
|
12
|
+
description: string;
|
|
13
|
+
values: Values;
|
|
14
|
+
created_at: string;
|
|
15
|
+
updated_at: string;
|
|
16
|
+
/** Only on a preview: true when the entry has a draft and this is it. */
|
|
17
|
+
is_draft?: boolean;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The shape of each content type's values, keyed by the content type's handle. It starts empty; running
|
|
22
|
+
* `ace-kit generate` fills it in for your site (by declaration merging), and the client then returns the right
|
|
23
|
+
* type for each content type.
|
|
24
|
+
*/
|
|
25
|
+
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
|
|
26
|
+
interface AceContentTypes {}
|
|
27
|
+
|
|
28
|
+
/** The handles of your content types once types are generated; any text before that. */
|
|
29
|
+
type AceContentTypeHandle = keyof AceContentTypes extends never
|
|
30
|
+
? string
|
|
31
|
+
: Extract<keyof AceContentTypes, string>;
|
|
32
|
+
|
|
33
|
+
/** The values of a content type's entries: what was generated for it, or loose before that. */
|
|
34
|
+
type AceValuesOf<Handle extends string> = Handle extends keyof AceContentTypes
|
|
35
|
+
? AceContentTypes[Handle]
|
|
36
|
+
: Record<string, unknown>;
|
|
37
|
+
|
|
38
|
+
/** One link of a menu as the public API returns it, with its address already worked out. */
|
|
39
|
+
type AceMenuItem = {
|
|
40
|
+
label: string;
|
|
41
|
+
/** A path on this site for a link to an entry, or the address as typed. */
|
|
42
|
+
url: string;
|
|
43
|
+
kind: "entry" | "url";
|
|
44
|
+
/** The entry an entry link goes to; null for a typed address. */
|
|
45
|
+
entry: { id: number; title: string; handle: string; content_type_handle: string } | null;
|
|
46
|
+
children: AceMenuItem[];
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
type AceMenu = {
|
|
50
|
+
name: string;
|
|
51
|
+
handle: string;
|
|
52
|
+
items: AceMenuItem[];
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
/** One link in a navigation bar, ready to draw. */
|
|
56
|
+
type AceNavigationLink = {
|
|
57
|
+
label: string;
|
|
58
|
+
to: string;
|
|
59
|
+
/** Links shown under this one. */
|
|
60
|
+
children?: AceNavigationLink[];
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
type AceClientOptions = {
|
|
64
|
+
/** Where the CMS Ace API lives, e.g. `https://api.example.com`. */
|
|
65
|
+
apiUrl: string;
|
|
66
|
+
/** The domain this site has in the CMS. */
|
|
67
|
+
siteDomain: string;
|
|
68
|
+
/** How long an answer is reused, in seconds, so busy pages don't hit the API's per-IP rate limit. 0 turns caching off. */
|
|
69
|
+
cacheSeconds?: number;
|
|
70
|
+
fetchImplementation?: typeof fetch;
|
|
71
|
+
now?: () => number;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
/** A field of a content type as `GET /public/content-types` describes it. */
|
|
75
|
+
type AceFieldSchema = {
|
|
76
|
+
handle: string;
|
|
77
|
+
label: string;
|
|
78
|
+
/** `text`, `textarea`, `select`, `number`, `checkbox`, `image` or `richtext`. */
|
|
79
|
+
component_key: string;
|
|
80
|
+
config: { required?: boolean; options?: string[] } | null;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
/** A content type as `GET /public/content-types` describes it. */
|
|
84
|
+
type AceContentTypeSchema = {
|
|
85
|
+
handle: string;
|
|
86
|
+
name: string;
|
|
87
|
+
fields: AceFieldSchema[];
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* A client for the CMS Ace public API. It runs on the server only, so the visitor's browser never talks to the
|
|
92
|
+
* API. The API finds the site from X-Forwarded-Host, which this client sets to the site's domain.
|
|
93
|
+
*/
|
|
94
|
+
declare function createAceClient({ apiUrl, siteDomain, cacheSeconds, fetchImplementation, now, }: AceClientOptions): {
|
|
95
|
+
/**
|
|
96
|
+
* One live entry of a content type, by its handle; null if the site has no such entry. With a preview token the
|
|
97
|
+
* entry the token was made for comes back with its draft applied (never cached); a token that has run out throws
|
|
98
|
+
* AcePreviewExpiredError.
|
|
99
|
+
*/
|
|
100
|
+
getEntry<Handle extends AceContentTypeHandle>(contentTypeHandle: Handle, handle: string, options?: {
|
|
101
|
+
previewToken?: string | null;
|
|
102
|
+
}): Promise<AceEntry<AceValuesOf<Handle>> | null>;
|
|
103
|
+
/** The live entries of a content type, newest first. */
|
|
104
|
+
getEntries<Handle extends AceContentTypeHandle>(contentTypeHandle: Handle, options?: {
|
|
105
|
+
limit?: number;
|
|
106
|
+
}): Promise<AceEntry<AceValuesOf<Handle>>[]>;
|
|
107
|
+
/** One of the site's navigation menus by its handle, with every link worked out; null if the site has none. */
|
|
108
|
+
getMenu(handle: string): Promise<AceMenu | null>;
|
|
109
|
+
/** What the site's content types and fields are, which is what type generation reads. */
|
|
110
|
+
getContentTypes(): Promise<AceContentTypeSchema[]>;
|
|
111
|
+
};
|
|
112
|
+
/** The client `createAceClient` returns. */
|
|
113
|
+
type AceClient = ReturnType<typeof createAceClient>;
|
|
114
|
+
|
|
115
|
+
/** What the root loader (`createRootLoader`) puts in the root route's data, for `useAceMenu` and `useAcePreview`. */
|
|
116
|
+
type AceRootData = {
|
|
117
|
+
ace: {
|
|
118
|
+
/** The header menu's links, or the fallback. */
|
|
119
|
+
menuLinks: AceNavigationLink[];
|
|
120
|
+
/** Where the CMS admin app runs, when this page is a preview shown inside its editor; otherwise null. */
|
|
121
|
+
previewAdminOrigin: string | null;
|
|
122
|
+
};
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* The client for this site, made from the settings the first time it is needed. Most code only needs the functions
|
|
127
|
+
* below (`getEntry` and friends); this is for the rest of the client (`getContentTypes`).
|
|
128
|
+
*/
|
|
129
|
+
declare function getAceClient(): AceClient;
|
|
130
|
+
/** One live entry of a content type by its handle, or `null`. See `createAceClient`. */
|
|
131
|
+
declare const getEntry: AceClient["getEntry"];
|
|
132
|
+
/** A content type's live entries, newest first. */
|
|
133
|
+
declare const getEntries: AceClient["getEntries"];
|
|
134
|
+
/** A navigation menu with every link worked out, or `null`. */
|
|
135
|
+
declare const getMenu: (handle: string) => Promise<AceMenu | null>;
|
|
136
|
+
/**
|
|
137
|
+
* A menu's links, ready to draw. If the site has no such menu, or the CMS can't be reached, you get `fallback`
|
|
138
|
+
* instead, so a missing menu never takes a page down.
|
|
139
|
+
*/
|
|
140
|
+
declare const getMenuLinks: (handle: string, options?: {
|
|
141
|
+
fallback?: AceNavigationLink[];
|
|
142
|
+
}) => Promise<AceNavigationLink[]>;
|
|
143
|
+
/**
|
|
144
|
+
* The loader for the route that draws every page. The route is a catch-all (`route("*", "routes/page.tsx")`):
|
|
145
|
+
*
|
|
146
|
+
* export const loader = createPageLoader<PageValues>();
|
|
147
|
+
* export { headers } from "@sandboxsonny/ace-kit/react-router/server";
|
|
148
|
+
*
|
|
149
|
+
* It reads the address (`/{content type}/{entry}`, with `/` the home entry), loads the entry and answers with the
|
|
150
|
+
* right status and headers: a missing page is a real 404, an unreachable CMS a 503, an expired preview a 403, and
|
|
151
|
+
* `/pages/home` redirects to `/`. Previews are never cached or indexed. Draw `loaderData` yourself.
|
|
152
|
+
*/
|
|
153
|
+
declare function createPageLoader<Values = Record<string, unknown>>(): ({ request }: {
|
|
154
|
+
request: Request;
|
|
155
|
+
}) => Promise<Response | react_router.UNSAFE_DataWithResponseInit<{
|
|
156
|
+
kind: "not-found";
|
|
157
|
+
status: 404;
|
|
158
|
+
headers: Record<string, string>;
|
|
159
|
+
} | {
|
|
160
|
+
kind: "unavailable";
|
|
161
|
+
status: 503;
|
|
162
|
+
headers: Record<string, string>;
|
|
163
|
+
} | {
|
|
164
|
+
kind: "preview-expired";
|
|
165
|
+
status: 403;
|
|
166
|
+
headers: Record<string, string>;
|
|
167
|
+
} | {
|
|
168
|
+
kind: "entry";
|
|
169
|
+
status: 200;
|
|
170
|
+
entry: AceEntry<Values>;
|
|
171
|
+
headers: Record<string, string>;
|
|
172
|
+
}>>;
|
|
173
|
+
/** `createPageLoader()` for a site that has not generated types for its content. */
|
|
174
|
+
declare const loader: ({ request }: {
|
|
175
|
+
request: Request;
|
|
176
|
+
}) => Promise<Response | react_router.UNSAFE_DataWithResponseInit<{
|
|
177
|
+
kind: "not-found";
|
|
178
|
+
status: 404;
|
|
179
|
+
headers: Record<string, string>;
|
|
180
|
+
} | {
|
|
181
|
+
kind: "unavailable";
|
|
182
|
+
status: 503;
|
|
183
|
+
headers: Record<string, string>;
|
|
184
|
+
} | {
|
|
185
|
+
kind: "preview-expired";
|
|
186
|
+
status: 403;
|
|
187
|
+
headers: Record<string, string>;
|
|
188
|
+
} | {
|
|
189
|
+
kind: "entry";
|
|
190
|
+
status: 200;
|
|
191
|
+
entry: AceEntry<Record<string, unknown>>;
|
|
192
|
+
headers: Record<string, string>;
|
|
193
|
+
}>>;
|
|
194
|
+
/** The route's `headers`: send the ones the loader chose (a preview must never be cached or indexed). */
|
|
195
|
+
declare const headers: ({ loaderHeaders }: {
|
|
196
|
+
loaderHeaders: Headers;
|
|
197
|
+
}) => Headers;
|
|
198
|
+
/**
|
|
199
|
+
* The root route's loader: the header menu's links, and whether this page is a preview shown inside the CMS's
|
|
200
|
+
* editor. Read them in components with `useAceMenu()`, and call `useAcePreview()` once to keep a preview fresh.
|
|
201
|
+
*
|
|
202
|
+
* export const loader = createRootLoader({ menu: "main-menu", fallback: [{ label: "Home", to: "/" }] });
|
|
203
|
+
*
|
|
204
|
+
* The menu is the CMS menu with that handle; `fallback` is used if the site has none yet or the CMS is down.
|
|
205
|
+
*/
|
|
206
|
+
declare function createRootLoader({ menu, fallback }: {
|
|
207
|
+
menu: string;
|
|
208
|
+
fallback?: AceNavigationLink[];
|
|
209
|
+
}): ({ request }: {
|
|
210
|
+
request: Request;
|
|
211
|
+
}) => Promise<AceRootData>;
|
|
212
|
+
/** How long React Router waits for deferred data before giving up, as in its default server entry. */
|
|
213
|
+
declare const streamTimeout = 5000;
|
|
214
|
+
/**
|
|
215
|
+
* A server entry. `app/entry.server.tsx` is one line:
|
|
216
|
+
*
|
|
217
|
+
* export { handleRequest as default, streamTimeout } from "@sandboxsonny/ace-kit/react-router/server";
|
|
218
|
+
*
|
|
219
|
+
* It renders the page like React Router's default entry, except that it waits until the whole page is ready before
|
|
220
|
+
* answering (every page here is fully loaded by its loader, and crawlers get complete HTML), and it lets the CMS's
|
|
221
|
+
* admin app, and only it, frame the site for live preview (`Content-Security-Policy: frame-ancestors`).
|
|
222
|
+
*/
|
|
223
|
+
declare function handleRequest(request: Request, responseStatusCode: number, responseHeaders: Headers, routerContext: EntryContext, _loadContext: AppLoadContext): Promise<Response>;
|
|
224
|
+
|
|
225
|
+
export { createPageLoader, createRootLoader, getAceClient, getEntries, getEntry, getMenu, getMenuLinks, handleRequest, headers, loader, streamTimeout };
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { createAceClient, loadMenuLinks, loadPage } from './chunk-KLRCWAE4.js';
|
|
2
|
+
import './chunk-M5WR5TCR.js';
|
|
3
|
+
import { previewTokenFrom, addFramingPolicy } from './chunk-YLO6IAXF.js';
|
|
4
|
+
import { createReadableStreamFromReadable } from '@react-router/node';
|
|
5
|
+
import { PassThrough } from 'stream';
|
|
6
|
+
import { renderToPipeableStream } from 'react-dom/server';
|
|
7
|
+
import { redirect, data, ServerRouter } from 'react-router';
|
|
8
|
+
import { jsx } from 'react/jsx-runtime';
|
|
9
|
+
|
|
10
|
+
// src/react-router/settings.ts
|
|
11
|
+
function getAceSettings() {
|
|
12
|
+
return {
|
|
13
|
+
apiUrl: process.env.ACE_API_URL ?? "http://localhost:8080",
|
|
14
|
+
siteDomain: process.env.ACE_SITE_DOMAIN ?? "localhost",
|
|
15
|
+
previewAdminOrigin: process.env.ACE_PREVIEW_ADMIN_ORIGIN ?? "http://localhost:5173"
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
if (typeof window !== "undefined" && typeof process === "undefined") {
|
|
19
|
+
throw new Error(
|
|
20
|
+
"@sandboxsonny/ace-kit/react-router/server is server-only, but it was loaded in the browser. Use it only in a route's loader, headers or entry.server.tsx, not in a component. Components and hooks are in @sandboxsonny/ace-kit/react-router."
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
var client;
|
|
24
|
+
function getAceClient() {
|
|
25
|
+
if (!client) {
|
|
26
|
+
const { apiUrl, siteDomain } = getAceSettings();
|
|
27
|
+
client = createAceClient({ apiUrl, siteDomain });
|
|
28
|
+
}
|
|
29
|
+
return client;
|
|
30
|
+
}
|
|
31
|
+
var getEntry = ((...parameters) => getAceClient().getEntry(...parameters));
|
|
32
|
+
var getEntries = ((...parameters) => getAceClient().getEntries(...parameters));
|
|
33
|
+
var getMenu = (handle) => getAceClient().getMenu(handle);
|
|
34
|
+
var getMenuLinks = (handle, options = {}) => loadMenuLinks(getAceClient(), handle, options.fallback ?? []);
|
|
35
|
+
function createPageLoader() {
|
|
36
|
+
return async function loader2({ request }) {
|
|
37
|
+
const url = new URL(request.url);
|
|
38
|
+
const slug = url.pathname.replace(/^\/+|\/+$/g, "");
|
|
39
|
+
const page = await loadPage(getAceClient(), { slug, url });
|
|
40
|
+
if (page.kind === "redirect") return redirect(page.location, { status: page.status, headers: page.headers });
|
|
41
|
+
return data(page, { status: page.status, headers: page.headers });
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
var loader = createPageLoader();
|
|
45
|
+
var headers = ({ loaderHeaders }) => loaderHeaders;
|
|
46
|
+
function createRootLoader({ menu, fallback = [] }) {
|
|
47
|
+
return async function loader2({ request }) {
|
|
48
|
+
const menuLinks = await loadMenuLinks(getAceClient(), menu, fallback);
|
|
49
|
+
const previewAdminOrigin = previewTokenFrom(new URL(request.url)) ? getAceSettings().previewAdminOrigin : null;
|
|
50
|
+
return { ace: { menuLinks, previewAdminOrigin } };
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
var streamTimeout = 5e3;
|
|
54
|
+
function handleRequest(request, responseStatusCode, responseHeaders, routerContext, _loadContext) {
|
|
55
|
+
addFramingPolicy(responseHeaders, getAceSettings().previewAdminOrigin);
|
|
56
|
+
if (request.method.toUpperCase() === "HEAD") {
|
|
57
|
+
return Promise.resolve(new Response(null, { status: responseStatusCode, headers: responseHeaders }));
|
|
58
|
+
}
|
|
59
|
+
return new Promise((resolve, reject) => {
|
|
60
|
+
let statusCode = responseStatusCode;
|
|
61
|
+
let shellRendered = false;
|
|
62
|
+
let timeoutId = setTimeout(() => abort(), streamTimeout + 1e3);
|
|
63
|
+
const { pipe, abort } = renderToPipeableStream(/* @__PURE__ */ jsx(ServerRouter, { context: routerContext, url: request.url }), {
|
|
64
|
+
onAllReady() {
|
|
65
|
+
shellRendered = true;
|
|
66
|
+
const body = new PassThrough({
|
|
67
|
+
final(callback) {
|
|
68
|
+
clearTimeout(timeoutId);
|
|
69
|
+
timeoutId = void 0;
|
|
70
|
+
callback();
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
responseHeaders.set("Content-Type", "text/html");
|
|
74
|
+
pipe(body);
|
|
75
|
+
resolve(new Response(createReadableStreamFromReadable(body), { headers: responseHeaders, status: statusCode }));
|
|
76
|
+
},
|
|
77
|
+
onShellError(error) {
|
|
78
|
+
clearTimeout(timeoutId);
|
|
79
|
+
reject(error);
|
|
80
|
+
},
|
|
81
|
+
onError(error) {
|
|
82
|
+
statusCode = 500;
|
|
83
|
+
if (shellRendered) console.error(error);
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export { createPageLoader, createRootLoader, getAceClient, getEntries, getEntry, getMenu, getMenuLinks, handleRequest, headers, loader, streamTimeout };
|