@stack-mode/site-manager-kit 0.0.0-stage → 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 (67) hide show
  1. package/README.md +605 -2
  2. package/audit/ts-hooks.mjs +68 -0
  3. package/audit/wiring-audit.d.mts +37 -0
  4. package/audit/wiring-audit.mjs +604 -0
  5. package/bin/site-manager-kit.mjs +10 -0
  6. package/package.json +70 -4
  7. package/src/analytics.tsx +23 -0
  8. package/src/announcements.ts +13 -0
  9. package/src/attribution-fields.ts +52 -0
  10. package/src/attribution.tsx +47 -0
  11. package/src/blog.ts +122 -0
  12. package/src/chat-route.ts +136 -0
  13. package/src/chat-types.ts +76 -0
  14. package/src/client.ts +116 -0
  15. package/src/config.ts +78 -0
  16. package/src/copy-keys-route.ts +21 -0
  17. package/src/copy-keys.ts +77 -0
  18. package/src/data.ts +189 -0
  19. package/src/downloads.ts +7 -0
  20. package/src/faq.ts +21 -0
  21. package/src/floor-plans.ts +45 -0
  22. package/src/form-guard.tsx +37 -0
  23. package/src/form-token.ts +33 -0
  24. package/src/form-types.ts +13 -0
  25. package/src/forms.ts +76 -0
  26. package/src/glossary.ts +19 -0
  27. package/src/health-route.ts +34 -0
  28. package/src/image.ts +12 -0
  29. package/src/index.ts +36 -0
  30. package/src/indexnow.ts +40 -0
  31. package/src/listings.ts +38 -0
  32. package/src/metadata.ts +55 -0
  33. package/src/money.ts +10 -0
  34. package/src/not-found-reporter.tsx +16 -0
  35. package/src/not-found.ts +53 -0
  36. package/src/payment.ts +70 -0
  37. package/src/phone-input.tsx +26 -0
  38. package/src/phone.ts +37 -0
  39. package/src/preview-banner.tsx +49 -0
  40. package/src/preview-route.ts +48 -0
  41. package/src/preview.ts +94 -0
  42. package/src/redirects.ts +57 -0
  43. package/src/residents.ts +23 -0
  44. package/src/revalidate-route.ts +34 -0
  45. package/src/robots.ts +26 -0
  46. package/src/rss-route.ts +26 -0
  47. package/src/services.ts +52 -0
  48. package/src/signature.ts +23 -0
  49. package/src/site-chat-panel.tsx +537 -0
  50. package/src/site-chat.tsx +26 -0
  51. package/src/sitemap.ts +26 -0
  52. package/src/structured-data.tsx +207 -0
  53. package/src/types.ts +580 -0
  54. package/src/version.ts +1 -0
  55. package/src/visual-accents.ts +126 -0
  56. package/src/visual-bridge-client.tsx +1070 -0
  57. package/src/visual-bridge.tsx +30 -0
  58. package/src/visual-clicks.ts +13 -0
  59. package/src/visual-lists.ts +37 -0
  60. package/src/visual-match.ts +77 -0
  61. package/src/visual-messages.ts +160 -0
  62. package/src/visual-mode.ts +18 -0
  63. package/src/visual-preview.ts +69 -0
  64. package/src/visual-tags.ts +90 -0
  65. package/src/visual.ts +59 -0
  66. package/src/waitlist-form.ts +39 -0
  67. package/src/waitlist.ts +48 -0
@@ -0,0 +1,57 @@
1
+ import { after, NextResponse, type NextRequest } from "next/server";
2
+ import { bp } from "./client";
3
+ import type { Redirect } from "./types";
4
+
5
+ const TTL_MS = 5 * 60 * 1000;
6
+ let cache: { at: number; map: Map<string, Redirect> } | null = null;
7
+
8
+ /** Exact path, no trailing slash (except "/"), query string ignored. */
9
+ export function normalizePath(pathname: string): string {
10
+ const p = pathname.split("?")[0].replace(/\/{2,}/g, "/");
11
+ return p.length > 1 ? p.replace(/\/+$/, "") : p;
12
+ }
13
+
14
+ export function buildRedirectMap(list: Redirect[]): Map<string, Redirect> {
15
+ return new Map(list.map((r) => [normalizePath(r.from), r]));
16
+ }
17
+
18
+ /** Pure: the redirect for a path, if any. */
19
+ export function matchRedirect(map: Map<string, Redirect>, pathname: string): Redirect | null {
20
+ return map.get(normalizePath(pathname)) ?? null;
21
+ }
22
+
23
+ /** Called by the revalidate route when Site Manager says redirects changed. */
24
+ export function clearRedirectCache() {
25
+ cache = null;
26
+ }
27
+
28
+ async function redirectMap(): Promise<Map<string, Redirect>> {
29
+ if (cache && Date.now() - cache.at < TTL_MS) return cache.map;
30
+ // The proxy has no data cache, so this list is kept in memory here and cleared by the revalidate route.
31
+ const list = (await bp<Redirect[]>("/redirects", { cache: "no-store" })) ?? [];
32
+ cache = { at: Date.now(), map: buildRedirectMap(list) };
33
+ return cache.map;
34
+ }
35
+
36
+ /**
37
+ * For `src/proxy.ts`: returns a redirect response when Site Manager has one for this path, or undefined to carry
38
+ * on. The hit is counted after the response is sent. If Site Manager can't be reached, nothing redirects.
39
+ */
40
+ export async function handleRedirect(request: NextRequest): Promise<NextResponse | undefined> {
41
+ let match: Redirect | null = null;
42
+ try {
43
+ match = matchRedirect(await redirectMap(), request.nextUrl.pathname);
44
+ } catch {
45
+ return undefined;
46
+ }
47
+ if (!match) return undefined;
48
+ const target = /^https?:\/\//i.test(match.to) ? new URL(match.to) : new URL(match.to, request.nextUrl.origin);
49
+ if (!/^https?:\/\//i.test(match.to)) target.search = request.nextUrl.search;
50
+ const from = match.from;
51
+ try {
52
+ after(() => bp("/redirects/hit", { method: "POST", body: { from } }).catch(() => {}));
53
+ } catch {
54
+ void bp("/redirects/hit", { method: "POST", body: { from } }).catch(() => {});
55
+ }
56
+ return NextResponse.redirect(target, match.status);
57
+ }
@@ -0,0 +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
+ }
@@ -0,0 +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
+ }
package/src/robots.ts ADDED
@@ -0,0 +1,26 @@
1
+ import type { MetadataRoute } from "next";
2
+ import { bp, tagsFor } from "./client";
3
+ import { getSite } from "./data";
4
+ import type { Robots } from "./types";
5
+
6
+ /**
7
+ * Pure: the launch gate. Until the site is marked indexable in Site Manager, every crawler is asked to stay away,
8
+ * and no sitemap is advertised.
9
+ */
10
+ export function buildRobots(r: Robots | null, websiteUrl: string | null): MetadataRoute.Robots {
11
+ if (!r || !r.indexable) return { rules: [{ userAgent: "*", disallow: "/" }] };
12
+ return {
13
+ rules: [{ userAgent: "*", allow: "/", ...(r.disallow.length ? { disallow: r.disallow } : {}) }],
14
+ ...(websiteUrl ? { sitemap: `${websiteUrl.replace(/\/+$/, "")}/sitemap.xml` } : {}),
15
+ };
16
+ }
17
+
18
+ /** For `app/robots.ts`. If Site Manager can't be reached, it errs on the side of not indexing. */
19
+ export async function robots(): Promise<MetadataRoute.Robots> {
20
+ try {
21
+ const [r, site] = await Promise.all([bp<Robots>("/robots", { tags: tagsFor("seo") }), getSite()]);
22
+ return buildRobots(r, site?.websiteUrl ?? null);
23
+ } catch {
24
+ return buildRobots(null, null);
25
+ }
26
+ }
@@ -0,0 +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
+ }
@@ -0,0 +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
+ }
@@ -0,0 +1,23 @@
1
+ import { createHmac, timingSafeEqual } from "node:crypto";
2
+
3
+ // The same format Site Manager signs with: `t=<unix>,v1=<hex HMAC-SHA256 of "<t>.<body>">`.
4
+ export const SIGNATURE_HEADER = "x-blueprint-signature";
5
+ export const MAX_AGE_SECONDS = 300;
6
+
7
+ export function sign(secret: string, body: string, timestamp = Math.floor(Date.now() / 1000)): string {
8
+ return `t=${timestamp},v1=${createHmac("sha256", secret).update(`${timestamp}.${body}`).digest("hex")}`;
9
+ }
10
+
11
+ export type VerifyResult = { ok: true } | { ok: false; reason: "missing" | "malformed" | "expired" | "mismatch" };
12
+
13
+ /** Checks a signature and rejects anything older (or newer) than 5 minutes, so a captured request can't be replayed. */
14
+ export function verify(secret: string | undefined, header: string | null, body: string, now = Math.floor(Date.now() / 1000)): VerifyResult {
15
+ if (!secret || !header) return { ok: false, reason: "missing" };
16
+ const m = header.match(/^t=(\d+),v1=([0-9a-f]{64})$/);
17
+ if (!m) return { ok: false, reason: "malformed" };
18
+ const t = Number(m[1]);
19
+ if (Math.abs(now - t) > MAX_AGE_SECONDS) return { ok: false, reason: "expired" };
20
+ const expected = Buffer.from(createHmac("sha256", secret).update(`${t}.${body}`).digest("hex"));
21
+ const given = Buffer.from(m[2]);
22
+ return expected.length === given.length && timingSafeEqual(expected, given) ? { ok: true } : { ok: false, reason: "mismatch" };
23
+ }