@miliastry/quasar 1.0.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/LICENSE +119 -0
- package/README.md +45 -0
- package/dist/Visuals/lyne.css +1027 -0
- package/dist/Visuals/osu.css +257 -0
- package/dist/index.d.mts +4563 -0
- package/dist/index.d.ts +4563 -0
- package/dist/index.js +11291 -0
- package/dist/index.mjs +11197 -0
- package/package.json +51 -0
- package/src/Analysis/Contracts/AnalysisReport.ts +26 -0
- package/src/Analysis/Contracts/Contribution.ts +76 -0
- package/src/Analysis/Contracts/Pass.ts +76 -0
- package/src/Analysis/Contracts/PipelineContext.ts +48 -0
- package/src/Analysis/Passes/Analysis/GradientAnalyzer.ts +292 -0
- package/src/Analysis/Passes/Analysis/MergeableColorAnalyzer.ts +112 -0
- package/src/Analysis/Passes/Analysis/RainbowAnalyzer.ts +233 -0
- package/src/Analysis/Passes/Analysis/WaveAnalyzer.ts +211 -0
- package/src/Analysis/Passes/Analysis/__tests__/GradientAnalyzer.test.ts +135 -0
- package/src/Analysis/Passes/Analysis/__tests__/MergeableColorAnalyzer.test.ts +84 -0
- package/src/Analysis/Passes/Analysis/__tests__/RainbowAnalyzer.test.ts +99 -0
- package/src/Analysis/Passes/Analysis/__tests__/WaveAnalyzer.test.ts +119 -0
- package/src/Analysis/Passes/Decision/DefaultDecision.ts +139 -0
- package/src/Analysis/Passes/Decision/__tests__/DefaultDecision.test.ts +179 -0
- package/src/Analysis/Passes/Transform/CollapseGradientTransform.ts +176 -0
- package/src/Analysis/Passes/Transform/MergeColorsTransform.ts +126 -0
- package/src/Analysis/Passes/Transform/RainbowCollapseTransform.ts +83 -0
- package/src/Analysis/Passes/Transform/WaveCollapseTransform.ts +88 -0
- package/src/Analysis/Passes/Utility/CharacterCountAnalyzer.ts +45 -0
- package/src/Analysis/Pipeline/Pipeline.ts +133 -0
- package/src/Analysis/Pipeline/PipelineBuilder.ts +55 -0
- package/src/Analysis/Pipeline/PipelineStage.ts +19 -0
- package/src/Analysis/Utils/color-utils.ts +132 -0
- package/src/Analysis/__tests__/Integration.test.ts +162 -0
- package/src/Analysis/__tests__/Pipeline.test.ts +133 -0
- package/src/Analysis/index.ts +52 -0
- package/src/BBCode/BBCodeDocumentModel.ts +175 -0
- package/src/BBCode/BBCodeToGreenNode.ts +755 -0
- package/src/BBCode/Parser.ts +384 -0
- package/src/BBCode/index.ts +12 -0
- package/src/Collab/positions.ts +91 -0
- package/src/Commands/Command.ts +44 -0
- package/src/Commands/CommandRegistry.ts +78 -0
- package/src/Commands/DeleteNode.ts +20 -0
- package/src/Commands/InsertText.ts +21 -0
- package/src/Commands/SplitMerge.ts +28 -0
- package/src/Commands/WrapInTag.ts +21 -0
- package/src/Commands/index.ts +6 -0
- package/src/Diff/TreeDiffer.ts +264 -0
- package/src/Diff/__tests__/TreeDiffer.test.ts +65 -0
- package/src/Diff/index.ts +2 -0
- package/src/Events/EventBus.ts +160 -0
- package/src/Events/index.ts +2 -0
- package/src/Formatter/Formatter.ts +54 -0
- package/src/Formatter/index.ts +2 -0
- package/src/HTML/HTMLDocumentModel.ts +35 -0
- package/src/HTML/HTMLToGreenNode.ts +290 -0
- package/src/Incremental/ChangeTracker.ts +105 -0
- package/src/Incremental/IncrementalParser.ts +591 -0
- package/src/Incremental/__tests__/IncrementalParser.test.ts +164 -0
- package/src/Incremental/index.ts +4 -0
- package/src/Lexer/BBCodeLexer.ts +382 -0
- package/src/Lexer/Lexer.ts +181 -0
- package/src/Lexer/index.ts +10 -0
- package/src/Linter/Linter.ts +193 -0
- package/src/Linter/index.ts +2 -0
- package/src/Markdown/MarkdownAST.ts +112 -0
- package/src/Markdown/MarkdownDocumentModel.ts +55 -0
- package/src/Markdown/MarkdownLexer.ts +203 -0
- package/src/Markdown/MarkdownParser.ts +455 -0
- package/src/Markdown/MarkdownToGreenNode.ts +153 -0
- package/src/Model/DocumentModel.ts +694 -0
- package/src/Model/NodeFactory.ts +117 -0
- package/src/Model/TagRegistry.ts +495 -0
- package/src/Model/index.ts +5 -0
- package/src/Plugins/PluginAPI.ts +119 -0
- package/src/Plugins/PluginRegistry.ts +132 -0
- package/src/Plugins/index.ts +3 -0
- package/src/Queries/QueryEngine.ts +152 -0
- package/src/Queries/index.ts +1 -0
- package/src/RenderPipeline/RenderPipeline.ts +125 -0
- package/src/RenderPipeline/RenderTree.ts +134 -0
- package/src/RenderPipeline/index.ts +4 -0
- package/src/Semantic/SemanticAnalyzer.ts +506 -0
- package/src/Semantic/index.ts +2 -0
- package/src/Symbols/SymbolTable.ts +124 -0
- package/src/Symbols/index.ts +1 -0
- package/src/Syntax/GreenNode.ts +324 -0
- package/src/Syntax/GreenNodePool.ts +269 -0
- package/src/Syntax/NodeMatcher.ts +370 -0
- package/src/Syntax/RedNode.ts +569 -0
- package/src/Syntax/RedNodeStore.ts +184 -0
- package/src/Syntax/TreeBuilder.ts +214 -0
- package/src/Syntax/__tests__/GreenNode.test.ts +33 -0
- package/src/Syntax/__tests__/RedNode.test.ts +81 -0
- package/src/Syntax/__tests__/RedNodeStore.test.ts +104 -0
- package/src/Syntax/greenEdit.ts +110 -0
- package/src/Syntax/hash.ts +30 -0
- package/src/Syntax/index.ts +12 -0
- package/src/Syntax/partition.ts +161 -0
- package/src/Syntax/preserveNodeIds.ts +201 -0
- package/src/Tests/ASTOptimizerIdempotence.test.ts +77 -0
- package/src/Tests/BlockPatcher.test.ts +437 -0
- package/src/Tests/BlockPatcherWindowed.test.ts +364 -0
- package/src/Tests/BoxDrawer.test.ts +217 -0
- package/src/Tests/BoxRichTitle.test.ts +105 -0
- package/src/Tests/Chars500kBenchmark.test.ts +151 -0
- package/src/Tests/Chars500kEdits.test.ts +321 -0
- package/src/Tests/CollabPositions.test.ts +146 -0
- package/src/Tests/CompilerPathProfiling.test.ts +186 -0
- package/src/Tests/DOMMorpher.test.ts +142 -0
- package/src/Tests/DomPatchPerf.test.ts +60 -0
- package/src/Tests/EffectSegments.snapshot.json +616 -0
- package/src/Tests/EffectSegments.test.ts +68 -0
- package/src/Tests/FindNodeAtOffset.test.ts +65 -0
- package/src/Tests/Fuzzer.test.ts +166 -0
- package/src/Tests/GreenNodePool.test.ts +153 -0
- package/src/Tests/Lexer.test.ts +238 -0
- package/src/Tests/LyneMode.test.ts +187 -0
- package/src/Tests/ModelCoherence.test.ts +180 -0
- package/src/Tests/Partition.test.ts +238 -0
- package/src/Tests/PluginTags.test.ts +150 -0
- package/src/Tests/ProblematicSection.test.ts +46 -0
- package/src/Tests/ProblematicSectionHTML.test.ts +58 -0
- package/src/Tests/RedReuse.test.ts +134 -0
- package/src/Tests/ReproDelete20k.test.ts +62 -0
- package/src/Tests/SemanticValidators.test.ts +136 -0
- package/src/Tests/StableNodeIds.test.ts +210 -0
- package/src/Tests/StudioColorBloat.test.ts +25 -0
- package/src/Tests/StudioDebugText.test.ts +27 -0
- package/src/Tests/StudioTrailingChar.test.ts +25 -0
- package/src/Tests/StudioValidText.test.ts +25 -0
- package/src/Tests/UrlImgBug.test.ts +23 -0
- package/src/Tests/VisualBuilderFidelity.test.ts +105 -0
- package/src/Tests/referenceDocument.ts +119 -0
- package/src/Transactions/Transaction.ts +176 -0
- package/src/Transactions/UndoManager.ts +111 -0
- package/src/Transactions/index.ts +3 -0
- package/src/Transformers/ASTOptimizer.ts +315 -0
- package/src/Transformers/GradientTransformer.ts +143 -0
- package/src/Transformers/GrowTransformer.ts +115 -0
- package/src/Transformers/RainbowTransformer.ts +121 -0
- package/src/Transformers/SineWaveTransformer.ts +130 -0
- package/src/Transformers/Transformer.ts +22 -0
- package/src/Types/core.ts +270 -0
- package/src/Types/diagnostics.ts +156 -0
- package/src/Types/index.ts +23 -0
- package/src/Types/operations.ts +180 -0
- package/src/Types/queries.ts +121 -0
- package/src/Types/symbols.ts +69 -0
- package/src/Types/tokens.ts +186 -0
- package/src/Utils/BBCodeGenerator.ts +126 -0
- package/src/Utils/ColorMath.ts +276 -0
- package/src/Utils/color.ts +112 -0
- package/src/Utils/dom-to-svg.test.ts +86 -0
- package/src/Utils/dom-to-svg.ts +615 -0
- package/src/Utils/treeTransformers.ts +717 -0
- package/src/Visitors/BBBlocksExporter.ts +69 -0
- package/src/Visitors/BBCodeExporter.ts +318 -0
- package/src/Visitors/BlockPatcher.ts +963 -0
- package/src/Visitors/DOMMorpher.ts +134 -0
- package/src/Visitors/HTMLRenderer.ts +1077 -0
- package/src/Visitors/JSONExporter.ts +66 -0
- package/src/Visitors/MarkdownExporter.ts +99 -0
- package/src/Visitors/SVGRenderer.ts +35 -0
- package/src/Visitors/TiptapExporter.ts +145 -0
- package/src/Visitors/Visitor.ts +48 -0
- package/src/Visitors/index.ts +9 -0
- package/src/Visuals/BoxDrawer.ts +175 -0
- package/src/Visuals/index.ts +42 -0
- package/src/Visuals/lyne.css +1027 -0
- package/src/Visuals/osu.css +257 -0
- package/src/index.ts +197 -0
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,4563 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DocumentEngine — GreenNode (Immutable Syntax Tree)
|
|
3
|
+
*
|
|
4
|
+
* The Green Tree is IMMUTABLE. It never changes after creation.
|
|
5
|
+
* This is the "compiled" representation of the source text.
|
|
6
|
+
*
|
|
7
|
+
* Properties:
|
|
8
|
+
* - Fully immutable (readonly)
|
|
9
|
+
* - Contains only structural information (kind, text, children, widths)
|
|
10
|
+
* - **No absolute position** — see below
|
|
11
|
+
* - No parent references
|
|
12
|
+
* - No identity (IDs live in Red Tree)
|
|
13
|
+
* - Can be shared across document versions
|
|
14
|
+
* - Safe to cache
|
|
15
|
+
*
|
|
16
|
+
* ─── Why there is no `range` ────────────────────────────────────────────────
|
|
17
|
+
*
|
|
18
|
+
* A green node knows how WIDE it is, not WHERE it is. That is the whole point:
|
|
19
|
+
* the two `[color=#e8b04b]` runs in a gradient are the same structure, and if
|
|
20
|
+
* each carried its own offsets they could never be the same object. Measured on
|
|
21
|
+
* the reference document: 1736 nodes, but only 702 distinct structures — 59.6%
|
|
22
|
+
* of the tree is duplicate.
|
|
23
|
+
*
|
|
24
|
+
* Position is a property of a node's PLACE in a tree, so it lives on the red
|
|
25
|
+
* node, which is the thing that has a place. `RedNode.range` computes it once
|
|
26
|
+
* at construction by accumulating widths from the root and caches it, so
|
|
27
|
+
* consumers see exactly the same `{start, end}` they always did.
|
|
28
|
+
*
|
|
29
|
+
* This also makes incremental editing cheaper: text inserted before a subtree
|
|
30
|
+
* changes where it is, not what it is, so nothing about it needs rebuilding.
|
|
31
|
+
* `shiftGreen` — which used to deep-copy every following sibling to bump its
|
|
32
|
+
* offsets — no longer exists, because there is nothing left to shift.
|
|
33
|
+
*
|
|
34
|
+
* ─── Why `width` is derived and never passed ────────────────────────────────
|
|
35
|
+
*
|
|
36
|
+
* `width = leadingWidth + (children ? Σ child widths : ownWidth) + trailingWidth`
|
|
37
|
+
*
|
|
38
|
+
* The partition invariant of roadmap point 14 — every character owned exactly
|
|
39
|
+
* once — used to be a runtime check that a careless caller could violate. Now
|
|
40
|
+
* it is arithmetic the constructor performs, so a tree that does not partition
|
|
41
|
+
* its source cannot be built. `Syntax/partition.ts` still exists to verify the
|
|
42
|
+
* one thing this cannot: that the root's width matches the source length.
|
|
43
|
+
*
|
|
44
|
+
* Inspired by Roslyn's Green Tree and SwiftSyntax.
|
|
45
|
+
*/
|
|
46
|
+
declare class GreenNode {
|
|
47
|
+
/** Syntax kind — maps to TokenKind for leaves, NodeKind for internals */
|
|
48
|
+
readonly kind: string;
|
|
49
|
+
/** Raw text of this node (empty for internal nodes) */
|
|
50
|
+
readonly text: string;
|
|
51
|
+
/** Child nodes */
|
|
52
|
+
readonly children: readonly GreenNode[];
|
|
53
|
+
/** Total width of this node in characters, delimiters included */
|
|
54
|
+
readonly width: number;
|
|
55
|
+
/** Whether this is a leaf (token) or internal node */
|
|
56
|
+
readonly isLeaf: boolean;
|
|
57
|
+
/**
|
|
58
|
+
* Characters this node owns BEFORE its first child — its opening delimiter.
|
|
59
|
+
* For `[b]hola[/b]` that is the 3 characters of `[b]`.
|
|
60
|
+
*
|
|
61
|
+
* `declare` is load-bearing here for the same reason as `_hashCache` below:
|
|
62
|
+
* a plain field declaration would emit a `defineProperty` per construction.
|
|
63
|
+
*/
|
|
64
|
+
readonly leadingWidth: number;
|
|
65
|
+
/** Characters this node owns AFTER its last child — its closing delimiter. */
|
|
66
|
+
readonly trailingWidth: number;
|
|
67
|
+
/**
|
|
68
|
+
* Memoized structural hash — computed on first access, never invalidated.
|
|
69
|
+
*
|
|
70
|
+
* `declare` is load-bearing: with `target: ES2022`, TypeScript defaults
|
|
71
|
+
* `useDefineForClassFields` to true, so a plain field declaration emits a
|
|
72
|
+
* per-construction `defineProperty`. Measured on a 19.6 KB document that
|
|
73
|
+
* cost 23% of the parse phase (0.583 ms → 0.718 ms) for a slot most nodes
|
|
74
|
+
* never read. `declare` emits nothing; the constructor assigns it directly,
|
|
75
|
+
* which keeps the object shape monomorphic without the field-init overhead.
|
|
76
|
+
*/
|
|
77
|
+
private _hashCache;
|
|
78
|
+
/**
|
|
79
|
+
* @param ownWidth Width of a childless node's own content: the length of a
|
|
80
|
+
* `text` token, or the 1-2 characters of a newline. Ignored
|
|
81
|
+
* when there are children, whose widths sum to the same
|
|
82
|
+
* thing. An empty element like `[b][/b]` passes 0 and gets
|
|
83
|
+
* its width from its two delimiters.
|
|
84
|
+
*/
|
|
85
|
+
constructor(kind: string, text: string, children?: GreenNode[], leadingWidth?: number, trailingWidth?: number, ownWidth?: number);
|
|
86
|
+
/**
|
|
87
|
+
* Structural hash of this subtree: `(kind, text, widths, children hashes)`.
|
|
88
|
+
*
|
|
89
|
+
* Two subtrees that render identically at different offsets ARE structurally
|
|
90
|
+
* identical — that is the entire premise of structural sharing, and now that
|
|
91
|
+
* green nodes carry no position it is simply true rather than something the
|
|
92
|
+
* hash had to be careful to arrange.
|
|
93
|
+
*
|
|
94
|
+
* The widths ARE folded in: they are structure, not position. A `text` leaf
|
|
95
|
+
* of width 4 and one of width 5 are different nodes.
|
|
96
|
+
*
|
|
97
|
+
* Computed lazily: parsing paths that never intern pay nothing. Safe to
|
|
98
|
+
* memoize because a GreenNode is immutable once constructed.
|
|
99
|
+
*/
|
|
100
|
+
get _hash(): number;
|
|
101
|
+
/** Get child at index */
|
|
102
|
+
childAt(index: number): GreenNode | undefined;
|
|
103
|
+
/** Find first child matching predicate */
|
|
104
|
+
findChild(predicate: (n: GreenNode) => boolean): GreenNode | undefined;
|
|
105
|
+
/** Count of direct children */
|
|
106
|
+
get childCount(): number;
|
|
107
|
+
/** Check if this node has a specific kind */
|
|
108
|
+
isKind(kind: string): boolean;
|
|
109
|
+
/**
|
|
110
|
+
* Offset of this node's first child, relative to this node's own start.
|
|
111
|
+
* Equal to `leadingWidth`; named for the places that read it as a position.
|
|
112
|
+
*/
|
|
113
|
+
get innerOffset(): number;
|
|
114
|
+
/** Width of the span between this node's delimiters. */
|
|
115
|
+
get innerWidth(): number;
|
|
116
|
+
/** Walk all descendants in pre-order */
|
|
117
|
+
walk(visitor: (node: GreenNode, depth: number) => void | 'skip', depth?: number): void;
|
|
118
|
+
/**
|
|
119
|
+
* Walk all descendants in pre-order, with each node's absolute offset.
|
|
120
|
+
*
|
|
121
|
+
* The replacement for the walks that used to read `node.range`. Offsets are
|
|
122
|
+
* accumulated on the way down, which is the only place they exist.
|
|
123
|
+
*/
|
|
124
|
+
walkWithOffset(visitor: (node: GreenNode, start: number, depth: number) => void | 'skip', start?: number, depth?: number): void;
|
|
125
|
+
/** Convert to a debug string */
|
|
126
|
+
toString(depth?: number, start?: number): string;
|
|
127
|
+
/** Get the source text for this node, given where it starts. */
|
|
128
|
+
getSourceText(source: string, start: number): string;
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Build an internal node. Its width comes from its children plus its own
|
|
132
|
+
* delimiters — there is no way to declare a width that disagrees with them.
|
|
133
|
+
*/
|
|
134
|
+
declare function greenNode(kind: string, text: string, children?: GreenNode[], leadingWidth?: number, trailingWidth?: number, ownWidth?: number): GreenNode;
|
|
135
|
+
/**
|
|
136
|
+
* Build a token. `width` defaults to the text's own length, which is right for
|
|
137
|
+
* every leaf whose text IS its source (`text` nodes). Leaves whose `text` holds
|
|
138
|
+
* something else — `spacing` and `empty_line` carry `''` but occupy 1-2
|
|
139
|
+
* characters, element nodes carry their attributes — must pass it.
|
|
140
|
+
*/
|
|
141
|
+
declare function greenLeaf(kind: string, text: string, width?: number): GreenNode;
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* DocumentEngine — Token & Trivia Types
|
|
145
|
+
*
|
|
146
|
+
* Tokens are the output of the Lexer and input to the Parser.
|
|
147
|
+
* Trivia is whitespace, newlines, formatting that is preserved
|
|
148
|
+
* but not semantically meaningful — inspired by Roslyn.
|
|
149
|
+
*/
|
|
150
|
+
type TriviaKind = 'whitespace' | 'newline' | 'carriage_return' | 'comment' | 'line_continuation';
|
|
151
|
+
interface Trivia {
|
|
152
|
+
kind: TriviaKind;
|
|
153
|
+
text: string;
|
|
154
|
+
range: Range;
|
|
155
|
+
}
|
|
156
|
+
interface Range {
|
|
157
|
+
start: number;
|
|
158
|
+
end: number;
|
|
159
|
+
}
|
|
160
|
+
type TokenKind = 'open_tag' | 'close_tag' | 'self_closing_tag' | 'text' | 'newline' | 'whitespace' | 'attribute_name' | 'attribute_value' | 'equals' | 'quote' | 'end_of_file' | 'error';
|
|
161
|
+
interface Token {
|
|
162
|
+
kind: TokenKind;
|
|
163
|
+
text: string;
|
|
164
|
+
range: Range;
|
|
165
|
+
/** Tag name or attribute name if applicable */
|
|
166
|
+
name?: string;
|
|
167
|
+
/** Full raw tag text including brackets: [b], [/color], [*] */
|
|
168
|
+
tagText?: string;
|
|
169
|
+
/** Raw attribute string (everything between tag name and closing ]) */
|
|
170
|
+
attrs?: string;
|
|
171
|
+
/** Trivia attached BEFORE this token (whitespace, newlines) */
|
|
172
|
+
leadingTrivia: Trivia[];
|
|
173
|
+
/** Trivia attached AFTER this token */
|
|
174
|
+
trailingTrivia: Trivia[];
|
|
175
|
+
}
|
|
176
|
+
interface TokenStream {
|
|
177
|
+
/** Peek at the current token without consuming */
|
|
178
|
+
peek(): Token;
|
|
179
|
+
/** Consume and return the current token, advance */
|
|
180
|
+
advance(): Token;
|
|
181
|
+
/** Look ahead n tokens (0 = next) */
|
|
182
|
+
lookAhead(n: number): Token;
|
|
183
|
+
/** Whether we've consumed all tokens */
|
|
184
|
+
isEof(): boolean;
|
|
185
|
+
/** Get current position in the stream */
|
|
186
|
+
position(): number;
|
|
187
|
+
/** Reset to a position */
|
|
188
|
+
seek(pos: number): void;
|
|
189
|
+
/** Get all remaining tokens */
|
|
190
|
+
remaining(): Token[];
|
|
191
|
+
/** Get all tokens */
|
|
192
|
+
getAll(): Token[];
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* DocumentEngine — Diagnostic Types
|
|
197
|
+
*
|
|
198
|
+
* Diagnostics are messages attached to specific nodes or ranges
|
|
199
|
+
* in the document tree. Inspired by VSCode's diagnostic model.
|
|
200
|
+
*/
|
|
201
|
+
|
|
202
|
+
type DiagnosticSeverity = 'hint' | 'info' | 'warning' | 'error';
|
|
203
|
+
type DiagnosticTag = 'unnecessary' | 'deprecated' | 'unused' | 'redundant';
|
|
204
|
+
interface Diagnostic {
|
|
205
|
+
/** Machine-readable code (e.g. 'invalid-color', 'missing-close-tag') */
|
|
206
|
+
code: string;
|
|
207
|
+
/** Human-readable message */
|
|
208
|
+
message: string;
|
|
209
|
+
/** Severity level */
|
|
210
|
+
severity: DiagnosticSeverity;
|
|
211
|
+
/** Optional tags for additional categorization */
|
|
212
|
+
tags: DiagnosticTag[];
|
|
213
|
+
/** Source range in the original text */
|
|
214
|
+
range: Range | null;
|
|
215
|
+
/** Node ID this diagnostic is attached to */
|
|
216
|
+
nodeId: NodeId | null;
|
|
217
|
+
/** Node kind for context */
|
|
218
|
+
nodeKind: NodeKind | null;
|
|
219
|
+
/** Source of this diagnostic (built-in, plugin name, etc.) */
|
|
220
|
+
source: string;
|
|
221
|
+
/** Optional related information */
|
|
222
|
+
related?: DiagnosticRelatedInfo[];
|
|
223
|
+
/** Optional fix suggestions */
|
|
224
|
+
fixes?: DiagnosticFix[];
|
|
225
|
+
}
|
|
226
|
+
interface DiagnosticRelatedInfo {
|
|
227
|
+
message: string;
|
|
228
|
+
range: Range | null;
|
|
229
|
+
nodeId: NodeId | null;
|
|
230
|
+
}
|
|
231
|
+
interface DiagnosticFix {
|
|
232
|
+
description: string;
|
|
233
|
+
/** Whether this fix is automatic or requires user confirmation */
|
|
234
|
+
isAutomatic: boolean;
|
|
235
|
+
/** The operations to apply as a fix */
|
|
236
|
+
operations: FixOperation[];
|
|
237
|
+
}
|
|
238
|
+
type FixOperation = {
|
|
239
|
+
kind: 'replace_text';
|
|
240
|
+
range: Range;
|
|
241
|
+
newText: string;
|
|
242
|
+
} | {
|
|
243
|
+
kind: 'insert_text';
|
|
244
|
+
position: number;
|
|
245
|
+
text: string;
|
|
246
|
+
} | {
|
|
247
|
+
kind: 'delete_range';
|
|
248
|
+
range: Range;
|
|
249
|
+
} | {
|
|
250
|
+
kind: 'wrap_in_tag';
|
|
251
|
+
tagName: string;
|
|
252
|
+
range: Range;
|
|
253
|
+
};
|
|
254
|
+
interface DiagnosticCollection {
|
|
255
|
+
/** All diagnostics */
|
|
256
|
+
items: Diagnostic[];
|
|
257
|
+
/** Count by severity */
|
|
258
|
+
errorCount: number;
|
|
259
|
+
warningCount: number;
|
|
260
|
+
infoCount: number;
|
|
261
|
+
hintCount: number;
|
|
262
|
+
/** Quick access: only errors */
|
|
263
|
+
errors: Diagnostic[];
|
|
264
|
+
/** Quick access: only warnings */
|
|
265
|
+
warnings: Diagnostic[];
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* DocumentEngine — Core Types
|
|
270
|
+
*
|
|
271
|
+
* The foundational types for the entire Language Platform.
|
|
272
|
+
* Every layer builds on these.
|
|
273
|
+
*/
|
|
274
|
+
|
|
275
|
+
/** Stable identifier that survives incremental re-parses */
|
|
276
|
+
type NodeId = string & {
|
|
277
|
+
readonly __brand: 'NodeId';
|
|
278
|
+
};
|
|
279
|
+
/**
|
|
280
|
+
* The semantic kind of a node in the document tree.
|
|
281
|
+
*
|
|
282
|
+
* Language-agnostic at the core — BBCode tags, Markdown headings,
|
|
283
|
+
* HTML elements, etc. all map to these.
|
|
284
|
+
*/
|
|
285
|
+
type NodeKind = 'document' | 'paragraph' | 'text' | 'bold' | 'italic' | 'underline' | 'strikethrough' | 'color' | 'font_size' | 'font' | 'code' | 'inline_code' | 'spoiler' | 'heading' | 'center' | 'right' | 'left' | 'url' | 'email' | 'profile' | 'image' | 'video' | 'audio' | 'imagemap' | 'imagemap_area' | 'svg' | 'quote' | 'notice' | 'spoilerbox' | 'box' | 'boxw' | 'list' | 'list_item' | 'zalgo' | 'aesthetic' | 'sparkle' | 'bubble' | 'flower' | 'gradient' | 'grow' | 'sinewave' | 'rainbow' | 'spacing' | 'empty_line' | 'group' | 'wnotice' | 'align' | 'tables' | 'table_row' | 'table_col' | 'table_th' | 'gallery' | 'columns' | 'separator' | 'scroll' | 'sup' | 'sub' | 'abbr' | 'mark' | 'kbd' | 'tooltip' | 'flip' | 'raw' | 'plain' | 'effect' | 'anim' | 'container' | 'style_tag' | 'guild' | 'map' | 'custom' | 'error' | 'unknown';
|
|
286
|
+
type NodeAttributes = Record<string, string | number | boolean | null | undefined>;
|
|
287
|
+
interface NodeMetadata {
|
|
288
|
+
/** Arbitrary extensions data (plugins can add here) */
|
|
289
|
+
[key: string]: unknown;
|
|
290
|
+
}
|
|
291
|
+
interface SourceRange {
|
|
292
|
+
start: number;
|
|
293
|
+
end: number;
|
|
294
|
+
}
|
|
295
|
+
/**
|
|
296
|
+
* The primary user-facing node in the Document Model.
|
|
297
|
+
*
|
|
298
|
+
* This is the Red Tree node — mutable, with identity.
|
|
299
|
+
* It wraps/holds reference to the Green Tree node for structural info.
|
|
300
|
+
*/
|
|
301
|
+
interface DocumentNode {
|
|
302
|
+
/** Stable ID that survives incremental re-parses */
|
|
303
|
+
id: NodeId;
|
|
304
|
+
/** Monotonically increasing version number for change tracking */
|
|
305
|
+
version: number;
|
|
306
|
+
/** Semantic kind */
|
|
307
|
+
kind: NodeKind;
|
|
308
|
+
/** Raw text content (for text leaves) */
|
|
309
|
+
text: string;
|
|
310
|
+
/** Tag-level attributes (e.g. color=#ff0000, size=150) */
|
|
311
|
+
attributes: NodeAttributes;
|
|
312
|
+
/** Arbitrary metadata (extensible by plugins) */
|
|
313
|
+
metadata: NodeMetadata;
|
|
314
|
+
/** Child nodes */
|
|
315
|
+
children: DocumentNode[];
|
|
316
|
+
/** Diagnostics attached to this specific node */
|
|
317
|
+
diagnostics: Diagnostic[];
|
|
318
|
+
/** Source position in the original text (for mapping back) */
|
|
319
|
+
sourceRange: SourceRange | null;
|
|
320
|
+
/** Reference back to parent (null for root) */
|
|
321
|
+
parentId: NodeId | null;
|
|
322
|
+
/** Whether this node is "synthetic" (not from source, e.g. auto-generated) */
|
|
323
|
+
isSynthetic: boolean;
|
|
324
|
+
}
|
|
325
|
+
type DocumentChangeKind = 'node_inserted' | 'node_deleted' | 'node_updated' | 'node_moved' | 'attribute_changed' | 'text_changed' | 'source_changed' | 'full_rebuild';
|
|
326
|
+
interface DocumentChangeEvent {
|
|
327
|
+
kind: DocumentChangeKind;
|
|
328
|
+
nodeId: NodeId;
|
|
329
|
+
/** The affected node (after the change) */
|
|
330
|
+
node: DocumentNode | null;
|
|
331
|
+
/** Previous version of the node (before the change) */
|
|
332
|
+
previousNode: DocumentNode | null;
|
|
333
|
+
/** Additional context data */
|
|
334
|
+
metadata?: Record<string, unknown>;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* DocumentEngine — RedNode (Mutable Syntax Tree)
|
|
339
|
+
*
|
|
340
|
+
* The Red Tree wraps GreenNode and adds mutable state:
|
|
341
|
+
* - Parent references
|
|
342
|
+
* - Stable Node IDs
|
|
343
|
+
* - Version tracking
|
|
344
|
+
* - Diagnostic storage
|
|
345
|
+
*
|
|
346
|
+
* Multiple Red Trees can reference the same Green Tree
|
|
347
|
+
* (different views, different versions of edits).
|
|
348
|
+
*
|
|
349
|
+
* Inspired by Roslyn's Red Tree.
|
|
350
|
+
*/
|
|
351
|
+
|
|
352
|
+
declare class RedNode {
|
|
353
|
+
/** The underlying immutable green node */
|
|
354
|
+
readonly green: GreenNode;
|
|
355
|
+
/** Stable identity */
|
|
356
|
+
readonly id: NodeId;
|
|
357
|
+
/** Parent reference (null for root) */
|
|
358
|
+
parent: RedNode | null;
|
|
359
|
+
/** Children as red nodes */
|
|
360
|
+
children: RedNode[];
|
|
361
|
+
/** Version counter for change tracking */
|
|
362
|
+
version: number;
|
|
363
|
+
/** Diagnostics for this node */
|
|
364
|
+
diagnostics: Diagnostic[];
|
|
365
|
+
/** The semantic kind */
|
|
366
|
+
kind: NodeKind;
|
|
367
|
+
/** Additional metadata */
|
|
368
|
+
metadata: NodeMetadata;
|
|
369
|
+
/**
|
|
370
|
+
* Cached position of this node inside `parent.children`.
|
|
371
|
+
*
|
|
372
|
+
* `declare` is load-bearing — see the note on `GreenNode._hashCache`: with
|
|
373
|
+
* `target: ES2022` a plain field declaration emits a `defineProperty` on every
|
|
374
|
+
* construction, and this class is instantiated once per node per parse.
|
|
375
|
+
*
|
|
376
|
+
* The cache is *self-validating*: `index` only trusts it when
|
|
377
|
+
* `parent.children[_idxCache] === this`. That single reference compare is what
|
|
378
|
+
* makes it safe against code that mutates the `children` array directly
|
|
379
|
+
* (`treeTransformers` does `cloned.children = []`) instead of going through the
|
|
380
|
+
* mutation methods — a stale entry is detected and recomputed rather than
|
|
381
|
+
* silently returning a wrong index. `-1` never validates, so it is a safe
|
|
382
|
+
* initial value.
|
|
383
|
+
*/
|
|
384
|
+
private _idxCache;
|
|
385
|
+
/**
|
|
386
|
+
* Absolute start offset in the source.
|
|
387
|
+
*
|
|
388
|
+
* This is the red tree's job now: a green node knows its width, not its
|
|
389
|
+
* place, so that identical structures can be the same object (see the header
|
|
390
|
+
* of `GreenNode.ts`). The offset is accumulated on the way down during
|
|
391
|
+
* construction — one addition per node — and stored, so `range` stays the
|
|
392
|
+
* O(1) property every consumer already assumes it is.
|
|
393
|
+
*
|
|
394
|
+
* Held as the `Range` object itself rather than a number plus a getter that
|
|
395
|
+
* builds one: `range` is read from 148 call sites, and allocating there would
|
|
396
|
+
* trade a parse-time win for a read-time cost on every consumer. This is the
|
|
397
|
+
* same shape green nodes used to hold, so nothing downstream changes.
|
|
398
|
+
*
|
|
399
|
+
* `declare` for the usual reason: no `defineProperty` per construction.
|
|
400
|
+
*/
|
|
401
|
+
private _range;
|
|
402
|
+
/**
|
|
403
|
+
* Pending offset shift for this subtree, applied on first read.
|
|
404
|
+
*
|
|
405
|
+
* `setStart` defers the walk: it records how far the subtree moved instead of
|
|
406
|
+
* adding the delta to every node's `_range` on the spot. The shift is a single
|
|
407
|
+
* integer here, on the adopted subtree ROOT — the node `setStart` was called
|
|
408
|
+
* on. Any offset read (`range`, `innerStart`, `innerEnd`, `findNodeAtOffset`)
|
|
409
|
+
* materializes the nearest pending ancestor's subtree via {@link materialize}.
|
|
410
|
+
*
|
|
411
|
+
* Why this is sound: a mid-document edit displaces every adopted block after
|
|
412
|
+
* it by the same delta, so adoption alone (thousands of `setStart` calls) was
|
|
413
|
+
* walking thousands of nodes per keystroke — measured as the dominant phase
|
|
414
|
+
* of `buildRed` (3.5-5.8 ms on the 547 KB fixture). The BlockPatcher locates
|
|
415
|
+
* the edited window by reference identity and only ever reads the few changed
|
|
416
|
+
* blocks' ranges, so the displaced-but-unchanged subtrees stay pending
|
|
417
|
+
* indefinitely — the walk never happens.
|
|
418
|
+
*
|
|
419
|
+
* Contract for future readers: app-layer interaction code (hover, links,
|
|
420
|
+
* selection ranges) DOES read `range` on user interaction. The first such
|
|
421
|
+
* read after a mid-document edit fires the deferred walk for the displaced
|
|
422
|
+
* tail — a one-time cost off the keystroke path, which is exactly the trade
|
|
423
|
+
* the laziness makes. Do not "optimize" by reading ranges back into the
|
|
424
|
+
* per-keystroke pipeline; the patcher stays churn-based by design.
|
|
425
|
+
*
|
|
426
|
+
* Composition across reparses: a node re-adopted while its ancestor is still
|
|
427
|
+
* pending must end up at the SUM of both deltas. `setStart` recomputes the
|
|
428
|
+
* delta from the untouched base `_range`, which is exactly the pending
|
|
429
|
+
* ancestor's base too, so composing during `materialize` (add the ancestor's
|
|
430
|
+
* delta into a child's pending delta) stays consistent.
|
|
431
|
+
*/
|
|
432
|
+
private _lazyShift;
|
|
433
|
+
constructor(green: GreenNode, options?: {
|
|
434
|
+
id?: NodeId;
|
|
435
|
+
parent?: RedNode | null;
|
|
436
|
+
kind?: NodeKind;
|
|
437
|
+
diagnostics?: Diagnostic[];
|
|
438
|
+
metadata?: NodeMetadata;
|
|
439
|
+
/** Absolute start offset. Defaults to the parent's inner offset. */
|
|
440
|
+
start?: number;
|
|
441
|
+
});
|
|
442
|
+
/**
|
|
443
|
+
* Move this node (and its subtree) to a new absolute offset.
|
|
444
|
+
*
|
|
445
|
+
* Only for the tree-building paths, which create a node before they know
|
|
446
|
+
* where its parent will put it. Callers that mutate a live tree should
|
|
447
|
+
* rebuild instead — a red node's offset must always agree with its place.
|
|
448
|
+
*
|
|
449
|
+
* The move is deferred, not walked: the delta is recorded as a pending
|
|
450
|
+
* shift and applied by any offset read the first time the subtree is
|
|
451
|
+
* actually touched. Adoption calls this once per displaced block (thousands
|
|
452
|
+
* on a mid-document edit), and almost none of those subtrees are read on the
|
|
453
|
+
* same keystroke's hot path — the BlockPatcher locates the edit window by
|
|
454
|
+
* reference identity and only reads the few changed blocks.
|
|
455
|
+
*
|
|
456
|
+
* Re-adoption composes: a node whose subtree was shifted in a previous
|
|
457
|
+
* reparse and never read still carries a pending `_lazyShift`. The new
|
|
458
|
+
* target is absolute, so the delta is measured from the CURRENT effective
|
|
459
|
+
* start (`_range.start + _lazyShift`) and accumulated, not overwritten —
|
|
460
|
+
* otherwise the earlier shift would be applied twice.
|
|
461
|
+
*/
|
|
462
|
+
setStart(start: number): void;
|
|
463
|
+
/**
|
|
464
|
+
* Apply `delta` to `node`'s subtree, composing with any nested pending shift.
|
|
465
|
+
*
|
|
466
|
+
* A node with its own pending shift has a base `_range` the parent's delta is
|
|
467
|
+
* relative to as well (both were computed from the same pre-shift tree), so
|
|
468
|
+
* the parent's delta can be folded into the child's pending delta instead of
|
|
469
|
+
* into its `_range` — the child's later materialization applies the sum. This
|
|
470
|
+
* is what makes nested shifts across reparses compose without double counting.
|
|
471
|
+
*/
|
|
472
|
+
private static applyShift;
|
|
473
|
+
/**
|
|
474
|
+
* Materialize every pending shift on the path from here to the root.
|
|
475
|
+
*
|
|
476
|
+
* Walks ancestors top-down (root-most first), applying each pending delta to
|
|
477
|
+
* its subtree. Top-down order matters: an ancestor's delta must land in a
|
|
478
|
+
* child's pending delta BEFORE the child's own materialization runs, or the
|
|
479
|
+
* two shifts would be applied to different bases. After the ancestors are
|
|
480
|
+
* settled, this node itself is materialized if it still carries a shift.
|
|
481
|
+
*
|
|
482
|
+
* The common case — nothing pending anywhere on the path — is one upward
|
|
483
|
+
* pointer walk that allocates nothing: `_range` is read from 148 call sites,
|
|
484
|
+
* and a per-read allocation there would show up in every phase. The chain
|
|
485
|
+
* array is only built after a pending shift is actually found.
|
|
486
|
+
*/
|
|
487
|
+
private materialize;
|
|
488
|
+
private static _isMutating;
|
|
489
|
+
/**
|
|
490
|
+
* Run `fn` inside a mutation boundary, so the structural mutators below are
|
|
491
|
+
* allowed to run. Nests safely; the previous state is restored on exit.
|
|
492
|
+
*
|
|
493
|
+
* Not needed for building a fresh subtree — see {@link initChildren}.
|
|
494
|
+
*/
|
|
495
|
+
static allowMutation<T>(fn: () => T): T;
|
|
496
|
+
private assertMutating;
|
|
497
|
+
get text(): string;
|
|
498
|
+
/** Absolute span in the source. */
|
|
499
|
+
get range(): Range;
|
|
500
|
+
/** Absolute offset of this node's first child, past its opening delimiter. */
|
|
501
|
+
get innerStart(): number;
|
|
502
|
+
/** Absolute offset where this node's closing delimiter begins. */
|
|
503
|
+
get innerEnd(): number;
|
|
504
|
+
get isLeaf(): boolean;
|
|
505
|
+
get childCount(): number;
|
|
506
|
+
childAt(index: number): RedNode | undefined;
|
|
507
|
+
/** Get the root of the tree */
|
|
508
|
+
get root(): RedNode;
|
|
509
|
+
/** Get the depth from root */
|
|
510
|
+
get depth(): number;
|
|
511
|
+
/** Get previous sibling */
|
|
512
|
+
get previousSibling(): RedNode | null;
|
|
513
|
+
/** Get next sibling */
|
|
514
|
+
get nextSibling(): RedNode | null;
|
|
515
|
+
/**
|
|
516
|
+
* Position of this node within its parent, or -1 for a root.
|
|
517
|
+
*
|
|
518
|
+
* O(1) in the steady state. The cached index is seeded by the mutation methods
|
|
519
|
+
* (so the construction path never searches at all) and verified by a single
|
|
520
|
+
* reference compare on read, falling back to `indexOf` only when it is stale.
|
|
521
|
+
*
|
|
522
|
+
* This getter is hot: `HTMLRenderer.isPrevBlockBoundary` walks siblings
|
|
523
|
+
* backwards, so an O(n) implementation here made rendering quadratic —
|
|
524
|
+
* measured 14.11 ms for 4000 root siblings, growing 2.6x per doubling.
|
|
525
|
+
*/
|
|
526
|
+
get index(): number;
|
|
527
|
+
/** Walk all descendants in pre-order */
|
|
528
|
+
walk(visitor: (node: RedNode, depth: number) => void | 'skip', depth?: number): void;
|
|
529
|
+
/** Find a descendant by ID */
|
|
530
|
+
findById(id: NodeId): RedNode | null;
|
|
531
|
+
/**
|
|
532
|
+
* Find the deepest RedNode containing the given text offset.
|
|
533
|
+
*
|
|
534
|
+
* The range is half-open — a node owns `[start, end)` — y eso no es un
|
|
535
|
+
* detalle: con el final inclusivo, un offset que cae justo en una frontera
|
|
536
|
+
* pertenecía a DOS nodos, al que termina ahí y al que empieza. Como los
|
|
537
|
+
* hijos se recorren en orden, ganaba el que termina, así que preguntar por
|
|
538
|
+
* el principio de un nodo devolvía **el nodo anterior**.
|
|
539
|
+
*
|
|
540
|
+
* Se veía en el preview: en un degradado, cada carácter es su propio nodo
|
|
541
|
+
* de color, y hacer clic en la `m` de «Welcome» resaltaba la `o`. Monaco
|
|
542
|
+
* seleccionaba bien —usa el rango del nodo, no esta búsqueda—, así que el
|
|
543
|
+
* desfase era solo del resaltado, que es justo lo que hacía difícil verlo.
|
|
544
|
+
*
|
|
545
|
+
* La única excepción es el final del documento: un cursor aparcado tras el
|
|
546
|
+
* último carácter no tiene carácter que lo contenga, y la raíz lo reclama
|
|
547
|
+
* para que quien pregunte reciba algo con sentido.
|
|
548
|
+
*/
|
|
549
|
+
findNodeAtOffset(offset: number): RedNode | null;
|
|
550
|
+
/**
|
|
551
|
+
* Adopt a fully-built array of children in one shot.
|
|
552
|
+
*
|
|
553
|
+
* Construction-only, and deliberately outside the mutation lock. The tree
|
|
554
|
+
* builders used to call `allowMutation(() => { for (…) appendChild(…) })` once
|
|
555
|
+
* **per node**, which on a 1736-node document meant 1736 closures, 1736
|
|
556
|
+
* `try/finally` frames and 1736 static-flag saves — pure ceremony, since a
|
|
557
|
+
* node that no caller has seen yet cannot be observed mid-mutation. This
|
|
558
|
+
* populates `children`, `parent` and the index cache in a single pass instead.
|
|
559
|
+
*
|
|
560
|
+
* `version` intentionally stays at 1: a freshly built node has not been
|
|
561
|
+
* *edited*, and bumping once per child would make the initial version an
|
|
562
|
+
* accidental child count.
|
|
563
|
+
*
|
|
564
|
+
* Only safe while `this` is still unreachable from the rest of the tree. Use
|
|
565
|
+
* `appendChild` and friends for anything after that.
|
|
566
|
+
*/
|
|
567
|
+
initChildren(children: RedNode[]): void;
|
|
568
|
+
/**
|
|
569
|
+
* Append a child node.
|
|
570
|
+
* This creates a new RedNode wrapping the green node.
|
|
571
|
+
*/
|
|
572
|
+
appendChild(child: RedNode): void;
|
|
573
|
+
/**
|
|
574
|
+
* Refresh the cached index of every child from `from` onwards.
|
|
575
|
+
*
|
|
576
|
+
* Called after a splice, which shifts the tail. This is O(n) but so is the
|
|
577
|
+
* splice it follows, so it costs nothing asymptotically and keeps reads O(1).
|
|
578
|
+
*/
|
|
579
|
+
private reindexFrom;
|
|
580
|
+
/**
|
|
581
|
+
* Insert a child at a specific index.
|
|
582
|
+
*/
|
|
583
|
+
insertChildAt(index: number, child: RedNode): void;
|
|
584
|
+
/**
|
|
585
|
+
* Remove a child by ID.
|
|
586
|
+
*/
|
|
587
|
+
removeChild(id: NodeId): RedNode | null;
|
|
588
|
+
/**
|
|
589
|
+
* Replace a child with a new one by ID.
|
|
590
|
+
*/
|
|
591
|
+
replaceChild(id: NodeId, newChild: RedNode): boolean;
|
|
592
|
+
/**
|
|
593
|
+
* Bump version (when attributes/metadata change without structural change).
|
|
594
|
+
*/
|
|
595
|
+
bumpVersion(): void;
|
|
596
|
+
/** Convert to the public DocumentNode interface */
|
|
597
|
+
toDocumentNode(): DocumentNode;
|
|
598
|
+
/** Debug string */
|
|
599
|
+
toString(depth?: number): string;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
/**
|
|
603
|
+
* DocumentEngine — NodeMatcher
|
|
604
|
+
*
|
|
605
|
+
* Matches nodes between old and new syntax trees after edits.
|
|
606
|
+
* Uses stable IDs, fingerprints, and source ranges to determine
|
|
607
|
+
* which nodes were moved, inserted, deleted, or unchanged.
|
|
608
|
+
*
|
|
609
|
+
* Critical for:
|
|
610
|
+
* - Maintaining selection across re-parses
|
|
611
|
+
* - Preserving scroll position
|
|
612
|
+
* - Smooth animations in visual blocks mode
|
|
613
|
+
* - Undo/redo integrity
|
|
614
|
+
*/
|
|
615
|
+
|
|
616
|
+
type MatchStatus = 'exact' | 'moved' | 'updated' | 'inserted' | 'deleted' | 'merged' | 'split';
|
|
617
|
+
interface MatchResult {
|
|
618
|
+
oldNode: RedNode | null;
|
|
619
|
+
newNode: RedNode | null;
|
|
620
|
+
status: MatchStatus;
|
|
621
|
+
confidence: number;
|
|
622
|
+
}
|
|
623
|
+
interface NodeMatch {
|
|
624
|
+
/** Map from old node ID to new node */
|
|
625
|
+
oldToNew: Map<NodeId, RedNode>;
|
|
626
|
+
/** Map from new node ID to old node */
|
|
627
|
+
newToOld: Map<NodeId, RedNode>;
|
|
628
|
+
/** All matches */
|
|
629
|
+
matches: MatchResult[];
|
|
630
|
+
/** Statistics */
|
|
631
|
+
stats: {
|
|
632
|
+
exact: number;
|
|
633
|
+
moved: number;
|
|
634
|
+
updated: number;
|
|
635
|
+
inserted: number;
|
|
636
|
+
deleted: number;
|
|
637
|
+
};
|
|
638
|
+
}
|
|
639
|
+
declare class NodeMatcher {
|
|
640
|
+
/**
|
|
641
|
+
* Match nodes between old and new trees.
|
|
642
|
+
*
|
|
643
|
+
* Strategy (in order):
|
|
644
|
+
* 1. Match by stable ID (if present)
|
|
645
|
+
* 2. Match by fingerprint (content hash)
|
|
646
|
+
* 3. Match by source range overlap
|
|
647
|
+
* 4. Fall back to positional heuristic
|
|
648
|
+
*/
|
|
649
|
+
match(oldRoot: RedNode, newRoot: RedNode): NodeMatch;
|
|
650
|
+
/**
|
|
651
|
+
* Phase 0 — descend in lockstep, pairing every subtree that did not change.
|
|
652
|
+
*
|
|
653
|
+
* At each level the common prefix and common suffix are trimmed by comparing
|
|
654
|
+
* `green._hash`, and only the window between them is descended into. That is
|
|
655
|
+
* what makes this robust to an edit that changes the number of children:
|
|
656
|
+
* requiring equal child counts before recursing sounds reasonable but means a
|
|
657
|
+
* single inserted node near the root disables the fast path for the whole
|
|
658
|
+
* tree — measured, it left `match` at 5.11 ms instead of 0.96 ms.
|
|
659
|
+
*
|
|
660
|
+
* Whatever is left in the middle window is handed to the fingerprint
|
|
661
|
+
* heuristic, which is the part that can cope with reordering.
|
|
662
|
+
*/
|
|
663
|
+
private pairUnchanged;
|
|
664
|
+
/**
|
|
665
|
+
* Compute a content fingerprint for a node's subtree.
|
|
666
|
+
*
|
|
667
|
+
* Two nodes share a fingerprint iff their subtrees have the same pre-order
|
|
668
|
+
* sequence of (kind, text) pairs — the same equivalence the previous
|
|
669
|
+
* string-concatenating implementation tested, but as a 64-bit structural
|
|
670
|
+
* hash instead of an O(subtree) string built on every comparison.
|
|
671
|
+
*/
|
|
672
|
+
fingerprint(node: RedNode): string;
|
|
673
|
+
/** Memoized, bottom-up fingerprint. O(n) across a whole tree. */
|
|
674
|
+
private fingerprintInto;
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
/**
|
|
678
|
+
* DocumentEngine — ChangeTracker
|
|
679
|
+
*
|
|
680
|
+
* Tracks text changes made to the document source.
|
|
681
|
+
* Used by IncrementalParser to determine what needs to be re-parsed.
|
|
682
|
+
*/
|
|
683
|
+
interface TextChange {
|
|
684
|
+
start: number;
|
|
685
|
+
end: number;
|
|
686
|
+
text: string;
|
|
687
|
+
}
|
|
688
|
+
/**
|
|
689
|
+
* Source range of a text edit, in BOTH coordinate systems.
|
|
690
|
+
*
|
|
691
|
+
* An edit sits between two documents: `start`/`end` describe the changed
|
|
692
|
+
* region in NEW-source coordinates (the current document), while `endOld` is
|
|
693
|
+
* the end of the replaced region in OLD-source coordinates. The incremental
|
|
694
|
+
* preview (`BlockPatcher`) uses `start`/`endOld` to locate the affected blocks
|
|
695
|
+
* in the PREVIOUS tree (whose offsets are pre-edit) and `start`/`end` in the
|
|
696
|
+
* new one — insertions or deletions between the two make the naive single-`end`
|
|
697
|
+
* wrong, which is exactly why both ends are kept.
|
|
698
|
+
*/
|
|
699
|
+
interface TextChangeRange {
|
|
700
|
+
/** Start of the edited region (identical in both coordinate systems). */
|
|
701
|
+
start: number;
|
|
702
|
+
/** End of the edited region, in new-source coordinates. */
|
|
703
|
+
end: number;
|
|
704
|
+
/** End of the replaced region, in old-source coordinates. */
|
|
705
|
+
endOld: number;
|
|
706
|
+
}
|
|
707
|
+
interface TextChangeStats {
|
|
708
|
+
totalChanges: number;
|
|
709
|
+
totalInserted: number;
|
|
710
|
+
totalDeleted: number;
|
|
711
|
+
lastChange: TextChange | null;
|
|
712
|
+
}
|
|
713
|
+
declare class ChangeTracker {
|
|
714
|
+
private changes;
|
|
715
|
+
private maxHistory;
|
|
716
|
+
constructor(maxHistory?: number);
|
|
717
|
+
/**
|
|
718
|
+
* Track a text change.
|
|
719
|
+
*/
|
|
720
|
+
track(change: TextChange): void;
|
|
721
|
+
/**
|
|
722
|
+
* Get all tracked changes.
|
|
723
|
+
*/
|
|
724
|
+
getAll(): TextChange[];
|
|
725
|
+
/**
|
|
726
|
+
* Get the most recent change.
|
|
727
|
+
*/
|
|
728
|
+
getLast(): TextChange | null;
|
|
729
|
+
/**
|
|
730
|
+
* Get the affected range for the last N changes.
|
|
731
|
+
* Returns null if no changes have been tracked.
|
|
732
|
+
*/
|
|
733
|
+
getAffectedRange(count?: number): {
|
|
734
|
+
start: number;
|
|
735
|
+
end: number;
|
|
736
|
+
} | null;
|
|
737
|
+
/**
|
|
738
|
+
* Clear change history.
|
|
739
|
+
*/
|
|
740
|
+
clear(): void;
|
|
741
|
+
/**
|
|
742
|
+
* Get statistics about tracked changes.
|
|
743
|
+
*/
|
|
744
|
+
getStats(): TextChangeStats;
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
/**
|
|
748
|
+
* DocumentEngine — IncrementalParser
|
|
749
|
+
*
|
|
750
|
+
* Re-parses the smallest region a text change can have affected, instead of
|
|
751
|
+
* the whole document.
|
|
752
|
+
*
|
|
753
|
+
* ─── Why this was rewritten (roadmap S5 / point 9) ──────────────────────────
|
|
754
|
+
*
|
|
755
|
+
* The previous implementation spliced RED nodes in place and never touched
|
|
756
|
+
* ranges. Three consequences, all measured:
|
|
757
|
+
*
|
|
758
|
+
* - The re-parsed subtree came from `newSource.slice(start, end)`, so its
|
|
759
|
+
* ranges were based at 0 and were grafted in without rebasing. Every offset
|
|
760
|
+
* inside the edited region was wrong by `start`.
|
|
761
|
+
* - Siblings after the splice and every ancestor kept their old ranges, so
|
|
762
|
+
* the tree silently disagreed with the text it claimed to describe.
|
|
763
|
+
* - It returned `newRootRed.green` — the OLD green root — so the model's
|
|
764
|
+
* `_greenRoot` and `_redRoot` desynchronised permanently after the first
|
|
765
|
+
* incremental edit.
|
|
766
|
+
*
|
|
767
|
+
* Rendering the result and comparing it against a full rebuild of the same
|
|
768
|
+
* final text: 3 of 6 realistic editing scenarios produced DIFFERENT HTML, and
|
|
769
|
+
* one grew 14 phantom nodes (1752 vs 1738).
|
|
770
|
+
*
|
|
771
|
+
* ─── How it works now ───────────────────────────────────────────────────────
|
|
772
|
+
*
|
|
773
|
+
* Everything happens on the GREEN tree, and the red tree is derived from it.
|
|
774
|
+
* That is not a stylistic choice: green nodes carry widths and no position, so
|
|
775
|
+
* a position is something only a red node has — and therefore only a red node
|
|
776
|
+
* can be wrong about.
|
|
777
|
+
*
|
|
778
|
+
* 1. Descend to the deepest node whose inner span (the part between its
|
|
779
|
+
* delimiters) contains the change, refusing to enter the kinds whose
|
|
780
|
+
* children depend on context outside them (see `OPAQUE_KINDS`).
|
|
781
|
+
* 2. Take the run of that node's children the change touches, widened by one
|
|
782
|
+
* on each side, and re-parse just those.
|
|
783
|
+
* 3. Splice the result back over that run and rebuild the ancestor spine,
|
|
784
|
+
* sharing every untouched subtree by reference.
|
|
785
|
+
*
|
|
786
|
+
* The unit is a RUN OF SIBLINGS, not a whole node. Re-parsing a container's
|
|
787
|
+
* entire contents because one character changed inside it meant typing into a
|
|
788
|
+
* 13 KB `[notice]` re-lexed 66% of the document per keystroke — and an edit at
|
|
789
|
+
* document level had no enclosing container at all, so it fell back to a full
|
|
790
|
+
* rebuild, which is exactly where the caret sits while you write the end of a
|
|
791
|
+
* post.
|
|
792
|
+
*
|
|
793
|
+
* Step 1 relies on the partition invariant from point 14: without it, "the
|
|
794
|
+
* part between the delimiters" is not a well-defined range, which is exactly
|
|
795
|
+
* why this repair was blocked on that work.
|
|
796
|
+
*
|
|
797
|
+
* When any precondition fails the parser returns a full rebuild rather than a
|
|
798
|
+
* plausible-looking wrong tree. `path` says which happened, `reason` says why.
|
|
799
|
+
*/
|
|
800
|
+
|
|
801
|
+
interface EditOperation {
|
|
802
|
+
kind: 'insert' | 'delete' | 'replace';
|
|
803
|
+
start: number;
|
|
804
|
+
end: number;
|
|
805
|
+
text: string;
|
|
806
|
+
/** The minimal range that needs re-parsing */
|
|
807
|
+
affectedStart: number;
|
|
808
|
+
affectedEnd: number;
|
|
809
|
+
}
|
|
810
|
+
/** Options a caller's parse callback must understand. */
|
|
811
|
+
interface ReparseParseOptions {
|
|
812
|
+
/**
|
|
813
|
+
* Whether the text being parsed is document-level content. Inner spans of
|
|
814
|
+
* containers are not, and must not be grouped into paragraphs.
|
|
815
|
+
*/
|
|
816
|
+
normalizeParagraphs: boolean;
|
|
817
|
+
}
|
|
818
|
+
interface ReparseResult {
|
|
819
|
+
green: GreenNode;
|
|
820
|
+
red: RedNode;
|
|
821
|
+
/** Nodes that were affected by the change */
|
|
822
|
+
affectedNodes: RedNode[];
|
|
823
|
+
/** Time taken in ms (total) */
|
|
824
|
+
duration: number;
|
|
825
|
+
/** Per-phase timing breakdown in ms */
|
|
826
|
+
timings: {
|
|
827
|
+
findAffected: number;
|
|
828
|
+
safeBoundary: number;
|
|
829
|
+
parse: number;
|
|
830
|
+
buildRed: number;
|
|
831
|
+
mutate: number;
|
|
832
|
+
other: number;
|
|
833
|
+
};
|
|
834
|
+
/** Which path was used */
|
|
835
|
+
path: 'incremental' | 'full_rebuild';
|
|
836
|
+
/** When `full_rebuild`, why the incremental path was declined. */
|
|
837
|
+
reason?: FallbackReason;
|
|
838
|
+
}
|
|
839
|
+
type FallbackReason =
|
|
840
|
+
/** No sibling window could be formed around the change. */
|
|
841
|
+
'no-window'
|
|
842
|
+
/** The change touches a container's own delimiter. */
|
|
843
|
+
| 'touches-delimiter'
|
|
844
|
+
/** The region cannot be lexed in isolation — see `regionIsSelfContained`. */
|
|
845
|
+
| 'region-not-isolated'
|
|
846
|
+
/** An unclosed `[` before the region could claim a `]` the edit creates. */
|
|
847
|
+
| 'open-bracket-before'
|
|
848
|
+
/** The container covers so much of the document that a rebuild is cheaper. */
|
|
849
|
+
| 'region-too-large'
|
|
850
|
+
/** The document is small enough that rebuilding it outright costs less. */
|
|
851
|
+
| 'document-too-small'
|
|
852
|
+
/** The tree's ranges disagree with the source it is supposed to describe. */
|
|
853
|
+
| 'stale-ranges';
|
|
854
|
+
interface IncrementalParserOptions {
|
|
855
|
+
/** Override `MIN_SOURCE_LENGTH`. Set to 0 to always attempt a splice. */
|
|
856
|
+
minSourceLength?: number;
|
|
857
|
+
/** Override `MAX_REGION_FRACTION`. */
|
|
858
|
+
maxRegionFraction?: number;
|
|
859
|
+
}
|
|
860
|
+
declare class IncrementalParser {
|
|
861
|
+
private readonly minSourceLength;
|
|
862
|
+
private readonly maxRegionFraction;
|
|
863
|
+
/**
|
|
864
|
+
* The thresholds are constructor options because they are performance
|
|
865
|
+
* tuning, not semantics: the tree that comes out is the same either way, so
|
|
866
|
+
* a caller with a different document profile — or a test that wants to
|
|
867
|
+
* exercise the splice on a two-line document — can move them without
|
|
868
|
+
* changing what the parser means.
|
|
869
|
+
*/
|
|
870
|
+
constructor(options?: IncrementalParserOptions);
|
|
871
|
+
/**
|
|
872
|
+
* Reparse a tree after a text change.
|
|
873
|
+
*
|
|
874
|
+
* Always returns a result — either an incremental splice or a full rebuild.
|
|
875
|
+
* It never returns a tree whose ranges do not describe `newSource`.
|
|
876
|
+
*/
|
|
877
|
+
reparse(oldRed: RedNode, oldGreen: GreenNode, change: TextChange, newSource: string, parseCallback: (text: string, options?: ReparseParseOptions) => GreenNode, buildRedCallback: (green: GreenNode) => RedNode): ReparseResult;
|
|
878
|
+
/**
|
|
879
|
+
* Find the run of sibling children a change can have affected.
|
|
880
|
+
*
|
|
881
|
+
* Two steps. First descend to the deepest node whose INNER span contains the
|
|
882
|
+
* change — inner rather than full, so a container's own delimiters never go
|
|
883
|
+
* back through the parser: an edit that touches `[colo|r=red]` changes what
|
|
884
|
+
* that element IS, and is handled by re-parsing it as part of its parent's
|
|
885
|
+
* window instead.
|
|
886
|
+
*
|
|
887
|
+
* Then pick the children that the change touches, widened by one on each
|
|
888
|
+
* side. The widening is what lets a deletion MERGE two nodes: removing the
|
|
889
|
+
* blank line between two paragraphs changes only the node in between, and
|
|
890
|
+
* without a neighbour on each side the re-parse could not see that the two
|
|
891
|
+
* survivors have to become one.
|
|
892
|
+
*/
|
|
893
|
+
private findReparseWindow;
|
|
894
|
+
/**
|
|
895
|
+
* Nodes on the path from the root down to the change.
|
|
896
|
+
*
|
|
897
|
+
* Kept because it is part of the public surface and is genuinely useful for
|
|
898
|
+
* callers that want to know what an edit touched; the reparse itself no
|
|
899
|
+
* longer needs it.
|
|
900
|
+
*/
|
|
901
|
+
findAffectedNodes(root: RedNode, change: TextChange): RedNode[];
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
/**
|
|
905
|
+
* DocumentEngine — SemanticAnalyzer
|
|
906
|
+
*
|
|
907
|
+
* Walks the Red Tree and produces diagnostics by applying
|
|
908
|
+
* semantic rules to the syntax tree.
|
|
909
|
+
*
|
|
910
|
+
* This is where language-specific validation happens.
|
|
911
|
+
* The analyzer is extensible via registered validators.
|
|
912
|
+
*
|
|
913
|
+
* Inspired by Roslyn's Semantic Model and LSP diagnostics.
|
|
914
|
+
*/
|
|
915
|
+
|
|
916
|
+
interface Validator {
|
|
917
|
+
/** Unique code for this validator (e.g. 'invalid-color') */
|
|
918
|
+
code: string;
|
|
919
|
+
/** Severity of issues found by this validator */
|
|
920
|
+
severity: DiagnosticSeverity;
|
|
921
|
+
/**
|
|
922
|
+
* Node kinds this validator can ever fire on. Omit to run on every node.
|
|
923
|
+
*
|
|
924
|
+
* Three of the five built-ins open with nothing but a kind test, so on a
|
|
925
|
+
* document of 1736 nodes they were called 1736 times each to answer a
|
|
926
|
+
* question the dispatcher can answer once. Declaring the kinds turns the
|
|
927
|
+
* call into a lookup that never happens.
|
|
928
|
+
*/
|
|
929
|
+
kinds?: readonly string[];
|
|
930
|
+
/** Validate a node. Return diagnostics or null */
|
|
931
|
+
validate(node: RedNode, context: AnalyzerContext): Diagnostic | Diagnostic[] | null;
|
|
932
|
+
}
|
|
933
|
+
interface AnalyzerContext {
|
|
934
|
+
/**
|
|
935
|
+
* Every node in the tree, by id, for cross-reference validation.
|
|
936
|
+
*
|
|
937
|
+
* A getter, and deliberately: building this Map cost a second full walk of
|
|
938
|
+
* the tree plus 1736 `Map.set` calls — 23% of `analyze()` — and **no
|
|
939
|
+
* validator has ever read it**. It was built so that the caller could look up
|
|
940
|
+
* by id the node to attach each diagnostic to, which is the node the
|
|
941
|
+
* validator was looking at when it produced it. Validators that genuinely
|
|
942
|
+
* need cross-references still get it; everyone else stops paying for it.
|
|
943
|
+
*/
|
|
944
|
+
readonly allNodes: Map<string, RedNode>;
|
|
945
|
+
/** Previously collected diagnostics */
|
|
946
|
+
diagnostics: DiagnosticCollection;
|
|
947
|
+
/** Source text for position lookups */
|
|
948
|
+
source: string;
|
|
949
|
+
}
|
|
950
|
+
interface AnalyzeResult {
|
|
951
|
+
diagnostics: DiagnosticCollection;
|
|
952
|
+
/** Time taken in ms */
|
|
953
|
+
duration: number;
|
|
954
|
+
/** Number of nodes analyzed */
|
|
955
|
+
nodesAnalyzed: number;
|
|
956
|
+
}
|
|
957
|
+
/**
|
|
958
|
+
* What `analyze()` actually returns: an {@link AnalyzeResult} plus the node
|
|
959
|
+
* index it had to build anyway for cross-reference lookups.
|
|
960
|
+
*
|
|
961
|
+
* Kept as a separate type, and deliberately NOT part of `AnalyzeResult`,
|
|
962
|
+
* because the index holds a strong reference to every node in the tree. A
|
|
963
|
+
* caller that stores an `AnalyzeResult` long-term (as `DocumentModel` does)
|
|
964
|
+
* must not pin an entire stale tree; one that needs the index gets it here and
|
|
965
|
+
* owns that decision explicitly.
|
|
966
|
+
*/
|
|
967
|
+
interface IndexedAnalyzeResult extends AnalyzeResult {
|
|
968
|
+
allNodes: Map<string, RedNode>;
|
|
969
|
+
}
|
|
970
|
+
declare class SemanticAnalyzer {
|
|
971
|
+
private validators;
|
|
972
|
+
/**
|
|
973
|
+
* The same validators, arranged for the walk instead of for lookup.
|
|
974
|
+
*
|
|
975
|
+
* `_always` run on every node; `_byKind` are the ones that declared their
|
|
976
|
+
* kinds. Iterating the Map itself allocated an iterator and a destructuring
|
|
977
|
+
* pair per node — 9% of `analyze()` spent on bookkeeping, not on validating.
|
|
978
|
+
*
|
|
979
|
+
* Rebuilt on register/unregister, which happen once at construction and
|
|
980
|
+
* essentially never afterwards.
|
|
981
|
+
*/
|
|
982
|
+
private _always;
|
|
983
|
+
private _byKind;
|
|
984
|
+
constructor();
|
|
985
|
+
/**
|
|
986
|
+
* Register a validator.
|
|
987
|
+
*/
|
|
988
|
+
register(validator: Validator): void;
|
|
989
|
+
/**
|
|
990
|
+
* Remove a validator.
|
|
991
|
+
*/
|
|
992
|
+
unregister(code: string): void;
|
|
993
|
+
private rebuildDispatch;
|
|
994
|
+
/**
|
|
995
|
+
* Analyze a Red Tree and produce diagnostics.
|
|
996
|
+
*/
|
|
997
|
+
analyze(root: RedNode, source: string): IndexedAnalyzeResult;
|
|
998
|
+
private registerBuiltinValidators;
|
|
999
|
+
/**
|
|
1000
|
+
* Create a validator for a specific tag/kind.
|
|
1001
|
+
* Convenience method for plugin authors.
|
|
1002
|
+
*/
|
|
1003
|
+
createValidator(code: string, severity: DiagnosticSeverity, predicate: (node: RedNode, ctx: AnalyzerContext) => string | null): Validator;
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
/**
|
|
1007
|
+
* DocumentEngine — Operation & Transaction Types
|
|
1008
|
+
*
|
|
1009
|
+
* Operations are the atomic units of change in the document.
|
|
1010
|
+
* Every modification goes through operations, never direct mutation.
|
|
1011
|
+
*
|
|
1012
|
+
* Inspired by ProseMirror and Roslyn.
|
|
1013
|
+
*/
|
|
1014
|
+
|
|
1015
|
+
type OperationKind = 'insert_node' | 'delete_node' | 'replace_node' | 'move_node' | 'update_attributes' | 'set_text' | 'insert_text' | 'delete_text' | 'replace_text' | 'wrap_in_tag' | 'unwrap_node' | 'split_node' | 'merge_nodes';
|
|
1016
|
+
interface BaseOperation {
|
|
1017
|
+
kind: OperationKind;
|
|
1018
|
+
/** Stable ID for the operation (for undo/redo pairing) */
|
|
1019
|
+
id: string;
|
|
1020
|
+
/** Whether this operation can be undone */
|
|
1021
|
+
undoable: boolean;
|
|
1022
|
+
/** Timestamp */
|
|
1023
|
+
timestamp: number;
|
|
1024
|
+
}
|
|
1025
|
+
interface InsertNodeOperation extends BaseOperation {
|
|
1026
|
+
kind: 'insert_node';
|
|
1027
|
+
parentId: NodeId;
|
|
1028
|
+
index: number;
|
|
1029
|
+
node: RedNode;
|
|
1030
|
+
}
|
|
1031
|
+
interface DeleteNodeOperation extends BaseOperation {
|
|
1032
|
+
kind: 'delete_node';
|
|
1033
|
+
nodeId: NodeId;
|
|
1034
|
+
/** The deleted node (stored for undo) */
|
|
1035
|
+
node: RedNode;
|
|
1036
|
+
parentId: NodeId;
|
|
1037
|
+
index: number;
|
|
1038
|
+
}
|
|
1039
|
+
interface ReplaceNodeOperation extends BaseOperation {
|
|
1040
|
+
kind: 'replace_node';
|
|
1041
|
+
nodeId: NodeId;
|
|
1042
|
+
newNode: RedNode;
|
|
1043
|
+
oldNode: RedNode;
|
|
1044
|
+
}
|
|
1045
|
+
interface MoveNodeOperation extends BaseOperation {
|
|
1046
|
+
kind: 'move_node';
|
|
1047
|
+
nodeId: NodeId;
|
|
1048
|
+
fromParentId: NodeId;
|
|
1049
|
+
fromIndex: number;
|
|
1050
|
+
toParentId: NodeId;
|
|
1051
|
+
toIndex: number;
|
|
1052
|
+
}
|
|
1053
|
+
interface UpdateAttributesOperation extends BaseOperation {
|
|
1054
|
+
kind: 'update_attributes';
|
|
1055
|
+
nodeId: NodeId;
|
|
1056
|
+
newAttributes: NodeAttributes;
|
|
1057
|
+
oldAttributes: NodeAttributes;
|
|
1058
|
+
}
|
|
1059
|
+
interface SetTextOperation extends BaseOperation {
|
|
1060
|
+
kind: 'set_text';
|
|
1061
|
+
nodeId: NodeId;
|
|
1062
|
+
newText: string;
|
|
1063
|
+
oldText: string;
|
|
1064
|
+
}
|
|
1065
|
+
interface InsertTextOperation extends BaseOperation {
|
|
1066
|
+
kind: 'insert_text';
|
|
1067
|
+
nodeId: NodeId;
|
|
1068
|
+
position: number;
|
|
1069
|
+
text: string;
|
|
1070
|
+
}
|
|
1071
|
+
interface DeleteTextOperation extends BaseOperation {
|
|
1072
|
+
kind: 'delete_text';
|
|
1073
|
+
nodeId: NodeId;
|
|
1074
|
+
position: number;
|
|
1075
|
+
length: number;
|
|
1076
|
+
text: string;
|
|
1077
|
+
}
|
|
1078
|
+
interface ReplaceTextOperation extends BaseOperation {
|
|
1079
|
+
kind: 'replace_text';
|
|
1080
|
+
nodeId: NodeId;
|
|
1081
|
+
position: number;
|
|
1082
|
+
length: number;
|
|
1083
|
+
newText: string;
|
|
1084
|
+
oldText: string;
|
|
1085
|
+
}
|
|
1086
|
+
interface WrapInTagOperation extends BaseOperation {
|
|
1087
|
+
kind: 'wrap_in_tag';
|
|
1088
|
+
nodeId: NodeId;
|
|
1089
|
+
tagName: string;
|
|
1090
|
+
attributes: NodeAttributes;
|
|
1091
|
+
}
|
|
1092
|
+
interface UnwrapNodeOperation extends BaseOperation {
|
|
1093
|
+
kind: 'unwrap_node';
|
|
1094
|
+
nodeId: NodeId;
|
|
1095
|
+
parentId: NodeId;
|
|
1096
|
+
children: RedNode[];
|
|
1097
|
+
}
|
|
1098
|
+
interface SplitNodeOperation extends BaseOperation {
|
|
1099
|
+
kind: 'split_node';
|
|
1100
|
+
nodeId: NodeId;
|
|
1101
|
+
position: number;
|
|
1102
|
+
leftNode: RedNode;
|
|
1103
|
+
rightNode: RedNode;
|
|
1104
|
+
}
|
|
1105
|
+
interface MergeNodesOperation extends BaseOperation {
|
|
1106
|
+
kind: 'merge_nodes';
|
|
1107
|
+
leftNodeId: NodeId;
|
|
1108
|
+
rightNodeId: NodeId;
|
|
1109
|
+
mergedNode: RedNode;
|
|
1110
|
+
}
|
|
1111
|
+
type Operation = InsertNodeOperation | DeleteNodeOperation | ReplaceNodeOperation | MoveNodeOperation | UpdateAttributesOperation | SetTextOperation | InsertTextOperation | DeleteTextOperation | ReplaceTextOperation | WrapInTagOperation | UnwrapNodeOperation | SplitNodeOperation | MergeNodesOperation;
|
|
1112
|
+
|
|
1113
|
+
/**
|
|
1114
|
+
* DocumentEngine — UndoManager
|
|
1115
|
+
*
|
|
1116
|
+
* Manages the undo/redo history stack.
|
|
1117
|
+
*
|
|
1118
|
+
* Entries are source-text snapshots, not stored tree mutations: the model
|
|
1119
|
+
* rebuilds from text on undo/redo, so an entry stays replayable even after
|
|
1120
|
+
* intervening rebuilds have replaced every node id it could have referenced.
|
|
1121
|
+
* (The previous design stored `Transaction`s, whose `invert()` was only
|
|
1122
|
+
* defined for one of the six operation kinds and whose one-shot `_applied`
|
|
1123
|
+
* flag made redo a silent no-op.)
|
|
1124
|
+
*
|
|
1125
|
+
* Inspired by VSCode's undo stack.
|
|
1126
|
+
*/
|
|
1127
|
+
interface UndoEntry {
|
|
1128
|
+
/** Source text before the change */
|
|
1129
|
+
before: string;
|
|
1130
|
+
/** Source text after the change */
|
|
1131
|
+
after: string;
|
|
1132
|
+
label: string;
|
|
1133
|
+
timestamp: number;
|
|
1134
|
+
}
|
|
1135
|
+
declare class UndoManager {
|
|
1136
|
+
private undoStack;
|
|
1137
|
+
private redoStack;
|
|
1138
|
+
private maxSize;
|
|
1139
|
+
constructor(maxSize?: number);
|
|
1140
|
+
/**
|
|
1141
|
+
* Push a change (as before/after source snapshots) onto the undo stack.
|
|
1142
|
+
*/
|
|
1143
|
+
push(snapshot: {
|
|
1144
|
+
before: string;
|
|
1145
|
+
after: string;
|
|
1146
|
+
}, label?: string): void;
|
|
1147
|
+
/**
|
|
1148
|
+
* Undo the last change.
|
|
1149
|
+
*/
|
|
1150
|
+
undo(): UndoEntry | null;
|
|
1151
|
+
/**
|
|
1152
|
+
* Redo the last undone change.
|
|
1153
|
+
*/
|
|
1154
|
+
redo(): UndoEntry | null;
|
|
1155
|
+
/**
|
|
1156
|
+
* Peek at the top of the undo stack without removing.
|
|
1157
|
+
*/
|
|
1158
|
+
peekUndo(): UndoEntry | null;
|
|
1159
|
+
/**
|
|
1160
|
+
* Peek at the top of the redo stack without removing.
|
|
1161
|
+
*/
|
|
1162
|
+
peekRedo(): UndoEntry | null;
|
|
1163
|
+
/**
|
|
1164
|
+
* Clear both stacks.
|
|
1165
|
+
*/
|
|
1166
|
+
clear(): void;
|
|
1167
|
+
/**
|
|
1168
|
+
* Get the number of undoable actions.
|
|
1169
|
+
*/
|
|
1170
|
+
get undoCount(): number;
|
|
1171
|
+
/**
|
|
1172
|
+
* Get the number of redoable actions.
|
|
1173
|
+
*/
|
|
1174
|
+
get redoCount(): number;
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
/**
|
|
1178
|
+
* DocumentEngine — TreeBuilder
|
|
1179
|
+
*
|
|
1180
|
+
* Converts a TokenStream into a GreenNode tree.
|
|
1181
|
+
* This is the bridge between the Lexer and the Syntax Tree.
|
|
1182
|
+
*
|
|
1183
|
+
* The TreeBuilder is language-agnostic — it uses registry-based
|
|
1184
|
+
* node factories to build the appropriate tree structure.
|
|
1185
|
+
*/
|
|
1186
|
+
|
|
1187
|
+
interface BuildResult {
|
|
1188
|
+
green: GreenNode;
|
|
1189
|
+
red: RedNode;
|
|
1190
|
+
tokens: Token[];
|
|
1191
|
+
}
|
|
1192
|
+
interface NodeFactory$1 {
|
|
1193
|
+
/** Create a GreenNode from a token or sequence of tokens */
|
|
1194
|
+
createGreen(tokens: Token[], stream: TokenStream): GreenNode | null;
|
|
1195
|
+
/** Create a RedNode from a green node */
|
|
1196
|
+
createRed(green: GreenNode, parent?: RedNode | null): RedNode;
|
|
1197
|
+
}
|
|
1198
|
+
declare class TreeBuilder {
|
|
1199
|
+
private factories;
|
|
1200
|
+
private defaultKind;
|
|
1201
|
+
constructor(defaultKind?: NodeKind);
|
|
1202
|
+
/**
|
|
1203
|
+
* Register a node factory for a specific token kind or tag name.
|
|
1204
|
+
*/
|
|
1205
|
+
register(kind: string, factory: NodeFactory$1): void;
|
|
1206
|
+
/**
|
|
1207
|
+
* Build a tree from a token stream.
|
|
1208
|
+
*/
|
|
1209
|
+
build(stream: TokenStream): BuildResult;
|
|
1210
|
+
/**
|
|
1211
|
+
* Build a green tree from a token array.
|
|
1212
|
+
* Uses registered factories to determine node structure.
|
|
1213
|
+
*
|
|
1214
|
+
* By default, creates a flat document structure.
|
|
1215
|
+
* Language-specific builders (like BBCodeParser) override this.
|
|
1216
|
+
*/
|
|
1217
|
+
buildGreen(tokens: Token[]): GreenNode;
|
|
1218
|
+
/**
|
|
1219
|
+
* Build a red tree from a green tree.
|
|
1220
|
+
*/
|
|
1221
|
+
buildRed(green: GreenNode, parent?: RedNode | null, kind?: NodeKind): RedNode;
|
|
1222
|
+
/**
|
|
1223
|
+
* Create a RedNode directly from children (for language-specific parsers).
|
|
1224
|
+
*/
|
|
1225
|
+
createRedFromChildren(kind: NodeKind, text: string, children: RedNode[], parent?: RedNode | null): RedNode;
|
|
1226
|
+
private tagToKind;
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
/**
|
|
1230
|
+
* DocumentEngine — Query Types
|
|
1231
|
+
*
|
|
1232
|
+
* CSS-inspired query selectors for the document tree.
|
|
1233
|
+
* Enables queries like: `paragraph > bold`, `quote text`, `[color]`
|
|
1234
|
+
*/
|
|
1235
|
+
|
|
1236
|
+
type QuerySelector = {
|
|
1237
|
+
type: 'kind';
|
|
1238
|
+
kind: NodeKind;
|
|
1239
|
+
} | {
|
|
1240
|
+
type: 'tag';
|
|
1241
|
+
tag: string;
|
|
1242
|
+
} | {
|
|
1243
|
+
type: 'attribute';
|
|
1244
|
+
name: string;
|
|
1245
|
+
value?: string;
|
|
1246
|
+
} | {
|
|
1247
|
+
type: 'has_attribute';
|
|
1248
|
+
name: string;
|
|
1249
|
+
} | {
|
|
1250
|
+
type: 'text';
|
|
1251
|
+
pattern: string;
|
|
1252
|
+
} | {
|
|
1253
|
+
type: 'has_child';
|
|
1254
|
+
selector: QuerySelector;
|
|
1255
|
+
} | {
|
|
1256
|
+
type: 'has_descendant';
|
|
1257
|
+
selector: QuerySelector;
|
|
1258
|
+
} | {
|
|
1259
|
+
type: 'nth_child';
|
|
1260
|
+
n: number;
|
|
1261
|
+
} | {
|
|
1262
|
+
type: 'first_child';
|
|
1263
|
+
} | {
|
|
1264
|
+
type: 'last_child';
|
|
1265
|
+
} | {
|
|
1266
|
+
type: 'position';
|
|
1267
|
+
min?: number;
|
|
1268
|
+
max?: number;
|
|
1269
|
+
} | {
|
|
1270
|
+
type: 'depth';
|
|
1271
|
+
min?: number;
|
|
1272
|
+
max?: number;
|
|
1273
|
+
};
|
|
1274
|
+
type QueryCombinator = 'child' | 'descendant' | 'adjacent' | 'sibling';
|
|
1275
|
+
interface QueryStep {
|
|
1276
|
+
selector: QuerySelector[];
|
|
1277
|
+
combinator: QueryCombinator | 'root';
|
|
1278
|
+
}
|
|
1279
|
+
interface Query {
|
|
1280
|
+
steps: QueryStep[];
|
|
1281
|
+
}
|
|
1282
|
+
interface QueryMatch {
|
|
1283
|
+
node: RedNode;
|
|
1284
|
+
/** How well this node matches (for sorting results) */
|
|
1285
|
+
score: number;
|
|
1286
|
+
/** The specific selectors that matched */
|
|
1287
|
+
matchedSelectors: string[];
|
|
1288
|
+
}
|
|
1289
|
+
interface QueryResult {
|
|
1290
|
+
matches: QueryMatch[];
|
|
1291
|
+
total: number;
|
|
1292
|
+
time: number;
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
/**
|
|
1296
|
+
* DocumentEngine — QueryEngine
|
|
1297
|
+
*
|
|
1298
|
+
* CSS-inspired query engine for the document tree.
|
|
1299
|
+
* Enables queries like:
|
|
1300
|
+
* query('paragraph > bold')
|
|
1301
|
+
* query('quote text')
|
|
1302
|
+
* query('[color]')
|
|
1303
|
+
* query('list > *')
|
|
1304
|
+
*
|
|
1305
|
+
* Useful for:
|
|
1306
|
+
* - Finding nodes by structure
|
|
1307
|
+
* - AI context gathering
|
|
1308
|
+
* - Plugin discovery
|
|
1309
|
+
* - Lint rule targeting
|
|
1310
|
+
*/
|
|
1311
|
+
|
|
1312
|
+
declare class QueryEngine {
|
|
1313
|
+
/**
|
|
1314
|
+
* Execute a query against a RedNode tree.
|
|
1315
|
+
*/
|
|
1316
|
+
execute(query: Query, root: RedNode): QueryResult;
|
|
1317
|
+
/**
|
|
1318
|
+
* Filter candidates by applying a query step's selectors.
|
|
1319
|
+
*/
|
|
1320
|
+
private matchStep;
|
|
1321
|
+
/**
|
|
1322
|
+
* Check if a single node matches a single selector.
|
|
1323
|
+
*/
|
|
1324
|
+
private matchesSelector;
|
|
1325
|
+
}
|
|
1326
|
+
|
|
1327
|
+
/**
|
|
1328
|
+
* DocumentEngine — EventBus
|
|
1329
|
+
*
|
|
1330
|
+
* Publish/subscribe event system for document changes.
|
|
1331
|
+
* Each DocumentModel has its own EventBus instance.
|
|
1332
|
+
*
|
|
1333
|
+
* Events include:
|
|
1334
|
+
* - document_changed (rebuild, edit, undo/redo)
|
|
1335
|
+
* - node_changed (insert, delete, update, move)
|
|
1336
|
+
* - diagnostic_updated
|
|
1337
|
+
* - transaction_applied
|
|
1338
|
+
* - cursor_moved
|
|
1339
|
+
* - selection_changed
|
|
1340
|
+
*/
|
|
1341
|
+
|
|
1342
|
+
type DocumentEventType = 'document_changed' | 'node_inserted' | 'node_deleted' | 'node_updated' | 'diagnostics_updated' | 'transaction_applied' | 'undo_performed' | 'redo_performed' | 'cursor_moved' | 'selection_changed';
|
|
1343
|
+
interface DocumentEvent {
|
|
1344
|
+
type: DocumentEventType;
|
|
1345
|
+
kind?: DocumentChangeKind;
|
|
1346
|
+
version?: number;
|
|
1347
|
+
source?: string;
|
|
1348
|
+
nodeId?: NodeId;
|
|
1349
|
+
nodeMatch?: NodeMatch | null;
|
|
1350
|
+
change?: TextChange;
|
|
1351
|
+
operations?: Operation[];
|
|
1352
|
+
/**
|
|
1353
|
+
* Where the change came from: `'local'` for user editing, anything else for
|
|
1354
|
+
* programmatic/synced sources. Lets a collaboration layer ignore the echo
|
|
1355
|
+
* of changes it applied itself. Absent on events with no single cause.
|
|
1356
|
+
*/
|
|
1357
|
+
origin?: string;
|
|
1358
|
+
timestamp: number;
|
|
1359
|
+
[key: string]: unknown;
|
|
1360
|
+
}
|
|
1361
|
+
type DocumentEventHandler = (event: DocumentEvent) => void;
|
|
1362
|
+
declare class DocumentEventBus {
|
|
1363
|
+
private listeners;
|
|
1364
|
+
private history;
|
|
1365
|
+
private maxHistory;
|
|
1366
|
+
/**
|
|
1367
|
+
* Record emitted events so `getHistory()` returns them. Off by default:
|
|
1368
|
+
* nothing reads the history today, and a retained event pins its `source`
|
|
1369
|
+
* string — and, through the lazy `nodeMatch` accessor, the entire previous
|
|
1370
|
+
* red/green tree — so an always-on history held megabytes per document
|
|
1371
|
+
* for nobody. Turn it on for debugging or replay tooling.
|
|
1372
|
+
*/
|
|
1373
|
+
recordHistory: boolean;
|
|
1374
|
+
/**
|
|
1375
|
+
* Subscribe to an event type.
|
|
1376
|
+
*/
|
|
1377
|
+
on(type: DocumentEventType, handler: DocumentEventHandler): () => void;
|
|
1378
|
+
/**
|
|
1379
|
+
* Whether any handler is subscribed to `type` (or to anything, if omitted).
|
|
1380
|
+
*
|
|
1381
|
+
* Emitters use this to skip building event payloads nobody will see — the
|
|
1382
|
+
* common case, since a headless model has no subscribers at all.
|
|
1383
|
+
*/
|
|
1384
|
+
hasListeners(type?: DocumentEventType): boolean;
|
|
1385
|
+
/**
|
|
1386
|
+
* Subscribe to all events.
|
|
1387
|
+
*/
|
|
1388
|
+
onAny(handler: DocumentEventHandler): () => void;
|
|
1389
|
+
/**
|
|
1390
|
+
* Emit an event.
|
|
1391
|
+
*/
|
|
1392
|
+
emit(event: DocumentEvent): void;
|
|
1393
|
+
/**
|
|
1394
|
+
* Remove a specific handler.
|
|
1395
|
+
*/
|
|
1396
|
+
off(type: DocumentEventType, handler: DocumentEventHandler): void;
|
|
1397
|
+
/**
|
|
1398
|
+
* Get event history.
|
|
1399
|
+
*/
|
|
1400
|
+
getHistory(): DocumentEvent[];
|
|
1401
|
+
/**
|
|
1402
|
+
* Clear all listeners and history.
|
|
1403
|
+
*/
|
|
1404
|
+
clear(): void;
|
|
1405
|
+
}
|
|
1406
|
+
|
|
1407
|
+
/**
|
|
1408
|
+
* DocumentEngine — RenderTree
|
|
1409
|
+
*
|
|
1410
|
+
* An intermediate representation between DocumentModel and final output.
|
|
1411
|
+
* The RenderTree is what the RenderPipeline processes.
|
|
1412
|
+
*
|
|
1413
|
+
* This abstraction allows:
|
|
1414
|
+
* - Same DocumentModel → multiple render outputs (HTML, Canvas, SVG, PDF)
|
|
1415
|
+
* - Plugin renderers that intercept/modify render nodes
|
|
1416
|
+
* - Smooth visual transitions between document states
|
|
1417
|
+
* - Partial rendering for performance
|
|
1418
|
+
*/
|
|
1419
|
+
|
|
1420
|
+
type RenderVariant = 'html' | 'canvas' | 'svg' | 'pdf' | 'react' | 'text';
|
|
1421
|
+
interface RenderNode {
|
|
1422
|
+
/** Semantic kind */
|
|
1423
|
+
kind: NodeKind | string;
|
|
1424
|
+
/** Text content */
|
|
1425
|
+
text: string;
|
|
1426
|
+
/** Child render nodes */
|
|
1427
|
+
children: RenderNode[];
|
|
1428
|
+
/** Props/attributes for the renderer */
|
|
1429
|
+
props: Record<string, unknown>;
|
|
1430
|
+
/** Which render variant this node targets */
|
|
1431
|
+
variant?: RenderVariant;
|
|
1432
|
+
/** CSS class names */
|
|
1433
|
+
className?: string;
|
|
1434
|
+
/** Inline styles */
|
|
1435
|
+
style?: Record<string, string | number>;
|
|
1436
|
+
/** Events/handlers (for interactive renders) */
|
|
1437
|
+
events?: Record<string, string>;
|
|
1438
|
+
/** Metadata for renderers */
|
|
1439
|
+
metadata?: Record<string, unknown>;
|
|
1440
|
+
}
|
|
1441
|
+
declare class RenderTree {
|
|
1442
|
+
/**
|
|
1443
|
+
* Create a render tree from a document node.
|
|
1444
|
+
*/
|
|
1445
|
+
static fromNode(kind: NodeKind | string, text?: string, children?: RenderNode[], props?: Record<string, unknown>): RenderNode;
|
|
1446
|
+
/**
|
|
1447
|
+
* Create a text render node.
|
|
1448
|
+
*/
|
|
1449
|
+
static text(content: string): RenderNode;
|
|
1450
|
+
/**
|
|
1451
|
+
* Create a container render node.
|
|
1452
|
+
*/
|
|
1453
|
+
static container(kind: NodeKind | string, children: RenderNode[], props?: Record<string, unknown>): RenderNode;
|
|
1454
|
+
/**
|
|
1455
|
+
* Serialize a render tree to HTML.
|
|
1456
|
+
*/
|
|
1457
|
+
static toHTML(node: RenderNode): string;
|
|
1458
|
+
private static kindToTag;
|
|
1459
|
+
private static propsToHtml;
|
|
1460
|
+
private static escapeHtml;
|
|
1461
|
+
}
|
|
1462
|
+
|
|
1463
|
+
/**
|
|
1464
|
+
* DocumentEngine — TagRegistry
|
|
1465
|
+
*
|
|
1466
|
+
* The central registry for all known tags/node kinds.
|
|
1467
|
+
* This is the plugin extension point for adding new BBCode tags,
|
|
1468
|
+
* custom node types, or even entire new languages.
|
|
1469
|
+
*
|
|
1470
|
+
* Each tag definition includes:
|
|
1471
|
+
* - Parser handler (how to parse this tag from tokens)
|
|
1472
|
+
* - Renderer (how to render this tag to HTML, Markdown, etc.)
|
|
1473
|
+
* - Validator (semantic validation rules)
|
|
1474
|
+
* - Toolbar definition (for the visual editor)
|
|
1475
|
+
* - Property editor (for the properties panel)
|
|
1476
|
+
*
|
|
1477
|
+
* Inspired by VSCode's contribution points.
|
|
1478
|
+
*/
|
|
1479
|
+
|
|
1480
|
+
interface TagHandlerContext {
|
|
1481
|
+
node: RedNode;
|
|
1482
|
+
source: string;
|
|
1483
|
+
visitChildren: (node: RedNode) => string;
|
|
1484
|
+
renderChild: (node: RedNode) => RenderNode;
|
|
1485
|
+
}
|
|
1486
|
+
interface TagDefinition {
|
|
1487
|
+
/** The tag name (e.g. 'color', 'b', 'img') */
|
|
1488
|
+
name: string;
|
|
1489
|
+
/** Semantic kind */
|
|
1490
|
+
kind: NodeKind;
|
|
1491
|
+
/** Human-readable label */
|
|
1492
|
+
label: string;
|
|
1493
|
+
/** Category for grouping in UI */
|
|
1494
|
+
category?: 'formatting' | 'layout' | 'media' | 'special' | 'text';
|
|
1495
|
+
/** Icon name for UI */
|
|
1496
|
+
icon?: string;
|
|
1497
|
+
/** Whether this tag is inline (span) or block (div) */
|
|
1498
|
+
isInline: boolean;
|
|
1499
|
+
/** Whether this tag is self-closing */
|
|
1500
|
+
isSelfClosing: boolean;
|
|
1501
|
+
/** Whether this tag can contain children */
|
|
1502
|
+
canHaveChildren: boolean;
|
|
1503
|
+
/** Whether this tag is deprecated */
|
|
1504
|
+
isDeprecated?: boolean;
|
|
1505
|
+
/** Replacement tag name if deprecated */
|
|
1506
|
+
deprecatedReplacement?: string;
|
|
1507
|
+
/** Convert a RedNode back to BBCode text */
|
|
1508
|
+
toBBCode?: (ctx: TagHandlerContext) => string;
|
|
1509
|
+
/** Convert a RedNode to a RenderNode for the render pipeline */
|
|
1510
|
+
toRenderNode?: (ctx: TagHandlerContext) => RenderNode;
|
|
1511
|
+
/** Convert a RedNode to HTML string (legacy) */
|
|
1512
|
+
toHTML?: (ctx: TagHandlerContext) => string;
|
|
1513
|
+
/** Default attributes for new instances */
|
|
1514
|
+
defaultAttributes?: () => NodeAttributes;
|
|
1515
|
+
/** Additional validator for this specific tag */
|
|
1516
|
+
validator?: Validator;
|
|
1517
|
+
/** Toolbar button definition */
|
|
1518
|
+
toolbar?: {
|
|
1519
|
+
group: string;
|
|
1520
|
+
label: string;
|
|
1521
|
+
icon?: string;
|
|
1522
|
+
shortcut?: string;
|
|
1523
|
+
};
|
|
1524
|
+
/** Properties panel definition */
|
|
1525
|
+
properties?: PropertyDefinition[];
|
|
1526
|
+
}
|
|
1527
|
+
interface PropertyDefinition {
|
|
1528
|
+
name: string;
|
|
1529
|
+
label: string;
|
|
1530
|
+
type: 'text' | 'color' | 'number' | 'select' | 'boolean' | 'slider';
|
|
1531
|
+
defaultValue?: string | number | boolean;
|
|
1532
|
+
options?: {
|
|
1533
|
+
label: string;
|
|
1534
|
+
value: string;
|
|
1535
|
+
}[];
|
|
1536
|
+
min?: number;
|
|
1537
|
+
max?: number;
|
|
1538
|
+
step?: number;
|
|
1539
|
+
placeholder?: string;
|
|
1540
|
+
description?: string;
|
|
1541
|
+
}
|
|
1542
|
+
type TagHandler = {
|
|
1543
|
+
[K in keyof TagDefinition]: TagDefinition[K];
|
|
1544
|
+
};
|
|
1545
|
+
declare class TagRegistry {
|
|
1546
|
+
private tags;
|
|
1547
|
+
private kinds;
|
|
1548
|
+
/** Names registered by the constructor — the language's own tags. */
|
|
1549
|
+
private builtins;
|
|
1550
|
+
/**
|
|
1551
|
+
* Bumped on every register/unregister, so consumers that derive something
|
|
1552
|
+
* from the registry (the parser's `extraTags` map) can memoize against it
|
|
1553
|
+
* instead of rebuilding per parse.
|
|
1554
|
+
*/
|
|
1555
|
+
version: number;
|
|
1556
|
+
constructor();
|
|
1557
|
+
/**
|
|
1558
|
+
* Register a tag definition.
|
|
1559
|
+
*/
|
|
1560
|
+
register(tag: TagDefinition): void;
|
|
1561
|
+
/**
|
|
1562
|
+
* Unregister a tag definition.
|
|
1563
|
+
*/
|
|
1564
|
+
unregister(name: string): void;
|
|
1565
|
+
/**
|
|
1566
|
+
* Whether `name` is one of the language's own tags, as opposed to a
|
|
1567
|
+
* plugin registration. The distinction matters: plugin tags must reach the
|
|
1568
|
+
* parser (via `ParseOptions.extraTags`) and serialize with their own name,
|
|
1569
|
+
* while builtins already have both behaviors hardcoded — routing them
|
|
1570
|
+
* through the plugin path would change engine semantics.
|
|
1571
|
+
*/
|
|
1572
|
+
isBuiltin(name: string): boolean;
|
|
1573
|
+
/**
|
|
1574
|
+
* tag name → kind for every non-builtin registration: the parser's
|
|
1575
|
+
* `extraTags`. Returns `null` when there are none, so the common case
|
|
1576
|
+
* costs one size comparison and no allocation.
|
|
1577
|
+
*/
|
|
1578
|
+
customTags(): Map<string, NodeKind> | null;
|
|
1579
|
+
/**
|
|
1580
|
+
* Get a tag definition by name.
|
|
1581
|
+
*/
|
|
1582
|
+
get(name: string): TagDefinition | undefined;
|
|
1583
|
+
/**
|
|
1584
|
+
* Get a tag definition by semantic kind.
|
|
1585
|
+
*/
|
|
1586
|
+
getByKind(kind: NodeKind): TagDefinition | undefined;
|
|
1587
|
+
/**
|
|
1588
|
+
* Check if a tag is registered.
|
|
1589
|
+
*/
|
|
1590
|
+
has(name: string): boolean;
|
|
1591
|
+
/**
|
|
1592
|
+
* Get all registered tags.
|
|
1593
|
+
*/
|
|
1594
|
+
getAll(): TagDefinition[];
|
|
1595
|
+
/**
|
|
1596
|
+
* Get tags by category.
|
|
1597
|
+
*/
|
|
1598
|
+
getByCategory(category: string): TagDefinition[];
|
|
1599
|
+
/**
|
|
1600
|
+
* Get tags by whether they are inline.
|
|
1601
|
+
*/
|
|
1602
|
+
getInline(): TagDefinition[];
|
|
1603
|
+
/**
|
|
1604
|
+
* Get block-level tags.
|
|
1605
|
+
*/
|
|
1606
|
+
getBlock(): TagDefinition[];
|
|
1607
|
+
/**
|
|
1608
|
+
* Register the built-in BBCode tags.
|
|
1609
|
+
*/
|
|
1610
|
+
private registerBuiltins;
|
|
1611
|
+
}
|
|
1612
|
+
|
|
1613
|
+
/**
|
|
1614
|
+
* DocumentEngine — NodeFactory
|
|
1615
|
+
*
|
|
1616
|
+
* Creates DocumentNode instances, RedNode instances, and GreenNode
|
|
1617
|
+
* instances from tag definitions. Centralizes node creation so
|
|
1618
|
+
* plugins can provide custom node factories.
|
|
1619
|
+
*/
|
|
1620
|
+
|
|
1621
|
+
declare class NodeFactory {
|
|
1622
|
+
private registry;
|
|
1623
|
+
constructor(registry: TagRegistry);
|
|
1624
|
+
/**
|
|
1625
|
+
* Create a RedNode from a tag definition.
|
|
1626
|
+
*/
|
|
1627
|
+
createFromTag(tagName: string, attrs?: string, start?: number, end?: number, children?: RedNode[]): RedNode;
|
|
1628
|
+
/**
|
|
1629
|
+
* Create a text RedNode.
|
|
1630
|
+
*/
|
|
1631
|
+
createText(text: string, start?: number, end?: number): RedNode;
|
|
1632
|
+
/**
|
|
1633
|
+
* Create a self-closing tag RedNode (like [*], [img]src[/img]).
|
|
1634
|
+
*/
|
|
1635
|
+
createSelfClosing(tagName: string, attrs?: string, start?: number, end?: number): RedNode;
|
|
1636
|
+
/**
|
|
1637
|
+
* Create a document root RedNode.
|
|
1638
|
+
*/
|
|
1639
|
+
createDocument(children?: RedNode[]): RedNode;
|
|
1640
|
+
}
|
|
1641
|
+
|
|
1642
|
+
/**
|
|
1643
|
+
* DocumentEngine — DocumentModel
|
|
1644
|
+
*
|
|
1645
|
+
* THE CORE of the Language Platform.
|
|
1646
|
+
*
|
|
1647
|
+
* The DocumentModel is the single source of truth for the document.
|
|
1648
|
+
* BBCode is just ONE representation of this model.
|
|
1649
|
+
* The model is language-agnostic and format-agnostic.
|
|
1650
|
+
*
|
|
1651
|
+
* Responsibilities:
|
|
1652
|
+
* - Hold the current Red Tree
|
|
1653
|
+
* - Manage incremental parsing
|
|
1654
|
+
* - Coordinate transactions (undo/redo)
|
|
1655
|
+
* - Emit change events
|
|
1656
|
+
* - Provide query/find capabilities
|
|
1657
|
+
* - Manage diagnostics
|
|
1658
|
+
*
|
|
1659
|
+
* Inspired by ProseMirror's EditorState and Roslyn's Document.
|
|
1660
|
+
*/
|
|
1661
|
+
|
|
1662
|
+
interface DocumentModelOptions {
|
|
1663
|
+
/** Initial source text */
|
|
1664
|
+
source?: string;
|
|
1665
|
+
/** Language identifier */
|
|
1666
|
+
language?: string;
|
|
1667
|
+
/** Maximum undo stack size */
|
|
1668
|
+
maxUndo?: number;
|
|
1669
|
+
/** Whether to auto-analyze on change */
|
|
1670
|
+
autoAnalyze?: boolean;
|
|
1671
|
+
/**
|
|
1672
|
+
* Attempt incremental reparse on `applyChange`. Default `true`.
|
|
1673
|
+
*
|
|
1674
|
+
* Setting this to `false` makes every change a full rebuild. The result is
|
|
1675
|
+
* identical either way — that is the incremental parser's contract, checked
|
|
1676
|
+
* differentially over 22.100 edits — so this is a performance switch and a
|
|
1677
|
+
* debugging aid, not a correctness one.
|
|
1678
|
+
*/
|
|
1679
|
+
incremental?: boolean;
|
|
1680
|
+
/**
|
|
1681
|
+
* Reuse red subtrees across incremental reparses. Default `true`.
|
|
1682
|
+
*
|
|
1683
|
+
* When the splice shares a green subtree by reference, the old red subtree
|
|
1684
|
+
* is adopted into the new tree instead of being rebuilt — building red was
|
|
1685
|
+
* the largest phase of a keystroke. The trade is a contract: the previous
|
|
1686
|
+
* `redRoot` is consumed by the adoption and must not be walked afterwards.
|
|
1687
|
+
* No engine or app code does; this switch exists for any future consumer
|
|
1688
|
+
* that needs the superseded tree to stay intact.
|
|
1689
|
+
*/
|
|
1690
|
+
reuseRed?: boolean;
|
|
1691
|
+
}
|
|
1692
|
+
declare class DocumentModel {
|
|
1693
|
+
private _source;
|
|
1694
|
+
private _language;
|
|
1695
|
+
private _redRoot;
|
|
1696
|
+
private _greenRoot;
|
|
1697
|
+
private _version;
|
|
1698
|
+
readonly tagRegistry: TagRegistry;
|
|
1699
|
+
readonly nodeFactory: NodeFactory;
|
|
1700
|
+
readonly treeBuilder: TreeBuilder;
|
|
1701
|
+
readonly nodeMatcher: NodeMatcher;
|
|
1702
|
+
readonly semanticAnalyzer: SemanticAnalyzer;
|
|
1703
|
+
readonly incrementalParser: IncrementalParser;
|
|
1704
|
+
readonly changeTracker: ChangeTracker;
|
|
1705
|
+
readonly undoManager: UndoManager;
|
|
1706
|
+
readonly queryEngine: QueryEngine;
|
|
1707
|
+
readonly events: DocumentEventBus;
|
|
1708
|
+
private _diagnostics;
|
|
1709
|
+
private _lastAnalyzeResult;
|
|
1710
|
+
/**
|
|
1711
|
+
* Source range of the last applied edit — see {@link TextChangeRange}.
|
|
1712
|
+
* `null` after a full rebuild, which is not an edit.
|
|
1713
|
+
*/
|
|
1714
|
+
private _lastChangeRange;
|
|
1715
|
+
private _options;
|
|
1716
|
+
private _analyzeTimeout;
|
|
1717
|
+
/** The debounced post-edit work, kept so `ensureAnalyzed` can run it early. */
|
|
1718
|
+
private _pendingAnalyze;
|
|
1719
|
+
/** Last reparse timings breakdown (exposed for debugging) */
|
|
1720
|
+
lastReparsePath: string;
|
|
1721
|
+
/** Why the last reparse fell back to a full rebuild, if it did. */
|
|
1722
|
+
lastReparseFallbackReason: FallbackReason | null;
|
|
1723
|
+
lastReparseTimings: {
|
|
1724
|
+
findAffected: number;
|
|
1725
|
+
safeBoundary: number;
|
|
1726
|
+
parse: number;
|
|
1727
|
+
buildRed: number;
|
|
1728
|
+
mutate: number;
|
|
1729
|
+
other: number;
|
|
1730
|
+
} | null;
|
|
1731
|
+
constructor(options?: DocumentModelOptions);
|
|
1732
|
+
get source(): string;
|
|
1733
|
+
get language(): string;
|
|
1734
|
+
get version(): number;
|
|
1735
|
+
get redRoot(): RedNode | null;
|
|
1736
|
+
get greenRoot(): GreenNode | null;
|
|
1737
|
+
get diagnostics(): DiagnosticCollection | null;
|
|
1738
|
+
get lastAnalyze(): AnalyzeResult | null;
|
|
1739
|
+
/**
|
|
1740
|
+
* The source range of the last applied edit, in new-source coordinates.
|
|
1741
|
+
*
|
|
1742
|
+
* `null` after a `rebuild` (there is no incremental edit to point at). The
|
|
1743
|
+
* BlockPatcher reads this to reconcile only the blocks around the edit
|
|
1744
|
+
* instead of walking the whole document. The same range is also attached to
|
|
1745
|
+
* the current `redRoot` (`__changeRange`), so a consumer that only holds the
|
|
1746
|
+
* AST — like the live preview — can find it without plumbing the model.
|
|
1747
|
+
*/
|
|
1748
|
+
get lastChangeRange(): TextChangeRange | null;
|
|
1749
|
+
/**
|
|
1750
|
+
* Full rebuild from source text.
|
|
1751
|
+
* Used for initial load or when incremental parsing isn't possible.
|
|
1752
|
+
*/
|
|
1753
|
+
rebuild(source: string): void;
|
|
1754
|
+
/**
|
|
1755
|
+
* Install `nodeMatch` on an event as an accessor, so the match is only
|
|
1756
|
+
* computed if a subscriber actually reads it.
|
|
1757
|
+
*
|
|
1758
|
+
* Matching two 1736-node trees costs ~3.5 ms and allocates a result entry per
|
|
1759
|
+
* node plus five Maps/Sets — and it ran on *every* keystroke to populate a
|
|
1760
|
+
* field that no subscriber in this codebase reads. The capability is real
|
|
1761
|
+
* (preserving selection and scroll across re-parses is what it is for), so it
|
|
1762
|
+
* stays available; it just stops running speculatively.
|
|
1763
|
+
*
|
|
1764
|
+
* Callers still write `event.nodeMatch` and see a `NodeMatch | null`. Note the
|
|
1765
|
+
* closure keeps `oldRoot` alive for as long as the event object is retained.
|
|
1766
|
+
*/
|
|
1767
|
+
private defineLazyNodeMatch;
|
|
1768
|
+
/**
|
|
1769
|
+
* Attach the last change range to a root node.
|
|
1770
|
+
*
|
|
1771
|
+
* The range travels with the AST so the preview can read it from the root it
|
|
1772
|
+
* is about to patch — no prop drilling through workspace → window → preview.
|
|
1773
|
+
* `__changeRange` is deliberately not part of RedNode's API; it is a runtime
|
|
1774
|
+
* marker owned by the model (the BlockPatcher reads it with a cast).
|
|
1775
|
+
*/
|
|
1776
|
+
private _attachChangeRange;
|
|
1777
|
+
/**
|
|
1778
|
+
* Apply a source text change (e.g. from Monaco editor input).
|
|
1779
|
+
* Uses incremental parsing when possible.
|
|
1780
|
+
*
|
|
1781
|
+
* `origin` tags where the change came from — `'local'` (default) for the
|
|
1782
|
+
* user's own editing, anything else for programmatic or synced sources
|
|
1783
|
+
* (`'remote'`, `'sync'`, a peer id…). It travels on the emitted events, so
|
|
1784
|
+
* a collaboration layer can tell its own echo apart from user edits — the
|
|
1785
|
+
* classic infinite-loop bug when wiring a CRDT. See `QuasarCollab.MD`.
|
|
1786
|
+
*/
|
|
1787
|
+
applyChange(change: TextChange, origin?: string): void;
|
|
1788
|
+
/**
|
|
1789
|
+
* Flush the debounced post-edit analysis, so `diagnostics` describes the
|
|
1790
|
+
* *current* tree.
|
|
1791
|
+
*
|
|
1792
|
+
* `applyChange` defers analysis by a few milliseconds to keep the keystroke
|
|
1793
|
+
* path lean. A caller that reads `diagnostics` synchronously right after an
|
|
1794
|
+
* edit would otherwise be looking at the previous document's errors — which
|
|
1795
|
+
* is exactly what the editor's error panel did.
|
|
1796
|
+
*/
|
|
1797
|
+
ensureAnalyzed(): void;
|
|
1798
|
+
/**
|
|
1799
|
+
* Calculate a simple diff between the current source and the new source,
|
|
1800
|
+
* then apply the change.
|
|
1801
|
+
*/
|
|
1802
|
+
applyTextUpdate(newSource: string, origin?: string): void;
|
|
1803
|
+
/**
|
|
1804
|
+
* Execute operations within a transaction (undoable).
|
|
1805
|
+
*
|
|
1806
|
+
* Coherence contract: after a `transact` the model's `source`, green tree
|
|
1807
|
+
* and red tree all describe the same document. The transaction mutates the
|
|
1808
|
+
* red tree, the result is serialized back to text via `exportSource`, and
|
|
1809
|
+
* the model rebuilds from that text — parse stays the single source of
|
|
1810
|
+
* truth, exactly as in `applyChange`. (The previous implementation swapped
|
|
1811
|
+
* `_redRoot` and left `_source`/`_greenRoot` describing the old document,
|
|
1812
|
+
* so the next text edit diffed against a source the tree no longer matched.)
|
|
1813
|
+
*
|
|
1814
|
+
* Returns `true` if the document changed.
|
|
1815
|
+
*/
|
|
1816
|
+
transact(operations: Operation[], label?: string): boolean;
|
|
1817
|
+
/**
|
|
1818
|
+
* Undo the last transaction.
|
|
1819
|
+
*
|
|
1820
|
+
* Undo/redo replay *source snapshots*, not stored tree mutations: a rebuild
|
|
1821
|
+
* replaces every node id, so a retained `Transaction` could never be
|
|
1822
|
+
* re-applied against the current tree — and its `invert()` was only defined
|
|
1823
|
+
* for one of the six operation kinds anyway. Text is always replayable.
|
|
1824
|
+
*/
|
|
1825
|
+
undo(): boolean;
|
|
1826
|
+
/**
|
|
1827
|
+
* Redo the last undone transaction. See `undo` for why this replays text.
|
|
1828
|
+
*/
|
|
1829
|
+
redo(): boolean;
|
|
1830
|
+
/**
|
|
1831
|
+
* Run semantic analysis on the current tree.
|
|
1832
|
+
*/
|
|
1833
|
+
analyze(): AnalyzeResult;
|
|
1834
|
+
/**
|
|
1835
|
+
* Find a node by its ID.
|
|
1836
|
+
*/
|
|
1837
|
+
findNode(id: NodeId): RedNode | null;
|
|
1838
|
+
/**
|
|
1839
|
+
* Query the document tree.
|
|
1840
|
+
*/
|
|
1841
|
+
query(q: Query): QueryResult;
|
|
1842
|
+
/**
|
|
1843
|
+
* Register a semantic validator.
|
|
1844
|
+
*/
|
|
1845
|
+
registerValidator(validator: Validator): void;
|
|
1846
|
+
/**
|
|
1847
|
+
* Get the current document as a DocumentNode tree.
|
|
1848
|
+
*/
|
|
1849
|
+
toDocumentNode(): DocumentNode | null;
|
|
1850
|
+
/**
|
|
1851
|
+
* Get the current source text.
|
|
1852
|
+
*/
|
|
1853
|
+
toString(): string;
|
|
1854
|
+
/**
|
|
1855
|
+
* Parse source text to a GreenNode tree.
|
|
1856
|
+
* Override this for language-specific parsing.
|
|
1857
|
+
*
|
|
1858
|
+
* `options.normalizeParagraphs === false` means the text is the inner span
|
|
1859
|
+
* of a container rather than a whole document, and root-level grouping must
|
|
1860
|
+
* be skipped. Languages without such grouping can ignore it.
|
|
1861
|
+
*/
|
|
1862
|
+
protected parseToGreen(source: string, options?: ReparseParseOptions): GreenNode;
|
|
1863
|
+
/**
|
|
1864
|
+
* Build a RedNode tree from a GreenNode.
|
|
1865
|
+
*/
|
|
1866
|
+
protected buildRedFromGreen(green: GreenNode): RedNode;
|
|
1867
|
+
/**
|
|
1868
|
+
* Build a red tree for `green`, reusing subtrees of `oldRed` where the green
|
|
1869
|
+
* is shared by reference. The base model has no reuse-aware builder, so it
|
|
1870
|
+
* falls back to a full build; language models override this
|
|
1871
|
+
* (BBCodeDocumentModel uses `greenToRedNodeReusing`).
|
|
1872
|
+
*/
|
|
1873
|
+
protected buildRedFromGreenReusing(green: GreenNode, oldRed: RedNode, stats?: {
|
|
1874
|
+
adopted: number;
|
|
1875
|
+
}): RedNode;
|
|
1876
|
+
/**
|
|
1877
|
+
* Serialize a red tree back to source text. `transact`/`undo`/`redo` rebuild
|
|
1878
|
+
* from this text to keep source, green and red coherent.
|
|
1879
|
+
*
|
|
1880
|
+
* The base model has no syntax to serialize to, so this throws; language
|
|
1881
|
+
* models override it (BBCodeDocumentModel uses the BBCodeExporter).
|
|
1882
|
+
* Failing loudly here beats the alternative: silently desynchronizing the
|
|
1883
|
+
* model's three representations.
|
|
1884
|
+
*/
|
|
1885
|
+
protected exportSource(root: RedNode): string;
|
|
1886
|
+
}
|
|
1887
|
+
|
|
1888
|
+
/**
|
|
1889
|
+
* DocumentEngine — Lexer
|
|
1890
|
+
*
|
|
1891
|
+
* Tokenizes source text into a stream of Tokens with Trivia.
|
|
1892
|
+
* Trivia includes whitespace, newlines, and formatting that is
|
|
1893
|
+
* preserved in the token stream but not semantically significant.
|
|
1894
|
+
*
|
|
1895
|
+
* This is a LANGUAGE-INDEPENDENT lexer framework.
|
|
1896
|
+
* Language-specific tokenization is provided via the LexerOptions.
|
|
1897
|
+
*
|
|
1898
|
+
* Inspired by Roslyn's lexer architecture.
|
|
1899
|
+
*/
|
|
1900
|
+
|
|
1901
|
+
interface LexerRule {
|
|
1902
|
+
/** Pattern to match at the current position */
|
|
1903
|
+
pattern: RegExp;
|
|
1904
|
+
/** Token kind to produce */
|
|
1905
|
+
kind: TokenKind;
|
|
1906
|
+
/** Optional: extract a name from the match (for tags) */
|
|
1907
|
+
name?: (match: RegExpExecArray) => string;
|
|
1908
|
+
/** Optional: extract attributes from the match */
|
|
1909
|
+
attrs?: (match: RegExpExecArray) => string;
|
|
1910
|
+
}
|
|
1911
|
+
interface LexerOptions {
|
|
1912
|
+
/** Language-specific tokenization rules */
|
|
1913
|
+
rules: LexerRule[];
|
|
1914
|
+
/** Characters that count as whitespace trivia */
|
|
1915
|
+
whitespaceChars?: RegExp;
|
|
1916
|
+
/** Characters that start a newline trivia */
|
|
1917
|
+
newlineChars?: RegExp;
|
|
1918
|
+
/** Whether to collect trivia */
|
|
1919
|
+
collectTrivia?: boolean;
|
|
1920
|
+
/** Lexer hooks: called before each token */
|
|
1921
|
+
onToken?: (token: Token) => void;
|
|
1922
|
+
}
|
|
1923
|
+
declare class Lexer {
|
|
1924
|
+
private options;
|
|
1925
|
+
private source;
|
|
1926
|
+
private pos;
|
|
1927
|
+
private tokens;
|
|
1928
|
+
constructor(options: LexerOptions);
|
|
1929
|
+
/**
|
|
1930
|
+
* Tokenize source text into a TokenStream.
|
|
1931
|
+
*/
|
|
1932
|
+
tokenize(source: string): TokenStream;
|
|
1933
|
+
/**
|
|
1934
|
+
* Re-tokenize a portion of the source (for incremental parsing).
|
|
1935
|
+
* Returns just the new tokens for the affected range.
|
|
1936
|
+
*/
|
|
1937
|
+
retokenize(source: string, start: number, end: number): Token[];
|
|
1938
|
+
private tryRules;
|
|
1939
|
+
private scanTrivia;
|
|
1940
|
+
}
|
|
1941
|
+
|
|
1942
|
+
/**
|
|
1943
|
+
* DocumentEngine — RedNodeStore
|
|
1944
|
+
*
|
|
1945
|
+
* Canonical store for RedNodes. Ensures that structurally identical subtrees
|
|
1946
|
+
* share the EXACT SAME RedNode instance in RAM.
|
|
1947
|
+
*
|
|
1948
|
+
* This is the RedNode-level complement to GreenNodePool (which handles
|
|
1949
|
+
* structural sharing at the GreenNode level).
|
|
1950
|
+
*
|
|
1951
|
+
* Architecture:
|
|
1952
|
+
* - Keyed by GreenNode._hash (deterministic structural hash)
|
|
1953
|
+
* - Bottom-up: children are stored first, then parents reference them
|
|
1954
|
+
* - Immutable structural data: once a RedNode is in the store, its `children`
|
|
1955
|
+
* array and `green` reference never change
|
|
1956
|
+
* - Mutable metadata: the store's RedNode has canonical metadata (derived from
|
|
1957
|
+
* green.text). Position-specific metadata lives in PositionRef.localOverlay.
|
|
1958
|
+
*
|
|
1959
|
+
* Benefits:
|
|
1960
|
+
* - Memory deduplication: 100 identical blocks = 1 RedNode + 99 lightweight refs
|
|
1961
|
+
* - Render caching: BBCodeCanvas can memoize by canonicalId
|
|
1962
|
+
* - Faster incremental parse: unchanged subtrees keep their RedNode identity
|
|
1963
|
+
* - HTMLRenderer cache: same canonicalId = same HTML (cache hits)
|
|
1964
|
+
*/
|
|
1965
|
+
|
|
1966
|
+
/**
|
|
1967
|
+
* Derives a node's canonical metadata from its GreenNode.
|
|
1968
|
+
*
|
|
1969
|
+
* Injected rather than hardcoded: metadata extraction is *language* semantics
|
|
1970
|
+
* (what `=#ff0000,#00ff00` means for a `gradient`), and this module sits in the
|
|
1971
|
+
* language-agnostic `Syntax` layer. The BBCode implementation lives in
|
|
1972
|
+
* `BBCode/BBCodeToGreenNode.ts`; pass it via the constructor.
|
|
1973
|
+
*/
|
|
1974
|
+
type MetadataExtractor = (green: GreenNode) => NodeMetadata;
|
|
1975
|
+
declare class RedNodeStore {
|
|
1976
|
+
private canonicals;
|
|
1977
|
+
private _hits;
|
|
1978
|
+
private _misses;
|
|
1979
|
+
private _collisions;
|
|
1980
|
+
private readonly extractMetadata;
|
|
1981
|
+
/**
|
|
1982
|
+
* @param extractMetadata How to derive canonical metadata from a green node.
|
|
1983
|
+
* Defaults to producing none — the store stays language-agnostic unless a
|
|
1984
|
+
* caller supplies the mapping.
|
|
1985
|
+
*/
|
|
1986
|
+
constructor(extractMetadata?: MetadataExtractor);
|
|
1987
|
+
/**
|
|
1988
|
+
* Get or create a canonical RedNode for the given GreenNode.
|
|
1989
|
+
*
|
|
1990
|
+
* The store is keyed by `green._hash` — a 32-bit FNV structural hash — so a
|
|
1991
|
+
* matching key is necessary but NOT sufficient proof of identity. The
|
|
1992
|
+
* canonical pipeline (GreenNodePool interning) guarantees that structurally
|
|
1993
|
+
* identical greens ARE the same object, which is what makes reference
|
|
1994
|
+
* equality a sound discriminator:
|
|
1995
|
+
*
|
|
1996
|
+
* - same hash + same green reference → HIT, reuse the canonical
|
|
1997
|
+
* - same hash + different green → COLLISION: a distinct subtree shares
|
|
1998
|
+
* the 32-bit hash. It is never reused as the registered canonical and never
|
|
1999
|
+
* overwrites it (that would corrupt every future hit for the registered
|
|
2000
|
+
* node). The colliding subtree simply loses canonicalization — rebuilt on
|
|
2001
|
+
* each call. Guarding correctness costs a rare dedup miss; the alternative
|
|
2002
|
+
* costs a wrong AST.
|
|
2003
|
+
* - no hash entry → MISS, create and register
|
|
2004
|
+
*
|
|
2005
|
+
* Invariant preserved: a hash collision can never make one structure be
|
|
2006
|
+
* represented by another structure's RedNode.
|
|
2007
|
+
*
|
|
2008
|
+
* @param green The GreenNode to wrap
|
|
2009
|
+
* @param metadata Optional metadata to set (overrides intrinsic metadata)
|
|
2010
|
+
* @param kind Optional kind override
|
|
2011
|
+
* @returns The canonical RedNode (shared if already exists)
|
|
2012
|
+
*/
|
|
2013
|
+
getOrCreate(green: GreenNode, metadata?: NodeMetadata, kind?: NodeKind): RedNode;
|
|
2014
|
+
/**
|
|
2015
|
+
* Build a canonical RedNode for `green` WITHOUT registering it.
|
|
2016
|
+
*
|
|
2017
|
+
* Shared by the miss path (which registers the result) and the collision
|
|
2018
|
+
* path (which must not). Both need the same bottom-up construction:
|
|
2019
|
+
* children are canonicalized first, then the parent references them.
|
|
2020
|
+
*/
|
|
2021
|
+
private buildCanonical;
|
|
2022
|
+
/**
|
|
2023
|
+
* Look up a canonical RedNode by its structural hash.
|
|
2024
|
+
*/
|
|
2025
|
+
getByHash(hash: string): RedNode | undefined;
|
|
2026
|
+
/**
|
|
2027
|
+
* Check if a GreenNode already has a canonical RedNode.
|
|
2028
|
+
*/
|
|
2029
|
+
has(green: GreenNode): boolean;
|
|
2030
|
+
/**
|
|
2031
|
+
* Get the number of unique canonical nodes.
|
|
2032
|
+
*/
|
|
2033
|
+
get size(): number;
|
|
2034
|
+
/**
|
|
2035
|
+
* Get store statistics.
|
|
2036
|
+
*/
|
|
2037
|
+
get stats(): {
|
|
2038
|
+
size: number;
|
|
2039
|
+
hits: number;
|
|
2040
|
+
misses: number;
|
|
2041
|
+
collisions: number;
|
|
2042
|
+
deduplicationRatio: string;
|
|
2043
|
+
};
|
|
2044
|
+
/**
|
|
2045
|
+
* Clear all canonical nodes.
|
|
2046
|
+
*/
|
|
2047
|
+
clear(): void;
|
|
2048
|
+
}
|
|
2049
|
+
|
|
2050
|
+
/**
|
|
2051
|
+
* DocumentEngine — TreeDiffer
|
|
2052
|
+
*
|
|
2053
|
+
* Computes structural differences between two Red Trees.
|
|
2054
|
+
* Produces a list of DiffOperations that can be used for:
|
|
2055
|
+
* - Updating the UI with minimal DOM changes
|
|
2056
|
+
* - Syncing with collaboration servers
|
|
2057
|
+
* - Animation interpolation
|
|
2058
|
+
* - Undo/redo granularity
|
|
2059
|
+
*
|
|
2060
|
+
* Inspired by React's reconciliation and ProseMirror's diff.
|
|
2061
|
+
*/
|
|
2062
|
+
|
|
2063
|
+
type DiffKind = 'insert' | 'delete' | 'update' | 'move' | 'preserve';
|
|
2064
|
+
interface DiffOperation {
|
|
2065
|
+
kind: DiffKind;
|
|
2066
|
+
node: RedNode;
|
|
2067
|
+
oldNode?: RedNode;
|
|
2068
|
+
/** Index in parent */
|
|
2069
|
+
oldIndex?: number;
|
|
2070
|
+
newIndex?: number;
|
|
2071
|
+
/** Parent node ID */
|
|
2072
|
+
parentId?: string;
|
|
2073
|
+
}
|
|
2074
|
+
interface DiffResult {
|
|
2075
|
+
operations: DiffOperation[];
|
|
2076
|
+
/** Count by kind */
|
|
2077
|
+
stats: {
|
|
2078
|
+
inserts: number;
|
|
2079
|
+
deletes: number;
|
|
2080
|
+
updates: number;
|
|
2081
|
+
moves: number;
|
|
2082
|
+
preserves: number;
|
|
2083
|
+
};
|
|
2084
|
+
/** Time taken in ms */
|
|
2085
|
+
duration: number;
|
|
2086
|
+
}
|
|
2087
|
+
declare class TreeDiffer {
|
|
2088
|
+
/**
|
|
2089
|
+
* Compute the diff between two Red Trees.
|
|
2090
|
+
*/
|
|
2091
|
+
diff(oldRoot: RedNode, newRoot: RedNode): DiffResult;
|
|
2092
|
+
private diffChildren;
|
|
2093
|
+
/**
|
|
2094
|
+
* Check if a node's content has changed.
|
|
2095
|
+
*/
|
|
2096
|
+
private nodeContentChanged;
|
|
2097
|
+
/**
|
|
2098
|
+
* Deep equality check for content equivalence when pairing unmatched nodes.
|
|
2099
|
+
*/
|
|
2100
|
+
private nodesAreEquivalent;
|
|
2101
|
+
}
|
|
2102
|
+
|
|
2103
|
+
/**
|
|
2104
|
+
* DocumentEngine — Visitor
|
|
2105
|
+
*
|
|
2106
|
+
* Base visitor pattern for traversing the Red Tree.
|
|
2107
|
+
* All exporters and renderers extend this.
|
|
2108
|
+
*
|
|
2109
|
+
* Instead of switch statements on tag names, use the visitor pattern.
|
|
2110
|
+
* New languages/exporters don't modify existing code.
|
|
2111
|
+
*
|
|
2112
|
+
* Inspired by SwiftSyntax's Visitor pattern.
|
|
2113
|
+
*/
|
|
2114
|
+
|
|
2115
|
+
interface VisitorContext {
|
|
2116
|
+
source: string;
|
|
2117
|
+
options: Record<string, unknown>;
|
|
2118
|
+
}
|
|
2119
|
+
declare abstract class Visitor<T = string> {
|
|
2120
|
+
protected context: VisitorContext;
|
|
2121
|
+
/**
|
|
2122
|
+
* Visit a node and produce an output value.
|
|
2123
|
+
*/
|
|
2124
|
+
abstract visit(node: RedNode, context?: VisitorContext): T;
|
|
2125
|
+
/**
|
|
2126
|
+
* Visit all children and combine results.
|
|
2127
|
+
*/
|
|
2128
|
+
visitChildren(node: RedNode, separator?: string): T[];
|
|
2129
|
+
/**
|
|
2130
|
+
* Get the text content of a node.
|
|
2131
|
+
*/
|
|
2132
|
+
getText(node: RedNode): string;
|
|
2133
|
+
/**
|
|
2134
|
+
* Set the visitor context.
|
|
2135
|
+
*/
|
|
2136
|
+
setContext(context: VisitorContext): void;
|
|
2137
|
+
}
|
|
2138
|
+
|
|
2139
|
+
/**
|
|
2140
|
+
* DocumentEngine — BBCodeExporter
|
|
2141
|
+
*
|
|
2142
|
+
* Exports the Document Model / Red Tree back to BBCode text.
|
|
2143
|
+
* This is how the internal representation becomes editable text.
|
|
2144
|
+
*
|
|
2145
|
+
* Uses the TagRegistry to determine how each node serializes.
|
|
2146
|
+
* Plugins can register custom serializers for custom tags.
|
|
2147
|
+
*/
|
|
2148
|
+
|
|
2149
|
+
/**
|
|
2150
|
+
* Export target for BBCodeExporter.
|
|
2151
|
+
* - 'osu': Expands Miliastry/Lyne-native tags into osu!-compatible BBCode.
|
|
2152
|
+
* - 'miliastry': Preserves Miliastry-native tags as-is for round-trip editing.
|
|
2153
|
+
* - 'lyne': Preserves Lyne-native tags as-is for round-trip editing.
|
|
2154
|
+
*/
|
|
2155
|
+
type ExportTarget$1 = 'osu' | 'miliastry' | 'lyne';
|
|
2156
|
+
/**
|
|
2157
|
+
* Tags que existen SOLO en Miliastry (registrados en el TagRegistry) y que
|
|
2158
|
+
* osu! NO renderiza. Si un usuario pega `[shadow]`/`[zalgo]`/etc. en osu!,
|
|
2159
|
+
* el texto sale roto (los corchetes se ven literalmente). Cuando el target
|
|
2160
|
+
* es 'osu', estos tags se DEGRADAN a su contenido plano: mejor perder el
|
|
2161
|
+
* efecto que romper la userpage. El target 'miliastry' los conserva.
|
|
2162
|
+
*/
|
|
2163
|
+
declare const MILIASTRY_ONLY_TAGS: Set<string>;
|
|
2164
|
+
declare const LYNE_ONLY_TAGS: Set<string>;
|
|
2165
|
+
declare class BBCodeExporter extends Visitor<string> {
|
|
2166
|
+
private registry;
|
|
2167
|
+
private depth;
|
|
2168
|
+
private target;
|
|
2169
|
+
constructor(registry?: TagRegistry, target?: ExportTarget$1);
|
|
2170
|
+
/**
|
|
2171
|
+
* Set the export target. Controls how Miliastry-native tags are serialized.
|
|
2172
|
+
*/
|
|
2173
|
+
setTarget(target: ExportTarget$1): void;
|
|
2174
|
+
/**
|
|
2175
|
+
* Export a RedNode tree to BBCode.
|
|
2176
|
+
*/
|
|
2177
|
+
visit(node: RedNode, context?: VisitorContext): string;
|
|
2178
|
+
/**
|
|
2179
|
+
* Export the entire document to BBCode.
|
|
2180
|
+
* Optionally override the export target for this specific call.
|
|
2181
|
+
*/
|
|
2182
|
+
export(root: RedNode, target?: ExportTarget$1): string;
|
|
2183
|
+
private exportNode;
|
|
2184
|
+
private kindToTagName;
|
|
2185
|
+
private getTagAttributes;
|
|
2186
|
+
}
|
|
2187
|
+
|
|
2188
|
+
interface UIBBBlock {
|
|
2189
|
+
id: string;
|
|
2190
|
+
type: string;
|
|
2191
|
+
attributes?: Record<string, string>;
|
|
2192
|
+
children: UIBBBlock[];
|
|
2193
|
+
content?: string;
|
|
2194
|
+
}
|
|
2195
|
+
declare class BBBlocksExporter extends Visitor<UIBBBlock[]> {
|
|
2196
|
+
private idCounter;
|
|
2197
|
+
private generateId;
|
|
2198
|
+
visit(node: RedNode, context?: VisitorContext): UIBBBlock[];
|
|
2199
|
+
export(root: RedNode): UIBBBlock[];
|
|
2200
|
+
private exportNode;
|
|
2201
|
+
}
|
|
2202
|
+
|
|
2203
|
+
/**
|
|
2204
|
+
* DocumentEngine — BBCodeToGreenNode
|
|
2205
|
+
*
|
|
2206
|
+
* Bridge between the existing BBCode Parser (BBBlock[]) and the
|
|
2207
|
+
* DocumentEngine's GreenNode/RedNode syntax tree.
|
|
2208
|
+
*
|
|
2209
|
+
* This is how we integrate the mature, battle-tested BBCode parser
|
|
2210
|
+
* with the new Language Platform architecture.
|
|
2211
|
+
*
|
|
2212
|
+
* The flow:
|
|
2213
|
+
* BBCode text
|
|
2214
|
+
* → parseBBCode() [existing] → BBBlock[]
|
|
2215
|
+
* → convertToGreenNode() [this] → GreenNode
|
|
2216
|
+
* → buildRedNode() [this] → RedNode
|
|
2217
|
+
* → DocumentModel
|
|
2218
|
+
*/
|
|
2219
|
+
|
|
2220
|
+
type BBCodeDialect = 'osu' | 'miliastry' | 'lyne';
|
|
2221
|
+
declare const BBCODE_TAG_NAMES: readonly string[];
|
|
2222
|
+
declare function getBBCodeTagNames(dialect?: BBCodeDialect): readonly string[];
|
|
2223
|
+
declare function tagToNodeKind(tag: string | null, dialect?: BBCodeDialect): NodeKind;
|
|
2224
|
+
declare function nodeKindToTag(kind: NodeKind): string | null;
|
|
2225
|
+
declare function isBlockKind(kind: NodeKind): boolean;
|
|
2226
|
+
interface BBBlock {
|
|
2227
|
+
id: string;
|
|
2228
|
+
tag: string | null;
|
|
2229
|
+
attrs: string;
|
|
2230
|
+
content: string;
|
|
2231
|
+
rawStart: number;
|
|
2232
|
+
rawEnd: number;
|
|
2233
|
+
children: BBBlock[];
|
|
2234
|
+
attrChildren?: BBBlock[];
|
|
2235
|
+
html?: string;
|
|
2236
|
+
}
|
|
2237
|
+
/**
|
|
2238
|
+
* Convert a single BBBlock to a GreenNode.
|
|
2239
|
+
* Recursively converts children.
|
|
2240
|
+
*/
|
|
2241
|
+
declare function bbBlockToGreenNode(block: BBBlock): GreenNode;
|
|
2242
|
+
/**
|
|
2243
|
+
* Convert an array of BBBlock[] (the root of the existing parser's output)
|
|
2244
|
+
* to a GreenNode tree.
|
|
2245
|
+
*
|
|
2246
|
+
* Note: This function is kept for backward compatibility with the old parser.
|
|
2247
|
+
* The new DocumentEngine parser (Parser.ts + BBCodeLexer) produces GreenNode
|
|
2248
|
+
* directly and does NOT go through BBBlock[].
|
|
2249
|
+
*/
|
|
2250
|
+
declare function bbBlocksToGreenTree(blocks: BBBlock[], source: string): GreenNode;
|
|
2251
|
+
/**
|
|
2252
|
+
* Build a RedNode tree from a GreenNode.
|
|
2253
|
+
* Similar to TreeBuilder.buildRed but uses actual kind from the green node
|
|
2254
|
+
* and extracts BBCode metadata from attributes.
|
|
2255
|
+
*
|
|
2256
|
+
* When a RedNodeStore is provided, instance RedNodes are created with
|
|
2257
|
+
* correct parent references copied from the canonical's metadata.
|
|
2258
|
+
* The canonicalId (from green._hash) enables:
|
|
2259
|
+
* - React.memo in BBCodeCanvas by canonicalId
|
|
2260
|
+
* - HTMLRenderer cache by canonicalId
|
|
2261
|
+
* - Diff optimization (same canonicalId = unchanged subtree)
|
|
2262
|
+
* - Future PositionRef-based sharing
|
|
2263
|
+
*/
|
|
2264
|
+
declare function greenToRedNode(green: GreenNode, parent?: RedNode | null, store?: RedNodeStore, start?: number): RedNode;
|
|
2265
|
+
/**
|
|
2266
|
+
* Convert a BBBlock array directly to a RedNode root.
|
|
2267
|
+
* This is the main entry point for the bridge.
|
|
2268
|
+
*/
|
|
2269
|
+
declare function bbBlocksToRedTree(blocks: BBBlock[], source: string): RedNode;
|
|
2270
|
+
|
|
2271
|
+
/**
|
|
2272
|
+
* DocumentEngine — HTMLRenderer
|
|
2273
|
+
*
|
|
2274
|
+
* Renders the Document Model to HTML for preview.
|
|
2275
|
+
* This is the main renderer for the visual BBCode preview.
|
|
2276
|
+
*
|
|
2277
|
+
* Uses the TagRegistry for custom rendering.
|
|
2278
|
+
* Plugins can register custom renderers for preview components.
|
|
2279
|
+
*/
|
|
2280
|
+
|
|
2281
|
+
interface HTMLRendererOptions {
|
|
2282
|
+
/**
|
|
2283
|
+
* Replicate osu! forum BBCode spacing quirks.
|
|
2284
|
+
* Defaults to true for full compatibility with Miliastry.
|
|
2285
|
+
* Set to false for a more logical, predictable rendering engine.
|
|
2286
|
+
*/
|
|
2287
|
+
osuBehaviour?: boolean;
|
|
2288
|
+
/** Registry for resolving custom tags */
|
|
2289
|
+
registry?: TagRegistry;
|
|
2290
|
+
/** BBCode dialect to render for ('osu' | 'miliastry' | 'lyne') */
|
|
2291
|
+
dialect?: BBCodeDialect;
|
|
2292
|
+
/** Visual theme for markup classes ('osu' | 'lyne' | 'miliastry') */
|
|
2293
|
+
theme?: 'osu' | 'lyne' | 'miliastry';
|
|
2294
|
+
/** Safe media proxy callback to rewrite image and media URLs */
|
|
2295
|
+
mediaProxy?: (url: string) => string;
|
|
2296
|
+
/** Resolver for entity links like profile, guild, map */
|
|
2297
|
+
entityLinkResolver?: (kind: string, value: string) => {
|
|
2298
|
+
href: string;
|
|
2299
|
+
external?: boolean;
|
|
2300
|
+
} | null;
|
|
2301
|
+
}
|
|
2302
|
+
declare class HTMLRenderer extends Visitor<string> {
|
|
2303
|
+
private options;
|
|
2304
|
+
constructor(options?: HTMLRendererOptions);
|
|
2305
|
+
private readonly BLOCK_TAGS;
|
|
2306
|
+
private readonly INLINE_TAGS;
|
|
2307
|
+
/**
|
|
2308
|
+
* The ` data-node-id="…"` attribute, or `''` for nodes nobody looks up.
|
|
2309
|
+
*
|
|
2310
|
+
* ─── Why this is not emitted on everything ──────────────────────────────
|
|
2311
|
+
*
|
|
2312
|
+
* It used to be, carrying `node.id` — a process-global counter minted per
|
|
2313
|
+
* RedNode, so a reparse renames every node in the document. Measured on a
|
|
2314
|
+
* one-character edit: zero ids survive.
|
|
2315
|
+
*
|
|
2316
|
+
* `DOMMorpher` already noted that this rules the ids out as morph KEYS. The
|
|
2317
|
+
* larger cost is that it also breaks the morpher's prefix/suffix trim, which
|
|
2318
|
+
* uses `isEqualNode` — a comparison that includes attributes. An attribute
|
|
2319
|
+
* that always differs makes every element compare unequal, so the trim never
|
|
2320
|
+
* fires and the morpher walks the whole document. Measured on one keystroke
|
|
2321
|
+
* in the reference document: **1665 `setAttribute` calls across 1725
|
|
2322
|
+
* elements**, to write new numbers meaning the same thing, when the actual
|
|
2323
|
+
* change was 10 insertions and 9 removals.
|
|
2324
|
+
*
|
|
2325
|
+
* ─── Why BLOCKS, and not stable ids ─────────────────────────────────────
|
|
2326
|
+
*
|
|
2327
|
+
* Making the id stable was the obvious repair and it does not work. Keying on
|
|
2328
|
+
* the node's SPAN was measured: an edit at the END drops the writes to 6, but
|
|
2329
|
+
* an insertion shifts every offset after it, so an edit near the START still
|
|
2330
|
+
* cost 1637 — and spans are longer strings than `nN`, so the emitted HTML grew
|
|
2331
|
+
* 10%. Stability under insertion is not something a position can have.
|
|
2332
|
+
*
|
|
2333
|
+
* The real observation is that no consumer ever wanted these on inline nodes.
|
|
2334
|
+
* Both readers in the app are block-level: `usePreviewClick` walks up with
|
|
2335
|
+
* `closest('[data-node-id]')` for *block selection*, and `useBlockHighlight`
|
|
2336
|
+
* highlights and scrolls to a *block*. Emitting ids on the per-character
|
|
2337
|
+
* spans of a gradient did not just cost — it made `closest()` stop at a
|
|
2338
|
+
* character instead of the block the click meant.
|
|
2339
|
+
*
|
|
2340
|
+
* So the attribute goes where it is read. Inline nodes carry no id, compare
|
|
2341
|
+
* equal, and let `isEqualNode` skip their subtrees natively; the handful of
|
|
2342
|
+
* block containers that do carry one are few enough that their churn is
|
|
2343
|
+
* noise.
|
|
2344
|
+
*/
|
|
2345
|
+
/**
|
|
2346
|
+
* Text leaves emit their content directly, with no wrapper element.
|
|
2347
|
+
*
|
|
2348
|
+
* They used to come wrapped in `<span class="bb-text">` — one extra DOM
|
|
2349
|
+
* element per text leaf, which is HALF the preview's elements (measured:
|
|
2350
|
+
* 1726 → 851 on a 19.6 KB post, 17251 → 8510 on a 196 KB one). The class
|
|
2351
|
+
* earned none of it: it has no CSS rule anywhere in the repo, carries no
|
|
2352
|
+
* `data-node-id` (text is not an id-bearing kind, so click mapping and
|
|
2353
|
+
* highlighting never looked at it), and every style a text leaf can have
|
|
2354
|
+
* still emits its own `<span style="…">` below.
|
|
2355
|
+
*
|
|
2356
|
+
* A/B in a production build, steady state: flush 41.4 → 39.7 ms at 19.6 KB
|
|
2357
|
+
* and 55.4 → 46.4 ms at 196 KB, plus ~27% less HTML to serialize and parse.
|
|
2358
|
+
*/
|
|
2359
|
+
private textWrap;
|
|
2360
|
+
/**
|
|
2361
|
+
* A qué elementos se les pone `data-node-id`.
|
|
2362
|
+
*
|
|
2363
|
+
* `'all'` (por defecto) — a todos. Es lo que permite que un clic en
|
|
2364
|
+
* CUALQUIER punto del preview señale ese nodo exacto en el editor: en un
|
|
2365
|
+
* degradado cada carácter es su propio nodo, y sin id no hay nada a lo que
|
|
2366
|
+
* `closest()` pueda agarrarse.
|
|
2367
|
+
*
|
|
2368
|
+
* Esto estuvo desactivado por una buena razón que ya no aplica. Los ids se
|
|
2369
|
+
* regeneraban en cada parseo, así que el atributo cambiaba en TODOS los
|
|
2370
|
+
* elementos por pulsación: `isEqualNode` no casaba nunca, el morpher no
|
|
2371
|
+
* podía saltarse ningún subárbol y se medían 1665 `setAttribute` sobre 1725
|
|
2372
|
+
* elementos para escribir números nuevos que significaban lo mismo. Con la
|
|
2373
|
+
* identidad estable entre reparseos eso desapareció: un nodo que no cambia
|
|
2374
|
+
* conserva su id, su HTML es idéntico y el camino rápido del morpher sigue
|
|
2375
|
+
* funcionando.
|
|
2376
|
+
*
|
|
2377
|
+
* El coste que queda es el tamaño: ~10 bytes por elemento, que hay que
|
|
2378
|
+
* serializar y parsear. Medido con A/B en la misma sesión sobre el post con
|
|
2379
|
+
* degradados (19,6 KB, 852 elementos):
|
|
2380
|
+
*
|
|
2381
|
+
* solo bloques (9 con id): latencia 48 ms · p95 72 ms
|
|
2382
|
+
* todos (808 con id): latencia 56 ms · p95 104 ms
|
|
2383
|
+
*
|
|
2384
|
+
* Se paga a propósito: la precisión del clic es una función que se pidió, y
|
|
2385
|
+
* 56 ms sigue holgadamente dentro de lo que se percibe como inmediato.
|
|
2386
|
+
* `'blocks'` queda disponible para quien priorice la latencia, y `'none'`
|
|
2387
|
+
* para los consumidores de solo lectura (foros, render estático): sin
|
|
2388
|
+
* `data-node-id` en absoluto, el HTML es más pequeño y no hay nada que
|
|
2389
|
+
* mantenga vivos los nodos del árbol.
|
|
2390
|
+
*/
|
|
2391
|
+
static idMode: 'blocks' | 'all' | 'none';
|
|
2392
|
+
private idAttr;
|
|
2393
|
+
/**
|
|
2394
|
+
* Kinds that carry `data-node-id`.
|
|
2395
|
+
*
|
|
2396
|
+
* The block containers a user can select or be scrolled to, plus the media
|
|
2397
|
+
* nodes, which are atomic and clickable in their own right. Deliberately NOT
|
|
2398
|
+
* `text` or any inline formatting kind — see {@link idAttr}.
|
|
2399
|
+
*/
|
|
2400
|
+
private static readonly ID_BEARING_KINDS;
|
|
2401
|
+
visit(node: RedNode): string;
|
|
2402
|
+
render(root: RedNode): string;
|
|
2403
|
+
/**
|
|
2404
|
+
* Render every child and concatenate.
|
|
2405
|
+
*
|
|
2406
|
+
* Replaces the `children.map(c => this.renderNode(c)).join('')` idiom, which
|
|
2407
|
+
* was repeated at 16 call sites and allocated a closure plus an intermediate
|
|
2408
|
+
* array of N strings at every level of the tree. Appending to one string lets
|
|
2409
|
+
* the engine use its rope representation instead.
|
|
2410
|
+
*/
|
|
2411
|
+
/**
|
|
2412
|
+
* Render the direct children of `node` to HTML (no wrapper element).
|
|
2413
|
+
*
|
|
2414
|
+
* Public so the incremental preview (`BlockPatcher`) can morph a block
|
|
2415
|
+
* element's inner content without re-rendering the whole document.
|
|
2416
|
+
*/
|
|
2417
|
+
renderChildren(node: RedNode): string;
|
|
2418
|
+
private renderNode;
|
|
2419
|
+
/** osu! quirk: newlines immediately preceding a [code] block are completely ignored */
|
|
2420
|
+
private isNextCodeBlock;
|
|
2421
|
+
/** Checks if this is the LAST empty_line right before a code block (skipping only spacing) */
|
|
2422
|
+
private isImmediateEmptyLineBeforeCode;
|
|
2423
|
+
private isPrevBlockBoundary;
|
|
2424
|
+
private isTrailingBlockBoundary;
|
|
2425
|
+
private renderError;
|
|
2426
|
+
private wrapInline;
|
|
2427
|
+
private wrapBlock;
|
|
2428
|
+
/** #rgb / #rgba / #rrggbb / #rrggbbaa, a bare CSS color keyword, or rgb()/hsl(). */
|
|
2429
|
+
private static readonly CSS_COLOR_RE;
|
|
2430
|
+
/** Bare number — interpolated as a percentage. */
|
|
2431
|
+
private static readonly CSS_SIZE_RE;
|
|
2432
|
+
/** Font family list. Quotes are rejected outright; unquoted names are valid CSS. */
|
|
2433
|
+
private static readonly CSS_FONT_RE;
|
|
2434
|
+
/** Characters `escapeHtml` has to rewrite. Non-global on purpose: `test` must not carry `lastIndex`. */
|
|
2435
|
+
private static readonly HTML_ESCAPE_RE;
|
|
2436
|
+
private sanitizeColor;
|
|
2437
|
+
private sanitizeFontSize;
|
|
2438
|
+
private sanitizeFontFamily;
|
|
2439
|
+
/** Keyword-or-number CSS values (font-weight, font-style, text-decoration). */
|
|
2440
|
+
private isCssKeyword;
|
|
2441
|
+
/** Read a metadata field, falling back to the raw tag attribute. */
|
|
2442
|
+
private metaOrAttr;
|
|
2443
|
+
private colorStyle;
|
|
2444
|
+
private fontSizeStyle;
|
|
2445
|
+
private fontStyle;
|
|
2446
|
+
/**
|
|
2447
|
+
* Extract the attribute value from a BBCode tag node.
|
|
2448
|
+
*
|
|
2449
|
+
* BBCode attributes come in the format `=VALUE` (e.g. `=#61afef`, `="Author"`,
|
|
2450
|
+
* `=https://osu.ppy.sh`). This strips the leading `=` and any surrounding quotes.
|
|
2451
|
+
*
|
|
2452
|
+
* For tags without attrs (like text nodes, img content), returns the node text as-is.
|
|
2453
|
+
*/
|
|
2454
|
+
private extractValue;
|
|
2455
|
+
private renderLink;
|
|
2456
|
+
private renderProfile;
|
|
2457
|
+
private renderEntity;
|
|
2458
|
+
private parseImgAttr;
|
|
2459
|
+
private renderImage;
|
|
2460
|
+
private renderVideo;
|
|
2461
|
+
private renderAudio;
|
|
2462
|
+
private hexToRgba;
|
|
2463
|
+
private renderNotice;
|
|
2464
|
+
private renderTables;
|
|
2465
|
+
private renderGallery;
|
|
2466
|
+
private renderColumns;
|
|
2467
|
+
private renderSeparator;
|
|
2468
|
+
private renderScroll;
|
|
2469
|
+
private renderAbbr;
|
|
2470
|
+
private renderTooltip;
|
|
2471
|
+
private renderRaw;
|
|
2472
|
+
private renderPlain;
|
|
2473
|
+
private renderAlign;
|
|
2474
|
+
private renderEffect;
|
|
2475
|
+
private renderAnim;
|
|
2476
|
+
private renderContainer;
|
|
2477
|
+
private static readonly STYLE_PROP_WHITELIST;
|
|
2478
|
+
private renderStyleTag;
|
|
2479
|
+
private renderQuote;
|
|
2480
|
+
private renderSpoilerbox;
|
|
2481
|
+
private renderBox;
|
|
2482
|
+
private renderTitle;
|
|
2483
|
+
/**
|
|
2484
|
+
* `[box=Title:#hex]`, `[tables=striped:#hex]`, `[columns=2:#hex]` → un
|
|
2485
|
+
* ` style="--<suffix>-accent:#hex;"` del que el CSS deriva la paleta
|
|
2486
|
+
* (título + chevron y borde en boxw; gradientes/header/hover en tables;
|
|
2487
|
+
* borde + fondo en columns). Devuelve '' si no hay color válido.
|
|
2488
|
+
*/
|
|
2489
|
+
private boxAccentStyle;
|
|
2490
|
+
private renderList;
|
|
2491
|
+
private renderListItem;
|
|
2492
|
+
private renderCode;
|
|
2493
|
+
private renderSVG;
|
|
2494
|
+
/**
|
|
2495
|
+
* Render an osu! BBCode imagemap.
|
|
2496
|
+
*
|
|
2497
|
+
* Structure:
|
|
2498
|
+
* [imagemap]
|
|
2499
|
+
* https://example.com/image.png ← first line = image URL
|
|
2500
|
+
* 10 20 50 60 https://... Label ← subsequent lines = clickable areas
|
|
2501
|
+
* [/imagemap]
|
|
2502
|
+
*
|
|
2503
|
+
* Each area line format: x y width height url [label]
|
|
2504
|
+
* All values are PERCENTAGES (0–100) relative to the image dimensions.
|
|
2505
|
+
* Uses CSS absolute positioning with percentage coordinates.
|
|
2506
|
+
*/
|
|
2507
|
+
private renderImagemap;
|
|
2508
|
+
private collectNodeText;
|
|
2509
|
+
private renderGradient;
|
|
2510
|
+
/**
|
|
2511
|
+
* Escape for both text content and double-quoted attribute values.
|
|
2512
|
+
*
|
|
2513
|
+
* `'` is included because attribute values elsewhere in the codebase (and in
|
|
2514
|
+
* consumer-supplied tag handlers) may be single-quoted; leaving it raw makes
|
|
2515
|
+
* the escaping context-dependent, which is how injections get reintroduced.
|
|
2516
|
+
*/
|
|
2517
|
+
/**
|
|
2518
|
+
* Escape the five HTML-significant characters.
|
|
2519
|
+
*
|
|
2520
|
+
* Was five chained `.replace(/x/g, …)` calls: five full scans of the string
|
|
2521
|
+
* and up to five intermediate allocations for *every* text node, even though
|
|
2522
|
+
* ordinary prose contains none of these characters. This tests once and
|
|
2523
|
+
* returns the input untouched in that common case, then does a single pass
|
|
2524
|
+
* when there is actually something to escape.
|
|
2525
|
+
*/
|
|
2526
|
+
private escapeHtml;
|
|
2527
|
+
}
|
|
2528
|
+
|
|
2529
|
+
/**
|
|
2530
|
+
* DocumentEngine — DOMMorpher
|
|
2531
|
+
*
|
|
2532
|
+
* Reconciles and morphs an existing HTML DOM tree in-place to match a new HTML string.
|
|
2533
|
+
* Operating on pure HTML string output from HTMLRenderer means ZERO changes to Quasar AST schemas.
|
|
2534
|
+
*
|
|
2535
|
+
* Features:
|
|
2536
|
+
* - In-place attribute synchronization
|
|
2537
|
+
* - In-place text node value updates
|
|
2538
|
+
* - Preserves state of unchanged elements (spoilers, audio/video, selections)
|
|
2539
|
+
* - Zero extra wrapper <div> elements
|
|
2540
|
+
*/
|
|
2541
|
+
declare function morphHTML(container: HTMLElement, newHTML: string): void;
|
|
2542
|
+
|
|
2543
|
+
/**
|
|
2544
|
+
* DocumentEngine — BlockPatcher
|
|
2545
|
+
*
|
|
2546
|
+
* Incremental rendering for the HTML preview.
|
|
2547
|
+
*
|
|
2548
|
+
* The naive path — `renderToHTML(root)` + `morphHTML(container, html)` on every
|
|
2549
|
+
* keystroke — re-serializes the ENTIRE document to a string and, worse, makes
|
|
2550
|
+
* the browser re-parse that whole string (`template.innerHTML`) even when only
|
|
2551
|
+
* one character changed. Profiled on an 18.5 KB document: `morphHTML` ≈ 29.8 ms
|
|
2552
|
+
* per keystroke, of which ~27 ms is the full-HTML parse (morph ≈ baseline
|
|
2553
|
+
* `innerHTML=`). The parser itself is ~0.5 ms.
|
|
2554
|
+
*
|
|
2555
|
+
* This module makes the DOM update O(changed block) instead of O(document):
|
|
2556
|
+
*
|
|
2557
|
+
* - The document's top-level children are the independent "blocks".
|
|
2558
|
+
* - With stable `data-node-id`s (see `preserveNodeIds`) a block that did not
|
|
2559
|
+
* change renders to the SAME html string, or is even the SAME RedNode
|
|
2560
|
+
* object (red-subtree reuse). Either way we can detect "unchanged" without
|
|
2561
|
+
* touching the DOM.
|
|
2562
|
+
* - Only changed blocks are re-rendered and re-morphed, in place, keyed by
|
|
2563
|
+
* their element.
|
|
2564
|
+
* - Structural edits — a block inserted, removed or reordered (Enter,
|
|
2565
|
+
* backspace between blocks, moving a block in the visual builder) — are
|
|
2566
|
+
* reconciled BY KEY too. The stable ids let us insert/remove/move ONLY the
|
|
2567
|
+
* affected elements, so every untouched block keeps its DOM identity and
|
|
2568
|
+
* runtime state (an open `<details>`, media playback). When the edit
|
|
2569
|
+
* script adds more blocks than the previous document had (a whole new
|
|
2570
|
+
* document patched into the same container, ids regenerated en masse) we
|
|
2571
|
+
* fall back to a full rebuild: one `innerHTML` parse beats N per-block
|
|
2572
|
+
* ones.
|
|
2573
|
+
*
|
|
2574
|
+
* DOM alignment — "runs": a top-level block may render to an element, to a
|
|
2575
|
+
* bare text node (`text`, osu `spacing`/`empty_line` quirks) or to nothing
|
|
2576
|
+
* (`''`, an empty text leaf). Two adjacent bare-text blocks are merged into a
|
|
2577
|
+
* single text node by the HTML parser, so a block↔node 1:1 pairing would
|
|
2578
|
+
* silently misalign after such a merge. The reconciliation therefore works on
|
|
2579
|
+
* RUNS: consecutive bare-text blocks form one "text run" that maps to exactly
|
|
2580
|
+
* one text node (mirroring the parser), and element blocks are their own runs.
|
|
2581
|
+
* The run list is the exact 1:1 mirror of `container.childNodes`.
|
|
2582
|
+
*/
|
|
2583
|
+
|
|
2584
|
+
interface PatchBlocksOptions {
|
|
2585
|
+
/** Renderer used to serialize blocks. Defaults to a shared HTMLRenderer. */
|
|
2586
|
+
renderer?: HTMLRenderer;
|
|
2587
|
+
/** Called when a block fails to morph; the caller can log it. */
|
|
2588
|
+
onError?: (err: unknown) => void;
|
|
2589
|
+
/**
|
|
2590
|
+
* The source range of the edit that produced `rootNode`, if known.
|
|
2591
|
+
*
|
|
2592
|
+
* Lets the patcher reconcile ONLY the runs overlapping the edit (O(edit))
|
|
2593
|
+
* instead of walking the whole document — the difference between ~2ms and
|
|
2594
|
+
* ~100ms per keystroke on a 500k-character document. Coordinates are in
|
|
2595
|
+
* new-source space with the old end kept separately; see `TextChangeRange`.
|
|
2596
|
+
* When omitted, the patcher falls back to the full keyed reconcile — and it
|
|
2597
|
+
* also reads the range attached to the root by `DocumentModel`.
|
|
2598
|
+
*/
|
|
2599
|
+
change?: TextChangeRange;
|
|
2600
|
+
/**
|
|
2601
|
+
* Minimum top-level blocks for the windowed path to run; below it the full
|
|
2602
|
+
* keyed walk is cheaper than the windowed bookkeeping. Defaults to
|
|
2603
|
+
* `MIN_WINDOWED_BLOCKS` (200). Tests force `0` to exercise the windowed
|
|
2604
|
+
* path on small documents.
|
|
2605
|
+
*/
|
|
2606
|
+
minWindowedBlocks?: number;
|
|
2607
|
+
}
|
|
2608
|
+
interface PatchBlocksStats {
|
|
2609
|
+
/** Which path was taken. */
|
|
2610
|
+
mode: 'full' | 'blocks';
|
|
2611
|
+
/** Number of top-level blocks. */
|
|
2612
|
+
total: number;
|
|
2613
|
+
/** Blocks whose DOM was actually updated (0 = nothing changed). */
|
|
2614
|
+
patched: number;
|
|
2615
|
+
/** True when the windowed (O(edit)) reconcile ran, not the full keyed walk. */
|
|
2616
|
+
windowed?: boolean;
|
|
2617
|
+
}
|
|
2618
|
+
/**
|
|
2619
|
+
* Reconcile `container`'s children against `rootNode`'s top-level blocks.
|
|
2620
|
+
*
|
|
2621
|
+
* Inline edits morph only the changed block; structural edits (insert, remove,
|
|
2622
|
+
* reorder) are reconciled by key so untouched blocks keep their DOM identity.
|
|
2623
|
+
* Returns what was done so callers/tests can assert the fast path fired.
|
|
2624
|
+
*/
|
|
2625
|
+
declare function patchBlocksInto(container: HTMLElement, rootNode: RedNode | null, options?: PatchBlocksOptions): PatchBlocksStats;
|
|
2626
|
+
|
|
2627
|
+
/**
|
|
2628
|
+
* SVGRenderer (Runtime ForeignObject Strategy)
|
|
2629
|
+
*
|
|
2630
|
+
* Instead of attempting to calculate CSS layouts manually,
|
|
2631
|
+
* this runtime renderer wraps the exact HTML output in an SVG <foreignObject>.
|
|
2632
|
+
*
|
|
2633
|
+
* This gives 100% precision with zero DOM scanning overhead for real-time previews.
|
|
2634
|
+
*/
|
|
2635
|
+
declare class SVGRenderer extends Visitor<string> {
|
|
2636
|
+
private htmlRenderer;
|
|
2637
|
+
visit(node: RedNode, context?: VisitorContext): string;
|
|
2638
|
+
render(root: RedNode): string;
|
|
2639
|
+
}
|
|
2640
|
+
|
|
2641
|
+
/**
|
|
2642
|
+
* DocumentEngine — MarkdownExporter
|
|
2643
|
+
*
|
|
2644
|
+
* Exports the Document Model to Markdown.
|
|
2645
|
+
* Demonstrates the format-agnostic power of the engine:
|
|
2646
|
+
* BBCode in → DocumentModel → Markdown out.
|
|
2647
|
+
*
|
|
2648
|
+
* A single visitor, no parser changes needed.
|
|
2649
|
+
*/
|
|
2650
|
+
|
|
2651
|
+
declare class MarkdownExporter extends Visitor<string> {
|
|
2652
|
+
visit(node: RedNode): string;
|
|
2653
|
+
export(node: RedNode): string;
|
|
2654
|
+
private exportNode;
|
|
2655
|
+
private renderTitleMarkdown;
|
|
2656
|
+
/**
|
|
2657
|
+
* Read a BBCode tag's `=VALUE` attribute.
|
|
2658
|
+
*
|
|
2659
|
+
* This previously read `node.properties` / `node.attributes`, neither of
|
|
2660
|
+
* which exists on RedNode — so it silently returned `undefined` for every
|
|
2661
|
+
* node and every call site fell through to its placeholder. That is why
|
|
2662
|
+
* Markdown export dropped link targets and quote/box titles.
|
|
2663
|
+
*/
|
|
2664
|
+
private extractValue;
|
|
2665
|
+
}
|
|
2666
|
+
|
|
2667
|
+
/**
|
|
2668
|
+
* DocumentEngine — JSONExporter
|
|
2669
|
+
*
|
|
2670
|
+
* Exports the Document Model to JSON.
|
|
2671
|
+
* Useful for:
|
|
2672
|
+
* - Saving/loading documents in a structured format
|
|
2673
|
+
* - Sending over the wire (collaboration)
|
|
2674
|
+
* - Debugging and inspecting the document tree
|
|
2675
|
+
* - Import/export between different tools
|
|
2676
|
+
*/
|
|
2677
|
+
|
|
2678
|
+
interface JSONDocument {
|
|
2679
|
+
version: number;
|
|
2680
|
+
kind: string;
|
|
2681
|
+
nodes: JSONNode[];
|
|
2682
|
+
metadata?: Record<string, unknown>;
|
|
2683
|
+
}
|
|
2684
|
+
interface JSONNode {
|
|
2685
|
+
id: string;
|
|
2686
|
+
kind: string;
|
|
2687
|
+
text: string;
|
|
2688
|
+
version: number;
|
|
2689
|
+
children: JSONNode[];
|
|
2690
|
+
attributes?: Record<string, unknown>;
|
|
2691
|
+
metadata?: Record<string, unknown>;
|
|
2692
|
+
range?: {
|
|
2693
|
+
start: number;
|
|
2694
|
+
end: number;
|
|
2695
|
+
};
|
|
2696
|
+
}
|
|
2697
|
+
declare class JSONExporter extends Visitor<string> {
|
|
2698
|
+
/**
|
|
2699
|
+
* Export a RedNode tree to a JSON string.
|
|
2700
|
+
*/
|
|
2701
|
+
visit(node: RedNode): string;
|
|
2702
|
+
/**
|
|
2703
|
+
* Export to a JSON object.
|
|
2704
|
+
*/
|
|
2705
|
+
toJSON(node: RedNode): JSONDocument;
|
|
2706
|
+
private serializeNode;
|
|
2707
|
+
}
|
|
2708
|
+
|
|
2709
|
+
/**
|
|
2710
|
+
* DocumentEngine — TiptapExporter
|
|
2711
|
+
*
|
|
2712
|
+
* Exports the Document Model to Tiptap (ProseMirror) JSON format.
|
|
2713
|
+
* This proves how easily Quasar's AST bridges to visual WYSIWYG editors.
|
|
2714
|
+
*
|
|
2715
|
+
* Tiptap uses a JSON format structured like:
|
|
2716
|
+
* {
|
|
2717
|
+
* type: "doc",
|
|
2718
|
+
* content: [
|
|
2719
|
+
* {
|
|
2720
|
+
* type: "paragraph",
|
|
2721
|
+
* content: [
|
|
2722
|
+
* { type: "text", text: "Hello ", marks: [{ type: "bold" }] }
|
|
2723
|
+
* ]
|
|
2724
|
+
* }
|
|
2725
|
+
* ]
|
|
2726
|
+
* }
|
|
2727
|
+
*/
|
|
2728
|
+
|
|
2729
|
+
interface TiptapNode {
|
|
2730
|
+
type: string;
|
|
2731
|
+
attrs?: Record<string, any>;
|
|
2732
|
+
content?: TiptapNode[];
|
|
2733
|
+
marks?: TiptapMark[];
|
|
2734
|
+
text?: string;
|
|
2735
|
+
}
|
|
2736
|
+
interface TiptapMark {
|
|
2737
|
+
type: string;
|
|
2738
|
+
attrs?: Record<string, any>;
|
|
2739
|
+
}
|
|
2740
|
+
declare class TiptapExporter extends Visitor<string> {
|
|
2741
|
+
visit(node: RedNode): string;
|
|
2742
|
+
toTiptap(node: RedNode): TiptapNode;
|
|
2743
|
+
private processChildren;
|
|
2744
|
+
private processNode;
|
|
2745
|
+
private kindToMark;
|
|
2746
|
+
private kindToBlockType;
|
|
2747
|
+
}
|
|
2748
|
+
|
|
2749
|
+
/**
|
|
2750
|
+
* DocumentEngine — Transaction
|
|
2751
|
+
*
|
|
2752
|
+
* Groups operations into an atomic, undoable unit.
|
|
2753
|
+
* Every modification to the document MUST go through a Transaction.
|
|
2754
|
+
*
|
|
2755
|
+
* Inspired by ProseMirror's Transaction system.
|
|
2756
|
+
*/
|
|
2757
|
+
|
|
2758
|
+
declare class Transaction {
|
|
2759
|
+
readonly operations: Operation[];
|
|
2760
|
+
readonly id: string;
|
|
2761
|
+
readonly timestamp: number;
|
|
2762
|
+
private _applied;
|
|
2763
|
+
constructor(operations: Operation[]);
|
|
2764
|
+
/**
|
|
2765
|
+
* Apply the transaction to a RedNode tree.
|
|
2766
|
+
* Returns the updated tree or null if application failed.
|
|
2767
|
+
*/
|
|
2768
|
+
apply(root: RedNode): RedNode | null;
|
|
2769
|
+
/**
|
|
2770
|
+
* Create the inverse transaction (for undo).
|
|
2771
|
+
*/
|
|
2772
|
+
invert(root: RedNode): Transaction;
|
|
2773
|
+
/**
|
|
2774
|
+
* Get a human-readable label for this transaction.
|
|
2775
|
+
*/
|
|
2776
|
+
getLabel(): string;
|
|
2777
|
+
private applyOperation;
|
|
2778
|
+
private applyInsert;
|
|
2779
|
+
private applyDelete;
|
|
2780
|
+
private applyReplace;
|
|
2781
|
+
private applyMove;
|
|
2782
|
+
private applySetText;
|
|
2783
|
+
}
|
|
2784
|
+
|
|
2785
|
+
/**
|
|
2786
|
+
* DocumentEngine — Command
|
|
2787
|
+
*
|
|
2788
|
+
* Commands are high-level user actions that MAY produce
|
|
2789
|
+
* a Transaction (or multiple) to modify the document.
|
|
2790
|
+
*
|
|
2791
|
+
* Commands are stateless — they receive context and produce operations.
|
|
2792
|
+
* This makes them testable, undoable, and safe for AI use.
|
|
2793
|
+
*
|
|
2794
|
+
* Inspired by ProseMirror's Commands and VSCode's Command system.
|
|
2795
|
+
*/
|
|
2796
|
+
|
|
2797
|
+
interface CommandContext {
|
|
2798
|
+
model: DocumentModel;
|
|
2799
|
+
root: RedNode;
|
|
2800
|
+
selection?: {
|
|
2801
|
+
nodeId: string;
|
|
2802
|
+
start: number;
|
|
2803
|
+
end: number;
|
|
2804
|
+
};
|
|
2805
|
+
}
|
|
2806
|
+
type CommandResult = {
|
|
2807
|
+
success: boolean;
|
|
2808
|
+
operations?: Operation[];
|
|
2809
|
+
message?: string;
|
|
2810
|
+
};
|
|
2811
|
+
interface Command {
|
|
2812
|
+
readonly id: string;
|
|
2813
|
+
readonly label: string;
|
|
2814
|
+
readonly description?: string;
|
|
2815
|
+
readonly shortcut?: string;
|
|
2816
|
+
execute(ctx: CommandContext): CommandResult;
|
|
2817
|
+
canExecute?(ctx: CommandContext): boolean;
|
|
2818
|
+
}
|
|
2819
|
+
|
|
2820
|
+
/**
|
|
2821
|
+
* DocumentEngine — CommandRegistry
|
|
2822
|
+
*
|
|
2823
|
+
* Central registry for all document commands.
|
|
2824
|
+
* Commands can be added by plugins.
|
|
2825
|
+
*
|
|
2826
|
+
* Enables:
|
|
2827
|
+
* - Keyboard shortcut binding
|
|
2828
|
+
* - Toolbar integration
|
|
2829
|
+
* - AI command execution
|
|
2830
|
+
* - Menu integration
|
|
2831
|
+
*/
|
|
2832
|
+
|
|
2833
|
+
declare class CommandRegistry {
|
|
2834
|
+
private commands;
|
|
2835
|
+
/**
|
|
2836
|
+
* Register a command.
|
|
2837
|
+
*/
|
|
2838
|
+
register(command: Command): void;
|
|
2839
|
+
/**
|
|
2840
|
+
* Unregister a command.
|
|
2841
|
+
*/
|
|
2842
|
+
unregister(id: string): void;
|
|
2843
|
+
/**
|
|
2844
|
+
* Get a command by ID.
|
|
2845
|
+
*/
|
|
2846
|
+
get(id: string): Command | undefined;
|
|
2847
|
+
/**
|
|
2848
|
+
* Check if a command is registered.
|
|
2849
|
+
*/
|
|
2850
|
+
has(id: string): boolean;
|
|
2851
|
+
/**
|
|
2852
|
+
* Execute a command by ID.
|
|
2853
|
+
*/
|
|
2854
|
+
execute(id: string, ctx: CommandContext): CommandResult;
|
|
2855
|
+
/**
|
|
2856
|
+
* Get all registered commands.
|
|
2857
|
+
*/
|
|
2858
|
+
getAll(): Command[];
|
|
2859
|
+
/**
|
|
2860
|
+
* Get commands by a filter function.
|
|
2861
|
+
*/
|
|
2862
|
+
filter(predicate: (cmd: Command) => boolean): Command[];
|
|
2863
|
+
/**
|
|
2864
|
+
* Get the count of registered commands.
|
|
2865
|
+
*/
|
|
2866
|
+
get size(): number;
|
|
2867
|
+
}
|
|
2868
|
+
|
|
2869
|
+
/**
|
|
2870
|
+
* DocumentEngine — InsertText Command
|
|
2871
|
+
*
|
|
2872
|
+
* Inserts text at the current cursor position.
|
|
2873
|
+
* Creates or reuses a text node.
|
|
2874
|
+
*/
|
|
2875
|
+
|
|
2876
|
+
declare const InsertText: Command;
|
|
2877
|
+
|
|
2878
|
+
/**
|
|
2879
|
+
* DocumentEngine — DeleteNode Command
|
|
2880
|
+
*
|
|
2881
|
+
* Deletes a node from the document tree.
|
|
2882
|
+
*/
|
|
2883
|
+
|
|
2884
|
+
declare const DeleteNode: Command;
|
|
2885
|
+
|
|
2886
|
+
/**
|
|
2887
|
+
* DocumentEngine — WrapInTag Command
|
|
2888
|
+
*
|
|
2889
|
+
* Wraps the current selection in a BBCode tag.
|
|
2890
|
+
* E.g. selected text → [b]selected text[/b]
|
|
2891
|
+
*/
|
|
2892
|
+
|
|
2893
|
+
declare const WrapInTag: Command;
|
|
2894
|
+
|
|
2895
|
+
/**
|
|
2896
|
+
* DocumentEngine — SplitNode & MergeNodes Commands
|
|
2897
|
+
*
|
|
2898
|
+
* Split a node at a position, or merge two adjacent nodes.
|
|
2899
|
+
* Essential for block editing and backspace handling.
|
|
2900
|
+
*/
|
|
2901
|
+
|
|
2902
|
+
declare const SplitNode: Command;
|
|
2903
|
+
declare const MergeNode: Command;
|
|
2904
|
+
|
|
2905
|
+
/**
|
|
2906
|
+
* DocumentEngine — Formatter
|
|
2907
|
+
*
|
|
2908
|
+
* Formats the document tree into "pretty" BBCode.
|
|
2909
|
+
* Normalizes indentation, spacing, and whitespace.
|
|
2910
|
+
*
|
|
2911
|
+
* Uses the TagRegistry and Visitor pattern.
|
|
2912
|
+
* Plugins can register custom formatters for custom tags.
|
|
2913
|
+
*/
|
|
2914
|
+
|
|
2915
|
+
interface FormatOptions {
|
|
2916
|
+
/** Whether to add newlines between block elements */
|
|
2917
|
+
blockNewlines?: boolean;
|
|
2918
|
+
/** Whether to normalize whitespace inside inline elements */
|
|
2919
|
+
normalizeInlineWhitespace?: boolean;
|
|
2920
|
+
/** Maximum line width (0 = no limit) */
|
|
2921
|
+
maxLineWidth?: number;
|
|
2922
|
+
/** Whether to collapse consecutive empty lines */
|
|
2923
|
+
collapseEmptyLines?: boolean;
|
|
2924
|
+
/** Indentation string */
|
|
2925
|
+
indent?: string;
|
|
2926
|
+
}
|
|
2927
|
+
declare class Formatter {
|
|
2928
|
+
/**
|
|
2929
|
+
* Format a RedNode tree (extracts text while preserving exact layout/spacing).
|
|
2930
|
+
*/
|
|
2931
|
+
format(root: RedNode): string;
|
|
2932
|
+
/**
|
|
2933
|
+
* Format a single node at a given depth.
|
|
2934
|
+
*/
|
|
2935
|
+
private formatNode;
|
|
2936
|
+
}
|
|
2937
|
+
|
|
2938
|
+
/**
|
|
2939
|
+
* DocumentEngine — Linter
|
|
2940
|
+
*
|
|
2941
|
+
* Lints the document tree for issues.
|
|
2942
|
+
* Similar to ESLint but for BBCode documents.
|
|
2943
|
+
*
|
|
2944
|
+
* Rules can be added via the Plugin API.
|
|
2945
|
+
*/
|
|
2946
|
+
|
|
2947
|
+
type LintSeverity = 'error' | 'warning' | 'info' | 'hint';
|
|
2948
|
+
interface LintRule {
|
|
2949
|
+
code: string;
|
|
2950
|
+
severity: LintSeverity;
|
|
2951
|
+
description: string;
|
|
2952
|
+
validate(node: RedNode, context: LintContext): LintIssue | LintIssue[] | null;
|
|
2953
|
+
}
|
|
2954
|
+
interface LintContext {
|
|
2955
|
+
source: string;
|
|
2956
|
+
allNodes: Map<string, RedNode>;
|
|
2957
|
+
}
|
|
2958
|
+
interface LintIssue {
|
|
2959
|
+
code: string;
|
|
2960
|
+
message: string;
|
|
2961
|
+
severity: LintSeverity;
|
|
2962
|
+
nodeId: string;
|
|
2963
|
+
range: {
|
|
2964
|
+
start: number;
|
|
2965
|
+
end: number;
|
|
2966
|
+
} | null;
|
|
2967
|
+
fix?: {
|
|
2968
|
+
description: string;
|
|
2969
|
+
apply: () => void;
|
|
2970
|
+
};
|
|
2971
|
+
}
|
|
2972
|
+
interface LintResult {
|
|
2973
|
+
issues: LintIssue[];
|
|
2974
|
+
errorCount: number;
|
|
2975
|
+
warningCount: number;
|
|
2976
|
+
infoCount: number;
|
|
2977
|
+
hintCount: number;
|
|
2978
|
+
}
|
|
2979
|
+
declare class Linter {
|
|
2980
|
+
private rules;
|
|
2981
|
+
constructor();
|
|
2982
|
+
/**
|
|
2983
|
+
* Register a lint rule.
|
|
2984
|
+
*/
|
|
2985
|
+
register(rule: LintRule): void;
|
|
2986
|
+
/**
|
|
2987
|
+
* Unregister a lint rule.
|
|
2988
|
+
*/
|
|
2989
|
+
unregister(code: string): void;
|
|
2990
|
+
/**
|
|
2991
|
+
* Lint a RedNode tree.
|
|
2992
|
+
*/
|
|
2993
|
+
lint(root: RedNode, source: string): LintResult;
|
|
2994
|
+
private registerBuiltinRules;
|
|
2995
|
+
}
|
|
2996
|
+
|
|
2997
|
+
/**
|
|
2998
|
+
* DocumentEngine — Symbol Types
|
|
2999
|
+
*
|
|
3000
|
+
* Symbol system for definitions and references in BBCode documents.
|
|
3001
|
+
* Enables: go-to-definition, find-all-references, rename, etc.
|
|
3002
|
+
*
|
|
3003
|
+
* Inspired by Language Server Protocol (LSP).
|
|
3004
|
+
*/
|
|
3005
|
+
|
|
3006
|
+
type SymbolKind = 'tag' | 'id_definition' | 'id_reference' | 'class' | 'property' | 'variable' | 'function' | 'module' | 'snippet' | 'template' | 'other';
|
|
3007
|
+
interface SymbolInfo {
|
|
3008
|
+
/** Unique ID for this symbol */
|
|
3009
|
+
id: string;
|
|
3010
|
+
/** Symbol name (e.g. 'hero', 'my-block') */
|
|
3011
|
+
name: string;
|
|
3012
|
+
/** Symbol kind */
|
|
3013
|
+
kind: SymbolKind;
|
|
3014
|
+
/** The node that defines this symbol */
|
|
3015
|
+
definitionNodeId: NodeId;
|
|
3016
|
+
/** Source range of the definition */
|
|
3017
|
+
definitionRange: SourceRange;
|
|
3018
|
+
/** Container node (e.g. the document) */
|
|
3019
|
+
containerNodeId: NodeId;
|
|
3020
|
+
/** All references to this symbol */
|
|
3021
|
+
references: Reference[];
|
|
3022
|
+
/** Additional data */
|
|
3023
|
+
data?: Record<string, unknown>;
|
|
3024
|
+
}
|
|
3025
|
+
interface Reference {
|
|
3026
|
+
nodeId: NodeId;
|
|
3027
|
+
range: SourceRange;
|
|
3028
|
+
kind: 'definition' | 'reference' | 'implementation';
|
|
3029
|
+
}
|
|
3030
|
+
interface SymbolSearchResult {
|
|
3031
|
+
symbol: SymbolInfo;
|
|
3032
|
+
score: number;
|
|
3033
|
+
}
|
|
3034
|
+
|
|
3035
|
+
/**
|
|
3036
|
+
* DocumentEngine — SymbolTable
|
|
3037
|
+
*
|
|
3038
|
+
* Tracks symbols (definitions and references) in the document.
|
|
3039
|
+
* Enables IDE features like:
|
|
3040
|
+
* - Go to definition
|
|
3041
|
+
* - Find all references
|
|
3042
|
+
* - Rename symbol
|
|
3043
|
+
* - Document outline
|
|
3044
|
+
*
|
|
3045
|
+
* In BBCode, symbols include:
|
|
3046
|
+
* - [id=hero] → Definition
|
|
3047
|
+
* - [goto=hero] → Reference
|
|
3048
|
+
* - Named anchors and targets
|
|
3049
|
+
*/
|
|
3050
|
+
|
|
3051
|
+
declare class SymbolTable {
|
|
3052
|
+
private symbols;
|
|
3053
|
+
private nodeToSymbolId;
|
|
3054
|
+
/**
|
|
3055
|
+
* Build the symbol table from a RedNode tree.
|
|
3056
|
+
*/
|
|
3057
|
+
build(root: RedNode): void;
|
|
3058
|
+
/**
|
|
3059
|
+
* Get a symbol by name.
|
|
3060
|
+
*/
|
|
3061
|
+
get(name: string): SymbolInfo | undefined;
|
|
3062
|
+
/**
|
|
3063
|
+
* Get the symbol associated with a node.
|
|
3064
|
+
*/
|
|
3065
|
+
getSymbolForNode(nodeId: string): SymbolInfo | undefined;
|
|
3066
|
+
/**
|
|
3067
|
+
* Search for symbols by name.
|
|
3068
|
+
*/
|
|
3069
|
+
search(query: string, maxResults?: number): SymbolSearchResult[];
|
|
3070
|
+
/**
|
|
3071
|
+
* Get all symbols.
|
|
3072
|
+
*/
|
|
3073
|
+
getAll(): SymbolInfo[];
|
|
3074
|
+
/**
|
|
3075
|
+
* Clear all symbols.
|
|
3076
|
+
*/
|
|
3077
|
+
clear(): void;
|
|
3078
|
+
private addSymbol;
|
|
3079
|
+
private createSymbolId;
|
|
3080
|
+
}
|
|
3081
|
+
|
|
3082
|
+
/**
|
|
3083
|
+
* DocumentEngine — RenderPipeline
|
|
3084
|
+
*
|
|
3085
|
+
* The Render Pipeline transforms a DocumentModel into renderable output.
|
|
3086
|
+
* It sits between the model and the view layer.
|
|
3087
|
+
*
|
|
3088
|
+
* The pipeline is:
|
|
3089
|
+
* DocumentModel → RenderTree → HTML/SVG/Canvas/React
|
|
3090
|
+
*
|
|
3091
|
+
* Plugins can intercept at any stage:
|
|
3092
|
+
* - Add/transform render nodes
|
|
3093
|
+
* - Provide custom renderers for custom tags
|
|
3094
|
+
* - Post-process the output
|
|
3095
|
+
*/
|
|
3096
|
+
|
|
3097
|
+
type RenderPhase = 'build' | 'transform' | 'serialize';
|
|
3098
|
+
interface RenderHook {
|
|
3099
|
+
phase: RenderPhase;
|
|
3100
|
+
hook: (node: RenderNode, context: RenderContext) => RenderNode | null;
|
|
3101
|
+
}
|
|
3102
|
+
interface RenderContext {
|
|
3103
|
+
variant: RenderVariant;
|
|
3104
|
+
registry: TagRegistry;
|
|
3105
|
+
source: string;
|
|
3106
|
+
}
|
|
3107
|
+
declare class RenderPipeline {
|
|
3108
|
+
private hooks;
|
|
3109
|
+
/**
|
|
3110
|
+
* Register a render hook.
|
|
3111
|
+
*/
|
|
3112
|
+
register(hook: RenderHook): void;
|
|
3113
|
+
/**
|
|
3114
|
+
* Unregister a render hook.
|
|
3115
|
+
*/
|
|
3116
|
+
unregister(hook: RenderHook): void;
|
|
3117
|
+
/**
|
|
3118
|
+
* Render a RedNode tree to a string.
|
|
3119
|
+
*/
|
|
3120
|
+
render(root: RedNode, variant: RenderVariant | undefined, registry: TagRegistry, source?: string): string;
|
|
3121
|
+
/**
|
|
3122
|
+
* Build a RenderNode tree from a RedNode tree.
|
|
3123
|
+
*/
|
|
3124
|
+
private buildRenderTree;
|
|
3125
|
+
}
|
|
3126
|
+
|
|
3127
|
+
/**
|
|
3128
|
+
* DocumentEngine — PluginRegistry
|
|
3129
|
+
*
|
|
3130
|
+
* Manages the lifecycle of plugins/extensions.
|
|
3131
|
+
* Each plugin registers contributions (tags, commands, validators, etc.)
|
|
3132
|
+
* and can be loaded/unloaded at runtime.
|
|
3133
|
+
*
|
|
3134
|
+
* Inspired by VSCode's extension system.
|
|
3135
|
+
*/
|
|
3136
|
+
|
|
3137
|
+
interface PluginManifest {
|
|
3138
|
+
name: string;
|
|
3139
|
+
version: string;
|
|
3140
|
+
description?: string;
|
|
3141
|
+
author?: string;
|
|
3142
|
+
/** Dependencies on other plugins */
|
|
3143
|
+
dependencies?: string[];
|
|
3144
|
+
}
|
|
3145
|
+
interface PluginContribution {
|
|
3146
|
+
/** Tags to register */
|
|
3147
|
+
tags?: TagDefinition[];
|
|
3148
|
+
/** Commands to register */
|
|
3149
|
+
commands?: Command[];
|
|
3150
|
+
/** Semantic validators */
|
|
3151
|
+
validators?: Validator[];
|
|
3152
|
+
/** Lint rules */
|
|
3153
|
+
lintRules?: LintRule[];
|
|
3154
|
+
/** Render hooks */
|
|
3155
|
+
renderHooks?: RenderHook[];
|
|
3156
|
+
/** CSS to inject */
|
|
3157
|
+
styles?: string[];
|
|
3158
|
+
/** Initialization function */
|
|
3159
|
+
activate?: () => void | Promise<void>;
|
|
3160
|
+
/** Cleanup function */
|
|
3161
|
+
deactivate?: () => void;
|
|
3162
|
+
}
|
|
3163
|
+
interface Plugin {
|
|
3164
|
+
manifest: PluginManifest;
|
|
3165
|
+
contributions: PluginContribution;
|
|
3166
|
+
/** Whether the plugin is currently active */
|
|
3167
|
+
active: boolean;
|
|
3168
|
+
}
|
|
3169
|
+
declare class PluginRegistry {
|
|
3170
|
+
private plugins;
|
|
3171
|
+
/**
|
|
3172
|
+
* Register a plugin.
|
|
3173
|
+
*/
|
|
3174
|
+
register(manifest: PluginManifest, contributions: PluginContribution): Plugin;
|
|
3175
|
+
/**
|
|
3176
|
+
* Unregister and deactivate a plugin.
|
|
3177
|
+
*/
|
|
3178
|
+
unregister(name: string): boolean;
|
|
3179
|
+
/**
|
|
3180
|
+
* Get a plugin by name.
|
|
3181
|
+
*/
|
|
3182
|
+
get(name: string): Plugin | undefined;
|
|
3183
|
+
/**
|
|
3184
|
+
* Get all registered plugins.
|
|
3185
|
+
*/
|
|
3186
|
+
getAll(): Plugin[];
|
|
3187
|
+
/**
|
|
3188
|
+
* Activate a plugin (calls activate function).
|
|
3189
|
+
*/
|
|
3190
|
+
activate(plugin: Plugin): void;
|
|
3191
|
+
/**
|
|
3192
|
+
* Deactivate a plugin (calls deactivate function).
|
|
3193
|
+
*/
|
|
3194
|
+
deactivate(plugin: Plugin): void;
|
|
3195
|
+
/**
|
|
3196
|
+
* Activate all registered plugins.
|
|
3197
|
+
*/
|
|
3198
|
+
activateAll(): void;
|
|
3199
|
+
/**
|
|
3200
|
+
* Deactivate all plugins.
|
|
3201
|
+
*/
|
|
3202
|
+
deactivateAll(): void;
|
|
3203
|
+
}
|
|
3204
|
+
|
|
3205
|
+
/**
|
|
3206
|
+
* DocumentEngine — PluginAPI
|
|
3207
|
+
*
|
|
3208
|
+
* The public API exposed to plugins.
|
|
3209
|
+
* Plugin authors use this to register tags, commands, validators, etc.
|
|
3210
|
+
*
|
|
3211
|
+
* Inspired by VSCode's `vscode` module.
|
|
3212
|
+
*/
|
|
3213
|
+
|
|
3214
|
+
declare class PluginAPI {
|
|
3215
|
+
readonly model: DocumentModel;
|
|
3216
|
+
readonly tags: TagRegistry;
|
|
3217
|
+
readonly commands: CommandRegistry;
|
|
3218
|
+
readonly semantic: SemanticAnalyzer;
|
|
3219
|
+
readonly linter: Linter;
|
|
3220
|
+
readonly renderer: RenderPipeline;
|
|
3221
|
+
readonly plugins: PluginRegistry;
|
|
3222
|
+
constructor(model: DocumentModel);
|
|
3223
|
+
/**
|
|
3224
|
+
* Register a plugin with all its contributions.
|
|
3225
|
+
*
|
|
3226
|
+
* Example:
|
|
3227
|
+
* ```ts
|
|
3228
|
+
* api.registerPlugin({
|
|
3229
|
+
* name: 'my-plugin',
|
|
3230
|
+
* version: '1.0.0'
|
|
3231
|
+
* }, {
|
|
3232
|
+
* tags: [{ name: 'blur', kind: 'custom', ... }],
|
|
3233
|
+
* commands: [{ id: 'my-command', ... }]
|
|
3234
|
+
* })
|
|
3235
|
+
* ```
|
|
3236
|
+
*/
|
|
3237
|
+
registerPlugin(manifest: PluginManifest, contributions: PluginContribution): void;
|
|
3238
|
+
/**
|
|
3239
|
+
* Unregister a plugin and remove its contributions.
|
|
3240
|
+
*/
|
|
3241
|
+
unregisterPlugin(name: string): void;
|
|
3242
|
+
}
|
|
3243
|
+
|
|
3244
|
+
/**
|
|
3245
|
+
* DocumentEngine — GreenNodePool (Structural Sharing via Interning)
|
|
3246
|
+
*
|
|
3247
|
+
* Roslyn-grade GreenNode Deduplication Pool.
|
|
3248
|
+
*
|
|
3249
|
+
* Ensures that structurally identical subtrees share the EXACT SAME
|
|
3250
|
+
* GreenNode memory address in RAM. This is structural sharing:
|
|
3251
|
+
* when the same BBCode block is pasted 10 times, the parser creates
|
|
3252
|
+
* GreenNodes for it ONCE, and all 10 copies reference the same objects.
|
|
3253
|
+
*
|
|
3254
|
+
* Architecture:
|
|
3255
|
+
* - Flyweight Pattern: GreenNodes are pure structural data without parent pointers.
|
|
3256
|
+
* - Hash-based lookup: O(1) amortized via _hash property on GreenNode.
|
|
3257
|
+
* - Reference-equality verification: identical children = same reference (because
|
|
3258
|
+
* children are interned first, bottom-up construction guarantees this).
|
|
3259
|
+
* - Per-document scope: interners are created per BBCodeDocumentModel, cleared on dispose.
|
|
3260
|
+
*
|
|
3261
|
+
* Key insight: because GreenNodes are immutable and children are interned bottom-up,
|
|
3262
|
+
* two nodes with the same (kind, text, widths, children-references) are structurally
|
|
3263
|
+
* identical. We use _hash for fast lookup, then verify with reference equality on
|
|
3264
|
+
* children.
|
|
3265
|
+
*
|
|
3266
|
+
* ─── Why this used to be broken (roadmap S2) ────────────────────────────────
|
|
3267
|
+
*
|
|
3268
|
+
* Green nodes used to carry an absolute `range`, and the two halves of the pool
|
|
3269
|
+
* disagreed about what to do with it:
|
|
3270
|
+
*
|
|
3271
|
+
* - `internLeaf` keyed on `kind:text` alone, so the second `"Hello"` in a
|
|
3272
|
+
* document got back the node built for the FIRST one — carrying the first
|
|
3273
|
+
* one's offsets. Enabling the interner made every position downstream lie
|
|
3274
|
+
* silently: incremental reparse, `findNodeAtOffset`, diagnostics, the
|
|
3275
|
+
* Monaco↔AST mapping.
|
|
3276
|
+
* - `internNode` did the opposite and required identical start AND end before
|
|
3277
|
+
* deduplicating, which no two distinct occurrences can have. The same block
|
|
3278
|
+
* pasted ten times gave 0% dedup and 100% of the scanning cost.
|
|
3279
|
+
*
|
|
3280
|
+
* Neither was fixable while position lived on the green node, because those are
|
|
3281
|
+
* the only two options: ignore it and lie, or respect it and never match. The
|
|
3282
|
+
* fix was to remove position from the green tree entirely — see `GreenNode.ts`.
|
|
3283
|
+
* Now `kind:text:width` genuinely identifies a leaf, and interning is simply
|
|
3284
|
+
* correct.
|
|
3285
|
+
*/
|
|
3286
|
+
|
|
3287
|
+
/**
|
|
3288
|
+
* How much of the tree to deduplicate.
|
|
3289
|
+
*
|
|
3290
|
+
* Measured on the 19.6 KB reference document, against no interning at all:
|
|
3291
|
+
*
|
|
3292
|
+
* | mode | parse time | green tree memory |
|
|
3293
|
+
* |----------|------------|-------------------|
|
|
3294
|
+
* | `leaves` | +16% | −42% |
|
|
3295
|
+
* | `full` | +110% | −56% |
|
|
3296
|
+
*
|
|
3297
|
+
* `leaves` is the default because it buys three quarters of the memory for a
|
|
3298
|
+
* seventh of the time. Interning an internal node means hashing its whole
|
|
3299
|
+
* subtree — `_hash` is lazy, so a parse that never interns never pays for it —
|
|
3300
|
+
* and then walking its children to verify a bucket hit. Leaves cost one string
|
|
3301
|
+
* key and one Map lookup, and they are where the duplication is: the reference
|
|
3302
|
+
* document has 930 leaves and only 122 distinct ones.
|
|
3303
|
+
*/
|
|
3304
|
+
type InterningMode = 'leaves' | 'full';
|
|
3305
|
+
|
|
3306
|
+
/**
|
|
3307
|
+
* DocumentEngine — BBCodeDocumentModel
|
|
3308
|
+
*
|
|
3309
|
+
* A DocumentModel subclass that uses the DocumentEngine's built-in
|
|
3310
|
+
* BBCode Lexer + Parser to produce GreenNode/RedNode trees.
|
|
3311
|
+
*
|
|
3312
|
+
* This COMPLETELY replaces the old BBCode parser (MiliastryNovaFeatures/BBCode/Parser).
|
|
3313
|
+
* The old parser is DEPRECATED.
|
|
3314
|
+
*
|
|
3315
|
+
* Architecture:
|
|
3316
|
+
* BBCode text
|
|
3317
|
+
* ↓ BBCodeLexer.scanBBCode()
|
|
3318
|
+
* BBCodeToken[]
|
|
3319
|
+
* ↓ Parser.parseTokensToGreen()
|
|
3320
|
+
* GreenNode
|
|
3321
|
+
* ↓ BBCodeToGreenNode.greenToRedNode()
|
|
3322
|
+
* RedNode
|
|
3323
|
+
* ↓ HTMLRenderer.render()
|
|
3324
|
+
* HTML preview
|
|
3325
|
+
*
|
|
3326
|
+
* Usage:
|
|
3327
|
+
* const model = new BBCodeDocumentModel({ source: '[b]Hello[/b]' })
|
|
3328
|
+
* const html = new HTMLRenderer().render(model.redRoot!)
|
|
3329
|
+
*/
|
|
3330
|
+
|
|
3331
|
+
interface BBCodeDocumentModelOptions extends DocumentModelOptions {
|
|
3332
|
+
/** Source BBCode text to parse */
|
|
3333
|
+
source?: string;
|
|
3334
|
+
/** Language identifier (default: 'bbcode') */
|
|
3335
|
+
language?: string;
|
|
3336
|
+
/** Enforce strict tag nesting (disables osu! auto-close legacy behavior) */
|
|
3337
|
+
strictMode?: boolean;
|
|
3338
|
+
/** BBCode dialect to parse against ('osu' | 'miliastry' | 'lyne'). Default: 'miliastry'. */
|
|
3339
|
+
dialect?: BBCodeDialect;
|
|
3340
|
+
/** Alias for dialect ('osu' | 'miliastry' | 'lyne') */
|
|
3341
|
+
mode?: BBCodeDialect;
|
|
3342
|
+
/**
|
|
3343
|
+
* Deduplicate structurally identical green nodes across the document.
|
|
3344
|
+
*
|
|
3345
|
+
* Off by default. It is correct now — see the header of `GreenNodePool.ts`
|
|
3346
|
+
* for why it was not — but it is a memory/latency trade, and this engine's
|
|
3347
|
+
* declared budget is latency: on the 19.6 KB reference document `'leaves'`
|
|
3348
|
+
* costs 16% more parse time to save 42% of the green tree's memory. Turn it
|
|
3349
|
+
* on for workloads that hold many documents at once; leave it off to type
|
|
3350
|
+
* into one.
|
|
3351
|
+
*/
|
|
3352
|
+
interning?: InterningMode;
|
|
3353
|
+
}
|
|
3354
|
+
declare class BBCodeDocumentModel extends DocumentModel {
|
|
3355
|
+
private _strictMode;
|
|
3356
|
+
private _dialect;
|
|
3357
|
+
/** Per-document interner, or null when interning is off. */
|
|
3358
|
+
private _interner;
|
|
3359
|
+
/** Memoized parser `extraTags`, rebuilt only when the registry changes. */
|
|
3360
|
+
private _extraTags;
|
|
3361
|
+
private _extraTagsVersion;
|
|
3362
|
+
constructor(options?: BBCodeDocumentModelOptions);
|
|
3363
|
+
get dialect(): BBCodeDialect;
|
|
3364
|
+
get root(): RedNode | null;
|
|
3365
|
+
toHTML(renderer?: HTMLRenderer): string;
|
|
3366
|
+
/**
|
|
3367
|
+
* Parse BBCode text directly to a GreenNode tree using the
|
|
3368
|
+
* DocumentEngine's built-in BBCode Lexer + Parser.
|
|
3369
|
+
* No dependency on the deprecated old parser.
|
|
3370
|
+
*/
|
|
3371
|
+
protected parseToGreen(source: string, options?: ReparseParseOptions): GreenNode;
|
|
3372
|
+
/**
|
|
3373
|
+
* Build a RedNode from a GreenNode, extracting metadata
|
|
3374
|
+
* from BBCode attributes in the process.
|
|
3375
|
+
*/
|
|
3376
|
+
protected buildRedFromGreen(green: GreenNode): RedNode;
|
|
3377
|
+
/**
|
|
3378
|
+
* Reuse-aware red build for the incremental path: adopts old red subtrees
|
|
3379
|
+
* wherever the splice shared their green by reference. See the contract on
|
|
3380
|
+
* `greenToRedNodeReusing` — the old tree is consumed.
|
|
3381
|
+
*/
|
|
3382
|
+
protected buildRedFromGreenReusing(green: GreenNode, oldRed: RedNode, stats?: {
|
|
3383
|
+
adopted: number;
|
|
3384
|
+
}): RedNode;
|
|
3385
|
+
/**
|
|
3386
|
+
* Serialize the red tree back to BBCode, so `transact`/`undo`/`redo` can
|
|
3387
|
+
* rebuild from text. Target 'miliastry' keeps native tags (gradient, grow,
|
|
3388
|
+
* …) as-is instead of expanding them, which is what round-tripping needs.
|
|
3389
|
+
*/
|
|
3390
|
+
protected exportSource(root: RedNode): string;
|
|
3391
|
+
}
|
|
3392
|
+
|
|
3393
|
+
/**
|
|
3394
|
+
* DocumentEngine — Position transforms
|
|
3395
|
+
*
|
|
3396
|
+
* Where does a position end up after a text edit?
|
|
3397
|
+
*
|
|
3398
|
+
* This is the primitive collaboration stands on: keeping carets, selections
|
|
3399
|
+
* and remote cursors pointing at the same *content* while the text shifts
|
|
3400
|
+
* under them — whether the edit came from the local user, a remote peer, or
|
|
3401
|
+
* a programmatic `transact`. See `QuasarCollab.MD` for the architecture; the
|
|
3402
|
+
* short version is that Quasar syncs TEXT, so mapping positions through
|
|
3403
|
+
* `TextChange`s is all the transform machinery the engine needs. This module
|
|
3404
|
+
* transforms positions, not changes-against-changes: convergence of
|
|
3405
|
+
* concurrent edits is the CRDT's job, not ours.
|
|
3406
|
+
*/
|
|
3407
|
+
|
|
3408
|
+
/**
|
|
3409
|
+
* Which side a position sticks to when an edit happens exactly at it.
|
|
3410
|
+
*
|
|
3411
|
+
* A caret usually wants `'right'`: text inserted at the caret by someone else
|
|
3412
|
+
* should push it forward (you keep typing after their insertion). The start
|
|
3413
|
+
* of a persistent highlight usually wants `'left'`: text inserted exactly at
|
|
3414
|
+
* its start belongs before the highlight, not inside it.
|
|
3415
|
+
*/
|
|
3416
|
+
type TransformBias = 'left' | 'right';
|
|
3417
|
+
/**
|
|
3418
|
+
* Map `offset` through one change or an ordered sequence of changes.
|
|
3419
|
+
*
|
|
3420
|
+
* Rules, in order:
|
|
3421
|
+
* - strictly before the edit → unchanged;
|
|
3422
|
+
* - strictly after the replaced span → shifted by the length delta;
|
|
3423
|
+
* - inside the replaced span → collapsed to the edit's boundary (`'left'` →
|
|
3424
|
+
* where the replacement starts, `'right'` → where it ends). A position
|
|
3425
|
+
* inside deleted text has no content to point at anymore; the boundary is
|
|
3426
|
+
* the only honest answer;
|
|
3427
|
+
* - exactly at a pure insertion point → `bias` decides which side of the
|
|
3428
|
+
* inserted text it lands on.
|
|
3429
|
+
*/
|
|
3430
|
+
declare function transformOffset(offset: number, changes: TextChange | readonly TextChange[], bias?: TransformBias): number;
|
|
3431
|
+
/**
|
|
3432
|
+
* Map a `{start, end}` range through one change or a sequence.
|
|
3433
|
+
*
|
|
3434
|
+
* The start sticks RIGHT and the end sticks LEFT, which is what preserves the
|
|
3435
|
+
* selected content: text inserted exactly at a boundary lands OUTSIDE the
|
|
3436
|
+
* range, so the range keeps covering exactly the characters it covered — it
|
|
3437
|
+
* neither absorbs a neighbour's insertion nor leaks its own content. The
|
|
3438
|
+
* result is clamped so it can never come out inverted; a range entirely
|
|
3439
|
+
* inside deleted text collapses to a point at the edit boundary.
|
|
3440
|
+
*/
|
|
3441
|
+
declare function transformRange(range: {
|
|
3442
|
+
start: number;
|
|
3443
|
+
end: number;
|
|
3444
|
+
}, changes: TextChange | readonly TextChange[]): {
|
|
3445
|
+
start: number;
|
|
3446
|
+
end: number;
|
|
3447
|
+
};
|
|
3448
|
+
|
|
3449
|
+
/**
|
|
3450
|
+
* DocumentEngine — BoxDrawer
|
|
3451
|
+
*
|
|
3452
|
+
* Drawer-style open/close animation for the `<details>` elements that
|
|
3453
|
+
* HTMLRenderer emits for `[box]` and `[spoilerbox]`. The box grows downwards
|
|
3454
|
+
* and progressively uncovers its content, which stays anchored under the
|
|
3455
|
+
* summary instead of popping in all at once.
|
|
3456
|
+
*
|
|
3457
|
+
* This lives in Quasar rather than in a consumer because Quasar is what emits
|
|
3458
|
+
* the `<details>` in the first place — the same reason `morphHTML` lives here.
|
|
3459
|
+
* Any host that renders Quasar output gets the behaviour by binding it once.
|
|
3460
|
+
*
|
|
3461
|
+
* Why not CSS: while a `<details>` is closed its content is not rendered, so
|
|
3462
|
+
* there is no height to interpolate between. `::details-content` combined with
|
|
3463
|
+
* `interpolate-size: allow-keywords` would solve it, but it is Chromium-only in
|
|
3464
|
+
* practice and hosts running on WebKit would silently lose the animation.
|
|
3465
|
+
* Measuring the real height and driving it with the Web Animations API works on
|
|
3466
|
+
* every engine.
|
|
3467
|
+
*
|
|
3468
|
+
* Heights are measured, not computed from summary + padding + borders, because
|
|
3469
|
+
* that arithmetic breaks as soon as a theme puts margins on the summary or the
|
|
3470
|
+
* boxes are nested. Toggling `open` and reading the rect yields the exact
|
|
3471
|
+
* height the element will settle at, whatever the stylesheet does.
|
|
3472
|
+
*/
|
|
3473
|
+
interface BoxDrawerOptions {
|
|
3474
|
+
/** Length of a full open or close, in milliseconds. Partial travel is scaled down. */
|
|
3475
|
+
durationMs?: number;
|
|
3476
|
+
/** CSS easing function. */
|
|
3477
|
+
easing?: string;
|
|
3478
|
+
}
|
|
3479
|
+
/**
|
|
3480
|
+
* Animate one `<details>` to its opposite state, reversing cleanly when it is
|
|
3481
|
+
* already mid-animation.
|
|
3482
|
+
*
|
|
3483
|
+
* Exported so hosts that toggle boxes programmatically (a keyboard command, an
|
|
3484
|
+
* "expand all" action) go through the same animation as a click.
|
|
3485
|
+
*/
|
|
3486
|
+
declare function toggleBoxWithDrawer(details: HTMLDetailsElement, options?: BoxDrawerOptions): void;
|
|
3487
|
+
/**
|
|
3488
|
+
* Intercept the native toggle of every box inside `root`.
|
|
3489
|
+
*
|
|
3490
|
+
* The listener is delegated rather than attached per box: hosts typically feed
|
|
3491
|
+
* the preview through `morphHTML` on every keystroke, and per-element listeners
|
|
3492
|
+
* would be torn down and re-attached continuously.
|
|
3493
|
+
*
|
|
3494
|
+
* @param root - container holding the rendered BBCode
|
|
3495
|
+
* @returns disposer that removes the listener
|
|
3496
|
+
*/
|
|
3497
|
+
declare function bindBoxDrawer(root: HTMLElement, options?: BoxDrawerOptions): () => void;
|
|
3498
|
+
|
|
3499
|
+
/**
|
|
3500
|
+
* DocumentEngine — Visuals
|
|
3501
|
+
*
|
|
3502
|
+
* Catálogo de estilos visuales por plataforma para la previsualización
|
|
3503
|
+
* de BBCode. Cada plataforma puede tener su propia apariencia sin
|
|
3504
|
+
* modificar el renderer HTML.
|
|
3505
|
+
*
|
|
3506
|
+
* Uso:
|
|
3507
|
+
* // Importar como CSS directo (soportado por Next.js, Vite, Webpack):
|
|
3508
|
+
* import 'MiliastryPlatform/DocumentEngine/Visuals/osu.css'
|
|
3509
|
+
*
|
|
3510
|
+
* // O desde un componente React:
|
|
3511
|
+
* import 'MiliastryPlatform/DocumentEngine/Visuals/osu.css'
|
|
3512
|
+
* // Luego en el JSX: <div className="bbcode-preview" ...
|
|
3513
|
+
*
|
|
3514
|
+
* Para cambiar de tema visual, solo cambia el import del CSS.
|
|
3515
|
+
*
|
|
3516
|
+
* Temas disponibles:
|
|
3517
|
+
* - osu → Estilo visual inspirado en los foros de osu!
|
|
3518
|
+
* - miliastry → Estilo por defecto de Miliastry (próximamente)
|
|
3519
|
+
*/
|
|
3520
|
+
|
|
3521
|
+
declare const visualThemes: readonly [{
|
|
3522
|
+
readonly id: "osu";
|
|
3523
|
+
readonly name: "osu! Forum Style";
|
|
3524
|
+
readonly description: "Estilo visual inspirado en los foros de osu!";
|
|
3525
|
+
readonly cssFile: "osu.css";
|
|
3526
|
+
}, {
|
|
3527
|
+
readonly id: "lyne";
|
|
3528
|
+
readonly name: "Lyne Style";
|
|
3529
|
+
readonly description: "Estilo visual cyberpunk con cortes a 45° inspirado en Lyne";
|
|
3530
|
+
readonly cssFile: "lyne.css";
|
|
3531
|
+
}];
|
|
3532
|
+
type VisualThemeId = (typeof visualThemes)[number]['id'];
|
|
3533
|
+
|
|
3534
|
+
/**
|
|
3535
|
+
* Quasar — Color Utilities
|
|
3536
|
+
*
|
|
3537
|
+
* Standalone color functions for gradient/grow/exporter interpolation.
|
|
3538
|
+
* No external dependencies — pure math.
|
|
3539
|
+
*
|
|
3540
|
+
* Ported from TextStudio/lib/color.ts (originally backed by culori).
|
|
3541
|
+
* These are the minimal subset needed for internal tag export.
|
|
3542
|
+
*/
|
|
3543
|
+
type Easing = 'linear' | 'easeIn' | 'easeOut' | 'easeInOut';
|
|
3544
|
+
|
|
3545
|
+
/**
|
|
3546
|
+
* Quasar — Tree Transformers
|
|
3547
|
+
*
|
|
3548
|
+
* Apply effects (gradient, grow, rainbow, central, multi) across an entire RedNode tree.
|
|
3549
|
+
* These functions walk the tree, track global text position,
|
|
3550
|
+
* and wrap each text leaf in internal effect nodes
|
|
3551
|
+
* with position metadata so the TagRegistry handlers produce
|
|
3552
|
+
* correctly interpolated output.
|
|
3553
|
+
*
|
|
3554
|
+
* DESIGN:
|
|
3555
|
+
* Two-pass approach:
|
|
3556
|
+
* 1. Collect total text length (for normalization)
|
|
3557
|
+
* 2. Walk again, wrapping text nodes with effect nodes
|
|
3558
|
+
*
|
|
3559
|
+
* Non-text nodes (img, video, audio, code blocks) are preserved unchanged.
|
|
3560
|
+
* Structural nodes (box, quote, list) recurse into children.
|
|
3561
|
+
* Formatting nodes (b, i, u, color) recurse — effects go INSIDE formatting.
|
|
3562
|
+
*
|
|
3563
|
+
* Example:
|
|
3564
|
+
* Input: [box][b]Hello[/b] World[/box]
|
|
3565
|
+
* Output: [box][b][gradient][b]H[/b][gradient][b]e[/b]...[/b]
|
|
3566
|
+
* [gradient] W[/gradient]...[/gradient]
|
|
3567
|
+
*
|
|
3568
|
+
* Actually simpler — each text leaf gets its own gradient node with
|
|
3569
|
+
* globalOffset metadata, and the handler does the interpolation.
|
|
3570
|
+
*/
|
|
3571
|
+
|
|
3572
|
+
interface GradientEffect {
|
|
3573
|
+
kind: 'gradient';
|
|
3574
|
+
colors: string[];
|
|
3575
|
+
unit?: 'character' | 'word' | 'line';
|
|
3576
|
+
easing?: Easing;
|
|
3577
|
+
}
|
|
3578
|
+
interface GrowEffect {
|
|
3579
|
+
kind: 'grow';
|
|
3580
|
+
min?: number;
|
|
3581
|
+
max?: number;
|
|
3582
|
+
cycles?: number;
|
|
3583
|
+
}
|
|
3584
|
+
interface RainbowEffect {
|
|
3585
|
+
kind: 'rainbow';
|
|
3586
|
+
saturation?: number;
|
|
3587
|
+
lightness?: number;
|
|
3588
|
+
spread?: number;
|
|
3589
|
+
offset?: number;
|
|
3590
|
+
}
|
|
3591
|
+
interface CentralGradientEffect {
|
|
3592
|
+
kind: 'central_gradient';
|
|
3593
|
+
colors: string[];
|
|
3594
|
+
unit?: 'character' | 'word' | 'line';
|
|
3595
|
+
easing?: Easing;
|
|
3596
|
+
}
|
|
3597
|
+
interface MultiGradientEffect {
|
|
3598
|
+
kind: 'multi_gradient';
|
|
3599
|
+
colors: string[];
|
|
3600
|
+
mode?: 'wave' | 'pulse';
|
|
3601
|
+
unit?: 'character' | 'word' | 'line';
|
|
3602
|
+
easing?: Easing;
|
|
3603
|
+
}
|
|
3604
|
+
type TreeEffect = GradientEffect | GrowEffect | RainbowEffect | CentralGradientEffect | MultiGradientEffect;
|
|
3605
|
+
/**
|
|
3606
|
+
* Count total plain text length across all text leaves in a tree.
|
|
3607
|
+
*/
|
|
3608
|
+
declare function countTextLength(node: RedNode): number;
|
|
3609
|
+
/**
|
|
3610
|
+
* Apply a gradient effect across an entire RedNode tree.
|
|
3611
|
+
*
|
|
3612
|
+
* The gradient is computed across ALL text content in the tree,
|
|
3613
|
+
* so color transitions span across structural boundaries:
|
|
3614
|
+
*
|
|
3615
|
+
* [b]Hello[/b] World → [b][color=#FF0000]H[/color]...[/b][color=#00FF00]W[/color]...
|
|
3616
|
+
*
|
|
3617
|
+
* @param root The RedNode tree to transform
|
|
3618
|
+
* @param colors Array of hex colors to interpolate across
|
|
3619
|
+
* @param options.unit Split unit: 'character' (default), 'word', or 'line'
|
|
3620
|
+
* @param options.easing Easing function: 'linear' (default), 'easeIn', 'easeOut', 'easeInOut'
|
|
3621
|
+
* @returns A new RedNode tree with gradient applied
|
|
3622
|
+
*/
|
|
3623
|
+
declare function applyGradient(root: RedNode, colors: string[], options?: {
|
|
3624
|
+
unit?: 'character' | 'word' | 'line';
|
|
3625
|
+
easing?: Easing;
|
|
3626
|
+
}): RedNode;
|
|
3627
|
+
/**
|
|
3628
|
+
* Apply a grow (size wave) effect across an entire RedNode tree.
|
|
3629
|
+
*
|
|
3630
|
+
* Size oscillates via sine wave across ALL text content:
|
|
3631
|
+
*
|
|
3632
|
+
* [b]Hello[/b] World → [b][size=90]H[/size]...[/b][size=160]W[/size]...
|
|
3633
|
+
*
|
|
3634
|
+
* @param root The RedNode tree to transform
|
|
3635
|
+
* @param options.min Minimum size % (default: 90)
|
|
3636
|
+
* @param options.max Maximum size % (default: 160)
|
|
3637
|
+
* @param options.cycles Number of sine cycles across text (default: 1)
|
|
3638
|
+
* @returns A new RedNode tree with grow applied
|
|
3639
|
+
*/
|
|
3640
|
+
declare function applyGrow(root: RedNode, options?: {
|
|
3641
|
+
min?: number;
|
|
3642
|
+
max?: number;
|
|
3643
|
+
cycles?: number;
|
|
3644
|
+
}): RedNode;
|
|
3645
|
+
/**
|
|
3646
|
+
* Apply a rainbow effect across an entire RedNode tree.
|
|
3647
|
+
*
|
|
3648
|
+
* Hue rotates across ALL text content using HSL color space:
|
|
3649
|
+
*
|
|
3650
|
+
* [b]Hello[/b] World → [b][color=#FF0000]H[/color]...[/b][color=#00FF00]W[/color]...
|
|
3651
|
+
*
|
|
3652
|
+
* @param root The RedNode tree to transform
|
|
3653
|
+
* @param options.saturation Saturation 0-100 (default: 80)
|
|
3654
|
+
* @param options.lightness Lightness 0-100 (default: 60)
|
|
3655
|
+
* @param options.spread Hue spread in degrees (default: 300)
|
|
3656
|
+
* @param options.offset Starting hue offset in degrees (default: 0)
|
|
3657
|
+
* @returns A new RedNode tree with rainbow applied
|
|
3658
|
+
*/
|
|
3659
|
+
declare function applyRainbow(root: RedNode, options?: {
|
|
3660
|
+
saturation?: number;
|
|
3661
|
+
lightness?: number;
|
|
3662
|
+
spread?: number;
|
|
3663
|
+
offset?: number;
|
|
3664
|
+
}): RedNode;
|
|
3665
|
+
/**
|
|
3666
|
+
* Apply a central gradient across an entire RedNode tree.
|
|
3667
|
+
*
|
|
3668
|
+
* Gradient radiates from center outward (mirrored):
|
|
3669
|
+
*
|
|
3670
|
+
* [b]Hello[/b] World → [b][color=#FF0000]H[/color]...[/b][color=#00FF00]W[/color]...
|
|
3671
|
+
*
|
|
3672
|
+
* @param root The RedNode tree to transform
|
|
3673
|
+
* @param colors Array of hex colors to interpolate across
|
|
3674
|
+
* @param options.unit Split unit: 'character' (default), 'word', or 'line'
|
|
3675
|
+
* @param options.easing Easing function: 'linear' (default), 'easeIn', 'easeOut', 'easeInOut'
|
|
3676
|
+
* @returns A new RedNode tree with central gradient applied
|
|
3677
|
+
*/
|
|
3678
|
+
declare function applyCentralGradient(root: RedNode, colors: string[], options?: {
|
|
3679
|
+
unit?: 'character' | 'word' | 'line';
|
|
3680
|
+
easing?: Easing;
|
|
3681
|
+
}): RedNode;
|
|
3682
|
+
/**
|
|
3683
|
+
* Apply a multi gradient (wave or pulse) across an entire RedNode tree.
|
|
3684
|
+
*
|
|
3685
|
+
* Wave mode: sine wave pattern across colors.
|
|
3686
|
+
* Pulse mode: repeating gradient segments.
|
|
3687
|
+
*
|
|
3688
|
+
* @param root The RedNode tree to transform
|
|
3689
|
+
* @param colors Array of hex colors to interpolate across
|
|
3690
|
+
* @param options.mode 'wave' (default) or 'pulse'
|
|
3691
|
+
* @param options.unit Split unit: 'character' (default), 'word', or 'line'
|
|
3692
|
+
* @param options.easing Easing function: 'linear' (default), 'easeIn', 'easeOut', 'easeInOut'
|
|
3693
|
+
* @returns A new RedNode tree with multi gradient applied
|
|
3694
|
+
*/
|
|
3695
|
+
declare function applyMultiGradient(root: RedNode, colors: string[], options?: {
|
|
3696
|
+
mode?: 'wave' | 'pulse';
|
|
3697
|
+
unit?: 'character' | 'word' | 'line';
|
|
3698
|
+
easing?: Easing;
|
|
3699
|
+
}): RedNode;
|
|
3700
|
+
/**
|
|
3701
|
+
* Apply any supported effect across an entire RedNode tree.
|
|
3702
|
+
*/
|
|
3703
|
+
declare function applyEffect(root: RedNode, effect: TreeEffect): RedNode;
|
|
3704
|
+
|
|
3705
|
+
interface DomToSVGOptions {
|
|
3706
|
+
backgroundColor?: string;
|
|
3707
|
+
includeBackground?: boolean;
|
|
3708
|
+
/** Scale factor applied to all coordinates (default 1) */
|
|
3709
|
+
scale?: number;
|
|
3710
|
+
}
|
|
3711
|
+
interface SVGLayerInfo {
|
|
3712
|
+
id: string;
|
|
3713
|
+
tag: string;
|
|
3714
|
+
depth: number;
|
|
3715
|
+
x: number;
|
|
3716
|
+
y: number;
|
|
3717
|
+
width: number;
|
|
3718
|
+
height: number;
|
|
3719
|
+
type: 'image' | 'iframe' | 'audio' | 'background' | 'border' | 'group' | 'text';
|
|
3720
|
+
text?: string;
|
|
3721
|
+
}
|
|
3722
|
+
interface DomToSVGResult {
|
|
3723
|
+
svg: string;
|
|
3724
|
+
width: number;
|
|
3725
|
+
height: number;
|
|
3726
|
+
layerCount: number;
|
|
3727
|
+
layers: SVGLayerInfo[];
|
|
3728
|
+
}
|
|
3729
|
+
declare function domToSVG(root: HTMLElement, options?: DomToSVGOptions): string;
|
|
3730
|
+
declare function domToSVGResult(root: HTMLElement, options?: DomToSVGOptions): DomToSVGResult;
|
|
3731
|
+
|
|
3732
|
+
interface TransformResult {
|
|
3733
|
+
document: DocumentModel;
|
|
3734
|
+
transaction?: Transaction;
|
|
3735
|
+
}
|
|
3736
|
+
interface Transformer {
|
|
3737
|
+
/**
|
|
3738
|
+
* Transforms a DocumentModel.
|
|
3739
|
+
* Should ideally return a Transaction that contains the operations applied.
|
|
3740
|
+
*/
|
|
3741
|
+
transform(document: DocumentModel): TransformResult;
|
|
3742
|
+
}
|
|
3743
|
+
|
|
3744
|
+
declare class ASTOptimizer implements Transformer {
|
|
3745
|
+
transform(document: DocumentModel): TransformResult;
|
|
3746
|
+
private applyRules;
|
|
3747
|
+
private static VOID_TAGS;
|
|
3748
|
+
private static UNMERGEABLE;
|
|
3749
|
+
private isEmptyNode;
|
|
3750
|
+
/**
|
|
3751
|
+
* Two `paragraph` siblings with *nothing* between them.
|
|
3752
|
+
*
|
|
3753
|
+
* `paragraph` is in {@link UNMERGEABLE} for good reason — paragraphs are block
|
|
3754
|
+
* separators and collapsing them normally would change the document. But this
|
|
3755
|
+
* particular shape is one the optimizer creates itself and the parser never
|
|
3756
|
+
* does: paragraphs exist because a block element split the inline content, so
|
|
3757
|
+
* when a rule deletes that block (an empty `[notice]`, say) the two halves are
|
|
3758
|
+
* left as adjacent paragraphs that no longer have anything separating them.
|
|
3759
|
+
*
|
|
3760
|
+
* That was the whole non-idempotence bug. Exporting such a tree emits no
|
|
3761
|
+
* separator, so re-parsing folds the two paragraphs back into one and a second
|
|
3762
|
+
* optimizer pass finds more work to do. Merging here makes the tree agree with
|
|
3763
|
+
* its own round-trip.
|
|
3764
|
+
*
|
|
3765
|
+
* Verified empirically before relying on it: across 202.764 nodes (both real
|
|
3766
|
+
* fixtures plus 20.000 seeded random documents) the parser produced this shape
|
|
3767
|
+
* **zero** times, so the rule can only ever fire on optimizer-made trees.
|
|
3768
|
+
*
|
|
3769
|
+
* `spaces` must be empty — paragraphs separated by real spacing or blank lines
|
|
3770
|
+
* are a genuine authored break and must survive.
|
|
3771
|
+
*/
|
|
3772
|
+
private isOrphanedParagraphPair;
|
|
3773
|
+
private canMerge;
|
|
3774
|
+
private getRank;
|
|
3775
|
+
}
|
|
3776
|
+
|
|
3777
|
+
interface SineWaveOptions {
|
|
3778
|
+
minSize: number;
|
|
3779
|
+
maxSize: number;
|
|
3780
|
+
frequency: number;
|
|
3781
|
+
step: 'char' | 'word';
|
|
3782
|
+
selectionRange?: {
|
|
3783
|
+
start: number;
|
|
3784
|
+
end: number;
|
|
3785
|
+
};
|
|
3786
|
+
}
|
|
3787
|
+
declare class SineWaveTransformer implements Transformer {
|
|
3788
|
+
private options;
|
|
3789
|
+
constructor(options?: Partial<SineWaveOptions>);
|
|
3790
|
+
transform(document: DocumentModel): TransformResult;
|
|
3791
|
+
}
|
|
3792
|
+
|
|
3793
|
+
interface RainbowOptions {
|
|
3794
|
+
saturation: number;
|
|
3795
|
+
lightness: number;
|
|
3796
|
+
spread: number;
|
|
3797
|
+
offset: number;
|
|
3798
|
+
selectionRange?: {
|
|
3799
|
+
start: number;
|
|
3800
|
+
end: number;
|
|
3801
|
+
};
|
|
3802
|
+
}
|
|
3803
|
+
declare class RainbowTransformer implements Transformer {
|
|
3804
|
+
private options;
|
|
3805
|
+
constructor(options?: Partial<RainbowOptions>);
|
|
3806
|
+
transform(document: DocumentModel): TransformResult;
|
|
3807
|
+
}
|
|
3808
|
+
|
|
3809
|
+
interface GradientOptions {
|
|
3810
|
+
from: string;
|
|
3811
|
+
to: string;
|
|
3812
|
+
unit: 'char' | 'word';
|
|
3813
|
+
easing: Easing;
|
|
3814
|
+
selectionRange?: {
|
|
3815
|
+
start: number;
|
|
3816
|
+
end: number;
|
|
3817
|
+
};
|
|
3818
|
+
}
|
|
3819
|
+
declare class GradientTransformer implements Transformer {
|
|
3820
|
+
private options;
|
|
3821
|
+
constructor(options?: Partial<GradientOptions>);
|
|
3822
|
+
transform(document: DocumentModel): TransformResult;
|
|
3823
|
+
}
|
|
3824
|
+
|
|
3825
|
+
interface GrowOptions {
|
|
3826
|
+
minSize: number;
|
|
3827
|
+
maxSize: number;
|
|
3828
|
+
cycles: number;
|
|
3829
|
+
selectionRange?: {
|
|
3830
|
+
start: number;
|
|
3831
|
+
end: number;
|
|
3832
|
+
};
|
|
3833
|
+
}
|
|
3834
|
+
declare class GrowTransformer implements Transformer {
|
|
3835
|
+
private options;
|
|
3836
|
+
constructor(options?: Partial<GrowOptions>);
|
|
3837
|
+
transform(document: DocumentModel): TransformResult;
|
|
3838
|
+
}
|
|
3839
|
+
|
|
3840
|
+
interface MarkdownDocumentModelOptions extends DocumentModelOptions {
|
|
3841
|
+
source?: string;
|
|
3842
|
+
language?: string;
|
|
3843
|
+
}
|
|
3844
|
+
declare class MarkdownDocumentModel extends DocumentModel {
|
|
3845
|
+
constructor(options?: MarkdownDocumentModelOptions);
|
|
3846
|
+
static fromMarkdown(source: string): MarkdownDocumentModel;
|
|
3847
|
+
protected parseToGreen(source: string): GreenNode;
|
|
3848
|
+
protected buildRedFromGreen(green: GreenNode): RedNode;
|
|
3849
|
+
applyTextUpdate(newSource: string): void;
|
|
3850
|
+
applyChange(change: any): void;
|
|
3851
|
+
}
|
|
3852
|
+
|
|
3853
|
+
/**
|
|
3854
|
+
* DocumentEngine — Markdown AST Nodes
|
|
3855
|
+
*/
|
|
3856
|
+
type MarkdownNode = MarkdownDocument | MarkdownParagraph | MarkdownHeading | MarkdownText | MarkdownCodeBlock | MarkdownCodeInline | MarkdownStrong | MarkdownEmphasis | MarkdownLink | MarkdownImage | MarkdownList | MarkdownListItem | MarkdownBlockquote | MarkdownSpoiler | MarkdownNotice | MarkdownSpacing | MarkdownEmptyLine;
|
|
3857
|
+
interface MarkdownDocument {
|
|
3858
|
+
type: 'document';
|
|
3859
|
+
children: MarkdownNode[];
|
|
3860
|
+
}
|
|
3861
|
+
interface MarkdownParagraph {
|
|
3862
|
+
type: 'paragraph';
|
|
3863
|
+
children: MarkdownNode[];
|
|
3864
|
+
}
|
|
3865
|
+
interface MarkdownHeading {
|
|
3866
|
+
type: 'heading';
|
|
3867
|
+
level: number;
|
|
3868
|
+
children: MarkdownNode[];
|
|
3869
|
+
}
|
|
3870
|
+
interface MarkdownText {
|
|
3871
|
+
type: 'text';
|
|
3872
|
+
value: string;
|
|
3873
|
+
}
|
|
3874
|
+
interface MarkdownCodeBlock {
|
|
3875
|
+
type: 'code_block';
|
|
3876
|
+
lang: string;
|
|
3877
|
+
value: string;
|
|
3878
|
+
}
|
|
3879
|
+
interface MarkdownCodeInline {
|
|
3880
|
+
type: 'code_inline';
|
|
3881
|
+
value: string;
|
|
3882
|
+
}
|
|
3883
|
+
interface MarkdownStrong {
|
|
3884
|
+
type: 'strong';
|
|
3885
|
+
children: MarkdownNode[];
|
|
3886
|
+
}
|
|
3887
|
+
interface MarkdownEmphasis {
|
|
3888
|
+
type: 'emphasis';
|
|
3889
|
+
children: MarkdownNode[];
|
|
3890
|
+
}
|
|
3891
|
+
interface MarkdownLink {
|
|
3892
|
+
type: 'link';
|
|
3893
|
+
url: string;
|
|
3894
|
+
children: MarkdownNode[];
|
|
3895
|
+
}
|
|
3896
|
+
interface MarkdownImage {
|
|
3897
|
+
type: 'image';
|
|
3898
|
+
url: string;
|
|
3899
|
+
alt: string;
|
|
3900
|
+
}
|
|
3901
|
+
interface MarkdownList {
|
|
3902
|
+
type: 'list';
|
|
3903
|
+
ordered: boolean;
|
|
3904
|
+
children: MarkdownListItem[];
|
|
3905
|
+
}
|
|
3906
|
+
interface MarkdownListItem {
|
|
3907
|
+
type: 'list_item';
|
|
3908
|
+
children: MarkdownNode[];
|
|
3909
|
+
}
|
|
3910
|
+
interface MarkdownBlockquote {
|
|
3911
|
+
type: 'blockquote';
|
|
3912
|
+
children: MarkdownNode[];
|
|
3913
|
+
}
|
|
3914
|
+
/** `>!` spoiler block — produced by MarkdownParser, maps to BBCode [spoiler]. */
|
|
3915
|
+
interface MarkdownSpoiler {
|
|
3916
|
+
type: 'spoiler';
|
|
3917
|
+
children: MarkdownNode[];
|
|
3918
|
+
}
|
|
3919
|
+
/** `> [!NOTE]` callout — produced by MarkdownParser, maps to BBCode [notice]. */
|
|
3920
|
+
interface MarkdownNotice {
|
|
3921
|
+
type: 'notice';
|
|
3922
|
+
children: MarkdownNode[];
|
|
3923
|
+
}
|
|
3924
|
+
interface MarkdownSpacing {
|
|
3925
|
+
type: 'spacing';
|
|
3926
|
+
}
|
|
3927
|
+
interface MarkdownEmptyLine {
|
|
3928
|
+
type: 'empty_line';
|
|
3929
|
+
}
|
|
3930
|
+
|
|
3931
|
+
declare function markdownAstToGreenTree(root: MarkdownNode): GreenNode;
|
|
3932
|
+
declare function markdownAstToRedTree(root: MarkdownNode): RedNode;
|
|
3933
|
+
|
|
3934
|
+
interface HTMLDocumentModelOptions extends DocumentModelOptions {
|
|
3935
|
+
source?: string;
|
|
3936
|
+
language?: string;
|
|
3937
|
+
}
|
|
3938
|
+
declare class HTMLDocumentModel extends DocumentModel {
|
|
3939
|
+
constructor(options?: HTMLDocumentModelOptions);
|
|
3940
|
+
static fromHTML(source: string): HTMLDocumentModel;
|
|
3941
|
+
protected parseToGreen(source: string): GreenNode;
|
|
3942
|
+
protected buildRedFromGreen(green: GreenNode): RedNode;
|
|
3943
|
+
}
|
|
3944
|
+
|
|
3945
|
+
declare function htmlStringToGreenTree(html: string): GreenNode;
|
|
3946
|
+
|
|
3947
|
+
/**
|
|
3948
|
+
* Quasar Analysis Framework — Pipeline Context
|
|
3949
|
+
*
|
|
3950
|
+
* PipelineContext is **read-only by contract**. It is built *before*
|
|
3951
|
+
* the pipeline starts and MUST NOT be modified by any pass.
|
|
3952
|
+
*
|
|
3953
|
+
* If a pass needs to make information available to downstream passes it
|
|
3954
|
+
* should emit a Contribution rather than mutating the context.
|
|
3955
|
+
*
|
|
3956
|
+
* @immutable
|
|
3957
|
+
*/
|
|
3958
|
+
declare const PipelineMode: {
|
|
3959
|
+
readonly Interactive: "interactive";
|
|
3960
|
+
readonly Batch: "batch";
|
|
3961
|
+
readonly Import: "import";
|
|
3962
|
+
};
|
|
3963
|
+
type PipelineMode = (typeof PipelineMode)[keyof typeof PipelineMode];
|
|
3964
|
+
declare const ExportTarget: {
|
|
3965
|
+
readonly Osu: "osu";
|
|
3966
|
+
readonly Miliastry: "miliastry";
|
|
3967
|
+
readonly HTML: "html";
|
|
3968
|
+
readonly Markdown: "markdown";
|
|
3969
|
+
};
|
|
3970
|
+
type ExportTarget = (typeof ExportTarget)[keyof typeof ExportTarget];
|
|
3971
|
+
interface PipelineContext {
|
|
3972
|
+
/** Where this pipeline run originates from. */
|
|
3973
|
+
readonly mode: PipelineMode;
|
|
3974
|
+
/** The format we intend to export to (influences decisions). */
|
|
3975
|
+
readonly target: ExportTarget;
|
|
3976
|
+
/** Feature flags that may enable/disable certain behaviours. */
|
|
3977
|
+
readonly featureFlags: Readonly<Record<string, boolean>>;
|
|
3978
|
+
/** Free-form metadata provided by the caller (e.g. selection range). */
|
|
3979
|
+
readonly metadata: Readonly<Record<string, unknown>>;
|
|
3980
|
+
}
|
|
3981
|
+
|
|
3982
|
+
/**
|
|
3983
|
+
* Quasar Analysis Framework — Contribution Types
|
|
3984
|
+
*
|
|
3985
|
+
* Every AnalyzerPass produces an array of Contributions. A Contribution
|
|
3986
|
+
* is a discriminated union so that downstream consumers (DecisionPass,
|
|
3987
|
+
* AnalysisReport consumers, debug UI) can switch on `kind` and get
|
|
3988
|
+
* perfect TypeScript narrowing.
|
|
3989
|
+
*
|
|
3990
|
+
* @see AnalysisReport
|
|
3991
|
+
*/
|
|
3992
|
+
declare const ContributionKind: {
|
|
3993
|
+
readonly Semantic: "semantic";
|
|
3994
|
+
readonly Diagnostic: "diagnostic";
|
|
3995
|
+
readonly Optimization: "optimization";
|
|
3996
|
+
readonly Metrics: "metrics";
|
|
3997
|
+
};
|
|
3998
|
+
type ContributionKind = (typeof ContributionKind)[keyof typeof ContributionKind];
|
|
3999
|
+
/**
|
|
4000
|
+
* A semantic pattern recognised in the tree (gradient, wave, rainbow,…).
|
|
4001
|
+
* These describe *meaning* of the document, not just surface syntax.
|
|
4002
|
+
*/
|
|
4003
|
+
interface SemanticContribution {
|
|
4004
|
+
readonly kind: typeof ContributionKind.Semantic;
|
|
4005
|
+
readonly label: string;
|
|
4006
|
+
readonly confidence: number;
|
|
4007
|
+
readonly range: {
|
|
4008
|
+
start: number;
|
|
4009
|
+
end: number;
|
|
4010
|
+
};
|
|
4011
|
+
readonly metadata: Record<string, unknown>;
|
|
4012
|
+
/** Human-readable description (optional). */
|
|
4013
|
+
readonly description?: string;
|
|
4014
|
+
}
|
|
4015
|
+
/**
|
|
4016
|
+
* A diagnostic (warning, error, info) attached to a range in the tree.
|
|
4017
|
+
*/
|
|
4018
|
+
interface DiagnosticContribution {
|
|
4019
|
+
readonly kind: typeof ContributionKind.Diagnostic;
|
|
4020
|
+
readonly severity: 'error' | 'warning' | 'info' | 'hint';
|
|
4021
|
+
readonly message: string;
|
|
4022
|
+
readonly range?: {
|
|
4023
|
+
start: number;
|
|
4024
|
+
end: number;
|
|
4025
|
+
};
|
|
4026
|
+
readonly code?: string;
|
|
4027
|
+
}
|
|
4028
|
+
/**
|
|
4029
|
+
* An optimisation opportunity detected in the tree (mergeable colours,
|
|
4030
|
+
* redundant wrapping, empty tags, …).
|
|
4031
|
+
*/
|
|
4032
|
+
interface OptimizationContribution {
|
|
4033
|
+
readonly kind: typeof ContributionKind.Optimization;
|
|
4034
|
+
readonly label: string;
|
|
4035
|
+
readonly description: string;
|
|
4036
|
+
readonly estimatedImprovement?: string;
|
|
4037
|
+
readonly range: {
|
|
4038
|
+
start: number;
|
|
4039
|
+
end: number;
|
|
4040
|
+
};
|
|
4041
|
+
}
|
|
4042
|
+
/**
|
|
4043
|
+
* Aggregate metrics about the tree (character count, node depth, …).
|
|
4044
|
+
*/
|
|
4045
|
+
interface MetricsContribution {
|
|
4046
|
+
readonly kind: typeof ContributionKind.Metrics;
|
|
4047
|
+
readonly metrics: Record<string, number | string>;
|
|
4048
|
+
}
|
|
4049
|
+
type Contribution = SemanticContribution | DiagnosticContribution | OptimizationContribution | MetricsContribution;
|
|
4050
|
+
|
|
4051
|
+
/**
|
|
4052
|
+
* Quasar Analysis Framework — Analysis Report
|
|
4053
|
+
*
|
|
4054
|
+
* Once all AnalyzerPasses have run, their Contributions are collected
|
|
4055
|
+
* into an immutable AnalysisReport. This report is the sole input to
|
|
4056
|
+
* the DecisionPass(es).
|
|
4057
|
+
*
|
|
4058
|
+
* We deliberately keep the aggregator simple for now — just an array
|
|
4059
|
+
* of contributions. As more pass types emerge we may introduce a
|
|
4060
|
+
* structured Aggregator that indexes/categorises contributions.
|
|
4061
|
+
*
|
|
4062
|
+
* @see DecisionPass
|
|
4063
|
+
*/
|
|
4064
|
+
|
|
4065
|
+
interface AnalysisReport {
|
|
4066
|
+
/** All contributions gathered from every AnalyzerPass. */
|
|
4067
|
+
readonly contributions: readonly Contribution[];
|
|
4068
|
+
/** Total number of analysis passes that ran. */
|
|
4069
|
+
readonly passCount: number;
|
|
4070
|
+
/** Wall-clock time spent in analysis (ms). */
|
|
4071
|
+
readonly elapsedMs: number;
|
|
4072
|
+
}
|
|
4073
|
+
|
|
4074
|
+
/**
|
|
4075
|
+
* Quasar Analysis Framework — Pass Contracts
|
|
4076
|
+
*
|
|
4077
|
+
* Each pass in the analysis/transform pipeline implements one of these
|
|
4078
|
+
* specialized interfaces. The three kinds mirror LLVM's pass structure:
|
|
4079
|
+
*
|
|
4080
|
+
* Analysis — observe the tree, produce Contributions (never mutate)
|
|
4081
|
+
* Decision — consume an AnalysisReport, produce a TransformationPlan
|
|
4082
|
+
* Transform — mutate the tree according to a plan
|
|
4083
|
+
*
|
|
4084
|
+
* @see ARCHITECTURE.md (Analysis Framework)
|
|
4085
|
+
*/
|
|
4086
|
+
|
|
4087
|
+
/** Every pass has an identity string and belongs to a stage. */
|
|
4088
|
+
interface Pass {
|
|
4089
|
+
readonly id: string;
|
|
4090
|
+
}
|
|
4091
|
+
/**
|
|
4092
|
+
* An AnalyzerPass observes the Green Tree and produces one or more
|
|
4093
|
+
* Contributions. It MUST NOT mutate the tree or the context.
|
|
4094
|
+
*/
|
|
4095
|
+
interface AnalyzerPass extends Pass {
|
|
4096
|
+
run(tree: GreenNode, context: PipelineContext): Contribution[];
|
|
4097
|
+
}
|
|
4098
|
+
/**
|
|
4099
|
+
* A DecisionPass consumes an AnalysisReport (which itself is the
|
|
4100
|
+
* aggregated output of all AnalyzerPasses) and produces a
|
|
4101
|
+
* TransformationPlan that describes *intended* tree mutations.
|
|
4102
|
+
*/
|
|
4103
|
+
interface DecisionPass extends Pass {
|
|
4104
|
+
run(report: AnalysisReport, context: PipelineContext): TransformationPlan;
|
|
4105
|
+
}
|
|
4106
|
+
/**
|
|
4107
|
+
* A TransformPass receives the current Green Tree together with a
|
|
4108
|
+
* TransformationPlan and returns a **new** Green Tree. It MUST NOT
|
|
4109
|
+
* mutate the original tree — immutability guarantees deterministic
|
|
4110
|
+
* replay and debugging.
|
|
4111
|
+
*/
|
|
4112
|
+
interface TransformPass extends Pass {
|
|
4113
|
+
run(tree: GreenNode, plan: TransformationPlan, context: PipelineContext): GreenNode;
|
|
4114
|
+
}
|
|
4115
|
+
/**
|
|
4116
|
+
* A single action described by a TransformationPlan.
|
|
4117
|
+
* `kind` tells the TransformPass what to do; `payload` carries
|
|
4118
|
+
* per-kind parameters.
|
|
4119
|
+
*/
|
|
4120
|
+
interface TransformAction {
|
|
4121
|
+
readonly kind: string;
|
|
4122
|
+
readonly payload: Record<string, unknown>;
|
|
4123
|
+
}
|
|
4124
|
+
/**
|
|
4125
|
+
* Ordered set of actions that a DecisionPass produces.
|
|
4126
|
+
*/
|
|
4127
|
+
interface TransformationPlan {
|
|
4128
|
+
readonly actions: readonly TransformAction[];
|
|
4129
|
+
}
|
|
4130
|
+
|
|
4131
|
+
/**
|
|
4132
|
+
* Quasar Analysis Framework — Pipeline Stage
|
|
4133
|
+
*
|
|
4134
|
+
* Passes are grouped into stages that execute in order:
|
|
4135
|
+
*
|
|
4136
|
+
* 1. Analysis — observe the tree, produce Contributions
|
|
4137
|
+
* 2. Decision — consume the AnalysisReport, produce a TransformationPlan
|
|
4138
|
+
* 3. Transform — apply the plan to produce a new Green Tree
|
|
4139
|
+
*
|
|
4140
|
+
* @see Pass
|
|
4141
|
+
*/
|
|
4142
|
+
declare const PipelineStage: {
|
|
4143
|
+
readonly Analysis: "analysis";
|
|
4144
|
+
readonly Decision: "decision";
|
|
4145
|
+
readonly Transform: "transform";
|
|
4146
|
+
};
|
|
4147
|
+
type PipelineStage = (typeof PipelineStage)[keyof typeof PipelineStage];
|
|
4148
|
+
|
|
4149
|
+
/**
|
|
4150
|
+
* Quasar Analysis Framework — Pipeline
|
|
4151
|
+
*
|
|
4152
|
+
* The Pipeline is itself a Pass — it can be composed inside larger
|
|
4153
|
+
* pipelines. Execution is strictly ordered:
|
|
4154
|
+
*
|
|
4155
|
+
* 1. All analysis passes run, collecting contributions.
|
|
4156
|
+
* 2. The aggregated AnalysisReport is fed to each decision pass.
|
|
4157
|
+
* 3. Each transform pass receives the current tree + plan and
|
|
4158
|
+
* returns a new tree (never mutating the original).
|
|
4159
|
+
*
|
|
4160
|
+
* @example
|
|
4161
|
+
* ```ts
|
|
4162
|
+
* const pipeline = new Pipeline([
|
|
4163
|
+
* { stage: 'analysis', pass: new CharacterCountAnalyzer() },
|
|
4164
|
+
* ])
|
|
4165
|
+
* const report = pipeline.run(tree, context)
|
|
4166
|
+
* ```
|
|
4167
|
+
*/
|
|
4168
|
+
|
|
4169
|
+
interface StageEntry {
|
|
4170
|
+
readonly stage: PipelineStage;
|
|
4171
|
+
readonly pass: AnalyzerPass | DecisionPass | TransformPass;
|
|
4172
|
+
}
|
|
4173
|
+
interface PipelineResult {
|
|
4174
|
+
/** The final (possibly transformed) Green Tree. */
|
|
4175
|
+
readonly tree: GreenNode;
|
|
4176
|
+
/** The aggregated analysis report. */
|
|
4177
|
+
readonly report: AnalysisReport;
|
|
4178
|
+
}
|
|
4179
|
+
declare class Pipeline {
|
|
4180
|
+
private readonly stages;
|
|
4181
|
+
constructor(stages: readonly StageEntry[]);
|
|
4182
|
+
/**
|
|
4183
|
+
* Execute the full pipeline against `tree` with the given `context`.
|
|
4184
|
+
*
|
|
4185
|
+
* @returns The final tree and analysis report.
|
|
4186
|
+
*/
|
|
4187
|
+
run(tree: GreenNode, context: PipelineContext): PipelineResult;
|
|
4188
|
+
/**
|
|
4189
|
+
* Use PipelineBuilder to construct a Pipeline:
|
|
4190
|
+
*
|
|
4191
|
+
* ```ts
|
|
4192
|
+
* import { PipelineBuilder } from './PipelineBuilder'
|
|
4193
|
+
* const pipeline = new PipelineBuilder()
|
|
4194
|
+
* .analysis(new CharacterCountAnalyzer())
|
|
4195
|
+
* .build()
|
|
4196
|
+
* ```
|
|
4197
|
+
*/
|
|
4198
|
+
private runAnalysis;
|
|
4199
|
+
private runDecision;
|
|
4200
|
+
private runTransform;
|
|
4201
|
+
private buildReport;
|
|
4202
|
+
}
|
|
4203
|
+
|
|
4204
|
+
/**
|
|
4205
|
+
* Quasar Analysis Framework — Pipeline Builder
|
|
4206
|
+
*
|
|
4207
|
+
* Provides a fluent API for assembling a Pipeline from individually
|
|
4208
|
+
* registered passes. Each stage (analysis / decision / transform)
|
|
4209
|
+
* has its own registration method so TypeScript catches misuse at
|
|
4210
|
+
* compile time.
|
|
4211
|
+
*
|
|
4212
|
+
* @example
|
|
4213
|
+
* ```ts
|
|
4214
|
+
* const pipeline = Pipeline
|
|
4215
|
+
* .builder()
|
|
4216
|
+
* .analysis(new CharacterCountAnalyzer())
|
|
4217
|
+
* .decision(new DefaultDecision())
|
|
4218
|
+
* .transform(new CollapseGradientTransform())
|
|
4219
|
+
* .build()
|
|
4220
|
+
* ```
|
|
4221
|
+
*/
|
|
4222
|
+
|
|
4223
|
+
declare class PipelineBuilder {
|
|
4224
|
+
private readonly passes;
|
|
4225
|
+
/** Register one or more AnalyzerPasses (observation stage). */
|
|
4226
|
+
analysis(...passes: AnalyzerPass[]): this;
|
|
4227
|
+
/** Register one or more DecisionPasses (planning stage). */
|
|
4228
|
+
decision(...passes: DecisionPass[]): this;
|
|
4229
|
+
/** Register one or more TransformPasses (mutation stage). */
|
|
4230
|
+
transform(...passes: TransformPass[]): this;
|
|
4231
|
+
/** Build the Pipeline from the registered passes. */
|
|
4232
|
+
build(): Pipeline;
|
|
4233
|
+
}
|
|
4234
|
+
|
|
4235
|
+
/**
|
|
4236
|
+
* Quasar Analysis Framework — Character Count Analyzer (Proof of Concept)
|
|
4237
|
+
*
|
|
4238
|
+
* The simplest possible AnalyzerPass: it walks the Green Tree and counts
|
|
4239
|
+
* text characters. This validates that the full pipeline works:
|
|
4240
|
+
*
|
|
4241
|
+
* Green Tree → Analyzer → Contribution → Aggregated Report
|
|
4242
|
+
*
|
|
4243
|
+
* No algorithms, no colours, no decisions — just validating the pipe.
|
|
4244
|
+
*/
|
|
4245
|
+
|
|
4246
|
+
declare class CharacterCountAnalyzer implements AnalyzerPass {
|
|
4247
|
+
readonly id = "character-count";
|
|
4248
|
+
run(tree: GreenNode, _context: PipelineContext): Contribution[];
|
|
4249
|
+
private countChars;
|
|
4250
|
+
}
|
|
4251
|
+
|
|
4252
|
+
/**
|
|
4253
|
+
* Quasar Analysis Framework — Mergeable Color Analyzer
|
|
4254
|
+
*
|
|
4255
|
+
* Detects sequences of consecutive [color=#HEX] wrappers where ALL
|
|
4256
|
+
* adjacent colours are identical. These are optimisation opportunities:
|
|
4257
|
+
* the user could merge them into a single `[color]` tag.
|
|
4258
|
+
*
|
|
4259
|
+
* Example:
|
|
4260
|
+
* [color=#FF0000]H[/color][color=#FF0000]e[/color] → mergeable
|
|
4261
|
+
* [color=#FF0000]H[/color][color=#EE1100]e[/color] → NOT mergeable
|
|
4262
|
+
*
|
|
4263
|
+
* @see OptimizationContribution
|
|
4264
|
+
*/
|
|
4265
|
+
|
|
4266
|
+
declare class MergeableColorAnalyzer implements AnalyzerPass {
|
|
4267
|
+
readonly id = "mergeable-color";
|
|
4268
|
+
run(tree: GreenNode, _context: PipelineContext): Contribution[];
|
|
4269
|
+
/**
|
|
4270
|
+
* Walk the tree and find consecutive color nodes with identical colours.
|
|
4271
|
+
*/
|
|
4272
|
+
private findMergeableColorSequences;
|
|
4273
|
+
private emitMergeable;
|
|
4274
|
+
}
|
|
4275
|
+
|
|
4276
|
+
/**
|
|
4277
|
+
* Quasar Analysis Framework — Gradient Analyzer (v2)
|
|
4278
|
+
*
|
|
4279
|
+
* Detects sequences of [color=#HEX] tags that form a gradient pattern
|
|
4280
|
+
* and reports them as SemanticContributions with a confidence score.
|
|
4281
|
+
*
|
|
4282
|
+
* ## Refinements over v1
|
|
4283
|
+
*
|
|
4284
|
+
* 1. **OKLab color space** — Uses OKLab (Bottosson 2020) for perceptual
|
|
4285
|
+
* colour distance instead of RGB. Two colours with small OKLab distance
|
|
4286
|
+
* look nearly identical to the human eye, making gradient detection
|
|
4287
|
+
* much more accurate.
|
|
4288
|
+
*
|
|
4289
|
+
* 2. **Change-point detection** — Identifies gradient *stops* even when
|
|
4290
|
+
* plateaus (runs of identical colour) exist, so a sequence like
|
|
4291
|
+
* RRRR→G→BBBB is correctly parsed as a 3-stop gradient rather than
|
|
4292
|
+
* a failed linear interpolation.
|
|
4293
|
+
*
|
|
4294
|
+
* 3. **Sharper sigmoid (k=6)** — Better separation between "likely
|
|
4295
|
+
* gradient" and "maybe gradient" at the decision thresholds.
|
|
4296
|
+
*
|
|
4297
|
+
* ## Confidence features (weights sum to 1.0)
|
|
4298
|
+
*
|
|
4299
|
+
* - uniformPerceptualSpacing (+0.30)
|
|
4300
|
+
* - contiguousWrappers (+0.25)
|
|
4301
|
+
* - noFormattingBreaks (+0.15)
|
|
4302
|
+
* - monotonicProgression (+0.20)
|
|
4303
|
+
* - lowPerceptualError (+0.10)
|
|
4304
|
+
*
|
|
4305
|
+
* @see SemanticContribution
|
|
4306
|
+
*/
|
|
4307
|
+
|
|
4308
|
+
interface GradientModel {
|
|
4309
|
+
readonly colors: string[];
|
|
4310
|
+
readonly easing: 'linear' | 'easeIn' | 'easeOut' | 'easeInOut';
|
|
4311
|
+
readonly stops: GradientStop[];
|
|
4312
|
+
readonly rangeStart: number;
|
|
4313
|
+
readonly rangeEnd: number;
|
|
4314
|
+
readonly charCount: number;
|
|
4315
|
+
readonly diagnostics: GradientDiagnostics;
|
|
4316
|
+
}
|
|
4317
|
+
interface GradientStop {
|
|
4318
|
+
/** Hex colour at this stop */
|
|
4319
|
+
readonly color: string;
|
|
4320
|
+
/** Normalised position 0-1 */
|
|
4321
|
+
readonly position: number;
|
|
4322
|
+
}
|
|
4323
|
+
interface GradientDiagnostics {
|
|
4324
|
+
readonly uniformSpacing: boolean;
|
|
4325
|
+
readonly monotonic: boolean;
|
|
4326
|
+
readonly plateauCount: number;
|
|
4327
|
+
readonly maxPerceptualError: number;
|
|
4328
|
+
readonly stopCount: number;
|
|
4329
|
+
readonly featureScores: Readonly<Record<string, number>>;
|
|
4330
|
+
}
|
|
4331
|
+
declare class GradientAnalyzer implements AnalyzerPass {
|
|
4332
|
+
readonly id = "gradient-analyzer";
|
|
4333
|
+
run(tree: GreenNode, _context: PipelineContext): Contribution[];
|
|
4334
|
+
private findGradients;
|
|
4335
|
+
/**
|
|
4336
|
+
* Detect gradient stops from a sequence of hex colours, handling plateaus.
|
|
4337
|
+
*
|
|
4338
|
+
* Algorithm: Scan for "significant changes" in perceptual distance.
|
|
4339
|
+
* Wherever the cumulative perceptual distance from the last stop exceeds a
|
|
4340
|
+
* threshold, a new stop is recorded. This naturally skips over plateaus.
|
|
4341
|
+
*/
|
|
4342
|
+
private detectStops;
|
|
4343
|
+
private buildDiagnostics;
|
|
4344
|
+
private calculateRawScore;
|
|
4345
|
+
}
|
|
4346
|
+
|
|
4347
|
+
/**
|
|
4348
|
+
* Quasar Analysis Framework — Rainbow Analyzer
|
|
4349
|
+
*
|
|
4350
|
+
* Detects sequences of [color=#HEX] tags where the HSL hue rotates
|
|
4351
|
+
* continuously while saturation and lightness remain approximately
|
|
4352
|
+
* constant — the classic "rainbow" pattern.
|
|
4353
|
+
*
|
|
4354
|
+
* ## Confidence features
|
|
4355
|
+
*
|
|
4356
|
+
* - hueRotation (+0.35) — hue changes monotonically (increasing or decreasing)
|
|
4357
|
+
* - stableSaturation (+0.20) — saturation stays within a narrow band
|
|
4358
|
+
* - stableLightness (+0.20) — lightness stays within a narrow band
|
|
4359
|
+
* - contiguousWrappers (+0.15) — no gaps in the colour sequence
|
|
4360
|
+
* - noFormattingBreaks (+0.10) — no formatting tags between colours
|
|
4361
|
+
*
|
|
4362
|
+
* @see SemanticContribution
|
|
4363
|
+
*/
|
|
4364
|
+
|
|
4365
|
+
interface RainbowModel {
|
|
4366
|
+
readonly colors: string[];
|
|
4367
|
+
readonly hueStart: number;
|
|
4368
|
+
readonly hueEnd: number;
|
|
4369
|
+
readonly avgSaturation: number;
|
|
4370
|
+
readonly avgLightness: number;
|
|
4371
|
+
readonly rangeStart: number;
|
|
4372
|
+
readonly rangeEnd: number;
|
|
4373
|
+
readonly charCount: number;
|
|
4374
|
+
readonly diagnostics: RainbowDiagnostics;
|
|
4375
|
+
}
|
|
4376
|
+
interface RainbowDiagnostics {
|
|
4377
|
+
readonly hueDelta: number;
|
|
4378
|
+
readonly saturationDeviation: number;
|
|
4379
|
+
readonly lightnessDeviation: number;
|
|
4380
|
+
readonly isContinuous: boolean;
|
|
4381
|
+
readonly featureScores: Readonly<Record<string, number>>;
|
|
4382
|
+
}
|
|
4383
|
+
declare class RainbowAnalyzer implements AnalyzerPass {
|
|
4384
|
+
readonly id = "rainbow-analyzer";
|
|
4385
|
+
run(tree: GreenNode, _context: PipelineContext): Contribution[];
|
|
4386
|
+
private findRainbows;
|
|
4387
|
+
private extractHSL;
|
|
4388
|
+
private analyzeRainbow;
|
|
4389
|
+
private calculateRawScore;
|
|
4390
|
+
}
|
|
4391
|
+
|
|
4392
|
+
/**
|
|
4393
|
+
* Quasar Analysis Framework — Wave (Size Oscillation) Analyzer
|
|
4394
|
+
*
|
|
4395
|
+
* Detects sequences of [size=N] tags where the sizes follow a sinusoidal
|
|
4396
|
+
* (or other periodic) oscillation pattern — the classic "wave/grow" effect.
|
|
4397
|
+
*
|
|
4398
|
+
* ## Confidence features
|
|
4399
|
+
*
|
|
4400
|
+
* - periodicPattern (+0.35) — sizes follow a clear periodic pattern
|
|
4401
|
+
* - boundedRange (+0.20) — sizes stay within a plausible min-max range
|
|
4402
|
+
* - smoothTransitions (+0.20) — adjacent size differences are small
|
|
4403
|
+
* - contiguousWrappers (+0.15) — no gaps in the size sequence
|
|
4404
|
+
* - noFormattingBreaks (+0.10) — no formatting tags between size nodes
|
|
4405
|
+
*
|
|
4406
|
+
* @see SemanticContribution
|
|
4407
|
+
*/
|
|
4408
|
+
|
|
4409
|
+
interface WaveModel {
|
|
4410
|
+
readonly sizes: number[];
|
|
4411
|
+
readonly minSize: number;
|
|
4412
|
+
readonly maxSize: number;
|
|
4413
|
+
readonly estimatedFrequency: number;
|
|
4414
|
+
readonly rangeStart: number;
|
|
4415
|
+
readonly rangeEnd: number;
|
|
4416
|
+
readonly charCount: number;
|
|
4417
|
+
readonly diagnostics: WaveDiagnostics;
|
|
4418
|
+
}
|
|
4419
|
+
interface WaveDiagnostics {
|
|
4420
|
+
readonly periodicityScore: number;
|
|
4421
|
+
readonly hasSymmetry: boolean;
|
|
4422
|
+
readonly isBounded: boolean;
|
|
4423
|
+
readonly transitionSmoothness: number;
|
|
4424
|
+
readonly featureScores: Readonly<Record<string, number>>;
|
|
4425
|
+
}
|
|
4426
|
+
declare class WaveAnalyzer implements AnalyzerPass {
|
|
4427
|
+
readonly id = "wave-analyzer";
|
|
4428
|
+
run(tree: GreenNode, _context: PipelineContext): Contribution[];
|
|
4429
|
+
private findWaves;
|
|
4430
|
+
private analyzeWave;
|
|
4431
|
+
private calculateRawScore;
|
|
4432
|
+
}
|
|
4433
|
+
|
|
4434
|
+
/**
|
|
4435
|
+
* Quasar Analysis Framework — Default Decision
|
|
4436
|
+
*
|
|
4437
|
+
* Consumes an AnalysisReport and produces a TransformationPlan.
|
|
4438
|
+
* The decision logic depends on the PipelineContext:
|
|
4439
|
+
*
|
|
4440
|
+
* - target === 'miliastry':
|
|
4441
|
+
* Aggressively collapses detected gradients (confidence ≥ 0.6).
|
|
4442
|
+
* - target === 'osu':
|
|
4443
|
+
* Only collapses very high-confidence gradients (confidence ≥ 0.95).
|
|
4444
|
+
* - mode === 'batch' || mode === 'import':
|
|
4445
|
+
* Uses the OSU threshold by default.
|
|
4446
|
+
*
|
|
4447
|
+
* Optimisation opportunities (mergeable colours) are always included
|
|
4448
|
+
* regardless of target, but only when the count exceeds 1.
|
|
4449
|
+
*
|
|
4450
|
+
* @see DecisionPass
|
|
4451
|
+
* @see TransformationPlan
|
|
4452
|
+
*/
|
|
4453
|
+
|
|
4454
|
+
declare class DefaultDecision implements DecisionPass {
|
|
4455
|
+
readonly id = "default-decision";
|
|
4456
|
+
run(report: AnalysisReport, context: PipelineContext): TransformationPlan;
|
|
4457
|
+
/** Map contribution label → transform action kind */
|
|
4458
|
+
private labelToActionKind;
|
|
4459
|
+
private handleSemantic;
|
|
4460
|
+
private handleOptimization;
|
|
4461
|
+
}
|
|
4462
|
+
|
|
4463
|
+
/**
|
|
4464
|
+
* Quasar Analysis Framework — Collapse Gradient Transform
|
|
4465
|
+
*
|
|
4466
|
+
* Applies `collapse-gradient` actions from a TransformationPlan.
|
|
4467
|
+
* Each action targets a sequence of adjacent [color] nodes and replaces
|
|
4468
|
+
* them with a single [gradient] node containing all text children.
|
|
4469
|
+
*
|
|
4470
|
+
* The transform operates on the Green Tree and returns a new tree —
|
|
4471
|
+
* it NEVER mutates the original.
|
|
4472
|
+
*
|
|
4473
|
+
* @example
|
|
4474
|
+
* Before:
|
|
4475
|
+
* [color=#FF0000]H[/color][color=#EE1100]e[/color][color=#DD2200]l[/color]
|
|
4476
|
+
* After:
|
|
4477
|
+
* [gradient=#FF0000,#EE1100,#DD2200]Hello[/gradient]
|
|
4478
|
+
*
|
|
4479
|
+
* @see TransformPass
|
|
4480
|
+
*/
|
|
4481
|
+
|
|
4482
|
+
declare class CollapseGradientTransform implements TransformPass {
|
|
4483
|
+
readonly id = "collapse-gradient";
|
|
4484
|
+
run(tree: GreenNode, plan: TransformationPlan, _context: PipelineContext): GreenNode;
|
|
4485
|
+
/**
|
|
4486
|
+
* Walk the tree and collapse any sequences that fall within our ranges.
|
|
4487
|
+
*/
|
|
4488
|
+
private transformNode;
|
|
4489
|
+
/**
|
|
4490
|
+
* Rebuild a node with the same kind/text/range but new children.
|
|
4491
|
+
*/
|
|
4492
|
+
private rebuildNode;
|
|
4493
|
+
/**
|
|
4494
|
+
* Extract hex colour from a color node's text.
|
|
4495
|
+
*/
|
|
4496
|
+
private extractHex;
|
|
4497
|
+
}
|
|
4498
|
+
|
|
4499
|
+
/**
|
|
4500
|
+
* Quasar Analysis Framework — Rainbow Collapse Transform
|
|
4501
|
+
*
|
|
4502
|
+
* Applies `collapse-rainbow` actions from a TransformationPlan.
|
|
4503
|
+
* Replaces sequences of [color] nodes forming a hue-rotation pattern
|
|
4504
|
+
* with a single [rainbow] node containing all text children.
|
|
4505
|
+
*
|
|
4506
|
+
* @example
|
|
4507
|
+
* Before: [color=#FF0000]R[/color][color=#FFFF00]O[/color][color=#00FF00]Y[/color]
|
|
4508
|
+
* After: [rainbow]ROY[/rainbow]
|
|
4509
|
+
*/
|
|
4510
|
+
|
|
4511
|
+
declare class RainbowCollapseTransform implements TransformPass {
|
|
4512
|
+
readonly id = "rainbow-collapse";
|
|
4513
|
+
run(tree: GreenNode, plan: TransformationPlan, _context: PipelineContext): GreenNode;
|
|
4514
|
+
private transformNode;
|
|
4515
|
+
}
|
|
4516
|
+
|
|
4517
|
+
/**
|
|
4518
|
+
* Quasar Analysis Framework — Wave Collapse Transform
|
|
4519
|
+
*
|
|
4520
|
+
* Applies `collapse-wave` actions from a TransformationPlan.
|
|
4521
|
+
* Replaces sequences of [size=N] nodes forming a sinusoidal pattern
|
|
4522
|
+
* with a single [grow] node containing all text children and the
|
|
4523
|
+
* detected size range in metadata.
|
|
4524
|
+
*
|
|
4525
|
+
* @example
|
|
4526
|
+
* Before: [size=90]A[/size][size=150]B[/size][size=90]C[/size]
|
|
4527
|
+
* After: [grow]ABC[/grow] (with metadata: min=90, max=150)
|
|
4528
|
+
*/
|
|
4529
|
+
|
|
4530
|
+
declare class WaveCollapseTransform implements TransformPass {
|
|
4531
|
+
readonly id = "wave-collapse";
|
|
4532
|
+
run(tree: GreenNode, plan: TransformationPlan, _context: PipelineContext): GreenNode;
|
|
4533
|
+
private transformNode;
|
|
4534
|
+
}
|
|
4535
|
+
|
|
4536
|
+
/**
|
|
4537
|
+
* Quasar Analysis Framework — Merge Colors Transform
|
|
4538
|
+
*
|
|
4539
|
+
* Applies `merge-colors` actions from a TransformationPlan.
|
|
4540
|
+
* Each action targets a sequence of identical consecutive [color=#HEX]
|
|
4541
|
+
* nodes and merges them into a single `[color]` wrapper with combined text.
|
|
4542
|
+
*
|
|
4543
|
+
* The transform operates on the Green Tree and returns a new tree —
|
|
4544
|
+
* it NEVER mutates the original.
|
|
4545
|
+
*
|
|
4546
|
+
* @example
|
|
4547
|
+
* Before:
|
|
4548
|
+
* [color=#FF0000]H[/color][color=#FF0000]e[/color][color=#FF0000]l[/color]
|
|
4549
|
+
* After:
|
|
4550
|
+
* [color=#FF0000]Hel[/color]
|
|
4551
|
+
*
|
|
4552
|
+
* @see TransformPass
|
|
4553
|
+
*/
|
|
4554
|
+
|
|
4555
|
+
declare class MergeColorsTransform implements TransformPass {
|
|
4556
|
+
readonly id = "merge-colors";
|
|
4557
|
+
run(tree: GreenNode, plan: TransformationPlan, _context: PipelineContext): GreenNode;
|
|
4558
|
+
private transformNode;
|
|
4559
|
+
private rebuildNode;
|
|
4560
|
+
private extractHex;
|
|
4561
|
+
}
|
|
4562
|
+
|
|
4563
|
+
export { ASTOptimizer, type AnalysisReport, type AnalyzeResult, type AnalyzerPass, type BBBlock, BBBlocksExporter, BBCODE_TAG_NAMES, type BBCodeDialect, BBCodeDocumentModel, type BBCodeDocumentModelOptions, BBCodeExporter, type BoxDrawerOptions, type BuildResult, type CentralGradientEffect, ChangeTracker, CharacterCountAnalyzer, CollapseGradientTransform, type Command, type CommandContext, CommandRegistry, type CommandResult, type Contribution, ContributionKind, type DecisionPass, DefaultDecision, DeleteNode, type Diagnostic, type DiagnosticContribution, type DiagnosticSeverity, type DiagnosticTag, type DiffKind, type DiffOperation, type DocumentChangeEvent, type DocumentEvent, DocumentEventBus, type DocumentEventHandler, DocumentModel, type DocumentNode, type DomToSVGOptions, type DomToSVGResult, type EditOperation, type ExportTarget$1 as ExportTarget, type FormatOptions, Formatter, GradientAnalyzer, type GradientDiagnostics, type GradientEffect, type GradientModel, type GradientOptions, type GradientStop, GradientTransformer, GreenNode, type GrowEffect, type GrowOptions, GrowTransformer, HTMLDocumentModel, HTMLRenderer, type HTMLRendererOptions, IncrementalParser, InsertText, JSONExporter, LYNE_ONLY_TAGS, Lexer, type LexerOptions, type LintResult, type LintRule, Linter, MILIASTRY_ONLY_TAGS, MarkdownDocumentModel, MarkdownExporter, type MatchResult, MergeColorsTransform, MergeNode, MergeableColorAnalyzer, type MetricsContribution, type MultiGradientEffect, type NodeAttributes, NodeFactory, type NodeKind, NodeMatcher, type NodeMetadata, type Operation, type OptimizationContribution, type Pass, Pipeline, PipelineBuilder, type PipelineContext, PipelineMode, type PipelineResult, PipelineStage, PluginAPI, type PluginContribution, type PluginManifest, PluginRegistry, type Query, QueryEngine, type QueryMatch, type QueryResult, RainbowAnalyzer, RainbowCollapseTransform, type RainbowDiagnostics, type RainbowEffect, type RainbowModel, type RainbowOptions, RainbowTransformer, RedNode, RedNodeStore, type Reference, type RenderNode, RenderPipeline, RenderTree, type RenderVariant, type SVGLayerInfo, SVGRenderer, SemanticAnalyzer, type SemanticContribution, type SineWaveOptions, SineWaveTransformer, type SourceRange, SplitNode, type SymbolInfo, type SymbolKind, type SymbolSearchResult, SymbolTable, type TagDefinition, type TagHandler, TagRegistry, type TextChange, TiptapExporter, type Token, type TokenKind, Transaction, type TransformAction, type TransformBias, type TransformPass, type TransformationPlan, type Transformer, TreeBuilder, TreeDiffer, type TreeEffect, type Trivia, type TriviaKind, type UIBBBlock, type UndoEntry, UndoManager, Visitor, type VisitorContext, type VisualThemeId, WaveAnalyzer, WaveCollapseTransform, type WaveDiagnostics, type WaveModel, WrapInTag, applyCentralGradient, applyEffect, applyGradient, applyGrow, applyMultiGradient, applyRainbow, bbBlockToGreenNode, bbBlocksToGreenTree, bbBlocksToRedTree, bindBoxDrawer, countTextLength, domToSVG, domToSVGResult, getBBCodeTagNames, greenLeaf, greenNode, greenToRedNode, htmlStringToGreenTree, isBlockKind, markdownAstToGreenTree, markdownAstToRedTree, morphHTML, nodeKindToTag, patchBlocksInto, tagToNodeKind, toggleBoxWithDrawer, transformOffset, transformRange, visualThemes };
|