@miliastry/quasar 1.0.5 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (273) hide show
  1. package/dist/Visuals/lyne.css +191 -21
  2. package/dist/Visuals/osu.css +36 -5
  3. package/dist/index.js +9619 -3135
  4. package/dist/index.mjs +9490 -3136
  5. package/dist/types/Analysis/Contracts/AnalysisReport.d.ts +22 -0
  6. package/dist/types/Analysis/Contracts/Contribution.d.ts +68 -0
  7. package/dist/types/Analysis/Contracts/Pass.d.ts +59 -0
  8. package/dist/types/Analysis/Contracts/PipelineContext.d.ts +34 -0
  9. package/dist/types/Analysis/Passes/Analysis/ColorUsageAnalyzer.d.ts +56 -0
  10. package/dist/types/Analysis/Passes/Analysis/GradientAnalyzer.d.ts +111 -0
  11. package/dist/types/Analysis/Passes/Analysis/MergeableColorAnalyzer.d.ts +26 -0
  12. package/dist/types/Analysis/Passes/Analysis/RainbowAnalyzer.d.ts +47 -0
  13. package/dist/types/Analysis/Passes/Analysis/SymbolAnalyzer.d.ts +89 -0
  14. package/dist/types/Analysis/Passes/Analysis/WaveAnalyzer.d.ts +44 -0
  15. package/dist/types/Analysis/Passes/Analysis/__tests__/GradientAnalyzer.test.d.ts +4 -0
  16. package/dist/types/Analysis/Passes/Analysis/__tests__/MergeableColorAnalyzer.test.d.ts +4 -0
  17. package/dist/types/Analysis/Passes/Analysis/__tests__/RainbowAnalyzer.test.d.ts +4 -0
  18. package/dist/types/Analysis/Passes/Analysis/__tests__/WaveAnalyzer.test.d.ts +4 -0
  19. package/dist/types/Analysis/Passes/Decision/DefaultDecision.d.ts +30 -0
  20. package/dist/types/Analysis/Passes/Decision/PaletteRemapDecision.d.ts +169 -0
  21. package/dist/types/Analysis/Passes/Decision/__tests__/DefaultDecision.test.d.ts +4 -0
  22. package/dist/types/Analysis/Passes/Transform/CollapseGradientTransform.d.ts +37 -0
  23. package/dist/types/Analysis/Passes/Transform/MergeColorsTransform.d.ts +28 -0
  24. package/dist/types/Analysis/Passes/Transform/RainbowCollapseTransform.d.ts +19 -0
  25. package/dist/types/Analysis/Passes/Transform/WaveCollapseTransform.d.ts +20 -0
  26. package/dist/types/Analysis/Passes/Utility/CharacterCountAnalyzer.d.ts +19 -0
  27. package/dist/types/Analysis/Pipeline/Pipeline.d.ts +69 -0
  28. package/dist/types/Analysis/Pipeline/PipelineBuilder.d.ts +31 -0
  29. package/dist/types/Analysis/Pipeline/PipelineStage.d.ts +17 -0
  30. package/dist/types/Analysis/Utils/color-utils.d.ts +60 -0
  31. package/dist/types/Analysis/__tests__/Integration.test.d.ts +8 -0
  32. package/dist/types/Analysis/__tests__/PaletteRemap.test.d.ts +14 -0
  33. package/dist/types/Analysis/__tests__/Pipeline.test.d.ts +10 -0
  34. package/dist/types/Analysis/__tests__/RoundTrip.test.d.ts +30 -0
  35. package/dist/types/Analysis/__tests__/SymbolAnalyzer.test.d.ts +11 -0
  36. package/dist/types/Analysis/index.d.ts +40 -0
  37. package/dist/types/BBCode/BBCodeDocumentModel.d.ts +92 -0
  38. package/dist/types/BBCode/BBCodeToGreenNode.d.ts +111 -0
  39. package/dist/types/BBCode/Parser.d.ts +78 -0
  40. package/dist/types/BBCode/index.d.ts +4 -0
  41. package/dist/types/Collab/positions.d.ts +55 -0
  42. package/dist/types/Commands/Command.d.ts +37 -0
  43. package/dist/types/Commands/CommandRegistry.d.ts +48 -0
  44. package/dist/types/Commands/DeleteNode.d.ts +7 -0
  45. package/dist/types/Commands/InsertText.d.ts +8 -0
  46. package/dist/types/Commands/SplitMerge.d.ts +9 -0
  47. package/dist/types/Commands/WrapInTag.d.ts +8 -0
  48. package/dist/types/Commands/index.d.ts +6 -0
  49. package/dist/types/Diff/TreeDiffer.d.ts +52 -0
  50. package/dist/types/Diff/__tests__/TreeDiffer.test.d.ts +1 -0
  51. package/dist/types/Diff/index.d.ts +2 -0
  52. package/dist/types/Edits/EditPlan.d.ts +144 -0
  53. package/dist/types/Edits/Optimizer.d.ts +78 -0
  54. package/dist/types/Edits/Rules/Rule.d.ts +91 -0
  55. package/dist/types/Edits/Rules/dropEmptyTags.d.ts +40 -0
  56. package/dist/types/Edits/Rules/dropRedundantNesting.d.ts +59 -0
  57. package/dist/types/Edits/Rules/index.d.ts +9 -0
  58. package/dist/types/Edits/Rules/mergeAdjacent.d.ts +93 -0
  59. package/dist/types/Edits/Rules/reorderWrappers.d.ts +42 -0
  60. package/dist/types/Edits/Rules/shortenHex.d.ts +30 -0
  61. package/dist/types/Edits/Rules/tagValue.d.ts +56 -0
  62. package/dist/types/Edits/Rules/unwrapInvisibleColor.d.ts +47 -0
  63. package/dist/types/Edits/applyEdits.d.ts +36 -0
  64. package/dist/types/Edits/index.d.ts +6 -0
  65. package/dist/types/Events/EventBus.d.ts +103 -0
  66. package/dist/types/Events/index.d.ts +2 -0
  67. package/dist/types/Formatter/Formatter.d.ts +32 -0
  68. package/dist/types/Formatter/index.d.ts +2 -0
  69. package/dist/types/HTML/HTMLDocumentModel.d.ts +13 -0
  70. package/dist/types/HTML/HTMLToGreenNode.d.ts +4 -0
  71. package/dist/types/Incremental/BracketIndex.d.ts +101 -0
  72. package/dist/types/Incremental/ChangeTracker.d.ts +69 -0
  73. package/dist/types/Incremental/IncrementalParser.d.ts +246 -0
  74. package/dist/types/Incremental/__tests__/BracketIndex.test.d.ts +1 -0
  75. package/dist/types/Incremental/__tests__/IncrementalParser.test.d.ts +1 -0
  76. package/dist/types/Incremental/index.d.ts +4 -0
  77. package/dist/types/Lexer/BBCodeLexer.d.ts +74 -0
  78. package/dist/types/Lexer/Lexer.d.ts +54 -0
  79. package/dist/types/Lexer/index.d.ts +4 -0
  80. package/dist/types/Linter/Linter.d.ts +58 -0
  81. package/dist/types/Linter/index.d.ts +2 -0
  82. package/dist/types/Markdown/MarkdownAST.d.ts +122 -0
  83. package/dist/types/Markdown/MarkdownDocumentModel.d.ts +15 -0
  84. package/dist/types/Markdown/MarkdownLexer.d.ts +155 -0
  85. package/dist/types/Markdown/MarkdownParser.d.ts +42 -0
  86. package/dist/types/Markdown/MarkdownToGreenNode.d.ts +6 -0
  87. package/dist/types/MilHibri/MilHibriDocumentModel.d.ts +21 -0
  88. package/dist/types/Model/DocumentModel.d.ts +318 -0
  89. package/dist/types/Model/NodeFactory.d.ts +29 -0
  90. package/dist/types/Model/TagRegistry.d.ts +165 -0
  91. package/dist/types/Model/index.d.ts +5 -0
  92. package/dist/types/Plugins/PluginAPI.d.ts +44 -0
  93. package/dist/types/Plugins/PluginRegistry.d.ts +81 -0
  94. package/dist/types/Plugins/index.d.ts +3 -0
  95. package/dist/types/Queries/QueryEngine.d.ts +32 -0
  96. package/dist/types/Queries/index.d.ts +1 -0
  97. package/dist/types/Reconciler/SurgicalReconciler.d.ts +23 -0
  98. package/dist/types/RenderPipeline/RenderPipeline.d.ts +46 -0
  99. package/dist/types/RenderPipeline/RenderTree.d.ts +55 -0
  100. package/dist/types/RenderPipeline/index.d.ts +4 -0
  101. package/dist/types/Repair/NestingRepair.d.ts +48 -0
  102. package/dist/types/Repair/index.d.ts +1 -0
  103. package/dist/types/Semantic/SemanticAnalyzer.d.ts +321 -0
  104. package/dist/types/Semantic/index.d.ts +2 -0
  105. package/dist/types/Symbols/SymbolTable.d.ts +47 -0
  106. package/dist/types/Symbols/index.d.ts +1 -0
  107. package/dist/types/Syntax/GreenNode.d.ts +154 -0
  108. package/dist/types/Syntax/GreenNodePool.d.ts +134 -0
  109. package/dist/types/Syntax/NodeMatcher.d.ts +75 -0
  110. package/dist/types/Syntax/RedNode.d.ts +280 -0
  111. package/dist/types/Syntax/RedNodeStore.d.ts +116 -0
  112. package/dist/types/Syntax/TreeBuilder.d.ts +54 -0
  113. package/dist/types/Syntax/__tests__/GreenNode.test.d.ts +1 -0
  114. package/dist/types/Syntax/__tests__/RedNode.test.d.ts +1 -0
  115. package/dist/types/Syntax/__tests__/RedNodeStore.test.d.ts +1 -0
  116. package/dist/types/Syntax/greenEdit.d.ts +64 -0
  117. package/dist/types/Syntax/hash.d.ts +17 -0
  118. package/dist/types/Syntax/index.d.ts +8 -0
  119. package/dist/types/Syntax/nodeAttr.d.ts +63 -0
  120. package/dist/types/Syntax/partition.d.ts +67 -0
  121. package/dist/types/Syntax/preserveNodeIds.d.ts +47 -0
  122. package/dist/types/Tokens/TokenResolver.d.ts +25 -0
  123. package/dist/types/Tokens/index.d.ts +1 -0
  124. package/dist/types/Transactions/Transaction.d.ts +36 -0
  125. package/dist/types/Transactions/UndoManager.d.ts +63 -0
  126. package/dist/types/Transactions/index.d.ts +3 -0
  127. package/dist/types/Transformers/ASTOptimizer.d.ts +34 -0
  128. package/dist/types/Transformers/GradientTransformer.d.ts +18 -0
  129. package/dist/types/Transformers/GrowTransformer.d.ts +16 -0
  130. package/dist/types/Transformers/RainbowTransformer.d.ts +17 -0
  131. package/dist/types/Transformers/SineWaveTransformer.d.ts +17 -0
  132. package/dist/types/Transformers/Transformer.d.ts +17 -0
  133. package/dist/types/Types/core.d.ts +104 -0
  134. package/dist/types/Types/diagnostics.d.ts +85 -0
  135. package/dist/types/Types/index.d.ts +11 -0
  136. package/dist/types/Types/operations.d.ts +109 -0
  137. package/dist/types/Types/queries.d.ts +67 -0
  138. package/dist/types/Types/symbols.d.ts +42 -0
  139. package/dist/types/Types/tokens.d.ts +75 -0
  140. package/dist/types/Utils/BBCodeGenerator.d.ts +21 -0
  141. package/dist/types/Utils/ColorMath.d.ts +92 -0
  142. package/dist/types/Utils/EffectMath.d.ts +643 -0
  143. package/dist/types/Utils/TagVocabulary.d.ts +104 -0
  144. package/dist/types/Utils/TagVocabulary.test.d.ts +1 -0
  145. package/dist/types/Utils/color.d.ts +29 -0
  146. package/dist/types/Utils/dom-to-svg.d.ts +28 -0
  147. package/dist/types/Utils/dom-to-svg.test.d.ts +1 -0
  148. package/dist/types/Utils/treeTransformers.d.ts +160 -0
  149. package/dist/types/Visitors/BBBlocksExporter.d.ts +27 -0
  150. package/dist/types/Visitors/BBCodeExporter.d.ts +90 -0
  151. package/dist/types/Visitors/BlockPatcher.d.ts +91 -0
  152. package/dist/types/Visitors/DOMMorpher.d.ts +13 -0
  153. package/dist/types/Visitors/HTMLRenderer.d.ts +380 -0
  154. package/dist/types/Visitors/JSONExporter.d.ts +42 -0
  155. package/dist/types/Visitors/MarkdownExporter.d.ts +28 -0
  156. package/dist/types/Visitors/SVGRenderer.d.ts +16 -0
  157. package/dist/types/Visitors/TiptapExporter.d.ts +40 -0
  158. package/dist/types/Visitors/Visitor.d.ts +35 -0
  159. package/dist/types/Visitors/index.d.ts +9 -0
  160. package/dist/types/Visuals/BoxDrawer.d.ts +49 -0
  161. package/dist/types/Visuals/index.d.ts +38 -0
  162. package/dist/types/index.d.ts +154 -0
  163. package/package.json +14 -10
  164. package/src/Analysis/Passes/Analysis/ColorUsageAnalyzer.ts +103 -0
  165. package/src/Analysis/Passes/Analysis/GradientAnalyzer.ts +294 -20
  166. package/src/Analysis/Passes/Analysis/SymbolAnalyzer.ts +257 -0
  167. package/src/Analysis/Passes/Decision/PaletteRemapDecision.ts +425 -0
  168. package/src/Analysis/Pipeline/Pipeline.ts +15 -2
  169. package/src/Analysis/index.ts +8 -2
  170. package/src/BBCode/BBCodeDocumentModel.ts +3 -0
  171. package/src/BBCode/BBCodeToGreenNode.ts +34 -11
  172. package/src/BBCode/Parser.ts +38 -1
  173. package/src/Edits/EditPlan.ts +290 -0
  174. package/src/Edits/Optimizer.ts +185 -0
  175. package/src/Edits/Rules/Rule.ts +145 -0
  176. package/src/Edits/Rules/dropEmptyTags.ts +110 -0
  177. package/src/Edits/Rules/dropRedundantNesting.ts +120 -0
  178. package/src/Edits/Rules/index.ts +28 -0
  179. package/src/Edits/Rules/mergeAdjacent.ts +224 -0
  180. package/src/Edits/Rules/reorderWrappers.ts +154 -0
  181. package/src/Edits/Rules/shortenHex.ts +79 -0
  182. package/src/Edits/Rules/tagValue.ts +105 -0
  183. package/src/Edits/Rules/unwrapInvisibleColor.ts +93 -0
  184. package/src/Edits/applyEdits.ts +54 -0
  185. package/src/Edits/index.ts +22 -0
  186. package/src/Events/EventBus.ts +18 -0
  187. package/src/HTML/HTMLToGreenNode.ts +185 -37
  188. package/src/Incremental/BracketIndex.ts +249 -0
  189. package/src/Incremental/IncrementalParser.ts +464 -41
  190. package/src/Lexer/BBCodeLexer.ts +25 -3
  191. package/src/Lexer/index.ts +1 -1
  192. package/src/Markdown/MarkdownAST.ts +65 -2
  193. package/src/Markdown/MarkdownLexer.ts +69 -14
  194. package/src/Markdown/MarkdownParser.ts +749 -62
  195. package/src/Markdown/MarkdownToGreenNode.ts +148 -27
  196. package/src/MilHibri/MilHibriDocumentModel.ts +56 -0
  197. package/src/Model/DocumentModel.ts +138 -5
  198. package/src/Model/TagRegistry.ts +132 -134
  199. package/src/Reconciler/SurgicalReconciler.ts +460 -0
  200. package/src/Repair/NestingRepair.ts +116 -0
  201. package/src/Repair/index.ts +1 -0
  202. package/src/Semantic/SemanticAnalyzer.ts +1802 -65
  203. package/src/Syntax/GreenNodePool.ts +12 -12
  204. package/src/Syntax/RedNode.ts +17 -3
  205. package/src/Syntax/RedNodeStore.ts +9 -1
  206. package/src/Syntax/nodeAttr.ts +172 -0
  207. package/src/Tokens/TokenResolver.ts +55 -0
  208. package/src/Tokens/index.ts +1 -0
  209. package/src/Types/core.ts +15 -0
  210. package/src/Utils/ColorMath.ts +276 -55
  211. package/src/Utils/EffectMath.ts +2462 -0
  212. package/src/Utils/TagVocabulary.ts +141 -0
  213. package/src/Utils/dom-to-svg.ts +28 -12
  214. package/src/Visitors/BBBlocksExporter.ts +15 -1
  215. package/src/Visitors/BBCodeExporter.ts +319 -32
  216. package/src/Visitors/BlockPatcher.ts +87 -11
  217. package/src/Visitors/DOMMorpher.ts +15 -5
  218. package/src/Visitors/HTMLRenderer.ts +452 -142
  219. package/src/Visitors/MarkdownExporter.ts +98 -41
  220. package/src/Visitors/TiptapExporter.ts +1 -0
  221. package/src/Visuals/index.ts +7 -4
  222. package/src/Visuals/lyne.css +191 -21
  223. package/src/Visuals/osu.css +36 -5
  224. package/src/index.ts +172 -5
  225. package/dist/index.d.mts +0 -4609
  226. package/dist/index.d.ts +0 -4609
  227. package/src/Analysis/Passes/Analysis/__tests__/GradientAnalyzer.test.ts +0 -135
  228. package/src/Analysis/Passes/Analysis/__tests__/MergeableColorAnalyzer.test.ts +0 -84
  229. package/src/Analysis/Passes/Analysis/__tests__/RainbowAnalyzer.test.ts +0 -99
  230. package/src/Analysis/Passes/Analysis/__tests__/WaveAnalyzer.test.ts +0 -119
  231. package/src/Analysis/Passes/Decision/__tests__/DefaultDecision.test.ts +0 -179
  232. package/src/Analysis/__tests__/Integration.test.ts +0 -162
  233. package/src/Analysis/__tests__/Pipeline.test.ts +0 -133
  234. package/src/Diff/__tests__/TreeDiffer.test.ts +0 -65
  235. package/src/Incremental/__tests__/IncrementalParser.test.ts +0 -164
  236. package/src/Syntax/__tests__/GreenNode.test.ts +0 -33
  237. package/src/Syntax/__tests__/RedNode.test.ts +0 -81
  238. package/src/Syntax/__tests__/RedNodeStore.test.ts +0 -104
  239. package/src/Tests/ASTOptimizerIdempotence.test.ts +0 -77
  240. package/src/Tests/BlockPatcher.test.ts +0 -437
  241. package/src/Tests/BlockPatcherWindowed.test.ts +0 -364
  242. package/src/Tests/BoxDrawer.test.ts +0 -217
  243. package/src/Tests/BoxRichTitle.test.ts +0 -105
  244. package/src/Tests/Chars500kBenchmark.test.ts +0 -151
  245. package/src/Tests/Chars500kEdits.test.ts +0 -321
  246. package/src/Tests/CollabPositions.test.ts +0 -146
  247. package/src/Tests/CompilerPathProfiling.test.ts +0 -186
  248. package/src/Tests/DOMMorpher.test.ts +0 -142
  249. package/src/Tests/DomPatchPerf.test.ts +0 -60
  250. package/src/Tests/EffectSegments.snapshot.json +0 -616
  251. package/src/Tests/EffectSegments.test.ts +0 -68
  252. package/src/Tests/FindNodeAtOffset.test.ts +0 -65
  253. package/src/Tests/Fuzzer.test.ts +0 -166
  254. package/src/Tests/GreenNodePool.test.ts +0 -153
  255. package/src/Tests/Lexer.test.ts +0 -238
  256. package/src/Tests/LyneMode.test.ts +0 -291
  257. package/src/Tests/ModelCoherence.test.ts +0 -180
  258. package/src/Tests/Partition.test.ts +0 -238
  259. package/src/Tests/PluginTags.test.ts +0 -150
  260. package/src/Tests/ProblematicSection.test.ts +0 -46
  261. package/src/Tests/ProblematicSectionHTML.test.ts +0 -58
  262. package/src/Tests/RedReuse.test.ts +0 -134
  263. package/src/Tests/ReproDelete20k.test.ts +0 -62
  264. package/src/Tests/SemanticValidators.test.ts +0 -136
  265. package/src/Tests/StableNodeIds.test.ts +0 -210
  266. package/src/Tests/StudioColorBloat.test.ts +0 -25
  267. package/src/Tests/StudioDebugText.test.ts +0 -27
  268. package/src/Tests/StudioTrailingChar.test.ts +0 -25
  269. package/src/Tests/StudioValidText.test.ts +0 -25
  270. package/src/Tests/UrlImgBug.test.ts +0 -23
  271. package/src/Tests/VisualBuilderFidelity.test.ts +0 -105
  272. package/src/Tests/referenceDocument.ts +0 -119
  273. package/src/Utils/dom-to-svg.test.ts +0 -86
@@ -9,11 +9,25 @@
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
33
  /**
@@ -44,19 +58,30 @@ export interface HTMLRendererOptions {
44
58
  * timestamp as plain text. Used by map hosts that deep-link into an editor.
45
59
  */
46
60
  timestampResolver?: (ms: number, label: string) => { href: string; external?: boolean } | null
61
+ /** Design tokens or resolver function */
62
+ tokens?: TokenSource
47
63
  }
48
64
 
65
+ /** Saltos de línea a convertir en `<br>` al pintar un efecto. */
66
+ const NEWLINE_RE = /\n/g
67
+
68
+ /** Referencia a un token de diseño dentro de un texto: `$nombre`. */
69
+ const TOKEN_REF_RE = /\$([a-zA-Z_][a-zA-Z0-9_-]*)/g
70
+
49
71
  export class HTMLRenderer extends Visitor<string> {
50
- private options: Required<Omit<HTMLRendererOptions, 'registry' | 'mediaProxy' | 'entityLinkResolver' | 'mentionResolver' | 'timestampResolver'>> & {
72
+ private tokenResolver?: TokenResolverFn
73
+ private options: Required<Omit<HTMLRendererOptions, 'registry' | 'mediaProxy' | 'entityLinkResolver' | 'mentionResolver' | 'timestampResolver' | 'tokens'>> & {
51
74
  registry?: TagRegistry
52
75
  mediaProxy?: (url: string) => string
53
76
  entityLinkResolver?: (kind: string, value: string) => { href: string; external?: boolean } | null
54
77
  mentionResolver?: (name: string) => { href: string; external?: boolean } | null
55
78
  timestampResolver?: (ms: number, label: string) => { href: string; external?: boolean } | null
79
+ tokens?: TokenSource
56
80
  }
57
81
 
58
82
  constructor(options: HTMLRendererOptions = {}) {
59
83
  super()
84
+ this.tokenResolver = toTokenResolver(options.tokens)
60
85
  this.options = {
61
86
  osuBehaviour: options.osuBehaviour ?? true,
62
87
  registry: options.registry,
@@ -66,9 +91,19 @@ export class HTMLRenderer extends Visitor<string> {
66
91
  entityLinkResolver: options.entityLinkResolver,
67
92
  mentionResolver: options.mentionResolver,
68
93
  timestampResolver: options.timestampResolver,
94
+ tokens: options.tokens,
69
95
  }
70
96
  }
71
97
 
98
+ setTokens(tokens?: TokenSource): void {
99
+ this.tokenResolver = toTokenResolver(tokens)
100
+ this.options.tokens = tokens
101
+ }
102
+
103
+ getTokenResolver(): TokenResolverFn | undefined {
104
+ return this.tokenResolver
105
+ }
106
+
72
107
  // ─── Tag → HTML Element Map ─────────────────────────────
73
108
 
74
109
  private readonly BLOCK_TAGS = new Set([
@@ -203,6 +238,7 @@ export class HTMLRenderer extends Visitor<string> {
203
238
  'notice', 'wnotice', 'spoilerbox', 'box', 'boxw', 'list', 'list_item', 'quote', 'code', 'svg',
204
239
  'heading', 'center', 'right', 'left', 'align', 'imagemap', 'image', 'video', 'audio',
205
240
  'tables', 'table_row', 'gallery', 'columns', 'separator', 'scroll', 'container',
241
+ 'spacing', 'empty_line',
206
242
  ])
207
243
 
208
244
  // ─── Main Entry ─────────────────────────────────────────
@@ -211,7 +247,22 @@ export class HTMLRenderer extends Visitor<string> {
211
247
  return this.renderNode(node)
212
248
  }
213
249
 
250
+ /**
251
+ * Ancho de cada tabla abierta, apilado igual que `tableDepth`.
252
+ *
253
+ * `getTableContext` corre una vez POR CELDA y recorría todas las filas de
254
+ * la tabla en cada llamada, con un `.filter()` —y su array— por fila: una
255
+ * tabla de 20×20 hacía 8.000 arrays intermedios por renderizado para
256
+ * llegar 400 veces al mismo número. Ahora se calcula una vez al entrar en
257
+ * la tabla. Va en una pila y no en un caché por nodo porque los hijos de un
258
+ * `RedNode` SON mutables (`appendChild`/`removeChild`), así que la
259
+ * identidad del nodo no garantiza que su estructura siga siendo la misma
260
+ * entre renderizados.
261
+ */
262
+ private maxColsStack: number[] = []
263
+
214
264
  render(root: RedNode): string {
265
+ this.maxColsStack.length = 0
215
266
  return this.renderNode(root)
216
267
  }
217
268
 
@@ -243,14 +294,26 @@ export class HTMLRenderer extends Visitor<string> {
243
294
  private renderNode(node: RedNode): string {
244
295
  // Leaf nodes
245
296
  if (node.children.length === 0 && node.kind === 'text') {
297
+ let rawText = node.text
298
+ // El `indexOf` va delante: casi ningún texto lleva un `$`, y así ni se
299
+ // arranca el motor de expresiones regulares. El patrón es de módulo
300
+ // porque este es el nodo más frecuente del documento y un literal aquí
301
+ // dentro construía un `RegExp` por hoja, en cada renderizado.
302
+ if (this.tokenResolver !== undefined && rawText.indexOf('$') !== -1) {
303
+ const resolve = this.tokenResolver
304
+ rawText = rawText.replace(TOKEN_REF_RE, (match, name: string) => {
305
+ const resolved = resolve(name) ?? resolve(match)
306
+ return resolved !== undefined ? resolved : match
307
+ })
308
+ }
246
309
  let out = this.mentionResolver || this.options.timestampResolver
247
- ? this.linkifyText(node.text)
248
- : this.escapeHtml(node.text)
310
+ ? this.linkifyText(rawText)
311
+ : this.escapeHtml(rawText)
249
312
  const style = node.metadata?.style as Record<string, string> | undefined
250
313
  if (style) {
251
314
  const inlineStyles = []
252
- const color = style.color && this.sanitizeColor(style.color)
253
- const fontSize = style.fontSize && this.sanitizeFontSize(style.fontSize)
315
+ const color = style.color && sanitizeColor(style.color, this.tokenResolver)
316
+ const fontSize = style.fontSize && sanitizeFontSize(style.fontSize, this.tokenResolver)
254
317
  if (color) inlineStyles.push(`color: ${color}`)
255
318
  if (fontSize) inlineStyles.push(`font-size: ${fontSize}%`)
256
319
  if (this.isCssKeyword(style.fontWeight)) inlineStyles.push(`font-weight: ${style.fontWeight}`)
@@ -289,7 +352,15 @@ export class HTMLRenderer extends Visitor<string> {
289
352
  case 'center': return this.wrapBlock('div', node, 'style="text-align:center;"')
290
353
  case 'right': return this.wrapBlock('div', node, 'style="text-align:right;"')
291
354
  case 'left': return this.wrapBlock('div', node, 'style="text-align:left;"')
292
- case 'heading': return this.wrapBlock('h2', node)
355
+ // Sigue siendo siempre `h2`, como antes: el nivel de BBCode no mapea al
356
+ // de HTML y un `[heading=9]` daría un `<h9>` inválido. `data-bare-level`
357
+ // sólo anota que el 2 lo puso el renderer, para que el camino de vuelta
358
+ // no escriba `[heading=2]` donde el autor había puesto `[heading]`.
359
+ case 'heading': return this.wrapBlock(
360
+ 'h2',
361
+ node,
362
+ node.metadata?.level === undefined ? 'data-bare-level="1"' : '',
363
+ )
293
364
  case 'notice': return this.renderNotice(node, false)
294
365
  case 'wnotice': return this.renderNotice(node, true)
295
366
  case 'quote': return this.renderQuote(node)
@@ -304,12 +375,39 @@ export class HTMLRenderer extends Visitor<string> {
304
375
  case 'align': return this.renderAlign(node)
305
376
  case 'tables': return this.renderTables(node)
306
377
  case 'table_row': return this.tableDepth > 0 ? this.wrapBlock('tr', node) : this.wrapInline('span', node)
307
- case 'table_col': return this.tableDepth > 0 ? this.wrapInline('td', node) : this.wrapInline('span', node)
378
+ case 'table_col': {
379
+ if (this.tableDepth <= 0) return this.wrapInline('span', node)
380
+ const { cellCount, maxCols, isDirect } = this.getTableContext(node)
381
+ let colspanAttr = ''
382
+ if (node.metadata?.colspan) {
383
+ colspanAttr = ` colspan="${node.metadata.colspan}"`
384
+ } else if (cellCount === 1 && maxCols > 1) {
385
+ colspanAttr = ` colspan="${maxCols}"`
386
+ } else if (cellCount < maxCols && maxCols % cellCount === 0) {
387
+ colspanAttr = ` colspan="${maxCols / cellCount}"`
388
+ }
389
+ const align = node.metadata?.align ? ` style="text-align:${node.metadata.align};"` : ''
390
+ const td = `<td${this.idAttr(node)}${colspanAttr}${align}>${this.renderChildren(node)}</td>`
391
+ return isDirect ? `<tr>${td}</tr>` : td
392
+ }
308
393
  case 'table_th': {
309
394
  const content = this.renderChildren(node)
310
- return this.tableDepth > 0
311
- ? `<th${this.idAttr(node)} class="bb-th"><span class="bb-table-badge">${content}</span></th>`
312
- : this.wrapInline('span', node)
395
+ if (this.tableDepth <= 0) return this.wrapInline('span', node)
396
+ const { cellCount, maxCols, isDirect } = this.getTableContext(node)
397
+ let colspanAttr = ''
398
+ let fullCls = ''
399
+ if (node.metadata?.colspan) {
400
+ colspanAttr = ` colspan="${node.metadata.colspan}"`
401
+ } else if (cellCount === 1) {
402
+ colspanAttr = maxCols > 1 ? ` colspan="${maxCols}"` : ' colspan="100"'
403
+ fullCls = ' bb-th-full'
404
+ } else if (cellCount < maxCols && maxCols % cellCount === 0) {
405
+ colspanAttr = ` colspan="${maxCols / cellCount}"`
406
+ }
407
+ const alignCls = node.metadata?.align ? ` bb-th-${node.metadata.align}` : ''
408
+ const alignStyle = node.metadata?.align ? ` style="text-align:${node.metadata.align};"` : ''
409
+ const th = `<th${this.idAttr(node)}${colspanAttr} class="bb-th${fullCls}${alignCls}"${alignStyle}><span class="bb-table-badge">${content}</span></th>`
410
+ return isDirect ? `<tr>${th}</tr>` : th
313
411
  }
314
412
  case 'gallery': return this.renderGallery(node)
315
413
  case 'columns': return this.renderColumns(node)
@@ -336,14 +434,20 @@ export class HTMLRenderer extends Visitor<string> {
336
434
  case 'bubble': return this.wrapInline('span', node, 'class="bubble"')
337
435
  case 'flower': return this.wrapInline('span', node, 'class="flower"')
338
436
  case 'gradient': return this.renderGradient(node)
437
+ // These three had no case at all, so `[rainbow]Hi[/rainbow]` previewed
438
+ // as bare text while its export was a full colour sweep.
439
+ case 'rainbow': return this.renderEffectSegments(node, 'rainbow')
440
+ case 'grow': return this.renderEffectSegments(node, 'grow')
441
+ case 'sinewave': return this.renderEffectSegments(node, 'sinewave')
442
+ case 'paint': return this.renderEffectSegments(node, 'paint')
339
443
  case 'spacing':
340
444
  if (this.options.osuBehaviour && this.isNextCodeBlock(node)) return '\n'
341
445
  if (this.isTrailingBlockBoundary(node)) return '\n'
342
- return this.isPrevBlockBoundary(node) ? '\n' : '<br>'
446
+ return this.isPrevBlockBoundary(node) ? '\n' : `<br${this.idAttr(node)}>`
343
447
  case 'empty_line':
344
448
  if (this.options.osuBehaviour && this.isImmediateEmptyLineBeforeCode(node)) return '\n'
345
449
  if (this.isTrailingBlockBoundary(node)) return '\n'
346
- return '<br>'
450
+ return `<div class="bb-empty-line"${this.idAttr(node)}><br></div>`
347
451
  case 'group': return this.wrapInline('span', node, 'class="group"')
348
452
  // Un párrafo no tiene etiqueta propia en BBCode, pero sí necesita un
349
453
  // elemento: sin él, la prosa suelta entre bloques no es clicable —
@@ -351,6 +455,9 @@ export class HTMLRenderer extends Visitor<string> {
351
455
  // Un `span` es inline, así que no altera el flujo del texto.
352
456
  case 'paragraph': return this.wrapInline('span', node, 'class="bb-paragraph"')
353
457
  case 'error': return this.renderError(node)
458
+ // osu! no muestra un cierre que no cerró nada; el nodo existe solo para
459
+ // que su rango siga perteneciendo a alguien.
460
+ case 'discarded_tag': return ''
354
461
  case 'text':
355
462
  return this.textWrap(node, this.escapeHtml(node.text || ''))
356
463
  default:
@@ -462,32 +569,28 @@ export class HTMLRenderer extends Visitor<string> {
462
569
  // half-escaped value still emits broken CSS, so we validate the shape and
463
570
  // drop anything that isn't a value we intended to support.
464
571
 
465
- /** #rgb / #rgba / #rrggbb / #rrggbbaa, a bare CSS color keyword, or rgb()/hsl(). */
466
- private static readonly CSS_COLOR_RE =
467
- /^(?:#(?:[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
468
-
469
- /** Bare number — interpolated as a percentage. */
470
- private static readonly CSS_SIZE_RE = /^\d{1,4}(?:\.\d{1,2})?$/
471
-
472
- /** Font family list. Quotes are rejected outright; unquoted names are valid CSS. */
473
- private static readonly CSS_FONT_RE = /^[A-Za-z0-9 ,_-]{1,120}$/
474
-
475
572
  /** Characters `escapeHtml` has to rewrite. Non-global on purpose: `test` must not carry `lastIndex`. */
476
573
  private static readonly HTML_ESCAPE_RE = /[&<>"']/
477
574
 
478
- private sanitizeColor(raw: string): string | null {
479
- const v = raw.trim()
480
- return HTMLRenderer.CSS_COLOR_RE.test(v) ? v : null
481
- }
482
-
483
- private sanitizeFontSize(raw: string): string | null {
484
- const v = raw.trim()
485
- return HTMLRenderer.CSS_SIZE_RE.test(v) ? v : null
486
- }
487
-
488
- private sanitizeFontFamily(raw: string): string | null {
489
- const v = raw.trim()
490
- return HTMLRenderer.CSS_FONT_RE.test(v) ? v : null
575
+ private static readonly NAMED_COLORS: Record<string, string> = {
576
+ black: '#000000',
577
+ white: '#FFFFFF',
578
+ red: '#FF4C4C',
579
+ green: '#2ECC71',
580
+ blue: '#3498DB',
581
+ yellow: '#F1C40F',
582
+ cyan: '#2EE6E2',
583
+ magenta: '#E056FD',
584
+ pink: '#FF94C4',
585
+ purple: '#A972FF',
586
+ orange: '#FF9F43',
587
+ amber: '#FFC34D',
588
+ gray: '#8395A7',
589
+ grey: '#8395A7',
590
+ lime: '#10AC84',
591
+ teal: '#01CBC6',
592
+ violet: '#5F27CD',
593
+ gold: '#FFD700',
491
594
  }
492
595
 
493
596
  /** Keyword-or-number CSS values (font-weight, font-style, text-decoration). */
@@ -496,63 +599,39 @@ export class HTMLRenderer extends Visitor<string> {
496
599
  }
497
600
 
498
601
  /** Read a metadata field, falling back to the raw tag attribute. */
499
- private metaOrAttr(node: RedNode, key: string): string {
500
- const meta = node.metadata?.[key]
501
- return (typeof meta === 'string' && meta) || this.extractValue(node)
502
- }
503
602
 
504
603
  private colorStyle(node: RedNode): string {
505
- const color = this.sanitizeColor(this.metaOrAttr(node, 'color'))
604
+ const color = sanitizeColor(nodeAttrValue(node, 'color'), this.tokenResolver)
506
605
  return color ? `style="color:${color};"` : ''
507
606
  }
508
607
 
509
608
  private fontSizeStyle(node: RedNode): string {
510
- const size = this.sanitizeFontSize(this.metaOrAttr(node, 'size'))
609
+ const size = sanitizeFontSize(nodeAttrValue(node, 'size'), this.tokenResolver)
511
610
  return size ? `style="font-size:${size}%;"` : ''
512
611
  }
513
612
 
514
613
  private fontStyle(node: RedNode): string {
515
- const font = this.sanitizeFontFamily(this.metaOrAttr(node, 'font'))
614
+ const font = sanitizeFontFamily(nodeAttrValue(node, 'font'), this.tokenResolver)
516
615
  return font ? `style="font-family:${font};"` : ''
517
616
  }
518
617
 
519
- /**
520
- * Extract the attribute value from a BBCode tag node.
521
- *
522
- * BBCode attributes come in the format `=VALUE` (e.g. `=#61afef`, `="Author"`,
523
- * `=https://osu.ppy.sh`). This strips the leading `=` and any surrounding quotes.
524
- *
525
- * For tags without attrs (like text nodes, img content), returns the node text as-is.
526
- */
527
- private extractValue(node: RedNode): string {
528
- const text = node.text || ''
529
- if (!text) return ''
530
-
531
- const eqIdx = text.indexOf('=')
532
- if (eqIdx >= 0) {
533
- // Strip `=` prefix and surrounding quotes
534
- let value = text.slice(eqIdx + 1)
535
- // Remove surrounding quotes: "value" or 'value'
536
- if ((value.startsWith('"') && value.endsWith('"')) ||
537
- (value.startsWith("'") && value.endsWith("'"))) {
538
- value = value.slice(1, -1)
539
- }
540
- return value
541
- }
542
-
543
- // No `=` prefix — return as-is (used for media content like img URLs)
544
- return text
545
- }
546
-
547
618
  private renderLink(node: RedNode, kind: string): string {
548
- let href = String(node.metadata?.href ?? '') || this.extractValue(node)
619
+ let href = String(node.metadata?.href ?? '') || nodeAttrValue(node)
620
+ // Un `[email]a@b.com[/email]` llega con href exactamente `mailto:` y la
621
+ // dirección en el hijo de texto. Sin completarlo aquí el href quedaba
622
+ // vacío y el round-trip devolvía `[url=mailto:]a@b.com[/url]`.
623
+ if (kind === 'email' && href === 'mailto:') {
624
+ href += node.children.map(c => c.text || '').join('')
625
+ }
549
626
  // Ensure the URL has a protocol for external links
550
627
  if (href && !href.startsWith('http://') && !href.startsWith('https://') && !href.startsWith('mailto:')) {
551
628
  href = 'https://' + href
552
629
  }
553
630
  const content = this.renderChildren(node) || href
554
631
  const h = href ? ` href="${this.escapeHtml(href)}"` : ''
555
- return `<a${this.idAttr(node)}${h} target="_blank" rel="noopener">${content}</a>`
632
+ const hasMediaChild = node.children.some(c => c.kind === 'image' || c.kind === 'svg' || c.kind === 'video')
633
+ const imgCls = hasMediaChild ? ' class="bb-link-img"' : ''
634
+ return `<a${this.idAttr(node)}${imgCls}${h} target="_blank" rel="noopener">${content}</a>`
556
635
  }
557
636
 
558
637
  private renderProfile(node: RedNode): string {
@@ -561,48 +640,55 @@ export class HTMLRenderer extends Visitor<string> {
561
640
 
562
641
  private renderEntity(node: RedNode, type: 'profile' | 'guild' | 'map'): string {
563
642
  const key = type === 'profile' ? 'username' : type === 'guild' ? 'tag' : 'id'
564
- const val = String(node.metadata?.[key] ?? '') || this.extractValue(node)
643
+ const val = String(node.metadata?.[key] ?? '') || nodeAttrValue(node)
565
644
  const content = this.renderChildren(node) || val
645
+ // El converter HTML→BBCode ve `<strong><a>`, no la etiqueta original. Sin
646
+ // esta marca `[profile]peppy[/profile]` volvía como `[b][url=...]…[/url][/b]`,
647
+ // y sin el valor `[profile=5458323]` perdía el id y volvía como `[profile]`.
648
+ const entity = ` data-entity="${type}"` +
649
+ (val ? ` data-entity-value="${this.escapeHtml(val)}"` : '')
566
650
  if (this.options.entityLinkResolver) {
567
651
  const link = this.options.entityLinkResolver(type, val || content)
568
652
  if (link) {
569
653
  const ext = link.external ? ' target="_blank" rel="noopener noreferrer"' : ''
570
- return `<strong><a${this.idAttr(node)} href="${this.escapeHtml(link.href)}"${ext}>${content}</a></strong>`
654
+ return `<strong${entity}><a${this.idAttr(node)} href="${this.escapeHtml(link.href)}"${ext}>${content}</a></strong>`
571
655
  }
572
656
  }
573
657
  if (type === 'profile') {
574
658
  const url = this.options.theme === 'lyne' || this.options.dialect === 'lyne'
575
659
  ? `/u/${encodeURIComponent(val || content)}`
576
660
  : `https://osu.ppy.sh/users/${this.escapeHtml(val || content)}`
577
- return `<strong><a${this.idAttr(node)} href="${url}" target="_blank" rel="noopener">${content}</a></strong>`
661
+ return `<strong${entity}><a${this.idAttr(node)} href="${url}" target="_blank" rel="noopener">${content}</a></strong>`
578
662
  }
579
663
  if (type === 'guild') {
580
- return `<strong><a${this.idAttr(node)} href="/guilds/${encodeURIComponent(val || content)}">${content}</a></strong>`
664
+ return `<strong${entity}><a${this.idAttr(node)} href="/guilds/${encodeURIComponent(val || content)}">${content}</a></strong>`
581
665
  }
582
666
  if (type === 'map') {
583
- return `<strong><a${this.idAttr(node)} href="/maps/${encodeURIComponent(val || content)}">${content}</a></strong>`
667
+ return `<strong${entity}><a${this.idAttr(node)} href="/maps/${encodeURIComponent(val || content)}">${content}</a></strong>`
584
668
  }
585
- return `<strong><a${this.idAttr(node)} href="#">${content}</a></strong>`
669
+ return `<strong${entity}><a${this.idAttr(node)} href="#">${content}</a></strong>`
586
670
  }
587
671
 
588
- private parseImgAttr(v: string | null): { w?: number; h?: number; round?: boolean; shadow?: boolean; float?: boolean } {
589
- if (!v) return {}
590
- const t = v.trim().toLowerCase()
591
- if (t === 'round') return { round: true }
592
- if (t === 'shadow') return { shadow: true }
593
- if (t === 'float') return { float: true }
594
- const m = /^(\d{1,4})x(\d{1,4})$/i.exec(t)
595
- if (m) return { w: Math.min(2000, parseInt(m[1], 10)), h: Math.min(2000, parseInt(m[2], 10)) }
596
- return {}
672
+ /**
673
+ * El aviso que ocupa el sitio de un medio que no se puede pintar.
674
+ *
675
+ * Lleva `data-bb-empty` con la etiqueta de origen porque el camino
676
+ * HTML→BBCode lee de vuelta lo que hay en el lienzo: sin la marca, el texto
677
+ * del aviso entraba al documento como contenido y se comía la etiqueta. En
678
+ * `docs/ai/hxovc.bbcode`, con cinco `[img][/img]` vacíos, cada edición del
679
+ * bloque convertía otro en la frase «[img] missing source URL».
680
+ */
681
+ private mediaError(tag: string, message: string): string {
682
+ return `<div class="media-error" data-bb-empty="${tag}">${message}</div>`
597
683
  }
598
684
 
599
685
  private renderImage(node: RedNode): string {
600
- let src = String(node.metadata?.src ?? '') || this.extractValue(node) || ''
601
- if (!src) return '<div class="media-error">[img] missing source URL</div>'
686
+ let src = String(node.metadata?.src ?? '') || nodeAttrValue(node) || ''
687
+ if (!src) return this.mediaError('img', '[img] missing source URL')
602
688
  if (this.options.mediaProxy) {
603
689
  src = this.options.mediaProxy(src)
604
690
  }
605
- const imgAttr = this.parseImgAttr(this.extractValue(node))
691
+ const imgAttr = parseImgAttr(nodeAttrValue(node))
606
692
  const styles: string[] = []
607
693
  if (imgAttr.w) styles.push(`width:${imgAttr.w}px`)
608
694
  if (imgAttr.h) styles.push(`height:${imgAttr.h}px`)
@@ -617,15 +703,21 @@ export class HTMLRenderer extends Visitor<string> {
617
703
  styles.push('max-width:100%', 'height:auto', 'display:inline-block')
618
704
  }
619
705
  const cls = imgAttr.round ? '' : ' class="bb-img"'
620
- return `<img${this.idAttr(node)}${cls} src="${this.escapeHtml(src)}" alt="" style="${styles.join(';')};">`
706
+ // El modificador no se puede reconstruir desde el `style` resultante
707
+ // (`round` y `120x120` producen el mismo CSS), así que viaja literal.
708
+ const rawAttr = nodeAttrValue(node)
709
+ const attr = rawAttr ? ` data-img-attr="${this.escapeHtml(rawAttr)}"` : ''
710
+ return `<img${this.idAttr(node)}${cls}${attr} src="${this.escapeHtml(src)}" alt="" style="${styles.join(';')};">`
621
711
  }
622
712
 
623
713
  private renderVideo(node: RedNode): string {
624
- let id = String(node.metadata?.videoId ?? '') || this.extractValue(node) || ''
625
- if (!id) return '<div class="media-error">[youtube] missing video ID</div>'
714
+ let id = String(node.metadata?.videoId ?? '') || nodeAttrValue(node) || ''
715
+ if (!id) return this.mediaError('youtube', '[youtube] missing video ID')
626
716
  const ytMatch = /(?:youtu\.be\/|v=|\/embed\/|\/shorts\/)([\w-]{11})/.exec(id)
627
717
  if (ytMatch) id = ytMatch[1]
628
- return `<iframe${this.idAttr(node)} src="https://www.youtube.com/embed/${this.escapeHtml(id)}" frameborder="0" allowfullscreen></iframe>`
718
+ // `data-youtube` es lo que deja al converter HTML→BBCode reconocer este
719
+ // iframe. Sin él el vídeo volvía como un `group` vacío: se perdía entero.
720
+ return `<iframe${this.idAttr(node)} class="bb-youtube" data-youtube="${this.escapeHtml(id)}" src="https://www.youtube.com/embed/${this.escapeHtml(id)}" frameborder="0" allowfullscreen></iframe>`
629
721
  }
630
722
 
631
723
  private renderAudio(node: RedNode): string {
@@ -637,8 +729,12 @@ export class HTMLRenderer extends Visitor<string> {
637
729
  }
638
730
 
639
731
  private hexToRgba(hex: string, alpha: number): string {
640
- if (!hex.startsWith('#')) return hex
641
- const t = hex.replace('#', '')
732
+ let clean = hex.trim().toLowerCase()
733
+ if (HTMLRenderer.NAMED_COLORS[clean]) {
734
+ clean = HTMLRenderer.NAMED_COLORS[clean]
735
+ }
736
+ if (!clean.startsWith('#')) return clean
737
+ const t = clean.replace('#', '')
642
738
  const full = t.length <= 4 ? t.split('').map(c => c + c).join('') : t
643
739
  const r = parseInt(full.slice(0, 2), 16) || 0
644
740
  const g = parseInt(full.slice(2, 4), 16) || 0
@@ -649,7 +745,7 @@ export class HTMLRenderer extends Visitor<string> {
649
745
  private renderNotice(node: RedNode, warning: boolean): string {
650
746
  const isLyne = this.options.theme === 'lyne' || this.options.dialect === 'lyne' || warning
651
747
  if (isLyne) {
652
- const color = this.sanitizeColor(this.metaOrAttr(node, 'color'))
748
+ const color = sanitizeColor(nodeAttrValue(node, 'color'), this.tokenResolver)
653
749
  const styleAttr = color
654
750
  ? ` style="background:${this.hexToRgba(color, 0.08)};border-color:${this.hexToRgba(color, 0.4)};border-left-color:${color};color:${this.hexToRgba(color, 0.85)};"`
655
751
  : ''
@@ -663,7 +759,7 @@ export class HTMLRenderer extends Visitor<string> {
663
759
  }
664
760
 
665
761
  private renderTables(node: RedNode): string {
666
- const variant = (String(node.metadata?.variant ?? '') || this.extractValue(node) || '').trim().toLowerCase()
762
+ const variant = (String(node.metadata?.variant ?? '') || nodeAttrValue(node) || '').trim().toLowerCase()
667
763
  const variants = new Set(variant.split(/[\s,]+/).filter(Boolean))
668
764
  const tableClasses = [
669
765
  'bb-table',
@@ -671,7 +767,9 @@ export class HTMLRenderer extends Visitor<string> {
671
767
  variants.has('borders') ? 'bb-table-borders' : '',
672
768
  ].filter(Boolean).join(' ')
673
769
  this.tableDepth++
770
+ this.maxColsStack.push(HTMLRenderer.widestRow(node))
674
771
  const content = this.renderChildren(node)
772
+ this.maxColsStack.pop()
675
773
  this.tableDepth--
676
774
  // `[tables=striped:#hex]`: de `--table-accent` se deriva toda la paleta
677
775
  // de la tabla (gradiente del frame, hover de filas, encabezado, bordes).
@@ -679,13 +777,63 @@ export class HTMLRenderer extends Visitor<string> {
679
777
  return `<div${this.idAttr(node)} class="bb-table-frame"${accent}><table class="${tableClasses}"><tbody>${content}</tbody></table></div>`
680
778
  }
681
779
 
780
+ /** Celdas directas de una fila, sin materializar un array intermedio. */
781
+ private static countCells(node: RedNode): number {
782
+ let count = 0
783
+ const children = node.children
784
+ for (let i = 0; i < children.length; i++) {
785
+ const kind = children[i].kind
786
+ if (kind === 'table_th' || kind === 'table_col') count++
787
+ }
788
+ return count
789
+ }
790
+
791
+ /**
792
+ * Columnas de la tabla más ancha bajo `tableNode`, memoizado por tabla.
793
+ *
794
+ * `getTableContext` se llama una vez POR CELDA, y recorría todas las filas
795
+ * de la tabla en cada llamada, con un `.filter()` —y su array— por fila.
796
+ * Una tabla de 20×20 hacía 8.000 arrays intermedios en cada renderizado
797
+ * para llegar 400 veces al mismo número. El caché se vacía al empezar cada
798
+ * render, así que no retiene nodos entre documentos.
799
+ */
800
+ private static widestRow(tableNode: RedNode): number {
801
+ let maxCols = 1
802
+ const rows = tableNode.children
803
+ for (let i = 0; i < rows.length; i++) {
804
+ const row = rows[i]
805
+ if (row.kind !== 'table_row') continue
806
+ const count = HTMLRenderer.countCells(row)
807
+ if (count > maxCols) maxCols = count
808
+ }
809
+ return maxCols
810
+ }
811
+
812
+ private getTableContext(node: RedNode): { cellCount: number; maxCols: number; isDirect: boolean } {
813
+ const parent = node.parent
814
+ const isDirect = parent?.kind === 'tables'
815
+ const tableNode = isDirect ? parent : (parent?.parent?.kind === 'tables' ? parent.parent : undefined)
816
+ const cellCount = parent ? HTMLRenderer.countCells(parent) : 1
817
+
818
+ let maxCols = cellCount
819
+ if (tableNode) {
820
+ // La tabla en curso está en lo alto de la pila; solo se recorre si esta
821
+ // celda llegó por un camino que no pasó por `renderTables`.
822
+ const widest = this.maxColsStack.length > 0
823
+ ? this.maxColsStack[this.maxColsStack.length - 1]
824
+ : HTMLRenderer.widestRow(tableNode)
825
+ if (widest > maxCols) maxCols = widest
826
+ }
827
+ return { cellCount, maxCols, isDirect }
828
+ }
829
+
682
830
  private renderGallery(node: RedNode): string {
683
831
  const content = this.renderChildren(node)
684
832
  return `<div${this.idAttr(node)} class="bb-gallery">${content}</div>`
685
833
  }
686
834
 
687
835
  private renderColumns(node: RedNode): string {
688
- const cols = parseInt(String(node.metadata?.columns ?? '') || this.extractValue(node) || '2', 10)
836
+ const cols = parseInt(String(node.metadata?.columns ?? '') || nodeAttrValue(node) || '2', 10)
689
837
  const numCols = Math.max(2, Math.min(4, isNaN(cols) ? 2 : cols))
690
838
  const content = this.renderChildren(node)
691
839
  // `[columns=2:#hex]`: con color se añade la superficie (borde + fondo
@@ -697,27 +845,27 @@ export class HTMLRenderer extends Visitor<string> {
697
845
  }
698
846
 
699
847
  private renderSeparator(node: RedNode): string {
700
- const variant = (String(node.metadata?.variant ?? '') || this.extractValue(node) || 'line').trim().toLowerCase()
848
+ const variant = (String(node.metadata?.variant ?? '') || nodeAttrValue(node) || 'line').trim().toLowerCase()
701
849
  if (variant === 'dots') return `<div${this.idAttr(node)} class="bb-separator">· · ·</div>`
702
850
  if (variant === 'stars') return `<div${this.idAttr(node)} class="bb-separator">✦ ✦ ✦</div>`
703
851
  return `<hr${this.idAttr(node)} class="bb-separator" />`
704
852
  }
705
853
 
706
854
  private renderScroll(node: RedNode): string {
707
- const h = parseInt(String(node.metadata?.height ?? '') || this.extractValue(node) || '200', 10)
855
+ const h = parseInt(String(node.metadata?.height ?? '') || nodeAttrValue(node) || '200', 10)
708
856
  const maxH = Math.max(50, Math.min(2000, isNaN(h) ? 200 : h))
709
857
  const content = this.renderChildren(node)
710
858
  return `<div${this.idAttr(node)} class="bb-scroll" style="max-height:${maxH}px;">${content}</div>`
711
859
  }
712
860
 
713
861
  private renderAbbr(node: RedNode): string {
714
- const title = String(node.metadata?.title ?? '') || this.extractValue(node)
862
+ const title = String(node.metadata?.title ?? '') || nodeAttrValue(node)
715
863
  const attr = title ? ` title="${this.escapeHtml(title)}"` : ''
716
864
  return this.wrapInline('abbr', node, attr)
717
865
  }
718
866
 
719
867
  private renderTooltip(node: RedNode): string {
720
- const tip = String(node.metadata?.tip ?? '') || this.extractValue(node)
868
+ const tip = String(node.metadata?.tip ?? '') || nodeAttrValue(node)
721
869
  const attr = tip ? ` title="${this.escapeHtml(tip)}"` : ''
722
870
  return this.wrapInline('span', node, `class="bb-tooltip"${attr}`)
723
871
  }
@@ -732,16 +880,16 @@ export class HTMLRenderer extends Visitor<string> {
732
880
  }
733
881
 
734
882
  private renderAlign(node: RedNode): string {
735
- const alignVal = (String(node.metadata?.align ?? '') || this.extractValue(node) || 'center').trim().toLowerCase()
883
+ const alignVal = (String(node.metadata?.align ?? '') || nodeAttrValue(node) || 'center').trim().toLowerCase()
736
884
  const validAlign = alignVal === 'left' || alignVal === 'right' ? alignVal : 'center'
737
885
  return this.wrapBlock('div', node, `style="text-align:${validAlign};"`)
738
886
  }
739
887
 
740
888
  private renderEffect(node: RedNode): string {
741
- const raw = (String(node.metadata?.effectType ?? '') || this.extractValue(node) || 'glow').toLowerCase().trim()
889
+ const raw = (String(node.metadata?.effectType ?? '') || nodeAttrValue(node) || 'glow').toLowerCase().trim()
742
890
  const effectType = raw.includes(':') ? raw.split(':')[0] : (raw.startsWith('#') ? 'glow' : raw)
743
- const rawColor = String(node.metadata?.color ?? '') || (raw.includes(':') ? raw.split(':')[1] : (raw.startsWith('#') ? raw : this.extractValue(node)))
744
- const color = this.sanitizeColor(rawColor)
891
+ const rawColor = String(node.metadata?.color ?? '') || (raw.includes(':') ? raw.split(':')[1] : (raw.startsWith('#') ? raw : nodeAttrValue(node)))
892
+ const color = sanitizeColor(rawColor, this.tokenResolver)
745
893
  const content = this.renderChildren(node)
746
894
  const idAttr = this.idAttr(node)
747
895
 
@@ -774,7 +922,7 @@ export class HTMLRenderer extends Visitor<string> {
774
922
  }
775
923
 
776
924
  private renderAnim(node: RedNode): string {
777
- const raw = (String(node.metadata?.animType ?? '') || this.extractValue(node) || 'pulse').toLowerCase().trim()
925
+ const raw = (String(node.metadata?.animType ?? '') || nodeAttrValue(node) || 'pulse').toLowerCase().trim()
778
926
  const animType = raw.includes(':') ? raw.split(':')[0] : raw
779
927
  const content = this.renderChildren(node)
780
928
  const idAttr = this.idAttr(node)
@@ -802,7 +950,7 @@ export class HTMLRenderer extends Visitor<string> {
802
950
  }
803
951
 
804
952
  private renderContainer(node: RedNode): string {
805
- const raw = (String(node.metadata?.containerType ?? '') || this.extractValue(node) || 'stack').trim()
953
+ const raw = (String(node.metadata?.containerType ?? '') || nodeAttrValue(node) || 'stack').trim()
806
954
  const [type, ...params] = raw.split(':')
807
955
  const containerType = type.toLowerCase()
808
956
  const param = params.join(':')
@@ -824,8 +972,14 @@ export class HTMLRenderer extends Visitor<string> {
824
972
  }
825
973
  case 'middle':
826
974
  return `<div${idAttr} class="bb-middle">${content}</div>`
827
- case 'circle':
828
- return `<div${idAttr} class="bb-circle">${content}</div>`
975
+ case 'square':
976
+ case 'circle': {
977
+ const accent = sanitizeColor(param, this.tokenResolver)
978
+ const border = accent ? this.hexToRgba(accent, 0.35) : ''
979
+ const bg = accent ? this.hexToRgba(accent, 0.08) : ''
980
+ const style = accent ? ` style="--square-accent:${accent};--square-border:${border};--square-bg:${bg};"` : ''
981
+ return `<div${idAttr} class="bb-square bb-cut-panel"${style}>${content}</div>`
982
+ }
829
983
  case 'card':
830
984
  case 'glass': {
831
985
  // `[card=#hex]` / `[container=glass:#hex]`: el color se pasa como
@@ -833,14 +987,14 @@ export class HTMLRenderer extends Visitor<string> {
833
987
  // del que el CSS deriva borde, tinte de fondo y —vía la clase
834
988
  // `bb-accented`— la paleta del contenido sin color propio. Sin color,
835
989
  // defaults y sin clase.
836
- const accent = this.sanitizeColor(param)
990
+ const accent = sanitizeColor(param, this.tokenResolver)
837
991
  const cls = accent ? ' bb-accented' : ''
838
992
  const style = accent ? ` style="--${containerType}-accent:${accent};"` : ''
839
993
  return `<div${idAttr} class="bb-cut-panel bb-${containerType}${cls}"${style}>${content}</div>`
840
994
  }
841
995
  case 'neon-box':
842
996
  case 'neonbox': {
843
- const c = this.sanitizeColor(param || this.extractValue(node)) || 'var(--color-accent, #2EE6E2)'
997
+ const c = sanitizeColor(param || nodeAttrValue(node), this.tokenResolver) || 'var(--color-accent, #2EE6E2)'
844
998
  return `<div${idAttr} class="bb-cut-panel bb-neon-box" style="--neon-color:${c};border-color:${c};">${content}</div>`
845
999
  }
846
1000
  default:
@@ -859,7 +1013,7 @@ export class HTMLRenderer extends Visitor<string> {
859
1013
  ])
860
1014
 
861
1015
  private renderStyleTag(node: RedNode): string {
862
- const raw = (String(node.metadata?.style ?? '') || this.extractValue(node) || '').trim()
1016
+ const raw = (String(node.metadata?.style ?? '') || nodeAttrValue(node) || '').trim()
863
1017
  const content = this.renderChildren(node)
864
1018
  const idAttr = this.idAttr(node)
865
1019
  if (!raw) return `<span${idAttr}>${content}</span>`
@@ -877,7 +1031,10 @@ export class HTMLRenderer extends Visitor<string> {
877
1031
  // color-mix()…) — antes se eliminaban y rompían esos valores. Lo que sí
878
1032
  // se bloquea son las funciones/fuentes peligrosas (url(), expression(),
879
1033
  // javascript:), que no tienen uso legítimo en el whitelist de props.
880
- const safeVal = val.replace(/["'{}<>]/g, '').slice(0, 100)
1034
+ let safeVal = val.replace(/["'{}<>]/g, '').slice(0, 100)
1035
+ if (safeVal.startsWith('$') && this.tokenResolver) {
1036
+ safeVal = resolveTokenValue(safeVal, this.tokenResolver)
1037
+ }
881
1038
  if (/url\s*\(|expression\s*\(|javascript\s*:/i.test(safeVal)) continue
882
1039
  safeStyles.push(`${prop}:${safeVal}`)
883
1040
  }
@@ -887,7 +1044,7 @@ export class HTMLRenderer extends Visitor<string> {
887
1044
  }
888
1045
 
889
1046
  private renderQuote(node: RedNode): string {
890
- const source = node.metadata?.source || this.extractValue(node)
1047
+ const source = node.metadata?.source || nodeAttrValue(node)
891
1048
  const content = this.renderChildren(node)
892
1049
  if (source) {
893
1050
  return `<blockquote${this.idAttr(node)}><div style="margin-bottom:8px"><strong>${this.escapeHtml(String(source))} wrote:</strong></div>${content}</blockquote>`
@@ -897,6 +1054,7 @@ export class HTMLRenderer extends Visitor<string> {
897
1054
 
898
1055
  private renderSpoilerbox(node: RedNode): string {
899
1056
  const title = this.renderTitle(node, 'Spoiler')
1057
+ const bare = this.bareTitleAttr(node)
900
1058
  const content = this.renderChildren(node)
901
1059
  const isLyne = this.options.theme === 'lyne' || this.options.dialect === 'lyne'
902
1060
  const bodyCls = isLyne ? 'bb-box-body' : 'bbcode-box-body'
@@ -906,11 +1064,12 @@ export class HTMLRenderer extends Visitor<string> {
906
1064
  // `--box-accent` colorea el acento del box (título + chevron, y el borde
907
1065
  // en boxw) cuando el autor puso `[box=Title:#hex]`.
908
1066
  const accent = this.boxAccentStyle(node)
909
- return `<details${this.idAttr(node)}${accent}><summary><span class="bb-box-heading">${title}</span></summary><div class="${bodyCls}">${content}</div></details>`
1067
+ return `<details${this.idAttr(node)}${bare}${accent}><summary><span class="bb-box-heading">${title}</span></summary><div class="${bodyCls}">${content}</div></details>`
910
1068
  }
911
1069
 
912
1070
  private renderBox(node: RedNode): string {
913
1071
  const title = this.renderTitle(node, 'Box')
1072
+ const bare = this.bareTitleAttr(node)
914
1073
  const content = this.renderChildren(node)
915
1074
  const isLyne = this.options.theme === 'lyne' || this.options.dialect === 'lyne'
916
1075
  const bodyCls = isLyne ? 'bb-box-body' : 'bbcode-box-body'
@@ -922,7 +1081,20 @@ export class HTMLRenderer extends Visitor<string> {
922
1081
  // (no `bb-box-title`) evita la regla legacy `.bbcode-preview .bb-box-title`
923
1082
  // de la app, que pinta un fondo sobre el título.
924
1083
  const accent = this.boxAccentStyle(node)
925
- return `<details${this.idAttr(node)} class="${cls}"${accent}><summary><span class="bb-box-heading">${title}</span></summary><div class="${bodyCls}">${content}</div></details>`
1084
+ return `<details${this.idAttr(node)} class="${cls}"${bare}${accent}><summary><span class="bb-box-heading">${title}</span></summary><div class="${bodyCls}">${content}</div></details>`
1085
+ }
1086
+
1087
+ /**
1088
+ * Marca un `[box]`/`[spoilerbox]` sin título propio.
1089
+ *
1090
+ * El `<summary>` siempre lleva texto ("Box", "Spoiler"), así que sin esta
1091
+ * marca el camino HTML→BBCode leía ese relleno como si el autor lo hubiera
1092
+ * escrito y devolvía `[box=Box]`.
1093
+ */
1094
+ private bareTitleAttr(node: RedNode): string {
1095
+ const raw = node.metadata?.rawTitle
1096
+ const hasOwnTitle = raw !== undefined ? String(raw) !== '' : node.metadata?.title !== undefined
1097
+ return hasOwnTitle ? '' : ' data-bare-title="1"'
926
1098
  }
927
1099
 
928
1100
  private renderTitle(node: RedNode, fallback: string): string {
@@ -930,8 +1102,14 @@ export class HTMLRenderer extends Visitor<string> {
930
1102
  if (titleNodes && titleNodes.length > 0) {
931
1103
  return titleNodes.map(c => this.renderNode(c)).join('')
932
1104
  }
933
- const title = node.metadata?.title ?? this.extractValue(node) ?? fallback
934
- return this.escapeHtml(String(title))
1105
+ let title = String(node.metadata?.title ?? nodeAttrValue(node) ?? fallback)
1106
+ if (this.tokenResolver) {
1107
+ title = title.replace(/\$([a-zA-Z_][a-zA-Z0-9_-]*)/g, (match, name) => {
1108
+ const resolved = this.tokenResolver!(name) ?? this.tokenResolver!(match)
1109
+ return resolved !== undefined ? resolved : match
1110
+ })
1111
+ }
1112
+ return this.escapeHtml(title)
935
1113
  }
936
1114
 
937
1115
  /**
@@ -941,13 +1119,16 @@ export class HTMLRenderer extends Visitor<string> {
941
1119
  * borde + fondo en columns). Devuelve '' si no hay color válido.
942
1120
  */
943
1121
  private boxAccentStyle(node: RedNode, suffix: 'box' | 'table' | 'columns' = 'box'): string {
944
- const color = this.sanitizeColor(String(node.metadata?.color ?? ''))
945
- return color ? ` style="--${suffix}-accent:${color};"` : ''
1122
+ const color = sanitizeColor(String(node.metadata?.color ?? ''), this.tokenResolver)
1123
+ if (!color) return ''
1124
+ const border = this.hexToRgba(color, 0.35)
1125
+ const bg = this.hexToRgba(color, 0.08)
1126
+ return ` style="--${suffix}-accent:${color};--${suffix}-border:${border};--${suffix}-bg:${bg};"`
946
1127
  }
947
1128
 
948
1129
  private renderList(node: RedNode): string {
949
1130
  // Check for ordered list: [list=1], [list=a], or metadata
950
- const attrs = this.extractValue(node)
1131
+ const attrs = nodeAttrValue(node)
951
1132
  const isOrdered = node.metadata?.ordered === true || attrs === '1' || attrs === 'a'
952
1133
  const tag = isOrdered ? 'ol' : 'ul'
953
1134
  const content = node.children.map(c => this.renderNode(c)).join('\n')
@@ -968,7 +1149,12 @@ export class HTMLRenderer extends Visitor<string> {
968
1149
  const trailingMatch = content.match(/(?:[\r\n][\t ]*)+$/)
969
1150
  if (trailingMatch) content = content.slice(0, -trailingMatch[0].length)
970
1151
 
971
- return `<pre${this.idAttr(node)}><code>${this.escapeHtml(content)}</code></pre>`
1152
+ // Los saltos recortados arriba son de presentación: si no se anotan, el
1153
+ // camino HTML→BBCode los da por inexistentes y reescribe el bloque del
1154
+ // autor como `[code]…[/code]` en una sola línea.
1155
+ const pad = `${leadingMatch ? leadingMatch[0] : ''}\u0000${trailingMatch ? trailingMatch[0] : ''}`
1156
+ const padAttr = pad === '\u0000' ? '' : ` data-code-pad="${this.escapeHtml(JSON.stringify(pad))}"`
1157
+ return `<pre${this.idAttr(node)}${padAttr}><code>${this.escapeHtml(content)}</code></pre>`
972
1158
  }
973
1159
 
974
1160
  private renderSVG(node: RedNode): string {
@@ -1004,12 +1190,12 @@ export class HTMLRenderer extends Visitor<string> {
1004
1190
  .filter(Boolean)
1005
1191
 
1006
1192
  if (textLines.length === 0) {
1007
- return '<div class="media-error">[imagemap] missing image URL</div>'
1193
+ return this.mediaError('imagemap', '[imagemap] missing image URL')
1008
1194
  }
1009
1195
 
1010
1196
  const imageUrl = textLines[0]
1011
1197
  if (!imageUrl.startsWith('http://') && !imageUrl.startsWith('https://')) {
1012
- return `<div class="media-error">[imagemap] invalid image URL: ${this.escapeHtml(imageUrl)}</div>`
1198
+ return this.mediaError('imagemap', `[imagemap] invalid image URL: ${this.escapeHtml(imageUrl)}`)
1013
1199
  }
1014
1200
 
1015
1201
  let areas = ''
@@ -1046,26 +1232,127 @@ export class HTMLRenderer extends Visitor<string> {
1046
1232
  return node.text || ''
1047
1233
  }
1048
1234
 
1235
+ /**
1236
+ * Effect parameters off a node, with every colour re-validated.
1237
+ *
1238
+ * `startsWith('#')` was NOT a filter: `#a" onmouseover="alert(1)` passes
1239
+ * it. Every stop has to survive the full colour allowlist or be dropped,
1240
+ * because these values end up inside a `style` attribute.
1241
+ */
1242
+ private effectParams(node: RedNode): EffectParams {
1243
+ const meta = (node.metadata ?? {}) as Record<string, unknown>
1244
+ const params: EffectParams = {}
1245
+ for (const [key, value] of Object.entries(meta)) {
1246
+ if (key === 'globalOffset' || key === 'documentLength') continue
1247
+ ;(params as Record<string, unknown>)[key] = value
1248
+ }
1249
+
1250
+ if (Array.isArray(params.colors)) {
1251
+ params.colors = params.colors
1252
+ .map(c => (typeof c === 'string' ? sanitizeColor(c, this.tokenResolver) : null))
1253
+ .filter((c): c is string => c !== null)
1254
+ if (params.colors.length === 0) delete params.colors
1255
+ }
1256
+ if (Array.isArray(params.stops)) {
1257
+ params.stops = params.stops
1258
+ .filter(st => st && typeof st.color === 'string' && sanitizeColor(st.color, this.tokenResolver) !== null)
1259
+ if (params.stops.length === 0) delete params.stops
1260
+ }
1261
+ return params
1262
+ }
1263
+
1264
+ /** Text inside an effect node, ignoring layout-only children. */
1265
+ /**
1266
+ * The plain text an effect modulates, line breaks included.
1267
+ *
1268
+ * A break is a childless `spacing` leaf carrying no text, so collecting
1269
+ * it naively contributes nothing and the effect sees one long line.
1270
+ * Every two-dimensional axis then collapses: `line` reports zero for
1271
+ * the whole block, `column` stretches one row across the paragraph, and
1272
+ * `radial` measures a rectangle one line tall. The break was being
1273
+ * dropped from the OUTPUT as well, so a multi-line effect previewed as
1274
+ * a single run-on line.
1275
+ *
1276
+ * The document's own plain text spells a break `\n` — that is what
1277
+ * `stripColorWrappers` and the studio's sampler both count — so this
1278
+ * agrees with them rather than inventing a third answer.
1279
+ */
1280
+ private effectText(node: RedNode): string {
1281
+ return node.children.map(c => this.effectChildText(c)).join('')
1282
+ }
1283
+
1284
+ private effectChildText(node: RedNode): string {
1285
+ if (node.kind === 'spacing' || node.kind === 'empty_line') return '\n'
1286
+ if (node.kind === 'text') return node.text ?? ''
1287
+ if (node.children.length === 0) return this.collectNodeText(node)
1288
+ return node.children.map(c => this.effectChildText(c)).join('')
1289
+ }
1290
+
1291
+ /**
1292
+ * Paint an effect's computed segments as spans.
1293
+ *
1294
+ * Shared by rainbow, grow and any modulated gradient, so the preview
1295
+ * shows exactly the colours and sizes the BBCode export will contain
1296
+ * rather than an approximation of them.
1297
+ */
1298
+ private renderEffectSegments(node: RedNode, kind: EffectKind): string {
1299
+ const text = this.effectText(node)
1300
+ if (!text) return this.renderChildren(node)
1301
+
1302
+ const meta = (node.metadata ?? {}) as Record<string, unknown>
1303
+ const segments = evaluateEffect(text, kind, this.effectParams(node), {
1304
+ globalOffset: meta.globalOffset as number | undefined,
1305
+ documentLength: meta.documentLength as number | undefined,
1306
+ })
1307
+
1308
+ let out = ''
1309
+ for (const seg of segments) {
1310
+ // The break is markup, not text: escaping it would print a newline
1311
+ // that HTML collapses to a space, which is how a painted block of
1312
+ // ASCII art would arrive as one unreadable line.
1313
+ // El `indexOf` delante: un degradado sin fusionar emite un segmento POR
1314
+ // CARÁCTER, y `/\n/g` es un literal —en ES2015+ eso asigna un objeto
1315
+ // nuevo cada vez que se evalúa— para recorrer una cadena de un carácter
1316
+ // que casi nunca es un salto de línea.
1317
+ const html = this.escapeHtml(seg.text)
1318
+ const escaped = html.indexOf('\n') === -1 ? html : html.replace(NEWLINE_RE, '<br>')
1319
+ if (seg.color) {
1320
+ const safe = sanitizeColor(seg.color, this.tokenResolver)
1321
+ out += safe ? `<span style="color:${safe}">${escaped}</span>` : escaped
1322
+ } else if (seg.size !== undefined) {
1323
+ out += `<span style="font-size:${Math.max(10, Math.min(400, seg.size))}%">${escaped}</span>`
1324
+ } else {
1325
+ out += escaped
1326
+ }
1327
+ }
1328
+ return `<span${this.idAttr(node)} class="bb-effect bb-${kind}">${out}</span>`
1329
+ }
1330
+
1331
+ /**
1332
+ * A gradient previews as a real CSS gradient when it is a plain
1333
+ * left-to-right ramp, and as computed per-character spans otherwise.
1334
+ *
1335
+ * The CSS path is genuinely smoother — it interpolates per pixel rather
1336
+ * than per glyph — but it can only express a linear sweep. A gradient
1337
+ * with a waveform, a non-index axis or quantisation steps looked
1338
+ * nothing like its own export while this was the only path.
1339
+ */
1049
1340
  private renderGradient(node: RedNode): string {
1050
- // `startsWith('#')` was NOT a filter: `#a" onmouseover="alert(1)` passes it.
1051
- // Every stop must survive the full color allowlist or it is dropped.
1052
- const raw = (node.metadata?.colors as string[]) || []
1053
- const valid = raw
1054
- .map(c => (typeof c === 'string' ? this.sanitizeColor(c) : null))
1055
- .filter((c): c is string => c !== null)
1056
- const colors =
1057
- valid.length === 0 ? ['#FF0000', '#00FF00']
1058
- : valid.length === 1 ? [valid[0], valid[0]]
1059
- : valid
1060
- // Skip spacing/empty_line children so source formatting (newlines inside the tag)
1061
- // doesn't produce extra <br> in the preview. Both:
1062
- // [gradient]Hello[/gradient] and [gradient]\nHello\n[/gradient]
1063
- // render identically.
1341
+ const params = this.effectParams(node)
1064
1342
  const content = node.children
1065
1343
  .filter(c => c.kind !== 'spacing' && c.kind !== 'empty_line')
1066
1344
  .map(c => this.renderNode(c)).join('')
1067
- const gradientCss = `linear-gradient(to right, ${colors.join(', ')})`
1068
- return `<span${this.idAttr(node)} style="background: ${gradientCss}; -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text;">${content}</span>`
1345
+
1346
+ if (isPlainGradient(params)) {
1347
+ const stops = params.stops && params.stops.length > 0
1348
+ ? params.stops.map(st => `${st.color} ${(st.position * 100).toFixed(1)}%`)
1349
+ : (params.colors ?? ['#FF0000', '#00FF00'])
1350
+ const list = stops.length === 1 ? [stops[0], stops[0]] : stops
1351
+ const gradientCss = `linear-gradient(to right, ${list.join(', ')})`
1352
+ return `<span${this.idAttr(node)} style="background: ${gradientCss}; -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text;">${content}</span>`
1353
+ }
1354
+
1355
+ return this.renderEffectSegments(node, 'gradient')
1069
1356
  }
1070
1357
 
1071
1358
  /**
@@ -1194,3 +1481,26 @@ export class HTMLRenderer extends Visitor<string> {
1194
1481
  return out + text.slice(last)
1195
1482
  }
1196
1483
  }
1484
+
1485
+ /**
1486
+ * Whether a gradient is a plain left-to-right ramp.
1487
+ *
1488
+ * Only then can CSS express it. Anything with a waveform, a non-index
1489
+ * axis, quantisation, inversion or per-word stepping has to be painted
1490
+ * per character instead, or the preview and the export disagree.
1491
+ */
1492
+ function isPlainGradient(params: EffectParams): boolean {
1493
+ // A tag carrying a span is one fragment of a longer ramp. Painting it
1494
+ // as a full-width CSS gradient restarts the sweep inside every
1495
+ // fragment, which is exactly the discontinuity the span exists to fix.
1496
+ if (params.documentLength !== undefined && params.documentLength > 1) return false
1497
+ return (params.wave === undefined || params.wave === 'none')
1498
+ && (params.axis === undefined || params.axis === 'index')
1499
+ && (params.easing === undefined || params.easing === 'linear')
1500
+ && (params.steps === undefined || params.steps < 2)
1501
+ && !params.invert
1502
+ && (params.unit === undefined || params.unit === 'character')
1503
+ && (params.cycles === undefined || params.cycles === 1)
1504
+ && (params.phase === undefined || params.phase === 0)
1505
+ && (params.expression === undefined || params.expression === '')
1506
+ }