Setting the file. One moment.
Rich Content · Wix Headless Templates · wix/skills · Skills Docs
ContentsBack to the top of the page export function richContentToHtml
— line 156
This file
Number 16.200
Position 200 of 828
Type TypeScript
Size 8 KB
Lines 180 cms/project/src/wix/ rich-content.ts
TypeScript · 180 lines · 8 KB
type
ImgSrc
=
(
value
:
any
,
width
:
number
,
height
:
number
)
=>
string
;
12
13 export interface RichContentHtmlOptions {
14 /**
15 * The level (1–6) a Ricos heading of `level` renders at. Default: its own level, never above h2,
16 * since the page's h1 is the title of what the document belongs to.
17 */
18 headingLevel ?: ( level : number ) => number ;
19 /** Size an inline image is resolved at — one URL per image, wide enough for a prose column. */
20 imageWidth ?: number ;
21 imageHeight ?: number ;
22 }
23
24 const IMAGE_WIDTH = 1200 ;
25 const IMAGE_HEIGHT = 800 ;
26
27 export function escapeHtml ( s : string ) : string {
28 return s. replace ( /&/ g , "&" ). replace ( /</ g , "<" ). replace ( />/ g , ">" ). replace ( /"/ g , """ ). replace ( /'/ g , "'" );
29 }
30
31 const isHttpUrl = ( u : unknown ) : u is string => typeof u === "string" && / ^ https ? : \/\/ / i . test (u);
32
33 /** Ricos Link → the anchor's attributes, or null when the URL is not http(s) (javascript:, data:, relative). */
34 function linkAttrs ( link : Raw | undefined ) : string | null {
35 if ( ! link) return null ;
36 if ( isHttpUrl (link.url)) {
37 const rel = [ "noopener" , "noreferrer" ];
38 if (link.rel?.nofollow) rel. push ( "nofollow" );
39 if (link.rel?.sponsored) rel. push ( "sponsored" );
40 if (link.rel?.ugc) rel. push ( "ugc" );
41 const target = link.target === "BLANK" ? ` target="_blank"` : "" ;
42 return `href="${ escapeHtml ( link . url ) }" rel="${ rel . join ( " " ) }"${ target }` ;
43 }
44 if ( typeof link.anchor === "string" && link.anchor) return `href="#${ escapeHtml ( link . anchor ) }"` ;
45 return null ;
46 }
47
48 /** One TEXT node → inline HTML: escaped text wrapped by its decorations (unknown decorations keep the text). */
49 function textNodeToHtml ( node : Raw ) : string {
50 let html = escapeHtml ( String (node.textData?.text ?? "" ));
51 for ( const d of (node.textData?.decorations ?? []) as Raw []) {
52 switch (d?.type) {
53 case "BOLD" :
54 // The editor writes fontWeightValue 700 for bold and a lighter weight to un-bold inside a bold block.
55 if ( typeof d.fontWeightValue !== "number" || d.fontWeightValue >= 600 ) html = `<strong>${ html }</strong>` ;
56 break ;
57 case "ITALIC" :
58 if (d.italicData !== false ) html = `<em>${ html }</em>` ;
59 break ;
60 case "UNDERLINE" : html = `<u>${ html }</u>` ; break ;
61 case "STRIKETHROUGH" : html = `<s>${ html }</s>` ; break ;
62 case "SUPERSCRIPT" : html = `<sup>${ html }</sup>` ; break ;
63 case "SUBSCRIPT" : html = `<sub>${ html }</sub>` ; break ;
64 case "LINK" : {
65 const attrs = linkAttrs (d.linkData?.link);
66 if (attrs) html = `<a ${ attrs }>${ html }</a>` ;
67 break ;
68 }
69 case "ANCHOR" :
70 if ( typeof d.anchorData?.anchor === "string" && d.anchorData.anchor) html = `<a href="#${ escapeHtml ( d . anchorData . anchor ) }">${ html }</a>` ;
71 break ;
72 default :
73 break ; // COLOR, FONT_SIZE, FONT_FAMILY, MENTION, SPOILER, SKETCH, EXTERNAL — text kept, styling dropped
74 }
75 }
76 return html;
77 }
78
79 /**
80 * The media value in the form imgSrc scales: `wix:image://v1/<id>/<id>#originWidth=W&originHeight=H`
81 * (the Ricos FileSource has no filename; the id stands in — the scaler reads only the id). An absolute
82 * https URL passes through; anything else is no image.
83 */
84 export function imageValue ( image : Raw | undefined ) : string {
85 const src = image?.src;
86 if ( ! src) return "" ;
87 if ( typeof src.id === "string" && src.id) {
88 const size = image?.width && image?.height ? `#originWidth=${ image . width }&originHeight=${ image . height }` : "" ;
89 return `wix:image://v1/${ src . id }/${ src . id }${ size }` ;
90 }
91 return isHttpUrl (src.url) ? src.url : "" ;
92 }
93
94 interface Ctx {
95 imgSrc : ImgSrc ;
96 headingLevel : ( level : number ) => number ;
97 width : number ;
98 height : number ;
99 }
100
101 function imgTag ( image : Raw | undefined , alt : unknown , ctx : Ctx ) : string {
102 const src = ctx. imgSrc ( imageValue (image), ctx.width, ctx.height);
103 if ( ! src) return "" ;
104 return `<img src="${ escapeHtml ( src ) }" alt="${ escapeHtml ( String ( alt ?? "" )) }" loading="lazy" decoding="async">` ;
105 }
106
107 function childrenToHtml ( node : Raw , ctx : Ctx ) : string {
108 return ((node.nodes ?? []) as Raw []). map (( n ) => nodeToHtml (n, ctx)). join ( "" );
109 }
110
111 function nodeToHtml ( node : Raw , ctx : Ctx ) : string {
112 switch (node?.type) {
113 case "TEXT" : return textNodeToHtml (node);
114 case "PARAGRAPH" : return `<p>${ childrenToHtml ( node , ctx ) }</p>` ;
115 case "HEADING" : {
116 const level = Math. min ( 6 , Math. max ( 1 , Math. round (ctx. headingLevel ( Number (node.headingData?.level) || 2 ))));
117 return `<h${ level }>${ childrenToHtml ( node , ctx ) }</h${ level }>` ;
118 }
119 case "BULLETED_LIST" : return `<ul>${ childrenToHtml ( node , ctx ) }</ul>` ;
120 case "ORDERED_LIST" : return `<ol>${ childrenToHtml ( node , ctx ) }</ol>` ;
121 case "LIST_ITEM" : return `<li>${ childrenToHtml ( node , ctx ) }</li>` ;
122 case "BLOCKQUOTE" : return `<blockquote>${ childrenToHtml ( node , ctx ) }</blockquote>` ;
123 case "CODE_BLOCK" : return `<pre><code>${ childrenToHtml ( node , ctx ) }</code></pre>` ;
124 case "DIVIDER" : return "<hr>" ;
125 case "TABLE" : return `<table><tbody>${ childrenToHtml ( node , ctx ) }</tbody></table>` ;
126 case "TABLE_ROW" : return `<tr>${ childrenToHtml ( node , ctx ) }</tr>` ;
127 case "TABLE_CELL" : return `<td>${ childrenToHtml ( node , ctx ) }</td>` ;
128 case "IMAGE" : {
129 const img = imgTag (node.imageData?.image, node.imageData?.altText, ctx);
130 if ( ! img) return "" ;
131 const attrs = linkAttrs (node.imageData?.link);
132 return attrs ? `<a ${ attrs }>${ img }</a>` : img;
133 }
134 case "GALLERY" : {
135 const imgs = ((node.galleryData?.items ?? []) as Raw [])
136 . map (( item ) => imgTag (item?.image?.media, item?.altText ?? item?.title, ctx))
137 . filter (Boolean);
138 return imgs. length ? `<figure>${ imgs . join ( "" ) }</figure>` : "" ;
139 }
140 case "VIDEO" : {
141 // A link to the video, not a player: an embed would need a host-specific iframe.
142 const url = node.videoData?.video?.src?.url;
143 if ( ! isHttpUrl (url)) break ;
144 const label = escapeHtml ( String (node.videoData?.title || url));
145 return `<p><a href="${ escapeHtml ( url ) }" rel="noopener noreferrer" target="_blank">${ label }</a></p>` ;
146 }
147 default :
148 break ;
149 }
150 // COLLAPSIBLE_LIST, GIF, HTML, EMBED, APP_EMBED, BUTTON, … (and a video with no link) — text only.
151 const text = nodeToText (node). trim ();
152 return text ? `<p>${ escapeHtml ( text ) }</p>` : "" ;
153 }
154
155 /** Sanitized HTML for a Ricos document ("" for none). */
156 export function richContentToHtml ( rc : Raw | null | undefined , imgSrc : ImgSrc , options : RichContentHtmlOptions = {}) : string {
157 const ctx : Ctx = {
158 imgSrc,
159 headingLevel: options.headingLevel ?? (( level ) => Math. max ( 2 , level)),
160 width: options.imageWidth ?? IMAGE_WIDTH ,
161 height: options.imageHeight ?? IMAGE_HEIGHT ,
162 };
163 return ((rc?.nodes ?? []) as Raw []). map (( n ) => nodeToHtml (n, ctx)). join ( "" );
164 }
165
166 const INLINE_TYPES = new Set ([ "TEXT" ]);
167
168 function nodeToText ( node : Raw ) : string {
169 if (node?.type === "TEXT" ) return String (node.textData?.text ?? "" );
170 if (node?.type === "BUTTON" ) return String (node.buttonData?.text ?? "" );
171 const children = (node?.nodes ?? []) as Raw [];
172 const parts = children. map (nodeToText);
173 // Blocks join with newlines; inline runs concatenate.
174 return children. every (( c ) => INLINE_TYPES . has (c?.type)) ? parts. join ( "" ) : parts. filter (Boolean). join ( " \n " );
175 }
176
177 /** Plain text of a Ricos document — one line per block ("" for none). */
178 export function richContentToText ( rc : Raw | null | undefined ) : string {
179 return ((rc?.nodes ?? []) as Raw []). map (nodeToText). filter (Boolean). join ( " \n " ). trim ();
180 }