@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.
Files changed (278) hide show
  1. package/README.md +1 -1
  2. package/dist/Visuals/lyne.css +374 -9
  3. package/dist/Visuals/osu.css +393 -11
  4. package/dist/index.js +10470 -3336
  5. package/dist/index.mjs +10336 -3337
  6. package/dist/types/Analysis/Contracts/AnalysisReport.d.ts +22 -0
  7. package/dist/types/Analysis/Contracts/Contribution.d.ts +68 -0
  8. package/dist/types/Analysis/Contracts/Pass.d.ts +59 -0
  9. package/dist/types/Analysis/Contracts/PipelineContext.d.ts +34 -0
  10. package/dist/types/Analysis/Passes/Analysis/ColorUsageAnalyzer.d.ts +56 -0
  11. package/dist/types/Analysis/Passes/Analysis/GradientAnalyzer.d.ts +111 -0
  12. package/dist/types/Analysis/Passes/Analysis/MergeableColorAnalyzer.d.ts +26 -0
  13. package/dist/types/Analysis/Passes/Analysis/RainbowAnalyzer.d.ts +47 -0
  14. package/dist/types/Analysis/Passes/Analysis/SymbolAnalyzer.d.ts +89 -0
  15. package/dist/types/Analysis/Passes/Analysis/WaveAnalyzer.d.ts +44 -0
  16. package/dist/types/Analysis/Passes/Analysis/__tests__/GradientAnalyzer.test.d.ts +4 -0
  17. package/dist/types/Analysis/Passes/Analysis/__tests__/MergeableColorAnalyzer.test.d.ts +4 -0
  18. package/dist/types/Analysis/Passes/Analysis/__tests__/RainbowAnalyzer.test.d.ts +4 -0
  19. package/dist/types/Analysis/Passes/Analysis/__tests__/WaveAnalyzer.test.d.ts +4 -0
  20. package/dist/types/Analysis/Passes/Decision/DefaultDecision.d.ts +30 -0
  21. package/dist/types/Analysis/Passes/Decision/PaletteRemapDecision.d.ts +169 -0
  22. package/dist/types/Analysis/Passes/Decision/__tests__/DefaultDecision.test.d.ts +4 -0
  23. package/dist/types/Analysis/Passes/Transform/CollapseGradientTransform.d.ts +37 -0
  24. package/dist/types/Analysis/Passes/Transform/MergeColorsTransform.d.ts +28 -0
  25. package/dist/types/Analysis/Passes/Transform/RainbowCollapseTransform.d.ts +19 -0
  26. package/dist/types/Analysis/Passes/Transform/WaveCollapseTransform.d.ts +20 -0
  27. package/dist/types/Analysis/Passes/Utility/CharacterCountAnalyzer.d.ts +19 -0
  28. package/dist/types/Analysis/Pipeline/Pipeline.d.ts +69 -0
  29. package/dist/types/Analysis/Pipeline/PipelineBuilder.d.ts +31 -0
  30. package/dist/types/Analysis/Pipeline/PipelineStage.d.ts +17 -0
  31. package/dist/types/Analysis/Utils/color-utils.d.ts +60 -0
  32. package/dist/types/Analysis/__tests__/Integration.test.d.ts +8 -0
  33. package/dist/types/Analysis/__tests__/PaletteRemap.test.d.ts +14 -0
  34. package/dist/types/Analysis/__tests__/Pipeline.test.d.ts +10 -0
  35. package/dist/types/Analysis/__tests__/RoundTrip.test.d.ts +30 -0
  36. package/dist/types/Analysis/__tests__/SymbolAnalyzer.test.d.ts +11 -0
  37. package/dist/types/Analysis/index.d.ts +40 -0
  38. package/dist/types/BBCode/BBCodeDocumentModel.d.ts +102 -0
  39. package/dist/types/BBCode/BBCodeToGreenNode.d.ts +111 -0
  40. package/dist/types/BBCode/Parser.d.ts +78 -0
  41. package/dist/types/BBCode/index.d.ts +4 -0
  42. package/dist/types/Collab/positions.d.ts +55 -0
  43. package/dist/types/Commands/Command.d.ts +37 -0
  44. package/dist/types/Commands/CommandRegistry.d.ts +48 -0
  45. package/dist/types/Commands/DeleteNode.d.ts +7 -0
  46. package/dist/types/Commands/InsertText.d.ts +8 -0
  47. package/dist/types/Commands/SplitMerge.d.ts +9 -0
  48. package/dist/types/Commands/WrapInTag.d.ts +8 -0
  49. package/dist/types/Commands/index.d.ts +6 -0
  50. package/dist/types/Diff/TreeDiffer.d.ts +52 -0
  51. package/dist/types/Diff/__tests__/TreeDiffer.test.d.ts +1 -0
  52. package/dist/types/Diff/index.d.ts +2 -0
  53. package/dist/types/Edits/EditPlan.d.ts +144 -0
  54. package/dist/types/Edits/Optimizer.d.ts +78 -0
  55. package/dist/types/Edits/Rules/Rule.d.ts +91 -0
  56. package/dist/types/Edits/Rules/dropEmptyTags.d.ts +40 -0
  57. package/dist/types/Edits/Rules/dropRedundantNesting.d.ts +59 -0
  58. package/dist/types/Edits/Rules/index.d.ts +9 -0
  59. package/dist/types/Edits/Rules/mergeAdjacent.d.ts +93 -0
  60. package/dist/types/Edits/Rules/reorderWrappers.d.ts +42 -0
  61. package/dist/types/Edits/Rules/shortenHex.d.ts +30 -0
  62. package/dist/types/Edits/Rules/tagValue.d.ts +56 -0
  63. package/dist/types/Edits/Rules/unwrapInvisibleColor.d.ts +47 -0
  64. package/dist/types/Edits/applyEdits.d.ts +36 -0
  65. package/dist/types/Edits/index.d.ts +6 -0
  66. package/dist/types/Events/EventBus.d.ts +103 -0
  67. package/dist/types/Events/index.d.ts +2 -0
  68. package/dist/types/Formatter/Formatter.d.ts +32 -0
  69. package/dist/types/Formatter/index.d.ts +2 -0
  70. package/dist/types/HTML/HTMLDocumentModel.d.ts +13 -0
  71. package/dist/types/HTML/HTMLToGreenNode.d.ts +4 -0
  72. package/dist/types/Incremental/BracketIndex.d.ts +101 -0
  73. package/dist/types/Incremental/ChangeTracker.d.ts +69 -0
  74. package/dist/types/Incremental/IncrementalParser.d.ts +246 -0
  75. package/dist/types/Incremental/__tests__/BracketIndex.test.d.ts +1 -0
  76. package/dist/types/Incremental/__tests__/IncrementalParser.test.d.ts +1 -0
  77. package/dist/types/Incremental/index.d.ts +4 -0
  78. package/dist/types/Lexer/BBCodeLexer.d.ts +74 -0
  79. package/dist/types/Lexer/Lexer.d.ts +54 -0
  80. package/dist/types/Lexer/index.d.ts +4 -0
  81. package/dist/types/Linter/Linter.d.ts +58 -0
  82. package/dist/types/Linter/index.d.ts +2 -0
  83. package/dist/types/Markdown/MarkdownAST.d.ts +122 -0
  84. package/dist/types/Markdown/MarkdownDocumentModel.d.ts +15 -0
  85. package/dist/types/Markdown/MarkdownLexer.d.ts +155 -0
  86. package/dist/types/Markdown/MarkdownParser.d.ts +42 -0
  87. package/dist/types/Markdown/MarkdownToGreenNode.d.ts +6 -0
  88. package/dist/types/MilHibri/MilHibriDocumentModel.d.ts +21 -0
  89. package/dist/types/Model/DocumentModel.d.ts +318 -0
  90. package/dist/types/Model/NodeFactory.d.ts +29 -0
  91. package/dist/types/Model/TagRegistry.d.ts +165 -0
  92. package/dist/types/Model/index.d.ts +5 -0
  93. package/dist/types/Plugins/PluginAPI.d.ts +44 -0
  94. package/dist/types/Plugins/PluginRegistry.d.ts +81 -0
  95. package/dist/types/Plugins/index.d.ts +3 -0
  96. package/dist/types/Queries/QueryEngine.d.ts +32 -0
  97. package/dist/types/Queries/index.d.ts +1 -0
  98. package/dist/types/Reconciler/SurgicalReconciler.d.ts +23 -0
  99. package/dist/types/RenderPipeline/RenderPipeline.d.ts +46 -0
  100. package/dist/types/RenderPipeline/RenderTree.d.ts +55 -0
  101. package/dist/types/RenderPipeline/index.d.ts +4 -0
  102. package/dist/types/Repair/NestingRepair.d.ts +48 -0
  103. package/dist/types/Repair/index.d.ts +1 -0
  104. package/dist/types/Semantic/SemanticAnalyzer.d.ts +321 -0
  105. package/dist/types/Semantic/index.d.ts +2 -0
  106. package/dist/types/Symbols/SymbolTable.d.ts +47 -0
  107. package/dist/types/Symbols/index.d.ts +1 -0
  108. package/dist/types/Syntax/GreenNode.d.ts +154 -0
  109. package/dist/types/Syntax/GreenNodePool.d.ts +134 -0
  110. package/dist/types/Syntax/NodeMatcher.d.ts +75 -0
  111. package/dist/types/Syntax/RedNode.d.ts +280 -0
  112. package/dist/types/Syntax/RedNodeStore.d.ts +116 -0
  113. package/dist/types/Syntax/TreeBuilder.d.ts +54 -0
  114. package/dist/types/Syntax/__tests__/GreenNode.test.d.ts +1 -0
  115. package/dist/types/Syntax/__tests__/RedNode.test.d.ts +1 -0
  116. package/dist/types/Syntax/__tests__/RedNodeStore.test.d.ts +1 -0
  117. package/dist/types/Syntax/greenEdit.d.ts +64 -0
  118. package/dist/types/Syntax/hash.d.ts +17 -0
  119. package/dist/types/Syntax/index.d.ts +8 -0
  120. package/dist/types/Syntax/nodeAttr.d.ts +63 -0
  121. package/dist/types/Syntax/partition.d.ts +67 -0
  122. package/dist/types/Syntax/preserveNodeIds.d.ts +47 -0
  123. package/dist/types/Tokens/TokenResolver.d.ts +25 -0
  124. package/dist/types/Tokens/index.d.ts +1 -0
  125. package/dist/types/Transactions/Transaction.d.ts +36 -0
  126. package/dist/types/Transactions/UndoManager.d.ts +63 -0
  127. package/dist/types/Transactions/index.d.ts +3 -0
  128. package/dist/types/Transformers/ASTOptimizer.d.ts +34 -0
  129. package/dist/types/Transformers/GradientTransformer.d.ts +18 -0
  130. package/dist/types/Transformers/GrowTransformer.d.ts +16 -0
  131. package/dist/types/Transformers/RainbowTransformer.d.ts +17 -0
  132. package/dist/types/Transformers/SineWaveTransformer.d.ts +17 -0
  133. package/dist/types/Transformers/Transformer.d.ts +17 -0
  134. package/dist/types/Types/core.d.ts +104 -0
  135. package/dist/types/Types/diagnostics.d.ts +85 -0
  136. package/dist/types/Types/index.d.ts +11 -0
  137. package/dist/types/Types/operations.d.ts +109 -0
  138. package/dist/types/Types/queries.d.ts +67 -0
  139. package/dist/types/Types/symbols.d.ts +42 -0
  140. package/dist/types/Types/tokens.d.ts +75 -0
  141. package/dist/types/Utils/BBCodeGenerator.d.ts +21 -0
  142. package/dist/types/Utils/ColorMath.d.ts +92 -0
  143. package/dist/types/Utils/EffectMath.d.ts +643 -0
  144. package/dist/types/Utils/TagVocabulary.d.ts +104 -0
  145. package/dist/types/Utils/TagVocabulary.test.d.ts +1 -0
  146. package/dist/types/Utils/color.d.ts +29 -0
  147. package/dist/types/Utils/dom-to-svg.d.ts +28 -0
  148. package/dist/types/Utils/dom-to-svg.test.d.ts +1 -0
  149. package/dist/types/Utils/treeTransformers.d.ts +160 -0
  150. package/dist/types/Visitors/BBBlocksExporter.d.ts +27 -0
  151. package/dist/types/Visitors/BBCodeExporter.d.ts +90 -0
  152. package/dist/types/Visitors/BlockPatcher.d.ts +91 -0
  153. package/dist/types/Visitors/DOMMorpher.d.ts +19 -0
  154. package/dist/types/Visitors/HTMLRenderer.d.ts +492 -0
  155. package/dist/types/Visitors/JSONExporter.d.ts +42 -0
  156. package/dist/types/Visitors/MarkdownExporter.d.ts +28 -0
  157. package/dist/types/Visitors/SVGRenderer.d.ts +16 -0
  158. package/dist/types/Visitors/TiptapExporter.d.ts +40 -0
  159. package/dist/types/Visitors/Visitor.d.ts +35 -0
  160. package/dist/types/Visitors/index.d.ts +9 -0
  161. package/dist/types/Visuals/BoxDrawer.d.ts +54 -0
  162. package/dist/types/Visuals/LyneAudio.d.ts +8 -0
  163. package/dist/types/Visuals/index.d.ts +39 -0
  164. package/dist/types/index.d.ts +155 -0
  165. package/package.json +14 -10
  166. package/src/Analysis/Passes/Analysis/ColorUsageAnalyzer.ts +103 -0
  167. package/src/Analysis/Passes/Analysis/GradientAnalyzer.ts +294 -20
  168. package/src/Analysis/Passes/Analysis/SymbolAnalyzer.ts +257 -0
  169. package/src/Analysis/Passes/Decision/PaletteRemapDecision.ts +425 -0
  170. package/src/Analysis/Pipeline/Pipeline.ts +15 -2
  171. package/src/Analysis/index.ts +8 -2
  172. package/src/BBCode/BBCodeDocumentModel.ts +39 -1
  173. package/src/BBCode/BBCodeToGreenNode.ts +18 -12
  174. package/src/BBCode/Parser.ts +38 -1
  175. package/src/Edits/EditPlan.ts +290 -0
  176. package/src/Edits/Optimizer.ts +185 -0
  177. package/src/Edits/Rules/Rule.ts +145 -0
  178. package/src/Edits/Rules/dropEmptyTags.ts +110 -0
  179. package/src/Edits/Rules/dropRedundantNesting.ts +120 -0
  180. package/src/Edits/Rules/index.ts +28 -0
  181. package/src/Edits/Rules/mergeAdjacent.ts +224 -0
  182. package/src/Edits/Rules/reorderWrappers.ts +154 -0
  183. package/src/Edits/Rules/shortenHex.ts +79 -0
  184. package/src/Edits/Rules/tagValue.ts +105 -0
  185. package/src/Edits/Rules/unwrapInvisibleColor.ts +93 -0
  186. package/src/Edits/applyEdits.ts +54 -0
  187. package/src/Edits/index.ts +22 -0
  188. package/src/Events/EventBus.ts +18 -0
  189. package/src/HTML/HTMLToGreenNode.ts +193 -37
  190. package/src/Incremental/BracketIndex.ts +249 -0
  191. package/src/Incremental/IncrementalParser.ts +464 -41
  192. package/src/Lexer/BBCodeLexer.ts +25 -3
  193. package/src/Lexer/index.ts +1 -1
  194. package/src/Markdown/MarkdownAST.ts +65 -2
  195. package/src/Markdown/MarkdownLexer.ts +69 -14
  196. package/src/Markdown/MarkdownParser.ts +749 -62
  197. package/src/Markdown/MarkdownToGreenNode.ts +148 -27
  198. package/src/MilHibri/MilHibriDocumentModel.ts +56 -0
  199. package/src/Model/DocumentModel.ts +138 -5
  200. package/src/Model/TagRegistry.ts +129 -134
  201. package/src/Reconciler/SurgicalReconciler.ts +460 -0
  202. package/src/Repair/NestingRepair.ts +116 -0
  203. package/src/Repair/index.ts +1 -0
  204. package/src/Semantic/SemanticAnalyzer.ts +1845 -65
  205. package/src/Syntax/GreenNodePool.ts +12 -12
  206. package/src/Syntax/RedNode.ts +17 -3
  207. package/src/Syntax/RedNodeStore.ts +9 -1
  208. package/src/Syntax/nodeAttr.ts +172 -0
  209. package/src/Tokens/TokenResolver.ts +55 -0
  210. package/src/Tokens/index.ts +1 -0
  211. package/src/Types/core.ts +15 -0
  212. package/src/Utils/ColorMath.ts +276 -55
  213. package/src/Utils/EffectMath.ts +2462 -0
  214. package/src/Utils/TagVocabulary.ts +141 -0
  215. package/src/Utils/dom-to-svg.ts +28 -12
  216. package/src/Visitors/BBBlocksExporter.ts +15 -1
  217. package/src/Visitors/BBCodeExporter.ts +365 -32
  218. package/src/Visitors/BlockPatcher.ts +123 -22
  219. package/src/Visitors/DOMMorpher.ts +44 -28
  220. package/src/Visitors/HTMLRenderer.ts +771 -205
  221. package/src/Visitors/MarkdownExporter.ts +98 -41
  222. package/src/Visitors/TiptapExporter.ts +1 -0
  223. package/src/Visitors/index.ts +1 -1
  224. package/src/Visuals/BoxDrawer.ts +56 -10
  225. package/src/Visuals/LyneAudio.ts +346 -0
  226. package/src/Visuals/index.ts +8 -4
  227. package/src/Visuals/lyne.css +374 -9
  228. package/src/Visuals/osu.css +393 -11
  229. package/src/index.ts +178 -8
  230. package/dist/index.d.mts +0 -4612
  231. package/dist/index.d.ts +0 -4612
  232. package/src/Analysis/Passes/Analysis/__tests__/GradientAnalyzer.test.ts +0 -135
  233. package/src/Analysis/Passes/Analysis/__tests__/MergeableColorAnalyzer.test.ts +0 -84
  234. package/src/Analysis/Passes/Analysis/__tests__/RainbowAnalyzer.test.ts +0 -99
  235. package/src/Analysis/Passes/Analysis/__tests__/WaveAnalyzer.test.ts +0 -119
  236. package/src/Analysis/Passes/Decision/__tests__/DefaultDecision.test.ts +0 -179
  237. package/src/Analysis/__tests__/Integration.test.ts +0 -162
  238. package/src/Analysis/__tests__/Pipeline.test.ts +0 -133
  239. package/src/Diff/__tests__/TreeDiffer.test.ts +0 -65
  240. package/src/Incremental/__tests__/IncrementalParser.test.ts +0 -164
  241. package/src/Syntax/__tests__/GreenNode.test.ts +0 -33
  242. package/src/Syntax/__tests__/RedNode.test.ts +0 -81
  243. package/src/Syntax/__tests__/RedNodeStore.test.ts +0 -104
  244. package/src/Tests/ASTOptimizerIdempotence.test.ts +0 -77
  245. package/src/Tests/BlockPatcher.test.ts +0 -437
  246. package/src/Tests/BlockPatcherWindowed.test.ts +0 -364
  247. package/src/Tests/BoxDrawer.test.ts +0 -217
  248. package/src/Tests/BoxRichTitle.test.ts +0 -105
  249. package/src/Tests/Chars500kBenchmark.test.ts +0 -151
  250. package/src/Tests/Chars500kEdits.test.ts +0 -321
  251. package/src/Tests/CollabPositions.test.ts +0 -146
  252. package/src/Tests/CompilerPathProfiling.test.ts +0 -186
  253. package/src/Tests/DOMMorpher.test.ts +0 -142
  254. package/src/Tests/DomPatchPerf.test.ts +0 -60
  255. package/src/Tests/EffectSegments.snapshot.json +0 -616
  256. package/src/Tests/EffectSegments.test.ts +0 -68
  257. package/src/Tests/FindNodeAtOffset.test.ts +0 -65
  258. package/src/Tests/Fuzzer.test.ts +0 -166
  259. package/src/Tests/GreenNodePool.test.ts +0 -153
  260. package/src/Tests/Lexer.test.ts +0 -238
  261. package/src/Tests/LyneMode.test.ts +0 -291
  262. package/src/Tests/ModelCoherence.test.ts +0 -180
  263. package/src/Tests/Partition.test.ts +0 -238
  264. package/src/Tests/PluginTags.test.ts +0 -150
  265. package/src/Tests/ProblematicSection.test.ts +0 -46
  266. package/src/Tests/ProblematicSectionHTML.test.ts +0 -58
  267. package/src/Tests/RedReuse.test.ts +0 -134
  268. package/src/Tests/ReproDelete20k.test.ts +0 -62
  269. package/src/Tests/SemanticValidators.test.ts +0 -136
  270. package/src/Tests/StableNodeIds.test.ts +0 -210
  271. package/src/Tests/StudioColorBloat.test.ts +0 -25
  272. package/src/Tests/StudioDebugText.test.ts +0 -27
  273. package/src/Tests/StudioTrailingChar.test.ts +0 -25
  274. package/src/Tests/StudioValidText.test.ts +0 -25
  275. package/src/Tests/UrlImgBug.test.ts +0 -23
  276. package/src/Tests/VisualBuilderFidelity.test.ts +0 -105
  277. package/src/Tests/referenceDocument.ts +0 -119
  278. package/src/Utils/dom-to-svg.test.ts +0 -86
@@ -12,6 +12,12 @@ import { RedNode } from '../Syntax/RedNode'
12
12
  import { Visitor } from './Visitor'
13
13
  import type { VisitorContext } from './Visitor'
14
14
  import { TagRegistry, type TagDefinition } from '../Model/TagRegistry'
15
+ import {
16
+ toTokenResolver,
17
+ resolveTokenValue,
18
+ type TokenResolverFn,
19
+ type TokenSource,
20
+ } from '../Tokens'
15
21
 
16
22
  /**
17
23
  * Export target for BBCodeExporter.
@@ -21,8 +27,42 @@ import { TagRegistry, type TagDefinition } from '../Model/TagRegistry'
21
27
  */
22
28
  export type ExportTarget = 'osu' | 'miliastry' | 'lyne'
23
29
 
30
+ export interface BBCodeExporterOptions {
31
+ registry?: TagRegistry
32
+ target?: ExportTarget
33
+ tokens?: TokenSource
34
+ resolveTokens?: boolean
35
+ }
36
+
37
+ export type BBCodeExportOptions = {
38
+ target?: ExportTarget
39
+ tokens?: TokenSource
40
+ resolveTokens?: boolean
41
+ }
42
+
43
+ /**
44
+ * Referencia a un token de diseño dentro de un texto: `$nombre`.
45
+ *
46
+ * A nivel de módulo porque el sitio caliente es la rama de nodo TEXTO, la
47
+ * clase de nodo más frecuente de cualquier documento: un literal de expresión
48
+ * regular ahí dentro construye un `RegExp` por nodo. Había además tres copias
49
+ * idénticas del mismo patrón repartidas por el fichero.
50
+ */
51
+ const TOKEN_REF_RE = /\$([a-zA-Z_][a-zA-Z0-9_-]*)/g
52
+
53
+ /** Sustituye cada `$token` por su valor; deja intacto el que no resuelva. */
54
+ function expandTokenRefs(text: string, resolve: TokenResolverFn): string {
55
+ // `indexOf` antes que `replace`: la inmensa mayoría de los textos no lleva
56
+ // ningún `$`, y así no se pone en marcha el motor de expresiones regulares.
57
+ if (text.indexOf('$') === -1) return text
58
+ return text.replace(TOKEN_REF_RE, (match, name: string) => {
59
+ const resolved = resolve(name) ?? resolve(match)
60
+ return resolved !== undefined ? resolved : match
61
+ })
62
+ }
63
+
24
64
  /** Miliastry-native effect tags that osu! doesn't support natively */
25
- const MILIASTRY_INTERNAL_TAGS = new Set(['gradient', 'grow', 'sinewave', 'rainbow'])
65
+ const MILIASTRY_INTERNAL_TAGS = new Set(['gradient', 'grow', 'sinewave', 'rainbow', 'paint'])
26
66
 
27
67
  /**
28
68
  * Tags que existen SOLO en Miliastry (registrados en el TagRegistry) y que
@@ -39,6 +79,9 @@ export const LYNE_ONLY_TAGS = new Set([
39
79
  'boxw', 'wnotice', 'tables', 'table_row', 'table_col', 'table_th', 'gallery', 'columns',
40
80
  'separator', 'scroll', 'sup', 'sub', 'abbr', 'mark', 'kbd', 'tooltip', 'flip',
41
81
  'raw', 'plain', 'guild', 'map', 'align', 'effect', 'anim', 'container', 'style_tag',
82
+ 'neon', 'shimmer', 'glitch', 'typewriter', 'wave', 'fire', 'ice', 'ghost', 'glow',
83
+ 'outline', 'emboss', 'engrave', 'pulse', 'bounce', 'shake', 'levitate', 'fade-in', 'fade-out',
84
+ 'card', 'glass', 'neon-box', 'square', 'row', 'col', 'th',
42
85
  ])
43
86
 
44
87
  /**
@@ -89,11 +132,17 @@ const KIND_TO_TAG_NAME: Record<string, string> = {
89
132
  flower: 'flower',
90
133
  gradient: 'gradient',
91
134
  grow: 'grow',
135
+ rainbow: 'rainbow',
136
+ sinewave: 'sinewave',
137
+ paint: 'paint',
92
138
  align: 'align',
93
139
  tables: 'tables',
94
140
  table_row: 'row',
95
141
  table_col: 'col',
96
142
  table_th: 'th',
143
+ row: 'row',
144
+ col: 'col',
145
+ th: 'th',
97
146
  gallery: 'gallery',
98
147
  columns: 'columns',
99
148
  separator: 'separator',
@@ -111,18 +160,147 @@ const KIND_TO_TAG_NAME: Record<string, string> = {
111
160
  anim: 'anim',
112
161
  container: 'container',
113
162
  style_tag: 'style',
163
+ neon: 'neon',
164
+ shimmer: 'shimmer',
165
+ glitch: 'glitch',
166
+ typewriter: 'typewriter',
167
+ wave: 'wave',
168
+ fire: 'fire',
169
+ ice: 'ice',
170
+ ghost: 'ghost',
171
+ glow: 'glow',
172
+ outline: 'outline',
173
+ emboss: 'emboss',
174
+ engrave: 'engrave',
175
+ pulse: 'pulse',
176
+ bounce: 'bounce',
177
+ shake: 'shake',
178
+ levitate: 'levitate',
179
+ 'fade-in': 'fade-in',
180
+ 'fade-out': 'fade-out',
181
+ card: 'card',
182
+ glass: 'glass',
183
+ 'neon-box': 'neon-box',
184
+ square: 'square',
185
+ }
186
+
187
+
188
+ /**
189
+ * Whether a parsed attribute actually carries a value worth re-emitting.
190
+ *
191
+ * The parser stores an empty string for the attribute of a bare tag, so a
192
+ * plain `[box]` arrives here with `rawTitle: ''` rather than `undefined`.
193
+ * The guards below used to test `!== undefined`, which an empty string
194
+ * passes — exporting `[box]` as `[box=]` and `[quote]` as `[quote=""]`.
195
+ * Both are corruption, not normalization: bare boxes and quotes are
196
+ * ordinary BBCode and must round-trip untouched.
197
+ */
198
+ function hasAttrValue(value: unknown): boolean {
199
+ return value !== undefined && value !== null && String(value) !== ''
200
+ }
201
+
202
+ /**
203
+ * Expande un cuerpo hexadecimal a los 6 dígitos que exige osu, o `null` si no
204
+ * es un hex de longitud reconocible (3, 4, 6 u 8).
205
+ *
206
+ * El canal alfa se descarta a propósito: osu no soporta transparencia en
207
+ * ninguna forma, así que la alternativa a perderlo es publicar un `[color=…]`
208
+ * que su parser rechaza y deja como texto literal en la página.
209
+ */
210
+ function expandHexForOsu(body: string): string | null {
211
+ if (!/^[0-9a-fA-F]+$/.test(body)) return null
212
+ switch (body.length) {
213
+ case 3:
214
+ return body[0] + body[0] + body[1] + body[1] + body[2] + body[2]
215
+ case 4:
216
+ // RGBA corto: expandimos RGB y tiramos el alfa.
217
+ return body[0] + body[0] + body[1] + body[1] + body[2] + body[2]
218
+ case 6:
219
+ // Ya es legal: se devuelve tal cual, sin tocar mayúsculas del autor.
220
+ return body
221
+ case 8:
222
+ // RGBA largo: los dos últimos dígitos son el alfa.
223
+ return body.slice(0, 6)
224
+ default:
225
+ return null
226
+ }
114
227
  }
115
228
 
229
+ /**
230
+ * `target` decide si además de convertir `rgb()` hay que adaptar el hex a la
231
+ * gramática de osu (`BBCodeForDB::parseColour`), que sólo acepta `#` + 6
232
+ * dígitos hex o una secuencia puramente alfabética. Para 'miliastry' y 'lyne'
233
+ * el comportamiento es el de siempre: no se reescribe nada.
234
+ */
235
+ function normalizeColorToHex(color: string, target: ExportTarget = 'miliastry'): string {
236
+ if (!color) return color
237
+ const trimmed = color.trim()
238
+ // Un hex ya es canónico: bajarlo a minúsculas reescribía `[color=#FF0000]`
239
+ // del autor en cada exportación, y `Analysis/RoundTrip` fija lo contrario.
240
+ if (trimmed.startsWith('#')) {
241
+ if (target !== 'osu') return trimmed
242
+ const expanded = expandHexForOsu(trimmed.slice(1))
243
+ return expanded === null ? trimmed : `#${expanded}`
244
+ }
245
+
246
+ const rgbMatch = trimmed.match(/^rgba?\s*\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)(?:\s*,\s*([\d.]+))?\s*\)$/i)
247
+ if (rgbMatch) {
248
+ const r = parseInt(rgbMatch[1], 10).toString(16).padStart(2, '0')
249
+ const g = parseInt(rgbMatch[2], 10).toString(16).padStart(2, '0')
250
+ const b = parseInt(rgbMatch[3], 10).toString(16).padStart(2, '0')
251
+ return `#${r}${g}${b}`.toLowerCase()
252
+ }
253
+
254
+ // Hex desnudo: osu exige el `#`, así que `ff0000` se publicaría roto. Sólo
255
+ // lo tratamos como hex si trae algún dígito; una palabra puramente
256
+ // alfabética (`red`, y también `beef`, que es hex válido) ya matchea la
257
+ // alternativa alfabética de osu y no hay que tocarla.
258
+ if (target === 'osu' && /\d/.test(trimmed)) {
259
+ const expanded = expandHexForOsu(trimmed)
260
+ if (expanded !== null) return `#${expanded}`
261
+ }
262
+
263
+ return trimmed
264
+ }
116
265
 
117
266
  export class BBCodeExporter extends Visitor<string> {
118
267
  private registry: TagRegistry
119
268
  private depth: number = 0
120
269
  private target: ExportTarget
270
+ private tokens?: TokenSource
271
+ private tokenResolver?: TokenResolverFn
272
+ private explicitResolveTokens?: boolean
273
+ /**
274
+ * El resolvedor a aplicar en ESTE recorrido, o `null` si no hay expansión.
275
+ *
276
+ * Se decide una vez en `visit`, no por nodo: `shouldResolveTokens()` da la
277
+ * misma respuesta para todo el documento, y se estaba llamando en la rama
278
+ * de nodo texto, la más frecuente que hay.
279
+ */
280
+ private expandTokens: TokenResolverFn | null = null
121
281
 
122
- constructor(registry: TagRegistry = new TagRegistry(), target: ExportTarget = 'osu') {
282
+ constructor(
283
+ registryOrOptions?: TagRegistry | BBCodeExporterOptions,
284
+ target: ExportTarget = 'osu',
285
+ options?: BBCodeExporterOptions | { tokens?: TokenSource; resolveTokens?: boolean },
286
+ ) {
123
287
  super()
124
- this.registry = registry
125
- this.target = target
288
+ if (registryOrOptions && !(registryOrOptions instanceof TagRegistry) && !('get' in registryOrOptions)) {
289
+ const opts = registryOrOptions as BBCodeExporterOptions
290
+ this.registry = opts.registry ?? new TagRegistry()
291
+ this.target = opts.target ?? 'osu'
292
+ this.tokens = opts.tokens
293
+ this.tokenResolver = toTokenResolver(opts.tokens)
294
+ this.explicitResolveTokens = opts.resolveTokens
295
+ } else {
296
+ this.registry = (registryOrOptions as TagRegistry) ?? new TagRegistry()
297
+ this.target = target
298
+ if (options) {
299
+ this.tokens = options.tokens
300
+ this.tokenResolver = toTokenResolver(options.tokens)
301
+ this.explicitResolveTokens = options.resolveTokens
302
+ }
303
+ }
126
304
  }
127
305
 
128
306
  /**
@@ -132,30 +310,95 @@ export class BBCodeExporter extends Visitor<string> {
132
310
  this.target = target
133
311
  }
134
312
 
313
+ setTokens(tokens?: TokenSource): void {
314
+ this.tokens = tokens
315
+ this.tokenResolver = toTokenResolver(tokens)
316
+ }
317
+
318
+ getTokens(): TokenSource | undefined {
319
+ return this.tokens
320
+ }
321
+
322
+ getTokenResolver(): TokenResolverFn | undefined {
323
+ return this.tokenResolver
324
+ }
325
+
326
+ private shouldResolveTokens(): boolean {
327
+ if (this.explicitResolveTokens !== undefined) {
328
+ return this.explicitResolveTokens
329
+ }
330
+ return this.target === 'osu'
331
+ }
332
+
135
333
  /**
136
334
  * Export a RedNode tree to BBCode.
137
335
  */
138
336
  visit(node: RedNode, context?: VisitorContext): string {
139
337
  if (context) this.context = context
140
338
  this.depth = 0
339
+ this.expandTokens =
340
+ this.tokenResolver !== undefined && this.shouldResolveTokens() ? this.tokenResolver : null
141
341
  return this.exportNode(node)
142
342
  }
143
343
 
144
344
  /**
145
345
  * Export the entire document to BBCode.
146
- * Optionally override the export target for this specific call.
346
+ * Optionally override the export target or options for this specific call.
147
347
  */
148
- export(root: RedNode, target?: ExportTarget): string {
149
- if (target !== undefined) this.target = target
348
+ export(
349
+ root: RedNode,
350
+ targetOrOptions?: ExportTarget | BBCodeExportOptions,
351
+ options?: BBCodeExportOptions,
352
+ ): string {
353
+ if (typeof targetOrOptions === 'string') {
354
+ this.target = targetOrOptions
355
+ if (options?.tokens !== undefined) {
356
+ this.setTokens(options.tokens)
357
+ }
358
+ if (options?.resolveTokens !== undefined) {
359
+ this.explicitResolveTokens = options.resolveTokens
360
+ }
361
+ } else if (targetOrOptions && typeof targetOrOptions === 'object') {
362
+ if (targetOrOptions.target !== undefined) {
363
+ this.target = targetOrOptions.target
364
+ }
365
+ if (targetOrOptions.tokens !== undefined) {
366
+ this.setTokens(targetOrOptions.tokens)
367
+ }
368
+ if (targetOrOptions.resolveTokens !== undefined) {
369
+ this.explicitResolveTokens = targetOrOptions.resolveTokens
370
+ }
371
+ }
150
372
  return this.visit(root)
151
373
  }
152
374
 
375
+ /**
376
+ * Exporta los hijos directos y los concatena.
377
+ *
378
+ * Reemplaza el `children.map(...).join('')`, que asignaba un cierre y un
379
+ * array intermedio de N cadenas en cada nivel del árbol. `HTMLRenderer`
380
+ * hizo este mismo cambio y dejó la nota; el exportador se quedó atrás, y es
381
+ * el camino que corre bajo un límite de 60.000 caracteres.
382
+ */
383
+ private exportChildren(node: RedNode): string {
384
+ const children = node.children
385
+ let out = ''
386
+ for (let i = 0; i < children.length; i++) out += this.exportNode(children[i])
387
+ return out
388
+ }
389
+
153
390
  private exportNode(node: RedNode): string {
391
+ // Un cierre que no cerró nada no vuelve al source. Escribirlo hacía que el
392
+ // siguiente parseo lo leyera otra vez como etiqueta viva, y el documento no
393
+ // convergía al reexportarlo.
394
+ if (node.kind === 'discarded_tag') return ''
395
+
154
396
  const tagDef = this.registry.getByKind(node.kind)
155
397
 
156
398
  // Text nodes
157
399
  if (node.kind === 'text') {
158
400
  let out = node.text
401
+ if (this.expandTokens !== null) out = expandTokenRefs(out, this.expandTokens)
159
402
  const style = node.metadata?.style as Record<string, string> | undefined
160
403
  if (style) {
161
404
  // Envolvemos desde adentro hacia afuera (o al revés, no importa mucho para osu)
@@ -163,8 +406,20 @@ export class BBCodeExporter extends Visitor<string> {
163
406
  if (style.fontStyle === 'italic') out = `[i]${out}[/i]`
164
407
  if (style.textDecoration === 'underline') out = `[u]${out}[/u]`
165
408
  if (style.textDecoration === 'line-through') out = `[s]${out}[/s]`
166
- if (style.color) out = `[color=${style.color}]${out}[/color]`
167
- if (style.fontSize) out = `[size=${style.fontSize}]${out}[/size]`
409
+ if (style.color) {
410
+ let col = style.color
411
+ if (this.shouldResolveTokens() && col.startsWith('$')) {
412
+ col = resolveTokenValue(col, this.tokenResolver)
413
+ }
414
+ out = `[color=${normalizeColorToHex(col, this.target)}]${out}[/color]`
415
+ }
416
+ if (style.fontSize) {
417
+ let size = style.fontSize
418
+ if (this.shouldResolveTokens() && size.startsWith('$')) {
419
+ size = resolveTokenValue(size, this.tokenResolver)
420
+ }
421
+ out = `[size=${size}]${out}[/size]`
422
+ }
168
423
  // Se pueden seguir sumando estilos dinámicos
169
424
  }
170
425
  return out
@@ -172,7 +427,7 @@ export class BBCodeExporter extends Visitor<string> {
172
427
 
173
428
  // Document root
174
429
  if (node.kind === 'document') {
175
- return node.children.map(c => this.exportNode(c)).join('')
430
+ return this.exportChildren(node)
176
431
  }
177
432
 
178
433
  // Custom tag handlers from registry
@@ -196,7 +451,7 @@ export class BBCodeExporter extends Visitor<string> {
196
451
  // Default BBCode serialization
197
452
  let tagName = this.kindToTagName(node.kind)
198
453
  const attrs = this.getTagAttributes(node)
199
- const content = node.children.map(c => this.exportNode(c)).join('')
454
+ const content = this.exportChildren(node)
200
455
 
201
456
  if (!tagName) {
202
457
  if (node.kind === 'spacing') return '\n'
@@ -246,38 +501,78 @@ export class BBCodeExporter extends Visitor<string> {
246
501
  if (tag?.properties) {
247
502
  const parts: string[] = []
248
503
  for (const prop of tag.properties) {
249
- const val = node.metadata[prop.name]
504
+ let val = node.metadata[prop.name]
250
505
  if (val !== undefined && val !== null) {
506
+ if (this.shouldResolveTokens() && typeof val === 'string' && val.startsWith('$')) {
507
+ val = resolveTokenValue(val, this.tokenResolver)
508
+ }
251
509
  parts.push(`${prop.name}=${val}`)
252
510
  }
253
511
  }
254
512
  if (parts.length > 0) return `=${parts[0]}`
255
513
  }
256
- } else if (node.kind === 'font_size' && node.metadata.size !== undefined) {
257
- return `=${node.metadata.size}`
258
- } else if (node.kind === 'color' && node.metadata.color !== undefined) {
259
- return `=${node.metadata.color}`
260
- } else if (node.kind === 'font' && node.metadata.font !== undefined) {
261
- return `=${node.metadata.font}`
514
+ } else if (node.kind === 'font_size' && hasAttrValue(node.metadata.size)) {
515
+ let size = String(node.metadata.size)
516
+ if (this.shouldResolveTokens() && size.startsWith('$')) {
517
+ size = resolveTokenValue(size, this.tokenResolver)
518
+ }
519
+ return `=${size}`
520
+ } else if (node.kind === 'color' && hasAttrValue(node.metadata.color)) {
521
+ let color = String(node.metadata.color)
522
+ if (this.shouldResolveTokens() && color.startsWith('$')) {
523
+ color = resolveTokenValue(color, this.tokenResolver)
524
+ }
525
+ return `=${normalizeColorToHex(color, this.target)}`
526
+ } else if (node.kind === 'font' && hasAttrValue(node.metadata.font)) {
527
+ let font = String(node.metadata.font)
528
+ if (this.shouldResolveTokens() && font.startsWith('$')) {
529
+ font = resolveTokenValue(font, this.tokenResolver)
530
+ }
531
+ return `=${font}`
262
532
  } else if (node.kind === 'url' && node.metadata.href !== undefined) {
263
533
  return `=${node.metadata.href}`
264
- } else if (node.kind === 'email' && node.metadata.href !== undefined) {
265
- return `=${(node.metadata.href as string).replace('mailto:', '')}`
266
- } else if (node.kind === 'quote' && node.metadata.source !== undefined) {
267
- return `="${node.metadata.source}"`
268
- } else if ((node.kind === 'box' || node.kind === 'boxw' || node.kind === 'spoilerbox') && (node.metadata.rawTitle !== undefined || node.metadata.title !== undefined)) {
269
- const titleVal = node.metadata.rawTitle !== undefined ? node.metadata.rawTitle : node.metadata.title
534
+ } else if (node.kind === 'email' && hasAttrValue(node.metadata.href)) {
535
+ // The parser prefixes the address with `mailto:`, so a bare `[email]`
536
+ // arrives as exactly that prefix and nothing else — test the address.
537
+ const address = (node.metadata.href as string).replace('mailto:', '')
538
+ return address === '' ? '' : `=${address}`
539
+ } else if (node.kind === 'quote' && hasAttrValue(node.metadata.source)) {
540
+ let src = String(node.metadata.source)
541
+ if (this.expandTokens !== null) src = expandTokenRefs(src, this.expandTokens)
542
+ return `="${src}"`
543
+ } else if (node.kind === 'box' || node.kind === 'boxw' || node.kind === 'spoilerbox') {
544
+ // `rawTitle` is the source spelling; `title` is the parsed one, which
545
+ // falls back to a synthetic "Box"/"Spoiler" when the tag is bare. Only
546
+ // the raw form can be trusted to reproduce the input, so it wins.
547
+ const raw = node.metadata.rawTitle
548
+ let titleVal = String((raw !== undefined ? raw : node.metadata.title) ?? '')
549
+ if (this.expandTokens !== null) titleVal = expandTokenRefs(titleVal, this.expandTokens)
270
550
  // `[box=Title:#hex]`: el color se guardó aparte en metadata; se vuelve
271
551
  // a añadir aquí para que el round-trip no pierda el sufijo.
272
- const color = node.metadata.color as string | undefined
552
+ let color = node.metadata.color as string | undefined
553
+ if (this.shouldResolveTokens() && color && color.startsWith('$')) {
554
+ color = resolveTokenValue(color, this.tokenResolver)
555
+ }
556
+ // A bare `[box]` has neither, and must stay bare.
557
+ if (titleVal === '' && !color) return ''
273
558
  return `=${titleVal}${color ? `:${color}` : ''}`
274
559
  } else if ((node.kind === 'notice' || node.kind === 'wnotice') && node.metadata.color !== undefined) {
275
- return `=${node.metadata.color}`
560
+ let color = String(node.metadata.color)
561
+ if (this.shouldResolveTokens() && color.startsWith('$')) {
562
+ color = resolveTokenValue(color, this.tokenResolver)
563
+ }
564
+ return `=${color}`
276
565
  } else if (node.kind === 'tables' && node.metadata.variant !== undefined) {
277
- const color = node.metadata.color as string | undefined
566
+ let color = node.metadata.color as string | undefined
567
+ if (this.shouldResolveTokens() && color && color.startsWith('$')) {
568
+ color = resolveTokenValue(color, this.tokenResolver)
569
+ }
278
570
  return `=${node.metadata.variant}${color ? `:${color}` : ''}`
279
571
  } else if (node.kind === 'columns' && node.metadata.columns !== undefined) {
280
- const color = node.metadata.color as string | undefined
572
+ let color = node.metadata.color as string | undefined
573
+ if (this.shouldResolveTokens() && color && color.startsWith('$')) {
574
+ color = resolveTokenValue(color, this.tokenResolver)
575
+ }
281
576
  return `=${node.metadata.columns}${color ? `:${color}` : ''}`
282
577
  } else if (node.kind === 'separator' && node.metadata.variant !== undefined) {
283
578
  return `=${node.metadata.variant}`
@@ -294,22 +589,60 @@ export class BBCodeExporter extends Visitor<string> {
294
589
  } else if (node.kind === 'align' && node.metadata.align !== undefined) {
295
590
  return `=${node.metadata.align}`
296
591
  } else if (node.kind === 'effect' && node.metadata.effectType !== undefined) {
297
- return `=${node.metadata.effectType}`
592
+ let color = node.metadata.color as string | undefined
593
+ if (this.shouldResolveTokens() && color && color.startsWith('$')) {
594
+ color = resolveTokenValue(color, this.tokenResolver)
595
+ }
596
+ return `=${node.metadata.effectType}${color ? `:${color}` : ''}`
298
597
  } else if (node.kind === 'image' && node.metadata.imgAttr !== undefined) {
299
598
  return `=${node.metadata.imgAttr}`
300
599
  } else if (node.kind === 'anim' && node.metadata.animType !== undefined) {
301
600
  return `=${node.metadata.animType}`
302
601
  } else if (node.kind === 'container' && node.metadata.containerType !== undefined) {
303
- return `=${node.metadata.containerType}`
602
+ let color = node.metadata.color as string | undefined
603
+ if (this.shouldResolveTokens() && color && color.startsWith('$')) {
604
+ color = resolveTokenValue(color, this.tokenResolver)
605
+ }
606
+ return `=${node.metadata.containerType}${color ? `:${color}` : ''}`
304
607
  } else if (node.kind === 'style_tag' && node.metadata.style !== undefined) {
305
- return `=${node.metadata.style}`
608
+ let style = String(node.metadata.style)
609
+ if (this.shouldResolveTokens()) {
610
+ style = style.replace(/\$([a-zA-Z0-9_.-]+)/g, (match) => {
611
+ return resolveTokenValue(match, this.tokenResolver)
612
+ })
613
+ }
614
+ return `=${style}`
615
+ } else if (hasAttrValue(node.metadata.value)) {
616
+ let val = String(node.metadata.value)
617
+ if (this.shouldResolveTokens() && val.startsWith('$')) {
618
+ val = resolveTokenValue(val, this.tokenResolver)
619
+ }
620
+ return `=${val}`
621
+ } else if (hasAttrValue(node.metadata.raw)) {
622
+ let raw = String(node.metadata.raw)
623
+ if (this.shouldResolveTokens() && raw.startsWith('$')) {
624
+ raw = resolveTokenValue(raw, this.tokenResolver)
625
+ }
626
+ return `=${raw}`
306
627
  }
307
628
  }
308
629
 
309
630
  // Fallback: check node text for attribute info
310
631
  const text = node.text || ''
311
632
  if (text.startsWith('=') || text.startsWith(' ')) {
312
- // The text is exactly the attributes string (e.g. "=50" or " min=50 max=200")
633
+ if (node.kind === 'color' && text.startsWith('=')) {
634
+ let col = text.slice(1)
635
+ if (this.shouldResolveTokens() && col.startsWith('$')) {
636
+ col = resolveTokenValue(col, this.tokenResolver)
637
+ }
638
+ return `=${normalizeColorToHex(col, this.target)}`
639
+ }
640
+ if (this.shouldResolveTokens() && text.startsWith('=')) {
641
+ let val = text.slice(1)
642
+ if (val.startsWith('$')) {
643
+ return `=${resolveTokenValue(val, this.tokenResolver)}`
644
+ }
645
+ }
313
646
  return text
314
647
  }
315
648