@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.
- package/LICENSE +119 -0
- package/README.md +45 -0
- package/dist/Visuals/lyne.css +1027 -0
- package/dist/Visuals/osu.css +257 -0
- package/dist/index.d.mts +4563 -0
- package/dist/index.d.ts +4563 -0
- package/dist/index.js +11291 -0
- package/dist/index.mjs +11197 -0
- package/package.json +51 -0
- package/src/Analysis/Contracts/AnalysisReport.ts +26 -0
- package/src/Analysis/Contracts/Contribution.ts +76 -0
- package/src/Analysis/Contracts/Pass.ts +76 -0
- package/src/Analysis/Contracts/PipelineContext.ts +48 -0
- package/src/Analysis/Passes/Analysis/GradientAnalyzer.ts +292 -0
- package/src/Analysis/Passes/Analysis/MergeableColorAnalyzer.ts +112 -0
- package/src/Analysis/Passes/Analysis/RainbowAnalyzer.ts +233 -0
- package/src/Analysis/Passes/Analysis/WaveAnalyzer.ts +211 -0
- package/src/Analysis/Passes/Analysis/__tests__/GradientAnalyzer.test.ts +135 -0
- package/src/Analysis/Passes/Analysis/__tests__/MergeableColorAnalyzer.test.ts +84 -0
- package/src/Analysis/Passes/Analysis/__tests__/RainbowAnalyzer.test.ts +99 -0
- package/src/Analysis/Passes/Analysis/__tests__/WaveAnalyzer.test.ts +119 -0
- package/src/Analysis/Passes/Decision/DefaultDecision.ts +139 -0
- package/src/Analysis/Passes/Decision/__tests__/DefaultDecision.test.ts +179 -0
- package/src/Analysis/Passes/Transform/CollapseGradientTransform.ts +176 -0
- package/src/Analysis/Passes/Transform/MergeColorsTransform.ts +126 -0
- package/src/Analysis/Passes/Transform/RainbowCollapseTransform.ts +83 -0
- package/src/Analysis/Passes/Transform/WaveCollapseTransform.ts +88 -0
- package/src/Analysis/Passes/Utility/CharacterCountAnalyzer.ts +45 -0
- package/src/Analysis/Pipeline/Pipeline.ts +133 -0
- package/src/Analysis/Pipeline/PipelineBuilder.ts +55 -0
- package/src/Analysis/Pipeline/PipelineStage.ts +19 -0
- package/src/Analysis/Utils/color-utils.ts +132 -0
- package/src/Analysis/__tests__/Integration.test.ts +162 -0
- package/src/Analysis/__tests__/Pipeline.test.ts +133 -0
- package/src/Analysis/index.ts +52 -0
- package/src/BBCode/BBCodeDocumentModel.ts +175 -0
- package/src/BBCode/BBCodeToGreenNode.ts +755 -0
- package/src/BBCode/Parser.ts +384 -0
- package/src/BBCode/index.ts +12 -0
- package/src/Collab/positions.ts +91 -0
- package/src/Commands/Command.ts +44 -0
- package/src/Commands/CommandRegistry.ts +78 -0
- package/src/Commands/DeleteNode.ts +20 -0
- package/src/Commands/InsertText.ts +21 -0
- package/src/Commands/SplitMerge.ts +28 -0
- package/src/Commands/WrapInTag.ts +21 -0
- package/src/Commands/index.ts +6 -0
- package/src/Diff/TreeDiffer.ts +264 -0
- package/src/Diff/__tests__/TreeDiffer.test.ts +65 -0
- package/src/Diff/index.ts +2 -0
- package/src/Events/EventBus.ts +160 -0
- package/src/Events/index.ts +2 -0
- package/src/Formatter/Formatter.ts +54 -0
- package/src/Formatter/index.ts +2 -0
- package/src/HTML/HTMLDocumentModel.ts +35 -0
- package/src/HTML/HTMLToGreenNode.ts +290 -0
- package/src/Incremental/ChangeTracker.ts +105 -0
- package/src/Incremental/IncrementalParser.ts +591 -0
- package/src/Incremental/__tests__/IncrementalParser.test.ts +164 -0
- package/src/Incremental/index.ts +4 -0
- package/src/Lexer/BBCodeLexer.ts +382 -0
- package/src/Lexer/Lexer.ts +181 -0
- package/src/Lexer/index.ts +10 -0
- package/src/Linter/Linter.ts +193 -0
- package/src/Linter/index.ts +2 -0
- package/src/Markdown/MarkdownAST.ts +112 -0
- package/src/Markdown/MarkdownDocumentModel.ts +55 -0
- package/src/Markdown/MarkdownLexer.ts +203 -0
- package/src/Markdown/MarkdownParser.ts +455 -0
- package/src/Markdown/MarkdownToGreenNode.ts +153 -0
- package/src/Model/DocumentModel.ts +694 -0
- package/src/Model/NodeFactory.ts +117 -0
- package/src/Model/TagRegistry.ts +495 -0
- package/src/Model/index.ts +5 -0
- package/src/Plugins/PluginAPI.ts +119 -0
- package/src/Plugins/PluginRegistry.ts +132 -0
- package/src/Plugins/index.ts +3 -0
- package/src/Queries/QueryEngine.ts +152 -0
- package/src/Queries/index.ts +1 -0
- package/src/RenderPipeline/RenderPipeline.ts +125 -0
- package/src/RenderPipeline/RenderTree.ts +134 -0
- package/src/RenderPipeline/index.ts +4 -0
- package/src/Semantic/SemanticAnalyzer.ts +506 -0
- package/src/Semantic/index.ts +2 -0
- package/src/Symbols/SymbolTable.ts +124 -0
- package/src/Symbols/index.ts +1 -0
- package/src/Syntax/GreenNode.ts +324 -0
- package/src/Syntax/GreenNodePool.ts +269 -0
- package/src/Syntax/NodeMatcher.ts +370 -0
- package/src/Syntax/RedNode.ts +569 -0
- package/src/Syntax/RedNodeStore.ts +184 -0
- package/src/Syntax/TreeBuilder.ts +214 -0
- package/src/Syntax/__tests__/GreenNode.test.ts +33 -0
- package/src/Syntax/__tests__/RedNode.test.ts +81 -0
- package/src/Syntax/__tests__/RedNodeStore.test.ts +104 -0
- package/src/Syntax/greenEdit.ts +110 -0
- package/src/Syntax/hash.ts +30 -0
- package/src/Syntax/index.ts +12 -0
- package/src/Syntax/partition.ts +161 -0
- package/src/Syntax/preserveNodeIds.ts +201 -0
- package/src/Tests/ASTOptimizerIdempotence.test.ts +77 -0
- package/src/Tests/BlockPatcher.test.ts +437 -0
- package/src/Tests/BlockPatcherWindowed.test.ts +364 -0
- package/src/Tests/BoxDrawer.test.ts +217 -0
- package/src/Tests/BoxRichTitle.test.ts +105 -0
- package/src/Tests/Chars500kBenchmark.test.ts +151 -0
- package/src/Tests/Chars500kEdits.test.ts +321 -0
- package/src/Tests/CollabPositions.test.ts +146 -0
- package/src/Tests/CompilerPathProfiling.test.ts +186 -0
- package/src/Tests/DOMMorpher.test.ts +142 -0
- package/src/Tests/DomPatchPerf.test.ts +60 -0
- package/src/Tests/EffectSegments.snapshot.json +616 -0
- package/src/Tests/EffectSegments.test.ts +68 -0
- package/src/Tests/FindNodeAtOffset.test.ts +65 -0
- package/src/Tests/Fuzzer.test.ts +166 -0
- package/src/Tests/GreenNodePool.test.ts +153 -0
- package/src/Tests/Lexer.test.ts +238 -0
- package/src/Tests/LyneMode.test.ts +187 -0
- package/src/Tests/ModelCoherence.test.ts +180 -0
- package/src/Tests/Partition.test.ts +238 -0
- package/src/Tests/PluginTags.test.ts +150 -0
- package/src/Tests/ProblematicSection.test.ts +46 -0
- package/src/Tests/ProblematicSectionHTML.test.ts +58 -0
- package/src/Tests/RedReuse.test.ts +134 -0
- package/src/Tests/ReproDelete20k.test.ts +62 -0
- package/src/Tests/SemanticValidators.test.ts +136 -0
- package/src/Tests/StableNodeIds.test.ts +210 -0
- package/src/Tests/StudioColorBloat.test.ts +25 -0
- package/src/Tests/StudioDebugText.test.ts +27 -0
- package/src/Tests/StudioTrailingChar.test.ts +25 -0
- package/src/Tests/StudioValidText.test.ts +25 -0
- package/src/Tests/UrlImgBug.test.ts +23 -0
- package/src/Tests/VisualBuilderFidelity.test.ts +105 -0
- package/src/Tests/referenceDocument.ts +119 -0
- package/src/Transactions/Transaction.ts +176 -0
- package/src/Transactions/UndoManager.ts +111 -0
- package/src/Transactions/index.ts +3 -0
- package/src/Transformers/ASTOptimizer.ts +315 -0
- package/src/Transformers/GradientTransformer.ts +143 -0
- package/src/Transformers/GrowTransformer.ts +115 -0
- package/src/Transformers/RainbowTransformer.ts +121 -0
- package/src/Transformers/SineWaveTransformer.ts +130 -0
- package/src/Transformers/Transformer.ts +22 -0
- package/src/Types/core.ts +270 -0
- package/src/Types/diagnostics.ts +156 -0
- package/src/Types/index.ts +23 -0
- package/src/Types/operations.ts +180 -0
- package/src/Types/queries.ts +121 -0
- package/src/Types/symbols.ts +69 -0
- package/src/Types/tokens.ts +186 -0
- package/src/Utils/BBCodeGenerator.ts +126 -0
- package/src/Utils/ColorMath.ts +276 -0
- package/src/Utils/color.ts +112 -0
- package/src/Utils/dom-to-svg.test.ts +86 -0
- package/src/Utils/dom-to-svg.ts +615 -0
- package/src/Utils/treeTransformers.ts +717 -0
- package/src/Visitors/BBBlocksExporter.ts +69 -0
- package/src/Visitors/BBCodeExporter.ts +318 -0
- package/src/Visitors/BlockPatcher.ts +963 -0
- package/src/Visitors/DOMMorpher.ts +134 -0
- package/src/Visitors/HTMLRenderer.ts +1077 -0
- package/src/Visitors/JSONExporter.ts +66 -0
- package/src/Visitors/MarkdownExporter.ts +99 -0
- package/src/Visitors/SVGRenderer.ts +35 -0
- package/src/Visitors/TiptapExporter.ts +145 -0
- package/src/Visitors/Visitor.ts +48 -0
- package/src/Visitors/index.ts +9 -0
- package/src/Visuals/BoxDrawer.ts +175 -0
- package/src/Visuals/index.ts +42 -0
- package/src/Visuals/lyne.css +1027 -0
- package/src/Visuals/osu.css +257 -0
- 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 = '&'; break // &
|
|
1066
|
+
case 60: replacement = '<'; break // <
|
|
1067
|
+
case 62: replacement = '>'; break // >
|
|
1068
|
+
case 34: replacement = '"'; break // "
|
|
1069
|
+
case 39: replacement = '''; 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
|
+
}
|