@wix/zero-config-implementation 1.5.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/README.md +72 -0
- package/dist/component-loader.d.ts +42 -0
- package/dist/component-renderer.d.ts +31 -0
- package/dist/converters/data-item-builder.d.ts +15 -0
- package/dist/converters/index.d.ts +1 -0
- package/dist/converters/to-editor-component.d.ts +3 -0
- package/dist/converters/utils.d.ts +16 -0
- package/dist/errors.d.ts +230 -0
- package/dist/index.d.ts +42 -0
- package/dist/index.js +51978 -0
- package/dist/information-extractors/css/index.d.ts +3 -0
- package/dist/information-extractors/css/parse.d.ts +7 -0
- package/dist/information-extractors/css/selector-matcher.d.ts +3 -0
- package/dist/information-extractors/css/types.d.ts +49 -0
- package/dist/information-extractors/react/extractors/core/index.d.ts +6 -0
- package/dist/information-extractors/react/extractors/core/runner.d.ts +19 -0
- package/dist/information-extractors/react/extractors/core/store.d.ts +17 -0
- package/dist/information-extractors/react/extractors/core/tree-builder.d.ts +15 -0
- package/dist/information-extractors/react/extractors/core/types.d.ts +40 -0
- package/dist/information-extractors/react/extractors/css-properties.d.ts +20 -0
- package/dist/information-extractors/react/extractors/index.d.ts +11 -0
- package/dist/information-extractors/react/extractors/prop-tracker.d.ts +24 -0
- package/dist/information-extractors/react/index.d.ts +9 -0
- package/dist/information-extractors/react/types.d.ts +51 -0
- package/dist/information-extractors/react/utils/mock-generator.d.ts +9 -0
- package/dist/information-extractors/react/utils/prop-spy.d.ts +10 -0
- package/dist/information-extractors/ts/components.d.ts +9 -0
- package/dist/information-extractors/ts/css-imports.d.ts +2 -0
- package/dist/information-extractors/ts/index.d.ts +3 -0
- package/dist/information-extractors/ts/types.d.ts +47 -0
- package/dist/information-extractors/ts/utils/semantic-type-resolver.d.ts +3 -0
- package/dist/jsx-runtime-interceptor.d.ts +42 -0
- package/dist/jsx-runtime-interceptor.js +63 -0
- package/dist/jsx-runtime-loader.d.ts +23 -0
- package/dist/jsx-runtime-loader.js +7 -0
- package/dist/manifest-pipeline.d.ts +33 -0
- package/dist/schema.d.ts +167 -0
- package/dist/ts-compiler.d.ts +13 -0
- package/package.json +81 -0
- package/src/component-loader.test.ts +277 -0
- package/src/component-loader.ts +256 -0
- package/src/component-renderer.ts +192 -0
- package/src/converters/data-item-builder.ts +354 -0
- package/src/converters/index.ts +1 -0
- package/src/converters/to-editor-component.ts +167 -0
- package/src/converters/utils.ts +21 -0
- package/src/errors.ts +103 -0
- package/src/index.ts +223 -0
- package/src/information-extractors/css/README.md +3 -0
- package/src/information-extractors/css/index.ts +3 -0
- package/src/information-extractors/css/parse.ts +450 -0
- package/src/information-extractors/css/selector-matcher.ts +88 -0
- package/src/information-extractors/css/types.ts +56 -0
- package/src/information-extractors/react/extractors/core/index.ts +6 -0
- package/src/information-extractors/react/extractors/core/runner.ts +89 -0
- package/src/information-extractors/react/extractors/core/store.ts +36 -0
- package/src/information-extractors/react/extractors/core/tree-builder.ts +273 -0
- package/src/information-extractors/react/extractors/core/types.ts +48 -0
- package/src/information-extractors/react/extractors/css-properties.ts +214 -0
- package/src/information-extractors/react/extractors/index.ts +27 -0
- package/src/information-extractors/react/extractors/prop-tracker.ts +132 -0
- package/src/information-extractors/react/index.ts +53 -0
- package/src/information-extractors/react/types.ts +70 -0
- package/src/information-extractors/react/utils/mock-generator.ts +331 -0
- package/src/information-extractors/react/utils/prop-spy.ts +168 -0
- package/src/information-extractors/ts/components.ts +300 -0
- package/src/information-extractors/ts/css-imports.ts +26 -0
- package/src/information-extractors/ts/index.ts +3 -0
- package/src/information-extractors/ts/types.ts +56 -0
- package/src/information-extractors/ts/utils/semantic-type-resolver.ts +377 -0
- package/src/jsx-runtime-interceptor.ts +146 -0
- package/src/jsx-runtime-loader.ts +38 -0
- package/src/manifest-pipeline.ts +362 -0
- package/src/schema.ts +174 -0
- package/src/ts-compiler.ts +41 -0
- package/tsconfig.json +17 -0
- package/typedoc.json +18 -0
- package/vite.config.ts +45 -0
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import * as cheerio from 'cheerio'
|
|
2
|
+
import { TRACE_ATTR } from '../../component-renderer'
|
|
3
|
+
import type { ExtractedCssInfo } from '../../index'
|
|
4
|
+
import type { ExtractedElement } from '../react/extractors/core/tree-builder'
|
|
5
|
+
import type { CSSProperty, CssSelectorMatch, MatchedCssData } from './types'
|
|
6
|
+
|
|
7
|
+
export function matchCssSelectors(
|
|
8
|
+
html: string,
|
|
9
|
+
elements: ExtractedElement[],
|
|
10
|
+
cssInfos: ExtractedCssInfo[],
|
|
11
|
+
): ExtractedElement[] {
|
|
12
|
+
const $ = cheerio.load(html)
|
|
13
|
+
|
|
14
|
+
// Build traceId -> matches map
|
|
15
|
+
const matchesByTraceId = new Map<string, CssSelectorMatch[]>()
|
|
16
|
+
const customPropsByTraceId = new Map<string, Record<string, string>>()
|
|
17
|
+
|
|
18
|
+
for (const cssInfo of cssInfos) {
|
|
19
|
+
const allProps = cssInfo.api.getAllProperties()
|
|
20
|
+
|
|
21
|
+
for (const [selector, properties] of allProps) {
|
|
22
|
+
// Skip @keyframes - not element selectors
|
|
23
|
+
if (selector.startsWith('@')) continue
|
|
24
|
+
|
|
25
|
+
// Get DOM-matchable selector from the API
|
|
26
|
+
const domSelector = cssInfo.api.getDomSelector(selector)
|
|
27
|
+
if (!domSelector) continue
|
|
28
|
+
|
|
29
|
+
try {
|
|
30
|
+
$(domSelector).each((_, elem) => {
|
|
31
|
+
const traceId = $(elem).attr(TRACE_ATTR)
|
|
32
|
+
if (!traceId) return
|
|
33
|
+
|
|
34
|
+
// Use reduce to separate regular and custom properties in one pass
|
|
35
|
+
const { regular, custom } = properties.reduce<{
|
|
36
|
+
regular: CSSProperty[]
|
|
37
|
+
custom: Record<string, string>
|
|
38
|
+
}>(
|
|
39
|
+
(acc, prop) => {
|
|
40
|
+
if (prop.name.startsWith('--')) {
|
|
41
|
+
acc.custom[prop.name] = prop.value
|
|
42
|
+
} else {
|
|
43
|
+
acc.regular.push(prop)
|
|
44
|
+
}
|
|
45
|
+
return acc
|
|
46
|
+
},
|
|
47
|
+
{ regular: [], custom: {} },
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
if (regular.length > 0) {
|
|
51
|
+
const existing = matchesByTraceId.get(traceId) ?? []
|
|
52
|
+
existing.push({ selector, properties: regular })
|
|
53
|
+
matchesByTraceId.set(traceId, existing)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (Object.keys(custom).length > 0) {
|
|
57
|
+
const existing = customPropsByTraceId.get(traceId) ?? {}
|
|
58
|
+
Object.assign(existing, custom)
|
|
59
|
+
customPropsByTraceId.set(traceId, existing)
|
|
60
|
+
}
|
|
61
|
+
})
|
|
62
|
+
} catch {
|
|
63
|
+
// Invalid selector for Cheerio, skip
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return enrichElements(elements, matchesByTraceId, customPropsByTraceId)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function enrichElements(
|
|
72
|
+
elements: ExtractedElement[],
|
|
73
|
+
matchesByTraceId: Map<string, CssSelectorMatch[]>,
|
|
74
|
+
customPropsByTraceId: Map<string, Record<string, string>>,
|
|
75
|
+
): ExtractedElement[] {
|
|
76
|
+
return elements.map((el) => {
|
|
77
|
+
const data: MatchedCssData = {
|
|
78
|
+
matches: matchesByTraceId.get(el.traceId) ?? [],
|
|
79
|
+
customProperties: customPropsByTraceId.get(el.traceId) ?? {},
|
|
80
|
+
}
|
|
81
|
+
el.extractorData.set('css-matcher', data)
|
|
82
|
+
|
|
83
|
+
return {
|
|
84
|
+
...el,
|
|
85
|
+
children: enrichElements(el.children, matchesByTraceId, customPropsByTraceId),
|
|
86
|
+
}
|
|
87
|
+
})
|
|
88
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
export interface CSSProperty {
|
|
2
|
+
name: string
|
|
3
|
+
value: string
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export interface CssSelectorMatch {
|
|
7
|
+
selector: string
|
|
8
|
+
properties: CSSProperty[]
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface MatchedCssData {
|
|
12
|
+
matches: CssSelectorMatch[]
|
|
13
|
+
customProperties: Record<string, string>
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* API returned by parseCss function for querying parsed CSS
|
|
18
|
+
*/
|
|
19
|
+
export interface CSSParserAPI {
|
|
20
|
+
/**
|
|
21
|
+
* Gets CSS properties for a specific selector
|
|
22
|
+
* @param selector - The selector to extract properties for
|
|
23
|
+
* @returns Array of CSS properties for the specified selector
|
|
24
|
+
*/
|
|
25
|
+
getPropertiesForSelector: (selector: string) => CSSProperty[]
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Gets all selectors and their properties from the CSS
|
|
29
|
+
* @returns Map from selector to its CSS properties
|
|
30
|
+
*/
|
|
31
|
+
getAllProperties: () => Map<string, CSSProperty[]>
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Gets all CSS property names that use a specific CSS variable
|
|
35
|
+
* @param varName - The CSS variable name (with or without --)
|
|
36
|
+
* @returns Array of CSS property names that use this variable
|
|
37
|
+
*/
|
|
38
|
+
getVarUsages: (varName: string) => string[]
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Gets unique properties from multiple selectors with CSS cascade rules:
|
|
42
|
+
* - First selector wins (properties from earlier selectors take precedence)
|
|
43
|
+
* - Within a selector, last value wins (if property appears multiple times)
|
|
44
|
+
* @param selectors - Array of selectors to extract properties from
|
|
45
|
+
* @returns Map of property names to their values
|
|
46
|
+
*/
|
|
47
|
+
getUniqueProperties: (selectors: string[]) => Map<string, string>
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Gets a DOM-matchable selector by stripping pseudo-classes.
|
|
51
|
+
* Returns null if the selector contains pseudo-elements (unmatchable against real DOM).
|
|
52
|
+
* @param selector - The original CSS selector string
|
|
53
|
+
* @returns DOM-matchable selector string or null
|
|
54
|
+
*/
|
|
55
|
+
getDomSelector: (selector: string) => string | null
|
|
56
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { ExtractorStore } from './store'
|
|
2
|
+
export { runExtractors } from './runner'
|
|
3
|
+
export type { ExtractionResult } from './runner'
|
|
4
|
+
export { buildElementTree } from './tree-builder'
|
|
5
|
+
export type { ExtractedElement } from './tree-builder'
|
|
6
|
+
export type { ReactExtractor, RenderContext, CreateElementEvent, RenderCompleteEvent } from './types'
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generic Extractor Runner
|
|
3
|
+
*
|
|
4
|
+
* Orchestrates the extractor lifecycle:
|
|
5
|
+
* 1. Create shared ExtractorStore
|
|
6
|
+
* 2. Run beforeRender phase (extractors can modify props)
|
|
7
|
+
* 3. Render with onCreateElement hooks
|
|
8
|
+
* 4. Run renderComplete phase
|
|
9
|
+
* 5. Parse HTML and merge with store data
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { ComponentType } from 'react'
|
|
13
|
+
import { type CreateElementListener, renderWithExtractors } from '../../../../component-renderer'
|
|
14
|
+
import type { ComponentInfo } from '../../../ts/types'
|
|
15
|
+
import { generateMockProps, resetMockCounter } from '../../utils/mock-generator'
|
|
16
|
+
import { ExtractorStore } from './store'
|
|
17
|
+
import { type ExtractedElement, buildElementTree } from './tree-builder'
|
|
18
|
+
import type { CreateElementEvent, ReactExtractor, RenderContext } from './types'
|
|
19
|
+
|
|
20
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
21
|
+
// Types
|
|
22
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
export interface ExtractionResult {
|
|
25
|
+
html: string
|
|
26
|
+
store: ExtractorStore
|
|
27
|
+
elements: ExtractedElement[]
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
31
|
+
// Runner
|
|
32
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Runs extractors through the full lifecycle and returns extraction results.
|
|
36
|
+
*
|
|
37
|
+
* @param componentInfo - TypeScript-extracted component information
|
|
38
|
+
* @param component - The React component to render
|
|
39
|
+
* @param extractors - Array of extractors to run
|
|
40
|
+
* @returns Extraction results including HTML, store, and element tree
|
|
41
|
+
*/
|
|
42
|
+
export function runExtractors(
|
|
43
|
+
componentInfo: ComponentInfo,
|
|
44
|
+
component: ComponentType<unknown>,
|
|
45
|
+
extractors: ReactExtractor[],
|
|
46
|
+
): ExtractionResult {
|
|
47
|
+
// Reset mock counter for reproducible results
|
|
48
|
+
resetMockCounter()
|
|
49
|
+
|
|
50
|
+
// Create shared store
|
|
51
|
+
const store = new ExtractorStore()
|
|
52
|
+
|
|
53
|
+
// Create render context with mutable props
|
|
54
|
+
const context: RenderContext = {
|
|
55
|
+
componentInfo,
|
|
56
|
+
component,
|
|
57
|
+
props: generateMockProps(componentInfo),
|
|
58
|
+
store,
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Phase 1: beforeRender - extractors can modify props
|
|
62
|
+
for (const ext of extractors) {
|
|
63
|
+
ext.onBeforeRender?.(context)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Create listeners that call onCreateElement for each extractor
|
|
67
|
+
const listeners: CreateElementListener[] = [
|
|
68
|
+
{
|
|
69
|
+
onCreateElement(event: CreateElementEvent): void {
|
|
70
|
+
for (const ext of extractors) {
|
|
71
|
+
ext.onCreateElement?.(event)
|
|
72
|
+
}
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
]
|
|
76
|
+
|
|
77
|
+
// Phase 2: Render with element creation interception
|
|
78
|
+
const html = renderWithExtractors(context.component, context.props, listeners, store)
|
|
79
|
+
|
|
80
|
+
// Phase 3: renderComplete - extractors can post-process
|
|
81
|
+
for (const ext of extractors) {
|
|
82
|
+
ext.onRenderComplete?.({ html, context })
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Phase 4: Parse HTML and merge with store data
|
|
86
|
+
const elements = buildElementTree(html, store)
|
|
87
|
+
|
|
88
|
+
return { html, store, elements }
|
|
89
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ExtractorStore - Shared store for extractors with namespace support
|
|
3
|
+
*
|
|
4
|
+
* Extractors write to this store during render, namespaced by their name:
|
|
5
|
+
* store.set(traceId, 'prop-tracker', { boundProps: ['label', 'onClick'] })
|
|
6
|
+
* store.set(traceId, 'css-properties', { relevant: ['color', 'background'] })
|
|
7
|
+
*
|
|
8
|
+
* Store structure: Map<traceId, Map<extractorName, data>>
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export class ExtractorStore {
|
|
12
|
+
private data = new Map<string, Map<string, unknown>>()
|
|
13
|
+
|
|
14
|
+
set<T>(traceId: string, extractorName: string, value: T): void {
|
|
15
|
+
if (!this.data.has(traceId)) {
|
|
16
|
+
this.data.set(traceId, new Map())
|
|
17
|
+
}
|
|
18
|
+
this.data.get(traceId)!.set(extractorName, value)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
get<T>(traceId: string, extractorName: string): T | undefined {
|
|
22
|
+
return this.data.get(traceId)?.get(extractorName) as T | undefined
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
getAll(traceId: string): Map<string, unknown> | undefined {
|
|
26
|
+
return this.data.get(traceId)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
entries(): IterableIterator<[string, Map<string, unknown>]> {
|
|
30
|
+
return this.data.entries()
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
clear(): void {
|
|
34
|
+
this.data.clear()
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build Element Tree
|
|
3
|
+
*
|
|
4
|
+
* Parses HTML to get hierarchy and merges with ExtractorStore data.
|
|
5
|
+
* Each element gets a semantic name computed by concatenating ancestor names.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { pascalCase } from 'case-anything'
|
|
9
|
+
import { type DefaultTreeAdapterMap, parseFragment } from 'parse5'
|
|
10
|
+
import { TRACE_ATTR } from '../../../../component-renderer'
|
|
11
|
+
import type { CssPropertiesData } from '../css-properties'
|
|
12
|
+
import { addTextProperties } from '../css-properties'
|
|
13
|
+
import type { ExtractorStore } from './store'
|
|
14
|
+
|
|
15
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
16
|
+
// Types
|
|
17
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
export interface ExtractedElement {
|
|
20
|
+
traceId: string
|
|
21
|
+
name: string // Semantic name for the element (see naming rules)
|
|
22
|
+
tag: string
|
|
23
|
+
attributes: Record<string, string>
|
|
24
|
+
extractorData: Map<string, unknown> // extractorName → data
|
|
25
|
+
children: ExtractedElement[]
|
|
26
|
+
hasTextContent?: boolean
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
30
|
+
// Tag Normalization Map
|
|
31
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
32
|
+
|
|
33
|
+
const TAG_NAMES: Record<string, string> = {
|
|
34
|
+
a: 'Anchor',
|
|
35
|
+
abbr: 'Abbreviation',
|
|
36
|
+
article: 'Article',
|
|
37
|
+
aside: 'Aside',
|
|
38
|
+
button: 'Button',
|
|
39
|
+
caption: 'Caption',
|
|
40
|
+
col: 'TableColumn',
|
|
41
|
+
colgroup: 'TableColumnGroup',
|
|
42
|
+
dd: 'DescriptionDetails',
|
|
43
|
+
details: 'Details',
|
|
44
|
+
div: 'Div',
|
|
45
|
+
dl: 'DescriptionList',
|
|
46
|
+
dt: 'DescriptionTerm',
|
|
47
|
+
fieldset: 'Fieldset',
|
|
48
|
+
figcaption: 'FigureCaption',
|
|
49
|
+
figure: 'Figure',
|
|
50
|
+
footer: 'Footer',
|
|
51
|
+
form: 'Form',
|
|
52
|
+
h1: 'Heading1',
|
|
53
|
+
h2: 'Heading2',
|
|
54
|
+
h3: 'Heading3',
|
|
55
|
+
h4: 'Heading4',
|
|
56
|
+
h5: 'Heading5',
|
|
57
|
+
h6: 'Heading6',
|
|
58
|
+
header: 'Header',
|
|
59
|
+
hr: 'HorizontalRule',
|
|
60
|
+
img: 'Image',
|
|
61
|
+
input: 'Input',
|
|
62
|
+
label: 'Label',
|
|
63
|
+
legend: 'Legend',
|
|
64
|
+
li: 'ListItem',
|
|
65
|
+
main: 'Main',
|
|
66
|
+
nav: 'Navigation',
|
|
67
|
+
ol: 'OrderedList',
|
|
68
|
+
optgroup: 'OptionGroup',
|
|
69
|
+
option: 'Option',
|
|
70
|
+
p: 'Paragraph',
|
|
71
|
+
pre: 'Preformatted',
|
|
72
|
+
progress: 'Progress',
|
|
73
|
+
section: 'Section',
|
|
74
|
+
select: 'Select',
|
|
75
|
+
span: 'Span',
|
|
76
|
+
strong: 'Strong',
|
|
77
|
+
summary: 'Summary',
|
|
78
|
+
table: 'Table',
|
|
79
|
+
tbody: 'TableBody',
|
|
80
|
+
td: 'TableCell',
|
|
81
|
+
textarea: 'TextArea',
|
|
82
|
+
tfoot: 'TableFooter',
|
|
83
|
+
th: 'TableHeader',
|
|
84
|
+
thead: 'TableHead',
|
|
85
|
+
tr: 'TableRow',
|
|
86
|
+
ul: 'UnorderedList',
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
90
|
+
// Helpers
|
|
91
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
92
|
+
|
|
93
|
+
type Element = DefaultTreeAdapterMap['element']
|
|
94
|
+
type TextNode = DefaultTreeAdapterMap['textNode']
|
|
95
|
+
type Node = DefaultTreeAdapterMap['node']
|
|
96
|
+
|
|
97
|
+
const isElement = (node: Node): node is Element => 'tagName' in node
|
|
98
|
+
const isTextNode = (node: Node): node is TextNode => 'nodeName' in node && node.nodeName === '#text'
|
|
99
|
+
|
|
100
|
+
const getAttribute = (element: Element, name: string): string | undefined =>
|
|
101
|
+
element.attrs.find((attr) => attr.name === name)?.value
|
|
102
|
+
|
|
103
|
+
const getAttributes = (element: Element): Record<string, string> => {
|
|
104
|
+
const attrs: Record<string, string> = {}
|
|
105
|
+
for (const attr of element.attrs) {
|
|
106
|
+
attrs[attr.name] = attr.value
|
|
107
|
+
}
|
|
108
|
+
return attrs
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Checks if an element has direct text content (non-whitespace text nodes as children)
|
|
113
|
+
*/
|
|
114
|
+
const hasDirectTextContent = (element: Element): boolean =>
|
|
115
|
+
element.childNodes.some((child) => isTextNode(child) && child.value.trim().length > 0)
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Normalizes a tag name to its semantic PascalCase form
|
|
119
|
+
*/
|
|
120
|
+
function normalizeTagName(tag: string): string {
|
|
121
|
+
return TAG_NAMES[tag.toLowerCase()] ?? pascalCase(tag)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Gets the text content of an element (for aria-labelledby resolution)
|
|
126
|
+
*/
|
|
127
|
+
function getTextContent(element: Element): string {
|
|
128
|
+
let text = ''
|
|
129
|
+
for (const child of element.childNodes) {
|
|
130
|
+
if (isTextNode(child)) {
|
|
131
|
+
text += child.value
|
|
132
|
+
} else if (isElement(child)) {
|
|
133
|
+
text += getTextContent(child)
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return text.trim()
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
140
|
+
// Element Naming
|
|
141
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Computes the name contribution for a single element.
|
|
145
|
+
* Priority: id > aria-label > aria-labelledby > normalized tag name
|
|
146
|
+
*/
|
|
147
|
+
function getElementNamePart(element: Element, getElementById: (id: string) => Element | undefined): string {
|
|
148
|
+
const id = getAttribute(element, 'id')
|
|
149
|
+
if (id) {
|
|
150
|
+
return pascalCase(id)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const ariaLabel = getAttribute(element, 'aria-label')
|
|
154
|
+
if (ariaLabel) {
|
|
155
|
+
return pascalCase(ariaLabel)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const ariaLabelledBy = getAttribute(element, 'aria-labelledby')
|
|
159
|
+
if (ariaLabelledBy) {
|
|
160
|
+
const labelElement = getElementById(ariaLabelledBy)
|
|
161
|
+
if (labelElement) {
|
|
162
|
+
const labelText = getTextContent(labelElement)
|
|
163
|
+
if (labelText) {
|
|
164
|
+
return pascalCase(labelText)
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return normalizeTagName(element.tagName)
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
173
|
+
// Tree Building
|
|
174
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Builds an element tree from HTML, merging with store data.
|
|
178
|
+
* Each element gets a semantic name from concatenated ancestor names.
|
|
179
|
+
*/
|
|
180
|
+
export function buildElementTree(html: string, store: ExtractorStore): ExtractedElement[] {
|
|
181
|
+
const fragment = parseFragment(html)
|
|
182
|
+
|
|
183
|
+
// Build ID lookup for aria-labelledby resolution
|
|
184
|
+
const elementsById = new Map<string, Element>()
|
|
185
|
+
const collectIds = (node: Node): void => {
|
|
186
|
+
if (isElement(node)) {
|
|
187
|
+
const id = getAttribute(node, 'id')
|
|
188
|
+
if (id) {
|
|
189
|
+
elementsById.set(id, node)
|
|
190
|
+
}
|
|
191
|
+
node.childNodes.forEach(collectIds)
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
fragment.childNodes.forEach(collectIds)
|
|
195
|
+
|
|
196
|
+
const getElementById = (id: string): Element | undefined => elementsById.get(id)
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Recursively walks the tree, building ExtractedElements.
|
|
200
|
+
* @param node - The current parse5 node
|
|
201
|
+
* @param ancestorPath - The concatenated name path from ancestors (empty for root's children)
|
|
202
|
+
* @param isRoot - Whether this is the root element (first traced element)
|
|
203
|
+
*/
|
|
204
|
+
const walkTree = (node: Node, ancestorPath: string, isRoot: boolean): ExtractedElement[] => {
|
|
205
|
+
if (!isElement(node)) return []
|
|
206
|
+
|
|
207
|
+
const traceId = getAttribute(node, TRACE_ATTR)
|
|
208
|
+
|
|
209
|
+
// If this element has a traceId, it's a traced element
|
|
210
|
+
if (traceId) {
|
|
211
|
+
// Compute this element's name part
|
|
212
|
+
const namePart = isRoot ? 'root' : getElementNamePart(node, getElementById)
|
|
213
|
+
|
|
214
|
+
// Full name is ancestor path + this element's name (no separator)
|
|
215
|
+
const name = isRoot ? 'root' : ancestorPath + namePart
|
|
216
|
+
|
|
217
|
+
// Get extractor data from store
|
|
218
|
+
const extractorData = store.getAll(traceId) ?? new Map()
|
|
219
|
+
|
|
220
|
+
// Check for text content
|
|
221
|
+
const hasText = hasDirectTextContent(node)
|
|
222
|
+
|
|
223
|
+
// If element has text content and has css-properties data, add text properties
|
|
224
|
+
if (hasText) {
|
|
225
|
+
const cssData = extractorData.get('css-properties') as CssPropertiesData | undefined
|
|
226
|
+
if (cssData) {
|
|
227
|
+
const enhanced: CssPropertiesData = {
|
|
228
|
+
relevant: addTextProperties(cssData.relevant),
|
|
229
|
+
}
|
|
230
|
+
extractorData.set('css-properties', enhanced)
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Recursively process children
|
|
235
|
+
// Children use this element's full name as their ancestor path
|
|
236
|
+
const childPath = isRoot ? '' : name
|
|
237
|
+
const children = node.childNodes.flatMap((child) => walkTree(child, childPath, false))
|
|
238
|
+
|
|
239
|
+
// Build attributes (excluding trace-id)
|
|
240
|
+
const attributes = getAttributes(node)
|
|
241
|
+
delete attributes[TRACE_ATTR]
|
|
242
|
+
|
|
243
|
+
return [
|
|
244
|
+
{
|
|
245
|
+
traceId,
|
|
246
|
+
name,
|
|
247
|
+
tag: node.tagName,
|
|
248
|
+
attributes,
|
|
249
|
+
extractorData,
|
|
250
|
+
children,
|
|
251
|
+
...(hasText && { hasTextContent: true }),
|
|
252
|
+
},
|
|
253
|
+
]
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// No traceId - just pass through to children with same ancestor path
|
|
257
|
+
return node.childNodes.flatMap((child) => walkTree(child, ancestorPath, false))
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Start with empty ancestor path, first traced element is root
|
|
261
|
+
let isFirst = true
|
|
262
|
+
const result: ExtractedElement[] = []
|
|
263
|
+
|
|
264
|
+
for (const node of fragment.childNodes) {
|
|
265
|
+
const elements = walkTree(node, '', isFirst)
|
|
266
|
+
if (elements.length > 0) {
|
|
267
|
+
result.push(...elements)
|
|
268
|
+
isFirst = false // Only the first traced element is root
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
return result
|
|
273
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Core interfaces for the pluggable extractor system
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { ComponentType } from 'react'
|
|
6
|
+
import type { ComponentInfo } from '../../../ts/types'
|
|
7
|
+
import type { ExtractorStore } from './store'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Context available to extractors during the beforeRender phase.
|
|
11
|
+
* Props are mutable - extractors can modify them before render.
|
|
12
|
+
*/
|
|
13
|
+
export interface RenderContext {
|
|
14
|
+
componentInfo: ComponentInfo
|
|
15
|
+
component: ComponentType<unknown>
|
|
16
|
+
props: Record<string, unknown> // Mutable - extractors can modify
|
|
17
|
+
store: ExtractorStore
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Event emitted for each DOM element created during render.
|
|
22
|
+
*/
|
|
23
|
+
export interface CreateElementEvent {
|
|
24
|
+
tag: string
|
|
25
|
+
props: Record<string, unknown>
|
|
26
|
+
traceId: string
|
|
27
|
+
children: unknown[]
|
|
28
|
+
store: ExtractorStore
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Event emitted after render completes.
|
|
33
|
+
*/
|
|
34
|
+
export interface RenderCompleteEvent {
|
|
35
|
+
html: string
|
|
36
|
+
context: RenderContext
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Interface for pluggable extractors.
|
|
41
|
+
* Extractors can hook into any combination of lifecycle events.
|
|
42
|
+
*/
|
|
43
|
+
export interface ReactExtractor {
|
|
44
|
+
name: string
|
|
45
|
+
onBeforeRender?(context: RenderContext): void
|
|
46
|
+
onCreateElement?(event: CreateElementEvent): void
|
|
47
|
+
onRenderComplete?(event: RenderCompleteEvent): void
|
|
48
|
+
}
|