mui-atelier 0.1.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 (77) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/LICENSE +21 -0
  3. package/NOTICE +32 -0
  4. package/README.md +282 -0
  5. package/dist/components/AddressFields.js +83 -0
  6. package/dist/components/AuthCard.js +94 -0
  7. package/dist/components/AuthSplash.js +88 -0
  8. package/dist/components/BrandHeader.js +81 -0
  9. package/dist/components/BrandLink.js +22 -0
  10. package/dist/components/BrandMark.js +69 -0
  11. package/dist/components/BrandProvider.js +14 -0
  12. package/dist/components/Breadcrumbs.js +62 -0
  13. package/dist/components/CardHead.js +46 -0
  14. package/dist/components/ConfirmProvider.js +77 -0
  15. package/dist/components/DashboardLayout.js +645 -0
  16. package/dist/components/DataTable.js +546 -0
  17. package/dist/components/DetailRows.js +42 -0
  18. package/dist/components/EmptyNote.js +28 -0
  19. package/dist/components/Eyebrow.js +21 -0
  20. package/dist/components/HeadingHelp.js +38 -0
  21. package/dist/components/HeadingLevel.js +18 -0
  22. package/dist/components/IconTile.js +42 -0
  23. package/dist/components/ImagePreviewDialog.js +86 -0
  24. package/dist/components/LabeledField.js +156 -0
  25. package/dist/components/LanguageSwitcher.js +43 -0
  26. package/dist/components/ListRow.js +59 -0
  27. package/dist/components/ModeToggle.js +35 -0
  28. package/dist/components/PageHeader.js +65 -0
  29. package/dist/components/PasswordField.js +43 -0
  30. package/dist/components/PublicLayout.js +27 -0
  31. package/dist/components/PublicNav.js +17 -0
  32. package/dist/components/RouteMessage.js +31 -0
  33. package/dist/components/RowList.js +10 -0
  34. package/dist/components/SafeHtml.js +57 -0
  35. package/dist/components/SaveState.js +13 -0
  36. package/dist/components/SectionPanel.js +48 -0
  37. package/dist/components/SectionTabs.js +139 -0
  38. package/dist/components/SiteNav.js +130 -0
  39. package/dist/components/Skeletons.js +63 -0
  40. package/dist/components/Sortable.js +81 -0
  41. package/dist/components/StatTile.js +66 -0
  42. package/dist/components/StatusChip.js +17 -0
  43. package/dist/components/StatusDot.js +42 -0
  44. package/dist/components/SubCard.js +30 -0
  45. package/dist/components/TabbedCard.js +20 -0
  46. package/dist/components/TableFilters.js +247 -0
  47. package/dist/components/ToastProvider.js +65 -0
  48. package/dist/eslint/chipRules.mjs +65 -0
  49. package/dist/hooks/useDocumentTitle.js +19 -0
  50. package/dist/hooks/useOverflowTabs.js +53 -0
  51. package/dist/hooks/useUnsavedChanges.js +46 -0
  52. package/dist/i18n/loadMessages.js +15 -0
  53. package/dist/i18n/mergeMessages.js +13 -0
  54. package/dist/i18n/messages/en.json +103 -0
  55. package/dist/i18n/messages/es.json +103 -0
  56. package/dist/i18n/messages/zh.json +103 -0
  57. package/dist/lib/authCard.js +4 -0
  58. package/dist/lib/chipTone.js +46 -0
  59. package/dist/lib/countryRegionData.js +29 -0
  60. package/dist/lib/countryRegionData.json +1 -0
  61. package/dist/lib/navLock.js +22 -0
  62. package/dist/lib/navTabSx.js +78 -0
  63. package/dist/lib/orgTime.js +243 -0
  64. package/dist/lib/pageHtml.js +139 -0
  65. package/dist/lib/safeNext.js +13 -0
  66. package/dist/lib/sanitizeHtml.js +36 -0
  67. package/dist/lib/slugify.js +7 -0
  68. package/dist/lib/tableFilter.js +147 -0
  69. package/dist/lib/tableStyle.js +87 -0
  70. package/dist/test/jsdomShims.js +53 -0
  71. package/dist/test/themeContract.js +178 -0
  72. package/dist/theme/OrgThemeRegistry.js +71 -0
  73. package/dist/theme/ThemeRegistry.js +17 -0
  74. package/dist/theme/berryNavRow.js +21 -0
  75. package/dist/theme/createSiteTheme.js +974 -0
  76. package/dist/theme/themes.js +438 -0
  77. package/package.json +118 -0
@@ -0,0 +1,22 @@
1
+ "use client";
2
+ import { jsx } from "react/jsx-runtime";
3
+ import { createContext, useContext, useMemo, useState } from "react";
4
+ const NavLockedContext = createContext(false);
5
+ const SetNavLockContext = createContext(() => {
6
+ });
7
+ function NavLockProvider({ children }) {
8
+ const [locked, setLocked] = useState(false);
9
+ const value = useMemo(() => setLocked, []);
10
+ return /* @__PURE__ */ jsx(SetNavLockContext.Provider, { value, children: /* @__PURE__ */ jsx(NavLockedContext.Provider, { value: locked, children }) });
11
+ }
12
+ function useNavLock() {
13
+ return useContext(SetNavLockContext);
14
+ }
15
+ function useNavLocked() {
16
+ return useContext(NavLockedContext);
17
+ }
18
+ export {
19
+ NavLockProvider,
20
+ useNavLock,
21
+ useNavLocked
22
+ };
@@ -0,0 +1,78 @@
1
+ const WELL_PAD = 4;
2
+ function wellSx(theme) {
3
+ const radius = theme.rt?.radii?.button ?? 8;
4
+ return {
5
+ display: "inline-flex",
6
+ alignItems: "center",
7
+ maxWidth: "100%",
8
+ p: `${WELL_PAD}px`,
9
+ gap: `${WELL_PAD}px`,
10
+ borderRadius: `${radius + WELL_PAD}px`,
11
+ bgcolor: "rgba(0, 0, 0, 0.05)",
12
+ ...theme.applyStyles?.("dark", { backgroundColor: "rgba(0, 0, 0, 0.32)" }),
13
+ border: "1px solid",
14
+ borderColor: "divider"
15
+ };
16
+ }
17
+ function wellTabSx(theme, active) {
18
+ const radius = theme.rt?.radii?.button ?? 8;
19
+ return {
20
+ display: "inline-flex",
21
+ alignItems: "center",
22
+ gap: 0.5,
23
+ minHeight: 34,
24
+ px: 1.75,
25
+ borderRadius: `${radius}px`,
26
+ // Always a border, so activation changes colour and never width.
27
+ border: "1px solid transparent",
28
+ whiteSpace: "nowrap",
29
+ textDecoration: "none",
30
+ fontSize: "0.9rem",
31
+ fontWeight: 600,
32
+ lineHeight: 1,
33
+ color: "text.secondary",
34
+ cursor: "pointer",
35
+ transition: "color 150ms ease, background-color 150ms ease, box-shadow 150ms ease",
36
+ "&:hover": { color: "text.primary" },
37
+ ...active ? {
38
+ color: "primary.main",
39
+ bgcolor: "background.paper",
40
+ borderColor: "divider",
41
+ boxShadow: theme.rt?.lift?.rest ?? theme.shadows[1],
42
+ ...theme.applyStyles?.("dark", { boxShadow: theme.rt?.lift?.restDark ?? theme.shadows[1] }),
43
+ "&:hover": { color: "primary.main" }
44
+ } : null,
45
+ "@media (prefers-reduced-motion: reduce)": { transition: "none" }
46
+ };
47
+ }
48
+ function underlineRowSx() {
49
+ return { display: "flex", alignItems: "stretch", columnGap: 3 };
50
+ }
51
+ function underlineTabSx(active) {
52
+ return {
53
+ display: "inline-flex",
54
+ alignItems: "center",
55
+ gap: 0.5,
56
+ py: 1.5,
57
+ whiteSpace: "nowrap",
58
+ textDecoration: "none",
59
+ fontSize: "0.875rem",
60
+ fontWeight: 500,
61
+ color: active ? "secondary.main" : "text.secondary",
62
+ borderBottom: "2px solid",
63
+ borderColor: active ? "secondary.main" : "transparent",
64
+ mb: "-1px",
65
+ // sit the indicator on the bar's own bottom border
66
+ cursor: "pointer",
67
+ transition: "color 150ms ease, border-color 150ms ease",
68
+ "&:hover": { color: active ? "secondary.main" : "text.primary" },
69
+ "@media (prefers-reduced-motion: reduce)": { transition: "none" }
70
+ };
71
+ }
72
+ export {
73
+ WELL_PAD,
74
+ underlineRowSx,
75
+ underlineTabSx,
76
+ wellSx,
77
+ wellTabSx
78
+ };
@@ -0,0 +1,243 @@
1
+ const MONTHS_SHORT = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
2
+ const MONTHS_LONG = [
3
+ "January",
4
+ "February",
5
+ "March",
6
+ "April",
7
+ "May",
8
+ "June",
9
+ "July",
10
+ "August",
11
+ "September",
12
+ "October",
13
+ "November",
14
+ "December"
15
+ ];
16
+ const pad = (n) => String(n).padStart(2, "0");
17
+ const DEFAULT_DATE_FORMAT = "medium";
18
+ const DEFAULT_TIME_FORMAT = "24h";
19
+ const RENDERERS = {
20
+ medium: (p) => `${p.d} ${MONTHS_SHORT[p.m - 1]} ${p.y}`,
21
+ medium_us: (p) => `${MONTHS_SHORT[p.m - 1]} ${p.d}, ${p.y}`,
22
+ long: (p) => `${p.d} ${MONTHS_LONG[p.m - 1]} ${p.y}`,
23
+ dmy: (p) => `${pad(p.d)}/${pad(p.m)}/${p.y}`,
24
+ mdy: (p) => `${pad(p.m)}/${pad(p.d)}/${p.y}`,
25
+ iso: (p) => `${p.y}-${pad(p.m)}-${pad(p.d)}`
26
+ };
27
+ const DATE_FORMATS = [
28
+ { key: "medium", label: "Day Month Year" },
29
+ { key: "medium_us", label: "Month Day, Year" },
30
+ { key: "long", label: "Day Month Year (full)" },
31
+ { key: "dmy", label: "Numeric, day first" },
32
+ { key: "mdy", label: "Numeric, month first" },
33
+ { key: "iso", label: "ISO 8601" }
34
+ ].map((f) => ({ ...f, example: RENDERERS[f.key]({ y: 2026, m: 9, d: 30 }) }));
35
+ const CLOCKS = { "24h": "24h", "12h": "12h" };
36
+ const TIME_FORMATS = [
37
+ { key: "24h", label: "24-hour", clock: "24h", zone: null },
38
+ { key: "12h", label: "12-hour", clock: "12h", zone: null },
39
+ { key: "24h_zone", label: "24-hour with timezone", clock: "24h", zone: "abbr" },
40
+ { key: "12h_zone", label: "12-hour with timezone", clock: "12h", zone: "abbr" },
41
+ { key: "24h_offset", label: "24-hour with UTC offset", clock: "24h", zone: "offset" },
42
+ { key: "12h_offset", label: "12-hour with UTC offset", clock: "12h", zone: "offset" },
43
+ { key: "24h_zone_offset", label: "24-hour with timezone and UTC offset", clock: "24h", zone: "both" },
44
+ { key: "12h_zone_offset", label: "12-hour with timezone and UTC offset", clock: "12h", zone: "both" }
45
+ ];
46
+ const timeSpec = (key) => TIME_FORMATS.find((f) => f.key === key) ?? TIME_FORMATS[0];
47
+ function timeFormatOptions(timezone, sample = "2026-09-30T17:00") {
48
+ return TIME_FORMATS.map((f) => ({
49
+ ...f,
50
+ example: formatOrgTime(sample, { timezone, time_format: f.key })
51
+ }));
52
+ }
53
+ function partsOf(value, tz) {
54
+ const raw = String(value ?? "");
55
+ if (raw === "") return null;
56
+ const local = /^(\d{4})-(\d{2})-(\d{2})(?:[T ](\d{2}):(\d{2}))?$/.exec(raw);
57
+ if (local) {
58
+ return {
59
+ y: Number(local[1]),
60
+ m: Number(local[2]),
61
+ d: Number(local[3]),
62
+ hh: local[4] === void 0 ? 0 : Number(local[4]),
63
+ mm: local[5] === void 0 ? 0 : Number(local[5]),
64
+ hasTime: local[4] !== void 0,
65
+ // No instant yet: a wall-clock string names a time in the org's zone, and
66
+ // which instant that is depends on the zone's offset on that very date.
67
+ // Resolved on demand in instantOf, only when a zone marker is wanted.
68
+ instant: null
69
+ };
70
+ }
71
+ const dt = new Date(raw);
72
+ if (Number.isNaN(dt.getTime())) return null;
73
+ let f;
74
+ try {
75
+ f = new Intl.DateTimeFormat("en-CA", {
76
+ timeZone: tz || "UTC",
77
+ year: "numeric",
78
+ month: "2-digit",
79
+ day: "2-digit",
80
+ hour: "2-digit",
81
+ minute: "2-digit",
82
+ hour12: false
83
+ }).formatToParts(dt);
84
+ } catch {
85
+ return partsOf(raw, "UTC");
86
+ }
87
+ const p = Object.fromEntries(f.map((x) => [x.type, x.value]));
88
+ return {
89
+ y: Number(p.year),
90
+ m: Number(p.month),
91
+ d: Number(p.day),
92
+ // en-CA renders midnight as "24" rather than "00" in some engines.
93
+ hh: Number(p.hour) % 24,
94
+ mm: Number(p.minute),
95
+ hasTime: true,
96
+ instant: dt
97
+ };
98
+ }
99
+ function offsetMinutesAt(date, tz) {
100
+ const f = new Intl.DateTimeFormat("en-CA", {
101
+ timeZone: tz,
102
+ year: "numeric",
103
+ month: "2-digit",
104
+ day: "2-digit",
105
+ hour: "2-digit",
106
+ minute: "2-digit",
107
+ second: "2-digit",
108
+ hour12: false
109
+ }).formatToParts(date);
110
+ const p = Object.fromEntries(f.map((x) => [x.type, x.value]));
111
+ const asUtc = Date.UTC(
112
+ Number(p.year),
113
+ Number(p.month) - 1,
114
+ Number(p.day),
115
+ Number(p.hour) % 24,
116
+ Number(p.minute),
117
+ Number(p.second)
118
+ );
119
+ return (asUtc - date.getTime()) / 6e4;
120
+ }
121
+ function instantOf(p, tz) {
122
+ if (p.instant) return p.instant;
123
+ const guess = Date.UTC(p.y, p.m - 1, p.d, p.hh, p.mm);
124
+ const first = offsetMinutesAt(new Date(guess), tz);
125
+ const corrected = guess - first * 6e4;
126
+ return new Date(guess - offsetMinutesAt(new Date(corrected), tz) * 6e4);
127
+ }
128
+ function zoneMarker(p, tz, spec) {
129
+ if (!spec.zone) return "";
130
+ const zone = tz || "UTC";
131
+ const named = (style) => new Intl.DateTimeFormat("en-US", { timeZone: zone, timeZoneName: style, year: "numeric" }).formatToParts(instantOf(p, zone)).find((x) => x.type === "timeZoneName")?.value;
132
+ try {
133
+ if (spec.zone === "abbr") return named("short") ?? "";
134
+ const offset = named("longOffset");
135
+ if (!offset) return "";
136
+ const utc = `(UTC${normalizeOffset(offset)})`;
137
+ if (spec.zone === "offset") return utc;
138
+ const abbr = named("short");
139
+ return abbr ? `${abbr} ${utc}` : utc;
140
+ } catch {
141
+ return "";
142
+ }
143
+ }
144
+ function clock(p, spec) {
145
+ if (CLOCKS[spec.clock] === "12h") {
146
+ const h = p.hh % 12 === 0 ? 12 : p.hh % 12;
147
+ return `${h}:${pad(p.mm)} ${p.hh < 12 ? "AM" : "PM"}`;
148
+ }
149
+ return `${pad(p.hh)}:${pad(p.mm)}`;
150
+ }
151
+ function formatOrgTime(value, org = {}, { zone = true } = {}) {
152
+ const p = partsOf(value, org.timezone);
153
+ if (!p) return null;
154
+ const spec = timeSpec(org.time_format ?? DEFAULT_TIME_FORMAT);
155
+ const marker = zone ? zoneMarker(p, org.timezone, spec) : null;
156
+ return marker ? `${clock(p, spec)} ${marker}` : clock(p, spec);
157
+ }
158
+ function formatOrgDate(value, org = {}, { withTime = "auto" } = {}) {
159
+ const p = partsOf(value, org.timezone);
160
+ if (!p) return null;
161
+ const render = RENDERERS[org.date_format] ?? RENDERERS[DEFAULT_DATE_FORMAT];
162
+ const date = render(p);
163
+ const show = withTime === "auto" ? p.hasTime && !(p.hh === 0 && p.mm === 0) : withTime;
164
+ if (!show) return date;
165
+ return `${date}, ${formatOrgTime(value, org)}`;
166
+ }
167
+ function formatOrgDateTimeShort(value, org = {}) {
168
+ return formatOrgDate(value, org, { withTime: true });
169
+ }
170
+ function orgDay(value, org = {}) {
171
+ const p = partsOf(value, org.timezone);
172
+ if (!p) return null;
173
+ return `${String(p.y).padStart(4, "0")}-${String(p.m).padStart(2, "0")}-${String(p.d).padStart(2, "0")}`;
174
+ }
175
+ function normalizeOffset(name) {
176
+ const raw = String(name || "").replace(/^GMT/, "").trim();
177
+ if (raw === "") return "+00:00";
178
+ const m = raw.match(/^([+-])(\d{1,2})(?::?(\d{2}))?$/);
179
+ return m ? `${m[1]}${m[2].padStart(2, "0")}:${m[3] || "00"}` : "+00:00";
180
+ }
181
+ function formatOrgDateTime(iso, org = {}) {
182
+ const p = partsOf(iso, org.timezone);
183
+ if (!p) return null;
184
+ const render = RENDERERS[org.date_format] ?? RENDERERS[DEFAULT_DATE_FORMAT];
185
+ const stamp = `${render(p)}, ${formatOrgTime(iso, org)}`;
186
+ if (timeSpec(org.time_format ?? DEFAULT_TIME_FORMAT).zone) return stamp;
187
+ const zone = zoneSuffix(iso, org.timezone);
188
+ return zone ? `${stamp} ${zone}` : stamp;
189
+ }
190
+ function orgZoneMark(value, org = {}) {
191
+ if (timeSpec(org.time_format ?? DEFAULT_TIME_FORMAT).zone) return "";
192
+ if (!partsOf(value, org.timezone)?.hasTime) return "";
193
+ return zoneSuffix(value, org.timezone);
194
+ }
195
+ function zoneSuffix(value, tz) {
196
+ const d = new Date(String(value ?? ""));
197
+ if (Number.isNaN(d.getTime())) return "";
198
+ try {
199
+ const zone = tz || "UTC";
200
+ const short = new Intl.DateTimeFormat("en-US", { timeZone: zone, timeZoneName: "shortGeneric", year: "numeric" }).formatToParts(d).find((x) => x.type === "timeZoneName")?.value;
201
+ const long = new Intl.DateTimeFormat("en-US", { timeZone: zone, timeZoneName: "longOffset", year: "numeric" }).formatToParts(d).find((x) => x.type === "timeZoneName")?.value;
202
+ return `${short} (GMT/UTC ${normalizeOffset(long)})`;
203
+ } catch {
204
+ return "";
205
+ }
206
+ }
207
+ function timeLeftToSubmit(endIso) {
208
+ const left = timeLeftParts(endIso);
209
+ if (!left) return null;
210
+ return `${left.count} ${left.unit}${left.count === 1 ? "" : "s"} left to submit`;
211
+ }
212
+ function timeLeftParts(endIso) {
213
+ if (!endIso) return null;
214
+ const end = new Date(endIso);
215
+ if (Number.isNaN(end.getTime())) return null;
216
+ const ms = end.getTime() - Date.now();
217
+ if (ms <= 0) return null;
218
+ const mins = Math.floor(ms / 6e4);
219
+ const days = Math.floor(mins / 1440);
220
+ const hours = Math.floor(mins % 1440 / 60);
221
+ if (days >= 1) return { count: days, unit: "day" };
222
+ if (hours >= 1) return { count: hours, unit: "hour" };
223
+ return { count: Math.max(1, mins), unit: "minute" };
224
+ }
225
+ function timeLeftKey(unit) {
226
+ return { day: "timeLeftDays", hour: "timeLeftHours", minute: "timeLeftMinutes" }[unit];
227
+ }
228
+ export {
229
+ DATE_FORMATS,
230
+ DEFAULT_DATE_FORMAT,
231
+ DEFAULT_TIME_FORMAT,
232
+ TIME_FORMATS,
233
+ formatOrgDate,
234
+ formatOrgDateTime,
235
+ formatOrgDateTimeShort,
236
+ formatOrgTime,
237
+ orgDay,
238
+ orgZoneMark,
239
+ timeFormatOptions,
240
+ timeLeftKey,
241
+ timeLeftParts,
242
+ timeLeftToSubmit
243
+ };
@@ -0,0 +1,139 @@
1
+ const HEADINGS = ["h1", "h2", "h3", "h4", "h5", "h6"];
2
+ const SAFE_STYLES = {
3
+ color: [/^#(?:[0-9a-fA-F]{3,4}){1,2}$/, /^rgba?\([\d.,%\s]+\)$/, /^[a-z]+$/i],
4
+ "background-color": [/^#(?:[0-9a-fA-F]{3,4}){1,2}$/, /^rgba?\([\d.,%\s]+\)$/, /^[a-z]+$/i],
5
+ "text-align": [/^(?:left|right|center|justify)$/],
6
+ "font-weight": [/^(?:normal|bold|[1-9]00)$/],
7
+ "font-style": [/^(?:normal|italic|oblique)$/],
8
+ "text-decoration": [/^(?:none|underline|line-through|overline)$/]
9
+ };
10
+ const PAGE_SANITIZE_OPTIONS = {
11
+ allowedTags: [
12
+ ...HEADINGS,
13
+ "p",
14
+ "br",
15
+ "hr",
16
+ "blockquote",
17
+ "pre",
18
+ "code",
19
+ "strong",
20
+ "em",
21
+ "b",
22
+ "i",
23
+ "u",
24
+ "s",
25
+ "sub",
26
+ "sup",
27
+ "span",
28
+ "ul",
29
+ "ol",
30
+ "li",
31
+ "a",
32
+ "img",
33
+ "figure",
34
+ "figcaption",
35
+ // Tables, including the parts a real table needs: a caption, a footer, and
36
+ // column groups.
37
+ //
38
+ // colgroup/col are kept for STRUCTURE, not for widths — an earlier comment
39
+ // here claimed the opposite. Column widths never survive: `width` is not in
40
+ // SAFE_STYLES, so a <col style="width:220px"> arrives as a bare <col />.
41
+ // The tags stay because dropping them would leave orphaned <col> elements
42
+ // for the parser to relocate inside the table, rearranging markup an author
43
+ // wrote by hand.
44
+ "table",
45
+ "thead",
46
+ "tbody",
47
+ "tfoot",
48
+ "caption",
49
+ "colgroup",
50
+ "col",
51
+ "tr",
52
+ "th",
53
+ "td"
54
+ ],
55
+ allowedAttributes: {
56
+ a: ["href", "title", "target", "rel"],
57
+ img: ["src", "alt", "title", "width", "height"],
58
+ // Alignment is emitted as an inline style on the BLOCK, not on a span, so
59
+ // the tags a block can be need to carry `style` — constrained to
60
+ // SAFE_STYLES above, which is what keeps that widening harmless.
61
+ span: ["style"],
62
+ p: ["style"],
63
+ ...Object.fromEntries(HEADINGS.map((h) => [h, ["style"]])),
64
+ // colspan/rowspan are how merged cells survive; without them a merged table
65
+ // silently reflows into the wrong shape.
66
+ td: ["style", "colspan", "rowspan"],
67
+ th: ["style", "colspan", "rowspan", "scope"],
68
+ col: ["span", "style"],
69
+ "*": ["class"]
70
+ },
71
+ allowedStyles: { "*": SAFE_STYLES },
72
+ allowedSchemes: ["http", "https", "mailto"],
73
+ /*
74
+ * Images are SAME-ORIGIN ONLY: no scheme is allowed for <img>, which leaves
75
+ * relative sources — e.g. `/images/{key}`, served from the site's own domain
76
+ * by a route handler.
77
+ *
78
+ * Refusing remote images is what lets a public page's `img-src` close to
79
+ * 'self' data:. A page that renders administrator-authored HTML may share a
80
+ * registrable parent domain with an authenticated app, so an <img> pointing
81
+ * anywhere it likes is an exfiltration channel for anything that survives
82
+ * this sanitiser. Hotlinking is also the worse feature: the image rots when
83
+ * the far end moves it, and it leaks every reader's IP to a third party.
84
+ *
85
+ * `data:` stays for <img> alone — the editor can produce one while an upload
86
+ * is in flight, and a data: URL on any OTHER tag is a script-execution vector.
87
+ */
88
+ allowedSchemesByTag: { img: ["data"] },
89
+ // `//evil.test/x.png` has no scheme, so a scheme allow-list does not stop it —
90
+ // the browser resolves it against the page's protocol and fetches it. Without
91
+ // this, every restriction above is one slash away from being bypassed.
92
+ allowProtocolRelative: false,
93
+ transformTags: {
94
+ // Any link that opens a new tab must not hand the opener over.
95
+ a: (tagName, attribs) => ({
96
+ tagName,
97
+ attribs: { ...attribs, ...attribs.target === "_blank" ? { rel: "noopener noreferrer" } : {} }
98
+ })
99
+ }
100
+ };
101
+ const NO_IMAGES = ["img", "figure", "figcaption"];
102
+ const EMAIL_TABLE_ATTRS = {
103
+ table: ["align", "width", "border", "cellpadding", "cellspacing", "bgcolor"],
104
+ thead: ["align", "bgcolor"],
105
+ tbody: ["align", "bgcolor"],
106
+ tfoot: ["align", "bgcolor"],
107
+ tr: ["align", "valign", "bgcolor"],
108
+ td: ["align", "valign", "bgcolor", "width", "height", "nowrap"],
109
+ th: ["align", "valign", "bgcolor", "width", "height", "nowrap"],
110
+ col: ["align", "valign", "width"]
111
+ };
112
+ const EMAIL_SANITIZE_OPTIONS = {
113
+ ...PAGE_SANITIZE_OPTIONS,
114
+ allowedTags: PAGE_SANITIZE_OPTIONS.allowedTags.filter((t) => !NO_IMAGES.includes(t)),
115
+ /*
116
+ * Derived by ADDITION, the mirror of the subtraction above: each widened tag
117
+ * is the PAGE list plus the email-only extras, so a tag that gains an
118
+ * attribute on the page side gains it here automatically. Restating the lists
119
+ * would be the second copy this file's opening comment warns about.
120
+ */
121
+ allowedAttributes: {
122
+ ...PAGE_SANITIZE_OPTIONS.allowedAttributes,
123
+ ...Object.fromEntries(
124
+ Object.entries(EMAIL_TABLE_ATTRS).map(([tag, extra]) => [
125
+ tag,
126
+ [...PAGE_SANITIZE_OPTIONS.allowedAttributes[tag] ?? [], ...extra]
127
+ ])
128
+ )
129
+ }
130
+ };
131
+ const GUIDELINES_SANITIZE_OPTIONS = {
132
+ ...PAGE_SANITIZE_OPTIONS,
133
+ allowedTags: PAGE_SANITIZE_OPTIONS.allowedTags.filter((t) => !NO_IMAGES.includes(t))
134
+ };
135
+ export {
136
+ EMAIL_SANITIZE_OPTIONS,
137
+ GUIDELINES_SANITIZE_OPTIONS,
138
+ PAGE_SANITIZE_OPTIONS
139
+ };
@@ -0,0 +1,13 @@
1
+ function safeNext(next, fallback = "/", prefix = null) {
2
+ if (typeof next !== "string" || next === "") return fallback;
3
+ if (!next.startsWith("/") || next[1] === "/" || next[1] === "\\") return fallback;
4
+ if (/^\/[^/]*:/.test(next)) return fallback;
5
+ if (prefix !== null) {
6
+ const inside = next === prefix || next.startsWith(`${prefix}/`) || next.startsWith(`${prefix}?`) || next.startsWith(`${prefix}#`);
7
+ if (!inside) return fallback;
8
+ }
9
+ return next;
10
+ }
11
+ export {
12
+ safeNext as default
13
+ };
@@ -0,0 +1,36 @@
1
+ import DOMPurify from "dompurify";
2
+ import { GUIDELINES_SANITIZE_OPTIONS } from "./pageHtml.js";
3
+ const INLINE_TAGS = ["strong", "b", "em", "i", "u", "a", "br"];
4
+ const BLOCK_TAGS = ["p", ...INLINE_TAGS];
5
+ const RICH_TAGS = GUIDELINES_SANITIZE_OPTIONS.allowedTags;
6
+ const RICH_ATTR = [
7
+ ...new Set(Object.values(GUIDELINES_SANITIZE_OPTIONS.allowedAttributes).flat())
8
+ ].filter((attr) => attr !== "style");
9
+ let hooked = false;
10
+ function ensureLinkHook() {
11
+ if (hooked || typeof window === "undefined" || typeof DOMPurify.addHook !== "function") return;
12
+ DOMPurify.addHook("afterSanitizeAttributes", (node) => {
13
+ if (node.tagName === "A") {
14
+ node.setAttribute("target", "_blank");
15
+ node.setAttribute("rel", "noopener noreferrer nofollow");
16
+ }
17
+ });
18
+ hooked = true;
19
+ }
20
+ function sanitizeHtml(html, { block = false, rich = false } = {}) {
21
+ if (!html) return "";
22
+ if (typeof window === "undefined") return "";
23
+ ensureLinkHook();
24
+ let input = String(html);
25
+ if (!block && !rich) {
26
+ input = input.replace(/<\/p\s*>\s*<p\b[^>]*>/gi, "<br>").replace(/<\/?p\b[^>]*>/gi, "");
27
+ }
28
+ return DOMPurify.sanitize(input, {
29
+ ALLOWED_TAGS: rich ? RICH_TAGS : block ? BLOCK_TAGS : INLINE_TAGS,
30
+ ALLOWED_ATTR: rich ? RICH_ATTR : ["href", "target", "rel"],
31
+ ALLOWED_URI_REGEXP: /^(?:https?:|mailto:|\/)/i
32
+ });
33
+ }
34
+ export {
35
+ sanitizeHtml
36
+ };
@@ -0,0 +1,7 @@
1
+ function slugify(name) {
2
+ const slug = String(name ?? "").normalize("NFKD").replace(/[\u0300-\u036f]/g, "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 63).replace(/-+$/g, "");
3
+ return slug.length >= 3 ? slug : "";
4
+ }
5
+ export {
6
+ slugify as default
7
+ };