@miliastry/quasar 1.0.6 → 1.1.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 (273) hide show
  1. package/dist/Visuals/lyne.css +117 -6
  2. package/dist/Visuals/osu.css +36 -5
  3. package/dist/index.js +9664 -3282
  4. package/dist/index.mjs +9535 -3283
  5. package/dist/types/Analysis/Contracts/AnalysisReport.d.ts +22 -0
  6. package/dist/types/Analysis/Contracts/Contribution.d.ts +68 -0
  7. package/dist/types/Analysis/Contracts/Pass.d.ts +59 -0
  8. package/dist/types/Analysis/Contracts/PipelineContext.d.ts +34 -0
  9. package/dist/types/Analysis/Passes/Analysis/ColorUsageAnalyzer.d.ts +56 -0
  10. package/dist/types/Analysis/Passes/Analysis/GradientAnalyzer.d.ts +111 -0
  11. package/dist/types/Analysis/Passes/Analysis/MergeableColorAnalyzer.d.ts +26 -0
  12. package/dist/types/Analysis/Passes/Analysis/RainbowAnalyzer.d.ts +47 -0
  13. package/dist/types/Analysis/Passes/Analysis/SymbolAnalyzer.d.ts +89 -0
  14. package/dist/types/Analysis/Passes/Analysis/WaveAnalyzer.d.ts +44 -0
  15. package/dist/types/Analysis/Passes/Analysis/__tests__/GradientAnalyzer.test.d.ts +4 -0
  16. package/dist/types/Analysis/Passes/Analysis/__tests__/MergeableColorAnalyzer.test.d.ts +4 -0
  17. package/dist/types/Analysis/Passes/Analysis/__tests__/RainbowAnalyzer.test.d.ts +4 -0
  18. package/dist/types/Analysis/Passes/Analysis/__tests__/WaveAnalyzer.test.d.ts +4 -0
  19. package/dist/types/Analysis/Passes/Decision/DefaultDecision.d.ts +30 -0
  20. package/dist/types/Analysis/Passes/Decision/PaletteRemapDecision.d.ts +169 -0
  21. package/dist/types/Analysis/Passes/Decision/__tests__/DefaultDecision.test.d.ts +4 -0
  22. package/dist/types/Analysis/Passes/Transform/CollapseGradientTransform.d.ts +37 -0
  23. package/dist/types/Analysis/Passes/Transform/MergeColorsTransform.d.ts +28 -0
  24. package/dist/types/Analysis/Passes/Transform/RainbowCollapseTransform.d.ts +19 -0
  25. package/dist/types/Analysis/Passes/Transform/WaveCollapseTransform.d.ts +20 -0
  26. package/dist/types/Analysis/Passes/Utility/CharacterCountAnalyzer.d.ts +19 -0
  27. package/dist/types/Analysis/Pipeline/Pipeline.d.ts +69 -0
  28. package/dist/types/Analysis/Pipeline/PipelineBuilder.d.ts +31 -0
  29. package/dist/types/Analysis/Pipeline/PipelineStage.d.ts +17 -0
  30. package/dist/types/Analysis/Utils/color-utils.d.ts +60 -0
  31. package/dist/types/Analysis/__tests__/Integration.test.d.ts +8 -0
  32. package/dist/types/Analysis/__tests__/PaletteRemap.test.d.ts +14 -0
  33. package/dist/types/Analysis/__tests__/Pipeline.test.d.ts +10 -0
  34. package/dist/types/Analysis/__tests__/RoundTrip.test.d.ts +30 -0
  35. package/dist/types/Analysis/__tests__/SymbolAnalyzer.test.d.ts +11 -0
  36. package/dist/types/Analysis/index.d.ts +40 -0
  37. package/dist/types/BBCode/BBCodeDocumentModel.d.ts +92 -0
  38. package/dist/types/BBCode/BBCodeToGreenNode.d.ts +111 -0
  39. package/dist/types/BBCode/Parser.d.ts +78 -0
  40. package/dist/types/BBCode/index.d.ts +4 -0
  41. package/dist/types/Collab/positions.d.ts +55 -0
  42. package/dist/types/Commands/Command.d.ts +37 -0
  43. package/dist/types/Commands/CommandRegistry.d.ts +48 -0
  44. package/dist/types/Commands/DeleteNode.d.ts +7 -0
  45. package/dist/types/Commands/InsertText.d.ts +8 -0
  46. package/dist/types/Commands/SplitMerge.d.ts +9 -0
  47. package/dist/types/Commands/WrapInTag.d.ts +8 -0
  48. package/dist/types/Commands/index.d.ts +6 -0
  49. package/dist/types/Diff/TreeDiffer.d.ts +52 -0
  50. package/dist/types/Diff/__tests__/TreeDiffer.test.d.ts +1 -0
  51. package/dist/types/Diff/index.d.ts +2 -0
  52. package/dist/types/Edits/EditPlan.d.ts +144 -0
  53. package/dist/types/Edits/Optimizer.d.ts +78 -0
  54. package/dist/types/Edits/Rules/Rule.d.ts +91 -0
  55. package/dist/types/Edits/Rules/dropEmptyTags.d.ts +40 -0
  56. package/dist/types/Edits/Rules/dropRedundantNesting.d.ts +59 -0
  57. package/dist/types/Edits/Rules/index.d.ts +9 -0
  58. package/dist/types/Edits/Rules/mergeAdjacent.d.ts +93 -0
  59. package/dist/types/Edits/Rules/reorderWrappers.d.ts +42 -0
  60. package/dist/types/Edits/Rules/shortenHex.d.ts +30 -0
  61. package/dist/types/Edits/Rules/tagValue.d.ts +56 -0
  62. package/dist/types/Edits/Rules/unwrapInvisibleColor.d.ts +47 -0
  63. package/dist/types/Edits/applyEdits.d.ts +36 -0
  64. package/dist/types/Edits/index.d.ts +6 -0
  65. package/dist/types/Events/EventBus.d.ts +103 -0
  66. package/dist/types/Events/index.d.ts +2 -0
  67. package/dist/types/Formatter/Formatter.d.ts +32 -0
  68. package/dist/types/Formatter/index.d.ts +2 -0
  69. package/dist/types/HTML/HTMLDocumentModel.d.ts +13 -0
  70. package/dist/types/HTML/HTMLToGreenNode.d.ts +4 -0
  71. package/dist/types/Incremental/BracketIndex.d.ts +101 -0
  72. package/dist/types/Incremental/ChangeTracker.d.ts +69 -0
  73. package/dist/types/Incremental/IncrementalParser.d.ts +246 -0
  74. package/dist/types/Incremental/__tests__/BracketIndex.test.d.ts +1 -0
  75. package/dist/types/Incremental/__tests__/IncrementalParser.test.d.ts +1 -0
  76. package/dist/types/Incremental/index.d.ts +4 -0
  77. package/dist/types/Lexer/BBCodeLexer.d.ts +74 -0
  78. package/dist/types/Lexer/Lexer.d.ts +54 -0
  79. package/dist/types/Lexer/index.d.ts +4 -0
  80. package/dist/types/Linter/Linter.d.ts +58 -0
  81. package/dist/types/Linter/index.d.ts +2 -0
  82. package/dist/types/Markdown/MarkdownAST.d.ts +122 -0
  83. package/dist/types/Markdown/MarkdownDocumentModel.d.ts +15 -0
  84. package/dist/types/Markdown/MarkdownLexer.d.ts +155 -0
  85. package/dist/types/Markdown/MarkdownParser.d.ts +42 -0
  86. package/dist/types/Markdown/MarkdownToGreenNode.d.ts +6 -0
  87. package/dist/types/MilHibri/MilHibriDocumentModel.d.ts +21 -0
  88. package/dist/types/Model/DocumentModel.d.ts +318 -0
  89. package/dist/types/Model/NodeFactory.d.ts +29 -0
  90. package/dist/types/Model/TagRegistry.d.ts +165 -0
  91. package/dist/types/Model/index.d.ts +5 -0
  92. package/dist/types/Plugins/PluginAPI.d.ts +44 -0
  93. package/dist/types/Plugins/PluginRegistry.d.ts +81 -0
  94. package/dist/types/Plugins/index.d.ts +3 -0
  95. package/dist/types/Queries/QueryEngine.d.ts +32 -0
  96. package/dist/types/Queries/index.d.ts +1 -0
  97. package/dist/types/Reconciler/SurgicalReconciler.d.ts +23 -0
  98. package/dist/types/RenderPipeline/RenderPipeline.d.ts +46 -0
  99. package/dist/types/RenderPipeline/RenderTree.d.ts +55 -0
  100. package/dist/types/RenderPipeline/index.d.ts +4 -0
  101. package/dist/types/Repair/NestingRepair.d.ts +48 -0
  102. package/dist/types/Repair/index.d.ts +1 -0
  103. package/dist/types/Semantic/SemanticAnalyzer.d.ts +321 -0
  104. package/dist/types/Semantic/index.d.ts +2 -0
  105. package/dist/types/Symbols/SymbolTable.d.ts +47 -0
  106. package/dist/types/Symbols/index.d.ts +1 -0
  107. package/dist/types/Syntax/GreenNode.d.ts +154 -0
  108. package/dist/types/Syntax/GreenNodePool.d.ts +134 -0
  109. package/dist/types/Syntax/NodeMatcher.d.ts +75 -0
  110. package/dist/types/Syntax/RedNode.d.ts +280 -0
  111. package/dist/types/Syntax/RedNodeStore.d.ts +116 -0
  112. package/dist/types/Syntax/TreeBuilder.d.ts +54 -0
  113. package/dist/types/Syntax/__tests__/GreenNode.test.d.ts +1 -0
  114. package/dist/types/Syntax/__tests__/RedNode.test.d.ts +1 -0
  115. package/dist/types/Syntax/__tests__/RedNodeStore.test.d.ts +1 -0
  116. package/dist/types/Syntax/greenEdit.d.ts +64 -0
  117. package/dist/types/Syntax/hash.d.ts +17 -0
  118. package/dist/types/Syntax/index.d.ts +8 -0
  119. package/dist/types/Syntax/nodeAttr.d.ts +63 -0
  120. package/dist/types/Syntax/partition.d.ts +67 -0
  121. package/dist/types/Syntax/preserveNodeIds.d.ts +47 -0
  122. package/dist/types/Tokens/TokenResolver.d.ts +25 -0
  123. package/dist/types/Tokens/index.d.ts +1 -0
  124. package/dist/types/Transactions/Transaction.d.ts +36 -0
  125. package/dist/types/Transactions/UndoManager.d.ts +63 -0
  126. package/dist/types/Transactions/index.d.ts +3 -0
  127. package/dist/types/Transformers/ASTOptimizer.d.ts +34 -0
  128. package/dist/types/Transformers/GradientTransformer.d.ts +18 -0
  129. package/dist/types/Transformers/GrowTransformer.d.ts +16 -0
  130. package/dist/types/Transformers/RainbowTransformer.d.ts +17 -0
  131. package/dist/types/Transformers/SineWaveTransformer.d.ts +17 -0
  132. package/dist/types/Transformers/Transformer.d.ts +17 -0
  133. package/dist/types/Types/core.d.ts +104 -0
  134. package/dist/types/Types/diagnostics.d.ts +85 -0
  135. package/dist/types/Types/index.d.ts +11 -0
  136. package/dist/types/Types/operations.d.ts +109 -0
  137. package/dist/types/Types/queries.d.ts +67 -0
  138. package/dist/types/Types/symbols.d.ts +42 -0
  139. package/dist/types/Types/tokens.d.ts +75 -0
  140. package/dist/types/Utils/BBCodeGenerator.d.ts +21 -0
  141. package/dist/types/Utils/ColorMath.d.ts +92 -0
  142. package/dist/types/Utils/EffectMath.d.ts +643 -0
  143. package/dist/types/Utils/TagVocabulary.d.ts +104 -0
  144. package/dist/types/Utils/TagVocabulary.test.d.ts +1 -0
  145. package/dist/types/Utils/color.d.ts +29 -0
  146. package/dist/types/Utils/dom-to-svg.d.ts +28 -0
  147. package/dist/types/Utils/dom-to-svg.test.d.ts +1 -0
  148. package/dist/types/Utils/treeTransformers.d.ts +160 -0
  149. package/dist/types/Visitors/BBBlocksExporter.d.ts +27 -0
  150. package/dist/types/Visitors/BBCodeExporter.d.ts +90 -0
  151. package/dist/types/Visitors/BlockPatcher.d.ts +91 -0
  152. package/dist/types/Visitors/DOMMorpher.d.ts +13 -0
  153. package/dist/types/Visitors/HTMLRenderer.d.ts +380 -0
  154. package/dist/types/Visitors/JSONExporter.d.ts +42 -0
  155. package/dist/types/Visitors/MarkdownExporter.d.ts +28 -0
  156. package/dist/types/Visitors/SVGRenderer.d.ts +16 -0
  157. package/dist/types/Visitors/TiptapExporter.d.ts +40 -0
  158. package/dist/types/Visitors/Visitor.d.ts +35 -0
  159. package/dist/types/Visitors/index.d.ts +9 -0
  160. package/dist/types/Visuals/BoxDrawer.d.ts +49 -0
  161. package/dist/types/Visuals/index.d.ts +38 -0
  162. package/dist/types/index.d.ts +154 -0
  163. package/package.json +14 -10
  164. package/src/Analysis/Passes/Analysis/ColorUsageAnalyzer.ts +103 -0
  165. package/src/Analysis/Passes/Analysis/GradientAnalyzer.ts +294 -20
  166. package/src/Analysis/Passes/Analysis/SymbolAnalyzer.ts +257 -0
  167. package/src/Analysis/Passes/Decision/PaletteRemapDecision.ts +425 -0
  168. package/src/Analysis/Pipeline/Pipeline.ts +15 -2
  169. package/src/Analysis/index.ts +8 -2
  170. package/src/BBCode/BBCodeDocumentModel.ts +3 -0
  171. package/src/BBCode/BBCodeToGreenNode.ts +18 -12
  172. package/src/BBCode/Parser.ts +38 -1
  173. package/src/Edits/EditPlan.ts +290 -0
  174. package/src/Edits/Optimizer.ts +185 -0
  175. package/src/Edits/Rules/Rule.ts +145 -0
  176. package/src/Edits/Rules/dropEmptyTags.ts +110 -0
  177. package/src/Edits/Rules/dropRedundantNesting.ts +120 -0
  178. package/src/Edits/Rules/index.ts +28 -0
  179. package/src/Edits/Rules/mergeAdjacent.ts +224 -0
  180. package/src/Edits/Rules/reorderWrappers.ts +154 -0
  181. package/src/Edits/Rules/shortenHex.ts +79 -0
  182. package/src/Edits/Rules/tagValue.ts +105 -0
  183. package/src/Edits/Rules/unwrapInvisibleColor.ts +93 -0
  184. package/src/Edits/applyEdits.ts +54 -0
  185. package/src/Edits/index.ts +22 -0
  186. package/src/Events/EventBus.ts +18 -0
  187. package/src/HTML/HTMLToGreenNode.ts +185 -37
  188. package/src/Incremental/BracketIndex.ts +249 -0
  189. package/src/Incremental/IncrementalParser.ts +464 -41
  190. package/src/Lexer/BBCodeLexer.ts +25 -3
  191. package/src/Lexer/index.ts +1 -1
  192. package/src/Markdown/MarkdownAST.ts +65 -2
  193. package/src/Markdown/MarkdownLexer.ts +69 -14
  194. package/src/Markdown/MarkdownParser.ts +749 -62
  195. package/src/Markdown/MarkdownToGreenNode.ts +148 -27
  196. package/src/MilHibri/MilHibriDocumentModel.ts +56 -0
  197. package/src/Model/DocumentModel.ts +138 -5
  198. package/src/Model/TagRegistry.ts +129 -134
  199. package/src/Reconciler/SurgicalReconciler.ts +460 -0
  200. package/src/Repair/NestingRepair.ts +116 -0
  201. package/src/Repair/index.ts +1 -0
  202. package/src/Semantic/SemanticAnalyzer.ts +1802 -65
  203. package/src/Syntax/GreenNodePool.ts +12 -12
  204. package/src/Syntax/RedNode.ts +17 -3
  205. package/src/Syntax/RedNodeStore.ts +9 -1
  206. package/src/Syntax/nodeAttr.ts +172 -0
  207. package/src/Tokens/TokenResolver.ts +55 -0
  208. package/src/Tokens/index.ts +1 -0
  209. package/src/Types/core.ts +15 -0
  210. package/src/Utils/ColorMath.ts +276 -55
  211. package/src/Utils/EffectMath.ts +2462 -0
  212. package/src/Utils/TagVocabulary.ts +141 -0
  213. package/src/Utils/dom-to-svg.ts +28 -12
  214. package/src/Visitors/BBBlocksExporter.ts +15 -1
  215. package/src/Visitors/BBCodeExporter.ts +319 -32
  216. package/src/Visitors/BlockPatcher.ts +87 -11
  217. package/src/Visitors/DOMMorpher.ts +15 -5
  218. package/src/Visitors/HTMLRenderer.ts +374 -151
  219. package/src/Visitors/MarkdownExporter.ts +98 -41
  220. package/src/Visitors/TiptapExporter.ts +1 -0
  221. package/src/Visuals/index.ts +7 -4
  222. package/src/Visuals/lyne.css +117 -6
  223. package/src/Visuals/osu.css +36 -5
  224. package/src/index.ts +172 -5
  225. package/dist/index.d.mts +0 -4612
  226. package/dist/index.d.ts +0 -4612
  227. package/src/Analysis/Passes/Analysis/__tests__/GradientAnalyzer.test.ts +0 -135
  228. package/src/Analysis/Passes/Analysis/__tests__/MergeableColorAnalyzer.test.ts +0 -84
  229. package/src/Analysis/Passes/Analysis/__tests__/RainbowAnalyzer.test.ts +0 -99
  230. package/src/Analysis/Passes/Analysis/__tests__/WaveAnalyzer.test.ts +0 -119
  231. package/src/Analysis/Passes/Decision/__tests__/DefaultDecision.test.ts +0 -179
  232. package/src/Analysis/__tests__/Integration.test.ts +0 -162
  233. package/src/Analysis/__tests__/Pipeline.test.ts +0 -133
  234. package/src/Diff/__tests__/TreeDiffer.test.ts +0 -65
  235. package/src/Incremental/__tests__/IncrementalParser.test.ts +0 -164
  236. package/src/Syntax/__tests__/GreenNode.test.ts +0 -33
  237. package/src/Syntax/__tests__/RedNode.test.ts +0 -81
  238. package/src/Syntax/__tests__/RedNodeStore.test.ts +0 -104
  239. package/src/Tests/ASTOptimizerIdempotence.test.ts +0 -77
  240. package/src/Tests/BlockPatcher.test.ts +0 -437
  241. package/src/Tests/BlockPatcherWindowed.test.ts +0 -364
  242. package/src/Tests/BoxDrawer.test.ts +0 -217
  243. package/src/Tests/BoxRichTitle.test.ts +0 -105
  244. package/src/Tests/Chars500kBenchmark.test.ts +0 -151
  245. package/src/Tests/Chars500kEdits.test.ts +0 -321
  246. package/src/Tests/CollabPositions.test.ts +0 -146
  247. package/src/Tests/CompilerPathProfiling.test.ts +0 -186
  248. package/src/Tests/DOMMorpher.test.ts +0 -142
  249. package/src/Tests/DomPatchPerf.test.ts +0 -60
  250. package/src/Tests/EffectSegments.snapshot.json +0 -616
  251. package/src/Tests/EffectSegments.test.ts +0 -68
  252. package/src/Tests/FindNodeAtOffset.test.ts +0 -65
  253. package/src/Tests/Fuzzer.test.ts +0 -166
  254. package/src/Tests/GreenNodePool.test.ts +0 -153
  255. package/src/Tests/Lexer.test.ts +0 -238
  256. package/src/Tests/LyneMode.test.ts +0 -291
  257. package/src/Tests/ModelCoherence.test.ts +0 -180
  258. package/src/Tests/Partition.test.ts +0 -238
  259. package/src/Tests/PluginTags.test.ts +0 -150
  260. package/src/Tests/ProblematicSection.test.ts +0 -46
  261. package/src/Tests/ProblematicSectionHTML.test.ts +0 -58
  262. package/src/Tests/RedReuse.test.ts +0 -134
  263. package/src/Tests/ReproDelete20k.test.ts +0 -62
  264. package/src/Tests/SemanticValidators.test.ts +0 -136
  265. package/src/Tests/StableNodeIds.test.ts +0 -210
  266. package/src/Tests/StudioColorBloat.test.ts +0 -25
  267. package/src/Tests/StudioDebugText.test.ts +0 -27
  268. package/src/Tests/StudioTrailingChar.test.ts +0 -25
  269. package/src/Tests/StudioValidText.test.ts +0 -25
  270. package/src/Tests/UrlImgBug.test.ts +0 -23
  271. package/src/Tests/VisualBuilderFidelity.test.ts +0 -105
  272. package/src/Tests/referenceDocument.ts +0 -119
  273. package/src/Utils/dom-to-svg.test.ts +0 -86
@@ -0,0 +1,460 @@
1
+ import { RedNode } from '../Syntax/RedNode'
2
+ import { HTMLDocumentModel } from '../HTML/HTMLDocumentModel'
3
+ import { BBCodeExporter } from '../Visitors/BBCodeExporter'
4
+ import { HTMLRenderer } from '../Visitors/HTMLRenderer'
5
+
6
+ export interface SurgicalEdit {
7
+ start: number
8
+ end: number
9
+ text: string
10
+ }
11
+
12
+ export interface ReconcileResult {
13
+ edits: SurgicalEdit[]
14
+ hasChanges: boolean
15
+ resultingSource: string
16
+ }
17
+
18
+ const ZWSP = /​/g
19
+
20
+ /**
21
+ * Traverses a RedNode tree and builds a map of NodeId -> RedNode for fast lookup.
22
+ */
23
+ function buildNodeIdMap(root: RedNode): Map<string, RedNode> {
24
+ const map = new Map<string, RedNode>()
25
+ function walk(node: RedNode) {
26
+ if (node.id) {
27
+ map.set(node.id, node)
28
+ }
29
+ for (const child of node.children) {
30
+ walk(child)
31
+ }
32
+ }
33
+ walk(root)
34
+ return map
35
+ }
36
+
37
+ /**
38
+ * Computes the minimal single-range text edit (common prefix + suffix diff)
39
+ * between two strings, ensuring Monaco only replaces the exact changed characters.
40
+ */
41
+ export function computeTextDelta(original: string, updated: string): SurgicalEdit[] {
42
+ if (original === updated) return []
43
+
44
+ let prefix = 0
45
+ const minLen = Math.min(original.length, updated.length)
46
+ while (prefix < minLen && original.charCodeAt(prefix) === updated.charCodeAt(prefix)) {
47
+ prefix++
48
+ }
49
+
50
+ let origSuffix = original.length
51
+ let updSuffix = updated.length
52
+ while (
53
+ origSuffix > prefix &&
54
+ updSuffix > prefix &&
55
+ original.charCodeAt(origSuffix - 1) === updated.charCodeAt(updSuffix - 1)
56
+ ) {
57
+ origSuffix--
58
+ updSuffix--
59
+ }
60
+
61
+ const newText = updated.slice(prefix, updSuffix)
62
+ return [
63
+ {
64
+ start: prefix,
65
+ end: origSuffix,
66
+ text: newText,
67
+ },
68
+ ]
69
+ }
70
+
71
+ /** Every text node under `root`, in document order, with zero-width joiners removed. */
72
+ function domTextNodes(root: Node): string[] {
73
+ const out: string[] = []
74
+ const walk = (node: Node) => {
75
+ for (const child of Array.from(node.childNodes)) {
76
+ if (child.nodeType === 3) out.push((child.nodeValue || '').replace(ZWSP, ''))
77
+ else if (child.nodeType === 1) walk(child)
78
+ }
79
+ }
80
+ walk(root)
81
+ return out
82
+ }
83
+
84
+ /** Every `text` leaf under `node`, in document order. */
85
+ function textLeaves(node: RedNode): RedNode[] {
86
+ const out: RedNode[] = []
87
+ const walk = (n: RedNode) => {
88
+ if (n.kind === 'text') {
89
+ out.push(n)
90
+ return
91
+ }
92
+ for (const child of n.children) walk(child)
93
+ }
94
+ walk(node)
95
+ return out
96
+ }
97
+
98
+ /**
99
+ * The edit for an element whose *text* changed but whose structure did not.
100
+ *
101
+ * This is the only path that deserves the name "surgical": it rewrites the
102
+ * source range of each changed text leaf and nothing else, so the author's
103
+ * spelling of everything around it — hex casing, indentation, attribute
104
+ * quoting, tag casing — comes out of the edit byte for byte.
105
+ *
106
+ * Returns `null` when the mapping cannot be trusted, which sends the caller to
107
+ * the coarser re-serialising path. Reasons to distrust it:
108
+ *
109
+ * - the number of text nodes changed, so the user altered structure;
110
+ * - the render injects text of its own (a `[quote]` author line, a `[box]`
111
+ * summary, the padding a `[code]` block loses), so DOM position and AST leaf
112
+ * no longer line up one to one.
113
+ */
114
+ function diffTextLeaves(
115
+ originalNode: RedNode,
116
+ originalRenderedHtml: string,
117
+ el: HTMLElement,
118
+ ): SurgicalEdit[] | null {
119
+ const probe = el.ownerDocument.createElement('div')
120
+ probe.innerHTML = originalRenderedHtml
121
+
122
+ const before = domTextNodes(probe)
123
+ const after = domTextNodes(el)
124
+ if (before.length !== after.length) return null
125
+
126
+ const leaves = textLeaves(originalNode)
127
+ // Strict one-to-one: any render-injected text makes the positions ambiguous.
128
+ if (leaves.length !== before.length) return null
129
+ for (let i = 0; i < leaves.length; i++) {
130
+ if (leaves[i].text !== before[i]) return null
131
+ }
132
+
133
+ const edits: SurgicalEdit[] = []
134
+ for (let i = 0; i < leaves.length; i++) {
135
+ if (before[i] === after[i]) continue
136
+ edits.push({
137
+ start: leaves[i].range.start,
138
+ end: leaves[i].range.end,
139
+ text: after[i],
140
+ })
141
+ }
142
+ return edits
143
+ }
144
+
145
+ /**
146
+ * The edit for a blank line the user typed into.
147
+ *
148
+ * An `empty_line` node *is* the newline that terminates its own empty line, so
149
+ * filling that line in means replacing the node with the new content plus that
150
+ * same newline. The surrounding blank lines are the author's and stay put:
151
+ *
152
+ * [centre]…[/centre] [centre]…[/centre]
153
+ * -> Miliastry
154
+ * [notice]…[/notice] [notice]…[/notice]
155
+ */
156
+ function fillEmptyLine(
157
+ originalNode: RedNode,
158
+ cleanHtml: string,
159
+ exporter: BBCodeExporter,
160
+ ): SurgicalEdit[] | null {
161
+ const subtree = HTMLDocumentModel.fromHTML(cleanHtml)
162
+ if (!subtree.redRoot || subtree.redRoot.children.length === 0) return null
163
+
164
+ const typed = exporter
165
+ .export(subtree.redRoot.children[0])
166
+ // A contenteditable keeps a bogus trailing `<br>` in a block it just filled;
167
+ // it is the caret's placeholder, not a line the author asked for.
168
+ .replace(/\n+$/, '')
169
+ if (typed === '') return null
170
+
171
+ return [{ start: originalNode.range.start, end: originalNode.range.end, text: `${typed}\n` }]
172
+ }
173
+
174
+ /** Parses one rendered fragment and returns its single top-level node, or null. */
175
+ function singleNodeOf(html: string, doc: Document): Node | null {
176
+ const probe = doc.createElement('div')
177
+ probe.innerHTML = html
178
+ return probe.childNodes.length === 1 ? probe.childNodes[0] : null
179
+ }
180
+
181
+ /** How many newlines a string ends with. */
182
+ function trailingNewlines(text: string): number {
183
+ const match = /\n+$/.exec(text)
184
+ return match ? match[0].length : 0
185
+ }
186
+
187
+ interface ExpectedSlot {
188
+ /** The AST children this slot was rendered from. */
189
+ nodes: RedNode[]
190
+ /** The rendered DOM node for an element slot, or null for a text run. */
191
+ element: Node | null
192
+ /** The rendered text for a text run. */
193
+ text: string
194
+ }
195
+
196
+ /**
197
+ * Groups a node's children into the DOM slots they render into.
198
+ *
199
+ * Consecutive children that render to bare text (a `spacing` or `empty_line`
200
+ * that the renderer emits as a plain newline) end up merged into one text node
201
+ * by the HTML parser, so they have to be grouped the same way here.
202
+ */
203
+ function expectedSlots(node: RedNode, renderer: HTMLRenderer, doc: Document): ExpectedSlot[] | null {
204
+ const slots: ExpectedSlot[] = []
205
+ for (const child of node.children) {
206
+ const html = renderer.render(child)
207
+ if (html === '') continue
208
+
209
+ const rendered = singleNodeOf(html, doc)
210
+ if (!rendered) return null
211
+
212
+ if (rendered.nodeType === 3) {
213
+ const last = slots[slots.length - 1]
214
+ if (last && last.element === null) {
215
+ last.nodes.push(child)
216
+ last.text += rendered.nodeValue ?? ''
217
+ continue
218
+ }
219
+ slots.push({ nodes: [child], element: null, text: rendered.nodeValue ?? '' })
220
+ continue
221
+ }
222
+ slots.push({ nodes: [child], element: rendered, text: '' })
223
+ }
224
+ return slots
225
+ }
226
+
227
+ /**
228
+ * Edits for the children of `node`, by pairing them with the DOM's children.
229
+ *
230
+ * This is what keeps an edit from taking its whole enclosing block with it. The
231
+ * reconciler used to compare only the container's top-level elements, so typing
232
+ * anywhere inside a long `[centre]…[/centre]` re-serialised every line of it —
233
+ * and a document whose tags span many lines is mostly one such element.
234
+ *
235
+ * Returns `null` when the pairing cannot be trusted, which sends the caller to
236
+ * the coarser paths.
237
+ */
238
+ function descend(
239
+ node: RedNode,
240
+ el: HTMLElement,
241
+ exporter: BBCodeExporter,
242
+ renderer: HTMLRenderer,
243
+ ): SurgicalEdit[] | null {
244
+ const slots = expectedSlots(node, renderer, el.ownerDocument)
245
+ if (!slots || slots.length === 0) return null
246
+
247
+ const actual = Array.from(el.childNodes)
248
+ if (actual.length !== slots.length) return null
249
+
250
+ const edits: SurgicalEdit[] = []
251
+ for (let i = 0; i < slots.length; i++) {
252
+ const slot = slots[i]
253
+ const got = actual[i]
254
+
255
+ if (slot.element === null) {
256
+ if (got.nodeType !== 3) return null
257
+ const text = (got.nodeValue ?? '').replace(ZWSP, '')
258
+ if (text === slot.text) continue
259
+ // A text run is only ever line structure — `spacing` and `empty_line`
260
+ // rendered as bare newlines. Anything else would need the exporter and is
261
+ // not safe to guess at.
262
+ if (slot.nodes.some(n => n.kind !== 'spacing' && n.kind !== 'empty_line')) return null
263
+ // The DOM text already carries its own newlines, so it goes to the source
264
+ // as written. The one thing it must not do is lose the newline that used
265
+ // to close the run, or the typed text glues onto the block below it.
266
+ const closed = trailingNewlines(slot.text) > 0
267
+ edits.push({
268
+ start: slot.nodes[0].range.start,
269
+ end: slot.nodes[slot.nodes.length - 1].range.end,
270
+ text: closed && trailingNewlines(text) === 0 ? `${text}\n` : text,
271
+ })
272
+ continue
273
+ }
274
+
275
+ if (got.nodeType !== 1) return null
276
+ const gotEl = got as HTMLElement
277
+ const wantEl = slot.element as HTMLElement
278
+ if (gotEl.getAttribute('data-node-id') !== wantEl.getAttribute('data-node-id')) return null
279
+ if (gotEl.outerHTML.replace(ZWSP, '') === wantEl.outerHTML.replace(ZWSP, '')) continue
280
+
281
+ const childEdits = editsForNode(slot.nodes[0], gotEl, exporter, renderer)
282
+ if (!childEdits) return null
283
+ edits.push(...childEdits)
284
+ }
285
+
286
+ return edits
287
+ }
288
+
289
+ /**
290
+ * The edits for one AST node whose DOM no longer matches its render.
291
+ *
292
+ * Ordered cheapest-and-most-precise first: fill a blank line, descend into the
293
+ * children, rewrite only the changed text leaves, and only then rebuild the
294
+ * node from its DOM.
295
+ */
296
+ function editsForNode(
297
+ node: RedNode,
298
+ el: HTMLElement,
299
+ exporter: BBCodeExporter,
300
+ renderer: HTMLRenderer,
301
+ ): SurgicalEdit[] | null {
302
+ const cleanHtml = el.outerHTML.replace(ZWSP, '')
303
+ const rendered = renderer.render(node).replace(ZWSP, '')
304
+ if (cleanHtml === rendered) return []
305
+
306
+ if (node.kind === 'empty_line') return fillEmptyLine(node, cleanHtml, exporter)
307
+
308
+ return (
309
+ descend(node, el, exporter, renderer)
310
+ ?? diffTextLeaves(node, rendered, el)
311
+ ?? reserializeElement(node, cleanHtml, exporter)
312
+ )
313
+ }
314
+
315
+ /** Last resort for one element: rebuild just that block from its DOM. */
316
+ function reserializeElement(
317
+ originalNode: RedNode,
318
+ cleanHtml: string,
319
+ exporter: BBCodeExporter,
320
+ ): SurgicalEdit[] | null {
321
+ const subtree = HTMLDocumentModel.fromHTML(cleanHtml)
322
+ if (!subtree.redRoot || subtree.redRoot.children.length === 0) return null
323
+ return [
324
+ {
325
+ start: originalNode.range.start,
326
+ end: originalNode.range.end,
327
+ text: exporter.export(subtree.redRoot.children[0]).trim(),
328
+ },
329
+ ]
330
+ }
331
+
332
+ /**
333
+ * The edits for whatever the user deleted.
334
+ *
335
+ * Walking the DOM only ever finds what still exists, so a block the user
336
+ * removed produced no edit at all: the canvas showed it gone, the source still
337
+ * had it, and the next repaint brought it back. The DOM is the truth here — any
338
+ * top-level node whose id is no longer in it was deleted.
339
+ *
340
+ * Nodes that render to nothing carry no id into the DOM, so their absence
341
+ * proves nothing and they are left alone.
342
+ */
343
+ function deletionsFor(
344
+ originalAST: RedNode,
345
+ editorContainer: HTMLElement,
346
+ renderer: HTMLRenderer,
347
+ ): SurgicalEdit[] {
348
+ const present = new Set<string>()
349
+ for (const el of Array.from(editorContainer.querySelectorAll('[data-node-id]'))) {
350
+ const id = el.getAttribute('data-node-id')
351
+ if (id) present.add(id)
352
+ }
353
+
354
+ const out: SurgicalEdit[] = []
355
+ for (const child of originalAST.children) {
356
+ if (!child.id || present.has(child.id)) continue
357
+ if (!renderer.render(child).includes('data-node-id')) continue
358
+ out.push({ start: child.range.start, end: child.range.end, text: '' })
359
+ }
360
+ return out
361
+ }
362
+
363
+ /**
364
+ * Reconciles the visual DOM of a contenteditable container against the original BBCode document AST.
365
+ * Produces minimal, non-destructive surgical edits preserving untouched BBCode blocks and structure.
366
+ */
367
+ export function reconcileVisualDOMToBBCode(
368
+ originalSource: string,
369
+ originalAST: RedNode | null,
370
+ editorContainer: HTMLElement,
371
+ exporter: BBCodeExporter = new BBCodeExporter(),
372
+ renderer: HTMLRenderer = new HTMLRenderer()
373
+ ): ReconcileResult {
374
+ const fullFallback = (): ReconcileResult => {
375
+ const rawHtml = editorContainer.innerHTML.replace(ZWSP, '').trim()
376
+ const doc = HTMLDocumentModel.fromHTML(rawHtml)
377
+ const exported = doc.redRoot
378
+ ? exporter.export(doc.redRoot)
379
+ : editorContainer.innerText.replace(ZWSP, '').trim()
380
+ const edits = computeTextDelta(originalSource, exported)
381
+ return { edits, hasChanges: edits.length > 0, resultingSource: exported }
382
+ }
383
+
384
+ if (!originalAST || !originalSource) return fullFallback()
385
+
386
+ const idMap = buildNodeIdMap(originalAST)
387
+
388
+ // The container renders the document node's children, so the same pairing
389
+ // used inside a block works here — including for a blank line that the
390
+ // renderer emitted as a bare newline instead of an element. Typing into one
391
+ // of those used to be an unmatched top-level text node, which sent the whole
392
+ // document through the re-serialising fallback.
393
+ const topLevel = descend(originalAST, editorContainer, exporter, renderer)
394
+ if (topLevel) {
395
+ return finalise(originalSource, [...topLevel, ...deletionsFor(originalAST, editorContainer, renderer)])
396
+ }
397
+
398
+ const edits: SurgicalEdit[] = []
399
+ const childNodes = Array.from(editorContainer.childNodes)
400
+ let requiresFullFallback = false
401
+
402
+ for (const node of childNodes) {
403
+ if (node.nodeType === 1) {
404
+ const el = node as HTMLElement
405
+ const tag = el.tagName.toLowerCase()
406
+
407
+ if (tag === 'br') {
408
+ continue
409
+ }
410
+
411
+ const nodeId = el.getAttribute('data-node-id')
412
+
413
+ if (nodeId && idMap.has(nodeId)) {
414
+ const elementEdits = editsForNode(idMap.get(nodeId)!, el, exporter, renderer)
415
+ if (!elementEdits) {
416
+ requiresFullFallback = true
417
+ break
418
+ }
419
+ edits.push(...elementEdits)
420
+ } else {
421
+ // Element without ID or newly added/split block -> fallback to full clean serialize
422
+ requiresFullFallback = true
423
+ break
424
+ }
425
+ } else if (node.nodeType === 3) {
426
+ const text = (node.nodeValue || '').replace(ZWSP, '').trim()
427
+ if (text.length > 0) {
428
+ requiresFullFallback = true
429
+ break
430
+ }
431
+ }
432
+ }
433
+
434
+ if (requiresFullFallback) return fullFallback()
435
+
436
+ return finalise(originalSource, [...edits, ...deletionsFor(originalAST, editorContainer, renderer)])
437
+ }
438
+
439
+ /** Applies the collected edits bottom-to-top so earlier ranges stay valid. */
440
+ function finalise(originalSource: string, edits: SurgicalEdit[]): ReconcileResult {
441
+ if (edits.length === 0) {
442
+ return {
443
+ edits: [],
444
+ hasChanges: false,
445
+ resultingSource: originalSource,
446
+ }
447
+ }
448
+
449
+ const sortedEdits = [...edits].sort((a, b) => b.start - a.start)
450
+ let updatedSource = originalSource
451
+ for (const edit of sortedEdits) {
452
+ updatedSource = updatedSource.slice(0, edit.start) + edit.text + updatedSource.slice(edit.end)
453
+ }
454
+
455
+ return {
456
+ edits: sortedEdits,
457
+ hasChanges: true,
458
+ resultingSource: updatedSource,
459
+ }
460
+ }
@@ -0,0 +1,116 @@
1
+ import { RedNode } from '../Syntax/RedNode'
2
+ import { isUnclosedTag, openingTagName } from '../Semantic/SemanticAnalyzer'
3
+ import type { SurgicalEdit } from '../Reconciler/SurgicalReconciler'
4
+
5
+ /** A closing tag the source never opened, kept by the parser as literal text. */
6
+ export interface OrphanCloser {
7
+ /** The tag name as written, lowercased. */
8
+ tag: string
9
+ /** Where the `[/tag]` sits in the source. */
10
+ range: { start: number; end: number }
11
+ }
12
+
13
+ /** An opening tag the parser had to close on the author's behalf. */
14
+ export interface UnclosedOpener {
15
+ tag: string
16
+ /** Where its `[/tag]` belongs. */
17
+ at: number
18
+ }
19
+
20
+ export interface NestingRepair {
21
+ /** Deletions for the orphans and insertions for the missing closers. */
22
+ edits: SurgicalEdit[]
23
+ orphans: OrphanCloser[]
24
+ unclosed: UnclosedOpener[]
25
+ /** The repaired source, for callers that want it whole. */
26
+ source: string
27
+ hasChanges: boolean
28
+ }
29
+
30
+ /**
31
+ * A `[/tag]` that closed nothing.
32
+ *
33
+ * The parser leaves two traces, and both are damage this repairs:
34
+ *
35
+ * - `discarded_tag`, a closer that arrived after its tag had already been
36
+ * auto-closed. The node exists only to own its range;
37
+ * - a text leaf whose whole content is a closing tag — a closer that never
38
+ * had an opener at all, kept as text so no character belongs to nothing.
39
+ */
40
+ const CLOSING_TAG = /^\[\/([a-zA-Z][a-zA-Z0-9]*)\]$/
41
+
42
+ /**
43
+ * Repairs BBCode nesting without touching a byte the author wrote elsewhere.
44
+ *
45
+ * Two kinds of damage, two kinds of edit:
46
+ *
47
+ * - a closing tag with no opener is deleted, because that is what osu! does
48
+ * with it — Quasar renders it as literal text, which is the one place the
49
+ * two disagree (see `Tests/OsuNestingFidelity.test.ts`);
50
+ * - an opener the parser had to close silently gets its `[/tag]` written in,
51
+ * at the offset the parser already chose.
52
+ *
53
+ * Crossed tags need no rule of their own. `[a][b]x[/a][/b]` already parses to
54
+ * the same tree as `[a][b]x[/b][/a]`; removing the leftover `[/b]` is all the
55
+ * repair it takes.
56
+ *
57
+ * Deliberately *not* an export of the tree. Round-tripping through
58
+ * `BBCodeExporter` also yields balanced BBCode, but it rewrites the whole
59
+ * document — re-spelling attributes, collapsing spacing — and none of that is
60
+ * a change the author asked for. Edits keep the blast radius at the damage.
61
+ */
62
+ export function repairNesting(source: string, root: RedNode | null): NestingRepair {
63
+ const orphans: OrphanCloser[] = []
64
+ const unclosed: UnclosedOpener[] = []
65
+
66
+ if (root) {
67
+ root.walk((node: RedNode) => {
68
+ if (node.kind === 'discarded_tag') {
69
+ const match = CLOSING_TAG.exec(node.text)
70
+ if (match) {
71
+ orphans.push({
72
+ tag: match[1].toLowerCase(),
73
+ range: { start: node.range.start, end: node.range.end },
74
+ })
75
+ }
76
+ return
77
+ }
78
+ if (node.kind === 'text') {
79
+ const match = CLOSING_TAG.exec(node.text)
80
+ // The range check keeps a literal the author typed inside `[code]` out
81
+ // of this: there the text is content, not a delimiter in the source.
82
+ if (match && source.slice(node.range.start, node.range.end) === node.text) {
83
+ orphans.push({
84
+ tag: match[1].toLowerCase(),
85
+ range: { start: node.range.start, end: node.range.end },
86
+ })
87
+ }
88
+ return
89
+ }
90
+ if (isUnclosedTag(node, source)) {
91
+ const tag = openingTagName(node, source)
92
+ if (tag) unclosed.push({ tag, at: node.range.end })
93
+ }
94
+ })
95
+ }
96
+
97
+ const edits: SurgicalEdit[] = [
98
+ ...orphans.map(o => ({ start: o.range.start, end: o.range.end, text: '' })),
99
+ ...unclosed.map(u => ({ start: u.at, end: u.at, text: `[/${u.tag}]` })),
100
+ ]
101
+
102
+ // Bottom-to-top, so an earlier range is still valid once a later one applied.
103
+ const ordered = [...edits].sort((a, b) => b.start - a.start || b.end - a.end)
104
+ let repaired = source
105
+ for (const edit of ordered) {
106
+ repaired = repaired.slice(0, edit.start) + edit.text + repaired.slice(edit.end)
107
+ }
108
+
109
+ return {
110
+ edits: ordered,
111
+ orphans,
112
+ unclosed,
113
+ source: repaired,
114
+ hasChanges: ordered.length > 0,
115
+ }
116
+ }
@@ -0,0 +1 @@
1
+ export { repairNesting, type NestingRepair, type OrphanCloser, type UnclosedOpener } from './NestingRepair'