@@ -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, "<" )
235+ . replace ( / > / g, ">" )
236+ . replace ( / " / g, """ )
237+ . replace ( / ' / g, "'" ) ;
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 = / ^ ( h t t p s ? : \/ \/ | m a i l t o : | \/ ) / i. test ( rawHref )
421+ ? rawHref
422+ : `https://${ rawHref } ` ;
423+ const key =
424+ typeof md . _key === "string" && / ^ [ a - z A - Z 0 - 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 - z A - Z 0 - 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 - z A - Z 0 - 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+ }
0 commit comments