@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,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,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
|
+
}
|