@miliastry/quasar 1.0.6 → 1.2.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/README.md +1 -1
- package/dist/Visuals/lyne.css +374 -9
- package/dist/Visuals/osu.css +393 -11
- package/dist/index.js +10470 -3336
- package/dist/index.mjs +10336 -3337
- 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 +102 -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 +19 -0
- package/dist/types/Visitors/HTMLRenderer.d.ts +492 -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 +54 -0
- package/dist/types/Visuals/LyneAudio.d.ts +8 -0
- package/dist/types/Visuals/index.d.ts +39 -0
- package/dist/types/index.d.ts +155 -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 +39 -1
- 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 +193 -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 +1845 -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 +365 -32
- package/src/Visitors/BlockPatcher.ts +123 -22
- package/src/Visitors/DOMMorpher.ts +44 -28
- package/src/Visitors/HTMLRenderer.ts +771 -205
- package/src/Visitors/MarkdownExporter.ts +98 -41
- package/src/Visitors/TiptapExporter.ts +1 -0
- package/src/Visitors/index.ts +1 -1
- package/src/Visuals/BoxDrawer.ts +56 -10
- package/src/Visuals/LyneAudio.ts +346 -0
- package/src/Visuals/index.ts +8 -4
- package/src/Visuals/lyne.css +374 -9
- package/src/Visuals/osu.css +393 -11
- package/src/index.ts +178 -8
- 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,17 +9,42 @@
|
|
|
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
|
-
/**
|
|
20
|
-
* Replicate osu! forum BBCode spacing quirks.
|
|
21
|
-
*
|
|
22
|
-
*
|
|
33
|
+
/**
|
|
34
|
+
* Replicate osu! forum BBCode spacing quirks.
|
|
35
|
+
*
|
|
36
|
+
* NO LO LEE NADIE desde que el motor implementa las reglas reales de
|
|
37
|
+
* consumo de saltos (ver {@link HTMLRenderer.NEWLINE_RULES}). Sólo gobernaba
|
|
38
|
+
* dos heurísticas sobre `[code]` que resultaron ser falsas — osu no se come
|
|
39
|
+
* ningún salto ANTES de un bloque, y el resto de las reglas nunca estuvo
|
|
40
|
+
* condicionado. El espaciado ya no es opcional: Miliastry es "osu con
|
|
41
|
+
* esteroides" y rompe líneas igual, así que apagarlo no tendría a qué
|
|
42
|
+
* volver.
|
|
43
|
+
*
|
|
44
|
+
* Se conserva porque es API pública del renderer y quitarlo rompería a quien
|
|
45
|
+
* lo pase. Es candidato a borrarse en la próxima ruptura de versión.
|
|
46
|
+
*
|
|
47
|
+
* @deprecated Sin efecto. El espaciado de osu es ahora incondicional.
|
|
23
48
|
*/
|
|
24
49
|
osuBehaviour?: boolean
|
|
25
50
|
/** Registry for resolving custom tags */
|
|
@@ -44,19 +69,30 @@ export interface HTMLRendererOptions {
|
|
|
44
69
|
* timestamp as plain text. Used by map hosts that deep-link into an editor.
|
|
45
70
|
*/
|
|
46
71
|
timestampResolver?: (ms: number, label: string) => { href: string; external?: boolean } | null
|
|
72
|
+
/** Design tokens or resolver function */
|
|
73
|
+
tokens?: TokenSource
|
|
47
74
|
}
|
|
48
75
|
|
|
76
|
+
/** Saltos de línea a convertir en `<br>` al pintar un efecto. */
|
|
77
|
+
const NEWLINE_RE = /\n/g
|
|
78
|
+
|
|
79
|
+
/** Referencia a un token de diseño dentro de un texto: `$nombre`. */
|
|
80
|
+
const TOKEN_REF_RE = /\$([a-zA-Z_][a-zA-Z0-9_-]*)/g
|
|
81
|
+
|
|
49
82
|
export class HTMLRenderer extends Visitor<string> {
|
|
50
|
-
private
|
|
83
|
+
private tokenResolver?: TokenResolverFn
|
|
84
|
+
private options: Required<Omit<HTMLRendererOptions, 'registry' | 'mediaProxy' | 'entityLinkResolver' | 'mentionResolver' | 'timestampResolver' | 'tokens'>> & {
|
|
51
85
|
registry?: TagRegistry
|
|
52
86
|
mediaProxy?: (url: string) => string
|
|
53
87
|
entityLinkResolver?: (kind: string, value: string) => { href: string; external?: boolean } | null
|
|
54
88
|
mentionResolver?: (name: string) => { href: string; external?: boolean } | null
|
|
55
89
|
timestampResolver?: (ms: number, label: string) => { href: string; external?: boolean } | null
|
|
90
|
+
tokens?: TokenSource
|
|
56
91
|
}
|
|
57
92
|
|
|
58
93
|
constructor(options: HTMLRendererOptions = {}) {
|
|
59
94
|
super()
|
|
95
|
+
this.tokenResolver = toTokenResolver(options.tokens)
|
|
60
96
|
this.options = {
|
|
61
97
|
osuBehaviour: options.osuBehaviour ?? true,
|
|
62
98
|
registry: options.registry,
|
|
@@ -66,9 +102,19 @@ export class HTMLRenderer extends Visitor<string> {
|
|
|
66
102
|
entityLinkResolver: options.entityLinkResolver,
|
|
67
103
|
mentionResolver: options.mentionResolver,
|
|
68
104
|
timestampResolver: options.timestampResolver,
|
|
105
|
+
tokens: options.tokens,
|
|
69
106
|
}
|
|
70
107
|
}
|
|
71
108
|
|
|
109
|
+
setTokens(tokens?: TokenSource): void {
|
|
110
|
+
this.tokenResolver = toTokenResolver(tokens)
|
|
111
|
+
this.options.tokens = tokens
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
getTokenResolver(): TokenResolverFn | undefined {
|
|
115
|
+
return this.tokenResolver
|
|
116
|
+
}
|
|
117
|
+
|
|
72
118
|
// ─── Tag → HTML Element Map ─────────────────────────────
|
|
73
119
|
|
|
74
120
|
private readonly BLOCK_TAGS = new Set([
|
|
@@ -184,6 +230,25 @@ export class HTMLRenderer extends Visitor<string> {
|
|
|
184
230
|
*/
|
|
185
231
|
private tableDepth = 0
|
|
186
232
|
|
|
233
|
+
/**
|
|
234
|
+
* ¿Estamos emitiendo el vocabulario de clases de osu!?
|
|
235
|
+
*
|
|
236
|
+
* osu estiliza box, spoilerbox, notice, imagemap, youtube, los alineados y
|
|
237
|
+
* los perfiles POR NOMBRE DE CLASE, no por estilo inline. Sobre una userpage
|
|
238
|
+
* real el HTML de Quasar salía sin estilo porque emitía su propio
|
|
239
|
+
* vocabulario (`<details>`, `.notice`, `.imagemap-container`…). Bajo
|
|
240
|
+
* `dialect: 'osu'` se emiten las clases y la estructura de osu; el resto de
|
|
241
|
+
* dialectos conserva la suya, que es la que sus hojas de estilo esperan.
|
|
242
|
+
*/
|
|
243
|
+
private isOsu(): boolean {
|
|
244
|
+
return this.options.dialect === 'osu'
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** osu recorta los saltos pegados a la apertura y al cierre de box/notice. */
|
|
248
|
+
private static trimOsuEdges(html: string): string {
|
|
249
|
+
return html.replace(/^[\t ]*\r?\n/, '').replace(/\r?\n[\t ]*$/, '')
|
|
250
|
+
}
|
|
251
|
+
|
|
187
252
|
private idAttr(node: RedNode): string {
|
|
188
253
|
if (HTMLRenderer.idMode === 'none') return ''
|
|
189
254
|
if (HTMLRenderer.idMode === 'all') return ` data-node-id="${node.id}"`
|
|
@@ -203,6 +268,7 @@ export class HTMLRenderer extends Visitor<string> {
|
|
|
203
268
|
'notice', 'wnotice', 'spoilerbox', 'box', 'boxw', 'list', 'list_item', 'quote', 'code', 'svg',
|
|
204
269
|
'heading', 'center', 'right', 'left', 'align', 'imagemap', 'image', 'video', 'audio',
|
|
205
270
|
'tables', 'table_row', 'gallery', 'columns', 'separator', 'scroll', 'container',
|
|
271
|
+
'spacing', 'empty_line',
|
|
206
272
|
])
|
|
207
273
|
|
|
208
274
|
// ─── Main Entry ─────────────────────────────────────────
|
|
@@ -211,7 +277,22 @@ export class HTMLRenderer extends Visitor<string> {
|
|
|
211
277
|
return this.renderNode(node)
|
|
212
278
|
}
|
|
213
279
|
|
|
280
|
+
/**
|
|
281
|
+
* Ancho de cada tabla abierta, apilado igual que `tableDepth`.
|
|
282
|
+
*
|
|
283
|
+
* `getTableContext` corre una vez POR CELDA y recorría todas las filas de
|
|
284
|
+
* la tabla en cada llamada, con un `.filter()` —y su array— por fila: una
|
|
285
|
+
* tabla de 20×20 hacía 8.000 arrays intermedios por renderizado para
|
|
286
|
+
* llegar 400 veces al mismo número. Ahora se calcula una vez al entrar en
|
|
287
|
+
* la tabla. Va en una pila y no en un caché por nodo porque los hijos de un
|
|
288
|
+
* `RedNode` SON mutables (`appendChild`/`removeChild`), así que la
|
|
289
|
+
* identidad del nodo no garantiza que su estructura siga siendo la misma
|
|
290
|
+
* entre renderizados.
|
|
291
|
+
*/
|
|
292
|
+
private maxColsStack: number[] = []
|
|
293
|
+
|
|
214
294
|
render(root: RedNode): string {
|
|
295
|
+
this.maxColsStack.length = 0
|
|
215
296
|
return this.renderNode(root)
|
|
216
297
|
}
|
|
217
298
|
|
|
@@ -243,14 +324,26 @@ export class HTMLRenderer extends Visitor<string> {
|
|
|
243
324
|
private renderNode(node: RedNode): string {
|
|
244
325
|
// Leaf nodes
|
|
245
326
|
if (node.children.length === 0 && node.kind === 'text') {
|
|
327
|
+
let rawText = node.text
|
|
328
|
+
// El `indexOf` va delante: casi ningún texto lleva un `$`, y así ni se
|
|
329
|
+
// arranca el motor de expresiones regulares. El patrón es de módulo
|
|
330
|
+
// porque este es el nodo más frecuente del documento y un literal aquí
|
|
331
|
+
// dentro construía un `RegExp` por hoja, en cada renderizado.
|
|
332
|
+
if (this.tokenResolver !== undefined && rawText.indexOf('$') !== -1) {
|
|
333
|
+
const resolve = this.tokenResolver
|
|
334
|
+
rawText = rawText.replace(TOKEN_REF_RE, (match, name: string) => {
|
|
335
|
+
const resolved = resolve(name) ?? resolve(match)
|
|
336
|
+
return resolved !== undefined ? resolved : match
|
|
337
|
+
})
|
|
338
|
+
}
|
|
246
339
|
let out = this.mentionResolver || this.options.timestampResolver
|
|
247
|
-
? this.linkifyText(
|
|
248
|
-
: this.escapeHtml(
|
|
340
|
+
? this.linkifyText(rawText)
|
|
341
|
+
: this.escapeHtml(rawText)
|
|
249
342
|
const style = node.metadata?.style as Record<string, string> | undefined
|
|
250
343
|
if (style) {
|
|
251
344
|
const inlineStyles = []
|
|
252
|
-
const color = style.color &&
|
|
253
|
-
const fontSize = style.fontSize &&
|
|
345
|
+
const color = style.color && sanitizeColor(style.color, this.tokenResolver)
|
|
346
|
+
const fontSize = style.fontSize && sanitizeFontSize(style.fontSize, this.tokenResolver)
|
|
254
347
|
if (color) inlineStyles.push(`color: ${color}`)
|
|
255
348
|
if (fontSize) inlineStyles.push(`font-size: ${fontSize}%`)
|
|
256
349
|
if (this.isCssKeyword(style.fontWeight)) inlineStyles.push(`font-weight: ${style.fontWeight}`)
|
|
@@ -277,7 +370,7 @@ export class HTMLRenderer extends Visitor<string> {
|
|
|
277
370
|
case 'strikethrough': return this.wrapInline('s', node)
|
|
278
371
|
case 'inline_code': return this.wrapInline('code', node, 'class="inline"')
|
|
279
372
|
case 'spoiler': return this.wrapInline('span', node, 'class="spoiler"')
|
|
280
|
-
case 'color': return this.
|
|
373
|
+
case 'color': return this.renderColor(node)
|
|
281
374
|
case 'font_size': return this.wrapInline('span', node, this.fontSizeStyle(node))
|
|
282
375
|
case 'font': return this.wrapInline('span', node, this.fontStyle(node))
|
|
283
376
|
case 'url': return this.renderLink(node, 'url')
|
|
@@ -286,10 +379,18 @@ export class HTMLRenderer extends Visitor<string> {
|
|
|
286
379
|
case 'image': return this.renderImage(node)
|
|
287
380
|
case 'video': return this.renderVideo(node)
|
|
288
381
|
case 'audio': return this.renderAudio(node)
|
|
289
|
-
case 'center': return this.
|
|
290
|
-
case 'right': return this.
|
|
291
|
-
case 'left': return this.
|
|
292
|
-
|
|
382
|
+
case 'center': return this.renderAlignAs(node, 'center')
|
|
383
|
+
case 'right': return this.renderAlignAs(node, 'right')
|
|
384
|
+
case 'left': return this.renderAlignAs(node, 'left')
|
|
385
|
+
// Sigue siendo siempre `h2`, como antes: el nivel de BBCode no mapea al
|
|
386
|
+
// de HTML y un `[heading=9]` daría un `<h9>` inválido. `data-bare-level`
|
|
387
|
+
// sólo anota que el 2 lo puso el renderer, para que el camino de vuelta
|
|
388
|
+
// no escriba `[heading=2]` donde el autor había puesto `[heading]`.
|
|
389
|
+
case 'heading': return this.wrapBlock(
|
|
390
|
+
'h2',
|
|
391
|
+
node,
|
|
392
|
+
node.metadata?.level === undefined ? 'data-bare-level="1"' : '',
|
|
393
|
+
)
|
|
293
394
|
case 'notice': return this.renderNotice(node, false)
|
|
294
395
|
case 'wnotice': return this.renderNotice(node, true)
|
|
295
396
|
case 'quote': return this.renderQuote(node)
|
|
@@ -363,14 +464,18 @@ export class HTMLRenderer extends Visitor<string> {
|
|
|
363
464
|
case 'bubble': return this.wrapInline('span', node, 'class="bubble"')
|
|
364
465
|
case 'flower': return this.wrapInline('span', node, 'class="flower"')
|
|
365
466
|
case 'gradient': return this.renderGradient(node)
|
|
467
|
+
// These three had no case at all, so `[rainbow]Hi[/rainbow]` previewed
|
|
468
|
+
// as bare text while its export was a full colour sweep.
|
|
469
|
+
case 'rainbow': return this.renderEffectSegments(node, 'rainbow')
|
|
470
|
+
case 'grow': return this.renderEffectSegments(node, 'grow')
|
|
471
|
+
case 'sinewave': return this.renderEffectSegments(node, 'sinewave')
|
|
472
|
+
case 'paint': return this.renderEffectSegments(node, 'paint')
|
|
366
473
|
case 'spacing':
|
|
367
|
-
if (this.
|
|
368
|
-
|
|
369
|
-
return this.isPrevBlockBoundary(node) ? '\n' : '<br>'
|
|
474
|
+
if (this.isNewlineSwallowed(node)) return '\n'
|
|
475
|
+
return `<br${this.idAttr(node)}>`
|
|
370
476
|
case 'empty_line':
|
|
371
|
-
if (this.
|
|
372
|
-
|
|
373
|
-
return '<br>'
|
|
477
|
+
if (this.isNewlineSwallowed(node)) return '\n'
|
|
478
|
+
return `<div class="bb-empty-line"${this.idAttr(node)}><br></div>`
|
|
374
479
|
case 'group': return this.wrapInline('span', node, 'class="group"')
|
|
375
480
|
// Un párrafo no tiene etiqueta propia en BBCode, pero sí necesita un
|
|
376
481
|
// elemento: sin él, la prosa suelta entre bloques no es clicable —
|
|
@@ -378,6 +483,9 @@ export class HTMLRenderer extends Visitor<string> {
|
|
|
378
483
|
// Un `span` es inline, así que no altera el flujo del texto.
|
|
379
484
|
case 'paragraph': return this.wrapInline('span', node, 'class="bb-paragraph"')
|
|
380
485
|
case 'error': return this.renderError(node)
|
|
486
|
+
// osu! no muestra un cierre que no cerró nada; el nodo existe solo para
|
|
487
|
+
// que su rango siga perteneciendo a alguien.
|
|
488
|
+
case 'discarded_tag': return ''
|
|
381
489
|
case 'text':
|
|
382
490
|
return this.textWrap(node, this.escapeHtml(node.text || ''))
|
|
383
491
|
default:
|
|
@@ -401,65 +509,224 @@ export class HTMLRenderer extends Visitor<string> {
|
|
|
401
509
|
}
|
|
402
510
|
}
|
|
403
511
|
|
|
404
|
-
// ───
|
|
512
|
+
// ─── Newline swallowing ─────────────────────────────────
|
|
513
|
+
//
|
|
514
|
+
// osu! turns newlines into `<br />` with one flat rule at the very end of
|
|
515
|
+
// `BBCodeFromDB::toHTML` — `str_replace("\n", '<br />')`. Every subtlety
|
|
516
|
+
// lives BEFORE that line: each block pass is a regex that eats the newlines
|
|
517
|
+
// touching its own tags, so those newlines are simply gone by the time the
|
|
518
|
+
// flat rule runs. The amount eaten differs per tag, and the asymmetries are
|
|
519
|
+
// not decorative:
|
|
520
|
+
//
|
|
521
|
+
// parseBox `\[box=…\]\n*` `\n*\[/box\]\n?`
|
|
522
|
+
// parseCode `\[code\]\n*` `\n*\[/code\]\n?`
|
|
523
|
+
// parseNotice `\[notice\]\n*` `\n*\[/notice\]\n?`
|
|
524
|
+
// parseList `\s*\[\*\]` `\s*\[/list\]\n?\n?`
|
|
525
|
+
// parseQuote `\[quote…\]\s*` `\s*\[/quote\]\n?\n?`
|
|
526
|
+
// parseHeading — `\[/heading\]\n?`
|
|
527
|
+
// parseImagemap — `\[/imagemap\]\n?`
|
|
528
|
+
// parseAlignment strtr of `[centre]\n` and `[/centre]\n` — exactly one
|
|
529
|
+
//
|
|
530
|
+
// Quasar used to approximate all of that with two neighbourhood heuristics
|
|
531
|
+
// (`isPrevBlockBoundary` / `isTrailingBlockBoundary`) that treated every
|
|
532
|
+
// block alike, so they over-ate at `[centre]`/`[/imagemap]` and under-ate at
|
|
533
|
+
// `[/list]`/`[/quote]`. This models the real rules instead.
|
|
534
|
+
//
|
|
535
|
+
// Deliberately NOT gated on the dialect: Miliastry is "osu with steroids"
|
|
536
|
+
// and has to break lines the same way. Blocks that only exist in Miliastry
|
|
537
|
+
// (tables, gallery, columns, scroll, …) have no osu counterpart to copy, so
|
|
538
|
+
// they keep the legacy behaviour via {@link HTMLRenderer.LEGACY_BLOCK_RULE}.
|
|
539
|
+
|
|
540
|
+
/** How many newlines a construct swallows around its own tags. */
|
|
541
|
+
private static readonly NEWLINE_RULES: Record<string, {
|
|
542
|
+
/** Newlines eaten right after the opening tag. */
|
|
543
|
+
afterOpen: 'all' | 'whitespace' | 'one' | 'none'
|
|
544
|
+
/** Newlines eaten right before the closing tag. */
|
|
545
|
+
beforeClose: 'all' | 'whitespace' | 'none'
|
|
546
|
+
/** Whitespace eaten right before the OPENING tag (`\s*\[\*\]`). */
|
|
547
|
+
beforeOpen: 'whitespace' | 'none'
|
|
548
|
+
/** Newlines eaten right after the closing tag. */
|
|
549
|
+
afterClose: number
|
|
550
|
+
}> = {
|
|
551
|
+
// `\n*` inside both edges, one newline after the close.
|
|
552
|
+
box: { afterOpen: 'all', beforeClose: 'all', beforeOpen: 'none', afterClose: 1 },
|
|
553
|
+
boxw: { afterOpen: 'all', beforeClose: 'all', beforeOpen: 'none', afterClose: 1 },
|
|
554
|
+
spoilerbox: { afterOpen: 'all', beforeClose: 'all', beforeOpen: 'none', afterClose: 1 },
|
|
555
|
+
notice: { afterOpen: 'all', beforeClose: 'all', beforeOpen: 'none', afterClose: 1 },
|
|
556
|
+
wnotice: { afterOpen: 'all', beforeClose: 'all', beforeOpen: 'none', afterClose: 1 },
|
|
557
|
+
code: { afterOpen: 'all', beforeClose: 'all', beforeOpen: 'none', afterClose: 1 },
|
|
558
|
+
// `\s*` — not just newlines — and TWO newlines after the close.
|
|
559
|
+
quote: { afterOpen: 'whitespace', beforeClose: 'whitespace', beforeOpen: 'none', afterClose: 2 },
|
|
560
|
+
// `[list]` itself eats nothing after its opening tag: the pass that eats
|
|
561
|
+
// is `\s*\[\*\]`, which needs an item to follow. `[list]\n\nloose text`
|
|
562
|
+
// keeps both newlines; `[list]\n[*]a` loses one to the item, not the list.
|
|
563
|
+
list: { afterOpen: 'none', beforeClose: 'whitespace', beforeOpen: 'none', afterClose: 2 },
|
|
564
|
+
// `\s*\[\*\]`. The matching `[/*]` of the table exists only in legacy
|
|
565
|
+
// phpBB rows — `BBCodeForDB` never emits one — so the item's close is
|
|
566
|
+
// width-less here and its two-newline budget is unreachable by design;
|
|
567
|
+
// `[*]a\n\n[*]b` loses both newlines to the NEXT item's `\s*`, which is
|
|
568
|
+
// the same output by a different route.
|
|
569
|
+
list_item: { afterOpen: 'none', beforeClose: 'none', beforeOpen: 'whitespace', afterClose: 0 },
|
|
570
|
+
// strtr with `[centre]\n` / `[/centre]\n`: exactly one on each outer edge,
|
|
571
|
+
// and nothing before the close — `x\n[/centre]` really does keep its `<br>`.
|
|
572
|
+
center: { afterOpen: 'one', beforeClose: 'none', beforeOpen: 'none', afterClose: 1 },
|
|
573
|
+
left: { afterOpen: 'one', beforeClose: 'none', beforeOpen: 'none', afterClose: 1 },
|
|
574
|
+
right: { afterOpen: 'one', beforeClose: 'none', beforeOpen: 'none', afterClose: 1 },
|
|
575
|
+
align: { afterOpen: 'one', beforeClose: 'none', beforeOpen: 'none', afterClose: 1 },
|
|
576
|
+
heading: { afterOpen: 'none', beforeClose: 'none', beforeOpen: 'none', afterClose: 1 },
|
|
577
|
+
imagemap: { afterOpen: 'none', beforeClose: 'none', beforeOpen: 'none', afterClose: 1 },
|
|
578
|
+
// `[img]` is inline in osu and swallows nothing at all.
|
|
579
|
+
image: { afterOpen: 'none', beforeClose: 'none', beforeOpen: 'none', afterClose: 0 },
|
|
580
|
+
document: { afterOpen: 'none', beforeClose: 'none', beforeOpen: 'none', afterClose: 0 },
|
|
581
|
+
}
|
|
405
582
|
|
|
406
|
-
/**
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
583
|
+
/**
|
|
584
|
+
* What a Miliastry-only block does. This is what the old
|
|
585
|
+
* `isPrevBlockBoundary` / `isTrailingBlockBoundary` pair did for every block:
|
|
586
|
+
* eat the first newline after the open, every newline before the close, and
|
|
587
|
+
* the first newline after the close.
|
|
588
|
+
*/
|
|
589
|
+
private static readonly LEGACY_BLOCK_RULE = {
|
|
590
|
+
afterOpen: 'one', beforeClose: 'all', beforeOpen: 'none', afterClose: 1,
|
|
591
|
+
} as const
|
|
592
|
+
|
|
593
|
+
/**
|
|
594
|
+
* Containers whose opening tag occupies no source text, so a backwards scan
|
|
595
|
+
* has to walk straight through them.
|
|
596
|
+
*/
|
|
597
|
+
private static readonly WIDTHLESS_OPEN = new Set(['paragraph', 'group'])
|
|
598
|
+
|
|
599
|
+
/**
|
|
600
|
+
* Same for the closing side. `list_item` is here because `[/*]` is never
|
|
601
|
+
* written: an item ends where the next `[*]` or the `[/list]` begins, so
|
|
602
|
+
* `\s*\[/list\]` sees the newline that Quasar stores inside the item.
|
|
603
|
+
*/
|
|
604
|
+
private static readonly WIDTHLESS_CLOSE = new Set(['paragraph', 'group', 'list_item'])
|
|
605
|
+
|
|
606
|
+
private newlineRule(kind: string) {
|
|
607
|
+
const rule = HTMLRenderer.NEWLINE_RULES[kind]
|
|
608
|
+
if (rule) return rule
|
|
609
|
+
return this.BLOCK_TAGS.has(kind) ? HTMLRenderer.LEGACY_BLOCK_RULE : null
|
|
413
610
|
}
|
|
414
611
|
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
let next = node.nextSibling
|
|
418
|
-
while (next && next.kind === 'spacing') {
|
|
419
|
-
next = next.nextSibling
|
|
420
|
-
}
|
|
421
|
-
return next?.kind === 'code'
|
|
612
|
+
private static isNewlineNode(node: RedNode): boolean {
|
|
613
|
+
return node.kind === 'spacing' || node.kind === 'empty_line'
|
|
422
614
|
}
|
|
423
615
|
|
|
424
|
-
private
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
616
|
+
private static isBlankText(node: RedNode): boolean {
|
|
617
|
+
return node.kind === 'text' && node.children.length === 0 && node.text.trim() === ''
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
/**
|
|
621
|
+
* Whether this `spacing` / `empty_line` leaf is eaten by a neighbouring tag
|
|
622
|
+
* and therefore renders nothing.
|
|
623
|
+
*
|
|
624
|
+
* Each leaf is exactly ONE source newline (the parser splits a run into one
|
|
625
|
+
* node per `\n`), so the four scans below can be read straight off the
|
|
626
|
+
* regexes they mirror. A newline eaten by any of them is eaten: osu's passes
|
|
627
|
+
* run in a fixed order, but since a consumed newline is consumed whichever
|
|
628
|
+
* pass claimed it, the union is enough — the per-pass order only matters for
|
|
629
|
+
* a budget that could be spent elsewhere, and budgets here are counted from
|
|
630
|
+
* the tag outwards, exactly as `\n?\n?` counts.
|
|
631
|
+
*/
|
|
632
|
+
private isNewlineSwallowed(node: RedNode): boolean {
|
|
633
|
+
return this.eatenByOpeningTag(node)
|
|
634
|
+
|| this.eatenByClosingTag(node)
|
|
635
|
+
|| this.eatenAfterClosingTag(node)
|
|
636
|
+
|| this.eatenBeforeOpeningTag(node)
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
/** `\[box\]\n*`, `\[quote\]\s*`, `[centre]\n`. */
|
|
640
|
+
private eatenByOpeningTag(node: RedNode): boolean {
|
|
641
|
+
let cur: RedNode = node
|
|
642
|
+
let newlinesBetween = 0
|
|
643
|
+
let blankBetween = false
|
|
644
|
+
for (;;) {
|
|
645
|
+
const prev = cur.previousSibling
|
|
646
|
+
if (prev) {
|
|
647
|
+
if (HTMLRenderer.isNewlineNode(prev)) { newlinesBetween++; cur = prev; continue }
|
|
648
|
+
if (HTMLRenderer.isBlankText(prev)) { blankBetween = true; cur = prev; continue }
|
|
649
|
+
return false
|
|
430
650
|
}
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
651
|
+
const parent = cur.parent
|
|
652
|
+
if (!parent) return false
|
|
653
|
+
if (HTMLRenderer.WIDTHLESS_OPEN.has(parent.kind)) { cur = parent; continue }
|
|
654
|
+
const rule = this.newlineRule(parent.kind)
|
|
655
|
+
if (!rule) return false
|
|
656
|
+
switch (rule.afterOpen) {
|
|
657
|
+
case 'whitespace': return true
|
|
658
|
+
// `\n*` matches newlines only: a stray space breaks the run.
|
|
659
|
+
case 'all': return !blankBetween
|
|
660
|
+
case 'one': return !blankBetween && newlinesBetween === 0
|
|
661
|
+
default: return false
|
|
434
662
|
}
|
|
435
|
-
break
|
|
436
663
|
}
|
|
437
|
-
|
|
438
|
-
if (prev && this.BLOCK_TAGS.has(prev.kind) && prev.kind !== 'image' && prev.kind !== 'imagemap') return true
|
|
439
|
-
if (!prev && node.parent && this.BLOCK_TAGS.has(node.parent.kind) && node.parent.kind !== 'image' && node.parent.kind !== 'imagemap') return true
|
|
664
|
+
}
|
|
440
665
|
|
|
441
|
-
|
|
666
|
+
/** `\n*\[/box\]`, `\s*\[/quote\]`, `\s*\[/list\]`. */
|
|
667
|
+
private eatenByClosingTag(node: RedNode): boolean {
|
|
668
|
+
let cur: RedNode = node
|
|
669
|
+
let blankBetween = false
|
|
670
|
+
for (;;) {
|
|
671
|
+
const next = cur.nextSibling
|
|
672
|
+
if (next) {
|
|
673
|
+
if (HTMLRenderer.isNewlineNode(next)) { cur = next; continue }
|
|
674
|
+
if (HTMLRenderer.isBlankText(next)) { blankBetween = true; cur = next; continue }
|
|
675
|
+
return false
|
|
676
|
+
}
|
|
677
|
+
const parent = cur.parent
|
|
678
|
+
if (!parent) return false
|
|
679
|
+
if (HTMLRenderer.WIDTHLESS_CLOSE.has(parent.kind)) { cur = parent; continue }
|
|
680
|
+
const rule = this.newlineRule(parent.kind)
|
|
681
|
+
if (!rule) return false
|
|
682
|
+
switch (rule.beforeClose) {
|
|
683
|
+
case 'whitespace': return true
|
|
684
|
+
case 'all': return !blankBetween
|
|
685
|
+
default: return false
|
|
686
|
+
}
|
|
687
|
+
}
|
|
442
688
|
}
|
|
443
689
|
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
690
|
+
/** `\[/box\]\n?`, `\[/list\]\n?\n?`. */
|
|
691
|
+
private eatenAfterClosingTag(node: RedNode): boolean {
|
|
692
|
+
let cur: RedNode = node
|
|
693
|
+
let newlinesBetween = 0
|
|
694
|
+
for (;;) {
|
|
695
|
+
const prev = cur.previousSibling
|
|
696
|
+
if (!prev) {
|
|
697
|
+
const parent = cur.parent
|
|
698
|
+
if (parent && HTMLRenderer.WIDTHLESS_OPEN.has(parent.kind)) { cur = parent; continue }
|
|
699
|
+
return false
|
|
450
700
|
}
|
|
451
|
-
if (
|
|
452
|
-
|
|
453
|
-
|
|
701
|
+
if (HTMLRenderer.isNewlineNode(prev)) { newlinesBetween++; cur = prev; continue }
|
|
702
|
+
// Descend to whatever real closing tag sits immediately to our left.
|
|
703
|
+
let closer: RedNode = prev
|
|
704
|
+
while (HTMLRenderer.WIDTHLESS_CLOSE.has(closer.kind) && closer.children.length > 0) {
|
|
705
|
+
closer = closer.children[closer.children.length - 1]
|
|
454
706
|
}
|
|
455
|
-
|
|
707
|
+
const rule = this.newlineRule(closer.kind)
|
|
708
|
+
return rule !== null && newlinesBetween < rule.afterClose
|
|
456
709
|
}
|
|
457
|
-
|
|
458
|
-
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
/** `\s*\[\*\]` — the only pass that eats whitespace BEFORE an opening tag. */
|
|
713
|
+
private eatenBeforeOpeningTag(node: RedNode): boolean {
|
|
714
|
+
let cur: RedNode = node
|
|
715
|
+
for (;;) {
|
|
716
|
+
const next = cur.nextSibling
|
|
717
|
+
if (next) {
|
|
718
|
+
if (HTMLRenderer.isNewlineNode(next) || HTMLRenderer.isBlankText(next)) { cur = next; continue }
|
|
719
|
+
return this.newlineRule(next.kind)?.beforeOpen === 'whitespace'
|
|
720
|
+
}
|
|
721
|
+
const parent = cur.parent
|
|
722
|
+
if (!parent) return false
|
|
723
|
+
if (HTMLRenderer.WIDTHLESS_CLOSE.has(parent.kind)) { cur = parent; continue }
|
|
724
|
+
return false
|
|
459
725
|
}
|
|
460
|
-
return false
|
|
461
726
|
}
|
|
462
727
|
|
|
728
|
+
// ─── Render Helpers ─────────────────────────────────────
|
|
729
|
+
|
|
463
730
|
private renderError(node: RedNode): string {
|
|
464
731
|
const errorMsg = this.escapeHtml((node.metadata?.message as string) || node.text || 'Syntax Error')
|
|
465
732
|
// The content is the raw offending tag, mapped as child text.
|
|
@@ -489,21 +756,9 @@ export class HTMLRenderer extends Visitor<string> {
|
|
|
489
756
|
// half-escaped value still emits broken CSS, so we validate the shape and
|
|
490
757
|
// drop anything that isn't a value we intended to support.
|
|
491
758
|
|
|
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
759
|
/** Characters `escapeHtml` has to rewrite. Non-global on purpose: `test` must not carry `lastIndex`. */
|
|
503
760
|
private static readonly HTML_ESCAPE_RE = /[&<>"']/
|
|
504
761
|
|
|
505
|
-
private static readonly BARE_HEX_RE = /^[0-9a-f]{3,8}$/i
|
|
506
|
-
|
|
507
762
|
private static readonly NAMED_COLORS: Record<string, string> = {
|
|
508
763
|
black: '#000000',
|
|
509
764
|
white: '#FFFFFF',
|
|
@@ -525,88 +780,84 @@ export class HTMLRenderer extends Visitor<string> {
|
|
|
525
780
|
gold: '#FFD700',
|
|
526
781
|
}
|
|
527
782
|
|
|
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
783
|
/** Keyword-or-number CSS values (font-weight, font-style, text-decoration). */
|
|
548
784
|
private isCssKeyword(raw: string | undefined): boolean {
|
|
549
785
|
return !!raw && /^[a-z]{2,20}(?: [a-z]{2,20})?$|^[1-9]00$/i.test(raw.trim())
|
|
550
786
|
}
|
|
551
787
|
|
|
552
788
|
/** Read a metadata field, falling back to the raw tag attribute. */
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
789
|
+
|
|
790
|
+
/**
|
|
791
|
+
* Lo único que osu! acepta en `[color=…]`.
|
|
792
|
+
*
|
|
793
|
+
* Su `BBCodeForDB::parseColour` sella el tag con un uid sólo si el valor
|
|
794
|
+
* matchea `#[[:xdigit:]]{6}` o `[[:alpha:]]+` — nada más. No valida que el
|
|
795
|
+
* nombre sea un color CSS de verdad (`banana` pasa), pero `#fff`, `#ffffffff`,
|
|
796
|
+
* `rgb(...)`, `$token` o un hex sin `#` no pasan. Sin uid, la segunda pasada
|
|
797
|
+
* no ve el tag y el opener *y* el closer quedan como texto en la página.
|
|
798
|
+
*/
|
|
799
|
+
private static readonly OSU_COLOR_RE = /^(?:#[0-9a-fA-F]{6}|[a-zA-Z]+)$/
|
|
800
|
+
|
|
801
|
+
/**
|
|
802
|
+
* `[color]` con el vocabulario de cada dialecto.
|
|
803
|
+
*
|
|
804
|
+
* Miliastry (y Lyne) aceptan a propósito más que osu: `#RGB`, `#RGBA`, un
|
|
805
|
+
* `$token` de diseño, nombres propios. Bajo `dialect: 'osu'` eso es una
|
|
806
|
+
* mentira: el editor pintaría color donde la página publicada muestra el
|
|
807
|
+
* BBCode crudo. Así que replicamos lo que hace osu — literal el opener,
|
|
808
|
+
* literal el closer, y los hijos renderizados normalmente en el medio.
|
|
809
|
+
*
|
|
810
|
+
* El chequeo mira el texto crudo del atributo, no el valor saneado: osu
|
|
811
|
+
* matchea sobre la fuente, así que `[color="#ffffff"]` (con comillas) también
|
|
812
|
+
* se le escapa.
|
|
813
|
+
*/
|
|
814
|
+
private renderColor(node: RedNode): string {
|
|
815
|
+
if (this.options.dialect === 'osu') {
|
|
816
|
+
const text = node.text || ''
|
|
817
|
+
const eq = text.indexOf('=')
|
|
818
|
+
// Sin `=` en el texto el nodo no vino del parser de BBCode (import de
|
|
819
|
+
// HTML, por ejemplo): ahí el único valor disponible es el de metadata.
|
|
820
|
+
const raw = eq >= 0 ? text.slice(eq + 1) : (text ? '' : nodeAttrValue(node, 'color'))
|
|
821
|
+
if (!HTMLRenderer.OSU_COLOR_RE.test(raw)) {
|
|
822
|
+
const opener = eq >= 0 ? `[color${text}]` : `[${text || 'color'}]`
|
|
823
|
+
return this.escapeHtml(opener) + this.renderChildren(node) + this.escapeHtml('[/color]')
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
return this.wrapInline('span', node, this.colorStyle(node))
|
|
556
827
|
}
|
|
557
828
|
|
|
558
829
|
private colorStyle(node: RedNode): string {
|
|
559
|
-
const color =
|
|
830
|
+
const color = sanitizeColor(nodeAttrValue(node, 'color'), this.tokenResolver)
|
|
560
831
|
return color ? `style="color:${color};"` : ''
|
|
561
832
|
}
|
|
562
833
|
|
|
563
834
|
private fontSizeStyle(node: RedNode): string {
|
|
564
|
-
const size =
|
|
835
|
+
const size = sanitizeFontSize(nodeAttrValue(node, 'size'), this.tokenResolver)
|
|
565
836
|
return size ? `style="font-size:${size}%;"` : ''
|
|
566
837
|
}
|
|
567
838
|
|
|
568
839
|
private fontStyle(node: RedNode): string {
|
|
569
|
-
const font =
|
|
840
|
+
const font = sanitizeFontFamily(nodeAttrValue(node, 'font'), this.tokenResolver)
|
|
570
841
|
return font ? `style="font-family:${font};"` : ''
|
|
571
842
|
}
|
|
572
843
|
|
|
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
844
|
private renderLink(node: RedNode, kind: string): string {
|
|
602
|
-
let href = String(node.metadata?.href ?? '') ||
|
|
845
|
+
let href = String(node.metadata?.href ?? '') || nodeAttrValue(node)
|
|
846
|
+
// Un `[email]a@b.com[/email]` llega con href exactamente `mailto:` y la
|
|
847
|
+
// dirección en el hijo de texto. Sin completarlo aquí el href quedaba
|
|
848
|
+
// vacío y el round-trip devolvía `[url=mailto:]a@b.com[/url]`.
|
|
849
|
+
if (kind === 'email' && href === 'mailto:') {
|
|
850
|
+
href += node.children.map(c => c.text || '').join('')
|
|
851
|
+
}
|
|
603
852
|
// Ensure the URL has a protocol for external links
|
|
604
853
|
if (href && !href.startsWith('http://') && !href.startsWith('https://') && !href.startsWith('mailto:')) {
|
|
605
854
|
href = 'https://' + href
|
|
606
855
|
}
|
|
607
856
|
const content = this.renderChildren(node) || href
|
|
608
857
|
const h = href ? ` href="${this.escapeHtml(href)}"` : ''
|
|
609
|
-
|
|
858
|
+
const hasMediaChild = node.children.some(c => c.kind === 'image' || c.kind === 'svg' || c.kind === 'video')
|
|
859
|
+
const imgCls = hasMediaChild ? ' class="bb-link-img"' : ''
|
|
860
|
+
return `<a${this.idAttr(node)}${imgCls}${h} target="_blank" rel="noopener">${content}</a>`
|
|
610
861
|
}
|
|
611
862
|
|
|
612
863
|
private renderProfile(node: RedNode): string {
|
|
@@ -615,48 +866,64 @@ export class HTMLRenderer extends Visitor<string> {
|
|
|
615
866
|
|
|
616
867
|
private renderEntity(node: RedNode, type: 'profile' | 'guild' | 'map'): string {
|
|
617
868
|
const key = type === 'profile' ? 'username' : type === 'guild' ? 'tag' : 'id'
|
|
618
|
-
const val = String(node.metadata?.[key] ?? '') ||
|
|
869
|
+
const val = String(node.metadata?.[key] ?? '') || nodeAttrValue(node)
|
|
619
870
|
const content = this.renderChildren(node) || val
|
|
871
|
+
// El converter HTML→BBCode ve `<strong><a>`, no la etiqueta original. Sin
|
|
872
|
+
// esta marca `[profile]peppy[/profile]` volvía como `[b][url=...]…[/url][/b]`,
|
|
873
|
+
// y sin el valor `[profile=5458323]` perdía el id y volvía como `[profile]`.
|
|
874
|
+
const entity = ` data-entity="${type}"` +
|
|
875
|
+
(val ? ` data-entity-value="${this.escapeHtml(val)}"` : '')
|
|
620
876
|
if (this.options.entityLinkResolver) {
|
|
621
877
|
const link = this.options.entityLinkResolver(type, val || content)
|
|
622
878
|
if (link) {
|
|
623
879
|
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>`
|
|
880
|
+
return `<strong${entity}><a${this.idAttr(node)} href="${this.escapeHtml(link.href)}"${ext}>${content}</a></strong>`
|
|
625
881
|
}
|
|
626
882
|
}
|
|
883
|
+
if (type === 'profile' && this.isOsu()) {
|
|
884
|
+
// Sacado de los fixtures `basic_profile*` de osu-web, no adivinado: el
|
|
885
|
+
// enlace es un `<a>` pelado (nada de `<strong>`), y `data-user-id` lleva
|
|
886
|
+
// el id numérico cuando `[profile=N]` lo trae y `@` + el texto crudo
|
|
887
|
+
// cuando no. El href es ese mismo valor, URL-encoded.
|
|
888
|
+
const key = val || `@${this.collectNodeText(node)}`
|
|
889
|
+
const href = `https://osu.ppy.sh/users/${encodeURIComponent(key)}`
|
|
890
|
+
return `<a${this.idAttr(node)}${entity} class="user-name js-usercard" data-user-id="${this.escapeHtml(key)}" href="${href}">${content}</a>`
|
|
891
|
+
}
|
|
627
892
|
if (type === 'profile') {
|
|
628
893
|
const url = this.options.theme === 'lyne' || this.options.dialect === 'lyne'
|
|
629
894
|
? `/u/${encodeURIComponent(val || content)}`
|
|
630
895
|
: `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>`
|
|
896
|
+
return `<strong${entity}><a${this.idAttr(node)} href="${url}" target="_blank" rel="noopener">${content}</a></strong>`
|
|
632
897
|
}
|
|
633
898
|
if (type === 'guild') {
|
|
634
|
-
return `<strong><a${this.idAttr(node)} href="/guilds/${encodeURIComponent(val || content)}">${content}</a></strong>`
|
|
899
|
+
return `<strong${entity}><a${this.idAttr(node)} href="/guilds/${encodeURIComponent(val || content)}">${content}</a></strong>`
|
|
635
900
|
}
|
|
636
901
|
if (type === 'map') {
|
|
637
|
-
return `<strong><a${this.idAttr(node)} href="/maps/${encodeURIComponent(val || content)}">${content}</a></strong>`
|
|
902
|
+
return `<strong${entity}><a${this.idAttr(node)} href="/maps/${encodeURIComponent(val || content)}">${content}</a></strong>`
|
|
638
903
|
}
|
|
639
|
-
return `<strong><a${this.idAttr(node)} href="#">${content}</a></strong>`
|
|
904
|
+
return `<strong${entity}><a${this.idAttr(node)} href="#">${content}</a></strong>`
|
|
640
905
|
}
|
|
641
906
|
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
907
|
+
/**
|
|
908
|
+
* El aviso que ocupa el sitio de un medio que no se puede pintar.
|
|
909
|
+
*
|
|
910
|
+
* Lleva `data-bb-empty` con la etiqueta de origen porque el camino
|
|
911
|
+
* HTML→BBCode lee de vuelta lo que hay en el lienzo: sin la marca, el texto
|
|
912
|
+
* del aviso entraba al documento como contenido y se comía la etiqueta. En
|
|
913
|
+
* `docs/ai/hxovc.bbcode`, con cinco `[img][/img]` vacíos, cada edición del
|
|
914
|
+
* bloque convertía otro en la frase «[img] missing source URL».
|
|
915
|
+
*/
|
|
916
|
+
private mediaError(tag: string, message: string): string {
|
|
917
|
+
return `<div class="media-error" data-bb-empty="${tag}">${message}</div>`
|
|
651
918
|
}
|
|
652
919
|
|
|
653
920
|
private renderImage(node: RedNode): string {
|
|
654
|
-
let src = String(node.metadata?.src ?? '') ||
|
|
655
|
-
if (!src) return '
|
|
921
|
+
let src = String(node.metadata?.src ?? '') || nodeAttrValue(node) || ''
|
|
922
|
+
if (!src) return this.mediaError('img', '[img] missing source URL')
|
|
656
923
|
if (this.options.mediaProxy) {
|
|
657
924
|
src = this.options.mediaProxy(src)
|
|
658
925
|
}
|
|
659
|
-
const imgAttr =
|
|
926
|
+
const imgAttr = parseImgAttr(nodeAttrValue(node))
|
|
660
927
|
const styles: string[] = []
|
|
661
928
|
if (imgAttr.w) styles.push(`width:${imgAttr.w}px`)
|
|
662
929
|
if (imgAttr.h) styles.push(`height:${imgAttr.h}px`)
|
|
@@ -671,15 +938,25 @@ export class HTMLRenderer extends Visitor<string> {
|
|
|
671
938
|
styles.push('max-width:100%', 'height:auto', 'display:inline-block')
|
|
672
939
|
}
|
|
673
940
|
const cls = imgAttr.round ? '' : ' class="bb-img"'
|
|
674
|
-
|
|
941
|
+
// El modificador no se puede reconstruir desde el `style` resultante
|
|
942
|
+
// (`round` y `120x120` producen el mismo CSS), así que viaja literal.
|
|
943
|
+
const rawAttr = nodeAttrValue(node)
|
|
944
|
+
const attr = rawAttr ? ` data-img-attr="${this.escapeHtml(rawAttr)}"` : ''
|
|
945
|
+
return `<img${this.idAttr(node)}${cls}${attr} src="${this.escapeHtml(src)}" alt="" style="${styles.join(';')};">`
|
|
675
946
|
}
|
|
676
947
|
|
|
677
948
|
private renderVideo(node: RedNode): string {
|
|
678
|
-
let id = String(node.metadata?.videoId ?? '') ||
|
|
679
|
-
if (!id) return '
|
|
949
|
+
let id = String(node.metadata?.videoId ?? '') || nodeAttrValue(node) || ''
|
|
950
|
+
if (!id) return this.mediaError('youtube', '[youtube] missing video ID')
|
|
680
951
|
const ytMatch = /(?:youtu\.be\/|v=|\/embed\/|\/shorts\/)([\w-]{11})/.exec(id)
|
|
681
952
|
if (ytMatch) id = ytMatch[1]
|
|
682
|
-
|
|
953
|
+
// `data-youtube` es lo que deja al converter HTML→BBCode reconocer este
|
|
954
|
+
// iframe. Sin él el vídeo volvía como un `group` vacío: se perdía entero.
|
|
955
|
+
// Las clases de osu van en el iframe mismo, no en un div contenedor: son
|
|
956
|
+
// las que le dan la caja 16:9. Y su `src` lleva siempre `?rel=0`.
|
|
957
|
+
const cls = this.isOsu() ? 'u-embed-wide u-embed-wide--bbcode' : 'bb-youtube'
|
|
958
|
+
const rel = this.isOsu() ? '?rel=0' : ''
|
|
959
|
+
return `<iframe${this.idAttr(node)} class="${cls}" data-youtube="${this.escapeHtml(id)}" src="https://www.youtube.com/embed/${this.escapeHtml(id)}${rel}" frameborder="0" allowfullscreen></iframe>`
|
|
683
960
|
}
|
|
684
961
|
|
|
685
962
|
private renderAudio(node: RedNode): string {
|
|
@@ -687,6 +964,31 @@ export class HTMLRenderer extends Visitor<string> {
|
|
|
687
964
|
if (this.options.mediaProxy && src) {
|
|
688
965
|
src = this.options.mediaProxy(src)
|
|
689
966
|
}
|
|
967
|
+
const isLyne = this.options.theme === 'lyne' || this.options.dialect === 'lyne'
|
|
968
|
+
if (isLyne) {
|
|
969
|
+
const rawName = src.split('?')[0].split('#')[0].split('/').filter(Boolean).pop() || 'audio_track.mp3'
|
|
970
|
+
let fileName = rawName
|
|
971
|
+
try {
|
|
972
|
+
fileName = decodeURIComponent(rawName)
|
|
973
|
+
} catch {}
|
|
974
|
+
return `<div${this.idAttr(node)} class="lx-audio bb-audio" data-src="${this.escapeHtml(src)}">`
|
|
975
|
+
+ `<audio preload="metadata" src="${this.escapeHtml(src)}"></audio>`
|
|
976
|
+
+ `<div class="lx-track" role="progressbar" aria-label="Audio progress"><div class="fill"></div></div>`
|
|
977
|
+
+ `<div class="lx-row">`
|
|
978
|
+
+ `<button type="button" class="lx-btn" aria-label="Play"><svg viewBox="0 0 16 16"><path d="M3 1.5 14 8 3 14.5z"/></svg></button>`
|
|
979
|
+
+ `<div class="lx-title">`
|
|
980
|
+
+ `<div class="name">${this.escapeHtml(fileName)}</div>`
|
|
981
|
+
+ `<div class="label"><span class="dot"></span> <span class="status-text">audio</span></div>`
|
|
982
|
+
+ `</div>`
|
|
983
|
+
+ `<div class="lx-vol">`
|
|
984
|
+
+ `<button type="button" class="lx-vol-btn" aria-label="Mute"><svg viewBox="0 0 16 16"><path d="M10.707 11.182A4.5 4.5 0 0 0 12.025 8a4.5 4.5 0 0 0-1.318-3.182L10 5.525A3.5 3.5 0 0 1 11.025 8 3.5 3.5 0 0 1 10 10.475zM6.717 3.55A.5.5 0 0 1 7 4v8a.5.5 0 0 1-.812.39L3.825 10.5H1.5A.5.5 0 0 1 1 10V6a.5.5 0 0 1 .5-.5h2.325l2.363-1.89a.5.5 0 0 1 .529-.06"/></svg></button>`
|
|
985
|
+
+ `<input type="range" class="lx-vol-slider" min="0" max="1" step="0.01" value="0.2" aria-label="Volume" style="background:linear-gradient(to right, var(--color-accent, #2EE6E2) 20%, var(--color-inset-well, #080D20) 20%);" />`
|
|
986
|
+
+ `</div>`
|
|
987
|
+
+ `<button type="button" class="lx-speed" aria-label="Playback speed">1.0×</button>`
|
|
988
|
+
+ `<div class="lx-time"><span class="cur">0:00</span> / <span class="total">–:––</span></div>`
|
|
989
|
+
+ `</div>`
|
|
990
|
+
+ `</div>`
|
|
991
|
+
}
|
|
690
992
|
return `<audio${this.idAttr(node)} controls src="${this.escapeHtml(src)}" class="bb-audio"></audio>`
|
|
691
993
|
}
|
|
692
994
|
|
|
@@ -707,7 +1009,7 @@ export class HTMLRenderer extends Visitor<string> {
|
|
|
707
1009
|
private renderNotice(node: RedNode, warning: boolean): string {
|
|
708
1010
|
const isLyne = this.options.theme === 'lyne' || this.options.dialect === 'lyne' || warning
|
|
709
1011
|
if (isLyne) {
|
|
710
|
-
const color =
|
|
1012
|
+
const color = sanitizeColor(nodeAttrValue(node, 'color'), this.tokenResolver)
|
|
711
1013
|
const styleAttr = color
|
|
712
1014
|
? ` 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
1015
|
: ''
|
|
@@ -717,11 +1019,18 @@ export class HTMLRenderer extends Visitor<string> {
|
|
|
717
1019
|
return `<div${this.idAttr(node)} class="notice bb-cut-panel bb-notice${warning ? ' bb-wnotice' : ''}" role="note"${styleAttr}>${warningIcon}<div class="bb-notice-body">${content}</div></div>`
|
|
718
1020
|
}
|
|
719
1021
|
|
|
1022
|
+
// osu pinta el aviso con `.well` a secas — no hay ninguna clase `notice`
|
|
1023
|
+
// en su hoja de estilos, que es por lo que el bloque salía desnudo.
|
|
1024
|
+
if (this.isOsu()) {
|
|
1025
|
+
const content = HTMLRenderer.trimOsuEdges(this.renderChildren(node))
|
|
1026
|
+
return `<div${this.idAttr(node)} class="well">${content}</div>`
|
|
1027
|
+
}
|
|
1028
|
+
|
|
720
1029
|
return this.wrapBlock('div', node, 'class="notice"')
|
|
721
1030
|
}
|
|
722
1031
|
|
|
723
1032
|
private renderTables(node: RedNode): string {
|
|
724
|
-
const variant = (String(node.metadata?.variant ?? '') ||
|
|
1033
|
+
const variant = (String(node.metadata?.variant ?? '') || nodeAttrValue(node) || '').trim().toLowerCase()
|
|
725
1034
|
const variants = new Set(variant.split(/[\s,]+/).filter(Boolean))
|
|
726
1035
|
const tableClasses = [
|
|
727
1036
|
'bb-table',
|
|
@@ -729,7 +1038,9 @@ export class HTMLRenderer extends Visitor<string> {
|
|
|
729
1038
|
variants.has('borders') ? 'bb-table-borders' : '',
|
|
730
1039
|
].filter(Boolean).join(' ')
|
|
731
1040
|
this.tableDepth++
|
|
1041
|
+
this.maxColsStack.push(HTMLRenderer.widestRow(node))
|
|
732
1042
|
const content = this.renderChildren(node)
|
|
1043
|
+
this.maxColsStack.pop()
|
|
733
1044
|
this.tableDepth--
|
|
734
1045
|
// `[tables=striped:#hex]`: de `--table-accent` se deriva toda la paleta
|
|
735
1046
|
// de la tabla (gradiente del frame, hover de filas, encabezado, bordes).
|
|
@@ -737,22 +1048,52 @@ export class HTMLRenderer extends Visitor<string> {
|
|
|
737
1048
|
return `<div${this.idAttr(node)} class="bb-table-frame"${accent}><table class="${tableClasses}"><tbody>${content}</tbody></table></div>`
|
|
738
1049
|
}
|
|
739
1050
|
|
|
1051
|
+
/** Celdas directas de una fila, sin materializar un array intermedio. */
|
|
1052
|
+
private static countCells(node: RedNode): number {
|
|
1053
|
+
let count = 0
|
|
1054
|
+
const children = node.children
|
|
1055
|
+
for (let i = 0; i < children.length; i++) {
|
|
1056
|
+
const kind = children[i].kind
|
|
1057
|
+
if (kind === 'table_th' || kind === 'table_col') count++
|
|
1058
|
+
}
|
|
1059
|
+
return count
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
/**
|
|
1063
|
+
* Columnas de la tabla más ancha bajo `tableNode`, memoizado por tabla.
|
|
1064
|
+
*
|
|
1065
|
+
* `getTableContext` se llama una vez POR CELDA, y recorría todas las filas
|
|
1066
|
+
* de la tabla en cada llamada, con un `.filter()` —y su array— por fila.
|
|
1067
|
+
* Una tabla de 20×20 hacía 8.000 arrays intermedios en cada renderizado
|
|
1068
|
+
* para llegar 400 veces al mismo número. El caché se vacía al empezar cada
|
|
1069
|
+
* render, así que no retiene nodos entre documentos.
|
|
1070
|
+
*/
|
|
1071
|
+
private static widestRow(tableNode: RedNode): number {
|
|
1072
|
+
let maxCols = 1
|
|
1073
|
+
const rows = tableNode.children
|
|
1074
|
+
for (let i = 0; i < rows.length; i++) {
|
|
1075
|
+
const row = rows[i]
|
|
1076
|
+
if (row.kind !== 'table_row') continue
|
|
1077
|
+
const count = HTMLRenderer.countCells(row)
|
|
1078
|
+
if (count > maxCols) maxCols = count
|
|
1079
|
+
}
|
|
1080
|
+
return maxCols
|
|
1081
|
+
}
|
|
1082
|
+
|
|
740
1083
|
private getTableContext(node: RedNode): { cellCount: number; maxCols: number; isDirect: boolean } {
|
|
741
1084
|
const parent = node.parent
|
|
742
1085
|
const isDirect = parent?.kind === 'tables'
|
|
743
1086
|
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
|
|
1087
|
+
const cellCount = parent ? HTMLRenderer.countCells(parent) : 1
|
|
747
1088
|
|
|
748
1089
|
let maxCols = cellCount
|
|
749
1090
|
if (tableNode) {
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
1091
|
+
// La tabla en curso está en lo alto de la pila; solo se recorre si esta
|
|
1092
|
+
// celda llegó por un camino que no pasó por `renderTables`.
|
|
1093
|
+
const widest = this.maxColsStack.length > 0
|
|
1094
|
+
? this.maxColsStack[this.maxColsStack.length - 1]
|
|
1095
|
+
: HTMLRenderer.widestRow(tableNode)
|
|
1096
|
+
if (widest > maxCols) maxCols = widest
|
|
756
1097
|
}
|
|
757
1098
|
return { cellCount, maxCols, isDirect }
|
|
758
1099
|
}
|
|
@@ -763,7 +1104,7 @@ export class HTMLRenderer extends Visitor<string> {
|
|
|
763
1104
|
}
|
|
764
1105
|
|
|
765
1106
|
private renderColumns(node: RedNode): string {
|
|
766
|
-
const cols = parseInt(String(node.metadata?.columns ?? '') ||
|
|
1107
|
+
const cols = parseInt(String(node.metadata?.columns ?? '') || nodeAttrValue(node) || '2', 10)
|
|
767
1108
|
const numCols = Math.max(2, Math.min(4, isNaN(cols) ? 2 : cols))
|
|
768
1109
|
const content = this.renderChildren(node)
|
|
769
1110
|
// `[columns=2:#hex]`: con color se añade la superficie (borde + fondo
|
|
@@ -775,27 +1116,27 @@ export class HTMLRenderer extends Visitor<string> {
|
|
|
775
1116
|
}
|
|
776
1117
|
|
|
777
1118
|
private renderSeparator(node: RedNode): string {
|
|
778
|
-
const variant = (String(node.metadata?.variant ?? '') ||
|
|
1119
|
+
const variant = (String(node.metadata?.variant ?? '') || nodeAttrValue(node) || 'line').trim().toLowerCase()
|
|
779
1120
|
if (variant === 'dots') return `<div${this.idAttr(node)} class="bb-separator">· · ·</div>`
|
|
780
1121
|
if (variant === 'stars') return `<div${this.idAttr(node)} class="bb-separator">✦ ✦ ✦</div>`
|
|
781
1122
|
return `<hr${this.idAttr(node)} class="bb-separator" />`
|
|
782
1123
|
}
|
|
783
1124
|
|
|
784
1125
|
private renderScroll(node: RedNode): string {
|
|
785
|
-
const h = parseInt(String(node.metadata?.height ?? '') ||
|
|
1126
|
+
const h = parseInt(String(node.metadata?.height ?? '') || nodeAttrValue(node) || '200', 10)
|
|
786
1127
|
const maxH = Math.max(50, Math.min(2000, isNaN(h) ? 200 : h))
|
|
787
1128
|
const content = this.renderChildren(node)
|
|
788
1129
|
return `<div${this.idAttr(node)} class="bb-scroll" style="max-height:${maxH}px;">${content}</div>`
|
|
789
1130
|
}
|
|
790
1131
|
|
|
791
1132
|
private renderAbbr(node: RedNode): string {
|
|
792
|
-
const title = String(node.metadata?.title ?? '') ||
|
|
1133
|
+
const title = String(node.metadata?.title ?? '') || nodeAttrValue(node)
|
|
793
1134
|
const attr = title ? ` title="${this.escapeHtml(title)}"` : ''
|
|
794
1135
|
return this.wrapInline('abbr', node, attr)
|
|
795
1136
|
}
|
|
796
1137
|
|
|
797
1138
|
private renderTooltip(node: RedNode): string {
|
|
798
|
-
const tip = String(node.metadata?.tip ?? '') ||
|
|
1139
|
+
const tip = String(node.metadata?.tip ?? '') || nodeAttrValue(node)
|
|
799
1140
|
const attr = tip ? ` title="${this.escapeHtml(tip)}"` : ''
|
|
800
1141
|
return this.wrapInline('span', node, `class="bb-tooltip"${attr}`)
|
|
801
1142
|
}
|
|
@@ -810,16 +1151,31 @@ export class HTMLRenderer extends Visitor<string> {
|
|
|
810
1151
|
}
|
|
811
1152
|
|
|
812
1153
|
private renderAlign(node: RedNode): string {
|
|
813
|
-
const alignVal = (String(node.metadata?.align ?? '') ||
|
|
1154
|
+
const alignVal = (String(node.metadata?.align ?? '') || nodeAttrValue(node) || 'center').trim().toLowerCase()
|
|
814
1155
|
const validAlign = alignVal === 'left' || alignVal === 'right' ? alignVal : 'center'
|
|
815
|
-
return this.
|
|
1156
|
+
return this.renderAlignAs(node, validAlign)
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
/**
|
|
1160
|
+
* `[centre]` / `[left]` / `[right]` (y `[align=…]`).
|
|
1161
|
+
*
|
|
1162
|
+
* osu! no usa `text-align` inline: estiliza el bloque por nombre de clase,
|
|
1163
|
+
* con la grafía británica `centre`. Fuera del dialecto osu el estilo inline
|
|
1164
|
+
* se mantiene, porque ni Miliastry ni Lyne traen esas reglas.
|
|
1165
|
+
*/
|
|
1166
|
+
private renderAlignAs(node: RedNode, align: 'center' | 'left' | 'right'): string {
|
|
1167
|
+
if (this.isOsu()) {
|
|
1168
|
+
const name = align === 'center' ? 'centre' : align
|
|
1169
|
+
return this.wrapBlock('div', node, `class="bbcode__align-${name}"`)
|
|
1170
|
+
}
|
|
1171
|
+
return this.wrapBlock('div', node, `style="text-align:${align};"`)
|
|
816
1172
|
}
|
|
817
1173
|
|
|
818
1174
|
private renderEffect(node: RedNode): string {
|
|
819
|
-
const raw = (String(node.metadata?.effectType ?? '') ||
|
|
1175
|
+
const raw = (String(node.metadata?.effectType ?? '') || nodeAttrValue(node) || 'glow').toLowerCase().trim()
|
|
820
1176
|
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 =
|
|
1177
|
+
const rawColor = String(node.metadata?.color ?? '') || (raw.includes(':') ? raw.split(':')[1] : (raw.startsWith('#') ? raw : nodeAttrValue(node)))
|
|
1178
|
+
const color = sanitizeColor(rawColor, this.tokenResolver)
|
|
823
1179
|
const content = this.renderChildren(node)
|
|
824
1180
|
const idAttr = this.idAttr(node)
|
|
825
1181
|
|
|
@@ -852,7 +1208,7 @@ export class HTMLRenderer extends Visitor<string> {
|
|
|
852
1208
|
}
|
|
853
1209
|
|
|
854
1210
|
private renderAnim(node: RedNode): string {
|
|
855
|
-
const raw = (String(node.metadata?.animType ?? '') ||
|
|
1211
|
+
const raw = (String(node.metadata?.animType ?? '') || nodeAttrValue(node) || 'pulse').toLowerCase().trim()
|
|
856
1212
|
const animType = raw.includes(':') ? raw.split(':')[0] : raw
|
|
857
1213
|
const content = this.renderChildren(node)
|
|
858
1214
|
const idAttr = this.idAttr(node)
|
|
@@ -880,7 +1236,7 @@ export class HTMLRenderer extends Visitor<string> {
|
|
|
880
1236
|
}
|
|
881
1237
|
|
|
882
1238
|
private renderContainer(node: RedNode): string {
|
|
883
|
-
const raw = (String(node.metadata?.containerType ?? '') ||
|
|
1239
|
+
const raw = (String(node.metadata?.containerType ?? '') || nodeAttrValue(node) || 'stack').trim()
|
|
884
1240
|
const [type, ...params] = raw.split(':')
|
|
885
1241
|
const containerType = type.toLowerCase()
|
|
886
1242
|
const param = params.join(':')
|
|
@@ -904,7 +1260,7 @@ export class HTMLRenderer extends Visitor<string> {
|
|
|
904
1260
|
return `<div${idAttr} class="bb-middle">${content}</div>`
|
|
905
1261
|
case 'square':
|
|
906
1262
|
case 'circle': {
|
|
907
|
-
const accent =
|
|
1263
|
+
const accent = sanitizeColor(param, this.tokenResolver)
|
|
908
1264
|
const border = accent ? this.hexToRgba(accent, 0.35) : ''
|
|
909
1265
|
const bg = accent ? this.hexToRgba(accent, 0.08) : ''
|
|
910
1266
|
const style = accent ? ` style="--square-accent:${accent};--square-border:${border};--square-bg:${bg};"` : ''
|
|
@@ -917,14 +1273,14 @@ export class HTMLRenderer extends Visitor<string> {
|
|
|
917
1273
|
// del que el CSS deriva borde, tinte de fondo y —vía la clase
|
|
918
1274
|
// `bb-accented`— la paleta del contenido sin color propio. Sin color,
|
|
919
1275
|
// defaults y sin clase.
|
|
920
|
-
const accent =
|
|
1276
|
+
const accent = sanitizeColor(param, this.tokenResolver)
|
|
921
1277
|
const cls = accent ? ' bb-accented' : ''
|
|
922
1278
|
const style = accent ? ` style="--${containerType}-accent:${accent};"` : ''
|
|
923
1279
|
return `<div${idAttr} class="bb-cut-panel bb-${containerType}${cls}"${style}>${content}</div>`
|
|
924
1280
|
}
|
|
925
1281
|
case 'neon-box':
|
|
926
1282
|
case 'neonbox': {
|
|
927
|
-
const c =
|
|
1283
|
+
const c = sanitizeColor(param || nodeAttrValue(node), this.tokenResolver) || 'var(--color-accent, #2EE6E2)'
|
|
928
1284
|
return `<div${idAttr} class="bb-cut-panel bb-neon-box" style="--neon-color:${c};border-color:${c};">${content}</div>`
|
|
929
1285
|
}
|
|
930
1286
|
default:
|
|
@@ -943,7 +1299,7 @@ export class HTMLRenderer extends Visitor<string> {
|
|
|
943
1299
|
])
|
|
944
1300
|
|
|
945
1301
|
private renderStyleTag(node: RedNode): string {
|
|
946
|
-
const raw = (String(node.metadata?.style ?? '') ||
|
|
1302
|
+
const raw = (String(node.metadata?.style ?? '') || nodeAttrValue(node) || '').trim()
|
|
947
1303
|
const content = this.renderChildren(node)
|
|
948
1304
|
const idAttr = this.idAttr(node)
|
|
949
1305
|
if (!raw) return `<span${idAttr}>${content}</span>`
|
|
@@ -961,7 +1317,10 @@ export class HTMLRenderer extends Visitor<string> {
|
|
|
961
1317
|
// color-mix()…) — antes se eliminaban y rompían esos valores. Lo que sí
|
|
962
1318
|
// se bloquea son las funciones/fuentes peligrosas (url(), expression(),
|
|
963
1319
|
// javascript:), que no tienen uso legítimo en el whitelist de props.
|
|
964
|
-
|
|
1320
|
+
let safeVal = val.replace(/["'{}<>]/g, '').slice(0, 100)
|
|
1321
|
+
if (safeVal.startsWith('$') && this.tokenResolver) {
|
|
1322
|
+
safeVal = resolveTokenValue(safeVal, this.tokenResolver)
|
|
1323
|
+
}
|
|
965
1324
|
if (/url\s*\(|expression\s*\(|javascript\s*:/i.test(safeVal)) continue
|
|
966
1325
|
safeStyles.push(`${prop}:${safeVal}`)
|
|
967
1326
|
}
|
|
@@ -971,7 +1330,7 @@ export class HTMLRenderer extends Visitor<string> {
|
|
|
971
1330
|
}
|
|
972
1331
|
|
|
973
1332
|
private renderQuote(node: RedNode): string {
|
|
974
|
-
const source = node.metadata?.source ||
|
|
1333
|
+
const source = node.metadata?.source || nodeAttrValue(node)
|
|
975
1334
|
const content = this.renderChildren(node)
|
|
976
1335
|
if (source) {
|
|
977
1336
|
return `<blockquote${this.idAttr(node)}><div style="margin-bottom:8px"><strong>${this.escapeHtml(String(source))} wrote:</strong></div>${content}</blockquote>`
|
|
@@ -979,8 +1338,34 @@ export class HTMLRenderer extends Visitor<string> {
|
|
|
979
1338
|
return `<blockquote${this.idAttr(node)}>${content}</blockquote>`
|
|
980
1339
|
}
|
|
981
1340
|
|
|
1341
|
+
/**
|
|
1342
|
+
* La estructura exacta que `bbcode-spoilerbox` de osu-web espera.
|
|
1343
|
+
*
|
|
1344
|
+
* El toggle de osu es JS: `js-spoilerbox__link` es el gancho del click y
|
|
1345
|
+
* `js-spoilerbox__body` el panel que abre. Si falta cualquiera de las dos
|
|
1346
|
+
* clases el box queda mudo, así que la estructura no es decorativa.
|
|
1347
|
+
*/
|
|
1348
|
+
private renderOsuSpoilerbox(node: RedNode, title: string, extra: string): string {
|
|
1349
|
+
const content = HTMLRenderer.trimOsuEdges(this.renderChildren(node))
|
|
1350
|
+
return `<div${this.idAttr(node)} class="js-spoilerbox bbcode-spoilerbox"${extra}>` +
|
|
1351
|
+
`<a class="js-spoilerbox__link bbcode-spoilerbox__link" href="#">` +
|
|
1352
|
+
`<span class="bbcode-spoilerbox__link-icon"></span>` +
|
|
1353
|
+
`<span class="bbcode-spoilerbox__link-text">${title}</span></a>` +
|
|
1354
|
+
`<div class="js-spoilerbox__body bbcode-spoilerbox__body">${content}</div></div>`
|
|
1355
|
+
}
|
|
1356
|
+
|
|
1357
|
+
/** El rótulo de un box bajo osu: el del autor, o `SPOILER` en mayúsculas. */
|
|
1358
|
+
private osuBoxTitle(node: RedNode): string {
|
|
1359
|
+
return this.hasOwnTitle(node) ? this.renderTitle(node, 'SPOILER') : 'SPOILER'
|
|
1360
|
+
}
|
|
1361
|
+
|
|
982
1362
|
private renderSpoilerbox(node: RedNode): string {
|
|
1363
|
+
if (this.isOsu()) {
|
|
1364
|
+
// Un [spoilerbox] sin título propio se rotula SPOILER, en mayúsculas.
|
|
1365
|
+
return this.renderOsuSpoilerbox(node, this.osuBoxTitle(node), this.bareTitleAttr(node))
|
|
1366
|
+
}
|
|
983
1367
|
const title = this.renderTitle(node, 'Spoiler')
|
|
1368
|
+
const bare = this.bareTitleAttr(node)
|
|
984
1369
|
const content = this.renderChildren(node)
|
|
985
1370
|
const isLyne = this.options.theme === 'lyne' || this.options.dialect === 'lyne'
|
|
986
1371
|
const bodyCls = isLyne ? 'bb-box-body' : 'bbcode-box-body'
|
|
@@ -990,11 +1375,17 @@ export class HTMLRenderer extends Visitor<string> {
|
|
|
990
1375
|
// `--box-accent` colorea el acento del box (título + chevron, y el borde
|
|
991
1376
|
// en boxw) cuando el autor puso `[box=Title:#hex]`.
|
|
992
1377
|
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>`
|
|
1378
|
+
return `<details${this.idAttr(node)}${bare}${accent}><summary><span class="bb-box-heading">${title}</span></summary><div class="${bodyCls}">${content}</div></details>`
|
|
994
1379
|
}
|
|
995
1380
|
|
|
996
1381
|
private renderBox(node: RedNode): string {
|
|
1382
|
+
if (this.isOsu()) {
|
|
1383
|
+
// osu no distingue box de spoilerbox: es la misma construcción, y sin
|
|
1384
|
+
// título propio rotula SPOILER igual que `[spoilerbox]`.
|
|
1385
|
+
return this.renderOsuSpoilerbox(node, this.osuBoxTitle(node), this.bareTitleAttr(node))
|
|
1386
|
+
}
|
|
997
1387
|
const title = this.renderTitle(node, 'Box')
|
|
1388
|
+
const bare = this.bareTitleAttr(node)
|
|
998
1389
|
const content = this.renderChildren(node)
|
|
999
1390
|
const isLyne = this.options.theme === 'lyne' || this.options.dialect === 'lyne'
|
|
1000
1391
|
const bodyCls = isLyne ? 'bb-box-body' : 'bbcode-box-body'
|
|
@@ -1006,7 +1397,30 @@ export class HTMLRenderer extends Visitor<string> {
|
|
|
1006
1397
|
// (no `bb-box-title`) evita la regla legacy `.bbcode-preview .bb-box-title`
|
|
1007
1398
|
// de la app, que pinta un fondo sobre el título.
|
|
1008
1399
|
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>`
|
|
1400
|
+
return `<details${this.idAttr(node)} class="${cls}"${bare}${accent}><summary><span class="bb-box-heading">${title}</span></summary><div class="${bodyCls}">${content}</div></details>`
|
|
1401
|
+
}
|
|
1402
|
+
|
|
1403
|
+
/**
|
|
1404
|
+
* Marca un `[box]`/`[spoilerbox]` sin título propio.
|
|
1405
|
+
*
|
|
1406
|
+
* El `<summary>` siempre lleva texto ("Box", "Spoiler"), así que sin esta
|
|
1407
|
+
* marca el camino HTML→BBCode leía ese relleno como si el autor lo hubiera
|
|
1408
|
+
* escrito y devolvía `[box=Box]`.
|
|
1409
|
+
*/
|
|
1410
|
+
private bareTitleAttr(node: RedNode): string {
|
|
1411
|
+
return this.hasOwnTitle(node) ? '' : ' data-bare-title="1"'
|
|
1412
|
+
}
|
|
1413
|
+
|
|
1414
|
+
/**
|
|
1415
|
+
* ¿El título del box lo escribió el autor, o es el relleno del parser?
|
|
1416
|
+
*
|
|
1417
|
+
* `BBCodeToGreenNode` ya deja `metadata.title = 'Box'`/`'Spoiler'` para un
|
|
1418
|
+
* tag pelado, así que el `fallback` de `renderTitle` nunca llega a usarse:
|
|
1419
|
+
* quien quiera otro rótulo por defecto tiene que preguntar por aquí.
|
|
1420
|
+
*/
|
|
1421
|
+
private hasOwnTitle(node: RedNode): boolean {
|
|
1422
|
+
const raw = node.metadata?.rawTitle
|
|
1423
|
+
return raw !== undefined ? String(raw) !== '' : node.metadata?.title !== undefined
|
|
1010
1424
|
}
|
|
1011
1425
|
|
|
1012
1426
|
private renderTitle(node: RedNode, fallback: string): string {
|
|
@@ -1014,8 +1428,14 @@ export class HTMLRenderer extends Visitor<string> {
|
|
|
1014
1428
|
if (titleNodes && titleNodes.length > 0) {
|
|
1015
1429
|
return titleNodes.map(c => this.renderNode(c)).join('')
|
|
1016
1430
|
}
|
|
1017
|
-
|
|
1018
|
-
|
|
1431
|
+
let title = String(node.metadata?.title ?? nodeAttrValue(node) ?? fallback)
|
|
1432
|
+
if (this.tokenResolver) {
|
|
1433
|
+
title = title.replace(/\$([a-zA-Z_][a-zA-Z0-9_-]*)/g, (match, name) => {
|
|
1434
|
+
const resolved = this.tokenResolver!(name) ?? this.tokenResolver!(match)
|
|
1435
|
+
return resolved !== undefined ? resolved : match
|
|
1436
|
+
})
|
|
1437
|
+
}
|
|
1438
|
+
return this.escapeHtml(title)
|
|
1019
1439
|
}
|
|
1020
1440
|
|
|
1021
1441
|
/**
|
|
@@ -1025,7 +1445,7 @@ export class HTMLRenderer extends Visitor<string> {
|
|
|
1025
1445
|
* borde + fondo en columns). Devuelve '' si no hay color válido.
|
|
1026
1446
|
*/
|
|
1027
1447
|
private boxAccentStyle(node: RedNode, suffix: 'box' | 'table' | 'columns' = 'box'): string {
|
|
1028
|
-
const color =
|
|
1448
|
+
const color = sanitizeColor(String(node.metadata?.color ?? ''), this.tokenResolver)
|
|
1029
1449
|
if (!color) return ''
|
|
1030
1450
|
const border = this.hexToRgba(color, 0.35)
|
|
1031
1451
|
const bg = this.hexToRgba(color, 0.08)
|
|
@@ -1034,7 +1454,7 @@ export class HTMLRenderer extends Visitor<string> {
|
|
|
1034
1454
|
|
|
1035
1455
|
private renderList(node: RedNode): string {
|
|
1036
1456
|
// Check for ordered list: [list=1], [list=a], or metadata
|
|
1037
|
-
const attrs =
|
|
1457
|
+
const attrs = nodeAttrValue(node)
|
|
1038
1458
|
const isOrdered = node.metadata?.ordered === true || attrs === '1' || attrs === 'a'
|
|
1039
1459
|
const tag = isOrdered ? 'ol' : 'ul'
|
|
1040
1460
|
const content = node.children.map(c => this.renderNode(c)).join('\n')
|
|
@@ -1055,7 +1475,12 @@ export class HTMLRenderer extends Visitor<string> {
|
|
|
1055
1475
|
const trailingMatch = content.match(/(?:[\r\n][\t ]*)+$/)
|
|
1056
1476
|
if (trailingMatch) content = content.slice(0, -trailingMatch[0].length)
|
|
1057
1477
|
|
|
1058
|
-
|
|
1478
|
+
// Los saltos recortados arriba son de presentación: si no se anotan, el
|
|
1479
|
+
// camino HTML→BBCode los da por inexistentes y reescribe el bloque del
|
|
1480
|
+
// autor como `[code]…[/code]` en una sola línea.
|
|
1481
|
+
const pad = `${leadingMatch ? leadingMatch[0] : ''}\u0000${trailingMatch ? trailingMatch[0] : ''}`
|
|
1482
|
+
const padAttr = pad === '\u0000' ? '' : ` data-code-pad="${this.escapeHtml(JSON.stringify(pad))}"`
|
|
1483
|
+
return `<pre${this.idAttr(node)}${padAttr}><code>${this.escapeHtml(content)}</code></pre>`
|
|
1059
1484
|
}
|
|
1060
1485
|
|
|
1061
1486
|
private renderSVG(node: RedNode): string {
|
|
@@ -1091,12 +1516,12 @@ export class HTMLRenderer extends Visitor<string> {
|
|
|
1091
1516
|
.filter(Boolean)
|
|
1092
1517
|
|
|
1093
1518
|
if (textLines.length === 0) {
|
|
1094
|
-
return '
|
|
1519
|
+
return this.mediaError('imagemap', '[imagemap] missing image URL')
|
|
1095
1520
|
}
|
|
1096
1521
|
|
|
1097
1522
|
const imageUrl = textLines[0]
|
|
1098
1523
|
if (!imageUrl.startsWith('http://') && !imageUrl.startsWith('https://')) {
|
|
1099
|
-
return
|
|
1524
|
+
return this.mediaError('imagemap', `[imagemap] invalid image URL: ${this.escapeHtml(imageUrl)}`)
|
|
1100
1525
|
}
|
|
1101
1526
|
|
|
1102
1527
|
let areas = ''
|
|
@@ -1119,9 +1544,26 @@ export class HTMLRenderer extends Visitor<string> {
|
|
|
1119
1544
|
areaUrl = 'https://' + areaUrl
|
|
1120
1545
|
}
|
|
1121
1546
|
|
|
1547
|
+
if (this.isOsu()) {
|
|
1548
|
+
// osu posiciona por CSS (`.imagemap__link` ya es absolute), así que el
|
|
1549
|
+
// style inline lleva sólo las cuatro coordenadas. Un destino `#` no es
|
|
1550
|
+
// un enlace: emite un `span` con la misma clase, para que la zona siga
|
|
1551
|
+
// mostrando su `title` sin navegar a ninguna parte.
|
|
1552
|
+
const pos = `left:${x}%;top:${y}%;width:${w}%;height:${h}%;`
|
|
1553
|
+
const title = ` title="${this.escapeHtml(label)}"`
|
|
1554
|
+
areas += url === '#'
|
|
1555
|
+
? `<span class="imagemap__link" style="${pos}"${title}></span>`
|
|
1556
|
+
: `<a class="imagemap__link" href="${this.escapeHtml(areaUrl)}" style="${pos}"${title}></a>`
|
|
1557
|
+
continue
|
|
1558
|
+
}
|
|
1122
1559
|
areas += `<a${this.idAttr(node)} href="${this.escapeHtml(areaUrl)}" target="_blank" rel="noopener" class="imagemap-area bbcode-imap-area" style="position:absolute;left:${x}%;top:${y}%;width:${w}%;height:${h}%;" title="${this.escapeHtml(label || 'Link')}"></a>`
|
|
1123
1560
|
}
|
|
1124
1561
|
|
|
1562
|
+
if (this.isOsu()) {
|
|
1563
|
+
return `<div${this.idAttr(node)} class="imagemap">` +
|
|
1564
|
+
`<img class="imagemap__image" loading="lazy" src="${this.escapeHtml(imageUrl)}" alt="">${areas}</div>`
|
|
1565
|
+
}
|
|
1566
|
+
|
|
1125
1567
|
return `<div${this.idAttr(node)} class="imagemap-container bbcode-imagemap" style="position:relative;display:inline-block;"><img src="${this.escapeHtml(imageUrl)}" alt="imagemap" style="max-width:100%;height:auto;display:block;">${areas}</div>`
|
|
1126
1568
|
}
|
|
1127
1569
|
|
|
@@ -1133,26 +1575,127 @@ export class HTMLRenderer extends Visitor<string> {
|
|
|
1133
1575
|
return node.text || ''
|
|
1134
1576
|
}
|
|
1135
1577
|
|
|
1578
|
+
/**
|
|
1579
|
+
* Effect parameters off a node, with every colour re-validated.
|
|
1580
|
+
*
|
|
1581
|
+
* `startsWith('#')` was NOT a filter: `#a" onmouseover="alert(1)` passes
|
|
1582
|
+
* it. Every stop has to survive the full colour allowlist or be dropped,
|
|
1583
|
+
* because these values end up inside a `style` attribute.
|
|
1584
|
+
*/
|
|
1585
|
+
private effectParams(node: RedNode): EffectParams {
|
|
1586
|
+
const meta = (node.metadata ?? {}) as Record<string, unknown>
|
|
1587
|
+
const params: EffectParams = {}
|
|
1588
|
+
for (const [key, value] of Object.entries(meta)) {
|
|
1589
|
+
if (key === 'globalOffset' || key === 'documentLength') continue
|
|
1590
|
+
;(params as Record<string, unknown>)[key] = value
|
|
1591
|
+
}
|
|
1592
|
+
|
|
1593
|
+
if (Array.isArray(params.colors)) {
|
|
1594
|
+
params.colors = params.colors
|
|
1595
|
+
.map(c => (typeof c === 'string' ? sanitizeColor(c, this.tokenResolver) : null))
|
|
1596
|
+
.filter((c): c is string => c !== null)
|
|
1597
|
+
if (params.colors.length === 0) delete params.colors
|
|
1598
|
+
}
|
|
1599
|
+
if (Array.isArray(params.stops)) {
|
|
1600
|
+
params.stops = params.stops
|
|
1601
|
+
.filter(st => st && typeof st.color === 'string' && sanitizeColor(st.color, this.tokenResolver) !== null)
|
|
1602
|
+
if (params.stops.length === 0) delete params.stops
|
|
1603
|
+
}
|
|
1604
|
+
return params
|
|
1605
|
+
}
|
|
1606
|
+
|
|
1607
|
+
/** Text inside an effect node, ignoring layout-only children. */
|
|
1608
|
+
/**
|
|
1609
|
+
* The plain text an effect modulates, line breaks included.
|
|
1610
|
+
*
|
|
1611
|
+
* A break is a childless `spacing` leaf carrying no text, so collecting
|
|
1612
|
+
* it naively contributes nothing and the effect sees one long line.
|
|
1613
|
+
* Every two-dimensional axis then collapses: `line` reports zero for
|
|
1614
|
+
* the whole block, `column` stretches one row across the paragraph, and
|
|
1615
|
+
* `radial` measures a rectangle one line tall. The break was being
|
|
1616
|
+
* dropped from the OUTPUT as well, so a multi-line effect previewed as
|
|
1617
|
+
* a single run-on line.
|
|
1618
|
+
*
|
|
1619
|
+
* The document's own plain text spells a break `\n` — that is what
|
|
1620
|
+
* `stripColorWrappers` and the studio's sampler both count — so this
|
|
1621
|
+
* agrees with them rather than inventing a third answer.
|
|
1622
|
+
*/
|
|
1623
|
+
private effectText(node: RedNode): string {
|
|
1624
|
+
return node.children.map(c => this.effectChildText(c)).join('')
|
|
1625
|
+
}
|
|
1626
|
+
|
|
1627
|
+
private effectChildText(node: RedNode): string {
|
|
1628
|
+
if (node.kind === 'spacing' || node.kind === 'empty_line') return '\n'
|
|
1629
|
+
if (node.kind === 'text') return node.text ?? ''
|
|
1630
|
+
if (node.children.length === 0) return this.collectNodeText(node)
|
|
1631
|
+
return node.children.map(c => this.effectChildText(c)).join('')
|
|
1632
|
+
}
|
|
1633
|
+
|
|
1634
|
+
/**
|
|
1635
|
+
* Paint an effect's computed segments as spans.
|
|
1636
|
+
*
|
|
1637
|
+
* Shared by rainbow, grow and any modulated gradient, so the preview
|
|
1638
|
+
* shows exactly the colours and sizes the BBCode export will contain
|
|
1639
|
+
* rather than an approximation of them.
|
|
1640
|
+
*/
|
|
1641
|
+
private renderEffectSegments(node: RedNode, kind: EffectKind): string {
|
|
1642
|
+
const text = this.effectText(node)
|
|
1643
|
+
if (!text) return this.renderChildren(node)
|
|
1644
|
+
|
|
1645
|
+
const meta = (node.metadata ?? {}) as Record<string, unknown>
|
|
1646
|
+
const segments = evaluateEffect(text, kind, this.effectParams(node), {
|
|
1647
|
+
globalOffset: meta.globalOffset as number | undefined,
|
|
1648
|
+
documentLength: meta.documentLength as number | undefined,
|
|
1649
|
+
})
|
|
1650
|
+
|
|
1651
|
+
let out = ''
|
|
1652
|
+
for (const seg of segments) {
|
|
1653
|
+
// The break is markup, not text: escaping it would print a newline
|
|
1654
|
+
// that HTML collapses to a space, which is how a painted block of
|
|
1655
|
+
// ASCII art would arrive as one unreadable line.
|
|
1656
|
+
// El `indexOf` delante: un degradado sin fusionar emite un segmento POR
|
|
1657
|
+
// CARÁCTER, y `/\n/g` es un literal —en ES2015+ eso asigna un objeto
|
|
1658
|
+
// nuevo cada vez que se evalúa— para recorrer una cadena de un carácter
|
|
1659
|
+
// que casi nunca es un salto de línea.
|
|
1660
|
+
const html = this.escapeHtml(seg.text)
|
|
1661
|
+
const escaped = html.indexOf('\n') === -1 ? html : html.replace(NEWLINE_RE, '<br>')
|
|
1662
|
+
if (seg.color) {
|
|
1663
|
+
const safe = sanitizeColor(seg.color, this.tokenResolver)
|
|
1664
|
+
out += safe ? `<span style="color:${safe}">${escaped}</span>` : escaped
|
|
1665
|
+
} else if (seg.size !== undefined) {
|
|
1666
|
+
out += `<span style="font-size:${Math.max(10, Math.min(400, seg.size))}%">${escaped}</span>`
|
|
1667
|
+
} else {
|
|
1668
|
+
out += escaped
|
|
1669
|
+
}
|
|
1670
|
+
}
|
|
1671
|
+
return `<span${this.idAttr(node)} class="bb-effect bb-${kind}">${out}</span>`
|
|
1672
|
+
}
|
|
1673
|
+
|
|
1674
|
+
/**
|
|
1675
|
+
* A gradient previews as a real CSS gradient when it is a plain
|
|
1676
|
+
* left-to-right ramp, and as computed per-character spans otherwise.
|
|
1677
|
+
*
|
|
1678
|
+
* The CSS path is genuinely smoother — it interpolates per pixel rather
|
|
1679
|
+
* than per glyph — but it can only express a linear sweep. A gradient
|
|
1680
|
+
* with a waveform, a non-index axis or quantisation steps looked
|
|
1681
|
+
* nothing like its own export while this was the only path.
|
|
1682
|
+
*/
|
|
1136
1683
|
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.
|
|
1684
|
+
const params = this.effectParams(node)
|
|
1151
1685
|
const content = node.children
|
|
1152
1686
|
.filter(c => c.kind !== 'spacing' && c.kind !== 'empty_line')
|
|
1153
1687
|
.map(c => this.renderNode(c)).join('')
|
|
1154
|
-
|
|
1155
|
-
|
|
1688
|
+
|
|
1689
|
+
if (isPlainGradient(params)) {
|
|
1690
|
+
const stops = params.stops && params.stops.length > 0
|
|
1691
|
+
? params.stops.map(st => `${st.color} ${(st.position * 100).toFixed(1)}%`)
|
|
1692
|
+
: (params.colors ?? ['#FF0000', '#00FF00'])
|
|
1693
|
+
const list = stops.length === 1 ? [stops[0], stops[0]] : stops
|
|
1694
|
+
const gradientCss = `linear-gradient(to right, ${list.join(', ')})`
|
|
1695
|
+
return `<span${this.idAttr(node)} style="background: ${gradientCss}; -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text;">${content}</span>`
|
|
1696
|
+
}
|
|
1697
|
+
|
|
1698
|
+
return this.renderEffectSegments(node, 'gradient')
|
|
1156
1699
|
}
|
|
1157
1700
|
|
|
1158
1701
|
/**
|
|
@@ -1281,3 +1824,26 @@ export class HTMLRenderer extends Visitor<string> {
|
|
|
1281
1824
|
return out + text.slice(last)
|
|
1282
1825
|
}
|
|
1283
1826
|
}
|
|
1827
|
+
|
|
1828
|
+
/**
|
|
1829
|
+
* Whether a gradient is a plain left-to-right ramp.
|
|
1830
|
+
*
|
|
1831
|
+
* Only then can CSS express it. Anything with a waveform, a non-index
|
|
1832
|
+
* axis, quantisation, inversion or per-word stepping has to be painted
|
|
1833
|
+
* per character instead, or the preview and the export disagree.
|
|
1834
|
+
*/
|
|
1835
|
+
function isPlainGradient(params: EffectParams): boolean {
|
|
1836
|
+
// A tag carrying a span is one fragment of a longer ramp. Painting it
|
|
1837
|
+
// as a full-width CSS gradient restarts the sweep inside every
|
|
1838
|
+
// fragment, which is exactly the discontinuity the span exists to fix.
|
|
1839
|
+
if (params.documentLength !== undefined && params.documentLength > 1) return false
|
|
1840
|
+
return (params.wave === undefined || params.wave === 'none')
|
|
1841
|
+
&& (params.axis === undefined || params.axis === 'index')
|
|
1842
|
+
&& (params.easing === undefined || params.easing === 'linear')
|
|
1843
|
+
&& (params.steps === undefined || params.steps < 2)
|
|
1844
|
+
&& !params.invert
|
|
1845
|
+
&& (params.unit === undefined || params.unit === 'character')
|
|
1846
|
+
&& (params.cycles === undefined || params.cycles === 1)
|
|
1847
|
+
&& (params.phase === undefined || params.phase === 0)
|
|
1848
|
+
&& (params.expression === undefined || params.expression === '')
|
|
1849
|
+
}
|