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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/README.md +606 -606
  2. package/audit/ts-hooks.mjs +68 -68
  3. package/audit/wiring-audit.d.mts +37 -37
  4. package/audit/wiring-audit.mjs +604 -604
  5. package/bin/site-manager-kit.mjs +10 -10
  6. package/package.json +72 -72
  7. package/src/announcements.ts +13 -13
  8. package/src/attribution-fields.ts +52 -52
  9. package/src/attribution.tsx +47 -47
  10. package/src/blog.ts +122 -122
  11. package/src/chat-route.ts +136 -136
  12. package/src/chat-types.ts +76 -76
  13. package/src/client.ts +116 -116
  14. package/src/config.ts +78 -78
  15. package/src/copy-keys-route.ts +21 -21
  16. package/src/copy-keys.ts +77 -77
  17. package/src/data.ts +189 -189
  18. package/src/downloads.ts +7 -7
  19. package/src/faq.ts +21 -21
  20. package/src/floor-plans.ts +45 -45
  21. package/src/form-guard.tsx +37 -37
  22. package/src/form-types.ts +13 -13
  23. package/src/forms.ts +76 -76
  24. package/src/glossary.ts +19 -19
  25. package/src/health-route.ts +34 -34
  26. package/src/index.ts +36 -36
  27. package/src/indexnow.ts +40 -40
  28. package/src/listings.ts +38 -38
  29. package/src/metadata.ts +55 -55
  30. package/src/not-found-reporter.tsx +16 -16
  31. package/src/not-found.ts +53 -53
  32. package/src/payment.ts +70 -70
  33. package/src/phone-input.tsx +26 -26
  34. package/src/phone.ts +37 -37
  35. package/src/preview-route.ts +48 -48
  36. package/src/preview.ts +94 -94
  37. package/src/residents.ts +23 -23
  38. package/src/revalidate-route.ts +34 -34
  39. package/src/rss-route.ts +26 -26
  40. package/src/services.ts +52 -52
  41. package/src/site-chat-panel.tsx +537 -537
  42. package/src/site-chat.tsx +26 -26
  43. package/src/sitemap.ts +26 -26
  44. package/src/structured-data.tsx +207 -207
  45. package/src/types.ts +580 -580
  46. package/src/version.ts +1 -1
  47. package/src/visual-accents.ts +126 -126
  48. package/src/visual-bridge-client.tsx +1070 -1070
  49. package/src/visual-bridge.tsx +30 -30
  50. package/src/visual-clicks.ts +13 -13
  51. package/src/visual-lists.ts +37 -37
  52. package/src/visual-match.ts +77 -77
  53. package/src/visual-messages.ts +160 -160
  54. package/src/visual-mode.ts +18 -18
  55. package/src/visual-preview.ts +69 -69
  56. package/src/visual-tags.ts +90 -90
  57. package/src/visual.ts +59 -59
  58. package/src/waitlist-form.ts +39 -39
  59. package/src/waitlist.ts +48 -48
@@ -1,160 +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
- }
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
+ }
@@ -1,18 +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
- }
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
+ }
@@ -1,69 +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
- }
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
+ }