Skip to content

Commit cb0865a

Browse files
authored
Merge pull request #770 from CodingCatDev/feat/guest-portal-portable-text-editor
feat(site): add Sanity Portable Text rich text editor for guest About section
2 parents e649b9f + b0d8eb2 commit cb0865a

4 files changed

Lines changed: 797 additions & 3 deletions

File tree

‎apps/sanity/scripts/sync-guest-socials.mjs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1800,7 +1800,7 @@ async function main() {
18001800
const patchMutations = ENRICHED_GUESTS.map((g) => ({
18011801
patch: {
18021802
id: g.id,
1803-
set: {
1803+
setIfMissing: {
18041804
socials: g.socials,
18051805
},
18061806
unset: ["socials.email", "portalToken", "portalTokenExpiresAt"],

‎apps/site/src/lib/guest-portal.ts‎

Lines changed: 302 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,3 +203,305 @@ export function normalizeSocialInput(
203203
return `https://${clean}`;
204204
}
205205
}
206+
207+
export interface PortableTextSpan {
208+
_type: "span";
209+
_key: string;
210+
text: string;
211+
marks: string[];
212+
}
213+
214+
export interface PortableTextMarkDef {
215+
_type: "link";
216+
_key: string;
217+
href: string;
218+
blank?: boolean;
219+
}
220+
221+
export interface PortableTextBlock {
222+
_type: "block";
223+
_key: string;
224+
style: "normal" | "h1" | "h2" | "h3" | "h4" | "blockquote";
225+
listItem?: "bullet" | "number";
226+
level?: number;
227+
markDefs: PortableTextMarkDef[];
228+
children: PortableTextSpan[];
229+
}
230+
231+
function escapeHtml(text: string): string {
232+
return text
233+
.replace(/&/g, "&")
234+
.replace(/</g, "&lt;")
235+
.replace(/>/g, "&gt;")
236+
.replace(/"/g, "&quot;")
237+
.replace(/'/g, "&#39;");
238+
}
239+
240+
function generateKey(): string {
241+
const bytes = new Uint8Array(6);
242+
crypto.getRandomValues(bytes);
243+
return Array.from(bytes)
244+
.map((b) => b.toString(16).padStart(2, "0"))
245+
.join("");
246+
}
247+
248+
/**
249+
* Converts Sanity Portable Text blocks into semantic HTML for the rich text editor.
250+
*/
251+
export function portableTextToHtml(
252+
rawBlocks: unknown[] | undefined | null,
253+
): string {
254+
if (!Array.isArray(rawBlocks) || rawBlocks.length === 0) {
255+
return "<h2>About</h2><p></p>";
256+
}
257+
258+
const htmlParts: string[] = [];
259+
let currentListType: "bullet" | "number" | null = null;
260+
261+
const closeListIfOpen = () => {
262+
if (currentListType === "bullet") {
263+
htmlParts.push("</ul>");
264+
currentListType = null;
265+
} else if (currentListType === "number") {
266+
htmlParts.push("</ol>");
267+
currentListType = null;
268+
}
269+
};
270+
271+
for (const raw of rawBlocks) {
272+
if (!raw || typeof raw !== "object") {
273+
continue;
274+
}
275+
const block = raw as Record<string, unknown>;
276+
if (block._type !== "block") {
277+
continue;
278+
}
279+
280+
const markDefs = Array.isArray(block.markDefs)
281+
? (block.markDefs as Array<Record<string, unknown>>)
282+
: [];
283+
const markDefByKey = new Map<string, Record<string, unknown>>();
284+
for (const md of markDefs) {
285+
if (typeof md._key === "string") {
286+
markDefByKey.set(md._key, md);
287+
}
288+
}
289+
290+
const children = Array.isArray(block.children)
291+
? (block.children as Array<Record<string, unknown>>)
292+
: [];
293+
294+
const inlineHtml = children
295+
.map((child) => {
296+
const rawText = typeof child.text === "string" ? child.text : "";
297+
let rendered = escapeHtml(rawText).replace(/\n/g, "<br>");
298+
const marks = Array.isArray(child.marks)
299+
? (child.marks as string[])
300+
: [];
301+
302+
for (const mark of marks) {
303+
if (mark === "strong") {
304+
rendered = `<strong>${rendered}</strong>`;
305+
} else if (mark === "em") {
306+
rendered = `<em>${rendered}</em>`;
307+
} else if (mark === "code") {
308+
rendered = `<code>${rendered}</code>`;
309+
} else if (mark === "underline") {
310+
rendered = `<u>${rendered}</u>`;
311+
} else if (mark === "strike-through") {
312+
rendered = `<s>${rendered}</s>`;
313+
} else {
314+
const md = markDefByKey.get(mark);
315+
if (md && typeof md.href === "string") {
316+
const safeHref = escapeHtml(md.href);
317+
rendered = `<a href="${safeHref}" target="_blank" rel="noopener noreferrer">${rendered}</a>`;
318+
}
319+
}
320+
}
321+
return rendered;
322+
})
323+
.join("");
324+
325+
const listItem =
326+
block.listItem === "bullet" || block.listItem === "number"
327+
? block.listItem
328+
: null;
329+
330+
if (listItem) {
331+
if (currentListType !== listItem) {
332+
closeListIfOpen();
333+
htmlParts.push(listItem === "bullet" ? "<ul>" : "<ol>");
334+
currentListType = listItem;
335+
}
336+
htmlParts.push(`<li>${inlineHtml || "<br>"}</li>`);
337+
continue;
338+
}
339+
340+
closeListIfOpen();
341+
342+
const style = typeof block.style === "string" ? block.style : "normal";
343+
const tag =
344+
style === "h1" ||
345+
style === "h2" ||
346+
style === "h3" ||
347+
style === "h4" ||
348+
style === "blockquote"
349+
? style
350+
: "p";
351+
htmlParts.push(`<${tag}>${inlineHtml || "<br>"}</${tag}>`);
352+
}
353+
354+
closeListIfOpen();
355+
return htmlParts.join("");
356+
}
357+
358+
const ALLOWED_STYLES = new Set([
359+
"normal",
360+
"h1",
361+
"h2",
362+
"h3",
363+
"h4",
364+
"blockquote",
365+
]);
366+
const ALLOWED_DECORATORS = new Set([
367+
"strong",
368+
"em",
369+
"code",
370+
"underline",
371+
"strike-through",
372+
]);
373+
374+
/**
375+
* Validates and normalizes incoming Portable Text blocks before persisting to Sanity.
376+
*/
377+
export function sanitizePortableTextBlocks(
378+
rawBlocks: unknown,
379+
): PortableTextBlock[] {
380+
if (!Array.isArray(rawBlocks)) {
381+
return [];
382+
}
383+
384+
const sanitized: PortableTextBlock[] = [];
385+
386+
for (const item of rawBlocks) {
387+
if (!item || typeof item !== "object") {
388+
continue;
389+
}
390+
const b = item as Record<string, unknown>;
391+
if (b._type !== "block") {
392+
continue;
393+
}
394+
395+
const styleRaw = typeof b.style === "string" ? b.style : "normal";
396+
const style = (
397+
ALLOWED_STYLES.has(styleRaw) ? styleRaw : "normal"
398+
) as PortableTextBlock["style"];
399+
400+
const listItem =
401+
b.listItem === "bullet" || b.listItem === "number"
402+
? b.listItem
403+
: undefined;
404+
405+
const rawMarkDefs = Array.isArray(b.markDefs)
406+
? (b.markDefs as Array<Record<string, unknown>>)
407+
: [];
408+
const validMarkDefs: PortableTextMarkDef[] = [];
409+
const validMarkKeys = new Set<string>();
410+
411+
for (const md of rawMarkDefs) {
412+
if (!md || typeof md !== "object") {
413+
continue;
414+
}
415+
const rawHref = typeof md.href === "string" ? md.href.trim() : "";
416+
if (!rawHref) {
417+
continue;
418+
}
419+
// Only allow http(s)://, mailto:, or relative / paths
420+
const safeHref = /^(https?:\/\/|mailto:|\/)/i.test(rawHref)
421+
? rawHref
422+
: `https://${rawHref}`;
423+
const key =
424+
typeof md._key === "string" && /^[a-zA-Z0-9_-]{4,32}$/.test(md._key)
425+
? md._key
426+
: generateKey();
427+
validMarkDefs.push({
428+
_type: "link",
429+
_key: key,
430+
href: safeHref,
431+
blank: true,
432+
});
433+
validMarkKeys.add(key);
434+
}
435+
436+
const rawChildren = Array.isArray(b.children)
437+
? (b.children as Array<Record<string, unknown>>)
438+
: [];
439+
const validChildren: PortableTextSpan[] = [];
440+
441+
for (const ch of rawChildren) {
442+
if (!ch || typeof ch !== "object") {
443+
continue;
444+
}
445+
const text = typeof ch.text === "string" ? ch.text : "";
446+
const rawMarks = Array.isArray(ch.marks) ? (ch.marks as unknown[]) : [];
447+
const marks = rawMarks.filter(
448+
(m): m is string =>
449+
typeof m === "string" &&
450+
(ALLOWED_DECORATORS.has(m) || validMarkKeys.has(m)),
451+
);
452+
const spanKey =
453+
typeof ch._key === "string" && /^[a-zA-Z0-9_-]{4,32}$/.test(ch._key)
454+
? ch._key
455+
: generateKey();
456+
validChildren.push({
457+
_type: "span",
458+
_key: spanKey,
459+
text,
460+
marks,
461+
});
462+
}
463+
464+
if (validChildren.length === 0) {
465+
validChildren.push({
466+
_type: "span",
467+
_key: generateKey(),
468+
text: "",
469+
marks: [],
470+
});
471+
}
472+
473+
// Skip completely empty trailing normal blocks unless it's the only block
474+
const hasNonEmptyText = validChildren.some((s) => s.text.trim().length > 0);
475+
if (
476+
!hasNonEmptyText &&
477+
sanitized.length > 0 &&
478+
style === "normal" &&
479+
!listItem
480+
) {
481+
continue;
482+
}
483+
484+
const blockKey =
485+
typeof b._key === "string" && /^[a-zA-Z0-9_-]{4,32}$/.test(b._key)
486+
? b._key
487+
: generateKey();
488+
489+
const cleanBlock: PortableTextBlock = {
490+
_type: "block",
491+
_key: blockKey,
492+
style,
493+
markDefs: validMarkDefs,
494+
children: validChildren,
495+
};
496+
497+
if (listItem) {
498+
cleanBlock.listItem = listItem;
499+
cleanBlock.level =
500+
typeof b.level === "number" && b.level >= 1 ? b.level : 1;
501+
}
502+
503+
sanitized.push(cleanBlock);
504+
}
505+
506+
return sanitized;
507+
}

‎apps/site/src/pages/api/guest-portal/update.ts‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type { APIRoute } from "astro";
22
import {
33
ALLOWED_SOCIAL_KEYS,
44
normalizeSocialInput,
5+
sanitizePortableTextBlocks,
56
verifyGuestPortalToken,
67
} from "@/lib/guest-portal";
78
import { getSanityServerClient } from "@/lib/sanity/write-client";
@@ -12,6 +13,8 @@ export const POST: APIRoute = async ({ request }) => {
1213
token?: string;
1314
title?: string;
1415
websiteUrl?: string;
16+
excerpt?: string;
17+
content?: unknown[];
1518
socials?: Record<string, string>;
1619
};
1720

@@ -112,6 +115,20 @@ export const POST: APIRoute = async ({ request }) => {
112115
}
113116
}
114117

118+
if (typeof body.excerpt === "string") {
119+
const cleanExcerpt = body.excerpt.trim();
120+
if (cleanExcerpt) {
121+
patchSet.excerpt = cleanExcerpt;
122+
}
123+
}
124+
125+
if (Array.isArray(body.content)) {
126+
const sanitizedBlocks = sanitizePortableTextBlocks(body.content);
127+
if (sanitizedBlocks.length > 0) {
128+
patchSet.content = sanitizedBlocks;
129+
}
130+
}
131+
115132
if (canWrite) {
116133
await client.patch(existingGuest._id).set(patchSet).commit();
117134
}

0 commit comments

Comments
 (0)