@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
package/README.md CHANGED
@@ -1,606 +1,606 @@
1
- # site-manager-kit
2
-
3
- Reads a website's content from Site Manager and gives a Next.js App Router site its metadata, sitemap, robots rules, redirects, form handling and content refreshes in a few lines. For sites hosted on Vercel. It ships TypeScript source with no build step, and its only runtime dependencies are `next`, `react` and `botid`.
4
-
5
- ## Connect a website
6
-
7
- For a Next.js App Router website (Next 16) hosted on Vercel. Follow the steps in order. This is the same guide as `docs/CONNECT_A_SITE.md` in Site Manager; change both together.
8
-
9
- ### Before you start
10
-
11
- In Site Manager, an agency admin sets up the site with **Sites > New Site** (the setup wizard). On its **Website Connection** step:
12
-
13
- 1. Press **Create Key and Show Env Vars** and copy the block. The API key is shown once.
14
- 2. Keep the page open. You'll come back to press **Check Connection**.
15
-
16
- The website needs Node 22 or later.
17
-
18
- ### 1. Install the kit
19
-
20
- ```bash
21
- npm i site-manager-kit@npm:@stack-mode/site-manager-kit botid --save-exact
22
- ```
23
-
24
- The kit is published on npm as `@stack-mode/site-manager-kit` and installed under the name `site-manager-kit`, so imports, `transpilePackages` and the `npx site-manager-kit` command all use the short name. `--save-exact` pins the version, so a new kit release never changes a live site by itself. No token or GitHub access is needed, on your machine, on Vercel or in CI.
25
-
26
- `next.config.ts`:
27
-
28
- ```ts
29
- import type { NextConfig } from "next";
30
- import { withBotId } from "botid/next/config";
31
- import { withSiteManager } from "site-manager-kit/config";
32
-
33
- const nextConfig: NextConfig = { transpilePackages: ["site-manager-kit"] };
34
-
35
- export default withBotId(withSiteManager(nextConfig));
36
- ```
37
-
38
- The kit ships TypeScript source, so `transpilePackages` is required. To upgrade later, run the install line again (it pins the newest version), or change the version after `@stack-mode/site-manager-kit@` in `package.json` and run `npm install`.
39
-
40
- `withSiteManager` gives every page the same baseline security headers (`nosniff`, a strict referrer policy, `X-Frame-Options: SAMEORIGIN`, HSTS and a Permissions-Policy that turns off camera, microphone and USB), stops sending `X-Powered-By`, and lets `next/image` resize Site Manager's media. Payment and location stay on, so checkout buttons and "near me" maps keep working. Options: `withSiteManager(nextConfig, { disableFeatures: ["camera", "microphone", "usb", "geolocation"], hstsSubdomains: false, frameOptions: "DENY" })`. A Content-Security-Policy stays the site's own, because every site loads different services; if it sends one, allow `https://app.mystackos.com` in `script-src` and `connect-src` for visitor tracking.
41
-
42
- Cache Components can be on or off (new apps from `create-next-app` 16.4 turn it on). Either way, pages that only read Site Manager content are built once and refreshed when content changes, and a form page renders only its timing token per visit.
43
-
44
- ### 2. Env vars
45
-
46
- Paste the block from the wizard into the website's Vercel project (Production and Preview) and into `.env.local` for local work. All five are server only; never prefix them with `NEXT_PUBLIC_`.
47
-
48
- | Name | What it is |
49
- |---|---|
50
- | `BLUEPRINT_URL` | Site Manager's address, for example `https://manager.example.com` |
51
- | `BLUEPRINT_API_KEY` | The site's API key |
52
- | `BLUEPRINT_SITE_ID` | The site's id |
53
- | `BLUEPRINT_REVALIDATE_SECRET` | The website connection secret |
54
- | `BLUEPRINT_FORM_SECRET` | A random secret for form timing tokens |
55
-
56
- Add `.env.local` to `.gitignore` if it isn't already.
57
-
58
- Working against a Site Manager running on your own machine? Set `BLUEPRINT_URL=http://localhost:3000`. Node can't resolve `*.localhost` names the way browsers do, and the key alone says which site it is.
59
-
60
- ### 3. Refresh and health routes
61
-
62
- `app/api/blueprint/revalidate/route.ts`:
63
-
64
- ```ts
65
- export { POST } from "site-manager-kit/revalidate-route";
66
- ```
67
-
68
- `app/api/blueprint/health/route.ts`:
69
-
70
- ```ts
71
- export { GET } from "site-manager-kit/health-route";
72
- ```
73
-
74
- Then, in the wizard's **Branding** or **StackOS** step (or the site's Connection tab), set the **Website revalidate address** to `https://<the website>/api/blueprint/revalidate`.
75
-
76
- `app/api/blueprint/preview/route.ts`, so editors can see drafts and scheduled changes on the real site before they go live:
77
-
78
- ```ts
79
- export { GET } from "site-manager-kit/preview-route";
80
- ```
81
-
82
- And once inside `<body>` in `app/layout.tsx`, the bar that says a preview is open, with a way out:
83
-
84
- ```tsx
85
- import { PreviewBanner } from "site-manager-kit/preview-banner";
86
- // ...
87
- <PreviewBanner />
88
- ```
89
-
90
- Site Manager's **Preview on Website** button opens `/api/blueprint/preview?token=...`, signed with the same `BLUEPRINT_REVALIDATE_SECRET`, so there is nothing new to set up. The route turns on Next.js draft mode for that browser only and opens the page. While it's on, every read skips the cache and includes drafts; everyone else keeps seeing the published site. Links last an hour, and the preview ends when the browser closes or on **Exit preview**.
91
-
92
- #### Visual Editor
93
-
94
- Site Manager's **Visual Editor** shows a page of the website with its text outlined, so people can click a value and change it. Changes are saved as drafts in Site Manager; the website's code and layout never change. It needs the preview route above, plus two lines.
95
-
96
- Once inside `<body>` in `app/layout.tsx`, next to `PreviewBanner`:
97
-
98
- ```tsx
99
- import { Suspense } from "react";
100
- import { VisualEditing } from "site-manager-kit/visual-editing";
101
- // ...
102
- <Suspense fallback={null}>
103
- <VisualEditing />
104
- </Suspense>
105
- ```
106
-
107
- In `src/proxy.ts`, so the page can be shown inside Site Manager while the editor is open (see step 4 for the rest of the file):
108
-
109
- ```ts
110
- import { visualFrameHeaders } from "site-manager-kit/visual";
111
-
112
- export async function proxy(request: NextRequest) {
113
- return visualFrameHeaders(request, (await handleRedirect(request)) ?? NextResponse.next());
114
- }
115
- ```
116
-
117
- That's all a site needs. The editor finds the site's Site Copy on the page by its words: an element whose words are exactly one value is outlined, and so is a bare run of text beside other elements (a heading's words before its accent span). Only values for the page being shown (their page in Site Copy) and values with no page (header, footer) count. Words that two values on the same page share are left unmarked, since there is no telling which one they are, and so are values shorter than three characters. Those stay editable in Site Copy.
118
-
119
- A site's `next.config` Content-Security-Policy keeps working: `withSiteManager` sends it without its `frame-ancestors` only to a browser that opened the page from the Visual Editor, and `visualFrameHeaders` then allows that one Site Manager address. `X-Frame-Options` stays on every response. A site that builds its policy in the proxy instead uses `frameAncestors(request)` for its `frame-ancestors` value.
120
-
121
- To mark a value by hand (a value the words can't find, or to be exact), use `editMarks()`. A hand mark always wins over a found one:
122
-
123
- ```tsx
124
- import { editMarks } from "site-manager-kit/visual";
125
-
126
- const mark = await editMarks();
127
- <h1 {...mark("copy/hero_title")}>{title}</h1>
128
- ```
129
-
130
- A ref is `copy/<copy key>`. Marks, the editor script and the frame permission exist only for a browser that opened the page from the Visual Editor (a signed link that lasts an hour); visitors get none of them and the site keeps `X-Frame-Options: SAMEORIGIN`. An element with only text in it is edited in place; anything else, and a run of text beside other elements, opens a side panel in Site Manager. In visual mode, `getCopy()` (and the helpers built on it) adds an invisible tag to each text and rich value, which the editor reads and removes, so a value is found exactly wherever it shows. Code that compares or slices Site Copy text should use `getCopyUntagged()` for that, or compare after `stripTags()` from `site-manager-kit/visual-tags`. A site that marks every value itself can turn the word matching off with `<VisualEditing autoMark={false} />`. While a plain text value is open in the side panel, the page shows the words as they're typed (unsaved, only in that browser); rich text changes on save.
131
-
132
- A value that comes from another section of Site Manager (a floor plan's name, the phone number, an FAQ answer) is marked `link/<section key>`, for example `mark("link/floor_plans")`. Clicking it in the Visual Editor offers a link to that section instead of an edit.
133
-
134
- #### Copy keys
135
-
136
- Declare every piece of text and every photo the site is built with in `src/copy-keys.ts`, with its starting text, and read it through those names so a key is never typed twice:
137
-
138
- ```ts
139
- import { defineCopyKeys } from "site-manager-kit/copy-keys";
140
-
141
- export const COPY = defineCopyKeys({
142
- "home.hero.title": { label: "Home page headline", kind: "text", maxLength: 80, text: "Welcome home" },
143
- "home.intro": { label: "Home page introduction", kind: "rich", text: "<p>Our story...</p>" },
144
- "home.hero.photo": { label: "Home page photo", kind: "image", image: "/images/hero.jpg", alt: "The pool at dusk" },
145
- });
146
- ```
147
-
148
- Keys are lowercase letters and numbers separated by dots, dashes or underscores; labels are written for the client, since they see them in Site Manager. An image is a path to a file in `public/`. A bad key fails the build.
149
-
150
- Serve them to Site Manager in `app/api/blueprint/copy-keys/route.ts`, signed like the health route:
151
-
152
- ```ts
153
- import { copyKeysRoute } from "site-manager-kit/copy-keys-route";
154
- import { COPY } from "@/copy-keys";
155
-
156
- export const GET = copyKeysRoute(COPY);
157
- ```
158
-
159
- Name keys by where they sit on the page, so Site Manager's Pages shows them in named sections and item cards: `<page>.<section>.<field>`, and `<page>.<section>.<list>.<n>.<field>` for repeating items, `n` counting from 1 (`home.process.steps.2.title`). Pages names a section from its own `label`, `eyebrow`, `title` or `heading` text and an item from its `title`, `label` or `name`, so give them one where it fits.
160
-
161
- A list whose items people may move, add or remove takes a rule in the second argument (1.26.0). `addRemove` also allows reordering, and works on the item slots you declare, so declare as many as the page has room for and show only items with something in them. Leave out any list where something about an item stays in code by position, like an icon:
162
-
163
- ```ts
164
- export const COPY = defineCopyKeys(
165
- {
166
- "home.process.steps.1.title": { label: "Home page, process, step, title", kind: "text", text: "Call us" },
167
- "home.process.steps.2.title": { label: "Home page, process, step, title", kind: "text", text: "" },
168
- },
169
- { lists: { "home.process.steps": { addRemove: true } } },
170
- );
171
- ```
172
-
173
- In Site Manager, **Site Copy > Sync from Website** creates the declared keys with their starting text, updates labels and limits, and fills empty values. It never overwrites text someone changed and never deletes a key. After launch, the starting text is only used for new or empty keys.
174
-
175
- ### 4. Sitemap, robots and redirects
176
-
177
- `app/sitemap.ts`:
178
-
179
- ```ts
180
- import { sitemap } from "site-manager-kit";
181
-
182
- export default function Sitemap() {
183
- return sitemap();
184
- }
185
- ```
186
-
187
- `app/robots.ts`:
188
-
189
- ```ts
190
- import { robots } from "site-manager-kit";
191
-
192
- export default function Robots() {
193
- return robots();
194
- }
195
- ```
196
-
197
- `src/proxy.ts` (or `proxy.ts` at the root when there's no `src` folder):
198
-
199
- ```ts
200
- import { NextResponse, type NextRequest } from "next/server";
201
- import { handleRedirect } from "site-manager-kit/redirects";
202
-
203
- export async function proxy(request: NextRequest) {
204
- return (await handleRedirect(request)) ?? NextResponse.next();
205
- }
206
-
207
- export const config = { matcher: ["/((?!_next|api|favicon.ico).*)"] };
208
- ```
209
-
210
- Robots disallows everything until the site goes live in the wizard, so a site under construction never gets indexed.
211
-
212
- ### 5. Metadata on every page
213
-
214
- ```ts
215
- import { pageMetadata } from "site-manager-kit";
216
-
217
- export const generateMetadata = () => pageMetadata("/floor-plans");
218
- ```
219
-
220
- For a detail page, build the path from the params:
221
-
222
- ```ts
223
- export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }) {
224
- const { slug } = await params;
225
- return pageMetadata(`/floor-plans/${slug}`);
226
- }
227
- ```
228
-
229
- Each path must match the page's address on the live site, because that is the key Search Listings uses.
230
-
231
- ### Missing pages and IndexNow
232
-
233
- Report visits to addresses the site doesn't have, so Site Manager can list them under Redirects, Not Found:
234
-
235
- ```tsx
236
- // app/api/not-found/route.ts
237
- export { notFoundRoute as POST } from "site-manager-kit/not-found";
238
-
239
- // app/not-found.tsx, anywhere in the page
240
- import { NotFoundReporter } from "site-manager-kit/not-found-reporter";
241
- <NotFoundReporter />
242
- ```
243
-
244
- Serve the IndexNow key from `src/proxy.ts`, before redirects: `const key = await handleIndexNow(request); if (key) return key;` (import from `site-manager-kit/indexnow`). It answers only `/{key}.txt` for Site Manager's key.
245
-
246
- ### Structured data
247
-
248
- Search engines read a summary of the business from every page. Build it from the site you already fetched and write it with `<StructuredData />` in the root layout; record pages add their own type and breadcrumbs:
249
-
250
- ```tsx
251
- import { StructuredData, businessJsonLd, breadcrumbJsonLd, breadcrumbsFor, eventJsonLd } from "site-manager-kit";
252
-
253
- // app/layout.tsx
254
- <StructuredData blocks={[businessJsonLd(site)]} />
255
-
256
- // app/events/[slug]/page.tsx
257
- const url = `${site.websiteUrl}/events/${event.slug}`;
258
- <StructuredData blocks={[eventJsonLd(event, site, url), breadcrumbJsonLd(breadcrumbsFor(`/events/${event.slug}`, site.websiteUrl!, { "/events": "Events", [`/events/${event.slug}`]: event.title }))]} />
259
- ```
260
-
261
- `listingJsonLd` and `floorPlanJsonLd` work the same way. Empty facts are left out. Search Listings' structured data check reads the result.
262
-
263
- ### 6. Content
264
-
265
- Replace hardcoded content with kit reads, one page at a time. Keep the design exactly as it is; only where the text and numbers come from changes.
266
-
267
- ```tsx
268
- import { copyText, formatMoney, getFloorPlans, getSite } from "site-manager-kit";
269
-
270
- export default async function Home() {
271
- const [site, plans] = await Promise.all([getSite(), getFloorPlans()]);
272
- return (
273
- <>
274
- <h1>{await copyText("home.hero.title", site?.business.displayName)}</h1>
275
- {plans.map((p) => (
276
- <p key={p.slug}>
277
- {p.name} from {formatMoney(p.startingRent) ?? "Call for pricing"}
278
- </p>
279
- ))}
280
- </>
281
- );
282
- }
283
- ```
284
-
285
- Images from Site Manager come with their size and alt text. Show them with `next/image`, which serves resized, modern versions:
286
-
287
- ```tsx
288
- import Image from "next/image";
289
- import { imageProps } from "site-manager-kit";
290
-
291
- const img = imageProps(plan.image);
292
- {img ? <Image {...img} sizes="(min-width: 768px) 50vw, 100vw" /> : null}
293
- ```
294
-
295
- Without a known size, `imageProps` uses `fill`, so the image needs a sized box with `position: relative`.
296
-
297
- Visitor tracking: add `<SiteAnalytics />` once inside `<body>` in `app/layout.tsx`. It adds StackOS's tracking script with the key the agency sets on the site's Connection tab, and adds nothing while there's no key.
298
-
299
- ```tsx
300
- import { SiteAnalytics } from "site-manager-kit/analytics";
301
- import { SiteAttribution } from "site-manager-kit/attribution";
302
-
303
- <body>
304
- {children}
305
- <SiteAnalytics />
306
- <SiteAttribution />
307
- </body>
308
- ```
309
-
310
- Where leads came from: `<SiteAttribution />` remembers the visitor's first `utm_` values, landing page and referring site for the browser tab (session storage, no cookie, so no consent banner), and `FormGuard` sends them with every form. Leads then show under their source in Site Manager (Leads > Leads by Source). Links made with Link Tools carry the `utm_` values.
311
-
312
-
313
- The full list of reads is in the kit README. Rules that matter:
314
-
315
- - Money is a decimal string like `"1450.00"`. Show it with `formatMoney`, never `Number()`.
316
- - Show availability with `availabilityText(plan)`, which follows the site's setting in Site Manager: "Available now" by default, the exact count only when the site chose it, and nothing when it's unknown. Don't print `availableCount` yourself; it's kept for older sites and is `null` when availability is unknown, never 0.
317
- - A plan with a running special has `concession.effectiveRent`. Show it next to the regular rent, never instead of it: `effectiveRentText(plan)` gives "$2,207.12 effective".
318
- - To compare plans side by side, `comparisonRows(plans)` gives one row per fact (bedrooms, size, rent, layout, availability) and per feature, ready for a table.
319
- - A section that is turned off for the site answers with `BlueprintError` code `module_off`. Only read sections the site has on.
320
-
321
- ### 7. Forms
322
-
323
- A form is a small client component, so it can show the result, plus a server page that hands it `FormGuard` (the guard signs its render time on the server).
324
-
325
- `app/contact/ContactForm.tsx`:
326
-
327
- ```tsx
328
- "use client";
329
-
330
- import { useActionState, type ReactNode } from "react";
331
- import { formAction } from "site-manager-kit/forms";
332
-
333
- export function ContactForm({ guard }: { guard: ReactNode }) {
334
- const [state, action, pending] = useActionState(formAction.bind(null, "inquiry"), null);
335
- if (state?.ok) return <p>{state.successMessage}</p>;
336
- return (
337
- <form action={action}>
338
- <input name="name" required />
339
- <input name="email" type="email" required />
340
- <textarea name="message" />
341
- {guard}
342
- {state && !state.ok ? <p>{state.error === "invalid" ? "Check the highlighted fields." : "That didn't send. Try again in a moment."}</p> : null}
343
- <button disabled={pending}>Send</button>
344
- </form>
345
- );
346
- }
347
- ```
348
-
349
- `app/contact/page.tsx`:
350
-
351
- ```tsx
352
- import { FormGuard } from "site-manager-kit/form-guard";
353
- import { ContactForm } from "./ContactForm";
354
-
355
- export default function Contact() {
356
- return <ContactForm guard={<FormGuard />} />;
357
- }
358
- ```
359
-
360
- The field names must match the form's fields in Site Manager (Forms > the form). For phone fields use `<PhoneInput name="phone" />` from `site-manager-kit/phone`: it formats as the visitor types, (555) 123-4567, the format Site Manager checks and stores. A field's `help` (from `getForm`) is a short line to show under it. `state.error` is `invalid` (with `state.fields` naming each field's problem), `rate_limited`, `slot_taken` (booking forms) or `unavailable`. Suspected spam answers like a normal submission and lands in Site Manager's spam folder. Don't pass `submitForm` straight to `<form action>`: it returns a result, which a plain form action can't. A form with its own signed timer, or a send with no form (a remembered visitor's one-click download), passes `submitForm(key, data, { timedBySite: true })` after the site's own checks: the FormGuard timer is skipped, the honeypot and BotID still apply.
361
-
362
- #### Booking forms
363
-
364
- A form whose kind is Booking (set by the agency in Site Manager) also needs one of the site's open times, set under Forms > Booking Times. Read them with `getBookingSlots` (never cached, since every booking changes them) and send the chosen `start` as the `bp_slot` field:
365
-
366
- ```tsx
367
- // app/tour/page.tsx
368
- import { Suspense } from "react";
369
- import { connection } from "next/server";
370
- import { getBookingSlots } from "site-manager-kit";
371
- import { FormGuard } from "site-manager-kit/form-guard";
372
- import { TourForm } from "./TourForm";
373
-
374
- export default function Tour() {
375
- // With Cache Components on, only the open times render per visit; the rest of the page stays static.
376
- return (
377
- <Suspense fallback={<p>Finding open times...</p>}>
378
- <Times />
379
- </Suspense>
380
- );
381
- }
382
-
383
- async function Times() {
384
- await connection();
385
- const slots = await getBookingSlots("tour", { days: 14 });
386
- return <TourForm days={slots?.days ?? []} guard={<FormGuard />} />;
387
- }
388
- ```
389
-
390
- ```tsx
391
- // app/tour/TourForm.tsx, inside the <form> from the contact example
392
- {days.map((d) => (
393
- <fieldset key={d.date}>
394
- <legend>{d.date}</legend>
395
- {d.slots.map((s) => (
396
- <label key={s.start}>
397
- <input type="radio" name="bp_slot" value={s.start} required /> {s.label}
398
- </label>
399
- ))}
400
- </fieldset>
401
- ))}
402
- ```
403
-
404
- `state.error` can also be `slot_taken` when someone booked that time a moment earlier: show the times again. Times are in the site's time zone (`slots.timezone`), and a day with no open times has an empty `slots` list.
405
-
406
- Protect each page that posts a form with BotID, in `instrumentation-client.ts`. It goes in `src/` when the app has a `src` folder, and at the project root otherwise; in the wrong place it's silently ignored and BotID warns about a misconfiguration:
407
-
408
- ```ts
409
- import { initBotId } from "botid/client/core";
410
-
411
- initBotId({ protect: [{ path: "/contact", method: "POST" }] });
412
- ```
413
-
414
- ### Waitlist (apartment sites)
415
-
416
- With the Waitlist section on in Site Manager, visitors can ask to hear when a floor plan, or any plan with their bedroom count, has a home available. Site Manager emails them once when it opens, with a link to leave the list. The form uses `FormGuard` and the same spam checks as other forms.
417
-
418
- On a plan page, send the plan's slug in a hidden field. A general form can offer one picker from `waitlistChoices(await getWaitlistOptions())`, which lists each bedroom count and the plans with nothing open now.
419
-
420
- ```tsx
421
- "use client";
422
-
423
- import { useActionState, type ReactNode } from "react";
424
- import { waitlistAction } from "site-manager-kit/waitlist";
425
-
426
- export function WaitlistForm({ plan, guard }: { plan: string; guard: ReactNode }) {
427
- const [state, action, pending] = useActionState(waitlistAction, null);
428
- if (state?.ok) return <p>You're on the list. We'll email you when one opens.</p>;
429
- return (
430
- <form action={action}>
431
- <input type="hidden" name="floorPlan" value={plan} />
432
- <input name="email" type="email" required />
433
- <input name="moveIn" type="date" />
434
- {guard}
435
- <button disabled={pending}>Notify Me</button>
436
- </form>
437
- );
438
- }
439
- ```
440
-
441
- Fields: `floorPlan` (a slug) or `bedrooms` (a number), or one `choice` (`plan:<slug>` or `beds:<n>`), plus `email`, and optionally `name`, `phone` and `moveIn` (`YYYY-MM-DD`). `state.error` is `invalid` with `fields`, `rate_limited` or `unavailable`. Add the page to BotID's `protect` list like any form page.
442
-
443
- ### Website chat
444
-
445
- With Website Chat on and live in Site Manager (kit 1.25.0), a chat button shows on every page. It answers visitors only from the site's published content in Site Manager, says when it doesn't know and gives the contact details, and turns visitors who want a tour, a quote or a call back into leads with the conversation attached. It runs on the site's own AI key, set in Site Manager.
446
-
447
- Add the route the chat talks to. It keeps the API key on the server, sends Site Manager only a hash of the visitor's address, and refuses requests that don't come from the website's own pages:
448
-
449
- ```ts
450
- // app/api/blueprint/chat/route.ts
451
- export { GET, POST } from "site-manager-kit/chat-route";
452
- ```
453
-
454
- Then put the component once inside `<body>` in `app/layout.tsx`. It renders nothing until the chat is live, and nothing when Website Chat is off for the site:
455
-
456
- ```tsx
457
- import { SiteChat } from "site-manager-kit/chat";
458
-
459
- <SiteChat />
460
- ```
461
-
462
- It takes the site's accent from Site Manager and follows the visitor's light or dark setting. Props, all optional: `accent` (a CSS color), `theme` (`"auto"`, `"light"` or `"dark"`), `position` (`"right"` or `"left"`), `offset` (pixels to raise it above a cookie bar) and `endpoint` (when the route lives somewhere else). Text on the accent switches between dark and white so a light accent stays readable.
463
-
464
- - Replies stream in as they're written. The conversation follows the visitor from page to page in the same tab, and a random visitor id in the browser's storage is the only thing kept there; Site Manager stores only its hash.
465
- - When a visitor wants a person, the chat offers a short form (name, email or phone, message). With Booking Times on and a booking form chosen in Site Manager, it offers open times and books one. Leads arrive like form submissions, with the source "Website chat".
466
- - Add the route to BotID's `protect` list to check for bots before Site Manager is asked: `{ path: "/api/blueprint/chat", method: "POST" }`. Site Manager limits each visitor and the site either way.
467
- - `getChatConfig()` (from `site-manager-kit/chat`) reads the settings if the site builds its own chat. The route's answers are NDJSON lines: `start` with the conversation id, `delta` with text, and `done` saying whether it couldn't answer and whether to offer a hand-off (`parseChatLines` reads them).
468
-
469
- ### 8. Caching
470
-
471
- - Every kit read is cached for an hour and refreshed the moment content changes in Site Manager, through the revalidate route. Don't add your own `revalidate` or `cache: "no-store"` around kit reads.
472
- - A refresh never makes a visitor wait: the first visit after a change gets the page as it was and rebuilds it in the background, and the next visit shows the change. Site Manager refreshes only the sections a change affects.
473
- - Pages that only use kit reads can stay static. Don't call `cookies()` or `headers()` on them unless the page needs it.
474
- - Redirects are cached for 5 minutes in the proxy and cleared when they change.
475
- - After a deploy, the first visit to each page fills the cache. Nothing needs warming.
476
- - Pages are prerendered from Site Manager at build time, so a build needs Site Manager to be reachable. If a build fails with `fetch failed`, check `BLUEPRINT_URL`.
477
- - A page with a form renders on each request (`FormGuard` does this), so its timing check always has a fresh render time.
478
-
479
- ### 9. Check it
480
-
481
- 1. `npm run build` passes.
482
- 2. Deploy (or run `npm run dev` with the env vars) and open the site. Content shows, and `/sitemap.xml` and `/robots.txt` load.
483
- 3. In the wizard, press **Check Connection**. Both "connected" and "bot protection, honeypot and timing checks are on" must show.
484
- 4. Change a piece of site copy in Site Manager and reload the page: the new text shows within a few seconds.
485
- 5. Send a test form from a Link Tools link and confirm it arrives in Leads under that link's source.
486
-
487
- ### 10. Wiring audit
488
-
489
- Run it after a new build, after adding or changing a page or section, and before Go live:
490
-
491
- ```bash
492
- npx site-manager-kit audit
493
- ```
494
-
495
- It reads `BLUEPRINT_URL` and `BLUEPRINT_API_KEY` from `.env.local` (or `--env <file>`; use the production values, it only reads), loads `src/copy-keys.ts` (`--keys <file>` for another), and writes `.wiring/report.md`. Every item is one of:
496
-
497
- - **Wired**: the site reads it from Site Manager.
498
- - **Not wired**: Site Manager has it, but editing it changes nothing on the website (a key the site never asks for, a section that's on but never read, a Business Info field the code ignores). Fails the audit.
499
- - **Missing in Site Manager**: the site uses it, but Site Manager has no such field (a key not synced yet, a form key with no form). Fails the audit.
500
- - **Hard-coded**: the code keeps its own copy of a Site Manager value, with file and line. Reported, never failing, since a fallback can be on purpose.
501
- - **Unknown**: the audit couldn't tell (the copy keys file didn't load, a section it doesn't know yet).
502
-
503
- Exit codes: 0 wired, 1 something Not wired or missing, 2 it couldn't run. Folders it shouldn't scan (an old admin, scripts) go in `package.json`:
504
-
505
- ```json
506
- "siteManagerAudit": { "ignore": ["src/admin"], "keysFile": "src/copy-keys.ts" }
507
- ```
508
-
509
- **Upload the result** so Site Manager shows it (the site's Wiring tab, the wiring score on Overview, and the Portfolio column):
510
-
511
- ```bash
512
- npx site-manager-kit audit --upload
513
- ```
514
-
515
- It needs a **Wiring audit** key from the site's API Keys tab, in `BLUEPRINT_AUDIT_KEY` (beside the website's own key, never in place of it). An audit key reads content and uploads results, and can't send forms or anything else, so it's the one to keep on a developer's computer or as a CI secret. When it's set, the audit reads with it too. A failed upload exits 2.
516
-
517
- **Deploys**: the health route sends `deployId` (`VERCEL_DEPLOYMENT_ID`, else `VERCEL_GIT_COMMIT_SHA`), so Site Manager notices each deploy and runs its own website check the same day, flagging problems the deploy brought in.
518
-
519
- Site Manager's `docs/wiring.md` explains the whole process and how to fix each kind of gap.
520
-
521
- ### Launch checklist
522
-
523
- - [ ] Every page reads its content and metadata from Site Manager, and the design is unchanged.
524
- - [ ] `sitemap.ts`, `robots.ts`, `proxy.ts`, and the revalidate and health routes are in place.
525
- - [ ] Every form uses `FormGuard`, and its page is protected in `instrumentation-client.ts`.
526
- - [ ] `<SiteAttribution />` is in the root layout, so leads record their source.
527
- - [ ] The five env vars are set in Vercel for Production and Preview.
528
- - [ ] System Status shows every check ok.
529
- - [ ] `npx site-manager-kit audit` reports nothing Not wired or missing in Site Manager.
530
- - [ ] Search Listings has every page with a target keyword, and the legal pages are published.
531
- - [ ] Go live in the wizard (this lets search engines in), then add the site to `docs/SITES.md`.
532
-
533
- ### Blog
534
-
535
- With the Blog section on, a site reads posts, categories and tags. Set the Blog posts page pattern in Site Manager (Search Listings, page addresses) to match the site's route, for example `/blog/{slug}`, so each post gets its search listing, sitemap entry and `url`.
536
-
537
- ```tsx
538
- import { blogPostingJsonLd, getPost, getPosts, pageMetadata } from "site-manager-kit";
539
-
540
- // app/blog/page.tsx
541
- const { posts, paging } = await getPosts({ page, perPage: 12 });
542
-
543
- // app/blog/[slug]/page.tsx
544
- export const generateMetadata = async ({ params }) => pageMetadata(`/blog/${(await params).slug}`);
545
- const post = await getPost(slug); // null: notFound()
546
- const ld = JSON.stringify(blogPostingJsonLd(post, { publisherName: site.business.displayName })).replace(/</g, "\\u003c");
547
- <script type="application/ld+json" dangerouslySetInnerHTML={{ __html: ld }} />
548
- ```
549
-
550
- RSS: `app/blog/rss.xml/route.ts` with
551
-
552
- ```ts
553
- import { rssRoute } from "site-manager-kit/rss-route";
554
- export const GET = rssRoute();
555
- ```
556
-
557
- Posts are in the sitemap already (Site Manager lists every published post's page). Category pages aren't pages in Site Manager; add them to `sitemap([...])` from `getBlogCategories()` if the site has them.
558
-
559
- ## Reference: content reads
560
-
561
- Available: `getSite`, `getCopy`, `copyText`, `copyImage(key)` (a built-in photo from an image key), `getReviews({ featured, sort })`, `getFloorPlans`, `getFloorPlan(slug)` (with units), `getUnitMaps()` (site plan drawings with open units placed on them), `getAmenities({ category })`, `getFees` (with `requiredMonthlyTotal`), `getPetPolicy`, `getParking`, `getSpecials`, `getEvents({ from, limit })`, `getEvent(slug)`, `getServices({ category })`, `getService(slug)`, `getLocations`, `getNearbyPlaces({ category })` (with `walkMinutes`, `driveMinutes`, `transitMinutes` and `school`; `category: "commute"` for commute times), `getForm(key)`, `getBookingSlots(key, { from, days })` (never cached), `getLegal`, `getLegalDoc(slug)`, plus the real estate reads below. Each returns `null` (or an empty list) when there's nothing, and throws `BlueprintError` when Site Manager answers with an error, for example `module_off`.
562
- Available: `getSite`, `getCopy`, `copyText`, `getReviews({ featured, sort })`, `getFloorPlans`, `getFloorPlan(slug)` (with units), `getAmenities({ category })`, `getFees` (with `requiredMonthlyTotal`), `getPetPolicy`, `getParking`, `getPolicies` (leasing policies), `getResidentResources` (the residents page sections), `pickupText` and `leaseTermsText`, `getSpecials`, `getEvents({ from, limit })`, `getEvent(slug)`, `getLocations`, `getNearbyPlaces({ category })` (with `walkMinutes`, `driveMinutes`, `transitMinutes` and `school`; `category: "commute"` for commute times), `getForm(key)`, `getBookingSlots(key, { from, days })` (never cached), `getLegal`, `getLegalDoc(slug)`, plus the real estate reads below. Each returns `null` (or an empty list) when there's nothing, and throws `BlueprintError` when Site Manager answers with an error, for example `module_off`.
563
- Available: `getSite`, `getCopy`, `copyText`, `getReviews({ featured, sort })`, `getFloorPlans`, `getFloorPlan(slug)` (with units), `getAmenities({ category })`, `getFees` (with `requiredMonthlyTotal`), `getPetPolicy`, `getParking`, `getSpecials`, `getEvents({ from, limit })`, `getEvent(slug)`, `getLocations`, `getNearbyPlaces({ category })` (with `walkMinutes`, `driveMinutes`, `transitMinutes` and `school`; `category: "commute"` for commute times), `getForm(key)`, `getBookingSlots(key, { from, days })` (never cached), `getLegal`, `getLegalDoc(slug)`, `getFaq`, `getFaqSet(key)`, `getAnnouncements`, `getDownloads`, plus the real estate reads below. Each returns `null` (or an empty list) when there's nothing, and throws `BlueprintError` when Site Manager answers with an error, for example `module_off`.
564
- Available: `getSite`, `getCopy`, `copyText`, `getReviews({ featured })`, `getFloorPlans`, `getFloorPlan(slug)` (with units), `getAmenities({ category })`, `getFees` (with `requiredMonthlyTotal`), `getPetPolicy`, `getParking`, `getSpecials`, `getEvents({ from, limit })`, `getEvent(slug)`, `getLocations`, `getNearbyPlaces({ category })` (with `walkMinutes`, `driveMinutes` and `school`), `getForm(key)`, `getBookingSlots(key, { from, days })` (never cached), `getLegal`, `getLegalDoc(slug)`, `getPosts({ page, perPage, category, tag })`, `getPost(slug)`, `getBlogCategories`, `getBlogCategory(slug, { page, perPage })`, `getBlogTags`, `getBlogFeed({ limit })`. Each returns `null` (or an empty list) when there's nothing, and throws `BlueprintError` when Site Manager answers with an error, for example `module_off`.
565
-
566
- Money is always a decimal string like `"1450.00"`. Format it with `formatMoney`, never `Number()`.
567
-
568
- `availableCount` is `null` when a floor plan lists no units: availability is unknown, not zero. Prefer `availability` (`{ status, count }`, with `count` only when the site shows exact numbers) through `availabilityText`.
569
-
570
- Floor plan helpers: `availabilityText(plan, labels?)`, `effectiveRentText(plan)` and `comparisonRows(plans)`.
571
-
572
- Real estate reads (1.6.0; listing details and past sales from 1.8.0; open houses and the agent filter from 1.10.0): `getListings({ status, minPrice, maxPrice, beds, type, featured, agent, area, limit })` (active first, each with `featuredImage`, `imageCount`, `badge`, `pricePerSqft` and its `agent`), `getListing(slug)` (adds `images`, `features`, `schools`, `history` and `transactions`: past sales, newest first, with buyer and seller only when the agent shows them), `getTeam`, `getTeamMember(slug)` and `getPaymentDefaults`. `status` takes one status or a list (`coming_soon`, `active`, `pending`, `sold`); off market listings never come back. Prices filter on the sold price for sold listings. Listings also carry `daysOnMarket`, `interior`, `exterior`, `lotDetails`, `parking`, `utilities`, `hoaName`, `hoaIncludes`, `taxes` and `openHouses` (upcoming only, each with a ready `label`); empty lists and nulls mean not known. `agent` takes a team member's short name, for an agent page with their listings. `getSoldListings({ area, agent, limit })` lists sold homes (each with `soldPrice` and `soldOn`) for a sold portfolio.
573
-
574
- Area guides (1.17.0): `getAreas()` and `getArea(slug)` return each area's `descriptionHtml`, `photos`, `highlights`, `schools` and `listingCount`; `getListings({ area: slug })` gives the homes in it. A listing is in an area when its neighborhood matches the area's name or one of its `otherNames`, ignoring case.
575
- Waitlist (1.18.0): `getWaitlistOptions()` (the plans, with whether each has a home open, and the bedroom counts), `waitlistChoices(options)` and `readWaitlistForm(formData)`; the form action is `waitlistAction` from `site-manager-kit/waitlist`.
576
-
577
- Real estate reads (1.6.0; listing details and past sales from 1.8.0; open houses and the agent filter from 1.10.0): `getListings({ status, minPrice, maxPrice, beds, type, featured, agent, limit })` (active first, each with `featuredImage`, `imageCount`, `badge`, `pricePerSqft` and its `agent`), `getListing(slug)` (adds `images`, `features`, `schools`, `history` and `transactions`: past sales, newest first, with buyer and seller only when the agent shows them), `getTeam`, `getTeamMember(slug)` and `getPaymentDefaults`. `status` takes one status or a list (`coming_soon`, `active`, `pending`, `sold`); off market listings never come back. Prices filter on the sold price for sold listings. Listings also carry `daysOnMarket`, `interior`, `exterior`, `lotDetails`, `parking`, `utilities`, `hoaName`, `hoaIncludes`, `taxes` and `openHouses` (upcoming only, each with a ready `label`); empty lists and nulls mean not known. `agent` takes a team member's short name, for an agent page with their listings.
578
-
579
- Services (1.12.0): each has `price.kind` (`none`, `fixed`, `range`, `starting_at`, `call`), `price.from` and `price.to` as decimal strings, an optional `price.note` ("per hour") and `price.label`, ready-to-show text like "Starting at $150" (null when no price is shown). `serviceJsonLd(service, site, url)` returns schema.org `Service` structured data for the page; render it in a `<script type="application/ld+json">`.
580
-
581
- Listing helpers: `listingAddress(listing)`, `listingPrice(listing)` (the sold price once sold), `bathsText(baths)` ("3.0" becomes "3"), `lotText(lot)` ("1.02 acres"), `hoaText(hoa)` ("$85 per month") and `estimatePayment(price, defaults, hoa?)`. The estimate uses whole-cent integer math and returns `null` until the site sets a rate, down payment and term in Site Manager (Listings > Settings), so show nothing rather than a guess. Always label it as an estimate.
582
-
583
- ```tsx
584
- const [listing, defaults] = await Promise.all([getListing(slug), getPaymentDefaults()]);
585
- const estimate = listing ? estimatePayment(listingPrice(listing), defaults, listing.hoa) : null;
586
- // estimate?.total is a decimal string: formatMoney(estimate.total)
587
- ```
588
-
589
- FAQ, announcements and downloads (1.15.0), for any kind of site:
590
-
591
- - `getFaq({ category })` gives `{ categories, questions }`, questions in category order. `getFaqSet(key)` gives one named set a page asks for (like `"leasing-page"`), its questions in the set's order, or `null` when there's no such set. Each question has `answerHtml` and `answerText`. `faqJsonLd(questions)` builds FAQPage structured data; render it with `jsonLdScript`, and only for the questions the page shows:
592
-
593
- ```tsx
594
- <script type="application/ld+json" dangerouslySetInnerHTML={{ __html: jsonLdScript(faqJsonLd(set.questions)) }} />
595
- ```
596
-
597
- - `getAnnouncements()` gives the notices live now, urgent first. It is never served before a start or past an end: the cached copy is used only until Site Manager's next start or end, then read fresh. Call it at request time, inside a Suspense boundary after `await connection()`, not in a prerendered shell. `announcementsLiveAt(list, now)` is the filter it uses.
598
- - `getDownloads({ category })` gives published files (`brochure`, `menu`, `application`, `price_sheet`, `other`). Link to each one's `url`: it goes through Site Manager, which counts the download and sends the file. `fileSizeText(bytes)` gives "2.4 MB".
599
-
600
- ## Development
601
-
602
- ```bash
603
- npm install
604
- npm test
605
- npm run typecheck
606
- ```
1
+ # site-manager-kit
2
+
3
+ Reads a website's content from Site Manager and gives a Next.js App Router site its metadata, sitemap, robots rules, redirects, form handling and content refreshes in a few lines. For sites hosted on Vercel. It ships TypeScript source with no build step, and its only runtime dependencies are `next`, `react` and `botid`.
4
+
5
+ ## Connect a website
6
+
7
+ For a Next.js App Router website (Next 16) hosted on Vercel. Follow the steps in order. This is the same guide as `docs/CONNECT_A_SITE.md` in Site Manager; change both together.
8
+
9
+ ### Before you start
10
+
11
+ In Site Manager, an agency admin sets up the site with **Sites > New Site** (the setup wizard). On its **Website Connection** step:
12
+
13
+ 1. Press **Create Key and Show Env Vars** and copy the block. The API key is shown once.
14
+ 2. Keep the page open. You'll come back to press **Check Connection**.
15
+
16
+ The website needs Node 22 or later.
17
+
18
+ ### 1. Install the kit
19
+
20
+ ```bash
21
+ npm i site-manager-kit@npm:@stack-mode/site-manager-kit botid --save-exact
22
+ ```
23
+
24
+ The kit is published on npm as `@stack-mode/site-manager-kit` and installed under the name `site-manager-kit`, so imports, `transpilePackages` and the `npx site-manager-kit` command all use the short name. `--save-exact` pins the version, so a new kit release never changes a live site by itself. No token or GitHub access is needed, on your machine, on Vercel or in CI.
25
+
26
+ `next.config.ts`:
27
+
28
+ ```ts
29
+ import type { NextConfig } from "next";
30
+ import { withBotId } from "botid/next/config";
31
+ import { withSiteManager } from "site-manager-kit/config";
32
+
33
+ const nextConfig: NextConfig = { transpilePackages: ["site-manager-kit"] };
34
+
35
+ export default withBotId(withSiteManager(nextConfig));
36
+ ```
37
+
38
+ The kit ships TypeScript source, so `transpilePackages` is required. To upgrade later, run the install line again (it pins the newest version), or change the version after `@stack-mode/site-manager-kit@` in `package.json` and run `npm install`.
39
+
40
+ `withSiteManager` gives every page the same baseline security headers (`nosniff`, a strict referrer policy, `X-Frame-Options: SAMEORIGIN`, HSTS and a Permissions-Policy that turns off camera, microphone and USB), stops sending `X-Powered-By`, and lets `next/image` resize Site Manager's media. Payment and location stay on, so checkout buttons and "near me" maps keep working. Options: `withSiteManager(nextConfig, { disableFeatures: ["camera", "microphone", "usb", "geolocation"], hstsSubdomains: false, frameOptions: "DENY" })`. A Content-Security-Policy stays the site's own, because every site loads different services; if it sends one, allow `https://app.mystackos.com` in `script-src` and `connect-src` for visitor tracking.
41
+
42
+ Cache Components can be on or off (new apps from `create-next-app` 16.4 turn it on). Either way, pages that only read Site Manager content are built once and refreshed when content changes, and a form page renders only its timing token per visit.
43
+
44
+ ### 2. Env vars
45
+
46
+ Paste the block from the wizard into the website's Vercel project (Production and Preview) and into `.env.local` for local work. All five are server only; never prefix them with `NEXT_PUBLIC_`.
47
+
48
+ | Name | What it is |
49
+ |---|---|
50
+ | `BLUEPRINT_URL` | Site Manager's address, for example `https://manager.example.com` |
51
+ | `BLUEPRINT_API_KEY` | The site's API key |
52
+ | `BLUEPRINT_SITE_ID` | The site's id |
53
+ | `BLUEPRINT_REVALIDATE_SECRET` | The website connection secret |
54
+ | `BLUEPRINT_FORM_SECRET` | A random secret for form timing tokens |
55
+
56
+ Add `.env.local` to `.gitignore` if it isn't already.
57
+
58
+ Working against a Site Manager running on your own machine? Set `BLUEPRINT_URL=http://localhost:3000`. Node can't resolve `*.localhost` names the way browsers do, and the key alone says which site it is.
59
+
60
+ ### 3. Refresh and health routes
61
+
62
+ `app/api/blueprint/revalidate/route.ts`:
63
+
64
+ ```ts
65
+ export { POST } from "site-manager-kit/revalidate-route";
66
+ ```
67
+
68
+ `app/api/blueprint/health/route.ts`:
69
+
70
+ ```ts
71
+ export { GET } from "site-manager-kit/health-route";
72
+ ```
73
+
74
+ Then, in the wizard's **Branding** or **StackOS** step (or the site's Connection tab), set the **Website revalidate address** to `https://<the website>/api/blueprint/revalidate`.
75
+
76
+ `app/api/blueprint/preview/route.ts`, so editors can see drafts and scheduled changes on the real site before they go live:
77
+
78
+ ```ts
79
+ export { GET } from "site-manager-kit/preview-route";
80
+ ```
81
+
82
+ And once inside `<body>` in `app/layout.tsx`, the bar that says a preview is open, with a way out:
83
+
84
+ ```tsx
85
+ import { PreviewBanner } from "site-manager-kit/preview-banner";
86
+ // ...
87
+ <PreviewBanner />
88
+ ```
89
+
90
+ Site Manager's **Preview on Website** button opens `/api/blueprint/preview?token=...`, signed with the same `BLUEPRINT_REVALIDATE_SECRET`, so there is nothing new to set up. The route turns on Next.js draft mode for that browser only and opens the page. While it's on, every read skips the cache and includes drafts; everyone else keeps seeing the published site. Links last an hour, and the preview ends when the browser closes or on **Exit preview**.
91
+
92
+ #### Visual Editor
93
+
94
+ Site Manager's **Visual Editor** shows a page of the website with its text outlined, so people can click a value and change it. Changes are saved as drafts in Site Manager; the website's code and layout never change. It needs the preview route above, plus two lines.
95
+
96
+ Once inside `<body>` in `app/layout.tsx`, next to `PreviewBanner`:
97
+
98
+ ```tsx
99
+ import { Suspense } from "react";
100
+ import { VisualEditing } from "site-manager-kit/visual-editing";
101
+ // ...
102
+ <Suspense fallback={null}>
103
+ <VisualEditing />
104
+ </Suspense>
105
+ ```
106
+
107
+ In `src/proxy.ts`, so the page can be shown inside Site Manager while the editor is open (see step 4 for the rest of the file):
108
+
109
+ ```ts
110
+ import { visualFrameHeaders } from "site-manager-kit/visual";
111
+
112
+ export async function proxy(request: NextRequest) {
113
+ return visualFrameHeaders(request, (await handleRedirect(request)) ?? NextResponse.next());
114
+ }
115
+ ```
116
+
117
+ That's all a site needs. The editor finds the site's Site Copy on the page by its words: an element whose words are exactly one value is outlined, and so is a bare run of text beside other elements (a heading's words before its accent span). Only values for the page being shown (their page in Site Copy) and values with no page (header, footer) count. Words that two values on the same page share are left unmarked, since there is no telling which one they are, and so are values shorter than three characters. Those stay editable in Site Copy.
118
+
119
+ A site's `next.config` Content-Security-Policy keeps working: `withSiteManager` sends it without its `frame-ancestors` only to a browser that opened the page from the Visual Editor, and `visualFrameHeaders` then allows that one Site Manager address. `X-Frame-Options` stays on every response. A site that builds its policy in the proxy instead uses `frameAncestors(request)` for its `frame-ancestors` value.
120
+
121
+ To mark a value by hand (a value the words can't find, or to be exact), use `editMarks()`. A hand mark always wins over a found one:
122
+
123
+ ```tsx
124
+ import { editMarks } from "site-manager-kit/visual";
125
+
126
+ const mark = await editMarks();
127
+ <h1 {...mark("copy/hero_title")}>{title}</h1>
128
+ ```
129
+
130
+ A ref is `copy/<copy key>`. Marks, the editor script and the frame permission exist only for a browser that opened the page from the Visual Editor (a signed link that lasts an hour); visitors get none of them and the site keeps `X-Frame-Options: SAMEORIGIN`. An element with only text in it is edited in place; anything else, and a run of text beside other elements, opens a side panel in Site Manager. In visual mode, `getCopy()` (and the helpers built on it) adds an invisible tag to each text and rich value, which the editor reads and removes, so a value is found exactly wherever it shows. Code that compares or slices Site Copy text should use `getCopyUntagged()` for that, or compare after `stripTags()` from `site-manager-kit/visual-tags`. A site that marks every value itself can turn the word matching off with `<VisualEditing autoMark={false} />`. While a plain text value is open in the side panel, the page shows the words as they're typed (unsaved, only in that browser); rich text changes on save.
131
+
132
+ A value that comes from another section of Site Manager (a floor plan's name, the phone number, an FAQ answer) is marked `link/<section key>`, for example `mark("link/floor_plans")`. Clicking it in the Visual Editor offers a link to that section instead of an edit.
133
+
134
+ #### Copy keys
135
+
136
+ Declare every piece of text and every photo the site is built with in `src/copy-keys.ts`, with its starting text, and read it through those names so a key is never typed twice:
137
+
138
+ ```ts
139
+ import { defineCopyKeys } from "site-manager-kit/copy-keys";
140
+
141
+ export const COPY = defineCopyKeys({
142
+ "home.hero.title": { label: "Home page headline", kind: "text", maxLength: 80, text: "Welcome home" },
143
+ "home.intro": { label: "Home page introduction", kind: "rich", text: "<p>Our story...</p>" },
144
+ "home.hero.photo": { label: "Home page photo", kind: "image", image: "/images/hero.jpg", alt: "The pool at dusk" },
145
+ });
146
+ ```
147
+
148
+ Keys are lowercase letters and numbers separated by dots, dashes or underscores; labels are written for the client, since they see them in Site Manager. An image is a path to a file in `public/`. A bad key fails the build.
149
+
150
+ Serve them to Site Manager in `app/api/blueprint/copy-keys/route.ts`, signed like the health route:
151
+
152
+ ```ts
153
+ import { copyKeysRoute } from "site-manager-kit/copy-keys-route";
154
+ import { COPY } from "@/copy-keys";
155
+
156
+ export const GET = copyKeysRoute(COPY);
157
+ ```
158
+
159
+ Name keys by where they sit on the page, so Site Manager's Pages shows them in named sections and item cards: `<page>.<section>.<field>`, and `<page>.<section>.<list>.<n>.<field>` for repeating items, `n` counting from 1 (`home.process.steps.2.title`). Pages names a section from its own `label`, `eyebrow`, `title` or `heading` text and an item from its `title`, `label` or `name`, so give them one where it fits.
160
+
161
+ A list whose items people may move, add or remove takes a rule in the second argument (1.26.0). `addRemove` also allows reordering, and works on the item slots you declare, so declare as many as the page has room for and show only items with something in them. Leave out any list where something about an item stays in code by position, like an icon:
162
+
163
+ ```ts
164
+ export const COPY = defineCopyKeys(
165
+ {
166
+ "home.process.steps.1.title": { label: "Home page, process, step, title", kind: "text", text: "Call us" },
167
+ "home.process.steps.2.title": { label: "Home page, process, step, title", kind: "text", text: "" },
168
+ },
169
+ { lists: { "home.process.steps": { addRemove: true } } },
170
+ );
171
+ ```
172
+
173
+ In Site Manager, **Site Copy > Sync from Website** creates the declared keys with their starting text, updates labels and limits, and fills empty values. It never overwrites text someone changed and never deletes a key. After launch, the starting text is only used for new or empty keys.
174
+
175
+ ### 4. Sitemap, robots and redirects
176
+
177
+ `app/sitemap.ts`:
178
+
179
+ ```ts
180
+ import { sitemap } from "site-manager-kit";
181
+
182
+ export default function Sitemap() {
183
+ return sitemap();
184
+ }
185
+ ```
186
+
187
+ `app/robots.ts`:
188
+
189
+ ```ts
190
+ import { robots } from "site-manager-kit";
191
+
192
+ export default function Robots() {
193
+ return robots();
194
+ }
195
+ ```
196
+
197
+ `src/proxy.ts` (or `proxy.ts` at the root when there's no `src` folder):
198
+
199
+ ```ts
200
+ import { NextResponse, type NextRequest } from "next/server";
201
+ import { handleRedirect } from "site-manager-kit/redirects";
202
+
203
+ export async function proxy(request: NextRequest) {
204
+ return (await handleRedirect(request)) ?? NextResponse.next();
205
+ }
206
+
207
+ export const config = { matcher: ["/((?!_next|api|favicon.ico).*)"] };
208
+ ```
209
+
210
+ Robots disallows everything until the site goes live in the wizard, so a site under construction never gets indexed.
211
+
212
+ ### 5. Metadata on every page
213
+
214
+ ```ts
215
+ import { pageMetadata } from "site-manager-kit";
216
+
217
+ export const generateMetadata = () => pageMetadata("/floor-plans");
218
+ ```
219
+
220
+ For a detail page, build the path from the params:
221
+
222
+ ```ts
223
+ export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }) {
224
+ const { slug } = await params;
225
+ return pageMetadata(`/floor-plans/${slug}`);
226
+ }
227
+ ```
228
+
229
+ Each path must match the page's address on the live site, because that is the key Search Listings uses.
230
+
231
+ ### Missing pages and IndexNow
232
+
233
+ Report visits to addresses the site doesn't have, so Site Manager can list them under Redirects, Not Found:
234
+
235
+ ```tsx
236
+ // app/api/not-found/route.ts
237
+ export { notFoundRoute as POST } from "site-manager-kit/not-found";
238
+
239
+ // app/not-found.tsx, anywhere in the page
240
+ import { NotFoundReporter } from "site-manager-kit/not-found-reporter";
241
+ <NotFoundReporter />
242
+ ```
243
+
244
+ Serve the IndexNow key from `src/proxy.ts`, before redirects: `const key = await handleIndexNow(request); if (key) return key;` (import from `site-manager-kit/indexnow`). It answers only `/{key}.txt` for Site Manager's key.
245
+
246
+ ### Structured data
247
+
248
+ Search engines read a summary of the business from every page. Build it from the site you already fetched and write it with `<StructuredData />` in the root layout; record pages add their own type and breadcrumbs:
249
+
250
+ ```tsx
251
+ import { StructuredData, businessJsonLd, breadcrumbJsonLd, breadcrumbsFor, eventJsonLd } from "site-manager-kit";
252
+
253
+ // app/layout.tsx
254
+ <StructuredData blocks={[businessJsonLd(site)]} />
255
+
256
+ // app/events/[slug]/page.tsx
257
+ const url = `${site.websiteUrl}/events/${event.slug}`;
258
+ <StructuredData blocks={[eventJsonLd(event, site, url), breadcrumbJsonLd(breadcrumbsFor(`/events/${event.slug}`, site.websiteUrl!, { "/events": "Events", [`/events/${event.slug}`]: event.title }))]} />
259
+ ```
260
+
261
+ `listingJsonLd` and `floorPlanJsonLd` work the same way. Empty facts are left out. Search Listings' structured data check reads the result.
262
+
263
+ ### 6. Content
264
+
265
+ Replace hardcoded content with kit reads, one page at a time. Keep the design exactly as it is; only where the text and numbers come from changes.
266
+
267
+ ```tsx
268
+ import { copyText, formatMoney, getFloorPlans, getSite } from "site-manager-kit";
269
+
270
+ export default async function Home() {
271
+ const [site, plans] = await Promise.all([getSite(), getFloorPlans()]);
272
+ return (
273
+ <>
274
+ <h1>{await copyText("home.hero.title", site?.business.displayName)}</h1>
275
+ {plans.map((p) => (
276
+ <p key={p.slug}>
277
+ {p.name} from {formatMoney(p.startingRent) ?? "Call for pricing"}
278
+ </p>
279
+ ))}
280
+ </>
281
+ );
282
+ }
283
+ ```
284
+
285
+ Images from Site Manager come with their size and alt text. Show them with `next/image`, which serves resized, modern versions:
286
+
287
+ ```tsx
288
+ import Image from "next/image";
289
+ import { imageProps } from "site-manager-kit";
290
+
291
+ const img = imageProps(plan.image);
292
+ {img ? <Image {...img} sizes="(min-width: 768px) 50vw, 100vw" /> : null}
293
+ ```
294
+
295
+ Without a known size, `imageProps` uses `fill`, so the image needs a sized box with `position: relative`.
296
+
297
+ Visitor tracking: add `<SiteAnalytics />` once inside `<body>` in `app/layout.tsx`. It adds StackOS's tracking script with the key the agency sets on the site's Connection tab, and adds nothing while there's no key.
298
+
299
+ ```tsx
300
+ import { SiteAnalytics } from "site-manager-kit/analytics";
301
+ import { SiteAttribution } from "site-manager-kit/attribution";
302
+
303
+ <body>
304
+ {children}
305
+ <SiteAnalytics />
306
+ <SiteAttribution />
307
+ </body>
308
+ ```
309
+
310
+ Where leads came from: `<SiteAttribution />` remembers the visitor's first `utm_` values, landing page and referring site for the browser tab (session storage, no cookie, so no consent banner), and `FormGuard` sends them with every form. Leads then show under their source in Site Manager (Leads > Leads by Source). Links made with Link Tools carry the `utm_` values.
311
+
312
+
313
+ The full list of reads is in the kit README. Rules that matter:
314
+
315
+ - Money is a decimal string like `"1450.00"`. Show it with `formatMoney`, never `Number()`.
316
+ - Show availability with `availabilityText(plan)`, which follows the site's setting in Site Manager: "Available now" by default, the exact count only when the site chose it, and nothing when it's unknown. Don't print `availableCount` yourself; it's kept for older sites and is `null` when availability is unknown, never 0.
317
+ - A plan with a running special has `concession.effectiveRent`. Show it next to the regular rent, never instead of it: `effectiveRentText(plan)` gives "$2,207.12 effective".
318
+ - To compare plans side by side, `comparisonRows(plans)` gives one row per fact (bedrooms, size, rent, layout, availability) and per feature, ready for a table.
319
+ - A section that is turned off for the site answers with `BlueprintError` code `module_off`. Only read sections the site has on.
320
+
321
+ ### 7. Forms
322
+
323
+ A form is a small client component, so it can show the result, plus a server page that hands it `FormGuard` (the guard signs its render time on the server).
324
+
325
+ `app/contact/ContactForm.tsx`:
326
+
327
+ ```tsx
328
+ "use client";
329
+
330
+ import { useActionState, type ReactNode } from "react";
331
+ import { formAction } from "site-manager-kit/forms";
332
+
333
+ export function ContactForm({ guard }: { guard: ReactNode }) {
334
+ const [state, action, pending] = useActionState(formAction.bind(null, "inquiry"), null);
335
+ if (state?.ok) return <p>{state.successMessage}</p>;
336
+ return (
337
+ <form action={action}>
338
+ <input name="name" required />
339
+ <input name="email" type="email" required />
340
+ <textarea name="message" />
341
+ {guard}
342
+ {state && !state.ok ? <p>{state.error === "invalid" ? "Check the highlighted fields." : "That didn't send. Try again in a moment."}</p> : null}
343
+ <button disabled={pending}>Send</button>
344
+ </form>
345
+ );
346
+ }
347
+ ```
348
+
349
+ `app/contact/page.tsx`:
350
+
351
+ ```tsx
352
+ import { FormGuard } from "site-manager-kit/form-guard";
353
+ import { ContactForm } from "./ContactForm";
354
+
355
+ export default function Contact() {
356
+ return <ContactForm guard={<FormGuard />} />;
357
+ }
358
+ ```
359
+
360
+ The field names must match the form's fields in Site Manager (Forms > the form). For phone fields use `<PhoneInput name="phone" />` from `site-manager-kit/phone`: it formats as the visitor types, (555) 123-4567, the format Site Manager checks and stores. A field's `help` (from `getForm`) is a short line to show under it. `state.error` is `invalid` (with `state.fields` naming each field's problem), `rate_limited`, `slot_taken` (booking forms) or `unavailable`. Suspected spam answers like a normal submission and lands in Site Manager's spam folder. Don't pass `submitForm` straight to `<form action>`: it returns a result, which a plain form action can't. A form with its own signed timer, or a send with no form (a remembered visitor's one-click download), passes `submitForm(key, data, { timedBySite: true })` after the site's own checks: the FormGuard timer is skipped, the honeypot and BotID still apply.
361
+
362
+ #### Booking forms
363
+
364
+ A form whose kind is Booking (set by the agency in Site Manager) also needs one of the site's open times, set under Forms > Booking Times. Read them with `getBookingSlots` (never cached, since every booking changes them) and send the chosen `start` as the `bp_slot` field:
365
+
366
+ ```tsx
367
+ // app/tour/page.tsx
368
+ import { Suspense } from "react";
369
+ import { connection } from "next/server";
370
+ import { getBookingSlots } from "site-manager-kit";
371
+ import { FormGuard } from "site-manager-kit/form-guard";
372
+ import { TourForm } from "./TourForm";
373
+
374
+ export default function Tour() {
375
+ // With Cache Components on, only the open times render per visit; the rest of the page stays static.
376
+ return (
377
+ <Suspense fallback={<p>Finding open times...</p>}>
378
+ <Times />
379
+ </Suspense>
380
+ );
381
+ }
382
+
383
+ async function Times() {
384
+ await connection();
385
+ const slots = await getBookingSlots("tour", { days: 14 });
386
+ return <TourForm days={slots?.days ?? []} guard={<FormGuard />} />;
387
+ }
388
+ ```
389
+
390
+ ```tsx
391
+ // app/tour/TourForm.tsx, inside the <form> from the contact example
392
+ {days.map((d) => (
393
+ <fieldset key={d.date}>
394
+ <legend>{d.date}</legend>
395
+ {d.slots.map((s) => (
396
+ <label key={s.start}>
397
+ <input type="radio" name="bp_slot" value={s.start} required /> {s.label}
398
+ </label>
399
+ ))}
400
+ </fieldset>
401
+ ))}
402
+ ```
403
+
404
+ `state.error` can also be `slot_taken` when someone booked that time a moment earlier: show the times again. Times are in the site's time zone (`slots.timezone`), and a day with no open times has an empty `slots` list.
405
+
406
+ Protect each page that posts a form with BotID, in `instrumentation-client.ts`. It goes in `src/` when the app has a `src` folder, and at the project root otherwise; in the wrong place it's silently ignored and BotID warns about a misconfiguration:
407
+
408
+ ```ts
409
+ import { initBotId } from "botid/client/core";
410
+
411
+ initBotId({ protect: [{ path: "/contact", method: "POST" }] });
412
+ ```
413
+
414
+ ### Waitlist (apartment sites)
415
+
416
+ With the Waitlist section on in Site Manager, visitors can ask to hear when a floor plan, or any plan with their bedroom count, has a home available. Site Manager emails them once when it opens, with a link to leave the list. The form uses `FormGuard` and the same spam checks as other forms.
417
+
418
+ On a plan page, send the plan's slug in a hidden field. A general form can offer one picker from `waitlistChoices(await getWaitlistOptions())`, which lists each bedroom count and the plans with nothing open now.
419
+
420
+ ```tsx
421
+ "use client";
422
+
423
+ import { useActionState, type ReactNode } from "react";
424
+ import { waitlistAction } from "site-manager-kit/waitlist";
425
+
426
+ export function WaitlistForm({ plan, guard }: { plan: string; guard: ReactNode }) {
427
+ const [state, action, pending] = useActionState(waitlistAction, null);
428
+ if (state?.ok) return <p>You're on the list. We'll email you when one opens.</p>;
429
+ return (
430
+ <form action={action}>
431
+ <input type="hidden" name="floorPlan" value={plan} />
432
+ <input name="email" type="email" required />
433
+ <input name="moveIn" type="date" />
434
+ {guard}
435
+ <button disabled={pending}>Notify Me</button>
436
+ </form>
437
+ );
438
+ }
439
+ ```
440
+
441
+ Fields: `floorPlan` (a slug) or `bedrooms` (a number), or one `choice` (`plan:<slug>` or `beds:<n>`), plus `email`, and optionally `name`, `phone` and `moveIn` (`YYYY-MM-DD`). `state.error` is `invalid` with `fields`, `rate_limited` or `unavailable`. Add the page to BotID's `protect` list like any form page.
442
+
443
+ ### Website chat
444
+
445
+ With Website Chat on and live in Site Manager (kit 1.25.0), a chat button shows on every page. It answers visitors only from the site's published content in Site Manager, says when it doesn't know and gives the contact details, and turns visitors who want a tour, a quote or a call back into leads with the conversation attached. It runs on the site's own AI key, set in Site Manager.
446
+
447
+ Add the route the chat talks to. It keeps the API key on the server, sends Site Manager only a hash of the visitor's address, and refuses requests that don't come from the website's own pages:
448
+
449
+ ```ts
450
+ // app/api/blueprint/chat/route.ts
451
+ export { GET, POST } from "site-manager-kit/chat-route";
452
+ ```
453
+
454
+ Then put the component once inside `<body>` in `app/layout.tsx`. It renders nothing until the chat is live, and nothing when Website Chat is off for the site:
455
+
456
+ ```tsx
457
+ import { SiteChat } from "site-manager-kit/chat";
458
+
459
+ <SiteChat />
460
+ ```
461
+
462
+ It takes the site's accent from Site Manager and follows the visitor's light or dark setting. Props, all optional: `accent` (a CSS color), `theme` (`"auto"`, `"light"` or `"dark"`), `position` (`"right"` or `"left"`), `offset` (pixels to raise it above a cookie bar) and `endpoint` (when the route lives somewhere else). Text on the accent switches between dark and white so a light accent stays readable.
463
+
464
+ - Replies stream in as they're written. The conversation follows the visitor from page to page in the same tab, and a random visitor id in the browser's storage is the only thing kept there; Site Manager stores only its hash.
465
+ - When a visitor wants a person, the chat offers a short form (name, email or phone, message). With Booking Times on and a booking form chosen in Site Manager, it offers open times and books one. Leads arrive like form submissions, with the source "Website chat".
466
+ - Add the route to BotID's `protect` list to check for bots before Site Manager is asked: `{ path: "/api/blueprint/chat", method: "POST" }`. Site Manager limits each visitor and the site either way.
467
+ - `getChatConfig()` (from `site-manager-kit/chat`) reads the settings if the site builds its own chat. The route's answers are NDJSON lines: `start` with the conversation id, `delta` with text, and `done` saying whether it couldn't answer and whether to offer a hand-off (`parseChatLines` reads them).
468
+
469
+ ### 8. Caching
470
+
471
+ - Every kit read is cached for an hour and refreshed the moment content changes in Site Manager, through the revalidate route. Don't add your own `revalidate` or `cache: "no-store"` around kit reads.
472
+ - A refresh never makes a visitor wait: the first visit after a change gets the page as it was and rebuilds it in the background, and the next visit shows the change. Site Manager refreshes only the sections a change affects.
473
+ - Pages that only use kit reads can stay static. Don't call `cookies()` or `headers()` on them unless the page needs it.
474
+ - Redirects are cached for 5 minutes in the proxy and cleared when they change.
475
+ - After a deploy, the first visit to each page fills the cache. Nothing needs warming.
476
+ - Pages are prerendered from Site Manager at build time, so a build needs Site Manager to be reachable. If a build fails with `fetch failed`, check `BLUEPRINT_URL`.
477
+ - A page with a form renders on each request (`FormGuard` does this), so its timing check always has a fresh render time.
478
+
479
+ ### 9. Check it
480
+
481
+ 1. `npm run build` passes.
482
+ 2. Deploy (or run `npm run dev` with the env vars) and open the site. Content shows, and `/sitemap.xml` and `/robots.txt` load.
483
+ 3. In the wizard, press **Check Connection**. Both "connected" and "bot protection, honeypot and timing checks are on" must show.
484
+ 4. Change a piece of site copy in Site Manager and reload the page: the new text shows within a few seconds.
485
+ 5. Send a test form from a Link Tools link and confirm it arrives in Leads under that link's source.
486
+
487
+ ### 10. Wiring audit
488
+
489
+ Run it after a new build, after adding or changing a page or section, and before Go live:
490
+
491
+ ```bash
492
+ npx site-manager-kit audit
493
+ ```
494
+
495
+ It reads `BLUEPRINT_URL` and `BLUEPRINT_API_KEY` from `.env.local` (or `--env <file>`; use the production values, it only reads), loads `src/copy-keys.ts` (`--keys <file>` for another), and writes `.wiring/report.md`. Every item is one of:
496
+
497
+ - **Wired**: the site reads it from Site Manager.
498
+ - **Not wired**: Site Manager has it, but editing it changes nothing on the website (a key the site never asks for, a section that's on but never read, a Business Info field the code ignores). Fails the audit.
499
+ - **Missing in Site Manager**: the site uses it, but Site Manager has no such field (a key not synced yet, a form key with no form). Fails the audit.
500
+ - **Hard-coded**: the code keeps its own copy of a Site Manager value, with file and line. Reported, never failing, since a fallback can be on purpose.
501
+ - **Unknown**: the audit couldn't tell (the copy keys file didn't load, a section it doesn't know yet).
502
+
503
+ Exit codes: 0 wired, 1 something Not wired or missing, 2 it couldn't run. Folders it shouldn't scan (an old admin, scripts) go in `package.json`:
504
+
505
+ ```json
506
+ "siteManagerAudit": { "ignore": ["src/admin"], "keysFile": "src/copy-keys.ts" }
507
+ ```
508
+
509
+ **Upload the result** so Site Manager shows it (the site's Wiring tab, the wiring score on Overview, and the Portfolio column):
510
+
511
+ ```bash
512
+ npx site-manager-kit audit --upload
513
+ ```
514
+
515
+ It needs a **Wiring audit** key from the site's API Keys tab, in `BLUEPRINT_AUDIT_KEY` (beside the website's own key, never in place of it). An audit key reads content and uploads results, and can't send forms or anything else, so it's the one to keep on a developer's computer or as a CI secret. When it's set, the audit reads with it too. A failed upload exits 2.
516
+
517
+ **Deploys**: the health route sends `deployId` (`VERCEL_DEPLOYMENT_ID`, else `VERCEL_GIT_COMMIT_SHA`), so Site Manager notices each deploy and runs its own website check the same day, flagging problems the deploy brought in.
518
+
519
+ Site Manager's `docs/wiring.md` explains the whole process and how to fix each kind of gap.
520
+
521
+ ### Launch checklist
522
+
523
+ - [ ] Every page reads its content and metadata from Site Manager, and the design is unchanged.
524
+ - [ ] `sitemap.ts`, `robots.ts`, `proxy.ts`, and the revalidate and health routes are in place.
525
+ - [ ] Every form uses `FormGuard`, and its page is protected in `instrumentation-client.ts`.
526
+ - [ ] `<SiteAttribution />` is in the root layout, so leads record their source.
527
+ - [ ] The five env vars are set in Vercel for Production and Preview.
528
+ - [ ] System Status shows every check ok.
529
+ - [ ] `npx site-manager-kit audit` reports nothing Not wired or missing in Site Manager.
530
+ - [ ] Search Listings has every page with a target keyword, and the legal pages are published.
531
+ - [ ] Go live in the wizard (this lets search engines in), then add the site to `docs/SITES.md`.
532
+
533
+ ### Blog
534
+
535
+ With the Blog section on, a site reads posts, categories and tags. Set the Blog posts page pattern in Site Manager (Search Listings, page addresses) to match the site's route, for example `/blog/{slug}`, so each post gets its search listing, sitemap entry and `url`.
536
+
537
+ ```tsx
538
+ import { blogPostingJsonLd, getPost, getPosts, pageMetadata } from "site-manager-kit";
539
+
540
+ // app/blog/page.tsx
541
+ const { posts, paging } = await getPosts({ page, perPage: 12 });
542
+
543
+ // app/blog/[slug]/page.tsx
544
+ export const generateMetadata = async ({ params }) => pageMetadata(`/blog/${(await params).slug}`);
545
+ const post = await getPost(slug); // null: notFound()
546
+ const ld = JSON.stringify(blogPostingJsonLd(post, { publisherName: site.business.displayName })).replace(/</g, "\\u003c");
547
+ <script type="application/ld+json" dangerouslySetInnerHTML={{ __html: ld }} />
548
+ ```
549
+
550
+ RSS: `app/blog/rss.xml/route.ts` with
551
+
552
+ ```ts
553
+ import { rssRoute } from "site-manager-kit/rss-route";
554
+ export const GET = rssRoute();
555
+ ```
556
+
557
+ Posts are in the sitemap already (Site Manager lists every published post's page). Category pages aren't pages in Site Manager; add them to `sitemap([...])` from `getBlogCategories()` if the site has them.
558
+
559
+ ## Reference: content reads
560
+
561
+ Available: `getSite`, `getCopy`, `copyText`, `copyImage(key)` (a built-in photo from an image key), `getReviews({ featured, sort })`, `getFloorPlans`, `getFloorPlan(slug)` (with units), `getUnitMaps()` (site plan drawings with open units placed on them), `getAmenities({ category })`, `getFees` (with `requiredMonthlyTotal`), `getPetPolicy`, `getParking`, `getSpecials`, `getEvents({ from, limit })`, `getEvent(slug)`, `getServices({ category })`, `getService(slug)`, `getLocations`, `getNearbyPlaces({ category })` (with `walkMinutes`, `driveMinutes`, `transitMinutes` and `school`; `category: "commute"` for commute times), `getForm(key)`, `getBookingSlots(key, { from, days })` (never cached), `getLegal`, `getLegalDoc(slug)`, plus the real estate reads below. Each returns `null` (or an empty list) when there's nothing, and throws `BlueprintError` when Site Manager answers with an error, for example `module_off`.
562
+ Available: `getSite`, `getCopy`, `copyText`, `getReviews({ featured, sort })`, `getFloorPlans`, `getFloorPlan(slug)` (with units), `getAmenities({ category })`, `getFees` (with `requiredMonthlyTotal`), `getPetPolicy`, `getParking`, `getPolicies` (leasing policies), `getResidentResources` (the residents page sections), `pickupText` and `leaseTermsText`, `getSpecials`, `getEvents({ from, limit })`, `getEvent(slug)`, `getLocations`, `getNearbyPlaces({ category })` (with `walkMinutes`, `driveMinutes`, `transitMinutes` and `school`; `category: "commute"` for commute times), `getForm(key)`, `getBookingSlots(key, { from, days })` (never cached), `getLegal`, `getLegalDoc(slug)`, plus the real estate reads below. Each returns `null` (or an empty list) when there's nothing, and throws `BlueprintError` when Site Manager answers with an error, for example `module_off`.
563
+ Available: `getSite`, `getCopy`, `copyText`, `getReviews({ featured, sort })`, `getFloorPlans`, `getFloorPlan(slug)` (with units), `getAmenities({ category })`, `getFees` (with `requiredMonthlyTotal`), `getPetPolicy`, `getParking`, `getSpecials`, `getEvents({ from, limit })`, `getEvent(slug)`, `getLocations`, `getNearbyPlaces({ category })` (with `walkMinutes`, `driveMinutes`, `transitMinutes` and `school`; `category: "commute"` for commute times), `getForm(key)`, `getBookingSlots(key, { from, days })` (never cached), `getLegal`, `getLegalDoc(slug)`, `getFaq`, `getFaqSet(key)`, `getAnnouncements`, `getDownloads`, plus the real estate reads below. Each returns `null` (or an empty list) when there's nothing, and throws `BlueprintError` when Site Manager answers with an error, for example `module_off`.
564
+ Available: `getSite`, `getCopy`, `copyText`, `getReviews({ featured })`, `getFloorPlans`, `getFloorPlan(slug)` (with units), `getAmenities({ category })`, `getFees` (with `requiredMonthlyTotal`), `getPetPolicy`, `getParking`, `getSpecials`, `getEvents({ from, limit })`, `getEvent(slug)`, `getLocations`, `getNearbyPlaces({ category })` (with `walkMinutes`, `driveMinutes` and `school`), `getForm(key)`, `getBookingSlots(key, { from, days })` (never cached), `getLegal`, `getLegalDoc(slug)`, `getPosts({ page, perPage, category, tag })`, `getPost(slug)`, `getBlogCategories`, `getBlogCategory(slug, { page, perPage })`, `getBlogTags`, `getBlogFeed({ limit })`. Each returns `null` (or an empty list) when there's nothing, and throws `BlueprintError` when Site Manager answers with an error, for example `module_off`.
565
+
566
+ Money is always a decimal string like `"1450.00"`. Format it with `formatMoney`, never `Number()`.
567
+
568
+ `availableCount` is `null` when a floor plan lists no units: availability is unknown, not zero. Prefer `availability` (`{ status, count }`, with `count` only when the site shows exact numbers) through `availabilityText`.
569
+
570
+ Floor plan helpers: `availabilityText(plan, labels?)`, `effectiveRentText(plan)` and `comparisonRows(plans)`.
571
+
572
+ Real estate reads (1.6.0; listing details and past sales from 1.8.0; open houses and the agent filter from 1.10.0): `getListings({ status, minPrice, maxPrice, beds, type, featured, agent, area, limit })` (active first, each with `featuredImage`, `imageCount`, `badge`, `pricePerSqft` and its `agent`), `getListing(slug)` (adds `images`, `features`, `schools`, `history` and `transactions`: past sales, newest first, with buyer and seller only when the agent shows them), `getTeam`, `getTeamMember(slug)` and `getPaymentDefaults`. `status` takes one status or a list (`coming_soon`, `active`, `pending`, `sold`); off market listings never come back. Prices filter on the sold price for sold listings. Listings also carry `daysOnMarket`, `interior`, `exterior`, `lotDetails`, `parking`, `utilities`, `hoaName`, `hoaIncludes`, `taxes` and `openHouses` (upcoming only, each with a ready `label`); empty lists and nulls mean not known. `agent` takes a team member's short name, for an agent page with their listings. `getSoldListings({ area, agent, limit })` lists sold homes (each with `soldPrice` and `soldOn`) for a sold portfolio.
573
+
574
+ Area guides (1.17.0): `getAreas()` and `getArea(slug)` return each area's `descriptionHtml`, `photos`, `highlights`, `schools` and `listingCount`; `getListings({ area: slug })` gives the homes in it. A listing is in an area when its neighborhood matches the area's name or one of its `otherNames`, ignoring case.
575
+ Waitlist (1.18.0): `getWaitlistOptions()` (the plans, with whether each has a home open, and the bedroom counts), `waitlistChoices(options)` and `readWaitlistForm(formData)`; the form action is `waitlistAction` from `site-manager-kit/waitlist`.
576
+
577
+ Real estate reads (1.6.0; listing details and past sales from 1.8.0; open houses and the agent filter from 1.10.0): `getListings({ status, minPrice, maxPrice, beds, type, featured, agent, limit })` (active first, each with `featuredImage`, `imageCount`, `badge`, `pricePerSqft` and its `agent`), `getListing(slug)` (adds `images`, `features`, `schools`, `history` and `transactions`: past sales, newest first, with buyer and seller only when the agent shows them), `getTeam`, `getTeamMember(slug)` and `getPaymentDefaults`. `status` takes one status or a list (`coming_soon`, `active`, `pending`, `sold`); off market listings never come back. Prices filter on the sold price for sold listings. Listings also carry `daysOnMarket`, `interior`, `exterior`, `lotDetails`, `parking`, `utilities`, `hoaName`, `hoaIncludes`, `taxes` and `openHouses` (upcoming only, each with a ready `label`); empty lists and nulls mean not known. `agent` takes a team member's short name, for an agent page with their listings.
578
+
579
+ Services (1.12.0): each has `price.kind` (`none`, `fixed`, `range`, `starting_at`, `call`), `price.from` and `price.to` as decimal strings, an optional `price.note` ("per hour") and `price.label`, ready-to-show text like "Starting at $150" (null when no price is shown). `serviceJsonLd(service, site, url)` returns schema.org `Service` structured data for the page; render it in a `<script type="application/ld+json">`.
580
+
581
+ Listing helpers: `listingAddress(listing)`, `listingPrice(listing)` (the sold price once sold), `bathsText(baths)` ("3.0" becomes "3"), `lotText(lot)` ("1.02 acres"), `hoaText(hoa)` ("$85 per month") and `estimatePayment(price, defaults, hoa?)`. The estimate uses whole-cent integer math and returns `null` until the site sets a rate, down payment and term in Site Manager (Listings > Settings), so show nothing rather than a guess. Always label it as an estimate.
582
+
583
+ ```tsx
584
+ const [listing, defaults] = await Promise.all([getListing(slug), getPaymentDefaults()]);
585
+ const estimate = listing ? estimatePayment(listingPrice(listing), defaults, listing.hoa) : null;
586
+ // estimate?.total is a decimal string: formatMoney(estimate.total)
587
+ ```
588
+
589
+ FAQ, announcements and downloads (1.15.0), for any kind of site:
590
+
591
+ - `getFaq({ category })` gives `{ categories, questions }`, questions in category order. `getFaqSet(key)` gives one named set a page asks for (like `"leasing-page"`), its questions in the set's order, or `null` when there's no such set. Each question has `answerHtml` and `answerText`. `faqJsonLd(questions)` builds FAQPage structured data; render it with `jsonLdScript`, and only for the questions the page shows:
592
+
593
+ ```tsx
594
+ <script type="application/ld+json" dangerouslySetInnerHTML={{ __html: jsonLdScript(faqJsonLd(set.questions)) }} />
595
+ ```
596
+
597
+ - `getAnnouncements()` gives the notices live now, urgent first. It is never served before a start or past an end: the cached copy is used only until Site Manager's next start or end, then read fresh. Call it at request time, inside a Suspense boundary after `await connection()`, not in a prerendered shell. `announcementsLiveAt(list, now)` is the filter it uses.
598
+ - `getDownloads({ category })` gives published files (`brochure`, `menu`, `application`, `price_sheet`, `other`). Link to each one's `url`: it goes through Site Manager, which counts the download and sends the file. `fileSizeText(bytes)` gives "2.4 MB".
599
+
600
+ ## Development
601
+
602
+ ```bash
603
+ npm install
604
+ npm test
605
+ npm run typecheck
606
+ ```