@stack-mode/site-manager-kit 1.44.0 → 1.44.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/README.md +606 -606
  2. package/audit/ts-hooks.mjs +68 -68
  3. package/audit/wiring-audit.d.mts +37 -37
  4. package/audit/wiring-audit.mjs +604 -604
  5. package/bin/site-manager-kit.mjs +10 -10
  6. package/package.json +72 -72
  7. package/src/announcements.ts +13 -13
  8. package/src/attribution-fields.ts +52 -52
  9. package/src/attribution.tsx +47 -47
  10. package/src/blog.ts +122 -122
  11. package/src/chat-route.ts +136 -136
  12. package/src/chat-types.ts +76 -76
  13. package/src/client.ts +116 -116
  14. package/src/config.ts +78 -78
  15. package/src/copy-keys-route.ts +21 -21
  16. package/src/copy-keys.ts +77 -77
  17. package/src/data.ts +189 -189
  18. package/src/downloads.ts +7 -7
  19. package/src/faq.ts +21 -21
  20. package/src/floor-plans.ts +45 -45
  21. package/src/form-guard.tsx +37 -37
  22. package/src/form-types.ts +13 -13
  23. package/src/forms.ts +76 -76
  24. package/src/glossary.ts +19 -19
  25. package/src/health-route.ts +34 -34
  26. package/src/index.ts +36 -36
  27. package/src/indexnow.ts +40 -40
  28. package/src/listings.ts +38 -38
  29. package/src/metadata.ts +55 -55
  30. package/src/not-found-reporter.tsx +16 -16
  31. package/src/not-found.ts +53 -53
  32. package/src/payment.ts +70 -70
  33. package/src/phone-input.tsx +26 -26
  34. package/src/phone.ts +37 -37
  35. package/src/preview-route.ts +48 -48
  36. package/src/preview.ts +94 -94
  37. package/src/residents.ts +23 -23
  38. package/src/revalidate-route.ts +34 -34
  39. package/src/rss-route.ts +26 -26
  40. package/src/services.ts +52 -52
  41. package/src/site-chat-panel.tsx +537 -537
  42. package/src/site-chat.tsx +26 -26
  43. package/src/sitemap.ts +26 -26
  44. package/src/structured-data.tsx +207 -207
  45. package/src/types.ts +580 -580
  46. package/src/version.ts +1 -1
  47. package/src/visual-accents.ts +126 -126
  48. package/src/visual-bridge-client.tsx +1070 -1070
  49. package/src/visual-bridge.tsx +30 -30
  50. package/src/visual-clicks.ts +13 -13
  51. package/src/visual-lists.ts +37 -37
  52. package/src/visual-match.ts +77 -77
  53. package/src/visual-messages.ts +160 -160
  54. package/src/visual-mode.ts +18 -18
  55. package/src/visual-preview.ts +69 -69
  56. package/src/visual-tags.ts +90 -90
  57. package/src/visual.ts +59 -59
  58. package/src/waitlist-form.ts +39 -39
  59. package/src/waitlist.ts +48 -48
@@ -1,48 +1,48 @@
1
- import { cookies, draftMode } from "next/headers";
2
- import { siteId } from "./client";
3
- import { safePath, signVisualCookie, verifyPreviewToken, VISUAL_COOKIE } from "./preview";
4
-
5
- /**
6
- * Opens a preview of unpublished changes from Site Manager. Put in app/api/blueprint/preview/route.ts:
7
- *
8
- * export { GET } from "site-manager-kit/preview-route";
9
- *
10
- * `?token=` is a signed link from Site Manager (it lasts an hour). It turns on Next.js draft mode for this browser
11
- * and opens the page; while it's on, every Site Manager read skips the cache and includes drafts and scheduled
12
- * changes. `?exit=1&path=/` turns it off again. Anything else gets a 401 and changes nothing.
13
- *
14
- * A Visual Editor link also names the Site Manager address it was made on. It sets the visual cookie, which lets
15
- * that address (and only that one) show this site in a frame while the preview lasts.
16
- */
17
- export async function GET(req: Request): Promise<Response> {
18
- const url = new URL(req.url);
19
- const draft = await draftMode();
20
- if (url.searchParams.get("exit") === "1") {
21
- draft.disable();
22
- (await cookies()).set({ name: VISUAL_COOKIE, value: "", ...frameCookie(url), maxAge: 0 });
23
- return redirect(url, safePath(url.searchParams.get("path")) ?? "/");
24
- }
25
- const secret = process.env.BLUEPRINT_REVALIDATE_SECRET;
26
- const ok = verifyPreviewToken(secret, url.searchParams.get("token"), siteId());
27
- if (!ok) return Response.json({ error: "invalid_preview_link" }, { status: 401, headers: { "cache-control": "no-store" } });
28
- draft.enable();
29
- if (!ok.adminOrigin || !secret) return redirect(url, ok.path);
30
- // Inside a frame on another site, cookies only stick when they are SameSite=None, Secure and, on browsers that
31
- // split third-party cookies by top site, Partitioned. Next sets the draft cookie without Partitioned, so set it again.
32
- const jar = await cookies();
33
- const bypass = jar.get("__prerender_bypass")?.value;
34
- if (bypass) jar.set({ name: "__prerender_bypass", value: bypass, httpOnly: true, ...frameCookie(url) });
35
- jar.set({ name: VISUAL_COOKIE, value: signVisualCookie(secret, siteId(), ok.adminOrigin), httpOnly: true, maxAge: 3600, ...frameCookie(url) });
36
- return redirect(url, ok.path, { "content-security-policy": `frame-ancestors 'self' ${ok.adminOrigin}` });
37
- }
38
-
39
- // Browsers accept Secure cookies from http://localhost, so local work behaves like the live site.
40
- function frameCookie(url: URL) {
41
- const secure = url.protocol === "https:" || url.hostname === "localhost" || url.hostname.endsWith(".localhost");
42
- return { path: "/", secure, sameSite: secure ? ("none" as const) : ("lax" as const), partitioned: secure };
43
- }
44
-
45
- // Relative to this site, so a preview can never send anyone somewhere else.
46
- function redirect(from: URL, path: string, headers: Record<string, string> = {}) {
47
- return new Response(null, { status: 307, headers: { location: new URL(path, from.origin).toString(), "cache-control": "no-store", ...headers } });
48
- }
1
+ import { cookies, draftMode } from "next/headers";
2
+ import { siteId } from "./client";
3
+ import { safePath, signVisualCookie, verifyPreviewToken, VISUAL_COOKIE } from "./preview";
4
+
5
+ /**
6
+ * Opens a preview of unpublished changes from Site Manager. Put in app/api/blueprint/preview/route.ts:
7
+ *
8
+ * export { GET } from "site-manager-kit/preview-route";
9
+ *
10
+ * `?token=` is a signed link from Site Manager (it lasts an hour). It turns on Next.js draft mode for this browser
11
+ * and opens the page; while it's on, every Site Manager read skips the cache and includes drafts and scheduled
12
+ * changes. `?exit=1&path=/` turns it off again. Anything else gets a 401 and changes nothing.
13
+ *
14
+ * A Visual Editor link also names the Site Manager address it was made on. It sets the visual cookie, which lets
15
+ * that address (and only that one) show this site in a frame while the preview lasts.
16
+ */
17
+ export async function GET(req: Request): Promise<Response> {
18
+ const url = new URL(req.url);
19
+ const draft = await draftMode();
20
+ if (url.searchParams.get("exit") === "1") {
21
+ draft.disable();
22
+ (await cookies()).set({ name: VISUAL_COOKIE, value: "", ...frameCookie(url), maxAge: 0 });
23
+ return redirect(url, safePath(url.searchParams.get("path")) ?? "/");
24
+ }
25
+ const secret = process.env.BLUEPRINT_REVALIDATE_SECRET;
26
+ const ok = verifyPreviewToken(secret, url.searchParams.get("token"), siteId());
27
+ if (!ok) return Response.json({ error: "invalid_preview_link" }, { status: 401, headers: { "cache-control": "no-store" } });
28
+ draft.enable();
29
+ if (!ok.adminOrigin || !secret) return redirect(url, ok.path);
30
+ // Inside a frame on another site, cookies only stick when they are SameSite=None, Secure and, on browsers that
31
+ // split third-party cookies by top site, Partitioned. Next sets the draft cookie without Partitioned, so set it again.
32
+ const jar = await cookies();
33
+ const bypass = jar.get("__prerender_bypass")?.value;
34
+ if (bypass) jar.set({ name: "__prerender_bypass", value: bypass, httpOnly: true, ...frameCookie(url) });
35
+ jar.set({ name: VISUAL_COOKIE, value: signVisualCookie(secret, siteId(), ok.adminOrigin), httpOnly: true, maxAge: 3600, ...frameCookie(url) });
36
+ return redirect(url, ok.path, { "content-security-policy": `frame-ancestors 'self' ${ok.adminOrigin}` });
37
+ }
38
+
39
+ // Browsers accept Secure cookies from http://localhost, so local work behaves like the live site.
40
+ function frameCookie(url: URL) {
41
+ const secure = url.protocol === "https:" || url.hostname === "localhost" || url.hostname.endsWith(".localhost");
42
+ return { path: "/", secure, sameSite: secure ? ("none" as const) : ("lax" as const), partitioned: secure };
43
+ }
44
+
45
+ // Relative to this site, so a preview can never send anyone somewhere else.
46
+ function redirect(from: URL, path: string, headers: Record<string, string> = {}) {
47
+ return new Response(null, { status: 307, headers: { location: new URL(path, from.origin).toString(), "cache-control": "no-store", ...headers } });
48
+ }
package/src/preview.ts CHANGED
@@ -1,94 +1,94 @@
1
- import { createHmac, timingSafeEqual } from "node:crypto";
2
-
3
- /*
4
- * Previews of unpublished changes, signed with BLUEPRINT_REVALIDATE_SECRET (the same formats Site Manager uses in
5
- * src/lib/changes/preview.ts):
6
- * link token <base64url JSON { s: siteId, p: path, e: unix expiry }>.<hex HMAC-SHA256 of "link.<payload>">
7
- * read header x-blueprint-preview: t=<unix>,v1=<hex HMAC-SHA256 of "<t>.preview">
8
- * A link for the Visual Editor adds v: 1 and o: <admin origin> to the link payload. Opening it sets the visual cookie
9
- * bp_visual <base64url JSON { s: siteId, o: admin origin, e: unix expiry }>.<hex HMAC-SHA256 of "visual.<payload>">
10
- * which lets that one admin origin show the site in a frame and talk to it.
11
- */
12
-
13
- export const PREVIEW_HEADER = "x-blueprint-preview";
14
- export const VISUAL_COOKIE = "bp_visual";
15
-
16
- const hmac = (secret: string, data: string) => createHmac("sha256", secret).update(data).digest("hex");
17
-
18
- function equal(a: string, b: string) {
19
- const x = Buffer.from(a);
20
- const y = Buffer.from(b);
21
- return x.length === y.length && timingSafeEqual(x, y);
22
- }
23
-
24
- /** A safe path on this site, or null. Never another address: no scheme, no `//host`, no backslashes. */
25
- export function safePath(p: unknown): string | null {
26
- if (typeof p !== "string" || !p.startsWith("/") || p.startsWith("//") || p.includes("\\") || p.length > 2000) return null;
27
- return p;
28
- }
29
-
30
- function readSigned(secret: string, value: string, purpose: string): Record<string, unknown> | null {
31
- const [payload, sig, extra] = value.split(".");
32
- if (!payload || !sig || extra !== undefined || !/^[0-9a-f]{64}$/.test(sig)) return null;
33
- if (!equal(hmac(secret, `${purpose}.${payload}`), sig)) return null;
34
- try {
35
- const data: unknown = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
36
- return data && typeof data === "object" && !Array.isArray(data) ? (data as Record<string, unknown>) : null;
37
- } catch {
38
- return null;
39
- }
40
- }
41
-
42
- const isLocalHost = (host: string) => host === "localhost" || host.endsWith(".localhost");
43
-
44
- /** An admin origin a visual link may name: https (http only for localhost and *.localhost), with nothing but scheme, host and port. */
45
- export function isAllowedAdminOrigin(o: unknown): o is string {
46
- if (typeof o !== "string" || o.length > 300) return false;
47
- let url: URL;
48
- try {
49
- url = new URL(o);
50
- } catch {
51
- return false;
52
- }
53
- if (url.origin !== o) return false;
54
- return url.protocol === "https:" || (url.protocol === "http:" && isLocalHost(url.hostname));
55
- }
56
-
57
- /**
58
- * A link from Site Manager for this site, or null. `adminOrigin` is set only for a Visual Editor link with an allowed
59
- * origin; a link naming a bad origin still opens as a plain preview.
60
- */
61
- export function verifyPreviewToken(
62
- secret: string | undefined,
63
- token: string | null,
64
- siteId: string,
65
- now = Math.floor(Date.now() / 1000),
66
- ): { path: string; adminOrigin: string | null } | null {
67
- if (!secret || !token) return null;
68
- const data = readSigned(secret, token, "link");
69
- if (!data || data.s !== siteId || typeof data.e !== "number" || data.e < now) return null;
70
- const path = safePath(data.p);
71
- if (!path) return null;
72
- return { path, adminOrigin: data.v === 1 && isAllowedAdminOrigin(data.o) ? data.o : null };
73
- }
74
-
75
- export function signVisualCookie(secret: string, siteId: string, adminOrigin: string, now = Math.floor(Date.now() / 1000)): string {
76
- const payload = Buffer.from(JSON.stringify({ s: siteId, o: adminOrigin, e: now + 3600 })).toString("base64url");
77
- return `${payload}.${hmac(secret, `visual.${payload}`)}`;
78
- }
79
-
80
- export function verifyVisualCookie(
81
- secret: string | undefined,
82
- value: string | undefined,
83
- siteId: string,
84
- now = Math.floor(Date.now() / 1000),
85
- ): { adminOrigin: string } | null {
86
- if (!secret || !value) return null;
87
- const data = readSigned(secret, value, "visual");
88
- if (!data || data.s !== siteId || typeof data.e !== "number" || data.e < now || !isAllowedAdminOrigin(data.o)) return null;
89
- return { adminOrigin: data.o };
90
- }
91
-
92
- export function signPreviewHeader(secret: string, now = Math.floor(Date.now() / 1000)): string {
93
- return `t=${now},v1=${hmac(secret, `${now}.preview`)}`;
94
- }
1
+ import { createHmac, timingSafeEqual } from "node:crypto";
2
+
3
+ /*
4
+ * Previews of unpublished changes, signed with BLUEPRINT_REVALIDATE_SECRET (the same formats Site Manager uses in
5
+ * src/lib/changes/preview.ts):
6
+ * link token <base64url JSON { s: siteId, p: path, e: unix expiry }>.<hex HMAC-SHA256 of "link.<payload>">
7
+ * read header x-blueprint-preview: t=<unix>,v1=<hex HMAC-SHA256 of "<t>.preview">
8
+ * A link for the Visual Editor adds v: 1 and o: <admin origin> to the link payload. Opening it sets the visual cookie
9
+ * bp_visual <base64url JSON { s: siteId, o: admin origin, e: unix expiry }>.<hex HMAC-SHA256 of "visual.<payload>">
10
+ * which lets that one admin origin show the site in a frame and talk to it.
11
+ */
12
+
13
+ export const PREVIEW_HEADER = "x-blueprint-preview";
14
+ export const VISUAL_COOKIE = "bp_visual";
15
+
16
+ const hmac = (secret: string, data: string) => createHmac("sha256", secret).update(data).digest("hex");
17
+
18
+ function equal(a: string, b: string) {
19
+ const x = Buffer.from(a);
20
+ const y = Buffer.from(b);
21
+ return x.length === y.length && timingSafeEqual(x, y);
22
+ }
23
+
24
+ /** A safe path on this site, or null. Never another address: no scheme, no `//host`, no backslashes. */
25
+ export function safePath(p: unknown): string | null {
26
+ if (typeof p !== "string" || !p.startsWith("/") || p.startsWith("//") || p.includes("\\") || p.length > 2000) return null;
27
+ return p;
28
+ }
29
+
30
+ function readSigned(secret: string, value: string, purpose: string): Record<string, unknown> | null {
31
+ const [payload, sig, extra] = value.split(".");
32
+ if (!payload || !sig || extra !== undefined || !/^[0-9a-f]{64}$/.test(sig)) return null;
33
+ if (!equal(hmac(secret, `${purpose}.${payload}`), sig)) return null;
34
+ try {
35
+ const data: unknown = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
36
+ return data && typeof data === "object" && !Array.isArray(data) ? (data as Record<string, unknown>) : null;
37
+ } catch {
38
+ return null;
39
+ }
40
+ }
41
+
42
+ const isLocalHost = (host: string) => host === "localhost" || host.endsWith(".localhost");
43
+
44
+ /** An admin origin a visual link may name: https (http only for localhost and *.localhost), with nothing but scheme, host and port. */
45
+ export function isAllowedAdminOrigin(o: unknown): o is string {
46
+ if (typeof o !== "string" || o.length > 300) return false;
47
+ let url: URL;
48
+ try {
49
+ url = new URL(o);
50
+ } catch {
51
+ return false;
52
+ }
53
+ if (url.origin !== o) return false;
54
+ return url.protocol === "https:" || (url.protocol === "http:" && isLocalHost(url.hostname));
55
+ }
56
+
57
+ /**
58
+ * A link from Site Manager for this site, or null. `adminOrigin` is set only for a Visual Editor link with an allowed
59
+ * origin; a link naming a bad origin still opens as a plain preview.
60
+ */
61
+ export function verifyPreviewToken(
62
+ secret: string | undefined,
63
+ token: string | null,
64
+ siteId: string,
65
+ now = Math.floor(Date.now() / 1000),
66
+ ): { path: string; adminOrigin: string | null } | null {
67
+ if (!secret || !token) return null;
68
+ const data = readSigned(secret, token, "link");
69
+ if (!data || data.s !== siteId || typeof data.e !== "number" || data.e < now) return null;
70
+ const path = safePath(data.p);
71
+ if (!path) return null;
72
+ return { path, adminOrigin: data.v === 1 && isAllowedAdminOrigin(data.o) ? data.o : null };
73
+ }
74
+
75
+ export function signVisualCookie(secret: string, siteId: string, adminOrigin: string, now = Math.floor(Date.now() / 1000)): string {
76
+ const payload = Buffer.from(JSON.stringify({ s: siteId, o: adminOrigin, e: now + 3600 })).toString("base64url");
77
+ return `${payload}.${hmac(secret, `visual.${payload}`)}`;
78
+ }
79
+
80
+ export function verifyVisualCookie(
81
+ secret: string | undefined,
82
+ value: string | undefined,
83
+ siteId: string,
84
+ now = Math.floor(Date.now() / 1000),
85
+ ): { adminOrigin: string } | null {
86
+ if (!secret || !value) return null;
87
+ const data = readSigned(secret, value, "visual");
88
+ if (!data || data.s !== siteId || typeof data.e !== "number" || data.e < now || !isAllowedAdminOrigin(data.o)) return null;
89
+ return { adminOrigin: data.o };
90
+ }
91
+
92
+ export function signPreviewHeader(secret: string, now = Math.floor(Date.now() / 1000)): string {
93
+ return `t=${now},v1=${hmac(secret, `${now}.preview`)}`;
94
+ }
package/src/residents.ts CHANGED
@@ -1,23 +1,23 @@
1
- import type { Pickup, Weekday } from "./types";
2
-
3
- // Plain-language text for resident and policy facts. Formatting only: the site's own code decides where it shows.
4
-
5
- const DAY_NAMES: Record<Weekday, string> = { mon: "Monday", tue: "Tuesday", wed: "Wednesday", thu: "Thursday", fri: "Friday", sat: "Saturday", sun: "Sunday" };
6
-
7
- const list = (parts: string[], joiner: string) => (parts.length <= 1 ? (parts[0] ?? "") : `${parts.slice(0, -1).join(", ")} ${joiner} ${parts[parts.length - 1]}`);
8
-
9
- /** "Monday and Thursday by 7 am". Empty when no days are set. */
10
- export function pickupText(p: Pick<Pickup, "days" | "time">): string {
11
- if (!p.days.length) return "";
12
- const days = p.days.length === 7 ? "Every day" : list(p.days.map((d) => DAY_NAMES[d]), "and");
13
- if (!p.time) return days;
14
- const [h, m] = p.time.split(":").map(Number);
15
- const hour = h % 12 === 0 ? 12 : h % 12;
16
- return `${days} by ${hour}${m ? `:${String(m).padStart(2, "0")}` : ""} ${h < 12 ? "am" : "pm"}`;
17
- }
18
-
19
- /** "6, 12 or 15 months". Empty when no lengths are set. */
20
- export function leaseTermsText(months: number[]): string {
21
- if (!months.length) return "";
22
- return `${list(months.map(String), "or")} month${months.length === 1 && months[0] === 1 ? "" : "s"}`;
23
- }
1
+ import type { Pickup, Weekday } from "./types";
2
+
3
+ // Plain-language text for resident and policy facts. Formatting only: the site's own code decides where it shows.
4
+
5
+ const DAY_NAMES: Record<Weekday, string> = { mon: "Monday", tue: "Tuesday", wed: "Wednesday", thu: "Thursday", fri: "Friday", sat: "Saturday", sun: "Sunday" };
6
+
7
+ const list = (parts: string[], joiner: string) => (parts.length <= 1 ? (parts[0] ?? "") : `${parts.slice(0, -1).join(", ")} ${joiner} ${parts[parts.length - 1]}`);
8
+
9
+ /** "Monday and Thursday by 7 am". Empty when no days are set. */
10
+ export function pickupText(p: Pick<Pickup, "days" | "time">): string {
11
+ if (!p.days.length) return "";
12
+ const days = p.days.length === 7 ? "Every day" : list(p.days.map((d) => DAY_NAMES[d]), "and");
13
+ if (!p.time) return days;
14
+ const [h, m] = p.time.split(":").map(Number);
15
+ const hour = h % 12 === 0 ? 12 : h % 12;
16
+ return `${days} by ${hour}${m ? `:${String(m).padStart(2, "0")}` : ""} ${h < 12 ? "am" : "pm"}`;
17
+ }
18
+
19
+ /** "6, 12 or 15 months". Empty when no lengths are set. */
20
+ export function leaseTermsText(months: number[]): string {
21
+ if (!months.length) return "";
22
+ return `${list(months.map(String), "or")} month${months.length === 1 && months[0] === 1 ? "" : "s"}`;
23
+ }
@@ -1,34 +1,34 @@
1
- import { revalidateTag } from "next/cache";
2
- import { siteId } from "./client";
3
- import { clearRedirectCache } from "./redirects";
4
- import { SIGNATURE_HEADER, verify } from "./signature";
5
-
6
- /**
7
- * Receives Site Manager's "content changed" requests. Put in app/api/blueprint/revalidate/route.ts:
8
- *
9
- * export { POST } from "site-manager-kit/revalidate-route";
10
- *
11
- * Only signed requests from the last 5 minutes are accepted, and only this site's tags are refreshed. No visitor waits
12
- * for a refresh: the first visit after a change still gets the cached page and starts its rebuild.
13
- */
14
- export async function POST(req: Request): Promise<Response> {
15
- const body = await req.text();
16
- const check = verify(process.env.BLUEPRINT_REVALIDATE_SECRET, req.headers.get(SIGNATURE_HEADER), body);
17
- if (!check.ok) return Response.json({ error: check.reason }, { status: 401 });
18
-
19
- let tags: unknown;
20
- try {
21
- tags = (JSON.parse(body) as { tags?: unknown }).tags;
22
- } catch {
23
- return Response.json({ error: "bad_request" }, { status: 400 });
24
- }
25
- if (!Array.isArray(tags)) return Response.json({ error: "bad_request" }, { status: 400 });
26
- const prefix = `bp:${siteId()}`;
27
- const mine = tags.filter((t): t is string => typeof t === "string" && (t === prefix || t.startsWith(`${prefix}:`)) && t.length <= 256);
28
- // Marked stale rather than expired: the next visitor gets the page as it was, instantly, while the page rebuilds in
29
- // the background. A page rebuild waits for fresh reads before it is saved, so the visit after it shows the change.
30
- // Expiring instead made the next visit to every affected page wait for the whole rebuild.
31
- for (const t of mine) revalidateTag(t, "max");
32
- if (mine.some((t) => t === prefix || t.endsWith(":redirects"))) clearRedirectCache();
33
- return Response.json({ revalidated: mine.length });
34
- }
1
+ import { revalidateTag } from "next/cache";
2
+ import { siteId } from "./client";
3
+ import { clearRedirectCache } from "./redirects";
4
+ import { SIGNATURE_HEADER, verify } from "./signature";
5
+
6
+ /**
7
+ * Receives Site Manager's "content changed" requests. Put in app/api/blueprint/revalidate/route.ts:
8
+ *
9
+ * export { POST } from "site-manager-kit/revalidate-route";
10
+ *
11
+ * Only signed requests from the last 5 minutes are accepted, and only this site's tags are refreshed. No visitor waits
12
+ * for a refresh: the first visit after a change still gets the cached page and starts its rebuild.
13
+ */
14
+ export async function POST(req: Request): Promise<Response> {
15
+ const body = await req.text();
16
+ const check = verify(process.env.BLUEPRINT_REVALIDATE_SECRET, req.headers.get(SIGNATURE_HEADER), body);
17
+ if (!check.ok) return Response.json({ error: check.reason }, { status: 401 });
18
+
19
+ let tags: unknown;
20
+ try {
21
+ tags = (JSON.parse(body) as { tags?: unknown }).tags;
22
+ } catch {
23
+ return Response.json({ error: "bad_request" }, { status: 400 });
24
+ }
25
+ if (!Array.isArray(tags)) return Response.json({ error: "bad_request" }, { status: 400 });
26
+ const prefix = `bp:${siteId()}`;
27
+ const mine = tags.filter((t): t is string => typeof t === "string" && (t === prefix || t.startsWith(`${prefix}:`)) && t.length <= 256);
28
+ // Marked stale rather than expired: the next visitor gets the page as it was, instantly, while the page rebuilds in
29
+ // the background. A page rebuild waits for fresh reads before it is saved, so the visit after it shows the change.
30
+ // Expiring instead made the next visit to every affected page wait for the whole rebuild.
31
+ for (const t of mine) revalidateTag(t, "max");
32
+ if (mine.some((t) => t === prefix || t.endsWith(":redirects"))) clearRedirectCache();
33
+ return Response.json({ revalidated: mine.length });
34
+ }
package/src/rss-route.ts CHANGED
@@ -1,26 +1,26 @@
1
- import { buildRss, getBlogFeed } from "./blog";
2
- import { BlueprintError } from "./client";
3
-
4
- /**
5
- * The blog's RSS feed. Put in app/blog/rss.xml/route.ts (or wherever the feed should live):
6
- *
7
- * import { rssRoute } from "site-manager-kit/rss-route";
8
- * export const GET = rssRoute();
9
- *
10
- * The feed's own address comes from the request. With the Blog section off in Site Manager the feed answers 404.
11
- */
12
- export function rssRoute(opts: { limit?: number; title?: string; description?: string } = {}) {
13
- return async function GET(req: Request): Promise<Response> {
14
- let feed;
15
- try {
16
- feed = await getBlogFeed({ limit: opts.limit });
17
- } catch (e) {
18
- if (e instanceof BlueprintError && e.code === "module_off") return new Response("Not found", { status: 404 });
19
- throw e;
20
- }
21
- if (!feed) return new Response("Not found", { status: 404 });
22
- const url = new URL(req.url);
23
- const body = buildRss(feed, { feedUrl: `${url.origin}${url.pathname}`, link: feed.link ?? url.origin, title: opts.title, description: opts.description });
24
- return new Response(body, { headers: { "content-type": "application/rss+xml; charset=utf-8", "cache-control": "public, max-age=0, s-maxage=3600, stale-while-revalidate=86400" } });
25
- };
26
- }
1
+ import { buildRss, getBlogFeed } from "./blog";
2
+ import { BlueprintError } from "./client";
3
+
4
+ /**
5
+ * The blog's RSS feed. Put in app/blog/rss.xml/route.ts (or wherever the feed should live):
6
+ *
7
+ * import { rssRoute } from "site-manager-kit/rss-route";
8
+ * export const GET = rssRoute();
9
+ *
10
+ * The feed's own address comes from the request. With the Blog section off in Site Manager the feed answers 404.
11
+ */
12
+ export function rssRoute(opts: { limit?: number; title?: string; description?: string } = {}) {
13
+ return async function GET(req: Request): Promise<Response> {
14
+ let feed;
15
+ try {
16
+ feed = await getBlogFeed({ limit: opts.limit });
17
+ } catch (e) {
18
+ if (e instanceof BlueprintError && e.code === "module_off") return new Response("Not found", { status: 404 });
19
+ throw e;
20
+ }
21
+ if (!feed) return new Response("Not found", { status: 404 });
22
+ const url = new URL(req.url);
23
+ const body = buildRss(feed, { feedUrl: `${url.origin}${url.pathname}`, link: feed.link ?? url.origin, title: opts.title, description: opts.description });
24
+ return new Response(body, { headers: { "content-type": "application/rss+xml; charset=utf-8", "cache-control": "public, max-age=0, s-maxage=3600, stale-while-revalidate=86400" } });
25
+ };
26
+ }
package/src/services.ts CHANGED
@@ -1,52 +1,52 @@
1
- import type { Service, Site } from "./types";
2
-
3
- type Json = Record<string, unknown>;
4
-
5
- /**
6
- * schema.org `Service` structured data for a service page, with the business as its provider. A price becomes an
7
- * `Offer` (a range as a `PriceSpecification` with min and max); "call for pricing" and no price add no offer, so
8
- * search engines are never told a price the business didn't give. Amounts stay decimal strings. Prices are in USD,
9
- * the only currency Site Manager's sites use.
10
- */
11
- export function serviceJsonLd(service: Service, site: Pick<Site, "name" | "business" | "websiteUrl">, url: string): Json {
12
- const b = site.business;
13
- const a = b.address;
14
- const provider: Json = {
15
- "@type": "LocalBusiness",
16
- name: b.displayName || site.name,
17
- ...(site.websiteUrl ? { url: site.websiteUrl } : {}),
18
- ...(b.phone ? { telephone: b.phone } : {}),
19
- ...(a.line1 || a.city
20
- ? {
21
- address: {
22
- "@type": "PostalAddress",
23
- ...(a.line1 ? { streetAddress: [a.line1, a.line2].filter(Boolean).join(", ") } : {}),
24
- ...(a.city ? { addressLocality: a.city } : {}),
25
- ...(a.region ? { addressRegion: a.region } : {}),
26
- ...(a.postalCode ? { postalCode: a.postalCode } : {}),
27
- ...(a.country ? { addressCountry: a.country } : {}),
28
- },
29
- }
30
- : {}),
31
- };
32
- const p = service.price;
33
- let offers: Json | null = null;
34
- if (p.kind === "fixed" && p.from) offers = { "@type": "Offer", priceCurrency: "USD", price: p.from };
35
- else if (p.kind === "starting_at" && p.from) {
36
- offers = { "@type": "Offer", priceCurrency: "USD", price: p.from, priceSpecification: { "@type": "PriceSpecification", priceCurrency: "USD", minPrice: p.from } };
37
- } else if (p.kind === "range" && p.from && p.to) {
38
- offers = { "@type": "Offer", priceCurrency: "USD", priceSpecification: { "@type": "PriceSpecification", priceCurrency: "USD", minPrice: p.from, maxPrice: p.to } };
39
- }
40
- return {
41
- "@context": "https://schema.org",
42
- "@type": "Service",
43
- name: service.name,
44
- url,
45
- ...(service.summary ? { description: service.summary } : {}),
46
- ...(service.category ? { serviceType: service.category } : {}),
47
- ...(service.photos.length ? { image: service.photos.map((ph) => ph.url) } : {}),
48
- provider,
49
- ...(a.city ? { areaServed: a.region ? `${a.city}, ${a.region}` : a.city } : {}),
50
- ...(offers ? { offers } : {}),
51
- };
52
- }
1
+ import type { Service, Site } from "./types";
2
+
3
+ type Json = Record<string, unknown>;
4
+
5
+ /**
6
+ * schema.org `Service` structured data for a service page, with the business as its provider. A price becomes an
7
+ * `Offer` (a range as a `PriceSpecification` with min and max); "call for pricing" and no price add no offer, so
8
+ * search engines are never told a price the business didn't give. Amounts stay decimal strings. Prices are in USD,
9
+ * the only currency Site Manager's sites use.
10
+ */
11
+ export function serviceJsonLd(service: Service, site: Pick<Site, "name" | "business" | "websiteUrl">, url: string): Json {
12
+ const b = site.business;
13
+ const a = b.address;
14
+ const provider: Json = {
15
+ "@type": "LocalBusiness",
16
+ name: b.displayName || site.name,
17
+ ...(site.websiteUrl ? { url: site.websiteUrl } : {}),
18
+ ...(b.phone ? { telephone: b.phone } : {}),
19
+ ...(a.line1 || a.city
20
+ ? {
21
+ address: {
22
+ "@type": "PostalAddress",
23
+ ...(a.line1 ? { streetAddress: [a.line1, a.line2].filter(Boolean).join(", ") } : {}),
24
+ ...(a.city ? { addressLocality: a.city } : {}),
25
+ ...(a.region ? { addressRegion: a.region } : {}),
26
+ ...(a.postalCode ? { postalCode: a.postalCode } : {}),
27
+ ...(a.country ? { addressCountry: a.country } : {}),
28
+ },
29
+ }
30
+ : {}),
31
+ };
32
+ const p = service.price;
33
+ let offers: Json | null = null;
34
+ if (p.kind === "fixed" && p.from) offers = { "@type": "Offer", priceCurrency: "USD", price: p.from };
35
+ else if (p.kind === "starting_at" && p.from) {
36
+ offers = { "@type": "Offer", priceCurrency: "USD", price: p.from, priceSpecification: { "@type": "PriceSpecification", priceCurrency: "USD", minPrice: p.from } };
37
+ } else if (p.kind === "range" && p.from && p.to) {
38
+ offers = { "@type": "Offer", priceCurrency: "USD", priceSpecification: { "@type": "PriceSpecification", priceCurrency: "USD", minPrice: p.from, maxPrice: p.to } };
39
+ }
40
+ return {
41
+ "@context": "https://schema.org",
42
+ "@type": "Service",
43
+ name: service.name,
44
+ url,
45
+ ...(service.summary ? { description: service.summary } : {}),
46
+ ...(service.category ? { serviceType: service.category } : {}),
47
+ ...(service.photos.length ? { image: service.photos.map((ph) => ph.url) } : {}),
48
+ provider,
49
+ ...(a.city ? { areaServed: a.region ? `${a.city}, ${a.region}` : a.city } : {}),
50
+ ...(offers ? { offers } : {}),
51
+ };
52
+ }