@stack-mode/site-manager-kit 0.0.0-stage → 1.44.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/README.md +606 -3
  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 +72 -6
  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,30 @@
1
+ import { getCopyUntagged } from "./data";
2
+ import { KIT_VERSION } from "./version";
3
+ import { VisualBridge } from "./visual-bridge-client";
4
+ import { copyTexts, type CopyText } from "./visual-match";
5
+ import { visualMode } from "./visual-mode";
6
+
7
+ /**
8
+ * Put once inside <body> in app/layout.tsx, inside a Suspense boundary:
9
+ *
10
+ * <Suspense fallback={null}><VisualEditing /></Suspense>
11
+ *
12
+ * Renders nothing unless this browser opened the page from Site Manager's Visual Editor. Then it outlines the values
13
+ * Site Manager stores and passes clicks and edits to Site Manager, which saves them as drafts.
14
+ *
15
+ * Site Copy read through getCopy() carries exact tags in visual mode, so a site needs no marks of its own. Values a
16
+ * site reads some other way are found by their words; `autoMark={false}` turns that off for a site that marks every
17
+ * value itself with editMarks().
18
+ */
19
+ export async function VisualEditing({ autoMark = true }: { autoMark?: boolean } = {}) {
20
+ try {
21
+ const ok = await visualMode();
22
+ if (!ok) return null;
23
+ // Untagged words for matching values a site reads some other way; values read through getCopy() carry exact tags.
24
+ let copy: CopyText[] = [];
25
+ if (autoMark) copy = copyTexts(await getCopyUntagged().catch(() => ({})));
26
+ return <VisualBridge adminOrigin={ok.adminOrigin} kit={KIT_VERSION} copy={copy} />;
27
+ } catch {
28
+ return null;
29
+ }
30
+ }
@@ -0,0 +1,13 @@
1
+ /*
2
+ * In Edit mode the page's links, buttons and forms don't act: a click selects the words, it never follows a link or
3
+ * submits a form. Browse mode lets them act. Pure, so it's checked without a browser.
4
+ */
5
+
6
+ export const ACTIVATES =
7
+ 'a[href], button, input[type="submit"], input[type="image"], input[type="reset"], input[type="button"], [role="link"], [role="button"], summary';
8
+
9
+ /** True when a click, Enter or Space on this target would follow a link, press a button or submit a form. */
10
+ export function blocksActivation(target: unknown): boolean {
11
+ const el = target as { closest?: (selector: string) => unknown } | null;
12
+ return !!el && typeof el.closest === "function" && !!el.closest(ACTIVATES);
13
+ }
@@ -0,0 +1,37 @@
1
+ /*
2
+ * List controls in the Visual Editor: small Move Up, Move Down, Remove and Add buttons on an item of a list the site
3
+ * lets Site Manager reorder or resize (copy list rules, kit 1.26.0). Site Manager says which lists are on the page and
4
+ * which values each item holds; the page only finds where each item is and shows the buttons. Every change is made
5
+ * and saved by Site Manager.
6
+ */
7
+
8
+ export type ListAction = "up" | "down" | "remove" | "add";
9
+ export type PageListItem = { refs: string[] };
10
+ export type PageList = { path: string; reorder: boolean; addRemove: boolean; shown: number; items: PageListItem[] };
11
+
12
+ /** The buttons an item gets: by the list's rules, its place, and whether the list has room for another item. */
13
+ export function actionsFor(list: PageList, index: number): ListAction[] {
14
+ const out: ListAction[] = [];
15
+ const count = Math.min(list.shown, list.items.length);
16
+ if (index >= count) return out;
17
+ if (list.reorder && index > 0) out.push("up");
18
+ if (list.reorder && index < count - 1) out.push("down");
19
+ if (list.addRemove && count > 1) out.push("remove");
20
+ if (list.addRemove && count < list.items.length) out.push("add");
21
+ return out;
22
+ }
23
+
24
+ type Containing = { contains(other: Containing | null): boolean; parentElement: Containing | null };
25
+
26
+ /**
27
+ * The element that is an item: the nearest one holding every place its values show and none of another item's.
28
+ * Null when no such element exists (the values are spread among other items' elements).
29
+ */
30
+ export function itemElement<E extends Containing>(mine: E[], others: E[]): E | null {
31
+ if (!mine.length) return null;
32
+ for (let el: E | null = mine[0]!; el; el = el.parentElement as E | null) {
33
+ if (!mine.every((m) => el!.contains(m))) continue;
34
+ return others.some((o) => el!.contains(o)) ? null : el;
35
+ }
36
+ return null;
37
+ }
@@ -0,0 +1,77 @@
1
+ /*
2
+ * Automatic marks for the Visual Editor. In visual mode the bridge gets the site's Site Copy text (drafts included,
3
+ * so it matches what the page shows) and marks the elements and text nodes on the page whose words are exactly one
4
+ * value. Sites then need no marks of their own; marks a site does add with editMarks() always win.
5
+ *
6
+ * Pure functions here, so they can be tested without a browser; the bridge walks the page with them.
7
+ */
8
+
9
+ import type { CopyValue } from "./types";
10
+
11
+ /** One Site Copy value the bridge can look for: its key, its words as plain text, and the page it belongs to. */
12
+ export type CopyText = { k: string; t: string; p: string | null };
13
+
14
+ // Shorter values ("Yes", "OK") are too likely to match words that aren't that value.
15
+ const MIN_CHARS = 3;
16
+
17
+ const ENTITIES: Record<string, string> = { amp: "&", lt: "<", gt: ">", quot: '"', apos: "'", nbsp: " " };
18
+
19
+ function decode(s: string): string {
20
+ return s.replace(/&(#x[0-9a-f]+|#\d+|[a-z]+);/gi, (whole, e: string) => {
21
+ if (e[0] === "#") {
22
+ const n = e[1] === "x" || e[1] === "X" ? parseInt(e.slice(2), 16) : parseInt(e.slice(1), 10);
23
+ return Number.isFinite(n) && n > 0 && n <= 0x10ffff ? String.fromCodePoint(n) : whole;
24
+ }
25
+ return ENTITIES[e.toLowerCase()] ?? whole;
26
+ });
27
+ }
28
+
29
+ /** Rich text's words as the page shows them: tags dropped, entities read. */
30
+ export function htmlText(html: string): string {
31
+ return decode(html.replace(/<(script|style)[\s\S]*?<\/\1>/gi, "").replace(/<[^>]*>/g, " "));
32
+ }
33
+
34
+ /**
35
+ * The form words are compared in. All whitespace is dropped, since the page and the stored value break lines and
36
+ * space blocks differently (rich text's paragraphs have no space between them in the page's text).
37
+ */
38
+ export const comparable = (s: string): string => s.replace(/[\s ​]+/g, "");
39
+
40
+ /** The text and rich values of getCopy(), ready to send to the bridge. Photos, links and record picks aren't words on the page. */
41
+ export function copyTexts(all: Record<string, CopyValue>): CopyText[] {
42
+ const out: CopyText[] = [];
43
+ for (const [k, v] of Object.entries(all)) {
44
+ const t = v.kind === "text" ? v.text : v.kind === "rich" ? (v.html ? htmlText(v.html) : null) : null;
45
+ if (t && comparable(t).length >= MIN_CHARS) out.push({ k, t, p: v.pagePath ?? null });
46
+ }
47
+ return out;
48
+ }
49
+
50
+ const trimPath = (p: string) => (p.length > 1 ? p.replace(/\/+$/, "") : p);
51
+
52
+ /**
53
+ * Words to ref, for one page. Only values that belong to this page or to no page (header, footer, site-wide wording)
54
+ * count: another page's value with the same words (a nav label that matches a page's eyebrow) is a different value.
55
+ * Where the same words are more than one value, this page's wins over a site-wide one; still more than one at that
56
+ * level, the words are left unmarked, since there is no telling which one this is.
57
+ */
58
+ export function matchIndex(values: CopyText[], path: string): Map<string, string> {
59
+ const here = trimPath(path);
60
+ const best = new Map<string, { tier: number; ref: string | null }>();
61
+ for (const v of values) {
62
+ const words = comparable(v.t);
63
+ if (words.length < MIN_CHARS) continue;
64
+ const tier = v.p === null ? 1 : trimPath(v.p) === here ? 0 : -1;
65
+ if (tier < 0) continue;
66
+ const ref = `copy/${v.k}`;
67
+ const seen = best.get(words);
68
+ if (!seen || tier < seen.tier) best.set(words, { tier, ref });
69
+ else if (tier === seen.tier && seen.ref !== ref) seen.ref = null;
70
+ }
71
+ const out = new Map<string, string>();
72
+ for (const [words, { ref }] of best) if (ref) out.set(words, ref);
73
+ return out;
74
+ }
75
+
76
+ /** The longest words in the index, so the walk can skip comparing anything longer. */
77
+ export const longest = (index: Map<string, string>): number => Math.max(0, ...Array.from(index.keys(), (k) => k.length));
@@ -0,0 +1,160 @@
1
+ /*
2
+ * Messages between a page in visual mode and Site Manager's Visual Editor. Every message is { bp: 1, type, ...fields };
3
+ * anything else is dropped. Site Manager checks the same shapes in src/lib/visual/messages.ts; change both together.
4
+ */
5
+
6
+ export const LIMITS = { ref: 200, path: 2000, text: 10000, html: 50000, refs: 500, label: 120 } as const;
7
+
8
+ import type { ListAction, PageList } from "./visual-lists";
9
+
10
+ export type EditMode = "inline" | "panel" | "link";
11
+ // label: what the value is, in the words people see in Site Manager. Older Site Managers leave it out.
12
+ export type Editable = { ref: string; mode: EditMode; maxLength?: number; label?: string };
13
+ export type Rect = { x: number; y: number; w: number; h: number };
14
+
15
+ export type KitMessage =
16
+ | { bp: 1; type: "ready"; kit: string; path: string; refs: string[] }
17
+ | { bp: 1; type: "navigated"; path: string; refs: string[] }
18
+ // plain: the element holds only text, so it can be edited in place.
19
+ | { bp: 1; type: "select"; ref: string; rect: Rect; plain: boolean }
20
+ // html: a formatted value edited in place; Site Manager sanitizes it before saving.
21
+ | { bp: 1; type: "commit"; ref: string; text: string; html?: string; accents?: [number, number][]; strong?: [number, number][]; em?: [number, number][] }
22
+ | { bp: 1; type: "cancel"; ref: string }
23
+ // The words of an edit in place so far, so Site Manager can check them while they're typed.
24
+ | { bp: 1; type: "typing"; ref: string; text: string }
25
+ // Ctrl+Z or Ctrl+Shift+Z (Ctrl+Y) pressed in the page while nothing is being typed.
26
+ | { bp: 1; type: "key"; key: "undo" | "redo" }
27
+ // The person asked to edit this value in Site Manager's side panel instead (the expand button on the editing bar).
28
+ | { bp: 1; type: "expand"; ref: string }
29
+ // A list button on an item was pressed. Site Manager makes and saves the change.
30
+ | { bp: 1; type: "listAction"; path: string; index: number; action: ListAction };
31
+
32
+ export type AdminMessage =
33
+ | { bp: 1; type: "hello"; editable: Editable[] }
34
+ | { bp: 1; type: "mode"; editing: boolean }
35
+ | { bp: 1; type: "inline"; ref: string; maxLength: number }
36
+ // Edit a formatted value (rich text) in place, with a small Bold, Italic and Link bar.
37
+ | { bp: 1; type: "inlineRich"; ref: string }
38
+ // Edit a heading in place with an Accent button for its gold words: its words and ranges as stored.
39
+ // strong and marks (1.37.0): the words marked Bold, and which of Accent and Bold the value offers (both when left out).
40
+ // em (1.40.0): the words marked Italic.
41
+ | { bp: 1; type: "inlineHeading"; ref: string; maxLength: number; text: string; accents: [number, number][]; strong?: [number, number][]; em?: [number, number][]; marks?: ("accent" | "strong" | "em")[] }
42
+ | { bp: 1; type: "refresh" }
43
+ | { bp: 1; type: "navigate"; path: string }
44
+ | { bp: 1; type: "focus"; ref: string }
45
+ // The words typed so far in the side panel, shown on the page unsaved; null puts the original words back.
46
+ | { bp: 1; type: "preview"; ref: string; text: string | null }
47
+ // A value was saved as a draft with these words, which the page already shows: keep them, no refresh needed.
48
+ | { bp: 1; type: "saved"; ref: string; text: string }
49
+ // The values on this page with unsaved drafts, marked with a small dot.
50
+ | { bp: 1; type: "drafts"; refs: string[] }
51
+ // The lists on this page that can be reordered or resized, and the values each item holds.
52
+ | { bp: 1; type: "lists"; lists: PageList[] }
53
+ // A photo chosen in the side panel, shown in place of the value's photo, unsaved; null puts the photo back.
54
+ | { bp: 1; type: "previewImage"; ref: string; url: string | null };
55
+
56
+ const str = (v: unknown, max: number): v is string => typeof v === "string" && v.length > 0 && v.length <= max;
57
+ const count = (v: unknown): v is number => typeof v === "number" && Number.isInteger(v) && v > 0 && v <= LIMITS.text;
58
+ const MODES: readonly unknown[] = ["inline", "panel", "link"];
59
+
60
+ function isEditable(v: unknown): v is Editable {
61
+ if (!v || typeof v !== "object") return false;
62
+ const e = v as Record<string, unknown>;
63
+ return (
64
+ str(e.ref, LIMITS.ref) &&
65
+ MODES.includes(e.mode) &&
66
+ (e.maxLength === undefined || count(e.maxLength)) &&
67
+ (e.label === undefined || str(e.label, LIMITS.label))
68
+ );
69
+ }
70
+
71
+ function isPageList(v: unknown): v is PageList {
72
+ if (!v || typeof v !== "object") return false;
73
+ const l = v as Record<string, unknown>;
74
+ return (
75
+ str(l.path, LIMITS.ref) &&
76
+ typeof l.reorder === "boolean" &&
77
+ typeof l.addRemove === "boolean" &&
78
+ typeof l.shown === "number" &&
79
+ Number.isInteger(l.shown) &&
80
+ l.shown >= 0 &&
81
+ Array.isArray(l.items) &&
82
+ l.items.length <= 50 &&
83
+ l.items.every((i) => {
84
+ const refs = (i as { refs?: unknown } | null)?.refs;
85
+ return Array.isArray(refs) && refs.length <= 50 && refs.every((r) => str(r, LIMITS.ref));
86
+ })
87
+ );
88
+ }
89
+
90
+ const ranges = (v: unknown) =>
91
+ Array.isArray(v) && v.length <= 50 && v.every((a) => Array.isArray(a) && a.length === 2 && a.every((n) => Number.isInteger(n) && n >= 0 && n <= LIMITS.text));
92
+
93
+ /** True only for a well formed message from Site Manager. */
94
+ export function isAdminMessage(data: unknown): data is AdminMessage {
95
+ if (!data || typeof data !== "object") return false;
96
+ const m = data as Record<string, unknown>;
97
+ if (m.bp !== 1) return false;
98
+ switch (m.type) {
99
+ case "hello":
100
+ return Array.isArray(m.editable) && m.editable.length <= LIMITS.refs && m.editable.every(isEditable);
101
+ case "mode":
102
+ return typeof m.editing === "boolean";
103
+ case "inline":
104
+ return str(m.ref, LIMITS.ref) && count(m.maxLength);
105
+ case "inlineRich":
106
+ return str(m.ref, LIMITS.ref);
107
+ case "inlineHeading":
108
+ return (
109
+ str(m.ref, LIMITS.ref) &&
110
+ count(m.maxLength) &&
111
+ typeof m.text === "string" &&
112
+ m.text.length <= LIMITS.text &&
113
+ Array.isArray(m.accents) &&
114
+ m.accents.length <= 50 &&
115
+ ranges(m.accents) &&
116
+ (m.strong === undefined || ranges(m.strong)) &&
117
+ (m.em === undefined || ranges(m.em)) &&
118
+ (m.marks === undefined || (Array.isArray(m.marks) && m.marks.length <= 3 && m.marks.every((k) => k === "accent" || k === "strong" || k === "em")))
119
+ );
120
+ case "refresh":
121
+ return true;
122
+ case "navigate":
123
+ return str(m.path, LIMITS.path) && m.path.startsWith("/") && !m.path.startsWith("//") && !m.path.includes("\\");
124
+ case "focus":
125
+ return str(m.ref, LIMITS.ref);
126
+ case "saved":
127
+ return str(m.ref, LIMITS.ref) && typeof m.text === "string" && m.text.length <= LIMITS.text;
128
+ case "drafts":
129
+ return Array.isArray(m.refs) && m.refs.length <= LIMITS.refs && m.refs.every((r) => str(r, LIMITS.ref));
130
+ case "previewImage":
131
+ // Only an address a page can load: https, or a path on the site.
132
+ return str(m.ref, LIMITS.ref) && (m.url === null || (str(m.url, LIMITS.path) && /^(https:\/\/|\/(?!\/))/.test(m.url)));
133
+ case "lists":
134
+ return Array.isArray(m.lists) && m.lists.length <= 50 && m.lists.every(isPageList);
135
+ case "preview":
136
+ // An empty string is allowed: someone cleared the box.
137
+ return str(m.ref, LIMITS.ref) && (m.text === null || (typeof m.text === "string" && m.text.length <= LIMITS.text));
138
+ default:
139
+ return false;
140
+ }
141
+ }
142
+
143
+ /**
144
+ * The one check every message from Site Manager goes through: from the window that showed the page, at the signed
145
+ * admin origin, well formed. Anything else (another frame, another origin, a forged shape) is null.
146
+ */
147
+ export function adminMessageFrom(e: { source: unknown; origin: string; data: unknown }, from: unknown, adminOrigin: string): AdminMessage | null {
148
+ if (!from || e.source !== from || e.origin !== adminOrigin) return null;
149
+ return isAdminMessage(e.data) ? e.data : null;
150
+ }
151
+
152
+ /** The marked refs on the page: unique, in page order, at most 500. */
153
+ export function collectRefs(values: Iterable<string | null>): string[] {
154
+ const out = new Set<string>();
155
+ for (const v of values) {
156
+ if (out.size >= LIMITS.refs) break;
157
+ if (v && v.length <= LIMITS.ref) out.add(v);
158
+ }
159
+ return [...out];
160
+ }
@@ -0,0 +1,18 @@
1
+ import { cookies, draftMode } from "next/headers";
2
+ import { verifyVisualCookie, VISUAL_COOKIE } from "./preview";
3
+
4
+ /**
5
+ * The signed admin origin when this request comes from Site Manager's Visual Editor, else null. Cookies are read only
6
+ * once draft mode is on, so a visitor's page never becomes dynamic because of this check.
7
+ */
8
+ export async function visualMode(): Promise<{ adminOrigin: string } | null> {
9
+ try {
10
+ if (!(await draftMode()).isEnabled) return null;
11
+ const id = process.env.BLUEPRINT_SITE_ID?.trim();
12
+ if (!id) return null;
13
+ const ok = verifyVisualCookie(process.env.BLUEPRINT_REVALIDATE_SECRET, (await cookies()).get(VISUAL_COOKIE)?.value, id);
14
+ return ok ? { adminOrigin: ok.adminOrigin } : null;
15
+ } catch {
16
+ return null;
17
+ }
18
+ }
@@ -0,0 +1,69 @@
1
+ /*
2
+ * Live preview in the Visual Editor: while someone types a value in Site Manager's side panel, the page shows the
3
+ * words in place, unsaved, so they see line breaks and overflow as they type. Only text nodes are changed, through
4
+ * nodeValue, so the words are never read as HTML and the elements around them (an accent span, a link) keep their
5
+ * styling. React keeps owning the same nodes, so a refresh after Save Draft updates them as usual.
6
+ */
7
+
8
+ /** The part of a DOM Text node this needs, so the logic can be checked without a browser. */
9
+ export type TextLike = { nodeValue: string | null };
10
+
11
+ type Slot = { node: TextLike; before: string };
12
+
13
+ const lead = (s: string) => /^\s*/.exec(s)![0];
14
+ const trail = (s: string) => /\s*$/.exec(s)![0];
15
+
16
+ export class LivePreview {
17
+ // Per ref, each place the value shows on the page, as the text nodes it was made of and what they said.
18
+ private shown = new Map<string, Slot[][]>();
19
+
20
+ /** True while any value shows unsaved words. Marking the page again waits: the words no longer match the value. */
21
+ get active(): boolean {
22
+ return this.shown.size > 0;
23
+ }
24
+
25
+ /**
26
+ * Shows text where ref is. find runs only the first time, so every keystroke changes the same nodes and the
27
+ * original words are kept from before the first one. False when the value has no plain text place on the page.
28
+ */
29
+ show(ref: string, text: string, find: () => TextLike[][]): boolean {
30
+ let places = this.shown.get(ref);
31
+ if (!places) {
32
+ places = find()
33
+ .map((nodes) => nodes.map((node) => ({ node, before: node.nodeValue ?? "" })))
34
+ .filter((slots) => slots.length > 0);
35
+ if (!places.length) return false;
36
+ this.shown.set(ref, places);
37
+ }
38
+ for (const slots of places) {
39
+ // The words go in the first node that had any, keeping the spaces that separated it from what's beside it.
40
+ let first = slots.findIndex((s) => s.before.trim() !== "");
41
+ if (first < 0) first = 0;
42
+ slots.forEach((s, i) => {
43
+ if (i === first) s.node.nodeValue = lead(s.before) + text.trim() + trail(s.before);
44
+ else if (s.before.trim() !== "") s.node.nodeValue = "";
45
+ });
46
+ }
47
+ return true;
48
+ }
49
+
50
+ /** Puts the original words back (Cancel, or the panel closed without saving). */
51
+ restore(ref: string): void {
52
+ for (const slots of this.shown.get(ref) ?? []) for (const s of slots) s.node.nodeValue = s.before;
53
+ this.shown.delete(ref);
54
+ }
55
+
56
+ restoreAll(): void {
57
+ for (const ref of [...this.shown.keys()]) this.restore(ref);
58
+ }
59
+
60
+ /** Lets go without restoring: the page is being refreshed with the saved words, or has moved on. */
61
+ forget(): void {
62
+ this.shown.clear();
63
+ }
64
+
65
+ /** Lets go of one value without restoring: its words were saved and stay on the page. */
66
+ keep(ref: string): void {
67
+ this.shown.delete(ref);
68
+ }
69
+ }
@@ -0,0 +1,90 @@
1
+ /*
2
+ * Exact tags for the Visual Editor. In visual mode only, each Site Copy text the site reads carries an invisible tag
3
+ * naming its value, so the page can say exactly which words are which value instead of matching words. The tag is
4
+ * zero width characters: a start marker, the ref's UTF-8 bytes as base-4 digits, an end marker. The bridge removes
5
+ * every tag from the page before anyone sees or copies it.
6
+ */
7
+
8
+ const DIGITS = ["​", "‌", "‍", "⁠"] as const;
9
+ // Marker pairs no ordinary text contains: a word joiner and a zero width no-break space around the digits.
10
+ const START = "⁠⁠";
11
+ const END = "⁠";
12
+ const MAX_REF = 200;
13
+
14
+ const TAG = new RegExp(`${START}([${DIGITS.join("")}]+)${END}`, "g");
15
+ const ANY_MARK = /[​‌‍⁠]/;
16
+
17
+ export function encodeTag(ref: string): string {
18
+ const bytes = new TextEncoder().encode(ref);
19
+ let out = START;
20
+ for (const b of bytes) out += DIGITS[(b >> 6) & 3]! + DIGITS[(b >> 4) & 3]! + DIGITS[(b >> 2) & 3]! + DIGITS[b & 3]!;
21
+ return out + END;
22
+ }
23
+
24
+ function decode(digits: string): string | null {
25
+ if (digits.length % 4 !== 0) return null;
26
+ const bytes = new Uint8Array(digits.length / 4);
27
+ for (let i = 0; i < bytes.length; i++) {
28
+ let b = 0;
29
+ for (let j = 0; j < 4; j++) b = (b << 2) | DIGITS.indexOf(digits[i * 4 + j] as (typeof DIGITS)[number]);
30
+ bytes[i] = b;
31
+ }
32
+ try {
33
+ const ref = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
34
+ return ref.length > 0 && ref.length <= MAX_REF ? ref : null;
35
+ } catch {
36
+ return null;
37
+ }
38
+ }
39
+
40
+ /** A tag found in a string: the ref it names and where it ends in the clean text. */
41
+ export type FoundTag = { ref: string; at: number };
42
+
43
+ /** The text with its tags removed, and the tags in order. A broken tag is removed but not reported. */
44
+ export function readTags(text: string): { clean: string; tags: FoundTag[] } {
45
+ if (!ANY_MARK.test(text)) return { clean: text, tags: [] };
46
+ const tags: FoundTag[] = [];
47
+ let clean = "";
48
+ let last = 0;
49
+ for (const m of text.matchAll(TAG)) {
50
+ clean += text.slice(last, m.index);
51
+ const ref = decode(m[1]!);
52
+ if (ref) tags.push({ ref, at: clean.length });
53
+ last = m.index! + m[0].length;
54
+ }
55
+ clean += text.slice(last);
56
+ return { clean, tags };
57
+ }
58
+
59
+ /** The text alone, for attributes and anywhere else a tag must never show. */
60
+ export function stripTags(text: string): string {
61
+ return ANY_MARK.test(text) ? text.replace(TAG, "") : text;
62
+ }
63
+
64
+ /** True when the text may hold a tag; a cheap test before the full read. */
65
+ export const mayHaveTag = (text: string) => text.includes(START);
66
+
67
+ /** Puts the tag inside rich HTML's last text, before its closing tags, so it lands in the element the words are in. */
68
+ export function tagHtml(html: string, ref: string): string {
69
+ const m = /(?:<\/[a-zA-Z][a-zA-Z0-9]*>\s*)+$/.exec(html);
70
+ const at = m ? m.index : html.length;
71
+ return html.slice(0, at) + encodeTag(ref) + html.slice(at);
72
+ }
73
+
74
+ type CopyLike = { kind: string; text?: string | null; html?: string | null; image?: { alt: string } | null };
75
+
76
+ /**
77
+ * Site Copy with each non-empty text and rich value tagged as `copy/<key>`, and each photo tagged in its alt text (the
78
+ * one thing about a photo the page always renders). Links and picks are untouched.
79
+ */
80
+ export function tagCopy<T extends CopyLike>(all: Record<string, T>): Record<string, T> {
81
+ const out: Record<string, T> = {};
82
+ for (const [key, v] of Object.entries(all)) {
83
+ const ref = `copy/${key}`;
84
+ if (v.kind === "text" && v.text) out[key] = { ...v, text: v.text + encodeTag(ref) } as T;
85
+ else if (v.kind === "rich" && v.html) out[key] = { ...v, html: tagHtml(v.html, ref) } as T;
86
+ else if (v.kind === "image" && v.image) out[key] = { ...v, image: { ...v.image, alt: (v.image.alt ?? "") + encodeTag(ref) } } as T;
87
+ else out[key] = v;
88
+ }
89
+ return out;
90
+ }
package/src/visual.ts ADDED
@@ -0,0 +1,59 @@
1
+ import { draftMode } from "next/headers";
2
+ import type { NextRequest, NextResponse } from "next/server";
3
+ import { verifyVisualCookie, VISUAL_COOKIE } from "./preview";
4
+
5
+ /*
6
+ * Marking values for Site Manager's Visual Editor. A marked element is a value Site Manager already stores; the
7
+ * Visual Editor only changes the value, never the page around it. Marks exist only in a preview, so visitors never
8
+ * see them.
9
+ */
10
+
11
+ export const REF_PATTERN = /^[a-z_]+\/[A-Za-z0-9_\-./]{1,200}$/;
12
+
13
+ type Mark = (ref: string) => Record<string, string>;
14
+
15
+ const none: Mark = () => ({});
16
+
17
+ /**
18
+ * Attributes for marking a value, or nothing outside a preview:
19
+ *
20
+ * const mark = await editMarks();
21
+ * <h1 {...mark("copy/hero_title")}>{title}</h1>
22
+ *
23
+ * A bad ref gives nothing rather than an error, so a typo can never break a visitor's page.
24
+ */
25
+ export async function editMarks(): Promise<Mark> {
26
+ try {
27
+ if (!(await draftMode()).isEnabled) return none;
28
+ } catch {
29
+ return none;
30
+ }
31
+ return (ref): Record<string, string> => (REF_PATTERN.test(ref) ? { "data-bp-edit": ref } : {});
32
+ }
33
+
34
+ function adminOrigin(request: NextRequest): string | null {
35
+ const id = process.env.BLUEPRINT_SITE_ID?.trim();
36
+ if (!id) return null;
37
+ return verifyVisualCookie(process.env.BLUEPRINT_REVALIDATE_SECRET, request.cookies.get(VISUAL_COOKIE)?.value, id)?.adminOrigin ?? null;
38
+ }
39
+
40
+ /** The frame-ancestors value for sites that build their own Content-Security-Policy: "'self'", plus the Visual Editor's address while it's open. */
41
+ export function frameAncestors(request: NextRequest): string {
42
+ const origin = adminOrigin(request);
43
+ return origin ? `'self' ${origin}` : "'self'";
44
+ }
45
+
46
+ /**
47
+ * For the site's `src/proxy.ts`. While the Visual Editor is open in this browser, lets its Site Manager address show
48
+ * the page in a frame. Browsers ignore X-Frame-Options when frame-ancestors is sent, so every other response keeps
49
+ * the site's usual SAMEORIGIN. Redirects are left alone: they have no page to show.
50
+ */
51
+ export function visualFrameHeaders(request: NextRequest, response: NextResponse): NextResponse {
52
+ if (response.headers.has("location")) return response;
53
+ const origin = adminOrigin(request);
54
+ if (!origin) return response;
55
+ const existing = response.headers.get("content-security-policy");
56
+ const value = `frame-ancestors 'self' ${origin}`;
57
+ response.headers.set("content-security-policy", existing ? `${existing}; ${value}` : value);
58
+ return response;
59
+ }
@@ -0,0 +1,39 @@
1
+ import type { WaitlistOptions } from "./types";
2
+
3
+ // The waitlist form's pure parts, kept out of the "use server" file so they can be tested and shared.
4
+
5
+ /** The fields a waitlist form sends. `choice` is "plan:<slug>" or "beds:<n>", for a form with one picker. */
6
+ export const WAITLIST_FIELDS = ["choice", "floorPlan", "bedrooms", "email", "name", "phone", "moveIn"] as const;
7
+
8
+ export type WaitlistInput = { floorPlan?: string; bedrooms?: number; email: string; name?: string; phone?: string; moveIn?: string };
9
+
10
+ const text = (fd: FormData, k: string) => {
11
+ const v = fd.get(k);
12
+ return typeof v === "string" && v.trim() ? v.trim() : undefined;
13
+ };
14
+
15
+ /**
16
+ * Reads a submitted waitlist form. A plan page usually sends a hidden `floorPlan`; a general form sends one `choice`
17
+ * from waitlistChoices(). A choice wins over the separate fields.
18
+ */
19
+ export function readWaitlistForm(fd: FormData): WaitlistInput {
20
+ const choice = text(fd, "choice");
21
+ let floorPlan = text(fd, "floorPlan");
22
+ let bedrooms = text(fd, "bedrooms");
23
+ if (choice?.startsWith("plan:")) [floorPlan, bedrooms] = [choice.slice(5), undefined];
24
+ else if (choice?.startsWith("beds:")) [floorPlan, bedrooms] = [undefined, choice.slice(5)];
25
+ const beds = bedrooms !== undefined && /^\d{1,2}$/.test(bedrooms) ? Number(bedrooms) : undefined;
26
+ return { floorPlan, bedrooms: floorPlan ? undefined : beds, email: text(fd, "email") ?? "", name: text(fd, "name"), phone: text(fd, "phone"), moveIn: text(fd, "moveIn") };
27
+ }
28
+
29
+ /**
30
+ * Options for one waitlist picker: each bedroom count ("Any 2 bedroom plan") and then each plan. Plans with a home
31
+ * available now are left out unless asked for, since a visitor can apply for those today.
32
+ */
33
+ export function waitlistChoices(options: WaitlistOptions, opts: { includeAvailable?: boolean } = {}): { value: string; label: string }[] {
34
+ const plans = options.floorPlans.filter((p) => opts.includeAvailable || !p.available);
35
+ return [
36
+ ...options.bedrooms.map((b) => ({ value: `beds:${b.value}`, label: b.value === 0 ? "Any studio" : `Any ${b.label.replace(/s$/, "")} plan` })),
37
+ ...plans.map((p) => ({ value: `plan:${p.slug}`, label: p.name })),
38
+ ];
39
+ }
@@ -0,0 +1,48 @@
1
+ "use server";
2
+
3
+ import { createHash } from "node:crypto";
4
+ import { headers } from "next/headers";
5
+ import { bpRaw } from "./client";
6
+ import { checkFormToken } from "./form-token";
7
+ import type { WaitlistResult } from "./types";
8
+ import { readWaitlistForm } from "./waitlist-form";
9
+
10
+ async function isBot(): Promise<boolean> {
11
+ try {
12
+ const { checkBotId } = await import("botid/server");
13
+ const r = await checkBotId();
14
+ return r.isBot && !r.isVerifiedBot;
15
+ } catch {
16
+ // BotID isn't set up on this site. The honeypot and time check still apply.
17
+ return false;
18
+ }
19
+ }
20
+
21
+ /**
22
+ * Adds a visitor to a floor plan's waitlist, or a bedroom count's, from the site's server. The same spam checks as
23
+ * forms (FormGuard's honeypot and render time, and BotID) run first; suspected spam gets the usual thank-you and is
24
+ * dropped. The answer is the same whether or not the visitor was already waiting.
25
+ */
26
+ export async function joinWaitlist(formData: FormData): Promise<WaitlistResult> {
27
+ const token = checkFormToken(String(formData.get("bp_ts") ?? ""));
28
+ const spam = String(formData.get("bp_hp") ?? "").trim() !== "" ? "honeypot" : !token.ok ? (token.reason === "too_fast" ? "too_fast" : "bot") : (await isBot()) ? "bot" : null;
29
+ if (spam) return { ok: true, waitingFor: null };
30
+
31
+ const h = await headers();
32
+ const ip = h.get("x-real-ip") ?? h.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "";
33
+ const { status, json } = await bpRaw("/waitlist", {
34
+ ...readWaitlistForm(formData),
35
+ pageUrl: h.get("referer") ?? undefined,
36
+ ipHash: ip ? createHash("sha256").update(ip).digest("hex") : undefined,
37
+ });
38
+ const body = json as { data?: { waitingFor: string | null }; error?: { fields?: Record<string, string> } } | null;
39
+ if (status === 201 || status === 200) return { ok: true, waitingFor: body?.data?.waitingFor ?? null };
40
+ if (status === 422) return { ok: false, error: "invalid", fields: body?.error?.fields ?? {} };
41
+ if (status === 429) return { ok: false, error: "rate_limited" };
42
+ return { ok: false, error: "unavailable" };
43
+ }
44
+
45
+ /** The same, shaped for React's useActionState: `useActionState(waitlistAction, null)`. */
46
+ export async function waitlistAction(_prev: WaitlistResult | null, formData: FormData): Promise<WaitlistResult> {
47
+ return joinWaitlist(formData);
48
+ }