@miliastry/quasar 1.0.6 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (273) hide show
  1. package/dist/Visuals/lyne.css +117 -6
  2. package/dist/Visuals/osu.css +36 -5
  3. package/dist/index.js +9664 -3282
  4. package/dist/index.mjs +9535 -3283
  5. package/dist/types/Analysis/Contracts/AnalysisReport.d.ts +22 -0
  6. package/dist/types/Analysis/Contracts/Contribution.d.ts +68 -0
  7. package/dist/types/Analysis/Contracts/Pass.d.ts +59 -0
  8. package/dist/types/Analysis/Contracts/PipelineContext.d.ts +34 -0
  9. package/dist/types/Analysis/Passes/Analysis/ColorUsageAnalyzer.d.ts +56 -0
  10. package/dist/types/Analysis/Passes/Analysis/GradientAnalyzer.d.ts +111 -0
  11. package/dist/types/Analysis/Passes/Analysis/MergeableColorAnalyzer.d.ts +26 -0
  12. package/dist/types/Analysis/Passes/Analysis/RainbowAnalyzer.d.ts +47 -0
  13. package/dist/types/Analysis/Passes/Analysis/SymbolAnalyzer.d.ts +89 -0
  14. package/dist/types/Analysis/Passes/Analysis/WaveAnalyzer.d.ts +44 -0
  15. package/dist/types/Analysis/Passes/Analysis/__tests__/GradientAnalyzer.test.d.ts +4 -0
  16. package/dist/types/Analysis/Passes/Analysis/__tests__/MergeableColorAnalyzer.test.d.ts +4 -0
  17. package/dist/types/Analysis/Passes/Analysis/__tests__/RainbowAnalyzer.test.d.ts +4 -0
  18. package/dist/types/Analysis/Passes/Analysis/__tests__/WaveAnalyzer.test.d.ts +4 -0
  19. package/dist/types/Analysis/Passes/Decision/DefaultDecision.d.ts +30 -0
  20. package/dist/types/Analysis/Passes/Decision/PaletteRemapDecision.d.ts +169 -0
  21. package/dist/types/Analysis/Passes/Decision/__tests__/DefaultDecision.test.d.ts +4 -0
  22. package/dist/types/Analysis/Passes/Transform/CollapseGradientTransform.d.ts +37 -0
  23. package/dist/types/Analysis/Passes/Transform/MergeColorsTransform.d.ts +28 -0
  24. package/dist/types/Analysis/Passes/Transform/RainbowCollapseTransform.d.ts +19 -0
  25. package/dist/types/Analysis/Passes/Transform/WaveCollapseTransform.d.ts +20 -0
  26. package/dist/types/Analysis/Passes/Utility/CharacterCountAnalyzer.d.ts +19 -0
  27. package/dist/types/Analysis/Pipeline/Pipeline.d.ts +69 -0
  28. package/dist/types/Analysis/Pipeline/PipelineBuilder.d.ts +31 -0
  29. package/dist/types/Analysis/Pipeline/PipelineStage.d.ts +17 -0
  30. package/dist/types/Analysis/Utils/color-utils.d.ts +60 -0
  31. package/dist/types/Analysis/__tests__/Integration.test.d.ts +8 -0
  32. package/dist/types/Analysis/__tests__/PaletteRemap.test.d.ts +14 -0
  33. package/dist/types/Analysis/__tests__/Pipeline.test.d.ts +10 -0
  34. package/dist/types/Analysis/__tests__/RoundTrip.test.d.ts +30 -0
  35. package/dist/types/Analysis/__tests__/SymbolAnalyzer.test.d.ts +11 -0
  36. package/dist/types/Analysis/index.d.ts +40 -0
  37. package/dist/types/BBCode/BBCodeDocumentModel.d.ts +92 -0
  38. package/dist/types/BBCode/BBCodeToGreenNode.d.ts +111 -0
  39. package/dist/types/BBCode/Parser.d.ts +78 -0
  40. package/dist/types/BBCode/index.d.ts +4 -0
  41. package/dist/types/Collab/positions.d.ts +55 -0
  42. package/dist/types/Commands/Command.d.ts +37 -0
  43. package/dist/types/Commands/CommandRegistry.d.ts +48 -0
  44. package/dist/types/Commands/DeleteNode.d.ts +7 -0
  45. package/dist/types/Commands/InsertText.d.ts +8 -0
  46. package/dist/types/Commands/SplitMerge.d.ts +9 -0
  47. package/dist/types/Commands/WrapInTag.d.ts +8 -0
  48. package/dist/types/Commands/index.d.ts +6 -0
  49. package/dist/types/Diff/TreeDiffer.d.ts +52 -0
  50. package/dist/types/Diff/__tests__/TreeDiffer.test.d.ts +1 -0
  51. package/dist/types/Diff/index.d.ts +2 -0
  52. package/dist/types/Edits/EditPlan.d.ts +144 -0
  53. package/dist/types/Edits/Optimizer.d.ts +78 -0
  54. package/dist/types/Edits/Rules/Rule.d.ts +91 -0
  55. package/dist/types/Edits/Rules/dropEmptyTags.d.ts +40 -0
  56. package/dist/types/Edits/Rules/dropRedundantNesting.d.ts +59 -0
  57. package/dist/types/Edits/Rules/index.d.ts +9 -0
  58. package/dist/types/Edits/Rules/mergeAdjacent.d.ts +93 -0
  59. package/dist/types/Edits/Rules/reorderWrappers.d.ts +42 -0
  60. package/dist/types/Edits/Rules/shortenHex.d.ts +30 -0
  61. package/dist/types/Edits/Rules/tagValue.d.ts +56 -0
  62. package/dist/types/Edits/Rules/unwrapInvisibleColor.d.ts +47 -0
  63. package/dist/types/Edits/applyEdits.d.ts +36 -0
  64. package/dist/types/Edits/index.d.ts +6 -0
  65. package/dist/types/Events/EventBus.d.ts +103 -0
  66. package/dist/types/Events/index.d.ts +2 -0
  67. package/dist/types/Formatter/Formatter.d.ts +32 -0
  68. package/dist/types/Formatter/index.d.ts +2 -0
  69. package/dist/types/HTML/HTMLDocumentModel.d.ts +13 -0
  70. package/dist/types/HTML/HTMLToGreenNode.d.ts +4 -0
  71. package/dist/types/Incremental/BracketIndex.d.ts +101 -0
  72. package/dist/types/Incremental/ChangeTracker.d.ts +69 -0
  73. package/dist/types/Incremental/IncrementalParser.d.ts +246 -0
  74. package/dist/types/Incremental/__tests__/BracketIndex.test.d.ts +1 -0
  75. package/dist/types/Incremental/__tests__/IncrementalParser.test.d.ts +1 -0
  76. package/dist/types/Incremental/index.d.ts +4 -0
  77. package/dist/types/Lexer/BBCodeLexer.d.ts +74 -0
  78. package/dist/types/Lexer/Lexer.d.ts +54 -0
  79. package/dist/types/Lexer/index.d.ts +4 -0
  80. package/dist/types/Linter/Linter.d.ts +58 -0
  81. package/dist/types/Linter/index.d.ts +2 -0
  82. package/dist/types/Markdown/MarkdownAST.d.ts +122 -0
  83. package/dist/types/Markdown/MarkdownDocumentModel.d.ts +15 -0
  84. package/dist/types/Markdown/MarkdownLexer.d.ts +155 -0
  85. package/dist/types/Markdown/MarkdownParser.d.ts +42 -0
  86. package/dist/types/Markdown/MarkdownToGreenNode.d.ts +6 -0
  87. package/dist/types/MilHibri/MilHibriDocumentModel.d.ts +21 -0
  88. package/dist/types/Model/DocumentModel.d.ts +318 -0
  89. package/dist/types/Model/NodeFactory.d.ts +29 -0
  90. package/dist/types/Model/TagRegistry.d.ts +165 -0
  91. package/dist/types/Model/index.d.ts +5 -0
  92. package/dist/types/Plugins/PluginAPI.d.ts +44 -0
  93. package/dist/types/Plugins/PluginRegistry.d.ts +81 -0
  94. package/dist/types/Plugins/index.d.ts +3 -0
  95. package/dist/types/Queries/QueryEngine.d.ts +32 -0
  96. package/dist/types/Queries/index.d.ts +1 -0
  97. package/dist/types/Reconciler/SurgicalReconciler.d.ts +23 -0
  98. package/dist/types/RenderPipeline/RenderPipeline.d.ts +46 -0
  99. package/dist/types/RenderPipeline/RenderTree.d.ts +55 -0
  100. package/dist/types/RenderPipeline/index.d.ts +4 -0
  101. package/dist/types/Repair/NestingRepair.d.ts +48 -0
  102. package/dist/types/Repair/index.d.ts +1 -0
  103. package/dist/types/Semantic/SemanticAnalyzer.d.ts +321 -0
  104. package/dist/types/Semantic/index.d.ts +2 -0
  105. package/dist/types/Symbols/SymbolTable.d.ts +47 -0
  106. package/dist/types/Symbols/index.d.ts +1 -0
  107. package/dist/types/Syntax/GreenNode.d.ts +154 -0
  108. package/dist/types/Syntax/GreenNodePool.d.ts +134 -0
  109. package/dist/types/Syntax/NodeMatcher.d.ts +75 -0
  110. package/dist/types/Syntax/RedNode.d.ts +280 -0
  111. package/dist/types/Syntax/RedNodeStore.d.ts +116 -0
  112. package/dist/types/Syntax/TreeBuilder.d.ts +54 -0
  113. package/dist/types/Syntax/__tests__/GreenNode.test.d.ts +1 -0
  114. package/dist/types/Syntax/__tests__/RedNode.test.d.ts +1 -0
  115. package/dist/types/Syntax/__tests__/RedNodeStore.test.d.ts +1 -0
  116. package/dist/types/Syntax/greenEdit.d.ts +64 -0
  117. package/dist/types/Syntax/hash.d.ts +17 -0
  118. package/dist/types/Syntax/index.d.ts +8 -0
  119. package/dist/types/Syntax/nodeAttr.d.ts +63 -0
  120. package/dist/types/Syntax/partition.d.ts +67 -0
  121. package/dist/types/Syntax/preserveNodeIds.d.ts +47 -0
  122. package/dist/types/Tokens/TokenResolver.d.ts +25 -0
  123. package/dist/types/Tokens/index.d.ts +1 -0
  124. package/dist/types/Transactions/Transaction.d.ts +36 -0
  125. package/dist/types/Transactions/UndoManager.d.ts +63 -0
  126. package/dist/types/Transactions/index.d.ts +3 -0
  127. package/dist/types/Transformers/ASTOptimizer.d.ts +34 -0
  128. package/dist/types/Transformers/GradientTransformer.d.ts +18 -0
  129. package/dist/types/Transformers/GrowTransformer.d.ts +16 -0
  130. package/dist/types/Transformers/RainbowTransformer.d.ts +17 -0
  131. package/dist/types/Transformers/SineWaveTransformer.d.ts +17 -0
  132. package/dist/types/Transformers/Transformer.d.ts +17 -0
  133. package/dist/types/Types/core.d.ts +104 -0
  134. package/dist/types/Types/diagnostics.d.ts +85 -0
  135. package/dist/types/Types/index.d.ts +11 -0
  136. package/dist/types/Types/operations.d.ts +109 -0
  137. package/dist/types/Types/queries.d.ts +67 -0
  138. package/dist/types/Types/symbols.d.ts +42 -0
  139. package/dist/types/Types/tokens.d.ts +75 -0
  140. package/dist/types/Utils/BBCodeGenerator.d.ts +21 -0
  141. package/dist/types/Utils/ColorMath.d.ts +92 -0
  142. package/dist/types/Utils/EffectMath.d.ts +643 -0
  143. package/dist/types/Utils/TagVocabulary.d.ts +104 -0
  144. package/dist/types/Utils/TagVocabulary.test.d.ts +1 -0
  145. package/dist/types/Utils/color.d.ts +29 -0
  146. package/dist/types/Utils/dom-to-svg.d.ts +28 -0
  147. package/dist/types/Utils/dom-to-svg.test.d.ts +1 -0
  148. package/dist/types/Utils/treeTransformers.d.ts +160 -0
  149. package/dist/types/Visitors/BBBlocksExporter.d.ts +27 -0
  150. package/dist/types/Visitors/BBCodeExporter.d.ts +90 -0
  151. package/dist/types/Visitors/BlockPatcher.d.ts +91 -0
  152. package/dist/types/Visitors/DOMMorpher.d.ts +13 -0
  153. package/dist/types/Visitors/HTMLRenderer.d.ts +380 -0
  154. package/dist/types/Visitors/JSONExporter.d.ts +42 -0
  155. package/dist/types/Visitors/MarkdownExporter.d.ts +28 -0
  156. package/dist/types/Visitors/SVGRenderer.d.ts +16 -0
  157. package/dist/types/Visitors/TiptapExporter.d.ts +40 -0
  158. package/dist/types/Visitors/Visitor.d.ts +35 -0
  159. package/dist/types/Visitors/index.d.ts +9 -0
  160. package/dist/types/Visuals/BoxDrawer.d.ts +49 -0
  161. package/dist/types/Visuals/index.d.ts +38 -0
  162. package/dist/types/index.d.ts +154 -0
  163. package/package.json +14 -10
  164. package/src/Analysis/Passes/Analysis/ColorUsageAnalyzer.ts +103 -0
  165. package/src/Analysis/Passes/Analysis/GradientAnalyzer.ts +294 -20
  166. package/src/Analysis/Passes/Analysis/SymbolAnalyzer.ts +257 -0
  167. package/src/Analysis/Passes/Decision/PaletteRemapDecision.ts +425 -0
  168. package/src/Analysis/Pipeline/Pipeline.ts +15 -2
  169. package/src/Analysis/index.ts +8 -2
  170. package/src/BBCode/BBCodeDocumentModel.ts +3 -0
  171. package/src/BBCode/BBCodeToGreenNode.ts +18 -12
  172. package/src/BBCode/Parser.ts +38 -1
  173. package/src/Edits/EditPlan.ts +290 -0
  174. package/src/Edits/Optimizer.ts +185 -0
  175. package/src/Edits/Rules/Rule.ts +145 -0
  176. package/src/Edits/Rules/dropEmptyTags.ts +110 -0
  177. package/src/Edits/Rules/dropRedundantNesting.ts +120 -0
  178. package/src/Edits/Rules/index.ts +28 -0
  179. package/src/Edits/Rules/mergeAdjacent.ts +224 -0
  180. package/src/Edits/Rules/reorderWrappers.ts +154 -0
  181. package/src/Edits/Rules/shortenHex.ts +79 -0
  182. package/src/Edits/Rules/tagValue.ts +105 -0
  183. package/src/Edits/Rules/unwrapInvisibleColor.ts +93 -0
  184. package/src/Edits/applyEdits.ts +54 -0
  185. package/src/Edits/index.ts +22 -0
  186. package/src/Events/EventBus.ts +18 -0
  187. package/src/HTML/HTMLToGreenNode.ts +185 -37
  188. package/src/Incremental/BracketIndex.ts +249 -0
  189. package/src/Incremental/IncrementalParser.ts +464 -41
  190. package/src/Lexer/BBCodeLexer.ts +25 -3
  191. package/src/Lexer/index.ts +1 -1
  192. package/src/Markdown/MarkdownAST.ts +65 -2
  193. package/src/Markdown/MarkdownLexer.ts +69 -14
  194. package/src/Markdown/MarkdownParser.ts +749 -62
  195. package/src/Markdown/MarkdownToGreenNode.ts +148 -27
  196. package/src/MilHibri/MilHibriDocumentModel.ts +56 -0
  197. package/src/Model/DocumentModel.ts +138 -5
  198. package/src/Model/TagRegistry.ts +129 -134
  199. package/src/Reconciler/SurgicalReconciler.ts +460 -0
  200. package/src/Repair/NestingRepair.ts +116 -0
  201. package/src/Repair/index.ts +1 -0
  202. package/src/Semantic/SemanticAnalyzer.ts +1802 -65
  203. package/src/Syntax/GreenNodePool.ts +12 -12
  204. package/src/Syntax/RedNode.ts +17 -3
  205. package/src/Syntax/RedNodeStore.ts +9 -1
  206. package/src/Syntax/nodeAttr.ts +172 -0
  207. package/src/Tokens/TokenResolver.ts +55 -0
  208. package/src/Tokens/index.ts +1 -0
  209. package/src/Types/core.ts +15 -0
  210. package/src/Utils/ColorMath.ts +276 -55
  211. package/src/Utils/EffectMath.ts +2462 -0
  212. package/src/Utils/TagVocabulary.ts +141 -0
  213. package/src/Utils/dom-to-svg.ts +28 -12
  214. package/src/Visitors/BBBlocksExporter.ts +15 -1
  215. package/src/Visitors/BBCodeExporter.ts +319 -32
  216. package/src/Visitors/BlockPatcher.ts +87 -11
  217. package/src/Visitors/DOMMorpher.ts +15 -5
  218. package/src/Visitors/HTMLRenderer.ts +374 -151
  219. package/src/Visitors/MarkdownExporter.ts +98 -41
  220. package/src/Visitors/TiptapExporter.ts +1 -0
  221. package/src/Visuals/index.ts +7 -4
  222. package/src/Visuals/lyne.css +117 -6
  223. package/src/Visuals/osu.css +36 -5
  224. package/src/index.ts +172 -5
  225. package/dist/index.d.mts +0 -4612
  226. package/dist/index.d.ts +0 -4612
  227. package/src/Analysis/Passes/Analysis/__tests__/GradientAnalyzer.test.ts +0 -135
  228. package/src/Analysis/Passes/Analysis/__tests__/MergeableColorAnalyzer.test.ts +0 -84
  229. package/src/Analysis/Passes/Analysis/__tests__/RainbowAnalyzer.test.ts +0 -99
  230. package/src/Analysis/Passes/Analysis/__tests__/WaveAnalyzer.test.ts +0 -119
  231. package/src/Analysis/Passes/Decision/__tests__/DefaultDecision.test.ts +0 -179
  232. package/src/Analysis/__tests__/Integration.test.ts +0 -162
  233. package/src/Analysis/__tests__/Pipeline.test.ts +0 -133
  234. package/src/Diff/__tests__/TreeDiffer.test.ts +0 -65
  235. package/src/Incremental/__tests__/IncrementalParser.test.ts +0 -164
  236. package/src/Syntax/__tests__/GreenNode.test.ts +0 -33
  237. package/src/Syntax/__tests__/RedNode.test.ts +0 -81
  238. package/src/Syntax/__tests__/RedNodeStore.test.ts +0 -104
  239. package/src/Tests/ASTOptimizerIdempotence.test.ts +0 -77
  240. package/src/Tests/BlockPatcher.test.ts +0 -437
  241. package/src/Tests/BlockPatcherWindowed.test.ts +0 -364
  242. package/src/Tests/BoxDrawer.test.ts +0 -217
  243. package/src/Tests/BoxRichTitle.test.ts +0 -105
  244. package/src/Tests/Chars500kBenchmark.test.ts +0 -151
  245. package/src/Tests/Chars500kEdits.test.ts +0 -321
  246. package/src/Tests/CollabPositions.test.ts +0 -146
  247. package/src/Tests/CompilerPathProfiling.test.ts +0 -186
  248. package/src/Tests/DOMMorpher.test.ts +0 -142
  249. package/src/Tests/DomPatchPerf.test.ts +0 -60
  250. package/src/Tests/EffectSegments.snapshot.json +0 -616
  251. package/src/Tests/EffectSegments.test.ts +0 -68
  252. package/src/Tests/FindNodeAtOffset.test.ts +0 -65
  253. package/src/Tests/Fuzzer.test.ts +0 -166
  254. package/src/Tests/GreenNodePool.test.ts +0 -153
  255. package/src/Tests/Lexer.test.ts +0 -238
  256. package/src/Tests/LyneMode.test.ts +0 -291
  257. package/src/Tests/ModelCoherence.test.ts +0 -180
  258. package/src/Tests/Partition.test.ts +0 -238
  259. package/src/Tests/PluginTags.test.ts +0 -150
  260. package/src/Tests/ProblematicSection.test.ts +0 -46
  261. package/src/Tests/ProblematicSectionHTML.test.ts +0 -58
  262. package/src/Tests/RedReuse.test.ts +0 -134
  263. package/src/Tests/ReproDelete20k.test.ts +0 -62
  264. package/src/Tests/SemanticValidators.test.ts +0 -136
  265. package/src/Tests/StableNodeIds.test.ts +0 -210
  266. package/src/Tests/StudioColorBloat.test.ts +0 -25
  267. package/src/Tests/StudioDebugText.test.ts +0 -27
  268. package/src/Tests/StudioTrailingChar.test.ts +0 -25
  269. package/src/Tests/StudioValidText.test.ts +0 -25
  270. package/src/Tests/UrlImgBug.test.ts +0 -23
  271. package/src/Tests/VisualBuilderFidelity.test.ts +0 -105
  272. package/src/Tests/referenceDocument.ts +0 -119
  273. package/src/Utils/dom-to-svg.test.ts +0 -86
@@ -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)
@@ -363,14 +434,20 @@ export class HTMLRenderer extends Visitor<string> {
363
434
  case 'bubble': return this.wrapInline('span', node, 'class="bubble"')
364
435
  case 'flower': return this.wrapInline('span', node, 'class="flower"')
365
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')
366
443
  case 'spacing':
367
444
  if (this.options.osuBehaviour && this.isNextCodeBlock(node)) return '\n'
368
445
  if (this.isTrailingBlockBoundary(node)) return '\n'
369
- return this.isPrevBlockBoundary(node) ? '\n' : '<br>'
446
+ return this.isPrevBlockBoundary(node) ? '\n' : `<br${this.idAttr(node)}>`
370
447
  case 'empty_line':
371
448
  if (this.options.osuBehaviour && this.isImmediateEmptyLineBeforeCode(node)) return '\n'
372
449
  if (this.isTrailingBlockBoundary(node)) return '\n'
373
- return '<br>'
450
+ return `<div class="bb-empty-line"${this.idAttr(node)}><br></div>`
374
451
  case 'group': return this.wrapInline('span', node, 'class="group"')
375
452
  // Un párrafo no tiene etiqueta propia en BBCode, pero sí necesita un
376
453
  // elemento: sin él, la prosa suelta entre bloques no es clicable —
@@ -378,6 +455,9 @@ export class HTMLRenderer extends Visitor<string> {
378
455
  // Un `span` es inline, así que no altera el flujo del texto.
379
456
  case 'paragraph': return this.wrapInline('span', node, 'class="bb-paragraph"')
380
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 ''
381
461
  case 'text':
382
462
  return this.textWrap(node, this.escapeHtml(node.text || ''))
383
463
  default:
@@ -489,21 +569,9 @@ export class HTMLRenderer extends Visitor<string> {
489
569
  // half-escaped value still emits broken CSS, so we validate the shape and
490
570
  // drop anything that isn't a value we intended to support.
491
571
 
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
572
  /** Characters `escapeHtml` has to rewrite. Non-global on purpose: `test` must not carry `lastIndex`. */
503
573
  private static readonly HTML_ESCAPE_RE = /[&<>"']/
504
574
 
505
- private static readonly BARE_HEX_RE = /^[0-9a-f]{3,8}$/i
506
-
507
575
  private static readonly NAMED_COLORS: Record<string, string> = {
508
576
  black: '#000000',
509
577
  white: '#FFFFFF',
@@ -525,88 +593,45 @@ export class HTMLRenderer extends Visitor<string> {
525
593
  gold: '#FFD700',
526
594
  }
527
595
 
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
596
  /** Keyword-or-number CSS values (font-weight, font-style, text-decoration). */
548
597
  private isCssKeyword(raw: string | undefined): boolean {
549
598
  return !!raw && /^[a-z]{2,20}(?: [a-z]{2,20})?$|^[1-9]00$/i.test(raw.trim())
550
599
  }
551
600
 
552
601
  /** 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)
556
- }
557
602
 
558
603
  private colorStyle(node: RedNode): string {
559
- const color = this.sanitizeColor(this.metaOrAttr(node, 'color'))
604
+ const color = sanitizeColor(nodeAttrValue(node, 'color'), this.tokenResolver)
560
605
  return color ? `style="color:${color};"` : ''
561
606
  }
562
607
 
563
608
  private fontSizeStyle(node: RedNode): string {
564
- const size = this.sanitizeFontSize(this.metaOrAttr(node, 'size'))
609
+ const size = sanitizeFontSize(nodeAttrValue(node, 'size'), this.tokenResolver)
565
610
  return size ? `style="font-size:${size}%;"` : ''
566
611
  }
567
612
 
568
613
  private fontStyle(node: RedNode): string {
569
- const font = this.sanitizeFontFamily(this.metaOrAttr(node, 'font'))
614
+ const font = sanitizeFontFamily(nodeAttrValue(node, 'font'), this.tokenResolver)
570
615
  return font ? `style="font-family:${font};"` : ''
571
616
  }
572
617
 
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
618
  private renderLink(node: RedNode, kind: string): string {
602
- 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
+ }
603
626
  // Ensure the URL has a protocol for external links
604
627
  if (href && !href.startsWith('http://') && !href.startsWith('https://') && !href.startsWith('mailto:')) {
605
628
  href = 'https://' + href
606
629
  }
607
630
  const content = this.renderChildren(node) || href
608
631
  const h = href ? ` href="${this.escapeHtml(href)}"` : ''
609
- 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>`
610
635
  }
611
636
 
612
637
  private renderProfile(node: RedNode): string {
@@ -615,48 +640,55 @@ export class HTMLRenderer extends Visitor<string> {
615
640
 
616
641
  private renderEntity(node: RedNode, type: 'profile' | 'guild' | 'map'): string {
617
642
  const key = type === 'profile' ? 'username' : type === 'guild' ? 'tag' : 'id'
618
- const val = String(node.metadata?.[key] ?? '') || this.extractValue(node)
643
+ const val = String(node.metadata?.[key] ?? '') || nodeAttrValue(node)
619
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)}"` : '')
620
650
  if (this.options.entityLinkResolver) {
621
651
  const link = this.options.entityLinkResolver(type, val || content)
622
652
  if (link) {
623
653
  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>`
654
+ return `<strong${entity}><a${this.idAttr(node)} href="${this.escapeHtml(link.href)}"${ext}>${content}</a></strong>`
625
655
  }
626
656
  }
627
657
  if (type === 'profile') {
628
658
  const url = this.options.theme === 'lyne' || this.options.dialect === 'lyne'
629
659
  ? `/u/${encodeURIComponent(val || content)}`
630
660
  : `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>`
661
+ return `<strong${entity}><a${this.idAttr(node)} href="${url}" target="_blank" rel="noopener">${content}</a></strong>`
632
662
  }
633
663
  if (type === 'guild') {
634
- 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>`
635
665
  }
636
666
  if (type === 'map') {
637
- 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>`
638
668
  }
639
- return `<strong><a${this.idAttr(node)} href="#">${content}</a></strong>`
669
+ return `<strong${entity}><a${this.idAttr(node)} href="#">${content}</a></strong>`
640
670
  }
641
671
 
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 {}
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>`
651
683
  }
652
684
 
653
685
  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>'
686
+ let src = String(node.metadata?.src ?? '') || nodeAttrValue(node) || ''
687
+ if (!src) return this.mediaError('img', '[img] missing source URL')
656
688
  if (this.options.mediaProxy) {
657
689
  src = this.options.mediaProxy(src)
658
690
  }
659
- const imgAttr = this.parseImgAttr(this.extractValue(node))
691
+ const imgAttr = parseImgAttr(nodeAttrValue(node))
660
692
  const styles: string[] = []
661
693
  if (imgAttr.w) styles.push(`width:${imgAttr.w}px`)
662
694
  if (imgAttr.h) styles.push(`height:${imgAttr.h}px`)
@@ -671,15 +703,21 @@ export class HTMLRenderer extends Visitor<string> {
671
703
  styles.push('max-width:100%', 'height:auto', 'display:inline-block')
672
704
  }
673
705
  const cls = imgAttr.round ? '' : ' class="bb-img"'
674
- 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(';')};">`
675
711
  }
676
712
 
677
713
  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>'
714
+ let id = String(node.metadata?.videoId ?? '') || nodeAttrValue(node) || ''
715
+ if (!id) return this.mediaError('youtube', '[youtube] missing video ID')
680
716
  const ytMatch = /(?:youtu\.be\/|v=|\/embed\/|\/shorts\/)([\w-]{11})/.exec(id)
681
717
  if (ytMatch) id = ytMatch[1]
682
- 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>`
683
721
  }
684
722
 
685
723
  private renderAudio(node: RedNode): string {
@@ -707,7 +745,7 @@ export class HTMLRenderer extends Visitor<string> {
707
745
  private renderNotice(node: RedNode, warning: boolean): string {
708
746
  const isLyne = this.options.theme === 'lyne' || this.options.dialect === 'lyne' || warning
709
747
  if (isLyne) {
710
- const color = this.sanitizeColor(this.metaOrAttr(node, 'color'))
748
+ const color = sanitizeColor(nodeAttrValue(node, 'color'), this.tokenResolver)
711
749
  const styleAttr = color
712
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)};"`
713
751
  : ''
@@ -721,7 +759,7 @@ export class HTMLRenderer extends Visitor<string> {
721
759
  }
722
760
 
723
761
  private renderTables(node: RedNode): string {
724
- const variant = (String(node.metadata?.variant ?? '') || this.extractValue(node) || '').trim().toLowerCase()
762
+ const variant = (String(node.metadata?.variant ?? '') || nodeAttrValue(node) || '').trim().toLowerCase()
725
763
  const variants = new Set(variant.split(/[\s,]+/).filter(Boolean))
726
764
  const tableClasses = [
727
765
  'bb-table',
@@ -729,7 +767,9 @@ export class HTMLRenderer extends Visitor<string> {
729
767
  variants.has('borders') ? 'bb-table-borders' : '',
730
768
  ].filter(Boolean).join(' ')
731
769
  this.tableDepth++
770
+ this.maxColsStack.push(HTMLRenderer.widestRow(node))
732
771
  const content = this.renderChildren(node)
772
+ this.maxColsStack.pop()
733
773
  this.tableDepth--
734
774
  // `[tables=striped:#hex]`: de `--table-accent` se deriva toda la paleta
735
775
  // de la tabla (gradiente del frame, hover de filas, encabezado, bordes).
@@ -737,22 +777,52 @@ export class HTMLRenderer extends Visitor<string> {
737
777
  return `<div${this.idAttr(node)} class="bb-table-frame"${accent}><table class="${tableClasses}"><tbody>${content}</tbody></table></div>`
738
778
  }
739
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
+
740
812
  private getTableContext(node: RedNode): { cellCount: number; maxCols: number; isDirect: boolean } {
741
813
  const parent = node.parent
742
814
  const isDirect = parent?.kind === 'tables'
743
815
  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
816
+ const cellCount = parent ? HTMLRenderer.countCells(parent) : 1
747
817
 
748
818
  let maxCols = cellCount
749
819
  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
- }
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
756
826
  }
757
827
  return { cellCount, maxCols, isDirect }
758
828
  }
@@ -763,7 +833,7 @@ export class HTMLRenderer extends Visitor<string> {
763
833
  }
764
834
 
765
835
  private renderColumns(node: RedNode): string {
766
- const cols = parseInt(String(node.metadata?.columns ?? '') || this.extractValue(node) || '2', 10)
836
+ const cols = parseInt(String(node.metadata?.columns ?? '') || nodeAttrValue(node) || '2', 10)
767
837
  const numCols = Math.max(2, Math.min(4, isNaN(cols) ? 2 : cols))
768
838
  const content = this.renderChildren(node)
769
839
  // `[columns=2:#hex]`: con color se añade la superficie (borde + fondo
@@ -775,27 +845,27 @@ export class HTMLRenderer extends Visitor<string> {
775
845
  }
776
846
 
777
847
  private renderSeparator(node: RedNode): string {
778
- const variant = (String(node.metadata?.variant ?? '') || this.extractValue(node) || 'line').trim().toLowerCase()
848
+ const variant = (String(node.metadata?.variant ?? '') || nodeAttrValue(node) || 'line').trim().toLowerCase()
779
849
  if (variant === 'dots') return `<div${this.idAttr(node)} class="bb-separator">· · ·</div>`
780
850
  if (variant === 'stars') return `<div${this.idAttr(node)} class="bb-separator">✦ ✦ ✦</div>`
781
851
  return `<hr${this.idAttr(node)} class="bb-separator" />`
782
852
  }
783
853
 
784
854
  private renderScroll(node: RedNode): string {
785
- const h = parseInt(String(node.metadata?.height ?? '') || this.extractValue(node) || '200', 10)
855
+ const h = parseInt(String(node.metadata?.height ?? '') || nodeAttrValue(node) || '200', 10)
786
856
  const maxH = Math.max(50, Math.min(2000, isNaN(h) ? 200 : h))
787
857
  const content = this.renderChildren(node)
788
858
  return `<div${this.idAttr(node)} class="bb-scroll" style="max-height:${maxH}px;">${content}</div>`
789
859
  }
790
860
 
791
861
  private renderAbbr(node: RedNode): string {
792
- const title = String(node.metadata?.title ?? '') || this.extractValue(node)
862
+ const title = String(node.metadata?.title ?? '') || nodeAttrValue(node)
793
863
  const attr = title ? ` title="${this.escapeHtml(title)}"` : ''
794
864
  return this.wrapInline('abbr', node, attr)
795
865
  }
796
866
 
797
867
  private renderTooltip(node: RedNode): string {
798
- const tip = String(node.metadata?.tip ?? '') || this.extractValue(node)
868
+ const tip = String(node.metadata?.tip ?? '') || nodeAttrValue(node)
799
869
  const attr = tip ? ` title="${this.escapeHtml(tip)}"` : ''
800
870
  return this.wrapInline('span', node, `class="bb-tooltip"${attr}`)
801
871
  }
@@ -810,16 +880,16 @@ export class HTMLRenderer extends Visitor<string> {
810
880
  }
811
881
 
812
882
  private renderAlign(node: RedNode): string {
813
- const alignVal = (String(node.metadata?.align ?? '') || this.extractValue(node) || 'center').trim().toLowerCase()
883
+ const alignVal = (String(node.metadata?.align ?? '') || nodeAttrValue(node) || 'center').trim().toLowerCase()
814
884
  const validAlign = alignVal === 'left' || alignVal === 'right' ? alignVal : 'center'
815
885
  return this.wrapBlock('div', node, `style="text-align:${validAlign};"`)
816
886
  }
817
887
 
818
888
  private renderEffect(node: RedNode): string {
819
- const raw = (String(node.metadata?.effectType ?? '') || this.extractValue(node) || 'glow').toLowerCase().trim()
889
+ const raw = (String(node.metadata?.effectType ?? '') || nodeAttrValue(node) || 'glow').toLowerCase().trim()
820
890
  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)
891
+ const rawColor = String(node.metadata?.color ?? '') || (raw.includes(':') ? raw.split(':')[1] : (raw.startsWith('#') ? raw : nodeAttrValue(node)))
892
+ const color = sanitizeColor(rawColor, this.tokenResolver)
823
893
  const content = this.renderChildren(node)
824
894
  const idAttr = this.idAttr(node)
825
895
 
@@ -852,7 +922,7 @@ export class HTMLRenderer extends Visitor<string> {
852
922
  }
853
923
 
854
924
  private renderAnim(node: RedNode): string {
855
- const raw = (String(node.metadata?.animType ?? '') || this.extractValue(node) || 'pulse').toLowerCase().trim()
925
+ const raw = (String(node.metadata?.animType ?? '') || nodeAttrValue(node) || 'pulse').toLowerCase().trim()
856
926
  const animType = raw.includes(':') ? raw.split(':')[0] : raw
857
927
  const content = this.renderChildren(node)
858
928
  const idAttr = this.idAttr(node)
@@ -880,7 +950,7 @@ export class HTMLRenderer extends Visitor<string> {
880
950
  }
881
951
 
882
952
  private renderContainer(node: RedNode): string {
883
- const raw = (String(node.metadata?.containerType ?? '') || this.extractValue(node) || 'stack').trim()
953
+ const raw = (String(node.metadata?.containerType ?? '') || nodeAttrValue(node) || 'stack').trim()
884
954
  const [type, ...params] = raw.split(':')
885
955
  const containerType = type.toLowerCase()
886
956
  const param = params.join(':')
@@ -904,7 +974,7 @@ export class HTMLRenderer extends Visitor<string> {
904
974
  return `<div${idAttr} class="bb-middle">${content}</div>`
905
975
  case 'square':
906
976
  case 'circle': {
907
- const accent = this.sanitizeColor(param)
977
+ const accent = sanitizeColor(param, this.tokenResolver)
908
978
  const border = accent ? this.hexToRgba(accent, 0.35) : ''
909
979
  const bg = accent ? this.hexToRgba(accent, 0.08) : ''
910
980
  const style = accent ? ` style="--square-accent:${accent};--square-border:${border};--square-bg:${bg};"` : ''
@@ -917,14 +987,14 @@ export class HTMLRenderer extends Visitor<string> {
917
987
  // del que el CSS deriva borde, tinte de fondo y —vía la clase
918
988
  // `bb-accented`— la paleta del contenido sin color propio. Sin color,
919
989
  // defaults y sin clase.
920
- const accent = this.sanitizeColor(param)
990
+ const accent = sanitizeColor(param, this.tokenResolver)
921
991
  const cls = accent ? ' bb-accented' : ''
922
992
  const style = accent ? ` style="--${containerType}-accent:${accent};"` : ''
923
993
  return `<div${idAttr} class="bb-cut-panel bb-${containerType}${cls}"${style}>${content}</div>`
924
994
  }
925
995
  case 'neon-box':
926
996
  case 'neonbox': {
927
- 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)'
928
998
  return `<div${idAttr} class="bb-cut-panel bb-neon-box" style="--neon-color:${c};border-color:${c};">${content}</div>`
929
999
  }
930
1000
  default:
@@ -943,7 +1013,7 @@ export class HTMLRenderer extends Visitor<string> {
943
1013
  ])
944
1014
 
945
1015
  private renderStyleTag(node: RedNode): string {
946
- const raw = (String(node.metadata?.style ?? '') || this.extractValue(node) || '').trim()
1016
+ const raw = (String(node.metadata?.style ?? '') || nodeAttrValue(node) || '').trim()
947
1017
  const content = this.renderChildren(node)
948
1018
  const idAttr = this.idAttr(node)
949
1019
  if (!raw) return `<span${idAttr}>${content}</span>`
@@ -961,7 +1031,10 @@ export class HTMLRenderer extends Visitor<string> {
961
1031
  // color-mix()…) — antes se eliminaban y rompían esos valores. Lo que sí
962
1032
  // se bloquea son las funciones/fuentes peligrosas (url(), expression(),
963
1033
  // javascript:), que no tienen uso legítimo en el whitelist de props.
964
- 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
+ }
965
1038
  if (/url\s*\(|expression\s*\(|javascript\s*:/i.test(safeVal)) continue
966
1039
  safeStyles.push(`${prop}:${safeVal}`)
967
1040
  }
@@ -971,7 +1044,7 @@ export class HTMLRenderer extends Visitor<string> {
971
1044
  }
972
1045
 
973
1046
  private renderQuote(node: RedNode): string {
974
- const source = node.metadata?.source || this.extractValue(node)
1047
+ const source = node.metadata?.source || nodeAttrValue(node)
975
1048
  const content = this.renderChildren(node)
976
1049
  if (source) {
977
1050
  return `<blockquote${this.idAttr(node)}><div style="margin-bottom:8px"><strong>${this.escapeHtml(String(source))} wrote:</strong></div>${content}</blockquote>`
@@ -981,6 +1054,7 @@ export class HTMLRenderer extends Visitor<string> {
981
1054
 
982
1055
  private renderSpoilerbox(node: RedNode): string {
983
1056
  const title = this.renderTitle(node, 'Spoiler')
1057
+ const bare = this.bareTitleAttr(node)
984
1058
  const content = this.renderChildren(node)
985
1059
  const isLyne = this.options.theme === 'lyne' || this.options.dialect === 'lyne'
986
1060
  const bodyCls = isLyne ? 'bb-box-body' : 'bbcode-box-body'
@@ -990,11 +1064,12 @@ export class HTMLRenderer extends Visitor<string> {
990
1064
  // `--box-accent` colorea el acento del box (título + chevron, y el borde
991
1065
  // en boxw) cuando el autor puso `[box=Title:#hex]`.
992
1066
  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>`
1067
+ return `<details${this.idAttr(node)}${bare}${accent}><summary><span class="bb-box-heading">${title}</span></summary><div class="${bodyCls}">${content}</div></details>`
994
1068
  }
995
1069
 
996
1070
  private renderBox(node: RedNode): string {
997
1071
  const title = this.renderTitle(node, 'Box')
1072
+ const bare = this.bareTitleAttr(node)
998
1073
  const content = this.renderChildren(node)
999
1074
  const isLyne = this.options.theme === 'lyne' || this.options.dialect === 'lyne'
1000
1075
  const bodyCls = isLyne ? 'bb-box-body' : 'bbcode-box-body'
@@ -1006,7 +1081,20 @@ export class HTMLRenderer extends Visitor<string> {
1006
1081
  // (no `bb-box-title`) evita la regla legacy `.bbcode-preview .bb-box-title`
1007
1082
  // de la app, que pinta un fondo sobre el título.
1008
1083
  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>`
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"'
1010
1098
  }
1011
1099
 
1012
1100
  private renderTitle(node: RedNode, fallback: string): string {
@@ -1014,8 +1102,14 @@ export class HTMLRenderer extends Visitor<string> {
1014
1102
  if (titleNodes && titleNodes.length > 0) {
1015
1103
  return titleNodes.map(c => this.renderNode(c)).join('')
1016
1104
  }
1017
- const title = node.metadata?.title ?? this.extractValue(node) ?? fallback
1018
- 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)
1019
1113
  }
1020
1114
 
1021
1115
  /**
@@ -1025,7 +1119,7 @@ export class HTMLRenderer extends Visitor<string> {
1025
1119
  * borde + fondo en columns). Devuelve '' si no hay color válido.
1026
1120
  */
1027
1121
  private boxAccentStyle(node: RedNode, suffix: 'box' | 'table' | 'columns' = 'box'): string {
1028
- const color = this.sanitizeColor(String(node.metadata?.color ?? ''))
1122
+ const color = sanitizeColor(String(node.metadata?.color ?? ''), this.tokenResolver)
1029
1123
  if (!color) return ''
1030
1124
  const border = this.hexToRgba(color, 0.35)
1031
1125
  const bg = this.hexToRgba(color, 0.08)
@@ -1034,7 +1128,7 @@ export class HTMLRenderer extends Visitor<string> {
1034
1128
 
1035
1129
  private renderList(node: RedNode): string {
1036
1130
  // Check for ordered list: [list=1], [list=a], or metadata
1037
- const attrs = this.extractValue(node)
1131
+ const attrs = nodeAttrValue(node)
1038
1132
  const isOrdered = node.metadata?.ordered === true || attrs === '1' || attrs === 'a'
1039
1133
  const tag = isOrdered ? 'ol' : 'ul'
1040
1134
  const content = node.children.map(c => this.renderNode(c)).join('\n')
@@ -1055,7 +1149,12 @@ export class HTMLRenderer extends Visitor<string> {
1055
1149
  const trailingMatch = content.match(/(?:[\r\n][\t ]*)+$/)
1056
1150
  if (trailingMatch) content = content.slice(0, -trailingMatch[0].length)
1057
1151
 
1058
- 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>`
1059
1158
  }
1060
1159
 
1061
1160
  private renderSVG(node: RedNode): string {
@@ -1091,12 +1190,12 @@ export class HTMLRenderer extends Visitor<string> {
1091
1190
  .filter(Boolean)
1092
1191
 
1093
1192
  if (textLines.length === 0) {
1094
- return '<div class="media-error">[imagemap] missing image URL</div>'
1193
+ return this.mediaError('imagemap', '[imagemap] missing image URL')
1095
1194
  }
1096
1195
 
1097
1196
  const imageUrl = textLines[0]
1098
1197
  if (!imageUrl.startsWith('http://') && !imageUrl.startsWith('https://')) {
1099
- 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)}`)
1100
1199
  }
1101
1200
 
1102
1201
  let areas = ''
@@ -1133,26 +1232,127 @@ export class HTMLRenderer extends Visitor<string> {
1133
1232
  return node.text || ''
1134
1233
  }
1135
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
+ */
1136
1340
  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.
1341
+ const params = this.effectParams(node)
1151
1342
  const content = node.children
1152
1343
  .filter(c => c.kind !== 'spacing' && c.kind !== 'empty_line')
1153
1344
  .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>`
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')
1156
1356
  }
1157
1357
 
1158
1358
  /**
@@ -1281,3 +1481,26 @@ export class HTMLRenderer extends Visitor<string> {
1281
1481
  return out + text.slice(last)
1282
1482
  }
1283
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
+ }