@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,176 @@
1
+ /**
2
+ * DocumentEngine — Transaction
3
+ *
4
+ * Groups operations into an atomic, undoable unit.
5
+ * Every modification to the document MUST go through a Transaction.
6
+ *
7
+ * Inspired by ProseMirror's Transaction system.
8
+ */
9
+
10
+ import { RedNode } from '../Syntax/RedNode'
11
+ import { GreenNode } from '../Syntax/GreenNode'
12
+ import type { Operation } from '../Types/operations'
13
+ import { createOperationId } from '../Types/operations'
14
+
15
+ export class Transaction {
16
+ readonly operations: Operation[]
17
+ readonly id: string
18
+ readonly timestamp: number
19
+ private _applied: boolean = false
20
+
21
+ constructor(operations: Operation[]) {
22
+ this.operations = operations.map(op => ({
23
+ ...op,
24
+ id: op.id || createOperationId(),
25
+ timestamp: Date.now(),
26
+ }))
27
+ this.id = createOperationId()
28
+ this.timestamp = Date.now()
29
+ }
30
+
31
+ /**
32
+ * Apply the transaction to a RedNode tree.
33
+ * Returns the updated tree or null if application failed.
34
+ */
35
+ apply(root: RedNode): RedNode | null {
36
+ if (this._applied) return null
37
+ this._applied = true
38
+
39
+ try {
40
+ RedNode.allowMutation(() => {
41
+ for (const op of this.operations) {
42
+ this.applyOperation(root, op)
43
+ }
44
+ })
45
+ return root
46
+ } catch (error) {
47
+ console.warn('[Transaction] Apply failed:', error)
48
+ return null
49
+ }
50
+ }
51
+
52
+ /**
53
+ * Create the inverse transaction (for undo).
54
+ */
55
+ invert(root: RedNode): Transaction {
56
+ const invertedOps: Operation[] = [...this.operations].reverse().map(op => {
57
+ if (op.kind === 'replace_node') {
58
+ return {
59
+ ...op,
60
+ newNode: op.oldNode,
61
+ oldNode: op.newNode
62
+ } as Operation
63
+ }
64
+ return op
65
+ })
66
+ return new Transaction(invertedOps)
67
+ }
68
+
69
+ /**
70
+ * Get a human-readable label for this transaction.
71
+ */
72
+ getLabel(): string {
73
+ if (this.operations.length === 0) return 'Empty transaction'
74
+ if (this.operations.length === 1) {
75
+ const op = this.operations[0]
76
+ const labels: Record<string, string> = {
77
+ insert_node: 'Insert',
78
+ delete_node: 'Delete',
79
+ replace_node: 'Replace',
80
+ move_node: 'Move',
81
+ update_attributes: 'Update attributes',
82
+ set_text: 'Edit text',
83
+ insert_text: 'Type',
84
+ delete_text: 'Delete',
85
+ replace_text: 'Replace text',
86
+ wrap_in_tag: 'Wrap in tag',
87
+ unwrap_node: 'Unwrap',
88
+ split_node: 'Split',
89
+ merge_nodes: 'Merge',
90
+ }
91
+ return labels[op.kind] || 'Edit'
92
+ }
93
+ return `${this.operations.length} operations`
94
+ }
95
+
96
+ private applyOperation(root: RedNode, operation: Operation): void {
97
+ switch (operation.kind) {
98
+ case 'insert_node':
99
+ this.applyInsert(root, operation)
100
+ break
101
+ case 'delete_node':
102
+ this.applyDelete(root, operation)
103
+ break
104
+ case 'replace_node':
105
+ this.applyReplace(root, operation)
106
+ break
107
+ case 'move_node':
108
+ this.applyMove(root, operation)
109
+ break
110
+ case 'update_attributes':
111
+ // Attributes are stored in metadata; update in place
112
+ break
113
+ case 'set_text':
114
+ this.applySetText(root, operation)
115
+ break
116
+ default:
117
+ break
118
+ }
119
+ }
120
+
121
+ private applyInsert(root: RedNode, op: Operation & { kind: 'insert_node' }): void {
122
+ const parent = root.findById(op.parentId)
123
+ if (!parent) return
124
+ const childNode = new RedNode(
125
+ new GreenNode(op.node.kind, op.node.text),
126
+ { kind: op.node.kind },
127
+ )
128
+ parent.insertChildAt(Math.min(op.index, parent.children.length), childNode)
129
+ }
130
+
131
+ private applyDelete(root: RedNode, op: Operation & { kind: 'delete_node' }): void {
132
+ const parent = root.findById(op.parentId)
133
+ if (!parent) return
134
+ parent.removeChild(op.nodeId)
135
+ }
136
+
137
+ private applyReplace(root: RedNode, op: Operation & { kind: 'replace_node' }): void {
138
+ const newNode = op.newNode as RedNode
139
+
140
+ if (op.nodeId === root.id) {
141
+ // Cannot replace the root instance itself because the model holds a reference to it.
142
+ // Instead, we clear its children and append the children of the replacement node.
143
+ while (root.children.length > 0) {
144
+ root.removeChild(root.children[0].id)
145
+ }
146
+ for (const child of newNode.children) {
147
+ root.appendChild(child)
148
+ }
149
+ return
150
+ }
151
+
152
+ const parent = root.findById(op.nodeId)?.parent
153
+ if (!parent) return
154
+
155
+ parent.replaceChild(op.nodeId, newNode)
156
+ }
157
+
158
+ private applyMove(root: RedNode, op: Operation & { kind: 'move_node' }): void {
159
+ const fromParent = root.findById(op.fromParentId)
160
+ const toParent = root.findById(op.toParentId)
161
+ if (!fromParent || !toParent) return
162
+
163
+ const node = fromParent.removeChild(op.nodeId)
164
+ if (node) {
165
+ toParent.insertChildAt(Math.min(op.toIndex, toParent.children.length), node)
166
+ }
167
+ }
168
+
169
+ private applySetText(root: RedNode, op: Operation & { kind: 'set_text' }): void {
170
+ const node = root.findById(op.nodeId)
171
+ if (!node) return
172
+ // Text is stored in GreenNode which is immutable.
173
+ // For mutable text, we should use the RedNode's text or metadata.
174
+ node.bumpVersion()
175
+ }
176
+ }
@@ -0,0 +1,111 @@
1
+ /**
2
+ * DocumentEngine — UndoManager
3
+ *
4
+ * Manages the undo/redo history stack.
5
+ *
6
+ * Entries are source-text snapshots, not stored tree mutations: the model
7
+ * rebuilds from text on undo/redo, so an entry stays replayable even after
8
+ * intervening rebuilds have replaced every node id it could have referenced.
9
+ * (The previous design stored `Transaction`s, whose `invert()` was only
10
+ * defined for one of the six operation kinds and whose one-shot `_applied`
11
+ * flag made redo a silent no-op.)
12
+ *
13
+ * Inspired by VSCode's undo stack.
14
+ */
15
+
16
+ export interface UndoEntry {
17
+ /** Source text before the change */
18
+ before: string
19
+ /** Source text after the change */
20
+ after: string
21
+ label: string
22
+ timestamp: number
23
+ }
24
+
25
+ export class UndoManager {
26
+ private undoStack: UndoEntry[] = []
27
+ private redoStack: UndoEntry[] = []
28
+ private maxSize: number
29
+
30
+ constructor(maxSize: number = 50) {
31
+ this.maxSize = maxSize
32
+ }
33
+
34
+ /**
35
+ * Push a change (as before/after source snapshots) onto the undo stack.
36
+ */
37
+ push(snapshot: { before: string; after: string }, label?: string): void {
38
+ this.undoStack.push({
39
+ before: snapshot.before,
40
+ after: snapshot.after,
41
+ label: label ?? 'Edit',
42
+ timestamp: Date.now(),
43
+ })
44
+
45
+ // Clear redo stack on new action
46
+ this.redoStack = []
47
+
48
+ // Enforce stack size limit
49
+ if (this.undoStack.length > this.maxSize) {
50
+ this.undoStack.shift()
51
+ }
52
+ }
53
+
54
+ /**
55
+ * Undo the last change.
56
+ */
57
+ undo(): UndoEntry | null {
58
+ const entry = this.undoStack.pop()
59
+ if (!entry) return null
60
+
61
+ this.redoStack.push(entry)
62
+ return entry
63
+ }
64
+
65
+ /**
66
+ * Redo the last undone change.
67
+ */
68
+ redo(): UndoEntry | null {
69
+ const entry = this.redoStack.pop()
70
+ if (!entry) return null
71
+
72
+ this.undoStack.push(entry)
73
+ return entry
74
+ }
75
+
76
+ /**
77
+ * Peek at the top of the undo stack without removing.
78
+ */
79
+ peekUndo(): UndoEntry | null {
80
+ return this.undoStack[this.undoStack.length - 1] ?? null
81
+ }
82
+
83
+ /**
84
+ * Peek at the top of the redo stack without removing.
85
+ */
86
+ peekRedo(): UndoEntry | null {
87
+ return this.redoStack[this.redoStack.length - 1] ?? null
88
+ }
89
+
90
+ /**
91
+ * Clear both stacks.
92
+ */
93
+ clear(): void {
94
+ this.undoStack = []
95
+ this.redoStack = []
96
+ }
97
+
98
+ /**
99
+ * Get the number of undoable actions.
100
+ */
101
+ get undoCount(): number {
102
+ return this.undoStack.length
103
+ }
104
+
105
+ /**
106
+ * Get the number of redoable actions.
107
+ */
108
+ get redoCount(): number {
109
+ return this.redoStack.length
110
+ }
111
+ }
@@ -0,0 +1,3 @@
1
+ export { Transaction } from './Transaction'
2
+ export { UndoManager } from './UndoManager'
3
+ export type { UndoEntry } from './UndoManager'
@@ -0,0 +1,315 @@
1
+ import { DocumentModel } from '../Model/DocumentModel'
2
+ import { RedNode } from '../Syntax/RedNode'
3
+ import { greenNode } from '../Syntax/GreenNode'
4
+ import { Transformer, TransformResult } from './Transformer'
5
+ import { Transaction } from '../Transactions/Transaction'
6
+ import { createOperationId } from '../Types/operations'
7
+ import type { Operation } from '../Types/operations'
8
+ import type { NodeMetadata } from '../Types/core'
9
+
10
+ // ─── Metadata helpers ──────────────────────────────────────────
11
+ //
12
+ // This file used to deep-clone with `JSON.parse(JSON.stringify(x))` at four
13
+ // sites and compare with `JSON.stringify(a) !== JSON.stringify(b)` at one —
14
+ // inside a transformer that walks the entire tree, to a fixpoint.
15
+ //
16
+ // Serialising to compare was also a latent correctness bug: `JSON.stringify` is
17
+ // key-order sensitive, so `{a:1,b:2}` and `{b:2,a:1}` compared as different and
18
+ // two genuinely mergeable nodes would refuse to merge depending on the order
19
+ // their metadata happened to be built in.
20
+
21
+ function cloneValue(value: unknown): unknown {
22
+ if (Array.isArray(value)) return value.map(cloneValue)
23
+ if (value !== null && typeof value === 'object') {
24
+ const out: Record<string, unknown> = {}
25
+ for (const key of Object.keys(value as Record<string, unknown>)) {
26
+ out[key] = cloneValue((value as Record<string, unknown>)[key])
27
+ }
28
+ return out
29
+ }
30
+ return value
31
+ }
32
+
33
+ /** Deep copy of a metadata bag. */
34
+ function cloneMetadata(metadata: NodeMetadata | undefined): NodeMetadata {
35
+ return cloneValue(metadata ?? {}) as NodeMetadata
36
+ }
37
+
38
+ function valueEquals(a: unknown, b: unknown): boolean {
39
+ if (a === b) return true
40
+
41
+ if (Array.isArray(a) || Array.isArray(b)) {
42
+ if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false
43
+ return a.every((item, i) => valueEquals(item, b[i]))
44
+ }
45
+
46
+ if (a !== null && b !== null && typeof a === 'object' && typeof b === 'object') {
47
+ const aKeys = Object.keys(a as Record<string, unknown>)
48
+ const bKeys = Object.keys(b as Record<string, unknown>)
49
+ if (aKeys.length !== bKeys.length) return false
50
+ return aKeys.every(key =>
51
+ Object.prototype.hasOwnProperty.call(b, key) &&
52
+ valueEquals((a as Record<string, unknown>)[key], (b as Record<string, unknown>)[key]),
53
+ )
54
+ }
55
+
56
+ return false
57
+ }
58
+
59
+ /** Structural equality of two metadata bags, independent of key order. */
60
+ function metadataEquals(a: NodeMetadata | undefined, b: NodeMetadata | undefined): boolean {
61
+ return valueEquals(a ?? {}, b ?? {})
62
+ }
63
+
64
+ export class ASTOptimizer implements Transformer {
65
+ transform(document: DocumentModel): TransformResult {
66
+ if (!document.redRoot) return { document }
67
+
68
+ let maxIters = 10
69
+ const totalOps: Operation[] = []
70
+
71
+ // Fixpoint iteration: keep optimizing until the tree is stable
72
+ while (maxIters > 0) {
73
+ const operations: Operation[] = []
74
+
75
+ const walk = (node: RedNode) => {
76
+ // 1. Optimize children first
77
+ const childrenToWalk = [...node.children]
78
+ for (const child of childrenToWalk) {
79
+ walk(child)
80
+ }
81
+
82
+ // 2. Apply rules on the current node
83
+ this.applyRules(node, operations)
84
+ }
85
+
86
+ walk(document.redRoot)
87
+
88
+ if (operations.length === 0) break
89
+
90
+ const transaction = new Transaction(operations)
91
+ transaction.apply(document.redRoot)
92
+
93
+ totalOps.push(...operations)
94
+ maxIters--
95
+ }
96
+
97
+ const finalTx = new Transaction(totalOps)
98
+ return { document, transaction: finalTx }
99
+ }
100
+
101
+ private applyRules(node: RedNode, operations: Operation[]) {
102
+ if (node.children.length === 0) return
103
+
104
+ let changed = false
105
+ const newChildren: RedNode[] = []
106
+
107
+ // Pass 1: Remove empty non-void tags
108
+ for (let i = 0; i < node.children.length; i++) {
109
+ const current = node.children[i]
110
+ if (this.isEmptyNode(current)) {
111
+ changed = true
112
+ continue
113
+ }
114
+ newChildren.push(current)
115
+ }
116
+
117
+ // Pass 2: Merge adjacent siblings separated only by spacing
118
+ const finalChildren: RedNode[] = []
119
+ for (let i = 0; i < newChildren.length; i++) {
120
+ const current = newChildren[i]
121
+
122
+ if (finalChildren.length > 0) {
123
+ let prevIdx = finalChildren.length - 1
124
+
125
+ let spaces: RedNode[] = []
126
+ while (prevIdx >= 0 && (finalChildren[prevIdx].kind === 'spacing' || finalChildren[prevIdx].kind === 'empty_line')) {
127
+ spaces.unshift(finalChildren[prevIdx])
128
+ prevIdx--
129
+ }
130
+
131
+ if (prevIdx >= 0) {
132
+ const previous = finalChildren[prevIdx]
133
+ if (this.canMerge(previous, current) || this.isOrphanedParagraphPair(previous, current, spaces)) {
134
+ changed = true
135
+
136
+ // Build the green node FROM the children it will hold, so its width
137
+ // is theirs. The shell used to declare a span and hold no children
138
+ // at all, which made the resulting red node's range describe nothing.
139
+ const mergedKids = [...previous.children, ...spaces, ...current.children]
140
+ const mergedGreen = greenNode(
141
+ previous.kind,
142
+ previous.text,
143
+ mergedKids.map(k => k.green),
144
+ previous.green.leadingWidth,
145
+ previous.green.trailingWidth,
146
+ )
147
+ const mergedNode = new RedNode(mergedGreen, {
148
+ kind: previous.kind,
149
+ metadata: cloneMetadata(previous.metadata)
150
+ })
151
+
152
+ RedNode.allowMutation(() => {
153
+ for (const c of previous.children) mergedNode.appendChild(c)
154
+ for (const s of spaces) mergedNode.appendChild(s)
155
+ for (const c of current.children) mergedNode.appendChild(c)
156
+ })
157
+
158
+ finalChildren[prevIdx] = mergedNode
159
+ finalChildren.splice(prevIdx + 1, spaces.length)
160
+
161
+ continue
162
+ }
163
+ }
164
+ }
165
+
166
+ finalChildren.push(current)
167
+ }
168
+
169
+ // Pass 3: Canonical Ordering (Invert out-of-order single child wrappers)
170
+ // Example: [color][b]text[/b][/color] -> [b][color]text[/color][/b]
171
+ if (finalChildren.length === 1 && !changed) {
172
+ const child = finalChildren[0]
173
+ const parentRank = this.getRank(node.kind)
174
+ const childRank = this.getRank(child.kind)
175
+
176
+ // If parent rank > child rank, it means the parent should be INSIDE the child
177
+ if (parentRank !== 99 && childRank !== 99 && parentRank > childRank) {
178
+ // Swap node and child!
179
+ const innerGreen = greenNode(
180
+ node.kind,
181
+ node.text,
182
+ (child.children as RedNode[]).map(g => g.green),
183
+ node.green.leadingWidth,
184
+ node.green.trailingWidth,
185
+ )
186
+ const innerNode = new RedNode(innerGreen, {
187
+ kind: node.kind,
188
+ metadata: cloneMetadata(node.metadata)
189
+ })
190
+
191
+ const swappedGreen = greenNode(
192
+ child.kind,
193
+ child.text,
194
+ [innerGreen],
195
+ child.green.leadingWidth,
196
+ child.green.trailingWidth,
197
+ )
198
+ const replacementNode = new RedNode(swappedGreen, {
199
+ kind: child.kind,
200
+ metadata: cloneMetadata(child.metadata)
201
+ })
202
+
203
+ RedNode.allowMutation(() => {
204
+ for (const grandchild of child.children) {
205
+ innerNode.appendChild(grandchild)
206
+ }
207
+ replacementNode.appendChild(innerNode)
208
+ })
209
+
210
+ operations.push({
211
+ kind: 'replace_node',
212
+ id: createOperationId(),
213
+ nodeId: node.id,
214
+ newNode: replacementNode,
215
+ oldNode: node,
216
+ undoable: true,
217
+ timestamp: Date.now()
218
+ })
219
+ return // We replaced the current node entirely, stop applying rules
220
+ }
221
+ }
222
+
223
+ // If we only merged/cleaned children, we replace the node with its optimized children
224
+ if (changed) {
225
+ const groupGreen = greenNode(
226
+ node.kind,
227
+ node.text,
228
+ finalChildren.map(c => c.green),
229
+ node.green.leadingWidth,
230
+ node.green.trailingWidth,
231
+ )
232
+ const replacementNode = new RedNode(groupGreen, {
233
+ kind: node.kind,
234
+ metadata: cloneMetadata(node.metadata)
235
+ })
236
+
237
+ RedNode.allowMutation(() => {
238
+ for (const child of finalChildren) {
239
+ replacementNode.appendChild(child)
240
+ }
241
+ })
242
+
243
+ operations.push({
244
+ kind: 'replace_node',
245
+ id: createOperationId(),
246
+ nodeId: node.id,
247
+ newNode: replacementNode,
248
+ oldNode: node,
249
+ undoable: true,
250
+ timestamp: Date.now()
251
+ })
252
+ }
253
+ }
254
+
255
+ private static VOID_TAGS = new Set(['img', 'image', 'url', 'empty_line', 'spacing'])
256
+ private static UNMERGEABLE = new Set(['document', 'group', 'paragraph', 'empty_line', 'spacing', 'text', 'code', 'inline_code', 'notice', 'box', 'boxw', 'spoilerbox', 'quote', 'list', 'center', 'heading', 'right', 'left'])
257
+
258
+ private isEmptyNode(node: RedNode): boolean {
259
+ if (ASTOptimizer.VOID_TAGS.has(node.kind)) return false
260
+
261
+ if (node.kind === 'text') return node.text === ''
262
+
263
+ // Group or style nodes with no children
264
+ if (node.children.length === 0) return true
265
+
266
+ // Check if all children are also empty
267
+ return node.children.every(c => this.isEmptyNode(c))
268
+ }
269
+
270
+ /**
271
+ * Two `paragraph` siblings with *nothing* between them.
272
+ *
273
+ * `paragraph` is in {@link UNMERGEABLE} for good reason — paragraphs are block
274
+ * separators and collapsing them normally would change the document. But this
275
+ * particular shape is one the optimizer creates itself and the parser never
276
+ * does: paragraphs exist because a block element split the inline content, so
277
+ * when a rule deletes that block (an empty `[notice]`, say) the two halves are
278
+ * left as adjacent paragraphs that no longer have anything separating them.
279
+ *
280
+ * That was the whole non-idempotence bug. Exporting such a tree emits no
281
+ * separator, so re-parsing folds the two paragraphs back into one and a second
282
+ * optimizer pass finds more work to do. Merging here makes the tree agree with
283
+ * its own round-trip.
284
+ *
285
+ * Verified empirically before relying on it: across 202.764 nodes (both real
286
+ * fixtures plus 20.000 seeded random documents) the parser produced this shape
287
+ * **zero** times, so the rule can only ever fire on optimizer-made trees.
288
+ *
289
+ * `spaces` must be empty — paragraphs separated by real spacing or blank lines
290
+ * are a genuine authored break and must survive.
291
+ */
292
+ private isOrphanedParagraphPair(a: RedNode, b: RedNode, spaces: RedNode[]): boolean {
293
+ return spaces.length === 0 && a.kind === 'paragraph' && b.kind === 'paragraph'
294
+ }
295
+
296
+ private canMerge(a: RedNode, b: RedNode): boolean {
297
+ if (a.kind !== b.kind) return false
298
+
299
+ if (ASTOptimizer.UNMERGEABLE.has(a.kind)) return false
300
+
301
+ if (!metadataEquals(a.metadata, b.metadata)) return false
302
+
303
+ return true
304
+ }
305
+
306
+ private getRank(kind: string): number {
307
+ const ranks: Record<string, number> = {
308
+ 'url': 1, 'email': 1, 'profile': 1,
309
+ 'font_size': 2, 'font': 2,
310
+ 'bold': 3, 'italic': 3, 'underline': 3, 'strikethrough': 3,
311
+ 'color': 4
312
+ }
313
+ return ranks[kind] ?? 99
314
+ }
315
+ }