@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
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DocumentEngine — BBCode Parser
|
|
3
|
+
*
|
|
4
|
+
* Builds a GreenNode (immutable syntax tree) from BBCode tokens.
|
|
5
|
+
*
|
|
6
|
+
* This is the core parsing logic that replaces the old BBCode parser.
|
|
7
|
+
* Unlike the old parser, this produces GreenNode directly — no BBBlock[]
|
|
8
|
+
* intermediate step. The flow is:
|
|
9
|
+
*
|
|
10
|
+
* BBCode text
|
|
11
|
+
* ↓ scanBBCode() [BBCodeLexer]
|
|
12
|
+
* BBCodeToken[]
|
|
13
|
+
* ↓ parseTokensToGreen() [this]
|
|
14
|
+
* GreenNode
|
|
15
|
+
* ↓ greenToRedNode()
|
|
16
|
+
* RedNode
|
|
17
|
+
*
|
|
18
|
+
* Key differences from the old parser:
|
|
19
|
+
* - Newlines are explicit tokens (not mixed into text) → precise empty_line detection
|
|
20
|
+
* - All syntactically valid tags are parsed (not just BLOCK_TAGS)
|
|
21
|
+
* - Produces GreenNode directly (no BBBlock[] intermediate)
|
|
22
|
+
* - No newline stripping between block-level tags
|
|
23
|
+
* - Consecutive newlines (2+) = empty_line node (paragraph break)
|
|
24
|
+
* - Single newlines between inline content = ignored (CSS handles spacing)
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { GreenNode, greenNode, greenLeaf } from '../Syntax/GreenNode'
|
|
28
|
+
import type { NodeKind } from '../Types/core'
|
|
29
|
+
import { GreenNodePool } from '../Syntax/GreenNodePool'
|
|
30
|
+
import { tagToNodeKind, type BBCodeDialect } from './BBCodeToGreenNode'
|
|
31
|
+
import type { BBCodeToken } from '../Lexer/BBCodeLexer'
|
|
32
|
+
import { scanBBCode } from '../Lexer/BBCodeLexer'
|
|
33
|
+
import { isBlockKind } from './BBCodeToGreenNode'
|
|
34
|
+
|
|
35
|
+
// ─── Main entry point ──────────────────────────────────────────
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Parse BBCode tokens into a GreenNode tree.
|
|
39
|
+
*
|
|
40
|
+
* @param tokens Flat array of tokens from scanBBCode()
|
|
41
|
+
* @param source Original source text (used for fallback error text)
|
|
42
|
+
* @returns A GreenNode tree with 'document' as root
|
|
43
|
+
*/
|
|
44
|
+
export interface ParseOptions {
|
|
45
|
+
strictMode?: boolean;
|
|
46
|
+
/** BBCode dialect to parse against ('osu' | 'miliastry' | 'lyne'). Default: 'miliastry'. */
|
|
47
|
+
dialect?: BBCodeDialect;
|
|
48
|
+
/** Optional interner for structural sharing. If provided, identical subtrees
|
|
49
|
+
* share the same GreenNode reference in memory. */
|
|
50
|
+
interner?: GreenNodePool;
|
|
51
|
+
/**
|
|
52
|
+
* Group top-level inline nodes into `paragraph` nodes. Default `true`.
|
|
53
|
+
*
|
|
54
|
+
* Paragraph grouping happens at the ROOT ONLY — inside `[centre]` or `[box]`
|
|
55
|
+
* the children stay flat. So when the incremental parser re-parses the inner
|
|
56
|
+
* span of such a container in isolation, that span's content is not root
|
|
57
|
+
* content and must not be grouped, or the re-parsed subtree would gain
|
|
58
|
+
* paragraphs the full parse never produces.
|
|
59
|
+
*/
|
|
60
|
+
normalizeParagraphs?: boolean;
|
|
61
|
+
/**
|
|
62
|
+
* Plugin-registered tags: tag name → the kind their nodes get.
|
|
63
|
+
*
|
|
64
|
+
* The built-in table deliberately turns unknown tags into literal text
|
|
65
|
+
* (`[Gateron]` in prose must stay visible), which also meant a plugin's tag
|
|
66
|
+
* could render and export but never PARSE. This is the missing link: a tag
|
|
67
|
+
* present here parses as a normal paired container. `BBCodeDocumentModel`
|
|
68
|
+
* fills it from the `TagRegistry`'s non-builtin entries; everything not
|
|
69
|
+
* registered still falls to literal text exactly as before.
|
|
70
|
+
*/
|
|
71
|
+
extraTags?: ReadonlyMap<string, NodeKind>;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function parseTokensToGreen(
|
|
75
|
+
tokens: BBCodeToken[],
|
|
76
|
+
source: string,
|
|
77
|
+
options: ParseOptions = {}
|
|
78
|
+
): GreenNode {
|
|
79
|
+
const strictMode = options.strictMode ?? false;
|
|
80
|
+
const dialect = options.dialect ?? 'miliastry';
|
|
81
|
+
const interner = options.interner ?? null;
|
|
82
|
+
const normalizeParagraphs = options.normalizeParagraphs ?? true;
|
|
83
|
+
const extraTags = options.extraTags;
|
|
84
|
+
const root: GreenNode[] = []
|
|
85
|
+
const stack: {
|
|
86
|
+
/** The literal tag name. Closing matches on THIS, not on `kind`: `[centre]`
|
|
87
|
+
* and `[center]` share a kind but do not close each other, and that is
|
|
88
|
+
* existing behaviour, not something to change while optimising. */
|
|
89
|
+
tag: string
|
|
90
|
+
/** Resolved once, when the tag opens. `tagToNodeKind` used to be called
|
|
91
|
+
* twice per element — once to test for `custom`, once to close it. */
|
|
92
|
+
kind: string
|
|
93
|
+
attrs: string
|
|
94
|
+
children: GreenNode[]
|
|
95
|
+
/** Width of the opening delimiter, e.g. 3 for `[b]`, 11 for `[color=red]`. */
|
|
96
|
+
leadingWidth: number
|
|
97
|
+
}[] = []
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Create an internal GreenNode, optionally interning for structural sharing.
|
|
101
|
+
*
|
|
102
|
+
* Note what is NOT here: a start or an end. A green node has a width, and its
|
|
103
|
+
* width is derived from its children plus its own delimiters, so the parser
|
|
104
|
+
* cannot state a span that disagrees with what it actually built.
|
|
105
|
+
*/
|
|
106
|
+
function createNode(
|
|
107
|
+
kind: string,
|
|
108
|
+
text: string,
|
|
109
|
+
children: GreenNode[],
|
|
110
|
+
leadingWidth: number = 0,
|
|
111
|
+
trailingWidth: number = 0,
|
|
112
|
+
): GreenNode {
|
|
113
|
+
if (interner) {
|
|
114
|
+
return interner.internNode(kind, text, children, leadingWidth, trailingWidth)
|
|
115
|
+
}
|
|
116
|
+
return greenNode(kind, text, children, leadingWidth, trailingWidth)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Create a token. `width` defaults to the text's own length. */
|
|
120
|
+
function createLeaf(kind: string, text: string, width: number = text.length): GreenNode {
|
|
121
|
+
if (interner) {
|
|
122
|
+
return interner.internLeaf(kind, text, width)
|
|
123
|
+
}
|
|
124
|
+
return greenLeaf(kind, text, width)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Close a stack frame into an element node.
|
|
129
|
+
*
|
|
130
|
+
* `trailingWidth` is how much of the element's tail is its own closing
|
|
131
|
+
* delimiter — 0 when it was auto-closed (by an enclosing tag, by a sibling
|
|
132
|
+
* `[*]`, or by end of input), because then the closing text belongs to
|
|
133
|
+
* somebody else or does not exist.
|
|
134
|
+
*
|
|
135
|
+
* The element's end used to be passed in as well. It no longer can be, and
|
|
136
|
+
* that is the point: the end is `start + leadingWidth + Σ children + trailing`
|
|
137
|
+
* by construction, so the partition invariant of point 14 stopped being
|
|
138
|
+
* something to check and became something to compute.
|
|
139
|
+
*/
|
|
140
|
+
function closeFrame(
|
|
141
|
+
frame: { kind: string; attrs: string; children: GreenNode[]; leadingWidth: number },
|
|
142
|
+
trailingWidth: number,
|
|
143
|
+
): GreenNode {
|
|
144
|
+
return createNode(
|
|
145
|
+
frame.kind,
|
|
146
|
+
frame.attrs,
|
|
147
|
+
frame.children,
|
|
148
|
+
frame.leadingWidth,
|
|
149
|
+
trailingWidth,
|
|
150
|
+
)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Add a node to the current stack frame, or to root if no frame is open.
|
|
155
|
+
*/
|
|
156
|
+
function addToParent(node: GreenNode): void {
|
|
157
|
+
if (stack.length > 0) {
|
|
158
|
+
stack[stack.length - 1].children.push(node)
|
|
159
|
+
} else {
|
|
160
|
+
root.push(node)
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
let i = 0
|
|
165
|
+
while (i < tokens.length) {
|
|
166
|
+
const tok = tokens[i]
|
|
167
|
+
|
|
168
|
+
// ── Newline token(s) ─────────────────────────────────────
|
|
169
|
+
if (tok.kind === 'newline') {
|
|
170
|
+
// Count consecutive newlines and emit semantic nodes.
|
|
171
|
+
// NO SUPPRESSION for inline context — the tree must preserve
|
|
172
|
+
// ALL source information. The HTMLRenderer handles inline-safe
|
|
173
|
+
// rendering of spacing/empty_line (as `<br>` / `<br><br>`).
|
|
174
|
+
// Each newline in the run gets ITS OWN range. They used to all share the
|
|
175
|
+
// whole run's span, so `"hola\n\nmundo"` produced `spacing [4..6]` and
|
|
176
|
+
// `empty_line [4..6]` — two nodes owning offset 5, which is exactly the
|
|
177
|
+
// ambiguity that made `shiftRanges` impossible to write correctly.
|
|
178
|
+
let first = true
|
|
179
|
+
|
|
180
|
+
while (i < tokens.length && tokens[i].kind === 'newline') {
|
|
181
|
+
const nl = tokens[i]
|
|
182
|
+
// The first newline is soft spacing (ignored in block context); any
|
|
183
|
+
// subsequent one is a hard empty line (rendered as `<br>` everywhere).
|
|
184
|
+
addToParent(createLeaf(first ? 'spacing' : 'empty_line', '', nl.end - nl.start))
|
|
185
|
+
first = false
|
|
186
|
+
i++
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
continue
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// ── Plain text ───────────────────────────────────────────
|
|
193
|
+
if (tok.kind === 'text') {
|
|
194
|
+
addToParent(createLeaf('text', tok.value))
|
|
195
|
+
i++
|
|
196
|
+
continue
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// ── Opening tag: [tag] or [tag=attrs] ──────────────────
|
|
200
|
+
if (tok.kind === 'open') {
|
|
201
|
+
// Unknown tags (not in the BBCode spec) must be preserved
|
|
202
|
+
// as literal text. Treating them as real tags would cause
|
|
203
|
+
// their content to disappear from the preview.
|
|
204
|
+
// E.g. [Gateron], [90 misses], [b][Gateron][/b] → visible text
|
|
205
|
+
//
|
|
206
|
+
// Plugin-registered tags (`extraTags`) are the one exception: they are
|
|
207
|
+
// known — just not to the built-in table — and parse as containers.
|
|
208
|
+
let openKind = tagToNodeKind(tok.tag, dialect)
|
|
209
|
+
if (openKind === 'custom') {
|
|
210
|
+
const pluginKind = extraTags?.get(tok.tag)
|
|
211
|
+
if (pluginKind === undefined) {
|
|
212
|
+
const tagText = source.slice(tok.start, tok.end)
|
|
213
|
+
addToParent(createLeaf('text', tagText))
|
|
214
|
+
i++
|
|
215
|
+
continue
|
|
216
|
+
}
|
|
217
|
+
openKind = pluginKind
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
if (tok.tag === '*') {
|
|
221
|
+
// Auto-close previous [*] if it's currently open. It ends where this
|
|
222
|
+
// one begins and owns no closing delimiter.
|
|
223
|
+
if (stack.length > 0 && stack[stack.length - 1].tag === '*') {
|
|
224
|
+
addToParent(closeFrame(stack.pop()!, 0))
|
|
225
|
+
}
|
|
226
|
+
stack.push({
|
|
227
|
+
tag: tok.tag,
|
|
228
|
+
kind: openKind,
|
|
229
|
+
attrs: tok.attrs,
|
|
230
|
+
children: [],
|
|
231
|
+
leadingWidth: tok.end - tok.start,
|
|
232
|
+
})
|
|
233
|
+
i++
|
|
234
|
+
continue
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
if (openKind === 'separator') {
|
|
238
|
+
addToParent(createLeaf('separator', tok.attrs, tok.end - tok.start))
|
|
239
|
+
i++
|
|
240
|
+
continue
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
let attrs = tok.attrs
|
|
244
|
+
if (openKind === 'effect' || openKind === 'anim' || openKind === 'container') {
|
|
245
|
+
if (tok.tag !== openKind) {
|
|
246
|
+
const param = tok.attrs.startsWith('=') ? tok.attrs.slice(1) : tok.attrs
|
|
247
|
+
attrs = param ? `=${tok.tag}:${param}` : `=${tok.tag}`
|
|
248
|
+
}
|
|
249
|
+
} else if (openKind === 'style_tag' && tok.tag !== 'style') {
|
|
250
|
+
if (tok.tag === 'nowrap') attrs = '=white-space:nowrap'
|
|
251
|
+
else if (tok.tag === 'smallcaps') attrs = '=font-variant:small-caps'
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// Push onto the stack — children will be added later.
|
|
255
|
+
stack.push({
|
|
256
|
+
tag: tok.tag,
|
|
257
|
+
kind: openKind,
|
|
258
|
+
attrs,
|
|
259
|
+
children: [],
|
|
260
|
+
leadingWidth: tok.end - tok.start,
|
|
261
|
+
})
|
|
262
|
+
i++
|
|
263
|
+
continue
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// ── Closing tag: [/tag] ──────────────────────────────────
|
|
267
|
+
if (tok.kind === 'close') {
|
|
268
|
+
const closeWidth = tok.end - tok.start
|
|
269
|
+
|
|
270
|
+
if (strictMode) {
|
|
271
|
+
if (stack.length > 0 && stack[stack.length - 1].tag === '*' && tok.tag === 'list') {
|
|
272
|
+
// Auto-close [*] before closing [list] even in strict mode. The
|
|
273
|
+
// `[/list]` belongs to the list, not to the item.
|
|
274
|
+
addToParent(closeFrame(stack.pop()!, 0))
|
|
275
|
+
}
|
|
276
|
+
// STRICT MODE: Only match the very top of the stack.
|
|
277
|
+
if (stack.length > 0 && stack[stack.length - 1].tag === tok.tag) {
|
|
278
|
+
addToParent(closeFrame(stack.pop()!, closeWidth))
|
|
279
|
+
} else {
|
|
280
|
+
// Strict Mode Mismatch: Emit an 'error' node with the raw tag as child
|
|
281
|
+
const expected = stack.length > 0 ? stack[stack.length - 1].tag : 'nothing'
|
|
282
|
+
const text = source.slice(tok.start, tok.end)
|
|
283
|
+
const errMsg = `Syntax Error: Expected /${expected}, got /${tok.tag}`
|
|
284
|
+
addToParent(createNode('error', errMsg, [createLeaf('text', text)]))
|
|
285
|
+
}
|
|
286
|
+
} else {
|
|
287
|
+
// LEGACY MODE: Walk backwards, auto-close inner tags, ignore orphaned closing tags.
|
|
288
|
+
let found = -1
|
|
289
|
+
for (let j = stack.length - 1; j >= 0; j--) {
|
|
290
|
+
if (stack[j].tag === tok.tag) {
|
|
291
|
+
found = j
|
|
292
|
+
break
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
if (found !== -1) {
|
|
297
|
+
// Auto-close any tags that were opened inside this one. They end
|
|
298
|
+
// where the closing delimiter BEGINS — the delimiter itself is owned
|
|
299
|
+
// by the tag it actually closes, so an auto-closed inner tag gets a
|
|
300
|
+
// trailing width of 0. This used to hand them `tok.end`, which made
|
|
301
|
+
// `[b][i]x[/b]` produce an `italic` and a `bold` both ending at 10,
|
|
302
|
+
// overlapping on the four characters of `[/b]`.
|
|
303
|
+
while (stack.length - 1 > found) {
|
|
304
|
+
const inner = stack.pop()!
|
|
305
|
+
stack[stack.length - 1].children.push(closeFrame(inner, 0))
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// Close the matched tag itself — this one does own the delimiter.
|
|
309
|
+
addToParent(closeFrame(stack.pop()!, closeWidth))
|
|
310
|
+
} else {
|
|
311
|
+
// Orphaned closing tag: no matching opener anywhere on the stack.
|
|
312
|
+
//
|
|
313
|
+
// This used to be dropped silently, which punched a hole in the
|
|
314
|
+
// source coverage — those characters belonged to no node, so the
|
|
315
|
+
// tree could not answer "what is at this offset?" for them. They are
|
|
316
|
+
// now kept as literal text, exactly as unknown tags already were
|
|
317
|
+
// (see the `custom` branch above), which is also what the user
|
|
318
|
+
// typed and therefore what they expect to see.
|
|
319
|
+
addToParent(createLeaf('text', source.slice(tok.start, tok.end)))
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
i++
|
|
324
|
+
continue
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// ── Close any remaining unclosed tags ────────────────────────
|
|
329
|
+
// These are tags that were opened but never closed in the source.
|
|
330
|
+
// They get all remaining source as their content.
|
|
331
|
+
while (stack.length > 0) {
|
|
332
|
+
// No closing delimiter exists, so trailing width is 0.
|
|
333
|
+
addToParent(closeFrame(stack.pop()!, 0))
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
if (!normalizeParagraphs) {
|
|
337
|
+
return createNode('document', '', root)
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// Normalize root inline nodes into paragraphs
|
|
341
|
+
const normalizedRoot: GreenNode[] = []
|
|
342
|
+
let currentParagraph: GreenNode[] = []
|
|
343
|
+
|
|
344
|
+
const flushParagraph = () => {
|
|
345
|
+
if (currentParagraph.length > 0) {
|
|
346
|
+
normalizedRoot.push(createNode('paragraph', '', currentParagraph))
|
|
347
|
+
currentParagraph = []
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
for (const child of root) {
|
|
352
|
+
// GreenNode.kind is a widened `string`; the kind vocabulary is shared with
|
|
353
|
+
// NodeKind and every value reaching here came from tagToNodeKind().
|
|
354
|
+
if (isBlockKind(child.kind as NodeKind) || child.kind === 'empty_line') {
|
|
355
|
+
flushParagraph()
|
|
356
|
+
normalizedRoot.push(child)
|
|
357
|
+
} else {
|
|
358
|
+
currentParagraph.push(child)
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
flushParagraph()
|
|
362
|
+
|
|
363
|
+
// The root spans the whole source, always. It used to be derived from its
|
|
364
|
+
// children (`root[0].start` … `last.end`), which meant that anything the
|
|
365
|
+
// parser dropped — an orphaned closing tag, most commonly — silently
|
|
366
|
+
// shrank the document. Every token now lands somewhere in the tree, so the
|
|
367
|
+
// children genuinely cover `[0..source.length]` and the root can say so.
|
|
368
|
+
return createNode('document', '', normalizedRoot)
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* Full parse: BBCode text → GreenNode.
|
|
373
|
+
*
|
|
374
|
+
* Convenience function that combines the lexer and parser.
|
|
375
|
+
* BBCodeDocumentModel internally uses scanBBCode() + parseTokensToGreen() directly.
|
|
376
|
+
*
|
|
377
|
+
* Usage:
|
|
378
|
+
* import { parseBBCode } from '../BBCode/Parser'
|
|
379
|
+
* const tree = parseBBCode('[b]Hello[/b]')
|
|
380
|
+
*/
|
|
381
|
+
export function parseBBCode(source: string, options: ParseOptions = {}): GreenNode {
|
|
382
|
+
const tokens = scanBBCode(source)
|
|
383
|
+
return parseTokensToGreen(tokens, source, options)
|
|
384
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export { BBCodeDocumentModel } from './BBCodeDocumentModel'
|
|
2
|
+
export { parseBBCode, parseTokensToGreen } from './Parser'
|
|
3
|
+
export {
|
|
4
|
+
bbBlocksToGreenTree,
|
|
5
|
+
greenToRedNode,
|
|
6
|
+
bbBlocksToRedTree,
|
|
7
|
+
bbBlockToGreenNode,
|
|
8
|
+
tagToNodeKind,
|
|
9
|
+
nodeKindToTag,
|
|
10
|
+
isBlockKind,
|
|
11
|
+
} from './BBCodeToGreenNode'
|
|
12
|
+
export type { BBBlock } from './BBCodeToGreenNode'
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DocumentEngine — Position transforms
|
|
3
|
+
*
|
|
4
|
+
* Where does a position end up after a text edit?
|
|
5
|
+
*
|
|
6
|
+
* This is the primitive collaboration stands on: keeping carets, selections
|
|
7
|
+
* and remote cursors pointing at the same *content* while the text shifts
|
|
8
|
+
* under them — whether the edit came from the local user, a remote peer, or
|
|
9
|
+
* a programmatic `transact`. See `QuasarCollab.MD` for the architecture; the
|
|
10
|
+
* short version is that Quasar syncs TEXT, so mapping positions through
|
|
11
|
+
* `TextChange`s is all the transform machinery the engine needs. This module
|
|
12
|
+
* transforms positions, not changes-against-changes: convergence of
|
|
13
|
+
* concurrent edits is the CRDT's job, not ours.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type { TextChange } from '../Incremental/ChangeTracker'
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Which side a position sticks to when an edit happens exactly at it.
|
|
20
|
+
*
|
|
21
|
+
* A caret usually wants `'right'`: text inserted at the caret by someone else
|
|
22
|
+
* should push it forward (you keep typing after their insertion). The start
|
|
23
|
+
* of a persistent highlight usually wants `'left'`: text inserted exactly at
|
|
24
|
+
* its start belongs before the highlight, not inside it.
|
|
25
|
+
*/
|
|
26
|
+
export type TransformBias = 'left' | 'right'
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Map `offset` through one change or an ordered sequence of changes.
|
|
30
|
+
*
|
|
31
|
+
* Rules, in order:
|
|
32
|
+
* - strictly before the edit → unchanged;
|
|
33
|
+
* - strictly after the replaced span → shifted by the length delta;
|
|
34
|
+
* - inside the replaced span → collapsed to the edit's boundary (`'left'` →
|
|
35
|
+
* where the replacement starts, `'right'` → where it ends). A position
|
|
36
|
+
* inside deleted text has no content to point at anymore; the boundary is
|
|
37
|
+
* the only honest answer;
|
|
38
|
+
* - exactly at a pure insertion point → `bias` decides which side of the
|
|
39
|
+
* inserted text it lands on.
|
|
40
|
+
*/
|
|
41
|
+
export function transformOffset(
|
|
42
|
+
offset: number,
|
|
43
|
+
changes: TextChange | readonly TextChange[],
|
|
44
|
+
bias: TransformBias = 'right',
|
|
45
|
+
): number {
|
|
46
|
+
const list = Array.isArray(changes) ? (changes as readonly TextChange[]) : [changes as TextChange]
|
|
47
|
+
let pos = offset
|
|
48
|
+
for (const change of list) {
|
|
49
|
+
pos = transformOne(pos, change, bias)
|
|
50
|
+
}
|
|
51
|
+
return pos
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function transformOne(offset: number, change: TextChange, bias: TransformBias): number {
|
|
55
|
+
const { start, end, text } = change
|
|
56
|
+
|
|
57
|
+
if (offset < start) return offset
|
|
58
|
+
|
|
59
|
+
const inserted = text.length
|
|
60
|
+
const isInsertion = end === start
|
|
61
|
+
|
|
62
|
+
if (isInsertion && offset === start) {
|
|
63
|
+
return bias === 'right' ? offset + inserted : offset
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (offset > end) {
|
|
67
|
+
return offset + inserted - (end - start)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Inside the replaced span (start <= offset <= end, with something replaced).
|
|
71
|
+
return bias === 'right' ? start + inserted : start
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Map a `{start, end}` range through one change or a sequence.
|
|
76
|
+
*
|
|
77
|
+
* The start sticks RIGHT and the end sticks LEFT, which is what preserves the
|
|
78
|
+
* selected content: text inserted exactly at a boundary lands OUTSIDE the
|
|
79
|
+
* range, so the range keeps covering exactly the characters it covered — it
|
|
80
|
+
* neither absorbs a neighbour's insertion nor leaks its own content. The
|
|
81
|
+
* result is clamped so it can never come out inverted; a range entirely
|
|
82
|
+
* inside deleted text collapses to a point at the edit boundary.
|
|
83
|
+
*/
|
|
84
|
+
export function transformRange(
|
|
85
|
+
range: { start: number; end: number },
|
|
86
|
+
changes: TextChange | readonly TextChange[],
|
|
87
|
+
): { start: number; end: number } {
|
|
88
|
+
const start = transformOffset(range.start, changes, 'right')
|
|
89
|
+
const end = transformOffset(range.end, changes, 'left')
|
|
90
|
+
return end < start ? { start, end: start } : { start, end }
|
|
91
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DocumentEngine — Command
|
|
3
|
+
*
|
|
4
|
+
* Commands are high-level user actions that MAY produce
|
|
5
|
+
* a Transaction (or multiple) to modify the document.
|
|
6
|
+
*
|
|
7
|
+
* Commands are stateless — they receive context and produce operations.
|
|
8
|
+
* This makes them testable, undoable, and safe for AI use.
|
|
9
|
+
*
|
|
10
|
+
* Inspired by ProseMirror's Commands and VSCode's Command system.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { DocumentModel } from '../Model/DocumentModel'
|
|
14
|
+
import type { Operation } from '../Types/operations'
|
|
15
|
+
import type { RedNode } from '../Syntax/RedNode'
|
|
16
|
+
|
|
17
|
+
export interface CommandContext {
|
|
18
|
+
model: DocumentModel
|
|
19
|
+
root: RedNode
|
|
20
|
+
selection?: {
|
|
21
|
+
nodeId: string
|
|
22
|
+
start: number
|
|
23
|
+
end: number
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export type CommandResult = {
|
|
28
|
+
success: boolean
|
|
29
|
+
operations?: Operation[]
|
|
30
|
+
message?: string
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface Command {
|
|
34
|
+
readonly id: string
|
|
35
|
+
readonly label: string
|
|
36
|
+
readonly description?: string
|
|
37
|
+
readonly shortcut?: string
|
|
38
|
+
execute(ctx: CommandContext): CommandResult
|
|
39
|
+
canExecute?(ctx: CommandContext): boolean
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function canExecute(command: Command, ctx: CommandContext): boolean {
|
|
43
|
+
return command.canExecute?.(ctx) ?? true
|
|
44
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DocumentEngine — CommandRegistry
|
|
3
|
+
*
|
|
4
|
+
* Central registry for all document commands.
|
|
5
|
+
* Commands can be added by plugins.
|
|
6
|
+
*
|
|
7
|
+
* Enables:
|
|
8
|
+
* - Keyboard shortcut binding
|
|
9
|
+
* - Toolbar integration
|
|
10
|
+
* - AI command execution
|
|
11
|
+
* - Menu integration
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { Command, CommandContext, CommandResult } from './Command'
|
|
15
|
+
|
|
16
|
+
export class CommandRegistry {
|
|
17
|
+
private commands: Map<string, Command> = new Map()
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Register a command.
|
|
21
|
+
*/
|
|
22
|
+
register(command: Command): void {
|
|
23
|
+
this.commands.set(command.id, command)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Unregister a command.
|
|
28
|
+
*/
|
|
29
|
+
unregister(id: string): void {
|
|
30
|
+
this.commands.delete(id)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Get a command by ID.
|
|
35
|
+
*/
|
|
36
|
+
get(id: string): Command | undefined {
|
|
37
|
+
return this.commands.get(id)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Check if a command is registered.
|
|
42
|
+
*/
|
|
43
|
+
has(id: string): boolean {
|
|
44
|
+
return this.commands.has(id)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Execute a command by ID.
|
|
49
|
+
*/
|
|
50
|
+
execute(id: string, ctx: CommandContext): CommandResult {
|
|
51
|
+
const command = this.commands.get(id)
|
|
52
|
+
if (!command) {
|
|
53
|
+
return { success: false, message: `Unknown command: ${id}` }
|
|
54
|
+
}
|
|
55
|
+
return command.execute(ctx)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Get all registered commands.
|
|
60
|
+
*/
|
|
61
|
+
getAll(): Command[] {
|
|
62
|
+
return Array.from(this.commands.values())
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Get commands by a filter function.
|
|
67
|
+
*/
|
|
68
|
+
filter(predicate: (cmd: Command) => boolean): Command[] {
|
|
69
|
+
return this.getAll().filter(predicate)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Get the count of registered commands.
|
|
74
|
+
*/
|
|
75
|
+
get size(): number {
|
|
76
|
+
return this.commands.size
|
|
77
|
+
}
|
|
78
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DocumentEngine — DeleteNode Command
|
|
3
|
+
*
|
|
4
|
+
* Deletes a node from the document tree.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { Command, CommandContext, CommandResult } from './Command'
|
|
8
|
+
|
|
9
|
+
export const DeleteNode: Command = {
|
|
10
|
+
id: 'delete-node',
|
|
11
|
+
label: 'Delete Node',
|
|
12
|
+
description: 'Delete the selected node',
|
|
13
|
+
|
|
14
|
+
execute(ctx: CommandContext): CommandResult {
|
|
15
|
+
return {
|
|
16
|
+
success: true,
|
|
17
|
+
message: 'Node deleted',
|
|
18
|
+
}
|
|
19
|
+
},
|
|
20
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DocumentEngine — InsertText Command
|
|
3
|
+
*
|
|
4
|
+
* Inserts text at the current cursor position.
|
|
5
|
+
* Creates or reuses a text node.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { Command, CommandContext, CommandResult } from './Command'
|
|
9
|
+
|
|
10
|
+
export const InsertText: Command = {
|
|
11
|
+
id: 'insert-text',
|
|
12
|
+
label: 'Insert Text',
|
|
13
|
+
description: 'Insert text at the current cursor position',
|
|
14
|
+
|
|
15
|
+
execute(ctx: CommandContext): CommandResult {
|
|
16
|
+
return {
|
|
17
|
+
success: true,
|
|
18
|
+
message: 'Text inserted',
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DocumentEngine — SplitNode & MergeNodes Commands
|
|
3
|
+
*
|
|
4
|
+
* Split a node at a position, or merge two adjacent nodes.
|
|
5
|
+
* Essential for block editing and backspace handling.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { Command, CommandContext, CommandResult } from './Command'
|
|
9
|
+
|
|
10
|
+
export const SplitNode: Command = {
|
|
11
|
+
id: 'split-node',
|
|
12
|
+
label: 'Split Node',
|
|
13
|
+
description: 'Split a node at the current cursor position',
|
|
14
|
+
|
|
15
|
+
execute(ctx: CommandContext): CommandResult {
|
|
16
|
+
return { success: true, message: 'Node split' }
|
|
17
|
+
},
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const MergeNode: Command = {
|
|
21
|
+
id: 'merge-nodes',
|
|
22
|
+
label: 'Merge Nodes',
|
|
23
|
+
description: 'Merge two adjacent nodes',
|
|
24
|
+
|
|
25
|
+
execute(ctx: CommandContext): CommandResult {
|
|
26
|
+
return { success: true, message: 'Nodes merged' }
|
|
27
|
+
},
|
|
28
|
+
}
|