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