@@ -182,6 +182,161 @@ describe('validateAndFixTipTapContent', () => {
182182 } ) ;
183183 } ) ;
184184
185+ describe ( 'stringified JSON nodes' , ( ) => {
186+ it ( 'should parse stringified JSON nodes in an array' , ( ) => {
187+ const content = [
188+ JSON . stringify ( { type : 'heading' , attrs : { level : 2 } , content : [ { type : 'text' , text : 'Purpose' } ] } ) ,
189+ JSON . stringify ( { type : 'paragraph' , attrs : { textAlign : null } , content : [ { type : 'text' , text : 'Some policy text.' } ] } ) ,
190+ ] ;
191+
192+ const fixed = validateAndFixTipTapContent ( content ) ;
193+ expect ( fixed . type ) . toBe ( 'doc' ) ;
194+ const nodes = fixed . content as any [ ] ;
195+ expect ( nodes ) . toHaveLength ( 2 ) ;
196+ expect ( nodes [ 0 ] . type ) . toBe ( 'heading' ) ;
197+ expect ( nodes [ 0 ] . content [ 0 ] . text ) . toBe ( 'Purpose' ) ;
198+ expect ( nodes [ 1 ] . type ) . toBe ( 'paragraph' ) ;
199+ expect ( nodes [ 1 ] . content [ 0 ] . text ) . toBe ( 'Some policy text.' ) ;
200+ } ) ;
201+
202+ it ( 'should handle mixed stringified and object nodes' , ( ) => {
203+ const content = [
204+ JSON . stringify ( { type : 'heading' , attrs : { level : 2 } , content : [ { type : 'text' , text : 'Title' } ] } ) ,
205+ { type : 'paragraph' , content : [ { type : 'text' , text : 'Body text' } ] } ,
206+ ] ;
207+
208+ const fixed = validateAndFixTipTapContent ( content ) ;
209+ const nodes = fixed . content as any [ ] ;
210+ expect ( nodes ) . toHaveLength ( 2 ) ;
211+ expect ( nodes [ 0 ] . type ) . toBe ( 'heading' ) ;
212+ expect ( nodes [ 1 ] . type ) . toBe ( 'paragraph' ) ;
213+ } ) ;
214+
215+ it ( 'should skip invalid stringified JSON' , ( ) => {
216+ const content = [
217+ 'not valid json' ,
218+ JSON . stringify ( { type : 'paragraph' , content : [ { type : 'text' , text : 'Valid' } ] } ) ,
219+ ] ;
220+
221+ const fixed = validateAndFixTipTapContent ( content ) ;
222+ const nodes = fixed . content as any [ ] ;
223+ expect ( nodes ) . toHaveLength ( 1 ) ;
224+ expect ( nodes [ 0 ] . type ) . toBe ( 'paragraph' ) ;
225+ } ) ;
226+ } ) ;
227+
228+ describe ( 'orphaned listItem handling' , ( ) => {
229+ it ( 'should wrap orphaned listItems in a bulletList' , ( ) => {
230+ const content = [
231+ { type : 'heading' , attrs : { level : 2 } , content : [ { type : 'text' , text : 'Title' } ] } ,
232+ { type : 'listItem' , content : [ { type : 'paragraph' , content : [ { type : 'text' , text : 'Item 1' } ] } ] } ,
233+ { type : 'listItem' , content : [ { type : 'paragraph' , content : [ { type : 'text' , text : 'Item 2' } ] } ] } ,
234+ ] ;
235+
236+ const fixed = validateAndFixTipTapContent ( content ) ;
237+ const nodes = fixed . content as any [ ] ;
238+ expect ( nodes ) . toHaveLength ( 2 ) ;
239+ expect ( nodes [ 0 ] . type ) . toBe ( 'heading' ) ;
240+ expect ( nodes [ 1 ] . type ) . toBe ( 'bulletList' ) ;
241+ expect ( nodes [ 1 ] . content ) . toHaveLength ( 2 ) ;
242+ expect ( nodes [ 1 ] . content [ 0 ] . type ) . toBe ( 'listItem' ) ;
243+ expect ( nodes [ 1 ] . content [ 1 ] . type ) . toBe ( 'listItem' ) ;
244+ } ) ;
245+
246+ it ( 'should append orphaned listItems to a preceding list' , ( ) => {
247+ const content = [
248+ { type : 'bulletList' , content : [
249+ { type : 'listItem' , content : [ { type : 'paragraph' , content : [ { type : 'text' , text : 'First' } ] } ] } ,
250+ ] } ,
251+ { type : 'listItem' , content : [ { type : 'paragraph' , content : [ { type : 'text' , text : 'Second' } ] } ] } ,
252+ { type : 'listItem' , content : [ { type : 'paragraph' , content : [ { type : 'text' , text : 'Third' } ] } ] } ,
253+ ] ;
254+
255+ const fixed = validateAndFixTipTapContent ( content ) ;
256+ const nodes = fixed . content as any [ ] ;
257+ expect ( nodes ) . toHaveLength ( 1 ) ;
258+ expect ( nodes [ 0 ] . type ) . toBe ( 'bulletList' ) ;
259+ expect ( nodes [ 0 ] . content ) . toHaveLength ( 3 ) ;
260+ } ) ;
261+ } ) ;
262+
263+ describe ( 'list with non-listItem children' , ( ) => {
264+ it ( 'should wrap bare paragraphs inside a bulletList in listItems' , ( ) => {
265+ const content = [
266+ { type : 'bulletList' , content : [
267+ { type : 'paragraph' , attrs : { textAlign : null } , content : [ { type : 'text' , text : 'Bare paragraph' } ] } ,
268+ ] } ,
269+ ] ;
270+
271+ const fixed = validateAndFixTipTapContent ( content ) ;
272+ const nodes = fixed . content as any [ ] ;
273+ expect ( nodes [ 0 ] . type ) . toBe ( 'bulletList' ) ;
274+ expect ( nodes [ 0 ] . content [ 0 ] . type ) . toBe ( 'listItem' ) ;
275+ expect ( nodes [ 0 ] . content [ 0 ] . content [ 0 ] . type ) . toBe ( 'paragraph' ) ;
276+ expect ( nodes [ 0 ] . content [ 0 ] . content [ 0 ] . content [ 0 ] . text ) . toBe ( 'Bare paragraph' ) ;
277+ } ) ;
278+ } ) ;
279+
280+ describe ( 'textStyle mark removal' , ( ) => {
281+ it ( 'should strip textStyle marks from content' , ( ) => {
282+ const content = {
283+ type : 'doc' ,
284+ content : [
285+ {
286+ type : 'paragraph' ,
287+ content : [
288+ {
289+ type : 'text' ,
290+ text : 'Styled text' ,
291+ marks : [
292+ { type : 'textStyle' , attrs : { color : 'red' } } ,
293+ { type : 'bold' } ,
294+ ] ,
295+ } ,
296+ ] ,
297+ } ,
298+ ] ,
299+ } ;
300+
301+ const fixed = validateAndFixTipTapContent ( content ) ;
302+ const textNode = ( fixed . content as any [ ] ) [ 0 ] . content [ 0 ] ;
303+ expect ( textNode . marks ) . toHaveLength ( 1 ) ;
304+ expect ( textNode . marks [ 0 ] . type ) . toBe ( 'bold' ) ;
305+ } ) ;
306+ } ) ;
307+
308+ describe ( 'real-world AI-generated malformed content' , ( ) => {
309+ it ( 'should fix the exact content from ENG-197' , ( ) => {
310+ // This is the actual content from the bug report — each node is a
311+ // JSON string, the bulletList contains a bare paragraph, and
312+ // listItems are orphaned at the top level.
313+ const content = [
314+ JSON . stringify ( { type : 'heading' , attrs : { level : 2 , textAlign : null } , content : [ { text : 'Purpose' , type : 'text' } ] } ) ,
315+ JSON . stringify ( { type : 'paragraph' , attrs : { textAlign : null } , content : [ { text : 'Ensure all governance...' , type : 'text' } ] } ) ,
316+ JSON . stringify ( { type : 'heading' , attrs : { level : 2 , textAlign : null } , content : [ { text : 'Version Control & Distribution' , type : 'text' } ] } ) ,
317+ JSON . stringify ( { type : 'bulletList' , content : [ { type : 'paragraph' , attrs : { textAlign : null } , content : [ { text : 'Keep policies under version control.' , type : 'text' } ] } ] } ) ,
318+ JSON . stringify ( { type : 'listItem' , content : [ { type : 'paragraph' , attrs : { textAlign : null } , content : [ { text : 'Include a version number.' , type : 'text' } ] } ] } ) ,
319+ JSON . stringify ( { type : 'listItem' , content : [ { type : 'paragraph' , attrs : { textAlign : null } , content : [ { text : 'Notify personnel.' , type : 'text' } ] } ] } ) ,
320+ ] ;
321+
322+ const fixed = validateAndFixTipTapContent ( content ) ;
323+ expect ( fixed . type ) . toBe ( 'doc' ) ;
324+ const nodes = fixed . content as any [ ] ;
325+
326+ // heading, paragraph, heading, bulletList (merged)
327+ expect ( nodes ) . toHaveLength ( 4 ) ;
328+ expect ( nodes [ 0 ] . type ) . toBe ( 'heading' ) ;
329+ expect ( nodes [ 1 ] . type ) . toBe ( 'paragraph' ) ;
330+ expect ( nodes [ 2 ] . type ) . toBe ( 'heading' ) ;
331+ expect ( nodes [ 3 ] . type ) . toBe ( 'bulletList' ) ;
332+
333+ // The bulletList should contain 3 listItems:
334+ // 1 from the bare paragraph wrapped in listItem + 2 orphaned listItems
335+ expect ( nodes [ 3 ] . content ) . toHaveLength ( 3 ) ;
336+ expect ( nodes [ 3 ] . content . every ( ( n : any ) => n . type === 'listItem' ) ) . toBe ( true ) ;
337+ } ) ;
338+ } ) ;
339+
185340 describe ( 'empty text node handling' , ( ) => {
186341 const strip = ( s : string ) => s . replace ( / [ \u00A0 \u200B \u202F ] / g, '' ) . trim ( ) ;
187342
0 commit comments