@bygghammar/cms-platform 0.94.0 → 0.96.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 (43) hide show
  1. package/CHANGELOG.md +146 -0
  2. package/docs/PAGES.md +72 -4
  3. package/package.json +8 -1
  4. package/src/admin/describe-log.ts +3 -0
  5. package/src/backoffice/BookingSettingsCard.tsx +252 -0
  6. package/src/backoffice/NavigationView.tsx +38 -0
  7. package/src/backoffice/PageBuilderView.tsx +149 -3
  8. package/src/backoffice/PageEditorView.tsx +47 -24
  9. package/src/backoffice/PagesListView.tsx +173 -57
  10. package/src/backoffice/SiteSettingsView.tsx +4 -0
  11. package/src/backoffice/StatusBadge.tsx +38 -2
  12. package/src/backoffice/media/ConfirmDialog.tsx +19 -1
  13. package/src/backoffice/routes/host.ts +24 -0
  14. package/src/backoffice/routes/index.tsx +54 -0
  15. package/src/backoffice/routes/pages.tsx +13 -0
  16. package/src/cms/booking-links.ts +176 -0
  17. package/src/cms/page-publication.ts +11 -6
  18. package/src/cms/page-visibility.ts +206 -0
  19. package/src/forms/form-audit.ts +22 -2
  20. package/src/html/html-tokenizer.ts +254 -0
  21. package/src/html/sanitize-html.ts +72 -37
  22. package/src/navigation/visibility.ts +235 -0
  23. package/src/popups/runtime.tsx +155 -4
  24. package/src/server/forms-core.ts +9 -0
  25. package/src/server/forms-delivery-core.ts +29 -22
  26. package/src/server/forms-submissions-core.ts +8 -5
  27. package/src/server/media-usage-core.ts +53 -13
  28. package/src/server/outbound-address.ts +131 -0
  29. package/src/server/outbound-http.ts +565 -0
  30. package/src/server/page-visibility-core.ts +450 -0
  31. package/src/server/pages-core.ts +238 -9
  32. package/src/server/popup-optin-webhook-core.ts +79 -140
  33. package/src/server/published-cache.ts +23 -3
  34. package/src/server/schemas.ts +17 -0
  35. package/src/site/PageRenderer.tsx +34 -5
  36. package/src/site/SiteFooter.tsx +11 -2
  37. package/src/site/SiteHeader.tsx +41 -21
  38. package/src/site/link-visibility.tsx +28 -0
  39. package/src/site/preview-messaging.ts +232 -0
  40. package/src/site/route-aliases.ts +74 -0
  41. package/src/site/routes/preview-page.tsx +75 -31
  42. package/src/site/routes/public-page.tsx +81 -9
  43. package/src/util/redact-url.ts +18 -0
@@ -0,0 +1,206 @@
1
+ /**
2
+ * Page listing and the public policy built from it.
3
+ *
4
+ * Two independent properties decide how a page appears to visitors:
5
+ *
6
+ * - **Publication** — can a visitor open it at all? Publication windows answer
7
+ * that (`cms/page-publication`); Unpublish closes them.
8
+ * - **Listing** — is it advertised? A `listed` page appears in menus, footers
9
+ * and the sitemap like any other. A `link-only` page is just as reachable,
10
+ * but only for someone who has its address: it is left out of navigation and
11
+ * the sitemap and asks search engines not to index it.
12
+ *
13
+ * Listing never publishes, unpublishes, replaces or reschedules anything, and
14
+ * nothing that publishes changes listing. It is not access control either:
15
+ * a link-only address can be shared, guessed or found, and `noindex` is a
16
+ * request to crawlers, not a removal from results that already exist.
17
+ */
18
+ import type { PublicationState } from "./page-publication";
19
+ import { isPubliclyVisible } from "./page-publication";
20
+
21
+ export const PAGE_LISTINGS = ["listed", "link-only"] as const;
22
+ export type PageListing = (typeof PAGE_LISTINGS)[number];
23
+
24
+ /** Pages are listed unless somebody decided otherwise. */
25
+ export const DEFAULT_PAGE_LISTING: PageListing = "listed";
26
+
27
+ export function normalizePageListing(raw: unknown): PageListing {
28
+ return raw === "link-only" ? "link-only" : "listed";
29
+ }
30
+
31
+ /** The three answers the screens lead with, in a visitor's terms. */
32
+ export type PageVisibility = "published" | "link-only" | "offline";
33
+
34
+ export const PAGE_VISIBILITY_LABELS: Record<PageVisibility, string> = {
35
+ published: "Published",
36
+ "link-only": "Link only",
37
+ offline: "Offline",
38
+ };
39
+
40
+ export const PAGE_VISIBILITY_DESCRIPTIONS: Record<PageVisibility, string> = {
41
+ published: "Visible on the public website and listed in menus, footer and sitemap.",
42
+ "link-only":
43
+ "Visible to anyone who has the exact address, but left out of menus, footer and sitemap, " +
44
+ "and search engines are asked not to index it. It is not private: the address can be shared.",
45
+ offline: "Not on the public website. Visitors get “page not found”.",
46
+ };
47
+
48
+ /**
49
+ * The headline answer for one page, from both properties.
50
+ *
51
+ * Only the headline: schedules and editorial status keep their own badges,
52
+ * because "offline" says nothing about a window that opens tomorrow.
53
+ */
54
+ export function pageVisibility(publication: PublicationState, listing: PageListing): PageVisibility {
55
+ if (!isPubliclyVisible(publication)) return "offline";
56
+ return listing === "link-only" ? "link-only" : "published";
57
+ }
58
+
59
+ /* ------------------------------------------------------------------------ */
60
+ /* Confirmation copy */
61
+ /* ------------------------------------------------------------------------ */
62
+
63
+ export type VisibilityConfirmation = {
64
+ title: string;
65
+ body: string[];
66
+ confirmLabel: string;
67
+ };
68
+
69
+ /** What Make link-only does to this page, said before it happens. */
70
+ export function linkOnlyConfirmation(page: {
71
+ name: string;
72
+ path: string;
73
+ live: boolean;
74
+ }): VisibilityConfirmation {
75
+ return {
76
+ title: `Make “${page.name}” link-only?`,
77
+ body: [
78
+ page.live
79
+ ? `${page.path} stays online for anyone who has the address.`
80
+ : `${page.path} is offline now; once published it will only be reachable by its address.`,
81
+ "It disappears from the menu, the footer and the sitemap, and search engines are asked not to index it.",
82
+ "This is not a password: anyone with the link can open it, and it can be shared.",
83
+ ],
84
+ confirmLabel: "Make link-only",
85
+ };
86
+ }
87
+
88
+ /** What Show on website does: listing only, never a publish. */
89
+ export function showOnWebsiteConfirmation(page: {
90
+ name: string;
91
+ path: string;
92
+ live: boolean;
93
+ }): VisibilityConfirmation {
94
+ return {
95
+ title: `Show “${page.name}” on the website?`,
96
+ body: [
97
+ "Menu and footer links to it reappear, it is added to the sitemap, and search engines may index it.",
98
+ page.live
99
+ ? `${page.path} is already online; nothing about its content changes.`
100
+ : `${page.path} is offline: this does not publish it. It is listed again once it is published.`,
101
+ ],
102
+ confirmLabel: "Show on website",
103
+ };
104
+ }
105
+
106
+ /** The extra sentences Unpublish adds to the existing schedule-aware warning. */
107
+ export function unpublishConsequences(page: { path: string; listing: PageListing }): string[] {
108
+ const lines = [
109
+ `Visitors to ${page.path} will get “page not found”.`,
110
+ "Menu and footer links to it are hidden until it is published again; the menu itself is not changed.",
111
+ ];
112
+ if (page.listing === "link-only") {
113
+ lines.push("It stays link-only, so publishing it again will not list it.");
114
+ }
115
+ return lines;
116
+ }
117
+
118
+ /** Why a protected page offers neither action. */
119
+ export const PROTECTED_PAGE_REASON =
120
+ "This page is protected: the start page and the contact page always stay published and listed.";
121
+
122
+ /* ------------------------------------------------------------------------ */
123
+ /* Public policy */
124
+ /* ------------------------------------------------------------------------ */
125
+
126
+ /** One page, as public rendering needs to know it. */
127
+ export type PolicyPage = {
128
+ id: string;
129
+ key: string;
130
+ /** Can a visitor open it right now (publication windows, trash excluded)? */
131
+ live: boolean;
132
+ listing: PageListing;
133
+ /** Booking links in this page's own content stay while booking is off. */
134
+ bookingException: boolean;
135
+ };
136
+
137
+ /**
138
+ * Everything public rendering decides from, read once on the server.
139
+ *
140
+ * `known: false` is a read that failed. It is never a default: every question
141
+ * below then gets its restrictive answer — booking hidden, nothing indexable,
142
+ * no page reported offline (a failed read is not proof a page is gone).
143
+ */
144
+ export type PublicPolicySnapshot = {
145
+ known: boolean;
146
+ bookingEnabled: boolean;
147
+ pages: PolicyPage[];
148
+ /** The next instant any of this changes on its own (a publication boundary). */
149
+ revalidateAt: number | null;
150
+ };
151
+
152
+ export const UNKNOWN_PUBLIC_POLICY: PublicPolicySnapshot = Object.freeze({
153
+ known: false,
154
+ bookingEnabled: false,
155
+ pages: [],
156
+ revalidateAt: null,
157
+ }) as PublicPolicySnapshot;
158
+
159
+ export function policyPage(policy: PublicPolicySnapshot, key: string): PolicyPage | null {
160
+ return policy.pages.find((page) => page.key === key) ?? null;
161
+ }
162
+
163
+ /** May search engines index this page? Only a live, listed page, read successfully. */
164
+ export function pageIsIndexable(policy: PublicPolicySnapshot, key: string): boolean {
165
+ const page = policyPage(policy, key);
166
+ return policy.known && page !== null && page.live && page.listing === "listed";
167
+ }
168
+
169
+ /**
170
+ * Is this page positively known to be offline?
171
+ *
172
+ * Only a successful read that found the page and found it not live says so.
173
+ * An unknown page (no CMS row) or a failed read is not offline — the caller
174
+ * renders what it always rendered, or reports the error.
175
+ */
176
+ export function pageIsKnownOffline(policy: PublicPolicySnapshot, key: string): boolean {
177
+ if (!policy.known) return false;
178
+ const page = policyPage(policy, key);
179
+ return page !== null && !page.live;
180
+ }
181
+
182
+ /** Booking in site-wide chrome: header button, menu, footer, popups. */
183
+ export function chromeBookingVisible(policy: PublicPolicySnapshot): boolean {
184
+ return policy.known && policy.bookingEnabled;
185
+ }
186
+
187
+ /** Booking in one page's own content: on when booking is on, or the page is an exception. */
188
+ export function pageBookingVisible(policy: PublicPolicySnapshot, key: string | null): boolean {
189
+ if (!policy.known) return false;
190
+ if (policy.bookingEnabled) return true;
191
+ return key !== null && policyPage(policy, key)?.bookingException === true;
192
+ }
193
+
194
+ export type HiddenPageReason = "offline" | "link-only";
195
+
196
+ /** Pages navigation must not point at, and why. */
197
+ export function unlistedPages(
198
+ policy: PublicPolicySnapshot,
199
+ ): Array<{ key: string; reason: HiddenPageReason }> {
200
+ const out: Array<{ key: string; reason: HiddenPageReason }> = [];
201
+ for (const page of policy.pages) {
202
+ if (!page.live) out.push({ key: page.key, reason: "offline" });
203
+ else if (page.listing === "link-only") out.push({ key: page.key, reason: "link-only" });
204
+ }
205
+ return out;
206
+ }
@@ -2,6 +2,8 @@
2
2
  // Every backoffice change to a form (settings + fields) is turned into a list
3
3
  // of short change descriptions so the Activity view answers who/what/when.
4
4
 
5
+ import { redactUrl } from "../util/redact-url";
6
+
5
7
  export type FormAuditChange = {
6
8
  /** Machine scope: "form" | "settings" | "field" */
7
9
  scope: "form" | "settings" | "field";
@@ -26,6 +28,24 @@ export type FormSnapshotInput = {
26
28
 
27
29
  const MAX_LEN = 160;
28
30
 
31
+ /** Delivery settings whose value is a credential: the audit says it changed, never what to. */
32
+ const SECRET_SETTING = /secret|token|password|apikey|api_key/i;
33
+
34
+ /** An address as the audit shows it: its origin only — keys travel in paths and query strings. */
35
+ function addressOnly(v: unknown): unknown {
36
+ if (typeof v !== "string" || v.trim() === "") return v;
37
+ const redacted = redactUrl(v.trim());
38
+ return redacted === "(invalid address)" ? "(not a valid address)" : redacted;
39
+ }
40
+
41
+ /** A delivery setting's value as the audit may record it. */
42
+ function auditable(group: string, key: string, v: unknown): unknown {
43
+ if (group !== "private_settings") return v;
44
+ if (SECRET_SETTING.test(key)) return v == null || v === "" ? v : "(hidden)";
45
+ if (/url$/i.test(key)) return addressOnly(v);
46
+ return v;
47
+ }
48
+
29
49
  function short(v: unknown): string {
30
50
  if (v == null) return "—";
31
51
  if (typeof v === "string") return v.length > MAX_LEN ? `${v.slice(0, MAX_LEN)}…` : v || "—";
@@ -143,8 +163,8 @@ export function diffForms(
143
163
  out.push({
144
164
  scope: "settings",
145
165
  label: `${label}: ${humanKey(k)}`,
146
- from: short(a[k]),
147
- to: short(b[k]),
166
+ from: short(auditable(group, k, a[k])),
167
+ to: short(auditable(group, k, b[k])),
148
168
  });
149
169
  }
150
170
  }
@@ -0,0 +1,254 @@
1
+ /**
2
+ * An HTML tokenizer for the sanitizer, following the WHATWG tokenizer states
3
+ * that matter for a fragment of body content. No DOM is needed, so it runs the
4
+ * same on the server and in the browser.
5
+ *
6
+ * It splits markup into text, start tags and end tags, the way a browser
7
+ * would. Comments, DOCTYPEs, CDATA sections and processing instructions are
8
+ * consumed and produce nothing. A tag that runs into the end of the input is
9
+ * dropped, as a browser drops it. The content of raw-text and RCDATA elements
10
+ * (`<script>`, `<style>`, `<textarea>`, …) is returned as one text token, as
11
+ * the browser's tree builder makes the tokenizer read it.
12
+ *
13
+ * Values are returned exactly as written — text and attribute values are not
14
+ * decoded here. The sanitizer re-serializes everything it keeps, so nothing
15
+ * from the input reaches its output without being re-encoded.
16
+ */
17
+
18
+ export type HtmlToken =
19
+ | { kind: "text"; raw: string }
20
+ | {
21
+ kind: "start";
22
+ name: string;
23
+ /** In source order; a repeated name keeps its first value, as browsers do. */
24
+ attrs: Array<{ name: string; raw: string }>;
25
+ selfClosing: boolean;
26
+ }
27
+ | { kind: "end"; name: string };
28
+
29
+ /** Elements whose content the browser reads as text up to their own end tag. */
30
+ const RAW_TEXT = new Set([
31
+ "script",
32
+ "style",
33
+ "xmp",
34
+ "iframe",
35
+ "noembed",
36
+ "noframes",
37
+ "noscript",
38
+ "title",
39
+ "textarea",
40
+ ]);
41
+
42
+ const isWhitespace = (c: string) =>
43
+ c === " " || c === "\t" || c === "\n" || c === "\f" || c === "\r";
44
+ const isAlpha = (c: string) => (c >= "a" && c <= "z") || (c >= "A" && c <= "Z");
45
+
46
+ /** Lower-cases ASCII only, as the HTML tokenizer does for names. */
47
+ function asciiLower(value: string): string {
48
+ return value.replace(/[A-Z]+/g, (m) => m.toLowerCase());
49
+ }
50
+
51
+ export function tokenizeHtml(input: string): HtmlToken[] {
52
+ const html = input.replace(/\r\n?/g, "\n").replace(/\u0000/g, "�");
53
+ const tokens: HtmlToken[] = [];
54
+ const n = html.length;
55
+ let lower: string | undefined;
56
+ let i = 0;
57
+ let textStart = 0;
58
+
59
+ const flushText = (end: number) => {
60
+ if (end > textStart) tokens.push({ kind: "text", raw: html.slice(textStart, end) });
61
+ };
62
+
63
+ /** Skips a bogus comment: everything up to and including the next `>`. */
64
+ const skipBogusComment = (from: number): number => {
65
+ const end = html.indexOf(">", from);
66
+ return end < 0 ? n : end + 1;
67
+ };
68
+
69
+ /** Skips a comment whose `<!--` ends at `from`. */
70
+ const skipComment = (from: number): number => {
71
+ if (html.startsWith(">", from)) return from + 1; // <!-->
72
+ if (html.startsWith("->", from)) return from + 2; // <!--->
73
+ let j = from;
74
+ while (j < n) {
75
+ if (html.startsWith("-->", j)) return j + 3;
76
+ if (html.startsWith("--!>", j)) return j + 4;
77
+ j++;
78
+ }
79
+ return n;
80
+ };
81
+
82
+ /**
83
+ * Reads a tag whose name starts at `from`. Returns the token and the index
84
+ * after the tag, or null when the input ends inside it.
85
+ */
86
+ const readTag = (
87
+ from: number,
88
+ closing: boolean,
89
+ ): { token: HtmlToken; next: number } | null => {
90
+ let j = from;
91
+ while (j < n && !isWhitespace(html[j]!) && html[j] !== "/" && html[j] !== ">") j++;
92
+ const name = asciiLower(html.slice(from, j));
93
+ const attrs: Array<{ name: string; raw: string }> = [];
94
+ const seen = new Set<string>();
95
+ let selfClosing = false;
96
+
97
+ for (;;) {
98
+ // Before attribute name.
99
+ while (j < n && (isWhitespace(html[j]!) || html[j] === "/")) {
100
+ if (html[j] === "/") {
101
+ // Self-closing start tag state.
102
+ if (html[j + 1] === ">") {
103
+ selfClosing = true;
104
+ j++;
105
+ break;
106
+ }
107
+ }
108
+ j++;
109
+ }
110
+ if (j >= n) return null;
111
+ if (html[j] === ">") {
112
+ j++;
113
+ break;
114
+ }
115
+ // Attribute name: a leading `=` is part of the name.
116
+ const nameStart = j;
117
+ j++;
118
+ while (j < n && !isWhitespace(html[j]!) && html[j] !== "/" && html[j] !== ">" && html[j] !== "=") {
119
+ j++;
120
+ }
121
+ const attrName = asciiLower(html.slice(nameStart, j));
122
+ // After attribute name.
123
+ while (j < n && isWhitespace(html[j]!)) j++;
124
+ let raw = "";
125
+ if (j < n && html[j] === "=") {
126
+ j++;
127
+ while (j < n && isWhitespace(html[j]!)) j++;
128
+ if (j >= n) return null;
129
+ const quote = html[j];
130
+ if (quote === '"' || quote === "'") {
131
+ const end = html.indexOf(quote, j + 1);
132
+ if (end < 0) return null;
133
+ raw = html.slice(j + 1, end);
134
+ j = end + 1;
135
+ } else if (html[j] !== ">") {
136
+ const start = j;
137
+ while (j < n && !isWhitespace(html[j]!) && html[j] !== ">") j++;
138
+ raw = html.slice(start, j);
139
+ }
140
+ }
141
+ if (j >= n) return null;
142
+ if (!seen.has(attrName)) {
143
+ seen.add(attrName);
144
+ attrs.push({ name: attrName, raw });
145
+ }
146
+ }
147
+
148
+ const token: HtmlToken = closing
149
+ ? { kind: "end", name }
150
+ : { kind: "start", name, attrs, selfClosing };
151
+ return { token, next: j };
152
+ };
153
+
154
+ while (i < n) {
155
+ const lt = html.indexOf("<", i);
156
+ if (lt < 0) break;
157
+ const c = html[lt + 1] ?? "";
158
+
159
+ if (isAlpha(c)) {
160
+ const tag = readTag(lt + 1, false);
161
+ flushText(lt);
162
+ if (!tag) {
163
+ // The input ends inside the tag: the browser drops it.
164
+ i = textStart = n;
165
+ break;
166
+ }
167
+ tokens.push(tag.token);
168
+ i = textStart = tag.next;
169
+ const name = tag.token.kind === "start" ? tag.token.name : "";
170
+ if (name === "plaintext") {
171
+ flushText(n);
172
+ textStart = n;
173
+ i = n;
174
+ break;
175
+ }
176
+ if (RAW_TEXT.has(name)) {
177
+ // Read the content as text, up to `</name` followed by a delimiter.
178
+ // ASCII-only lower-casing keeps every index the same as in `html`.
179
+ lower ??= asciiLower(html);
180
+ let j = i;
181
+ let end = -1;
182
+ while ((j = lower.indexOf(`</${name}`, j)) >= 0) {
183
+ const after = html[j + 2 + name.length] ?? "";
184
+ if (after === "" || isWhitespace(after) || after === "/" || after === ">") {
185
+ end = j;
186
+ break;
187
+ }
188
+ j += 2 + name.length;
189
+ }
190
+ if (end < 0) {
191
+ if (i < n) tokens.push({ kind: "text", raw: html.slice(i) });
192
+ i = textStart = n;
193
+ break;
194
+ }
195
+ if (end > i) tokens.push({ kind: "text", raw: html.slice(i, end) });
196
+ const close = readTag(end + 2, true);
197
+ tokens.push({ kind: "end", name });
198
+ i = textStart = close ? close.next : n;
199
+ }
200
+ continue;
201
+ }
202
+
203
+ if (c === "/") {
204
+ const d = html[lt + 2] ?? "";
205
+ if (isAlpha(d)) {
206
+ const tag = readTag(lt + 2, true);
207
+ flushText(lt);
208
+ if (!tag) {
209
+ i = textStart = n;
210
+ break;
211
+ }
212
+ tokens.push(tag.token);
213
+ i = textStart = tag.next;
214
+ continue;
215
+ }
216
+ if (d === ">") {
217
+ flushText(lt);
218
+ i = textStart = lt + 3;
219
+ continue;
220
+ }
221
+ if (d === "") {
222
+ // `</` at the end of the input is text.
223
+ i = n;
224
+ break;
225
+ }
226
+ flushText(lt);
227
+ i = textStart = skipBogusComment(lt + 2);
228
+ continue;
229
+ }
230
+
231
+ if (c === "!") {
232
+ flushText(lt);
233
+ if (html.startsWith("--", lt + 2)) {
234
+ i = textStart = skipComment(lt + 4);
235
+ } else {
236
+ // DOCTYPE, CDATA and anything else after `<!` end at the next `>`.
237
+ i = textStart = skipBogusComment(lt + 2);
238
+ }
239
+ continue;
240
+ }
241
+
242
+ if (c === "?") {
243
+ flushText(lt);
244
+ i = textStart = skipBogusComment(lt + 1);
245
+ continue;
246
+ }
247
+
248
+ // A `<` that starts nothing is text.
249
+ i = lt + 1;
250
+ }
251
+
252
+ flushText(n);
253
+ return tokens;
254
+ }
@@ -5,7 +5,16 @@
5
5
  * only staff can author it, the output is sanitized on every render so a
6
6
  * pasted `<script>`, inline event handler or `javascript:` URL can never end
7
7
  * up in a page.
8
+ *
9
+ * The input is tokenized the way a browser tokenizes it (`html-tokenizer`),
10
+ * and the output is written from scratch: allowed elements with allowed,
11
+ * re-encoded attributes, and text with every `<` and `>` escaped. Nothing from
12
+ * the input is ever copied through as written, so markup the sanitizer does
13
+ * not recognise — an unterminated tag, a stray `<` — reaches the page as
14
+ * visible text and can never be completed into an element by the markup that
15
+ * follows it.
8
16
  */
17
+ import { tokenizeHtml } from "./html-tokenizer";
9
18
 
10
19
  const ALLOWED_TAGS = new Set([
11
20
  "p", "br", "hr", "span", "div",
@@ -19,9 +28,13 @@ const ALLOWED_TAGS = new Set([
19
28
 
20
29
  /** Tags whose entire contents are dropped. */
21
30
  const DROP_WITH_CONTENT = new Set([
22
- "script", "style", "iframe", "object", "embed", "noscript", "template", "svg", "math",
31
+ "script", "style", "iframe", "object", "noscript", "template", "svg", "math",
32
+ "textarea", "title", "xmp", "noembed", "noframes", "plaintext",
23
33
  ]);
24
34
 
35
+ /** Dropped elements that can contain themselves, so their end tags are counted. */
36
+ const NESTING = new Set(["svg", "math", "object", "template"]);
37
+
25
38
  const VOID_TAGS = new Set(["br", "hr", "img", "col"]);
26
39
 
27
40
  const GLOBAL_ATTRS = new Set(["class", "style", "id", "title", "dir", "lang"]);
@@ -58,10 +71,13 @@ const ALLOWED_CSS = new Set([
58
71
 
59
72
  const SAFE_URL = /^(https?:\/\/|mailto:|tel:|\/|#|\.\/|\.\.\/)/i;
60
73
 
61
- function safeUrl(raw: string): string | null {
74
+ function safeUrl(raw: string, attribute: string): string | null {
62
75
  const value = raw.trim().replace(/[\u0000-\u001f]/g, "");
63
76
  if (!value) return null;
64
- if (/^data:image\/(png|jpe?g|gif|webp|avif);base64,/i.test(value)) return value;
77
+ // An inline image is an image source, never a link target.
78
+ if (/^data:image\/(png|jpe?g|gif|webp|avif);base64,/i.test(value)) {
79
+ return attribute === "src" ? value : null;
80
+ }
65
81
  if (!SAFE_URL.test(value)) return null;
66
82
  if (/^\s*javascript:/i.test(value)) return null;
67
83
  return value;
@@ -114,7 +130,7 @@ const NAMED_REFERENCES: Record<string, string> = {
114
130
  * was checked: `&#106;avascript:` is judged as `javascript:`, and an
115
131
  * undecoded `javascript&colon;` never becomes one.
116
132
  */
117
- function decodeAttr(value: string): string {
133
+ export function decodeAttr(value: string): string {
118
134
  return value.replace(
119
135
  /&(?:#([0-9]{1,7})|#[xX]([0-9a-fA-F]{1,6})|([a-zA-Z][a-zA-Z0-9]{0,31}));/g,
120
136
  (reference, decimal?: string, hex?: string, name?: string) => {
@@ -127,21 +143,17 @@ function decodeAttr(value: string): string {
127
143
  );
128
144
  }
129
145
 
130
- const ATTR_RE = /([a-zA-Z_:][-a-zA-Z0-9_:.]*)\s*=\s*("([^"]*)"|'([^']*)'|([^\s"'>]+))/g;
131
-
132
- function sanitizeAttrs(tag: string, raw: string): string {
133
- const allowed = TAG_ATTRS[tag];
146
+ function sanitizeAttrs(tag: string, attrs: ReadonlyArray<{ name: string; raw: string }>): string {
147
+ const allowed = Object.prototype.hasOwnProperty.call(TAG_ATTRS, tag) ? TAG_ATTRS[tag] : undefined;
134
148
  const parts: string[] = [];
135
- let m: RegExpExecArray | null;
136
- ATTR_RE.lastIndex = 0;
137
- while ((m = ATTR_RE.exec(raw))) {
138
- const name = m[1].toLowerCase();
139
- let value = decodeAttr(m[3] ?? m[4] ?? m[5] ?? "");
149
+ for (const attr of attrs) {
150
+ const name = attr.name;
151
+ let value = decodeAttr(attr.raw);
140
152
  if (name.startsWith("on")) continue;
141
153
  if (!GLOBAL_ATTRS.has(name) && !allowed?.has(name)) continue;
142
154
  if (name === "href" || name === "src" || name === "srcset") {
143
155
  if (name === "srcset") continue;
144
- const url = safeUrl(value);
156
+ const url = safeUrl(value, name);
145
157
  if (!url) continue;
146
158
  value = url;
147
159
  }
@@ -164,30 +176,56 @@ function sanitizeAttrs(tag: string, raw: string): string {
164
176
  return parts.length ? " " + parts.join(" ") : "";
165
177
  }
166
178
 
179
+ /** A character reference as written in text: kept, since it can only decode to text. */
180
+ const CHAR_REF = /^&(?:#[0-9]{1,7}|#[xX][0-9a-fA-F]{1,6}|[a-zA-Z][a-zA-Z0-9]{0,31});/;
181
+
182
+ /** Text as written, made safe to emit: no `<` or `>`, and every `&` a reference. */
183
+ function escapeText(raw: string): string {
184
+ let out = "";
185
+ for (let i = 0; i < raw.length; i++) {
186
+ const c = raw[i]!;
187
+ if (c === "<") out += "&lt;";
188
+ else if (c === ">") out += "&gt;";
189
+ else if (c === "&") out += CHAR_REF.test(raw.slice(i, i + 42)) ? "&" : "&amp;";
190
+ else out += c;
191
+ }
192
+ return out;
193
+ }
194
+
167
195
  /** Sanitize an HTML fragment down to the editor's allowlist. */
168
196
  export function sanitizeHtml(input: string | undefined | null): string {
169
197
  if (!input) return "";
170
- // Remove comments and any element whose content must go with it.
171
- let html = input.replace(/<!--[\s\S]*?-->/g, "");
172
- for (const tag of DROP_WITH_CONTENT) {
173
- html = html.replace(new RegExp(`<${tag}[\\s\\S]*?<\\/${tag}\\s*>`, "gi"), "");
174
- html = html.replace(new RegExp(`<${tag}\\b[^>]*\\/?>`, "gi"), "");
175
- }
176
-
177
198
  const open: string[] = [];
178
199
  let out = "";
179
- let last = 0;
180
- const tagRe = /<\s*(\/)?\s*([a-zA-Z][a-zA-Z0-9-]*)((?:[^>"']|"[^"]*"|'[^']*')*)>/g;
181
- let m: RegExpExecArray | null;
182
-
183
- while ((m = tagRe.exec(html))) {
184
- out += html.slice(last, m.index);
185
- last = m.index + m[0].length;
186
- const closing = Boolean(m[1]);
187
- const tag = m[2].toLowerCase();
200
+ /** The dropped element being skipped, and how deep inside it we are. */
201
+ let skipping: { tag: string; depth: number } | null = null;
202
+
203
+ for (const token of tokenizeHtml(input)) {
204
+ if (skipping) {
205
+ if (token.kind === "start" && token.name === skipping.tag && NESTING.has(token.name)) {
206
+ if (!token.selfClosing) skipping.depth++;
207
+ } else if (token.kind === "end" && token.name === skipping.tag) {
208
+ if (--skipping.depth === 0) skipping = null;
209
+ }
210
+ continue;
211
+ }
212
+
213
+ if (token.kind === "text") {
214
+ out += escapeText(token.raw);
215
+ continue;
216
+ }
217
+
218
+ const tag = token.name;
219
+ if (token.kind === "start" && DROP_WITH_CONTENT.has(tag)) {
220
+ // A self-closing <svg/> or <math/> is empty; every other one has content.
221
+ if (!(token.selfClosing && (tag === "svg" || tag === "math"))) {
222
+ skipping = { tag, depth: 1 };
223
+ }
224
+ continue;
225
+ }
188
226
  if (!ALLOWED_TAGS.has(tag)) continue;
189
227
 
190
- if (closing) {
228
+ if (token.kind === "end") {
191
229
  if (VOID_TAGS.has(tag)) continue;
192
230
  const at = open.lastIndexOf(tag);
193
231
  if (at < 0) continue;
@@ -197,22 +235,19 @@ export function sanitizeHtml(input: string | undefined | null): string {
197
235
  }
198
236
 
199
237
  if (VOID_TAGS.has(tag)) {
200
- out += `<${tag}${sanitizeAttrs(tag, m[3] ?? "")} />`;
238
+ out += `<${tag}${sanitizeAttrs(tag, token.attrs)} />`;
201
239
  continue;
202
240
  }
203
- out += `<${tag}${sanitizeAttrs(tag, m[3] ?? "")}>`;
241
+ out += `<${tag}${sanitizeAttrs(tag, token.attrs)}>`;
204
242
  open.push(tag);
205
243
  }
206
- out += html.slice(last);
207
244
  while (open.length) out += `</${open.pop()}>`;
208
245
  return out;
209
246
  }
210
247
 
211
248
  /** Plain-text preview of rich text (used for thumbnails and summaries). */
212
249
  export function htmlToText(input: string | undefined | null): string {
213
- return sanitizeHtml(input)
214
- .replace(/<[^>]+>/g, " ")
215
- .replace(/&nbsp;/g, " ")
250
+ return decodeAttr(sanitizeHtml(input).replace(/<[^>]+>/g, " "))
216
251
  .replace(/\s+/g, " ")
217
252
  .trim();
218
253
  }