@sandboxsonny/ace-kit 0.10.0 → 0.12.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 CHANGED
@@ -1,5 +1,17 @@
1
1
  # @sandboxsonny/ace-kit
2
2
 
3
+ ## 0.12.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 4d64734: Add a sitemap and `robots.txt`. `getSitemapResponse` and `getRobotsResponse` (Astro), and `sitemapLoader` and `robotsLoader` (React Router), each make the file in one line. The sitemap lists the site's live pages from the CMS, leaving out content types whose entries aren't pages of their own, with when each last changed. Also adds `client.getSitemap()`, and the pure `sitemapXml`, `robotsTxt` and `publicOrigin` helpers.
8
+
9
+ ## 0.11.0
10
+
11
+ ### Minor Changes
12
+
13
+ - f0a08c3: Follow the redirects set up in the CMS. When a request is for an address the site has no page for, `getPage` (and the React Router page loader) now asks the CMS whether the site's owners redirected it, and answers with that redirect (301 or 302, to a path on the site or another address) instead of "not found". Your page already handles redirects, so there is nothing to change. A page shown in the CMS's preview frame is never redirected. Also adds `client.getRedirect(address)`.
14
+
3
15
  ## 0.10.0
4
16
 
5
17
  ### Minor Changes
package/dist/astro.d.ts CHANGED
@@ -53,6 +53,24 @@ type AceSite = {
53
53
  favicon: AceFavicon | null;
54
54
  };
55
55
 
56
+ /** One page of the site as the CMS lists it for the sitemap. */
57
+ type AceSitemapEntry = {
58
+ /** The address visitors use: "/" for the home entry, otherwise "/{content type}/{entry}". */
59
+ path: string;
60
+ /** When the entry was last changed, as an ISO date and time. */
61
+ updated_at: string;
62
+ };
63
+
64
+ /** A redirect the CMS has for an address, as its public API returns it. */
65
+ type AceRedirect = {
66
+ /** The old address the redirect is for. */
67
+ from: string;
68
+ /** Where it goes: a path on the site, or a full web address. */
69
+ to: string;
70
+ /** 301 is permanent; 302 is temporary. */
71
+ status: 301 | 302;
72
+ };
73
+
56
74
  /** One link of a menu as the public API returns it, with its address already worked out. */
57
75
  type AceMenuItem = {
58
76
  label: string;
@@ -112,6 +130,13 @@ type AceValuesOf<Handle extends string> = Handle extends keyof AceContentTypes
112
130
  ? AceContentTypes[Handle]
113
131
  : Record<string, unknown>;
114
132
 
133
+ type RobotsOptions = {
134
+ /** Asks every crawler to stay away, for a site that isn't live yet. */
135
+ disallowAll?: boolean;
136
+ /** The site's address, e.g. `https://example.com`. Left out, the address the request came to is used. */
137
+ origin?: string;
138
+ };
139
+
115
140
  /** What to do with a request for a page: show an entry, redirect, or explain. */
116
141
  type AcePage<Values = Record<string, unknown>> =
117
142
  | { kind: "entry"; status: 200; entry: AceEntry<Values>; headers: Record<string, string> }
@@ -129,6 +154,8 @@ declare const ace: {
129
154
  limit?: number;
130
155
  }): Promise<AceEntry<AceValuesOf<Handle>>[]>;
131
156
  getMenu(handle: string): Promise<AceMenu | null>;
157
+ getRedirect(address: string): Promise<AceRedirect | null>;
158
+ getSitemap(): Promise<AceSitemapEntry[]>;
132
159
  getSite(): Promise<AceSite | null>;
133
160
  getContentTypes(): Promise<AceContentTypeSchema[]>;
134
161
  };
@@ -177,5 +204,31 @@ declare function getPage<Values = Record<string, unknown>>(astro: {
177
204
  headers: Headers;
178
205
  };
179
206
  }): Promise<AcePage<Values>>;
207
+ /**
208
+ * The response for `/sitemap.xml`: the site's live pages (of the content types that have pages), with when each last
209
+ * changed. Make `src/pages/sitemap.xml.ts` with
210
+ *
211
+ * import { getSitemapResponse } from "@sandboxsonny/ace-kit/astro";
212
+ * export const GET = ({ url }) => getSitemapResponse({ url });
213
+ *
214
+ * The addresses use https unless the site is on localhost; pass `{ origin: "https://example.com" }` to choose.
215
+ */
216
+ declare const getSitemapResponse: (astro: {
217
+ url: URL;
218
+ }, options?: {
219
+ origin?: string;
220
+ }) => Promise<Response>;
221
+ /**
222
+ * The response for `/robots.txt`, which lets crawlers in and points them at the sitemap. Make `src/pages/robots.txt.ts`
223
+ * with
224
+ *
225
+ * import { getRobotsResponse } from "@sandboxsonny/ace-kit/astro";
226
+ * export const GET = ({ url }) => getRobotsResponse({ url });
227
+ *
228
+ * Pass `{ disallowAll: true }` for a site that isn't live yet.
229
+ */
230
+ declare const getRobotsResponse: (astro: {
231
+ url: URL;
232
+ }, options?: RobotsOptions) => Response;
180
233
 
181
- export { ace, applyPage, getEntries, getEntry, getMenu, getMenuLinks, getPage, getSite };
234
+ export { ace, applyPage, getEntries, getEntry, getMenu, getMenuLinks, getPage, getRobotsResponse, getSite, getSitemapResponse };
package/dist/astro.js CHANGED
@@ -1,4 +1,4 @@
1
- import { createAceClient, loadMenuLinks, loadPage } from './chunk-EKZRLWNO.js';
1
+ import { createAceClient, loadMenuLinks, loadPage, sitemapResponse, robotsResponse } from './chunk-AJLQFATM.js';
2
2
  import './chunk-M5WR5TCR.js';
3
3
  import './chunk-YLO6IAXF.js';
4
4
  import { ACE_SITE_DOMAIN, ACE_API_URL } from 'astro:env/server';
@@ -21,5 +21,7 @@ async function getPage(astro) {
21
21
  applyPage(astro.response, page);
22
22
  return page;
23
23
  }
24
+ var getSitemapResponse = (astro, options = {}) => sitemapResponse(ace, astro.url, options);
25
+ var getRobotsResponse = (astro, options = {}) => robotsResponse(astro.url, options);
24
26
 
25
- export { ace, applyPage, getEntries, getEntry, getMenu, getMenuLinks, getPage, getSite };
27
+ export { ace, applyPage, getEntries, getEntry, getMenu, getMenuLinks, getPage, getRobotsResponse, getSite, getSitemapResponse };
@@ -89,6 +89,19 @@ function createAceClient({
89
89
  getMenu(handle) {
90
90
  return get(`/public/menus/${encodeURIComponent(handle)}`);
91
91
  },
92
+ /**
93
+ * Where an address the site has no page for should redirect to, if the site's owners set that up; null if not.
94
+ * `address` is the path with its query string, e.g. "/old-page?id=7".
95
+ */
96
+ async getRedirect(address) {
97
+ const redirect = await get(`/public/redirects?from=${encodeURIComponent(address)}`);
98
+ const goesSomewhereSafe = redirect !== null && typeof redirect.to === "string" && (redirect.to.startsWith("/") && !redirect.to.startsWith("//") && !redirect.to.includes("\\") || /^https?:\/\/[^\s]+$/i.test(redirect.to)) && (redirect.status === 301 || redirect.status === 302);
99
+ return goesSomewhereSafe ? redirect : null;
100
+ },
101
+ /** The site's live pages for its sitemap, by path. Pages of content types that have no pages of their own are left out. */
102
+ async getSitemap() {
103
+ return await get("/public/sitemap") ?? [];
104
+ },
92
105
  /** The site itself: its name, and the social image its owners chose (null if none). */
93
106
  getSite() {
94
107
  return get("/public/site");
@@ -132,12 +145,12 @@ async function loadPage(client, { slug, url }) {
132
145
  if (slug && address && isHomeAddress(address)) {
133
146
  return { kind: "redirect", status: url.search ? 302 : 301, location: `/${url.search}`, headers };
134
147
  }
135
- if (!address) return { kind: "not-found", status: 404, headers };
148
+ if (!address) return notFoundOrRedirect(client, url, previewToken, headers);
136
149
  try {
137
150
  const entry = await client.getEntry(address.contentTypeHandle, address.entryHandle, {
138
151
  previewToken
139
152
  });
140
- return entry ? { kind: "entry", status: 200, entry, headers } : { kind: "not-found", status: 404, headers };
153
+ return entry ? { kind: "entry", status: 200, entry, headers } : notFoundOrRedirect(client, url, previewToken, headers);
141
154
  } catch (error) {
142
155
  if (error instanceof AcePreviewExpiredError) return { kind: "preview-expired", status: 403, headers };
143
156
  if (error instanceof AceUnavailableError) {
@@ -147,5 +160,66 @@ async function loadPage(client, { slug, url }) {
147
160
  throw error;
148
161
  }
149
162
  }
163
+ async function notFoundOrRedirect(client, url, previewToken, headers) {
164
+ if (previewToken) return { kind: "not-found", status: 404, headers };
165
+ try {
166
+ const redirect = await client.getRedirect(`${url.pathname}${url.search}`);
167
+ if (redirect) return { kind: "redirect", status: redirect.status, location: redirect.to, headers };
168
+ } catch (error) {
169
+ if (!(error instanceof AceUnavailableError)) throw error;
170
+ console.error(error.message);
171
+ return { kind: "unavailable", status: 503, headers };
172
+ }
173
+ return { kind: "not-found", status: 404, headers };
174
+ }
175
+
176
+ // src/helpers/sitemap.ts
177
+ var escapeXml = (text) => text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&apos;");
178
+ function publicOrigin(url) {
179
+ const local = url.hostname === "localhost" || url.hostname.endsWith(".localhost") || /^[\d.]+$|:/.test(url.hostname);
180
+ return local ? url.origin : `https://${url.host}`;
181
+ }
182
+ function sitemapXml(entries, origin) {
183
+ const base = origin.replace(/\/+$/, "");
184
+ const urls = entries.map((entry) => {
185
+ const lastChanged = Number.isNaN(Date.parse(entry.updated_at)) ? "" : new Date(entry.updated_at).toISOString();
186
+ return ` <url>
187
+ <loc>${escapeXml(`${base}${entry.path}`)}</loc>${lastChanged ? `
188
+ <lastmod>${lastChanged}</lastmod>` : ""}
189
+ </url>`;
190
+ }).join("\n");
191
+ return `<?xml version="1.0" encoding="UTF-8"?>
192
+ <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
193
+ ${urls}${urls ? "\n" : ""}</urlset>
194
+ `;
195
+ }
196
+ function robotsTxt({ origin, disallowAll = false }) {
197
+ if (disallowAll) return "User-agent: *\nDisallow: /\n";
198
+ return `User-agent: *
199
+ Allow: /
200
+
201
+ Sitemap: ${origin.replace(/\/+$/, "")}/sitemap.xml
202
+ `;
203
+ }
204
+ async function sitemapResponse(client, url, options = {}) {
205
+ try {
206
+ const entries = await client.getSitemap();
207
+ return new Response(sitemapXml(entries, options.origin ?? publicOrigin(url)), {
208
+ headers: { "Content-Type": "application/xml; charset=utf-8", "Cache-Control": "public, max-age=300" }
209
+ });
210
+ } catch (error) {
211
+ if (!(error instanceof AceUnavailableError)) throw error;
212
+ console.error(error.message);
213
+ return new Response("The sitemap is unavailable right now.", {
214
+ status: 503,
215
+ headers: { "Content-Type": "text/plain; charset=utf-8", "Retry-After": "300" }
216
+ });
217
+ }
218
+ }
219
+ function robotsResponse(url, options = {}) {
220
+ return new Response(robotsTxt({ origin: options.origin ?? publicOrigin(url), disallowAll: options.disallowAll }), {
221
+ headers: { "Content-Type": "text/plain; charset=utf-8", "Cache-Control": "public, max-age=3600" }
222
+ });
223
+ }
150
224
 
151
- export { AcePreviewExpiredError, AceUnavailableError, createAceClient, isCurrentPage, linksFromMenu, loadMenuLinks, loadPage };
225
+ export { AcePreviewExpiredError, AceUnavailableError, createAceClient, isCurrentPage, linksFromMenu, loadMenuLinks, loadPage, publicOrigin, robotsResponse, robotsTxt, sitemapResponse, sitemapXml };
package/dist/cli.js CHANGED
@@ -9,8 +9,8 @@ if (major < 22 || major === 22 && minor < 12) {
9
9
  process.exit(1);
10
10
  }
11
11
  try {
12
- const { createRealEnvironment } = await import("./environment-M3B4UZ5T.js");
13
- const { runCli } = await import("./main-6O2IIVHO.js");
12
+ const { createRealEnvironment } = await import("./environment-4FBNWIHO.js");
13
+ const { runCli } = await import("./main-4QMQF3L3.js");
14
14
  const environment = await createRealEnvironment();
15
15
  process.exit(await runCli(process.argv.slice(2), environment));
16
16
  } catch (error) {
@@ -145,7 +145,7 @@ function createLastApiUrl(path) {
145
145
  }
146
146
 
147
147
  // src/cli/version.ts
148
- var aceKitVersion = true ? "0.10.0" : "development";
148
+ var aceKitVersion = true ? "0.12.0" : "development";
149
149
 
150
150
  // src/cli/environment.ts
151
151
  var runCommand = (command, commandArguments, input) => new Promise((resolve, reject) => {
package/dist/index.d.ts CHANGED
@@ -84,6 +84,16 @@ type AceNavigationLink = {
84
84
  children?: AceNavigationLink[];
85
85
  };
86
86
 
87
+ /** A redirect the CMS has for an address, as its public API returns it. */
88
+ type AceRedirect = {
89
+ /** The old address the redirect is for. */
90
+ from: string;
91
+ /** Where it goes: a path on the site, or a full web address. */
92
+ to: string;
93
+ /** 301 is permanent; 302 is temporary. */
94
+ status: 301 | 302;
95
+ };
96
+
87
97
  /** An image as the CMS Ace public API returns it. Use `src` and `srcset` in an `<img>`. */
88
98
  type AceImageData = {
89
99
  id: number;
@@ -123,6 +133,14 @@ type AceSite = {
123
133
  favicon: AceFavicon | null;
124
134
  };
125
135
 
136
+ /** One page of the site as the CMS lists it for the sitemap. */
137
+ type AceSitemapEntry = {
138
+ /** The address visitors use: "/" for the home entry, otherwise "/{content type}/{entry}". */
139
+ path: string;
140
+ /** When the entry was last changed, as an ISO date and time. */
141
+ updated_at: string;
142
+ };
143
+
126
144
  /**
127
145
  * A client for the CMS Ace public API. It runs on the server only, so the visitor's browser never talks to the
128
146
  * API. The API finds the site from X-Ace-Site-Domain, which this client sets to the site's domain. X-Forwarded-Host
@@ -143,6 +161,13 @@ declare function createAceClient({ apiUrl, siteDomain, cacheSeconds, fetchImplem
143
161
  }): Promise<AceEntry<AceValuesOf<Handle>>[]>;
144
162
  /** One of the site's navigation menus by its handle, with every link worked out; null if the site has none. */
145
163
  getMenu(handle: string): Promise<AceMenu | null>;
164
+ /**
165
+ * Where an address the site has no page for should redirect to, if the site's owners set that up; null if not.
166
+ * `address` is the path with its query string, e.g. "/old-page?id=7".
167
+ */
168
+ getRedirect(address: string): Promise<AceRedirect | null>;
169
+ /** The site's live pages for its sitemap, by path. Pages of content types that have no pages of their own are left out. */
170
+ getSitemap(): Promise<AceSitemapEntry[]>;
146
171
  /** The site itself: its name, and the social image its owners chose (null if none). */
147
172
  getSite(): Promise<AceSite | null>;
148
173
  /** What the site's content types and fields are, which is what type generation reads. */
@@ -415,7 +440,8 @@ type AcePage<Values = Record<string, unknown>> =
415
440
  * - `slug` is the part of the path after the site's root, e.g. "pages/about" ("" or undefined for the home page).
416
441
  * - `url` is the full request address; it carries the preview token the CMS puts in its preview frame.
417
442
  *
418
- * Addresses are `/{content type}/{entry}` as on Shopify, and `/` is the home entry. A page shown with a preview
443
+ * Addresses are `/{content type}/{entry}` as on Shopify, and `/` is the home entry. An address with no page is
444
+ * redirected if the site's owners set up a redirect for it (in the CMS), and otherwise not found. A page shown with a preview
419
445
  * token is never cached or indexed (the answer's `headers` say so).
420
446
  */
421
447
  declare function loadPage<Values = Record<string, unknown>>(client: AceClient, { slug, url }: {
@@ -535,6 +561,35 @@ type AceSeoProperties = {
535
561
  */
536
562
  declare function seoProperties(options: AceSeoOptions): AceSeoProperties;
537
563
 
564
+ /**
565
+ * The address a visitor reached the site at, for the full addresses in a sitemap. A site on a real domain is served
566
+ * over https, but behind a proxy the server often sees plain http, so anything that isn't `localhost` or an IP address
567
+ * is given https. Pass `origin` to the response helpers to choose it yourself.
568
+ */
569
+ declare function publicOrigin(url: URL): string;
570
+ /** The sitemap file for a site: one `<url>` per page, with when it last changed. `origin` is e.g. `https://example.com`. */
571
+ declare function sitemapXml(entries: AceSitemapEntry[], origin: string): string;
572
+ type RobotsOptions = {
573
+ /** Asks every crawler to stay away, for a site that isn't live yet. */
574
+ disallowAll?: boolean;
575
+ /** The site's address, e.g. `https://example.com`. Left out, the address the request came to is used. */
576
+ origin?: string;
577
+ };
578
+ /** The `robots.txt` for a site: everything may be crawled, and the sitemap is pointed at. */
579
+ declare function robotsTxt({ origin, disallowAll }: {
580
+ origin: string;
581
+ disallowAll?: boolean;
582
+ }): string;
583
+ /**
584
+ * The response for `/sitemap.xml`: the site's live pages from the CMS. If the CMS can't be reached it answers 503, so a
585
+ * crawler tries again later instead of reading an empty sitemap.
586
+ */
587
+ declare function sitemapResponse(client: AceClient, url: URL, options?: {
588
+ origin?: string;
589
+ }): Promise<Response>;
590
+ /** The response for `/robots.txt`. */
591
+ declare function robotsResponse(url: URL, options?: RobotsOptions): Response;
592
+
538
593
  /**
539
594
  * A text area field's text as HTML. With `multiline` (the default) blank lines start new paragraphs (`<p>`) and a
540
595
  * single new line is a line break; without it the text is one line, with no element around it. Every piece of text
@@ -576,4 +631,4 @@ type AceSection = {
576
631
  blocks: AceSectionBlock[];
577
632
  };
578
633
 
579
- export { type AceAddress, type AceClient, type AceClientOptions, type AceContentTypeHandle, type AceContentTypeSchema, type AceContentTypes, type AceDateFormat, type AceEntry, type AceFavicon, 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, type AceSeoOptions, type AceSeoProperties, type AceSite, 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, seoDescriptionHandle, seoProperties, seoTitleHandle, swapPageContent, visitablePath };
634
+ export { type AceAddress, type AceClient, type AceClientOptions, type AceContentTypeHandle, type AceContentTypeSchema, type AceContentTypes, type AceDateFormat, type AceEntry, type AceFavicon, type AceFieldSchema, type AceImageAttributes, type AceImageData, type AceImageOptions, type AceLinkAttributes, type AceLinkData, type AceMenu, type AceMenuItem, type AceNavigationLink, type AcePage, AcePreviewExpiredError, type AceRedirect, type AceRichTextDocument, type AceRichTextMark, type AceRichTextNode, type AceSection, type AceSectionBlock, type AceSeoOptions, type AceSeoProperties, type AceSite, type AceSitemapEntry, AceUnavailableError, type AceValuesOf, type BlockDefinition, type RobotsOptions, 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, publicOrigin, refreshMessageType, refreshPageContent, renderRichText, renderTextArea, richTextImageSizes, robotsResponse, robotsTxt, sectionTypesPayload, seoDescriptionHandle, seoProperties, seoTitleHandle, sitemapResponse, sitemapXml, swapPageContent, visitablePath };
package/dist/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  export { pageNotices } from './chunk-XPZY5R5A.js';
2
2
  export { headingLevel, imageAttributes, isInternalPath, linkAddress, linkAttributes, listStart, renderRichText, renderTextArea, richTextImageSizes } from './chunk-FVR55GIU.js';
3
3
  export { listenForPreviewRefresh, refreshPageContent } from './chunk-YN7LJWGI.js';
4
- export { AcePreviewExpiredError, AceUnavailableError, createAceClient, isCurrentPage, linksFromMenu, loadMenuLinks, loadPage } from './chunk-EKZRLWNO.js';
4
+ export { AcePreviewExpiredError, AceUnavailableError, createAceClient, isCurrentPage, linksFromMenu, loadMenuLinks, loadPage, publicOrigin, robotsResponse, robotsTxt, sitemapResponse, sitemapXml } from './chunk-AJLQFATM.js';
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
 
@@ -1367,6 +1367,19 @@ function createAceClient({
1367
1367
  getMenu(handle) {
1368
1368
  return get(`/public/menus/${encodeURIComponent(handle)}`);
1369
1369
  },
1370
+ /**
1371
+ * Where an address the site has no page for should redirect to, if the site's owners set that up; null if not.
1372
+ * `address` is the path with its query string, e.g. "/old-page?id=7".
1373
+ */
1374
+ async getRedirect(address) {
1375
+ const redirect = await get(`/public/redirects?from=${encodeURIComponent(address)}`);
1376
+ const goesSomewhereSafe = redirect !== null && typeof redirect.to === "string" && (redirect.to.startsWith("/") && !redirect.to.startsWith("//") && !redirect.to.includes("\\") || /^https?:\/\/[^\s]+$/i.test(redirect.to)) && (redirect.status === 301 || redirect.status === 302);
1377
+ return goesSomewhereSafe ? redirect : null;
1378
+ },
1379
+ /** The site's live pages for its sitemap, by path. Pages of content types that have no pages of their own are left out. */
1380
+ async getSitemap() {
1381
+ return await get("/public/sitemap") ?? [];
1382
+ },
1370
1383
  /** The site itself: its name, and the social image its owners chose (null if none). */
1371
1384
  getSite() {
1372
1385
  return get("/public/site");
@@ -126,6 +126,24 @@ type AceContentTypeSchema = {
126
126
  fields: AceFieldSchema[];
127
127
  };
128
128
 
129
+ /** A redirect the CMS has for an address, as its public API returns it. */
130
+ type AceRedirect = {
131
+ /** The old address the redirect is for. */
132
+ from: string;
133
+ /** Where it goes: a path on the site, or a full web address. */
134
+ to: string;
135
+ /** 301 is permanent; 302 is temporary. */
136
+ status: 301 | 302;
137
+ };
138
+
139
+ /** One page of the site as the CMS lists it for the sitemap. */
140
+ type AceSitemapEntry = {
141
+ /** The address visitors use: "/" for the home entry, otherwise "/{content type}/{entry}". */
142
+ path: string;
143
+ /** When the entry was last changed, as an ISO date and time. */
144
+ updated_at: string;
145
+ };
146
+
129
147
  /**
130
148
  * A client for the CMS Ace public API. It runs on the server only, so the visitor's browser never talks to the
131
149
  * API. The API finds the site from X-Ace-Site-Domain, which this client sets to the site's domain. X-Forwarded-Host
@@ -146,6 +164,13 @@ declare function createAceClient({ apiUrl, siteDomain, cacheSeconds, fetchImplem
146
164
  }): Promise<AceEntry<AceValuesOf<Handle>>[]>;
147
165
  /** One of the site's navigation menus by its handle, with every link worked out; null if the site has none. */
148
166
  getMenu(handle: string): Promise<AceMenu | null>;
167
+ /**
168
+ * Where an address the site has no page for should redirect to, if the site's owners set that up; null if not.
169
+ * `address` is the path with its query string, e.g. "/old-page?id=7".
170
+ */
171
+ getRedirect(address: string): Promise<AceRedirect | null>;
172
+ /** The site's live pages for its sitemap, by path. Pages of content types that have no pages of their own are left out. */
173
+ getSitemap(): Promise<AceSitemapEntry[]>;
149
174
  /** The site itself: its name, and the social image its owners chose (null if none). */
150
175
  getSite(): Promise<AceSite | null>;
151
176
  /** What the site's content types and fields are, which is what type generation reads. */
@@ -154,6 +179,13 @@ declare function createAceClient({ apiUrl, siteDomain, cacheSeconds, fetchImplem
154
179
  /** The client `createAceClient` returns. */
155
180
  type AceClient = ReturnType<typeof createAceClient>;
156
181
 
182
+ type RobotsOptions = {
183
+ /** Asks every crawler to stay away, for a site that isn't live yet. */
184
+ disallowAll?: boolean;
185
+ /** The site's address, e.g. `https://example.com`. Left out, the address the request came to is used. */
186
+ origin?: string;
187
+ };
188
+
157
189
  /** What the root loader (`createRootLoader`) puts in the root route's data, for `useAceMenu` and `useAcePreview`. */
158
190
  type AceRootData = {
159
191
  ace: {
@@ -214,6 +246,31 @@ declare function createPageLoader<Values = Record<string, unknown>>(): ({ reques
214
246
  entry: AceEntry<Values>;
215
247
  headers: Record<string, string>;
216
248
  }>>;
249
+ /**
250
+ * The loader for `/sitemap.xml`: the site's live pages (of the content types that have pages), with when each last
251
+ * changed. Add a route `route("sitemap.xml", "routes/sitemap.tsx")` (before the catch-all) whose file is
252
+ *
253
+ * export { sitemapLoader as loader } from "@sandboxsonny/ace-kit/react-router/server";
254
+ *
255
+ * It uses https unless the site is on localhost; for another address use `createSitemapLoader({ origin })`.
256
+ */
257
+ declare const createSitemapLoader: (options?: {
258
+ origin?: string;
259
+ }) => ({ request }: {
260
+ request: Request;
261
+ }) => Promise<Response>;
262
+ /** `createSitemapLoader()` with the defaults. */
263
+ declare const sitemapLoader: ({ request }: {
264
+ request: Request;
265
+ }) => Promise<Response>;
266
+ /** The loader for `/robots.txt`, which lets crawlers in and points them at the sitemap. See `createSitemapLoader`. */
267
+ declare const createRobotsLoader: (options?: RobotsOptions) => ({ request }: {
268
+ request: Request;
269
+ }) => Response;
270
+ /** `createRobotsLoader()` with the defaults. */
271
+ declare const robotsLoader: ({ request }: {
272
+ request: Request;
273
+ }) => Response;
217
274
  /** `createPageLoader()` for a site that has not generated types for its content. */
218
275
  declare const loader: ({ request }: {
219
276
  request: Request;
@@ -266,4 +323,4 @@ declare const streamTimeout = 5000;
266
323
  */
267
324
  declare function handleRequest(request: Request, responseStatusCode: number, responseHeaders: Headers, routerContext: EntryContext, _loadContext: AppLoadContext): Promise<Response>;
268
325
 
269
- export { createPageLoader, createRootLoader, getAceClient, getEntries, getEntry, getMenu, getMenuLinks, getSite, handleRequest, headers, loader, streamTimeout };
326
+ export { createPageLoader, createRobotsLoader, createRootLoader, createSitemapLoader, getAceClient, getEntries, getEntry, getMenu, getMenuLinks, getSite, handleRequest, headers, loader, robotsLoader, sitemapLoader, streamTimeout };
@@ -1,4 +1,4 @@
1
- import { createAceClient, loadMenuLinks, loadPage } from './chunk-EKZRLWNO.js';
1
+ import { createAceClient, loadMenuLinks, loadPage, sitemapResponse, robotsResponse } from './chunk-AJLQFATM.js';
2
2
  import './chunk-M5WR5TCR.js';
3
3
  import { hostedAdminOrigin, hostedApiUrl } from './chunk-N5DIMG3X.js';
4
4
  import { previewTokenFrom, addFramingPolicy } from './chunk-YLO6IAXF.js';
@@ -43,6 +43,14 @@ function createPageLoader() {
43
43
  return data(page, { status: page.status, headers: page.headers });
44
44
  };
45
45
  }
46
+ var createSitemapLoader = (options = {}) => function loader2({ request }) {
47
+ return sitemapResponse(getAceClient(), new URL(request.url), options);
48
+ };
49
+ var sitemapLoader = createSitemapLoader();
50
+ var createRobotsLoader = (options = {}) => function loader2({ request }) {
51
+ return robotsResponse(new URL(request.url), options);
52
+ };
53
+ var robotsLoader = createRobotsLoader();
46
54
  var loader = createPageLoader();
47
55
  var headers = ({ loaderHeaders }) => loaderHeaders;
48
56
  function createRootLoader({ menu, fallback = [] }) {
@@ -88,4 +96,4 @@ function handleRequest(request, responseStatusCode, responseHeaders, routerConte
88
96
  });
89
97
  }
90
98
 
91
- export { createPageLoader, createRootLoader, getAceClient, getEntries, getEntry, getMenu, getMenuLinks, getSite, handleRequest, headers, loader, streamTimeout };
99
+ export { createPageLoader, createRobotsLoader, createRootLoader, createSitemapLoader, getAceClient, getEntries, getEntry, getMenu, getMenuLinks, getSite, handleRequest, headers, loader, robotsLoader, sitemapLoader, streamTimeout };
@@ -86,13 +86,14 @@ Types: `AceSection`, `AceSectionBlock`, `SectionDefinition`, `SectionFieldDefini
86
86
 
87
87
  `@sandboxsonny/ace-kit/astro` (uses the integration's settings, so there is no client to create):
88
88
 
89
- | | |
90
- | ----------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
91
- | `getPage(Astro)` | What to show for the page being requested; also sets the response's status and headers. A `redirect` is returned for you to act on with `Astro.redirect`. |
92
- | `getEntry(contentType, handle)`, `getEntries(contentType, { limit? })`, `getMenu(handle)` | As on the client. |
93
- | `getMenuLinks(handle, { fallback? })` | A menu's links, or the fallback if the menu is missing or the CMS is down. |
94
- | `applyPage(response, page)` | Puts a page's status and headers on a response. |
95
- | `ace` | The client these use. |
89
+ | | |
90
+ | --------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
91
+ | `getPage(Astro)` | What to show for the page being requested; also sets the response's status and headers. A `redirect` is returned for you to act on with `Astro.redirect`. |
92
+ | `getEntry(contentType, handle)`, `getEntries(contentType, { limit? })`, `getMenu(handle)` | As on the client. |
93
+ | `getSitemapResponse({ url }, { origin? })`, `getRobotsResponse({ url }, { origin?, disallowAll? })` | The responses for `/sitemap.xml` and `/robots.txt` (see [Astro](./astro.md)). |
94
+ | `getMenuLinks(handle, { fallback? })` | A menu's links, or the fallback if the menu is missing or the CMS is down. |
95
+ | `applyPage(response, page)` | Puts a page's status and headers on a response. |
96
+ | `ace` | The client these use. |
96
97
 
97
98
  Components, by file: `AceImage.astro`, `AceLink.astro`, `AceSections.astro`, `AceRichText.astro`, `AceTextArea.astro`, `AceMenu.astro`, `AceNotice.astro`, `AcePreviewRefresh.astro`.
98
99
 
@@ -110,13 +111,14 @@ Components, by file: `AceImage.astro`, `AceLink.astro`, `AceSections.astro`, `Ac
110
111
 
111
112
  `@sandboxsonny/ace-kit/react-router/server` (server only):
112
113
 
113
- | | |
114
- | ------------------------------------------------------------------- | ---------------------------------------------------------------- |
115
- | `createPageLoader<Values>()`, `loader` | The loader for the catch-all route that draws every page. |
116
- | `headers` | The route's `headers` export. |
117
- | `createRootLoader({ menu, fallback? })` | The root loader: the menu's links and whether this is a preview. |
118
- | `handleRequest`, `streamTimeout` | The server entry. |
119
- | `getEntry`, `getEntries`, `getMenu`, `getMenuLinks`, `getAceClient` | Read content from your own loaders. |
114
+ | | |
115
+ | ---------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- |
116
+ | `createPageLoader<Values>()`, `loader` | The loader for the catch-all route that draws every page. |
117
+ | `headers` | The route's `headers` export. |
118
+ | `createRootLoader({ menu, fallback? })` | The root loader: the menu's links and whether this is a preview. |
119
+ | `handleRequest`, `streamTimeout` | The server entry. |
120
+ | `sitemapLoader`, `robotsLoader`, `createSitemapLoader(options)`, `createRobotsLoader(options)` | The loaders for `/sitemap.xml` and `/robots.txt` (see [React Router](./react-router.md)). |
121
+ | `getEntry`, `getEntries`, `getMenu`, `getMenuLinks`, `getAceClient` | Read content from your own loaders. |
120
122
 
121
123
  ## React (`@sandboxsonny/ace-kit/react`)
122
124
 
package/docs/astro.md CHANGED
@@ -46,7 +46,25 @@ if (page.kind === "redirect") return Astro.redirect(page.location, page.status);
46
46
  {page.kind === "entry" ? <h1>{page.entry.title}</h1> : <AceNotice page={page} />}
47
47
  ```
48
48
 
49
- Addresses are `/{content type}/{entry}` as on Shopify, `/` is the `home` entry of `pages`, and `/pages/home` redirects to `/`. `page.kind` is `entry`, `redirect`, `not-found`, `unavailable` or `preview-expired`. `AceNotice` draws a heading and a message for the last three; draw your own if you prefer.
49
+ Addresses are `/{content type}/{entry}` as on Shopify, `/` is the `home` entry of `pages`, and `/pages/home` redirects to `/`. An address with no page is redirected too if the site's owners set up a redirect for it in the CMS (Redirects), so old addresses from a site you moved keep working. `page.kind` is `entry`, `redirect`, `not-found`, `unavailable` or `preview-expired`. `AceNotice` draws a heading and a message for the last three; draw your own if you prefer.
50
+
51
+ ## Sitemap and robots.txt
52
+
53
+ Search engines find a site's pages through `/sitemap.xml` and are pointed at it by `/robots.txt`. Both are one small file each; the sitemap lists the live pages from the CMS (only the content types that have "Entries have their own pages" ticked), with when each last changed.
54
+
55
+ ```ts
56
+ // src/pages/sitemap.xml.ts
57
+ import { getSitemapResponse } from "@sandboxsonny/ace-kit/astro";
58
+ export const GET = ({ url }) => getSitemapResponse({ url });
59
+ ```
60
+
61
+ ```ts
62
+ // src/pages/robots.txt.ts
63
+ import { getRobotsResponse } from "@sandboxsonny/ace-kit/astro";
64
+ export const GET = ({ url }) => getRobotsResponse({ url });
65
+ ```
66
+
67
+ Addresses in the sitemap use `https` unless the site is on `localhost`; pass `{ origin: "https://www.example.com" }` to either to choose. `getRobotsResponse({ url }, { disallowAll: true })` asks crawlers to stay away, for a site that isn't live yet. Delete any `robots.txt` in `public/`, which would take its place.
50
68
 
51
69
  ## Reading content
52
70
 
@@ -69,7 +69,7 @@ export default function Page({ loaderData: page }: Route.ComponentProps) {
69
69
  }
70
70
  ```
71
71
 
72
- The loader reads the address (`/{content type}/{entry}`, and `/` is the `home` entry of `pages`), loads the entry and answers with the right status and headers: a missing page is a real 404, an unreachable CMS a 503, an expired preview a 403, `/pages/home` redirects to `/`, and a preview is never cached or indexed. `page.kind` is `entry`, `not-found`, `unavailable` or `preview-expired`. `createMeta` titles the page from the entry (followed by the site's name), adds its description, and asks search engines to ignore a preview. With [typed content](./typed-content.md), `createPageLoader<PageValues>()` gives `page.entry.values` its real shape.
72
+ The loader reads the address (`/{content type}/{entry}`, and `/` is the `home` entry of `pages`), loads the entry and answers with the right status and headers: a missing page is a real 404, an unreachable CMS a 503, an expired preview a 403, `/pages/home` redirects to `/`, an address with no page follows the redirect the site's owners set up in the CMS (if any), and a preview is never cached or indexed. `page.kind` is `entry`, `not-found`, `unavailable` or `preview-expired`. `createMeta` titles the page from the entry (followed by the site's name), adds its description, and asks search engines to ignore a preview. With [typed content](./typed-content.md), `createPageLoader<PageValues>()` gives `page.entry.values` its real shape.
73
73
 
74
74
  **4. The root route** loads the header menu and keeps previews fresh:
75
75
 
@@ -102,6 +102,22 @@ From `@sandboxsonny/ace-kit/react-router` (these are safe in the browser):
102
102
  - **AceImage** is an `<img>` with `src`, `srcset`, `sizes`, size and alt text. Set `sizes` to how wide it is shown, and `lazy={false}` for the image at the top of the page.
103
103
  - **AceNotice** is the heading and message for a page that isn't an entry. Give it a `className`.
104
104
 
105
+ ## Sitemap and robots.txt
106
+
107
+ Search engines find a site's pages through `/sitemap.xml` and are pointed at it by `/robots.txt`. Add two routes before the catch-all in `app/routes.ts`: `route("sitemap.xml", "routes/sitemap.tsx")` and `route("robots.txt", "routes/robots.tsx")`, whose files are one line each:
108
+
109
+ ```ts
110
+ // app/routes/sitemap.tsx
111
+ export { sitemapLoader as loader } from "@sandboxsonny/ace-kit/react-router/server";
112
+ ```
113
+
114
+ ```ts
115
+ // app/routes/robots.tsx
116
+ export { robotsLoader as loader } from "@sandboxsonny/ace-kit/react-router/server";
117
+ ```
118
+
119
+ The sitemap lists the live pages from the CMS (only the content types that have "Entries have their own pages" ticked), with when each last changed. Addresses use `https` unless the site is on `localhost`. For another address, or to keep crawlers away from a site that isn't live yet, use `createSitemapLoader({ origin })` and `createRobotsLoader({ origin, disallowAll })`.
120
+
105
121
  ## Reading content in your own loaders
106
122
 
107
123
  From `@sandboxsonny/ace-kit/react-router/server`, with no client to create:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sandboxsonny/ace-kit",
3
- "version": "0.10.0",
3
+ "version": "0.12.0",
4
4
  "description": "Everything a website needs to use CMS Ace: a typed client, addressing, images, menus, rich text, live preview, and Astro and React adapters.",
5
5
  "keywords": [
6
6
  "cms",