@mindfiredigital/ignix-lite-engine 1.1.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/.turbo/turbo-build.log +22 -0
- package/CHANGELOG.md +7 -0
- package/LICENSE +21 -0
- package/README.md +283 -0
- package/dist/index.d.ts +171 -0
- package/dist/index.js +2540 -0
- package/dist/index.js.map +1 -0
- package/dist/manifests/accordion.json +61 -0
- package/dist/manifests/alert.json +69 -0
- package/dist/manifests/avatar.json +75 -0
- package/dist/manifests/badge.json +74 -0
- package/dist/manifests/breadcrumb.json +87 -0
- package/dist/manifests/button.json +85 -0
- package/dist/manifests/card.json +91 -0
- package/dist/manifests/checkbox.json +122 -0
- package/dist/manifests/codeblock.json +63 -0
- package/dist/manifests/combobox.json +33 -0
- package/dist/manifests/dialog.json +64 -0
- package/dist/manifests/divider.json +47 -0
- package/dist/manifests/dropdown.json +105 -0
- package/dist/manifests/form.json +81 -0
- package/dist/manifests/grid.json +143 -0
- package/dist/manifests/input.json +99 -0
- package/dist/manifests/meter.json +103 -0
- package/dist/manifests/navigation.json +70 -0
- package/dist/manifests/progress.json +88 -0
- package/dist/manifests/radio.json +121 -0
- package/dist/manifests/select.json +109 -0
- package/dist/manifests/skeleton.json +101 -0
- package/dist/manifests/tab.json +88 -0
- package/dist/manifests/table.json +92 -0
- package/dist/manifests/textarea.json +117 -0
- package/dist/manifests/toast.json +157 -0
- package/dist/manifests/tooltip.json +115 -0
- package/dist/vector-index.json +14015 -0
- package/package.json +33 -0
- package/src/global.d.ts +3 -0
- package/src/index.ts +14 -0
- package/src/manifests/accordion.json +61 -0
- package/src/manifests/alert.json +69 -0
- package/src/manifests/avatar.json +75 -0
- package/src/manifests/badge.json +74 -0
- package/src/manifests/breadcrumb.json +87 -0
- package/src/manifests/button.json +85 -0
- package/src/manifests/card.json +91 -0
- package/src/manifests/checkbox.json +122 -0
- package/src/manifests/codeblock.json +63 -0
- package/src/manifests/combobox.json +33 -0
- package/src/manifests/dialog.json +64 -0
- package/src/manifests/divider.json +47 -0
- package/src/manifests/dropdown.json +105 -0
- package/src/manifests/form.json +81 -0
- package/src/manifests/grid.json +143 -0
- package/src/manifests/index.ts +49 -0
- package/src/manifests/input.json +99 -0
- package/src/manifests/meter.json +103 -0
- package/src/manifests/navigation.json +70 -0
- package/src/manifests/progress.json +88 -0
- package/src/manifests/radio.json +121 -0
- package/src/manifests/select.json +109 -0
- package/src/manifests/skeleton.json +101 -0
- package/src/manifests/tab.json +88 -0
- package/src/manifests/table.json +92 -0
- package/src/manifests/textarea.json +117 -0
- package/src/manifests/toast.json +157 -0
- package/src/manifests/tooltip.json +115 -0
- package/src/tools/build-index.ts +43 -0
- package/src/tools/check-a11y.ts +96 -0
- package/src/tools/embedder.ts +18 -0
- package/src/tools/generate-theme.ts +42 -0
- package/src/tools/get-emmet.ts +64 -0
- package/src/tools/get-manifests.ts +55 -0
- package/src/tools/handoff.ts +302 -0
- package/src/tools/intent-engine.ts +215 -0
- package/src/tools/list-components.ts +20 -0
- package/src/tools/preview.ts +186 -0
- package/src/tools/search-index.ts +82 -0
- package/src/tools/theme-palette.ts +65 -0
- package/src/tools/theme-tokens.ts +176 -0
- package/src/tools/token-counter.ts +59 -0
- package/src/tools/validator.ts +353 -0
- package/src/types.ts +63 -0
- package/src/utils/a11y-rules.ts +873 -0
- package/src/utils/a11y-types.ts +15 -0
- package/src/utils/cosine.ts +15 -0
- package/src/utils/emmet-helpers.ts +283 -0
- package/src/utils/intent-helpers.ts +66 -0
- package/src/utils/intent-parser.ts +175 -0
- package/src/utils/tokenizer.ts +7 -0
- package/tsconfig.json +17 -0
- package/tsup.config.ts +10 -0
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { readdirSync, readFileSync, writeFileSync } from 'fs'
|
|
2
|
+
|
|
3
|
+
import { embedText } from './embedder.js'
|
|
4
|
+
|
|
5
|
+
type Example = {
|
|
6
|
+
label?: string
|
|
7
|
+
emmet?: string
|
|
8
|
+
html?: string
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const files = readdirSync('src/manifests')
|
|
12
|
+
|
|
13
|
+
const index = files
|
|
14
|
+
.filter((file) => file.endsWith('.json'))
|
|
15
|
+
.map((file) => {
|
|
16
|
+
const manifest = JSON.parse(readFileSync(`src/manifests/${file}`, 'utf8'))
|
|
17
|
+
|
|
18
|
+
const searchable = [
|
|
19
|
+
manifest.component ?? '',
|
|
20
|
+
manifest.description ?? '',
|
|
21
|
+
manifest.element ?? '',
|
|
22
|
+
manifest.emmet ?? '',
|
|
23
|
+
|
|
24
|
+
...(manifest.states ?? []),
|
|
25
|
+
...(manifest.do ?? []),
|
|
26
|
+
...(manifest.dont ?? []),
|
|
27
|
+
...(manifest.examples ?? []).flatMap((x: Example) => [
|
|
28
|
+
x.label ?? '',
|
|
29
|
+
x.emmet ?? '',
|
|
30
|
+
x.html ?? ''
|
|
31
|
+
])
|
|
32
|
+
].join(' ')
|
|
33
|
+
|
|
34
|
+
return {
|
|
35
|
+
name: manifest.component,
|
|
36
|
+
emmet: manifest.emmet,
|
|
37
|
+
searchable,
|
|
38
|
+
embedding: embedText(searchable)
|
|
39
|
+
}
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
writeFileSync('dist/vector-index.json', JSON.stringify(index, null, 2))
|
|
43
|
+
console.log('Vector index built')
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { parse } from 'node-html-parser'
|
|
2
|
+
import type { A11yIssue, RuleResult } from '../utils/a11y-types.js'
|
|
3
|
+
import {
|
|
4
|
+
checkImages,
|
|
5
|
+
checkFormLabels,
|
|
6
|
+
checkEmptyLabels,
|
|
7
|
+
checkButtons,
|
|
8
|
+
checkLinks,
|
|
9
|
+
checkAriaStates,
|
|
10
|
+
checkDuplicateIds,
|
|
11
|
+
checkTabIndex,
|
|
12
|
+
checkHeadings,
|
|
13
|
+
checkTables,
|
|
14
|
+
checkDialogs,
|
|
15
|
+
checkRoles,
|
|
16
|
+
checkAutocomplete,
|
|
17
|
+
checkFocusStyle,
|
|
18
|
+
checkLang
|
|
19
|
+
} from '../utils/a11y-rules.js'
|
|
20
|
+
|
|
21
|
+
const RULE_CONFIDENCES: Record<string, { error: number; warning: number }> = {
|
|
22
|
+
'WCAG 1.1.1 Non-text Content': { error: 0.99, warning: 0.80 },
|
|
23
|
+
'WCAG 1.3.1 Form Labels': { error: 0.98, warning: 0.78 },
|
|
24
|
+
'WCAG 2.4.6 Empty Labels': { error: 0.95, warning: 0.75 },
|
|
25
|
+
'WCAG 4.1.2 Button Names': { error: 0.99, warning: 0.80 },
|
|
26
|
+
'WCAG 2.4.4 Link Purpose': { error: 0.97, warning: 0.75 },
|
|
27
|
+
'WCAG 3.3.1 Error Identification': { error: 0.98, warning: 0.75 },
|
|
28
|
+
'WCAG 4.1.2 ARIA State Values': { error: 0.96, warning: 0.75 },
|
|
29
|
+
'WCAG 4.1.1 Parsing': { error: 0.99, warning: 0.80 },
|
|
30
|
+
'WCAG 2.1.1 Keyboard': { error: 0.95, warning: 0.75 },
|
|
31
|
+
'WCAG 2.4.6 Heading Hierarchy': { error: 0.95, warning: 0.75 },
|
|
32
|
+
'WCAG 1.3.1 Table Structure': { error: 0.97, warning: 0.75 },
|
|
33
|
+
'WCAG 4.1.2 Dialog Accessibility': { error: 0.98, warning: 0.75 },
|
|
34
|
+
'WCAG 4.1.2 ARIA Role Requirements': { error: 0.98, warning: 0.75 },
|
|
35
|
+
'WCAG 1.3.5 Input Purpose': { error: 0.95, warning: 0.75 },
|
|
36
|
+
'WCAG 2.4.7 Focus Visible': { error: 0.95, warning: 0.75 },
|
|
37
|
+
'WCAG 3.1.1 Language of Page': { error: 0.99, warning: 0.80 }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function getConfidenceForRule(
|
|
41
|
+
ruleName: string,
|
|
42
|
+
type: 'error' | 'warning'
|
|
43
|
+
): number {
|
|
44
|
+
const conf = RULE_CONFIDENCES[ruleName]
|
|
45
|
+
if (conf) {
|
|
46
|
+
return type === 'error' ? conf.error : conf.warning
|
|
47
|
+
}
|
|
48
|
+
return type === 'error' ? 0.98 : 0.75
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function computeScore(issues: A11yIssue[]): number {
|
|
52
|
+
const errors = issues.filter((i) => i.type === 'error').length
|
|
53
|
+
const warnings = issues.filter((i) => i.type === 'warning').length
|
|
54
|
+
return Math.max(0, 100 - errors * 10 - warnings * 3)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function getPassingRules(results: RuleResult[]): string[] {
|
|
58
|
+
return results.filter((r) => r.issues.length === 0).map((r) => r.ruleName)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function auditA11y(html: string): { score: number; passes: string[]; issues: A11yIssue[]; wcag: string } {
|
|
62
|
+
const root = parse(html)
|
|
63
|
+
|
|
64
|
+
const results: RuleResult[] = [
|
|
65
|
+
checkImages(root),
|
|
66
|
+
checkFormLabels(root),
|
|
67
|
+
checkEmptyLabels(root),
|
|
68
|
+
checkButtons(root),
|
|
69
|
+
checkLinks(root),
|
|
70
|
+
checkAriaStates(root),
|
|
71
|
+
checkDuplicateIds(root),
|
|
72
|
+
checkTabIndex(root),
|
|
73
|
+
checkHeadings(root),
|
|
74
|
+
checkTables(root),
|
|
75
|
+
checkDialogs(root),
|
|
76
|
+
checkRoles(root),
|
|
77
|
+
checkAutocomplete(root),
|
|
78
|
+
checkFocusStyle(root),
|
|
79
|
+
checkLang(root)
|
|
80
|
+
]
|
|
81
|
+
|
|
82
|
+
const rawIssues = results.flatMap((r) => r.issues)
|
|
83
|
+
const issues: A11yIssue[] = rawIssues.map((i) => ({
|
|
84
|
+
...i,
|
|
85
|
+
confidence: getConfidenceForRule(i.rule, i.type)
|
|
86
|
+
}))
|
|
87
|
+
const passes = getPassingRules(results)
|
|
88
|
+
const score = computeScore(issues)
|
|
89
|
+
|
|
90
|
+
return {
|
|
91
|
+
score,
|
|
92
|
+
passes,
|
|
93
|
+
issues,
|
|
94
|
+
wcag: 'AA'
|
|
95
|
+
}
|
|
96
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
const VOCAB_SIZE = 512
|
|
2
|
+
|
|
3
|
+
export function embedText(text: string): number[] {
|
|
4
|
+
const vector = new Array(VOCAB_SIZE).fill(0)
|
|
5
|
+
const words = text
|
|
6
|
+
.toLowerCase()
|
|
7
|
+
.replace(/[^a-z0-9\s]/g, ' ')
|
|
8
|
+
.split(/\s+/)
|
|
9
|
+
.filter(Boolean)
|
|
10
|
+
words.forEach((word) => {
|
|
11
|
+
let hash = 0
|
|
12
|
+
for (let i = 0; i < word.length; i++) {
|
|
13
|
+
hash = (hash * 31 + word.charCodeAt(i)) % VOCAB_SIZE
|
|
14
|
+
}
|
|
15
|
+
vector[hash] += 1
|
|
16
|
+
})
|
|
17
|
+
return vector
|
|
18
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
import type { MCPResponse } from '../types.js'
|
|
3
|
+
import { resolveTokens, buildCss } from './theme-tokens.js'
|
|
4
|
+
|
|
5
|
+
const schema = z.object({ prompt: z.string().min(1) })
|
|
6
|
+
|
|
7
|
+
export function generateTheme(args: unknown): MCPResponse {
|
|
8
|
+
const parsed = schema.safeParse(args)
|
|
9
|
+
|
|
10
|
+
if (!parsed.success) {
|
|
11
|
+
return {
|
|
12
|
+
content: [
|
|
13
|
+
{
|
|
14
|
+
type: 'text',
|
|
15
|
+
text: JSON.stringify({
|
|
16
|
+
error: 'Invalid input',
|
|
17
|
+
suggestion: 'Expected { prompt: string }',
|
|
18
|
+
tokens_used: 2
|
|
19
|
+
})
|
|
20
|
+
}
|
|
21
|
+
]
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const { prompt } = parsed.data
|
|
26
|
+
const tokens = resolveTokens(prompt.toLowerCase().trim())
|
|
27
|
+
|
|
28
|
+
return {
|
|
29
|
+
content: [
|
|
30
|
+
{
|
|
31
|
+
type: 'text',
|
|
32
|
+
text: JSON.stringify({
|
|
33
|
+
prompt,
|
|
34
|
+
primary: tokens.resolvedPrimary,
|
|
35
|
+
isDark: tokens.isDark,
|
|
36
|
+
css: buildCss(tokens),
|
|
37
|
+
tokens_used: 10
|
|
38
|
+
})
|
|
39
|
+
}
|
|
40
|
+
]
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
import { manifests } from '../manifests/index.js'
|
|
3
|
+
import type { MCPResponse } from '../types.js'
|
|
4
|
+
|
|
5
|
+
const schema = z.object({
|
|
6
|
+
name: z.string()
|
|
7
|
+
})
|
|
8
|
+
|
|
9
|
+
export function getEmmet(args: unknown): MCPResponse {
|
|
10
|
+
const parsed = schema.safeParse(args)
|
|
11
|
+
|
|
12
|
+
if (!parsed.success) {
|
|
13
|
+
return {
|
|
14
|
+
content: [
|
|
15
|
+
{
|
|
16
|
+
type: 'text',
|
|
17
|
+
|
|
18
|
+
text: JSON.stringify({
|
|
19
|
+
error: 'Invalid input',
|
|
20
|
+
|
|
21
|
+
suggestion: 'Expected { name: string }',
|
|
22
|
+
|
|
23
|
+
tokens_used: 2
|
|
24
|
+
})
|
|
25
|
+
}
|
|
26
|
+
]
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const { name } = parsed.data
|
|
31
|
+
|
|
32
|
+
const manifest = manifests[name]
|
|
33
|
+
|
|
34
|
+
if (!manifest) {
|
|
35
|
+
return {
|
|
36
|
+
content: [
|
|
37
|
+
{
|
|
38
|
+
type: 'text',
|
|
39
|
+
|
|
40
|
+
text: JSON.stringify({
|
|
41
|
+
error: `Unknown component: ${name}`,
|
|
42
|
+
suggestion: 'Call list_components() first',
|
|
43
|
+
tokens_used: 2
|
|
44
|
+
})
|
|
45
|
+
}
|
|
46
|
+
]
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return {
|
|
51
|
+
content: [
|
|
52
|
+
{
|
|
53
|
+
type: 'text',
|
|
54
|
+
|
|
55
|
+
text: JSON.stringify({
|
|
56
|
+
component: manifest.component,
|
|
57
|
+
emmet: manifest.emmet,
|
|
58
|
+
tokens: manifest.tokens,
|
|
59
|
+
tokens_used: 3
|
|
60
|
+
})
|
|
61
|
+
}
|
|
62
|
+
]
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
import { manifests } from '../manifests/index.js'
|
|
3
|
+
import type { MCPResponse } from '../types.js'
|
|
4
|
+
|
|
5
|
+
const schema = z.object({
|
|
6
|
+
name: z.string()
|
|
7
|
+
})
|
|
8
|
+
|
|
9
|
+
export function getManifest(args: unknown): MCPResponse {
|
|
10
|
+
const parsed = schema.safeParse(args)
|
|
11
|
+
|
|
12
|
+
if (!parsed.success) {
|
|
13
|
+
return {
|
|
14
|
+
content: [
|
|
15
|
+
{
|
|
16
|
+
type: 'text',
|
|
17
|
+
text: JSON.stringify({
|
|
18
|
+
error: 'Invalid input',
|
|
19
|
+
suggestion: 'Expected { name: string }',
|
|
20
|
+
tokens_used: 2
|
|
21
|
+
})
|
|
22
|
+
}
|
|
23
|
+
]
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const { name } = parsed.data
|
|
28
|
+
|
|
29
|
+
const manifest = manifests[name]
|
|
30
|
+
|
|
31
|
+
if (!manifest) {
|
|
32
|
+
return {
|
|
33
|
+
content: [
|
|
34
|
+
{
|
|
35
|
+
type: 'text',
|
|
36
|
+
|
|
37
|
+
text: JSON.stringify({
|
|
38
|
+
error: `Unknown component: ${name}`,
|
|
39
|
+
suggestion: 'Call list_components() first',
|
|
40
|
+
tokens_used: 2
|
|
41
|
+
})
|
|
42
|
+
}
|
|
43
|
+
]
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return {
|
|
48
|
+
content: [
|
|
49
|
+
{
|
|
50
|
+
type: 'text',
|
|
51
|
+
text: JSON.stringify(manifest)
|
|
52
|
+
}
|
|
53
|
+
]
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
import { parse, type HTMLElement } from 'node-html-parser'
|
|
2
|
+
import { expandEmmet } from '../utils/emmet-helpers.js'
|
|
3
|
+
import { getTokenCount } from '../utils/tokenizer.js'
|
|
4
|
+
import type { MCPResponse } from '../types.js'
|
|
5
|
+
|
|
6
|
+
export interface HandoffComponent {
|
|
7
|
+
selector: string
|
|
8
|
+
emmet: string
|
|
9
|
+
state: Record<string, string>
|
|
10
|
+
tokens: number
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface HandoffEnvelope {
|
|
14
|
+
schema: 'ignix-lite-handoff'
|
|
15
|
+
version: '1.0'
|
|
16
|
+
id: string
|
|
17
|
+
timestamp: number
|
|
18
|
+
html: string
|
|
19
|
+
components: HandoffComponent[]
|
|
20
|
+
total_tokens: number
|
|
21
|
+
metadata?: Record<string, unknown>
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const MAX_HANDOFFS = 100
|
|
25
|
+
const handoffs = new Map<string, HandoffEnvelope>()
|
|
26
|
+
|
|
27
|
+
function saveHandoff(id: string, envelope: HandoffEnvelope): void {
|
|
28
|
+
if (handoffs.size >= MAX_HANDOFFS) {
|
|
29
|
+
const oldestKey = handoffs.keys().next().value
|
|
30
|
+
if (oldestKey !== undefined) {
|
|
31
|
+
handoffs.delete(oldestKey)
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
handoffs.set(id, envelope)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function generateHandoffId(): string {
|
|
38
|
+
return 'hndff_' + Math.random().toString(36).substring(2, 15)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function extractState(element: HTMLElement): Record<string, string> {
|
|
42
|
+
const state: Record<string, string> = {}
|
|
43
|
+
const attrs = [
|
|
44
|
+
'data-intent',
|
|
45
|
+
'disabled',
|
|
46
|
+
'checked',
|
|
47
|
+
'open',
|
|
48
|
+
'aria-busy',
|
|
49
|
+
'aria-invalid',
|
|
50
|
+
'aria-selected',
|
|
51
|
+
'aria-expanded'
|
|
52
|
+
]
|
|
53
|
+
for (const attr of attrs) {
|
|
54
|
+
const val = element.getAttribute(attr)
|
|
55
|
+
if (val !== undefined && val !== null) {
|
|
56
|
+
state[attr] = val === '' ? 'true' : val
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return state
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function getElementSelector(el: HTMLElement, root: HTMLElement): string {
|
|
63
|
+
const elementId = el.getAttribute('id')
|
|
64
|
+
if (elementId) {
|
|
65
|
+
return `#${elementId}`
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const tag = el.tagName.toLowerCase()
|
|
69
|
+
const parent = el.parentNode as HTMLElement | null
|
|
70
|
+
|
|
71
|
+
if (!parent || parent === root || !parent.tagName) {
|
|
72
|
+
const siblings = (root.childNodes || []).filter(
|
|
73
|
+
(node): node is HTMLElement => node.nodeType === 1
|
|
74
|
+
)
|
|
75
|
+
const sameTagSiblings = siblings.filter(
|
|
76
|
+
(node) => node.tagName && node.tagName.toLowerCase() === tag
|
|
77
|
+
)
|
|
78
|
+
const index = Math.max(1, sameTagSiblings.indexOf(el) + 1)
|
|
79
|
+
return `${tag}:nth-of-type(${index})`
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const siblings = (parent.childNodes || []).filter(
|
|
83
|
+
(node): node is HTMLElement => node.nodeType === 1
|
|
84
|
+
)
|
|
85
|
+
const sameTagSiblings = siblings.filter(
|
|
86
|
+
(node) => node.tagName && node.tagName.toLowerCase() === tag
|
|
87
|
+
)
|
|
88
|
+
const index = Math.max(1, sameTagSiblings.indexOf(el) + 1)
|
|
89
|
+
|
|
90
|
+
const parentSelector = getElementSelector(parent, root)
|
|
91
|
+
return `${parentSelector} > ${tag}:nth-of-type(${index})`
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function createHandoff(args: {
|
|
95
|
+
rendered_html: string
|
|
96
|
+
metadata?: Record<string, unknown>
|
|
97
|
+
}): MCPResponse {
|
|
98
|
+
const { rendered_html, metadata } = args
|
|
99
|
+
const root = parse(rendered_html)
|
|
100
|
+
|
|
101
|
+
const id = generateHandoffId()
|
|
102
|
+
const timestamp = Date.now()
|
|
103
|
+
const components: HandoffComponent[] = []
|
|
104
|
+
|
|
105
|
+
const componentTags = [
|
|
106
|
+
'button',
|
|
107
|
+
'input',
|
|
108
|
+
'textarea',
|
|
109
|
+
'select',
|
|
110
|
+
'aside',
|
|
111
|
+
'mark',
|
|
112
|
+
'article',
|
|
113
|
+
'dialog',
|
|
114
|
+
'details',
|
|
115
|
+
'progress',
|
|
116
|
+
'meter',
|
|
117
|
+
'nav',
|
|
118
|
+
'hr',
|
|
119
|
+
'pre',
|
|
120
|
+
'table',
|
|
121
|
+
'ix-tabs',
|
|
122
|
+
'ix-dropdown',
|
|
123
|
+
'ix-combobox',
|
|
124
|
+
'ix-tooltip',
|
|
125
|
+
'ix-toast'
|
|
126
|
+
]
|
|
127
|
+
|
|
128
|
+
const elements = root.querySelectorAll('*')
|
|
129
|
+
for (const el of elements) {
|
|
130
|
+
const tag = el.tagName.toLowerCase()
|
|
131
|
+
if (componentTags.includes(tag) || el.getAttribute('data-intent')) {
|
|
132
|
+
const selector = getElementSelector(el, root)
|
|
133
|
+
|
|
134
|
+
const outerHtml = el.outerHTML
|
|
135
|
+
const tokens = getTokenCount(outerHtml)
|
|
136
|
+
const state = extractState(el)
|
|
137
|
+
|
|
138
|
+
components.push({
|
|
139
|
+
selector,
|
|
140
|
+
emmet: outerHtml,
|
|
141
|
+
state,
|
|
142
|
+
tokens
|
|
143
|
+
})
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const envelope: HandoffEnvelope = {
|
|
148
|
+
schema: 'ignix-lite-handoff',
|
|
149
|
+
version: '1.0',
|
|
150
|
+
id,
|
|
151
|
+
timestamp,
|
|
152
|
+
html: rendered_html,
|
|
153
|
+
components,
|
|
154
|
+
total_tokens: getTokenCount(rendered_html),
|
|
155
|
+
metadata
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
saveHandoff(id, envelope)
|
|
159
|
+
|
|
160
|
+
return {
|
|
161
|
+
content: [
|
|
162
|
+
{
|
|
163
|
+
type: 'text',
|
|
164
|
+
text: JSON.stringify({
|
|
165
|
+
handoff_id: id,
|
|
166
|
+
snapshot: {
|
|
167
|
+
schema: envelope.schema,
|
|
168
|
+
version: envelope.version,
|
|
169
|
+
id: envelope.id,
|
|
170
|
+
timestamp: envelope.timestamp,
|
|
171
|
+
components: envelope.components.map((c) => ({
|
|
172
|
+
selector: c.selector,
|
|
173
|
+
emmet: c.emmet,
|
|
174
|
+
state: c.state,
|
|
175
|
+
tokens: c.tokens
|
|
176
|
+
})),
|
|
177
|
+
total_tokens: envelope.total_tokens,
|
|
178
|
+
metadata: envelope.metadata
|
|
179
|
+
},
|
|
180
|
+
tokens_used: 10
|
|
181
|
+
})
|
|
182
|
+
}
|
|
183
|
+
]
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export interface HandoffChange {
|
|
188
|
+
selector: string
|
|
189
|
+
action: 'update' | 'add' | 'remove'
|
|
190
|
+
emmet?: string
|
|
191
|
+
html?: string
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export function applyHandoff(args: {
|
|
195
|
+
handoff_id: string
|
|
196
|
+
changes: HandoffChange[]
|
|
197
|
+
}): MCPResponse {
|
|
198
|
+
const { handoff_id, changes } = args
|
|
199
|
+
const envelope = handoffs.get(handoff_id)
|
|
200
|
+
|
|
201
|
+
if (!envelope) {
|
|
202
|
+
return {
|
|
203
|
+
content: [
|
|
204
|
+
{
|
|
205
|
+
type: 'text',
|
|
206
|
+
text: JSON.stringify({
|
|
207
|
+
error: `Handoff snapshot not found: ${handoff_id}`,
|
|
208
|
+
tokens_used: 5
|
|
209
|
+
})
|
|
210
|
+
}
|
|
211
|
+
]
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Assert that change.html or change.emmet is present for add and update actions
|
|
216
|
+
for (const change of changes) {
|
|
217
|
+
if (change.action === 'update' || change.action === 'add') {
|
|
218
|
+
const hasHtml = change.html !== undefined && change.html !== null && change.html.trim() !== ''
|
|
219
|
+
const hasEmmet = change.emmet !== undefined && change.emmet !== null && change.emmet.trim() !== ''
|
|
220
|
+
if (!hasHtml && !hasEmmet) {
|
|
221
|
+
return {
|
|
222
|
+
content: [
|
|
223
|
+
{
|
|
224
|
+
type: 'text',
|
|
225
|
+
text: JSON.stringify({
|
|
226
|
+
error: `Validation error: change for selector "${change.selector}" with action "${change.action}" must provide a non-empty "html" or "emmet" payload.`,
|
|
227
|
+
tokens_used: 5
|
|
228
|
+
})
|
|
229
|
+
}
|
|
230
|
+
]
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const root = parse(envelope.html)
|
|
237
|
+
let diffTokens = 0
|
|
238
|
+
const failedSelectors: string[] = []
|
|
239
|
+
const errors: string[] = []
|
|
240
|
+
|
|
241
|
+
const sortedChanges = [...changes].sort((a, b) => {
|
|
242
|
+
if (a.action === 'remove' && b.action !== 'remove') return 1
|
|
243
|
+
if (a.action !== 'remove' && b.action === 'remove') return -1
|
|
244
|
+
return 0
|
|
245
|
+
})
|
|
246
|
+
|
|
247
|
+
for (const change of sortedChanges) {
|
|
248
|
+
try {
|
|
249
|
+
const target = root.querySelector(change.selector)
|
|
250
|
+
if (!target) {
|
|
251
|
+
failedSelectors.push(change.selector)
|
|
252
|
+
continue
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const changeContent =
|
|
256
|
+
change.html || (change.emmet ? expandEmmet(change.emmet) : '')
|
|
257
|
+
diffTokens += getTokenCount(changeContent || change.selector)
|
|
258
|
+
|
|
259
|
+
if (change.action === 'update') {
|
|
260
|
+
const newNode = parse(changeContent)
|
|
261
|
+
target.replaceWith(newNode)
|
|
262
|
+
} else if (change.action === 'add') {
|
|
263
|
+
const newNode = parse(changeContent)
|
|
264
|
+
target.appendChild(newNode)
|
|
265
|
+
} else if (change.action === 'remove') {
|
|
266
|
+
target.remove()
|
|
267
|
+
}
|
|
268
|
+
} catch (err: unknown) {
|
|
269
|
+
const message = err instanceof Error ? err.message : String(err)
|
|
270
|
+
errors.push(
|
|
271
|
+
`Failed to apply change on selector "${change.selector}": ${message}`
|
|
272
|
+
)
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const updatedHtml = root.toString()
|
|
277
|
+
const fullTokens = getTokenCount(updatedHtml)
|
|
278
|
+
const savingsPct =
|
|
279
|
+
fullTokens > 0 ? Math.round((1 - diffTokens / fullTokens) * 100) : 0
|
|
280
|
+
|
|
281
|
+
envelope.html = updatedHtml
|
|
282
|
+
envelope.total_tokens = fullTokens
|
|
283
|
+
saveHandoff(handoff_id, envelope)
|
|
284
|
+
|
|
285
|
+
return {
|
|
286
|
+
content: [
|
|
287
|
+
{
|
|
288
|
+
type: 'text',
|
|
289
|
+
text: JSON.stringify({
|
|
290
|
+
updated_html: updatedHtml,
|
|
291
|
+
diff_tokens: diffTokens,
|
|
292
|
+
full_tokens: fullTokens,
|
|
293
|
+
savings_pct: Math.max(0, savingsPct),
|
|
294
|
+
failed_selectors:
|
|
295
|
+
failedSelectors.length > 0 ? failedSelectors : undefined,
|
|
296
|
+
errors: errors.length > 0 ? errors : undefined,
|
|
297
|
+
tokens_used: 15
|
|
298
|
+
})
|
|
299
|
+
}
|
|
300
|
+
]
|
|
301
|
+
}
|
|
302
|
+
}
|