diff --git a/apps/sanity/scripts/sync-guest-socials.mjs b/apps/sanity/scripts/sync-guest-socials.mjs index b15f5477..a92b3c35 100644 --- a/apps/sanity/scripts/sync-guest-socials.mjs +++ b/apps/sanity/scripts/sync-guest-socials.mjs @@ -1800,7 +1800,7 @@ async function main() { const patchMutations = ENRICHED_GUESTS.map((g) => ({ patch: { id: g.id, - set: { + setIfMissing: { socials: g.socials, }, unset: ["socials.email", "portalToken", "portalTokenExpiresAt"], diff --git a/apps/site/src/lib/guest-portal.ts b/apps/site/src/lib/guest-portal.ts index 17ea16a8..19c72505 100644 --- a/apps/site/src/lib/guest-portal.ts +++ b/apps/site/src/lib/guest-portal.ts @@ -203,3 +203,305 @@ export function normalizeSocialInput( return `https://${clean}`; } } + +export interface PortableTextSpan { + _type: "span"; + _key: string; + text: string; + marks: string[]; +} + +export interface PortableTextMarkDef { + _type: "link"; + _key: string; + href: string; + blank?: boolean; +} + +export interface PortableTextBlock { + _type: "block"; + _key: string; + style: "normal" | "h1" | "h2" | "h3" | "h4" | "blockquote"; + listItem?: "bullet" | "number"; + level?: number; + markDefs: PortableTextMarkDef[]; + children: PortableTextSpan[]; +} + +function escapeHtml(text: string): string { + return text + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +function generateKey(): string { + const bytes = new Uint8Array(6); + crypto.getRandomValues(bytes); + return Array.from(bytes) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); +} + +/** + * Converts Sanity Portable Text blocks into semantic HTML for the rich text editor. + */ +export function portableTextToHtml( + rawBlocks: unknown[] | undefined | null, +): string { + if (!Array.isArray(rawBlocks) || rawBlocks.length === 0) { + return "

About

"; + } + + const htmlParts: string[] = []; + let currentListType: "bullet" | "number" | null = null; + + const closeListIfOpen = () => { + if (currentListType === "bullet") { + htmlParts.push(""); + currentListType = null; + } else if (currentListType === "number") { + htmlParts.push(""); + currentListType = null; + } + }; + + for (const raw of rawBlocks) { + if (!raw || typeof raw !== "object") { + continue; + } + const block = raw as Record; + if (block._type !== "block") { + continue; + } + + const markDefs = Array.isArray(block.markDefs) + ? (block.markDefs as Array>) + : []; + const markDefByKey = new Map>(); + for (const md of markDefs) { + if (typeof md._key === "string") { + markDefByKey.set(md._key, md); + } + } + + const children = Array.isArray(block.children) + ? (block.children as Array>) + : []; + + const inlineHtml = children + .map((child) => { + const rawText = typeof child.text === "string" ? child.text : ""; + let rendered = escapeHtml(rawText).replace(/\n/g, "
"); + const marks = Array.isArray(child.marks) + ? (child.marks as string[]) + : []; + + for (const mark of marks) { + if (mark === "strong") { + rendered = `${rendered}`; + } else if (mark === "em") { + rendered = `${rendered}`; + } else if (mark === "code") { + rendered = `${rendered}`; + } else if (mark === "underline") { + rendered = `${rendered}`; + } else if (mark === "strike-through") { + rendered = `${rendered}`; + } else { + const md = markDefByKey.get(mark); + if (md && typeof md.href === "string") { + const safeHref = escapeHtml(md.href); + rendered = `${rendered}`; + } + } + } + return rendered; + }) + .join(""); + + const listItem = + block.listItem === "bullet" || block.listItem === "number" + ? block.listItem + : null; + + if (listItem) { + if (currentListType !== listItem) { + closeListIfOpen(); + htmlParts.push(listItem === "bullet" ? "
    " : "
      "); + currentListType = listItem; + } + htmlParts.push(`
    1. ${inlineHtml || "
      "}
    2. `); + continue; + } + + closeListIfOpen(); + + const style = typeof block.style === "string" ? block.style : "normal"; + const tag = + style === "h1" || + style === "h2" || + style === "h3" || + style === "h4" || + style === "blockquote" + ? style + : "p"; + htmlParts.push(`<${tag}>${inlineHtml || "
      "}`); + } + + closeListIfOpen(); + return htmlParts.join(""); +} + +const ALLOWED_STYLES = new Set([ + "normal", + "h1", + "h2", + "h3", + "h4", + "blockquote", +]); +const ALLOWED_DECORATORS = new Set([ + "strong", + "em", + "code", + "underline", + "strike-through", +]); + +/** + * Validates and normalizes incoming Portable Text blocks before persisting to Sanity. + */ +export function sanitizePortableTextBlocks( + rawBlocks: unknown, +): PortableTextBlock[] { + if (!Array.isArray(rawBlocks)) { + return []; + } + + const sanitized: PortableTextBlock[] = []; + + for (const item of rawBlocks) { + if (!item || typeof item !== "object") { + continue; + } + const b = item as Record; + if (b._type !== "block") { + continue; + } + + const styleRaw = typeof b.style === "string" ? b.style : "normal"; + const style = ( + ALLOWED_STYLES.has(styleRaw) ? styleRaw : "normal" + ) as PortableTextBlock["style"]; + + const listItem = + b.listItem === "bullet" || b.listItem === "number" + ? b.listItem + : undefined; + + const rawMarkDefs = Array.isArray(b.markDefs) + ? (b.markDefs as Array>) + : []; + const validMarkDefs: PortableTextMarkDef[] = []; + const validMarkKeys = new Set(); + + for (const md of rawMarkDefs) { + if (!md || typeof md !== "object") { + continue; + } + const rawHref = typeof md.href === "string" ? md.href.trim() : ""; + if (!rawHref) { + continue; + } + // Only allow http(s)://, mailto:, or relative / paths + const safeHref = /^(https?:\/\/|mailto:|\/)/i.test(rawHref) + ? rawHref + : `https://${rawHref}`; + const key = + typeof md._key === "string" && /^[a-zA-Z0-9_-]{4,32}$/.test(md._key) + ? md._key + : generateKey(); + validMarkDefs.push({ + _type: "link", + _key: key, + href: safeHref, + blank: true, + }); + validMarkKeys.add(key); + } + + const rawChildren = Array.isArray(b.children) + ? (b.children as Array>) + : []; + const validChildren: PortableTextSpan[] = []; + + for (const ch of rawChildren) { + if (!ch || typeof ch !== "object") { + continue; + } + const text = typeof ch.text === "string" ? ch.text : ""; + const rawMarks = Array.isArray(ch.marks) ? (ch.marks as unknown[]) : []; + const marks = rawMarks.filter( + (m): m is string => + typeof m === "string" && + (ALLOWED_DECORATORS.has(m) || validMarkKeys.has(m)), + ); + const spanKey = + typeof ch._key === "string" && /^[a-zA-Z0-9_-]{4,32}$/.test(ch._key) + ? ch._key + : generateKey(); + validChildren.push({ + _type: "span", + _key: spanKey, + text, + marks, + }); + } + + if (validChildren.length === 0) { + validChildren.push({ + _type: "span", + _key: generateKey(), + text: "", + marks: [], + }); + } + + // Skip completely empty trailing normal blocks unless it's the only block + const hasNonEmptyText = validChildren.some((s) => s.text.trim().length > 0); + if ( + !hasNonEmptyText && + sanitized.length > 0 && + style === "normal" && + !listItem + ) { + continue; + } + + const blockKey = + typeof b._key === "string" && /^[a-zA-Z0-9_-]{4,32}$/.test(b._key) + ? b._key + : generateKey(); + + const cleanBlock: PortableTextBlock = { + _type: "block", + _key: blockKey, + style, + markDefs: validMarkDefs, + children: validChildren, + }; + + if (listItem) { + cleanBlock.listItem = listItem; + cleanBlock.level = + typeof b.level === "number" && b.level >= 1 ? b.level : 1; + } + + sanitized.push(cleanBlock); + } + + return sanitized; +} diff --git a/apps/site/src/pages/api/guest-portal/update.ts b/apps/site/src/pages/api/guest-portal/update.ts index 018673a4..3950661e 100644 --- a/apps/site/src/pages/api/guest-portal/update.ts +++ b/apps/site/src/pages/api/guest-portal/update.ts @@ -2,6 +2,7 @@ import type { APIRoute } from "astro"; import { ALLOWED_SOCIAL_KEYS, normalizeSocialInput, + sanitizePortableTextBlocks, verifyGuestPortalToken, } from "@/lib/guest-portal"; import { getSanityServerClient } from "@/lib/sanity/write-client"; @@ -12,6 +13,8 @@ export const POST: APIRoute = async ({ request }) => { token?: string; title?: string; websiteUrl?: string; + excerpt?: string; + content?: unknown[]; socials?: Record; }; @@ -112,6 +115,20 @@ export const POST: APIRoute = async ({ request }) => { } } + if (typeof body.excerpt === "string") { + const cleanExcerpt = body.excerpt.trim(); + if (cleanExcerpt) { + patchSet.excerpt = cleanExcerpt; + } + } + + if (Array.isArray(body.content)) { + const sanitizedBlocks = sanitizePortableTextBlocks(body.content); + if (sanitizedBlocks.length > 0) { + patchSet.content = sanitizedBlocks; + } + } + if (canWrite) { await client.patch(existingGuest._id).set(patchSet).commit(); } diff --git a/apps/site/src/pages/guest-portal/[token].astro b/apps/site/src/pages/guest-portal/[token].astro index 9cc89c77..122ca0cc 100644 --- a/apps/site/src/pages/guest-portal/[token].astro +++ b/apps/site/src/pages/guest-portal/[token].astro @@ -1,7 +1,10 @@ --- import Breadcrumbs from "@/components/Breadcrumbs.astro"; import BaseLayout from "@/layouts/BaseLayout.astro"; -import { verifyGuestPortalToken } from "@/lib/guest-portal"; +import { + portableTextToHtml, + verifyGuestPortalToken, +} from "@/lib/guest-portal"; import { getSanityServerClient } from "@/lib/sanity/write-client"; const { token } = Astro.params; @@ -20,6 +23,8 @@ interface GuestDoc { title: string; slug: string; avatarUrl?: string; + excerpt?: string | null; + content?: unknown[]; socials?: Record; websites?: Array<{ site?: string; link?: { href?: string } }>; episodes: EpisodeRef[]; @@ -42,6 +47,8 @@ if (isValid && verification.payload) { title, "slug": slug.current, "avatarUrl": coverImage.asset->url, + excerpt, + content, socials, websites, "episodes": *[_type == "podcast" && references(^._id) && !(_id in path("drafts.**"))] | order(season desc, episode desc) { @@ -60,6 +67,11 @@ if (isValid && verification.payload) { const socials = guestDoc?.socials || {}; const primaryWebsite = guestDoc?.websites?.[0]?.link?.href || guestDoc?.websites?.[0]?.site || ""; +const initialExcerpt = + guestDoc?.excerpt && guestDoc.excerpt.trim().toLowerCase() !== "fix me" + ? guestDoc.excerpt.trim() + : ""; +const initialAboutHtml = portableTextToHtml(guestDoc?.content); const socialFields = [ { @@ -289,6 +301,176 @@ const socialFields = [
      +
      +
      +
      +

      + About Section (Sanity Portable Text) +

      +

      + Edit your speaker bio with headings, links, bold/italic text, code, and lists — saved directly as Sanity Portable Text blocks on your public profile. +

      +
      + + Portable Text Ready + +
      + +
      + + +
      + +
      + + +
      +
      + +
      + + Inspect Generated Sanity Portable Text Blocks JSON + +
      
      +						
      +
      + +
      +

      Social Handles for Episode Tagging @@ -352,6 +534,294 @@ const socialFields = [ const form = document.getElementById("guest-profile-form") as HTMLFormElement | null; const statusEl = document.getElementById("save-status"); const saveBtn = document.getElementById("save-btn") as HTMLButtonElement | null; + const editorEl = document.getElementById("pt-editor") as HTMLElement | null; + const toolbarEl = document.getElementById("pt-toolbar") as HTMLElement | null; + const jsonPreviewEl = document.getElementById("pt-json-preview") as HTMLElement | null; + const badgeEl = document.getElementById("pt-block-badge") as HTMLElement | null; + + interface PtSpan { + _type: "span"; + _key: string; + text: string; + marks: string[]; + } + + interface PtMarkDef { + _type: "link"; + _key: string; + href: string; + blank: boolean; + } + + interface PtBlock { + _type: "block"; + _key: string; + style: "normal" | "h1" | "h2" | "h3" | "h4" | "blockquote"; + listItem?: "bullet" | "number"; + level?: number; + markDefs: PtMarkDef[]; + children: PtSpan[]; + } + + function genKey(): string { + const bytes = new Uint8Array(6); + crypto.getRandomValues(bytes); + return Array.from(bytes) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); + } + + function extractInlineSpans( + rootNode: Node, + activeMarks: string[], + markDefs: PtMarkDef[], + spans: PtSpan[], + ): void { + if (rootNode.nodeType === Node.TEXT_NODE) { + const text = rootNode.textContent ?? ""; + if (!text) return; + const prev = spans[spans.length - 1]; + const sameMarks = + prev && + prev.marks.length === activeMarks.length && + prev.marks.every((m, idx) => m === activeMarks[idx]); + if (sameMarks) { + prev.text += text; + } else { + spans.push({ + _type: "span", + _key: genKey(), + text, + marks: [...activeMarks], + }); + } + return; + } + + if (rootNode.nodeType !== Node.ELEMENT_NODE) return; + const el = rootNode as HTMLElement; + const tag = el.tagName.toLowerCase(); + + if (tag === "br") { + spans.push({ + _type: "span", + _key: genKey(), + text: "\n", + marks: [...activeMarks], + }); + return; + } + + const nextMarks = [...activeMarks]; + const addMark = (m: string) => { + if (!nextMarks.includes(m)) nextMarks.push(m); + }; + + if (tag === "strong" || tag === "b") addMark("strong"); + if (tag === "em" || tag === "i") addMark("em"); + if (tag === "u") addMark("underline"); + if (tag === "s" || tag === "strike" || tag === "del") addMark("strike-through"); + if (tag === "code") addMark("code"); + + if (tag === "a") { + const rawHref = el.getAttribute("href")?.trim() || ""; + if (rawHref) { + const safeHref = /^(https?:\/\/|mailto:|\/)/i.test(rawHref) + ? rawHref + : `https://${rawHref}`; + let existing = markDefs.find((md) => md.href === safeHref); + if (!existing) { + existing = { + _type: "link", + _key: genKey(), + href: safeHref, + blank: true, + }; + markDefs.push(existing); + } + addMark(existing._key); + } + } + + for (const child of Array.from(el.childNodes)) { + extractInlineSpans(child, nextMarks, markDefs, spans); + } + } + + function buildBlockFromNode( + node: Node, + style: PtBlock["style"], + listItem?: "bullet" | "number", + ): PtBlock { + const markDefs: PtMarkDef[] = []; + const spans: PtSpan[] = []; + for (const child of Array.from(node.childNodes)) { + extractInlineSpans(child, [], markDefs, spans); + } + // Strip trailing solitary newline from contenteditable
      + if (spans.length === 1 && spans[0].text === "\n") { + spans[0].text = ""; + } + if (spans.length === 0) { + spans.push({ + _type: "span", + _key: genKey(), + text: "", + marks: [], + }); + } + const block: PtBlock = { + _type: "block", + _key: genKey(), + style, + markDefs, + children: spans, + }; + if (listItem) { + block.listItem = listItem; + block.level = 1; + } + return block; + } + + function serializeEditorToPortableText(root: HTMLElement): PtBlock[] { + const blocks: PtBlock[] = []; + const BLOCK_TAGS = new Set([ + "p", + "h1", + "h2", + "h3", + "h4", + "blockquote", + "ul", + "ol", + "div", + ]); + + const walkChildren = (parent: HTMLElement) => { + for (const node of Array.from(parent.childNodes)) { + if (node.nodeType === Node.TEXT_NODE) { + const text = node.textContent?.trim() ?? ""; + if (text) { + blocks.push({ + _type: "block", + _key: genKey(), + style: "normal", + markDefs: [], + children: [ + { + _type: "span", + _key: genKey(), + text, + marks: [], + }, + ], + }); + } + continue; + } + if (node.nodeType !== Node.ELEMENT_NODE) continue; + const el = node as HTMLElement; + const tag = el.tagName.toLowerCase(); + + if (tag === "ul" || tag === "ol") { + const listType = tag === "ul" ? "bullet" : "number"; + for (const li of Array.from(el.children)) { + if (li.tagName.toLowerCase() === "li") { + blocks.push(buildBlockFromNode(li, "normal", listType)); + } + } + continue; + } + + if ( + tag === "h1" || + tag === "h2" || + tag === "h3" || + tag === "h4" || + tag === "blockquote" + ) { + blocks.push(buildBlockFromNode(el, tag)); + continue; + } + + if (tag === "p" || tag === "div") { + // Check if div contains nested block elements + const hasNestedBlocks = Array.from(el.children).some((c) => + BLOCK_TAGS.has(c.tagName.toLowerCase()), + ); + if (hasNestedBlocks) { + walkChildren(el); + } else { + blocks.push(buildBlockFromNode(el, "normal")); + } + continue; + } + + blocks.push(buildBlockFromNode(el, "normal")); + } + }; + + walkChildren(root); + + // Filter out empty trailing normal blocks when there is other content + const nonEmpty = blocks.filter( + (b, idx) => + idx === 0 || + b.style !== "normal" || + b.listItem || + b.children.some((s) => s.text.trim().length > 0), + ); + return nonEmpty; + } + + function refreshPreview() { + if (!editorEl) return; + const ptBlocks = serializeEditorToPortableText(editorEl); + if (jsonPreviewEl) { + jsonPreviewEl.textContent = JSON.stringify(ptBlocks, null, 2); + } + if (badgeEl) { + badgeEl.textContent = `${ptBlocks.length} Portable Text ${ptBlocks.length === 1 ? "block" : "blocks"}`; + } + } + + toolbarEl?.addEventListener("click", (e) => { + const btn = (e.target as HTMLElement | null)?.closest("button"); + if (!btn || !editorEl) return; + e.preventDefault(); + editorEl.focus(); + + const blockTag = btn.dataset.ptBlock; + const cmd = btn.dataset.ptCmd; + const inlineTag = btn.dataset.ptInline; + const action = btn.dataset.ptAction; + + if (blockTag) { + document.execCommand("formatBlock", false, `<${blockTag}>`); + } else if (cmd) { + document.execCommand(cmd, false); + } else if (inlineTag === "code") { + const sel = window.getSelection(); + if (sel && sel.rangeCount > 0 && !sel.isCollapsed) { + const text = sel.toString(); + document.execCommand("insertHTML", false, `${text}`); + } + } else if (action === "link") { + const url = window.prompt("Enter link URL:", "https://"); + if (url && url.trim() && url.trim() !== "https://") { + document.execCommand("createLink", false, url.trim()); + } + } + + refreshPreview(); + }); + + editorEl?.addEventListener("input", () => { + refreshPreview(); + }); + + refreshPreview(); form?.addEventListener("submit", async (e) => { e.preventDefault(); @@ -360,6 +830,7 @@ const socialFields = [ const token = form.dataset.token; const titleInput = document.getElementById("field-title") as HTMLInputElement | null; const websiteInput = document.getElementById("field-website") as HTMLInputElement | null; + const excerptInput = document.getElementById("field-excerpt") as HTMLInputElement | null; const socialInputs = form.querySelectorAll("input[data-social-key]"); const socials: Record = {}; @@ -368,6 +839,8 @@ const socialFields = [ if (k) socials[k] = input.value.trim(); }); + const content = editorEl ? serializeEditorToPortableText(editorEl) : undefined; + saveBtn.disabled = true; saveBtn.textContent = "Saving to Sanity..."; statusEl.textContent = ""; @@ -380,6 +853,8 @@ const socialFields = [ token, title: titleInput?.value.trim(), websiteUrl: websiteInput?.value.trim(), + excerpt: excerptInput?.value.trim(), + content, socials, }), }); @@ -388,7 +863,7 @@ const socialFields = [ throw new Error(data.error || "Failed to update profile"); } statusEl.className = "text-sm font-medium text-emerald-400"; - statusEl.textContent = "Saved! Your CodingCat.dev speaker profile has been updated."; + statusEl.textContent = "Saved! Your CodingCat.dev speaker profile and About section have been updated."; } catch (err) { statusEl.className = "text-sm font-medium text-red-400"; statusEl.textContent = err instanceof Error ? err.message : "Error saving profile";