@miliastry/quasar 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (172) hide show
  1. package/LICENSE +119 -0
  2. package/README.md +45 -0
  3. package/dist/Visuals/lyne.css +1027 -0
  4. package/dist/Visuals/osu.css +257 -0
  5. package/dist/index.d.mts +4563 -0
  6. package/dist/index.d.ts +4563 -0
  7. package/dist/index.js +11291 -0
  8. package/dist/index.mjs +11197 -0
  9. package/package.json +51 -0
  10. package/src/Analysis/Contracts/AnalysisReport.ts +26 -0
  11. package/src/Analysis/Contracts/Contribution.ts +76 -0
  12. package/src/Analysis/Contracts/Pass.ts +76 -0
  13. package/src/Analysis/Contracts/PipelineContext.ts +48 -0
  14. package/src/Analysis/Passes/Analysis/GradientAnalyzer.ts +292 -0
  15. package/src/Analysis/Passes/Analysis/MergeableColorAnalyzer.ts +112 -0
  16. package/src/Analysis/Passes/Analysis/RainbowAnalyzer.ts +233 -0
  17. package/src/Analysis/Passes/Analysis/WaveAnalyzer.ts +211 -0
  18. package/src/Analysis/Passes/Analysis/__tests__/GradientAnalyzer.test.ts +135 -0
  19. package/src/Analysis/Passes/Analysis/__tests__/MergeableColorAnalyzer.test.ts +84 -0
  20. package/src/Analysis/Passes/Analysis/__tests__/RainbowAnalyzer.test.ts +99 -0
  21. package/src/Analysis/Passes/Analysis/__tests__/WaveAnalyzer.test.ts +119 -0
  22. package/src/Analysis/Passes/Decision/DefaultDecision.ts +139 -0
  23. package/src/Analysis/Passes/Decision/__tests__/DefaultDecision.test.ts +179 -0
  24. package/src/Analysis/Passes/Transform/CollapseGradientTransform.ts +176 -0
  25. package/src/Analysis/Passes/Transform/MergeColorsTransform.ts +126 -0
  26. package/src/Analysis/Passes/Transform/RainbowCollapseTransform.ts +83 -0
  27. package/src/Analysis/Passes/Transform/WaveCollapseTransform.ts +88 -0
  28. package/src/Analysis/Passes/Utility/CharacterCountAnalyzer.ts +45 -0
  29. package/src/Analysis/Pipeline/Pipeline.ts +133 -0
  30. package/src/Analysis/Pipeline/PipelineBuilder.ts +55 -0
  31. package/src/Analysis/Pipeline/PipelineStage.ts +19 -0
  32. package/src/Analysis/Utils/color-utils.ts +132 -0
  33. package/src/Analysis/__tests__/Integration.test.ts +162 -0
  34. package/src/Analysis/__tests__/Pipeline.test.ts +133 -0
  35. package/src/Analysis/index.ts +52 -0
  36. package/src/BBCode/BBCodeDocumentModel.ts +175 -0
  37. package/src/BBCode/BBCodeToGreenNode.ts +755 -0
  38. package/src/BBCode/Parser.ts +384 -0
  39. package/src/BBCode/index.ts +12 -0
  40. package/src/Collab/positions.ts +91 -0
  41. package/src/Commands/Command.ts +44 -0
  42. package/src/Commands/CommandRegistry.ts +78 -0
  43. package/src/Commands/DeleteNode.ts +20 -0
  44. package/src/Commands/InsertText.ts +21 -0
  45. package/src/Commands/SplitMerge.ts +28 -0
  46. package/src/Commands/WrapInTag.ts +21 -0
  47. package/src/Commands/index.ts +6 -0
  48. package/src/Diff/TreeDiffer.ts +264 -0
  49. package/src/Diff/__tests__/TreeDiffer.test.ts +65 -0
  50. package/src/Diff/index.ts +2 -0
  51. package/src/Events/EventBus.ts +160 -0
  52. package/src/Events/index.ts +2 -0
  53. package/src/Formatter/Formatter.ts +54 -0
  54. package/src/Formatter/index.ts +2 -0
  55. package/src/HTML/HTMLDocumentModel.ts +35 -0
  56. package/src/HTML/HTMLToGreenNode.ts +290 -0
  57. package/src/Incremental/ChangeTracker.ts +105 -0
  58. package/src/Incremental/IncrementalParser.ts +591 -0
  59. package/src/Incremental/__tests__/IncrementalParser.test.ts +164 -0
  60. package/src/Incremental/index.ts +4 -0
  61. package/src/Lexer/BBCodeLexer.ts +382 -0
  62. package/src/Lexer/Lexer.ts +181 -0
  63. package/src/Lexer/index.ts +10 -0
  64. package/src/Linter/Linter.ts +193 -0
  65. package/src/Linter/index.ts +2 -0
  66. package/src/Markdown/MarkdownAST.ts +112 -0
  67. package/src/Markdown/MarkdownDocumentModel.ts +55 -0
  68. package/src/Markdown/MarkdownLexer.ts +203 -0
  69. package/src/Markdown/MarkdownParser.ts +455 -0
  70. package/src/Markdown/MarkdownToGreenNode.ts +153 -0
  71. package/src/Model/DocumentModel.ts +694 -0
  72. package/src/Model/NodeFactory.ts +117 -0
  73. package/src/Model/TagRegistry.ts +495 -0
  74. package/src/Model/index.ts +5 -0
  75. package/src/Plugins/PluginAPI.ts +119 -0
  76. package/src/Plugins/PluginRegistry.ts +132 -0
  77. package/src/Plugins/index.ts +3 -0
  78. package/src/Queries/QueryEngine.ts +152 -0
  79. package/src/Queries/index.ts +1 -0
  80. package/src/RenderPipeline/RenderPipeline.ts +125 -0
  81. package/src/RenderPipeline/RenderTree.ts +134 -0
  82. package/src/RenderPipeline/index.ts +4 -0
  83. package/src/Semantic/SemanticAnalyzer.ts +506 -0
  84. package/src/Semantic/index.ts +2 -0
  85. package/src/Symbols/SymbolTable.ts +124 -0
  86. package/src/Symbols/index.ts +1 -0
  87. package/src/Syntax/GreenNode.ts +324 -0
  88. package/src/Syntax/GreenNodePool.ts +269 -0
  89. package/src/Syntax/NodeMatcher.ts +370 -0
  90. package/src/Syntax/RedNode.ts +569 -0
  91. package/src/Syntax/RedNodeStore.ts +184 -0
  92. package/src/Syntax/TreeBuilder.ts +214 -0
  93. package/src/Syntax/__tests__/GreenNode.test.ts +33 -0
  94. package/src/Syntax/__tests__/RedNode.test.ts +81 -0
  95. package/src/Syntax/__tests__/RedNodeStore.test.ts +104 -0
  96. package/src/Syntax/greenEdit.ts +110 -0
  97. package/src/Syntax/hash.ts +30 -0
  98. package/src/Syntax/index.ts +12 -0
  99. package/src/Syntax/partition.ts +161 -0
  100. package/src/Syntax/preserveNodeIds.ts +201 -0
  101. package/src/Tests/ASTOptimizerIdempotence.test.ts +77 -0
  102. package/src/Tests/BlockPatcher.test.ts +437 -0
  103. package/src/Tests/BlockPatcherWindowed.test.ts +364 -0
  104. package/src/Tests/BoxDrawer.test.ts +217 -0
  105. package/src/Tests/BoxRichTitle.test.ts +105 -0
  106. package/src/Tests/Chars500kBenchmark.test.ts +151 -0
  107. package/src/Tests/Chars500kEdits.test.ts +321 -0
  108. package/src/Tests/CollabPositions.test.ts +146 -0
  109. package/src/Tests/CompilerPathProfiling.test.ts +186 -0
  110. package/src/Tests/DOMMorpher.test.ts +142 -0
  111. package/src/Tests/DomPatchPerf.test.ts +60 -0
  112. package/src/Tests/EffectSegments.snapshot.json +616 -0
  113. package/src/Tests/EffectSegments.test.ts +68 -0
  114. package/src/Tests/FindNodeAtOffset.test.ts +65 -0
  115. package/src/Tests/Fuzzer.test.ts +166 -0
  116. package/src/Tests/GreenNodePool.test.ts +153 -0
  117. package/src/Tests/Lexer.test.ts +238 -0
  118. package/src/Tests/LyneMode.test.ts +187 -0
  119. package/src/Tests/ModelCoherence.test.ts +180 -0
  120. package/src/Tests/Partition.test.ts +238 -0
  121. package/src/Tests/PluginTags.test.ts +150 -0
  122. package/src/Tests/ProblematicSection.test.ts +46 -0
  123. package/src/Tests/ProblematicSectionHTML.test.ts +58 -0
  124. package/src/Tests/RedReuse.test.ts +134 -0
  125. package/src/Tests/ReproDelete20k.test.ts +62 -0
  126. package/src/Tests/SemanticValidators.test.ts +136 -0
  127. package/src/Tests/StableNodeIds.test.ts +210 -0
  128. package/src/Tests/StudioColorBloat.test.ts +25 -0
  129. package/src/Tests/StudioDebugText.test.ts +27 -0
  130. package/src/Tests/StudioTrailingChar.test.ts +25 -0
  131. package/src/Tests/StudioValidText.test.ts +25 -0
  132. package/src/Tests/UrlImgBug.test.ts +23 -0
  133. package/src/Tests/VisualBuilderFidelity.test.ts +105 -0
  134. package/src/Tests/referenceDocument.ts +119 -0
  135. package/src/Transactions/Transaction.ts +176 -0
  136. package/src/Transactions/UndoManager.ts +111 -0
  137. package/src/Transactions/index.ts +3 -0
  138. package/src/Transformers/ASTOptimizer.ts +315 -0
  139. package/src/Transformers/GradientTransformer.ts +143 -0
  140. package/src/Transformers/GrowTransformer.ts +115 -0
  141. package/src/Transformers/RainbowTransformer.ts +121 -0
  142. package/src/Transformers/SineWaveTransformer.ts +130 -0
  143. package/src/Transformers/Transformer.ts +22 -0
  144. package/src/Types/core.ts +270 -0
  145. package/src/Types/diagnostics.ts +156 -0
  146. package/src/Types/index.ts +23 -0
  147. package/src/Types/operations.ts +180 -0
  148. package/src/Types/queries.ts +121 -0
  149. package/src/Types/symbols.ts +69 -0
  150. package/src/Types/tokens.ts +186 -0
  151. package/src/Utils/BBCodeGenerator.ts +126 -0
  152. package/src/Utils/ColorMath.ts +276 -0
  153. package/src/Utils/color.ts +112 -0
  154. package/src/Utils/dom-to-svg.test.ts +86 -0
  155. package/src/Utils/dom-to-svg.ts +615 -0
  156. package/src/Utils/treeTransformers.ts +717 -0
  157. package/src/Visitors/BBBlocksExporter.ts +69 -0
  158. package/src/Visitors/BBCodeExporter.ts +318 -0
  159. package/src/Visitors/BlockPatcher.ts +963 -0
  160. package/src/Visitors/DOMMorpher.ts +134 -0
  161. package/src/Visitors/HTMLRenderer.ts +1077 -0
  162. package/src/Visitors/JSONExporter.ts +66 -0
  163. package/src/Visitors/MarkdownExporter.ts +99 -0
  164. package/src/Visitors/SVGRenderer.ts +35 -0
  165. package/src/Visitors/TiptapExporter.ts +145 -0
  166. package/src/Visitors/Visitor.ts +48 -0
  167. package/src/Visitors/index.ts +9 -0
  168. package/src/Visuals/BoxDrawer.ts +175 -0
  169. package/src/Visuals/index.ts +42 -0
  170. package/src/Visuals/lyne.css +1027 -0
  171. package/src/Visuals/osu.css +257 -0
  172. package/src/index.ts +197 -0
@@ -0,0 +1,1077 @@
1
+ /**
2
+ * DocumentEngine — HTMLRenderer
3
+ *
4
+ * Renders the Document Model to HTML for preview.
5
+ * This is the main renderer for the visual BBCode preview.
6
+ *
7
+ * Uses the TagRegistry for custom rendering.
8
+ * Plugins can register custom renderers for preview components.
9
+ */
10
+
11
+ import { RedNode } from '../Syntax/RedNode'
12
+ import { Visitor } from './Visitor'
13
+ import type { TagRegistry } from '../Model/TagRegistry'
14
+ import { RenderTree } from '../RenderPipeline/RenderTree'
15
+
16
+ import type { BBCodeDialect } from '../BBCode/BBCodeToGreenNode'
17
+
18
+ export interface HTMLRendererOptions {
19
+ /**
20
+ * Replicate osu! forum BBCode spacing quirks.
21
+ * Defaults to true for full compatibility with Miliastry.
22
+ * Set to false for a more logical, predictable rendering engine.
23
+ */
24
+ osuBehaviour?: boolean
25
+ /** Registry for resolving custom tags */
26
+ registry?: TagRegistry
27
+ /** BBCode dialect to render for ('osu' | 'miliastry' | 'lyne') */
28
+ dialect?: BBCodeDialect
29
+ /** Visual theme for markup classes ('osu' | 'lyne' | 'miliastry') */
30
+ theme?: 'osu' | 'lyne' | 'miliastry'
31
+ /** Safe media proxy callback to rewrite image and media URLs */
32
+ mediaProxy?: (url: string) => string
33
+ /** Resolver for entity links like profile, guild, map */
34
+ entityLinkResolver?: (kind: string, value: string) => { href: string; external?: boolean } | null
35
+ }
36
+
37
+ export class HTMLRenderer extends Visitor<string> {
38
+ private options: Required<Omit<HTMLRendererOptions, 'registry' | 'mediaProxy' | 'entityLinkResolver'>> & {
39
+ registry?: TagRegistry
40
+ mediaProxy?: (url: string) => string
41
+ entityLinkResolver?: (kind: string, value: string) => { href: string; external?: boolean } | null
42
+ }
43
+
44
+ constructor(options: HTMLRendererOptions = {}) {
45
+ super()
46
+ this.options = {
47
+ osuBehaviour: options.osuBehaviour ?? true,
48
+ registry: options.registry,
49
+ dialect: options.dialect ?? (options.theme === 'lyne' ? 'lyne' : 'miliastry'),
50
+ theme: options.theme ?? (options.dialect === 'lyne' ? 'lyne' : 'osu'),
51
+ mediaProxy: options.mediaProxy,
52
+ entityLinkResolver: options.entityLinkResolver,
53
+ }
54
+ }
55
+
56
+ // ─── Tag → HTML Element Map ─────────────────────────────
57
+
58
+ private readonly BLOCK_TAGS = new Set([
59
+ 'notice', 'wnotice', 'spoilerbox', 'box', 'boxw', 'list', 'quote', 'code', 'svg',
60
+ 'heading', 'center', 'right', 'left', 'align', 'imagemap', 'image', 'document',
61
+ 'tables', 'table_row', 'gallery', 'columns', 'separator', 'scroll',
62
+ 'container',
63
+ ])
64
+
65
+ private readonly INLINE_TAGS = new Set([
66
+ 'bold', 'italic', 'underline', 'strikethrough',
67
+ 'color', 'font_size', 'font',
68
+ 'inline_code', 'spoiler', 'url', 'email', 'profile', 'guild', 'map',
69
+ 'zalgo', 'aesthetic', 'sparkle', 'bubble', 'flower',
70
+ 'sup', 'sub', 'abbr', 'mark', 'kbd', 'tooltip', 'flip', 'raw', 'plain',
71
+ 'effect', 'anim', 'style_tag',
72
+ ])
73
+
74
+ /**
75
+ * The ` data-node-id="…"` attribute, or `''` for nodes nobody looks up.
76
+ *
77
+ * ─── Why this is not emitted on everything ──────────────────────────────
78
+ *
79
+ * It used to be, carrying `node.id` — a process-global counter minted per
80
+ * RedNode, so a reparse renames every node in the document. Measured on a
81
+ * one-character edit: zero ids survive.
82
+ *
83
+ * `DOMMorpher` already noted that this rules the ids out as morph KEYS. The
84
+ * larger cost is that it also breaks the morpher's prefix/suffix trim, which
85
+ * uses `isEqualNode` — a comparison that includes attributes. An attribute
86
+ * that always differs makes every element compare unequal, so the trim never
87
+ * fires and the morpher walks the whole document. Measured on one keystroke
88
+ * in the reference document: **1665 `setAttribute` calls across 1725
89
+ * elements**, to write new numbers meaning the same thing, when the actual
90
+ * change was 10 insertions and 9 removals.
91
+ *
92
+ * ─── Why BLOCKS, and not stable ids ─────────────────────────────────────
93
+ *
94
+ * Making the id stable was the obvious repair and it does not work. Keying on
95
+ * the node's SPAN was measured: an edit at the END drops the writes to 6, but
96
+ * an insertion shifts every offset after it, so an edit near the START still
97
+ * cost 1637 — and spans are longer strings than `nN`, so the emitted HTML grew
98
+ * 10%. Stability under insertion is not something a position can have.
99
+ *
100
+ * The real observation is that no consumer ever wanted these on inline nodes.
101
+ * Both readers in the app are block-level: `usePreviewClick` walks up with
102
+ * `closest('[data-node-id]')` for *block selection*, and `useBlockHighlight`
103
+ * highlights and scrolls to a *block*. Emitting ids on the per-character
104
+ * spans of a gradient did not just cost — it made `closest()` stop at a
105
+ * character instead of the block the click meant.
106
+ *
107
+ * So the attribute goes where it is read. Inline nodes carry no id, compare
108
+ * equal, and let `isEqualNode` skip their subtrees natively; the handful of
109
+ * block containers that do carry one are few enough that their churn is
110
+ * noise.
111
+ */
112
+ /**
113
+ * Text leaves emit their content directly, with no wrapper element.
114
+ *
115
+ * They used to come wrapped in `<span class="bb-text">` — one extra DOM
116
+ * element per text leaf, which is HALF the preview's elements (measured:
117
+ * 1726 → 851 on a 19.6 KB post, 17251 → 8510 on a 196 KB one). The class
118
+ * earned none of it: it has no CSS rule anywhere in the repo, carries no
119
+ * `data-node-id` (text is not an id-bearing kind, so click mapping and
120
+ * highlighting never looked at it), and every style a text leaf can have
121
+ * still emits its own `<span style="…">` below.
122
+ *
123
+ * A/B in a production build, steady state: flush 41.4 → 39.7 ms at 19.6 KB
124
+ * and 55.4 → 46.4 ms at 196 KB, plus ~27% less HTML to serialize and parse.
125
+ */
126
+ private textWrap(node: RedNode, inner: string): string {
127
+ return inner
128
+ }
129
+
130
+ /**
131
+ * A qué elementos se les pone `data-node-id`.
132
+ *
133
+ * `'all'` (por defecto) — a todos. Es lo que permite que un clic en
134
+ * CUALQUIER punto del preview señale ese nodo exacto en el editor: en un
135
+ * degradado cada carácter es su propio nodo, y sin id no hay nada a lo que
136
+ * `closest()` pueda agarrarse.
137
+ *
138
+ * Esto estuvo desactivado por una buena razón que ya no aplica. Los ids se
139
+ * regeneraban en cada parseo, así que el atributo cambiaba en TODOS los
140
+ * elementos por pulsación: `isEqualNode` no casaba nunca, el morpher no
141
+ * podía saltarse ningún subárbol y se medían 1665 `setAttribute` sobre 1725
142
+ * elementos para escribir números nuevos que significaban lo mismo. Con la
143
+ * identidad estable entre reparseos eso desapareció: un nodo que no cambia
144
+ * conserva su id, su HTML es idéntico y el camino rápido del morpher sigue
145
+ * funcionando.
146
+ *
147
+ * El coste que queda es el tamaño: ~10 bytes por elemento, que hay que
148
+ * serializar y parsear. Medido con A/B en la misma sesión sobre el post con
149
+ * degradados (19,6 KB, 852 elementos):
150
+ *
151
+ * solo bloques (9 con id): latencia 48 ms · p95 72 ms
152
+ * todos (808 con id): latencia 56 ms · p95 104 ms
153
+ *
154
+ * Se paga a propósito: la precisión del clic es una función que se pidió, y
155
+ * 56 ms sigue holgadamente dentro de lo que se percibe como inmediato.
156
+ * `'blocks'` queda disponible para quien priorice la latencia, y `'none'`
157
+ * para los consumidores de solo lectura (foros, render estático): sin
158
+ * `data-node-id` en absoluto, el HTML es más pequeño y no hay nada que
159
+ * mantenga vivos los nodos del árbol.
160
+ */
161
+ static idMode: 'blocks' | 'all' | 'none' = 'all'
162
+
163
+ private idAttr(node: RedNode): string {
164
+ if (HTMLRenderer.idMode === 'none') return ''
165
+ if (HTMLRenderer.idMode === 'all') return ` data-node-id="${node.id}"`
166
+ return HTMLRenderer.ID_BEARING_KINDS.has(node.kind)
167
+ ? ` data-node-id="${node.id}"`
168
+ : ''
169
+ }
170
+
171
+ /**
172
+ * Kinds that carry `data-node-id`.
173
+ *
174
+ * The block containers a user can select or be scrolled to, plus the media
175
+ * nodes, which are atomic and clickable in their own right. Deliberately NOT
176
+ * `text` or any inline formatting kind — see {@link idAttr}.
177
+ */
178
+ private static readonly ID_BEARING_KINDS = new Set([
179
+ 'notice', 'wnotice', 'spoilerbox', 'box', 'boxw', 'list', 'list_item', 'quote', 'code', 'svg',
180
+ 'heading', 'center', 'right', 'left', 'align', 'imagemap', 'image', 'video', 'audio',
181
+ 'tables', 'table_row', 'gallery', 'columns', 'separator', 'scroll', 'container',
182
+ ])
183
+
184
+ // ─── Main Entry ─────────────────────────────────────────
185
+
186
+ visit(node: RedNode): string {
187
+ return this.renderNode(node)
188
+ }
189
+
190
+ render(root: RedNode): string {
191
+ return this.renderNode(root)
192
+ }
193
+
194
+ /**
195
+ * Render every child and concatenate.
196
+ *
197
+ * Replaces the `children.map(c => this.renderNode(c)).join('')` idiom, which
198
+ * was repeated at 16 call sites and allocated a closure plus an intermediate
199
+ * array of N strings at every level of the tree. Appending to one string lets
200
+ * the engine use its rope representation instead.
201
+ */
202
+ /**
203
+ * Render the direct children of `node` to HTML (no wrapper element).
204
+ *
205
+ * Public so the incremental preview (`BlockPatcher`) can morph a block
206
+ * element's inner content without re-rendering the whole document.
207
+ */
208
+ renderChildren(node: RedNode): string {
209
+ const children = node.children
210
+ let out = ''
211
+ for (let i = 0; i < children.length; i++) {
212
+ out += this.renderNode(children[i])
213
+ }
214
+ return out
215
+ }
216
+
217
+ // ─── Node Rendering ─────────────────────────────────────
218
+
219
+ private renderNode(node: RedNode): string {
220
+ // Leaf nodes
221
+ if (node.children.length === 0 && node.kind === 'text') {
222
+ let out = this.escapeHtml(node.text)
223
+ const style = node.metadata?.style as Record<string, string> | undefined
224
+ if (style) {
225
+ const inlineStyles = []
226
+ const color = style.color && this.sanitizeColor(style.color)
227
+ const fontSize = style.fontSize && this.sanitizeFontSize(style.fontSize)
228
+ if (color) inlineStyles.push(`color: ${color}`)
229
+ if (fontSize) inlineStyles.push(`font-size: ${fontSize}%`)
230
+ if (this.isCssKeyword(style.fontWeight)) inlineStyles.push(`font-weight: ${style.fontWeight}`)
231
+ if (this.isCssKeyword(style.fontStyle)) inlineStyles.push(`font-style: ${style.fontStyle}`)
232
+ if (this.isCssKeyword(style.textDecoration)) inlineStyles.push(`text-decoration: ${style.textDecoration}`)
233
+
234
+ if (inlineStyles.length > 0) {
235
+ out = `<span style="${inlineStyles.join('; ')}">${out}</span>`
236
+ }
237
+ }
238
+ return this.textWrap(node, out)
239
+ }
240
+
241
+ // Document root
242
+ if (node.kind === 'document') {
243
+ return this.renderChildren(node)
244
+ }
245
+
246
+ // Dispatch by kind
247
+ switch (node.kind) {
248
+ case 'bold': return this.wrapInline('strong', node)
249
+ case 'italic': return this.wrapInline('em', node)
250
+ case 'underline': return this.wrapInline('u', node)
251
+ case 'strikethrough': return this.wrapInline('s', node)
252
+ case 'inline_code': return this.wrapInline('code', node, 'class="inline"')
253
+ case 'spoiler': return this.wrapInline('span', node, 'class="spoiler"')
254
+ case 'color': return this.wrapInline('span', node, this.colorStyle(node))
255
+ case 'font_size': return this.wrapInline('span', node, this.fontSizeStyle(node))
256
+ case 'font': return this.wrapInline('span', node, this.fontStyle(node))
257
+ case 'url': return this.renderLink(node, 'url')
258
+ case 'email': return this.renderLink(node, 'email')
259
+ case 'profile': return this.renderProfile(node)
260
+ case 'image': return this.renderImage(node)
261
+ case 'video': return this.renderVideo(node)
262
+ case 'audio': return this.renderAudio(node)
263
+ case 'center': return this.wrapBlock('div', node, 'style="text-align:center;"')
264
+ case 'right': return this.wrapBlock('div', node, 'style="text-align:right;"')
265
+ case 'left': return this.wrapBlock('div', node, 'style="text-align:left;"')
266
+ case 'heading': return this.wrapBlock('h2', node)
267
+ case 'notice': return this.renderNotice(node, false)
268
+ case 'wnotice': return this.renderNotice(node, true)
269
+ case 'quote': return this.renderQuote(node)
270
+ case 'spoilerbox': return this.renderSpoilerbox(node)
271
+ case 'box':
272
+ case 'boxw': return this.renderBox(node)
273
+ case 'list': return this.renderList(node)
274
+ case 'list_item': return this.renderListItem(node)
275
+ case 'code': return this.renderCode(node)
276
+ case 'svg': return this.renderSVG(node)
277
+ case 'imagemap': return this.renderImagemap(node)
278
+ case 'align': return this.renderAlign(node)
279
+ case 'tables': return this.renderTables(node)
280
+ case 'table_row': return this.wrapBlock('tr', node)
281
+ case 'table_col': return this.wrapInline('td', node)
282
+ case 'table_th': {
283
+ const content = this.renderChildren(node)
284
+ return `<th${this.idAttr(node)} class="bb-th"><span class="bb-table-badge">${content}</span></th>`
285
+ }
286
+ case 'gallery': return this.renderGallery(node)
287
+ case 'columns': return this.renderColumns(node)
288
+ case 'separator': return this.renderSeparator(node)
289
+ case 'scroll': return this.renderScroll(node)
290
+ case 'sup': return this.wrapInline('sup', node)
291
+ case 'sub': return this.wrapInline('sub', node)
292
+ case 'abbr': return this.renderAbbr(node)
293
+ case 'mark': return this.wrapInline('mark', node, 'class="bb-mark"')
294
+ case 'kbd': return this.wrapInline('kbd', node, 'class="bb-kbd"')
295
+ case 'tooltip': return this.renderTooltip(node)
296
+ case 'flip': return this.wrapInline('span', node, 'style="display:inline-block;transform:scaleX(-1);"')
297
+ case 'raw': return this.renderRaw(node)
298
+ case 'plain': return this.renderPlain(node)
299
+ case 'guild': return this.renderEntity(node, 'guild')
300
+ case 'map': return this.renderEntity(node, 'map')
301
+ case 'effect': return this.renderEffect(node)
302
+ case 'anim': return this.renderAnim(node)
303
+ case 'container': return this.renderContainer(node)
304
+ case 'style_tag': return this.renderStyleTag(node)
305
+ case 'zalgo': return this.wrapInline('span', node, 'class="zalgo"')
306
+ case 'aesthetic': return this.wrapInline('span', node, 'class="aesthetic"')
307
+ case 'sparkle': return this.wrapInline('span', node, 'class="sparkle"')
308
+ case 'bubble': return this.wrapInline('span', node, 'class="bubble"')
309
+ case 'flower': return this.wrapInline('span', node, 'class="flower"')
310
+ case 'gradient': return this.renderGradient(node)
311
+ case 'spacing':
312
+ if (this.options.osuBehaviour && this.isNextCodeBlock(node)) return '\n'
313
+ if (this.isTrailingBlockBoundary(node)) return '\n'
314
+ return this.isPrevBlockBoundary(node) ? '\n' : '<br>'
315
+ case 'empty_line':
316
+ if (this.options.osuBehaviour && this.isImmediateEmptyLineBeforeCode(node)) return '\n'
317
+ if (this.isTrailingBlockBoundary(node)) return '\n'
318
+ return '<br>'
319
+ case 'group': return this.wrapInline('span', node, 'class="group"')
320
+ // Un párrafo no tiene etiqueta propia en BBCode, pero sí necesita un
321
+ // elemento: sin él, la prosa suelta entre bloques no es clicable —
322
+ // `closest('[data-node-id]')` no encuentra nada y el clic se pierde.
323
+ // Un `span` es inline, así que no altera el flujo del texto.
324
+ case 'paragraph': return this.wrapInline('span', node, 'class="bb-paragraph"')
325
+ case 'error': return this.renderError(node)
326
+ case 'text':
327
+ return this.textWrap(node, this.escapeHtml(node.text || ''))
328
+ default:
329
+ if (this.options.registry) {
330
+ const tagDef = this.options.registry.getByKind(node.kind)
331
+ if (tagDef?.toRenderNode) {
332
+ const renderNode = tagDef.toRenderNode({
333
+ node,
334
+ source: '', // We don't have the original source string here, but it's rarely needed for effect tags
335
+ visitChildren: (n) => this.renderChildren(n),
336
+ renderChild: (n) => RenderTree.text('unsupported'),
337
+ })
338
+ return RenderTree.toHTML(renderNode)
339
+ }
340
+ }
341
+ // A container of unknown kind (a plugin tag rendered without its
342
+ // registry, or with no toRenderNode) must not swallow its children —
343
+ // render them and skip only the unknown wrapper.
344
+ if (node.children.length > 0) return this.renderChildren(node)
345
+ return this.escapeHtml(node.text || '')
346
+ }
347
+ }
348
+
349
+ // ─── Render Helpers ─────────────────────────────────────
350
+
351
+ /** osu! quirk: newlines immediately preceding a [code] block are completely ignored */
352
+ private isNextCodeBlock(node: RedNode): boolean {
353
+ let next = node.nextSibling
354
+ while (next && (next.kind === 'spacing' || next.kind === 'empty_line')) {
355
+ next = next.nextSibling
356
+ }
357
+ return next?.kind === 'code'
358
+ }
359
+
360
+ /** Checks if this is the LAST empty_line right before a code block (skipping only spacing) */
361
+ private isImmediateEmptyLineBeforeCode(node: RedNode): boolean {
362
+ let next = node.nextSibling
363
+ while (next && next.kind === 'spacing') {
364
+ next = next.nextSibling
365
+ }
366
+ return next?.kind === 'code'
367
+ }
368
+
369
+ private isPrevBlockBoundary(node: RedNode): boolean {
370
+ let prev = node.previousSibling
371
+ while (prev) {
372
+ if (prev.kind === 'spacing' || prev.kind === 'empty_line') {
373
+ prev = prev.previousSibling
374
+ continue
375
+ }
376
+ if (prev.kind === 'text' && prev.text.trim() === '') {
377
+ prev = prev.previousSibling
378
+ continue
379
+ }
380
+ break
381
+ }
382
+
383
+ if (prev && this.BLOCK_TAGS.has(prev.kind) && prev.kind !== 'image' && prev.kind !== 'imagemap') return true
384
+ if (!prev && node.parent && this.BLOCK_TAGS.has(node.parent.kind) && node.parent.kind !== 'image' && node.parent.kind !== 'imagemap') return true
385
+
386
+ return false
387
+ }
388
+
389
+ private isTrailingBlockBoundary(node: RedNode): boolean {
390
+ let next = node.nextSibling
391
+ while (next) {
392
+ if (next.kind === 'spacing' || next.kind === 'empty_line') {
393
+ next = next.nextSibling
394
+ continue
395
+ }
396
+ if (next.kind === 'text' && next.text.trim() === '') {
397
+ next = next.nextSibling
398
+ continue
399
+ }
400
+ break
401
+ }
402
+ if (!next && node.parent && this.BLOCK_TAGS.has(node.parent.kind) && node.parent.kind !== 'document') {
403
+ return true
404
+ }
405
+ return false
406
+ }
407
+
408
+ private renderError(node: RedNode): string {
409
+ const errorMsg = this.escapeHtml((node.metadata?.message as string) || node.text || 'Syntax Error')
410
+ // The content is the raw offending tag, mapped as child text.
411
+ const content = this.renderChildren(node) || this.escapeHtml(node.text || '')
412
+ return `<span class="syntax-error" style="color: #ff4d4f; border-bottom: 2px wavy #ff4d4f; cursor: help;" title="${errorMsg}">⚠️ ${content}</span>`
413
+ }
414
+
415
+ private wrapInline(tag: string, node: RedNode, extra: string = ''): string {
416
+ const content = this.renderChildren(node)
417
+ const extraSpace = extra ? ` ${extra}` : ''
418
+ return `<${tag}${this.idAttr(node)}${extraSpace}>${content}</${tag}>`
419
+ }
420
+
421
+ private wrapBlock(tag: string, node: RedNode, extra: string = ''): string {
422
+ const content = this.renderChildren(node)
423
+ const extraSpace = extra ? ` ${extra}` : ''
424
+ return `<${tag}${this.idAttr(node)}${extraSpace}>${content}</${tag}>`
425
+ }
426
+
427
+ // ─── CSS Value Allowlists ───────────────────────────────
428
+ //
429
+ // Tag attributes are attacker-controlled: any BBCode pasted from a forum
430
+ // post reaches these functions verbatim. Interpolating them into a
431
+ // `style="..."` attribute without validation lets a payload such as
432
+ // `[color=red;" onmouseover="alert(1)]` close the attribute and inject a
433
+ // live event handler. Escaping alone would neuter the injection, but a
434
+ // half-escaped value still emits broken CSS, so we validate the shape and
435
+ // drop anything that isn't a value we intended to support.
436
+
437
+ /** #rgb / #rgba / #rrggbb / #rrggbbaa, a bare CSS color keyword, or rgb()/hsl(). */
438
+ private static readonly CSS_COLOR_RE =
439
+ /^(?:#(?:[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
440
+
441
+ /** Bare number — interpolated as a percentage. */
442
+ private static readonly CSS_SIZE_RE = /^\d{1,4}(?:\.\d{1,2})?$/
443
+
444
+ /** Font family list. Quotes are rejected outright; unquoted names are valid CSS. */
445
+ private static readonly CSS_FONT_RE = /^[A-Za-z0-9 ,_-]{1,120}$/
446
+
447
+ /** Characters `escapeHtml` has to rewrite. Non-global on purpose: `test` must not carry `lastIndex`. */
448
+ private static readonly HTML_ESCAPE_RE = /[&<>"']/
449
+
450
+ private sanitizeColor(raw: string): string | null {
451
+ const v = raw.trim()
452
+ return HTMLRenderer.CSS_COLOR_RE.test(v) ? v : null
453
+ }
454
+
455
+ private sanitizeFontSize(raw: string): string | null {
456
+ const v = raw.trim()
457
+ return HTMLRenderer.CSS_SIZE_RE.test(v) ? v : null
458
+ }
459
+
460
+ private sanitizeFontFamily(raw: string): string | null {
461
+ const v = raw.trim()
462
+ return HTMLRenderer.CSS_FONT_RE.test(v) ? v : null
463
+ }
464
+
465
+ /** Keyword-or-number CSS values (font-weight, font-style, text-decoration). */
466
+ private isCssKeyword(raw: string | undefined): boolean {
467
+ return !!raw && /^[a-z]{2,20}(?: [a-z]{2,20})?$|^[1-9]00$/i.test(raw.trim())
468
+ }
469
+
470
+ /** Read a metadata field, falling back to the raw tag attribute. */
471
+ private metaOrAttr(node: RedNode, key: string): string {
472
+ const meta = node.metadata?.[key]
473
+ return (typeof meta === 'string' && meta) || this.extractValue(node)
474
+ }
475
+
476
+ private colorStyle(node: RedNode): string {
477
+ const color = this.sanitizeColor(this.metaOrAttr(node, 'color'))
478
+ return color ? `style="color:${color};"` : ''
479
+ }
480
+
481
+ private fontSizeStyle(node: RedNode): string {
482
+ const size = this.sanitizeFontSize(this.metaOrAttr(node, 'size'))
483
+ return size ? `style="font-size:${size}%;"` : ''
484
+ }
485
+
486
+ private fontStyle(node: RedNode): string {
487
+ const font = this.sanitizeFontFamily(this.metaOrAttr(node, 'font'))
488
+ return font ? `style="font-family:${font};"` : ''
489
+ }
490
+
491
+ /**
492
+ * Extract the attribute value from a BBCode tag node.
493
+ *
494
+ * BBCode attributes come in the format `=VALUE` (e.g. `=#61afef`, `="Author"`,
495
+ * `=https://osu.ppy.sh`). This strips the leading `=` and any surrounding quotes.
496
+ *
497
+ * For tags without attrs (like text nodes, img content), returns the node text as-is.
498
+ */
499
+ private extractValue(node: RedNode): string {
500
+ const text = node.text || ''
501
+ if (!text) return ''
502
+
503
+ const eqIdx = text.indexOf('=')
504
+ if (eqIdx >= 0) {
505
+ // Strip `=` prefix and surrounding quotes
506
+ let value = text.slice(eqIdx + 1)
507
+ // Remove surrounding quotes: "value" or 'value'
508
+ if ((value.startsWith('"') && value.endsWith('"')) ||
509
+ (value.startsWith("'") && value.endsWith("'"))) {
510
+ value = value.slice(1, -1)
511
+ }
512
+ return value
513
+ }
514
+
515
+ // No `=` prefix — return as-is (used for media content like img URLs)
516
+ return text
517
+ }
518
+
519
+ private renderLink(node: RedNode, kind: string): string {
520
+ let href = String(node.metadata?.href ?? '') || this.extractValue(node)
521
+ // Ensure the URL has a protocol for external links
522
+ if (href && !href.startsWith('http://') && !href.startsWith('https://') && !href.startsWith('mailto:')) {
523
+ href = 'https://' + href
524
+ }
525
+ const content = this.renderChildren(node) || href
526
+ const h = href ? ` href="${this.escapeHtml(href)}"` : ''
527
+ return `<a${this.idAttr(node)}${h} target="_blank" rel="noopener">${content}</a>`
528
+ }
529
+
530
+ private renderProfile(node: RedNode): string {
531
+ return this.renderEntity(node, 'profile')
532
+ }
533
+
534
+ private renderEntity(node: RedNode, type: 'profile' | 'guild' | 'map'): string {
535
+ const key = type === 'profile' ? 'username' : type === 'guild' ? 'tag' : 'id'
536
+ const val = String(node.metadata?.[key] ?? '') || this.extractValue(node)
537
+ const content = this.renderChildren(node) || val
538
+ if (this.options.entityLinkResolver) {
539
+ const link = this.options.entityLinkResolver(type, val || content)
540
+ if (link) {
541
+ const ext = link.external ? ' target="_blank" rel="noopener noreferrer"' : ''
542
+ return `<strong><a${this.idAttr(node)} href="${this.escapeHtml(link.href)}"${ext}>${content}</a></strong>`
543
+ }
544
+ }
545
+ if (type === 'profile') {
546
+ const url = this.options.theme === 'lyne' || this.options.dialect === 'lyne'
547
+ ? `/u/${encodeURIComponent(val || content)}`
548
+ : `https://osu.ppy.sh/users/${this.escapeHtml(val || content)}`
549
+ return `<strong><a${this.idAttr(node)} href="${url}" target="_blank" rel="noopener">${content}</a></strong>`
550
+ }
551
+ if (type === 'guild') {
552
+ return `<strong><a${this.idAttr(node)} href="/guilds/${encodeURIComponent(val || content)}">${content}</a></strong>`
553
+ }
554
+ if (type === 'map') {
555
+ return `<strong><a${this.idAttr(node)} href="/maps/${encodeURIComponent(val || content)}">${content}</a></strong>`
556
+ }
557
+ return `<strong><a${this.idAttr(node)} href="#">${content}</a></strong>`
558
+ }
559
+
560
+ private parseImgAttr(v: string | null): { w?: number; h?: number; round?: boolean; shadow?: boolean; float?: boolean } {
561
+ if (!v) return {}
562
+ const t = v.trim().toLowerCase()
563
+ if (t === 'round') return { round: true }
564
+ if (t === 'shadow') return { shadow: true }
565
+ if (t === 'float') return { float: true }
566
+ const m = /^(\d{1,4})x(\d{1,4})$/i.exec(t)
567
+ if (m) return { w: Math.min(2000, parseInt(m[1], 10)), h: Math.min(2000, parseInt(m[2], 10)) }
568
+ return {}
569
+ }
570
+
571
+ private renderImage(node: RedNode): string {
572
+ let src = String(node.metadata?.src ?? '') || this.extractValue(node) || ''
573
+ if (!src) return '<div class="media-error">[img] missing source URL</div>'
574
+ if (this.options.mediaProxy) {
575
+ src = this.options.mediaProxy(src)
576
+ }
577
+ const imgAttr = this.parseImgAttr(this.extractValue(node))
578
+ const styles: string[] = []
579
+ if (imgAttr.w) styles.push(`width:${imgAttr.w}px`)
580
+ if (imgAttr.h) styles.push(`height:${imgAttr.h}px`)
581
+ if (imgAttr.round) {
582
+ styles.push('border-radius:50%', 'object-fit:cover')
583
+ if (!imgAttr.w) styles.push('width:120px')
584
+ if (!imgAttr.h) styles.push('height:120px')
585
+ }
586
+ if (imgAttr.shadow) styles.push('box-shadow:0 0 15px rgba(0,0,0,0.5)')
587
+ if (imgAttr.float) styles.push('float:left', 'margin:0 8px 8px 0')
588
+ if (styles.length === 0) {
589
+ styles.push('max-width:100%', 'height:auto', 'display:inline-block')
590
+ }
591
+ const cls = imgAttr.round ? '' : ' class="bb-img"'
592
+ return `<img${this.idAttr(node)}${cls} src="${this.escapeHtml(src)}" alt="" style="${styles.join(';')};">`
593
+ }
594
+
595
+ private renderVideo(node: RedNode): string {
596
+ let id = String(node.metadata?.videoId ?? '') || this.extractValue(node) || ''
597
+ if (!id) return '<div class="media-error">[youtube] missing video ID</div>'
598
+ const ytMatch = /(?:youtu\.be\/|v=|\/embed\/|\/shorts\/)([\w-]{11})/.exec(id)
599
+ if (ytMatch) id = ytMatch[1]
600
+ return `<iframe${this.idAttr(node)} src="https://www.youtube.com/embed/${this.escapeHtml(id)}" frameborder="0" allowfullscreen></iframe>`
601
+ }
602
+
603
+ private renderAudio(node: RedNode): string {
604
+ let src = String(node.metadata?.src ?? '') || node.text || ''
605
+ if (this.options.mediaProxy && src) {
606
+ src = this.options.mediaProxy(src)
607
+ }
608
+ return `<audio${this.idAttr(node)} controls src="${this.escapeHtml(src)}" class="bb-audio"></audio>`
609
+ }
610
+
611
+ private hexToRgba(hex: string, alpha: number): string {
612
+ if (!hex.startsWith('#')) return hex
613
+ const t = hex.replace('#', '')
614
+ const full = t.length <= 4 ? t.split('').map(c => c + c).join('') : t
615
+ const r = parseInt(full.slice(0, 2), 16) || 0
616
+ const g = parseInt(full.slice(2, 4), 16) || 0
617
+ const b = parseInt(full.slice(4, 6), 16) || 0
618
+ return `rgba(${r}, ${g}, ${b}, ${alpha})`
619
+ }
620
+
621
+ private renderNotice(node: RedNode, warning: boolean): string {
622
+ const isLyne = this.options.theme === 'lyne' || this.options.dialect === 'lyne' || warning
623
+ if (isLyne) {
624
+ const color = this.sanitizeColor(this.metaOrAttr(node, 'color'))
625
+ const styleAttr = color
626
+ ? ` style="background:${this.hexToRgba(color, 0.08)};border-color:${this.hexToRgba(color, 0.4)};border-left-color:${color};color:${this.hexToRgba(color, 0.85)};"`
627
+ : ''
628
+ const markStyle = color ? ` style="color:${color};"` : ''
629
+ const content = this.renderChildren(node)
630
+ const warningIcon = warning ? `<span aria-hidden class="bb-notice-mark"${markStyle}>⚠</span>` : ''
631
+ return `<div${this.idAttr(node)} class="notice bb-cut-panel bb-notice${warning ? ' bb-wnotice' : ''}" role="note"${styleAttr}>${warningIcon}<div class="bb-notice-body">${content}</div></div>`
632
+ }
633
+
634
+ return this.wrapBlock('div', node, 'class="notice"')
635
+ }
636
+
637
+ private renderTables(node: RedNode): string {
638
+ const variant = (String(node.metadata?.variant ?? '') || this.extractValue(node) || '').trim().toLowerCase()
639
+ const variants = new Set(variant.split(/[\s,]+/).filter(Boolean))
640
+ const tableClasses = [
641
+ 'bb-table',
642
+ variants.has('striped') ? 'bb-table-striped' : '',
643
+ variants.has('borders') ? 'bb-table-borders' : '',
644
+ ].filter(Boolean).join(' ')
645
+ const content = this.renderChildren(node)
646
+ // `[tables=striped:#hex]`: de `--table-accent` se deriva toda la paleta
647
+ // de la tabla (gradiente del frame, hover de filas, encabezado, bordes).
648
+ const accent = this.boxAccentStyle(node, 'table')
649
+ return `<div${this.idAttr(node)} class="bb-table-frame"${accent}><table class="${tableClasses}"><tbody>${content}</tbody></table></div>`
650
+ }
651
+
652
+ private renderGallery(node: RedNode): string {
653
+ const content = this.renderChildren(node)
654
+ return `<div${this.idAttr(node)} class="bb-gallery">${content}</div>`
655
+ }
656
+
657
+ private renderColumns(node: RedNode): string {
658
+ const cols = parseInt(String(node.metadata?.columns ?? '') || this.extractValue(node) || '2', 10)
659
+ const numCols = Math.max(2, Math.min(4, isNaN(cols) ? 2 : cols))
660
+ const content = this.renderChildren(node)
661
+ // `[columns=2:#hex]`: con color se añade la superficie (borde + fondo
662
+ // derivados del acento) para que el color se vea; sin color, grid limpio.
663
+ const accent = this.boxAccentStyle(node, 'columns')
664
+ const surfaceCls = accent ? ' bb-columns-surface' : ''
665
+ const styleBase = `column-count:${numCols};`
666
+ return `<div${this.idAttr(node)} class="bb-columns${surfaceCls}" style="${styleBase}">${content}</div>`
667
+ }
668
+
669
+ private renderSeparator(node: RedNode): string {
670
+ const variant = (String(node.metadata?.variant ?? '') || this.extractValue(node) || 'line').trim().toLowerCase()
671
+ if (variant === 'dots') return `<div${this.idAttr(node)} class="bb-separator">· · ·</div>`
672
+ if (variant === 'stars') return `<div${this.idAttr(node)} class="bb-separator">✦ ✦ ✦</div>`
673
+ return `<hr${this.idAttr(node)} class="bb-separator" />`
674
+ }
675
+
676
+ private renderScroll(node: RedNode): string {
677
+ const h = parseInt(String(node.metadata?.height ?? '') || this.extractValue(node) || '200', 10)
678
+ const maxH = Math.max(50, Math.min(2000, isNaN(h) ? 200 : h))
679
+ const content = this.renderChildren(node)
680
+ return `<div${this.idAttr(node)} class="bb-scroll" style="max-height:${maxH}px;">${content}</div>`
681
+ }
682
+
683
+ private renderAbbr(node: RedNode): string {
684
+ const title = String(node.metadata?.title ?? '') || this.extractValue(node)
685
+ const attr = title ? ` title="${this.escapeHtml(title)}"` : ''
686
+ return this.wrapInline('abbr', node, attr)
687
+ }
688
+
689
+ private renderTooltip(node: RedNode): string {
690
+ const tip = String(node.metadata?.tip ?? '') || this.extractValue(node)
691
+ const attr = tip ? ` title="${this.escapeHtml(tip)}"` : ''
692
+ return this.wrapInline('span', node, `class="bb-tooltip"${attr}`)
693
+ }
694
+
695
+ private renderRaw(node: RedNode): string {
696
+ const text = this.collectNodeText(node) || node.text || ''
697
+ return `<span${this.idAttr(node)} class="bb-raw">${this.escapeHtml(text)}</span>`
698
+ }
699
+
700
+ private renderPlain(node: RedNode): string {
701
+ return `<span${this.idAttr(node)}>${this.escapeHtml(this.collectNodeText(node))}</span>`
702
+ }
703
+
704
+ private renderAlign(node: RedNode): string {
705
+ const alignVal = (String(node.metadata?.align ?? '') || this.extractValue(node) || 'center').trim().toLowerCase()
706
+ const validAlign = alignVal === 'left' || alignVal === 'right' ? alignVal : 'center'
707
+ return this.wrapBlock('div', node, `style="text-align:${validAlign};"`)
708
+ }
709
+
710
+ private renderEffect(node: RedNode): string {
711
+ const raw = (String(node.metadata?.effectType ?? '') || this.extractValue(node) || 'glow').toLowerCase().trim()
712
+ const effectType = raw.includes(':') ? raw.split(':')[0] : (raw.startsWith('#') ? 'glow' : raw)
713
+ const rawColor = String(node.metadata?.color ?? '') || (raw.includes(':') ? raw.split(':')[1] : (raw.startsWith('#') ? raw : this.extractValue(node)))
714
+ const color = this.sanitizeColor(rawColor)
715
+ const content = this.renderChildren(node)
716
+ const idAttr = this.idAttr(node)
717
+
718
+ switch (effectType) {
719
+ case 'glow': {
720
+ const c = color || 'var(--color-accent, #2EE6E2)'
721
+ return `<span${idAttr} class="bb-glow" style="--glow-color:${c};text-shadow:0 0 4px ${c}, 0 0 8px ${c};">${content}</span>`
722
+ }
723
+ case 'neon': {
724
+ // Fallback blanco como el renderer original de Lyne (c || '#fff').
725
+ const c = color || '#fff'
726
+ return `<span${idAttr} class="bb-neon" style="--neon-color:${c};">${content}</span>`
727
+ }
728
+ case 'outline': {
729
+ const c = color || '#000'
730
+ return `<span${idAttr} style="-webkit-text-stroke:1px ${c};paint-order:stroke fill;">${content}</span>`
731
+ }
732
+ case 'emboss': return `<span${idAttr} class="bb-emboss">${content}</span>`
733
+ case 'engrave': return `<span${idAttr} class="bb-engrave">${content}</span>`
734
+ // NOTA: el tipo 'shadow' se eliminó de Lyne — ignoraba el color y
735
+ // chocaba con el kind `shadow` de osu. El `[shadow]` de osu sigue vivo
736
+ // vía su propio kind (shadowStyle, con color).
737
+ case 'shimmer': return `<span${idAttr} class="bb-shimmer">${content}</span>`
738
+ case 'ghost': return `<span${idAttr} class="bb-ghost">${content}</span>`
739
+ case 'rainbow': return `<span${idAttr} class="bb-rainbow">${content}</span>`
740
+ case 'fire': return `<span${idAttr} class="bb-fire">${content}</span>`
741
+ case 'ice': return `<span${idAttr} class="bb-ice">${content}</span>`
742
+ default: return `<span${idAttr}>${content}</span>`
743
+ }
744
+ }
745
+
746
+ private renderAnim(node: RedNode): string {
747
+ const raw = (String(node.metadata?.animType ?? '') || this.extractValue(node) || 'pulse').toLowerCase().trim()
748
+ const animType = raw.includes(':') ? raw.split(':')[0] : raw
749
+ const content = this.renderChildren(node)
750
+ const idAttr = this.idAttr(node)
751
+
752
+ switch (animType) {
753
+ case 'bounce': return `<span${idAttr} class="bb-bounce">${content}</span>`
754
+ case 'shake': return `<span${idAttr} class="bb-shake">${content}</span>`
755
+ case 'pulse': return `<span${idAttr} class="bb-pulse">${content}</span>`
756
+ // NOTA: el tipo 'fade' se eliminó de Lyne (era duplicado de fade-in).
757
+ case 'fade-in': return `<span${idAttr} class="bb-fade-in">${content}</span>`
758
+ case 'fade-out': return `<span${idAttr} class="bb-fade-out">${content}</span>`
759
+ case 'typewriter': {
760
+ const charCount = this.collectNodeText(node).length || 20
761
+ return `<span${idAttr} class="bb-typewriter-wrap" style="--bb-ch:${charCount};"><span class="bb-typewriter">${content}</span></span>`
762
+ }
763
+ case 'wave': return `<span${idAttr} class="bb-wave" style="display:inline-block;">${content}</span>`
764
+ case 'sparkle': return `<span${idAttr} class="bb-sparkle">${content}</span>`
765
+ case 'glitch': {
766
+ const rawText = this.collectNodeText(node)
767
+ return `<span${idAttr} class="bb-glitch" data-text="${this.escapeHtml(rawText)}">${content}</span>`
768
+ }
769
+ case 'levitate': return `<span${idAttr} class="bb-levitate" style="display:inline-block;">${content}</span>`
770
+ default: return `<span${idAttr} class="bb-pulse">${content}</span>`
771
+ }
772
+ }
773
+
774
+ private renderContainer(node: RedNode): string {
775
+ const raw = (String(node.metadata?.containerType ?? '') || this.extractValue(node) || 'stack').trim()
776
+ const [type, ...params] = raw.split(':')
777
+ const containerType = type.toLowerCase()
778
+ const param = params.join(':')
779
+ const content = this.renderChildren(node)
780
+ const idAttr = this.idAttr(node)
781
+
782
+ switch (containerType) {
783
+ case 'stack':
784
+ return `<div${idAttr} class="bb-stack">${content}</div>`
785
+ case 'flex': {
786
+ const gap = parseInt(param || '8', 10)
787
+ const safeG = Math.max(0, Math.min(100, isNaN(gap) ? 8 : gap))
788
+ return `<div${idAttr} class="bb-flex" style="gap:${safeG}px;">${content}</div>`
789
+ }
790
+ case 'grid': {
791
+ const n = parseInt(param || '2', 10)
792
+ const cols = Math.max(1, Math.min(6, isNaN(n) ? 2 : n))
793
+ return `<div${idAttr} class="bb-grid" style="grid-template-columns:repeat(${cols}, 1fr);">${content}</div>`
794
+ }
795
+ case 'middle':
796
+ return `<div${idAttr} class="bb-middle">${content}</div>`
797
+ case 'circle':
798
+ return `<div${idAttr} class="bb-circle">${content}</div>`
799
+ case 'card':
800
+ case 'glass': {
801
+ // `[card=#hex]` / `[container=glass:#hex]`: el color se pasa como
802
+ // `type:param` (igual que neon-box) y se emite como `--<tipo>-accent`
803
+ // del que el CSS deriva borde, tinte de fondo y —vía la clase
804
+ // `bb-accented`— la paleta del contenido sin color propio. Sin color,
805
+ // defaults y sin clase.
806
+ const accent = this.sanitizeColor(param)
807
+ const cls = accent ? ' bb-accented' : ''
808
+ const style = accent ? ` style="--${containerType}-accent:${accent};"` : ''
809
+ return `<div${idAttr} class="bb-cut-panel bb-${containerType}${cls}"${style}>${content}</div>`
810
+ }
811
+ case 'neon-box':
812
+ case 'neonbox': {
813
+ const c = this.sanitizeColor(param || this.extractValue(node)) || 'var(--color-accent, #2EE6E2)'
814
+ return `<div${idAttr} class="bb-cut-panel bb-neon-box" style="--neon-color:${c};border-color:${c};">${content}</div>`
815
+ }
816
+ default:
817
+ return `<div${idAttr} class="bb-stack">${content}</div>`
818
+ }
819
+ }
820
+
821
+ private static readonly STYLE_PROP_WHITELIST = new Set([
822
+ 'width', 'height', 'padding', 'margin', 'display',
823
+ 'background', 'border', 'opacity', 'filter', 'transform',
824
+ 'float', 'clear', 'white-space', 'font-variant', 'text-transform',
825
+ 'text-align', 'gap', 'color', 'font-size', 'font-weight',
826
+ 'padding-left', 'padding-right', 'padding-top', 'padding-bottom',
827
+ 'margin-left', 'margin-right', 'margin-top', 'margin-bottom',
828
+ 'line-height', 'letter-spacing', 'word-spacing',
829
+ ])
830
+
831
+ private renderStyleTag(node: RedNode): string {
832
+ const raw = (String(node.metadata?.style ?? '') || this.extractValue(node) || '').trim()
833
+ const content = this.renderChildren(node)
834
+ const idAttr = this.idAttr(node)
835
+ if (!raw) return `<span${idAttr}>${content}</span>`
836
+
837
+ const safeStyles: string[] = []
838
+ const decls = raw.split(';')
839
+ for (const decl of decls) {
840
+ const colon = decl.indexOf(':')
841
+ if (colon < 0) continue
842
+ const prop = decl.slice(0, colon).trim().toLowerCase()
843
+ const val = decl.slice(colon + 1).trim()
844
+ if (!prop || !val) continue
845
+ if (!HTMLRenderer.STYLE_PROP_WHITELIST.has(prop)) continue
846
+ // Los paréntesis son válidos en CSS (rgba(), blur(), rotate(), scale(),
847
+ // color-mix()…) — antes se eliminaban y rompían esos valores. Lo que sí
848
+ // se bloquea son las funciones/fuentes peligrosas (url(), expression(),
849
+ // javascript:), que no tienen uso legítimo en el whitelist de props.
850
+ const safeVal = val.replace(/["'{}<>]/g, '').slice(0, 100)
851
+ if (/url\s*\(|expression\s*\(|javascript\s*:/i.test(safeVal)) continue
852
+ safeStyles.push(`${prop}:${safeVal}`)
853
+ }
854
+
855
+ if (safeStyles.length === 0) return `<span${idAttr}>${content}</span>`
856
+ return `<span${idAttr} style="${safeStyles.join(';')}">${content}</span>`
857
+ }
858
+
859
+ private renderQuote(node: RedNode): string {
860
+ const source = node.metadata?.source || this.extractValue(node)
861
+ const content = this.renderChildren(node)
862
+ if (source) {
863
+ return `<blockquote${this.idAttr(node)}><div style="margin-bottom:8px"><strong>${this.escapeHtml(String(source))} wrote:</strong></div>${content}</blockquote>`
864
+ }
865
+ return `<blockquote${this.idAttr(node)}>${content}</blockquote>`
866
+ }
867
+
868
+ private renderSpoilerbox(node: RedNode): string {
869
+ const title = this.renderTitle(node, 'Spoiler')
870
+ const content = this.renderChildren(node)
871
+ const isLyne = this.options.theme === 'lyne' || this.options.dialect === 'lyne'
872
+ const bodyCls = isLyne ? 'bb-box-body' : 'bbcode-box-body'
873
+ // El wrapper agrupa el título en un solo flex item (ver renderBox) y usa
874
+ // `bb-box-heading` (no `bb-box-title`) para no colisionar con la regla
875
+ // legacy `.bbcode-preview .bb-box-title` de la app, que pinta un fondo.
876
+ // `--box-accent` colorea el acento del box (título + chevron, y el borde
877
+ // en boxw) cuando el autor puso `[box=Title:#hex]`.
878
+ const accent = this.boxAccentStyle(node)
879
+ return `<details${this.idAttr(node)}${accent}><summary><span class="bb-box-heading">${title}</span></summary><div class="${bodyCls}">${content}</div></details>`
880
+ }
881
+
882
+ private renderBox(node: RedNode): string {
883
+ const title = this.renderTitle(node, 'Box')
884
+ const content = this.renderChildren(node)
885
+ const isLyne = this.options.theme === 'lyne' || this.options.dialect === 'lyne'
886
+ const bodyCls = isLyne ? 'bb-box-body' : 'bbcode-box-body'
887
+ // boxw = box con líneas y fondo (estilo Lyne). [box] normal queda limpio
888
+ // por defecto; la clase `boxw` es la que dispara ese look en lyne.css.
889
+ const cls = node.kind === 'boxw' ? 'box boxw' : 'box'
890
+ // El wrapper agrupa el título en un solo flex item: sin él, el summary
891
+ // flex de Lyne separa cada span de [color] con su gap. `bb-box-heading`
892
+ // (no `bb-box-title`) evita la regla legacy `.bbcode-preview .bb-box-title`
893
+ // de la app, que pinta un fondo sobre el título.
894
+ const accent = this.boxAccentStyle(node)
895
+ return `<details${this.idAttr(node)} class="${cls}"${accent}><summary><span class="bb-box-heading">${title}</span></summary><div class="${bodyCls}">${content}</div></details>`
896
+ }
897
+
898
+ private renderTitle(node: RedNode, fallback: string): string {
899
+ const titleNodes = node.metadata?.titleNodes as RedNode[] | undefined
900
+ if (titleNodes && titleNodes.length > 0) {
901
+ return titleNodes.map(c => this.renderNode(c)).join('')
902
+ }
903
+ const title = node.metadata?.title ?? this.extractValue(node) ?? fallback
904
+ return this.escapeHtml(String(title))
905
+ }
906
+
907
+ /**
908
+ * `[box=Title:#hex]`, `[tables=striped:#hex]`, `[columns=2:#hex]` → un
909
+ * ` style="--<suffix>-accent:#hex;"` del que el CSS deriva la paleta
910
+ * (título + chevron y borde en boxw; gradientes/header/hover en tables;
911
+ * borde + fondo en columns). Devuelve '' si no hay color válido.
912
+ */
913
+ private boxAccentStyle(node: RedNode, suffix: 'box' | 'table' | 'columns' = 'box'): string {
914
+ const color = this.sanitizeColor(String(node.metadata?.color ?? ''))
915
+ return color ? ` style="--${suffix}-accent:${color};"` : ''
916
+ }
917
+
918
+ private renderList(node: RedNode): string {
919
+ // Check for ordered list: [list=1], [list=a], or metadata
920
+ const attrs = this.extractValue(node)
921
+ const isOrdered = node.metadata?.ordered === true || attrs === '1' || attrs === 'a'
922
+ const tag = isOrdered ? 'ol' : 'ul'
923
+ const content = node.children.map(c => this.renderNode(c)).join('\n')
924
+ return `<${tag}${this.idAttr(node)}>${content}</${tag}>`
925
+ }
926
+
927
+ private renderListItem(node: RedNode): string {
928
+ const content = this.renderChildren(node)
929
+ return `<li${this.idAttr(node)}>${content}</li>`
930
+ }
931
+
932
+ private renderCode(node: RedNode): string {
933
+ let content = node.children.map(c => c.text || '').join('')
934
+ // osu! quirk: strip leading and trailing empty lines inside [code]
935
+ const leadingMatch = content.match(/^(?:[\t ]*[\r\n])+/)
936
+ if (leadingMatch) content = content.slice(leadingMatch[0].length)
937
+
938
+ const trailingMatch = content.match(/(?:[\r\n][\t ]*)+$/)
939
+ if (trailingMatch) content = content.slice(0, -trailingMatch[0].length)
940
+
941
+ return `<pre${this.idAttr(node)}><code>${this.escapeHtml(content)}</code></pre>`
942
+ }
943
+
944
+ private renderSVG(node: RedNode): string {
945
+ const innerHtml = node.children ? node.children.map(c => this.visit(c)).join('') : ''
946
+
947
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%"${this.idAttr(node)}>
948
+ <foreignObject width="100%" height="100%">
949
+ <div xmlns="http://www.w3.org/1999/xhtml" class="bbcode-preview miliastry-svg-container" style="width: 100%; height: 100%; overflow: auto;">
950
+ ${innerHtml}
951
+ </div>
952
+ </foreignObject>
953
+ </svg>`
954
+ }
955
+
956
+ /**
957
+ * Render an osu! BBCode imagemap.
958
+ *
959
+ * Structure:
960
+ * [imagemap]
961
+ * https://example.com/image.png ← first line = image URL
962
+ * 10 20 50 60 https://... Label ← subsequent lines = clickable areas
963
+ * [/imagemap]
964
+ *
965
+ * Each area line format: x y width height url [label]
966
+ * All values are PERCENTAGES (0–100) relative to the image dimensions.
967
+ * Uses CSS absolute positioning with percentage coordinates.
968
+ */
969
+ private renderImagemap(node: RedNode): string {
970
+ const rawText = this.collectNodeText(node)
971
+ const textLines = rawText
972
+ .split(/\r?\n/)
973
+ .map(s => s.trim())
974
+ .filter(Boolean)
975
+
976
+ if (textLines.length === 0) {
977
+ return '<div class="media-error">[imagemap] missing image URL</div>'
978
+ }
979
+
980
+ const imageUrl = textLines[0]
981
+ if (!imageUrl.startsWith('http://') && !imageUrl.startsWith('https://')) {
982
+ return `<div class="media-error">[imagemap] invalid image URL: ${this.escapeHtml(imageUrl)}</div>`
983
+ }
984
+
985
+ let areas = ''
986
+ for (let i = 1; i < textLines.length; i++) {
987
+ const line = textLines[i]
988
+ const parts = line.split(/\s+/)
989
+ if (parts.length < 5) continue
990
+
991
+ const x = parseFloat(parts[0])
992
+ const y = parseFloat(parts[1])
993
+ const w = parseFloat(parts[2])
994
+ const h = parseFloat(parts[3])
995
+ const url = parts[4]
996
+ const label = parts.slice(5).join(' ')
997
+
998
+ if (isNaN(x) || isNaN(y) || isNaN(w) || isNaN(h)) continue
999
+
1000
+ let areaUrl = url
1001
+ if (areaUrl && !areaUrl.startsWith('http://') && !areaUrl.startsWith('https://') && !areaUrl.startsWith('mailto:')) {
1002
+ areaUrl = 'https://' + areaUrl
1003
+ }
1004
+
1005
+ areas += `<a${this.idAttr(node)} href="${this.escapeHtml(areaUrl)}" target="_blank" rel="noopener" class="imagemap-area bbcode-imap-area" style="position:absolute;left:${x}%;top:${y}%;width:${w}%;height:${h}%;" title="${this.escapeHtml(label || 'Link')}"></a>`
1006
+ }
1007
+
1008
+ return `<div${this.idAttr(node)} class="imagemap-container bbcode-imagemap" style="position:relative;display:inline-block;"><img src="${this.escapeHtml(imageUrl)}" alt="imagemap" style="max-width:100%;height:auto;display:block;">${areas}</div>`
1009
+ }
1010
+
1011
+ private collectNodeText(node: RedNode): string {
1012
+ if (node.kind === 'spacing' || node.kind === 'empty_line') return '\n'
1013
+ if (node.children && node.children.length > 0) {
1014
+ return node.children.map(c => this.collectNodeText(c)).join('')
1015
+ }
1016
+ return node.text || ''
1017
+ }
1018
+
1019
+ private renderGradient(node: RedNode): string {
1020
+ // `startsWith('#')` was NOT a filter: `#a" onmouseover="alert(1)` passes it.
1021
+ // Every stop must survive the full color allowlist or it is dropped.
1022
+ const raw = (node.metadata?.colors as string[]) || []
1023
+ const valid = raw
1024
+ .map(c => (typeof c === 'string' ? this.sanitizeColor(c) : null))
1025
+ .filter((c): c is string => c !== null)
1026
+ const colors =
1027
+ valid.length === 0 ? ['#FF0000', '#00FF00']
1028
+ : valid.length === 1 ? [valid[0], valid[0]]
1029
+ : valid
1030
+ // Skip spacing/empty_line children so source formatting (newlines inside the tag)
1031
+ // doesn't produce extra <br> in the preview. Both:
1032
+ // [gradient]Hello[/gradient] and [gradient]\nHello\n[/gradient]
1033
+ // render identically.
1034
+ const content = node.children
1035
+ .filter(c => c.kind !== 'spacing' && c.kind !== 'empty_line')
1036
+ .map(c => this.renderNode(c)).join('')
1037
+ const gradientCss = `linear-gradient(to right, ${colors.join(', ')})`
1038
+ return `<span${this.idAttr(node)} style="background: ${gradientCss}; -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text;">${content}</span>`
1039
+ }
1040
+
1041
+ /**
1042
+ * Escape for both text content and double-quoted attribute values.
1043
+ *
1044
+ * `'` is included because attribute values elsewhere in the codebase (and in
1045
+ * consumer-supplied tag handlers) may be single-quoted; leaving it raw makes
1046
+ * the escaping context-dependent, which is how injections get reintroduced.
1047
+ */
1048
+ /**
1049
+ * Escape the five HTML-significant characters.
1050
+ *
1051
+ * Was five chained `.replace(/x/g, …)` calls: five full scans of the string
1052
+ * and up to five intermediate allocations for *every* text node, even though
1053
+ * ordinary prose contains none of these characters. This tests once and
1054
+ * returns the input untouched in that common case, then does a single pass
1055
+ * when there is actually something to escape.
1056
+ */
1057
+ private escapeHtml(text: string): string {
1058
+ if (!HTMLRenderer.HTML_ESCAPE_RE.test(text)) return text
1059
+
1060
+ let out = ''
1061
+ let last = 0
1062
+ for (let i = 0; i < text.length; i++) {
1063
+ let replacement: string
1064
+ switch (text.charCodeAt(i)) {
1065
+ case 38: replacement = '&amp;'; break // &
1066
+ case 60: replacement = '&lt;'; break // <
1067
+ case 62: replacement = '&gt;'; break // >
1068
+ case 34: replacement = '&quot;'; break // "
1069
+ case 39: replacement = '&#39;'; break // '
1070
+ default: continue
1071
+ }
1072
+ out += text.slice(last, i) + replacement
1073
+ last = i + 1
1074
+ }
1075
+ return out + text.slice(last)
1076
+ }
1077
+ }