@miliastry/quasar 1.0.6 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/Visuals/lyne.css +117 -6
- package/dist/Visuals/osu.css +36 -5
- package/dist/index.js +9664 -3282
- package/dist/index.mjs +9535 -3283
- 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 +18 -12
- 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 +129 -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 +374 -151
- 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 +117 -6
- package/src/Visuals/osu.css +36 -5
- package/src/index.ts +172 -5
- package/dist/index.d.mts +0 -4612
- package/dist/index.d.ts +0 -4612
- 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)
|
|
@@ -363,14 +434,20 @@ export class HTMLRenderer extends Visitor<string> {
|
|
|
363
434
|
case 'bubble': return this.wrapInline('span', node, 'class="bubble"')
|
|
364
435
|
case 'flower': return this.wrapInline('span', node, 'class="flower"')
|
|
365
436
|
case 'gradient': return this.renderGradient(node)
|
|
437
|
+
// These three had no case at all, so `[rainbow]Hi[/rainbow]` previewed
|
|
438
|
+
// as bare text while its export was a full colour sweep.
|
|
439
|
+
case 'rainbow': return this.renderEffectSegments(node, 'rainbow')
|
|
440
|
+
case 'grow': return this.renderEffectSegments(node, 'grow')
|
|
441
|
+
case 'sinewave': return this.renderEffectSegments(node, 'sinewave')
|
|
442
|
+
case 'paint': return this.renderEffectSegments(node, 'paint')
|
|
366
443
|
case 'spacing':
|
|
367
444
|
if (this.options.osuBehaviour && this.isNextCodeBlock(node)) return '\n'
|
|
368
445
|
if (this.isTrailingBlockBoundary(node)) return '\n'
|
|
369
|
-
return this.isPrevBlockBoundary(node) ? '\n' :
|
|
446
|
+
return this.isPrevBlockBoundary(node) ? '\n' : `<br${this.idAttr(node)}>`
|
|
370
447
|
case 'empty_line':
|
|
371
448
|
if (this.options.osuBehaviour && this.isImmediateEmptyLineBeforeCode(node)) return '\n'
|
|
372
449
|
if (this.isTrailingBlockBoundary(node)) return '\n'
|
|
373
|
-
return
|
|
450
|
+
return `<div class="bb-empty-line"${this.idAttr(node)}><br></div>`
|
|
374
451
|
case 'group': return this.wrapInline('span', node, 'class="group"')
|
|
375
452
|
// Un párrafo no tiene etiqueta propia en BBCode, pero sí necesita un
|
|
376
453
|
// elemento: sin él, la prosa suelta entre bloques no es clicable —
|
|
@@ -378,6 +455,9 @@ export class HTMLRenderer extends Visitor<string> {
|
|
|
378
455
|
// Un `span` es inline, así que no altera el flujo del texto.
|
|
379
456
|
case 'paragraph': return this.wrapInline('span', node, 'class="bb-paragraph"')
|
|
380
457
|
case 'error': return this.renderError(node)
|
|
458
|
+
// osu! no muestra un cierre que no cerró nada; el nodo existe solo para
|
|
459
|
+
// que su rango siga perteneciendo a alguien.
|
|
460
|
+
case 'discarded_tag': return ''
|
|
381
461
|
case 'text':
|
|
382
462
|
return this.textWrap(node, this.escapeHtml(node.text || ''))
|
|
383
463
|
default:
|
|
@@ -489,21 +569,9 @@ export class HTMLRenderer extends Visitor<string> {
|
|
|
489
569
|
// half-escaped value still emits broken CSS, so we validate the shape and
|
|
490
570
|
// drop anything that isn't a value we intended to support.
|
|
491
571
|
|
|
492
|
-
/** #rgb / #rgba / #rrggbb / #rrggbbaa, a bare CSS color keyword, or rgb()/hsl(). */
|
|
493
|
-
private static readonly CSS_COLOR_RE =
|
|
494
|
-
/^(?:#(?:[0-9a-f]{3}|[0-9a-f]{4}|[0-9a-f]{6}|[0-9a-f]{8})|[a-z]{3,20}|(?:rgb|hsl)a?\([0-9a-z.,%\s/+-]{1,64}\))$/i
|
|
495
|
-
|
|
496
|
-
/** Bare number — interpolated as a percentage. */
|
|
497
|
-
private static readonly CSS_SIZE_RE = /^\d{1,4}(?:\.\d{1,2})?$/
|
|
498
|
-
|
|
499
|
-
/** Font family list. Quotes are rejected outright; unquoted names are valid CSS. */
|
|
500
|
-
private static readonly CSS_FONT_RE = /^[A-Za-z0-9 ,_-]{1,120}$/
|
|
501
|
-
|
|
502
572
|
/** Characters `escapeHtml` has to rewrite. Non-global on purpose: `test` must not carry `lastIndex`. */
|
|
503
573
|
private static readonly HTML_ESCAPE_RE = /[&<>"']/
|
|
504
574
|
|
|
505
|
-
private static readonly BARE_HEX_RE = /^[0-9a-f]{3,8}$/i
|
|
506
|
-
|
|
507
575
|
private static readonly NAMED_COLORS: Record<string, string> = {
|
|
508
576
|
black: '#000000',
|
|
509
577
|
white: '#FFFFFF',
|
|
@@ -525,88 +593,45 @@ export class HTMLRenderer extends Visitor<string> {
|
|
|
525
593
|
gold: '#FFD700',
|
|
526
594
|
}
|
|
527
595
|
|
|
528
|
-
private sanitizeColor(raw: string): string | null {
|
|
529
|
-
let v = raw.trim()
|
|
530
|
-
if (!v) return null
|
|
531
|
-
if (HTMLRenderer.BARE_HEX_RE.test(v) && (v.length === 3 || v.length === 4 || v.length === 6 || v.length === 8)) {
|
|
532
|
-
v = '#' + v
|
|
533
|
-
}
|
|
534
|
-
return HTMLRenderer.CSS_COLOR_RE.test(v) ? v : null
|
|
535
|
-
}
|
|
536
|
-
|
|
537
|
-
private sanitizeFontSize(raw: string): string | null {
|
|
538
|
-
const v = raw.trim()
|
|
539
|
-
return HTMLRenderer.CSS_SIZE_RE.test(v) ? v : null
|
|
540
|
-
}
|
|
541
|
-
|
|
542
|
-
private sanitizeFontFamily(raw: string): string | null {
|
|
543
|
-
const v = raw.trim()
|
|
544
|
-
return HTMLRenderer.CSS_FONT_RE.test(v) ? v : null
|
|
545
|
-
}
|
|
546
|
-
|
|
547
596
|
/** Keyword-or-number CSS values (font-weight, font-style, text-decoration). */
|
|
548
597
|
private isCssKeyword(raw: string | undefined): boolean {
|
|
549
598
|
return !!raw && /^[a-z]{2,20}(?: [a-z]{2,20})?$|^[1-9]00$/i.test(raw.trim())
|
|
550
599
|
}
|
|
551
600
|
|
|
552
601
|
/** Read a metadata field, falling back to the raw tag attribute. */
|
|
553
|
-
private metaOrAttr(node: RedNode, key: string): string {
|
|
554
|
-
const meta = node.metadata?.[key]
|
|
555
|
-
return (typeof meta === 'string' && meta) || this.extractValue(node)
|
|
556
|
-
}
|
|
557
602
|
|
|
558
603
|
private colorStyle(node: RedNode): string {
|
|
559
|
-
const color =
|
|
604
|
+
const color = sanitizeColor(nodeAttrValue(node, 'color'), this.tokenResolver)
|
|
560
605
|
return color ? `style="color:${color};"` : ''
|
|
561
606
|
}
|
|
562
607
|
|
|
563
608
|
private fontSizeStyle(node: RedNode): string {
|
|
564
|
-
const size =
|
|
609
|
+
const size = sanitizeFontSize(nodeAttrValue(node, 'size'), this.tokenResolver)
|
|
565
610
|
return size ? `style="font-size:${size}%;"` : ''
|
|
566
611
|
}
|
|
567
612
|
|
|
568
613
|
private fontStyle(node: RedNode): string {
|
|
569
|
-
const font =
|
|
614
|
+
const font = sanitizeFontFamily(nodeAttrValue(node, 'font'), this.tokenResolver)
|
|
570
615
|
return font ? `style="font-family:${font};"` : ''
|
|
571
616
|
}
|
|
572
617
|
|
|
573
|
-
/**
|
|
574
|
-
* Extract the attribute value from a BBCode tag node.
|
|
575
|
-
*
|
|
576
|
-
* BBCode attributes come in the format `=VALUE` (e.g. `=#61afef`, `="Author"`,
|
|
577
|
-
* `=https://osu.ppy.sh`). This strips the leading `=` and any surrounding quotes.
|
|
578
|
-
*
|
|
579
|
-
* For tags without attrs (like text nodes, img content), returns the node text as-is.
|
|
580
|
-
*/
|
|
581
|
-
private extractValue(node: RedNode): string {
|
|
582
|
-
const text = node.text || ''
|
|
583
|
-
if (!text) return ''
|
|
584
|
-
|
|
585
|
-
const eqIdx = text.indexOf('=')
|
|
586
|
-
if (eqIdx >= 0) {
|
|
587
|
-
// Strip `=` prefix and surrounding quotes
|
|
588
|
-
let value = text.slice(eqIdx + 1)
|
|
589
|
-
// Remove surrounding quotes: "value" or 'value'
|
|
590
|
-
if ((value.startsWith('"') && value.endsWith('"')) ||
|
|
591
|
-
(value.startsWith("'") && value.endsWith("'"))) {
|
|
592
|
-
value = value.slice(1, -1)
|
|
593
|
-
}
|
|
594
|
-
return value
|
|
595
|
-
}
|
|
596
|
-
|
|
597
|
-
// No `=` prefix — return as-is (used for media content like img URLs)
|
|
598
|
-
return text
|
|
599
|
-
}
|
|
600
|
-
|
|
601
618
|
private renderLink(node: RedNode, kind: string): string {
|
|
602
|
-
let href = String(node.metadata?.href ?? '') ||
|
|
619
|
+
let href = String(node.metadata?.href ?? '') || nodeAttrValue(node)
|
|
620
|
+
// Un `[email]a@b.com[/email]` llega con href exactamente `mailto:` y la
|
|
621
|
+
// dirección en el hijo de texto. Sin completarlo aquí el href quedaba
|
|
622
|
+
// vacío y el round-trip devolvía `[url=mailto:]a@b.com[/url]`.
|
|
623
|
+
if (kind === 'email' && href === 'mailto:') {
|
|
624
|
+
href += node.children.map(c => c.text || '').join('')
|
|
625
|
+
}
|
|
603
626
|
// Ensure the URL has a protocol for external links
|
|
604
627
|
if (href && !href.startsWith('http://') && !href.startsWith('https://') && !href.startsWith('mailto:')) {
|
|
605
628
|
href = 'https://' + href
|
|
606
629
|
}
|
|
607
630
|
const content = this.renderChildren(node) || href
|
|
608
631
|
const h = href ? ` href="${this.escapeHtml(href)}"` : ''
|
|
609
|
-
|
|
632
|
+
const hasMediaChild = node.children.some(c => c.kind === 'image' || c.kind === 'svg' || c.kind === 'video')
|
|
633
|
+
const imgCls = hasMediaChild ? ' class="bb-link-img"' : ''
|
|
634
|
+
return `<a${this.idAttr(node)}${imgCls}${h} target="_blank" rel="noopener">${content}</a>`
|
|
610
635
|
}
|
|
611
636
|
|
|
612
637
|
private renderProfile(node: RedNode): string {
|
|
@@ -615,48 +640,55 @@ export class HTMLRenderer extends Visitor<string> {
|
|
|
615
640
|
|
|
616
641
|
private renderEntity(node: RedNode, type: 'profile' | 'guild' | 'map'): string {
|
|
617
642
|
const key = type === 'profile' ? 'username' : type === 'guild' ? 'tag' : 'id'
|
|
618
|
-
const val = String(node.metadata?.[key] ?? '') ||
|
|
643
|
+
const val = String(node.metadata?.[key] ?? '') || nodeAttrValue(node)
|
|
619
644
|
const content = this.renderChildren(node) || val
|
|
645
|
+
// El converter HTML→BBCode ve `<strong><a>`, no la etiqueta original. Sin
|
|
646
|
+
// esta marca `[profile]peppy[/profile]` volvía como `[b][url=...]…[/url][/b]`,
|
|
647
|
+
// y sin el valor `[profile=5458323]` perdía el id y volvía como `[profile]`.
|
|
648
|
+
const entity = ` data-entity="${type}"` +
|
|
649
|
+
(val ? ` data-entity-value="${this.escapeHtml(val)}"` : '')
|
|
620
650
|
if (this.options.entityLinkResolver) {
|
|
621
651
|
const link = this.options.entityLinkResolver(type, val || content)
|
|
622
652
|
if (link) {
|
|
623
653
|
const ext = link.external ? ' target="_blank" rel="noopener noreferrer"' : ''
|
|
624
|
-
return `<strong><a${this.idAttr(node)} href="${this.escapeHtml(link.href)}"${ext}>${content}</a></strong>`
|
|
654
|
+
return `<strong${entity}><a${this.idAttr(node)} href="${this.escapeHtml(link.href)}"${ext}>${content}</a></strong>`
|
|
625
655
|
}
|
|
626
656
|
}
|
|
627
657
|
if (type === 'profile') {
|
|
628
658
|
const url = this.options.theme === 'lyne' || this.options.dialect === 'lyne'
|
|
629
659
|
? `/u/${encodeURIComponent(val || content)}`
|
|
630
660
|
: `https://osu.ppy.sh/users/${this.escapeHtml(val || content)}`
|
|
631
|
-
return `<strong><a${this.idAttr(node)} href="${url}" target="_blank" rel="noopener">${content}</a></strong>`
|
|
661
|
+
return `<strong${entity}><a${this.idAttr(node)} href="${url}" target="_blank" rel="noopener">${content}</a></strong>`
|
|
632
662
|
}
|
|
633
663
|
if (type === 'guild') {
|
|
634
|
-
return `<strong><a${this.idAttr(node)} href="/guilds/${encodeURIComponent(val || content)}">${content}</a></strong>`
|
|
664
|
+
return `<strong${entity}><a${this.idAttr(node)} href="/guilds/${encodeURIComponent(val || content)}">${content}</a></strong>`
|
|
635
665
|
}
|
|
636
666
|
if (type === 'map') {
|
|
637
|
-
return `<strong><a${this.idAttr(node)} href="/maps/${encodeURIComponent(val || content)}">${content}</a></strong>`
|
|
667
|
+
return `<strong${entity}><a${this.idAttr(node)} href="/maps/${encodeURIComponent(val || content)}">${content}</a></strong>`
|
|
638
668
|
}
|
|
639
|
-
return `<strong><a${this.idAttr(node)} href="#">${content}</a></strong>`
|
|
669
|
+
return `<strong${entity}><a${this.idAttr(node)} href="#">${content}</a></strong>`
|
|
640
670
|
}
|
|
641
671
|
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
672
|
+
/**
|
|
673
|
+
* El aviso que ocupa el sitio de un medio que no se puede pintar.
|
|
674
|
+
*
|
|
675
|
+
* Lleva `data-bb-empty` con la etiqueta de origen porque el camino
|
|
676
|
+
* HTML→BBCode lee de vuelta lo que hay en el lienzo: sin la marca, el texto
|
|
677
|
+
* del aviso entraba al documento como contenido y se comía la etiqueta. En
|
|
678
|
+
* `docs/ai/hxovc.bbcode`, con cinco `[img][/img]` vacíos, cada edición del
|
|
679
|
+
* bloque convertía otro en la frase «[img] missing source URL».
|
|
680
|
+
*/
|
|
681
|
+
private mediaError(tag: string, message: string): string {
|
|
682
|
+
return `<div class="media-error" data-bb-empty="${tag}">${message}</div>`
|
|
651
683
|
}
|
|
652
684
|
|
|
653
685
|
private renderImage(node: RedNode): string {
|
|
654
|
-
let src = String(node.metadata?.src ?? '') ||
|
|
655
|
-
if (!src) return '
|
|
686
|
+
let src = String(node.metadata?.src ?? '') || nodeAttrValue(node) || ''
|
|
687
|
+
if (!src) return this.mediaError('img', '[img] missing source URL')
|
|
656
688
|
if (this.options.mediaProxy) {
|
|
657
689
|
src = this.options.mediaProxy(src)
|
|
658
690
|
}
|
|
659
|
-
const imgAttr =
|
|
691
|
+
const imgAttr = parseImgAttr(nodeAttrValue(node))
|
|
660
692
|
const styles: string[] = []
|
|
661
693
|
if (imgAttr.w) styles.push(`width:${imgAttr.w}px`)
|
|
662
694
|
if (imgAttr.h) styles.push(`height:${imgAttr.h}px`)
|
|
@@ -671,15 +703,21 @@ export class HTMLRenderer extends Visitor<string> {
|
|
|
671
703
|
styles.push('max-width:100%', 'height:auto', 'display:inline-block')
|
|
672
704
|
}
|
|
673
705
|
const cls = imgAttr.round ? '' : ' class="bb-img"'
|
|
674
|
-
|
|
706
|
+
// El modificador no se puede reconstruir desde el `style` resultante
|
|
707
|
+
// (`round` y `120x120` producen el mismo CSS), así que viaja literal.
|
|
708
|
+
const rawAttr = nodeAttrValue(node)
|
|
709
|
+
const attr = rawAttr ? ` data-img-attr="${this.escapeHtml(rawAttr)}"` : ''
|
|
710
|
+
return `<img${this.idAttr(node)}${cls}${attr} src="${this.escapeHtml(src)}" alt="" style="${styles.join(';')};">`
|
|
675
711
|
}
|
|
676
712
|
|
|
677
713
|
private renderVideo(node: RedNode): string {
|
|
678
|
-
let id = String(node.metadata?.videoId ?? '') ||
|
|
679
|
-
if (!id) return '
|
|
714
|
+
let id = String(node.metadata?.videoId ?? '') || nodeAttrValue(node) || ''
|
|
715
|
+
if (!id) return this.mediaError('youtube', '[youtube] missing video ID')
|
|
680
716
|
const ytMatch = /(?:youtu\.be\/|v=|\/embed\/|\/shorts\/)([\w-]{11})/.exec(id)
|
|
681
717
|
if (ytMatch) id = ytMatch[1]
|
|
682
|
-
|
|
718
|
+
// `data-youtube` es lo que deja al converter HTML→BBCode reconocer este
|
|
719
|
+
// iframe. Sin él el vídeo volvía como un `group` vacío: se perdía entero.
|
|
720
|
+
return `<iframe${this.idAttr(node)} class="bb-youtube" data-youtube="${this.escapeHtml(id)}" src="https://www.youtube.com/embed/${this.escapeHtml(id)}" frameborder="0" allowfullscreen></iframe>`
|
|
683
721
|
}
|
|
684
722
|
|
|
685
723
|
private renderAudio(node: RedNode): string {
|
|
@@ -707,7 +745,7 @@ export class HTMLRenderer extends Visitor<string> {
|
|
|
707
745
|
private renderNotice(node: RedNode, warning: boolean): string {
|
|
708
746
|
const isLyne = this.options.theme === 'lyne' || this.options.dialect === 'lyne' || warning
|
|
709
747
|
if (isLyne) {
|
|
710
|
-
const color =
|
|
748
|
+
const color = sanitizeColor(nodeAttrValue(node, 'color'), this.tokenResolver)
|
|
711
749
|
const styleAttr = color
|
|
712
750
|
? ` style="background:${this.hexToRgba(color, 0.08)};border-color:${this.hexToRgba(color, 0.4)};border-left-color:${color};color:${this.hexToRgba(color, 0.85)};"`
|
|
713
751
|
: ''
|
|
@@ -721,7 +759,7 @@ export class HTMLRenderer extends Visitor<string> {
|
|
|
721
759
|
}
|
|
722
760
|
|
|
723
761
|
private renderTables(node: RedNode): string {
|
|
724
|
-
const variant = (String(node.metadata?.variant ?? '') ||
|
|
762
|
+
const variant = (String(node.metadata?.variant ?? '') || nodeAttrValue(node) || '').trim().toLowerCase()
|
|
725
763
|
const variants = new Set(variant.split(/[\s,]+/).filter(Boolean))
|
|
726
764
|
const tableClasses = [
|
|
727
765
|
'bb-table',
|
|
@@ -729,7 +767,9 @@ export class HTMLRenderer extends Visitor<string> {
|
|
|
729
767
|
variants.has('borders') ? 'bb-table-borders' : '',
|
|
730
768
|
].filter(Boolean).join(' ')
|
|
731
769
|
this.tableDepth++
|
|
770
|
+
this.maxColsStack.push(HTMLRenderer.widestRow(node))
|
|
732
771
|
const content = this.renderChildren(node)
|
|
772
|
+
this.maxColsStack.pop()
|
|
733
773
|
this.tableDepth--
|
|
734
774
|
// `[tables=striped:#hex]`: de `--table-accent` se deriva toda la paleta
|
|
735
775
|
// de la tabla (gradiente del frame, hover de filas, encabezado, bordes).
|
|
@@ -737,22 +777,52 @@ export class HTMLRenderer extends Visitor<string> {
|
|
|
737
777
|
return `<div${this.idAttr(node)} class="bb-table-frame"${accent}><table class="${tableClasses}"><tbody>${content}</tbody></table></div>`
|
|
738
778
|
}
|
|
739
779
|
|
|
780
|
+
/** Celdas directas de una fila, sin materializar un array intermedio. */
|
|
781
|
+
private static countCells(node: RedNode): number {
|
|
782
|
+
let count = 0
|
|
783
|
+
const children = node.children
|
|
784
|
+
for (let i = 0; i < children.length; i++) {
|
|
785
|
+
const kind = children[i].kind
|
|
786
|
+
if (kind === 'table_th' || kind === 'table_col') count++
|
|
787
|
+
}
|
|
788
|
+
return count
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
/**
|
|
792
|
+
* Columnas de la tabla más ancha bajo `tableNode`, memoizado por tabla.
|
|
793
|
+
*
|
|
794
|
+
* `getTableContext` se llama una vez POR CELDA, y recorría todas las filas
|
|
795
|
+
* de la tabla en cada llamada, con un `.filter()` —y su array— por fila.
|
|
796
|
+
* Una tabla de 20×20 hacía 8.000 arrays intermedios en cada renderizado
|
|
797
|
+
* para llegar 400 veces al mismo número. El caché se vacía al empezar cada
|
|
798
|
+
* render, así que no retiene nodos entre documentos.
|
|
799
|
+
*/
|
|
800
|
+
private static widestRow(tableNode: RedNode): number {
|
|
801
|
+
let maxCols = 1
|
|
802
|
+
const rows = tableNode.children
|
|
803
|
+
for (let i = 0; i < rows.length; i++) {
|
|
804
|
+
const row = rows[i]
|
|
805
|
+
if (row.kind !== 'table_row') continue
|
|
806
|
+
const count = HTMLRenderer.countCells(row)
|
|
807
|
+
if (count > maxCols) maxCols = count
|
|
808
|
+
}
|
|
809
|
+
return maxCols
|
|
810
|
+
}
|
|
811
|
+
|
|
740
812
|
private getTableContext(node: RedNode): { cellCount: number; maxCols: number; isDirect: boolean } {
|
|
741
813
|
const parent = node.parent
|
|
742
814
|
const isDirect = parent?.kind === 'tables'
|
|
743
815
|
const tableNode = isDirect ? parent : (parent?.parent?.kind === 'tables' ? parent.parent : undefined)
|
|
744
|
-
const cellCount = parent
|
|
745
|
-
? parent.children.filter(c => c.kind === 'table_th' || c.kind === 'table_col').length
|
|
746
|
-
: 1
|
|
816
|
+
const cellCount = parent ? HTMLRenderer.countCells(parent) : 1
|
|
747
817
|
|
|
748
818
|
let maxCols = cellCount
|
|
749
819
|
if (tableNode) {
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
820
|
+
// La tabla en curso está en lo alto de la pila; solo se recorre si esta
|
|
821
|
+
// celda llegó por un camino que no pasó por `renderTables`.
|
|
822
|
+
const widest = this.maxColsStack.length > 0
|
|
823
|
+
? this.maxColsStack[this.maxColsStack.length - 1]
|
|
824
|
+
: HTMLRenderer.widestRow(tableNode)
|
|
825
|
+
if (widest > maxCols) maxCols = widest
|
|
756
826
|
}
|
|
757
827
|
return { cellCount, maxCols, isDirect }
|
|
758
828
|
}
|
|
@@ -763,7 +833,7 @@ export class HTMLRenderer extends Visitor<string> {
|
|
|
763
833
|
}
|
|
764
834
|
|
|
765
835
|
private renderColumns(node: RedNode): string {
|
|
766
|
-
const cols = parseInt(String(node.metadata?.columns ?? '') ||
|
|
836
|
+
const cols = parseInt(String(node.metadata?.columns ?? '') || nodeAttrValue(node) || '2', 10)
|
|
767
837
|
const numCols = Math.max(2, Math.min(4, isNaN(cols) ? 2 : cols))
|
|
768
838
|
const content = this.renderChildren(node)
|
|
769
839
|
// `[columns=2:#hex]`: con color se añade la superficie (borde + fondo
|
|
@@ -775,27 +845,27 @@ export class HTMLRenderer extends Visitor<string> {
|
|
|
775
845
|
}
|
|
776
846
|
|
|
777
847
|
private renderSeparator(node: RedNode): string {
|
|
778
|
-
const variant = (String(node.metadata?.variant ?? '') ||
|
|
848
|
+
const variant = (String(node.metadata?.variant ?? '') || nodeAttrValue(node) || 'line').trim().toLowerCase()
|
|
779
849
|
if (variant === 'dots') return `<div${this.idAttr(node)} class="bb-separator">· · ·</div>`
|
|
780
850
|
if (variant === 'stars') return `<div${this.idAttr(node)} class="bb-separator">✦ ✦ ✦</div>`
|
|
781
851
|
return `<hr${this.idAttr(node)} class="bb-separator" />`
|
|
782
852
|
}
|
|
783
853
|
|
|
784
854
|
private renderScroll(node: RedNode): string {
|
|
785
|
-
const h = parseInt(String(node.metadata?.height ?? '') ||
|
|
855
|
+
const h = parseInt(String(node.metadata?.height ?? '') || nodeAttrValue(node) || '200', 10)
|
|
786
856
|
const maxH = Math.max(50, Math.min(2000, isNaN(h) ? 200 : h))
|
|
787
857
|
const content = this.renderChildren(node)
|
|
788
858
|
return `<div${this.idAttr(node)} class="bb-scroll" style="max-height:${maxH}px;">${content}</div>`
|
|
789
859
|
}
|
|
790
860
|
|
|
791
861
|
private renderAbbr(node: RedNode): string {
|
|
792
|
-
const title = String(node.metadata?.title ?? '') ||
|
|
862
|
+
const title = String(node.metadata?.title ?? '') || nodeAttrValue(node)
|
|
793
863
|
const attr = title ? ` title="${this.escapeHtml(title)}"` : ''
|
|
794
864
|
return this.wrapInline('abbr', node, attr)
|
|
795
865
|
}
|
|
796
866
|
|
|
797
867
|
private renderTooltip(node: RedNode): string {
|
|
798
|
-
const tip = String(node.metadata?.tip ?? '') ||
|
|
868
|
+
const tip = String(node.metadata?.tip ?? '') || nodeAttrValue(node)
|
|
799
869
|
const attr = tip ? ` title="${this.escapeHtml(tip)}"` : ''
|
|
800
870
|
return this.wrapInline('span', node, `class="bb-tooltip"${attr}`)
|
|
801
871
|
}
|
|
@@ -810,16 +880,16 @@ export class HTMLRenderer extends Visitor<string> {
|
|
|
810
880
|
}
|
|
811
881
|
|
|
812
882
|
private renderAlign(node: RedNode): string {
|
|
813
|
-
const alignVal = (String(node.metadata?.align ?? '') ||
|
|
883
|
+
const alignVal = (String(node.metadata?.align ?? '') || nodeAttrValue(node) || 'center').trim().toLowerCase()
|
|
814
884
|
const validAlign = alignVal === 'left' || alignVal === 'right' ? alignVal : 'center'
|
|
815
885
|
return this.wrapBlock('div', node, `style="text-align:${validAlign};"`)
|
|
816
886
|
}
|
|
817
887
|
|
|
818
888
|
private renderEffect(node: RedNode): string {
|
|
819
|
-
const raw = (String(node.metadata?.effectType ?? '') ||
|
|
889
|
+
const raw = (String(node.metadata?.effectType ?? '') || nodeAttrValue(node) || 'glow').toLowerCase().trim()
|
|
820
890
|
const effectType = raw.includes(':') ? raw.split(':')[0] : (raw.startsWith('#') ? 'glow' : raw)
|
|
821
|
-
const rawColor = String(node.metadata?.color ?? '') || (raw.includes(':') ? raw.split(':')[1] : (raw.startsWith('#') ? raw :
|
|
822
|
-
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)
|
|
823
893
|
const content = this.renderChildren(node)
|
|
824
894
|
const idAttr = this.idAttr(node)
|
|
825
895
|
|
|
@@ -852,7 +922,7 @@ export class HTMLRenderer extends Visitor<string> {
|
|
|
852
922
|
}
|
|
853
923
|
|
|
854
924
|
private renderAnim(node: RedNode): string {
|
|
855
|
-
const raw = (String(node.metadata?.animType ?? '') ||
|
|
925
|
+
const raw = (String(node.metadata?.animType ?? '') || nodeAttrValue(node) || 'pulse').toLowerCase().trim()
|
|
856
926
|
const animType = raw.includes(':') ? raw.split(':')[0] : raw
|
|
857
927
|
const content = this.renderChildren(node)
|
|
858
928
|
const idAttr = this.idAttr(node)
|
|
@@ -880,7 +950,7 @@ export class HTMLRenderer extends Visitor<string> {
|
|
|
880
950
|
}
|
|
881
951
|
|
|
882
952
|
private renderContainer(node: RedNode): string {
|
|
883
|
-
const raw = (String(node.metadata?.containerType ?? '') ||
|
|
953
|
+
const raw = (String(node.metadata?.containerType ?? '') || nodeAttrValue(node) || 'stack').trim()
|
|
884
954
|
const [type, ...params] = raw.split(':')
|
|
885
955
|
const containerType = type.toLowerCase()
|
|
886
956
|
const param = params.join(':')
|
|
@@ -904,7 +974,7 @@ export class HTMLRenderer extends Visitor<string> {
|
|
|
904
974
|
return `<div${idAttr} class="bb-middle">${content}</div>`
|
|
905
975
|
case 'square':
|
|
906
976
|
case 'circle': {
|
|
907
|
-
const accent =
|
|
977
|
+
const accent = sanitizeColor(param, this.tokenResolver)
|
|
908
978
|
const border = accent ? this.hexToRgba(accent, 0.35) : ''
|
|
909
979
|
const bg = accent ? this.hexToRgba(accent, 0.08) : ''
|
|
910
980
|
const style = accent ? ` style="--square-accent:${accent};--square-border:${border};--square-bg:${bg};"` : ''
|
|
@@ -917,14 +987,14 @@ export class HTMLRenderer extends Visitor<string> {
|
|
|
917
987
|
// del que el CSS deriva borde, tinte de fondo y —vía la clase
|
|
918
988
|
// `bb-accented`— la paleta del contenido sin color propio. Sin color,
|
|
919
989
|
// defaults y sin clase.
|
|
920
|
-
const accent =
|
|
990
|
+
const accent = sanitizeColor(param, this.tokenResolver)
|
|
921
991
|
const cls = accent ? ' bb-accented' : ''
|
|
922
992
|
const style = accent ? ` style="--${containerType}-accent:${accent};"` : ''
|
|
923
993
|
return `<div${idAttr} class="bb-cut-panel bb-${containerType}${cls}"${style}>${content}</div>`
|
|
924
994
|
}
|
|
925
995
|
case 'neon-box':
|
|
926
996
|
case 'neonbox': {
|
|
927
|
-
const c =
|
|
997
|
+
const c = sanitizeColor(param || nodeAttrValue(node), this.tokenResolver) || 'var(--color-accent, #2EE6E2)'
|
|
928
998
|
return `<div${idAttr} class="bb-cut-panel bb-neon-box" style="--neon-color:${c};border-color:${c};">${content}</div>`
|
|
929
999
|
}
|
|
930
1000
|
default:
|
|
@@ -943,7 +1013,7 @@ export class HTMLRenderer extends Visitor<string> {
|
|
|
943
1013
|
])
|
|
944
1014
|
|
|
945
1015
|
private renderStyleTag(node: RedNode): string {
|
|
946
|
-
const raw = (String(node.metadata?.style ?? '') ||
|
|
1016
|
+
const raw = (String(node.metadata?.style ?? '') || nodeAttrValue(node) || '').trim()
|
|
947
1017
|
const content = this.renderChildren(node)
|
|
948
1018
|
const idAttr = this.idAttr(node)
|
|
949
1019
|
if (!raw) return `<span${idAttr}>${content}</span>`
|
|
@@ -961,7 +1031,10 @@ export class HTMLRenderer extends Visitor<string> {
|
|
|
961
1031
|
// color-mix()…) — antes se eliminaban y rompían esos valores. Lo que sí
|
|
962
1032
|
// se bloquea son las funciones/fuentes peligrosas (url(), expression(),
|
|
963
1033
|
// javascript:), que no tienen uso legítimo en el whitelist de props.
|
|
964
|
-
|
|
1034
|
+
let safeVal = val.replace(/["'{}<>]/g, '').slice(0, 100)
|
|
1035
|
+
if (safeVal.startsWith('$') && this.tokenResolver) {
|
|
1036
|
+
safeVal = resolveTokenValue(safeVal, this.tokenResolver)
|
|
1037
|
+
}
|
|
965
1038
|
if (/url\s*\(|expression\s*\(|javascript\s*:/i.test(safeVal)) continue
|
|
966
1039
|
safeStyles.push(`${prop}:${safeVal}`)
|
|
967
1040
|
}
|
|
@@ -971,7 +1044,7 @@ export class HTMLRenderer extends Visitor<string> {
|
|
|
971
1044
|
}
|
|
972
1045
|
|
|
973
1046
|
private renderQuote(node: RedNode): string {
|
|
974
|
-
const source = node.metadata?.source ||
|
|
1047
|
+
const source = node.metadata?.source || nodeAttrValue(node)
|
|
975
1048
|
const content = this.renderChildren(node)
|
|
976
1049
|
if (source) {
|
|
977
1050
|
return `<blockquote${this.idAttr(node)}><div style="margin-bottom:8px"><strong>${this.escapeHtml(String(source))} wrote:</strong></div>${content}</blockquote>`
|
|
@@ -981,6 +1054,7 @@ export class HTMLRenderer extends Visitor<string> {
|
|
|
981
1054
|
|
|
982
1055
|
private renderSpoilerbox(node: RedNode): string {
|
|
983
1056
|
const title = this.renderTitle(node, 'Spoiler')
|
|
1057
|
+
const bare = this.bareTitleAttr(node)
|
|
984
1058
|
const content = this.renderChildren(node)
|
|
985
1059
|
const isLyne = this.options.theme === 'lyne' || this.options.dialect === 'lyne'
|
|
986
1060
|
const bodyCls = isLyne ? 'bb-box-body' : 'bbcode-box-body'
|
|
@@ -990,11 +1064,12 @@ export class HTMLRenderer extends Visitor<string> {
|
|
|
990
1064
|
// `--box-accent` colorea el acento del box (título + chevron, y el borde
|
|
991
1065
|
// en boxw) cuando el autor puso `[box=Title:#hex]`.
|
|
992
1066
|
const accent = this.boxAccentStyle(node)
|
|
993
|
-
return `<details${this.idAttr(node)}${accent}><summary><span class="bb-box-heading">${title}</span></summary><div class="${bodyCls}">${content}</div></details>`
|
|
1067
|
+
return `<details${this.idAttr(node)}${bare}${accent}><summary><span class="bb-box-heading">${title}</span></summary><div class="${bodyCls}">${content}</div></details>`
|
|
994
1068
|
}
|
|
995
1069
|
|
|
996
1070
|
private renderBox(node: RedNode): string {
|
|
997
1071
|
const title = this.renderTitle(node, 'Box')
|
|
1072
|
+
const bare = this.bareTitleAttr(node)
|
|
998
1073
|
const content = this.renderChildren(node)
|
|
999
1074
|
const isLyne = this.options.theme === 'lyne' || this.options.dialect === 'lyne'
|
|
1000
1075
|
const bodyCls = isLyne ? 'bb-box-body' : 'bbcode-box-body'
|
|
@@ -1006,7 +1081,20 @@ export class HTMLRenderer extends Visitor<string> {
|
|
|
1006
1081
|
// (no `bb-box-title`) evita la regla legacy `.bbcode-preview .bb-box-title`
|
|
1007
1082
|
// de la app, que pinta un fondo sobre el título.
|
|
1008
1083
|
const accent = this.boxAccentStyle(node)
|
|
1009
|
-
return `<details${this.idAttr(node)} class="${cls}"${accent}><summary><span class="bb-box-heading">${title}</span></summary><div class="${bodyCls}">${content}</div></details>`
|
|
1084
|
+
return `<details${this.idAttr(node)} class="${cls}"${bare}${accent}><summary><span class="bb-box-heading">${title}</span></summary><div class="${bodyCls}">${content}</div></details>`
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
/**
|
|
1088
|
+
* Marca un `[box]`/`[spoilerbox]` sin título propio.
|
|
1089
|
+
*
|
|
1090
|
+
* El `<summary>` siempre lleva texto ("Box", "Spoiler"), así que sin esta
|
|
1091
|
+
* marca el camino HTML→BBCode leía ese relleno como si el autor lo hubiera
|
|
1092
|
+
* escrito y devolvía `[box=Box]`.
|
|
1093
|
+
*/
|
|
1094
|
+
private bareTitleAttr(node: RedNode): string {
|
|
1095
|
+
const raw = node.metadata?.rawTitle
|
|
1096
|
+
const hasOwnTitle = raw !== undefined ? String(raw) !== '' : node.metadata?.title !== undefined
|
|
1097
|
+
return hasOwnTitle ? '' : ' data-bare-title="1"'
|
|
1010
1098
|
}
|
|
1011
1099
|
|
|
1012
1100
|
private renderTitle(node: RedNode, fallback: string): string {
|
|
@@ -1014,8 +1102,14 @@ export class HTMLRenderer extends Visitor<string> {
|
|
|
1014
1102
|
if (titleNodes && titleNodes.length > 0) {
|
|
1015
1103
|
return titleNodes.map(c => this.renderNode(c)).join('')
|
|
1016
1104
|
}
|
|
1017
|
-
|
|
1018
|
-
|
|
1105
|
+
let title = String(node.metadata?.title ?? nodeAttrValue(node) ?? fallback)
|
|
1106
|
+
if (this.tokenResolver) {
|
|
1107
|
+
title = title.replace(/\$([a-zA-Z_][a-zA-Z0-9_-]*)/g, (match, name) => {
|
|
1108
|
+
const resolved = this.tokenResolver!(name) ?? this.tokenResolver!(match)
|
|
1109
|
+
return resolved !== undefined ? resolved : match
|
|
1110
|
+
})
|
|
1111
|
+
}
|
|
1112
|
+
return this.escapeHtml(title)
|
|
1019
1113
|
}
|
|
1020
1114
|
|
|
1021
1115
|
/**
|
|
@@ -1025,7 +1119,7 @@ export class HTMLRenderer extends Visitor<string> {
|
|
|
1025
1119
|
* borde + fondo en columns). Devuelve '' si no hay color válido.
|
|
1026
1120
|
*/
|
|
1027
1121
|
private boxAccentStyle(node: RedNode, suffix: 'box' | 'table' | 'columns' = 'box'): string {
|
|
1028
|
-
const color =
|
|
1122
|
+
const color = sanitizeColor(String(node.metadata?.color ?? ''), this.tokenResolver)
|
|
1029
1123
|
if (!color) return ''
|
|
1030
1124
|
const border = this.hexToRgba(color, 0.35)
|
|
1031
1125
|
const bg = this.hexToRgba(color, 0.08)
|
|
@@ -1034,7 +1128,7 @@ export class HTMLRenderer extends Visitor<string> {
|
|
|
1034
1128
|
|
|
1035
1129
|
private renderList(node: RedNode): string {
|
|
1036
1130
|
// Check for ordered list: [list=1], [list=a], or metadata
|
|
1037
|
-
const attrs =
|
|
1131
|
+
const attrs = nodeAttrValue(node)
|
|
1038
1132
|
const isOrdered = node.metadata?.ordered === true || attrs === '1' || attrs === 'a'
|
|
1039
1133
|
const tag = isOrdered ? 'ol' : 'ul'
|
|
1040
1134
|
const content = node.children.map(c => this.renderNode(c)).join('\n')
|
|
@@ -1055,7 +1149,12 @@ export class HTMLRenderer extends Visitor<string> {
|
|
|
1055
1149
|
const trailingMatch = content.match(/(?:[\r\n][\t ]*)+$/)
|
|
1056
1150
|
if (trailingMatch) content = content.slice(0, -trailingMatch[0].length)
|
|
1057
1151
|
|
|
1058
|
-
|
|
1152
|
+
// Los saltos recortados arriba son de presentación: si no se anotan, el
|
|
1153
|
+
// camino HTML→BBCode los da por inexistentes y reescribe el bloque del
|
|
1154
|
+
// autor como `[code]…[/code]` en una sola línea.
|
|
1155
|
+
const pad = `${leadingMatch ? leadingMatch[0] : ''}\u0000${trailingMatch ? trailingMatch[0] : ''}`
|
|
1156
|
+
const padAttr = pad === '\u0000' ? '' : ` data-code-pad="${this.escapeHtml(JSON.stringify(pad))}"`
|
|
1157
|
+
return `<pre${this.idAttr(node)}${padAttr}><code>${this.escapeHtml(content)}</code></pre>`
|
|
1059
1158
|
}
|
|
1060
1159
|
|
|
1061
1160
|
private renderSVG(node: RedNode): string {
|
|
@@ -1091,12 +1190,12 @@ export class HTMLRenderer extends Visitor<string> {
|
|
|
1091
1190
|
.filter(Boolean)
|
|
1092
1191
|
|
|
1093
1192
|
if (textLines.length === 0) {
|
|
1094
|
-
return '
|
|
1193
|
+
return this.mediaError('imagemap', '[imagemap] missing image URL')
|
|
1095
1194
|
}
|
|
1096
1195
|
|
|
1097
1196
|
const imageUrl = textLines[0]
|
|
1098
1197
|
if (!imageUrl.startsWith('http://') && !imageUrl.startsWith('https://')) {
|
|
1099
|
-
return
|
|
1198
|
+
return this.mediaError('imagemap', `[imagemap] invalid image URL: ${this.escapeHtml(imageUrl)}`)
|
|
1100
1199
|
}
|
|
1101
1200
|
|
|
1102
1201
|
let areas = ''
|
|
@@ -1133,26 +1232,127 @@ export class HTMLRenderer extends Visitor<string> {
|
|
|
1133
1232
|
return node.text || ''
|
|
1134
1233
|
}
|
|
1135
1234
|
|
|
1235
|
+
/**
|
|
1236
|
+
* Effect parameters off a node, with every colour re-validated.
|
|
1237
|
+
*
|
|
1238
|
+
* `startsWith('#')` was NOT a filter: `#a" onmouseover="alert(1)` passes
|
|
1239
|
+
* it. Every stop has to survive the full colour allowlist or be dropped,
|
|
1240
|
+
* because these values end up inside a `style` attribute.
|
|
1241
|
+
*/
|
|
1242
|
+
private effectParams(node: RedNode): EffectParams {
|
|
1243
|
+
const meta = (node.metadata ?? {}) as Record<string, unknown>
|
|
1244
|
+
const params: EffectParams = {}
|
|
1245
|
+
for (const [key, value] of Object.entries(meta)) {
|
|
1246
|
+
if (key === 'globalOffset' || key === 'documentLength') continue
|
|
1247
|
+
;(params as Record<string, unknown>)[key] = value
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
if (Array.isArray(params.colors)) {
|
|
1251
|
+
params.colors = params.colors
|
|
1252
|
+
.map(c => (typeof c === 'string' ? sanitizeColor(c, this.tokenResolver) : null))
|
|
1253
|
+
.filter((c): c is string => c !== null)
|
|
1254
|
+
if (params.colors.length === 0) delete params.colors
|
|
1255
|
+
}
|
|
1256
|
+
if (Array.isArray(params.stops)) {
|
|
1257
|
+
params.stops = params.stops
|
|
1258
|
+
.filter(st => st && typeof st.color === 'string' && sanitizeColor(st.color, this.tokenResolver) !== null)
|
|
1259
|
+
if (params.stops.length === 0) delete params.stops
|
|
1260
|
+
}
|
|
1261
|
+
return params
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
/** Text inside an effect node, ignoring layout-only children. */
|
|
1265
|
+
/**
|
|
1266
|
+
* The plain text an effect modulates, line breaks included.
|
|
1267
|
+
*
|
|
1268
|
+
* A break is a childless `spacing` leaf carrying no text, so collecting
|
|
1269
|
+
* it naively contributes nothing and the effect sees one long line.
|
|
1270
|
+
* Every two-dimensional axis then collapses: `line` reports zero for
|
|
1271
|
+
* the whole block, `column` stretches one row across the paragraph, and
|
|
1272
|
+
* `radial` measures a rectangle one line tall. The break was being
|
|
1273
|
+
* dropped from the OUTPUT as well, so a multi-line effect previewed as
|
|
1274
|
+
* a single run-on line.
|
|
1275
|
+
*
|
|
1276
|
+
* The document's own plain text spells a break `\n` — that is what
|
|
1277
|
+
* `stripColorWrappers` and the studio's sampler both count — so this
|
|
1278
|
+
* agrees with them rather than inventing a third answer.
|
|
1279
|
+
*/
|
|
1280
|
+
private effectText(node: RedNode): string {
|
|
1281
|
+
return node.children.map(c => this.effectChildText(c)).join('')
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1284
|
+
private effectChildText(node: RedNode): string {
|
|
1285
|
+
if (node.kind === 'spacing' || node.kind === 'empty_line') return '\n'
|
|
1286
|
+
if (node.kind === 'text') return node.text ?? ''
|
|
1287
|
+
if (node.children.length === 0) return this.collectNodeText(node)
|
|
1288
|
+
return node.children.map(c => this.effectChildText(c)).join('')
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1291
|
+
/**
|
|
1292
|
+
* Paint an effect's computed segments as spans.
|
|
1293
|
+
*
|
|
1294
|
+
* Shared by rainbow, grow and any modulated gradient, so the preview
|
|
1295
|
+
* shows exactly the colours and sizes the BBCode export will contain
|
|
1296
|
+
* rather than an approximation of them.
|
|
1297
|
+
*/
|
|
1298
|
+
private renderEffectSegments(node: RedNode, kind: EffectKind): string {
|
|
1299
|
+
const text = this.effectText(node)
|
|
1300
|
+
if (!text) return this.renderChildren(node)
|
|
1301
|
+
|
|
1302
|
+
const meta = (node.metadata ?? {}) as Record<string, unknown>
|
|
1303
|
+
const segments = evaluateEffect(text, kind, this.effectParams(node), {
|
|
1304
|
+
globalOffset: meta.globalOffset as number | undefined,
|
|
1305
|
+
documentLength: meta.documentLength as number | undefined,
|
|
1306
|
+
})
|
|
1307
|
+
|
|
1308
|
+
let out = ''
|
|
1309
|
+
for (const seg of segments) {
|
|
1310
|
+
// The break is markup, not text: escaping it would print a newline
|
|
1311
|
+
// that HTML collapses to a space, which is how a painted block of
|
|
1312
|
+
// ASCII art would arrive as one unreadable line.
|
|
1313
|
+
// El `indexOf` delante: un degradado sin fusionar emite un segmento POR
|
|
1314
|
+
// CARÁCTER, y `/\n/g` es un literal —en ES2015+ eso asigna un objeto
|
|
1315
|
+
// nuevo cada vez que se evalúa— para recorrer una cadena de un carácter
|
|
1316
|
+
// que casi nunca es un salto de línea.
|
|
1317
|
+
const html = this.escapeHtml(seg.text)
|
|
1318
|
+
const escaped = html.indexOf('\n') === -1 ? html : html.replace(NEWLINE_RE, '<br>')
|
|
1319
|
+
if (seg.color) {
|
|
1320
|
+
const safe = sanitizeColor(seg.color, this.tokenResolver)
|
|
1321
|
+
out += safe ? `<span style="color:${safe}">${escaped}</span>` : escaped
|
|
1322
|
+
} else if (seg.size !== undefined) {
|
|
1323
|
+
out += `<span style="font-size:${Math.max(10, Math.min(400, seg.size))}%">${escaped}</span>`
|
|
1324
|
+
} else {
|
|
1325
|
+
out += escaped
|
|
1326
|
+
}
|
|
1327
|
+
}
|
|
1328
|
+
return `<span${this.idAttr(node)} class="bb-effect bb-${kind}">${out}</span>`
|
|
1329
|
+
}
|
|
1330
|
+
|
|
1331
|
+
/**
|
|
1332
|
+
* A gradient previews as a real CSS gradient when it is a plain
|
|
1333
|
+
* left-to-right ramp, and as computed per-character spans otherwise.
|
|
1334
|
+
*
|
|
1335
|
+
* The CSS path is genuinely smoother — it interpolates per pixel rather
|
|
1336
|
+
* than per glyph — but it can only express a linear sweep. A gradient
|
|
1337
|
+
* with a waveform, a non-index axis or quantisation steps looked
|
|
1338
|
+
* nothing like its own export while this was the only path.
|
|
1339
|
+
*/
|
|
1136
1340
|
private renderGradient(node: RedNode): string {
|
|
1137
|
-
|
|
1138
|
-
// Every stop must survive the full color allowlist or it is dropped.
|
|
1139
|
-
const raw = (node.metadata?.colors as string[]) || []
|
|
1140
|
-
const valid = raw
|
|
1141
|
-
.map(c => (typeof c === 'string' ? this.sanitizeColor(c) : null))
|
|
1142
|
-
.filter((c): c is string => c !== null)
|
|
1143
|
-
const colors =
|
|
1144
|
-
valid.length === 0 ? ['#FF0000', '#00FF00']
|
|
1145
|
-
: valid.length === 1 ? [valid[0], valid[0]]
|
|
1146
|
-
: valid
|
|
1147
|
-
// Skip spacing/empty_line children so source formatting (newlines inside the tag)
|
|
1148
|
-
// doesn't produce extra <br> in the preview. Both:
|
|
1149
|
-
// [gradient]Hello[/gradient] and [gradient]\nHello\n[/gradient]
|
|
1150
|
-
// render identically.
|
|
1341
|
+
const params = this.effectParams(node)
|
|
1151
1342
|
const content = node.children
|
|
1152
1343
|
.filter(c => c.kind !== 'spacing' && c.kind !== 'empty_line')
|
|
1153
1344
|
.map(c => this.renderNode(c)).join('')
|
|
1154
|
-
|
|
1155
|
-
|
|
1345
|
+
|
|
1346
|
+
if (isPlainGradient(params)) {
|
|
1347
|
+
const stops = params.stops && params.stops.length > 0
|
|
1348
|
+
? params.stops.map(st => `${st.color} ${(st.position * 100).toFixed(1)}%`)
|
|
1349
|
+
: (params.colors ?? ['#FF0000', '#00FF00'])
|
|
1350
|
+
const list = stops.length === 1 ? [stops[0], stops[0]] : stops
|
|
1351
|
+
const gradientCss = `linear-gradient(to right, ${list.join(', ')})`
|
|
1352
|
+
return `<span${this.idAttr(node)} style="background: ${gradientCss}; -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text;">${content}</span>`
|
|
1353
|
+
}
|
|
1354
|
+
|
|
1355
|
+
return this.renderEffectSegments(node, 'gradient')
|
|
1156
1356
|
}
|
|
1157
1357
|
|
|
1158
1358
|
/**
|
|
@@ -1281,3 +1481,26 @@ export class HTMLRenderer extends Visitor<string> {
|
|
|
1281
1481
|
return out + text.slice(last)
|
|
1282
1482
|
}
|
|
1283
1483
|
}
|
|
1484
|
+
|
|
1485
|
+
/**
|
|
1486
|
+
* Whether a gradient is a plain left-to-right ramp.
|
|
1487
|
+
*
|
|
1488
|
+
* Only then can CSS express it. Anything with a waveform, a non-index
|
|
1489
|
+
* axis, quantisation, inversion or per-word stepping has to be painted
|
|
1490
|
+
* per character instead, or the preview and the export disagree.
|
|
1491
|
+
*/
|
|
1492
|
+
function isPlainGradient(params: EffectParams): boolean {
|
|
1493
|
+
// A tag carrying a span is one fragment of a longer ramp. Painting it
|
|
1494
|
+
// as a full-width CSS gradient restarts the sweep inside every
|
|
1495
|
+
// fragment, which is exactly the discontinuity the span exists to fix.
|
|
1496
|
+
if (params.documentLength !== undefined && params.documentLength > 1) return false
|
|
1497
|
+
return (params.wave === undefined || params.wave === 'none')
|
|
1498
|
+
&& (params.axis === undefined || params.axis === 'index')
|
|
1499
|
+
&& (params.easing === undefined || params.easing === 'linear')
|
|
1500
|
+
&& (params.steps === undefined || params.steps < 2)
|
|
1501
|
+
&& !params.invert
|
|
1502
|
+
&& (params.unit === undefined || params.unit === 'character')
|
|
1503
|
+
&& (params.cycles === undefined || params.cycles === 1)
|
|
1504
|
+
&& (params.phase === undefined || params.phase === 0)
|
|
1505
|
+
&& (params.expression === undefined || params.expression === '')
|
|
1506
|
+
}
|