-
Notifications
You must be signed in to change notification settings - Fork 56
Expand file tree
/
Copy pathdata-attrs-handler.ts
More file actions
203 lines (162 loc) · 6.12 KB
/
data-attrs-handler.ts
File metadata and controls
203 lines (162 loc) · 6.12 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
import * as ts from 'typescript';
import type { DataAttrsExtraction } from './types.js';
function unwrapObjectLiteral(node: ts.Expression): ts.ObjectLiteralExpression | undefined {
if (ts.isObjectLiteralExpression(node)) {
return node;
}
if (ts.isParenthesizedExpression(node)) {
return unwrapObjectLiteral(node.expression);
}
if (ts.isAsExpression(node)) {
return unwrapObjectLiteral(node.expression);
}
if (ts.isSatisfiesExpression(node)) {
return unwrapObjectLiteral(node.expression);
}
return undefined;
}
function extractSatisfiesExpression(node: ts.Expression): ts.TypeNode | undefined {
if (ts.isSatisfiesExpression(node)) return node.type;
if (ts.isParenthesizedExpression(node)) return extractSatisfiesExpression(node.expression);
if (ts.isAsExpression(node)) return extractSatisfiesExpression(node.expression);
return undefined;
}
function inferStateTypes(satisfiesType: ts.TypeNode, program: ts.Program): Map<string, string> | undefined {
if (!ts.isTypeReferenceNode(satisfiesType) || !satisfiesType.typeArguments?.length) {
return undefined;
}
const stateTypeArg = satisfiesType.typeArguments[0]!;
const checker = program.getTypeChecker();
const resolvedType = checker.getTypeAtLocation(stateTypeArg);
const properties = resolvedType.getProperties();
if (properties.length === 0) return undefined;
const result = new Map<string, string>();
for (const prop of properties) {
const propType = checker.getTypeOfSymbol(prop);
// Expand union types to avoid showing alias names (e.g., VolumeLevel → 'off' | 'low')
let typeStr: string;
if (propType.isUnion()) {
typeStr = propType.types.map((t) => checker.typeToString(t)).join(' | ');
} else {
typeStr = checker.typeToString(propType);
}
if (typeStr === 'boolean' || typeStr === 'false | true') continue;
result.set(prop.name, typeStr.replace(/"/g, "'"));
}
return result;
}
/**
* Extract data attributes from a data-attrs file.
*
* Looks for patterns like:
* ```ts
* export const PlayButtonDataAttrs = {
* /** Present when the media is paused. *\/
* paused: 'data-paused',
* /** Present when the media has ended. *\/
* ended: 'data-ended',
* } as const;
* ```
*/
export function extractDataAttrs(
filePath: string,
program: ts.Program,
componentName: string
): DataAttrsExtraction | null {
const sourceFile = program.getSourceFile(filePath);
if (!sourceFile) {
return null;
}
const attrs: Array<{ name: string; description: string }> = [];
// Common naming patterns for data attributes exports
const possibleNames = [`${componentName}DataAttrs`, `${componentName}DataAttributes`];
const visit = (node: ts.Node) => {
// Look for variable declaration like: export const PlayButtonDataAttrs = { ... }
if (ts.isVariableStatement(node)) {
for (const decl of node.declarationList.declarations) {
if (!ts.isIdentifier(decl.name) || !possibleNames.includes(decl.name.text) || !decl.initializer) {
continue;
}
const objLiteral = unwrapObjectLiteral(decl.initializer);
if (!objLiteral) continue;
// Infer types from satisfies StateAttrMap<State>
const satisfiesType = extractSatisfiesExpression(decl.initializer);
const stateTypes = satisfiesType ? inferStateTypes(satisfiesType, program) : undefined;
// Extract properties with their JSDoc comments
for (const prop of objLiteral.properties) {
if (ts.isPropertyAssignment(prop) && ts.isIdentifier(prop.name)) {
const propName = prop.name.text;
let dataAttrValue = '';
// Get the value (e.g., 'data-paused')
if (ts.isStringLiteral(prop.initializer)) {
dataAttrValue = prop.initializer.text;
}
// Get JSDoc comment and optional @type for this property
const { description: jsDocComment, type: jsDocType } = parseJsDoc(prop, sourceFile);
const attrEntry: { name: string; description: string; type?: string } = {
name: dataAttrValue || `data-${propName}`,
description: jsDocComment || '',
};
// JSDoc @type takes priority, then inferred type from satisfies
if (jsDocType) {
attrEntry.type = jsDocType;
} else if (stateTypes?.has(propName)) {
attrEntry.type = stateTypes.get(propName)!;
}
attrs.push(attrEntry);
}
}
}
}
ts.forEachChild(node, visit);
};
visit(sourceFile);
if (attrs.length === 0) {
return null;
}
return { attrs };
}
/**
* Parse JSDoc comment, extracting description and optional `@type` tag.
*/
export function parseJsDoc(
node: ts.PropertyAssignment,
sourceFile: ts.SourceFile
): { description: string; type?: string } {
const raw = getJsDocComment(node, sourceFile);
if (!raw) return { description: '' };
// Extract @type {value} tag
const typeMatch = raw.match(/@type\s*\{([^}]+)\}/);
if (!typeMatch) return { description: raw };
const type = typeMatch[1]!.trim();
// Remove the @type line from description
const description = raw.replace(/@type\s*\{[^}]+\}/, '').trim();
return { description, type };
}
/**
* Extract JSDoc comment from a property assignment.
*/
export function getJsDocComment(node: ts.PropertyAssignment, sourceFile: ts.SourceFile): string {
// Get leading comment ranges
const fullText = sourceFile.getFullText();
const nodeStart = node.getFullStart();
const ranges = ts.getLeadingCommentRanges(fullText, nodeStart);
if (!ranges || ranges.length === 0) return '';
// Get the last comment (closest to the property)
const lastRange = ranges[ranges.length - 1];
if (!lastRange) return '';
const commentText = fullText.substring(lastRange.pos, lastRange.end);
// Parse JSDoc comment
if (commentText.startsWith('/**')) {
return commentText
.replace(/^\/\*\*\s*/, '')
.replace(/\s*\*\/$/, '')
.replace(/^\s*\*\s?/gm, '')
.trim();
}
// Single-line comment
if (commentText.startsWith('//')) {
return commentText.replace(/^\/\/\s*/, '').trim();
}
return '';
}