@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
package/src/BBCode/Parser.ts
CHANGED
|
@@ -82,6 +82,21 @@ export function parseTokensToGreen(
|
|
|
82
82
|
const normalizeParagraphs = options.normalizeParagraphs ?? true;
|
|
83
83
|
const extraTags = options.extraTags;
|
|
84
84
|
const root: GreenNode[] = []
|
|
85
|
+
/**
|
|
86
|
+
* Etiquetas que un cierre mal emparejado cerró por su cuenta y cuyo `[/tag]`
|
|
87
|
+
* todavía está por llegar.
|
|
88
|
+
*
|
|
89
|
+
* Cuando `[/notice]` cierra un `[box]` que quedaba dentro, el `[/box]` que
|
|
90
|
+
* viene después ya no tiene a quién cerrar. osu! lo tira; subir por la pila a
|
|
91
|
+
* buscar otro `[box]` cierra uno que el autor no quería cerrar y saca del
|
|
92
|
+
* contenedor a todo lo que sigue. En `docs/ai/NyuPenyu` eso aplanaba 43 cajas
|
|
93
|
+
* anidadas a 9 y hacía la página un 78% más alta de lo que osu! muestra.
|
|
94
|
+
*
|
|
95
|
+
* La marca se consume al usarla y se borra si la etiqueta se vuelve a abrir,
|
|
96
|
+
* porque entonces el cierre sí es suyo.
|
|
97
|
+
*/
|
|
98
|
+
const autoClosed = new Set<string>()
|
|
99
|
+
|
|
85
100
|
const stack: {
|
|
86
101
|
/** The literal tag name. Closing matches on THIS, not on `kind`: `[centre]`
|
|
87
102
|
* and `[center]` share a kind but do not close each other, and that is
|
|
@@ -223,6 +238,7 @@ export function parseTokensToGreen(
|
|
|
223
238
|
if (stack.length > 0 && stack[stack.length - 1].tag === '*') {
|
|
224
239
|
addToParent(closeFrame(stack.pop()!, 0))
|
|
225
240
|
}
|
|
241
|
+
autoClosed.delete(tok.tag)
|
|
226
242
|
stack.push({
|
|
227
243
|
tag: tok.tag,
|
|
228
244
|
kind: openKind,
|
|
@@ -252,6 +268,7 @@ export function parseTokensToGreen(
|
|
|
252
268
|
}
|
|
253
269
|
|
|
254
270
|
// Push onto the stack — children will be added later.
|
|
271
|
+
autoClosed.delete(tok.tag)
|
|
255
272
|
stack.push({
|
|
256
273
|
tag: tok.tag,
|
|
257
274
|
kind: openKind,
|
|
@@ -283,6 +300,18 @@ export function parseTokensToGreen(
|
|
|
283
300
|
const errMsg = `Syntax Error: Expected /${expected}, got /${tok.tag}`
|
|
284
301
|
addToParent(createNode('error', errMsg, [createLeaf('text', text)]))
|
|
285
302
|
}
|
|
303
|
+
} else if (autoClosed.has(tok.tag)) {
|
|
304
|
+
// Este cierre llega tarde: su etiqueta ya se cerró sola cuando un
|
|
305
|
+
// cierre anterior pasó por encima de ella. No puede reclamar un
|
|
306
|
+
// ancestro del mismo nombre — hacerlo cerraba el contenedor de fuera
|
|
307
|
+
// y expulsaba de él a todo el resto del documento.
|
|
308
|
+
//
|
|
309
|
+
// Se conserva como nodo `discarded_tag`, que guarda su rango pero no
|
|
310
|
+
// se ve ni se exporta. Como texto volvía a salir del exportador
|
|
311
|
+
// convertido en etiqueta viva, y el documento dejaba de ser estable al
|
|
312
|
+
// reexportarlo (lo cazó `Fuzzer`).
|
|
313
|
+
autoClosed.delete(tok.tag)
|
|
314
|
+
addToParent(createLeaf('discarded_tag', source.slice(tok.start, tok.end)))
|
|
286
315
|
} else {
|
|
287
316
|
// LEGACY MODE: Walk backwards, auto-close inner tags, ignore orphaned closing tags.
|
|
288
317
|
let found = -1
|
|
@@ -300,13 +329,18 @@ export function parseTokensToGreen(
|
|
|
300
329
|
// trailing width of 0. This used to hand them `tok.end`, which made
|
|
301
330
|
// `[b][i]x[/b]` produce an `italic` and a `bold` both ending at 10,
|
|
302
331
|
// overlapping on the four characters of `[/b]`.
|
|
332
|
+
const autoClosedHere: string[] = []
|
|
303
333
|
while (stack.length - 1 > found) {
|
|
304
334
|
const inner = stack.pop()!
|
|
335
|
+
autoClosedHere.push(inner.tag)
|
|
305
336
|
stack[stack.length - 1].children.push(closeFrame(inner, 0))
|
|
306
337
|
}
|
|
307
338
|
|
|
308
339
|
// Close the matched tag itself — this one does own the delimiter.
|
|
309
340
|
addToParent(closeFrame(stack.pop()!, closeWidth))
|
|
341
|
+
// Las etiquetas que se cerraron solas quedan pendientes en el nivel
|
|
342
|
+
// que ha quedado abierto tras cerrar esta.
|
|
343
|
+
for (const tag of autoClosedHere) autoClosed.add(tag)
|
|
310
344
|
} else {
|
|
311
345
|
// Orphaned closing tag: no matching opener anywhere on the stack.
|
|
312
346
|
//
|
|
@@ -351,7 +385,10 @@ export function parseTokensToGreen(
|
|
|
351
385
|
for (const child of root) {
|
|
352
386
|
// GreenNode.kind is a widened `string`; the kind vocabulary is shared with
|
|
353
387
|
// NodeKind and every value reaching here came from tagToNodeKind().
|
|
354
|
-
|
|
388
|
+
// `discarded_tag` viaja suelto igual que `empty_line`: es un tramo de
|
|
389
|
+
// fuente que no se ve, y envolverlo en un párrafo dejaba un `<span>` vacío
|
|
390
|
+
// en el render por cada cierre descartado.
|
|
391
|
+
if (isBlockKind(child.kind as NodeKind) || child.kind === 'empty_line' || child.kind === 'discarded_tag') {
|
|
355
392
|
flushParagraph()
|
|
356
393
|
normalizedRoot.push(child)
|
|
357
394
|
} else {
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Quasar — Surgical Edit Plan & Conflict Resolution
|
|
3
|
+
*
|
|
4
|
+
* The optimizer has exactly one output: a set of `SurgicalEdit`s over the
|
|
5
|
+
* ORIGINAL source. Two appliers consume it — Monaco in-place, and a pure
|
|
6
|
+
* string rewrite for export — so neither ever re-serializes the document and
|
|
7
|
+
* neither can drift from the other.
|
|
8
|
+
*
|
|
9
|
+
* That design has one hard prerequisite, which is what this file is:
|
|
10
|
+
* **the edits handed to an applier must be pairwise conflict-free.**
|
|
11
|
+
* Monaco is explicit that it will not arbitrate:
|
|
12
|
+
*
|
|
13
|
+
* > Overlapping ranges are the caller's responsibility — Monaco has no
|
|
14
|
+
* > answer for two edits claiming the same character.
|
|
15
|
+
* — `packages/core/Editor/Core_Editor.tsx`
|
|
16
|
+
*
|
|
17
|
+
* Rules are authored independently and WILL collide: merging a run of
|
|
18
|
+
* identical `[color=#FF0000]` tags and shortening that same `#FF0000` to
|
|
19
|
+
* `#F00` claim overlapping bytes. The resolution must never be "last writer
|
|
20
|
+
* wins", because that makes the output depend on the order rules happen to be
|
|
21
|
+
* registered in. Everything here exists to make the outcome a pure function of
|
|
22
|
+
* the edit set itself.
|
|
23
|
+
*
|
|
24
|
+
* @see resolveEditConflicts
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import type { SurgicalEdit } from '../Reconciler/SurgicalReconciler'
|
|
28
|
+
|
|
29
|
+
// ── Planned edits ─────────────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* A `SurgicalEdit` plus the provenance the resolver needs to arbitrate and
|
|
33
|
+
* the UI needs to explain itself.
|
|
34
|
+
*
|
|
35
|
+
* `priority` is declared by the rule, not derived from the edit. A rule that
|
|
36
|
+
* rewrites a whole region outranks one that tweaks a token inside it, and
|
|
37
|
+
* that relationship is a property of the rules — not something you can read
|
|
38
|
+
* off two ranges.
|
|
39
|
+
*/
|
|
40
|
+
export interface PlannedEdit extends SurgicalEdit {
|
|
41
|
+
/** Identifies the rule that produced this edit. Also the final tie-break. */
|
|
42
|
+
readonly ruleId: string
|
|
43
|
+
/** Higher wins. See {@link compareEditPriority} for the full order. */
|
|
44
|
+
readonly priority: number
|
|
45
|
+
/** Human-readable intent, for the "what did the minifier do" panel. */
|
|
46
|
+
readonly label?: string
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Why an edit did not make it into the applied set.
|
|
51
|
+
*
|
|
52
|
+
* - `invalid-range` — the rule emitted a range that is not a valid slice of
|
|
53
|
+
* the source. A rule bug; dropped rather than thrown so one broken rule
|
|
54
|
+
* cannot destroy an otherwise good batch.
|
|
55
|
+
* - `subsumed` — a winning edit's range fully contains this one (equal ranges
|
|
56
|
+
* included). The winner rewrites these bytes wholesale, so this edit's
|
|
57
|
+
* intent is either already covered or no longer meaningful.
|
|
58
|
+
* - `straddle` — partial overlap where NEITHER range contains the other.
|
|
59
|
+
* Reported separately from `subsumed` on purpose: see the note on
|
|
60
|
+
* {@link classifyOverlap}.
|
|
61
|
+
*/
|
|
62
|
+
export type EditRejectionReason = 'invalid-range' | 'subsumed' | 'straddle'
|
|
63
|
+
|
|
64
|
+
export interface RejectedEdit {
|
|
65
|
+
readonly edit: PlannedEdit
|
|
66
|
+
readonly reason: EditRejectionReason
|
|
67
|
+
/** The accepted edit that displaced it. Absent for `invalid-range`. */
|
|
68
|
+
readonly winner?: PlannedEdit
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface ResolvedEditPlan {
|
|
72
|
+
/**
|
|
73
|
+
* Pairwise conflict-free, sorted by ascending `start`. Safe to hand
|
|
74
|
+
* verbatim to `applySurgicalEdits` (Monaco resolves original offsets
|
|
75
|
+
* itself) or to a back-to-front string rewrite.
|
|
76
|
+
*/
|
|
77
|
+
readonly accepted: readonly PlannedEdit[]
|
|
78
|
+
/** Everything dropped, with the reason and the edit that displaced it. */
|
|
79
|
+
readonly rejected: readonly RejectedEdit[]
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ── Overlap geometry ──────────────────────────────────────────────
|
|
83
|
+
|
|
84
|
+
/** How two claimed ranges relate. */
|
|
85
|
+
export type OverlapRelation = 'disjoint' | 'subsumption' | 'straddle'
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Classify two edits by the bytes they claim.
|
|
89
|
+
*
|
|
90
|
+
* Ranges are half-open `[start, end)` over the original source, so edits that
|
|
91
|
+
* merely touch (`a.end === b.start`) are disjoint — adjacent replacements are
|
|
92
|
+
* perfectly legal and common.
|
|
93
|
+
*
|
|
94
|
+
* Zero-width edits (insertions, `start === end`) need two special cases that
|
|
95
|
+
* pure interval intersection gets wrong:
|
|
96
|
+
*
|
|
97
|
+
* 1. Two insertions at the SAME offset have an empty intersection, yet both
|
|
98
|
+
* write at one point with no defined order between them. That is a
|
|
99
|
+
* conflict, classified as `subsumption` so priority decides it.
|
|
100
|
+
* 2. An insertion strictly inside a replacement also has an empty
|
|
101
|
+
* intersection, but the replacement destroys the bytes the insertion point
|
|
102
|
+
* refers to, so the insertion is meaningless. Also `subsumption`.
|
|
103
|
+
*
|
|
104
|
+
* An insertion exactly at a replacement's boundary stays disjoint — it is
|
|
105
|
+
* unambiguously before or after, and both appliers agree on that.
|
|
106
|
+
*
|
|
107
|
+
* `straddle` is deliberately its own relation rather than folded into
|
|
108
|
+
* `subsumption`. It is resolved the same way (drop the loser), but two
|
|
109
|
+
* *maximal* rules should never straddle each other: a straddle almost always
|
|
110
|
+
* means a rule computed a partial range instead of the whole normal form.
|
|
111
|
+
* Keeping it distinct is what makes that bug visible instead of silent.
|
|
112
|
+
*/
|
|
113
|
+
export function classifyOverlap(a: SurgicalEdit, b: SurgicalEdit): OverlapRelation {
|
|
114
|
+
const aEmpty = a.start === a.end
|
|
115
|
+
const bEmpty = b.start === b.end
|
|
116
|
+
|
|
117
|
+
// Case 1: two insertions. They conflict only at the very same offset.
|
|
118
|
+
if (aEmpty && bEmpty) {
|
|
119
|
+
return a.start === b.start ? 'subsumption' : 'disjoint'
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const lo = Math.max(a.start, b.start)
|
|
123
|
+
const hi = Math.min(a.end, b.end)
|
|
124
|
+
|
|
125
|
+
if (lo >= hi) {
|
|
126
|
+
// Case 2: an insertion strictly interior to the other's range.
|
|
127
|
+
if (aEmpty && b.start < a.start && a.start < b.end) return 'subsumption'
|
|
128
|
+
if (bEmpty && a.start < b.start && b.start < a.end) return 'subsumption'
|
|
129
|
+
return 'disjoint'
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const aContainsB = a.start <= b.start && b.end <= a.end
|
|
133
|
+
const bContainsA = b.start <= a.start && a.end <= b.end
|
|
134
|
+
|
|
135
|
+
// Equal ranges satisfy both, and land here as subsumption — correct: one of
|
|
136
|
+
// the two must go, and priority is what decides which.
|
|
137
|
+
return aContainsB || bContainsA ? 'subsumption' : 'straddle'
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Whether two edits may not be applied together. */
|
|
141
|
+
export function editsConflict(a: SurgicalEdit, b: SurgicalEdit): boolean {
|
|
142
|
+
return classifyOverlap(a, b) !== 'disjoint'
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// ── Priority ──────────────────────────────────────────────────────
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* The arbitration order, as a comparator: negative means `a` is considered
|
|
149
|
+
* first and therefore wins any conflict against `b`.
|
|
150
|
+
*
|
|
151
|
+
* The chain is **total** — it never returns 0 for two distinguishable edits.
|
|
152
|
+
* That is the whole point. A partial order would leave ties broken by
|
|
153
|
+
* `Array.prototype.sort` stability, i.e. by the order rules were registered,
|
|
154
|
+
* which is exactly the non-determinism this contract exists to prevent.
|
|
155
|
+
*
|
|
156
|
+
* 1. `priority` descending — the rule's own declaration, and the only lever a
|
|
157
|
+
* rule author has.
|
|
158
|
+
* 2. Width descending — at equal priority the wider claim wins, because a
|
|
159
|
+
* broader rewrite subsumes the work of a narrower one more often than the
|
|
160
|
+
* reverse. (A zero-width insertion therefore loses to any replacement it
|
|
161
|
+
* ties with, which is what you want: the replacement carries text anyway.)
|
|
162
|
+
* 3. `start` ascending — earlier in the document.
|
|
163
|
+
* 4. `ruleId`, then `text`, lexicographically — no semantic meaning, purely
|
|
164
|
+
* the guarantee that the order is total.
|
|
165
|
+
*/
|
|
166
|
+
export function compareEditPriority(a: PlannedEdit, b: PlannedEdit): number {
|
|
167
|
+
if (a.priority !== b.priority) return b.priority - a.priority
|
|
168
|
+
|
|
169
|
+
const aWidth = a.end - a.start
|
|
170
|
+
const bWidth = b.end - b.start
|
|
171
|
+
if (aWidth !== bWidth) return bWidth - aWidth
|
|
172
|
+
|
|
173
|
+
if (a.start !== b.start) return a.start - b.start
|
|
174
|
+
if (a.ruleId !== b.ruleId) return a.ruleId < b.ruleId ? -1 : 1
|
|
175
|
+
if (a.text !== b.text) return a.text < b.text ? -1 : 1
|
|
176
|
+
return 0
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// ── Resolution ────────────────────────────────────────────────────
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Overlap geometry and rejection reasons are separate vocabularies on purpose:
|
|
183
|
+
* one describes how two ranges sit, the other why an edit was dropped. This is
|
|
184
|
+
* the only place they meet. It used to be a cast, which quietly turned
|
|
185
|
+
* `'subsumption'` into an invalid reason string.
|
|
186
|
+
*/
|
|
187
|
+
const REJECTION_FOR: Record<Exclude<OverlapRelation, 'disjoint'>, EditRejectionReason> = {
|
|
188
|
+
subsumption: 'subsumed',
|
|
189
|
+
straddle: 'straddle',
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function isValidRange(edit: SurgicalEdit, sourceLength: number): boolean {
|
|
193
|
+
return (
|
|
194
|
+
Number.isInteger(edit.start) &&
|
|
195
|
+
Number.isInteger(edit.end) &&
|
|
196
|
+
edit.start >= 0 &&
|
|
197
|
+
edit.end >= edit.start &&
|
|
198
|
+
edit.end <= sourceLength
|
|
199
|
+
)
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** Index of the first accepted edit ordered at or after `edit`. */
|
|
203
|
+
function lowerBound(sorted: readonly PlannedEdit[], edit: PlannedEdit): number {
|
|
204
|
+
let lo = 0
|
|
205
|
+
let hi = sorted.length
|
|
206
|
+
while (lo < hi) {
|
|
207
|
+
const mid = (lo + hi) >> 1
|
|
208
|
+
const probe = sorted[mid]
|
|
209
|
+
if (probe.start < edit.start || (probe.start === edit.start && probe.end < edit.end)) {
|
|
210
|
+
lo = mid + 1
|
|
211
|
+
} else {
|
|
212
|
+
hi = mid
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
return lo
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Reduce a rule-authored edit set to a conflict-free one.
|
|
220
|
+
*
|
|
221
|
+
* Greedy by {@link compareEditPriority}: walk the edits in arbitration order
|
|
222
|
+
* and accept each one that collides with nothing already accepted.
|
|
223
|
+
*
|
|
224
|
+
* This is deliberately **not** a maximum-weight independent set. Greedy can
|
|
225
|
+
* drop two small edits to keep one large one that saves fewer bytes, and that
|
|
226
|
+
* is an accepted trade: the optimizer's contract is that the same document
|
|
227
|
+
* always minifies to the same output, not that it minifies to the smallest
|
|
228
|
+
* possible output. Predictability is worth more than the last few bytes in a
|
|
229
|
+
* tool that rewrites someone's document.
|
|
230
|
+
*
|
|
231
|
+
* The result is order-independent — shuffling `edits` cannot change
|
|
232
|
+
* `accepted` — which is the property the tests pin down.
|
|
233
|
+
*
|
|
234
|
+
* @param edits Rule output, in any order.
|
|
235
|
+
* @param sourceLength Length of the ORIGINAL source every range indexes into.
|
|
236
|
+
*/
|
|
237
|
+
export function resolveEditConflicts(
|
|
238
|
+
edits: readonly PlannedEdit[],
|
|
239
|
+
sourceLength: number,
|
|
240
|
+
): ResolvedEditPlan {
|
|
241
|
+
const rejected: RejectedEdit[] = []
|
|
242
|
+
const candidates: PlannedEdit[] = []
|
|
243
|
+
|
|
244
|
+
for (const edit of edits) {
|
|
245
|
+
if (isValidRange(edit, sourceLength)) candidates.push(edit)
|
|
246
|
+
else rejected.push({ edit, reason: 'invalid-range' })
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
candidates.sort(compareEditPriority)
|
|
250
|
+
|
|
251
|
+
// Kept sorted by (start, end). Every member is disjoint from every other,
|
|
252
|
+
// so a conflicting neighbour is always found next to the insertion point.
|
|
253
|
+
const accepted: PlannedEdit[] = []
|
|
254
|
+
|
|
255
|
+
for (const candidate of candidates) {
|
|
256
|
+
const at = lowerBound(accepted, candidate)
|
|
257
|
+
|
|
258
|
+
let winner: PlannedEdit | undefined
|
|
259
|
+
let reason: EditRejectionReason | undefined
|
|
260
|
+
|
|
261
|
+
// The predecessor can extend past the candidate's start...
|
|
262
|
+
if (at > 0) {
|
|
263
|
+
const previous = accepted[at - 1]
|
|
264
|
+
const rel = classifyOverlap(candidate, previous)
|
|
265
|
+
if (rel !== 'disjoint') {
|
|
266
|
+
winner = previous
|
|
267
|
+
reason = REJECTION_FOR[rel]
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// ...and any accepted edit beginning at or before the candidate's end may
|
|
272
|
+
// reach back into it. Scanning stops at the first hit, and the accepted
|
|
273
|
+
// set is disjoint, so this walks a couple of entries, never the list.
|
|
274
|
+
for (let i = at; !winner && i < accepted.length && accepted[i].start <= candidate.end; i++) {
|
|
275
|
+
const rel = classifyOverlap(candidate, accepted[i])
|
|
276
|
+
if (rel !== 'disjoint') {
|
|
277
|
+
winner = accepted[i]
|
|
278
|
+
reason = REJECTION_FOR[rel]
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
if (winner && reason) {
|
|
283
|
+
rejected.push({ edit: candidate, reason, winner })
|
|
284
|
+
} else {
|
|
285
|
+
accepted.splice(at, 0, candidate)
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
return { accepted, rejected }
|
|
290
|
+
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Quasar — BBCode Optimizer
|
|
3
|
+
*
|
|
4
|
+
* Parses a document, runs every optimization rule over the tree, arbitrates
|
|
5
|
+
* the edits they produce, and hands back one conflict-free `SurgicalEdit[]`
|
|
6
|
+
* addressed to the ORIGINAL source.
|
|
7
|
+
*
|
|
8
|
+
* That edit set is the optimizer's only output. There is deliberately no
|
|
9
|
+
* "optimized tree" — two appliers consume the edits instead:
|
|
10
|
+
*
|
|
11
|
+
* in-place → applySurgicalEdits(result.edits) (Monaco, one undo stop)
|
|
12
|
+
* export → result.output (applyEditsToSource)
|
|
13
|
+
*
|
|
14
|
+
* Keeping a tree optimizer *and* a range optimizer in step forever was the
|
|
15
|
+
* alternative, and it was rejected before the first rule was written. A tree
|
|
16
|
+
* optimizer's output can only reach the document through a whole-document
|
|
17
|
+
* re-export, which silently rewrites spellings the author chose (`[B]`→`[b]`,
|
|
18
|
+
* `\r\n`→`\n`, unclosed tags closed). That is fine for an export and
|
|
19
|
+
* unacceptable for someone's open buffer.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { BBCodeDocumentModel } from '../BBCode/BBCodeDocumentModel'
|
|
23
|
+
import type { BBCodeDialect } from '../BBCode/BBCodeToGreenNode'
|
|
24
|
+
import type { GreenNode } from '../Syntax/GreenNode'
|
|
25
|
+
import { resolveEditConflicts, type PlannedEdit, type ResolvedEditPlan } from './EditPlan'
|
|
26
|
+
import { applyEditsToSource } from './applyEdits'
|
|
27
|
+
import type { OptimizationRule } from './Rules/Rule'
|
|
28
|
+
import { MergeAdjacentRule } from './Rules/mergeAdjacent'
|
|
29
|
+
import { DropEmptyTagsRule } from './Rules/dropEmptyTags'
|
|
30
|
+
import { DropRedundantNestingRule } from './Rules/dropRedundantNesting'
|
|
31
|
+
import { ShortenHexRule } from './Rules/shortenHex'
|
|
32
|
+
import { UnwrapInvisibleColorRule } from './Rules/unwrapInvisibleColor'
|
|
33
|
+
import { ReorderWrappersRule } from './Rules/reorderWrappers'
|
|
34
|
+
|
|
35
|
+
// ── Presets ───────────────────────────────────────────────────────
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The rules a plain "minify" run applies.
|
|
39
|
+
*
|
|
40
|
+
* Ordered by descending priority, which is also the order in which their
|
|
41
|
+
* regions subsume one another: a merge deletes the delimiters that a hex
|
|
42
|
+
* shortening inside them would have edited.
|
|
43
|
+
*
|
|
44
|
+
* `ReorderWrappersRule` is **not** here. It is a real, tested rule, but it
|
|
45
|
+
* saves exactly zero bytes — it canonicalises nesting order rather than
|
|
46
|
+
* removing anything. A minifier that rewrites someone's document without
|
|
47
|
+
* making it smaller is a diff with no upside, so it is opt-in.
|
|
48
|
+
*/
|
|
49
|
+
export function defaultRules(): OptimizationRule[] {
|
|
50
|
+
return [
|
|
51
|
+
new MergeAdjacentRule(),
|
|
52
|
+
new DropRedundantNestingRule(),
|
|
53
|
+
new UnwrapInvisibleColorRule(),
|
|
54
|
+
new DropEmptyTagsRule(),
|
|
55
|
+
new ShortenHexRule(),
|
|
56
|
+
]
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Every rule that exists, including the ones outside the default preset. */
|
|
60
|
+
export function allRules(): OptimizationRule[] {
|
|
61
|
+
return [...defaultRules(), new ReorderWrappersRule()]
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// ── Result ────────────────────────────────────────────────────────
|
|
65
|
+
|
|
66
|
+
export interface RuleStat {
|
|
67
|
+
readonly ruleId: string
|
|
68
|
+
readonly label: string
|
|
69
|
+
/** Edits this rule contributed to the applied set. */
|
|
70
|
+
readonly edits: number
|
|
71
|
+
/** Characters removed by those edits. Never negative for the shipped rules. */
|
|
72
|
+
readonly savedChars: number
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface OptimizationResult {
|
|
76
|
+
/** The document as given. */
|
|
77
|
+
readonly source: string
|
|
78
|
+
/** Conflict-free, sorted by ascending start. Hand straight to either applier. */
|
|
79
|
+
readonly edits: readonly PlannedEdit[]
|
|
80
|
+
/** Full arbitration record, including what was dropped and why. */
|
|
81
|
+
readonly plan: ResolvedEditPlan
|
|
82
|
+
/** `source` with `edits` applied. */
|
|
83
|
+
readonly output: string
|
|
84
|
+
readonly savedChars: number
|
|
85
|
+
/** Per-rule breakdown of the *applied* edits, in descending saving order. */
|
|
86
|
+
readonly stats: readonly RuleStat[]
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export interface OptimizeOptions {
|
|
90
|
+
readonly rules?: readonly OptimizationRule[]
|
|
91
|
+
/** Dialect to parse against. Defaults to `'lyne'`, the superset. */
|
|
92
|
+
readonly dialect?: BBCodeDialect
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ── Entry points ──────────────────────────────────────────────────
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Optimize a parsed tree.
|
|
99
|
+
*
|
|
100
|
+
* Prefer this when the caller already holds a green tree — the editor reparses
|
|
101
|
+
* on every keystroke and has no reason to pay for another parse.
|
|
102
|
+
*
|
|
103
|
+
* `root.width` must equal `source.length`; the rules address offsets into that
|
|
104
|
+
* exact string. A mismatch means the tree describes a different document, and
|
|
105
|
+
* every range would land in the wrong place, so it is refused outright.
|
|
106
|
+
*/
|
|
107
|
+
export function optimizeTree(
|
|
108
|
+
source: string,
|
|
109
|
+
root: GreenNode,
|
|
110
|
+
options: OptimizeOptions = {},
|
|
111
|
+
): OptimizationResult {
|
|
112
|
+
if (root.width !== source.length) {
|
|
113
|
+
throw new Error(
|
|
114
|
+
`optimizeTree: tree width ${root.width} does not match source length ${source.length}; ` +
|
|
115
|
+
'the tree must be the parse of exactly this source.',
|
|
116
|
+
)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const rules = options.rules ?? defaultRules()
|
|
120
|
+
const context = { source, root }
|
|
121
|
+
|
|
122
|
+
const proposed: PlannedEdit[] = []
|
|
123
|
+
for (const rule of rules) proposed.push(...rule.run(context))
|
|
124
|
+
|
|
125
|
+
const plan = resolveEditConflicts(proposed, source.length)
|
|
126
|
+
const output = applyEditsToSource(source, plan.accepted)
|
|
127
|
+
|
|
128
|
+
return {
|
|
129
|
+
source,
|
|
130
|
+
edits: plan.accepted,
|
|
131
|
+
plan,
|
|
132
|
+
output,
|
|
133
|
+
savedChars: source.length - output.length,
|
|
134
|
+
stats: buildStats(plan.accepted, rules),
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Parse `source` and optimize it. */
|
|
139
|
+
export function optimizeBBCode(source: string, options: OptimizeOptions = {}): OptimizationResult {
|
|
140
|
+
const model = new BBCodeDocumentModel({
|
|
141
|
+
source,
|
|
142
|
+
dialect: options.dialect ?? 'lyne',
|
|
143
|
+
autoAnalyze: false,
|
|
144
|
+
})
|
|
145
|
+
const root = model.greenRoot
|
|
146
|
+
|
|
147
|
+
if (!root) {
|
|
148
|
+
return {
|
|
149
|
+
source,
|
|
150
|
+
edits: [],
|
|
151
|
+
plan: { accepted: [], rejected: [] },
|
|
152
|
+
output: source,
|
|
153
|
+
savedChars: 0,
|
|
154
|
+
stats: [],
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return optimizeTree(source, root, options)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// ── Reporting ─────────────────────────────────────────────────────
|
|
162
|
+
|
|
163
|
+
function buildStats(
|
|
164
|
+
accepted: readonly PlannedEdit[],
|
|
165
|
+
rules: readonly OptimizationRule[],
|
|
166
|
+
): RuleStat[] {
|
|
167
|
+
const labels = new Map(rules.map(rule => [rule.id, rule.label]))
|
|
168
|
+
const totals = new Map<string, { edits: number; savedChars: number }>()
|
|
169
|
+
|
|
170
|
+
for (const edit of accepted) {
|
|
171
|
+
const entry = totals.get(edit.ruleId) ?? { edits: 0, savedChars: 0 }
|
|
172
|
+
entry.edits++
|
|
173
|
+
entry.savedChars += edit.end - edit.start - edit.text.length
|
|
174
|
+
totals.set(edit.ruleId, entry)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return [...totals.entries()]
|
|
178
|
+
.map(([ruleId, entry]) => ({
|
|
179
|
+
ruleId,
|
|
180
|
+
label: labels.get(ruleId) ?? ruleId,
|
|
181
|
+
edits: entry.edits,
|
|
182
|
+
savedChars: entry.savedChars,
|
|
183
|
+
}))
|
|
184
|
+
.sort((a, b) => b.savedChars - a.savedChars || a.ruleId.localeCompare(b.ruleId))
|
|
185
|
+
}
|