@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,184 @@
1
+ /**
2
+ * DocumentEngine — RedNodeStore
3
+ *
4
+ * Canonical store for RedNodes. Ensures that structurally identical subtrees
5
+ * share the EXACT SAME RedNode instance in RAM.
6
+ *
7
+ * This is the RedNode-level complement to GreenNodePool (which handles
8
+ * structural sharing at the GreenNode level).
9
+ *
10
+ * Architecture:
11
+ * - Keyed by GreenNode._hash (deterministic structural hash)
12
+ * - Bottom-up: children are stored first, then parents reference them
13
+ * - Immutable structural data: once a RedNode is in the store, its `children`
14
+ * array and `green` reference never change
15
+ * - Mutable metadata: the store's RedNode has canonical metadata (derived from
16
+ * green.text). Position-specific metadata lives in PositionRef.localOverlay.
17
+ *
18
+ * Benefits:
19
+ * - Memory deduplication: 100 identical blocks = 1 RedNode + 99 lightweight refs
20
+ * - Render caching: BBCodeCanvas can memoize by canonicalId
21
+ * - Faster incremental parse: unchanged subtrees keep their RedNode identity
22
+ * - HTMLRenderer cache: same canonicalId = same HTML (cache hits)
23
+ */
24
+
25
+ import { GreenNode } from './GreenNode'
26
+ import { RedNode } from './RedNode'
27
+ import type { NodeKind, NodeMetadata } from '../Types/core'
28
+
29
+ /**
30
+ * Derives a node's canonical metadata from its GreenNode.
31
+ *
32
+ * Injected rather than hardcoded: metadata extraction is *language* semantics
33
+ * (what `=#ff0000,#00ff00` means for a `gradient`), and this module sits in the
34
+ * language-agnostic `Syntax` layer. The BBCode implementation lives in
35
+ * `BBCode/BBCodeToGreenNode.ts`; pass it via the constructor.
36
+ */
37
+ export type MetadataExtractor = (green: GreenNode) => NodeMetadata
38
+
39
+ /** Default for callers with no language semantics to contribute. */
40
+ const NO_METADATA: MetadataExtractor = () => ({})
41
+
42
+ export class RedNodeStore {
43
+ private canonicals = new Map<string, RedNode>()
44
+ private _hits = 0
45
+ private _misses = 0
46
+ private _collisions = 0
47
+ private readonly extractMetadata: MetadataExtractor
48
+
49
+ /**
50
+ * @param extractMetadata How to derive canonical metadata from a green node.
51
+ * Defaults to producing none — the store stays language-agnostic unless a
52
+ * caller supplies the mapping.
53
+ */
54
+ constructor(extractMetadata: MetadataExtractor = NO_METADATA) {
55
+ this.extractMetadata = extractMetadata
56
+ }
57
+
58
+ /**
59
+ * Get or create a canonical RedNode for the given GreenNode.
60
+ *
61
+ * The store is keyed by `green._hash` — a 32-bit FNV structural hash — so a
62
+ * matching key is necessary but NOT sufficient proof of identity. The
63
+ * canonical pipeline (GreenNodePool interning) guarantees that structurally
64
+ * identical greens ARE the same object, which is what makes reference
65
+ * equality a sound discriminator:
66
+ *
67
+ * - same hash + same green reference → HIT, reuse the canonical
68
+ * - same hash + different green → COLLISION: a distinct subtree shares
69
+ * the 32-bit hash. It is never reused as the registered canonical and never
70
+ * overwrites it (that would corrupt every future hit for the registered
71
+ * node). The colliding subtree simply loses canonicalization — rebuilt on
72
+ * each call. Guarding correctness costs a rare dedup miss; the alternative
73
+ * costs a wrong AST.
74
+ * - no hash entry → MISS, create and register
75
+ *
76
+ * Invariant preserved: a hash collision can never make one structure be
77
+ * represented by another structure's RedNode.
78
+ *
79
+ * @param green The GreenNode to wrap
80
+ * @param metadata Optional metadata to set (overrides intrinsic metadata)
81
+ * @param kind Optional kind override
82
+ * @returns The canonical RedNode (shared if already exists)
83
+ */
84
+ getOrCreate(
85
+ green: GreenNode,
86
+ metadata?: NodeMetadata,
87
+ kind?: NodeKind,
88
+ ): RedNode {
89
+ const hash = green._hash.toString()
90
+ const existing = this.canonicals.get(hash)
91
+ if (existing) {
92
+ if (existing.green === green) {
93
+ this._hits++
94
+ return existing
95
+ }
96
+ // Hash collision: different green, same 32-bit hash. Never reuse the
97
+ // registered canonical and never overwrite it — build a fresh node and
98
+ // leave the registered entry untouched.
99
+ this._collisions++
100
+ return this.buildCanonical(green, metadata, kind)
101
+ }
102
+
103
+ this._misses++
104
+ const red = this.buildCanonical(green, metadata, kind)
105
+ this.canonicals.set(hash, red)
106
+ return red
107
+ }
108
+
109
+ /**
110
+ * Build a canonical RedNode for `green` WITHOUT registering it.
111
+ *
112
+ * Shared by the miss path (which registers the result) and the collision
113
+ * path (which must not). Both need the same bottom-up construction:
114
+ * children are canonicalized first, then the parent references them.
115
+ */
116
+ private buildCanonical(
117
+ green: GreenNode,
118
+ metadata?: NodeMetadata,
119
+ kind?: NodeKind,
120
+ ): RedNode {
121
+ // First, recursively store children (bottom-up construction)
122
+ const childRedNodes: RedNode[] = []
123
+ for (const childGreen of green.children as GreenNode[]) {
124
+ // Children inherit parent's kind from green, no metadata override
125
+ childRedNodes.push(this.getOrCreate(childGreen))
126
+ }
127
+
128
+ // Create the RedNode with canonical metadata from green.text
129
+ const red = new RedNode(green, {
130
+ kind: kind ?? (green.kind as NodeKind),
131
+ metadata: metadata ?? this.extractMetadata(green),
132
+ })
133
+
134
+ // Populate children (construction-time, outside the mutation lock)
135
+ red.initChildren(childRedNodes)
136
+ return red
137
+ }
138
+
139
+ /**
140
+ * Look up a canonical RedNode by its structural hash.
141
+ */
142
+ getByHash(hash: string): RedNode | undefined {
143
+ return this.canonicals.get(hash)
144
+ }
145
+
146
+ /**
147
+ * Check if a GreenNode already has a canonical RedNode.
148
+ */
149
+ has(green: GreenNode): boolean {
150
+ return this.canonicals.has(green._hash.toString())
151
+ }
152
+
153
+ /**
154
+ * Get the number of unique canonical nodes.
155
+ */
156
+ get size(): number {
157
+ return this.canonicals.size
158
+ }
159
+
160
+ /**
161
+ * Get store statistics.
162
+ */
163
+ get stats() {
164
+ return {
165
+ size: this.canonicals.size,
166
+ hits: this._hits,
167
+ misses: this._misses,
168
+ collisions: this._collisions,
169
+ deduplicationRatio: this._hits > 0
170
+ ? (this._hits / (this._hits + this._misses) * 100).toFixed(1) + '%'
171
+ : '0%',
172
+ }
173
+ }
174
+
175
+ /**
176
+ * Clear all canonical nodes.
177
+ */
178
+ clear(): void {
179
+ this.canonicals.clear()
180
+ this._hits = 0
181
+ this._misses = 0
182
+ this._collisions = 0
183
+ }
184
+ }
@@ -0,0 +1,214 @@
1
+ /**
2
+ * DocumentEngine — TreeBuilder
3
+ *
4
+ * Converts a TokenStream into a GreenNode tree.
5
+ * This is the bridge between the Lexer and the Syntax Tree.
6
+ *
7
+ * The TreeBuilder is language-agnostic — it uses registry-based
8
+ * node factories to build the appropriate tree structure.
9
+ */
10
+
11
+ import { GreenNode, greenNode, greenLeaf } from './GreenNode'
12
+ import { RedNode } from './RedNode'
13
+ import type { TokenStream, Token } from '../Types/tokens'
14
+ import type { NodeKind } from '../Types/core'
15
+
16
+ // ─── Build Result ──────────────────────────────────────────────
17
+
18
+ export interface BuildResult {
19
+ green: GreenNode
20
+ red: RedNode
21
+ tokens: Token[]
22
+ }
23
+
24
+ // ─── Node Factory Interface ────────────────────────────────────
25
+
26
+ export interface NodeFactory {
27
+ /** Create a GreenNode from a token or sequence of tokens */
28
+ createGreen(tokens: Token[], stream: TokenStream): GreenNode | null
29
+ /** Create a RedNode from a green node */
30
+ createRed(green: GreenNode, parent?: RedNode | null): RedNode
31
+ }
32
+
33
+ // ─── TreeBuilder ───────────────────────────────────────────────
34
+
35
+ export class TreeBuilder {
36
+ private factories: Map<string, NodeFactory> = new Map()
37
+ private defaultKind: NodeKind = 'unknown'
38
+
39
+ constructor(defaultKind?: NodeKind) {
40
+ if (defaultKind) this.defaultKind = defaultKind
41
+ }
42
+
43
+ /**
44
+ * Register a node factory for a specific token kind or tag name.
45
+ */
46
+ register(kind: string, factory: NodeFactory): void {
47
+ this.factories.set(kind, factory)
48
+ }
49
+
50
+ /**
51
+ * Build a tree from a token stream.
52
+ */
53
+ build(stream: TokenStream): BuildResult {
54
+ const tokens: Token[] = []
55
+
56
+ // Collect all tokens
57
+ while (!stream.isEof()) {
58
+ tokens.push(stream.advance())
59
+ }
60
+
61
+ // Build the green tree
62
+ const green = this.buildGreen(tokens)
63
+
64
+ // Build the red tree
65
+ const red = this.buildRed(green)
66
+
67
+ return { green, red, tokens }
68
+ }
69
+
70
+ /**
71
+ * Build a green tree from a token array.
72
+ * Uses registered factories to determine node structure.
73
+ *
74
+ * By default, creates a flat document structure.
75
+ * Language-specific builders (like BBCodeParser) override this.
76
+ */
77
+ buildGreen(tokens: Token[]): GreenNode {
78
+ // Default: create a document node with all tokens as direct children
79
+ const children: GreenNode[] = []
80
+ let start = tokens.length > 0 ? tokens[0].range.start : 0
81
+ let end = tokens.length > 0 ? tokens[tokens.length - 1].range.end : 0
82
+
83
+ for (const token of tokens) {
84
+ // Try registered factories
85
+ let green: GreenNode | null = null
86
+
87
+ if (token.name && this.factories.has(token.name)) {
88
+ green = this.factories.get(token.name)!.createGreen([token], {
89
+ peek: () => token,
90
+ advance: () => token,
91
+ lookAhead: () => token,
92
+ isEof: () => true,
93
+ position: () => 0,
94
+ seek: () => {},
95
+ remaining: () => [],
96
+ getAll: () => [token],
97
+ } as TokenStream)
98
+ }
99
+
100
+ if (!green && this.factories.has(token.kind)) {
101
+ green = this.factories.get(token.kind)!.createGreen([token], {
102
+ peek: () => token,
103
+ advance: () => token,
104
+ lookAhead: () => token,
105
+ isEof: () => true,
106
+ position: () => 0,
107
+ seek: () => {},
108
+ remaining: () => [],
109
+ getAll: () => [token],
110
+ } as TokenStream)
111
+ }
112
+
113
+ if (!green) {
114
+ // Default: create a text leaf or tag leaf
115
+ if (token.kind === 'text' || token.kind === 'newline') {
116
+ green = greenLeaf('text', token.text)
117
+ } else if (token.kind === 'open_tag' || token.kind === 'close_tag' || token.kind === 'self_closing_tag') {
118
+ green = greenLeaf(token.name ? `tag:${token.name}` : 'tag', token.text)
119
+ } else {
120
+ green = greenLeaf(token.kind, token.text)
121
+ }
122
+ }
123
+
124
+ children.push(green)
125
+ }
126
+
127
+ return greenNode('document', '', children)
128
+ }
129
+
130
+ /**
131
+ * Build a red tree from a green tree.
132
+ */
133
+ buildRed(green: GreenNode, parent?: RedNode | null, kind?: NodeKind): RedNode {
134
+ const red = new RedNode(green, {
135
+ parent: parent ?? null,
136
+ kind: kind ?? this.defaultKind,
137
+ })
138
+
139
+ for (const childGreen of green.children as GreenNode[]) {
140
+ // Determine child kind based on green node kind
141
+ let childKind: NodeKind = childGreen.kind as NodeKind
142
+ if (childGreen.kind.startsWith('tag:')) {
143
+ const tagName = childGreen.kind.slice(4)
144
+ childKind = this.tagToKind(tagName)
145
+ }
146
+
147
+ const childRed = this.buildRed(childGreen, red, childKind)
148
+ red.appendChild(childRed)
149
+ }
150
+
151
+ return red
152
+ }
153
+
154
+ /**
155
+ * Create a RedNode directly from children (for language-specific parsers).
156
+ */
157
+ createRedFromChildren(
158
+ kind: NodeKind,
159
+ text: string,
160
+ children: RedNode[],
161
+ parent?: RedNode | null,
162
+ ): RedNode {
163
+ const green = greenNode(kind, text, children.map(c => c.green))
164
+ const red = new RedNode(green, { parent: parent ?? null, kind })
165
+ for (const child of children) {
166
+ child.parent = red
167
+ red.appendChild(child)
168
+ }
169
+ return red
170
+ }
171
+
172
+ // ─── Tag to Kind Mapping ────────────────────────────────
173
+
174
+ private tagToKind(tag: string): NodeKind {
175
+ const map: Record<string, NodeKind> = {
176
+ 'b': 'bold',
177
+ 'i': 'italic',
178
+ 'u': 'underline',
179
+ 's': 'strikethrough',
180
+ 'strike': 'strikethrough',
181
+ 'color': 'color',
182
+ 'size': 'font_size',
183
+ 'font': 'font',
184
+ 'c': 'inline_code',
185
+ 'code': 'code',
186
+ 'spoiler': 'spoiler',
187
+ 'centre': 'center',
188
+ 'right': 'right',
189
+ 'left': 'left',
190
+ 'center': 'center',
191
+ 'url': 'url',
192
+ 'email': 'email',
193
+ 'profile': 'profile',
194
+ 'img': 'image',
195
+ 'youtube': 'video',
196
+ 'audio': 'audio',
197
+ 'imagemap': 'imagemap',
198
+ 'quote': 'quote',
199
+ 'notice': 'notice',
200
+ 'spoilerbox': 'spoilerbox',
201
+ 'box': 'box',
202
+ 'boxw': 'boxw',
203
+ 'list': 'list',
204
+ '*': 'list_item',
205
+ 'heading': 'heading',
206
+ 'zalgo': 'zalgo',
207
+ 'aesthetic': 'aesthetic',
208
+ 'sparkle': 'sparkle',
209
+ 'bubble': 'bubble',
210
+ 'flower': 'flower',
211
+ }
212
+ return map[tag] ?? 'custom'
213
+ }
214
+ }
@@ -0,0 +1,33 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { greenNode, greenLeaf } from '../GreenNode'
3
+
4
+ describe('GreenNode', () => {
5
+ it('creates a green node with correct properties', () => {
6
+ const leaf1 = greenLeaf('Text', 'hello ')
7
+ const leaf2 = greenLeaf('Text', 'world')
8
+ const node = greenNode('Paragraph', '', [leaf1, leaf2])
9
+
10
+ expect(node.kind).toBe('Paragraph')
11
+ expect(node.text).toBe('')
12
+ expect(node.width).toBe(11)
13
+ expect(node.childCount).toBe(2)
14
+ expect(node.isLeaf).toBe(false)
15
+
16
+ expect(leaf1.isLeaf).toBe(true)
17
+ expect(leaf1.text).toBe('hello ')
18
+ expect(leaf1.width).toBe(6)
19
+ })
20
+
21
+ it('is immutable', () => {
22
+ const leaf1 = greenLeaf('Text', 'hello')
23
+ const leaf2 = greenLeaf('Text', 'world')
24
+ const input = [leaf1]
25
+ const node = greenNode('Paragraph', '', input)
26
+
27
+ // Children should be a copy, not the same reference as input
28
+ expect(node.children).not.toBe(input)
29
+ // But should contain the same elements
30
+ expect(node.children.length).toBe(1)
31
+ expect(node.children[0]).toBe(leaf1)
32
+ })
33
+ })
@@ -0,0 +1,81 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { RedNode } from '../RedNode'
3
+ import { greenNode, greenLeaf } from '../GreenNode'
4
+
5
+ describe('RedNode', () => {
6
+ it('creates a red node from a green node', () => {
7
+ const green = greenLeaf('Text', 'hello')
8
+ const red = new RedNode(green)
9
+
10
+ expect(red.kind).toBe('Text')
11
+ expect(red.text).toBe('hello')
12
+ expect(red.version).toBe(1)
13
+ expect(red.id).toBeDefined()
14
+ expect(red.parent).toBeNull()
15
+ })
16
+
17
+ it('supports tree navigation', () => {
18
+ const rootGreen = greenNode('Root', '')
19
+ const childGreen = greenLeaf('Text', 'hello')
20
+
21
+ const root = new RedNode(rootGreen)
22
+ const child = new RedNode(childGreen)
23
+
24
+ RedNode.allowMutation(() => {
25
+ root.appendChild(child)
26
+ })
27
+
28
+ expect(child.parent).toBe(root)
29
+ expect(root.childCount).toBe(1)
30
+ expect(root.childAt(0)).toBe(child)
31
+ expect(child.root).toBe(root)
32
+ expect(child.depth).toBe(1)
33
+ expect(root.depth).toBe(0)
34
+ })
35
+
36
+ it('supports mutation and bumps version', () => {
37
+ const rootGreen = greenNode('Root', '')
38
+ const childGreen = greenLeaf('Text', 'hello')
39
+
40
+ const root = new RedNode(rootGreen)
41
+ const child = new RedNode(childGreen)
42
+
43
+ expect(root.version).toBe(1)
44
+
45
+ RedNode.allowMutation(() => {
46
+ root.appendChild(child)
47
+ })
48
+ expect(root.version).toBe(2)
49
+
50
+ RedNode.allowMutation(() => {
51
+ root.removeChild(child.id)
52
+ })
53
+ expect(root.version).toBe(3)
54
+ expect(root.childCount).toBe(0)
55
+ expect(child.parent).toBeNull()
56
+ })
57
+
58
+ it('throws an error if mutation methods are called without an active transaction or lock', () => {
59
+ const rootGreen = greenNode('Root', '')
60
+ const childGreen = greenLeaf('Text', 'hello')
61
+
62
+ const root = new RedNode(rootGreen)
63
+ const child = new RedNode(childGreen)
64
+
65
+ expect(() => {
66
+ root.appendChild(child)
67
+ }).toThrow(/mutation boundary/)
68
+
69
+ RedNode.allowMutation(() => {
70
+ root.appendChild(child)
71
+ })
72
+
73
+ expect(() => {
74
+ root.removeChild(child.id)
75
+ }).toThrow(/mutation boundary/)
76
+
77
+ expect(() => {
78
+ root.bumpVersion()
79
+ }).toThrow(/mutation boundary/)
80
+ })
81
+ })
@@ -0,0 +1,104 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { greenLeaf } from '../GreenNode'
3
+ import { RedNodeStore } from '../RedNodeStore'
4
+
5
+ /**
6
+ * Forge a deterministic FNV-32 collision for a test green node.
7
+ *
8
+ * `GreenNode._hash` is a lazy getter over `_hashCache` (computed once, then
9
+ * memoized). The store keys purely on `green._hash`, so forcing two
10
+ * structurally DIFFERENT greens to the same cached hash is a faithful
11
+ * simulation of a real 32-bit hash collision — the store cannot and should
12
+ * not distinguish how the collision arose.
13
+ */
14
+ function forceHash(green: ReturnType<typeof greenLeaf>, hash: number): void {
15
+ ;(green as unknown as { _hashCache: number })._hashCache = hash
16
+ }
17
+
18
+ describe('RedNodeStore', () => {
19
+ it('canonicalizes: same green reference reuses the same RedNode', () => {
20
+ const store = new RedNodeStore()
21
+ const green = greenLeaf('Text', 'hello')
22
+
23
+ const first = store.getOrCreate(green)
24
+ const second = store.getOrCreate(green)
25
+
26
+ expect(second).toBe(first)
27
+ expect(store.size).toBe(1)
28
+ expect(store.stats.hits).toBe(1)
29
+ expect(store.stats.misses).toBe(1)
30
+ expect(store.stats.collisions).toBe(0)
31
+ })
32
+
33
+ it('treats a hash collision as neither a hit nor an overwrite', () => {
34
+ const store = new RedNodeStore()
35
+
36
+ // Two genuinely different structures that share a hash.
37
+ const greenA = greenLeaf('Text', 'alpha')
38
+ const greenB = greenLeaf('Text', 'bravo')
39
+ expect(greenA).not.toBe(greenB)
40
+ expect(greenA.text).not.toBe(greenB.text)
41
+
42
+ const COLLISION_HASH = 0xdeadbeef
43
+ forceHash(greenA, COLLISION_HASH)
44
+ forceHash(greenB, COLLISION_HASH)
45
+ expect(greenA._hash).toBe(greenB._hash)
46
+
47
+ const canonicalA = store.getOrCreate(greenA)
48
+ expect(store.stats.misses).toBe(1)
49
+
50
+ // The colliding green must NOT be answered with A's canonical...
51
+ const fromB = store.getOrCreate(greenB)
52
+ expect(fromB).not.toBe(canonicalA)
53
+ // ...and the returned node must wrap B, never A.
54
+ expect(fromB.green).toBe(greenB)
55
+
56
+ // The registered entry under the hash is still A — never overwritten.
57
+ expect(store.getByHash(COLLISION_HASH.toString())).toBe(canonicalA)
58
+ expect(store.stats.collisions).toBe(1)
59
+ expect(store.size).toBe(1)
60
+
61
+ // A still canonicalizes normally after the collision.
62
+ expect(store.getOrCreate(greenA)).toBe(canonicalA)
63
+ expect(store.stats.hits).toBe(1)
64
+ })
65
+
66
+ it('does not register the colliding green (no canonicalization for it)', () => {
67
+ const store = new RedNodeStore()
68
+
69
+ const greenA = greenLeaf('Tag', 'same-hash-a')
70
+ const greenB = greenLeaf('Tag', 'same-hash-b')
71
+ forceHash(greenA, 42)
72
+ forceHash(greenB, 42)
73
+
74
+ store.getOrCreate(greenA)
75
+ const b1 = store.getOrCreate(greenB)
76
+ const b2 = store.getOrCreate(greenB)
77
+
78
+ // v1 semantic: a colliding subtree is rebuilt on each request rather than
79
+ // risk returning the wrong canonical.
80
+ expect(b1).not.toBe(b2)
81
+ expect(b1.green).toBe(greenB)
82
+ expect(b2.green).toBe(greenB)
83
+ expect(store.stats.collisions).toBe(2)
84
+ expect(store.size).toBe(1)
85
+ })
86
+
87
+ it('resets collision stats on clear', () => {
88
+ const store = new RedNodeStore()
89
+ const greenA = greenLeaf('Text', 'x')
90
+ const greenB = greenLeaf('Text', 'y')
91
+ forceHash(greenA, 7)
92
+ forceHash(greenB, 7)
93
+
94
+ store.getOrCreate(greenA)
95
+ store.getOrCreate(greenB)
96
+ expect(store.stats.collisions).toBe(1)
97
+
98
+ store.clear()
99
+ expect(store.size).toBe(0)
100
+ expect(store.stats.hits).toBe(0)
101
+ expect(store.stats.misses).toBe(0)
102
+ expect(store.stats.collisions).toBe(0)
103
+ })
104
+ })