@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.
Files changed (172) hide show
  1. package/LICENSE +119 -0
  2. package/README.md +45 -0
  3. package/dist/Visuals/lyne.css +1027 -0
  4. package/dist/Visuals/osu.css +257 -0
  5. package/dist/index.d.mts +4563 -0
  6. package/dist/index.d.ts +4563 -0
  7. package/dist/index.js +11291 -0
  8. package/dist/index.mjs +11197 -0
  9. package/package.json +51 -0
  10. package/src/Analysis/Contracts/AnalysisReport.ts +26 -0
  11. package/src/Analysis/Contracts/Contribution.ts +76 -0
  12. package/src/Analysis/Contracts/Pass.ts +76 -0
  13. package/src/Analysis/Contracts/PipelineContext.ts +48 -0
  14. package/src/Analysis/Passes/Analysis/GradientAnalyzer.ts +292 -0
  15. package/src/Analysis/Passes/Analysis/MergeableColorAnalyzer.ts +112 -0
  16. package/src/Analysis/Passes/Analysis/RainbowAnalyzer.ts +233 -0
  17. package/src/Analysis/Passes/Analysis/WaveAnalyzer.ts +211 -0
  18. package/src/Analysis/Passes/Analysis/__tests__/GradientAnalyzer.test.ts +135 -0
  19. package/src/Analysis/Passes/Analysis/__tests__/MergeableColorAnalyzer.test.ts +84 -0
  20. package/src/Analysis/Passes/Analysis/__tests__/RainbowAnalyzer.test.ts +99 -0
  21. package/src/Analysis/Passes/Analysis/__tests__/WaveAnalyzer.test.ts +119 -0
  22. package/src/Analysis/Passes/Decision/DefaultDecision.ts +139 -0
  23. package/src/Analysis/Passes/Decision/__tests__/DefaultDecision.test.ts +179 -0
  24. package/src/Analysis/Passes/Transform/CollapseGradientTransform.ts +176 -0
  25. package/src/Analysis/Passes/Transform/MergeColorsTransform.ts +126 -0
  26. package/src/Analysis/Passes/Transform/RainbowCollapseTransform.ts +83 -0
  27. package/src/Analysis/Passes/Transform/WaveCollapseTransform.ts +88 -0
  28. package/src/Analysis/Passes/Utility/CharacterCountAnalyzer.ts +45 -0
  29. package/src/Analysis/Pipeline/Pipeline.ts +133 -0
  30. package/src/Analysis/Pipeline/PipelineBuilder.ts +55 -0
  31. package/src/Analysis/Pipeline/PipelineStage.ts +19 -0
  32. package/src/Analysis/Utils/color-utils.ts +132 -0
  33. package/src/Analysis/__tests__/Integration.test.ts +162 -0
  34. package/src/Analysis/__tests__/Pipeline.test.ts +133 -0
  35. package/src/Analysis/index.ts +52 -0
  36. package/src/BBCode/BBCodeDocumentModel.ts +175 -0
  37. package/src/BBCode/BBCodeToGreenNode.ts +755 -0
  38. package/src/BBCode/Parser.ts +384 -0
  39. package/src/BBCode/index.ts +12 -0
  40. package/src/Collab/positions.ts +91 -0
  41. package/src/Commands/Command.ts +44 -0
  42. package/src/Commands/CommandRegistry.ts +78 -0
  43. package/src/Commands/DeleteNode.ts +20 -0
  44. package/src/Commands/InsertText.ts +21 -0
  45. package/src/Commands/SplitMerge.ts +28 -0
  46. package/src/Commands/WrapInTag.ts +21 -0
  47. package/src/Commands/index.ts +6 -0
  48. package/src/Diff/TreeDiffer.ts +264 -0
  49. package/src/Diff/__tests__/TreeDiffer.test.ts +65 -0
  50. package/src/Diff/index.ts +2 -0
  51. package/src/Events/EventBus.ts +160 -0
  52. package/src/Events/index.ts +2 -0
  53. package/src/Formatter/Formatter.ts +54 -0
  54. package/src/Formatter/index.ts +2 -0
  55. package/src/HTML/HTMLDocumentModel.ts +35 -0
  56. package/src/HTML/HTMLToGreenNode.ts +290 -0
  57. package/src/Incremental/ChangeTracker.ts +105 -0
  58. package/src/Incremental/IncrementalParser.ts +591 -0
  59. package/src/Incremental/__tests__/IncrementalParser.test.ts +164 -0
  60. package/src/Incremental/index.ts +4 -0
  61. package/src/Lexer/BBCodeLexer.ts +382 -0
  62. package/src/Lexer/Lexer.ts +181 -0
  63. package/src/Lexer/index.ts +10 -0
  64. package/src/Linter/Linter.ts +193 -0
  65. package/src/Linter/index.ts +2 -0
  66. package/src/Markdown/MarkdownAST.ts +112 -0
  67. package/src/Markdown/MarkdownDocumentModel.ts +55 -0
  68. package/src/Markdown/MarkdownLexer.ts +203 -0
  69. package/src/Markdown/MarkdownParser.ts +455 -0
  70. package/src/Markdown/MarkdownToGreenNode.ts +153 -0
  71. package/src/Model/DocumentModel.ts +694 -0
  72. package/src/Model/NodeFactory.ts +117 -0
  73. package/src/Model/TagRegistry.ts +495 -0
  74. package/src/Model/index.ts +5 -0
  75. package/src/Plugins/PluginAPI.ts +119 -0
  76. package/src/Plugins/PluginRegistry.ts +132 -0
  77. package/src/Plugins/index.ts +3 -0
  78. package/src/Queries/QueryEngine.ts +152 -0
  79. package/src/Queries/index.ts +1 -0
  80. package/src/RenderPipeline/RenderPipeline.ts +125 -0
  81. package/src/RenderPipeline/RenderTree.ts +134 -0
  82. package/src/RenderPipeline/index.ts +4 -0
  83. package/src/Semantic/SemanticAnalyzer.ts +506 -0
  84. package/src/Semantic/index.ts +2 -0
  85. package/src/Symbols/SymbolTable.ts +124 -0
  86. package/src/Symbols/index.ts +1 -0
  87. package/src/Syntax/GreenNode.ts +324 -0
  88. package/src/Syntax/GreenNodePool.ts +269 -0
  89. package/src/Syntax/NodeMatcher.ts +370 -0
  90. package/src/Syntax/RedNode.ts +569 -0
  91. package/src/Syntax/RedNodeStore.ts +184 -0
  92. package/src/Syntax/TreeBuilder.ts +214 -0
  93. package/src/Syntax/__tests__/GreenNode.test.ts +33 -0
  94. package/src/Syntax/__tests__/RedNode.test.ts +81 -0
  95. package/src/Syntax/__tests__/RedNodeStore.test.ts +104 -0
  96. package/src/Syntax/greenEdit.ts +110 -0
  97. package/src/Syntax/hash.ts +30 -0
  98. package/src/Syntax/index.ts +12 -0
  99. package/src/Syntax/partition.ts +161 -0
  100. package/src/Syntax/preserveNodeIds.ts +201 -0
  101. package/src/Tests/ASTOptimizerIdempotence.test.ts +77 -0
  102. package/src/Tests/BlockPatcher.test.ts +437 -0
  103. package/src/Tests/BlockPatcherWindowed.test.ts +364 -0
  104. package/src/Tests/BoxDrawer.test.ts +217 -0
  105. package/src/Tests/BoxRichTitle.test.ts +105 -0
  106. package/src/Tests/Chars500kBenchmark.test.ts +151 -0
  107. package/src/Tests/Chars500kEdits.test.ts +321 -0
  108. package/src/Tests/CollabPositions.test.ts +146 -0
  109. package/src/Tests/CompilerPathProfiling.test.ts +186 -0
  110. package/src/Tests/DOMMorpher.test.ts +142 -0
  111. package/src/Tests/DomPatchPerf.test.ts +60 -0
  112. package/src/Tests/EffectSegments.snapshot.json +616 -0
  113. package/src/Tests/EffectSegments.test.ts +68 -0
  114. package/src/Tests/FindNodeAtOffset.test.ts +65 -0
  115. package/src/Tests/Fuzzer.test.ts +166 -0
  116. package/src/Tests/GreenNodePool.test.ts +153 -0
  117. package/src/Tests/Lexer.test.ts +238 -0
  118. package/src/Tests/LyneMode.test.ts +187 -0
  119. package/src/Tests/ModelCoherence.test.ts +180 -0
  120. package/src/Tests/Partition.test.ts +238 -0
  121. package/src/Tests/PluginTags.test.ts +150 -0
  122. package/src/Tests/ProblematicSection.test.ts +46 -0
  123. package/src/Tests/ProblematicSectionHTML.test.ts +58 -0
  124. package/src/Tests/RedReuse.test.ts +134 -0
  125. package/src/Tests/ReproDelete20k.test.ts +62 -0
  126. package/src/Tests/SemanticValidators.test.ts +136 -0
  127. package/src/Tests/StableNodeIds.test.ts +210 -0
  128. package/src/Tests/StudioColorBloat.test.ts +25 -0
  129. package/src/Tests/StudioDebugText.test.ts +27 -0
  130. package/src/Tests/StudioTrailingChar.test.ts +25 -0
  131. package/src/Tests/StudioValidText.test.ts +25 -0
  132. package/src/Tests/UrlImgBug.test.ts +23 -0
  133. package/src/Tests/VisualBuilderFidelity.test.ts +105 -0
  134. package/src/Tests/referenceDocument.ts +119 -0
  135. package/src/Transactions/Transaction.ts +176 -0
  136. package/src/Transactions/UndoManager.ts +111 -0
  137. package/src/Transactions/index.ts +3 -0
  138. package/src/Transformers/ASTOptimizer.ts +315 -0
  139. package/src/Transformers/GradientTransformer.ts +143 -0
  140. package/src/Transformers/GrowTransformer.ts +115 -0
  141. package/src/Transformers/RainbowTransformer.ts +121 -0
  142. package/src/Transformers/SineWaveTransformer.ts +130 -0
  143. package/src/Transformers/Transformer.ts +22 -0
  144. package/src/Types/core.ts +270 -0
  145. package/src/Types/diagnostics.ts +156 -0
  146. package/src/Types/index.ts +23 -0
  147. package/src/Types/operations.ts +180 -0
  148. package/src/Types/queries.ts +121 -0
  149. package/src/Types/symbols.ts +69 -0
  150. package/src/Types/tokens.ts +186 -0
  151. package/src/Utils/BBCodeGenerator.ts +126 -0
  152. package/src/Utils/ColorMath.ts +276 -0
  153. package/src/Utils/color.ts +112 -0
  154. package/src/Utils/dom-to-svg.test.ts +86 -0
  155. package/src/Utils/dom-to-svg.ts +615 -0
  156. package/src/Utils/treeTransformers.ts +717 -0
  157. package/src/Visitors/BBBlocksExporter.ts +69 -0
  158. package/src/Visitors/BBCodeExporter.ts +318 -0
  159. package/src/Visitors/BlockPatcher.ts +963 -0
  160. package/src/Visitors/DOMMorpher.ts +134 -0
  161. package/src/Visitors/HTMLRenderer.ts +1077 -0
  162. package/src/Visitors/JSONExporter.ts +66 -0
  163. package/src/Visitors/MarkdownExporter.ts +99 -0
  164. package/src/Visitors/SVGRenderer.ts +35 -0
  165. package/src/Visitors/TiptapExporter.ts +145 -0
  166. package/src/Visitors/Visitor.ts +48 -0
  167. package/src/Visitors/index.ts +9 -0
  168. package/src/Visuals/BoxDrawer.ts +175 -0
  169. package/src/Visuals/index.ts +42 -0
  170. package/src/Visuals/lyne.css +1027 -0
  171. package/src/Visuals/osu.css +257 -0
  172. 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
+ }