@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,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Quasar Analysis Framework — Collapse Gradient Transform
|
|
3
|
+
*
|
|
4
|
+
* Applies `collapse-gradient` actions from a TransformationPlan.
|
|
5
|
+
* Each action targets a sequence of adjacent [color] nodes and replaces
|
|
6
|
+
* them with a single [gradient] node containing all text children.
|
|
7
|
+
*
|
|
8
|
+
* The transform operates on the Green Tree and returns a new tree —
|
|
9
|
+
* it NEVER mutates the original.
|
|
10
|
+
*
|
|
11
|
+
* @example
|
|
12
|
+
* Before:
|
|
13
|
+
* [color=#FF0000]H[/color][color=#EE1100]e[/color][color=#DD2200]l[/color]
|
|
14
|
+
* After:
|
|
15
|
+
* [gradient=#FF0000,#EE1100,#DD2200]Hello[/gradient]
|
|
16
|
+
*
|
|
17
|
+
* @see TransformPass
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import type { TransformPass, TransformationPlan } from '../../Contracts/Pass'
|
|
21
|
+
import type { PipelineContext } from '../../Contracts/PipelineContext'
|
|
22
|
+
import type { GreenNode } from '../../../Syntax/GreenNode'
|
|
23
|
+
import { greenNode, greenLeaf, childOffsets } from '../../../Syntax/GreenNode'
|
|
24
|
+
|
|
25
|
+
// ── Supported action kinds ────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
const COLLAPSE_KINDS = new Set(['collapse-gradient', 'collapse-gradient-auto'])
|
|
28
|
+
|
|
29
|
+
export class CollapseGradientTransform implements TransformPass {
|
|
30
|
+
readonly id = 'collapse-gradient'
|
|
31
|
+
|
|
32
|
+
run(tree: GreenNode, plan: TransformationPlan, _context: PipelineContext): GreenNode {
|
|
33
|
+
// Filter only collapse-gradient actions
|
|
34
|
+
const actions = plan.actions.filter(a => COLLAPSE_KINDS.has(a.kind))
|
|
35
|
+
|
|
36
|
+
if (actions.length === 0) {
|
|
37
|
+
return tree // No changes
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Build a set of ranges to collapse (sorted by start position)
|
|
41
|
+
const ranges = actions
|
|
42
|
+
.map(a => ({
|
|
43
|
+
start: (a.payload.range as { start: number; end: number }).start,
|
|
44
|
+
end: (a.payload.range as { start: number; end: number }).end,
|
|
45
|
+
colors: ((a.payload.model as Record<string, unknown>)?.colors ?? []) as string[],
|
|
46
|
+
easing: ((a.payload.model as Record<string, unknown>)?.easing ?? 'linear') as string,
|
|
47
|
+
}))
|
|
48
|
+
.sort((a, b) => a.start - b.start)
|
|
49
|
+
|
|
50
|
+
// Apply transforms recursively
|
|
51
|
+
return this.transformNode(tree, ranges)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ── Private ─────────────────────────────────────────────────────
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Walk the tree and collapse any sequences that fall within our ranges.
|
|
58
|
+
*/
|
|
59
|
+
private transformNode(
|
|
60
|
+
node: GreenNode,
|
|
61
|
+
ranges: Array<{ start: number; end: number; colors: string[]; easing: string }>,
|
|
62
|
+
treeStart: number = 0,
|
|
63
|
+
): GreenNode {
|
|
64
|
+
// Leaf node — no children to transform
|
|
65
|
+
if (node.children.length === 0) {
|
|
66
|
+
return node
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const children = node.children as GreenNode[]
|
|
70
|
+
|
|
71
|
+
// Green nodes carry widths, not positions. The ranges being matched here
|
|
72
|
+
// come from the analyzers and are absolute, so the walk accumulates the
|
|
73
|
+
// offsets it needs as it descends.
|
|
74
|
+
const offsets = childOffsets(node, treeStart)
|
|
75
|
+
|
|
76
|
+
// Check if any range overlaps with this node's children
|
|
77
|
+
const nodeStart = offsets[0]
|
|
78
|
+
const nodeEnd = offsets[children.length]
|
|
79
|
+
const activeRanges = ranges.filter(
|
|
80
|
+
r => r.start >= nodeStart && r.end <= nodeEnd,
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
if (activeRanges.length === 0) {
|
|
84
|
+
// No ranges in this subtree — recurse into children
|
|
85
|
+
const newChildren = children.map((c, ci) => this.transformNode(c, ranges, offsets[ci]))
|
|
86
|
+
return this.rebuildNode(node, newChildren)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Build new children, collapsing ranges into gradient nodes
|
|
90
|
+
const newChildren: GreenNode[] = []
|
|
91
|
+
let i = 0
|
|
92
|
+
|
|
93
|
+
while (i < children.length) {
|
|
94
|
+
const child = children[i]
|
|
95
|
+
const matchingRange = activeRanges.find(
|
|
96
|
+
r => offsets[i] >= r.start && offsets[i + 1] <= r.end,
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
if (matchingRange) {
|
|
100
|
+
// Find all children within this range
|
|
101
|
+
const rangeChildren: GreenNode[] = []
|
|
102
|
+
while (i < children.length && offsets[i + 1] <= matchingRange.end) {
|
|
103
|
+
rangeChildren.push(children[i])
|
|
104
|
+
i++
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Collapse into a single gradient node
|
|
108
|
+
const textNodes: string[] = []
|
|
109
|
+
const colorNodes: GreenNode[] = []
|
|
110
|
+
|
|
111
|
+
for (const rc of rangeChildren) {
|
|
112
|
+
if (rc.kind === 'color') {
|
|
113
|
+
colorNodes.push(rc)
|
|
114
|
+
// Collect text from inside the colour node
|
|
115
|
+
for (const textChild of rc.children as GreenNode[]) {
|
|
116
|
+
if (textChild.kind === 'text') {
|
|
117
|
+
textNodes.push(textChild.text)
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
} else if (rc.kind === 'text') {
|
|
121
|
+
textNodes.push(rc.text)
|
|
122
|
+
} else {
|
|
123
|
+
textNodes.push(rc.text || '')
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Create a text leaf inside the gradient node
|
|
128
|
+
const combinedText = textNodes.join('')
|
|
129
|
+
const textLeaf = greenLeaf('text', combinedText)
|
|
130
|
+
|
|
131
|
+
// Use the detected colours; fall back to extracted colours from nodes
|
|
132
|
+
let colors = matchingRange.colors
|
|
133
|
+
if (colors.length === 0) {
|
|
134
|
+
colors = colorNodes
|
|
135
|
+
.map(cn => this.extractHex(cn))
|
|
136
|
+
.filter((h): h is string => h !== null)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const colorsAttr = colors.join(',')
|
|
140
|
+
const gradientNode = greenNode('gradient', `=${colorsAttr}`, [textLeaf])
|
|
141
|
+
newChildren.push(gradientNode)
|
|
142
|
+
} else {
|
|
143
|
+
// Not in a collapse range — recurse and preserve
|
|
144
|
+
newChildren.push(this.transformNode(child, ranges, offsets[i]))
|
|
145
|
+
i++
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return this.rebuildNode(node, newChildren)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Rebuild a node with the same kind/text/range but new children.
|
|
154
|
+
*/
|
|
155
|
+
private rebuildNode(node: GreenNode, newChildren: GreenNode[]): GreenNode {
|
|
156
|
+
// If the node is a colour node and all children were collapsed,
|
|
157
|
+
// just return the first child instead (no need for empty colour)
|
|
158
|
+
if (node.kind === 'color' && newChildren.length === 0) {
|
|
159
|
+
return greenLeaf('text', node.text)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return greenNode(node.kind, node.text, newChildren,
|
|
163
|
+
)
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Extract hex colour from a color node's text.
|
|
168
|
+
*/
|
|
169
|
+
private extractHex(node: GreenNode): string | null {
|
|
170
|
+
if (node.kind !== 'color') return null
|
|
171
|
+
const text = node.text || ''
|
|
172
|
+
const eqIdx = text.indexOf('=')
|
|
173
|
+
const hex = eqIdx >= 0 ? text.slice(eqIdx + 1).trim() : text.trim()
|
|
174
|
+
return /^#[0-9A-Fa-f]{6}$/.test(hex) ? hex.toUpperCase() : null
|
|
175
|
+
}
|
|
176
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Quasar Analysis Framework — Merge Colors Transform
|
|
3
|
+
*
|
|
4
|
+
* Applies `merge-colors` actions from a TransformationPlan.
|
|
5
|
+
* Each action targets a sequence of identical consecutive [color=#HEX]
|
|
6
|
+
* nodes and merges them into a single `[color]` wrapper with combined text.
|
|
7
|
+
*
|
|
8
|
+
* The transform operates on the Green Tree and returns a new tree —
|
|
9
|
+
* it NEVER mutates the original.
|
|
10
|
+
*
|
|
11
|
+
* @example
|
|
12
|
+
* Before:
|
|
13
|
+
* [color=#FF0000]H[/color][color=#FF0000]e[/color][color=#FF0000]l[/color]
|
|
14
|
+
* After:
|
|
15
|
+
* [color=#FF0000]Hel[/color]
|
|
16
|
+
*
|
|
17
|
+
* @see TransformPass
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import type { TransformPass, TransformationPlan } from '../../Contracts/Pass'
|
|
21
|
+
import type { PipelineContext } from '../../Contracts/PipelineContext'
|
|
22
|
+
import type { GreenNode } from '../../../Syntax/GreenNode'
|
|
23
|
+
import { greenNode, greenLeaf, childOffsets } from '../../../Syntax/GreenNode'
|
|
24
|
+
|
|
25
|
+
export class MergeColorsTransform implements TransformPass {
|
|
26
|
+
readonly id = 'merge-colors'
|
|
27
|
+
|
|
28
|
+
run(tree: GreenNode, plan: TransformationPlan, _context: PipelineContext): GreenNode {
|
|
29
|
+
const mergeActions = plan.actions.filter(a => a.kind === 'merge-colors')
|
|
30
|
+
|
|
31
|
+
if (mergeActions.length === 0) {
|
|
32
|
+
return tree
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const ranges = mergeActions
|
|
36
|
+
.map(a => ({
|
|
37
|
+
start: (a.payload.range as { start: number; end: number }).start,
|
|
38
|
+
end: (a.payload.range as { start: number; end: number }).end,
|
|
39
|
+
}))
|
|
40
|
+
.sort((a, b) => a.start - b.start)
|
|
41
|
+
|
|
42
|
+
return this.transformNode(tree, ranges)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ── Private ─────────────────────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
private transformNode(
|
|
48
|
+
node: GreenNode,
|
|
49
|
+
ranges: Array<{ start: number; end: number }>,
|
|
50
|
+
treeStart: number = 0,
|
|
51
|
+
): GreenNode {
|
|
52
|
+
if (node.children.length === 0) return node
|
|
53
|
+
|
|
54
|
+
const children = node.children as GreenNode[]
|
|
55
|
+
// Green nodes carry widths, not positions. The ranges being matched here
|
|
56
|
+
// come from the analyzers and are absolute, so the walk accumulates the
|
|
57
|
+
// offsets it needs as it descends.
|
|
58
|
+
const offsets = childOffsets(node, treeStart)
|
|
59
|
+
const nodeStart = offsets[0]
|
|
60
|
+
const nodeEnd = offsets[children.length]
|
|
61
|
+
|
|
62
|
+
const activeRanges = ranges.filter(
|
|
63
|
+
r => r.start >= nodeStart && r.end <= nodeEnd,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
if (activeRanges.length === 0) {
|
|
67
|
+
const newChildren = children.map((c, ci) => this.transformNode(c, ranges, offsets[ci]))
|
|
68
|
+
return this.rebuildNode(node, newChildren)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const newChildren: GreenNode[] = []
|
|
72
|
+
let i = 0
|
|
73
|
+
|
|
74
|
+
while (i < children.length) {
|
|
75
|
+
const child = children[i]
|
|
76
|
+
const matchingRange = activeRanges.find(
|
|
77
|
+
r => offsets[i] >= r.start && offsets[i + 1] <= r.end,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
if (matchingRange && child.kind === 'color') {
|
|
81
|
+
// Collect all consecutive colour nodes with the SAME hex
|
|
82
|
+
const hex = this.extractHex(child)
|
|
83
|
+
const mergedTexts: string[] = []
|
|
84
|
+
|
|
85
|
+
while (
|
|
86
|
+
i < children.length &&
|
|
87
|
+
children[i].kind === 'color' &&
|
|
88
|
+
this.extractHex(children[i]) === hex &&
|
|
89
|
+
offsets[i + 1] <= matchingRange.end
|
|
90
|
+
) {
|
|
91
|
+
// Get text from inside the colour node's children
|
|
92
|
+
for (const textChild of children[i].children as GreenNode[]) {
|
|
93
|
+
if (textChild.kind === 'text') {
|
|
94
|
+
mergedTexts.push(textChild.text)
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
i++
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Create a single merged colour node
|
|
101
|
+
const combinedText = mergedTexts.join('')
|
|
102
|
+
const textLeaf = greenLeaf('text', combinedText)
|
|
103
|
+
const merged = greenNode('color', child.text, [textLeaf], child.leadingWidth, child.trailingWidth)
|
|
104
|
+
newChildren.push(merged)
|
|
105
|
+
} else {
|
|
106
|
+
newChildren.push(this.transformNode(child, ranges, offsets[i]))
|
|
107
|
+
i++
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return this.rebuildNode(node, newChildren)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
private rebuildNode(node: GreenNode, newChildren: GreenNode[]): GreenNode {
|
|
115
|
+
return greenNode(node.kind, node.text, newChildren,
|
|
116
|
+
)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
private extractHex(node: GreenNode): string | null {
|
|
120
|
+
if (node.kind !== 'color') return null
|
|
121
|
+
const text = node.text || ''
|
|
122
|
+
const eqIdx = text.indexOf('=')
|
|
123
|
+
const hex = eqIdx >= 0 ? text.slice(eqIdx + 1).trim() : text.trim()
|
|
124
|
+
return /^#[0-9A-Fa-f]{6}$/.test(hex) ? hex.toUpperCase() : null
|
|
125
|
+
}
|
|
126
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Quasar Analysis Framework — Rainbow Collapse Transform
|
|
3
|
+
*
|
|
4
|
+
* Applies `collapse-rainbow` actions from a TransformationPlan.
|
|
5
|
+
* Replaces sequences of [color] nodes forming a hue-rotation pattern
|
|
6
|
+
* with a single [rainbow] node containing all text children.
|
|
7
|
+
*
|
|
8
|
+
* @example
|
|
9
|
+
* Before: [color=#FF0000]R[/color][color=#FFFF00]O[/color][color=#00FF00]Y[/color]
|
|
10
|
+
* After: [rainbow]ROY[/rainbow]
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { TransformPass, TransformationPlan } from '../../Contracts/Pass'
|
|
14
|
+
import type { PipelineContext } from '../../Contracts/PipelineContext'
|
|
15
|
+
import type { GreenNode } from '../../../Syntax/GreenNode'
|
|
16
|
+
import { greenNode, greenLeaf, childOffsets } from '../../../Syntax/GreenNode'
|
|
17
|
+
|
|
18
|
+
export class RainbowCollapseTransform implements TransformPass {
|
|
19
|
+
readonly id = 'rainbow-collapse'
|
|
20
|
+
|
|
21
|
+
run(tree: GreenNode, plan: TransformationPlan, _context: PipelineContext): GreenNode {
|
|
22
|
+
const actions = plan.actions.filter(a => a.kind === 'collapse-rainbow')
|
|
23
|
+
if (actions.length === 0) return tree
|
|
24
|
+
|
|
25
|
+
const ranges = actions.map(a => ({
|
|
26
|
+
start: (a.payload.range as { start: number; end: number }).start,
|
|
27
|
+
end: (a.payload.range as { start: number; end: number }).end,
|
|
28
|
+
})).sort((a, b) => a.start - b.start)
|
|
29
|
+
|
|
30
|
+
return this.transformNode(tree, ranges)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
private transformNode(node: GreenNode, ranges: Array<{ start: number; end: number }>, treeStart: number = 0): GreenNode {
|
|
34
|
+
if (node.children.length === 0) return node
|
|
35
|
+
|
|
36
|
+
const children = node.children as GreenNode[]
|
|
37
|
+
// Green nodes carry widths, not positions. The ranges being matched here
|
|
38
|
+
// come from the analyzers and are absolute, so the walk accumulates the
|
|
39
|
+
// offsets it needs as it descends.
|
|
40
|
+
const offsets = childOffsets(node, treeStart)
|
|
41
|
+
const nodeStart = offsets[0]
|
|
42
|
+
const nodeEnd = offsets[children.length]
|
|
43
|
+
const activeRanges = ranges.filter(r => r.start >= nodeStart && r.end <= nodeEnd)
|
|
44
|
+
|
|
45
|
+
if (activeRanges.length === 0) {
|
|
46
|
+
return greenNode(node.kind, node.text, children.map((c, ci) => this.transformNode(c, ranges, offsets[ci])))
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const newChildren: GreenNode[] = []
|
|
50
|
+
let i = 0
|
|
51
|
+
|
|
52
|
+
while (i < children.length) {
|
|
53
|
+
const child = children[i]
|
|
54
|
+
const range = activeRanges.find(r => offsets[i] >= r.start && offsets[i + 1] <= r.end)
|
|
55
|
+
|
|
56
|
+
if (range && child.kind === 'color') {
|
|
57
|
+
const rangeChildren: GreenNode[] = []
|
|
58
|
+
const texts: string[] = []
|
|
59
|
+
|
|
60
|
+
while (i < children.length && offsets[i + 1] <= range.end) {
|
|
61
|
+
rangeChildren.push(children[i])
|
|
62
|
+
if (children[i].kind === 'color') {
|
|
63
|
+
for (const tc of children[i].children as GreenNode[]) {
|
|
64
|
+
if (tc.kind === 'text') texts.push(tc.text)
|
|
65
|
+
}
|
|
66
|
+
} else if (children[i].kind === 'text') {
|
|
67
|
+
texts.push(children[i].text)
|
|
68
|
+
}
|
|
69
|
+
i++
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const combined = texts.join('')
|
|
73
|
+
const textLeaf = greenLeaf('text', combined)
|
|
74
|
+
newChildren.push(greenNode('rainbow', '', [textLeaf]))
|
|
75
|
+
} else {
|
|
76
|
+
newChildren.push(this.transformNode(child, ranges, offsets[i]))
|
|
77
|
+
i++
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return greenNode(node.kind, node.text, newChildren)
|
|
82
|
+
}
|
|
83
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Quasar Analysis Framework — Wave Collapse Transform
|
|
3
|
+
*
|
|
4
|
+
* Applies `collapse-wave` actions from a TransformationPlan.
|
|
5
|
+
* Replaces sequences of [size=N] nodes forming a sinusoidal pattern
|
|
6
|
+
* with a single [grow] node containing all text children and the
|
|
7
|
+
* detected size range in metadata.
|
|
8
|
+
*
|
|
9
|
+
* @example
|
|
10
|
+
* Before: [size=90]A[/size][size=150]B[/size][size=90]C[/size]
|
|
11
|
+
* After: [grow]ABC[/grow] (with metadata: min=90, max=150)
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { TransformPass, TransformationPlan } from '../../Contracts/Pass'
|
|
15
|
+
import type { PipelineContext } from '../../Contracts/PipelineContext'
|
|
16
|
+
import type { GreenNode } from '../../../Syntax/GreenNode'
|
|
17
|
+
import { greenNode, greenLeaf, childOffsets } from '../../../Syntax/GreenNode'
|
|
18
|
+
|
|
19
|
+
export class WaveCollapseTransform implements TransformPass {
|
|
20
|
+
readonly id = 'wave-collapse'
|
|
21
|
+
|
|
22
|
+
run(tree: GreenNode, plan: TransformationPlan, _context: PipelineContext): GreenNode {
|
|
23
|
+
const actions = plan.actions.filter(a => a.kind === 'collapse-wave')
|
|
24
|
+
if (actions.length === 0) return tree
|
|
25
|
+
|
|
26
|
+
const ranges = actions.map(a => ({
|
|
27
|
+
start: (a.payload.range as { start: number; end: number }).start,
|
|
28
|
+
end: (a.payload.range as { start: number; end: number }).end,
|
|
29
|
+
min: (a.payload.model as Record<string, unknown>)?.minSize as number ?? 90,
|
|
30
|
+
max: (a.payload.model as Record<string, unknown>)?.maxSize as number ?? 160,
|
|
31
|
+
})).sort((a, b) => a.start - b.start)
|
|
32
|
+
|
|
33
|
+
return this.transformNode(tree, ranges)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
private transformNode(
|
|
37
|
+
node: GreenNode,
|
|
38
|
+
ranges: Array<{ start: number; end: number; min: number; max: number }>,
|
|
39
|
+
treeStart: number = 0,
|
|
40
|
+
): GreenNode {
|
|
41
|
+
if (node.children.length === 0) return node
|
|
42
|
+
|
|
43
|
+
const children = node.children as GreenNode[]
|
|
44
|
+
// Green nodes carry widths, not positions. The ranges being matched here
|
|
45
|
+
// come from the analyzers and are absolute, so the walk accumulates the
|
|
46
|
+
// offsets it needs as it descends.
|
|
47
|
+
const offsets = childOffsets(node, treeStart)
|
|
48
|
+
const nodeStart = offsets[0]
|
|
49
|
+
const nodeEnd = offsets[children.length]
|
|
50
|
+
const activeRanges = ranges.filter(r => r.start >= nodeStart && r.end <= nodeEnd)
|
|
51
|
+
|
|
52
|
+
if (activeRanges.length === 0) {
|
|
53
|
+
return greenNode(node.kind, node.text, children.map((c, ci) => this.transformNode(c, ranges, offsets[ci])))
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const newChildren: GreenNode[] = []
|
|
57
|
+
let i = 0
|
|
58
|
+
|
|
59
|
+
while (i < children.length) {
|
|
60
|
+
const child = children[i]
|
|
61
|
+
const range = activeRanges.find(r => offsets[i] >= r.start && offsets[i + 1] <= r.end)
|
|
62
|
+
|
|
63
|
+
if (range && ['font_size', 'size'].includes(child.kind)) {
|
|
64
|
+
const texts: string[] = []
|
|
65
|
+
while (i < children.length && offsets[i + 1] <= range.end) {
|
|
66
|
+
if (['font_size', 'size'].includes(children[i].kind)) {
|
|
67
|
+
for (const tc of children[i].children as GreenNode[]) {
|
|
68
|
+
if (tc.kind === 'text') texts.push(tc.text)
|
|
69
|
+
}
|
|
70
|
+
} else if (children[i].kind === 'text') {
|
|
71
|
+
texts.push(children[i].text)
|
|
72
|
+
}
|
|
73
|
+
i++
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const combined = texts.join('')
|
|
77
|
+
const textLeaf = greenLeaf('text', combined)
|
|
78
|
+
const attr = `=${range.min},${range.max}`
|
|
79
|
+
newChildren.push(greenNode('grow', attr, [textLeaf]))
|
|
80
|
+
} else {
|
|
81
|
+
newChildren.push(this.transformNode(child, ranges, offsets[i]))
|
|
82
|
+
i++
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return greenNode(node.kind, node.text, newChildren)
|
|
87
|
+
}
|
|
88
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Quasar Analysis Framework — Character Count Analyzer (Proof of Concept)
|
|
3
|
+
*
|
|
4
|
+
* The simplest possible AnalyzerPass: it walks the Green Tree and counts
|
|
5
|
+
* text characters. This validates that the full pipeline works:
|
|
6
|
+
*
|
|
7
|
+
* Green Tree → Analyzer → Contribution → Aggregated Report
|
|
8
|
+
*
|
|
9
|
+
* No algorithms, no colours, no decisions — just validating the pipe.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { AnalyzerPass } from '../../Contracts/Pass'
|
|
13
|
+
import type { PipelineContext } from '../../Contracts/PipelineContext'
|
|
14
|
+
import type { Contribution } from '../../Contracts/Contribution'
|
|
15
|
+
import { ContributionKind } from '../../Contracts/Contribution'
|
|
16
|
+
import type { GreenNode } from '../../../Syntax/GreenNode'
|
|
17
|
+
|
|
18
|
+
export class CharacterCountAnalyzer implements AnalyzerPass {
|
|
19
|
+
readonly id = 'character-count'
|
|
20
|
+
|
|
21
|
+
run(tree: GreenNode, _context: PipelineContext): Contribution[] {
|
|
22
|
+
const count = this.countChars(tree)
|
|
23
|
+
|
|
24
|
+
return [
|
|
25
|
+
{
|
|
26
|
+
kind: ContributionKind.Metrics,
|
|
27
|
+
metrics: {
|
|
28
|
+
characters: count,
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
]
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// ── Private ─────────────────────────────────────────────────────
|
|
35
|
+
|
|
36
|
+
private countChars(node: GreenNode): number {
|
|
37
|
+
let total = node.text?.length ?? 0
|
|
38
|
+
if (node.children) {
|
|
39
|
+
for (const child of node.children) {
|
|
40
|
+
total += this.countChars(child)
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return total
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Quasar Analysis Framework — Pipeline
|
|
3
|
+
*
|
|
4
|
+
* The Pipeline is itself a Pass — it can be composed inside larger
|
|
5
|
+
* pipelines. Execution is strictly ordered:
|
|
6
|
+
*
|
|
7
|
+
* 1. All analysis passes run, collecting contributions.
|
|
8
|
+
* 2. The aggregated AnalysisReport is fed to each decision pass.
|
|
9
|
+
* 3. Each transform pass receives the current tree + plan and
|
|
10
|
+
* returns a new tree (never mutating the original).
|
|
11
|
+
*
|
|
12
|
+
* @example
|
|
13
|
+
* ```ts
|
|
14
|
+
* const pipeline = new Pipeline([
|
|
15
|
+
* { stage: 'analysis', pass: new CharacterCountAnalyzer() },
|
|
16
|
+
* ])
|
|
17
|
+
* const report = pipeline.run(tree, context)
|
|
18
|
+
* ```
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import type { AnalyzerPass, DecisionPass, TransformPass, TransformationPlan } from '../Contracts/Pass'
|
|
22
|
+
import type { AnalysisReport } from '../Contracts/AnalysisReport'
|
|
23
|
+
import type { PipelineContext } from '../Contracts/PipelineContext'
|
|
24
|
+
import type { PipelineStage } from './PipelineStage'
|
|
25
|
+
import type { GreenNode } from '../../Syntax/GreenNode'
|
|
26
|
+
import type { Contribution } from '../Contracts/Contribution'
|
|
27
|
+
|
|
28
|
+
interface StageEntry {
|
|
29
|
+
readonly stage: PipelineStage
|
|
30
|
+
readonly pass: AnalyzerPass | DecisionPass | TransformPass
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface PipelineResult {
|
|
34
|
+
/** The final (possibly transformed) Green Tree. */
|
|
35
|
+
readonly tree: GreenNode
|
|
36
|
+
/** The aggregated analysis report. */
|
|
37
|
+
readonly report: AnalysisReport
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export class Pipeline {
|
|
41
|
+
constructor(private readonly stages: readonly StageEntry[]) {}
|
|
42
|
+
|
|
43
|
+
// ── Public API ──────────────────────────────────────────────────
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Execute the full pipeline against `tree` with the given `context`.
|
|
47
|
+
*
|
|
48
|
+
* @returns The final tree and analysis report.
|
|
49
|
+
*/
|
|
50
|
+
run(tree: GreenNode, context: PipelineContext): PipelineResult {
|
|
51
|
+
const start = performance.now()
|
|
52
|
+
let currentTree = tree
|
|
53
|
+
const allContributions: Contribution[] = []
|
|
54
|
+
let latestPlan: TransformationPlan = { actions: [] }
|
|
55
|
+
const analysisPassCount: number = this.stages.filter(s => s.stage === 'analysis').length
|
|
56
|
+
|
|
57
|
+
for (const entry of this.stages) {
|
|
58
|
+
switch (entry.stage) {
|
|
59
|
+
case 'analysis':
|
|
60
|
+
this.runAnalysis(entry.pass as AnalyzerPass, currentTree, context, allContributions)
|
|
61
|
+
break
|
|
62
|
+
|
|
63
|
+
case 'decision': {
|
|
64
|
+
const report = this.buildReport(allContributions, analysisPassCount, start)
|
|
65
|
+
latestPlan = this.runDecision(entry.pass as DecisionPass, report, context)
|
|
66
|
+
break
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
case 'transform':
|
|
70
|
+
currentTree = this.runTransform(entry.pass as TransformPass, currentTree, latestPlan, context)
|
|
71
|
+
break
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const elapsed = performance.now() - start
|
|
76
|
+
const report = this.buildReport(allContributions, analysisPassCount, elapsed)
|
|
77
|
+
return { tree: currentTree, report }
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Use PipelineBuilder to construct a Pipeline:
|
|
82
|
+
*
|
|
83
|
+
* ```ts
|
|
84
|
+
* import { PipelineBuilder } from './PipelineBuilder'
|
|
85
|
+
* const pipeline = new PipelineBuilder()
|
|
86
|
+
* .analysis(new CharacterCountAnalyzer())
|
|
87
|
+
* .build()
|
|
88
|
+
* ```
|
|
89
|
+
*/
|
|
90
|
+
|
|
91
|
+
// ── Internal helpers ────────────────────────────────────────────
|
|
92
|
+
|
|
93
|
+
private runAnalysis(
|
|
94
|
+
pass: AnalyzerPass,
|
|
95
|
+
tree: GreenNode,
|
|
96
|
+
context: PipelineContext,
|
|
97
|
+
sink: Contribution[],
|
|
98
|
+
): void {
|
|
99
|
+
const contributions = pass.run(tree, context)
|
|
100
|
+
for (const c of contributions) {
|
|
101
|
+
sink.push(c)
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
private runDecision(
|
|
106
|
+
pass: DecisionPass,
|
|
107
|
+
report: AnalysisReport,
|
|
108
|
+
context: PipelineContext,
|
|
109
|
+
): TransformationPlan {
|
|
110
|
+
return pass.run(report, context)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
private runTransform(
|
|
114
|
+
pass: TransformPass,
|
|
115
|
+
tree: GreenNode,
|
|
116
|
+
plan: TransformationPlan,
|
|
117
|
+
context: PipelineContext,
|
|
118
|
+
): GreenNode {
|
|
119
|
+
return pass.run(tree, plan, context)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
private buildReport(
|
|
123
|
+
contributions: Contribution[],
|
|
124
|
+
passCount: number,
|
|
125
|
+
elapsedMs: number,
|
|
126
|
+
): AnalysisReport {
|
|
127
|
+
return {
|
|
128
|
+
contributions: Object.freeze([...contributions]),
|
|
129
|
+
passCount,
|
|
130
|
+
elapsedMs,
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|