@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,117 @@
1
+ /**
2
+ * DocumentEngine — NodeFactory
3
+ *
4
+ * Creates DocumentNode instances, RedNode instances, and GreenNode
5
+ * instances from tag definitions. Centralizes node creation so
6
+ * plugins can provide custom node factories.
7
+ */
8
+
9
+ import type { NodeKind, NodeAttributes, NodeMetadata, DocumentNode } from '../Types/core'
10
+ import { createNodeId, createDocumentNode } from '../Types/core'
11
+ import { RedNode } from '../Syntax/RedNode'
12
+ import { greenNode, greenLeaf } from '../Syntax/GreenNode'
13
+ import { TagRegistry, type TagDefinition } from './TagRegistry'
14
+
15
+ export class NodeFactory {
16
+ private registry: TagRegistry
17
+
18
+ constructor(registry: TagRegistry) {
19
+ this.registry = registry
20
+ }
21
+
22
+ /**
23
+ * Create a RedNode from a tag definition.
24
+ */
25
+ createFromTag(
26
+ tagName: string,
27
+ attrs: string = '',
28
+ start: number = 0,
29
+ end: number = 0,
30
+ children: RedNode[] = [],
31
+ ): RedNode {
32
+ const def = this.registry.get(tagName)
33
+ const kind = def?.kind ?? 'custom'
34
+ const text = `[${tagName}${attrs ? '=' + attrs : ''}]`
35
+
36
+ const attributes: NodeAttributes = {}
37
+
38
+ // Parse attribute string into named attributes
39
+ if (attrs) {
40
+ if (def?.properties) {
41
+ // Use property definitions to parse
42
+ const parts = attrs.split(/,(?![^\[]*\])/g)
43
+ for (let i = 0; i < parts.length && i < def.properties.length; i++) {
44
+ const prop = def.properties[i]
45
+ const value = parts[i].trim()
46
+ if (prop.type === 'number') {
47
+ attributes[prop.name] = parseFloat(value) || prop.defaultValue
48
+ } else if (prop.type === 'boolean') {
49
+ attributes[prop.name] = value === 'true' || value === '1'
50
+ } else {
51
+ attributes[prop.name] = value
52
+ }
53
+ }
54
+ } else {
55
+ attributes['value'] = attrs
56
+ }
57
+ }
58
+
59
+ // For named attributes like color=#FF0000
60
+ if (attrs.includes('=') && !attrs.startsWith('=')) {
61
+ const eqIdx = attrs.indexOf('=')
62
+ attributes[attrs.slice(0, eqIdx).trim()] = attrs.slice(eqIdx + 1).trim()
63
+ }
64
+
65
+ const green = greenNode(kind, text, children.map(c => c.green))
66
+
67
+ const red = new RedNode(green, {
68
+ kind,
69
+ metadata: { tagName },
70
+ })
71
+
72
+ for (const child of children) {
73
+ child.parent = red
74
+ red.appendChild(child)
75
+ }
76
+
77
+ return red
78
+ }
79
+
80
+ /**
81
+ * Create a text RedNode.
82
+ */
83
+ createText(text: string, start: number = 0, end: number = 0): RedNode {
84
+ const green = greenLeaf('text', text)
85
+ return new RedNode(green, { kind: 'text' })
86
+ }
87
+
88
+ /**
89
+ * Create a self-closing tag RedNode (like [*], [img]src[/img]).
90
+ */
91
+ createSelfClosing(
92
+ tagName: string,
93
+ attrs: string = '',
94
+ start: number = 0,
95
+ end: number = 0,
96
+ ): RedNode {
97
+ const def = this.registry.get(tagName)
98
+ const kind = def?.kind ?? 'custom'
99
+ const text = attrs || ''
100
+
101
+ const green = greenLeaf(kind, text)
102
+ return new RedNode(green, { kind, metadata: { tagName, isSelfClosing: true } })
103
+ }
104
+
105
+ /**
106
+ * Create a document root RedNode.
107
+ */
108
+ createDocument(children: RedNode[] = []): RedNode {
109
+ const green = greenNode('document', '', children.map(c => c.green))
110
+ const red = new RedNode(green, { kind: 'document' })
111
+ for (const child of children) {
112
+ child.parent = red
113
+ red.appendChild(child)
114
+ }
115
+ return red
116
+ }
117
+ }
@@ -0,0 +1,495 @@
1
+ /**
2
+ * DocumentEngine — TagRegistry
3
+ *
4
+ * The central registry for all known tags/node kinds.
5
+ * This is the plugin extension point for adding new BBCode tags,
6
+ * custom node types, or even entire new languages.
7
+ *
8
+ * Each tag definition includes:
9
+ * - Parser handler (how to parse this tag from tokens)
10
+ * - Renderer (how to render this tag to HTML, Markdown, etc.)
11
+ * - Validator (semantic validation rules)
12
+ * - Toolbar definition (for the visual editor)
13
+ * - Property editor (for the properties panel)
14
+ *
15
+ * Inspired by VSCode's contribution points.
16
+ */
17
+
18
+ import type { NodeKind, NodeAttributes } from '../Types/core'
19
+ import type { RedNode } from '../Syntax/RedNode'
20
+ import { GreenNode } from '../Syntax/GreenNode'
21
+ import type { RenderNode } from '../RenderPipeline/RenderTree'
22
+ import type { Validator } from '../Semantic/SemanticAnalyzer'
23
+ import { mixMultiple, ease, clamp, hslToHex, type Easing } from '../Utils/color'
24
+
25
+ // ─── Tag Handler ───────────────────────────────────────────────
26
+
27
+ export interface TagHandlerContext {
28
+ node: RedNode
29
+ source: string
30
+ visitChildren: (node: RedNode) => string
31
+ renderChild: (node: RedNode) => RenderNode
32
+ }
33
+
34
+ export interface TagDefinition {
35
+ /** The tag name (e.g. 'color', 'b', 'img') */
36
+ name: string
37
+ /** Semantic kind */
38
+ kind: NodeKind
39
+ /** Human-readable label */
40
+ label: string
41
+ /** Category for grouping in UI */
42
+ category?: 'formatting' | 'layout' | 'media' | 'special' | 'text'
43
+ /** Icon name for UI */
44
+ icon?: string
45
+ /** Whether this tag is inline (span) or block (div) */
46
+ isInline: boolean
47
+ /** Whether this tag is self-closing */
48
+ isSelfClosing: boolean
49
+ /** Whether this tag can contain children */
50
+ canHaveChildren: boolean
51
+ /** Whether this tag is deprecated */
52
+ isDeprecated?: boolean
53
+ /** Replacement tag name if deprecated */
54
+ deprecatedReplacement?: string
55
+
56
+ // ─── Handlers ───────────────────────────────────────────
57
+
58
+ /** Convert a RedNode back to BBCode text */
59
+ toBBCode?: (ctx: TagHandlerContext) => string
60
+ /** Convert a RedNode to a RenderNode for the render pipeline */
61
+ toRenderNode?: (ctx: TagHandlerContext) => RenderNode
62
+ /** Convert a RedNode to HTML string (legacy) */
63
+ toHTML?: (ctx: TagHandlerContext) => string
64
+ /** Default attributes for new instances */
65
+ defaultAttributes?: () => NodeAttributes
66
+
67
+ // ─── Validation ─────────────────────────────────────────
68
+
69
+ /** Additional validator for this specific tag */
70
+ validator?: Validator
71
+
72
+ // ─── UI Metadata ────────────────────────────────────────
73
+
74
+ /** Toolbar button definition */
75
+ toolbar?: {
76
+ group: string
77
+ label: string
78
+ icon?: string
79
+ shortcut?: string
80
+ }
81
+ /** Properties panel definition */
82
+ properties?: PropertyDefinition[]
83
+ }
84
+
85
+ // ─── Property Definition ───────────────────────────────────────
86
+
87
+ export interface PropertyDefinition {
88
+ name: string
89
+ label: string
90
+ type: 'text' | 'color' | 'number' | 'select' | 'boolean' | 'slider'
91
+ defaultValue?: string | number | boolean
92
+ options?: { label: string; value: string }[]
93
+ min?: number
94
+ max?: number
95
+ step?: number
96
+ placeholder?: string
97
+ description?: string
98
+ }
99
+
100
+ // ─── TagRegistry ───────────────────────────────────────────────
101
+
102
+ // ─── Internal tag helpers (gradient/grow export) ──────────────
103
+
104
+ /** Recursively extract plain text from a RedNode tree */
105
+ function extractTextContent(node: RedNode): string {
106
+ if (node.kind === 'text') return node.text
107
+ return node.children.map(extractTextContent).join('')
108
+ }
109
+
110
+ /** Split text by unit: 'character', 'word', or 'line' */
111
+ function splitByUnit(text: string, unit: string): string[] {
112
+ if (!text) return []
113
+ switch (unit) {
114
+ case 'word':
115
+ return text.split(/(\s+)/).filter(s => s.length > 0)
116
+ case 'line':
117
+ return text.split('\n')
118
+ default: // 'character'
119
+ return [...text]
120
+ }
121
+ }
122
+
123
+ /** Escape [ and ] inside BBCode content so they're not parsed as tags */
124
+ function escapeBracket(ch: string): string {
125
+ // BBCode has no standard escape. For internal tags produced by TextStudio,
126
+ // text never contains brackets. If it does, we use the backslash escape
127
+ // that some BBCode parsers support.
128
+ return ch.replace(/\[/g, '\\[').replace(/\]/g, '\\]')
129
+ }
130
+
131
+ // ─── Effect segment math — one source of truth per effect ─────
132
+ //
133
+ // Each effect (gradient, sinewave, grow, rainbow) serializes two ways: to
134
+ // osu!-compatible BBCode (`toBBCode`) and to a RenderNode tree
135
+ // (`toRenderNode`). Both used to carry their own copy of the per-segment
136
+ // math — t-progression, easing, color mixing, wave sizing — differing only in
137
+ // presentation, which is exactly how the two drift apart. The math now lives
138
+ // in ONE `*Segments` function per effect; the handlers are thin presenters.
139
+ //
140
+ // Note this is deliberately NOT the same algorithm as `HTMLRenderer`'s own
141
+ // `renderGradient`: the preview paints a smooth CSS `linear-gradient` with
142
+ // `background-clip: text`, while these segments reproduce the DISCRETE
143
+ // per-chunk colors that the exported `[color=…]` BBCode will actually have.
144
+
145
+ /**
146
+ * A run of text with the style its effect computed. `color` and `size` are
147
+ * mutually exclusive; neither set means the run passes through unstyled
148
+ * (whitespace between sized words).
149
+ */
150
+ interface StyledSegment {
151
+ text: string
152
+ color?: string
153
+ size?: number
154
+ }
155
+
156
+ /**
157
+ * Effect progression t ∈ [0,1] for segment `i` of `count`, honoring the
158
+ * document-wide fields (`globalOffset`/`documentLength`) that let one logical
159
+ * effect span several nodes. `single` is the t for a one-segment run — the
160
+ * effects legitimately disagree on it (gradient/rainbow use 0, grow 0.5).
161
+ */
162
+ function progressAt(i: number, count: number, node: RedNode, single: number): number {
163
+ const globalOffset = (node.metadata?.globalOffset as number) || 0
164
+ const documentLength = (node.metadata?.documentLength as number) || count
165
+ if (documentLength > 1) return (globalOffset + i) / (documentLength - 1)
166
+ return count > 1 ? i / (count - 1) : single
167
+ }
168
+
169
+ function gradientSegments(node: RedNode): StyledSegment[] {
170
+ const colors = (node.metadata?.colors as string[]) || ['#FF0000', '#00FF00']
171
+ const unit = (node.metadata?.unit as string) || 'character'
172
+ const easing = (node.metadata?.easing as Easing) || 'linear'
173
+ const text = extractTextContent(node)
174
+ if (!text) return []
175
+ const parts = splitByUnit(text, unit)
176
+ return parts.map((seg, i) => ({
177
+ text: seg,
178
+ color: mixMultiple(colors, ease(clamp(progressAt(i, parts.length, node, 0)), easing)),
179
+ }))
180
+ }
181
+
182
+ function sinewaveSegments(node: RedNode): StyledSegment[] {
183
+ const min = (node.metadata?.min as number) ?? 20
184
+ const max = (node.metadata?.max as number) ?? 80
185
+ const freq = (node.metadata?.freq as number) ?? 0.4
186
+ const step = (node.metadata?.step as string) ?? 'char'
187
+ const text = extractTextContent(node)
188
+ if (!text) return []
189
+ const amplitude = (max - min) / 2
190
+ const center = min + amplitude
191
+
192
+ if (step === 'word') {
193
+ let wordIndex = 0
194
+ return text.split(/(\s+)/).map(chunk => {
195
+ if (chunk.trim() === '') return { text: chunk }
196
+ const size = Math.round(center + Math.sin(wordIndex * freq) * amplitude)
197
+ wordIndex++
198
+ return { text: chunk, size }
199
+ })
200
+ }
201
+
202
+ return [...text].map((ch, i) => ({
203
+ text: ch,
204
+ size: Math.round(center + Math.sin(i * freq) * amplitude),
205
+ }))
206
+ }
207
+
208
+ function growSegments(node: RedNode): StyledSegment[] {
209
+ const min = (node.metadata?.min as number) ?? 50
210
+ const max = (node.metadata?.max as number) ?? 200
211
+ const cycles = (node.metadata?.cycles as number) ?? 1
212
+ const text = extractTextContent(node)
213
+ if (!text) return []
214
+ const chars = [...text]
215
+ return chars.map((ch, i) => {
216
+ const sine = Math.sin(progressAt(i, chars.length, node, 0.5) * cycles * Math.PI * 2)
217
+ const normalized = (sine + 1) / 2
218
+ return { text: ch, size: Math.round(min + (max - min) * normalized) }
219
+ })
220
+ }
221
+
222
+ function rainbowSegments(node: RedNode): StyledSegment[] {
223
+ const saturation = ((node.metadata?.saturation as number) ?? 80) / 100
224
+ const lightness = ((node.metadata?.lightness as number) ?? 60) / 100
225
+ const spread = (node.metadata?.spread as number) ?? 300
226
+ const offset = (node.metadata?.offset as number) ?? 0
227
+ const text = extractTextContent(node)
228
+ if (!text) return []
229
+ const chars = [...text]
230
+ return chars.map((ch, i) => ({
231
+ text: ch,
232
+ color: hslToHex(offset + progressAt(i, chars.length, node, 0) * spread, saturation, lightness),
233
+ }))
234
+ }
235
+
236
+ /** Present styled segments as osu!-compatible BBCode. */
237
+ function segmentsToBBCode(segments: StyledSegment[]): string {
238
+ let out = ''
239
+ for (const s of segments) {
240
+ if (s.color !== undefined) out += `[color=${s.color}]${escapeBracket(s.text)}[/color]`
241
+ else if (s.size !== undefined) out += `[size=${s.size}]${escapeBracket(s.text)}[/size]`
242
+ else out += s.text
243
+ }
244
+ return out
245
+ }
246
+
247
+ /** Present styled segments as a RenderNode tree. */
248
+ function segmentsToRenderNode(kind: string, segments: StyledSegment[]): RenderNode {
249
+ return {
250
+ kind,
251
+ text: '',
252
+ children: segments.map((s): RenderNode => {
253
+ const style: Record<string, string> =
254
+ s.color !== undefined ? { color: s.color }
255
+ : s.size !== undefined ? { fontSize: `${s.size}%` }
256
+ : {}
257
+ return { kind: 'text', text: s.text, children: [], props: {}, style }
258
+ }),
259
+ props: {},
260
+ }
261
+ }
262
+
263
+ export type TagHandler = {
264
+ [K in keyof TagDefinition]: TagDefinition[K]
265
+ }
266
+
267
+ export class TagRegistry {
268
+ private tags: Map<string, TagDefinition> = new Map()
269
+ private kinds: Map<NodeKind, TagDefinition> = new Map()
270
+ /** Names registered by the constructor — the language's own tags. */
271
+ private builtins: Set<string> = new Set()
272
+
273
+ /**
274
+ * Bumped on every register/unregister, so consumers that derive something
275
+ * from the registry (the parser's `extraTags` map) can memoize against it
276
+ * instead of rebuilding per parse.
277
+ */
278
+ version: number = 0
279
+
280
+ constructor() {
281
+ this.registerBuiltins()
282
+ for (const name of this.tags.keys()) this.builtins.add(name)
283
+ }
284
+
285
+ /**
286
+ * Register a tag definition.
287
+ */
288
+ register(tag: TagDefinition): void {
289
+ this.tags.set(tag.name, tag)
290
+ this.kinds.set(tag.kind, tag)
291
+ this.version++
292
+ }
293
+
294
+ /**
295
+ * Unregister a tag definition.
296
+ */
297
+ unregister(name: string): void {
298
+ const tag = this.tags.get(name)
299
+ if (tag) {
300
+ this.tags.delete(name)
301
+ this.kinds.delete(tag.kind)
302
+ this.version++
303
+ }
304
+ }
305
+
306
+ /**
307
+ * Whether `name` is one of the language's own tags, as opposed to a
308
+ * plugin registration. The distinction matters: plugin tags must reach the
309
+ * parser (via `ParseOptions.extraTags`) and serialize with their own name,
310
+ * while builtins already have both behaviors hardcoded — routing them
311
+ * through the plugin path would change engine semantics.
312
+ */
313
+ isBuiltin(name: string): boolean {
314
+ return this.builtins.has(name)
315
+ }
316
+
317
+ /**
318
+ * tag name → kind for every non-builtin registration: the parser's
319
+ * `extraTags`. Returns `null` when there are none, so the common case
320
+ * costs one size comparison and no allocation.
321
+ */
322
+ customTags(): Map<string, NodeKind> | null {
323
+ if (this.tags.size === this.builtins.size) return null
324
+ const out = new Map<string, NodeKind>()
325
+ for (const [name, def] of this.tags) {
326
+ if (!this.builtins.has(name)) out.set(name, def.kind)
327
+ }
328
+ return out.size > 0 ? out : null
329
+ }
330
+
331
+ /**
332
+ * Get a tag definition by name.
333
+ */
334
+ get(name: string): TagDefinition | undefined {
335
+ return this.tags.get(name)
336
+ }
337
+
338
+ /**
339
+ * Get a tag definition by semantic kind.
340
+ */
341
+ getByKind(kind: NodeKind): TagDefinition | undefined {
342
+ return this.kinds.get(kind)
343
+ }
344
+
345
+ /**
346
+ * Check if a tag is registered.
347
+ */
348
+ has(name: string): boolean {
349
+ return this.tags.has(name)
350
+ }
351
+
352
+ /**
353
+ * Get all registered tags.
354
+ */
355
+ getAll(): TagDefinition[] {
356
+ return Array.from(this.tags.values())
357
+ }
358
+
359
+ /**
360
+ * Get tags by category.
361
+ */
362
+ getByCategory(category: string): TagDefinition[] {
363
+ return this.getAll().filter(t => t.category === category)
364
+ }
365
+
366
+ /**
367
+ * Get tags by whether they are inline.
368
+ */
369
+ getInline(): TagDefinition[] {
370
+ return this.getAll().filter(t => t.isInline)
371
+ }
372
+
373
+ /**
374
+ * Get block-level tags.
375
+ */
376
+ getBlock(): TagDefinition[] {
377
+ return this.getAll().filter(t => !t.isInline && !t.isSelfClosing)
378
+ }
379
+
380
+ /**
381
+ * Register the built-in BBCode tags.
382
+ */
383
+ private registerBuiltins(): void {
384
+ const tags: TagDefinition[] = [
385
+ // ── Formatting ──
386
+ { name: 'b', kind: 'bold', label: 'Bold', category: 'formatting', isInline: true, isSelfClosing: false, canHaveChildren: true, toolbar: { group: 'formatting', label: 'Bold', icon: 'Bold', shortcut: 'Ctrl+B' } },
387
+ { name: 'i', kind: 'italic', label: 'Italic', category: 'formatting', isInline: true, isSelfClosing: false, canHaveChildren: true, toolbar: { group: 'formatting', label: 'Italic', icon: 'Italic', shortcut: 'Ctrl+I' } },
388
+ { name: 'u', kind: 'underline', label: 'Underline', category: 'formatting', isInline: true, isSelfClosing: false, canHaveChildren: true, toolbar: { group: 'formatting', label: 'Underline', icon: 'Underline', shortcut: 'Ctrl+U' } },
389
+ { name: 's', kind: 'strikethrough', label: 'Strikethrough', category: 'formatting', isInline: true, isSelfClosing: false, canHaveChildren: true },
390
+ { name: 'strike', kind: 'strikethrough', label: 'Strikethrough (deprecated)', category: 'formatting', isInline: true, isSelfClosing: false, canHaveChildren: true, isDeprecated: true, deprecatedReplacement: 's' },
391
+ { name: 'color', kind: 'color', label: 'Color', category: 'formatting', isInline: true, isSelfClosing: false, canHaveChildren: true, toolbar: { group: 'formatting', label: 'Color', icon: 'Palette' }, properties: [{ name: 'color', label: 'Color', type: 'color', defaultValue: '#FF66AB' }] },
392
+ { name: 'size', kind: 'font_size', label: 'Font Size', category: 'formatting', isInline: true, isSelfClosing: false, canHaveChildren: true, properties: [{ name: 'size', label: 'Size (%)', type: 'slider', defaultValue: 100, min: 50, max: 200, step: 5 }] },
393
+ { name: 'c', kind: 'inline_code', label: 'Inline Code', category: 'formatting', isInline: true, isSelfClosing: false, canHaveChildren: true },
394
+ { name: 'font', kind: 'font', label: 'Font', category: 'formatting', isInline: true, isSelfClosing: false, canHaveChildren: true },
395
+ { name: 'spoiler', kind: 'spoiler', label: 'Spoiler', category: 'formatting', isInline: true, isSelfClosing: false, canHaveChildren: true, toolbar: { group: 'formatting', label: 'Spoiler', icon: 'EyeOff' } },
396
+ { name: 'zalgo', kind: 'zalgo', label: 'Zalgo', category: 'text', isInline: true, isSelfClosing: false, canHaveChildren: true },
397
+ { name: 'aesthetic', kind: 'aesthetic', label: 'Aesthetic', category: 'text', isInline: true, isSelfClosing: false, canHaveChildren: true },
398
+ { name: 'sparkle', kind: 'sparkle', label: 'Sparkle', category: 'text', isInline: true, isSelfClosing: false, canHaveChildren: true },
399
+ { name: 'bubble', kind: 'bubble', label: 'Bubble', category: 'text', isInline: true, isSelfClosing: false, canHaveChildren: true },
400
+ { name: 'flower', kind: 'flower', label: 'Flower', category: 'text', isInline: true, isSelfClosing: false, canHaveChildren: true },
401
+
402
+ // ── Internal (export-only, not user-facing BBCode) ──
403
+ {
404
+ name: 'gradient',
405
+ kind: 'gradient',
406
+ label: 'Gradient',
407
+ category: 'text',
408
+ isInline: true,
409
+ isSelfClosing: false,
410
+ canHaveChildren: true,
411
+ toBBCode: (ctx) => {
412
+ const segments = gradientSegments(ctx.node)
413
+ return segments.length === 0 ? ctx.visitChildren(ctx.node) : segmentsToBBCode(segments)
414
+ },
415
+ toRenderNode: (ctx) => segmentsToRenderNode('gradient', gradientSegments(ctx.node)),
416
+ },
417
+ {
418
+ name: 'sinewave',
419
+ kind: 'sinewave',
420
+ label: 'Sine Wave',
421
+ category: 'text',
422
+ isInline: true,
423
+ isSelfClosing: false,
424
+ canHaveChildren: true,
425
+ toBBCode: (ctx) => {
426
+ const segments = sinewaveSegments(ctx.node)
427
+ return segments.length === 0 ? ctx.visitChildren(ctx.node) : segmentsToBBCode(segments)
428
+ },
429
+ toRenderNode: (ctx) => segmentsToRenderNode('sinewave', sinewaveSegments(ctx.node)),
430
+ },
431
+ {
432
+ name: 'grow',
433
+ kind: 'grow',
434
+ label: 'Grow',
435
+ category: 'text',
436
+ isInline: true,
437
+ isSelfClosing: false,
438
+ canHaveChildren: true,
439
+ toBBCode: (ctx) => {
440
+ const segments = growSegments(ctx.node)
441
+ return segments.length === 0 ? ctx.visitChildren(ctx.node) : segmentsToBBCode(segments)
442
+ },
443
+ toRenderNode: (ctx) => segmentsToRenderNode('grow', growSegments(ctx.node)),
444
+ },
445
+ {
446
+ name: 'rainbow',
447
+ kind: 'rainbow',
448
+ label: 'Rainbow',
449
+ category: 'text',
450
+ isInline: true,
451
+ isSelfClosing: false,
452
+ canHaveChildren: true,
453
+ toBBCode: (ctx) => {
454
+ const segments = rainbowSegments(ctx.node)
455
+ return segments.length === 0 ? ctx.visitChildren(ctx.node) : segmentsToBBCode(segments)
456
+ },
457
+ toRenderNode: (ctx) => segmentsToRenderNode('rainbow', rainbowSegments(ctx.node)),
458
+ },
459
+
460
+ // ── Layout ──
461
+ { name: 'centre', kind: 'center', label: 'Centre', category: 'layout', isInline: false, isSelfClosing: false, canHaveChildren: true, toolbar: { group: 'layout', label: 'Centre', icon: 'AlignCenter' } },
462
+ { name: 'center', kind: 'center', label: 'Center', category: 'layout', isInline: false, isSelfClosing: false, canHaveChildren: true, isDeprecated: true, deprecatedReplacement: 'centre' },
463
+ { name: 'right', kind: 'right', label: 'Right', category: 'layout', isInline: false, isSelfClosing: false, canHaveChildren: true },
464
+ { name: 'left', kind: 'left', label: 'Left', category: 'layout', isInline: false, isSelfClosing: false, canHaveChildren: true },
465
+ { name: 'heading', kind: 'heading', label: 'Heading', category: 'layout', isInline: false, isSelfClosing: false, canHaveChildren: true },
466
+ { name: 'notice', kind: 'notice', label: 'Notice', category: 'layout', isInline: false, isSelfClosing: false, canHaveChildren: true },
467
+ { name: 'spacing', kind: 'spacing', label: 'Spacing', category: 'layout', isInline: false, isSelfClosing: true, canHaveChildren: false },
468
+ { name: 'empty_line', kind: 'empty_line', label: 'Empty Line', category: 'layout', isInline: false, isSelfClosing: true, canHaveChildren: false },
469
+
470
+ // ── Media ──
471
+ { name: 'img', kind: 'image', label: 'Image', category: 'media', isInline: false, isSelfClosing: false, canHaveChildren: false },
472
+ { name: 'youtube', kind: 'video', label: 'YouTube', category: 'media', isInline: false, isSelfClosing: false, canHaveChildren: false },
473
+ { name: 'audio', kind: 'audio', label: 'Audio', category: 'media', isInline: false, isSelfClosing: false, canHaveChildren: false },
474
+ { name: 'imagemap', kind: 'imagemap', label: 'Image Map', category: 'media', isInline: false, isSelfClosing: false, canHaveChildren: true },
475
+
476
+ // ── Special ──
477
+ { name: 'quote', kind: 'quote', label: 'Quote', category: 'special', isInline: false, isSelfClosing: false, canHaveChildren: true, toolbar: { group: 'special', label: 'Quote', icon: 'Quote' } },
478
+ { name: 'code', kind: 'code', label: 'Code Block', category: 'special', isInline: false, isSelfClosing: false, canHaveChildren: true },
479
+ { name: 'svg', kind: 'svg', label: 'SVG Canvas', category: 'special', isInline: false, isSelfClosing: false, canHaveChildren: true },
480
+ { name: 'spoilerbox', kind: 'spoilerbox', label: 'Spoiler Box', category: 'special', isInline: false, isSelfClosing: false, canHaveChildren: true, toolbar: { group: 'special', label: 'Spoiler Box', icon: 'ChevronDown' } },
481
+ { name: 'box', kind: 'box', label: 'Box', category: 'special', isInline: false, isSelfClosing: false, canHaveChildren: true },
482
+ { name: 'boxw', kind: 'boxw', label: 'Box (líneas)', category: 'special', isInline: false, isSelfClosing: false, canHaveChildren: true },
483
+ { name: 'list', kind: 'list', label: 'List', category: 'special', isInline: false, isSelfClosing: false, canHaveChildren: true },
484
+ { name: '*', kind: 'list_item', label: 'List Item', category: 'special', isInline: false, isSelfClosing: true, canHaveChildren: false },
485
+ { name: 'url', kind: 'url', label: 'URL', category: 'special', isInline: true, isSelfClosing: false, canHaveChildren: false },
486
+ { name: 'email', kind: 'email', label: 'Email', category: 'special', isInline: true, isSelfClosing: false, canHaveChildren: false },
487
+ { name: 'profile', kind: 'profile', label: 'Profile', category: 'special', isInline: true, isSelfClosing: false, canHaveChildren: false },
488
+ { name: 'group', kind: 'group', label: 'Group', category: 'layout', isInline: false, isSelfClosing: false, canHaveChildren: true },
489
+ ]
490
+
491
+ for (const tag of tags) {
492
+ this.register(tag)
493
+ }
494
+ }
495
+ }
@@ -0,0 +1,5 @@
1
+ export { DocumentModel } from './DocumentModel'
2
+ export type { DocumentModelOptions } from './DocumentModel'
3
+ export { TagRegistry } from './TagRegistry'
4
+ export type { TagDefinition, TagHandler, PropertyDefinition, TagHandlerContext } from './TagRegistry'
5
+ export { NodeFactory } from './NodeFactory'