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