@miliastry/quasar 1.0.6 → 1.2.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.
- package/README.md +1 -1
- package/dist/Visuals/lyne.css +374 -9
- package/dist/Visuals/osu.css +393 -11
- package/dist/index.js +10470 -3336
- package/dist/index.mjs +10336 -3337
- package/dist/types/Analysis/Contracts/AnalysisReport.d.ts +22 -0
- package/dist/types/Analysis/Contracts/Contribution.d.ts +68 -0
- package/dist/types/Analysis/Contracts/Pass.d.ts +59 -0
- package/dist/types/Analysis/Contracts/PipelineContext.d.ts +34 -0
- package/dist/types/Analysis/Passes/Analysis/ColorUsageAnalyzer.d.ts +56 -0
- package/dist/types/Analysis/Passes/Analysis/GradientAnalyzer.d.ts +111 -0
- package/dist/types/Analysis/Passes/Analysis/MergeableColorAnalyzer.d.ts +26 -0
- package/dist/types/Analysis/Passes/Analysis/RainbowAnalyzer.d.ts +47 -0
- package/dist/types/Analysis/Passes/Analysis/SymbolAnalyzer.d.ts +89 -0
- package/dist/types/Analysis/Passes/Analysis/WaveAnalyzer.d.ts +44 -0
- package/dist/types/Analysis/Passes/Analysis/__tests__/GradientAnalyzer.test.d.ts +4 -0
- package/dist/types/Analysis/Passes/Analysis/__tests__/MergeableColorAnalyzer.test.d.ts +4 -0
- package/dist/types/Analysis/Passes/Analysis/__tests__/RainbowAnalyzer.test.d.ts +4 -0
- package/dist/types/Analysis/Passes/Analysis/__tests__/WaveAnalyzer.test.d.ts +4 -0
- package/dist/types/Analysis/Passes/Decision/DefaultDecision.d.ts +30 -0
- package/dist/types/Analysis/Passes/Decision/PaletteRemapDecision.d.ts +169 -0
- package/dist/types/Analysis/Passes/Decision/__tests__/DefaultDecision.test.d.ts +4 -0
- package/dist/types/Analysis/Passes/Transform/CollapseGradientTransform.d.ts +37 -0
- package/dist/types/Analysis/Passes/Transform/MergeColorsTransform.d.ts +28 -0
- package/dist/types/Analysis/Passes/Transform/RainbowCollapseTransform.d.ts +19 -0
- package/dist/types/Analysis/Passes/Transform/WaveCollapseTransform.d.ts +20 -0
- package/dist/types/Analysis/Passes/Utility/CharacterCountAnalyzer.d.ts +19 -0
- package/dist/types/Analysis/Pipeline/Pipeline.d.ts +69 -0
- package/dist/types/Analysis/Pipeline/PipelineBuilder.d.ts +31 -0
- package/dist/types/Analysis/Pipeline/PipelineStage.d.ts +17 -0
- package/dist/types/Analysis/Utils/color-utils.d.ts +60 -0
- package/dist/types/Analysis/__tests__/Integration.test.d.ts +8 -0
- package/dist/types/Analysis/__tests__/PaletteRemap.test.d.ts +14 -0
- package/dist/types/Analysis/__tests__/Pipeline.test.d.ts +10 -0
- package/dist/types/Analysis/__tests__/RoundTrip.test.d.ts +30 -0
- package/dist/types/Analysis/__tests__/SymbolAnalyzer.test.d.ts +11 -0
- package/dist/types/Analysis/index.d.ts +40 -0
- package/dist/types/BBCode/BBCodeDocumentModel.d.ts +102 -0
- package/dist/types/BBCode/BBCodeToGreenNode.d.ts +111 -0
- package/dist/types/BBCode/Parser.d.ts +78 -0
- package/dist/types/BBCode/index.d.ts +4 -0
- package/dist/types/Collab/positions.d.ts +55 -0
- package/dist/types/Commands/Command.d.ts +37 -0
- package/dist/types/Commands/CommandRegistry.d.ts +48 -0
- package/dist/types/Commands/DeleteNode.d.ts +7 -0
- package/dist/types/Commands/InsertText.d.ts +8 -0
- package/dist/types/Commands/SplitMerge.d.ts +9 -0
- package/dist/types/Commands/WrapInTag.d.ts +8 -0
- package/dist/types/Commands/index.d.ts +6 -0
- package/dist/types/Diff/TreeDiffer.d.ts +52 -0
- package/dist/types/Diff/__tests__/TreeDiffer.test.d.ts +1 -0
- package/dist/types/Diff/index.d.ts +2 -0
- package/dist/types/Edits/EditPlan.d.ts +144 -0
- package/dist/types/Edits/Optimizer.d.ts +78 -0
- package/dist/types/Edits/Rules/Rule.d.ts +91 -0
- package/dist/types/Edits/Rules/dropEmptyTags.d.ts +40 -0
- package/dist/types/Edits/Rules/dropRedundantNesting.d.ts +59 -0
- package/dist/types/Edits/Rules/index.d.ts +9 -0
- package/dist/types/Edits/Rules/mergeAdjacent.d.ts +93 -0
- package/dist/types/Edits/Rules/reorderWrappers.d.ts +42 -0
- package/dist/types/Edits/Rules/shortenHex.d.ts +30 -0
- package/dist/types/Edits/Rules/tagValue.d.ts +56 -0
- package/dist/types/Edits/Rules/unwrapInvisibleColor.d.ts +47 -0
- package/dist/types/Edits/applyEdits.d.ts +36 -0
- package/dist/types/Edits/index.d.ts +6 -0
- package/dist/types/Events/EventBus.d.ts +103 -0
- package/dist/types/Events/index.d.ts +2 -0
- package/dist/types/Formatter/Formatter.d.ts +32 -0
- package/dist/types/Formatter/index.d.ts +2 -0
- package/dist/types/HTML/HTMLDocumentModel.d.ts +13 -0
- package/dist/types/HTML/HTMLToGreenNode.d.ts +4 -0
- package/dist/types/Incremental/BracketIndex.d.ts +101 -0
- package/dist/types/Incremental/ChangeTracker.d.ts +69 -0
- package/dist/types/Incremental/IncrementalParser.d.ts +246 -0
- package/dist/types/Incremental/__tests__/BracketIndex.test.d.ts +1 -0
- package/dist/types/Incremental/__tests__/IncrementalParser.test.d.ts +1 -0
- package/dist/types/Incremental/index.d.ts +4 -0
- package/dist/types/Lexer/BBCodeLexer.d.ts +74 -0
- package/dist/types/Lexer/Lexer.d.ts +54 -0
- package/dist/types/Lexer/index.d.ts +4 -0
- package/dist/types/Linter/Linter.d.ts +58 -0
- package/dist/types/Linter/index.d.ts +2 -0
- package/dist/types/Markdown/MarkdownAST.d.ts +122 -0
- package/dist/types/Markdown/MarkdownDocumentModel.d.ts +15 -0
- package/dist/types/Markdown/MarkdownLexer.d.ts +155 -0
- package/dist/types/Markdown/MarkdownParser.d.ts +42 -0
- package/dist/types/Markdown/MarkdownToGreenNode.d.ts +6 -0
- package/dist/types/MilHibri/MilHibriDocumentModel.d.ts +21 -0
- package/dist/types/Model/DocumentModel.d.ts +318 -0
- package/dist/types/Model/NodeFactory.d.ts +29 -0
- package/dist/types/Model/TagRegistry.d.ts +165 -0
- package/dist/types/Model/index.d.ts +5 -0
- package/dist/types/Plugins/PluginAPI.d.ts +44 -0
- package/dist/types/Plugins/PluginRegistry.d.ts +81 -0
- package/dist/types/Plugins/index.d.ts +3 -0
- package/dist/types/Queries/QueryEngine.d.ts +32 -0
- package/dist/types/Queries/index.d.ts +1 -0
- package/dist/types/Reconciler/SurgicalReconciler.d.ts +23 -0
- package/dist/types/RenderPipeline/RenderPipeline.d.ts +46 -0
- package/dist/types/RenderPipeline/RenderTree.d.ts +55 -0
- package/dist/types/RenderPipeline/index.d.ts +4 -0
- package/dist/types/Repair/NestingRepair.d.ts +48 -0
- package/dist/types/Repair/index.d.ts +1 -0
- package/dist/types/Semantic/SemanticAnalyzer.d.ts +321 -0
- package/dist/types/Semantic/index.d.ts +2 -0
- package/dist/types/Symbols/SymbolTable.d.ts +47 -0
- package/dist/types/Symbols/index.d.ts +1 -0
- package/dist/types/Syntax/GreenNode.d.ts +154 -0
- package/dist/types/Syntax/GreenNodePool.d.ts +134 -0
- package/dist/types/Syntax/NodeMatcher.d.ts +75 -0
- package/dist/types/Syntax/RedNode.d.ts +280 -0
- package/dist/types/Syntax/RedNodeStore.d.ts +116 -0
- package/dist/types/Syntax/TreeBuilder.d.ts +54 -0
- package/dist/types/Syntax/__tests__/GreenNode.test.d.ts +1 -0
- package/dist/types/Syntax/__tests__/RedNode.test.d.ts +1 -0
- package/dist/types/Syntax/__tests__/RedNodeStore.test.d.ts +1 -0
- package/dist/types/Syntax/greenEdit.d.ts +64 -0
- package/dist/types/Syntax/hash.d.ts +17 -0
- package/dist/types/Syntax/index.d.ts +8 -0
- package/dist/types/Syntax/nodeAttr.d.ts +63 -0
- package/dist/types/Syntax/partition.d.ts +67 -0
- package/dist/types/Syntax/preserveNodeIds.d.ts +47 -0
- package/dist/types/Tokens/TokenResolver.d.ts +25 -0
- package/dist/types/Tokens/index.d.ts +1 -0
- package/dist/types/Transactions/Transaction.d.ts +36 -0
- package/dist/types/Transactions/UndoManager.d.ts +63 -0
- package/dist/types/Transactions/index.d.ts +3 -0
- package/dist/types/Transformers/ASTOptimizer.d.ts +34 -0
- package/dist/types/Transformers/GradientTransformer.d.ts +18 -0
- package/dist/types/Transformers/GrowTransformer.d.ts +16 -0
- package/dist/types/Transformers/RainbowTransformer.d.ts +17 -0
- package/dist/types/Transformers/SineWaveTransformer.d.ts +17 -0
- package/dist/types/Transformers/Transformer.d.ts +17 -0
- package/dist/types/Types/core.d.ts +104 -0
- package/dist/types/Types/diagnostics.d.ts +85 -0
- package/dist/types/Types/index.d.ts +11 -0
- package/dist/types/Types/operations.d.ts +109 -0
- package/dist/types/Types/queries.d.ts +67 -0
- package/dist/types/Types/symbols.d.ts +42 -0
- package/dist/types/Types/tokens.d.ts +75 -0
- package/dist/types/Utils/BBCodeGenerator.d.ts +21 -0
- package/dist/types/Utils/ColorMath.d.ts +92 -0
- package/dist/types/Utils/EffectMath.d.ts +643 -0
- package/dist/types/Utils/TagVocabulary.d.ts +104 -0
- package/dist/types/Utils/TagVocabulary.test.d.ts +1 -0
- package/dist/types/Utils/color.d.ts +29 -0
- package/dist/types/Utils/dom-to-svg.d.ts +28 -0
- package/dist/types/Utils/dom-to-svg.test.d.ts +1 -0
- package/dist/types/Utils/treeTransformers.d.ts +160 -0
- package/dist/types/Visitors/BBBlocksExporter.d.ts +27 -0
- package/dist/types/Visitors/BBCodeExporter.d.ts +90 -0
- package/dist/types/Visitors/BlockPatcher.d.ts +91 -0
- package/dist/types/Visitors/DOMMorpher.d.ts +19 -0
- package/dist/types/Visitors/HTMLRenderer.d.ts +492 -0
- package/dist/types/Visitors/JSONExporter.d.ts +42 -0
- package/dist/types/Visitors/MarkdownExporter.d.ts +28 -0
- package/dist/types/Visitors/SVGRenderer.d.ts +16 -0
- package/dist/types/Visitors/TiptapExporter.d.ts +40 -0
- package/dist/types/Visitors/Visitor.d.ts +35 -0
- package/dist/types/Visitors/index.d.ts +9 -0
- package/dist/types/Visuals/BoxDrawer.d.ts +54 -0
- package/dist/types/Visuals/LyneAudio.d.ts +8 -0
- package/dist/types/Visuals/index.d.ts +39 -0
- package/dist/types/index.d.ts +155 -0
- package/package.json +14 -10
- package/src/Analysis/Passes/Analysis/ColorUsageAnalyzer.ts +103 -0
- package/src/Analysis/Passes/Analysis/GradientAnalyzer.ts +294 -20
- package/src/Analysis/Passes/Analysis/SymbolAnalyzer.ts +257 -0
- package/src/Analysis/Passes/Decision/PaletteRemapDecision.ts +425 -0
- package/src/Analysis/Pipeline/Pipeline.ts +15 -2
- package/src/Analysis/index.ts +8 -2
- package/src/BBCode/BBCodeDocumentModel.ts +39 -1
- package/src/BBCode/BBCodeToGreenNode.ts +18 -12
- package/src/BBCode/Parser.ts +38 -1
- package/src/Edits/EditPlan.ts +290 -0
- package/src/Edits/Optimizer.ts +185 -0
- package/src/Edits/Rules/Rule.ts +145 -0
- package/src/Edits/Rules/dropEmptyTags.ts +110 -0
- package/src/Edits/Rules/dropRedundantNesting.ts +120 -0
- package/src/Edits/Rules/index.ts +28 -0
- package/src/Edits/Rules/mergeAdjacent.ts +224 -0
- package/src/Edits/Rules/reorderWrappers.ts +154 -0
- package/src/Edits/Rules/shortenHex.ts +79 -0
- package/src/Edits/Rules/tagValue.ts +105 -0
- package/src/Edits/Rules/unwrapInvisibleColor.ts +93 -0
- package/src/Edits/applyEdits.ts +54 -0
- package/src/Edits/index.ts +22 -0
- package/src/Events/EventBus.ts +18 -0
- package/src/HTML/HTMLToGreenNode.ts +193 -37
- package/src/Incremental/BracketIndex.ts +249 -0
- package/src/Incremental/IncrementalParser.ts +464 -41
- package/src/Lexer/BBCodeLexer.ts +25 -3
- package/src/Lexer/index.ts +1 -1
- package/src/Markdown/MarkdownAST.ts +65 -2
- package/src/Markdown/MarkdownLexer.ts +69 -14
- package/src/Markdown/MarkdownParser.ts +749 -62
- package/src/Markdown/MarkdownToGreenNode.ts +148 -27
- package/src/MilHibri/MilHibriDocumentModel.ts +56 -0
- package/src/Model/DocumentModel.ts +138 -5
- package/src/Model/TagRegistry.ts +129 -134
- package/src/Reconciler/SurgicalReconciler.ts +460 -0
- package/src/Repair/NestingRepair.ts +116 -0
- package/src/Repair/index.ts +1 -0
- package/src/Semantic/SemanticAnalyzer.ts +1845 -65
- package/src/Syntax/GreenNodePool.ts +12 -12
- package/src/Syntax/RedNode.ts +17 -3
- package/src/Syntax/RedNodeStore.ts +9 -1
- package/src/Syntax/nodeAttr.ts +172 -0
- package/src/Tokens/TokenResolver.ts +55 -0
- package/src/Tokens/index.ts +1 -0
- package/src/Types/core.ts +15 -0
- package/src/Utils/ColorMath.ts +276 -55
- package/src/Utils/EffectMath.ts +2462 -0
- package/src/Utils/TagVocabulary.ts +141 -0
- package/src/Utils/dom-to-svg.ts +28 -12
- package/src/Visitors/BBBlocksExporter.ts +15 -1
- package/src/Visitors/BBCodeExporter.ts +365 -32
- package/src/Visitors/BlockPatcher.ts +123 -22
- package/src/Visitors/DOMMorpher.ts +44 -28
- package/src/Visitors/HTMLRenderer.ts +771 -205
- package/src/Visitors/MarkdownExporter.ts +98 -41
- package/src/Visitors/TiptapExporter.ts +1 -0
- package/src/Visitors/index.ts +1 -1
- package/src/Visuals/BoxDrawer.ts +56 -10
- package/src/Visuals/LyneAudio.ts +346 -0
- package/src/Visuals/index.ts +8 -4
- package/src/Visuals/lyne.css +374 -9
- package/src/Visuals/osu.css +393 -11
- package/src/index.ts +178 -8
- package/dist/index.d.mts +0 -4612
- package/dist/index.d.ts +0 -4612
- package/src/Analysis/Passes/Analysis/__tests__/GradientAnalyzer.test.ts +0 -135
- package/src/Analysis/Passes/Analysis/__tests__/MergeableColorAnalyzer.test.ts +0 -84
- package/src/Analysis/Passes/Analysis/__tests__/RainbowAnalyzer.test.ts +0 -99
- package/src/Analysis/Passes/Analysis/__tests__/WaveAnalyzer.test.ts +0 -119
- package/src/Analysis/Passes/Decision/__tests__/DefaultDecision.test.ts +0 -179
- package/src/Analysis/__tests__/Integration.test.ts +0 -162
- package/src/Analysis/__tests__/Pipeline.test.ts +0 -133
- package/src/Diff/__tests__/TreeDiffer.test.ts +0 -65
- package/src/Incremental/__tests__/IncrementalParser.test.ts +0 -164
- package/src/Syntax/__tests__/GreenNode.test.ts +0 -33
- package/src/Syntax/__tests__/RedNode.test.ts +0 -81
- package/src/Syntax/__tests__/RedNodeStore.test.ts +0 -104
- package/src/Tests/ASTOptimizerIdempotence.test.ts +0 -77
- package/src/Tests/BlockPatcher.test.ts +0 -437
- package/src/Tests/BlockPatcherWindowed.test.ts +0 -364
- package/src/Tests/BoxDrawer.test.ts +0 -217
- package/src/Tests/BoxRichTitle.test.ts +0 -105
- package/src/Tests/Chars500kBenchmark.test.ts +0 -151
- package/src/Tests/Chars500kEdits.test.ts +0 -321
- package/src/Tests/CollabPositions.test.ts +0 -146
- package/src/Tests/CompilerPathProfiling.test.ts +0 -186
- package/src/Tests/DOMMorpher.test.ts +0 -142
- package/src/Tests/DomPatchPerf.test.ts +0 -60
- package/src/Tests/EffectSegments.snapshot.json +0 -616
- package/src/Tests/EffectSegments.test.ts +0 -68
- package/src/Tests/FindNodeAtOffset.test.ts +0 -65
- package/src/Tests/Fuzzer.test.ts +0 -166
- package/src/Tests/GreenNodePool.test.ts +0 -153
- package/src/Tests/Lexer.test.ts +0 -238
- package/src/Tests/LyneMode.test.ts +0 -291
- package/src/Tests/ModelCoherence.test.ts +0 -180
- package/src/Tests/Partition.test.ts +0 -238
- package/src/Tests/PluginTags.test.ts +0 -150
- package/src/Tests/ProblematicSection.test.ts +0 -46
- package/src/Tests/ProblematicSectionHTML.test.ts +0 -58
- package/src/Tests/RedReuse.test.ts +0 -134
- package/src/Tests/ReproDelete20k.test.ts +0 -62
- package/src/Tests/SemanticValidators.test.ts +0 -136
- package/src/Tests/StableNodeIds.test.ts +0 -210
- package/src/Tests/StudioColorBloat.test.ts +0 -25
- package/src/Tests/StudioDebugText.test.ts +0 -27
- package/src/Tests/StudioTrailingChar.test.ts +0 -25
- package/src/Tests/StudioValidText.test.ts +0 -25
- package/src/Tests/UrlImgBug.test.ts +0 -23
- package/src/Tests/VisualBuilderFidelity.test.ts +0 -105
- package/src/Tests/referenceDocument.ts +0 -119
- package/src/Utils/dom-to-svg.test.ts +0 -86
|
@@ -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
|
+
}
|
|
@@ -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
|
+
}
|