@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
package/package.json
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@miliastry/quasar",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Quasar Document Engine - Advanced BBCode/Markdown/HTML AST Engine",
|
|
5
|
+
"main": "src/index.ts",
|
|
6
|
+
"types": "src/index.ts",
|
|
7
|
+
"license": "MSL-1.0",
|
|
8
|
+
"sideEffects": [
|
|
9
|
+
"./src/Visuals/*.css",
|
|
10
|
+
"./dist/Visuals/*.css"
|
|
11
|
+
],
|
|
12
|
+
"exports": {
|
|
13
|
+
".": {
|
|
14
|
+
"import": {
|
|
15
|
+
"types": "./dist/index.d.mts",
|
|
16
|
+
"default": "./dist/index.mjs"
|
|
17
|
+
},
|
|
18
|
+
"require": {
|
|
19
|
+
"types": "./dist/index.d.ts",
|
|
20
|
+
"default": "./dist/index.js"
|
|
21
|
+
}
|
|
22
|
+
},
|
|
23
|
+
"./Visuals/lyne.css": "./dist/Visuals/lyne.css",
|
|
24
|
+
"./Visuals/osu.css": "./dist/Visuals/osu.css",
|
|
25
|
+
"./dist/Visuals/*": "./dist/Visuals/*",
|
|
26
|
+
"./src/*": "./src/*",
|
|
27
|
+
"./package.json": "./package.json"
|
|
28
|
+
},
|
|
29
|
+
"scripts": {
|
|
30
|
+
"build": "tsup && node scripts/copy-css.mjs",
|
|
31
|
+
"prepublishOnly": "npm run verify && npm run build",
|
|
32
|
+
"test": "vitest run",
|
|
33
|
+
"typecheck": "tsc --noEmit -p tsconfig.json",
|
|
34
|
+
"verify": "npm run typecheck && npm run test"
|
|
35
|
+
},
|
|
36
|
+
"publishConfig": {
|
|
37
|
+
"access": "public",
|
|
38
|
+
"main": "dist/index.js",
|
|
39
|
+
"types": "dist/index.d.ts"
|
|
40
|
+
},
|
|
41
|
+
"files": [
|
|
42
|
+
"dist",
|
|
43
|
+
"src"
|
|
44
|
+
],
|
|
45
|
+
"dependencies": {},
|
|
46
|
+
"devDependencies": {
|
|
47
|
+
"@miliastry/quasar-studio": "*",
|
|
48
|
+
"tsup": "^8.5.1",
|
|
49
|
+
"typescript": "5.7.3"
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Quasar Analysis Framework — Analysis Report
|
|
3
|
+
*
|
|
4
|
+
* Once all AnalyzerPasses have run, their Contributions are collected
|
|
5
|
+
* into an immutable AnalysisReport. This report is the sole input to
|
|
6
|
+
* the DecisionPass(es).
|
|
7
|
+
*
|
|
8
|
+
* We deliberately keep the aggregator simple for now — just an array
|
|
9
|
+
* of contributions. As more pass types emerge we may introduce a
|
|
10
|
+
* structured Aggregator that indexes/categorises contributions.
|
|
11
|
+
*
|
|
12
|
+
* @see DecisionPass
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { Contribution } from './Contribution'
|
|
16
|
+
|
|
17
|
+
export interface AnalysisReport {
|
|
18
|
+
/** All contributions gathered from every AnalyzerPass. */
|
|
19
|
+
readonly contributions: readonly Contribution[]
|
|
20
|
+
|
|
21
|
+
/** Total number of analysis passes that ran. */
|
|
22
|
+
readonly passCount: number
|
|
23
|
+
|
|
24
|
+
/** Wall-clock time spent in analysis (ms). */
|
|
25
|
+
readonly elapsedMs: number
|
|
26
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Quasar Analysis Framework — Contribution Types
|
|
3
|
+
*
|
|
4
|
+
* Every AnalyzerPass produces an array of Contributions. A Contribution
|
|
5
|
+
* is a discriminated union so that downstream consumers (DecisionPass,
|
|
6
|
+
* AnalysisReport consumers, debug UI) can switch on `kind` and get
|
|
7
|
+
* perfect TypeScript narrowing.
|
|
8
|
+
*
|
|
9
|
+
* @see AnalysisReport
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
// ── Kind enum ─────────────────────────────────────────────────────
|
|
13
|
+
|
|
14
|
+
export const ContributionKind = {
|
|
15
|
+
Semantic: 'semantic',
|
|
16
|
+
Diagnostic: 'diagnostic',
|
|
17
|
+
Optimization: 'optimization',
|
|
18
|
+
Metrics: 'metrics',
|
|
19
|
+
} as const
|
|
20
|
+
|
|
21
|
+
export type ContributionKind = (typeof ContributionKind)[keyof typeof ContributionKind]
|
|
22
|
+
|
|
23
|
+
// ── Individual contribution shapes ────────────────────────────────
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* A semantic pattern recognised in the tree (gradient, wave, rainbow,…).
|
|
27
|
+
* These describe *meaning* of the document, not just surface syntax.
|
|
28
|
+
*/
|
|
29
|
+
export interface SemanticContribution {
|
|
30
|
+
readonly kind: typeof ContributionKind.Semantic
|
|
31
|
+
readonly label: string
|
|
32
|
+
readonly confidence: number // 0-1
|
|
33
|
+
readonly range: { start: number; end: number }
|
|
34
|
+
readonly metadata: Record<string, unknown>
|
|
35
|
+
/** Human-readable description (optional). */
|
|
36
|
+
readonly description?: string
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* A diagnostic (warning, error, info) attached to a range in the tree.
|
|
41
|
+
*/
|
|
42
|
+
export interface DiagnosticContribution {
|
|
43
|
+
readonly kind: typeof ContributionKind.Diagnostic
|
|
44
|
+
readonly severity: 'error' | 'warning' | 'info' | 'hint'
|
|
45
|
+
readonly message: string
|
|
46
|
+
readonly range?: { start: number; end: number }
|
|
47
|
+
readonly code?: string
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* An optimisation opportunity detected in the tree (mergeable colours,
|
|
52
|
+
* redundant wrapping, empty tags, …).
|
|
53
|
+
*/
|
|
54
|
+
export interface OptimizationContribution {
|
|
55
|
+
readonly kind: typeof ContributionKind.Optimization
|
|
56
|
+
readonly label: string
|
|
57
|
+
readonly description: string
|
|
58
|
+
readonly estimatedImprovement?: string
|
|
59
|
+
readonly range: { start: number; end: number }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Aggregate metrics about the tree (character count, node depth, …).
|
|
64
|
+
*/
|
|
65
|
+
export interface MetricsContribution {
|
|
66
|
+
readonly kind: typeof ContributionKind.Metrics
|
|
67
|
+
readonly metrics: Record<string, number | string>
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ── Discriminated union ───────────────────────────────────────────
|
|
71
|
+
|
|
72
|
+
export type Contribution =
|
|
73
|
+
| SemanticContribution
|
|
74
|
+
| DiagnosticContribution
|
|
75
|
+
| OptimizationContribution
|
|
76
|
+
| MetricsContribution
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Quasar Analysis Framework — Pass Contracts
|
|
3
|
+
*
|
|
4
|
+
* Each pass in the analysis/transform pipeline implements one of these
|
|
5
|
+
* specialized interfaces. The three kinds mirror LLVM's pass structure:
|
|
6
|
+
*
|
|
7
|
+
* Analysis — observe the tree, produce Contributions (never mutate)
|
|
8
|
+
* Decision — consume an AnalysisReport, produce a TransformationPlan
|
|
9
|
+
* Transform — mutate the tree according to a plan
|
|
10
|
+
*
|
|
11
|
+
* @see ARCHITECTURE.md (Analysis Framework)
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { PipelineContext } from './PipelineContext'
|
|
15
|
+
import type { Contribution } from './Contribution'
|
|
16
|
+
import type { GreenNode } from '../../Syntax/GreenNode'
|
|
17
|
+
import type { AnalysisReport } from './AnalysisReport'
|
|
18
|
+
|
|
19
|
+
// ── Base ──────────────────────────────────────────────────────────
|
|
20
|
+
|
|
21
|
+
/** Every pass has an identity string and belongs to a stage. */
|
|
22
|
+
export interface Pass {
|
|
23
|
+
readonly id: string
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// ── Analysis — observe, never mutate ──────────────────────────────
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* An AnalyzerPass observes the Green Tree and produces one or more
|
|
30
|
+
* Contributions. It MUST NOT mutate the tree or the context.
|
|
31
|
+
*/
|
|
32
|
+
export interface AnalyzerPass extends Pass {
|
|
33
|
+
run(tree: GreenNode, context: PipelineContext): Contribution[]
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// ── Decision — plan actions from evidence ─────────────────────────
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* A DecisionPass consumes an AnalysisReport (which itself is the
|
|
40
|
+
* aggregated output of all AnalyzerPasses) and produces a
|
|
41
|
+
* TransformationPlan that describes *intended* tree mutations.
|
|
42
|
+
*/
|
|
43
|
+
export interface DecisionPass extends Pass {
|
|
44
|
+
run(report: AnalysisReport, context: PipelineContext): TransformationPlan
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// ── Transform — execute a plan on the tree ────────────────────────
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* A TransformPass receives the current Green Tree together with a
|
|
51
|
+
* TransformationPlan and returns a **new** Green Tree. It MUST NOT
|
|
52
|
+
* mutate the original tree — immutability guarantees deterministic
|
|
53
|
+
* replay and debugging.
|
|
54
|
+
*/
|
|
55
|
+
export interface TransformPass extends Pass {
|
|
56
|
+
run(tree: GreenNode, plan: TransformationPlan, context: PipelineContext): GreenNode
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ── Supporting types ──────────────────────────────────────────────
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* A single action described by a TransformationPlan.
|
|
63
|
+
* `kind` tells the TransformPass what to do; `payload` carries
|
|
64
|
+
* per-kind parameters.
|
|
65
|
+
*/
|
|
66
|
+
export interface TransformAction {
|
|
67
|
+
readonly kind: string
|
|
68
|
+
readonly payload: Record<string, unknown>
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Ordered set of actions that a DecisionPass produces.
|
|
73
|
+
*/
|
|
74
|
+
export interface TransformationPlan {
|
|
75
|
+
readonly actions: readonly TransformAction[]
|
|
76
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Quasar Analysis Framework — Pipeline Context
|
|
3
|
+
*
|
|
4
|
+
* PipelineContext is **read-only by contract**. It is built *before*
|
|
5
|
+
* the pipeline starts and MUST NOT be modified by any pass.
|
|
6
|
+
*
|
|
7
|
+
* If a pass needs to make information available to downstream passes it
|
|
8
|
+
* should emit a Contribution rather than mutating the context.
|
|
9
|
+
*
|
|
10
|
+
* @immutable
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
// ── Mode ──────────────────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
export const PipelineMode = {
|
|
16
|
+
Interactive: 'interactive', // TextStudio, editor integration
|
|
17
|
+
Batch: 'batch', // CLI, automated processing
|
|
18
|
+
Import: 'import', // Clipboard / file import
|
|
19
|
+
} as const
|
|
20
|
+
|
|
21
|
+
export type PipelineMode = (typeof PipelineMode)[keyof typeof PipelineMode]
|
|
22
|
+
|
|
23
|
+
// ── Export target ─────────────────────────────────────────────────
|
|
24
|
+
|
|
25
|
+
export const ExportTarget = {
|
|
26
|
+
Osu: 'osu',
|
|
27
|
+
Miliastry: 'miliastry',
|
|
28
|
+
HTML: 'html',
|
|
29
|
+
Markdown: 'markdown',
|
|
30
|
+
} as const
|
|
31
|
+
|
|
32
|
+
export type ExportTarget = (typeof ExportTarget)[keyof typeof ExportTarget]
|
|
33
|
+
|
|
34
|
+
// ── Context ───────────────────────────────────────────────────────
|
|
35
|
+
|
|
36
|
+
export interface PipelineContext {
|
|
37
|
+
/** Where this pipeline run originates from. */
|
|
38
|
+
readonly mode: PipelineMode
|
|
39
|
+
|
|
40
|
+
/** The format we intend to export to (influences decisions). */
|
|
41
|
+
readonly target: ExportTarget
|
|
42
|
+
|
|
43
|
+
/** Feature flags that may enable/disable certain behaviours. */
|
|
44
|
+
readonly featureFlags: Readonly<Record<string, boolean>>
|
|
45
|
+
|
|
46
|
+
/** Free-form metadata provided by the caller (e.g. selection range). */
|
|
47
|
+
readonly metadata: Readonly<Record<string, unknown>>
|
|
48
|
+
}
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Quasar Analysis Framework — Gradient Analyzer (v2)
|
|
3
|
+
*
|
|
4
|
+
* Detects sequences of [color=#HEX] tags that form a gradient pattern
|
|
5
|
+
* and reports them as SemanticContributions with a confidence score.
|
|
6
|
+
*
|
|
7
|
+
* ## Refinements over v1
|
|
8
|
+
*
|
|
9
|
+
* 1. **OKLab color space** — Uses OKLab (Bottosson 2020) for perceptual
|
|
10
|
+
* colour distance instead of RGB. Two colours with small OKLab distance
|
|
11
|
+
* look nearly identical to the human eye, making gradient detection
|
|
12
|
+
* much more accurate.
|
|
13
|
+
*
|
|
14
|
+
* 2. **Change-point detection** — Identifies gradient *stops* even when
|
|
15
|
+
* plateaus (runs of identical colour) exist, so a sequence like
|
|
16
|
+
* RRRR→G→BBBB is correctly parsed as a 3-stop gradient rather than
|
|
17
|
+
* a failed linear interpolation.
|
|
18
|
+
*
|
|
19
|
+
* 3. **Sharper sigmoid (k=6)** — Better separation between "likely
|
|
20
|
+
* gradient" and "maybe gradient" at the decision thresholds.
|
|
21
|
+
*
|
|
22
|
+
* ## Confidence features (weights sum to 1.0)
|
|
23
|
+
*
|
|
24
|
+
* - uniformPerceptualSpacing (+0.30)
|
|
25
|
+
* - contiguousWrappers (+0.25)
|
|
26
|
+
* - noFormattingBreaks (+0.15)
|
|
27
|
+
* - monotonicProgression (+0.20)
|
|
28
|
+
* - lowPerceptualError (+0.10)
|
|
29
|
+
*
|
|
30
|
+
* @see SemanticContribution
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import type { AnalyzerPass } from '../../Contracts/Pass'
|
|
34
|
+
import type { PipelineContext } from '../../Contracts/PipelineContext'
|
|
35
|
+
import type { Contribution } from '../../Contracts/Contribution'
|
|
36
|
+
import { ContributionKind } from '../../Contracts/Contribution'
|
|
37
|
+
import type { GreenNode } from '../../../Syntax/GreenNode'
|
|
38
|
+
import { childOffsets } from '../../../Syntax/GreenNode'
|
|
39
|
+
import { hexToOklab, mixHexOklab, perceptualDistance, hexToRgb } from '../../../Utils/ColorMath'
|
|
40
|
+
import { extractHex, sigmoid, extractSequences, checkFormattingBreaks } from '../../Utils/color-utils'
|
|
41
|
+
|
|
42
|
+
// ── Constants ─────────────────────────────────────────────────────
|
|
43
|
+
|
|
44
|
+
const MIN_SEQUENCE_LENGTH = 3
|
|
45
|
+
const SIGMOID_STEEPNESS = 6
|
|
46
|
+
|
|
47
|
+
const WEIGHTS = {
|
|
48
|
+
uniformPerceptualSpacing: 0.30,
|
|
49
|
+
contiguousWrappers: 0.25,
|
|
50
|
+
noFormattingBreaks: 0.15,
|
|
51
|
+
monotonicProgression: 0.20,
|
|
52
|
+
lowPerceptualError: 0.10,
|
|
53
|
+
} as const
|
|
54
|
+
|
|
55
|
+
// ── Types ─────────────────────────────────────────────────────────
|
|
56
|
+
|
|
57
|
+
export interface GradientModel {
|
|
58
|
+
readonly colors: string[]
|
|
59
|
+
readonly easing: 'linear' | 'easeIn' | 'easeOut' | 'easeInOut'
|
|
60
|
+
readonly stops: GradientStop[]
|
|
61
|
+
readonly rangeStart: number
|
|
62
|
+
readonly rangeEnd: number
|
|
63
|
+
readonly charCount: number
|
|
64
|
+
readonly diagnostics: GradientDiagnostics
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface GradientStop {
|
|
68
|
+
/** Hex colour at this stop */
|
|
69
|
+
readonly color: string
|
|
70
|
+
/** Normalised position 0-1 */
|
|
71
|
+
readonly position: number
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface GradientDiagnostics {
|
|
75
|
+
readonly uniformSpacing: boolean
|
|
76
|
+
readonly monotonic: boolean
|
|
77
|
+
readonly plateauCount: number
|
|
78
|
+
readonly maxPerceptualError: number
|
|
79
|
+
readonly stopCount: number
|
|
80
|
+
readonly featureScores: Readonly<Record<string, number>>
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ── Main Analyzer ─────────────────────────────────────────────────
|
|
84
|
+
|
|
85
|
+
export class GradientAnalyzer implements AnalyzerPass {
|
|
86
|
+
readonly id = 'gradient-analyzer'
|
|
87
|
+
|
|
88
|
+
run(tree: GreenNode, _context: PipelineContext): Contribution[] {
|
|
89
|
+
const contributions: Contribution[] = []
|
|
90
|
+
this.findGradients(tree, contributions)
|
|
91
|
+
return contributions
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ── Sequence Detection ──────────────────────────────────────────
|
|
95
|
+
|
|
96
|
+
private findGradients(node: GreenNode, sink: Contribution[], nodeStart: number = 0): void {
|
|
97
|
+
// Green nodes carry widths, not positions, so a walk that reports source
|
|
98
|
+
// ranges accumulates them on the way down.
|
|
99
|
+
const offsets = childOffsets(node, nodeStart)
|
|
100
|
+
if (node.children.length > 0) {
|
|
101
|
+
const children = node.children as GreenNode[]
|
|
102
|
+
const sequences = extractSequences(children, 'color', extractHex)
|
|
103
|
+
|
|
104
|
+
for (const seq of sequences) {
|
|
105
|
+
const colors = seq.values as string[]
|
|
106
|
+
if (colors.length < MIN_SEQUENCE_LENGTH) continue
|
|
107
|
+
|
|
108
|
+
// Sum text length in the sequence
|
|
109
|
+
let textLen = 0
|
|
110
|
+
for (let i = seq.startIdx; i < seq.endIdx; i++) {
|
|
111
|
+
for (const child of children[i].children as GreenNode[]) {
|
|
112
|
+
if (child.kind === 'text') textLen += child.text.length
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const hasBreaks = checkFormattingBreaks(children, seq, 'color')
|
|
117
|
+
const { stops, easing } = this.detectStops(colors)
|
|
118
|
+
const diag = this.buildDiagnostics(colors, stops)
|
|
119
|
+
const { score: rawScore, featureScores } = this.calculateRawScore(diag, hasBreaks)
|
|
120
|
+
const confidence = sigmoid(rawScore, SIGMOID_STEEPNESS)
|
|
121
|
+
|
|
122
|
+
sink.push({
|
|
123
|
+
kind: ContributionKind.Semantic,
|
|
124
|
+
label: 'Gradient',
|
|
125
|
+
confidence,
|
|
126
|
+
range: {
|
|
127
|
+
start: offsets[seq.startIdx],
|
|
128
|
+
end: offsets[seq.endIdx],
|
|
129
|
+
},
|
|
130
|
+
metadata: {
|
|
131
|
+
model: { colors, easing, stops, charCount: textLen },
|
|
132
|
+
diagnostics: { ...diag, featureScores },
|
|
133
|
+
},
|
|
134
|
+
})
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const kids = node.children as GreenNode[]
|
|
139
|
+
for (let i = 0; i < kids.length; i++) {
|
|
140
|
+
this.findGradients(kids[i], sink, offsets[i])
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// ── Change-point Detection ──────────────────────────────────────
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Detect gradient stops from a sequence of hex colours, handling plateaus.
|
|
148
|
+
*
|
|
149
|
+
* Algorithm: Scan for "significant changes" in perceptual distance.
|
|
150
|
+
* Wherever the cumulative perceptual distance from the last stop exceeds a
|
|
151
|
+
* threshold, a new stop is recorded. This naturally skips over plateaus.
|
|
152
|
+
*/
|
|
153
|
+
private detectStops(colors: string[]): { stops: GradientStop[]; easing: 'linear' | 'easeIn' | 'easeOut' | 'easeInOut' } {
|
|
154
|
+
const n = colors.length
|
|
155
|
+
const stops: GradientStop[] = [{ color: colors[0], position: 0 }]
|
|
156
|
+
|
|
157
|
+
const totalDist = perceptualDistance(colors[0], colors[n - 1])
|
|
158
|
+
const minStep = totalDist * 0.08 // At least 8% of total range
|
|
159
|
+
let accumulated = 0
|
|
160
|
+
|
|
161
|
+
for (let i = 1; i < n; i++) {
|
|
162
|
+
const dist = perceptualDistance(colors[i - 1], colors[i])
|
|
163
|
+
accumulated += dist
|
|
164
|
+
if (accumulated >= minStep && i < n - 1) {
|
|
165
|
+
stops.push({ color: colors[i], position: i / (n - 1) })
|
|
166
|
+
accumulated = 0
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Always include the last colour
|
|
171
|
+
if (stops[stops.length - 1].position < 1) {
|
|
172
|
+
stops.push({ color: colors[n - 1], position: 1 })
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Infer easing from change in perceptual distance across segments
|
|
176
|
+
let easing: 'linear' | 'easeIn' | 'easeOut' | 'easeInOut' = 'linear'
|
|
177
|
+
if (stops.length >= 3) {
|
|
178
|
+
const segDists: number[] = []
|
|
179
|
+
for (let i = 1; i < stops.length; i++) {
|
|
180
|
+
const segLen = stops[i].position - stops[i - 1].position
|
|
181
|
+
const segDist = perceptualDistance(stops[i - 1].color, stops[i].color)
|
|
182
|
+
segDists.push(segLen > 0 ? segDist / segLen : 0)
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const earlyAvg = segDists.slice(0, Math.ceil(segDists.length / 3)).reduce((a, b) => a + b, 0) / Math.max(1, Math.ceil(segDists.length / 3))
|
|
186
|
+
const lateAvg = segDists.slice(-Math.ceil(segDists.length / 3)).reduce((a, b) => a + b, 0) / Math.max(1, Math.ceil(segDists.length / 3))
|
|
187
|
+
|
|
188
|
+
if (earlyAvg > lateAvg * 1.5) easing = 'easeIn'
|
|
189
|
+
else if (lateAvg > earlyAvg * 1.5) easing = 'easeOut'
|
|
190
|
+
else if (segDists.length >= 3) {
|
|
191
|
+
const midStart = Math.floor(segDists.length / 3)
|
|
192
|
+
const midEnd = Math.floor(2 * segDists.length / 3)
|
|
193
|
+
const midAvg = segDists.slice(midStart, midEnd).reduce((a, b) => a + b, 0) / (midEnd - midStart)
|
|
194
|
+
const edgeAvg = [...segDists.slice(0, midStart), ...segDists.slice(midEnd)].reduce((a, b) => a + b, 0) / Math.max(1, segDists.length - (midEnd - midStart))
|
|
195
|
+
if (midAvg > edgeAvg * 1.3) easing = 'easeInOut'
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return { stops, easing }
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// ── Diagnostics ─────────────────────────────────────────────────
|
|
203
|
+
|
|
204
|
+
private buildDiagnostics(
|
|
205
|
+
colors: string[],
|
|
206
|
+
stops: GradientStop[],
|
|
207
|
+
): Omit<GradientDiagnostics, 'featureScores'> {
|
|
208
|
+
const n = colors.length
|
|
209
|
+
const oklabArray = colors.map(c => hexToOklab(c))
|
|
210
|
+
|
|
211
|
+
// 1. Uniform perceptual spacing
|
|
212
|
+
const pDiffs: number[] = []
|
|
213
|
+
for (let i = 1; i < n; i++) {
|
|
214
|
+
pDiffs.push(perceptualDistance(colors[i - 1], colors[i]))
|
|
215
|
+
}
|
|
216
|
+
const avgDiff = pDiffs.reduce((a, b) => a + b, 0) / pDiffs.length
|
|
217
|
+
const diffVariance = pDiffs.reduce((sum, d) => sum + (d - avgDiff) ** 2, 0) / pDiffs.length
|
|
218
|
+
const uniformSpacing = diffVariance < 0.001 // Tiny variance in perceptual space
|
|
219
|
+
|
|
220
|
+
// 2. Check monotonic: does each OKLab dimension change in one direction?
|
|
221
|
+
const first = oklabArray[0]
|
|
222
|
+
const last = oklabArray[n - 1]
|
|
223
|
+
const lDir = Math.sign(last[0] - first[0])
|
|
224
|
+
const aDir = Math.sign(last[1] - first[1])
|
|
225
|
+
const bDir = Math.sign(last[2] - first[2])
|
|
226
|
+
|
|
227
|
+
let violations = 0
|
|
228
|
+
for (let i = 1; i < n; i++) {
|
|
229
|
+
const curr = oklabArray[i]
|
|
230
|
+
const prev = oklabArray[i - 1]
|
|
231
|
+
if (lDir !== 0 && Math.sign(curr[0] - prev[0]) !== lDir && Math.abs(curr[0] - prev[0]) > 0.005) violations++
|
|
232
|
+
if (aDir !== 0 && Math.sign(curr[1] - prev[1]) !== aDir && Math.abs(curr[1] - prev[1]) > 0.005) violations++
|
|
233
|
+
if (bDir !== 0 && Math.sign(curr[2] - prev[2]) !== bDir && Math.abs(curr[2] - prev[2]) > 0.005) violations++
|
|
234
|
+
}
|
|
235
|
+
const monotonic = violations < n * 0.2
|
|
236
|
+
|
|
237
|
+
// 3. Count plateaus (consecutive perceptually identical colours)
|
|
238
|
+
let plateauCount = 0
|
|
239
|
+
for (let i = 1; i < n; i++) {
|
|
240
|
+
if (perceptualDistance(colors[i - 1], colors[i]) < 0.01) plateauCount++
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// 4. Perceptual error vs ideal OKLab interpolation
|
|
244
|
+
let maxPerceptualError = 0
|
|
245
|
+
if (n >= 3 && stops.length >= 2) {
|
|
246
|
+
for (let i = 0; i < n; i++) {
|
|
247
|
+
const t = n > 1 ? i / (n - 1) : 0
|
|
248
|
+
const ideal = mixHexOklab(stops[0].color, stops[stops.length - 1].color, t)
|
|
249
|
+
const error = perceptualDistance(ideal, colors[i])
|
|
250
|
+
maxPerceptualError = Math.max(maxPerceptualError, error)
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
return { uniformSpacing, monotonic, plateauCount, maxPerceptualError, stopCount: stops.length }
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// ── Confidence Scoring ──────────────────────────────────────────
|
|
258
|
+
|
|
259
|
+
private calculateRawScore(
|
|
260
|
+
diag: Omit<GradientDiagnostics, 'featureScores'>,
|
|
261
|
+
hasBreaks: boolean,
|
|
262
|
+
): { score: number; featureScores: Record<string, number> } {
|
|
263
|
+
let score = 0
|
|
264
|
+
|
|
265
|
+
if (diag.uniformSpacing) score += WEIGHTS.uniformPerceptualSpacing
|
|
266
|
+
else score -= WEIGHTS.uniformPerceptualSpacing * 0.5
|
|
267
|
+
|
|
268
|
+
score += WEIGHTS.contiguousWrappers
|
|
269
|
+
|
|
270
|
+
if (!hasBreaks) score += WEIGHTS.noFormattingBreaks
|
|
271
|
+
|
|
272
|
+
if (diag.monotonic) score += WEIGHTS.monotonicProgression
|
|
273
|
+
else score -= WEIGHTS.monotonicProgression * 0.3
|
|
274
|
+
|
|
275
|
+
if (diag.maxPerceptualError < 0.02) score += WEIGHTS.lowPerceptualError
|
|
276
|
+
else if (diag.maxPerceptualError < 0.05) score += WEIGHTS.lowPerceptualError * 0.5
|
|
277
|
+
else {
|
|
278
|
+
const penalty = WEIGHTS.lowPerceptualError * Math.min(diag.maxPerceptualError * 20, 5)
|
|
279
|
+
score -= penalty
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const featureScores = {
|
|
283
|
+
uniformPerceptualSpacing: diag.uniformSpacing ? WEIGHTS.uniformPerceptualSpacing : -WEIGHTS.uniformPerceptualSpacing * 0.5,
|
|
284
|
+
contiguousWrappers: WEIGHTS.contiguousWrappers,
|
|
285
|
+
noFormattingBreaks: hasBreaks ? -WEIGHTS.noFormattingBreaks : WEIGHTS.noFormattingBreaks,
|
|
286
|
+
monotonicProgression: diag.monotonic ? WEIGHTS.monotonicProgression : -WEIGHTS.monotonicProgression * 0.3,
|
|
287
|
+
lowPerceptualError: diag.maxPerceptualError < 0.05 ? WEIGHTS.lowPerceptualError : -WEIGHTS.lowPerceptualError * 0.5,
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
return { score, featureScores }
|
|
291
|
+
}
|
|
292
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Quasar Analysis Framework — Mergeable Color Analyzer
|
|
3
|
+
*
|
|
4
|
+
* Detects sequences of consecutive [color=#HEX] wrappers where ALL
|
|
5
|
+
* adjacent colours are identical. These are optimisation opportunities:
|
|
6
|
+
* the user could merge them into a single `[color]` tag.
|
|
7
|
+
*
|
|
8
|
+
* Example:
|
|
9
|
+
* [color=#FF0000]H[/color][color=#FF0000]e[/color] → mergeable
|
|
10
|
+
* [color=#FF0000]H[/color][color=#EE1100]e[/color] → NOT mergeable
|
|
11
|
+
*
|
|
12
|
+
* @see OptimizationContribution
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { AnalyzerPass } from '../../Contracts/Pass'
|
|
16
|
+
import type { PipelineContext } from '../../Contracts/PipelineContext'
|
|
17
|
+
import type { Contribution } from '../../Contracts/Contribution'
|
|
18
|
+
import { ContributionKind } from '../../Contracts/Contribution'
|
|
19
|
+
import type { GreenNode } from '../../../Syntax/GreenNode'
|
|
20
|
+
import { childOffsets } from '../../../Syntax/GreenNode'
|
|
21
|
+
import { extractHex } from '../../Utils/color-utils'
|
|
22
|
+
|
|
23
|
+
export class MergeableColorAnalyzer implements AnalyzerPass {
|
|
24
|
+
readonly id = 'mergeable-color'
|
|
25
|
+
|
|
26
|
+
run(tree: GreenNode, _context: PipelineContext): Contribution[] {
|
|
27
|
+
const contributions: Contribution[] = []
|
|
28
|
+
|
|
29
|
+
// Walk all children of each node to find sequences of colour wrappers
|
|
30
|
+
this.findMergeableColorSequences(tree, contributions)
|
|
31
|
+
|
|
32
|
+
return contributions
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// ── Private ─────────────────────────────────────────────────────
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Walk the tree and find consecutive color nodes with identical colours.
|
|
39
|
+
*/
|
|
40
|
+
private findMergeableColorSequences(node: GreenNode, sink: Contribution[], nodeStart: number = 0): void {
|
|
41
|
+
// Green nodes carry widths, not positions, so a walk that reports source
|
|
42
|
+
// ranges accumulates them on the way down.
|
|
43
|
+
const offsets = childOffsets(node, nodeStart)
|
|
44
|
+
// Only look at container nodes that might have colour children
|
|
45
|
+
if (node.children.length > 1) {
|
|
46
|
+
const children = node.children as GreenNode[]
|
|
47
|
+
let seqStart = -1
|
|
48
|
+
let seqHex: string | null = null
|
|
49
|
+
|
|
50
|
+
for (let i = 0; i < children.length; i++) {
|
|
51
|
+
const hex = extractHex(children[i])
|
|
52
|
+
|
|
53
|
+
if (hex !== null) {
|
|
54
|
+
if (seqStart === -1) {
|
|
55
|
+
// Start a new sequence
|
|
56
|
+
seqStart = i
|
|
57
|
+
seqHex = hex
|
|
58
|
+
} else if (hex !== seqHex) {
|
|
59
|
+
// Color changed — check if previous sequence was mergeable
|
|
60
|
+
if (i - seqStart > 1) {
|
|
61
|
+
this.emitMergeable(children, seqStart, i, seqHex!, sink, offsets)
|
|
62
|
+
}
|
|
63
|
+
seqStart = i
|
|
64
|
+
seqHex = hex
|
|
65
|
+
}
|
|
66
|
+
// If hex === seqHex, sequence continues — do nothing
|
|
67
|
+
} else {
|
|
68
|
+
// Non-color node — end any active sequence
|
|
69
|
+
if (seqStart !== -1 && seqStart < i - 1) {
|
|
70
|
+
this.emitMergeable(children, seqStart, i, seqHex!, sink, offsets)
|
|
71
|
+
}
|
|
72
|
+
seqStart = -1
|
|
73
|
+
seqHex = null
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Check if the last sequence is mergeable
|
|
78
|
+
if (seqStart !== -1 && seqStart < children.length - 1) {
|
|
79
|
+
this.emitMergeable(children, seqStart, children.length, seqHex!, sink, offsets)
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Recurse into children (colour nodes' children are text, so only recurse non-color nodes)
|
|
84
|
+
const kids = node.children as GreenNode[]
|
|
85
|
+
for (let i = 0; i < kids.length; i++) {
|
|
86
|
+
if (kids[i].kind !== 'color') {
|
|
87
|
+
this.findMergeableColorSequences(kids[i], sink, offsets[i])
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
private emitMergeable(
|
|
93
|
+
children: GreenNode[],
|
|
94
|
+
start: number,
|
|
95
|
+
end: number,
|
|
96
|
+
hex: string,
|
|
97
|
+
sink: Contribution[],
|
|
98
|
+
offsets: number[],
|
|
99
|
+
): void {
|
|
100
|
+
const count = end - start
|
|
101
|
+
sink.push({
|
|
102
|
+
kind: ContributionKind.Optimization,
|
|
103
|
+
label: 'Merge Colors',
|
|
104
|
+
description: `${count} consecutive [color=${hex}] tags can be merged into one`,
|
|
105
|
+
estimatedImprovement: `-${(count - 1) * 100}% color tags`,
|
|
106
|
+
range: {
|
|
107
|
+
start: offsets[start],
|
|
108
|
+
end: offsets[end],
|
|
109
|
+
},
|
|
110
|
+
})
|
|
111
|
+
}
|
|
112
|
+
}
|