@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,290 @@
1
+ /**
2
+ * Quasar — Surgical Edit Plan & Conflict Resolution
3
+ *
4
+ * The optimizer has exactly one output: a set of `SurgicalEdit`s over the
5
+ * ORIGINAL source. Two appliers consume it — Monaco in-place, and a pure
6
+ * string rewrite for export — so neither ever re-serializes the document and
7
+ * neither can drift from the other.
8
+ *
9
+ * That design has one hard prerequisite, which is what this file is:
10
+ * **the edits handed to an applier must be pairwise conflict-free.**
11
+ * Monaco is explicit that it will not arbitrate:
12
+ *
13
+ * > Overlapping ranges are the caller's responsibility — Monaco has no
14
+ * > answer for two edits claiming the same character.
15
+ * — `packages/core/Editor/Core_Editor.tsx`
16
+ *
17
+ * Rules are authored independently and WILL collide: merging a run of
18
+ * identical `[color=#FF0000]` tags and shortening that same `#FF0000` to
19
+ * `#F00` claim overlapping bytes. The resolution must never be "last writer
20
+ * wins", because that makes the output depend on the order rules happen to be
21
+ * registered in. Everything here exists to make the outcome a pure function of
22
+ * the edit set itself.
23
+ *
24
+ * @see resolveEditConflicts
25
+ */
26
+
27
+ import type { SurgicalEdit } from '../Reconciler/SurgicalReconciler'
28
+
29
+ // ── Planned edits ─────────────────────────────────────────────────
30
+
31
+ /**
32
+ * A `SurgicalEdit` plus the provenance the resolver needs to arbitrate and
33
+ * the UI needs to explain itself.
34
+ *
35
+ * `priority` is declared by the rule, not derived from the edit. A rule that
36
+ * rewrites a whole region outranks one that tweaks a token inside it, and
37
+ * that relationship is a property of the rules — not something you can read
38
+ * off two ranges.
39
+ */
40
+ export interface PlannedEdit extends SurgicalEdit {
41
+ /** Identifies the rule that produced this edit. Also the final tie-break. */
42
+ readonly ruleId: string
43
+ /** Higher wins. See {@link compareEditPriority} for the full order. */
44
+ readonly priority: number
45
+ /** Human-readable intent, for the "what did the minifier do" panel. */
46
+ readonly label?: string
47
+ }
48
+
49
+ /**
50
+ * Why an edit did not make it into the applied set.
51
+ *
52
+ * - `invalid-range` — the rule emitted a range that is not a valid slice of
53
+ * the source. A rule bug; dropped rather than thrown so one broken rule
54
+ * cannot destroy an otherwise good batch.
55
+ * - `subsumed` — a winning edit's range fully contains this one (equal ranges
56
+ * included). The winner rewrites these bytes wholesale, so this edit's
57
+ * intent is either already covered or no longer meaningful.
58
+ * - `straddle` — partial overlap where NEITHER range contains the other.
59
+ * Reported separately from `subsumed` on purpose: see the note on
60
+ * {@link classifyOverlap}.
61
+ */
62
+ export type EditRejectionReason = 'invalid-range' | 'subsumed' | 'straddle'
63
+
64
+ export interface RejectedEdit {
65
+ readonly edit: PlannedEdit
66
+ readonly reason: EditRejectionReason
67
+ /** The accepted edit that displaced it. Absent for `invalid-range`. */
68
+ readonly winner?: PlannedEdit
69
+ }
70
+
71
+ export interface ResolvedEditPlan {
72
+ /**
73
+ * Pairwise conflict-free, sorted by ascending `start`. Safe to hand
74
+ * verbatim to `applySurgicalEdits` (Monaco resolves original offsets
75
+ * itself) or to a back-to-front string rewrite.
76
+ */
77
+ readonly accepted: readonly PlannedEdit[]
78
+ /** Everything dropped, with the reason and the edit that displaced it. */
79
+ readonly rejected: readonly RejectedEdit[]
80
+ }
81
+
82
+ // ── Overlap geometry ──────────────────────────────────────────────
83
+
84
+ /** How two claimed ranges relate. */
85
+ export type OverlapRelation = 'disjoint' | 'subsumption' | 'straddle'
86
+
87
+ /**
88
+ * Classify two edits by the bytes they claim.
89
+ *
90
+ * Ranges are half-open `[start, end)` over the original source, so edits that
91
+ * merely touch (`a.end === b.start`) are disjoint — adjacent replacements are
92
+ * perfectly legal and common.
93
+ *
94
+ * Zero-width edits (insertions, `start === end`) need two special cases that
95
+ * pure interval intersection gets wrong:
96
+ *
97
+ * 1. Two insertions at the SAME offset have an empty intersection, yet both
98
+ * write at one point with no defined order between them. That is a
99
+ * conflict, classified as `subsumption` so priority decides it.
100
+ * 2. An insertion strictly inside a replacement also has an empty
101
+ * intersection, but the replacement destroys the bytes the insertion point
102
+ * refers to, so the insertion is meaningless. Also `subsumption`.
103
+ *
104
+ * An insertion exactly at a replacement's boundary stays disjoint — it is
105
+ * unambiguously before or after, and both appliers agree on that.
106
+ *
107
+ * `straddle` is deliberately its own relation rather than folded into
108
+ * `subsumption`. It is resolved the same way (drop the loser), but two
109
+ * *maximal* rules should never straddle each other: a straddle almost always
110
+ * means a rule computed a partial range instead of the whole normal form.
111
+ * Keeping it distinct is what makes that bug visible instead of silent.
112
+ */
113
+ export function classifyOverlap(a: SurgicalEdit, b: SurgicalEdit): OverlapRelation {
114
+ const aEmpty = a.start === a.end
115
+ const bEmpty = b.start === b.end
116
+
117
+ // Case 1: two insertions. They conflict only at the very same offset.
118
+ if (aEmpty && bEmpty) {
119
+ return a.start === b.start ? 'subsumption' : 'disjoint'
120
+ }
121
+
122
+ const lo = Math.max(a.start, b.start)
123
+ const hi = Math.min(a.end, b.end)
124
+
125
+ if (lo >= hi) {
126
+ // Case 2: an insertion strictly interior to the other's range.
127
+ if (aEmpty && b.start < a.start && a.start < b.end) return 'subsumption'
128
+ if (bEmpty && a.start < b.start && b.start < a.end) return 'subsumption'
129
+ return 'disjoint'
130
+ }
131
+
132
+ const aContainsB = a.start <= b.start && b.end <= a.end
133
+ const bContainsA = b.start <= a.start && a.end <= b.end
134
+
135
+ // Equal ranges satisfy both, and land here as subsumption — correct: one of
136
+ // the two must go, and priority is what decides which.
137
+ return aContainsB || bContainsA ? 'subsumption' : 'straddle'
138
+ }
139
+
140
+ /** Whether two edits may not be applied together. */
141
+ export function editsConflict(a: SurgicalEdit, b: SurgicalEdit): boolean {
142
+ return classifyOverlap(a, b) !== 'disjoint'
143
+ }
144
+
145
+ // ── Priority ──────────────────────────────────────────────────────
146
+
147
+ /**
148
+ * The arbitration order, as a comparator: negative means `a` is considered
149
+ * first and therefore wins any conflict against `b`.
150
+ *
151
+ * The chain is **total** — it never returns 0 for two distinguishable edits.
152
+ * That is the whole point. A partial order would leave ties broken by
153
+ * `Array.prototype.sort` stability, i.e. by the order rules were registered,
154
+ * which is exactly the non-determinism this contract exists to prevent.
155
+ *
156
+ * 1. `priority` descending — the rule's own declaration, and the only lever a
157
+ * rule author has.
158
+ * 2. Width descending — at equal priority the wider claim wins, because a
159
+ * broader rewrite subsumes the work of a narrower one more often than the
160
+ * reverse. (A zero-width insertion therefore loses to any replacement it
161
+ * ties with, which is what you want: the replacement carries text anyway.)
162
+ * 3. `start` ascending — earlier in the document.
163
+ * 4. `ruleId`, then `text`, lexicographically — no semantic meaning, purely
164
+ * the guarantee that the order is total.
165
+ */
166
+ export function compareEditPriority(a: PlannedEdit, b: PlannedEdit): number {
167
+ if (a.priority !== b.priority) return b.priority - a.priority
168
+
169
+ const aWidth = a.end - a.start
170
+ const bWidth = b.end - b.start
171
+ if (aWidth !== bWidth) return bWidth - aWidth
172
+
173
+ if (a.start !== b.start) return a.start - b.start
174
+ if (a.ruleId !== b.ruleId) return a.ruleId < b.ruleId ? -1 : 1
175
+ if (a.text !== b.text) return a.text < b.text ? -1 : 1
176
+ return 0
177
+ }
178
+
179
+ // ── Resolution ────────────────────────────────────────────────────
180
+
181
+ /**
182
+ * Overlap geometry and rejection reasons are separate vocabularies on purpose:
183
+ * one describes how two ranges sit, the other why an edit was dropped. This is
184
+ * the only place they meet. It used to be a cast, which quietly turned
185
+ * `'subsumption'` into an invalid reason string.
186
+ */
187
+ const REJECTION_FOR: Record<Exclude<OverlapRelation, 'disjoint'>, EditRejectionReason> = {
188
+ subsumption: 'subsumed',
189
+ straddle: 'straddle',
190
+ }
191
+
192
+ function isValidRange(edit: SurgicalEdit, sourceLength: number): boolean {
193
+ return (
194
+ Number.isInteger(edit.start) &&
195
+ Number.isInteger(edit.end) &&
196
+ edit.start >= 0 &&
197
+ edit.end >= edit.start &&
198
+ edit.end <= sourceLength
199
+ )
200
+ }
201
+
202
+ /** Index of the first accepted edit ordered at or after `edit`. */
203
+ function lowerBound(sorted: readonly PlannedEdit[], edit: PlannedEdit): number {
204
+ let lo = 0
205
+ let hi = sorted.length
206
+ while (lo < hi) {
207
+ const mid = (lo + hi) >> 1
208
+ const probe = sorted[mid]
209
+ if (probe.start < edit.start || (probe.start === edit.start && probe.end < edit.end)) {
210
+ lo = mid + 1
211
+ } else {
212
+ hi = mid
213
+ }
214
+ }
215
+ return lo
216
+ }
217
+
218
+ /**
219
+ * Reduce a rule-authored edit set to a conflict-free one.
220
+ *
221
+ * Greedy by {@link compareEditPriority}: walk the edits in arbitration order
222
+ * and accept each one that collides with nothing already accepted.
223
+ *
224
+ * This is deliberately **not** a maximum-weight independent set. Greedy can
225
+ * drop two small edits to keep one large one that saves fewer bytes, and that
226
+ * is an accepted trade: the optimizer's contract is that the same document
227
+ * always minifies to the same output, not that it minifies to the smallest
228
+ * possible output. Predictability is worth more than the last few bytes in a
229
+ * tool that rewrites someone's document.
230
+ *
231
+ * The result is order-independent — shuffling `edits` cannot change
232
+ * `accepted` — which is the property the tests pin down.
233
+ *
234
+ * @param edits Rule output, in any order.
235
+ * @param sourceLength Length of the ORIGINAL source every range indexes into.
236
+ */
237
+ export function resolveEditConflicts(
238
+ edits: readonly PlannedEdit[],
239
+ sourceLength: number,
240
+ ): ResolvedEditPlan {
241
+ const rejected: RejectedEdit[] = []
242
+ const candidates: PlannedEdit[] = []
243
+
244
+ for (const edit of edits) {
245
+ if (isValidRange(edit, sourceLength)) candidates.push(edit)
246
+ else rejected.push({ edit, reason: 'invalid-range' })
247
+ }
248
+
249
+ candidates.sort(compareEditPriority)
250
+
251
+ // Kept sorted by (start, end). Every member is disjoint from every other,
252
+ // so a conflicting neighbour is always found next to the insertion point.
253
+ const accepted: PlannedEdit[] = []
254
+
255
+ for (const candidate of candidates) {
256
+ const at = lowerBound(accepted, candidate)
257
+
258
+ let winner: PlannedEdit | undefined
259
+ let reason: EditRejectionReason | undefined
260
+
261
+ // The predecessor can extend past the candidate's start...
262
+ if (at > 0) {
263
+ const previous = accepted[at - 1]
264
+ const rel = classifyOverlap(candidate, previous)
265
+ if (rel !== 'disjoint') {
266
+ winner = previous
267
+ reason = REJECTION_FOR[rel]
268
+ }
269
+ }
270
+
271
+ // ...and any accepted edit beginning at or before the candidate's end may
272
+ // reach back into it. Scanning stops at the first hit, and the accepted
273
+ // set is disjoint, so this walks a couple of entries, never the list.
274
+ for (let i = at; !winner && i < accepted.length && accepted[i].start <= candidate.end; i++) {
275
+ const rel = classifyOverlap(candidate, accepted[i])
276
+ if (rel !== 'disjoint') {
277
+ winner = accepted[i]
278
+ reason = REJECTION_FOR[rel]
279
+ }
280
+ }
281
+
282
+ if (winner && reason) {
283
+ rejected.push({ edit: candidate, reason, winner })
284
+ } else {
285
+ accepted.splice(at, 0, candidate)
286
+ }
287
+ }
288
+
289
+ return { accepted, rejected }
290
+ }
@@ -0,0 +1,185 @@
1
+ /**
2
+ * Quasar — BBCode Optimizer
3
+ *
4
+ * Parses a document, runs every optimization rule over the tree, arbitrates
5
+ * the edits they produce, and hands back one conflict-free `SurgicalEdit[]`
6
+ * addressed to the ORIGINAL source.
7
+ *
8
+ * That edit set is the optimizer's only output. There is deliberately no
9
+ * "optimized tree" — two appliers consume the edits instead:
10
+ *
11
+ * in-place → applySurgicalEdits(result.edits) (Monaco, one undo stop)
12
+ * export → result.output (applyEditsToSource)
13
+ *
14
+ * Keeping a tree optimizer *and* a range optimizer in step forever was the
15
+ * alternative, and it was rejected before the first rule was written. A tree
16
+ * optimizer's output can only reach the document through a whole-document
17
+ * re-export, which silently rewrites spellings the author chose (`[B]`→`[b]`,
18
+ * `\r\n`→`\n`, unclosed tags closed). That is fine for an export and
19
+ * unacceptable for someone's open buffer.
20
+ */
21
+
22
+ import { BBCodeDocumentModel } from '../BBCode/BBCodeDocumentModel'
23
+ import type { BBCodeDialect } from '../BBCode/BBCodeToGreenNode'
24
+ import type { GreenNode } from '../Syntax/GreenNode'
25
+ import { resolveEditConflicts, type PlannedEdit, type ResolvedEditPlan } from './EditPlan'
26
+ import { applyEditsToSource } from './applyEdits'
27
+ import type { OptimizationRule } from './Rules/Rule'
28
+ import { MergeAdjacentRule } from './Rules/mergeAdjacent'
29
+ import { DropEmptyTagsRule } from './Rules/dropEmptyTags'
30
+ import { DropRedundantNestingRule } from './Rules/dropRedundantNesting'
31
+ import { ShortenHexRule } from './Rules/shortenHex'
32
+ import { UnwrapInvisibleColorRule } from './Rules/unwrapInvisibleColor'
33
+ import { ReorderWrappersRule } from './Rules/reorderWrappers'
34
+
35
+ // ── Presets ───────────────────────────────────────────────────────
36
+
37
+ /**
38
+ * The rules a plain "minify" run applies.
39
+ *
40
+ * Ordered by descending priority, which is also the order in which their
41
+ * regions subsume one another: a merge deletes the delimiters that a hex
42
+ * shortening inside them would have edited.
43
+ *
44
+ * `ReorderWrappersRule` is **not** here. It is a real, tested rule, but it
45
+ * saves exactly zero bytes — it canonicalises nesting order rather than
46
+ * removing anything. A minifier that rewrites someone's document without
47
+ * making it smaller is a diff with no upside, so it is opt-in.
48
+ */
49
+ export function defaultRules(): OptimizationRule[] {
50
+ return [
51
+ new MergeAdjacentRule(),
52
+ new DropRedundantNestingRule(),
53
+ new UnwrapInvisibleColorRule(),
54
+ new DropEmptyTagsRule(),
55
+ new ShortenHexRule(),
56
+ ]
57
+ }
58
+
59
+ /** Every rule that exists, including the ones outside the default preset. */
60
+ export function allRules(): OptimizationRule[] {
61
+ return [...defaultRules(), new ReorderWrappersRule()]
62
+ }
63
+
64
+ // ── Result ────────────────────────────────────────────────────────
65
+
66
+ export interface RuleStat {
67
+ readonly ruleId: string
68
+ readonly label: string
69
+ /** Edits this rule contributed to the applied set. */
70
+ readonly edits: number
71
+ /** Characters removed by those edits. Never negative for the shipped rules. */
72
+ readonly savedChars: number
73
+ }
74
+
75
+ export interface OptimizationResult {
76
+ /** The document as given. */
77
+ readonly source: string
78
+ /** Conflict-free, sorted by ascending start. Hand straight to either applier. */
79
+ readonly edits: readonly PlannedEdit[]
80
+ /** Full arbitration record, including what was dropped and why. */
81
+ readonly plan: ResolvedEditPlan
82
+ /** `source` with `edits` applied. */
83
+ readonly output: string
84
+ readonly savedChars: number
85
+ /** Per-rule breakdown of the *applied* edits, in descending saving order. */
86
+ readonly stats: readonly RuleStat[]
87
+ }
88
+
89
+ export interface OptimizeOptions {
90
+ readonly rules?: readonly OptimizationRule[]
91
+ /** Dialect to parse against. Defaults to `'lyne'`, the superset. */
92
+ readonly dialect?: BBCodeDialect
93
+ }
94
+
95
+ // ── Entry points ──────────────────────────────────────────────────
96
+
97
+ /**
98
+ * Optimize a parsed tree.
99
+ *
100
+ * Prefer this when the caller already holds a green tree — the editor reparses
101
+ * on every keystroke and has no reason to pay for another parse.
102
+ *
103
+ * `root.width` must equal `source.length`; the rules address offsets into that
104
+ * exact string. A mismatch means the tree describes a different document, and
105
+ * every range would land in the wrong place, so it is refused outright.
106
+ */
107
+ export function optimizeTree(
108
+ source: string,
109
+ root: GreenNode,
110
+ options: OptimizeOptions = {},
111
+ ): OptimizationResult {
112
+ if (root.width !== source.length) {
113
+ throw new Error(
114
+ `optimizeTree: tree width ${root.width} does not match source length ${source.length}; ` +
115
+ 'the tree must be the parse of exactly this source.',
116
+ )
117
+ }
118
+
119
+ const rules = options.rules ?? defaultRules()
120
+ const context = { source, root }
121
+
122
+ const proposed: PlannedEdit[] = []
123
+ for (const rule of rules) proposed.push(...rule.run(context))
124
+
125
+ const plan = resolveEditConflicts(proposed, source.length)
126
+ const output = applyEditsToSource(source, plan.accepted)
127
+
128
+ return {
129
+ source,
130
+ edits: plan.accepted,
131
+ plan,
132
+ output,
133
+ savedChars: source.length - output.length,
134
+ stats: buildStats(plan.accepted, rules),
135
+ }
136
+ }
137
+
138
+ /** Parse `source` and optimize it. */
139
+ export function optimizeBBCode(source: string, options: OptimizeOptions = {}): OptimizationResult {
140
+ const model = new BBCodeDocumentModel({
141
+ source,
142
+ dialect: options.dialect ?? 'lyne',
143
+ autoAnalyze: false,
144
+ })
145
+ const root = model.greenRoot
146
+
147
+ if (!root) {
148
+ return {
149
+ source,
150
+ edits: [],
151
+ plan: { accepted: [], rejected: [] },
152
+ output: source,
153
+ savedChars: 0,
154
+ stats: [],
155
+ }
156
+ }
157
+
158
+ return optimizeTree(source, root, options)
159
+ }
160
+
161
+ // ── Reporting ─────────────────────────────────────────────────────
162
+
163
+ function buildStats(
164
+ accepted: readonly PlannedEdit[],
165
+ rules: readonly OptimizationRule[],
166
+ ): RuleStat[] {
167
+ const labels = new Map(rules.map(rule => [rule.id, rule.label]))
168
+ const totals = new Map<string, { edits: number; savedChars: number }>()
169
+
170
+ for (const edit of accepted) {
171
+ const entry = totals.get(edit.ruleId) ?? { edits: 0, savedChars: 0 }
172
+ entry.edits++
173
+ entry.savedChars += edit.end - edit.start - edit.text.length
174
+ totals.set(edit.ruleId, entry)
175
+ }
176
+
177
+ return [...totals.entries()]
178
+ .map(([ruleId, entry]) => ({
179
+ ruleId,
180
+ label: labels.get(ruleId) ?? ruleId,
181
+ edits: entry.edits,
182
+ savedChars: entry.savedChars,
183
+ }))
184
+ .sort((a, b) => b.savedChars - a.savedChars || a.ruleId.localeCompare(b.ruleId))
185
+ }
@@ -0,0 +1,145 @@
1
+ /**
2
+ * Quasar — Optimization Rule contract
3
+ *
4
+ * Every rule reads the Green Tree and returns `PlannedEdit[]` addressed to
5
+ * offsets in the ORIGINAL source. Rules never mutate the tree, never build a
6
+ * new one, and never see each other's output — the only thing that arbitrates
7
+ * between them is `resolveEditConflicts`.
8
+ *
9
+ * Three obligations, all of them load-bearing:
10
+ *
11
+ * 1. **Maximal.** A rule emits the final form in one pass. It may not rely on
12
+ * being run again: applying an edit invalidates every later offset, so
13
+ * there is no second pass to be had. Where `ASTOptimizer` merged pairwise
14
+ * and iterated to a fixpoint, a rule here recognises the whole run.
15
+ * 2. **Deterministic.** Same tree in, same edits out, in the same order.
16
+ * 3. **Spelling-preserving.** Prefer deleting the author's bytes over
17
+ * generating replacements. `[Color = "#FF0000"]` must survive a merge
18
+ * intact; the exporter would have rewritten it to `[color=#FF0000]`.
19
+ *
20
+ * @see resolveEditConflicts
21
+ */
22
+
23
+ import type { GreenNode } from '../../Syntax/GreenNode'
24
+ import { childOffsets } from '../../Syntax/GreenNode'
25
+ import type { PlannedEdit } from '../EditPlan'
26
+
27
+ // ── Contract ──────────────────────────────────────────────────────
28
+
29
+ export interface RuleContext {
30
+ /** The document exactly as the author wrote it. */
31
+ readonly source: string
32
+ /** Green root, whose width equals `source.length`. */
33
+ readonly root: GreenNode
34
+ }
35
+
36
+ export interface OptimizationRule {
37
+ /** Stable identifier; also the final tie-break in the conflict order. */
38
+ readonly id: string
39
+ /**
40
+ * Arbitration weight. A rule that rewrites a whole region must outrank the
41
+ * rules that tweak tokens inside it, so the region's own normal form wins.
42
+ */
43
+ readonly priority: number
44
+ /** Short human label, shown in the "what did the minifier do" list. */
45
+ readonly label: string
46
+ run(context: RuleContext): PlannedEdit[]
47
+ }
48
+
49
+ // ── Positioned tree walking ───────────────────────────────────────
50
+
51
+ /**
52
+ * A node together with its absolute start offset in the source.
53
+ *
54
+ * Green nodes carry widths, not positions, so every rule that wants a range
55
+ * has to accumulate offsets on the way down. This pairing is what the rules
56
+ * pass around instead of bare nodes.
57
+ */
58
+ export interface Positioned {
59
+ readonly node: GreenNode
60
+ readonly start: number
61
+ }
62
+
63
+ /** Absolute end offset (exclusive). */
64
+ export function endOf(p: Positioned): number {
65
+ return p.start + p.node.width
66
+ }
67
+
68
+ /** The range of the opening delimiter — `[color=#F00]`. */
69
+ export function openRange(p: Positioned): { start: number; end: number } {
70
+ return { start: p.start, end: p.start + p.node.leadingWidth }
71
+ }
72
+
73
+ /** The range of the closing delimiter — `[/color]`. */
74
+ export function closeRange(p: Positioned): { start: number; end: number } {
75
+ const end = endOf(p)
76
+ return { start: end - p.node.trailingWidth, end }
77
+ }
78
+
79
+ /** Children paired with their absolute offsets, in document order. */
80
+ export function positionedChildren(p: Positioned): Positioned[] {
81
+ const children = p.node.children as readonly GreenNode[]
82
+ if (children.length === 0) return []
83
+ const offsets = childOffsets(p.node, p.start)
84
+ const out: Positioned[] = new Array(children.length)
85
+ for (let i = 0; i < children.length; i++) {
86
+ out[i] = { node: children[i], start: offsets[i] }
87
+ }
88
+ return out
89
+ }
90
+
91
+ /**
92
+ * A node that actually has both delimiters in the source.
93
+ *
94
+ * The parser synthesises a closing tag for `[b]never closed`, which comes back
95
+ * as `trailingWidth === 0`. Deleting a delimiter that occupies no bytes is a
96
+ * no-op at best; treating a synthetic close as if it were real is a
97
+ * correctness bug. Rules that move or remove delimiters check this first.
98
+ */
99
+ export function hasBothDelimiters(p: Positioned): boolean {
100
+ return p.node.leadingWidth > 0 && p.node.trailingWidth > 0
101
+ }
102
+
103
+ // ── Edit construction ─────────────────────────────────────────────
104
+
105
+ /** A deletion of an exact source range. */
106
+ export function deletion(
107
+ range: { start: number; end: number },
108
+ ruleId: string,
109
+ priority: number,
110
+ label?: string,
111
+ ): PlannedEdit {
112
+ return { start: range.start, end: range.end, text: '', ruleId, priority, label }
113
+ }
114
+
115
+ /**
116
+ * Merge deletions that touch into single edits.
117
+ *
118
+ * Two deletions `[a,b)` and `[b,c)` are legal side by side — the conflict
119
+ * resolver calls them disjoint — but they describe one contiguous removal.
120
+ * Emitting them separately inflates the edit count for no benefit and makes
121
+ * the "N optimisations applied" number lie about how much happened.
122
+ *
123
+ * Input must be sorted by `start` and non-overlapping.
124
+ */
125
+ export function coalesceDeletions(edits: readonly PlannedEdit[]): PlannedEdit[] {
126
+ if (edits.length < 2) return [...edits]
127
+
128
+ const out: PlannedEdit[] = []
129
+ for (const edit of edits) {
130
+ const previous = out[out.length - 1]
131
+ const joinable =
132
+ previous !== undefined &&
133
+ previous.text === '' &&
134
+ edit.text === '' &&
135
+ previous.end === edit.start &&
136
+ previous.ruleId === edit.ruleId &&
137
+ previous.priority === edit.priority
138
+ if (joinable) {
139
+ out[out.length - 1] = { ...previous, end: edit.end }
140
+ } else {
141
+ out.push(edit)
142
+ }
143
+ }
144
+ return out
145
+ }