@miliastry/quasar 1.0.6 → 1.2.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 (278) hide show
  1. package/README.md +1 -1
  2. package/dist/Visuals/lyne.css +374 -9
  3. package/dist/Visuals/osu.css +393 -11
  4. package/dist/index.js +10470 -3336
  5. package/dist/index.mjs +10336 -3337
  6. package/dist/types/Analysis/Contracts/AnalysisReport.d.ts +22 -0
  7. package/dist/types/Analysis/Contracts/Contribution.d.ts +68 -0
  8. package/dist/types/Analysis/Contracts/Pass.d.ts +59 -0
  9. package/dist/types/Analysis/Contracts/PipelineContext.d.ts +34 -0
  10. package/dist/types/Analysis/Passes/Analysis/ColorUsageAnalyzer.d.ts +56 -0
  11. package/dist/types/Analysis/Passes/Analysis/GradientAnalyzer.d.ts +111 -0
  12. package/dist/types/Analysis/Passes/Analysis/MergeableColorAnalyzer.d.ts +26 -0
  13. package/dist/types/Analysis/Passes/Analysis/RainbowAnalyzer.d.ts +47 -0
  14. package/dist/types/Analysis/Passes/Analysis/SymbolAnalyzer.d.ts +89 -0
  15. package/dist/types/Analysis/Passes/Analysis/WaveAnalyzer.d.ts +44 -0
  16. package/dist/types/Analysis/Passes/Analysis/__tests__/GradientAnalyzer.test.d.ts +4 -0
  17. package/dist/types/Analysis/Passes/Analysis/__tests__/MergeableColorAnalyzer.test.d.ts +4 -0
  18. package/dist/types/Analysis/Passes/Analysis/__tests__/RainbowAnalyzer.test.d.ts +4 -0
  19. package/dist/types/Analysis/Passes/Analysis/__tests__/WaveAnalyzer.test.d.ts +4 -0
  20. package/dist/types/Analysis/Passes/Decision/DefaultDecision.d.ts +30 -0
  21. package/dist/types/Analysis/Passes/Decision/PaletteRemapDecision.d.ts +169 -0
  22. package/dist/types/Analysis/Passes/Decision/__tests__/DefaultDecision.test.d.ts +4 -0
  23. package/dist/types/Analysis/Passes/Transform/CollapseGradientTransform.d.ts +37 -0
  24. package/dist/types/Analysis/Passes/Transform/MergeColorsTransform.d.ts +28 -0
  25. package/dist/types/Analysis/Passes/Transform/RainbowCollapseTransform.d.ts +19 -0
  26. package/dist/types/Analysis/Passes/Transform/WaveCollapseTransform.d.ts +20 -0
  27. package/dist/types/Analysis/Passes/Utility/CharacterCountAnalyzer.d.ts +19 -0
  28. package/dist/types/Analysis/Pipeline/Pipeline.d.ts +69 -0
  29. package/dist/types/Analysis/Pipeline/PipelineBuilder.d.ts +31 -0
  30. package/dist/types/Analysis/Pipeline/PipelineStage.d.ts +17 -0
  31. package/dist/types/Analysis/Utils/color-utils.d.ts +60 -0
  32. package/dist/types/Analysis/__tests__/Integration.test.d.ts +8 -0
  33. package/dist/types/Analysis/__tests__/PaletteRemap.test.d.ts +14 -0
  34. package/dist/types/Analysis/__tests__/Pipeline.test.d.ts +10 -0
  35. package/dist/types/Analysis/__tests__/RoundTrip.test.d.ts +30 -0
  36. package/dist/types/Analysis/__tests__/SymbolAnalyzer.test.d.ts +11 -0
  37. package/dist/types/Analysis/index.d.ts +40 -0
  38. package/dist/types/BBCode/BBCodeDocumentModel.d.ts +102 -0
  39. package/dist/types/BBCode/BBCodeToGreenNode.d.ts +111 -0
  40. package/dist/types/BBCode/Parser.d.ts +78 -0
  41. package/dist/types/BBCode/index.d.ts +4 -0
  42. package/dist/types/Collab/positions.d.ts +55 -0
  43. package/dist/types/Commands/Command.d.ts +37 -0
  44. package/dist/types/Commands/CommandRegistry.d.ts +48 -0
  45. package/dist/types/Commands/DeleteNode.d.ts +7 -0
  46. package/dist/types/Commands/InsertText.d.ts +8 -0
  47. package/dist/types/Commands/SplitMerge.d.ts +9 -0
  48. package/dist/types/Commands/WrapInTag.d.ts +8 -0
  49. package/dist/types/Commands/index.d.ts +6 -0
  50. package/dist/types/Diff/TreeDiffer.d.ts +52 -0
  51. package/dist/types/Diff/__tests__/TreeDiffer.test.d.ts +1 -0
  52. package/dist/types/Diff/index.d.ts +2 -0
  53. package/dist/types/Edits/EditPlan.d.ts +144 -0
  54. package/dist/types/Edits/Optimizer.d.ts +78 -0
  55. package/dist/types/Edits/Rules/Rule.d.ts +91 -0
  56. package/dist/types/Edits/Rules/dropEmptyTags.d.ts +40 -0
  57. package/dist/types/Edits/Rules/dropRedundantNesting.d.ts +59 -0
  58. package/dist/types/Edits/Rules/index.d.ts +9 -0
  59. package/dist/types/Edits/Rules/mergeAdjacent.d.ts +93 -0
  60. package/dist/types/Edits/Rules/reorderWrappers.d.ts +42 -0
  61. package/dist/types/Edits/Rules/shortenHex.d.ts +30 -0
  62. package/dist/types/Edits/Rules/tagValue.d.ts +56 -0
  63. package/dist/types/Edits/Rules/unwrapInvisibleColor.d.ts +47 -0
  64. package/dist/types/Edits/applyEdits.d.ts +36 -0
  65. package/dist/types/Edits/index.d.ts +6 -0
  66. package/dist/types/Events/EventBus.d.ts +103 -0
  67. package/dist/types/Events/index.d.ts +2 -0
  68. package/dist/types/Formatter/Formatter.d.ts +32 -0
  69. package/dist/types/Formatter/index.d.ts +2 -0
  70. package/dist/types/HTML/HTMLDocumentModel.d.ts +13 -0
  71. package/dist/types/HTML/HTMLToGreenNode.d.ts +4 -0
  72. package/dist/types/Incremental/BracketIndex.d.ts +101 -0
  73. package/dist/types/Incremental/ChangeTracker.d.ts +69 -0
  74. package/dist/types/Incremental/IncrementalParser.d.ts +246 -0
  75. package/dist/types/Incremental/__tests__/BracketIndex.test.d.ts +1 -0
  76. package/dist/types/Incremental/__tests__/IncrementalParser.test.d.ts +1 -0
  77. package/dist/types/Incremental/index.d.ts +4 -0
  78. package/dist/types/Lexer/BBCodeLexer.d.ts +74 -0
  79. package/dist/types/Lexer/Lexer.d.ts +54 -0
  80. package/dist/types/Lexer/index.d.ts +4 -0
  81. package/dist/types/Linter/Linter.d.ts +58 -0
  82. package/dist/types/Linter/index.d.ts +2 -0
  83. package/dist/types/Markdown/MarkdownAST.d.ts +122 -0
  84. package/dist/types/Markdown/MarkdownDocumentModel.d.ts +15 -0
  85. package/dist/types/Markdown/MarkdownLexer.d.ts +155 -0
  86. package/dist/types/Markdown/MarkdownParser.d.ts +42 -0
  87. package/dist/types/Markdown/MarkdownToGreenNode.d.ts +6 -0
  88. package/dist/types/MilHibri/MilHibriDocumentModel.d.ts +21 -0
  89. package/dist/types/Model/DocumentModel.d.ts +318 -0
  90. package/dist/types/Model/NodeFactory.d.ts +29 -0
  91. package/dist/types/Model/TagRegistry.d.ts +165 -0
  92. package/dist/types/Model/index.d.ts +5 -0
  93. package/dist/types/Plugins/PluginAPI.d.ts +44 -0
  94. package/dist/types/Plugins/PluginRegistry.d.ts +81 -0
  95. package/dist/types/Plugins/index.d.ts +3 -0
  96. package/dist/types/Queries/QueryEngine.d.ts +32 -0
  97. package/dist/types/Queries/index.d.ts +1 -0
  98. package/dist/types/Reconciler/SurgicalReconciler.d.ts +23 -0
  99. package/dist/types/RenderPipeline/RenderPipeline.d.ts +46 -0
  100. package/dist/types/RenderPipeline/RenderTree.d.ts +55 -0
  101. package/dist/types/RenderPipeline/index.d.ts +4 -0
  102. package/dist/types/Repair/NestingRepair.d.ts +48 -0
  103. package/dist/types/Repair/index.d.ts +1 -0
  104. package/dist/types/Semantic/SemanticAnalyzer.d.ts +321 -0
  105. package/dist/types/Semantic/index.d.ts +2 -0
  106. package/dist/types/Symbols/SymbolTable.d.ts +47 -0
  107. package/dist/types/Symbols/index.d.ts +1 -0
  108. package/dist/types/Syntax/GreenNode.d.ts +154 -0
  109. package/dist/types/Syntax/GreenNodePool.d.ts +134 -0
  110. package/dist/types/Syntax/NodeMatcher.d.ts +75 -0
  111. package/dist/types/Syntax/RedNode.d.ts +280 -0
  112. package/dist/types/Syntax/RedNodeStore.d.ts +116 -0
  113. package/dist/types/Syntax/TreeBuilder.d.ts +54 -0
  114. package/dist/types/Syntax/__tests__/GreenNode.test.d.ts +1 -0
  115. package/dist/types/Syntax/__tests__/RedNode.test.d.ts +1 -0
  116. package/dist/types/Syntax/__tests__/RedNodeStore.test.d.ts +1 -0
  117. package/dist/types/Syntax/greenEdit.d.ts +64 -0
  118. package/dist/types/Syntax/hash.d.ts +17 -0
  119. package/dist/types/Syntax/index.d.ts +8 -0
  120. package/dist/types/Syntax/nodeAttr.d.ts +63 -0
  121. package/dist/types/Syntax/partition.d.ts +67 -0
  122. package/dist/types/Syntax/preserveNodeIds.d.ts +47 -0
  123. package/dist/types/Tokens/TokenResolver.d.ts +25 -0
  124. package/dist/types/Tokens/index.d.ts +1 -0
  125. package/dist/types/Transactions/Transaction.d.ts +36 -0
  126. package/dist/types/Transactions/UndoManager.d.ts +63 -0
  127. package/dist/types/Transactions/index.d.ts +3 -0
  128. package/dist/types/Transformers/ASTOptimizer.d.ts +34 -0
  129. package/dist/types/Transformers/GradientTransformer.d.ts +18 -0
  130. package/dist/types/Transformers/GrowTransformer.d.ts +16 -0
  131. package/dist/types/Transformers/RainbowTransformer.d.ts +17 -0
  132. package/dist/types/Transformers/SineWaveTransformer.d.ts +17 -0
  133. package/dist/types/Transformers/Transformer.d.ts +17 -0
  134. package/dist/types/Types/core.d.ts +104 -0
  135. package/dist/types/Types/diagnostics.d.ts +85 -0
  136. package/dist/types/Types/index.d.ts +11 -0
  137. package/dist/types/Types/operations.d.ts +109 -0
  138. package/dist/types/Types/queries.d.ts +67 -0
  139. package/dist/types/Types/symbols.d.ts +42 -0
  140. package/dist/types/Types/tokens.d.ts +75 -0
  141. package/dist/types/Utils/BBCodeGenerator.d.ts +21 -0
  142. package/dist/types/Utils/ColorMath.d.ts +92 -0
  143. package/dist/types/Utils/EffectMath.d.ts +643 -0
  144. package/dist/types/Utils/TagVocabulary.d.ts +104 -0
  145. package/dist/types/Utils/TagVocabulary.test.d.ts +1 -0
  146. package/dist/types/Utils/color.d.ts +29 -0
  147. package/dist/types/Utils/dom-to-svg.d.ts +28 -0
  148. package/dist/types/Utils/dom-to-svg.test.d.ts +1 -0
  149. package/dist/types/Utils/treeTransformers.d.ts +160 -0
  150. package/dist/types/Visitors/BBBlocksExporter.d.ts +27 -0
  151. package/dist/types/Visitors/BBCodeExporter.d.ts +90 -0
  152. package/dist/types/Visitors/BlockPatcher.d.ts +91 -0
  153. package/dist/types/Visitors/DOMMorpher.d.ts +19 -0
  154. package/dist/types/Visitors/HTMLRenderer.d.ts +492 -0
  155. package/dist/types/Visitors/JSONExporter.d.ts +42 -0
  156. package/dist/types/Visitors/MarkdownExporter.d.ts +28 -0
  157. package/dist/types/Visitors/SVGRenderer.d.ts +16 -0
  158. package/dist/types/Visitors/TiptapExporter.d.ts +40 -0
  159. package/dist/types/Visitors/Visitor.d.ts +35 -0
  160. package/dist/types/Visitors/index.d.ts +9 -0
  161. package/dist/types/Visuals/BoxDrawer.d.ts +54 -0
  162. package/dist/types/Visuals/LyneAudio.d.ts +8 -0
  163. package/dist/types/Visuals/index.d.ts +39 -0
  164. package/dist/types/index.d.ts +155 -0
  165. package/package.json +14 -10
  166. package/src/Analysis/Passes/Analysis/ColorUsageAnalyzer.ts +103 -0
  167. package/src/Analysis/Passes/Analysis/GradientAnalyzer.ts +294 -20
  168. package/src/Analysis/Passes/Analysis/SymbolAnalyzer.ts +257 -0
  169. package/src/Analysis/Passes/Decision/PaletteRemapDecision.ts +425 -0
  170. package/src/Analysis/Pipeline/Pipeline.ts +15 -2
  171. package/src/Analysis/index.ts +8 -2
  172. package/src/BBCode/BBCodeDocumentModel.ts +39 -1
  173. package/src/BBCode/BBCodeToGreenNode.ts +18 -12
  174. package/src/BBCode/Parser.ts +38 -1
  175. package/src/Edits/EditPlan.ts +290 -0
  176. package/src/Edits/Optimizer.ts +185 -0
  177. package/src/Edits/Rules/Rule.ts +145 -0
  178. package/src/Edits/Rules/dropEmptyTags.ts +110 -0
  179. package/src/Edits/Rules/dropRedundantNesting.ts +120 -0
  180. package/src/Edits/Rules/index.ts +28 -0
  181. package/src/Edits/Rules/mergeAdjacent.ts +224 -0
  182. package/src/Edits/Rules/reorderWrappers.ts +154 -0
  183. package/src/Edits/Rules/shortenHex.ts +79 -0
  184. package/src/Edits/Rules/tagValue.ts +105 -0
  185. package/src/Edits/Rules/unwrapInvisibleColor.ts +93 -0
  186. package/src/Edits/applyEdits.ts +54 -0
  187. package/src/Edits/index.ts +22 -0
  188. package/src/Events/EventBus.ts +18 -0
  189. package/src/HTML/HTMLToGreenNode.ts +193 -37
  190. package/src/Incremental/BracketIndex.ts +249 -0
  191. package/src/Incremental/IncrementalParser.ts +464 -41
  192. package/src/Lexer/BBCodeLexer.ts +25 -3
  193. package/src/Lexer/index.ts +1 -1
  194. package/src/Markdown/MarkdownAST.ts +65 -2
  195. package/src/Markdown/MarkdownLexer.ts +69 -14
  196. package/src/Markdown/MarkdownParser.ts +749 -62
  197. package/src/Markdown/MarkdownToGreenNode.ts +148 -27
  198. package/src/MilHibri/MilHibriDocumentModel.ts +56 -0
  199. package/src/Model/DocumentModel.ts +138 -5
  200. package/src/Model/TagRegistry.ts +129 -134
  201. package/src/Reconciler/SurgicalReconciler.ts +460 -0
  202. package/src/Repair/NestingRepair.ts +116 -0
  203. package/src/Repair/index.ts +1 -0
  204. package/src/Semantic/SemanticAnalyzer.ts +1845 -65
  205. package/src/Syntax/GreenNodePool.ts +12 -12
  206. package/src/Syntax/RedNode.ts +17 -3
  207. package/src/Syntax/RedNodeStore.ts +9 -1
  208. package/src/Syntax/nodeAttr.ts +172 -0
  209. package/src/Tokens/TokenResolver.ts +55 -0
  210. package/src/Tokens/index.ts +1 -0
  211. package/src/Types/core.ts +15 -0
  212. package/src/Utils/ColorMath.ts +276 -55
  213. package/src/Utils/EffectMath.ts +2462 -0
  214. package/src/Utils/TagVocabulary.ts +141 -0
  215. package/src/Utils/dom-to-svg.ts +28 -12
  216. package/src/Visitors/BBBlocksExporter.ts +15 -1
  217. package/src/Visitors/BBCodeExporter.ts +365 -32
  218. package/src/Visitors/BlockPatcher.ts +123 -22
  219. package/src/Visitors/DOMMorpher.ts +44 -28
  220. package/src/Visitors/HTMLRenderer.ts +771 -205
  221. package/src/Visitors/MarkdownExporter.ts +98 -41
  222. package/src/Visitors/TiptapExporter.ts +1 -0
  223. package/src/Visitors/index.ts +1 -1
  224. package/src/Visuals/BoxDrawer.ts +56 -10
  225. package/src/Visuals/LyneAudio.ts +346 -0
  226. package/src/Visuals/index.ts +8 -4
  227. package/src/Visuals/lyne.css +374 -9
  228. package/src/Visuals/osu.css +393 -11
  229. package/src/index.ts +178 -8
  230. package/dist/index.d.mts +0 -4612
  231. package/dist/index.d.ts +0 -4612
  232. package/src/Analysis/Passes/Analysis/__tests__/GradientAnalyzer.test.ts +0 -135
  233. package/src/Analysis/Passes/Analysis/__tests__/MergeableColorAnalyzer.test.ts +0 -84
  234. package/src/Analysis/Passes/Analysis/__tests__/RainbowAnalyzer.test.ts +0 -99
  235. package/src/Analysis/Passes/Analysis/__tests__/WaveAnalyzer.test.ts +0 -119
  236. package/src/Analysis/Passes/Decision/__tests__/DefaultDecision.test.ts +0 -179
  237. package/src/Analysis/__tests__/Integration.test.ts +0 -162
  238. package/src/Analysis/__tests__/Pipeline.test.ts +0 -133
  239. package/src/Diff/__tests__/TreeDiffer.test.ts +0 -65
  240. package/src/Incremental/__tests__/IncrementalParser.test.ts +0 -164
  241. package/src/Syntax/__tests__/GreenNode.test.ts +0 -33
  242. package/src/Syntax/__tests__/RedNode.test.ts +0 -81
  243. package/src/Syntax/__tests__/RedNodeStore.test.ts +0 -104
  244. package/src/Tests/ASTOptimizerIdempotence.test.ts +0 -77
  245. package/src/Tests/BlockPatcher.test.ts +0 -437
  246. package/src/Tests/BlockPatcherWindowed.test.ts +0 -364
  247. package/src/Tests/BoxDrawer.test.ts +0 -217
  248. package/src/Tests/BoxRichTitle.test.ts +0 -105
  249. package/src/Tests/Chars500kBenchmark.test.ts +0 -151
  250. package/src/Tests/Chars500kEdits.test.ts +0 -321
  251. package/src/Tests/CollabPositions.test.ts +0 -146
  252. package/src/Tests/CompilerPathProfiling.test.ts +0 -186
  253. package/src/Tests/DOMMorpher.test.ts +0 -142
  254. package/src/Tests/DomPatchPerf.test.ts +0 -60
  255. package/src/Tests/EffectSegments.snapshot.json +0 -616
  256. package/src/Tests/EffectSegments.test.ts +0 -68
  257. package/src/Tests/FindNodeAtOffset.test.ts +0 -65
  258. package/src/Tests/Fuzzer.test.ts +0 -166
  259. package/src/Tests/GreenNodePool.test.ts +0 -153
  260. package/src/Tests/Lexer.test.ts +0 -238
  261. package/src/Tests/LyneMode.test.ts +0 -291
  262. package/src/Tests/ModelCoherence.test.ts +0 -180
  263. package/src/Tests/Partition.test.ts +0 -238
  264. package/src/Tests/PluginTags.test.ts +0 -150
  265. package/src/Tests/ProblematicSection.test.ts +0 -46
  266. package/src/Tests/ProblematicSectionHTML.test.ts +0 -58
  267. package/src/Tests/RedReuse.test.ts +0 -134
  268. package/src/Tests/ReproDelete20k.test.ts +0 -62
  269. package/src/Tests/SemanticValidators.test.ts +0 -136
  270. package/src/Tests/StableNodeIds.test.ts +0 -210
  271. package/src/Tests/StudioColorBloat.test.ts +0 -25
  272. package/src/Tests/StudioDebugText.test.ts +0 -27
  273. package/src/Tests/StudioTrailingChar.test.ts +0 -25
  274. package/src/Tests/StudioValidText.test.ts +0 -25
  275. package/src/Tests/UrlImgBug.test.ts +0 -23
  276. package/src/Tests/VisualBuilderFidelity.test.ts +0 -105
  277. package/src/Tests/referenceDocument.ts +0 -119
  278. package/src/Utils/dom-to-svg.test.ts +0 -86
@@ -9,17 +9,42 @@
9
9
  */
10
10
 
11
11
  import { RedNode } from '../Syntax/RedNode'
12
+ import {
13
+ nodeAttrValue,
14
+ parseImgAttr,
15
+ sanitizeColor,
16
+ sanitizeFontFamily,
17
+ sanitizeFontSize,
18
+ } from '../Syntax/nodeAttr'
12
19
  import { Visitor } from './Visitor'
13
20
  import type { TagRegistry } from '../Model/TagRegistry'
14
21
  import { RenderTree } from '../RenderPipeline/RenderTree'
15
22
 
16
23
  import type { BBCodeDialect } from '../BBCode/BBCodeToGreenNode'
24
+ import { evaluateEffect, type EffectKind, type EffectParams } from '../Utils/EffectMath'
25
+ import {
26
+ toTokenResolver,
27
+ resolveTokenValue,
28
+ type TokenResolverFn,
29
+ type TokenSource,
30
+ } from '../Tokens'
17
31
 
18
32
  export interface HTMLRendererOptions {
19
- /**
20
- * Replicate osu! forum BBCode spacing quirks.
21
- * Defaults to true for full compatibility with Miliastry.
22
- * Set to false for a more logical, predictable rendering engine.
33
+ /**
34
+ * Replicate osu! forum BBCode spacing quirks.
35
+ *
36
+ * NO LO LEE NADIE desde que el motor implementa las reglas reales de
37
+ * consumo de saltos (ver {@link HTMLRenderer.NEWLINE_RULES}). Sólo gobernaba
38
+ * dos heurísticas sobre `[code]` que resultaron ser falsas — osu no se come
39
+ * ningún salto ANTES de un bloque, y el resto de las reglas nunca estuvo
40
+ * condicionado. El espaciado ya no es opcional: Miliastry es "osu con
41
+ * esteroides" y rompe líneas igual, así que apagarlo no tendría a qué
42
+ * volver.
43
+ *
44
+ * Se conserva porque es API pública del renderer y quitarlo rompería a quien
45
+ * lo pase. Es candidato a borrarse en la próxima ruptura de versión.
46
+ *
47
+ * @deprecated Sin efecto. El espaciado de osu es ahora incondicional.
23
48
  */
24
49
  osuBehaviour?: boolean
25
50
  /** Registry for resolving custom tags */
@@ -44,19 +69,30 @@ export interface HTMLRendererOptions {
44
69
  * timestamp as plain text. Used by map hosts that deep-link into an editor.
45
70
  */
46
71
  timestampResolver?: (ms: number, label: string) => { href: string; external?: boolean } | null
72
+ /** Design tokens or resolver function */
73
+ tokens?: TokenSource
47
74
  }
48
75
 
76
+ /** Saltos de línea a convertir en `<br>` al pintar un efecto. */
77
+ const NEWLINE_RE = /\n/g
78
+
79
+ /** Referencia a un token de diseño dentro de un texto: `$nombre`. */
80
+ const TOKEN_REF_RE = /\$([a-zA-Z_][a-zA-Z0-9_-]*)/g
81
+
49
82
  export class HTMLRenderer extends Visitor<string> {
50
- private options: Required<Omit<HTMLRendererOptions, 'registry' | 'mediaProxy' | 'entityLinkResolver' | 'mentionResolver' | 'timestampResolver'>> & {
83
+ private tokenResolver?: TokenResolverFn
84
+ private options: Required<Omit<HTMLRendererOptions, 'registry' | 'mediaProxy' | 'entityLinkResolver' | 'mentionResolver' | 'timestampResolver' | 'tokens'>> & {
51
85
  registry?: TagRegistry
52
86
  mediaProxy?: (url: string) => string
53
87
  entityLinkResolver?: (kind: string, value: string) => { href: string; external?: boolean } | null
54
88
  mentionResolver?: (name: string) => { href: string; external?: boolean } | null
55
89
  timestampResolver?: (ms: number, label: string) => { href: string; external?: boolean } | null
90
+ tokens?: TokenSource
56
91
  }
57
92
 
58
93
  constructor(options: HTMLRendererOptions = {}) {
59
94
  super()
95
+ this.tokenResolver = toTokenResolver(options.tokens)
60
96
  this.options = {
61
97
  osuBehaviour: options.osuBehaviour ?? true,
62
98
  registry: options.registry,
@@ -66,9 +102,19 @@ export class HTMLRenderer extends Visitor<string> {
66
102
  entityLinkResolver: options.entityLinkResolver,
67
103
  mentionResolver: options.mentionResolver,
68
104
  timestampResolver: options.timestampResolver,
105
+ tokens: options.tokens,
69
106
  }
70
107
  }
71
108
 
109
+ setTokens(tokens?: TokenSource): void {
110
+ this.tokenResolver = toTokenResolver(tokens)
111
+ this.options.tokens = tokens
112
+ }
113
+
114
+ getTokenResolver(): TokenResolverFn | undefined {
115
+ return this.tokenResolver
116
+ }
117
+
72
118
  // ─── Tag → HTML Element Map ─────────────────────────────
73
119
 
74
120
  private readonly BLOCK_TAGS = new Set([
@@ -184,6 +230,25 @@ export class HTMLRenderer extends Visitor<string> {
184
230
  */
185
231
  private tableDepth = 0
186
232
 
233
+ /**
234
+ * ¿Estamos emitiendo el vocabulario de clases de osu!?
235
+ *
236
+ * osu estiliza box, spoilerbox, notice, imagemap, youtube, los alineados y
237
+ * los perfiles POR NOMBRE DE CLASE, no por estilo inline. Sobre una userpage
238
+ * real el HTML de Quasar salía sin estilo porque emitía su propio
239
+ * vocabulario (`<details>`, `.notice`, `.imagemap-container`…). Bajo
240
+ * `dialect: 'osu'` se emiten las clases y la estructura de osu; el resto de
241
+ * dialectos conserva la suya, que es la que sus hojas de estilo esperan.
242
+ */
243
+ private isOsu(): boolean {
244
+ return this.options.dialect === 'osu'
245
+ }
246
+
247
+ /** osu recorta los saltos pegados a la apertura y al cierre de box/notice. */
248
+ private static trimOsuEdges(html: string): string {
249
+ return html.replace(/^[\t ]*\r?\n/, '').replace(/\r?\n[\t ]*$/, '')
250
+ }
251
+
187
252
  private idAttr(node: RedNode): string {
188
253
  if (HTMLRenderer.idMode === 'none') return ''
189
254
  if (HTMLRenderer.idMode === 'all') return ` data-node-id="${node.id}"`
@@ -203,6 +268,7 @@ export class HTMLRenderer extends Visitor<string> {
203
268
  'notice', 'wnotice', 'spoilerbox', 'box', 'boxw', 'list', 'list_item', 'quote', 'code', 'svg',
204
269
  'heading', 'center', 'right', 'left', 'align', 'imagemap', 'image', 'video', 'audio',
205
270
  'tables', 'table_row', 'gallery', 'columns', 'separator', 'scroll', 'container',
271
+ 'spacing', 'empty_line',
206
272
  ])
207
273
 
208
274
  // ─── Main Entry ─────────────────────────────────────────
@@ -211,7 +277,22 @@ export class HTMLRenderer extends Visitor<string> {
211
277
  return this.renderNode(node)
212
278
  }
213
279
 
280
+ /**
281
+ * Ancho de cada tabla abierta, apilado igual que `tableDepth`.
282
+ *
283
+ * `getTableContext` corre una vez POR CELDA y recorría todas las filas de
284
+ * la tabla en cada llamada, con un `.filter()` —y su array— por fila: una
285
+ * tabla de 20×20 hacía 8.000 arrays intermedios por renderizado para
286
+ * llegar 400 veces al mismo número. Ahora se calcula una vez al entrar en
287
+ * la tabla. Va en una pila y no en un caché por nodo porque los hijos de un
288
+ * `RedNode` SON mutables (`appendChild`/`removeChild`), así que la
289
+ * identidad del nodo no garantiza que su estructura siga siendo la misma
290
+ * entre renderizados.
291
+ */
292
+ private maxColsStack: number[] = []
293
+
214
294
  render(root: RedNode): string {
295
+ this.maxColsStack.length = 0
215
296
  return this.renderNode(root)
216
297
  }
217
298
 
@@ -243,14 +324,26 @@ export class HTMLRenderer extends Visitor<string> {
243
324
  private renderNode(node: RedNode): string {
244
325
  // Leaf nodes
245
326
  if (node.children.length === 0 && node.kind === 'text') {
327
+ let rawText = node.text
328
+ // El `indexOf` va delante: casi ningún texto lleva un `$`, y así ni se
329
+ // arranca el motor de expresiones regulares. El patrón es de módulo
330
+ // porque este es el nodo más frecuente del documento y un literal aquí
331
+ // dentro construía un `RegExp` por hoja, en cada renderizado.
332
+ if (this.tokenResolver !== undefined && rawText.indexOf('$') !== -1) {
333
+ const resolve = this.tokenResolver
334
+ rawText = rawText.replace(TOKEN_REF_RE, (match, name: string) => {
335
+ const resolved = resolve(name) ?? resolve(match)
336
+ return resolved !== undefined ? resolved : match
337
+ })
338
+ }
246
339
  let out = this.mentionResolver || this.options.timestampResolver
247
- ? this.linkifyText(node.text)
248
- : this.escapeHtml(node.text)
340
+ ? this.linkifyText(rawText)
341
+ : this.escapeHtml(rawText)
249
342
  const style = node.metadata?.style as Record<string, string> | undefined
250
343
  if (style) {
251
344
  const inlineStyles = []
252
- const color = style.color && this.sanitizeColor(style.color)
253
- const fontSize = style.fontSize && this.sanitizeFontSize(style.fontSize)
345
+ const color = style.color && sanitizeColor(style.color, this.tokenResolver)
346
+ const fontSize = style.fontSize && sanitizeFontSize(style.fontSize, this.tokenResolver)
254
347
  if (color) inlineStyles.push(`color: ${color}`)
255
348
  if (fontSize) inlineStyles.push(`font-size: ${fontSize}%`)
256
349
  if (this.isCssKeyword(style.fontWeight)) inlineStyles.push(`font-weight: ${style.fontWeight}`)
@@ -277,7 +370,7 @@ export class HTMLRenderer extends Visitor<string> {
277
370
  case 'strikethrough': return this.wrapInline('s', node)
278
371
  case 'inline_code': return this.wrapInline('code', node, 'class="inline"')
279
372
  case 'spoiler': return this.wrapInline('span', node, 'class="spoiler"')
280
- case 'color': return this.wrapInline('span', node, this.colorStyle(node))
373
+ case 'color': return this.renderColor(node)
281
374
  case 'font_size': return this.wrapInline('span', node, this.fontSizeStyle(node))
282
375
  case 'font': return this.wrapInline('span', node, this.fontStyle(node))
283
376
  case 'url': return this.renderLink(node, 'url')
@@ -286,10 +379,18 @@ export class HTMLRenderer extends Visitor<string> {
286
379
  case 'image': return this.renderImage(node)
287
380
  case 'video': return this.renderVideo(node)
288
381
  case 'audio': return this.renderAudio(node)
289
- case 'center': return this.wrapBlock('div', node, 'style="text-align:center;"')
290
- case 'right': return this.wrapBlock('div', node, 'style="text-align:right;"')
291
- case 'left': return this.wrapBlock('div', node, 'style="text-align:left;"')
292
- case 'heading': return this.wrapBlock('h2', node)
382
+ case 'center': return this.renderAlignAs(node, 'center')
383
+ case 'right': return this.renderAlignAs(node, 'right')
384
+ case 'left': return this.renderAlignAs(node, 'left')
385
+ // Sigue siendo siempre `h2`, como antes: el nivel de BBCode no mapea al
386
+ // de HTML y un `[heading=9]` daría un `<h9>` inválido. `data-bare-level`
387
+ // sólo anota que el 2 lo puso el renderer, para que el camino de vuelta
388
+ // no escriba `[heading=2]` donde el autor había puesto `[heading]`.
389
+ case 'heading': return this.wrapBlock(
390
+ 'h2',
391
+ node,
392
+ node.metadata?.level === undefined ? 'data-bare-level="1"' : '',
393
+ )
293
394
  case 'notice': return this.renderNotice(node, false)
294
395
  case 'wnotice': return this.renderNotice(node, true)
295
396
  case 'quote': return this.renderQuote(node)
@@ -363,14 +464,18 @@ export class HTMLRenderer extends Visitor<string> {
363
464
  case 'bubble': return this.wrapInline('span', node, 'class="bubble"')
364
465
  case 'flower': return this.wrapInline('span', node, 'class="flower"')
365
466
  case 'gradient': return this.renderGradient(node)
467
+ // These three had no case at all, so `[rainbow]Hi[/rainbow]` previewed
468
+ // as bare text while its export was a full colour sweep.
469
+ case 'rainbow': return this.renderEffectSegments(node, 'rainbow')
470
+ case 'grow': return this.renderEffectSegments(node, 'grow')
471
+ case 'sinewave': return this.renderEffectSegments(node, 'sinewave')
472
+ case 'paint': return this.renderEffectSegments(node, 'paint')
366
473
  case 'spacing':
367
- if (this.options.osuBehaviour && this.isNextCodeBlock(node)) return '\n'
368
- if (this.isTrailingBlockBoundary(node)) return '\n'
369
- return this.isPrevBlockBoundary(node) ? '\n' : '<br>'
474
+ if (this.isNewlineSwallowed(node)) return '\n'
475
+ return `<br${this.idAttr(node)}>`
370
476
  case 'empty_line':
371
- if (this.options.osuBehaviour && this.isImmediateEmptyLineBeforeCode(node)) return '\n'
372
- if (this.isTrailingBlockBoundary(node)) return '\n'
373
- return '<br>'
477
+ if (this.isNewlineSwallowed(node)) return '\n'
478
+ return `<div class="bb-empty-line"${this.idAttr(node)}><br></div>`
374
479
  case 'group': return this.wrapInline('span', node, 'class="group"')
375
480
  // Un párrafo no tiene etiqueta propia en BBCode, pero sí necesita un
376
481
  // elemento: sin él, la prosa suelta entre bloques no es clicable —
@@ -378,6 +483,9 @@ export class HTMLRenderer extends Visitor<string> {
378
483
  // Un `span` es inline, así que no altera el flujo del texto.
379
484
  case 'paragraph': return this.wrapInline('span', node, 'class="bb-paragraph"')
380
485
  case 'error': return this.renderError(node)
486
+ // osu! no muestra un cierre que no cerró nada; el nodo existe solo para
487
+ // que su rango siga perteneciendo a alguien.
488
+ case 'discarded_tag': return ''
381
489
  case 'text':
382
490
  return this.textWrap(node, this.escapeHtml(node.text || ''))
383
491
  default:
@@ -401,65 +509,224 @@ export class HTMLRenderer extends Visitor<string> {
401
509
  }
402
510
  }
403
511
 
404
- // ─── Render Helpers ─────────────────────────────────────
512
+ // ─── Newline swallowing ─────────────────────────────────
513
+ //
514
+ // osu! turns newlines into `<br />` with one flat rule at the very end of
515
+ // `BBCodeFromDB::toHTML` — `str_replace("\n", '<br />')`. Every subtlety
516
+ // lives BEFORE that line: each block pass is a regex that eats the newlines
517
+ // touching its own tags, so those newlines are simply gone by the time the
518
+ // flat rule runs. The amount eaten differs per tag, and the asymmetries are
519
+ // not decorative:
520
+ //
521
+ // parseBox `\[box=…\]\n*` `\n*\[/box\]\n?`
522
+ // parseCode `\[code\]\n*` `\n*\[/code\]\n?`
523
+ // parseNotice `\[notice\]\n*` `\n*\[/notice\]\n?`
524
+ // parseList `\s*\[\*\]` `\s*\[/list\]\n?\n?`
525
+ // parseQuote `\[quote…\]\s*` `\s*\[/quote\]\n?\n?`
526
+ // parseHeading — `\[/heading\]\n?`
527
+ // parseImagemap — `\[/imagemap\]\n?`
528
+ // parseAlignment strtr of `[centre]\n` and `[/centre]\n` — exactly one
529
+ //
530
+ // Quasar used to approximate all of that with two neighbourhood heuristics
531
+ // (`isPrevBlockBoundary` / `isTrailingBlockBoundary`) that treated every
532
+ // block alike, so they over-ate at `[centre]`/`[/imagemap]` and under-ate at
533
+ // `[/list]`/`[/quote]`. This models the real rules instead.
534
+ //
535
+ // Deliberately NOT gated on the dialect: Miliastry is "osu with steroids"
536
+ // and has to break lines the same way. Blocks that only exist in Miliastry
537
+ // (tables, gallery, columns, scroll, …) have no osu counterpart to copy, so
538
+ // they keep the legacy behaviour via {@link HTMLRenderer.LEGACY_BLOCK_RULE}.
539
+
540
+ /** How many newlines a construct swallows around its own tags. */
541
+ private static readonly NEWLINE_RULES: Record<string, {
542
+ /** Newlines eaten right after the opening tag. */
543
+ afterOpen: 'all' | 'whitespace' | 'one' | 'none'
544
+ /** Newlines eaten right before the closing tag. */
545
+ beforeClose: 'all' | 'whitespace' | 'none'
546
+ /** Whitespace eaten right before the OPENING tag (`\s*\[\*\]`). */
547
+ beforeOpen: 'whitespace' | 'none'
548
+ /** Newlines eaten right after the closing tag. */
549
+ afterClose: number
550
+ }> = {
551
+ // `\n*` inside both edges, one newline after the close.
552
+ box: { afterOpen: 'all', beforeClose: 'all', beforeOpen: 'none', afterClose: 1 },
553
+ boxw: { afterOpen: 'all', beforeClose: 'all', beforeOpen: 'none', afterClose: 1 },
554
+ spoilerbox: { afterOpen: 'all', beforeClose: 'all', beforeOpen: 'none', afterClose: 1 },
555
+ notice: { afterOpen: 'all', beforeClose: 'all', beforeOpen: 'none', afterClose: 1 },
556
+ wnotice: { afterOpen: 'all', beforeClose: 'all', beforeOpen: 'none', afterClose: 1 },
557
+ code: { afterOpen: 'all', beforeClose: 'all', beforeOpen: 'none', afterClose: 1 },
558
+ // `\s*` — not just newlines — and TWO newlines after the close.
559
+ quote: { afterOpen: 'whitespace', beforeClose: 'whitespace', beforeOpen: 'none', afterClose: 2 },
560
+ // `[list]` itself eats nothing after its opening tag: the pass that eats
561
+ // is `\s*\[\*\]`, which needs an item to follow. `[list]\n\nloose text`
562
+ // keeps both newlines; `[list]\n[*]a` loses one to the item, not the list.
563
+ list: { afterOpen: 'none', beforeClose: 'whitespace', beforeOpen: 'none', afterClose: 2 },
564
+ // `\s*\[\*\]`. The matching `[/*]` of the table exists only in legacy
565
+ // phpBB rows — `BBCodeForDB` never emits one — so the item's close is
566
+ // width-less here and its two-newline budget is unreachable by design;
567
+ // `[*]a\n\n[*]b` loses both newlines to the NEXT item's `\s*`, which is
568
+ // the same output by a different route.
569
+ list_item: { afterOpen: 'none', beforeClose: 'none', beforeOpen: 'whitespace', afterClose: 0 },
570
+ // strtr with `[centre]\n` / `[/centre]\n`: exactly one on each outer edge,
571
+ // and nothing before the close — `x\n[/centre]` really does keep its `<br>`.
572
+ center: { afterOpen: 'one', beforeClose: 'none', beforeOpen: 'none', afterClose: 1 },
573
+ left: { afterOpen: 'one', beforeClose: 'none', beforeOpen: 'none', afterClose: 1 },
574
+ right: { afterOpen: 'one', beforeClose: 'none', beforeOpen: 'none', afterClose: 1 },
575
+ align: { afterOpen: 'one', beforeClose: 'none', beforeOpen: 'none', afterClose: 1 },
576
+ heading: { afterOpen: 'none', beforeClose: 'none', beforeOpen: 'none', afterClose: 1 },
577
+ imagemap: { afterOpen: 'none', beforeClose: 'none', beforeOpen: 'none', afterClose: 1 },
578
+ // `[img]` is inline in osu and swallows nothing at all.
579
+ image: { afterOpen: 'none', beforeClose: 'none', beforeOpen: 'none', afterClose: 0 },
580
+ document: { afterOpen: 'none', beforeClose: 'none', beforeOpen: 'none', afterClose: 0 },
581
+ }
405
582
 
406
- /** osu! quirk: newlines immediately preceding a [code] block are completely ignored */
407
- private isNextCodeBlock(node: RedNode): boolean {
408
- let next = node.nextSibling
409
- while (next && (next.kind === 'spacing' || next.kind === 'empty_line')) {
410
- next = next.nextSibling
411
- }
412
- return next?.kind === 'code'
583
+ /**
584
+ * What a Miliastry-only block does. This is what the old
585
+ * `isPrevBlockBoundary` / `isTrailingBlockBoundary` pair did for every block:
586
+ * eat the first newline after the open, every newline before the close, and
587
+ * the first newline after the close.
588
+ */
589
+ private static readonly LEGACY_BLOCK_RULE = {
590
+ afterOpen: 'one', beforeClose: 'all', beforeOpen: 'none', afterClose: 1,
591
+ } as const
592
+
593
+ /**
594
+ * Containers whose opening tag occupies no source text, so a backwards scan
595
+ * has to walk straight through them.
596
+ */
597
+ private static readonly WIDTHLESS_OPEN = new Set(['paragraph', 'group'])
598
+
599
+ /**
600
+ * Same for the closing side. `list_item` is here because `[/*]` is never
601
+ * written: an item ends where the next `[*]` or the `[/list]` begins, so
602
+ * `\s*\[/list\]` sees the newline that Quasar stores inside the item.
603
+ */
604
+ private static readonly WIDTHLESS_CLOSE = new Set(['paragraph', 'group', 'list_item'])
605
+
606
+ private newlineRule(kind: string) {
607
+ const rule = HTMLRenderer.NEWLINE_RULES[kind]
608
+ if (rule) return rule
609
+ return this.BLOCK_TAGS.has(kind) ? HTMLRenderer.LEGACY_BLOCK_RULE : null
413
610
  }
414
611
 
415
- /** Checks if this is the LAST empty_line right before a code block (skipping only spacing) */
416
- private isImmediateEmptyLineBeforeCode(node: RedNode): boolean {
417
- let next = node.nextSibling
418
- while (next && next.kind === 'spacing') {
419
- next = next.nextSibling
420
- }
421
- return next?.kind === 'code'
612
+ private static isNewlineNode(node: RedNode): boolean {
613
+ return node.kind === 'spacing' || node.kind === 'empty_line'
422
614
  }
423
615
 
424
- private isPrevBlockBoundary(node: RedNode): boolean {
425
- let prev = node.previousSibling
426
- while (prev) {
427
- if (prev.kind === 'spacing' || prev.kind === 'empty_line') {
428
- prev = prev.previousSibling
429
- continue
616
+ private static isBlankText(node: RedNode): boolean {
617
+ return node.kind === 'text' && node.children.length === 0 && node.text.trim() === ''
618
+ }
619
+
620
+ /**
621
+ * Whether this `spacing` / `empty_line` leaf is eaten by a neighbouring tag
622
+ * and therefore renders nothing.
623
+ *
624
+ * Each leaf is exactly ONE source newline (the parser splits a run into one
625
+ * node per `\n`), so the four scans below can be read straight off the
626
+ * regexes they mirror. A newline eaten by any of them is eaten: osu's passes
627
+ * run in a fixed order, but since a consumed newline is consumed whichever
628
+ * pass claimed it, the union is enough — the per-pass order only matters for
629
+ * a budget that could be spent elsewhere, and budgets here are counted from
630
+ * the tag outwards, exactly as `\n?\n?` counts.
631
+ */
632
+ private isNewlineSwallowed(node: RedNode): boolean {
633
+ return this.eatenByOpeningTag(node)
634
+ || this.eatenByClosingTag(node)
635
+ || this.eatenAfterClosingTag(node)
636
+ || this.eatenBeforeOpeningTag(node)
637
+ }
638
+
639
+ /** `\[box\]\n*`, `\[quote\]\s*`, `[centre]\n`. */
640
+ private eatenByOpeningTag(node: RedNode): boolean {
641
+ let cur: RedNode = node
642
+ let newlinesBetween = 0
643
+ let blankBetween = false
644
+ for (;;) {
645
+ const prev = cur.previousSibling
646
+ if (prev) {
647
+ if (HTMLRenderer.isNewlineNode(prev)) { newlinesBetween++; cur = prev; continue }
648
+ if (HTMLRenderer.isBlankText(prev)) { blankBetween = true; cur = prev; continue }
649
+ return false
430
650
  }
431
- if (prev.kind === 'text' && prev.text.trim() === '') {
432
- prev = prev.previousSibling
433
- continue
651
+ const parent = cur.parent
652
+ if (!parent) return false
653
+ if (HTMLRenderer.WIDTHLESS_OPEN.has(parent.kind)) { cur = parent; continue }
654
+ const rule = this.newlineRule(parent.kind)
655
+ if (!rule) return false
656
+ switch (rule.afterOpen) {
657
+ case 'whitespace': return true
658
+ // `\n*` matches newlines only: a stray space breaks the run.
659
+ case 'all': return !blankBetween
660
+ case 'one': return !blankBetween && newlinesBetween === 0
661
+ default: return false
434
662
  }
435
- break
436
663
  }
437
-
438
- if (prev && this.BLOCK_TAGS.has(prev.kind) && prev.kind !== 'image' && prev.kind !== 'imagemap') return true
439
- if (!prev && node.parent && this.BLOCK_TAGS.has(node.parent.kind) && node.parent.kind !== 'image' && node.parent.kind !== 'imagemap') return true
664
+ }
440
665
 
441
- return false
666
+ /** `\n*\[/box\]`, `\s*\[/quote\]`, `\s*\[/list\]`. */
667
+ private eatenByClosingTag(node: RedNode): boolean {
668
+ let cur: RedNode = node
669
+ let blankBetween = false
670
+ for (;;) {
671
+ const next = cur.nextSibling
672
+ if (next) {
673
+ if (HTMLRenderer.isNewlineNode(next)) { cur = next; continue }
674
+ if (HTMLRenderer.isBlankText(next)) { blankBetween = true; cur = next; continue }
675
+ return false
676
+ }
677
+ const parent = cur.parent
678
+ if (!parent) return false
679
+ if (HTMLRenderer.WIDTHLESS_CLOSE.has(parent.kind)) { cur = parent; continue }
680
+ const rule = this.newlineRule(parent.kind)
681
+ if (!rule) return false
682
+ switch (rule.beforeClose) {
683
+ case 'whitespace': return true
684
+ case 'all': return !blankBetween
685
+ default: return false
686
+ }
687
+ }
442
688
  }
443
689
 
444
- private isTrailingBlockBoundary(node: RedNode): boolean {
445
- let next = node.nextSibling
446
- while (next) {
447
- if (next.kind === 'spacing' || next.kind === 'empty_line') {
448
- next = next.nextSibling
449
- continue
690
+ /** `\[/box\]\n?`, `\[/list\]\n?\n?`. */
691
+ private eatenAfterClosingTag(node: RedNode): boolean {
692
+ let cur: RedNode = node
693
+ let newlinesBetween = 0
694
+ for (;;) {
695
+ const prev = cur.previousSibling
696
+ if (!prev) {
697
+ const parent = cur.parent
698
+ if (parent && HTMLRenderer.WIDTHLESS_OPEN.has(parent.kind)) { cur = parent; continue }
699
+ return false
450
700
  }
451
- if (next.kind === 'text' && next.text.trim() === '') {
452
- next = next.nextSibling
453
- continue
701
+ if (HTMLRenderer.isNewlineNode(prev)) { newlinesBetween++; cur = prev; continue }
702
+ // Descend to whatever real closing tag sits immediately to our left.
703
+ let closer: RedNode = prev
704
+ while (HTMLRenderer.WIDTHLESS_CLOSE.has(closer.kind) && closer.children.length > 0) {
705
+ closer = closer.children[closer.children.length - 1]
454
706
  }
455
- break
707
+ const rule = this.newlineRule(closer.kind)
708
+ return rule !== null && newlinesBetween < rule.afterClose
456
709
  }
457
- if (!next && node.parent && this.BLOCK_TAGS.has(node.parent.kind) && node.parent.kind !== 'document') {
458
- return true
710
+ }
711
+
712
+ /** `\s*\[\*\]` — the only pass that eats whitespace BEFORE an opening tag. */
713
+ private eatenBeforeOpeningTag(node: RedNode): boolean {
714
+ let cur: RedNode = node
715
+ for (;;) {
716
+ const next = cur.nextSibling
717
+ if (next) {
718
+ if (HTMLRenderer.isNewlineNode(next) || HTMLRenderer.isBlankText(next)) { cur = next; continue }
719
+ return this.newlineRule(next.kind)?.beforeOpen === 'whitespace'
720
+ }
721
+ const parent = cur.parent
722
+ if (!parent) return false
723
+ if (HTMLRenderer.WIDTHLESS_CLOSE.has(parent.kind)) { cur = parent; continue }
724
+ return false
459
725
  }
460
- return false
461
726
  }
462
727
 
728
+ // ─── Render Helpers ─────────────────────────────────────
729
+
463
730
  private renderError(node: RedNode): string {
464
731
  const errorMsg = this.escapeHtml((node.metadata?.message as string) || node.text || 'Syntax Error')
465
732
  // The content is the raw offending tag, mapped as child text.
@@ -489,21 +756,9 @@ export class HTMLRenderer extends Visitor<string> {
489
756
  // half-escaped value still emits broken CSS, so we validate the shape and
490
757
  // drop anything that isn't a value we intended to support.
491
758
 
492
- /** #rgb / #rgba / #rrggbb / #rrggbbaa, a bare CSS color keyword, or rgb()/hsl(). */
493
- private static readonly CSS_COLOR_RE =
494
- /^(?:#(?:[0-9a-f]{3}|[0-9a-f]{4}|[0-9a-f]{6}|[0-9a-f]{8})|[a-z]{3,20}|(?:rgb|hsl)a?\([0-9a-z.,%\s/+-]{1,64}\))$/i
495
-
496
- /** Bare number — interpolated as a percentage. */
497
- private static readonly CSS_SIZE_RE = /^\d{1,4}(?:\.\d{1,2})?$/
498
-
499
- /** Font family list. Quotes are rejected outright; unquoted names are valid CSS. */
500
- private static readonly CSS_FONT_RE = /^[A-Za-z0-9 ,_-]{1,120}$/
501
-
502
759
  /** Characters `escapeHtml` has to rewrite. Non-global on purpose: `test` must not carry `lastIndex`. */
503
760
  private static readonly HTML_ESCAPE_RE = /[&<>"']/
504
761
 
505
- private static readonly BARE_HEX_RE = /^[0-9a-f]{3,8}$/i
506
-
507
762
  private static readonly NAMED_COLORS: Record<string, string> = {
508
763
  black: '#000000',
509
764
  white: '#FFFFFF',
@@ -525,88 +780,84 @@ export class HTMLRenderer extends Visitor<string> {
525
780
  gold: '#FFD700',
526
781
  }
527
782
 
528
- private sanitizeColor(raw: string): string | null {
529
- let v = raw.trim()
530
- if (!v) return null
531
- if (HTMLRenderer.BARE_HEX_RE.test(v) && (v.length === 3 || v.length === 4 || v.length === 6 || v.length === 8)) {
532
- v = '#' + v
533
- }
534
- return HTMLRenderer.CSS_COLOR_RE.test(v) ? v : null
535
- }
536
-
537
- private sanitizeFontSize(raw: string): string | null {
538
- const v = raw.trim()
539
- return HTMLRenderer.CSS_SIZE_RE.test(v) ? v : null
540
- }
541
-
542
- private sanitizeFontFamily(raw: string): string | null {
543
- const v = raw.trim()
544
- return HTMLRenderer.CSS_FONT_RE.test(v) ? v : null
545
- }
546
-
547
783
  /** Keyword-or-number CSS values (font-weight, font-style, text-decoration). */
548
784
  private isCssKeyword(raw: string | undefined): boolean {
549
785
  return !!raw && /^[a-z]{2,20}(?: [a-z]{2,20})?$|^[1-9]00$/i.test(raw.trim())
550
786
  }
551
787
 
552
788
  /** Read a metadata field, falling back to the raw tag attribute. */
553
- private metaOrAttr(node: RedNode, key: string): string {
554
- const meta = node.metadata?.[key]
555
- return (typeof meta === 'string' && meta) || this.extractValue(node)
789
+
790
+ /**
791
+ * Lo único que osu! acepta en `[color=…]`.
792
+ *
793
+ * Su `BBCodeForDB::parseColour` sella el tag con un uid sólo si el valor
794
+ * matchea `#[[:xdigit:]]{6}` o `[[:alpha:]]+` — nada más. No valida que el
795
+ * nombre sea un color CSS de verdad (`banana` pasa), pero `#fff`, `#ffffffff`,
796
+ * `rgb(...)`, `$token` o un hex sin `#` no pasan. Sin uid, la segunda pasada
797
+ * no ve el tag y el opener *y* el closer quedan como texto en la página.
798
+ */
799
+ private static readonly OSU_COLOR_RE = /^(?:#[0-9a-fA-F]{6}|[a-zA-Z]+)$/
800
+
801
+ /**
802
+ * `[color]` con el vocabulario de cada dialecto.
803
+ *
804
+ * Miliastry (y Lyne) aceptan a propósito más que osu: `#RGB`, `#RGBA`, un
805
+ * `$token` de diseño, nombres propios. Bajo `dialect: 'osu'` eso es una
806
+ * mentira: el editor pintaría color donde la página publicada muestra el
807
+ * BBCode crudo. Así que replicamos lo que hace osu — literal el opener,
808
+ * literal el closer, y los hijos renderizados normalmente en el medio.
809
+ *
810
+ * El chequeo mira el texto crudo del atributo, no el valor saneado: osu
811
+ * matchea sobre la fuente, así que `[color="#ffffff"]` (con comillas) también
812
+ * se le escapa.
813
+ */
814
+ private renderColor(node: RedNode): string {
815
+ if (this.options.dialect === 'osu') {
816
+ const text = node.text || ''
817
+ const eq = text.indexOf('=')
818
+ // Sin `=` en el texto el nodo no vino del parser de BBCode (import de
819
+ // HTML, por ejemplo): ahí el único valor disponible es el de metadata.
820
+ const raw = eq >= 0 ? text.slice(eq + 1) : (text ? '' : nodeAttrValue(node, 'color'))
821
+ if (!HTMLRenderer.OSU_COLOR_RE.test(raw)) {
822
+ const opener = eq >= 0 ? `[color${text}]` : `[${text || 'color'}]`
823
+ return this.escapeHtml(opener) + this.renderChildren(node) + this.escapeHtml('[/color]')
824
+ }
825
+ }
826
+ return this.wrapInline('span', node, this.colorStyle(node))
556
827
  }
557
828
 
558
829
  private colorStyle(node: RedNode): string {
559
- const color = this.sanitizeColor(this.metaOrAttr(node, 'color'))
830
+ const color = sanitizeColor(nodeAttrValue(node, 'color'), this.tokenResolver)
560
831
  return color ? `style="color:${color};"` : ''
561
832
  }
562
833
 
563
834
  private fontSizeStyle(node: RedNode): string {
564
- const size = this.sanitizeFontSize(this.metaOrAttr(node, 'size'))
835
+ const size = sanitizeFontSize(nodeAttrValue(node, 'size'), this.tokenResolver)
565
836
  return size ? `style="font-size:${size}%;"` : ''
566
837
  }
567
838
 
568
839
  private fontStyle(node: RedNode): string {
569
- const font = this.sanitizeFontFamily(this.metaOrAttr(node, 'font'))
840
+ const font = sanitizeFontFamily(nodeAttrValue(node, 'font'), this.tokenResolver)
570
841
  return font ? `style="font-family:${font};"` : ''
571
842
  }
572
843
 
573
- /**
574
- * Extract the attribute value from a BBCode tag node.
575
- *
576
- * BBCode attributes come in the format `=VALUE` (e.g. `=#61afef`, `="Author"`,
577
- * `=https://osu.ppy.sh`). This strips the leading `=` and any surrounding quotes.
578
- *
579
- * For tags without attrs (like text nodes, img content), returns the node text as-is.
580
- */
581
- private extractValue(node: RedNode): string {
582
- const text = node.text || ''
583
- if (!text) return ''
584
-
585
- const eqIdx = text.indexOf('=')
586
- if (eqIdx >= 0) {
587
- // Strip `=` prefix and surrounding quotes
588
- let value = text.slice(eqIdx + 1)
589
- // Remove surrounding quotes: "value" or 'value'
590
- if ((value.startsWith('"') && value.endsWith('"')) ||
591
- (value.startsWith("'") && value.endsWith("'"))) {
592
- value = value.slice(1, -1)
593
- }
594
- return value
595
- }
596
-
597
- // No `=` prefix — return as-is (used for media content like img URLs)
598
- return text
599
- }
600
-
601
844
  private renderLink(node: RedNode, kind: string): string {
602
- let href = String(node.metadata?.href ?? '') || this.extractValue(node)
845
+ let href = String(node.metadata?.href ?? '') || nodeAttrValue(node)
846
+ // Un `[email]a@b.com[/email]` llega con href exactamente `mailto:` y la
847
+ // dirección en el hijo de texto. Sin completarlo aquí el href quedaba
848
+ // vacío y el round-trip devolvía `[url=mailto:]a@b.com[/url]`.
849
+ if (kind === 'email' && href === 'mailto:') {
850
+ href += node.children.map(c => c.text || '').join('')
851
+ }
603
852
  // Ensure the URL has a protocol for external links
604
853
  if (href && !href.startsWith('http://') && !href.startsWith('https://') && !href.startsWith('mailto:')) {
605
854
  href = 'https://' + href
606
855
  }
607
856
  const content = this.renderChildren(node) || href
608
857
  const h = href ? ` href="${this.escapeHtml(href)}"` : ''
609
- return `<a${this.idAttr(node)}${h} target="_blank" rel="noopener">${content}</a>`
858
+ const hasMediaChild = node.children.some(c => c.kind === 'image' || c.kind === 'svg' || c.kind === 'video')
859
+ const imgCls = hasMediaChild ? ' class="bb-link-img"' : ''
860
+ return `<a${this.idAttr(node)}${imgCls}${h} target="_blank" rel="noopener">${content}</a>`
610
861
  }
611
862
 
612
863
  private renderProfile(node: RedNode): string {
@@ -615,48 +866,64 @@ export class HTMLRenderer extends Visitor<string> {
615
866
 
616
867
  private renderEntity(node: RedNode, type: 'profile' | 'guild' | 'map'): string {
617
868
  const key = type === 'profile' ? 'username' : type === 'guild' ? 'tag' : 'id'
618
- const val = String(node.metadata?.[key] ?? '') || this.extractValue(node)
869
+ const val = String(node.metadata?.[key] ?? '') || nodeAttrValue(node)
619
870
  const content = this.renderChildren(node) || val
871
+ // El converter HTML→BBCode ve `<strong><a>`, no la etiqueta original. Sin
872
+ // esta marca `[profile]peppy[/profile]` volvía como `[b][url=...]…[/url][/b]`,
873
+ // y sin el valor `[profile=5458323]` perdía el id y volvía como `[profile]`.
874
+ const entity = ` data-entity="${type}"` +
875
+ (val ? ` data-entity-value="${this.escapeHtml(val)}"` : '')
620
876
  if (this.options.entityLinkResolver) {
621
877
  const link = this.options.entityLinkResolver(type, val || content)
622
878
  if (link) {
623
879
  const ext = link.external ? ' target="_blank" rel="noopener noreferrer"' : ''
624
- return `<strong><a${this.idAttr(node)} href="${this.escapeHtml(link.href)}"${ext}>${content}</a></strong>`
880
+ return `<strong${entity}><a${this.idAttr(node)} href="${this.escapeHtml(link.href)}"${ext}>${content}</a></strong>`
625
881
  }
626
882
  }
883
+ if (type === 'profile' && this.isOsu()) {
884
+ // Sacado de los fixtures `basic_profile*` de osu-web, no adivinado: el
885
+ // enlace es un `<a>` pelado (nada de `<strong>`), y `data-user-id` lleva
886
+ // el id numérico cuando `[profile=N]` lo trae y `@` + el texto crudo
887
+ // cuando no. El href es ese mismo valor, URL-encoded.
888
+ const key = val || `@${this.collectNodeText(node)}`
889
+ const href = `https://osu.ppy.sh/users/${encodeURIComponent(key)}`
890
+ return `<a${this.idAttr(node)}${entity} class="user-name js-usercard" data-user-id="${this.escapeHtml(key)}" href="${href}">${content}</a>`
891
+ }
627
892
  if (type === 'profile') {
628
893
  const url = this.options.theme === 'lyne' || this.options.dialect === 'lyne'
629
894
  ? `/u/${encodeURIComponent(val || content)}`
630
895
  : `https://osu.ppy.sh/users/${this.escapeHtml(val || content)}`
631
- return `<strong><a${this.idAttr(node)} href="${url}" target="_blank" rel="noopener">${content}</a></strong>`
896
+ return `<strong${entity}><a${this.idAttr(node)} href="${url}" target="_blank" rel="noopener">${content}</a></strong>`
632
897
  }
633
898
  if (type === 'guild') {
634
- return `<strong><a${this.idAttr(node)} href="/guilds/${encodeURIComponent(val || content)}">${content}</a></strong>`
899
+ return `<strong${entity}><a${this.idAttr(node)} href="/guilds/${encodeURIComponent(val || content)}">${content}</a></strong>`
635
900
  }
636
901
  if (type === 'map') {
637
- return `<strong><a${this.idAttr(node)} href="/maps/${encodeURIComponent(val || content)}">${content}</a></strong>`
902
+ return `<strong${entity}><a${this.idAttr(node)} href="/maps/${encodeURIComponent(val || content)}">${content}</a></strong>`
638
903
  }
639
- return `<strong><a${this.idAttr(node)} href="#">${content}</a></strong>`
904
+ return `<strong${entity}><a${this.idAttr(node)} href="#">${content}</a></strong>`
640
905
  }
641
906
 
642
- private parseImgAttr(v: string | null): { w?: number; h?: number; round?: boolean; shadow?: boolean; float?: boolean } {
643
- if (!v) return {}
644
- const t = v.trim().toLowerCase()
645
- if (t === 'round') return { round: true }
646
- if (t === 'shadow') return { shadow: true }
647
- if (t === 'float') return { float: true }
648
- const m = /^(\d{1,4})x(\d{1,4})$/i.exec(t)
649
- if (m) return { w: Math.min(2000, parseInt(m[1], 10)), h: Math.min(2000, parseInt(m[2], 10)) }
650
- return {}
907
+ /**
908
+ * El aviso que ocupa el sitio de un medio que no se puede pintar.
909
+ *
910
+ * Lleva `data-bb-empty` con la etiqueta de origen porque el camino
911
+ * HTML→BBCode lee de vuelta lo que hay en el lienzo: sin la marca, el texto
912
+ * del aviso entraba al documento como contenido y se comía la etiqueta. En
913
+ * `docs/ai/hxovc.bbcode`, con cinco `[img][/img]` vacíos, cada edición del
914
+ * bloque convertía otro en la frase «[img] missing source URL».
915
+ */
916
+ private mediaError(tag: string, message: string): string {
917
+ return `<div class="media-error" data-bb-empty="${tag}">${message}</div>`
651
918
  }
652
919
 
653
920
  private renderImage(node: RedNode): string {
654
- let src = String(node.metadata?.src ?? '') || this.extractValue(node) || ''
655
- if (!src) return '<div class="media-error">[img] missing source URL</div>'
921
+ let src = String(node.metadata?.src ?? '') || nodeAttrValue(node) || ''
922
+ if (!src) return this.mediaError('img', '[img] missing source URL')
656
923
  if (this.options.mediaProxy) {
657
924
  src = this.options.mediaProxy(src)
658
925
  }
659
- const imgAttr = this.parseImgAttr(this.extractValue(node))
926
+ const imgAttr = parseImgAttr(nodeAttrValue(node))
660
927
  const styles: string[] = []
661
928
  if (imgAttr.w) styles.push(`width:${imgAttr.w}px`)
662
929
  if (imgAttr.h) styles.push(`height:${imgAttr.h}px`)
@@ -671,15 +938,25 @@ export class HTMLRenderer extends Visitor<string> {
671
938
  styles.push('max-width:100%', 'height:auto', 'display:inline-block')
672
939
  }
673
940
  const cls = imgAttr.round ? '' : ' class="bb-img"'
674
- return `<img${this.idAttr(node)}${cls} src="${this.escapeHtml(src)}" alt="" style="${styles.join(';')};">`
941
+ // El modificador no se puede reconstruir desde el `style` resultante
942
+ // (`round` y `120x120` producen el mismo CSS), así que viaja literal.
943
+ const rawAttr = nodeAttrValue(node)
944
+ const attr = rawAttr ? ` data-img-attr="${this.escapeHtml(rawAttr)}"` : ''
945
+ return `<img${this.idAttr(node)}${cls}${attr} src="${this.escapeHtml(src)}" alt="" style="${styles.join(';')};">`
675
946
  }
676
947
 
677
948
  private renderVideo(node: RedNode): string {
678
- let id = String(node.metadata?.videoId ?? '') || this.extractValue(node) || ''
679
- if (!id) return '<div class="media-error">[youtube] missing video ID</div>'
949
+ let id = String(node.metadata?.videoId ?? '') || nodeAttrValue(node) || ''
950
+ if (!id) return this.mediaError('youtube', '[youtube] missing video ID')
680
951
  const ytMatch = /(?:youtu\.be\/|v=|\/embed\/|\/shorts\/)([\w-]{11})/.exec(id)
681
952
  if (ytMatch) id = ytMatch[1]
682
- return `<iframe${this.idAttr(node)} src="https://www.youtube.com/embed/${this.escapeHtml(id)}" frameborder="0" allowfullscreen></iframe>`
953
+ // `data-youtube` es lo que deja al converter HTML→BBCode reconocer este
954
+ // iframe. Sin él el vídeo volvía como un `group` vacío: se perdía entero.
955
+ // Las clases de osu van en el iframe mismo, no en un div contenedor: son
956
+ // las que le dan la caja 16:9. Y su `src` lleva siempre `?rel=0`.
957
+ const cls = this.isOsu() ? 'u-embed-wide u-embed-wide--bbcode' : 'bb-youtube'
958
+ const rel = this.isOsu() ? '?rel=0' : ''
959
+ return `<iframe${this.idAttr(node)} class="${cls}" data-youtube="${this.escapeHtml(id)}" src="https://www.youtube.com/embed/${this.escapeHtml(id)}${rel}" frameborder="0" allowfullscreen></iframe>`
683
960
  }
684
961
 
685
962
  private renderAudio(node: RedNode): string {
@@ -687,6 +964,31 @@ export class HTMLRenderer extends Visitor<string> {
687
964
  if (this.options.mediaProxy && src) {
688
965
  src = this.options.mediaProxy(src)
689
966
  }
967
+ const isLyne = this.options.theme === 'lyne' || this.options.dialect === 'lyne'
968
+ if (isLyne) {
969
+ const rawName = src.split('?')[0].split('#')[0].split('/').filter(Boolean).pop() || 'audio_track.mp3'
970
+ let fileName = rawName
971
+ try {
972
+ fileName = decodeURIComponent(rawName)
973
+ } catch {}
974
+ return `<div${this.idAttr(node)} class="lx-audio bb-audio" data-src="${this.escapeHtml(src)}">`
975
+ + `<audio preload="metadata" src="${this.escapeHtml(src)}"></audio>`
976
+ + `<div class="lx-track" role="progressbar" aria-label="Audio progress"><div class="fill"></div></div>`
977
+ + `<div class="lx-row">`
978
+ + `<button type="button" class="lx-btn" aria-label="Play"><svg viewBox="0 0 16 16"><path d="M3 1.5 14 8 3 14.5z"/></svg></button>`
979
+ + `<div class="lx-title">`
980
+ + `<div class="name">${this.escapeHtml(fileName)}</div>`
981
+ + `<div class="label"><span class="dot"></span> <span class="status-text">audio</span></div>`
982
+ + `</div>`
983
+ + `<div class="lx-vol">`
984
+ + `<button type="button" class="lx-vol-btn" aria-label="Mute"><svg viewBox="0 0 16 16"><path d="M10.707 11.182A4.5 4.5 0 0 0 12.025 8a4.5 4.5 0 0 0-1.318-3.182L10 5.525A3.5 3.5 0 0 1 11.025 8 3.5 3.5 0 0 1 10 10.475zM6.717 3.55A.5.5 0 0 1 7 4v8a.5.5 0 0 1-.812.39L3.825 10.5H1.5A.5.5 0 0 1 1 10V6a.5.5 0 0 1 .5-.5h2.325l2.363-1.89a.5.5 0 0 1 .529-.06"/></svg></button>`
985
+ + `<input type="range" class="lx-vol-slider" min="0" max="1" step="0.01" value="0.2" aria-label="Volume" style="background:linear-gradient(to right, var(--color-accent, #2EE6E2) 20%, var(--color-inset-well, #080D20) 20%);" />`
986
+ + `</div>`
987
+ + `<button type="button" class="lx-speed" aria-label="Playback speed">1.0×</button>`
988
+ + `<div class="lx-time"><span class="cur">0:00</span> / <span class="total">–:––</span></div>`
989
+ + `</div>`
990
+ + `</div>`
991
+ }
690
992
  return `<audio${this.idAttr(node)} controls src="${this.escapeHtml(src)}" class="bb-audio"></audio>`
691
993
  }
692
994
 
@@ -707,7 +1009,7 @@ export class HTMLRenderer extends Visitor<string> {
707
1009
  private renderNotice(node: RedNode, warning: boolean): string {
708
1010
  const isLyne = this.options.theme === 'lyne' || this.options.dialect === 'lyne' || warning
709
1011
  if (isLyne) {
710
- const color = this.sanitizeColor(this.metaOrAttr(node, 'color'))
1012
+ const color = sanitizeColor(nodeAttrValue(node, 'color'), this.tokenResolver)
711
1013
  const styleAttr = color
712
1014
  ? ` style="background:${this.hexToRgba(color, 0.08)};border-color:${this.hexToRgba(color, 0.4)};border-left-color:${color};color:${this.hexToRgba(color, 0.85)};"`
713
1015
  : ''
@@ -717,11 +1019,18 @@ export class HTMLRenderer extends Visitor<string> {
717
1019
  return `<div${this.idAttr(node)} class="notice bb-cut-panel bb-notice${warning ? ' bb-wnotice' : ''}" role="note"${styleAttr}>${warningIcon}<div class="bb-notice-body">${content}</div></div>`
718
1020
  }
719
1021
 
1022
+ // osu pinta el aviso con `.well` a secas — no hay ninguna clase `notice`
1023
+ // en su hoja de estilos, que es por lo que el bloque salía desnudo.
1024
+ if (this.isOsu()) {
1025
+ const content = HTMLRenderer.trimOsuEdges(this.renderChildren(node))
1026
+ return `<div${this.idAttr(node)} class="well">${content}</div>`
1027
+ }
1028
+
720
1029
  return this.wrapBlock('div', node, 'class="notice"')
721
1030
  }
722
1031
 
723
1032
  private renderTables(node: RedNode): string {
724
- const variant = (String(node.metadata?.variant ?? '') || this.extractValue(node) || '').trim().toLowerCase()
1033
+ const variant = (String(node.metadata?.variant ?? '') || nodeAttrValue(node) || '').trim().toLowerCase()
725
1034
  const variants = new Set(variant.split(/[\s,]+/).filter(Boolean))
726
1035
  const tableClasses = [
727
1036
  'bb-table',
@@ -729,7 +1038,9 @@ export class HTMLRenderer extends Visitor<string> {
729
1038
  variants.has('borders') ? 'bb-table-borders' : '',
730
1039
  ].filter(Boolean).join(' ')
731
1040
  this.tableDepth++
1041
+ this.maxColsStack.push(HTMLRenderer.widestRow(node))
732
1042
  const content = this.renderChildren(node)
1043
+ this.maxColsStack.pop()
733
1044
  this.tableDepth--
734
1045
  // `[tables=striped:#hex]`: de `--table-accent` se deriva toda la paleta
735
1046
  // de la tabla (gradiente del frame, hover de filas, encabezado, bordes).
@@ -737,22 +1048,52 @@ export class HTMLRenderer extends Visitor<string> {
737
1048
  return `<div${this.idAttr(node)} class="bb-table-frame"${accent}><table class="${tableClasses}"><tbody>${content}</tbody></table></div>`
738
1049
  }
739
1050
 
1051
+ /** Celdas directas de una fila, sin materializar un array intermedio. */
1052
+ private static countCells(node: RedNode): number {
1053
+ let count = 0
1054
+ const children = node.children
1055
+ for (let i = 0; i < children.length; i++) {
1056
+ const kind = children[i].kind
1057
+ if (kind === 'table_th' || kind === 'table_col') count++
1058
+ }
1059
+ return count
1060
+ }
1061
+
1062
+ /**
1063
+ * Columnas de la tabla más ancha bajo `tableNode`, memoizado por tabla.
1064
+ *
1065
+ * `getTableContext` se llama una vez POR CELDA, y recorría todas las filas
1066
+ * de la tabla en cada llamada, con un `.filter()` —y su array— por fila.
1067
+ * Una tabla de 20×20 hacía 8.000 arrays intermedios en cada renderizado
1068
+ * para llegar 400 veces al mismo número. El caché se vacía al empezar cada
1069
+ * render, así que no retiene nodos entre documentos.
1070
+ */
1071
+ private static widestRow(tableNode: RedNode): number {
1072
+ let maxCols = 1
1073
+ const rows = tableNode.children
1074
+ for (let i = 0; i < rows.length; i++) {
1075
+ const row = rows[i]
1076
+ if (row.kind !== 'table_row') continue
1077
+ const count = HTMLRenderer.countCells(row)
1078
+ if (count > maxCols) maxCols = count
1079
+ }
1080
+ return maxCols
1081
+ }
1082
+
740
1083
  private getTableContext(node: RedNode): { cellCount: number; maxCols: number; isDirect: boolean } {
741
1084
  const parent = node.parent
742
1085
  const isDirect = parent?.kind === 'tables'
743
1086
  const tableNode = isDirect ? parent : (parent?.parent?.kind === 'tables' ? parent.parent : undefined)
744
- const cellCount = parent?.children
745
- ? parent.children.filter(c => c.kind === 'table_th' || c.kind === 'table_col').length
746
- : 1
1087
+ const cellCount = parent ? HTMLRenderer.countCells(parent) : 1
747
1088
 
748
1089
  let maxCols = cellCount
749
1090
  if (tableNode) {
750
- for (const row of tableNode.children) {
751
- if (row.kind === 'table_row') {
752
- const count = row.children.filter(c => c.kind === 'table_th' || c.kind === 'table_col').length
753
- if (count > maxCols) maxCols = count
754
- }
755
- }
1091
+ // La tabla en curso está en lo alto de la pila; solo se recorre si esta
1092
+ // celda llegó por un camino que no pasó por `renderTables`.
1093
+ const widest = this.maxColsStack.length > 0
1094
+ ? this.maxColsStack[this.maxColsStack.length - 1]
1095
+ : HTMLRenderer.widestRow(tableNode)
1096
+ if (widest > maxCols) maxCols = widest
756
1097
  }
757
1098
  return { cellCount, maxCols, isDirect }
758
1099
  }
@@ -763,7 +1104,7 @@ export class HTMLRenderer extends Visitor<string> {
763
1104
  }
764
1105
 
765
1106
  private renderColumns(node: RedNode): string {
766
- const cols = parseInt(String(node.metadata?.columns ?? '') || this.extractValue(node) || '2', 10)
1107
+ const cols = parseInt(String(node.metadata?.columns ?? '') || nodeAttrValue(node) || '2', 10)
767
1108
  const numCols = Math.max(2, Math.min(4, isNaN(cols) ? 2 : cols))
768
1109
  const content = this.renderChildren(node)
769
1110
  // `[columns=2:#hex]`: con color se añade la superficie (borde + fondo
@@ -775,27 +1116,27 @@ export class HTMLRenderer extends Visitor<string> {
775
1116
  }
776
1117
 
777
1118
  private renderSeparator(node: RedNode): string {
778
- const variant = (String(node.metadata?.variant ?? '') || this.extractValue(node) || 'line').trim().toLowerCase()
1119
+ const variant = (String(node.metadata?.variant ?? '') || nodeAttrValue(node) || 'line').trim().toLowerCase()
779
1120
  if (variant === 'dots') return `<div${this.idAttr(node)} class="bb-separator">· · ·</div>`
780
1121
  if (variant === 'stars') return `<div${this.idAttr(node)} class="bb-separator">✦ ✦ ✦</div>`
781
1122
  return `<hr${this.idAttr(node)} class="bb-separator" />`
782
1123
  }
783
1124
 
784
1125
  private renderScroll(node: RedNode): string {
785
- const h = parseInt(String(node.metadata?.height ?? '') || this.extractValue(node) || '200', 10)
1126
+ const h = parseInt(String(node.metadata?.height ?? '') || nodeAttrValue(node) || '200', 10)
786
1127
  const maxH = Math.max(50, Math.min(2000, isNaN(h) ? 200 : h))
787
1128
  const content = this.renderChildren(node)
788
1129
  return `<div${this.idAttr(node)} class="bb-scroll" style="max-height:${maxH}px;">${content}</div>`
789
1130
  }
790
1131
 
791
1132
  private renderAbbr(node: RedNode): string {
792
- const title = String(node.metadata?.title ?? '') || this.extractValue(node)
1133
+ const title = String(node.metadata?.title ?? '') || nodeAttrValue(node)
793
1134
  const attr = title ? ` title="${this.escapeHtml(title)}"` : ''
794
1135
  return this.wrapInline('abbr', node, attr)
795
1136
  }
796
1137
 
797
1138
  private renderTooltip(node: RedNode): string {
798
- const tip = String(node.metadata?.tip ?? '') || this.extractValue(node)
1139
+ const tip = String(node.metadata?.tip ?? '') || nodeAttrValue(node)
799
1140
  const attr = tip ? ` title="${this.escapeHtml(tip)}"` : ''
800
1141
  return this.wrapInline('span', node, `class="bb-tooltip"${attr}`)
801
1142
  }
@@ -810,16 +1151,31 @@ export class HTMLRenderer extends Visitor<string> {
810
1151
  }
811
1152
 
812
1153
  private renderAlign(node: RedNode): string {
813
- const alignVal = (String(node.metadata?.align ?? '') || this.extractValue(node) || 'center').trim().toLowerCase()
1154
+ const alignVal = (String(node.metadata?.align ?? '') || nodeAttrValue(node) || 'center').trim().toLowerCase()
814
1155
  const validAlign = alignVal === 'left' || alignVal === 'right' ? alignVal : 'center'
815
- return this.wrapBlock('div', node, `style="text-align:${validAlign};"`)
1156
+ return this.renderAlignAs(node, validAlign)
1157
+ }
1158
+
1159
+ /**
1160
+ * `[centre]` / `[left]` / `[right]` (y `[align=…]`).
1161
+ *
1162
+ * osu! no usa `text-align` inline: estiliza el bloque por nombre de clase,
1163
+ * con la grafía británica `centre`. Fuera del dialecto osu el estilo inline
1164
+ * se mantiene, porque ni Miliastry ni Lyne traen esas reglas.
1165
+ */
1166
+ private renderAlignAs(node: RedNode, align: 'center' | 'left' | 'right'): string {
1167
+ if (this.isOsu()) {
1168
+ const name = align === 'center' ? 'centre' : align
1169
+ return this.wrapBlock('div', node, `class="bbcode__align-${name}"`)
1170
+ }
1171
+ return this.wrapBlock('div', node, `style="text-align:${align};"`)
816
1172
  }
817
1173
 
818
1174
  private renderEffect(node: RedNode): string {
819
- const raw = (String(node.metadata?.effectType ?? '') || this.extractValue(node) || 'glow').toLowerCase().trim()
1175
+ const raw = (String(node.metadata?.effectType ?? '') || nodeAttrValue(node) || 'glow').toLowerCase().trim()
820
1176
  const effectType = raw.includes(':') ? raw.split(':')[0] : (raw.startsWith('#') ? 'glow' : raw)
821
- const rawColor = String(node.metadata?.color ?? '') || (raw.includes(':') ? raw.split(':')[1] : (raw.startsWith('#') ? raw : this.extractValue(node)))
822
- const color = this.sanitizeColor(rawColor)
1177
+ const rawColor = String(node.metadata?.color ?? '') || (raw.includes(':') ? raw.split(':')[1] : (raw.startsWith('#') ? raw : nodeAttrValue(node)))
1178
+ const color = sanitizeColor(rawColor, this.tokenResolver)
823
1179
  const content = this.renderChildren(node)
824
1180
  const idAttr = this.idAttr(node)
825
1181
 
@@ -852,7 +1208,7 @@ export class HTMLRenderer extends Visitor<string> {
852
1208
  }
853
1209
 
854
1210
  private renderAnim(node: RedNode): string {
855
- const raw = (String(node.metadata?.animType ?? '') || this.extractValue(node) || 'pulse').toLowerCase().trim()
1211
+ const raw = (String(node.metadata?.animType ?? '') || nodeAttrValue(node) || 'pulse').toLowerCase().trim()
856
1212
  const animType = raw.includes(':') ? raw.split(':')[0] : raw
857
1213
  const content = this.renderChildren(node)
858
1214
  const idAttr = this.idAttr(node)
@@ -880,7 +1236,7 @@ export class HTMLRenderer extends Visitor<string> {
880
1236
  }
881
1237
 
882
1238
  private renderContainer(node: RedNode): string {
883
- const raw = (String(node.metadata?.containerType ?? '') || this.extractValue(node) || 'stack').trim()
1239
+ const raw = (String(node.metadata?.containerType ?? '') || nodeAttrValue(node) || 'stack').trim()
884
1240
  const [type, ...params] = raw.split(':')
885
1241
  const containerType = type.toLowerCase()
886
1242
  const param = params.join(':')
@@ -904,7 +1260,7 @@ export class HTMLRenderer extends Visitor<string> {
904
1260
  return `<div${idAttr} class="bb-middle">${content}</div>`
905
1261
  case 'square':
906
1262
  case 'circle': {
907
- const accent = this.sanitizeColor(param)
1263
+ const accent = sanitizeColor(param, this.tokenResolver)
908
1264
  const border = accent ? this.hexToRgba(accent, 0.35) : ''
909
1265
  const bg = accent ? this.hexToRgba(accent, 0.08) : ''
910
1266
  const style = accent ? ` style="--square-accent:${accent};--square-border:${border};--square-bg:${bg};"` : ''
@@ -917,14 +1273,14 @@ export class HTMLRenderer extends Visitor<string> {
917
1273
  // del que el CSS deriva borde, tinte de fondo y —vía la clase
918
1274
  // `bb-accented`— la paleta del contenido sin color propio. Sin color,
919
1275
  // defaults y sin clase.
920
- const accent = this.sanitizeColor(param)
1276
+ const accent = sanitizeColor(param, this.tokenResolver)
921
1277
  const cls = accent ? ' bb-accented' : ''
922
1278
  const style = accent ? ` style="--${containerType}-accent:${accent};"` : ''
923
1279
  return `<div${idAttr} class="bb-cut-panel bb-${containerType}${cls}"${style}>${content}</div>`
924
1280
  }
925
1281
  case 'neon-box':
926
1282
  case 'neonbox': {
927
- const c = this.sanitizeColor(param || this.extractValue(node)) || 'var(--color-accent, #2EE6E2)'
1283
+ const c = sanitizeColor(param || nodeAttrValue(node), this.tokenResolver) || 'var(--color-accent, #2EE6E2)'
928
1284
  return `<div${idAttr} class="bb-cut-panel bb-neon-box" style="--neon-color:${c};border-color:${c};">${content}</div>`
929
1285
  }
930
1286
  default:
@@ -943,7 +1299,7 @@ export class HTMLRenderer extends Visitor<string> {
943
1299
  ])
944
1300
 
945
1301
  private renderStyleTag(node: RedNode): string {
946
- const raw = (String(node.metadata?.style ?? '') || this.extractValue(node) || '').trim()
1302
+ const raw = (String(node.metadata?.style ?? '') || nodeAttrValue(node) || '').trim()
947
1303
  const content = this.renderChildren(node)
948
1304
  const idAttr = this.idAttr(node)
949
1305
  if (!raw) return `<span${idAttr}>${content}</span>`
@@ -961,7 +1317,10 @@ export class HTMLRenderer extends Visitor<string> {
961
1317
  // color-mix()…) — antes se eliminaban y rompían esos valores. Lo que sí
962
1318
  // se bloquea son las funciones/fuentes peligrosas (url(), expression(),
963
1319
  // javascript:), que no tienen uso legítimo en el whitelist de props.
964
- const safeVal = val.replace(/["'{}<>]/g, '').slice(0, 100)
1320
+ let safeVal = val.replace(/["'{}<>]/g, '').slice(0, 100)
1321
+ if (safeVal.startsWith('$') && this.tokenResolver) {
1322
+ safeVal = resolveTokenValue(safeVal, this.tokenResolver)
1323
+ }
965
1324
  if (/url\s*\(|expression\s*\(|javascript\s*:/i.test(safeVal)) continue
966
1325
  safeStyles.push(`${prop}:${safeVal}`)
967
1326
  }
@@ -971,7 +1330,7 @@ export class HTMLRenderer extends Visitor<string> {
971
1330
  }
972
1331
 
973
1332
  private renderQuote(node: RedNode): string {
974
- const source = node.metadata?.source || this.extractValue(node)
1333
+ const source = node.metadata?.source || nodeAttrValue(node)
975
1334
  const content = this.renderChildren(node)
976
1335
  if (source) {
977
1336
  return `<blockquote${this.idAttr(node)}><div style="margin-bottom:8px"><strong>${this.escapeHtml(String(source))} wrote:</strong></div>${content}</blockquote>`
@@ -979,8 +1338,34 @@ export class HTMLRenderer extends Visitor<string> {
979
1338
  return `<blockquote${this.idAttr(node)}>${content}</blockquote>`
980
1339
  }
981
1340
 
1341
+ /**
1342
+ * La estructura exacta que `bbcode-spoilerbox` de osu-web espera.
1343
+ *
1344
+ * El toggle de osu es JS: `js-spoilerbox__link` es el gancho del click y
1345
+ * `js-spoilerbox__body` el panel que abre. Si falta cualquiera de las dos
1346
+ * clases el box queda mudo, así que la estructura no es decorativa.
1347
+ */
1348
+ private renderOsuSpoilerbox(node: RedNode, title: string, extra: string): string {
1349
+ const content = HTMLRenderer.trimOsuEdges(this.renderChildren(node))
1350
+ return `<div${this.idAttr(node)} class="js-spoilerbox bbcode-spoilerbox"${extra}>` +
1351
+ `<a class="js-spoilerbox__link bbcode-spoilerbox__link" href="#">` +
1352
+ `<span class="bbcode-spoilerbox__link-icon"></span>` +
1353
+ `<span class="bbcode-spoilerbox__link-text">${title}</span></a>` +
1354
+ `<div class="js-spoilerbox__body bbcode-spoilerbox__body">${content}</div></div>`
1355
+ }
1356
+
1357
+ /** El rótulo de un box bajo osu: el del autor, o `SPOILER` en mayúsculas. */
1358
+ private osuBoxTitle(node: RedNode): string {
1359
+ return this.hasOwnTitle(node) ? this.renderTitle(node, 'SPOILER') : 'SPOILER'
1360
+ }
1361
+
982
1362
  private renderSpoilerbox(node: RedNode): string {
1363
+ if (this.isOsu()) {
1364
+ // Un [spoilerbox] sin título propio se rotula SPOILER, en mayúsculas.
1365
+ return this.renderOsuSpoilerbox(node, this.osuBoxTitle(node), this.bareTitleAttr(node))
1366
+ }
983
1367
  const title = this.renderTitle(node, 'Spoiler')
1368
+ const bare = this.bareTitleAttr(node)
984
1369
  const content = this.renderChildren(node)
985
1370
  const isLyne = this.options.theme === 'lyne' || this.options.dialect === 'lyne'
986
1371
  const bodyCls = isLyne ? 'bb-box-body' : 'bbcode-box-body'
@@ -990,11 +1375,17 @@ export class HTMLRenderer extends Visitor<string> {
990
1375
  // `--box-accent` colorea el acento del box (título + chevron, y el borde
991
1376
  // en boxw) cuando el autor puso `[box=Title:#hex]`.
992
1377
  const accent = this.boxAccentStyle(node)
993
- return `<details${this.idAttr(node)}${accent}><summary><span class="bb-box-heading">${title}</span></summary><div class="${bodyCls}">${content}</div></details>`
1378
+ return `<details${this.idAttr(node)}${bare}${accent}><summary><span class="bb-box-heading">${title}</span></summary><div class="${bodyCls}">${content}</div></details>`
994
1379
  }
995
1380
 
996
1381
  private renderBox(node: RedNode): string {
1382
+ if (this.isOsu()) {
1383
+ // osu no distingue box de spoilerbox: es la misma construcción, y sin
1384
+ // título propio rotula SPOILER igual que `[spoilerbox]`.
1385
+ return this.renderOsuSpoilerbox(node, this.osuBoxTitle(node), this.bareTitleAttr(node))
1386
+ }
997
1387
  const title = this.renderTitle(node, 'Box')
1388
+ const bare = this.bareTitleAttr(node)
998
1389
  const content = this.renderChildren(node)
999
1390
  const isLyne = this.options.theme === 'lyne' || this.options.dialect === 'lyne'
1000
1391
  const bodyCls = isLyne ? 'bb-box-body' : 'bbcode-box-body'
@@ -1006,7 +1397,30 @@ export class HTMLRenderer extends Visitor<string> {
1006
1397
  // (no `bb-box-title`) evita la regla legacy `.bbcode-preview .bb-box-title`
1007
1398
  // de la app, que pinta un fondo sobre el título.
1008
1399
  const accent = this.boxAccentStyle(node)
1009
- return `<details${this.idAttr(node)} class="${cls}"${accent}><summary><span class="bb-box-heading">${title}</span></summary><div class="${bodyCls}">${content}</div></details>`
1400
+ return `<details${this.idAttr(node)} class="${cls}"${bare}${accent}><summary><span class="bb-box-heading">${title}</span></summary><div class="${bodyCls}">${content}</div></details>`
1401
+ }
1402
+
1403
+ /**
1404
+ * Marca un `[box]`/`[spoilerbox]` sin título propio.
1405
+ *
1406
+ * El `<summary>` siempre lleva texto ("Box", "Spoiler"), así que sin esta
1407
+ * marca el camino HTML→BBCode leía ese relleno como si el autor lo hubiera
1408
+ * escrito y devolvía `[box=Box]`.
1409
+ */
1410
+ private bareTitleAttr(node: RedNode): string {
1411
+ return this.hasOwnTitle(node) ? '' : ' data-bare-title="1"'
1412
+ }
1413
+
1414
+ /**
1415
+ * ¿El título del box lo escribió el autor, o es el relleno del parser?
1416
+ *
1417
+ * `BBCodeToGreenNode` ya deja `metadata.title = 'Box'`/`'Spoiler'` para un
1418
+ * tag pelado, así que el `fallback` de `renderTitle` nunca llega a usarse:
1419
+ * quien quiera otro rótulo por defecto tiene que preguntar por aquí.
1420
+ */
1421
+ private hasOwnTitle(node: RedNode): boolean {
1422
+ const raw = node.metadata?.rawTitle
1423
+ return raw !== undefined ? String(raw) !== '' : node.metadata?.title !== undefined
1010
1424
  }
1011
1425
 
1012
1426
  private renderTitle(node: RedNode, fallback: string): string {
@@ -1014,8 +1428,14 @@ export class HTMLRenderer extends Visitor<string> {
1014
1428
  if (titleNodes && titleNodes.length > 0) {
1015
1429
  return titleNodes.map(c => this.renderNode(c)).join('')
1016
1430
  }
1017
- const title = node.metadata?.title ?? this.extractValue(node) ?? fallback
1018
- return this.escapeHtml(String(title))
1431
+ let title = String(node.metadata?.title ?? nodeAttrValue(node) ?? fallback)
1432
+ if (this.tokenResolver) {
1433
+ title = title.replace(/\$([a-zA-Z_][a-zA-Z0-9_-]*)/g, (match, name) => {
1434
+ const resolved = this.tokenResolver!(name) ?? this.tokenResolver!(match)
1435
+ return resolved !== undefined ? resolved : match
1436
+ })
1437
+ }
1438
+ return this.escapeHtml(title)
1019
1439
  }
1020
1440
 
1021
1441
  /**
@@ -1025,7 +1445,7 @@ export class HTMLRenderer extends Visitor<string> {
1025
1445
  * borde + fondo en columns). Devuelve '' si no hay color válido.
1026
1446
  */
1027
1447
  private boxAccentStyle(node: RedNode, suffix: 'box' | 'table' | 'columns' = 'box'): string {
1028
- const color = this.sanitizeColor(String(node.metadata?.color ?? ''))
1448
+ const color = sanitizeColor(String(node.metadata?.color ?? ''), this.tokenResolver)
1029
1449
  if (!color) return ''
1030
1450
  const border = this.hexToRgba(color, 0.35)
1031
1451
  const bg = this.hexToRgba(color, 0.08)
@@ -1034,7 +1454,7 @@ export class HTMLRenderer extends Visitor<string> {
1034
1454
 
1035
1455
  private renderList(node: RedNode): string {
1036
1456
  // Check for ordered list: [list=1], [list=a], or metadata
1037
- const attrs = this.extractValue(node)
1457
+ const attrs = nodeAttrValue(node)
1038
1458
  const isOrdered = node.metadata?.ordered === true || attrs === '1' || attrs === 'a'
1039
1459
  const tag = isOrdered ? 'ol' : 'ul'
1040
1460
  const content = node.children.map(c => this.renderNode(c)).join('\n')
@@ -1055,7 +1475,12 @@ export class HTMLRenderer extends Visitor<string> {
1055
1475
  const trailingMatch = content.match(/(?:[\r\n][\t ]*)+$/)
1056
1476
  if (trailingMatch) content = content.slice(0, -trailingMatch[0].length)
1057
1477
 
1058
- return `<pre${this.idAttr(node)}><code>${this.escapeHtml(content)}</code></pre>`
1478
+ // Los saltos recortados arriba son de presentación: si no se anotan, el
1479
+ // camino HTML→BBCode los da por inexistentes y reescribe el bloque del
1480
+ // autor como `[code]…[/code]` en una sola línea.
1481
+ const pad = `${leadingMatch ? leadingMatch[0] : ''}\u0000${trailingMatch ? trailingMatch[0] : ''}`
1482
+ const padAttr = pad === '\u0000' ? '' : ` data-code-pad="${this.escapeHtml(JSON.stringify(pad))}"`
1483
+ return `<pre${this.idAttr(node)}${padAttr}><code>${this.escapeHtml(content)}</code></pre>`
1059
1484
  }
1060
1485
 
1061
1486
  private renderSVG(node: RedNode): string {
@@ -1091,12 +1516,12 @@ export class HTMLRenderer extends Visitor<string> {
1091
1516
  .filter(Boolean)
1092
1517
 
1093
1518
  if (textLines.length === 0) {
1094
- return '<div class="media-error">[imagemap] missing image URL</div>'
1519
+ return this.mediaError('imagemap', '[imagemap] missing image URL')
1095
1520
  }
1096
1521
 
1097
1522
  const imageUrl = textLines[0]
1098
1523
  if (!imageUrl.startsWith('http://') && !imageUrl.startsWith('https://')) {
1099
- return `<div class="media-error">[imagemap] invalid image URL: ${this.escapeHtml(imageUrl)}</div>`
1524
+ return this.mediaError('imagemap', `[imagemap] invalid image URL: ${this.escapeHtml(imageUrl)}`)
1100
1525
  }
1101
1526
 
1102
1527
  let areas = ''
@@ -1119,9 +1544,26 @@ export class HTMLRenderer extends Visitor<string> {
1119
1544
  areaUrl = 'https://' + areaUrl
1120
1545
  }
1121
1546
 
1547
+ if (this.isOsu()) {
1548
+ // osu posiciona por CSS (`.imagemap__link` ya es absolute), así que el
1549
+ // style inline lleva sólo las cuatro coordenadas. Un destino `#` no es
1550
+ // un enlace: emite un `span` con la misma clase, para que la zona siga
1551
+ // mostrando su `title` sin navegar a ninguna parte.
1552
+ const pos = `left:${x}%;top:${y}%;width:${w}%;height:${h}%;`
1553
+ const title = ` title="${this.escapeHtml(label)}"`
1554
+ areas += url === '#'
1555
+ ? `<span class="imagemap__link" style="${pos}"${title}></span>`
1556
+ : `<a class="imagemap__link" href="${this.escapeHtml(areaUrl)}" style="${pos}"${title}></a>`
1557
+ continue
1558
+ }
1122
1559
  areas += `<a${this.idAttr(node)} href="${this.escapeHtml(areaUrl)}" target="_blank" rel="noopener" class="imagemap-area bbcode-imap-area" style="position:absolute;left:${x}%;top:${y}%;width:${w}%;height:${h}%;" title="${this.escapeHtml(label || 'Link')}"></a>`
1123
1560
  }
1124
1561
 
1562
+ if (this.isOsu()) {
1563
+ return `<div${this.idAttr(node)} class="imagemap">` +
1564
+ `<img class="imagemap__image" loading="lazy" src="${this.escapeHtml(imageUrl)}" alt="">${areas}</div>`
1565
+ }
1566
+
1125
1567
  return `<div${this.idAttr(node)} class="imagemap-container bbcode-imagemap" style="position:relative;display:inline-block;"><img src="${this.escapeHtml(imageUrl)}" alt="imagemap" style="max-width:100%;height:auto;display:block;">${areas}</div>`
1126
1568
  }
1127
1569
 
@@ -1133,26 +1575,127 @@ export class HTMLRenderer extends Visitor<string> {
1133
1575
  return node.text || ''
1134
1576
  }
1135
1577
 
1578
+ /**
1579
+ * Effect parameters off a node, with every colour re-validated.
1580
+ *
1581
+ * `startsWith('#')` was NOT a filter: `#a" onmouseover="alert(1)` passes
1582
+ * it. Every stop has to survive the full colour allowlist or be dropped,
1583
+ * because these values end up inside a `style` attribute.
1584
+ */
1585
+ private effectParams(node: RedNode): EffectParams {
1586
+ const meta = (node.metadata ?? {}) as Record<string, unknown>
1587
+ const params: EffectParams = {}
1588
+ for (const [key, value] of Object.entries(meta)) {
1589
+ if (key === 'globalOffset' || key === 'documentLength') continue
1590
+ ;(params as Record<string, unknown>)[key] = value
1591
+ }
1592
+
1593
+ if (Array.isArray(params.colors)) {
1594
+ params.colors = params.colors
1595
+ .map(c => (typeof c === 'string' ? sanitizeColor(c, this.tokenResolver) : null))
1596
+ .filter((c): c is string => c !== null)
1597
+ if (params.colors.length === 0) delete params.colors
1598
+ }
1599
+ if (Array.isArray(params.stops)) {
1600
+ params.stops = params.stops
1601
+ .filter(st => st && typeof st.color === 'string' && sanitizeColor(st.color, this.tokenResolver) !== null)
1602
+ if (params.stops.length === 0) delete params.stops
1603
+ }
1604
+ return params
1605
+ }
1606
+
1607
+ /** Text inside an effect node, ignoring layout-only children. */
1608
+ /**
1609
+ * The plain text an effect modulates, line breaks included.
1610
+ *
1611
+ * A break is a childless `spacing` leaf carrying no text, so collecting
1612
+ * it naively contributes nothing and the effect sees one long line.
1613
+ * Every two-dimensional axis then collapses: `line` reports zero for
1614
+ * the whole block, `column` stretches one row across the paragraph, and
1615
+ * `radial` measures a rectangle one line tall. The break was being
1616
+ * dropped from the OUTPUT as well, so a multi-line effect previewed as
1617
+ * a single run-on line.
1618
+ *
1619
+ * The document's own plain text spells a break `\n` — that is what
1620
+ * `stripColorWrappers` and the studio's sampler both count — so this
1621
+ * agrees with them rather than inventing a third answer.
1622
+ */
1623
+ private effectText(node: RedNode): string {
1624
+ return node.children.map(c => this.effectChildText(c)).join('')
1625
+ }
1626
+
1627
+ private effectChildText(node: RedNode): string {
1628
+ if (node.kind === 'spacing' || node.kind === 'empty_line') return '\n'
1629
+ if (node.kind === 'text') return node.text ?? ''
1630
+ if (node.children.length === 0) return this.collectNodeText(node)
1631
+ return node.children.map(c => this.effectChildText(c)).join('')
1632
+ }
1633
+
1634
+ /**
1635
+ * Paint an effect's computed segments as spans.
1636
+ *
1637
+ * Shared by rainbow, grow and any modulated gradient, so the preview
1638
+ * shows exactly the colours and sizes the BBCode export will contain
1639
+ * rather than an approximation of them.
1640
+ */
1641
+ private renderEffectSegments(node: RedNode, kind: EffectKind): string {
1642
+ const text = this.effectText(node)
1643
+ if (!text) return this.renderChildren(node)
1644
+
1645
+ const meta = (node.metadata ?? {}) as Record<string, unknown>
1646
+ const segments = evaluateEffect(text, kind, this.effectParams(node), {
1647
+ globalOffset: meta.globalOffset as number | undefined,
1648
+ documentLength: meta.documentLength as number | undefined,
1649
+ })
1650
+
1651
+ let out = ''
1652
+ for (const seg of segments) {
1653
+ // The break is markup, not text: escaping it would print a newline
1654
+ // that HTML collapses to a space, which is how a painted block of
1655
+ // ASCII art would arrive as one unreadable line.
1656
+ // El `indexOf` delante: un degradado sin fusionar emite un segmento POR
1657
+ // CARÁCTER, y `/\n/g` es un literal —en ES2015+ eso asigna un objeto
1658
+ // nuevo cada vez que se evalúa— para recorrer una cadena de un carácter
1659
+ // que casi nunca es un salto de línea.
1660
+ const html = this.escapeHtml(seg.text)
1661
+ const escaped = html.indexOf('\n') === -1 ? html : html.replace(NEWLINE_RE, '<br>')
1662
+ if (seg.color) {
1663
+ const safe = sanitizeColor(seg.color, this.tokenResolver)
1664
+ out += safe ? `<span style="color:${safe}">${escaped}</span>` : escaped
1665
+ } else if (seg.size !== undefined) {
1666
+ out += `<span style="font-size:${Math.max(10, Math.min(400, seg.size))}%">${escaped}</span>`
1667
+ } else {
1668
+ out += escaped
1669
+ }
1670
+ }
1671
+ return `<span${this.idAttr(node)} class="bb-effect bb-${kind}">${out}</span>`
1672
+ }
1673
+
1674
+ /**
1675
+ * A gradient previews as a real CSS gradient when it is a plain
1676
+ * left-to-right ramp, and as computed per-character spans otherwise.
1677
+ *
1678
+ * The CSS path is genuinely smoother — it interpolates per pixel rather
1679
+ * than per glyph — but it can only express a linear sweep. A gradient
1680
+ * with a waveform, a non-index axis or quantisation steps looked
1681
+ * nothing like its own export while this was the only path.
1682
+ */
1136
1683
  private renderGradient(node: RedNode): string {
1137
- // `startsWith('#')` was NOT a filter: `#a" onmouseover="alert(1)` passes it.
1138
- // Every stop must survive the full color allowlist or it is dropped.
1139
- const raw = (node.metadata?.colors as string[]) || []
1140
- const valid = raw
1141
- .map(c => (typeof c === 'string' ? this.sanitizeColor(c) : null))
1142
- .filter((c): c is string => c !== null)
1143
- const colors =
1144
- valid.length === 0 ? ['#FF0000', '#00FF00']
1145
- : valid.length === 1 ? [valid[0], valid[0]]
1146
- : valid
1147
- // Skip spacing/empty_line children so source formatting (newlines inside the tag)
1148
- // doesn't produce extra <br> in the preview. Both:
1149
- // [gradient]Hello[/gradient] and [gradient]\nHello\n[/gradient]
1150
- // render identically.
1684
+ const params = this.effectParams(node)
1151
1685
  const content = node.children
1152
1686
  .filter(c => c.kind !== 'spacing' && c.kind !== 'empty_line')
1153
1687
  .map(c => this.renderNode(c)).join('')
1154
- const gradientCss = `linear-gradient(to right, ${colors.join(', ')})`
1155
- return `<span${this.idAttr(node)} style="background: ${gradientCss}; -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text;">${content}</span>`
1688
+
1689
+ if (isPlainGradient(params)) {
1690
+ const stops = params.stops && params.stops.length > 0
1691
+ ? params.stops.map(st => `${st.color} ${(st.position * 100).toFixed(1)}%`)
1692
+ : (params.colors ?? ['#FF0000', '#00FF00'])
1693
+ const list = stops.length === 1 ? [stops[0], stops[0]] : stops
1694
+ const gradientCss = `linear-gradient(to right, ${list.join(', ')})`
1695
+ return `<span${this.idAttr(node)} style="background: ${gradientCss}; -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text;">${content}</span>`
1696
+ }
1697
+
1698
+ return this.renderEffectSegments(node, 'gradient')
1156
1699
  }
1157
1700
 
1158
1701
  /**
@@ -1281,3 +1824,26 @@ export class HTMLRenderer extends Visitor<string> {
1281
1824
  return out + text.slice(last)
1282
1825
  }
1283
1826
  }
1827
+
1828
+ /**
1829
+ * Whether a gradient is a plain left-to-right ramp.
1830
+ *
1831
+ * Only then can CSS express it. Anything with a waveform, a non-index
1832
+ * axis, quantisation, inversion or per-word stepping has to be painted
1833
+ * per character instead, or the preview and the export disagree.
1834
+ */
1835
+ function isPlainGradient(params: EffectParams): boolean {
1836
+ // A tag carrying a span is one fragment of a longer ramp. Painting it
1837
+ // as a full-width CSS gradient restarts the sweep inside every
1838
+ // fragment, which is exactly the discontinuity the span exists to fix.
1839
+ if (params.documentLength !== undefined && params.documentLength > 1) return false
1840
+ return (params.wave === undefined || params.wave === 'none')
1841
+ && (params.axis === undefined || params.axis === 'index')
1842
+ && (params.easing === undefined || params.easing === 'linear')
1843
+ && (params.steps === undefined || params.steps < 2)
1844
+ && !params.invert
1845
+ && (params.unit === undefined || params.unit === 'character')
1846
+ && (params.cycles === undefined || params.cycles === 1)
1847
+ && (params.phase === undefined || params.phase === 0)
1848
+ && (params.expression === undefined || params.expression === '')
1849
+ }