@miliastry/quasar 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (172) hide show
  1. package/LICENSE +119 -0
  2. package/README.md +45 -0
  3. package/dist/Visuals/lyne.css +1027 -0
  4. package/dist/Visuals/osu.css +257 -0
  5. package/dist/index.d.mts +4563 -0
  6. package/dist/index.d.ts +4563 -0
  7. package/dist/index.js +11291 -0
  8. package/dist/index.mjs +11197 -0
  9. package/package.json +51 -0
  10. package/src/Analysis/Contracts/AnalysisReport.ts +26 -0
  11. package/src/Analysis/Contracts/Contribution.ts +76 -0
  12. package/src/Analysis/Contracts/Pass.ts +76 -0
  13. package/src/Analysis/Contracts/PipelineContext.ts +48 -0
  14. package/src/Analysis/Passes/Analysis/GradientAnalyzer.ts +292 -0
  15. package/src/Analysis/Passes/Analysis/MergeableColorAnalyzer.ts +112 -0
  16. package/src/Analysis/Passes/Analysis/RainbowAnalyzer.ts +233 -0
  17. package/src/Analysis/Passes/Analysis/WaveAnalyzer.ts +211 -0
  18. package/src/Analysis/Passes/Analysis/__tests__/GradientAnalyzer.test.ts +135 -0
  19. package/src/Analysis/Passes/Analysis/__tests__/MergeableColorAnalyzer.test.ts +84 -0
  20. package/src/Analysis/Passes/Analysis/__tests__/RainbowAnalyzer.test.ts +99 -0
  21. package/src/Analysis/Passes/Analysis/__tests__/WaveAnalyzer.test.ts +119 -0
  22. package/src/Analysis/Passes/Decision/DefaultDecision.ts +139 -0
  23. package/src/Analysis/Passes/Decision/__tests__/DefaultDecision.test.ts +179 -0
  24. package/src/Analysis/Passes/Transform/CollapseGradientTransform.ts +176 -0
  25. package/src/Analysis/Passes/Transform/MergeColorsTransform.ts +126 -0
  26. package/src/Analysis/Passes/Transform/RainbowCollapseTransform.ts +83 -0
  27. package/src/Analysis/Passes/Transform/WaveCollapseTransform.ts +88 -0
  28. package/src/Analysis/Passes/Utility/CharacterCountAnalyzer.ts +45 -0
  29. package/src/Analysis/Pipeline/Pipeline.ts +133 -0
  30. package/src/Analysis/Pipeline/PipelineBuilder.ts +55 -0
  31. package/src/Analysis/Pipeline/PipelineStage.ts +19 -0
  32. package/src/Analysis/Utils/color-utils.ts +132 -0
  33. package/src/Analysis/__tests__/Integration.test.ts +162 -0
  34. package/src/Analysis/__tests__/Pipeline.test.ts +133 -0
  35. package/src/Analysis/index.ts +52 -0
  36. package/src/BBCode/BBCodeDocumentModel.ts +175 -0
  37. package/src/BBCode/BBCodeToGreenNode.ts +755 -0
  38. package/src/BBCode/Parser.ts +384 -0
  39. package/src/BBCode/index.ts +12 -0
  40. package/src/Collab/positions.ts +91 -0
  41. package/src/Commands/Command.ts +44 -0
  42. package/src/Commands/CommandRegistry.ts +78 -0
  43. package/src/Commands/DeleteNode.ts +20 -0
  44. package/src/Commands/InsertText.ts +21 -0
  45. package/src/Commands/SplitMerge.ts +28 -0
  46. package/src/Commands/WrapInTag.ts +21 -0
  47. package/src/Commands/index.ts +6 -0
  48. package/src/Diff/TreeDiffer.ts +264 -0
  49. package/src/Diff/__tests__/TreeDiffer.test.ts +65 -0
  50. package/src/Diff/index.ts +2 -0
  51. package/src/Events/EventBus.ts +160 -0
  52. package/src/Events/index.ts +2 -0
  53. package/src/Formatter/Formatter.ts +54 -0
  54. package/src/Formatter/index.ts +2 -0
  55. package/src/HTML/HTMLDocumentModel.ts +35 -0
  56. package/src/HTML/HTMLToGreenNode.ts +290 -0
  57. package/src/Incremental/ChangeTracker.ts +105 -0
  58. package/src/Incremental/IncrementalParser.ts +591 -0
  59. package/src/Incremental/__tests__/IncrementalParser.test.ts +164 -0
  60. package/src/Incremental/index.ts +4 -0
  61. package/src/Lexer/BBCodeLexer.ts +382 -0
  62. package/src/Lexer/Lexer.ts +181 -0
  63. package/src/Lexer/index.ts +10 -0
  64. package/src/Linter/Linter.ts +193 -0
  65. package/src/Linter/index.ts +2 -0
  66. package/src/Markdown/MarkdownAST.ts +112 -0
  67. package/src/Markdown/MarkdownDocumentModel.ts +55 -0
  68. package/src/Markdown/MarkdownLexer.ts +203 -0
  69. package/src/Markdown/MarkdownParser.ts +455 -0
  70. package/src/Markdown/MarkdownToGreenNode.ts +153 -0
  71. package/src/Model/DocumentModel.ts +694 -0
  72. package/src/Model/NodeFactory.ts +117 -0
  73. package/src/Model/TagRegistry.ts +495 -0
  74. package/src/Model/index.ts +5 -0
  75. package/src/Plugins/PluginAPI.ts +119 -0
  76. package/src/Plugins/PluginRegistry.ts +132 -0
  77. package/src/Plugins/index.ts +3 -0
  78. package/src/Queries/QueryEngine.ts +152 -0
  79. package/src/Queries/index.ts +1 -0
  80. package/src/RenderPipeline/RenderPipeline.ts +125 -0
  81. package/src/RenderPipeline/RenderTree.ts +134 -0
  82. package/src/RenderPipeline/index.ts +4 -0
  83. package/src/Semantic/SemanticAnalyzer.ts +506 -0
  84. package/src/Semantic/index.ts +2 -0
  85. package/src/Symbols/SymbolTable.ts +124 -0
  86. package/src/Symbols/index.ts +1 -0
  87. package/src/Syntax/GreenNode.ts +324 -0
  88. package/src/Syntax/GreenNodePool.ts +269 -0
  89. package/src/Syntax/NodeMatcher.ts +370 -0
  90. package/src/Syntax/RedNode.ts +569 -0
  91. package/src/Syntax/RedNodeStore.ts +184 -0
  92. package/src/Syntax/TreeBuilder.ts +214 -0
  93. package/src/Syntax/__tests__/GreenNode.test.ts +33 -0
  94. package/src/Syntax/__tests__/RedNode.test.ts +81 -0
  95. package/src/Syntax/__tests__/RedNodeStore.test.ts +104 -0
  96. package/src/Syntax/greenEdit.ts +110 -0
  97. package/src/Syntax/hash.ts +30 -0
  98. package/src/Syntax/index.ts +12 -0
  99. package/src/Syntax/partition.ts +161 -0
  100. package/src/Syntax/preserveNodeIds.ts +201 -0
  101. package/src/Tests/ASTOptimizerIdempotence.test.ts +77 -0
  102. package/src/Tests/BlockPatcher.test.ts +437 -0
  103. package/src/Tests/BlockPatcherWindowed.test.ts +364 -0
  104. package/src/Tests/BoxDrawer.test.ts +217 -0
  105. package/src/Tests/BoxRichTitle.test.ts +105 -0
  106. package/src/Tests/Chars500kBenchmark.test.ts +151 -0
  107. package/src/Tests/Chars500kEdits.test.ts +321 -0
  108. package/src/Tests/CollabPositions.test.ts +146 -0
  109. package/src/Tests/CompilerPathProfiling.test.ts +186 -0
  110. package/src/Tests/DOMMorpher.test.ts +142 -0
  111. package/src/Tests/DomPatchPerf.test.ts +60 -0
  112. package/src/Tests/EffectSegments.snapshot.json +616 -0
  113. package/src/Tests/EffectSegments.test.ts +68 -0
  114. package/src/Tests/FindNodeAtOffset.test.ts +65 -0
  115. package/src/Tests/Fuzzer.test.ts +166 -0
  116. package/src/Tests/GreenNodePool.test.ts +153 -0
  117. package/src/Tests/Lexer.test.ts +238 -0
  118. package/src/Tests/LyneMode.test.ts +187 -0
  119. package/src/Tests/ModelCoherence.test.ts +180 -0
  120. package/src/Tests/Partition.test.ts +238 -0
  121. package/src/Tests/PluginTags.test.ts +150 -0
  122. package/src/Tests/ProblematicSection.test.ts +46 -0
  123. package/src/Tests/ProblematicSectionHTML.test.ts +58 -0
  124. package/src/Tests/RedReuse.test.ts +134 -0
  125. package/src/Tests/ReproDelete20k.test.ts +62 -0
  126. package/src/Tests/SemanticValidators.test.ts +136 -0
  127. package/src/Tests/StableNodeIds.test.ts +210 -0
  128. package/src/Tests/StudioColorBloat.test.ts +25 -0
  129. package/src/Tests/StudioDebugText.test.ts +27 -0
  130. package/src/Tests/StudioTrailingChar.test.ts +25 -0
  131. package/src/Tests/StudioValidText.test.ts +25 -0
  132. package/src/Tests/UrlImgBug.test.ts +23 -0
  133. package/src/Tests/VisualBuilderFidelity.test.ts +105 -0
  134. package/src/Tests/referenceDocument.ts +119 -0
  135. package/src/Transactions/Transaction.ts +176 -0
  136. package/src/Transactions/UndoManager.ts +111 -0
  137. package/src/Transactions/index.ts +3 -0
  138. package/src/Transformers/ASTOptimizer.ts +315 -0
  139. package/src/Transformers/GradientTransformer.ts +143 -0
  140. package/src/Transformers/GrowTransformer.ts +115 -0
  141. package/src/Transformers/RainbowTransformer.ts +121 -0
  142. package/src/Transformers/SineWaveTransformer.ts +130 -0
  143. package/src/Transformers/Transformer.ts +22 -0
  144. package/src/Types/core.ts +270 -0
  145. package/src/Types/diagnostics.ts +156 -0
  146. package/src/Types/index.ts +23 -0
  147. package/src/Types/operations.ts +180 -0
  148. package/src/Types/queries.ts +121 -0
  149. package/src/Types/symbols.ts +69 -0
  150. package/src/Types/tokens.ts +186 -0
  151. package/src/Utils/BBCodeGenerator.ts +126 -0
  152. package/src/Utils/ColorMath.ts +276 -0
  153. package/src/Utils/color.ts +112 -0
  154. package/src/Utils/dom-to-svg.test.ts +86 -0
  155. package/src/Utils/dom-to-svg.ts +615 -0
  156. package/src/Utils/treeTransformers.ts +717 -0
  157. package/src/Visitors/BBBlocksExporter.ts +69 -0
  158. package/src/Visitors/BBCodeExporter.ts +318 -0
  159. package/src/Visitors/BlockPatcher.ts +963 -0
  160. package/src/Visitors/DOMMorpher.ts +134 -0
  161. package/src/Visitors/HTMLRenderer.ts +1077 -0
  162. package/src/Visitors/JSONExporter.ts +66 -0
  163. package/src/Visitors/MarkdownExporter.ts +99 -0
  164. package/src/Visitors/SVGRenderer.ts +35 -0
  165. package/src/Visitors/TiptapExporter.ts +145 -0
  166. package/src/Visitors/Visitor.ts +48 -0
  167. package/src/Visitors/index.ts +9 -0
  168. package/src/Visuals/BoxDrawer.ts +175 -0
  169. package/src/Visuals/index.ts +42 -0
  170. package/src/Visuals/lyne.css +1027 -0
  171. package/src/Visuals/osu.css +257 -0
  172. package/src/index.ts +197 -0
@@ -0,0 +1,69 @@
1
+ import { Visitor } from './Visitor'
2
+ import type { VisitorContext } from './Visitor'
3
+ import { RedNode } from '../Syntax/RedNode'
4
+
5
+ export interface UIBBBlock {
6
+ id: string
7
+ type: string
8
+ attributes?: Record<string, string>
9
+ children: UIBBBlock[]
10
+ content?: string
11
+ }
12
+
13
+ export class BBBlocksExporter extends Visitor<UIBBBlock[]> {
14
+ private idCounter = 0
15
+
16
+ private generateId(): string {
17
+ this.idCounter++
18
+ return `block-${Date.now().toString(36)}-${this.idCounter}`
19
+ }
20
+
21
+ visit(node: RedNode, context?: VisitorContext): UIBBBlock[] {
22
+ if (context) this.context = context
23
+
24
+ // If it's a document node, we just return the exported children
25
+ if (node.kind === 'document') {
26
+ return node.children.flatMap(child => this.exportNode(child))
27
+ }
28
+
29
+ return [this.exportNode(node)]
30
+ }
31
+
32
+ export(root: RedNode): UIBBBlock[] {
33
+ return this.visit(root)
34
+ }
35
+
36
+ private exportNode(node: RedNode): UIBBBlock {
37
+ const block: UIBBBlock = {
38
+ id: this.generateId(),
39
+ type: node.kind,
40
+ children: []
41
+ }
42
+
43
+ // Process attributes
44
+ if (node.metadata) {
45
+ const attrs: Record<string, string> = {}
46
+ for (const [key, value] of Object.entries(node.metadata)) {
47
+ if (value !== undefined && value !== null) {
48
+ attrs[key] = String(value)
49
+ }
50
+ }
51
+ if (Object.keys(attrs).length > 0) {
52
+ block.attributes = attrs
53
+ }
54
+ }
55
+
56
+ // Process content (leaf nodes or text nodes)
57
+ if (node.kind === 'text') {
58
+ block.content = node.text || ''
59
+ } else {
60
+ // Process children
61
+ block.children = node.children.map(child => this.exportNode(child))
62
+
63
+ // Some tags might store text content directly without children in RedNode representation depending on the parser,
64
+ // but RedNode generally structures text as child nodes of type 'text'.
65
+ }
66
+
67
+ return block
68
+ }
69
+ }
@@ -0,0 +1,318 @@
1
+ /**
2
+ * DocumentEngine — BBCodeExporter
3
+ *
4
+ * Exports the Document Model / Red Tree back to BBCode text.
5
+ * This is how the internal representation becomes editable text.
6
+ *
7
+ * Uses the TagRegistry to determine how each node serializes.
8
+ * Plugins can register custom serializers for custom tags.
9
+ */
10
+
11
+ import { RedNode } from '../Syntax/RedNode'
12
+ import { Visitor } from './Visitor'
13
+ import type { VisitorContext } from './Visitor'
14
+ import { TagRegistry, type TagDefinition } from '../Model/TagRegistry'
15
+
16
+ /**
17
+ * Export target for BBCodeExporter.
18
+ * - 'osu': Expands Miliastry/Lyne-native tags into osu!-compatible BBCode.
19
+ * - 'miliastry': Preserves Miliastry-native tags as-is for round-trip editing.
20
+ * - 'lyne': Preserves Lyne-native tags as-is for round-trip editing.
21
+ */
22
+ export type ExportTarget = 'osu' | 'miliastry' | 'lyne'
23
+
24
+ /** Miliastry-native effect tags that osu! doesn't support natively */
25
+ const MILIASTRY_INTERNAL_TAGS = new Set(['gradient', 'grow', 'sinewave', 'rainbow'])
26
+
27
+ /**
28
+ * Tags que existen SOLO en Miliastry (registrados en el TagRegistry) y que
29
+ * osu! NO renderiza. Si un usuario pega `[shadow]`/`[zalgo]`/etc. en osu!,
30
+ * el texto sale roto (los corchetes se ven literalmente). Cuando el target
31
+ * es 'osu', estos tags se DEGRADAN a su contenido plano: mejor perder el
32
+ * efecto que romper la userpage. El target 'miliastry' los conserva.
33
+ */
34
+ export const MILIASTRY_ONLY_TAGS = new Set([
35
+ 'font', 'zalgo', 'aesthetic', 'sparkle', 'bubble', 'flower', 'svg', 'group',
36
+ ])
37
+
38
+ export const LYNE_ONLY_TAGS = new Set([
39
+ 'boxw', 'wnotice', 'tables', 'table_row', 'table_col', 'table_th', 'gallery', 'columns',
40
+ 'separator', 'scroll', 'sup', 'sub', 'abbr', 'mark', 'kbd', 'tooltip', 'flip',
41
+ 'raw', 'plain', 'guild', 'map', 'align', 'effect', 'anim', 'container', 'style_tag',
42
+ ])
43
+
44
+ /**
45
+ * kind → BBCode tag name, in the osu! spelling (`centre`, `youtube`).
46
+ *
47
+ * Module-level on purpose: this used to be an object literal inside
48
+ * `kindToTagName`, which allocated a fresh 30-key object for every node of
49
+ * every export. Note it is NOT interchangeable with `nodeKindToTag` from
50
+ * `BBCodeToGreenNode` — that map inverts the parser's table, where the last
51
+ * spelling registered wins (`center`), while exports must emit osu!'s.
52
+ */
53
+ const KIND_TO_TAG_NAME: Record<string, string> = {
54
+ bold: 'b',
55
+ italic: 'i',
56
+ underline: 'u',
57
+ strikethrough: 's',
58
+ color: 'color',
59
+ font_size: 'size',
60
+ font: 'font',
61
+ inline_code: 'c',
62
+ code: 'code',
63
+ spoiler: 'spoiler',
64
+ center: 'centre',
65
+ left: 'left',
66
+ right: 'right',
67
+ heading: 'heading',
68
+ notice: 'notice',
69
+ wnotice: 'wnotice',
70
+ url: 'url',
71
+ email: 'email',
72
+ profile: 'profile',
73
+ guild: 'guild',
74
+ map: 'map',
75
+ image: 'img',
76
+ video: 'youtube',
77
+ audio: 'audio',
78
+ imagemap: 'imagemap',
79
+ quote: 'quote',
80
+ spoilerbox: 'spoilerbox',
81
+ box: 'box',
82
+ boxw: 'boxw',
83
+ list: 'list',
84
+ list_item: '*',
85
+ zalgo: 'zalgo',
86
+ aesthetic: 'aesthetic',
87
+ sparkle: 'sparkle',
88
+ bubble: 'bubble',
89
+ flower: 'flower',
90
+ gradient: 'gradient',
91
+ grow: 'grow',
92
+ align: 'align',
93
+ tables: 'tables',
94
+ table_row: 'row',
95
+ table_col: 'col',
96
+ table_th: 'th',
97
+ gallery: 'gallery',
98
+ columns: 'columns',
99
+ separator: 'separator',
100
+ scroll: 'scroll',
101
+ sup: 'sup',
102
+ sub: 'sub',
103
+ abbr: 'abbr',
104
+ mark: 'mark',
105
+ kbd: 'kbd',
106
+ tooltip: 'tooltip',
107
+ flip: 'flip',
108
+ raw: 'raw',
109
+ plain: 'plain',
110
+ effect: 'effect',
111
+ anim: 'anim',
112
+ container: 'container',
113
+ style_tag: 'style',
114
+ }
115
+
116
+
117
+ export class BBCodeExporter extends Visitor<string> {
118
+ private registry: TagRegistry
119
+ private depth: number = 0
120
+ private target: ExportTarget
121
+
122
+ constructor(registry: TagRegistry = new TagRegistry(), target: ExportTarget = 'osu') {
123
+ super()
124
+ this.registry = registry
125
+ this.target = target
126
+ }
127
+
128
+ /**
129
+ * Set the export target. Controls how Miliastry-native tags are serialized.
130
+ */
131
+ setTarget(target: ExportTarget): void {
132
+ this.target = target
133
+ }
134
+
135
+ /**
136
+ * Export a RedNode tree to BBCode.
137
+ */
138
+ visit(node: RedNode, context?: VisitorContext): string {
139
+ if (context) this.context = context
140
+ this.depth = 0
141
+ return this.exportNode(node)
142
+ }
143
+
144
+ /**
145
+ * Export the entire document to BBCode.
146
+ * Optionally override the export target for this specific call.
147
+ */
148
+ export(root: RedNode, target?: ExportTarget): string {
149
+ if (target !== undefined) this.target = target
150
+ return this.visit(root)
151
+ }
152
+
153
+ private exportNode(node: RedNode): string {
154
+ const tagDef = this.registry.getByKind(node.kind)
155
+
156
+ // Text nodes
157
+ if (node.kind === 'text') {
158
+ let out = node.text
159
+ const style = node.metadata?.style as Record<string, string> | undefined
160
+ if (style) {
161
+ // Envolvemos desde adentro hacia afuera (o al revés, no importa mucho para osu)
162
+ if (style.fontWeight === 'bold') out = `[b]${out}[/b]`
163
+ if (style.fontStyle === 'italic') out = `[i]${out}[/i]`
164
+ if (style.textDecoration === 'underline') out = `[u]${out}[/u]`
165
+ 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]`
168
+ // Se pueden seguir sumando estilos dinámicos
169
+ }
170
+ return out
171
+ }
172
+
173
+ // Document root
174
+ if (node.kind === 'document') {
175
+ return node.children.map(c => this.exportNode(c)).join('')
176
+ }
177
+
178
+ // Custom tag handlers from registry
179
+ // When target is 'miliastry', skip custom handlers for Miliastry-native tags
180
+ // so they serialize as-is instead of expanding to osu!-compatible formats.
181
+ if (tagDef?.toBBCode && !(this.target === 'miliastry' && MILIASTRY_INTERNAL_TAGS.has(node.kind))) {
182
+ return tagDef.toBBCode({
183
+ node,
184
+ source: this.context.source,
185
+ visitChildren: (n) => this.exportNode(n),
186
+ renderChild: () => ({ kind: 'text', text: '', children: [], props: {} }),
187
+ })
188
+ }
189
+
190
+ // Tags Miliastry-only / Lyne-only: en target 'osu' se degradan a contenido plano para
191
+ // que el BBCode generado SIEMPRE sea pegable en osu! sin tags rotos.
192
+ if (this.target === 'osu' && (MILIASTRY_ONLY_TAGS.has(node.kind) || LYNE_ONLY_TAGS.has(node.kind))) {
193
+ return node.children.map((c) => this.exportNode(c)).join('')
194
+ }
195
+
196
+ // Default BBCode serialization
197
+ let tagName = this.kindToTagName(node.kind)
198
+ const attrs = this.getTagAttributes(node)
199
+ const content = node.children.map(c => this.exportNode(c)).join('')
200
+
201
+ if (!tagName) {
202
+ if (node.kind === 'spacing') return '\n'
203
+ if (node.kind === 'empty_line') return '\n'
204
+ // Plugin tags: not in the builtin kind→tag table, but registered, so
205
+ // they serialize under their registered name and round-trip. Strictly
206
+ // limited to NON-builtin registrations — builtins missing from the
207
+ // table (sinewave, rainbow, svg…) keep their existing content-only
208
+ // serialization, which Studio flows depend on.
209
+ if (tagDef && !this.registry.isBuiltin(tagDef.name)) {
210
+ tagName = tagDef.name
211
+ } else {
212
+ return content
213
+ }
214
+ }
215
+
216
+ if (node.kind === 'image' || node.kind === 'video' || node.kind === 'audio') {
217
+ // image lleva su tamaño/modificador en el attr (`[img=400x300]url[/img]`)
218
+ // y la URL en el contenido; video/audio usan el attr como src, así que
219
+ // solo image debe re-emitir atributos.
220
+ const mediaAttrs = node.kind === 'image' ? attrs : ''
221
+ return `[${tagName}${mediaAttrs}]${content}[/${tagName}]`
222
+ }
223
+
224
+ const kindName: string = node.kind
225
+ if (kindName === 'list_item') {
226
+ return `[*]${content}`
227
+ }
228
+
229
+ if (content === '' && !node.children.length) {
230
+ if (kindName === 'list_item') return '[*]'
231
+ return `[${tagName}${attrs}]${content}[/${tagName}]`
232
+ }
233
+
234
+ return `[${tagName}${attrs}]${content}[/${tagName}]`
235
+ }
236
+
237
+ private kindToTagName(kind: string): string | null {
238
+ return KIND_TO_TAG_NAME[kind] ?? null
239
+ }
240
+
241
+ private getTagAttributes(node: RedNode): string {
242
+ // For BBCode, attributes are in the node text or metadata
243
+ if (node.metadata) {
244
+ if (node.metadata.tagName) {
245
+ const tag = this.registry.get(node.metadata.tagName as string)
246
+ if (tag?.properties) {
247
+ const parts: string[] = []
248
+ for (const prop of tag.properties) {
249
+ const val = node.metadata[prop.name]
250
+ if (val !== undefined && val !== null) {
251
+ parts.push(`${prop.name}=${val}`)
252
+ }
253
+ }
254
+ if (parts.length > 0) return `=${parts[0]}`
255
+ }
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}`
262
+ } else if (node.kind === 'url' && node.metadata.href !== undefined) {
263
+ 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
270
+ // `[box=Title:#hex]`: el color se guardó aparte en metadata; se vuelve
271
+ // a añadir aquí para que el round-trip no pierda el sufijo.
272
+ const color = node.metadata.color as string | undefined
273
+ return `=${titleVal}${color ? `:${color}` : ''}`
274
+ } else if ((node.kind === 'notice' || node.kind === 'wnotice') && node.metadata.color !== undefined) {
275
+ return `=${node.metadata.color}`
276
+ } else if (node.kind === 'tables' && node.metadata.variant !== undefined) {
277
+ const color = node.metadata.color as string | undefined
278
+ return `=${node.metadata.variant}${color ? `:${color}` : ''}`
279
+ } else if (node.kind === 'columns' && node.metadata.columns !== undefined) {
280
+ const color = node.metadata.color as string | undefined
281
+ return `=${node.metadata.columns}${color ? `:${color}` : ''}`
282
+ } else if (node.kind === 'separator' && node.metadata.variant !== undefined) {
283
+ return `=${node.metadata.variant}`
284
+ } else if (node.kind === 'scroll' && node.metadata.height !== undefined) {
285
+ return `=${node.metadata.height}`
286
+ } else if (node.kind === 'abbr' && node.metadata.title !== undefined) {
287
+ return `=${node.metadata.title}`
288
+ } else if (node.kind === 'tooltip' && node.metadata.tip !== undefined) {
289
+ return `=${node.metadata.tip}`
290
+ } else if (node.kind === 'guild' && node.metadata.tag !== undefined) {
291
+ return `=${node.metadata.tag}`
292
+ } else if (node.kind === 'map' && node.metadata.id !== undefined) {
293
+ return `=${node.metadata.id}`
294
+ } else if (node.kind === 'align' && node.metadata.align !== undefined) {
295
+ return `=${node.metadata.align}`
296
+ } else if (node.kind === 'effect' && node.metadata.effectType !== undefined) {
297
+ return `=${node.metadata.effectType}`
298
+ } else if (node.kind === 'image' && node.metadata.imgAttr !== undefined) {
299
+ return `=${node.metadata.imgAttr}`
300
+ } else if (node.kind === 'anim' && node.metadata.animType !== undefined) {
301
+ return `=${node.metadata.animType}`
302
+ } else if (node.kind === 'container' && node.metadata.containerType !== undefined) {
303
+ return `=${node.metadata.containerType}`
304
+ } else if (node.kind === 'style_tag' && node.metadata.style !== undefined) {
305
+ return `=${node.metadata.style}`
306
+ }
307
+ }
308
+
309
+ // Fallback: check node text for attribute info
310
+ const text = node.text || ''
311
+ if (text.startsWith('=') || text.startsWith(' ')) {
312
+ // The text is exactly the attributes string (e.g. "=50" or " min=50 max=200")
313
+ return text
314
+ }
315
+
316
+ return ''
317
+ }
318
+ }