@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,110 @@
1
+ /**
2
+ * Quasar — `drop-empty-tags`
3
+ *
4
+ * Removes formatting tags that enclose nothing: `[b][/b]`, `[color=#F00][/color]`.
5
+ *
6
+ * ## Only tags whose absence is invisible
7
+ *
8
+ * The droppable set is an explicit allowlist of inline formatting, not "any
9
+ * tag with no children". An empty `[box]` still draws a frame, an empty
10
+ * `[quote]` still draws a quote block, and `[img]`/`[youtube]` carry their
11
+ * payload in the attribute rather than in children — deleting any of those
12
+ * changes what the reader sees. Anything not on the list stays, including
13
+ * tags this engine does not recognise.
14
+ *
15
+ * ## Outermost-first, so no second pass is needed
16
+ *
17
+ * `[b][i][/i][/b]` is empty all the way down. Dropping the inner tag would
18
+ * leave `[b][/b]`, which is empty again — the classic reason `ASTOptimizer`
19
+ * needs a fixpoint. Instead this rule asks whether a node renders nothing
20
+ * *transitively* and deletes the outermost one it finds, in a single edit,
21
+ * without descending into it.
22
+ */
23
+
24
+ import type { GreenNode } from '../../Syntax/GreenNode'
25
+ import type { PlannedEdit } from '../EditPlan'
26
+ import {
27
+ type OptimizationRule,
28
+ type RuleContext,
29
+ type Positioned,
30
+ positionedChildren,
31
+ hasBothDelimiters,
32
+ deletion,
33
+ } from './Rule'
34
+
35
+ export const DROP_EMPTY_PRIORITY = 80
36
+
37
+ /**
38
+ * Inline formatting whose empty form is guaranteed to render nothing.
39
+ *
40
+ * Deliberately conservative. A kind is only here if an empty instance of it
41
+ * produces no glyphs, no box, no line break and no side effect.
42
+ */
43
+ export const DROPPABLE_WHEN_EMPTY: ReadonlySet<string> = new Set([
44
+ 'bold',
45
+ 'italic',
46
+ 'underline',
47
+ 'strikethrough',
48
+ 'color',
49
+ 'font_size',
50
+ 'font',
51
+ 'sup',
52
+ 'sub',
53
+ 'mark',
54
+ ])
55
+
56
+ export class DropEmptyTagsRule implements OptimizationRule {
57
+ readonly id = 'drop-empty-tags'
58
+ readonly priority = DROP_EMPTY_PRIORITY
59
+ readonly label = 'Remove empty formatting tags'
60
+
61
+ run(context: RuleContext): PlannedEdit[] {
62
+ const sink: PlannedEdit[] = []
63
+ this.scan({ node: context.root, start: 0 }, sink)
64
+ return sink
65
+ }
66
+
67
+ // ── Private ─────────────────────────────────────────────────────
68
+
69
+ private scan(item: Positioned, sink: PlannedEdit[]): void {
70
+ for (const child of positionedChildren(item)) {
71
+ if (this.isDroppable(child)) {
72
+ sink.push(
73
+ deletion(
74
+ { start: child.start, end: child.start + child.node.width },
75
+ this.id,
76
+ this.priority,
77
+ `Remove empty [${child.node.kind}]`,
78
+ ),
79
+ )
80
+ // Deleted whole; nothing inside it can still be worth an edit.
81
+ continue
82
+ }
83
+ this.scan(child, sink)
84
+ }
85
+ }
86
+
87
+ private isDroppable(item: Positioned): boolean {
88
+ return (
89
+ DROPPABLE_WHEN_EMPTY.has(item.node.kind) &&
90
+ hasBothDelimiters(item) &&
91
+ rendersNothing(item.node)
92
+ )
93
+ }
94
+ }
95
+
96
+ /**
97
+ * Whether a node contributes no visible output at all.
98
+ *
99
+ * `spacing` and `empty_line` are explicitly *not* nothing — they are the
100
+ * document's line breaks.
101
+ */
102
+ function rendersNothing(node: GreenNode): boolean {
103
+ if (node.kind === 'text') return (node.text ?? '') === ''
104
+ if (node.kind === 'spacing' || node.kind === 'empty_line') return false
105
+ if (!DROPPABLE_WHEN_EMPTY.has(node.kind)) return false
106
+
107
+ const children = node.children as readonly GreenNode[]
108
+ if (children.length === 0) return node.width - node.leadingWidth - node.trailingWidth === 0
109
+ return children.every(rendersNothing)
110
+ }
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Quasar — `drop-redundant-nesting`
3
+ *
4
+ * Removes a tag nested directly inside an identical one:
5
+ *
6
+ * [color=#F00]a[color=#F00]b[/color][/color] → [color=#F00]ab[/color]
7
+ *
8
+ * The inner delimiters are the ones deleted, so the outer tag — and the
9
+ * author's spelling of it — is what survives.
10
+ *
11
+ * Identity is the same normalised comparison the merge rule uses, so
12
+ * `[color=#F00]` inside `[color=#FF0000]` counts as redundant: the colours are
13
+ * equal even though the spellings are not.
14
+ *
15
+ * Siblings do not disqualify anything. In `[color=X]a[color=X]b[/color][/color]`
16
+ * the inner tag is still setting a colour that is already in force, so
17
+ * dropping its delimiters cannot change the render.
18
+ *
19
+ * ## Only tags that do nothing when nested in themselves
20
+ *
21
+ * "Redundant" has to be earned, not assumed. `[size=50]` renders as
22
+ * `font-size:50%`, and a percentage is relative to its parent — so
23
+ * `[size=50][size=50]x[/size][/size]` is 25%, not 50%, and the inner tag is
24
+ * doing real work. `[sup]` stacks its vertical offset the same way. Removing
25
+ * either would shrink or lift the text, which is not a smaller document, it is
26
+ * a different one.
27
+ *
28
+ * @see IDEMPOTENT_WHEN_NESTED
29
+ */
30
+
31
+ import type { PlannedEdit } from '../EditPlan'
32
+ import {
33
+ type OptimizationRule,
34
+ type RuleContext,
35
+ type Positioned,
36
+ positionedChildren,
37
+ openRange,
38
+ closeRange,
39
+ hasBothDelimiters,
40
+ deletion,
41
+ } from './Rule'
42
+ import { mergeIdentity } from './tagValue'
43
+
44
+ export const DROP_REDUNDANT_NESTING_PRIORITY = 90
45
+
46
+ /**
47
+ * Kinds whose effect is unchanged by being applied twice.
48
+ *
49
+ * A colour inside the same colour is still that colour; bold inside bold is
50
+ * still bold. Excluded are the properties that **compound**: `font_size`
51
+ * multiplies percentages, and `sup`/`sub` add their vertical offsets. Anything
52
+ * not listed is left alone, unknown tags included.
53
+ */
54
+ export const IDEMPOTENT_WHEN_NESTED: ReadonlySet<string> = new Set([
55
+ 'color',
56
+ 'bold',
57
+ 'italic',
58
+ 'underline',
59
+ 'strikethrough',
60
+ 'font',
61
+ 'mark',
62
+ ])
63
+
64
+ export class DropRedundantNestingRule implements OptimizationRule {
65
+ readonly id = 'drop-redundant-nesting'
66
+ readonly priority = DROP_REDUNDANT_NESTING_PRIORITY
67
+ readonly label = 'Remove tags nested inside an identical tag'
68
+
69
+ run(context: RuleContext): PlannedEdit[] {
70
+ const sink: PlannedEdit[] = []
71
+ this.scan({ node: context.root, start: 0 }, new Map(), sink)
72
+ sink.sort((a, b) => a.start - b.start || a.end - b.end)
73
+ return sink
74
+ }
75
+
76
+ // ── Private ─────────────────────────────────────────────────────
77
+
78
+ /**
79
+ * `inForce` maps a node kind to the identity its NEAREST ancestor of that
80
+ * kind established.
81
+ *
82
+ * Keyed by kind rather than carrying a single inherited identity, because an
83
+ * ancestor's colour survives an intervening tag of another kind:
84
+ * `[color=X][b][color=X]…` is still redundant. Overwriting on the way down
85
+ * is what makes it the *nearest* ancestor — in
86
+ * `[color=X][color=Y][color=X]…` the innermost tag restores a colour that Y
87
+ * had overridden, so it is doing real work and must stay.
88
+ */
89
+ private scan(
90
+ item: Positioned,
91
+ inForce: ReadonlyMap<string, string>,
92
+ sink: PlannedEdit[],
93
+ ): void {
94
+ for (const child of positionedChildren(item)) {
95
+ const identity = mergeIdentity(child.node)
96
+
97
+ if (
98
+ identity !== null &&
99
+ IDEMPOTENT_WHEN_NESTED.has(child.node.kind) &&
100
+ inForce.get(child.node.kind) === identity &&
101
+ hasBothDelimiters(child)
102
+ ) {
103
+ const label = `Remove redundant nested [${child.node.kind}]`
104
+ sink.push(deletion(openRange(child), this.id, this.priority, label))
105
+ sink.push(deletion(closeRange(child), this.id, this.priority, label))
106
+ // Deleting the delimiters leaves the ancestor's identity in force.
107
+ this.scan(child, inForce, sink)
108
+ continue
109
+ }
110
+
111
+ if (identity === null) {
112
+ this.scan(child, inForce, sink)
113
+ } else {
114
+ const nested = new Map(inForce)
115
+ nested.set(child.node.kind, identity)
116
+ this.scan(child, nested, sink)
117
+ }
118
+ }
119
+ }
120
+ }
@@ -0,0 +1,28 @@
1
+ export type { OptimizationRule, RuleContext, Positioned } from './Rule'
2
+ export {
3
+ endOf,
4
+ openRange,
5
+ closeRange,
6
+ positionedChildren,
7
+ hasBothDelimiters,
8
+ deletion,
9
+ coalesceDeletions,
10
+ } from './Rule'
11
+ export {
12
+ attributeValue,
13
+ normalizeColorValue,
14
+ shortenableHex,
15
+ mergeIdentity,
16
+ MERGEABLE_INLINE,
17
+ BRIDGES_WHITESPACE,
18
+ } from './tagValue'
19
+ export { MergeAdjacentRule, MERGE_ADJACENT_PRIORITY, type MergeAdjacentOptions } from './mergeAdjacent'
20
+ export { DropEmptyTagsRule, DROP_EMPTY_PRIORITY, DROPPABLE_WHEN_EMPTY } from './dropEmptyTags'
21
+ export { DropRedundantNestingRule, DROP_REDUNDANT_NESTING_PRIORITY } from './dropRedundantNesting'
22
+ export { ShortenHexRule, SHORTEN_HEX_PRIORITY } from './shortenHex'
23
+ export {
24
+ UnwrapInvisibleColorRule,
25
+ UNWRAP_INVISIBLE_COLOR_PRIORITY,
26
+ isInvisibleWhitespace,
27
+ } from './unwrapInvisibleColor'
28
+ export { ReorderWrappersRule, REORDER_WRAPPERS_PRIORITY } from './reorderWrappers'
@@ -0,0 +1,224 @@
1
+ /**
2
+ * Quasar — `merge-adjacent`
3
+ *
4
+ * Fuses runs of identically-attributed sibling tags into one:
5
+ *
6
+ * [color=#FF0000]H[/color][color=#FF0000]e[/color][color=#FF0000]y[/color]
7
+ * → [color=#FF0000]Hey[/color]
8
+ *
9
+ * This is where the bytes are. A per-character gradient spends ~23 bytes of
10
+ * delimiter on every 1 byte of text; collapsing a run of identical colours is
11
+ * the single largest saving available on a real userpage.
12
+ *
13
+ * ## It only ever deletes
14
+ *
15
+ * The merged tag is not generated. The first member's opening delimiter and
16
+ * the last member's closing delimiter are left exactly as the author typed
17
+ * them, and only the seams between members are removed:
18
+ *
19
+ * [Color = "#F00"]a[/color][color=#ff0000]b[/color]
20
+ * [Color = "#F00"]a‹ deleted ›b[/color]
21
+ *
22
+ * So `[Color = "#F00"]` survives with its capital C, its spaces and its
23
+ * quotes. A rule that rebuilt the tag would have normalised all three, which
24
+ * is exactly the drift that makes whole-document re-export unusable in an
25
+ * editor.
26
+ *
27
+ * ## Maximal in one pass
28
+ *
29
+ * `ASTOptimizer` merges pairwise and iterates to a fixpoint. Edits addressed
30
+ * to the original source cannot do that — applying one invalidates every later
31
+ * offset — so this rule recognises the **whole run at once**, and then
32
+ * descends into the content the merge is about to create:
33
+ *
34
+ * [b][color=X]a[/color][/b][b][color=X]bc[/color][/b]
35
+ *
36
+ * Merging the two `[b]` makes their colour children siblings, and those merge
37
+ * too. The recursion walks that *virtual* child list, so both levels come out
38
+ * of a single pass. Crucially the inner seams sit inside the members' content
39
+ * and the outer seams sit between members, so the two sets never overlap.
40
+ *
41
+ * @see mergeIdentity — what "identically attributed" means
42
+ * @see BRIDGES_WHITESPACE — when a gap between members may be absorbed
43
+ */
44
+
45
+ import type { GreenNode } from '../../Syntax/GreenNode'
46
+ import type { PlannedEdit } from '../EditPlan'
47
+ import {
48
+ type OptimizationRule,
49
+ type RuleContext,
50
+ type Positioned,
51
+ positionedChildren,
52
+ openRange,
53
+ closeRange,
54
+ hasBothDelimiters,
55
+ deletion,
56
+ coalesceDeletions,
57
+ } from './Rule'
58
+ import { mergeIdentity, BRIDGES_WHITESPACE, attributeValue } from './tagValue'
59
+ import { isInvisibleWhitespace } from './unwrapInvisibleColor'
60
+
61
+ /** Horizontal whitespace only — a newline is a paragraph break, never a gap. */
62
+ const HORIZONTAL_BLANK = /^[ \t]*$/
63
+
64
+ export const MERGE_ADJACENT_PRIORITY = 100
65
+
66
+ export interface MergeAdjacentOptions {
67
+ /**
68
+ * Restrict the rule to these node kinds. Omitted, every kind
69
+ * {@link mergeIdentity} recognises is eligible.
70
+ */
71
+ readonly kinds?: ReadonlySet<string>
72
+ }
73
+
74
+ export class MergeAdjacentRule implements OptimizationRule {
75
+ readonly id = 'merge-adjacent'
76
+ readonly priority = MERGE_ADJACENT_PRIORITY
77
+ readonly label = 'Merge adjacent identical tags'
78
+
79
+ private readonly kinds?: ReadonlySet<string>
80
+
81
+ constructor(options: MergeAdjacentOptions = {}) {
82
+ this.kinds = options.kinds
83
+ }
84
+
85
+ run(context: RuleContext): PlannedEdit[] {
86
+ const sink: PlannedEdit[] = []
87
+ this.scan(positionedChildren({ node: context.root, start: 0 }), sink)
88
+ // Emitted parent-first; the plan reads better in document order, and
89
+ // `coalesceDeletions` requires it.
90
+ sink.sort((a, b) => a.start - b.start || a.end - b.end)
91
+ return coalesceDeletions(sink)
92
+ }
93
+
94
+ // ── Private ─────────────────────────────────────────────────────
95
+
96
+ private identityOf(node: GreenNode): string | null {
97
+ if (this.kinds && !this.kinds.has(node.kind)) return null
98
+
99
+ // A colour wrapping nothing but whitespace is never a run *member*, only
100
+ // ever a bridge — `unwrap-invisible-color` owns those delimiters.
101
+ //
102
+ // Adopting one as a member is a genuine corruption, and an instructive
103
+ // one: a merged run keeps its first member's opening delimiter, so when
104
+ // the whitespace node led the run, merge kept the delimiter that unwrap
105
+ // was independently deleting. The two edits do not overlap, so the
106
+ // conflict resolver — which can only arbitrate claims on the same bytes —
107
+ // had nothing to arbitrate, and the document came out with an unbalanced
108
+ // `[/color]`. Rules must not depend on each other's edits surviving.
109
+ if (node.kind === 'color' && isInvisibleWhitespace(node)) return null
110
+
111
+ return mergeIdentity(node)
112
+ }
113
+
114
+ /**
115
+ * A node that may sit between two members and be absorbed by the merge.
116
+ *
117
+ * Blank text, or a colour tag wrapping nothing but blank text — the shape
118
+ * `unwrap-invisible-color` exists to delete. Treating it as a gap rather
119
+ * than as a wall lets `[color=A]x[/color][color=B] [/color][color=A]y[/color]`
120
+ * still fuse its two `A` members; the `B` tag survives this rule untouched
121
+ * and is removed by the other one, whose edits are disjoint from these.
122
+ */
123
+ private isBridge(item: Positioned): boolean {
124
+ if (item.node.kind === 'text') return HORIZONTAL_BLANK.test(item.node.text ?? '')
125
+ return item.node.kind === 'color' && isInvisibleWhitespace(item.node)
126
+ }
127
+
128
+ /**
129
+ * Walk one sibling list, fusing every maximal run it contains.
130
+ *
131
+ * The list is not always a real node's children: after a merge is planned,
132
+ * the members' children are spliced together and scanned as the sibling list
133
+ * the merged tag will actually have.
134
+ */
135
+ private scan(items: readonly Positioned[], sink: PlannedEdit[]): void {
136
+ let i = 0
137
+
138
+ while (i < items.length) {
139
+ const identity = this.identityOf(items[i].node)
140
+
141
+ if (identity === null) {
142
+ this.scan(positionedChildren(items[i]), sink)
143
+ i++
144
+ continue
145
+ }
146
+
147
+ const bridges = BRIDGES_WHITESPACE.has(items[i].node.kind)
148
+ const members: number[] = [i]
149
+
150
+ // Extend while the next sibling is either another member or — for kinds
151
+ // where it is invisible — blank text between two of them.
152
+ let j = i + 1
153
+ while (j < items.length) {
154
+ if (this.identityOf(items[j].node) === identity) {
155
+ members.push(j)
156
+ j++
157
+ continue
158
+ }
159
+ if (bridges && this.isBridge(items[j])) {
160
+ j++
161
+ continue
162
+ }
163
+ break
164
+ }
165
+
166
+ const last = members[members.length - 1]
167
+ const merging =
168
+ members.length >= 2 && members.every(index => hasBothDelimiters(items[index]))
169
+
170
+ if (merging) {
171
+ this.emitSeams(items, members, sink)
172
+ this.scan(this.virtualContent(items, members, last), sink)
173
+ } else {
174
+ // No merge: every element up to and including the last member still
175
+ // has to be visited on its own.
176
+ for (let t = i; t <= last; t++) this.scan(positionedChildren(items[t]), sink)
177
+ }
178
+
179
+ i = last + 1
180
+ }
181
+ }
182
+
183
+ /**
184
+ * Delete each member's closing delimiter and the next member's opening one.
185
+ *
186
+ * Two edits per seam rather than one span, because a bridge may sit between
187
+ * them and must survive. When nothing separates them the two deletions touch
188
+ * and `coalesceDeletions` folds them back into a single edit.
189
+ */
190
+ private emitSeams(
191
+ items: readonly Positioned[],
192
+ members: readonly number[],
193
+ sink: PlannedEdit[],
194
+ ): void {
195
+ const head = items[members[0]].node
196
+ const what = attributeValue(head) ? `${head.kind}=${attributeValue(head)}` : head.kind
197
+ const label = `Merge ${members.length} × [${what}]`
198
+
199
+ for (let m = 0; m < members.length - 1; m++) {
200
+ const current = items[members[m]]
201
+ const next = items[members[m + 1]]
202
+ sink.push(deletion(closeRange(current), this.id, this.priority, label))
203
+ sink.push(deletion(openRange(next), this.id, this.priority, label))
204
+ }
205
+ }
206
+
207
+ /**
208
+ * The sibling list the merged tag will hold: every member's children in
209
+ * order, with any bridging text kept in place between them.
210
+ */
211
+ private virtualContent(
212
+ items: readonly Positioned[],
213
+ members: readonly number[],
214
+ last: number,
215
+ ): Positioned[] {
216
+ const isMember = new Set(members)
217
+ const out: Positioned[] = []
218
+ for (let t = members[0]; t <= last; t++) {
219
+ if (isMember.has(t)) out.push(...positionedChildren(items[t]))
220
+ else out.push(items[t])
221
+ }
222
+ return out
223
+ }
224
+ }
@@ -0,0 +1,154 @@
1
+ /**
2
+ * Quasar — `reorder-wrappers` (opt-in)
3
+ *
4
+ * Puts a chain of single-child wrappers into a canonical order:
5
+ *
6
+ * [color=#F00][b]x[/b][/color] → [b][color=#F00]x[/color][/b]
7
+ *
8
+ * ## It saves no bytes, and that is why it is not in the default preset
9
+ *
10
+ * Reordering removes nothing. Its value is canonical form — two documents that
11
+ * differ only in wrapper order become byte-identical, which is what lets other
12
+ * rules recognise runs they would otherwise miss. On its own, in a *minifier*,
13
+ * it produces a diff across someone's document in exchange for zero
14
+ * characters, so `defaultRules()` leaves it out and callers opt in.
15
+ *
16
+ * ## Byte-swaps, never regeneration
17
+ *
18
+ * The delimiters are moved, not rebuilt: each slot receives the exact source
19
+ * bytes of the delimiter that belongs there. `[Color = "#F00"]` comes out the
20
+ * other side character for character, which a tag-rebuilding implementation
21
+ * could not promise.
22
+ *
23
+ * ## Maximal
24
+ *
25
+ * The whole chain is sorted at once. Swapping one adjacent pair at a time
26
+ * would need repeated passes over offsets that no longer exist, so
27
+ * `[color][b][i]x[/i][/b][/color]` is permuted in a single step. The sort is
28
+ * stable, so equal-rank wrappers keep the order the author wrote.
29
+ */
30
+
31
+ import type { PlannedEdit } from '../EditPlan'
32
+ import {
33
+ type OptimizationRule,
34
+ type RuleContext,
35
+ type Positioned,
36
+ positionedChildren,
37
+ openRange,
38
+ closeRange,
39
+ hasBothDelimiters,
40
+ } from './Rule'
41
+
42
+ export const REORDER_WRAPPERS_PRIORITY = 5
43
+
44
+ /**
45
+ * Nesting rank — lower belongs further out.
46
+ *
47
+ * Links outermost (a link wrapping styled text is the common authoring
48
+ * intent), then metrics, then weight/decoration, then colour innermost. Kinds
49
+ * absent from the table are not reordered at all: an unranked tag may carry
50
+ * layout or semantics that nesting order changes.
51
+ */
52
+ const RANK: Readonly<Record<string, number>> = {
53
+ url: 1,
54
+ email: 1,
55
+ profile: 1,
56
+ font_size: 2,
57
+ font: 2,
58
+ bold: 3,
59
+ italic: 3,
60
+ underline: 3,
61
+ strikethrough: 3,
62
+ color: 4,
63
+ }
64
+
65
+ export class ReorderWrappersRule implements OptimizationRule {
66
+ readonly id = 'reorder-wrappers'
67
+ readonly priority = REORDER_WRAPPERS_PRIORITY
68
+ readonly label = 'Canonicalise nested wrapper order'
69
+
70
+ run(context: RuleContext): PlannedEdit[] {
71
+ const sink: PlannedEdit[] = []
72
+ this.scan({ node: context.root, start: 0 }, context.source, sink)
73
+ sink.sort((a, b) => a.start - b.start || a.end - b.end)
74
+ return sink
75
+ }
76
+
77
+ // ── Private ─────────────────────────────────────────────────────
78
+
79
+ private scan(item: Positioned, source: string, sink: PlannedEdit[]): void {
80
+ for (const child of positionedChildren(item)) {
81
+ const chain = this.chainFrom(child)
82
+
83
+ if (chain.length >= 2) {
84
+ this.emitPermutation(chain, source, sink)
85
+ // Continue below the chain. `scan` iterates its argument's children,
86
+ // so the innermost wrapper is what gets handed down — passing its
87
+ // children instead would skip a level.
88
+ this.scan(chain[chain.length - 1], source, sink)
89
+ continue
90
+ }
91
+
92
+ this.scan(child, source, sink)
93
+ }
94
+ }
95
+
96
+ /** The maximal run of rankable single-child wrappers starting at `item`. */
97
+ private chainFrom(item: Positioned): Positioned[] {
98
+ const chain: Positioned[] = []
99
+ let current: Positioned | undefined = item
100
+
101
+ while (
102
+ current &&
103
+ RANK[current.node.kind] !== undefined &&
104
+ hasBothDelimiters(current) &&
105
+ current.node.children.length === 1
106
+ ) {
107
+ chain.push(current)
108
+ const [only] = positionedChildren(current)
109
+ current = only
110
+ }
111
+ return chain
112
+ }
113
+
114
+ private emitPermutation(
115
+ chain: readonly Positioned[],
116
+ source: string,
117
+ sink: PlannedEdit[],
118
+ ): void {
119
+ const order = chain.map((_, index) => index)
120
+ // Stable by construction: ties fall back to the original index.
121
+ order.sort((a, b) => RANK[chain[a].node.kind] - RANK[chain[b].node.kind] || a - b)
122
+
123
+ if (order.every((sourceIndex, slot) => sourceIndex === slot)) return
124
+
125
+ const label = `Canonicalise ${chain.map(c => `[${c.node.kind}]`).join('')}`
126
+
127
+ for (let slot = 0; slot < chain.length; slot++) {
128
+ const from = chain[order[slot]]
129
+ if (order[slot] === slot) continue
130
+
131
+ const openSlot = openRange(chain[slot])
132
+ const closeSlot = closeRange(chain[slot])
133
+ const openFrom = openRange(from)
134
+ const closeFrom = closeRange(from)
135
+
136
+ sink.push({
137
+ start: openSlot.start,
138
+ end: openSlot.end,
139
+ text: source.slice(openFrom.start, openFrom.end),
140
+ ruleId: this.id,
141
+ priority: this.priority,
142
+ label,
143
+ })
144
+ sink.push({
145
+ start: closeSlot.start,
146
+ end: closeSlot.end,
147
+ text: source.slice(closeFrom.start, closeFrom.end),
148
+ ruleId: this.id,
149
+ priority: this.priority,
150
+ label,
151
+ })
152
+ }
153
+ }
154
+ }