@miliastry/quasar 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (172) hide show
  1. package/LICENSE +119 -0
  2. package/README.md +45 -0
  3. package/dist/Visuals/lyne.css +1027 -0
  4. package/dist/Visuals/osu.css +257 -0
  5. package/dist/index.d.mts +4563 -0
  6. package/dist/index.d.ts +4563 -0
  7. package/dist/index.js +11291 -0
  8. package/dist/index.mjs +11197 -0
  9. package/package.json +51 -0
  10. package/src/Analysis/Contracts/AnalysisReport.ts +26 -0
  11. package/src/Analysis/Contracts/Contribution.ts +76 -0
  12. package/src/Analysis/Contracts/Pass.ts +76 -0
  13. package/src/Analysis/Contracts/PipelineContext.ts +48 -0
  14. package/src/Analysis/Passes/Analysis/GradientAnalyzer.ts +292 -0
  15. package/src/Analysis/Passes/Analysis/MergeableColorAnalyzer.ts +112 -0
  16. package/src/Analysis/Passes/Analysis/RainbowAnalyzer.ts +233 -0
  17. package/src/Analysis/Passes/Analysis/WaveAnalyzer.ts +211 -0
  18. package/src/Analysis/Passes/Analysis/__tests__/GradientAnalyzer.test.ts +135 -0
  19. package/src/Analysis/Passes/Analysis/__tests__/MergeableColorAnalyzer.test.ts +84 -0
  20. package/src/Analysis/Passes/Analysis/__tests__/RainbowAnalyzer.test.ts +99 -0
  21. package/src/Analysis/Passes/Analysis/__tests__/WaveAnalyzer.test.ts +119 -0
  22. package/src/Analysis/Passes/Decision/DefaultDecision.ts +139 -0
  23. package/src/Analysis/Passes/Decision/__tests__/DefaultDecision.test.ts +179 -0
  24. package/src/Analysis/Passes/Transform/CollapseGradientTransform.ts +176 -0
  25. package/src/Analysis/Passes/Transform/MergeColorsTransform.ts +126 -0
  26. package/src/Analysis/Passes/Transform/RainbowCollapseTransform.ts +83 -0
  27. package/src/Analysis/Passes/Transform/WaveCollapseTransform.ts +88 -0
  28. package/src/Analysis/Passes/Utility/CharacterCountAnalyzer.ts +45 -0
  29. package/src/Analysis/Pipeline/Pipeline.ts +133 -0
  30. package/src/Analysis/Pipeline/PipelineBuilder.ts +55 -0
  31. package/src/Analysis/Pipeline/PipelineStage.ts +19 -0
  32. package/src/Analysis/Utils/color-utils.ts +132 -0
  33. package/src/Analysis/__tests__/Integration.test.ts +162 -0
  34. package/src/Analysis/__tests__/Pipeline.test.ts +133 -0
  35. package/src/Analysis/index.ts +52 -0
  36. package/src/BBCode/BBCodeDocumentModel.ts +175 -0
  37. package/src/BBCode/BBCodeToGreenNode.ts +755 -0
  38. package/src/BBCode/Parser.ts +384 -0
  39. package/src/BBCode/index.ts +12 -0
  40. package/src/Collab/positions.ts +91 -0
  41. package/src/Commands/Command.ts +44 -0
  42. package/src/Commands/CommandRegistry.ts +78 -0
  43. package/src/Commands/DeleteNode.ts +20 -0
  44. package/src/Commands/InsertText.ts +21 -0
  45. package/src/Commands/SplitMerge.ts +28 -0
  46. package/src/Commands/WrapInTag.ts +21 -0
  47. package/src/Commands/index.ts +6 -0
  48. package/src/Diff/TreeDiffer.ts +264 -0
  49. package/src/Diff/__tests__/TreeDiffer.test.ts +65 -0
  50. package/src/Diff/index.ts +2 -0
  51. package/src/Events/EventBus.ts +160 -0
  52. package/src/Events/index.ts +2 -0
  53. package/src/Formatter/Formatter.ts +54 -0
  54. package/src/Formatter/index.ts +2 -0
  55. package/src/HTML/HTMLDocumentModel.ts +35 -0
  56. package/src/HTML/HTMLToGreenNode.ts +290 -0
  57. package/src/Incremental/ChangeTracker.ts +105 -0
  58. package/src/Incremental/IncrementalParser.ts +591 -0
  59. package/src/Incremental/__tests__/IncrementalParser.test.ts +164 -0
  60. package/src/Incremental/index.ts +4 -0
  61. package/src/Lexer/BBCodeLexer.ts +382 -0
  62. package/src/Lexer/Lexer.ts +181 -0
  63. package/src/Lexer/index.ts +10 -0
  64. package/src/Linter/Linter.ts +193 -0
  65. package/src/Linter/index.ts +2 -0
  66. package/src/Markdown/MarkdownAST.ts +112 -0
  67. package/src/Markdown/MarkdownDocumentModel.ts +55 -0
  68. package/src/Markdown/MarkdownLexer.ts +203 -0
  69. package/src/Markdown/MarkdownParser.ts +455 -0
  70. package/src/Markdown/MarkdownToGreenNode.ts +153 -0
  71. package/src/Model/DocumentModel.ts +694 -0
  72. package/src/Model/NodeFactory.ts +117 -0
  73. package/src/Model/TagRegistry.ts +495 -0
  74. package/src/Model/index.ts +5 -0
  75. package/src/Plugins/PluginAPI.ts +119 -0
  76. package/src/Plugins/PluginRegistry.ts +132 -0
  77. package/src/Plugins/index.ts +3 -0
  78. package/src/Queries/QueryEngine.ts +152 -0
  79. package/src/Queries/index.ts +1 -0
  80. package/src/RenderPipeline/RenderPipeline.ts +125 -0
  81. package/src/RenderPipeline/RenderTree.ts +134 -0
  82. package/src/RenderPipeline/index.ts +4 -0
  83. package/src/Semantic/SemanticAnalyzer.ts +506 -0
  84. package/src/Semantic/index.ts +2 -0
  85. package/src/Symbols/SymbolTable.ts +124 -0
  86. package/src/Symbols/index.ts +1 -0
  87. package/src/Syntax/GreenNode.ts +324 -0
  88. package/src/Syntax/GreenNodePool.ts +269 -0
  89. package/src/Syntax/NodeMatcher.ts +370 -0
  90. package/src/Syntax/RedNode.ts +569 -0
  91. package/src/Syntax/RedNodeStore.ts +184 -0
  92. package/src/Syntax/TreeBuilder.ts +214 -0
  93. package/src/Syntax/__tests__/GreenNode.test.ts +33 -0
  94. package/src/Syntax/__tests__/RedNode.test.ts +81 -0
  95. package/src/Syntax/__tests__/RedNodeStore.test.ts +104 -0
  96. package/src/Syntax/greenEdit.ts +110 -0
  97. package/src/Syntax/hash.ts +30 -0
  98. package/src/Syntax/index.ts +12 -0
  99. package/src/Syntax/partition.ts +161 -0
  100. package/src/Syntax/preserveNodeIds.ts +201 -0
  101. package/src/Tests/ASTOptimizerIdempotence.test.ts +77 -0
  102. package/src/Tests/BlockPatcher.test.ts +437 -0
  103. package/src/Tests/BlockPatcherWindowed.test.ts +364 -0
  104. package/src/Tests/BoxDrawer.test.ts +217 -0
  105. package/src/Tests/BoxRichTitle.test.ts +105 -0
  106. package/src/Tests/Chars500kBenchmark.test.ts +151 -0
  107. package/src/Tests/Chars500kEdits.test.ts +321 -0
  108. package/src/Tests/CollabPositions.test.ts +146 -0
  109. package/src/Tests/CompilerPathProfiling.test.ts +186 -0
  110. package/src/Tests/DOMMorpher.test.ts +142 -0
  111. package/src/Tests/DomPatchPerf.test.ts +60 -0
  112. package/src/Tests/EffectSegments.snapshot.json +616 -0
  113. package/src/Tests/EffectSegments.test.ts +68 -0
  114. package/src/Tests/FindNodeAtOffset.test.ts +65 -0
  115. package/src/Tests/Fuzzer.test.ts +166 -0
  116. package/src/Tests/GreenNodePool.test.ts +153 -0
  117. package/src/Tests/Lexer.test.ts +238 -0
  118. package/src/Tests/LyneMode.test.ts +187 -0
  119. package/src/Tests/ModelCoherence.test.ts +180 -0
  120. package/src/Tests/Partition.test.ts +238 -0
  121. package/src/Tests/PluginTags.test.ts +150 -0
  122. package/src/Tests/ProblematicSection.test.ts +46 -0
  123. package/src/Tests/ProblematicSectionHTML.test.ts +58 -0
  124. package/src/Tests/RedReuse.test.ts +134 -0
  125. package/src/Tests/ReproDelete20k.test.ts +62 -0
  126. package/src/Tests/SemanticValidators.test.ts +136 -0
  127. package/src/Tests/StableNodeIds.test.ts +210 -0
  128. package/src/Tests/StudioColorBloat.test.ts +25 -0
  129. package/src/Tests/StudioDebugText.test.ts +27 -0
  130. package/src/Tests/StudioTrailingChar.test.ts +25 -0
  131. package/src/Tests/StudioValidText.test.ts +25 -0
  132. package/src/Tests/UrlImgBug.test.ts +23 -0
  133. package/src/Tests/VisualBuilderFidelity.test.ts +105 -0
  134. package/src/Tests/referenceDocument.ts +119 -0
  135. package/src/Transactions/Transaction.ts +176 -0
  136. package/src/Transactions/UndoManager.ts +111 -0
  137. package/src/Transactions/index.ts +3 -0
  138. package/src/Transformers/ASTOptimizer.ts +315 -0
  139. package/src/Transformers/GradientTransformer.ts +143 -0
  140. package/src/Transformers/GrowTransformer.ts +115 -0
  141. package/src/Transformers/RainbowTransformer.ts +121 -0
  142. package/src/Transformers/SineWaveTransformer.ts +130 -0
  143. package/src/Transformers/Transformer.ts +22 -0
  144. package/src/Types/core.ts +270 -0
  145. package/src/Types/diagnostics.ts +156 -0
  146. package/src/Types/index.ts +23 -0
  147. package/src/Types/operations.ts +180 -0
  148. package/src/Types/queries.ts +121 -0
  149. package/src/Types/symbols.ts +69 -0
  150. package/src/Types/tokens.ts +186 -0
  151. package/src/Utils/BBCodeGenerator.ts +126 -0
  152. package/src/Utils/ColorMath.ts +276 -0
  153. package/src/Utils/color.ts +112 -0
  154. package/src/Utils/dom-to-svg.test.ts +86 -0
  155. package/src/Utils/dom-to-svg.ts +615 -0
  156. package/src/Utils/treeTransformers.ts +717 -0
  157. package/src/Visitors/BBBlocksExporter.ts +69 -0
  158. package/src/Visitors/BBCodeExporter.ts +318 -0
  159. package/src/Visitors/BlockPatcher.ts +963 -0
  160. package/src/Visitors/DOMMorpher.ts +134 -0
  161. package/src/Visitors/HTMLRenderer.ts +1077 -0
  162. package/src/Visitors/JSONExporter.ts +66 -0
  163. package/src/Visitors/MarkdownExporter.ts +99 -0
  164. package/src/Visitors/SVGRenderer.ts +35 -0
  165. package/src/Visitors/TiptapExporter.ts +145 -0
  166. package/src/Visitors/Visitor.ts +48 -0
  167. package/src/Visitors/index.ts +9 -0
  168. package/src/Visuals/BoxDrawer.ts +175 -0
  169. package/src/Visuals/index.ts +42 -0
  170. package/src/Visuals/lyne.css +1027 -0
  171. package/src/Visuals/osu.css +257 -0
  172. package/src/index.ts +197 -0
@@ -0,0 +1,755 @@
1
+ /**
2
+ * DocumentEngine — BBCodeToGreenNode
3
+ *
4
+ * Bridge between the existing BBCode Parser (BBBlock[]) and the
5
+ * DocumentEngine's GreenNode/RedNode syntax tree.
6
+ *
7
+ * This is how we integrate the mature, battle-tested BBCode parser
8
+ * with the new Language Platform architecture.
9
+ *
10
+ * The flow:
11
+ * BBCode text
12
+ * → parseBBCode() [existing] → BBBlock[]
13
+ * → convertToGreenNode() [this] → GreenNode
14
+ * → buildRedNode() [this] → RedNode
15
+ * → DocumentModel
16
+ */
17
+
18
+ import { GreenNode, greenNode, greenLeaf } from '../Syntax/GreenNode'
19
+ import { RedNode } from '../Syntax/RedNode'
20
+ import { RedNodeStore } from '../Syntax/RedNodeStore'
21
+ import type { NodeKind } from '../Types/core'
22
+ import { scanBBCode } from '../Lexer/BBCodeLexer'
23
+ import { parseTokensToGreen } from './Parser'
24
+
25
+ // ─── Dialect & Tag → Kind mapping ─────────────────────────────
26
+
27
+ export type BBCodeDialect = 'osu' | 'miliastry' | 'lyne'
28
+
29
+ const OSU_TAG_TO_KIND_ENTRIES: Record<string, NodeKind> = {
30
+ 'b': 'bold',
31
+ 'i': 'italic',
32
+ 'u': 'underline',
33
+ 's': 'strikethrough',
34
+ 'strike': 'strikethrough',
35
+ 'color': 'color',
36
+ 'colour': 'color',
37
+ 'size': 'font_size',
38
+ 'c': 'inline_code',
39
+ 'code': 'code',
40
+ 'spoiler': 'spoiler',
41
+ 'centre': 'center',
42
+ 'center': 'center',
43
+ 'right': 'right',
44
+ 'left': 'left',
45
+ 'url': 'url',
46
+ 'email': 'email',
47
+ 'profile': 'profile',
48
+ 'img': 'image',
49
+ 'youtube': 'video',
50
+ 'audio': 'audio',
51
+ 'imagemap': 'imagemap',
52
+ 'quote': 'quote',
53
+ 'notice': 'notice',
54
+ 'spoilerbox': 'spoilerbox',
55
+ 'box': 'box',
56
+ 'list': 'list',
57
+ '*': 'list_item',
58
+ 'heading': 'heading',
59
+ 'empty_line': 'empty_line',
60
+ }
61
+
62
+ const MILIASTRY_TAG_TO_KIND_ENTRIES: Record<string, NodeKind> = {
63
+ ...OSU_TAG_TO_KIND_ENTRIES,
64
+ // Extras de Miliastry: osu! real + cositas especiales propias. `font` vive
65
+ // aquí (y en Lyne), NO en osu: osu! solo tiene [size], no familia tipográfica.
66
+ 'font': 'font',
67
+ 'zalgo': 'zalgo',
68
+ 'aesthetic': 'aesthetic',
69
+ 'sparkle': 'sparkle',
70
+ 'bubble': 'bubble',
71
+ 'flower': 'flower',
72
+ 'gradient': 'gradient',
73
+ 'grow': 'grow',
74
+ 'sinewave': 'sinewave',
75
+ 'rainbow': 'rainbow',
76
+ 'svg': 'svg',
77
+ 'group': 'group',
78
+ }
79
+
80
+ /**
81
+ * Tags canónicos de Lyne — la superficie REAL del dialecto. Cada familia tiene
82
+ * UNA sola forma: `effect`, `anim`, `container`, `style` (el tipo va como
83
+ * atributo: `[effect=glow:#hex]`, `[anim=typewriter]`, `[container=glass]`).
84
+ *
85
+ * Los tipos de efecto que no tienen tag propio (emboss, engrave, …) se
86
+ * alcanzan igualmente vía `[effect=emboss]`: no necesitan fila aquí.
87
+ */
88
+ const LYNE_CANONICAL_TAG_TO_KIND: Record<string, NodeKind> = {
89
+ // Core & standard
90
+ 'b': 'bold',
91
+ 'i': 'italic',
92
+ 'u': 'underline',
93
+ 's': 'strikethrough',
94
+ 'strike': 'strikethrough',
95
+ 'color': 'color',
96
+ 'colour': 'color',
97
+ 'size': 'font_size',
98
+ 'font': 'font',
99
+ 'c': 'inline_code',
100
+ 'code': 'code',
101
+ 'url': 'url',
102
+ 'email': 'email',
103
+ 'profile': 'profile',
104
+ 'guild': 'guild',
105
+ 'map': 'map',
106
+ 'img': 'image',
107
+ 'youtube': 'video',
108
+ 'audio': 'audio',
109
+ 'video': 'video',
110
+ 'imagemap': 'imagemap',
111
+ 'heading': 'heading',
112
+ 'notice': 'notice',
113
+ 'wnotice': 'wnotice',
114
+ 'quote': 'quote',
115
+ 'box': 'box',
116
+ 'boxw': 'boxw',
117
+ 'spoilerbox': 'spoilerbox',
118
+ 'spoiler': 'spoiler',
119
+ 'list': 'list',
120
+ '*': 'list_item',
121
+ 'centre': 'center',
122
+ 'center': 'center',
123
+ 'right': 'right',
124
+ 'align': 'align',
125
+ 'hr': 'separator',
126
+ 'separator': 'separator',
127
+ 'scroll': 'scroll',
128
+ 'empty_line': 'empty_line',
129
+
130
+ // Tables
131
+ 'tables': 'tables',
132
+ 'row': 'table_row',
133
+ 'col': 'table_col',
134
+ 'th': 'table_th',
135
+
136
+ // Layout
137
+ 'gallery': 'gallery',
138
+ 'columns': 'columns',
139
+
140
+ // Inline / Typography
141
+ 'sup': 'sup',
142
+ 'sub': 'sub',
143
+ 'abbr': 'abbr',
144
+ 'mark': 'mark',
145
+ 'kbd': 'kbd',
146
+ 'tooltip': 'tooltip',
147
+ 'flip': 'flip',
148
+ 'gradient': 'gradient',
149
+ 'raw': 'raw',
150
+ 'noparse': 'raw',
151
+ 'plain': 'plain',
152
+
153
+ // Consolidated
154
+ 'effect': 'effect',
155
+ 'anim': 'anim',
156
+ 'container': 'container',
157
+ 'style': 'style_tag',
158
+ }
159
+
160
+ /**
161
+ * Grafías legacy que Lyne original aceptaba, mantenidas SOLO para que los
162
+ * posts antiguos sigan parseando. La forma canónica es la familia:
163
+ * `[effect=glow]`, `[anim=fade]`, `[container=glass]`, `[style=width:300px]`.
164
+ * El exporter ya normaliza a la forma canónica al re-exportar, así que el
165
+ * contenido legacy se auto-limpia con una edición. NO añadir más grafías
166
+ * aquí: un efecto o contenedor nuevo se usa con su familia (`[effect=tipo]`).
167
+ */
168
+ const LYNE_LEGACY_ALIASES: Record<string, NodeKind> = {
169
+ // Effect legacy aliases
170
+ 'glow': 'effect',
171
+ 'neon': 'effect',
172
+ 'outline': 'effect',
173
+ 'shimmer': 'effect',
174
+ 'ghost': 'effect',
175
+ 'rainbow': 'effect',
176
+ 'fire': 'effect',
177
+ 'ice': 'effect',
178
+
179
+ // Anim legacy aliases
180
+ 'typewriter': 'anim',
181
+ 'wave': 'anim',
182
+ 'sparkle': 'anim',
183
+ 'glitch': 'anim',
184
+ 'levitate': 'anim',
185
+
186
+ // Container legacy aliases
187
+ 'stack': 'container',
188
+ 'flex': 'container',
189
+ 'grid': 'container',
190
+ 'middle': 'container',
191
+ 'circle': 'container',
192
+ 'card': 'container',
193
+ 'glass': 'container',
194
+ 'neon-box': 'container',
195
+ 'neonbox': 'container',
196
+ }
197
+
198
+ /**
199
+ * Mapa completo de Lyne = canónicos + grafías legacy de compatibilidad.
200
+ *
201
+ * NOTA — podas realizadas (el texto queda literal, que es lo honesto):
202
+ * - `relief`, `gap`, `colspan`, `rowspan`: no hacían nada real (relief sin
203
+ * caso en el renderer, gap sin identidad, colspan/rowspan son atributos de
204
+ * `[col]`, no tags).
205
+ * - Los 18 aliases de estilo (`width`, `height`, `padding`, `margin`, …):
206
+ * rotos — pasaban el valor como CSS crudo (`[width=300]` → `style="300"`,
207
+ * inválido, el renderer lo descartaba). Solo `[style=…]` funciona.
208
+ * - `shadow`: el tipo de effect ignoraba el color y chocaba con el `shadow`
209
+ * de osu (significado distinto por dialecto). Eliminado de Lyne.
210
+ * - `fade`: duplicado exacto de `[anim=fade-in]`. Eliminado.
211
+ */
212
+ const LYNE_TAG_TO_KIND_ENTRIES: Record<string, NodeKind> = {
213
+ ...LYNE_CANONICAL_TAG_TO_KIND,
214
+ ...LYNE_LEGACY_ALIASES,
215
+ }
216
+
217
+ const DIALECT_MAPS: Record<BBCodeDialect, Map<string, NodeKind>> = {
218
+ osu: new Map(Object.entries(OSU_TAG_TO_KIND_ENTRIES)),
219
+ miliastry: new Map(Object.entries(MILIASTRY_TAG_TO_KIND_ENTRIES)),
220
+ lyne: new Map(Object.entries(LYNE_TAG_TO_KIND_ENTRIES)),
221
+ }
222
+
223
+ const TAG_TO_KIND = DIALECT_MAPS.miliastry
224
+
225
+ const KIND_TO_TAG: Partial<Record<NodeKind, string>> = {}
226
+ for (const [tag, kind] of TAG_TO_KIND) {
227
+ KIND_TO_TAG[kind] = tag
228
+ }
229
+
230
+ export const BBCODE_TAG_NAMES: readonly string[] = Object.freeze(
231
+ [...TAG_TO_KIND.keys()].sort(),
232
+ )
233
+
234
+ export function getBBCodeTagNames(dialect: BBCodeDialect = 'miliastry'): readonly string[] {
235
+ // Para Lyne, la superficie visible es la CANÓNICA: los legacy aliases se
236
+ // siguen parseando (compatibilidad) pero no se listan — así un consumidor
237
+ // (autocomplete, docs) solo ofrece las formas canónicas.
238
+ if (dialect === 'lyne') return Object.freeze([...Object.keys(LYNE_CANONICAL_TAG_TO_KIND)].sort())
239
+ const map = DIALECT_MAPS[dialect] || DIALECT_MAPS.miliastry
240
+ return Object.freeze([...map.keys()].sort())
241
+ }
242
+
243
+ export function tagToNodeKind(tag: string | null, dialect: BBCodeDialect = 'miliastry'): NodeKind {
244
+ if (tag === null) return 'text'
245
+ const map = DIALECT_MAPS[dialect] || DIALECT_MAPS.miliastry
246
+ return map.get(tag) ?? 'custom'
247
+ }
248
+
249
+ export function nodeKindToTag(kind: NodeKind): string | null {
250
+ return KIND_TO_TAG[kind] ?? null
251
+ }
252
+
253
+ // ─── Rendered kind — tags that produce inline or block HTML ──
254
+
255
+ const RENDERED_AS_BLOCK = new Set<NodeKind>([
256
+ 'notice', 'wnotice', 'spoilerbox', 'box', 'boxw', 'list', 'quote', 'code', 'svg',
257
+ 'heading', 'center', 'right', 'left', 'align', 'imagemap', 'document',
258
+ 'list_item', 'spacing', 'empty_line', 'paragraph',
259
+ 'tables', 'table_row', 'gallery', 'columns', 'separator', 'scroll',
260
+ 'container',
261
+ ])
262
+
263
+ export function isBlockKind(kind: NodeKind): boolean {
264
+ return RENDERED_AS_BLOCK.has(kind)
265
+ }
266
+
267
+ // ─── BBBlock Interface (mirror of existing parser's type) ──
268
+
269
+ export interface BBBlock {
270
+ id: string
271
+ tag: string | null
272
+ attrs: string
273
+ content: string
274
+ rawStart: number
275
+ rawEnd: number
276
+ children: BBBlock[]
277
+ attrChildren?: BBBlock[]
278
+ html?: string
279
+ }
280
+
281
+ // ─── Converters ─────────────────────────────────────────────
282
+
283
+ /**
284
+ * Convert a single BBBlock to a GreenNode.
285
+ * Recursively converts children.
286
+ */
287
+ export function bbBlockToGreenNode(block: BBBlock): GreenNode {
288
+ const kind = tagToNodeKind(block.tag)
289
+ const children: GreenNode[] = []
290
+ let text = ''
291
+
292
+ // For leaf blocks with a tag (self-closing like [*], or content like [img]src[/img])
293
+ if (block.tag === '*') {
294
+ // List item: content is in the attrs or in children
295
+ text = block.attrs || ''
296
+ } else if (block.tag === 'img' || block.tag === 'youtube' || block.tag === 'audio') {
297
+ // Media tags: content is in the text between tags
298
+ text = block.content
299
+ } else if (block.tag === null) {
300
+ // Text node
301
+ text = block.content
302
+ } else {
303
+ // Tag node with children
304
+ text = block.attrs || ''
305
+ }
306
+
307
+ // Convert children recursively
308
+ for (const child of block.children) {
309
+ children.push(bbBlockToGreenNode(child))
310
+ }
311
+
312
+ // Handle attrChildren (nested BBCode inside attributes like [box=[color]Title[/color]])
313
+ if (block.attrChildren && block.attrChildren.length > 0) {
314
+ for (const attrChild of block.attrChildren) {
315
+ children.push(bbBlockToGreenNode(attrChild))
316
+ }
317
+ }
318
+
319
+ // `rawStart`/`rawEnd` are gone: a green node's width comes from its children,
320
+ // or from its own text when it has none. This legacy bridge never fed the
321
+ // incremental parser, so nothing depended on the old absolute spans.
322
+ return children.length > 0
323
+ ? greenNode(kind, text, children)
324
+ : greenLeaf(kind, text, block.rawEnd - block.rawStart)
325
+ }
326
+
327
+ /**
328
+ * Convert an array of BBBlock[] (the root of the existing parser's output)
329
+ * to a GreenNode tree.
330
+ *
331
+ * Note: This function is kept for backward compatibility with the old parser.
332
+ * The new DocumentEngine parser (Parser.ts + BBCodeLexer) produces GreenNode
333
+ * directly and does NOT go through BBBlock[].
334
+ */
335
+ export function bbBlocksToGreenTree(blocks: BBBlock[], source: string): GreenNode {
336
+ const children: GreenNode[] = []
337
+ for (const block of blocks) {
338
+ children.push(bbBlockToGreenNode(block))
339
+ }
340
+
341
+ return greenNode('document', '', children)
342
+ }
343
+
344
+ /**
345
+ * Extract metadata from BBCode attributes stored in a GreenNode's text.
346
+ *
347
+ * The GreenNode stores `block.attrs` in its `text` field for tag nodes.
348
+ * BBCode attrs have the format `=VALUE` (e.g. `=#61afef`, `="Author"`,
349
+ * `=https://osu.ppy.sh`). This function parses them into typed metadata.
350
+ *
351
+ * For media tags (image, video, audio), the content may be in children
352
+ * rather than attrs (e.g. `[img]url[/img]` vs `[img=url]`).
353
+ * When attrs are empty, we fall back to the first child text node.
354
+ */
355
+ /**
356
+ * Get the URL from the first child text node (fallback for `[img]url[/img]`).
357
+ *
358
+ * Module-level on purpose: as a closure inside `extractGreenNodeMetadata` this
359
+ * was allocated for every node in the document, including the vast majority
360
+ * whose `switch` branch never calls it.
361
+ */
362
+ function firstChildText(green: GreenNode): string {
363
+ if (green.children.length > 0) {
364
+ const first = green.children[0] as GreenNode
365
+ if (first.kind === 'text' && first.text) return first.text
366
+ }
367
+ return ''
368
+ }
369
+
370
+ const BBCODE_TAG_RE = /\[\/?[a-zA-Z0-9_*-]+=?[^\]]*\]/g
371
+
372
+ /** Strip BBCode tags from text for clean display (e.g. `[b]title[/b]` → `title`) */
373
+ function stripBBCode(raw: string): string {
374
+ return raw.replace(BBCODE_TAG_RE, '').trim()
375
+ }
376
+
377
+ export function extractGreenNodeMetadata(green: GreenNode): Record<string, unknown> {
378
+ const kind = green.kind as NodeKind
379
+ if (kind === 'text') return {}
380
+
381
+ const rawText = green.text || ''
382
+
383
+ // Strip `=` prefix and surrounding quotes
384
+ let value = rawText
385
+ const eqIdx = value.indexOf('=')
386
+ if (eqIdx >= 0) {
387
+ value = value.slice(eqIdx + 1)
388
+ if ((value.startsWith('"') && value.endsWith('"')) ||
389
+ (value.startsWith("'") && value.endsWith("'"))) {
390
+ value = value.slice(1, -1)
391
+ }
392
+ }
393
+
394
+ switch (kind) {
395
+ case 'color': return { color: value }
396
+ case 'font_size':return { size: value }
397
+ case 'font': return { font: value }
398
+ case 'url': return { href: value || firstChildText(green) }
399
+ case 'email': return { href: `mailto:${value}` }
400
+ case 'profile': return { username: value }
401
+ case 'quote': return { source: value }
402
+ case 'spoilerbox':
403
+ case 'box':
404
+ case 'boxw':
405
+ // boxw es el box con líneas y fondo (estilo Lyne): se marca `styled`
406
+ // para que el renderer emita la clase que dispara ese look. El box
407
+ // normal ([box]) queda limpio por defecto.
408
+ //
409
+ // Sufijo de color `:#hex` (como `[container=neon-box:#FF0055]`):
410
+ // `[box=Mi Caja:#FF0055]` separa el color del título; el color se guarda
411
+ // aparte para que el renderer lo aplique como `--box-accent` y el
412
+ // exporter lo vuelva a emitir en el round-trip.
413
+ const colorMatch = /:#[0-9a-fA-F]{3,8}$/.exec(value)
414
+ const color = colorMatch ? colorMatch[0].slice(1) : undefined
415
+ const titleValue = colorMatch ? value.slice(0, colorMatch.index) : value
416
+ return {
417
+ title: stripBBCode(titleValue) || (kind === 'box' || kind === 'boxw' ? 'Box' : 'Spoiler'),
418
+ rawTitle: titleValue,
419
+ ...(color ? { color } : {}),
420
+ ...(kind === 'boxw' ? { styled: true } : {}),
421
+ }
422
+ case 'list': return { ordered: value === '1' || value === 'a' }
423
+ case 'image': {
424
+ // En el dialecto Lyne el atributo del tag es el TAMAÑO o modificador
425
+ // (`[img=400x300]url[/img]`, `[img round]url[/img]`) y la URL va como
426
+ // contenido — igual que en el renderer original (parseImgAttr(node.attr)
427
+ // + textOf(node.children)). Guardar `value` como src rompía la imagen:
428
+ // con `[img=400x300]url[/img]` el src quedaba "400x300" y la URL se perdía.
429
+ // Si el atributo parece una URL, es la forma `[img=url]` (sin contenido)
430
+ // y entonces sí es el src.
431
+ const looksLikeUrl = /^https?:\/\//i.test(value)
432
+ const src = looksLikeUrl ? value : (firstChildText(green) || undefined)
433
+ // imgAttr conserva el tamaño/modificador original (`400x300`, `round`, …)
434
+ // para que el exporter pueda reproducir `[img=400x300]` en el round-trip.
435
+ const imgAttr = looksLikeUrl ? undefined : (value || undefined)
436
+ return { src, imgAttr }
437
+ }
438
+ case 'video': return { videoId: value || firstChildText(green) }
439
+ case 'audio': return { src: value || firstChildText(green) }
440
+ case 'gradient':
441
+ // Parse "=#ff0000,#00ff00" → { colors: ['#FF0000', '#00FF00'] }
442
+ const gradientColors = value.split(',').map(c => c.trim()).filter(c => c.startsWith('#'))
443
+ return gradientColors.length > 0 ? { colors: gradientColors } : {}
444
+ case 'notice':
445
+ case 'wnotice': return value ? { color: value } : {}
446
+ case 'tables':
447
+ case 'columns': {
448
+ // Sufijo de color `:#hex` (como en box): `[tables=striped:#FF0055]` y
449
+ // `[columns=2:#FF0055]`. De ese color se deriva toda la paleta (bordes,
450
+ // filas, encabezado) vía `--table-accent` / `--columns-accent`.
451
+ if (!value) return {}
452
+ const colorMatch = /:#[0-9a-fA-F]{3,8}$/.exec(value)
453
+ const clean = colorMatch ? value.slice(0, colorMatch.index) : value
454
+ const color = colorMatch ? colorMatch[0].slice(1) : undefined
455
+ const base = kind === 'tables' ? { variant: clean } : { columns: clean }
456
+ return color ? { ...base, color } : base
457
+ }
458
+ case 'separator':return value ? { variant: value } : {}
459
+ case 'scroll': return value ? { height: value } : {}
460
+ case 'abbr': return value ? { title: value } : {}
461
+ case 'tooltip': return value ? { tip: value } : {}
462
+ case 'guild': return value ? { tag: value } : {}
463
+ case 'map': return value ? { id: value } : {}
464
+ case 'align': return value ? { align: value } : {}
465
+ case 'effect': {
466
+ if (!value) return {}
467
+ if (value.includes(':')) {
468
+ const [effectType, ...rest] = value.split(':')
469
+ return { effectType, color: rest.join(':') }
470
+ }
471
+ if (value.startsWith('#')) return { effectType: 'glow', color: value }
472
+ return { effectType: value }
473
+ }
474
+ case 'anim': {
475
+ if (!value) return {}
476
+ if (value.includes(':')) {
477
+ const [animType, ...rest] = value.split(':')
478
+ return { animType, param: rest.join(':') }
479
+ }
480
+ return { animType: value }
481
+ }
482
+ case 'container':return value ? { containerType: value } : {}
483
+ case 'style_tag':return value ? { style: value } : {}
484
+ case 'list_item':return {} // No metadata for list items
485
+ default: return {}
486
+ }
487
+ }
488
+
489
+ /**
490
+ * Parse rich BBCode inside a container attribute (e.g. `[box=[b]Title[/b]]`).
491
+ * Produces a list of RedNode children for the title slot.
492
+ */
493
+ function buildTitleNodes(
494
+ rawTitle: string,
495
+ parent: RedNode,
496
+ store?: RedNodeStore,
497
+ start: number = 0,
498
+ ): RedNode[] {
499
+ if (!rawTitle || !rawTitle.includes('[')) return []
500
+ try {
501
+ const tokens = scanBBCode(rawTitle)
502
+ const greenTitle = parseTokensToGreen(tokens, rawTitle, { normalizeParagraphs: false })
503
+ const redTitle = greenToRedNode(greenTitle, null, store, start)
504
+ for (const child of redTitle.children) {
505
+ child.parent = parent
506
+ }
507
+ return redTitle.children
508
+ } catch {
509
+ return []
510
+ }
511
+ }
512
+
513
+ /**
514
+ * Build a RedNode tree from a GreenNode.
515
+ * Similar to TreeBuilder.buildRed but uses actual kind from the green node
516
+ * and extracts BBCode metadata from attributes.
517
+ *
518
+ * When a RedNodeStore is provided, instance RedNodes are created with
519
+ * correct parent references copied from the canonical's metadata.
520
+ * The canonicalId (from green._hash) enables:
521
+ * - React.memo in BBCodeCanvas by canonicalId
522
+ * - HTMLRenderer cache by canonicalId
523
+ * - Diff optimization (same canonicalId = unchanged subtree)
524
+ * - Future PositionRef-based sharing
525
+ */
526
+ export function greenToRedNode(
527
+ green: GreenNode,
528
+ parent?: RedNode | null,
529
+ store?: RedNodeStore,
530
+ start: number = 0,
531
+ ): RedNode {
532
+ // Absolute offsets are woven in here, on the way down: a child begins where
533
+ // its parent's opening delimiter ends, and each sibling after the previous
534
+ // one. Green nodes carry only widths (see `GreenNode.ts`), so this walk is
535
+ // the single place a position comes into existence — and it costs one
536
+ // addition per node.
537
+ if (store) {
538
+ // Get/create canonical node (stores metadata extracted from green.text)
539
+ const canonical = store.getOrCreate(green)
540
+
541
+ // Create a NEW instance RedNode that:
542
+ // - Has correct parent reference (position-specific)
543
+ // - Has its OWN children array (position-specific)
544
+ // - Has the SAME canonicalId (enables identity-based optimizations)
545
+ // - Has the SAME metadata as canonical (copied)
546
+ const instance = new RedNode(green, {
547
+ parent: parent ?? null,
548
+ kind: green.kind as NodeKind,
549
+ metadata: { ...canonical.metadata },
550
+ start,
551
+ })
552
+
553
+ if ((green.kind === 'box' || green.kind === 'boxw' || green.kind === 'spoilerbox') && instance.metadata.rawTitle) {
554
+ const rawTitle = String(instance.metadata.rawTitle)
555
+ const tagName = green.kind
556
+ const rawText = green.text || ''
557
+ const isQuoted = (rawText.startsWith('="') && rawText.endsWith('"')) || (rawText.startsWith("='") && rawText.endsWith("'"))
558
+ const offsetToTitle = start + 1 + tagName.length + 1 + (isQuoted ? 1 : 0)
559
+ const titleNodes = buildTitleNodes(rawTitle, instance, store, offsetToTitle)
560
+ if (titleNodes.length > 0) {
561
+ instance.metadata.titleNodes = titleNodes
562
+ }
563
+ }
564
+
565
+ const greenChildren = green.children as GreenNode[]
566
+ if (greenChildren.length > 0) {
567
+ const kids: RedNode[] = new Array(greenChildren.length)
568
+ let offset = start + green.leadingWidth
569
+ for (let i = 0; i < greenChildren.length; i++) {
570
+ kids[i] = greenToRedNode(greenChildren[i], instance, store, offset)
571
+ offset += greenChildren[i].width
572
+ }
573
+ instance.initChildren(kids)
574
+ }
575
+
576
+ return instance
577
+ }
578
+
579
+ // Legacy path without store (backward compat)
580
+ const red = new RedNode(green, {
581
+ parent: parent ?? null,
582
+ kind: green.kind as NodeKind,
583
+ metadata: extractGreenNodeMetadata(green),
584
+ start,
585
+ })
586
+
587
+ if ((green.kind === 'box' || green.kind === 'boxw' || green.kind === 'spoilerbox') && red.metadata.rawTitle) {
588
+ const rawTitle = String(red.metadata.rawTitle)
589
+ const tagName = green.kind
590
+ const rawText = green.text || ''
591
+ const isQuoted = (rawText.startsWith('="') && rawText.endsWith('"')) || (rawText.startsWith("='") && rawText.endsWith("'"))
592
+ const offsetToTitle = start + 1 + tagName.length + 1 + (isQuoted ? 1 : 0)
593
+ const titleNodes = buildTitleNodes(rawTitle, red, store, offsetToTitle)
594
+ if (titleNodes.length > 0) {
595
+ red.metadata.titleNodes = titleNodes
596
+ }
597
+ }
598
+
599
+ const greenChildren = green.children as GreenNode[]
600
+ if (greenChildren.length > 0) {
601
+ const kids: RedNode[] = new Array(greenChildren.length)
602
+ let offset = start + green.leadingWidth
603
+ for (let i = 0; i < greenChildren.length; i++) {
604
+ kids[i] = greenToRedNode(greenChildren[i], red, store, offset)
605
+ offset += greenChildren[i].width
606
+ }
607
+ red.initChildren(kids)
608
+ }
609
+
610
+ return red
611
+ }
612
+
613
+ // ─── Red-tree reuse across incremental reparses ─────────────
614
+
615
+ /**
616
+ * Build the red tree for `green`, adopting subtrees of the PREVIOUS red tree
617
+ * wherever the new green shares a green node by reference with the old one.
618
+ *
619
+ * The incremental splice (`spliceGreen`) rebuilds only the spine of ancestors
620
+ * around an edit; every untouched sibling keeps its exact green object. Yet
621
+ * `greenToRedNode` reconstructed all ~1700 red nodes on every keystroke —
622
+ * measured as the single largest phase of a keystroke (0.30 of 0.94 ms).
623
+ * Reference equality of greens is proof the subtree did not change, so its old
624
+ * red subtree — ids, metadata, diagnostics and all — can be adopted wholesale.
625
+ * Adoption is one `parent` reassignment (done by `initChildren`) plus, only
626
+ * when the subtree moved, a `setStart` walk that adds a delta to two ints per
627
+ * node. Both are far cheaper than re-extracting metadata and reallocating.
628
+ *
629
+ * ⚠ Contract: the OLD red tree is consumed. Adopted subtrees are reparented
630
+ * into the new tree, so the previous `redRoot` must not be used again after
631
+ * this returns. Nothing in the engine or the app reads a superseded red tree
632
+ * (verified), and `DocumentModelOptions.reuseRed` is the kill-switch if a
633
+ * future consumer ever needs the old tree to stay intact.
634
+ *
635
+ * The child walk mirrors `NodeMatcher` Phase 0 / `preserveNodeIds`: trim the
636
+ * common prefix and suffix by green reference, descend into the changed window
637
+ * only when it is the single-child shape a keystroke produces, and build the
638
+ * rest fresh. Positional lockstep also makes double-adoption impossible: each
639
+ * old red child is adopted at most once, even when interning makes distinct
640
+ * positions share one green object.
641
+ */
642
+ export function greenToRedNodeReusing(
643
+ green: GreenNode,
644
+ oldRed: RedNode,
645
+ start: number = 0,
646
+ stats?: { adopted: number },
647
+ ): RedNode {
648
+ if (green === oldRed.green) {
649
+ // `setStart` records the shift lazily (no subtree walk) and no-ops when the
650
+ // offset did not move, so the former `oldRed.range.start !== start` guard —
651
+ // which READ the range, forcing a lazy materialization — is unnecessary.
652
+ oldRed.setStart(start)
653
+ oldRed.parent = null
654
+ if (stats) stats.adopted++
655
+ return oldRed
656
+ }
657
+
658
+ const red = new RedNode(green, {
659
+ parent: null,
660
+ kind: green.kind as NodeKind,
661
+ metadata: extractGreenNodeMetadata(green),
662
+ start,
663
+ })
664
+
665
+ if ((green.kind === 'box' || green.kind === 'boxw' || green.kind === 'spoilerbox') && red.metadata.rawTitle) {
666
+ const rawTitle = String(red.metadata.rawTitle)
667
+ const tagName = green.kind
668
+ const rawText = green.text || ''
669
+ const isQuoted = (rawText.startsWith('="') && rawText.endsWith('"')) || (rawText.startsWith("='") && rawText.endsWith("'"))
670
+ const offsetToTitle = start + 1 + tagName.length + 1 + (isQuoted ? 1 : 0)
671
+ const titleNodes = buildTitleNodes(rawTitle, red, undefined, offsetToTitle)
672
+ if (titleNodes.length > 0) {
673
+ red.metadata.titleNodes = titleNodes
674
+ }
675
+ }
676
+
677
+ const greenKids = green.children as GreenNode[]
678
+ const oldKids = oldRed.children
679
+ if (greenKids.length === 0) return red
680
+
681
+ // Absolute start of every green child, needed by the suffix walk, which
682
+ // cannot accumulate forward.
683
+ const offsets: number[] = new Array(greenKids.length)
684
+ let offset = start + green.leadingWidth
685
+ for (let i = 0; i < greenKids.length; i++) {
686
+ offsets[i] = offset
687
+ offset += greenKids[i].width
688
+ }
689
+
690
+ const kids: RedNode[] = new Array(greenKids.length)
691
+ const limit = Math.min(greenKids.length, oldKids.length)
692
+
693
+ // Common prefix: same green object, adopt.
694
+ let lo = 0
695
+ while (lo < limit && greenKids[lo] === oldKids[lo].green) {
696
+ kids[lo] = adoptShifted(oldKids[lo], offsets[lo], stats)
697
+ lo++
698
+ }
699
+
700
+ // Common suffix, stopping before the prefix already consumed.
701
+ let gHi = greenKids.length - 1
702
+ let oHi = oldKids.length - 1
703
+ while (gHi >= lo && oHi >= lo && greenKids[gHi] === oldKids[oHi].green) {
704
+ kids[gHi] = adoptShifted(oldKids[oHi], offsets[gHi], stats)
705
+ gHi--
706
+ oHi--
707
+ }
708
+
709
+ if (gHi === lo && oHi === lo) {
710
+ // The single changed child both sides — the keystroke shape. Descend so
711
+ // its own untouched children are still adopted.
712
+ kids[lo] = greenToRedNodeReusing(greenKids[lo], oldKids[lo], offsets[lo], stats)
713
+ } else {
714
+ // A wider window (multi-node paste, fallback rebuild): build it fresh.
715
+ for (let i = lo; i <= gHi; i++) {
716
+ kids[i] = greenToRedNode(greenKids[i], null, undefined, offsets[i])
717
+ }
718
+ }
719
+
720
+ // Sets parent and index cache on every child, adopted or fresh.
721
+ red.initChildren(kids)
722
+ return red
723
+ }
724
+
725
+ /** Adopt an old red subtree at (possibly) a new absolute offset. */
726
+ function adoptShifted(oldRed: RedNode, start: number, stats?: { adopted: number }): RedNode {
727
+ // Lazy shift: records the delta without walking the subtree (see
728
+ // `RedNode.setStart`). The former range read here would force a
729
+ // materialization, defeating the whole point.
730
+ oldRed.setStart(start)
731
+ if (stats) stats.adopted++
732
+ return oldRed
733
+ }
734
+
735
+ /**
736
+ * A `RedNodeStore` wired with BBCode metadata semantics.
737
+ *
738
+ * `RedNodeStore` used to carry its own verbatim copy of
739
+ * `extractGreenNodeMetadata` — its comment even said *"Mirrors
740
+ * extractGreenNodeMetadata"* — which meant two sources of truth for the
741
+ * attrs→metadata mapping, and they had already drifted apart. The store now
742
+ * takes the extractor as a dependency; this is the BBCode wiring.
743
+ */
744
+ export function createBBCodeRedNodeStore(): RedNodeStore {
745
+ return new RedNodeStore(extractGreenNodeMetadata)
746
+ }
747
+
748
+ /**
749
+ * Convert a BBBlock array directly to a RedNode root.
750
+ * This is the main entry point for the bridge.
751
+ */
752
+ export function bbBlocksToRedTree(blocks: BBBlock[], source: string): RedNode {
753
+ const greenTree = bbBlocksToGreenTree(blocks, source)
754
+ return greenToRedNode(greenTree)
755
+ }