@miliastry/quasar 1.0.6 → 1.1.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 (273) hide show
  1. package/dist/Visuals/lyne.css +117 -6
  2. package/dist/Visuals/osu.css +36 -5
  3. package/dist/index.js +9664 -3282
  4. package/dist/index.mjs +9535 -3283
  5. package/dist/types/Analysis/Contracts/AnalysisReport.d.ts +22 -0
  6. package/dist/types/Analysis/Contracts/Contribution.d.ts +68 -0
  7. package/dist/types/Analysis/Contracts/Pass.d.ts +59 -0
  8. package/dist/types/Analysis/Contracts/PipelineContext.d.ts +34 -0
  9. package/dist/types/Analysis/Passes/Analysis/ColorUsageAnalyzer.d.ts +56 -0
  10. package/dist/types/Analysis/Passes/Analysis/GradientAnalyzer.d.ts +111 -0
  11. package/dist/types/Analysis/Passes/Analysis/MergeableColorAnalyzer.d.ts +26 -0
  12. package/dist/types/Analysis/Passes/Analysis/RainbowAnalyzer.d.ts +47 -0
  13. package/dist/types/Analysis/Passes/Analysis/SymbolAnalyzer.d.ts +89 -0
  14. package/dist/types/Analysis/Passes/Analysis/WaveAnalyzer.d.ts +44 -0
  15. package/dist/types/Analysis/Passes/Analysis/__tests__/GradientAnalyzer.test.d.ts +4 -0
  16. package/dist/types/Analysis/Passes/Analysis/__tests__/MergeableColorAnalyzer.test.d.ts +4 -0
  17. package/dist/types/Analysis/Passes/Analysis/__tests__/RainbowAnalyzer.test.d.ts +4 -0
  18. package/dist/types/Analysis/Passes/Analysis/__tests__/WaveAnalyzer.test.d.ts +4 -0
  19. package/dist/types/Analysis/Passes/Decision/DefaultDecision.d.ts +30 -0
  20. package/dist/types/Analysis/Passes/Decision/PaletteRemapDecision.d.ts +169 -0
  21. package/dist/types/Analysis/Passes/Decision/__tests__/DefaultDecision.test.d.ts +4 -0
  22. package/dist/types/Analysis/Passes/Transform/CollapseGradientTransform.d.ts +37 -0
  23. package/dist/types/Analysis/Passes/Transform/MergeColorsTransform.d.ts +28 -0
  24. package/dist/types/Analysis/Passes/Transform/RainbowCollapseTransform.d.ts +19 -0
  25. package/dist/types/Analysis/Passes/Transform/WaveCollapseTransform.d.ts +20 -0
  26. package/dist/types/Analysis/Passes/Utility/CharacterCountAnalyzer.d.ts +19 -0
  27. package/dist/types/Analysis/Pipeline/Pipeline.d.ts +69 -0
  28. package/dist/types/Analysis/Pipeline/PipelineBuilder.d.ts +31 -0
  29. package/dist/types/Analysis/Pipeline/PipelineStage.d.ts +17 -0
  30. package/dist/types/Analysis/Utils/color-utils.d.ts +60 -0
  31. package/dist/types/Analysis/__tests__/Integration.test.d.ts +8 -0
  32. package/dist/types/Analysis/__tests__/PaletteRemap.test.d.ts +14 -0
  33. package/dist/types/Analysis/__tests__/Pipeline.test.d.ts +10 -0
  34. package/dist/types/Analysis/__tests__/RoundTrip.test.d.ts +30 -0
  35. package/dist/types/Analysis/__tests__/SymbolAnalyzer.test.d.ts +11 -0
  36. package/dist/types/Analysis/index.d.ts +40 -0
  37. package/dist/types/BBCode/BBCodeDocumentModel.d.ts +92 -0
  38. package/dist/types/BBCode/BBCodeToGreenNode.d.ts +111 -0
  39. package/dist/types/BBCode/Parser.d.ts +78 -0
  40. package/dist/types/BBCode/index.d.ts +4 -0
  41. package/dist/types/Collab/positions.d.ts +55 -0
  42. package/dist/types/Commands/Command.d.ts +37 -0
  43. package/dist/types/Commands/CommandRegistry.d.ts +48 -0
  44. package/dist/types/Commands/DeleteNode.d.ts +7 -0
  45. package/dist/types/Commands/InsertText.d.ts +8 -0
  46. package/dist/types/Commands/SplitMerge.d.ts +9 -0
  47. package/dist/types/Commands/WrapInTag.d.ts +8 -0
  48. package/dist/types/Commands/index.d.ts +6 -0
  49. package/dist/types/Diff/TreeDiffer.d.ts +52 -0
  50. package/dist/types/Diff/__tests__/TreeDiffer.test.d.ts +1 -0
  51. package/dist/types/Diff/index.d.ts +2 -0
  52. package/dist/types/Edits/EditPlan.d.ts +144 -0
  53. package/dist/types/Edits/Optimizer.d.ts +78 -0
  54. package/dist/types/Edits/Rules/Rule.d.ts +91 -0
  55. package/dist/types/Edits/Rules/dropEmptyTags.d.ts +40 -0
  56. package/dist/types/Edits/Rules/dropRedundantNesting.d.ts +59 -0
  57. package/dist/types/Edits/Rules/index.d.ts +9 -0
  58. package/dist/types/Edits/Rules/mergeAdjacent.d.ts +93 -0
  59. package/dist/types/Edits/Rules/reorderWrappers.d.ts +42 -0
  60. package/dist/types/Edits/Rules/shortenHex.d.ts +30 -0
  61. package/dist/types/Edits/Rules/tagValue.d.ts +56 -0
  62. package/dist/types/Edits/Rules/unwrapInvisibleColor.d.ts +47 -0
  63. package/dist/types/Edits/applyEdits.d.ts +36 -0
  64. package/dist/types/Edits/index.d.ts +6 -0
  65. package/dist/types/Events/EventBus.d.ts +103 -0
  66. package/dist/types/Events/index.d.ts +2 -0
  67. package/dist/types/Formatter/Formatter.d.ts +32 -0
  68. package/dist/types/Formatter/index.d.ts +2 -0
  69. package/dist/types/HTML/HTMLDocumentModel.d.ts +13 -0
  70. package/dist/types/HTML/HTMLToGreenNode.d.ts +4 -0
  71. package/dist/types/Incremental/BracketIndex.d.ts +101 -0
  72. package/dist/types/Incremental/ChangeTracker.d.ts +69 -0
  73. package/dist/types/Incremental/IncrementalParser.d.ts +246 -0
  74. package/dist/types/Incremental/__tests__/BracketIndex.test.d.ts +1 -0
  75. package/dist/types/Incremental/__tests__/IncrementalParser.test.d.ts +1 -0
  76. package/dist/types/Incremental/index.d.ts +4 -0
  77. package/dist/types/Lexer/BBCodeLexer.d.ts +74 -0
  78. package/dist/types/Lexer/Lexer.d.ts +54 -0
  79. package/dist/types/Lexer/index.d.ts +4 -0
  80. package/dist/types/Linter/Linter.d.ts +58 -0
  81. package/dist/types/Linter/index.d.ts +2 -0
  82. package/dist/types/Markdown/MarkdownAST.d.ts +122 -0
  83. package/dist/types/Markdown/MarkdownDocumentModel.d.ts +15 -0
  84. package/dist/types/Markdown/MarkdownLexer.d.ts +155 -0
  85. package/dist/types/Markdown/MarkdownParser.d.ts +42 -0
  86. package/dist/types/Markdown/MarkdownToGreenNode.d.ts +6 -0
  87. package/dist/types/MilHibri/MilHibriDocumentModel.d.ts +21 -0
  88. package/dist/types/Model/DocumentModel.d.ts +318 -0
  89. package/dist/types/Model/NodeFactory.d.ts +29 -0
  90. package/dist/types/Model/TagRegistry.d.ts +165 -0
  91. package/dist/types/Model/index.d.ts +5 -0
  92. package/dist/types/Plugins/PluginAPI.d.ts +44 -0
  93. package/dist/types/Plugins/PluginRegistry.d.ts +81 -0
  94. package/dist/types/Plugins/index.d.ts +3 -0
  95. package/dist/types/Queries/QueryEngine.d.ts +32 -0
  96. package/dist/types/Queries/index.d.ts +1 -0
  97. package/dist/types/Reconciler/SurgicalReconciler.d.ts +23 -0
  98. package/dist/types/RenderPipeline/RenderPipeline.d.ts +46 -0
  99. package/dist/types/RenderPipeline/RenderTree.d.ts +55 -0
  100. package/dist/types/RenderPipeline/index.d.ts +4 -0
  101. package/dist/types/Repair/NestingRepair.d.ts +48 -0
  102. package/dist/types/Repair/index.d.ts +1 -0
  103. package/dist/types/Semantic/SemanticAnalyzer.d.ts +321 -0
  104. package/dist/types/Semantic/index.d.ts +2 -0
  105. package/dist/types/Symbols/SymbolTable.d.ts +47 -0
  106. package/dist/types/Symbols/index.d.ts +1 -0
  107. package/dist/types/Syntax/GreenNode.d.ts +154 -0
  108. package/dist/types/Syntax/GreenNodePool.d.ts +134 -0
  109. package/dist/types/Syntax/NodeMatcher.d.ts +75 -0
  110. package/dist/types/Syntax/RedNode.d.ts +280 -0
  111. package/dist/types/Syntax/RedNodeStore.d.ts +116 -0
  112. package/dist/types/Syntax/TreeBuilder.d.ts +54 -0
  113. package/dist/types/Syntax/__tests__/GreenNode.test.d.ts +1 -0
  114. package/dist/types/Syntax/__tests__/RedNode.test.d.ts +1 -0
  115. package/dist/types/Syntax/__tests__/RedNodeStore.test.d.ts +1 -0
  116. package/dist/types/Syntax/greenEdit.d.ts +64 -0
  117. package/dist/types/Syntax/hash.d.ts +17 -0
  118. package/dist/types/Syntax/index.d.ts +8 -0
  119. package/dist/types/Syntax/nodeAttr.d.ts +63 -0
  120. package/dist/types/Syntax/partition.d.ts +67 -0
  121. package/dist/types/Syntax/preserveNodeIds.d.ts +47 -0
  122. package/dist/types/Tokens/TokenResolver.d.ts +25 -0
  123. package/dist/types/Tokens/index.d.ts +1 -0
  124. package/dist/types/Transactions/Transaction.d.ts +36 -0
  125. package/dist/types/Transactions/UndoManager.d.ts +63 -0
  126. package/dist/types/Transactions/index.d.ts +3 -0
  127. package/dist/types/Transformers/ASTOptimizer.d.ts +34 -0
  128. package/dist/types/Transformers/GradientTransformer.d.ts +18 -0
  129. package/dist/types/Transformers/GrowTransformer.d.ts +16 -0
  130. package/dist/types/Transformers/RainbowTransformer.d.ts +17 -0
  131. package/dist/types/Transformers/SineWaveTransformer.d.ts +17 -0
  132. package/dist/types/Transformers/Transformer.d.ts +17 -0
  133. package/dist/types/Types/core.d.ts +104 -0
  134. package/dist/types/Types/diagnostics.d.ts +85 -0
  135. package/dist/types/Types/index.d.ts +11 -0
  136. package/dist/types/Types/operations.d.ts +109 -0
  137. package/dist/types/Types/queries.d.ts +67 -0
  138. package/dist/types/Types/symbols.d.ts +42 -0
  139. package/dist/types/Types/tokens.d.ts +75 -0
  140. package/dist/types/Utils/BBCodeGenerator.d.ts +21 -0
  141. package/dist/types/Utils/ColorMath.d.ts +92 -0
  142. package/dist/types/Utils/EffectMath.d.ts +643 -0
  143. package/dist/types/Utils/TagVocabulary.d.ts +104 -0
  144. package/dist/types/Utils/TagVocabulary.test.d.ts +1 -0
  145. package/dist/types/Utils/color.d.ts +29 -0
  146. package/dist/types/Utils/dom-to-svg.d.ts +28 -0
  147. package/dist/types/Utils/dom-to-svg.test.d.ts +1 -0
  148. package/dist/types/Utils/treeTransformers.d.ts +160 -0
  149. package/dist/types/Visitors/BBBlocksExporter.d.ts +27 -0
  150. package/dist/types/Visitors/BBCodeExporter.d.ts +90 -0
  151. package/dist/types/Visitors/BlockPatcher.d.ts +91 -0
  152. package/dist/types/Visitors/DOMMorpher.d.ts +13 -0
  153. package/dist/types/Visitors/HTMLRenderer.d.ts +380 -0
  154. package/dist/types/Visitors/JSONExporter.d.ts +42 -0
  155. package/dist/types/Visitors/MarkdownExporter.d.ts +28 -0
  156. package/dist/types/Visitors/SVGRenderer.d.ts +16 -0
  157. package/dist/types/Visitors/TiptapExporter.d.ts +40 -0
  158. package/dist/types/Visitors/Visitor.d.ts +35 -0
  159. package/dist/types/Visitors/index.d.ts +9 -0
  160. package/dist/types/Visuals/BoxDrawer.d.ts +49 -0
  161. package/dist/types/Visuals/index.d.ts +38 -0
  162. package/dist/types/index.d.ts +154 -0
  163. package/package.json +14 -10
  164. package/src/Analysis/Passes/Analysis/ColorUsageAnalyzer.ts +103 -0
  165. package/src/Analysis/Passes/Analysis/GradientAnalyzer.ts +294 -20
  166. package/src/Analysis/Passes/Analysis/SymbolAnalyzer.ts +257 -0
  167. package/src/Analysis/Passes/Decision/PaletteRemapDecision.ts +425 -0
  168. package/src/Analysis/Pipeline/Pipeline.ts +15 -2
  169. package/src/Analysis/index.ts +8 -2
  170. package/src/BBCode/BBCodeDocumentModel.ts +3 -0
  171. package/src/BBCode/BBCodeToGreenNode.ts +18 -12
  172. package/src/BBCode/Parser.ts +38 -1
  173. package/src/Edits/EditPlan.ts +290 -0
  174. package/src/Edits/Optimizer.ts +185 -0
  175. package/src/Edits/Rules/Rule.ts +145 -0
  176. package/src/Edits/Rules/dropEmptyTags.ts +110 -0
  177. package/src/Edits/Rules/dropRedundantNesting.ts +120 -0
  178. package/src/Edits/Rules/index.ts +28 -0
  179. package/src/Edits/Rules/mergeAdjacent.ts +224 -0
  180. package/src/Edits/Rules/reorderWrappers.ts +154 -0
  181. package/src/Edits/Rules/shortenHex.ts +79 -0
  182. package/src/Edits/Rules/tagValue.ts +105 -0
  183. package/src/Edits/Rules/unwrapInvisibleColor.ts +93 -0
  184. package/src/Edits/applyEdits.ts +54 -0
  185. package/src/Edits/index.ts +22 -0
  186. package/src/Events/EventBus.ts +18 -0
  187. package/src/HTML/HTMLToGreenNode.ts +185 -37
  188. package/src/Incremental/BracketIndex.ts +249 -0
  189. package/src/Incremental/IncrementalParser.ts +464 -41
  190. package/src/Lexer/BBCodeLexer.ts +25 -3
  191. package/src/Lexer/index.ts +1 -1
  192. package/src/Markdown/MarkdownAST.ts +65 -2
  193. package/src/Markdown/MarkdownLexer.ts +69 -14
  194. package/src/Markdown/MarkdownParser.ts +749 -62
  195. package/src/Markdown/MarkdownToGreenNode.ts +148 -27
  196. package/src/MilHibri/MilHibriDocumentModel.ts +56 -0
  197. package/src/Model/DocumentModel.ts +138 -5
  198. package/src/Model/TagRegistry.ts +129 -134
  199. package/src/Reconciler/SurgicalReconciler.ts +460 -0
  200. package/src/Repair/NestingRepair.ts +116 -0
  201. package/src/Repair/index.ts +1 -0
  202. package/src/Semantic/SemanticAnalyzer.ts +1802 -65
  203. package/src/Syntax/GreenNodePool.ts +12 -12
  204. package/src/Syntax/RedNode.ts +17 -3
  205. package/src/Syntax/RedNodeStore.ts +9 -1
  206. package/src/Syntax/nodeAttr.ts +172 -0
  207. package/src/Tokens/TokenResolver.ts +55 -0
  208. package/src/Tokens/index.ts +1 -0
  209. package/src/Types/core.ts +15 -0
  210. package/src/Utils/ColorMath.ts +276 -55
  211. package/src/Utils/EffectMath.ts +2462 -0
  212. package/src/Utils/TagVocabulary.ts +141 -0
  213. package/src/Utils/dom-to-svg.ts +28 -12
  214. package/src/Visitors/BBBlocksExporter.ts +15 -1
  215. package/src/Visitors/BBCodeExporter.ts +319 -32
  216. package/src/Visitors/BlockPatcher.ts +87 -11
  217. package/src/Visitors/DOMMorpher.ts +15 -5
  218. package/src/Visitors/HTMLRenderer.ts +374 -151
  219. package/src/Visitors/MarkdownExporter.ts +98 -41
  220. package/src/Visitors/TiptapExporter.ts +1 -0
  221. package/src/Visuals/index.ts +7 -4
  222. package/src/Visuals/lyne.css +117 -6
  223. package/src/Visuals/osu.css +36 -5
  224. package/src/index.ts +172 -5
  225. package/dist/index.d.mts +0 -4612
  226. package/dist/index.d.ts +0 -4612
  227. package/src/Analysis/Passes/Analysis/__tests__/GradientAnalyzer.test.ts +0 -135
  228. package/src/Analysis/Passes/Analysis/__tests__/MergeableColorAnalyzer.test.ts +0 -84
  229. package/src/Analysis/Passes/Analysis/__tests__/RainbowAnalyzer.test.ts +0 -99
  230. package/src/Analysis/Passes/Analysis/__tests__/WaveAnalyzer.test.ts +0 -119
  231. package/src/Analysis/Passes/Decision/__tests__/DefaultDecision.test.ts +0 -179
  232. package/src/Analysis/__tests__/Integration.test.ts +0 -162
  233. package/src/Analysis/__tests__/Pipeline.test.ts +0 -133
  234. package/src/Diff/__tests__/TreeDiffer.test.ts +0 -65
  235. package/src/Incremental/__tests__/IncrementalParser.test.ts +0 -164
  236. package/src/Syntax/__tests__/GreenNode.test.ts +0 -33
  237. package/src/Syntax/__tests__/RedNode.test.ts +0 -81
  238. package/src/Syntax/__tests__/RedNodeStore.test.ts +0 -104
  239. package/src/Tests/ASTOptimizerIdempotence.test.ts +0 -77
  240. package/src/Tests/BlockPatcher.test.ts +0 -437
  241. package/src/Tests/BlockPatcherWindowed.test.ts +0 -364
  242. package/src/Tests/BoxDrawer.test.ts +0 -217
  243. package/src/Tests/BoxRichTitle.test.ts +0 -105
  244. package/src/Tests/Chars500kBenchmark.test.ts +0 -151
  245. package/src/Tests/Chars500kEdits.test.ts +0 -321
  246. package/src/Tests/CollabPositions.test.ts +0 -146
  247. package/src/Tests/CompilerPathProfiling.test.ts +0 -186
  248. package/src/Tests/DOMMorpher.test.ts +0 -142
  249. package/src/Tests/DomPatchPerf.test.ts +0 -60
  250. package/src/Tests/EffectSegments.snapshot.json +0 -616
  251. package/src/Tests/EffectSegments.test.ts +0 -68
  252. package/src/Tests/FindNodeAtOffset.test.ts +0 -65
  253. package/src/Tests/Fuzzer.test.ts +0 -166
  254. package/src/Tests/GreenNodePool.test.ts +0 -153
  255. package/src/Tests/Lexer.test.ts +0 -238
  256. package/src/Tests/LyneMode.test.ts +0 -291
  257. package/src/Tests/ModelCoherence.test.ts +0 -180
  258. package/src/Tests/Partition.test.ts +0 -238
  259. package/src/Tests/PluginTags.test.ts +0 -150
  260. package/src/Tests/ProblematicSection.test.ts +0 -46
  261. package/src/Tests/ProblematicSectionHTML.test.ts +0 -58
  262. package/src/Tests/RedReuse.test.ts +0 -134
  263. package/src/Tests/ReproDelete20k.test.ts +0 -62
  264. package/src/Tests/SemanticValidators.test.ts +0 -136
  265. package/src/Tests/StableNodeIds.test.ts +0 -210
  266. package/src/Tests/StudioColorBloat.test.ts +0 -25
  267. package/src/Tests/StudioDebugText.test.ts +0 -27
  268. package/src/Tests/StudioTrailingChar.test.ts +0 -25
  269. package/src/Tests/StudioValidText.test.ts +0 -25
  270. package/src/Tests/UrlImgBug.test.ts +0 -23
  271. package/src/Tests/VisualBuilderFidelity.test.ts +0 -105
  272. package/src/Tests/referenceDocument.ts +0 -119
  273. package/src/Utils/dom-to-svg.test.ts +0 -86
@@ -12,6 +12,12 @@ import { RedNode } from '../Syntax/RedNode'
12
12
  import { Visitor } from './Visitor'
13
13
  import type { VisitorContext } from './Visitor'
14
14
  import { TagRegistry, type TagDefinition } from '../Model/TagRegistry'
15
+ import {
16
+ toTokenResolver,
17
+ resolveTokenValue,
18
+ type TokenResolverFn,
19
+ type TokenSource,
20
+ } from '../Tokens'
15
21
 
16
22
  /**
17
23
  * Export target for BBCodeExporter.
@@ -21,8 +27,42 @@ import { TagRegistry, type TagDefinition } from '../Model/TagRegistry'
21
27
  */
22
28
  export type ExportTarget = 'osu' | 'miliastry' | 'lyne'
23
29
 
30
+ export interface BBCodeExporterOptions {
31
+ registry?: TagRegistry
32
+ target?: ExportTarget
33
+ tokens?: TokenSource
34
+ resolveTokens?: boolean
35
+ }
36
+
37
+ export type BBCodeExportOptions = {
38
+ target?: ExportTarget
39
+ tokens?: TokenSource
40
+ resolveTokens?: boolean
41
+ }
42
+
43
+ /**
44
+ * Referencia a un token de diseño dentro de un texto: `$nombre`.
45
+ *
46
+ * A nivel de módulo porque el sitio caliente es la rama de nodo TEXTO, la
47
+ * clase de nodo más frecuente de cualquier documento: un literal de expresión
48
+ * regular ahí dentro construye un `RegExp` por nodo. Había además tres copias
49
+ * idénticas del mismo patrón repartidas por el fichero.
50
+ */
51
+ const TOKEN_REF_RE = /\$([a-zA-Z_][a-zA-Z0-9_-]*)/g
52
+
53
+ /** Sustituye cada `$token` por su valor; deja intacto el que no resuelva. */
54
+ function expandTokenRefs(text: string, resolve: TokenResolverFn): string {
55
+ // `indexOf` antes que `replace`: la inmensa mayoría de los textos no lleva
56
+ // ningún `$`, y así no se pone en marcha el motor de expresiones regulares.
57
+ if (text.indexOf('$') === -1) return text
58
+ return text.replace(TOKEN_REF_RE, (match, name: string) => {
59
+ const resolved = resolve(name) ?? resolve(match)
60
+ return resolved !== undefined ? resolved : match
61
+ })
62
+ }
63
+
24
64
  /** Miliastry-native effect tags that osu! doesn't support natively */
25
- const MILIASTRY_INTERNAL_TAGS = new Set(['gradient', 'grow', 'sinewave', 'rainbow'])
65
+ const MILIASTRY_INTERNAL_TAGS = new Set(['gradient', 'grow', 'sinewave', 'rainbow', 'paint'])
26
66
 
27
67
  /**
28
68
  * Tags que existen SOLO en Miliastry (registrados en el TagRegistry) y que
@@ -39,6 +79,9 @@ export const LYNE_ONLY_TAGS = new Set([
39
79
  'boxw', 'wnotice', 'tables', 'table_row', 'table_col', 'table_th', 'gallery', 'columns',
40
80
  'separator', 'scroll', 'sup', 'sub', 'abbr', 'mark', 'kbd', 'tooltip', 'flip',
41
81
  'raw', 'plain', 'guild', 'map', 'align', 'effect', 'anim', 'container', 'style_tag',
82
+ 'neon', 'shimmer', 'glitch', 'typewriter', 'wave', 'fire', 'ice', 'ghost', 'glow',
83
+ 'outline', 'emboss', 'engrave', 'pulse', 'bounce', 'shake', 'levitate', 'fade-in', 'fade-out',
84
+ 'card', 'glass', 'neon-box', 'square', 'row', 'col', 'th',
42
85
  ])
43
86
 
44
87
  /**
@@ -89,11 +132,17 @@ const KIND_TO_TAG_NAME: Record<string, string> = {
89
132
  flower: 'flower',
90
133
  gradient: 'gradient',
91
134
  grow: 'grow',
135
+ rainbow: 'rainbow',
136
+ sinewave: 'sinewave',
137
+ paint: 'paint',
92
138
  align: 'align',
93
139
  tables: 'tables',
94
140
  table_row: 'row',
95
141
  table_col: 'col',
96
142
  table_th: 'th',
143
+ row: 'row',
144
+ col: 'col',
145
+ th: 'th',
97
146
  gallery: 'gallery',
98
147
  columns: 'columns',
99
148
  separator: 'separator',
@@ -111,18 +160,101 @@ const KIND_TO_TAG_NAME: Record<string, string> = {
111
160
  anim: 'anim',
112
161
  container: 'container',
113
162
  style_tag: 'style',
163
+ neon: 'neon',
164
+ shimmer: 'shimmer',
165
+ glitch: 'glitch',
166
+ typewriter: 'typewriter',
167
+ wave: 'wave',
168
+ fire: 'fire',
169
+ ice: 'ice',
170
+ ghost: 'ghost',
171
+ glow: 'glow',
172
+ outline: 'outline',
173
+ emboss: 'emboss',
174
+ engrave: 'engrave',
175
+ pulse: 'pulse',
176
+ bounce: 'bounce',
177
+ shake: 'shake',
178
+ levitate: 'levitate',
179
+ 'fade-in': 'fade-in',
180
+ 'fade-out': 'fade-out',
181
+ card: 'card',
182
+ glass: 'glass',
183
+ 'neon-box': 'neon-box',
184
+ square: 'square',
185
+ }
186
+
187
+
188
+ /**
189
+ * Whether a parsed attribute actually carries a value worth re-emitting.
190
+ *
191
+ * The parser stores an empty string for the attribute of a bare tag, so a
192
+ * plain `[box]` arrives here with `rawTitle: ''` rather than `undefined`.
193
+ * The guards below used to test `!== undefined`, which an empty string
194
+ * passes — exporting `[box]` as `[box=]` and `[quote]` as `[quote=""]`.
195
+ * Both are corruption, not normalization: bare boxes and quotes are
196
+ * ordinary BBCode and must round-trip untouched.
197
+ */
198
+ function hasAttrValue(value: unknown): boolean {
199
+ return value !== undefined && value !== null && String(value) !== ''
114
200
  }
115
201
 
202
+ function normalizeColorToHex(color: string): string {
203
+ if (!color) return color
204
+ const trimmed = color.trim()
205
+ // Un hex ya es canónico: bajarlo a minúsculas reescribía `[color=#FF0000]`
206
+ // del autor en cada exportación, y `Analysis/RoundTrip` fija lo contrario.
207
+ if (trimmed.startsWith('#')) return trimmed
208
+
209
+ const rgbMatch = trimmed.match(/^rgba?\s*\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)(?:\s*,\s*([\d.]+))?\s*\)$/i)
210
+ if (rgbMatch) {
211
+ const r = parseInt(rgbMatch[1], 10).toString(16).padStart(2, '0')
212
+ const g = parseInt(rgbMatch[2], 10).toString(16).padStart(2, '0')
213
+ const b = parseInt(rgbMatch[3], 10).toString(16).padStart(2, '0')
214
+ return `#${r}${g}${b}`.toLowerCase()
215
+ }
216
+
217
+ return trimmed
218
+ }
116
219
 
117
220
  export class BBCodeExporter extends Visitor<string> {
118
221
  private registry: TagRegistry
119
222
  private depth: number = 0
120
223
  private target: ExportTarget
224
+ private tokens?: TokenSource
225
+ private tokenResolver?: TokenResolverFn
226
+ private explicitResolveTokens?: boolean
227
+ /**
228
+ * El resolvedor a aplicar en ESTE recorrido, o `null` si no hay expansión.
229
+ *
230
+ * Se decide una vez en `visit`, no por nodo: `shouldResolveTokens()` da la
231
+ * misma respuesta para todo el documento, y se estaba llamando en la rama
232
+ * de nodo texto, la más frecuente que hay.
233
+ */
234
+ private expandTokens: TokenResolverFn | null = null
121
235
 
122
- constructor(registry: TagRegistry = new TagRegistry(), target: ExportTarget = 'osu') {
236
+ constructor(
237
+ registryOrOptions?: TagRegistry | BBCodeExporterOptions,
238
+ target: ExportTarget = 'osu',
239
+ options?: BBCodeExporterOptions | { tokens?: TokenSource; resolveTokens?: boolean },
240
+ ) {
123
241
  super()
124
- this.registry = registry
125
- this.target = target
242
+ if (registryOrOptions && !(registryOrOptions instanceof TagRegistry) && !('get' in registryOrOptions)) {
243
+ const opts = registryOrOptions as BBCodeExporterOptions
244
+ this.registry = opts.registry ?? new TagRegistry()
245
+ this.target = opts.target ?? 'osu'
246
+ this.tokens = opts.tokens
247
+ this.tokenResolver = toTokenResolver(opts.tokens)
248
+ this.explicitResolveTokens = opts.resolveTokens
249
+ } else {
250
+ this.registry = (registryOrOptions as TagRegistry) ?? new TagRegistry()
251
+ this.target = target
252
+ if (options) {
253
+ this.tokens = options.tokens
254
+ this.tokenResolver = toTokenResolver(options.tokens)
255
+ this.explicitResolveTokens = options.resolveTokens
256
+ }
257
+ }
126
258
  }
127
259
 
128
260
  /**
@@ -132,30 +264,95 @@ export class BBCodeExporter extends Visitor<string> {
132
264
  this.target = target
133
265
  }
134
266
 
267
+ setTokens(tokens?: TokenSource): void {
268
+ this.tokens = tokens
269
+ this.tokenResolver = toTokenResolver(tokens)
270
+ }
271
+
272
+ getTokens(): TokenSource | undefined {
273
+ return this.tokens
274
+ }
275
+
276
+ getTokenResolver(): TokenResolverFn | undefined {
277
+ return this.tokenResolver
278
+ }
279
+
280
+ private shouldResolveTokens(): boolean {
281
+ if (this.explicitResolveTokens !== undefined) {
282
+ return this.explicitResolveTokens
283
+ }
284
+ return this.target === 'osu'
285
+ }
286
+
135
287
  /**
136
288
  * Export a RedNode tree to BBCode.
137
289
  */
138
290
  visit(node: RedNode, context?: VisitorContext): string {
139
291
  if (context) this.context = context
140
292
  this.depth = 0
293
+ this.expandTokens =
294
+ this.tokenResolver !== undefined && this.shouldResolveTokens() ? this.tokenResolver : null
141
295
  return this.exportNode(node)
142
296
  }
143
297
 
144
298
  /**
145
299
  * Export the entire document to BBCode.
146
- * Optionally override the export target for this specific call.
300
+ * Optionally override the export target or options for this specific call.
147
301
  */
148
- export(root: RedNode, target?: ExportTarget): string {
149
- if (target !== undefined) this.target = target
302
+ export(
303
+ root: RedNode,
304
+ targetOrOptions?: ExportTarget | BBCodeExportOptions,
305
+ options?: BBCodeExportOptions,
306
+ ): string {
307
+ if (typeof targetOrOptions === 'string') {
308
+ this.target = targetOrOptions
309
+ if (options?.tokens !== undefined) {
310
+ this.setTokens(options.tokens)
311
+ }
312
+ if (options?.resolveTokens !== undefined) {
313
+ this.explicitResolveTokens = options.resolveTokens
314
+ }
315
+ } else if (targetOrOptions && typeof targetOrOptions === 'object') {
316
+ if (targetOrOptions.target !== undefined) {
317
+ this.target = targetOrOptions.target
318
+ }
319
+ if (targetOrOptions.tokens !== undefined) {
320
+ this.setTokens(targetOrOptions.tokens)
321
+ }
322
+ if (targetOrOptions.resolveTokens !== undefined) {
323
+ this.explicitResolveTokens = targetOrOptions.resolveTokens
324
+ }
325
+ }
150
326
  return this.visit(root)
151
327
  }
152
328
 
329
+ /**
330
+ * Exporta los hijos directos y los concatena.
331
+ *
332
+ * Reemplaza el `children.map(...).join('')`, que asignaba un cierre y un
333
+ * array intermedio de N cadenas en cada nivel del árbol. `HTMLRenderer`
334
+ * hizo este mismo cambio y dejó la nota; el exportador se quedó atrás, y es
335
+ * el camino que corre bajo un límite de 60.000 caracteres.
336
+ */
337
+ private exportChildren(node: RedNode): string {
338
+ const children = node.children
339
+ let out = ''
340
+ for (let i = 0; i < children.length; i++) out += this.exportNode(children[i])
341
+ return out
342
+ }
343
+
153
344
  private exportNode(node: RedNode): string {
345
+ // Un cierre que no cerró nada no vuelve al source. Escribirlo hacía que el
346
+ // siguiente parseo lo leyera otra vez como etiqueta viva, y el documento no
347
+ // convergía al reexportarlo.
348
+ if (node.kind === 'discarded_tag') return ''
349
+
154
350
  const tagDef = this.registry.getByKind(node.kind)
155
351
 
156
352
  // Text nodes
157
353
  if (node.kind === 'text') {
158
354
  let out = node.text
355
+ if (this.expandTokens !== null) out = expandTokenRefs(out, this.expandTokens)
159
356
  const style = node.metadata?.style as Record<string, string> | undefined
160
357
  if (style) {
161
358
  // Envolvemos desde adentro hacia afuera (o al revés, no importa mucho para osu)
@@ -163,8 +360,20 @@ export class BBCodeExporter extends Visitor<string> {
163
360
  if (style.fontStyle === 'italic') out = `[i]${out}[/i]`
164
361
  if (style.textDecoration === 'underline') out = `[u]${out}[/u]`
165
362
  if (style.textDecoration === 'line-through') out = `[s]${out}[/s]`
166
- if (style.color) out = `[color=${style.color}]${out}[/color]`
167
- if (style.fontSize) out = `[size=${style.fontSize}]${out}[/size]`
363
+ if (style.color) {
364
+ let col = style.color
365
+ if (this.shouldResolveTokens() && col.startsWith('$')) {
366
+ col = resolveTokenValue(col, this.tokenResolver)
367
+ }
368
+ out = `[color=${normalizeColorToHex(col)}]${out}[/color]`
369
+ }
370
+ if (style.fontSize) {
371
+ let size = style.fontSize
372
+ if (this.shouldResolveTokens() && size.startsWith('$')) {
373
+ size = resolveTokenValue(size, this.tokenResolver)
374
+ }
375
+ out = `[size=${size}]${out}[/size]`
376
+ }
168
377
  // Se pueden seguir sumando estilos dinámicos
169
378
  }
170
379
  return out
@@ -172,7 +381,7 @@ export class BBCodeExporter extends Visitor<string> {
172
381
 
173
382
  // Document root
174
383
  if (node.kind === 'document') {
175
- return node.children.map(c => this.exportNode(c)).join('')
384
+ return this.exportChildren(node)
176
385
  }
177
386
 
178
387
  // Custom tag handlers from registry
@@ -196,7 +405,7 @@ export class BBCodeExporter extends Visitor<string> {
196
405
  // Default BBCode serialization
197
406
  let tagName = this.kindToTagName(node.kind)
198
407
  const attrs = this.getTagAttributes(node)
199
- const content = node.children.map(c => this.exportNode(c)).join('')
408
+ const content = this.exportChildren(node)
200
409
 
201
410
  if (!tagName) {
202
411
  if (node.kind === 'spacing') return '\n'
@@ -246,38 +455,78 @@ export class BBCodeExporter extends Visitor<string> {
246
455
  if (tag?.properties) {
247
456
  const parts: string[] = []
248
457
  for (const prop of tag.properties) {
249
- const val = node.metadata[prop.name]
458
+ let val = node.metadata[prop.name]
250
459
  if (val !== undefined && val !== null) {
460
+ if (this.shouldResolveTokens() && typeof val === 'string' && val.startsWith('$')) {
461
+ val = resolveTokenValue(val, this.tokenResolver)
462
+ }
251
463
  parts.push(`${prop.name}=${val}`)
252
464
  }
253
465
  }
254
466
  if (parts.length > 0) return `=${parts[0]}`
255
467
  }
256
- } else if (node.kind === 'font_size' && node.metadata.size !== undefined) {
257
- return `=${node.metadata.size}`
258
- } else if (node.kind === 'color' && node.metadata.color !== undefined) {
259
- return `=${node.metadata.color}`
260
- } else if (node.kind === 'font' && node.metadata.font !== undefined) {
261
- return `=${node.metadata.font}`
468
+ } else if (node.kind === 'font_size' && hasAttrValue(node.metadata.size)) {
469
+ let size = String(node.metadata.size)
470
+ if (this.shouldResolveTokens() && size.startsWith('$')) {
471
+ size = resolveTokenValue(size, this.tokenResolver)
472
+ }
473
+ return `=${size}`
474
+ } else if (node.kind === 'color' && hasAttrValue(node.metadata.color)) {
475
+ let color = String(node.metadata.color)
476
+ if (this.shouldResolveTokens() && color.startsWith('$')) {
477
+ color = resolveTokenValue(color, this.tokenResolver)
478
+ }
479
+ return `=${normalizeColorToHex(color)}`
480
+ } else if (node.kind === 'font' && hasAttrValue(node.metadata.font)) {
481
+ let font = String(node.metadata.font)
482
+ if (this.shouldResolveTokens() && font.startsWith('$')) {
483
+ font = resolveTokenValue(font, this.tokenResolver)
484
+ }
485
+ return `=${font}`
262
486
  } else if (node.kind === 'url' && node.metadata.href !== undefined) {
263
487
  return `=${node.metadata.href}`
264
- } else if (node.kind === 'email' && node.metadata.href !== undefined) {
265
- return `=${(node.metadata.href as string).replace('mailto:', '')}`
266
- } else if (node.kind === 'quote' && node.metadata.source !== undefined) {
267
- return `="${node.metadata.source}"`
268
- } else if ((node.kind === 'box' || node.kind === 'boxw' || node.kind === 'spoilerbox') && (node.metadata.rawTitle !== undefined || node.metadata.title !== undefined)) {
269
- const titleVal = node.metadata.rawTitle !== undefined ? node.metadata.rawTitle : node.metadata.title
488
+ } else if (node.kind === 'email' && hasAttrValue(node.metadata.href)) {
489
+ // The parser prefixes the address with `mailto:`, so a bare `[email]`
490
+ // arrives as exactly that prefix and nothing else test the address.
491
+ const address = (node.metadata.href as string).replace('mailto:', '')
492
+ return address === '' ? '' : `=${address}`
493
+ } else if (node.kind === 'quote' && hasAttrValue(node.metadata.source)) {
494
+ let src = String(node.metadata.source)
495
+ if (this.expandTokens !== null) src = expandTokenRefs(src, this.expandTokens)
496
+ return `="${src}"`
497
+ } else if (node.kind === 'box' || node.kind === 'boxw' || node.kind === 'spoilerbox') {
498
+ // `rawTitle` is the source spelling; `title` is the parsed one, which
499
+ // falls back to a synthetic "Box"/"Spoiler" when the tag is bare. Only
500
+ // the raw form can be trusted to reproduce the input, so it wins.
501
+ const raw = node.metadata.rawTitle
502
+ let titleVal = String((raw !== undefined ? raw : node.metadata.title) ?? '')
503
+ if (this.expandTokens !== null) titleVal = expandTokenRefs(titleVal, this.expandTokens)
270
504
  // `[box=Title:#hex]`: el color se guardó aparte en metadata; se vuelve
271
505
  // a añadir aquí para que el round-trip no pierda el sufijo.
272
- const color = node.metadata.color as string | undefined
506
+ let color = node.metadata.color as string | undefined
507
+ if (this.shouldResolveTokens() && color && color.startsWith('$')) {
508
+ color = resolveTokenValue(color, this.tokenResolver)
509
+ }
510
+ // A bare `[box]` has neither, and must stay bare.
511
+ if (titleVal === '' && !color) return ''
273
512
  return `=${titleVal}${color ? `:${color}` : ''}`
274
513
  } else if ((node.kind === 'notice' || node.kind === 'wnotice') && node.metadata.color !== undefined) {
275
- return `=${node.metadata.color}`
514
+ let color = String(node.metadata.color)
515
+ if (this.shouldResolveTokens() && color.startsWith('$')) {
516
+ color = resolveTokenValue(color, this.tokenResolver)
517
+ }
518
+ return `=${color}`
276
519
  } else if (node.kind === 'tables' && node.metadata.variant !== undefined) {
277
- const color = node.metadata.color as string | undefined
520
+ let color = node.metadata.color as string | undefined
521
+ if (this.shouldResolveTokens() && color && color.startsWith('$')) {
522
+ color = resolveTokenValue(color, this.tokenResolver)
523
+ }
278
524
  return `=${node.metadata.variant}${color ? `:${color}` : ''}`
279
525
  } else if (node.kind === 'columns' && node.metadata.columns !== undefined) {
280
- const color = node.metadata.color as string | undefined
526
+ let color = node.metadata.color as string | undefined
527
+ if (this.shouldResolveTokens() && color && color.startsWith('$')) {
528
+ color = resolveTokenValue(color, this.tokenResolver)
529
+ }
281
530
  return `=${node.metadata.columns}${color ? `:${color}` : ''}`
282
531
  } else if (node.kind === 'separator' && node.metadata.variant !== undefined) {
283
532
  return `=${node.metadata.variant}`
@@ -294,22 +543,60 @@ export class BBCodeExporter extends Visitor<string> {
294
543
  } else if (node.kind === 'align' && node.metadata.align !== undefined) {
295
544
  return `=${node.metadata.align}`
296
545
  } else if (node.kind === 'effect' && node.metadata.effectType !== undefined) {
297
- return `=${node.metadata.effectType}`
546
+ let color = node.metadata.color as string | undefined
547
+ if (this.shouldResolveTokens() && color && color.startsWith('$')) {
548
+ color = resolveTokenValue(color, this.tokenResolver)
549
+ }
550
+ return `=${node.metadata.effectType}${color ? `:${color}` : ''}`
298
551
  } else if (node.kind === 'image' && node.metadata.imgAttr !== undefined) {
299
552
  return `=${node.metadata.imgAttr}`
300
553
  } else if (node.kind === 'anim' && node.metadata.animType !== undefined) {
301
554
  return `=${node.metadata.animType}`
302
555
  } else if (node.kind === 'container' && node.metadata.containerType !== undefined) {
303
- return `=${node.metadata.containerType}`
556
+ let color = node.metadata.color as string | undefined
557
+ if (this.shouldResolveTokens() && color && color.startsWith('$')) {
558
+ color = resolveTokenValue(color, this.tokenResolver)
559
+ }
560
+ return `=${node.metadata.containerType}${color ? `:${color}` : ''}`
304
561
  } else if (node.kind === 'style_tag' && node.metadata.style !== undefined) {
305
- return `=${node.metadata.style}`
562
+ let style = String(node.metadata.style)
563
+ if (this.shouldResolveTokens()) {
564
+ style = style.replace(/\$([a-zA-Z0-9_.-]+)/g, (match) => {
565
+ return resolveTokenValue(match, this.tokenResolver)
566
+ })
567
+ }
568
+ return `=${style}`
569
+ } else if (hasAttrValue(node.metadata.value)) {
570
+ let val = String(node.metadata.value)
571
+ if (this.shouldResolveTokens() && val.startsWith('$')) {
572
+ val = resolveTokenValue(val, this.tokenResolver)
573
+ }
574
+ return `=${val}`
575
+ } else if (hasAttrValue(node.metadata.raw)) {
576
+ let raw = String(node.metadata.raw)
577
+ if (this.shouldResolveTokens() && raw.startsWith('$')) {
578
+ raw = resolveTokenValue(raw, this.tokenResolver)
579
+ }
580
+ return `=${raw}`
306
581
  }
307
582
  }
308
583
 
309
584
  // Fallback: check node text for attribute info
310
585
  const text = node.text || ''
311
586
  if (text.startsWith('=') || text.startsWith(' ')) {
312
- // The text is exactly the attributes string (e.g. "=50" or " min=50 max=200")
587
+ if (node.kind === 'color' && text.startsWith('=')) {
588
+ let col = text.slice(1)
589
+ if (this.shouldResolveTokens() && col.startsWith('$')) {
590
+ col = resolveTokenValue(col, this.tokenResolver)
591
+ }
592
+ return `=${normalizeColorToHex(col)}`
593
+ }
594
+ if (this.shouldResolveTokens() && text.startsWith('=')) {
595
+ let val = text.slice(1)
596
+ if (val.startsWith('$')) {
597
+ return `=${resolveTokenValue(val, this.tokenResolver)}`
598
+ }
599
+ }
313
600
  return text
314
601
  }
315
602
 
@@ -67,6 +67,8 @@ export interface PatchBlocksOptions {
67
67
  * path on small documents.
68
68
  */
69
69
  minWindowedBlocks?: number
70
+ /** If true, forces a full rebuild of the container instead of incremental patching. */
71
+ forceRebuild?: boolean
70
72
  }
71
73
 
72
74
  export interface PatchBlocksStats {
@@ -143,6 +145,53 @@ interface PatchCache {
143
145
 
144
146
  const caches = new WeakMap<HTMLElement, PatchCache>()
145
147
 
148
+ /**
149
+ * ¿Está encendido el volcado verboso? (`globalThis.__BP_DEBUG__ = true`)
150
+ *
151
+ * Imprime por consola el detalle de la reconciliación por ventana. Es caro:
152
+ * sirve para leer un caso a mano, NO para medir con él encendido.
153
+ */
154
+ function BP_DEBUG(): boolean {
155
+ return (globalThis as { __BP_DEBUG__?: boolean }).__BP_DEBUG__ === true
156
+ }
157
+
158
+ /**
159
+ * Deja constancia de POR QUÉ `patchBlocksInto` tomó el camino que tomó, en
160
+ * `globalThis.__BP_LAST__` (`globalThis.__BP_TRACE__ = true` para encenderlo).
161
+ *
162
+ * Va aparte del volcado verboso a propósito: esta traza es UNA asignación por
163
+ * parcheo, así que se puede dejar encendida MIENTRAS SE MIDE sin falsear la
164
+ * medida, que es justo cuando hace falta.
165
+ *
166
+ * Existe porque "¿por qué esto no usó el camino incremental?" es cara de
167
+ * contestar desde fuera: el bundle está minificado, `patchBlocksInto` devuelve
168
+ * `mode` pero no el motivo, y quien lo llama (el preview) tira las
169
+ * estadísticas. Diagnosticar la caída a `fullRebuild` por tecla en el
170
+ * documento de 547 KB costó desminificar a mano una función dentro de una
171
+ * línea de 75.000 caracteres; con esto se lee `__BP_LAST__` y ya está.
172
+ *
173
+ * Coste apagada: una comparación de identidad por parcheo.
174
+ */
175
+ function bpTrace(reason: Record<string, unknown>): void {
176
+ if ((globalThis as { __BP_TRACE__?: boolean }).__BP_TRACE__ === true) {
177
+ ;(globalThis as { __BP_LAST__?: unknown }).__BP_LAST__ = reason
178
+ }
179
+ }
180
+
181
+ /**
182
+ * Evict `container` from the patch cache so the next patch rebuilds afresh.
183
+ */
184
+ export function clearPatchCache(container: HTMLElement): void {
185
+ // Contador de invalidaciones bajo la misma traza barata: "cuántas veces se
186
+ // tira la caché por pulsación" es la pregunta que destapó el fullRebuild por
187
+ // tecla, y no se puede contestar desde fuera del módulo.
188
+ if ((globalThis as { __BP_TRACE__?: boolean }).__BP_TRACE__ === true) {
189
+ const g = globalThis as { __BP_CLEARS__?: number }
190
+ g.__BP_CLEARS__ = (g.__BP_CLEARS__ ?? 0) + 1
191
+ }
192
+ caches.delete(container)
193
+ }
194
+
146
195
  const defaultRenderer = new HTMLRenderer()
147
196
 
148
197
  function getCache(container: HTMLElement): PatchCache {
@@ -607,10 +656,15 @@ function reconcileWindowed(
607
656
 
608
657
  const oldRuns = cache.lastRuns
609
658
 
610
- const DBG = (globalThis as { __BP_DEBUG__?: boolean }).__BP_DEBUG__ === true
659
+ const DBG = BP_DEBUG()
611
660
  const dbg = (...a: unknown[]): void => {
612
661
  if (DBG) console.log('[BP]', ...a)
613
662
  }
663
+ // OJO: `dbg(...)` evalúa sus argumentos ANTES de entrar, así que las
664
+ // llamadas que construyen algo —`.map()`, plantillas, `slice`— van dentro
665
+ // de un `if (DBG)`. Este es el camino de cada pulsación: los cuatro
666
+ // volcados de abajo recorrían la ventana entera y construían una cadena por
667
+ // ejecución, con la depuración apagada, para tirarlo todo.
614
668
 
615
669
 
616
670
  // ── Old window (OLD coordinates). ──────────────────────────────────────
@@ -749,14 +803,22 @@ function reconcileWindowed(
749
803
  const oldWinRunsFinal = oldRuns.slice(oldWinFrom2, oldWinTo)
750
804
  const newWinRunsFinal = newWinRuns
751
805
 
752
- dbg('change', { start: change.start, end: change.end, endOld: change.endOld })
753
- dbg('oldWin', oldWinFrom2, oldWinTo, 'keys', oldWinRunsFinal.map((r) => `${r.key}:${r.node.kind}`).slice(0, 40))
754
- dbg('newWin', fromNew, toNew, 'keys', newWinRunsFinal.map((r) => `${r.key}:${r.node.kind}`).slice(0, 40))
755
- dbg('newWinKeySet', [...newWinRunsFinal.map((r) => r.key).slice(0, 40)])
806
+ if (DBG) {
807
+ dbg('change', { start: change.start, end: change.end, endOld: change.endOld })
808
+ dbg('oldWin', oldWinFrom2, oldWinTo, 'keys', oldWinRunsFinal.map((r) => `${r.key}:${r.node.kind}`).slice(0, 40))
809
+ dbg('newWin', fromNew, toNew, 'keys', newWinRunsFinal.map((r) => `${r.key}:${r.node.kind}`).slice(0, 40))
810
+ dbg('newWinKeySet', [...newWinRunsFinal.map((r) => r.key).slice(0, 40)])
811
+ }
756
812
 
757
813
  // DOM anchor at the window start, and the window's old nodes.
758
- let anchorNode: Node | null = container.firstChild
759
- for (let k = 0; k < oldWinFrom2; k++) anchorNode = anchorNode?.nextSibling ?? null
814
+ // Indexed, not walked. `childNodes` is a live NodeList with indexed access,
815
+ // so asking for the window's first node is one lookup; stepping to it one
816
+ // `nextSibling` at a time was O(blocks before the edit) and, on a document
817
+ // of 30.000 top-level blocks, the single largest term left in this
818
+ // function's bookkeeping — 11 ms per keystroke, more than the reconcile it
819
+ // was preparing for. The two are exactly equivalent: the k-th child is the
820
+ // k-th child.
821
+ const anchorNode: Node | null = container.childNodes[oldWinFrom2] ?? null
760
822
  const oldWinNodes: Node[] = []
761
823
  let node: Node | null = anchorNode
762
824
  for (let i = oldWinFrom2; i < oldWinTo; i++) {
@@ -800,7 +862,7 @@ function reconcileWindowed(
800
862
  const k = oldWinRunsFinal[i].key
801
863
  if (newKeySet.has(k) || newByNode.has(oldWinRunsFinal[i].node)) continue
802
864
  const el = oldWinNodes[i]
803
- dbg('orphan?', k, oldWinRunsFinal[i].node.kind, 'el=', el ? (el.nodeType === 1 ? (el as Element).tagName : `text:'${String((el as Text).data ?? '').slice(0, 20)}'`) : 'null')
865
+ if (DBG) dbg('orphan?', k, oldWinRunsFinal[i].node.kind, 'el=', el ? (el.nodeType === 1 ? (el as Element).tagName : `text:'${String((el as Text).data ?? '').slice(0, 20)}'`) : 'null')
804
866
  if (el && !removed.has(el)) {
805
867
  removed.add(el)
806
868
  container.removeChild(el)
@@ -828,7 +890,7 @@ function reconcileWindowed(
828
890
  const prev = oldRunByKey.get(run.key) ?? oldRunByNode.get(run.node)
829
891
  const element = oldByKey.get(run.key) ?? oldByNode.get(run.node)
830
892
 
831
- dbg('WALK', run.key, run.node.kind, 'element=', element ? (element.nodeType === 1 ? (element as Element).tagName : 'text') : 'NEW', 'cursor=', cursor ? (cursor.nodeType === 1 ? (cursor as Element).tagName : 'text') : 'null')
893
+ if (DBG) dbg('WALK', run.key, run.node.kind, 'element=', element ? (element.nodeType === 1 ? (element as Element).tagName : 'text') : 'NEW', 'cursor=', cursor ? (cursor.nodeType === 1 ? (cursor as Element).tagName : 'text') : 'null')
832
894
 
833
895
  if (!element) {
834
896
  container.insertBefore(nodeFromHtml(run.html), cursor)
@@ -858,7 +920,7 @@ function reconcileWindowed(
858
920
  // the first suffix node. Leftovers of runs that rendered to a different
859
921
  // node count sit between the walk's end and the suffix — drop them.
860
922
  let tail = cursor
861
- dbg('tail-safety: cursor=', cursor ? (cursor.nodeType === 1 ? (cursor as Element).tagName : `text:'${String((cursor as Text).data ?? '').slice(0, 15)}'`) : 'null', 'afterWindow=', afterWindow ? (afterWindow.nodeType === 1 ? (afterWindow as Element).tagName : `text:'${String((afterWindow as Text).data ?? '').slice(0, 15)}'`) : 'null')
923
+ if (DBG) dbg('tail-safety: cursor=', cursor ? (cursor.nodeType === 1 ? (cursor as Element).tagName : `text:'${String((cursor as Text).data ?? '').slice(0, 15)}'`) : 'null', 'afterWindow=', afterWindow ? (afterWindow.nodeType === 1 ? (afterWindow as Element).tagName : `text:'${String((afterWindow as Text).data ?? '').slice(0, 15)}'`) : 'null')
862
924
  let tailCount = 0
863
925
  while (tail !== afterWindow) {
864
926
  if (!tail) break
@@ -923,9 +985,15 @@ export function patchBlocksInto(
923
985
  return { mode: 'full', total: 0, patched: 0 }
924
986
  }
925
987
 
988
+ if (options.forceRebuild) {
989
+ bpTrace({ why: 'forceRebuild' })
990
+ return fullRebuild(container, rootNode, renderer, cache)
991
+ }
992
+
926
993
  // DOM out of sync with the cache (a host replaced the innerHTML behind our
927
994
  // back, or a run rendered to a different node count than last time).
928
995
  if (container.childNodes.length !== cache.lastRuns.length) {
996
+ bpTrace({ why: 'desync', kids: container.childNodes.length, runs: cache.lastRuns.length })
929
997
  return fullRebuild(container, rootNode, renderer, cache)
930
998
  }
931
999
 
@@ -938,7 +1006,13 @@ export function patchBlocksInto(
938
1006
  undefined
939
1007
  if (change) {
940
1008
  const windowed = reconcileWindowed(container, rootNode, change, renderer, cache, options)
941
- if (windowed) return windowed
1009
+ if (windowed) {
1010
+ bpTrace({ why: 'windowed' })
1011
+ return windowed
1012
+ }
1013
+ bpTrace({ why: 'windowed-declinado' })
1014
+ } else {
1015
+ bpTrace({ why: 'sin-changeRange' })
942
1016
  }
943
1017
 
944
1018
  const blocks = rootNode.children
@@ -956,8 +1030,10 @@ export function patchBlocksInto(
956
1030
  oldSet.add(k)
957
1031
  }
958
1032
  if (added >= cache.lastKeys.length) {
1033
+ bpTrace({ why: 'added>=lastKeys', added, lastKeys: cache.lastKeys.length })
959
1034
  return fullRebuild(container, rootNode, renderer, cache)
960
1035
  }
961
1036
 
1037
+ bpTrace({ why: 'keyed' })
962
1038
  return reconcileKeyed(container, rootNode, keys, renderer, cache, options)
963
1039
  }