@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,164 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { BBCodeDocumentModel } from '../../BBCode/BBCodeDocumentModel'
3
+ import { GreenNode } from '../../Syntax/GreenNode'
4
+ import { checkPartition } from '../../Syntax/partition'
5
+ import { IncrementalParser } from '../IncrementalParser'
6
+
7
+ /**
8
+ * The contract of an incremental reparse is not "it is fast" — it is "you
9
+ * cannot tell". Every test here compares the incrementally updated tree
10
+ * against a full rebuild of the same final text and demands they be identical,
11
+ * because the failure mode of the old implementation was precisely a tree that
12
+ * looked plausible and rendered wrong.
13
+ *
14
+ * Note that RedNode identity is deliberately NOT preserved: the red tree is
15
+ * derived from the new green root, exactly as a full rebuild would derive it.
16
+ * The previous implementation kept ids for untouched siblings by splicing red
17
+ * nodes in place, which is also how it ended up with ranges that no longer
18
+ * described the text. Nothing depends on those ids — `DOMMorpher` matches on
19
+ * structure (`isEqualNode`), not on `data-node-id`.
20
+ */
21
+
22
+ /** First structural difference between two green trees, or null if identical. */
23
+ function firstDiff(a: GreenNode, b: GreenNode, path = a.kind): string | null {
24
+ if (a.kind !== b.kind) return `${path}: kind ${a.kind} vs ${b.kind}`
25
+ if (a.text !== b.text) return `${path}: text ${JSON.stringify(a.text)} vs ${JSON.stringify(b.text)}`
26
+ if (a.width !== b.width) return `${path}: width ${a.width} vs ${b.width}`
27
+ if (a.leadingWidth !== b.leadingWidth || a.trailingWidth !== b.trailingWidth) {
28
+ return `${path}: widths ${a.leadingWidth}/${a.trailingWidth} vs ${b.leadingWidth}/${b.trailingWidth}`
29
+ }
30
+ if (a.children.length !== b.children.length) {
31
+ return `${path}: ${a.children.length} children vs ${b.children.length}`
32
+ }
33
+ for (let i = 0; i < a.children.length; i++) {
34
+ const child = a.children[i] as GreenNode
35
+ const d = firstDiff(child, b.children[i] as GreenNode, `${path}/${child.kind}[${i}]`)
36
+ if (d) return d
37
+ }
38
+ return null
39
+ }
40
+
41
+ /**
42
+ * Apply `edits` incrementally and assert the result matches a full rebuild.
43
+ *
44
+ * The size thresholds are lifted so these documents stay short and readable:
45
+ * they are performance tuning, and what is under test here is the splice.
46
+ */
47
+ function expectMatchesRebuild(source: string, edits: string[]): BBCodeDocumentModel {
48
+ const model = new BBCodeDocumentModel({ source, autoAnalyze: false })
49
+ ;(model as { incrementalParser: IncrementalParser }).incrementalParser =
50
+ new IncrementalParser({ minSourceLength: 0, maxRegionFraction: 1 })
51
+ for (const text of edits) model.applyTextUpdate(text)
52
+
53
+ const final = edits[edits.length - 1]
54
+ const truth = new BBCodeDocumentModel({ source: final, autoAnalyze: false })
55
+
56
+ expect(firstDiff(model.greenRoot!, truth.greenRoot!)).toBeNull()
57
+ expect(checkPartition(model.greenRoot!, final.length, { limit: 5 })).toEqual([])
58
+ // The red tree must describe the same text as the green one it came from.
59
+ expect(model.redRoot!.range).toEqual({ start: 0, end: final.length })
60
+ return model
61
+ }
62
+
63
+ describe('IncrementalParser', () => {
64
+ it('reparses only the affected container', () => {
65
+ const source = '[quote]Hello[/quote][box]World[/box]'
66
+ const model = expectMatchesRebuild(source, ['[quote]Hello![/quote][box]World[/box]'])
67
+
68
+ expect(model.lastReparsePath).toBe('incremental')
69
+ const quote = model.redRoot!.children.find(c => c.kind === 'quote')!
70
+ expect(quote.children[0].text).toContain('Hello!')
71
+ })
72
+
73
+ it('shifts the ranges of everything after the edit', () => {
74
+ // The defect this pins: siblings and ancestors used to keep stale ranges.
75
+ const model = expectMatchesRebuild(
76
+ '[quote]ab[/quote][box]cd[/box]',
77
+ ['[quote]abXYZ[/quote][box]cd[/box]'],
78
+ )
79
+
80
+ expect(model.lastReparsePath).toBe('incremental')
81
+ const box = model.redRoot!.children.find(c => c.kind === 'box')!
82
+ // '[quote]abXYZ[/quote]' is 20 chars, so [box] must start at 20, not 17.
83
+ expect(box.range.start).toBe(20)
84
+ })
85
+
86
+ it('keeps green and red describing the same tree', () => {
87
+ // `reparse` used to return the OLD green root while returning a NEW red
88
+ // one, leaving the model permanently desynchronised.
89
+ const model = expectMatchesRebuild('[notice]uno[/notice]', ['[notice]uno dos[/notice]'])
90
+
91
+ expect(model.greenRoot).toBe(model.redRoot!.green)
92
+ expect(model.greenRoot!.width).toBe('[notice]uno dos[/notice]'.length)
93
+ })
94
+
95
+ it('survives a long sequence of edits in the same container', () => {
96
+ const base = '[centre][b]hola[/b] mundo[/centre]'
97
+ const edits: string[] = []
98
+ let text = base
99
+ for (const ch of 'abcdefghij') {
100
+ text = text.slice(0, 15) + ch + text.slice(15)
101
+ edits.push(text)
102
+ }
103
+ const model = expectMatchesRebuild(base, edits)
104
+ expect(model.lastReparsePath).toBe('incremental')
105
+ })
106
+
107
+ describe('edita a nivel de raíz sin caer a rebuild', () => {
108
+ // Escribir al final de un post es la posición de caret más común que hay, y
109
+ // durante mucho tiempo fue la que peor se comportaba: ningún contenedor de
110
+ // bloque la envolvía, así que cada pulsación reconstruía el documento
111
+ // entero. Con la ventana de hermanos, el padre es el propio `document`.
112
+ it('añadir al final', () => {
113
+ const model = expectMatchesRebuild('[quote]hola[/quote]', ['[quote]hola[/quote]cola'])
114
+ expect(model.lastReparsePath).toBe('incremental')
115
+ })
116
+
117
+ it('escribir dentro de un párrafo suelto', () => {
118
+ const model = expectMatchesRebuild(
119
+ 'primer parrafo\n\nsegundo parrafo',
120
+ ['primer parrafo\n\nsegundo parrafoX'],
121
+ )
122
+ expect(model.lastReparsePath).toBe('incremental')
123
+ })
124
+
125
+ it('una línea en blanco nueva PARTE el párrafo', () => {
126
+ // El caso que obliga a que `paragraph` sea opaco: la división solo es
127
+ // visible reparseando a nivel de raíz, no dentro del párrafo.
128
+ expectMatchesRebuild('uno dos tres', ['uno\n\ndos tres'])
129
+ })
130
+
131
+ it('borrar la línea en blanco FUNDE los párrafos', () => {
132
+ // El caso que obliga a ensanchar la ventana: el cambio solo toca el nodo
133
+ // de en medio, y la fusión ocurre entre sus dos vecinos.
134
+ expectMatchesRebuild('uno\n\ndos', ['uno\ndos', 'unodos'])
135
+ })
136
+ })
137
+
138
+ it('handles deletions as well as insertions', () => {
139
+ expectMatchesRebuild('[quote]abcdefgh[/quote]tail', [
140
+ '[quote]abcdefg[/quote]tail',
141
+ '[quote]abcdef[/quote]tail',
142
+ '[quote]abc[/quote]tail',
143
+ ])
144
+ })
145
+
146
+ describe('declines to splice when it cannot be sure', () => {
147
+ const cases: [string, string, string][] = [
148
+ // A nested container of the same kind would steal the closing delimiter.
149
+ ['un [centre] anidado', '[centre]hola[/centre]', '[centre]ho[centre]la[/centre]'],
150
+ // A half-typed tag leaves a bracket the region cannot resolve alone.
151
+ ['un [ a medias', '[quote]hola[/quote]', '[quote]ho[la[/quote]'],
152
+ // An unclosed [code] would swallow past the region.
153
+ ['un [code] sin cerrar', '[quote]hola[/quote]', '[quote]ho[code]la[/quote]'],
154
+ ]
155
+
156
+ for (const [name, source, edited] of cases) {
157
+ it(name, () => {
158
+ const model = expectMatchesRebuild(source, [edited])
159
+ expect(model.lastReparsePath).toBe('full_rebuild')
160
+ expect(model.lastReparseFallbackReason).not.toBeNull()
161
+ })
162
+ }
163
+ })
164
+ })
@@ -0,0 +1,4 @@
1
+ export { IncrementalParser } from './IncrementalParser'
2
+ export type { EditOperation, ReparseResult } from './IncrementalParser'
3
+ export { ChangeTracker } from './ChangeTracker'
4
+ export type { TextChange, TextChangeStats } from './ChangeTracker'
@@ -0,0 +1,382 @@
1
+ /**
2
+ * DocumentEngine — BBCodeLexer
3
+ *
4
+ * A purpose-built lexer for BBCode that produces explicit newline tokens.
5
+ * This replaces the old BBCode parser's Tokenizer by producing a cleaner
6
+ * token stream that allows the parser to make semantic decisions about
7
+ * spacing (single vs double newlines → empty_line nodes).
8
+ *
9
+ * Unlike the generic Lexer base class, this is tuned specifically for BBCode
10
+ * syntax: [tag], [/tag], [tag=value], text content, and newlines.
11
+ *
12
+ * Token types:
13
+ * - open: [tag] or [tag=attrs]
14
+ * - close: [/tag]
15
+ * - text: Any content between tags (never includes newlines or brackets)
16
+ * - newline: \n or \r\n (explicit — not mixed into text tokens)
17
+ *
18
+ * Architecture: Pure function, no state, no class. Input → Output.
19
+ * Can be used in workers, SSR, or client-side without initialization.
20
+ */
21
+
22
+ // ─── Token Types ───────────────────────────────────────────────
23
+
24
+ export interface BBCodeOpenToken {
25
+ kind: 'open'
26
+ tag: string
27
+ attrs: string
28
+ start: number
29
+ end: number
30
+ }
31
+
32
+ export interface BBCodeCloseToken {
33
+ kind: 'close'
34
+ tag: string
35
+ start: number
36
+ end: number
37
+ }
38
+
39
+ export interface BBCodeTextToken {
40
+ kind: 'text'
41
+ value: string
42
+ start: number
43
+ end: number
44
+ }
45
+
46
+ export interface BBCodeNewlineToken {
47
+ kind: 'newline'
48
+ value: string
49
+ start: number
50
+ end: number
51
+ }
52
+
53
+ export type BBCodeToken =
54
+ | BBCodeOpenToken
55
+ | BBCodeCloseToken
56
+ | BBCodeTextToken
57
+ | BBCodeNewlineToken
58
+
59
+ // ─── Tag name validation ───────────────────────────────────────
60
+ //
61
+ // Tag names used to be handled with two regexes: one tested CHARACTER BY
62
+ // CHARACTER to find where the name ends, and one validated the result. Between
63
+ // them they cost 30% of the lexer on the reference document — the per-character
64
+ // one alone was 20%, because every step allocated a one-character string and
65
+ // entered the regex engine to ask a question four integer comparisons answer.
66
+ //
67
+ // The scan below does the finding, the validating and the case detection in a
68
+ // single pass over char codes.
69
+
70
+ const CHAR_UPPER_A = 65
71
+ const CHAR_UPPER_Z = 90
72
+ const CHAR_LOWER_A = 97
73
+ const CHAR_LOWER_Z = 122
74
+ const CHAR_ZERO = 48
75
+ const CHAR_NINE = 57
76
+ const CHAR_UNDERSCORE = 95
77
+ const CHAR_HYPHEN = 45
78
+ const CHAR_STAR = 42
79
+ const CHAR_BRACKET_OPEN = 91
80
+ const CHAR_BRACKET_CLOSE = 93
81
+ const CHAR_SLASH = 47
82
+ const CHAR_LF = 10
83
+ const CHAR_CR = 13
84
+
85
+ /** Valid in a tag name: `a-z A-Z 0-9 _ * -` — the old `/[a-zA-Z0-9_*-]/`. */
86
+ function isNameChar(c: number): boolean {
87
+ return (
88
+ (c >= CHAR_LOWER_A && c <= CHAR_LOWER_Z) ||
89
+ (c >= CHAR_UPPER_A && c <= CHAR_UPPER_Z) ||
90
+ (c >= CHAR_ZERO && c <= CHAR_NINE) ||
91
+ c === CHAR_UNDERSCORE ||
92
+ c === CHAR_HYPHEN ||
93
+ c === CHAR_STAR
94
+ )
95
+ }
96
+
97
+ /**
98
+ * How many valid name characters run from `from`, and whether any is uppercase.
99
+ *
100
+ * The uppercase flag is what lets the caller skip `toLowerCase()` — a second
101
+ * 18% of the lexer, spent overwhelmingly on strings that were already lower
102
+ * case and came back as identical copies.
103
+ *
104
+ * Returns the length in the low bits and the uppercase flag in bit 31, so the
105
+ * hot path allocates nothing. `limit` is exclusive.
106
+ */
107
+ function scanNameChars(source: string, from: number, limit: number): number {
108
+ let i = from
109
+ let hasUpper = false
110
+ while (i < limit) {
111
+ const c = source.charCodeAt(i)
112
+ if (!isNameChar(c)) break
113
+ if (c >= CHAR_UPPER_A && c <= CHAR_UPPER_Z) hasUpper = true
114
+ i++
115
+ }
116
+ const length = i - from
117
+ return hasUpper ? length | 0x4000_0000 : length
118
+ }
119
+
120
+ const NAME_LENGTH_MASK = 0x3fff_ffff
121
+ const NAME_HAS_UPPER = 0x4000_0000
122
+
123
+ // ─── Scan ──────────────────────────────────────────────────────
124
+
125
+ /**
126
+ * Scan BBCode source text into a flat array of tokens.
127
+ *
128
+ * The lexer distinguishes between:
129
+ * - Valid BBCode tags: [b], [color=red], [/b], [*]
130
+ * - Invalid syntax: [bogus stuff, [unclosed, lone [
131
+ *
132
+ * Invalid syntax is treated as plain text — the parser will never
133
+ * see malformed tokens.
134
+ *
135
+ * Newlines are always emitted as distinct tokens so the parser
136
+ * can precisely determine spacing semantics.
137
+ */
138
+ export function scanBBCode(source: string): BBCodeToken[] {
139
+ const tokens: BBCodeToken[] = []
140
+ const length = source.length
141
+ let pos = 0
142
+
143
+ // Lower-cased copy of the WHOLE source, allocated lazily. It exists only to
144
+ // find the end of a raw `[code]` block, and most documents contain none —
145
+ // the reference document does not, and paid for a 19.6 KB copy anyway.
146
+ let lowerSource: string | null = null
147
+ const lowerOf = (): string => (lowerSource ??= source.toLowerCase())
148
+
149
+ // ── Bracket matching memo ──────────────────────────────────
150
+ //
151
+ // Naively rescanning for the closing bracket at every `[` is O(n^2): a
152
+ // document of unmatched brackets (`[[[[[...`, common in code snippets and
153
+ // ASCII art) made a 16 KB paste cost ~550 ms and froze the editor.
154
+ //
155
+ // A single scan already discovers the answer for EVERY `[` it walks past —
156
+ // the bracket stack tells us exactly which `]` closes each one. Recording
157
+ // those results makes the total work linear in the source length.
158
+
159
+ /** `[` offset → offset of its matching `]` at depth 0, or -1 if unmatched. */
160
+ const bracketMatch = new Map<number, number>()
161
+ /** Sticky: once `indexOf(']')` fails, no `]` exists in the rest of the source. */
162
+ let noCloseBracketRemains = false
163
+
164
+ function findMatchingBracket(from: number): number {
165
+ // The map is empty on any document without unmatched brackets, and a size
166
+ // check is cheaper than a lookup that is going to miss. Instrumented on the
167
+ // reference document: 804 lookups, 0 hits.
168
+ if (bracketMatch.size !== 0) {
169
+ const memo = bracketMatch.get(from)
170
+ if (memo !== undefined) return memo
171
+ }
172
+
173
+ const open: number[] = []
174
+ let result = -1
175
+
176
+ for (let j = from + 1; j < source.length; j++) {
177
+ const cj = source.charCodeAt(j)
178
+ if (cj === CHAR_BRACKET_OPEN) {
179
+ open.push(j)
180
+ } else if (cj === CHAR_BRACKET_CLOSE) {
181
+ if (open.length === 0) {
182
+ // First `]` at depth 0 relative to `from` — this is the answer.
183
+ result = j
184
+ break
185
+ }
186
+ // Closes a nested `[` (e.g. `[box=[b]title[/b]]`). Stack discipline
187
+ // guarantees this is that bracket's first depth-0 `]` too.
188
+ bracketMatch.set(open.pop()!, j)
189
+ }
190
+ }
191
+
192
+ // Deliberately NOT recording the answer for `from` itself. The main loop
193
+ // never asks twice: on success it jumps past `result`, and on failure it
194
+ // emits `[` as text and moves to `from + 1`. Either way it never comes back,
195
+ // so that entry could only ever be written, never read — 804 wasted `set`s
196
+ // on the reference document, which is what kept the map from being empty
197
+ // and made every lookup above pay for a miss.
198
+ //
199
+ // The entries for brackets found *inside* the scan are a different matter:
200
+ // when this scan fails, the main loop walks into them one by one, and those
201
+ // hits are the whole reason the memo exists. On `[`×16000 it turns 15.999
202
+ // rescans into 15.999 lookups.
203
+ //
204
+ // We only exit the loop with a non-empty stack when we reached the end of
205
+ // the source, so everything still open is genuinely unmatched.
206
+ for (const unmatched of open) bracketMatch.set(unmatched, -1)
207
+ return result
208
+ }
209
+
210
+ while (pos < length) {
211
+ const ch = source.charCodeAt(pos)
212
+
213
+ // ── Newlines ───────────────────────────────────────────────
214
+ if (ch === CHAR_LF || ch === CHAR_CR) {
215
+ const start = pos
216
+ // Consume \r\n as a single token
217
+ if (ch === CHAR_CR && pos + 1 < length && source.charCodeAt(pos + 1) === CHAR_LF) {
218
+ pos += 2
219
+ } else {
220
+ pos++
221
+ }
222
+ tokens.push({
223
+ kind: 'newline',
224
+ value: source.slice(start, pos),
225
+ start,
226
+ end: pos,
227
+ })
228
+ continue
229
+ }
230
+
231
+ // ── Potential tag: [...] ───────────────────────────────────
232
+ if (ch === CHAR_BRACKET_OPEN) {
233
+ // ── Closing tags: [/tag] ──────────────────────────────
234
+ // Simple ] find is sufficient since close tags never nest.
235
+ if (pos + 1 < length && source.charCodeAt(pos + 1) === CHAR_SLASH) {
236
+ const closeBracket = noCloseBracketRemains ? -1 : source.indexOf(']', pos)
237
+ if (closeBracket === -1) noCloseBracketRemains = true
238
+ if (closeBracket !== -1) {
239
+ // Validate in place. The name must fill the whole span — `[/has space]`
240
+ // is not a close tag — which is exactly what the old
241
+ // `isValidTagName(slice)` checked, without the slice or the regex.
242
+ const nameStart = pos + 2
243
+ const scanned = scanNameChars(source, nameStart, closeBracket)
244
+ const nameLength = scanned & NAME_LENGTH_MASK
245
+ if (nameLength > 0 && nameStart + nameLength === closeBracket) {
246
+ const raw = source.slice(nameStart, closeBracket)
247
+ const tagName = (scanned & NAME_HAS_UPPER) !== 0 ? raw.toLowerCase() : raw
248
+ tokens.push({
249
+ kind: 'close',
250
+ tag: tagName,
251
+ start: pos,
252
+ end: closeBracket + 1,
253
+ })
254
+ pos = closeBracket + 1
255
+ continue
256
+ }
257
+ }
258
+ // Invalid close tag → treat '[' as text
259
+ tokens.push({ kind: 'text', value: '[', start: pos, end: pos + 1 })
260
+ pos++
261
+ continue
262
+ }
263
+
264
+ // ── Opening tags: [tag] or [tag=nested[bb]code[/bb]] ─
265
+ // Bracket depth is tracked so nested BBCode in attributes
266
+ // (e.g. [box=[b]title[/b]]) resolves to the right `]`.
267
+ const closeBracket = findMatchingBracket(pos)
268
+
269
+ // No matching closing bracket → lone '[' = plain text
270
+ if (closeBracket === -1) {
271
+ tokens.push({ kind: 'text', value: '[', start: pos, end: pos + 1 })
272
+ pos++
273
+ continue
274
+ }
275
+
276
+ // The name runs from just after the `[`. Scanned over char codes, so no
277
+ // `inner` slice is needed to find it and no second regex to validate it:
278
+ // every character the scan accepted is by definition a valid name
279
+ // character, so the old `isValidTagName(tagName)` could only ever be true.
280
+ const nameStart = pos + 1
281
+ const scanned = scanNameChars(source, nameStart, closeBracket)
282
+ const nameEnd = scanned & NAME_LENGTH_MASK
283
+
284
+ if (nameEnd > 0) {
285
+ {
286
+ const raw = source.slice(nameStart, nameStart + nameEnd)
287
+ const tagName = (scanned & NAME_HAS_UPPER) !== 0 ? raw.toLowerCase() : raw
288
+ const attrs = source.slice(nameStart + nameEnd, closeBracket).trim()
289
+ tokens.push({
290
+ kind: 'open',
291
+ tag: tagName,
292
+ attrs,
293
+ start: pos,
294
+ end: closeBracket + 1,
295
+ })
296
+ pos = closeBracket + 1
297
+
298
+ // ── RAW BLOCK HANDLING (code, c) ──
299
+ // Contents of code blocks are strictly literal. No inner tags or newline tokens.
300
+ if (tagName === 'code' || tagName === 'c') {
301
+ const endTag = `[/${tagName}]`
302
+ const closeIdx = lowerOf().indexOf(endTag, pos)
303
+
304
+ if (closeIdx !== -1) {
305
+ if (closeIdx > pos) {
306
+ tokens.push({
307
+ kind: 'text',
308
+ value: source.slice(pos, closeIdx),
309
+ start: pos,
310
+ end: closeIdx,
311
+ })
312
+ }
313
+ tokens.push({
314
+ kind: 'close',
315
+ tag: tagName,
316
+ start: closeIdx,
317
+ end: closeIdx + endTag.length,
318
+ })
319
+ pos = closeIdx + endTag.length
320
+ } else {
321
+ // Unclosed raw block consumes the rest of the document
322
+ if (pos < length) {
323
+ tokens.push({
324
+ kind: 'text',
325
+ value: source.slice(pos),
326
+ start: pos,
327
+ end: length,
328
+ })
329
+ pos = length
330
+ }
331
+ }
332
+ }
333
+
334
+ continue
335
+ }
336
+ }
337
+
338
+ // Invalid tag syntax → treat '[' as text
339
+ tokens.push({ kind: 'text', value: '[', start: pos, end: pos + 1 })
340
+ pos++
341
+ continue
342
+ }
343
+
344
+ // ── Plain text (anything that isn't a tag start or newline) ─
345
+ const start = pos
346
+ while (pos < length) {
347
+ const c = source.charCodeAt(pos)
348
+ if (c === CHAR_BRACKET_OPEN || c === CHAR_LF || c === CHAR_CR) break
349
+ pos++
350
+ }
351
+ tokens.push({
352
+ kind: 'text',
353
+ value: source.slice(start, pos),
354
+ start,
355
+ end: pos,
356
+ })
357
+ }
358
+
359
+ return tokens
360
+ }
361
+
362
+ // ─── Helpers ───────────────────────────────────────────────────
363
+
364
+ /**
365
+ * Debug: format tokens for inspection.
366
+ */
367
+ export function formatTokens(tokens: BBCodeToken[]): string {
368
+ return tokens
369
+ .map(t => {
370
+ switch (t.kind) {
371
+ case 'open':
372
+ return `OPEN [${t.tag}] attrs="${t.attrs}" [${t.start}..${t.end}]`
373
+ case 'close':
374
+ return `CLOSE [/${t.tag}] [${t.start}..${t.end}]`
375
+ case 'text':
376
+ return `TEXT "${t.value.slice(0, 40)}" [${t.start}..${t.end}]`
377
+ case 'newline':
378
+ return `NL [${t.start}..${t.end}]`
379
+ }
380
+ })
381
+ .join('\n')
382
+ }