@@ -4,7 +4,6 @@ import type { Summary } from '../summary'
44
55interface ParseContext {
66 arrowLines : { content : string ; parentChildren : NodeObj [ ] } [ ]
7- summaryLines : { content : string ; parentChildren : NodeObj [ ] ; parentId : string } [ ]
87 nodeIdMap : Map < string , string > // maps [^id] to actual node id
98}
109
@@ -39,7 +38,8 @@ export const plaintextExample = `- Root Node
3938 - Child Node 4-3 [^id8]
4039 - } Summary of all previous nodes
4140 - Child Node 4-4
42- - > [^id1] <-Link position is not restricted, as long as the id can be found during rendering-> [^id8]
41+ - > [^id1] <-Link position is not restricted, as long as the id can be found during rendering-> [^id8]
42+
4343`
4444
4545/**
@@ -54,130 +54,106 @@ export const plaintextExample = `- Root Node
5454 * - Child 2 [^id2]
5555 * - > [^id1] <-label-> [^id2]
5656 *
57+ * When the plaintext contains more than one top-level node, a synthetic root
58+ * node is automatically created to wrap them as first-level children.
59+ *
5760 * @param plaintext - The plaintext string to convert
61+ * @param rootName - Optional name for the synthetic root node when multiple
62+ * top-level nodes are detected (defaults to 'Root')
5863 * @returns MindElixirData object
5964 */
60- export function plaintextToMindElixir ( plaintext : string ) : MindElixirData {
65+ export function plaintextToMindElixir ( plaintext : string , rootName = 'Root' ) : MindElixirData {
6166 const lines = plaintext . split ( '\n' ) . filter ( line => line . trim ( ) )
6267
68+ if ( lines . length === 0 ) {
69+ throw new Error ( 'Failed to parse plaintext: no root node found' )
70+ }
71+
6372 const context : ParseContext = {
6473 arrowLines : [ ] ,
65- summaryLines : [ ] ,
6674 nodeIdMap : new Map ( ) ,
6775 }
6876
69- // First pass: parse nodes and collect arrows/summaries for later processing
70- const root = parseNode ( lines , 0 , - 2 , context )
77+ const summaries : Summary [ ] = [ ]
7178
72- if ( ! root . node ) {
73- throw new Error ( 'Failed to parse plaintext: no root node found' )
74- }
79+ // Stack tracks the current ancestry path: each entry holds the indent level and the node
80+ const stack : { indent : number ; node : NodeObj } [ ] = [ ]
81+ // Collect top-level nodes
82+ const topLevelNodes : NodeObj [ ] = [ ]
7583
76- // Second pass: process arrows and summaries
77- const arrows = context . arrowLines . map ( ( { content } ) => parseArrow ( content , context ) ) . filter ( ( a ) : a is Arrow => a !== null )
84+ // Single pass: iterate through all lines once
85+ for ( const line of lines ) {
86+ const indent = getIndent ( line )
87+ const parsed = parseLine ( line )
7888
79- const summaries = context . summaryLines
80- . map ( ( { content, parentChildren, parentId } ) => parseSummary ( content , parentChildren , parentId ) )
81- . filter ( ( s ) : s is Summary => s !== null )
82-
83- return {
84- nodeData : root . node ,
85- arrows : arrows . length > 0 ? arrows : undefined ,
86- summaries : summaries . length > 0 ? summaries : undefined ,
87- }
88- }
89+ // Pop the stack until we find the parent for this indent level
90+ while ( stack . length > 0 && stack [ stack . length - 1 ] . indent >= indent ) {
91+ stack . pop ( )
92+ }
8993
90- interface ParseResult {
91- node : NodeObj | null
92- nextIndex : number
93- }
94+ const parent = stack . length > 0 ? stack [ stack . length - 1 ] . node : null
95+ const parentChildren = parent ? ( parent . children ??= [ ] ) : topLevelNodes
9496
95- function parseNode ( lines : string [ ] , index : number , parentIndent : number , context : ParseContext ) : ParseResult {
96- if ( index >= lines . length ) {
97- return { node : null , nextIndex : index }
98- }
97+ if ( parsed . type === 'arrow' ) {
98+ context . arrowLines . push ( {
99+ content : parsed . content ,
100+ parentChildren,
101+ } )
102+ continue
103+ }
99104
100- const line = lines [ index ]
101- const indent = getIndent ( line )
105+ if ( parsed . type === 'summary' ) {
106+ const summary = parseSummary ( parsed . content , parentChildren , parent ?. id ?? '' )
107+ if ( summary ) summaries . push ( summary )
108+ continue
109+ }
102110
103- if ( indent <= parentIndent ) {
104- return { node : null , nextIndex : index }
105- }
111+ // Create node
112+ const nodeId = generateId ( )
113+ const node : NodeObj = {
114+ topic : parsed . topic ,
115+ id : nodeId ,
116+ }
106117
107- const parsed = parseLine ( line )
118+ if ( parsed . style ) {
119+ node . style = parsed . style
120+ }
108121
109- // If this line is an arrow or summary at the current level, we need to handle it
110- // Note: We should only skip arrows/summaries when parsing a child recursively
111- // But when encountered directly, collect them and skip creating a node
112- if ( parsed . type === 'arrow' || parsed . type === 'summary' ) {
113- // We can't add to context here because we don't have the parent's children yet
114- // Just skip and let the parent handle it
115- return { node : null , nextIndex : index + 1 }
116- }
122+ if ( parsed . refId ) {
123+ context . nodeIdMap . set ( parsed . refId , nodeId )
124+ }
117125
118- // Create the node
119- const nodeId = generateId ( )
120- const node : NodeObj = {
121- topic : parsed . topic ,
122- id : nodeId ,
123- }
126+ // Attach to parent or top-level
127+ parentChildren . push ( node )
124128
125- if ( parsed . style ) {
126- node . style = parsed . style
129+ // Push onto stack so subsequent deeper lines become children of this node
130+ stack . push ( { indent , node } )
127131 }
128132
129- if ( parsed . refId ) {
130- context . nodeIdMap . set ( parsed . refId , nodeId )
133+ if ( topLevelNodes . length === 0 ) {
134+ throw new Error ( 'Failed to parse plaintext: no root node found' )
131135 }
132136
133- // Parse children
134- const children : NodeObj [ ] = [ ]
135- let currentIndex = index + 1
136-
137- while ( currentIndex < lines . length ) {
138- const childLine = lines [ currentIndex ]
139- const childIndent = getIndent ( childLine )
140-
141- // Not a child
142- if ( childIndent <= indent ) {
143- break
144- }
145-
146- // Direct child only
147- if ( childIndent === indent + 2 ) {
148- const childParsed = parseLine ( childLine )
149-
150- if ( childParsed . type === 'arrow' ) {
151- context . arrowLines . push ( {
152- content : childParsed . content ,
153- parentChildren : children ,
154- } )
155- currentIndex ++
156- } else if ( childParsed . type === 'summary' ) {
157- context . summaryLines . push ( {
158- content : childParsed . content ,
159- parentChildren : children ,
160- parentId : nodeId , // Pass parent node ID
161- } )
162- currentIndex ++
163- } else {
164- const result = parseNode ( lines , currentIndex , indent , context )
165- if ( result . node ) {
166- children . push ( result . node )
167- }
168- currentIndex = result . nextIndex
169- }
170- } else {
171- // Skip deeper indented lines (will be handled recursively)
172- currentIndex ++
137+ // If there are multiple top-level nodes, wrap them under a synthetic root
138+ let rootNode : NodeObj
139+ if ( topLevelNodes . length === 1 ) {
140+ rootNode = topLevelNodes [ 0 ]
141+ } else {
142+ rootNode = {
143+ topic : rootName ,
144+ id : generateId ( ) ,
145+ children : topLevelNodes ,
173146 }
174147 }
175148
176- if ( children . length > 0 ) {
177- node . children = children
178- }
149+ // Process arrows (deferred because they depend on nodeIdMap which is built during the pass)
150+ const arrows = context . arrowLines . map ( ( { content } ) => parseArrow ( content , context ) ) . filter ( ( a ) : a is Arrow => a !== null )
179151
180- return { node, nextIndex : currentIndex }
152+ return {
153+ nodeData : rootNode ,
154+ arrows : arrows . length > 0 ? arrows : undefined ,
155+ summaries : summaries . length > 0 ? summaries : undefined ,
156+ }
181157}
182158
183159function getIndent ( line : string ) : number {
0 commit comments