@@ -125,14 +125,40 @@ const oneLine = (text: string): string => text.replace(/\s*\n\s*/g, " ").trim();
125125
126126const HEADER_LINE = / ^ - - ( n a m e | d e s c r i p t i o n | t a g s | f o l d e r ) : (?: ( .* ) ) ? $ / ;
127127
128+ /** Either form the serializer writes: a JSON array, or names separated by commas. */
129+ function parseTags ( value : string ) : string [ ] {
130+ const trimmed = value . trim ( ) ;
131+ if ( trimmed . startsWith ( "[" ) ) {
132+ try {
133+ const parsed : unknown = JSON . parse ( trimmed ) ;
134+ if ( Array . isArray ( parsed ) ) {
135+ return parsed . map ( ( tag ) => String ( tag ) . trim ( ) ) . filter ( Boolean ) ;
136+ }
137+ } catch {
138+ // Not JSON after all: a tag list that happens to start with a bracket.
139+ }
140+ }
141+ return trimmed
142+ . split ( "," )
143+ . map ( ( tag ) => tag . trim ( ) )
144+ . filter ( Boolean ) ;
145+ }
146+
128147export function serializeQuery ( query : BundleQuery ) : string {
129148 const header = [ `-- name: ${ oneLine ( query . name ) } ` ] ;
130149 if ( query . description ) {
131150 for ( const line of normalizeNewlines ( query . description ) . split ( "\n" ) ) {
132151 header . push ( line ? `-- description: ${ line } ` : "-- description:" ) ;
133152 }
134153 }
135- if ( query . tags . length > 0 ) header . push ( `-- tags: ${ query . tags . map ( oneLine ) . join ( ", " ) } ` ) ;
154+ if ( query . tags . length > 0 ) {
155+ // A comma inside a tag would read back as two tags, so such a list is
156+ // written as JSON; the plain form stays for every other file.
157+ const tags = query . tags . map ( oneLine ) ;
158+ header . push (
159+ `-- tags: ${ tags . some ( ( tag ) => tag . includes ( "," ) ) ? JSON . stringify ( tags ) : tags . join ( ", " ) } `
160+ ) ;
161+ }
136162 if ( query . folder && query . folder !== "default" )
137163 header . push ( `-- folder: ${ oneLine ( query . folder ) } ` ) ;
138164 return withFinalNewline ( `${ header . join ( "\n" ) } \n\n${ query . sql } ` ) ;
@@ -158,10 +184,7 @@ export function parseQuery(text: string, fallbackName: string): BundleQuery {
158184 description . push ( value ) ;
159185 break ;
160186 case "tags" :
161- tags = value
162- . split ( "," )
163- . map ( ( tag ) => tag . trim ( ) )
164- . filter ( Boolean ) ;
187+ tags = parseTags ( value ) ;
165188 break ;
166189 case "folder" :
167190 folder = value . trim ( ) || "default" ;
@@ -188,6 +211,29 @@ export function parseQuery(text: string, fallbackName: string): BundleQuery {
188211
189212const CELL_MARKER = / ^ < ! - - c e l l : ( s q l | p y t h o n | m a r k d o w n ) ( c o l l a p s e d ) ? - - > $ / ;
190213const CODE_FENCE_OPEN = / ^ ( ` { 3 , } | ~ { 3 , } ) \s * ( s q l | p y t h o n | p y ) \b .* $ / i;
214+ /** A content line that is a marker, or a marker already behind backslashes. */
215+ const MARKER_LIKE_LINE = / ^ ( \\ * ) ( < ! - - c e l l : (?: s q l | p y t h o n | m a r k d o w n ) (?: c o l l a p s e d ) ? - - > ) $ / ;
216+
217+ /**
218+ * A cell whose text contains a line that is exactly a cell marker would be
219+ * cut in two on import. Such a line gets one more backslash in the file
220+ * (so lines that already start with backslashes stay distinct), and
221+ * `unescapeMarkers` takes one away. Files from before this never contain
222+ * the escaped form, so they read as they always did.
223+ */
224+ function escapeMarkers ( content : string ) : string {
225+ return content
226+ . split ( "\n" )
227+ . map ( ( line ) => line . replace ( MARKER_LIKE_LINE , "\\$1$2" ) )
228+ . join ( "\n" ) ;
229+ }
230+
231+ function unescapeMarkers ( content : string ) : string {
232+ return content
233+ . split ( "\n" )
234+ . map ( ( line ) => ( MARKER_LIKE_LINE . test ( line ) && line . startsWith ( "\\" ) ? line . slice ( 1 ) : line ) )
235+ . join ( "\n" ) ;
236+ }
191237
192238type CodeCellType = "sql" | "python" ;
193239
@@ -205,11 +251,12 @@ export function serializeNotebook(notebook: BundleNotebook): string {
205251 const parts = [ `# ${ oneLine ( notebook . title ) || "Untitled Notebook" } ` ] ;
206252 for ( const cell of notebook . cells ) {
207253 const marker = `<!-- cell:${ cell . type } ${ cell . collapsed ? " collapsed" : "" } -->` ;
254+ const content = escapeMarkers ( cell . content ) ;
208255 if ( cell . type === "sql" || cell . type === "python" ) {
209- const fence = fenceFor ( cell . content ) ;
210- parts . push ( `${ marker } \n${ fence } ${ cell . type } \n${ cell . content } \n${ fence } ` ) ;
256+ const fence = fenceFor ( content ) ;
257+ parts . push ( `${ marker } \n${ fence } ${ cell . type } \n${ content } \n${ fence } ` ) ;
211258 } else {
212- parts . push ( `${ marker } \n${ cell . content } ` ) ;
259+ parts . push ( `${ marker } \n${ content } ` ) ;
213260 }
214261 }
215262 return withFinalNewline ( parts . join ( "\n\n" ) ) ;
@@ -273,7 +320,7 @@ export function parseNotebook(text: string, fallbackTitle: string): BundleNotebo
273320 const cellType = type === "sql" || type === "python" ? type : "markdown" ;
274321 return {
275322 type : cellType ,
276- content : cellType === "markdown" ? body . join ( "\n" ) : parseCodeBody ( body ) ,
323+ content : unescapeMarkers ( cellType === "markdown" ? body . join ( "\n" ) : parseCodeBody ( body ) ) ,
277324 ...( collapsed ? { collapsed : true } : { } ) ,
278325 } ;
279326 } ) ;
0 commit comments