@stack-mode/site-manager-kit 1.44.0 → 1.45.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/README.md +618 -606
- package/audit/ts-hooks.mjs +68 -68
- package/audit/wiring-audit.d.mts +37 -37
- package/audit/wiring-audit.mjs +604 -604
- package/bin/site-manager-kit.mjs +10 -10
- package/package.json +73 -72
- package/src/announcements.ts +13 -13
- package/src/attribution-fields.ts +52 -52
- package/src/attribution.tsx +47 -47
- package/src/blog.ts +122 -122
- package/src/chat-route.ts +136 -136
- package/src/chat-types.ts +76 -76
- package/src/client.ts +116 -116
- package/src/config.ts +78 -78
- package/src/copy-keys-route.ts +21 -21
- package/src/copy-keys.ts +77 -77
- package/src/data.ts +189 -189
- package/src/downloads.ts +7 -7
- package/src/faq.ts +21 -21
- package/src/floor-plans.ts +45 -45
- package/src/form-guard.tsx +37 -37
- package/src/form-types.ts +13 -13
- package/src/forms.ts +76 -76
- package/src/glossary.ts +19 -19
- package/src/health-route.ts +34 -34
- package/src/index.ts +36 -36
- package/src/indexnow.ts +40 -40
- package/src/listings.ts +38 -38
- package/src/llms-route.ts +45 -0
- package/src/metadata.ts +55 -55
- package/src/not-found-reporter.tsx +16 -16
- package/src/not-found.ts +53 -53
- package/src/payment.ts +70 -70
- package/src/phone-input.tsx +26 -26
- package/src/phone.ts +37 -37
- package/src/preview-route.ts +48 -48
- package/src/preview.ts +94 -94
- package/src/residents.ts +23 -23
- package/src/revalidate-route.ts +34 -34
- package/src/robots.ts +5 -1
- package/src/rss-route.ts +26 -26
- package/src/services.ts +52 -52
- package/src/site-chat-panel.tsx +537 -537
- package/src/site-chat.tsx +26 -26
- package/src/sitemap.ts +26 -26
- package/src/structured-data.tsx +207 -207
- package/src/types.ts +581 -580
- package/src/version.ts +1 -1
- package/src/visual-accents.ts +126 -126
- package/src/visual-bridge-client.tsx +1070 -1070
- package/src/visual-bridge.tsx +30 -30
- package/src/visual-clicks.ts +13 -13
- package/src/visual-lists.ts +37 -37
- package/src/visual-match.ts +77 -77
- package/src/visual-messages.ts +160 -160
- package/src/visual-mode.ts +18 -18
- package/src/visual-preview.ts +69 -69
- package/src/visual-tags.ts +90 -90
- package/src/visual.ts +59 -59
- package/src/waitlist-form.ts +39 -39
- package/src/waitlist.ts +48 -48
package/src/phone.ts
CHANGED
|
@@ -1,37 +1,37 @@
|
|
|
1
|
-
// US phone numbers, one format everywhere: (555) 123-4567. The same rules as Site Manager's own (src/lib/phone.ts
|
|
2
|
-
// there), so what a visitor types, what Site Manager stores and what it shows all match.
|
|
3
|
-
|
|
4
|
-
/** The 10 digits of a US number, dropping a leading country code 1. Extra digits beyond 10 are ignored. */
|
|
5
|
-
export function phoneDigits(raw: string): string {
|
|
6
|
-
let d = raw.replace(/\D/g, "");
|
|
7
|
-
if (d.length > 10 && d.startsWith("1")) d = d.slice(1);
|
|
8
|
-
return d.slice(0, 10);
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
/** As typed: "(555) 123-4567", built up as the digits come in. `complete` once there are 10. */
|
|
12
|
-
export function formatUsPhone(raw: string): { text: string; complete: boolean } {
|
|
13
|
-
const d = phoneDigits(raw);
|
|
14
|
-
let text = "";
|
|
15
|
-
if (d.length > 0) text = `(${d.slice(0, 3)}`;
|
|
16
|
-
if (d.length >= 3) text += ")";
|
|
17
|
-
if (d.length > 3) text += ` ${d.slice(3, 6)}`;
|
|
18
|
-
if (d.length > 6) text += `-${d.slice(6)}`;
|
|
19
|
-
return { text, complete: d.length === 10 };
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
/**
|
|
23
|
-
* The next value of a masked input. Backspace over a ")" or "-" removes the digit before it too, so the mask never
|
|
24
|
-
* traps the cursor.
|
|
25
|
-
*/
|
|
26
|
-
export function maskPhoneInput(prev: string, next: string): string {
|
|
27
|
-
if (next.length < prev.length && phoneDigits(next) === phoneDigits(prev)) return formatUsPhone(phoneDigits(prev).slice(0, -1)).text;
|
|
28
|
-
return formatUsPhone(next).text;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
/** The stored form of a phone number, or null when it isn't a 10-digit US number. */
|
|
32
|
-
export function normalizeUsPhone(raw: string): string | null {
|
|
33
|
-
const f = formatUsPhone(raw);
|
|
34
|
-
return f.complete && raw.replace(/\D/g, "").length <= 11 ? f.text : null;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
export const PHONE_ERROR = "Enter a 10-digit phone number";
|
|
1
|
+
// US phone numbers, one format everywhere: (555) 123-4567. The same rules as Site Manager's own (src/lib/phone.ts
|
|
2
|
+
// there), so what a visitor types, what Site Manager stores and what it shows all match.
|
|
3
|
+
|
|
4
|
+
/** The 10 digits of a US number, dropping a leading country code 1. Extra digits beyond 10 are ignored. */
|
|
5
|
+
export function phoneDigits(raw: string): string {
|
|
6
|
+
let d = raw.replace(/\D/g, "");
|
|
7
|
+
if (d.length > 10 && d.startsWith("1")) d = d.slice(1);
|
|
8
|
+
return d.slice(0, 10);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** As typed: "(555) 123-4567", built up as the digits come in. `complete` once there are 10. */
|
|
12
|
+
export function formatUsPhone(raw: string): { text: string; complete: boolean } {
|
|
13
|
+
const d = phoneDigits(raw);
|
|
14
|
+
let text = "";
|
|
15
|
+
if (d.length > 0) text = `(${d.slice(0, 3)}`;
|
|
16
|
+
if (d.length >= 3) text += ")";
|
|
17
|
+
if (d.length > 3) text += ` ${d.slice(3, 6)}`;
|
|
18
|
+
if (d.length > 6) text += `-${d.slice(6)}`;
|
|
19
|
+
return { text, complete: d.length === 10 };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The next value of a masked input. Backspace over a ")" or "-" removes the digit before it too, so the mask never
|
|
24
|
+
* traps the cursor.
|
|
25
|
+
*/
|
|
26
|
+
export function maskPhoneInput(prev: string, next: string): string {
|
|
27
|
+
if (next.length < prev.length && phoneDigits(next) === phoneDigits(prev)) return formatUsPhone(phoneDigits(prev).slice(0, -1)).text;
|
|
28
|
+
return formatUsPhone(next).text;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** The stored form of a phone number, or null when it isn't a 10-digit US number. */
|
|
32
|
+
export function normalizeUsPhone(raw: string): string | null {
|
|
33
|
+
const f = formatUsPhone(raw);
|
|
34
|
+
return f.complete && raw.replace(/\D/g, "").length <= 11 ? f.text : null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export const PHONE_ERROR = "Enter a 10-digit phone number";
|
package/src/preview-route.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/revalidate-route.ts
CHANGED
|
@@ -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/robots.ts
CHANGED
|
@@ -10,7 +10,11 @@ import type { Robots } from "./types";
|
|
|
10
10
|
export function buildRobots(r: Robots | null, websiteUrl: string | null): MetadataRoute.Robots {
|
|
11
11
|
if (!r || !r.indexable) return { rules: [{ userAgent: "*", disallow: "/" }] };
|
|
12
12
|
return {
|
|
13
|
-
rules: [
|
|
13
|
+
rules: [
|
|
14
|
+
{ userAgent: "*", allow: "/", ...(r.disallow.length ? { disallow: r.disallow } : {}) },
|
|
15
|
+
// A crawler named in its own group ignores the "*" group, so the AI crawlers the site keeps out are listed apart.
|
|
16
|
+
...(r.blockedBots?.length ? [{ userAgent: r.blockedBots, disallow: "/" }] : []),
|
|
17
|
+
],
|
|
14
18
|
...(websiteUrl ? { sitemap: `${websiteUrl.replace(/\/+$/, "")}/sitemap.xml` } : {}),
|
|
15
19
|
};
|
|
16
20
|
}
|
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
|
+
}
|