@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,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DocumentEngine — Lexer
|
|
3
|
+
*
|
|
4
|
+
* Tokenizes source text into a stream of Tokens with Trivia.
|
|
5
|
+
* Trivia includes whitespace, newlines, and formatting that is
|
|
6
|
+
* preserved in the token stream but not semantically significant.
|
|
7
|
+
*
|
|
8
|
+
* This is a LANGUAGE-INDEPENDENT lexer framework.
|
|
9
|
+
* Language-specific tokenization is provided via the LexerOptions.
|
|
10
|
+
*
|
|
11
|
+
* Inspired by Roslyn's lexer architecture.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { Token, TokenStream, Trivia } from '../Types/tokens'
|
|
15
|
+
import { createToken, createTrivia, range, ArrayTokenStream } from '../Types/tokens'
|
|
16
|
+
import type { TokenKind } from '../Types/tokens'
|
|
17
|
+
|
|
18
|
+
// ─── Lexer Interface ───────────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
export interface LexerRule {
|
|
21
|
+
/** Pattern to match at the current position */
|
|
22
|
+
pattern: RegExp
|
|
23
|
+
/** Token kind to produce */
|
|
24
|
+
kind: TokenKind
|
|
25
|
+
/** Optional: extract a name from the match (for tags) */
|
|
26
|
+
name?: (match: RegExpExecArray) => string
|
|
27
|
+
/** Optional: extract attributes from the match */
|
|
28
|
+
attrs?: (match: RegExpExecArray) => string
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface LexerOptions {
|
|
32
|
+
/** Language-specific tokenization rules */
|
|
33
|
+
rules: LexerRule[]
|
|
34
|
+
/** Characters that count as whitespace trivia */
|
|
35
|
+
whitespaceChars?: RegExp
|
|
36
|
+
/** Characters that start a newline trivia */
|
|
37
|
+
newlineChars?: RegExp
|
|
38
|
+
/** Whether to collect trivia */
|
|
39
|
+
collectTrivia?: boolean
|
|
40
|
+
/** Lexer hooks: called before each token */
|
|
41
|
+
onToken?: (token: Token) => void
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ─── Default Lexer ─────────────────────────────────────────────
|
|
45
|
+
|
|
46
|
+
export class Lexer {
|
|
47
|
+
private options: LexerOptions
|
|
48
|
+
private source: string
|
|
49
|
+
private pos: number
|
|
50
|
+
private tokens: Token[]
|
|
51
|
+
|
|
52
|
+
constructor(options: LexerOptions) {
|
|
53
|
+
this.options = {
|
|
54
|
+
collectTrivia: true,
|
|
55
|
+
whitespaceChars: /[ \t]+/,
|
|
56
|
+
newlineChars: /\r?\n/,
|
|
57
|
+
...options,
|
|
58
|
+
}
|
|
59
|
+
this.source = ''
|
|
60
|
+
this.pos = 0
|
|
61
|
+
this.tokens = []
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Tokenize source text into a TokenStream.
|
|
66
|
+
*/
|
|
67
|
+
tokenize(source: string): TokenStream {
|
|
68
|
+
this.source = source
|
|
69
|
+
this.pos = 0
|
|
70
|
+
this.tokens = []
|
|
71
|
+
|
|
72
|
+
while (this.pos < source.length) {
|
|
73
|
+
const leadingTrivia = this.options.collectTrivia ? this.scanTrivia() : []
|
|
74
|
+
|
|
75
|
+
// Try each rule in order
|
|
76
|
+
const token = this.tryRules(leadingTrivia)
|
|
77
|
+
|
|
78
|
+
if (token) {
|
|
79
|
+
this.tokens.push(token)
|
|
80
|
+
} else {
|
|
81
|
+
// No rule matched — produce a text token for the character
|
|
82
|
+
const start = this.pos
|
|
83
|
+
this.pos++
|
|
84
|
+
const text = source[start]
|
|
85
|
+
const tok = createToken('text', text, start, this.pos, {
|
|
86
|
+
leadingTrivia,
|
|
87
|
+
})
|
|
88
|
+
this.tokens.push(tok)
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return new ArrayTokenStream(this.tokens) as TokenStream
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Re-tokenize a portion of the source (for incremental parsing).
|
|
97
|
+
* Returns just the new tokens for the affected range.
|
|
98
|
+
*/
|
|
99
|
+
retokenize(source: string, start: number, end: number): Token[] {
|
|
100
|
+
const oldSource = this.source
|
|
101
|
+
const oldPos = this.pos
|
|
102
|
+
|
|
103
|
+
this.source = source
|
|
104
|
+
this.pos = start
|
|
105
|
+
this.tokens = []
|
|
106
|
+
|
|
107
|
+
while (this.pos < end && this.pos < source.length) {
|
|
108
|
+
const leadingTrivia = this.scanTrivia()
|
|
109
|
+
const token = this.tryRules(leadingTrivia)
|
|
110
|
+
if (token) {
|
|
111
|
+
this.tokens.push(token)
|
|
112
|
+
} else {
|
|
113
|
+
const s = this.pos
|
|
114
|
+
this.pos++
|
|
115
|
+
this.tokens.push(createToken('text', source[s], s, this.pos, { leadingTrivia }))
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const result = [...this.tokens]
|
|
120
|
+
this.source = oldSource
|
|
121
|
+
this.pos = oldPos
|
|
122
|
+
this.tokens = []
|
|
123
|
+
|
|
124
|
+
return result
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// ── Private ─────────────────────────────────────────────
|
|
128
|
+
|
|
129
|
+
private tryRules(leadingTrivia: Trivia[]): Token | null {
|
|
130
|
+
for (const rule of this.options.rules) {
|
|
131
|
+
const match = rule.pattern.exec(this.source.slice(this.pos))
|
|
132
|
+
if (match && match.index === 0) {
|
|
133
|
+
const start = this.pos
|
|
134
|
+
const text = match[0]
|
|
135
|
+
this.pos += text.length
|
|
136
|
+
|
|
137
|
+
const trailingTrivia = this.options.collectTrivia ? this.scanTrivia() : []
|
|
138
|
+
|
|
139
|
+
const token = createToken(rule.kind, text, start, this.pos, {
|
|
140
|
+
name: rule.name?.(match),
|
|
141
|
+
attrs: rule.attrs?.(match),
|
|
142
|
+
tagText: text,
|
|
143
|
+
leadingTrivia,
|
|
144
|
+
trailingTrivia,
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
this.options.onToken?.(token)
|
|
148
|
+
return token
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return null
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
private scanTrivia(): Trivia[] {
|
|
155
|
+
const trivia: Trivia[] = []
|
|
156
|
+
|
|
157
|
+
while (this.pos < this.source.length) {
|
|
158
|
+
const rest = this.source.slice(this.pos)
|
|
159
|
+
|
|
160
|
+
const newlineMatch = this.options.newlineChars!.exec(rest)
|
|
161
|
+
if (newlineMatch && newlineMatch.index === 0) {
|
|
162
|
+
const start = this.pos
|
|
163
|
+
this.pos += newlineMatch[0].length
|
|
164
|
+
trivia.push(createTrivia('newline', newlineMatch[0], start, this.pos))
|
|
165
|
+
continue
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const wsMatch = this.options.whitespaceChars!.exec(rest)
|
|
169
|
+
if (wsMatch && wsMatch.index === 0) {
|
|
170
|
+
const start = this.pos
|
|
171
|
+
this.pos += wsMatch[0].length
|
|
172
|
+
trivia.push(createTrivia('whitespace', wsMatch[0], start, this.pos))
|
|
173
|
+
continue
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
break
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return trivia
|
|
180
|
+
}
|
|
181
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export { Lexer } from './Lexer'
|
|
2
|
+
export type { LexerOptions, LexerRule } from './Lexer'
|
|
3
|
+
export { scanBBCode, formatTokens } from './BBCodeLexer'
|
|
4
|
+
export type {
|
|
5
|
+
BBCodeToken,
|
|
6
|
+
BBCodeOpenToken,
|
|
7
|
+
BBCodeCloseToken,
|
|
8
|
+
BBCodeTextToken,
|
|
9
|
+
BBCodeNewlineToken,
|
|
10
|
+
} from './BBCodeLexer'
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DocumentEngine — Linter
|
|
3
|
+
*
|
|
4
|
+
* Lints the document tree for issues.
|
|
5
|
+
* Similar to ESLint but for BBCode documents.
|
|
6
|
+
*
|
|
7
|
+
* Rules can be added via the Plugin API.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { RedNode } from '../Syntax/RedNode'
|
|
11
|
+
import type { SemanticAnalyzer, Validator } from '../Semantic/SemanticAnalyzer'
|
|
12
|
+
|
|
13
|
+
export type LintSeverity = 'error' | 'warning' | 'info' | 'hint'
|
|
14
|
+
|
|
15
|
+
export interface LintRule {
|
|
16
|
+
code: string
|
|
17
|
+
severity: LintSeverity
|
|
18
|
+
description: string
|
|
19
|
+
validate(node: RedNode, context: LintContext): LintIssue | LintIssue[] | null
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface LintContext {
|
|
23
|
+
source: string
|
|
24
|
+
allNodes: Map<string, RedNode>
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface LintIssue {
|
|
28
|
+
code: string
|
|
29
|
+
message: string
|
|
30
|
+
severity: LintSeverity
|
|
31
|
+
nodeId: string
|
|
32
|
+
range: { start: number; end: number } | null
|
|
33
|
+
fix?: {
|
|
34
|
+
description: string
|
|
35
|
+
apply: () => void
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface LintResult {
|
|
40
|
+
issues: LintIssue[]
|
|
41
|
+
errorCount: number
|
|
42
|
+
warningCount: number
|
|
43
|
+
infoCount: number
|
|
44
|
+
hintCount: number
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export class Linter {
|
|
48
|
+
private rules: Map<string, LintRule> = new Map()
|
|
49
|
+
|
|
50
|
+
constructor() {
|
|
51
|
+
this.registerBuiltinRules()
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Register a lint rule.
|
|
56
|
+
*/
|
|
57
|
+
register(rule: LintRule): void {
|
|
58
|
+
this.rules.set(rule.code, rule)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Unregister a lint rule.
|
|
63
|
+
*/
|
|
64
|
+
unregister(code: string): void {
|
|
65
|
+
this.rules.delete(code)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Lint a RedNode tree.
|
|
70
|
+
*/
|
|
71
|
+
lint(root: RedNode, source: string): LintResult {
|
|
72
|
+
const issues: LintIssue[] = []
|
|
73
|
+
const result = { issues, errorCount: 0, warningCount: 0, infoCount: 0, hintCount: 0 }
|
|
74
|
+
|
|
75
|
+
// Build node index
|
|
76
|
+
const allNodes = new Map<string, RedNode>()
|
|
77
|
+
root.walk(node => { allNodes.set(node.id, node) })
|
|
78
|
+
|
|
79
|
+
const context: LintContext = { source, allNodes }
|
|
80
|
+
|
|
81
|
+
// Walk tree and apply rules
|
|
82
|
+
root.walk(node => {
|
|
83
|
+
for (const [, rule] of this.rules) {
|
|
84
|
+
try {
|
|
85
|
+
const r = rule.validate(node, context)
|
|
86
|
+
if (r) {
|
|
87
|
+
const items = Array.isArray(r) ? r : [r]
|
|
88
|
+
for (const issue of items) {
|
|
89
|
+
issues.push(issue)
|
|
90
|
+
switch (issue.severity) {
|
|
91
|
+
case 'error': result.errorCount++; break
|
|
92
|
+
case 'warning': result.warningCount++; break
|
|
93
|
+
case 'info': result.infoCount++; break
|
|
94
|
+
case 'hint': result.hintCount++; break
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
} catch {
|
|
99
|
+
// Rule error shouldn't break linting
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
return result
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
private registerBuiltinRules(): void {
|
|
108
|
+
this.register({
|
|
109
|
+
code: 'no-nested-bold',
|
|
110
|
+
severity: 'warning',
|
|
111
|
+
description: 'Nested bold tags are redundant',
|
|
112
|
+
validate: (node) => {
|
|
113
|
+
if (node.kind === 'bold' && node.parent?.kind === 'bold') {
|
|
114
|
+
return {
|
|
115
|
+
code: 'no-nested-bold',
|
|
116
|
+
message: 'Nested [b] tags are redundant',
|
|
117
|
+
severity: 'warning' as LintSeverity,
|
|
118
|
+
nodeId: node.id,
|
|
119
|
+
range: null,
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return null
|
|
123
|
+
},
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
this.register({
|
|
127
|
+
code: 'no-empty-tags',
|
|
128
|
+
severity: 'hint',
|
|
129
|
+
description: 'Empty tags have no effect',
|
|
130
|
+
validate: (node) => {
|
|
131
|
+
if (['bold', 'italic', 'underline', 'strikethrough', 'color', 'font_size', 'spoiler'].includes(node.kind)) {
|
|
132
|
+
if (node.children.length === 0 && (node.text === '' || !node.text)) {
|
|
133
|
+
return {
|
|
134
|
+
code: 'no-empty-tags',
|
|
135
|
+
message: `Empty [${node.kind}] tag has no effect`,
|
|
136
|
+
severity: 'hint' as LintSeverity,
|
|
137
|
+
nodeId: node.id,
|
|
138
|
+
range: null,
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return null
|
|
143
|
+
},
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
this.register({
|
|
147
|
+
code: 'max-quote-depth',
|
|
148
|
+
severity: 'warning',
|
|
149
|
+
description: 'Quote blocks should not be nested more than 3 levels deep',
|
|
150
|
+
validate: (node) => {
|
|
151
|
+
if (node.kind === 'quote') {
|
|
152
|
+
let depth = 0
|
|
153
|
+
let current = node.parent
|
|
154
|
+
while (current) {
|
|
155
|
+
if (current.kind === 'quote') depth++
|
|
156
|
+
current = current.parent
|
|
157
|
+
}
|
|
158
|
+
if (depth >= 3) {
|
|
159
|
+
return {
|
|
160
|
+
code: 'max-quote-depth',
|
|
161
|
+
message: 'Quote blocks should not be nested more than 3 levels deep',
|
|
162
|
+
severity: 'warning' as LintSeverity,
|
|
163
|
+
nodeId: node.id,
|
|
164
|
+
range: null,
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return null
|
|
169
|
+
},
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
this.register({
|
|
173
|
+
code: 'invalid-url-protocol',
|
|
174
|
+
severity: 'error',
|
|
175
|
+
description: 'URLs must start with http:// or https:// (javascript:, file:, etc. are not allowed)',
|
|
176
|
+
validate: (node) => {
|
|
177
|
+
if (node.kind === 'url') {
|
|
178
|
+
const destination = String(node.metadata?.href ?? '') || node.text
|
|
179
|
+
if (destination && !destination.startsWith('http://') && !destination.startsWith('https://') && !destination.startsWith('mailto:')) {
|
|
180
|
+
return {
|
|
181
|
+
code: 'invalid-url-protocol',
|
|
182
|
+
message: 'URLs must start with http:// or https:// (javascript:, file:, etc. are not allowed)',
|
|
183
|
+
severity: 'error' as LintSeverity,
|
|
184
|
+
nodeId: node.id,
|
|
185
|
+
range: null,
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
return null
|
|
190
|
+
},
|
|
191
|
+
})
|
|
192
|
+
}
|
|
193
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DocumentEngine — Markdown AST Nodes
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export type MarkdownNode =
|
|
6
|
+
| MarkdownDocument
|
|
7
|
+
| MarkdownParagraph
|
|
8
|
+
| MarkdownHeading
|
|
9
|
+
| MarkdownText
|
|
10
|
+
| MarkdownCodeBlock
|
|
11
|
+
| MarkdownCodeInline
|
|
12
|
+
| MarkdownStrong
|
|
13
|
+
| MarkdownEmphasis
|
|
14
|
+
| MarkdownLink
|
|
15
|
+
| MarkdownImage
|
|
16
|
+
| MarkdownList
|
|
17
|
+
| MarkdownListItem
|
|
18
|
+
| MarkdownBlockquote
|
|
19
|
+
| MarkdownSpoiler
|
|
20
|
+
| MarkdownNotice
|
|
21
|
+
| MarkdownSpacing
|
|
22
|
+
| MarkdownEmptyLine;
|
|
23
|
+
|
|
24
|
+
export interface MarkdownDocument {
|
|
25
|
+
type: 'document';
|
|
26
|
+
children: MarkdownNode[];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface MarkdownParagraph {
|
|
30
|
+
type: 'paragraph';
|
|
31
|
+
children: MarkdownNode[];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface MarkdownHeading {
|
|
35
|
+
type: 'heading';
|
|
36
|
+
level: number;
|
|
37
|
+
children: MarkdownNode[];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface MarkdownText {
|
|
41
|
+
type: 'text';
|
|
42
|
+
value: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface MarkdownCodeBlock {
|
|
46
|
+
type: 'code_block';
|
|
47
|
+
lang: string;
|
|
48
|
+
value: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface MarkdownCodeInline {
|
|
52
|
+
type: 'code_inline';
|
|
53
|
+
value: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface MarkdownStrong {
|
|
57
|
+
type: 'strong';
|
|
58
|
+
children: MarkdownNode[];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface MarkdownEmphasis {
|
|
62
|
+
type: 'emphasis';
|
|
63
|
+
children: MarkdownNode[];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface MarkdownLink {
|
|
67
|
+
type: 'link';
|
|
68
|
+
url: string;
|
|
69
|
+
children: MarkdownNode[];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface MarkdownImage {
|
|
73
|
+
type: 'image';
|
|
74
|
+
url: string;
|
|
75
|
+
alt: string;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface MarkdownList {
|
|
79
|
+
type: 'list';
|
|
80
|
+
ordered: boolean;
|
|
81
|
+
children: MarkdownListItem[];
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export interface MarkdownListItem {
|
|
85
|
+
type: 'list_item';
|
|
86
|
+
children: MarkdownNode[];
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export interface MarkdownBlockquote {
|
|
90
|
+
type: 'blockquote';
|
|
91
|
+
children: MarkdownNode[];
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** `>!` spoiler block — produced by MarkdownParser, maps to BBCode [spoiler]. */
|
|
95
|
+
export interface MarkdownSpoiler {
|
|
96
|
+
type: 'spoiler';
|
|
97
|
+
children: MarkdownNode[];
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** `> [!NOTE]` callout — produced by MarkdownParser, maps to BBCode [notice]. */
|
|
101
|
+
export interface MarkdownNotice {
|
|
102
|
+
type: 'notice';
|
|
103
|
+
children: MarkdownNode[];
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export interface MarkdownSpacing {
|
|
107
|
+
type: 'spacing';
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export interface MarkdownEmptyLine {
|
|
111
|
+
type: 'empty_line';
|
|
112
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { DocumentModel, type DocumentModelOptions } from '../Model/DocumentModel';
|
|
2
|
+
import { GreenNode, greenLeaf } from '../Syntax/GreenNode';
|
|
3
|
+
import { RedNode } from '../Syntax/RedNode';
|
|
4
|
+
import { scanMarkdown } from './MarkdownLexer';
|
|
5
|
+
import { parseMarkdown } from './MarkdownParser';
|
|
6
|
+
import { markdownAstToGreenTree, greenToRedNode } from './MarkdownToGreenNode';
|
|
7
|
+
|
|
8
|
+
export interface MarkdownDocumentModelOptions extends DocumentModelOptions {
|
|
9
|
+
source?: string;
|
|
10
|
+
language?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export class MarkdownDocumentModel extends DocumentModel {
|
|
14
|
+
constructor(options: MarkdownDocumentModelOptions = {}) {
|
|
15
|
+
super({ source: '', language: options.language ?? 'markdown' });
|
|
16
|
+
if (options.source) {
|
|
17
|
+
this.rebuild(options.source);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
static fromMarkdown(source: string): MarkdownDocumentModel {
|
|
22
|
+
return new MarkdownDocumentModel({ source });
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
protected parseToGreen(source: string): GreenNode {
|
|
26
|
+
try {
|
|
27
|
+
const tokens = scanMarkdown(source);
|
|
28
|
+
const ast = parseMarkdown(tokens);
|
|
29
|
+
return markdownAstToGreenTree(ast);
|
|
30
|
+
} catch (error) {
|
|
31
|
+
console.warn('[MarkdownDocumentModel] Parse error:', error);
|
|
32
|
+
return greenLeaf('text', source);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
protected buildRedFromGreen(green: GreenNode): RedNode {
|
|
37
|
+
return greenToRedNode(green);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Override text updates to bypass incremental parsing completely.
|
|
41
|
+
// The Markdown AST currently strips markup tokens (like **, [, ]),
|
|
42
|
+
// so GreenTree lengths do not match the raw text lengths.
|
|
43
|
+
// Incremental parsing relies on exact byte offsets, so it fails destructively here.
|
|
44
|
+
applyTextUpdate(newSource: string): void {
|
|
45
|
+
if (this.source === newSource) return;
|
|
46
|
+
this.rebuild(newSource);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
applyChange(change: any): void {
|
|
50
|
+
// Fallback to full rebuild using the text change
|
|
51
|
+
const before = this.source.slice(0, change.start);
|
|
52
|
+
const after = this.source.slice(change.end);
|
|
53
|
+
this.rebuild(before + change.text + after);
|
|
54
|
+
}
|
|
55
|
+
}
|