@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.
- package/dist/Visuals/lyne.css +117 -6
- package/dist/Visuals/osu.css +36 -5
- package/dist/index.js +9664 -3282
- package/dist/index.mjs +9535 -3283
- 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 +92 -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 +13 -0
- package/dist/types/Visitors/HTMLRenderer.d.ts +380 -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 +49 -0
- package/dist/types/Visuals/index.d.ts +38 -0
- package/dist/types/index.d.ts +154 -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 +3 -0
- 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 +185 -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 +1802 -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 +319 -32
- package/src/Visitors/BlockPatcher.ts +87 -11
- package/src/Visitors/DOMMorpher.ts +15 -5
- package/src/Visitors/HTMLRenderer.ts +374 -151
- package/src/Visitors/MarkdownExporter.ts +98 -41
- package/src/Visitors/TiptapExporter.ts +1 -0
- package/src/Visuals/index.ts +7 -4
- package/src/Visuals/lyne.css +117 -6
- package/src/Visuals/osu.css +36 -5
- package/src/index.ts +172 -5
- 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,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
|
+
}
|