@upgraide/ui-notes-cli 0.1.2 → 0.1.3
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/formatters/ai-context.ts +236 -0
- package/index.ts +1 -1
- package/package.json +4 -2
- package/resolvers/file-resolver.ts +477 -0
- package/resolvers/selector-parser.ts +80 -0
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
import type { ResolvedNote, FileMatch } from '../resolvers/file-resolver.js';
|
|
2
|
+
|
|
3
|
+
interface Note {
|
|
4
|
+
id: string;
|
|
5
|
+
type: string;
|
|
6
|
+
body: string;
|
|
7
|
+
url: string;
|
|
8
|
+
component: string | null;
|
|
9
|
+
selector: string | null;
|
|
10
|
+
tag: string | null;
|
|
11
|
+
text_content: string | null;
|
|
12
|
+
created_at: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface FormatOptions {
|
|
16
|
+
format: 'markdown' | 'xml' | 'json';
|
|
17
|
+
componentFilter?: string;
|
|
18
|
+
projectName: string;
|
|
19
|
+
resolvedNotes?: Map<string, ResolvedNote>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function formatNotesForAI(notes: Note[], opts: FormatOptions): string {
|
|
23
|
+
// Filter by component if specified
|
|
24
|
+
let filtered = notes;
|
|
25
|
+
if (opts.componentFilter) {
|
|
26
|
+
filtered = notes.filter(n =>
|
|
27
|
+
n.component?.toLowerCase().includes(opts.componentFilter!.toLowerCase())
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Group by component
|
|
32
|
+
const grouped = new Map<string, Note[]>();
|
|
33
|
+
for (const note of filtered) {
|
|
34
|
+
const key = note.component || '_unresolved';
|
|
35
|
+
if (!grouped.has(key)) grouped.set(key, []);
|
|
36
|
+
grouped.get(key)!.push(note);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const date = new Date().toISOString().split('T')[0];
|
|
40
|
+
|
|
41
|
+
if (opts.format === 'xml') {
|
|
42
|
+
return formatAsXML(grouped, filtered.length, opts.projectName, date, opts.resolvedNotes);
|
|
43
|
+
}
|
|
44
|
+
if (opts.format === 'json') {
|
|
45
|
+
return formatAsJSON(grouped, filtered.length, opts.projectName, date, opts.resolvedNotes);
|
|
46
|
+
}
|
|
47
|
+
return formatAsMarkdown(grouped, filtered.length, opts.projectName, date, opts.resolvedNotes);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function formatFileMatch(file: FileMatch): string {
|
|
51
|
+
const loc = file.lineNumber ? `${file.filePath}:${file.lineNumber}` : file.filePath;
|
|
52
|
+
return `\`${loc}\` (${file.confidence} confidence, matched on ${file.matchedOn})`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function formatAsMarkdown(
|
|
56
|
+
grouped: Map<string, Note[]>,
|
|
57
|
+
total: number,
|
|
58
|
+
projectName: string,
|
|
59
|
+
date: string,
|
|
60
|
+
resolvedNotes?: Map<string, ResolvedNote>,
|
|
61
|
+
): string {
|
|
62
|
+
const lines: string[] = [];
|
|
63
|
+
|
|
64
|
+
lines.push(`# UI Notes: ${projectName}`);
|
|
65
|
+
|
|
66
|
+
if (total === 0) {
|
|
67
|
+
lines.push('> No open notes.');
|
|
68
|
+
return lines.join('\n') + '\n';
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const resolveInfo = resolvedNotes ? ' | File resolution: enabled' : '';
|
|
72
|
+
lines.push(`> ${total} open note${total === 1 ? '' : 's'} as of ${date}${resolveInfo}`);
|
|
73
|
+
lines.push('');
|
|
74
|
+
|
|
75
|
+
for (const [component, notes] of grouped) {
|
|
76
|
+
if (component === '_unresolved') {
|
|
77
|
+
lines.push(`## _Unresolved (${notes.length} note${notes.length === 1 ? '' : 's'})`);
|
|
78
|
+
} else {
|
|
79
|
+
lines.push(`## Component: ${component} (${notes.length} note${notes.length === 1 ? '' : 's'})`);
|
|
80
|
+
}
|
|
81
|
+
lines.push('');
|
|
82
|
+
|
|
83
|
+
for (const note of notes) {
|
|
84
|
+
const typeLabel = (note.type || 'note').toUpperCase();
|
|
85
|
+
const firstLine = note.body?.split('\n')[0] || '(no body)';
|
|
86
|
+
lines.push(`### [${typeLabel}] ${firstLine}`);
|
|
87
|
+
|
|
88
|
+
// File resolution output
|
|
89
|
+
if (resolvedNotes) {
|
|
90
|
+
const resolved = resolvedNotes.get(note.id);
|
|
91
|
+
if (resolved && resolved.files.length > 0) {
|
|
92
|
+
for (const file of resolved.files) {
|
|
93
|
+
lines.push(`- **File**: ${formatFileMatch(file)}`);
|
|
94
|
+
}
|
|
95
|
+
} else {
|
|
96
|
+
lines.push(`- **File**: _(no match found)_`);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (note.selector) {
|
|
101
|
+
lines.push(`- **Selector**: \`${note.selector}\``);
|
|
102
|
+
}
|
|
103
|
+
if (note.url) {
|
|
104
|
+
lines.push(`- **URL**: ${note.url}`);
|
|
105
|
+
}
|
|
106
|
+
if (note.tag || note.text_content) {
|
|
107
|
+
const parts: string[] = [];
|
|
108
|
+
if (note.tag) parts.push(`<${note.tag}>`);
|
|
109
|
+
if (note.text_content) parts.push(`"${note.text_content}"`);
|
|
110
|
+
lines.push(`- **Element**: ${parts.join(' ')}`);
|
|
111
|
+
}
|
|
112
|
+
if (note.created_at) {
|
|
113
|
+
const reported = note.created_at.split('T')[0];
|
|
114
|
+
lines.push(`- **Reported**: ${reported}`);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Full body as blockquote
|
|
118
|
+
if (note.body) {
|
|
119
|
+
const bodyLines = note.body.split('\n').map(l => `> ${l}`);
|
|
120
|
+
lines.push(bodyLines.join('\n'));
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
lines.push('');
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return lines.join('\n');
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function formatAsXML(
|
|
131
|
+
grouped: Map<string, Note[]>,
|
|
132
|
+
total: number,
|
|
133
|
+
projectName: string,
|
|
134
|
+
date: string,
|
|
135
|
+
resolvedNotes?: Map<string, ResolvedNote>,
|
|
136
|
+
): string {
|
|
137
|
+
const lines: string[] = [];
|
|
138
|
+
|
|
139
|
+
lines.push(`<ui-notes project="${escapeXml(projectName)}" count="${total}" date="${date}">`);
|
|
140
|
+
|
|
141
|
+
for (const [component, notes] of grouped) {
|
|
142
|
+
const compName = component === '_unresolved' ? '_unresolved' : component;
|
|
143
|
+
lines.push(` <component name="${escapeXml(compName)}" count="${notes.length}">`);
|
|
144
|
+
|
|
145
|
+
for (const note of notes) {
|
|
146
|
+
const typeAttr = (note.type || 'note').toLowerCase();
|
|
147
|
+
lines.push(` <note type="${escapeXml(typeAttr)}" id="${escapeXml(note.id)}">`);
|
|
148
|
+
lines.push(` <body>${escapeXml(note.body || '')}</body>`);
|
|
149
|
+
|
|
150
|
+
// File resolution output
|
|
151
|
+
if (resolvedNotes) {
|
|
152
|
+
const resolved = resolvedNotes.get(note.id);
|
|
153
|
+
if (resolved && resolved.files.length > 0) {
|
|
154
|
+
lines.push(` <resolved-files>`);
|
|
155
|
+
for (const file of resolved.files) {
|
|
156
|
+
const loc = file.lineNumber ? `${file.filePath}:${file.lineNumber}` : file.filePath;
|
|
157
|
+
lines.push(` <file path="${escapeXml(loc)}" confidence="${file.confidence}" match-type="${file.matchType}" matched-on="${escapeXml(file.matchedOn)}" />`);
|
|
158
|
+
}
|
|
159
|
+
lines.push(` </resolved-files>`);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (note.selector) {
|
|
164
|
+
lines.push(` <selector>${escapeXml(note.selector)}</selector>`);
|
|
165
|
+
}
|
|
166
|
+
if (note.url) {
|
|
167
|
+
lines.push(` <url>${escapeXml(note.url)}</url>`);
|
|
168
|
+
}
|
|
169
|
+
if (note.tag) {
|
|
170
|
+
lines.push(` <tag>${escapeXml(note.tag)}</tag>`);
|
|
171
|
+
}
|
|
172
|
+
if (note.text_content) {
|
|
173
|
+
lines.push(` <text-content>${escapeXml(note.text_content)}</text-content>`);
|
|
174
|
+
}
|
|
175
|
+
if (note.created_at) {
|
|
176
|
+
lines.push(` <created>${note.created_at.split('T')[0]}</created>`);
|
|
177
|
+
}
|
|
178
|
+
lines.push(` </note>`);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
lines.push(` </component>`);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
lines.push(`</ui-notes>`);
|
|
185
|
+
return lines.join('\n') + '\n';
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function formatAsJSON(
|
|
189
|
+
grouped: Map<string, Note[]>,
|
|
190
|
+
total: number,
|
|
191
|
+
projectName: string,
|
|
192
|
+
date: string,
|
|
193
|
+
resolvedNotes?: Map<string, ResolvedNote>,
|
|
194
|
+
): string {
|
|
195
|
+
const components: Record<string, unknown[]> = {};
|
|
196
|
+
|
|
197
|
+
for (const [component, notes] of grouped) {
|
|
198
|
+
components[component] = notes.map(n => {
|
|
199
|
+
const entry: Record<string, unknown> = {
|
|
200
|
+
id: n.id,
|
|
201
|
+
type: n.type,
|
|
202
|
+
body: n.body,
|
|
203
|
+
selector: n.selector || undefined,
|
|
204
|
+
url: n.url || undefined,
|
|
205
|
+
tag: n.tag || undefined,
|
|
206
|
+
text_content: n.text_content || undefined,
|
|
207
|
+
created_at: n.created_at,
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
if (resolvedNotes) {
|
|
211
|
+
const resolved = resolvedNotes.get(n.id);
|
|
212
|
+
entry.resolved_files = resolved?.files || [];
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
return entry;
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const output = {
|
|
220
|
+
project: projectName,
|
|
221
|
+
total,
|
|
222
|
+
date,
|
|
223
|
+
components,
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
return JSON.stringify(output, null, 2) + '\n';
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function escapeXml(str: string): string {
|
|
230
|
+
return str
|
|
231
|
+
.replace(/&/g, '&')
|
|
232
|
+
.replace(/</g, '<')
|
|
233
|
+
.replace(/>/g, '>')
|
|
234
|
+
.replace(/"/g, '"')
|
|
235
|
+
.replace(/'/g, ''');
|
|
236
|
+
}
|
package/index.ts
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@upgraide/ui-notes-cli",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
4
4
|
"description": "CLI for UI Notes - pull and manage UI feedback notes",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"keywords": ["cli", "ui", "notes", "feedback", "ui-notes"],
|
|
@@ -9,7 +9,9 @@
|
|
|
9
9
|
},
|
|
10
10
|
"files": [
|
|
11
11
|
"*.ts",
|
|
12
|
-
"commands/*.ts"
|
|
12
|
+
"commands/*.ts",
|
|
13
|
+
"formatters/*.ts",
|
|
14
|
+
"resolvers/*.ts"
|
|
13
15
|
],
|
|
14
16
|
"engines": {
|
|
15
17
|
"bun": ">=1.0.0"
|
|
@@ -0,0 +1,477 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File resolver: maps UI note metadata (component names, CSS selectors) to source files.
|
|
3
|
+
*
|
|
4
|
+
* Resolution strategies in priority order:
|
|
5
|
+
* 1. data-testid / data-component attribute match (very high confidence)
|
|
6
|
+
* 2. Component name -> filename match (high confidence)
|
|
7
|
+
* 3. Component name -> export/function declaration (high confidence)
|
|
8
|
+
* 4. Semantic class name search (non-Tailwind) (high confidence)
|
|
9
|
+
* 5. ID attribute search (high confidence)
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { readdir, readFile, stat } from 'fs/promises';
|
|
13
|
+
import { join, relative, basename, extname } from 'path';
|
|
14
|
+
import { existsSync } from 'fs';
|
|
15
|
+
import {
|
|
16
|
+
parseSelector,
|
|
17
|
+
filterSemanticClasses,
|
|
18
|
+
type SelectorTokens,
|
|
19
|
+
} from './selector-parser.js';
|
|
20
|
+
|
|
21
|
+
export interface FileMatch {
|
|
22
|
+
filePath: string;
|
|
23
|
+
lineNumber: number | null;
|
|
24
|
+
matchType: 'data-attr' | 'component' | 'selector' | 'id' | 'tag';
|
|
25
|
+
confidence: 'high' | 'medium' | 'low';
|
|
26
|
+
matchedOn: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface ResolvedNote {
|
|
30
|
+
noteId: string;
|
|
31
|
+
files: FileMatch[];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface ResolveOptions {
|
|
35
|
+
rootDir: string;
|
|
36
|
+
extensions?: string[];
|
|
37
|
+
excludeDirs?: string[];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const DEFAULT_EXTENSIONS = ['.tsx', '.jsx', '.ts', '.js', '.vue', '.svelte'];
|
|
41
|
+
const DEFAULT_EXCLUDE_DIRS = [
|
|
42
|
+
'node_modules',
|
|
43
|
+
'.next',
|
|
44
|
+
'dist',
|
|
45
|
+
'build',
|
|
46
|
+
'.git',
|
|
47
|
+
'coverage',
|
|
48
|
+
];
|
|
49
|
+
|
|
50
|
+
interface NoteForResolution {
|
|
51
|
+
id: string;
|
|
52
|
+
component?: string | null;
|
|
53
|
+
selector?: string | null;
|
|
54
|
+
tag?: string | null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Check whether the project uses Tailwind CSS by looking for config files.
|
|
59
|
+
*/
|
|
60
|
+
function detectTailwind(rootDir: string): boolean {
|
|
61
|
+
const configFiles = [
|
|
62
|
+
'tailwind.config.js',
|
|
63
|
+
'tailwind.config.ts',
|
|
64
|
+
'tailwind.config.mjs',
|
|
65
|
+
'tailwind.config.cjs',
|
|
66
|
+
];
|
|
67
|
+
return configFiles.some((f) => existsSync(join(rootDir, f)));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Recursively collect source files under rootDir, respecting extension and exclusion filters.
|
|
72
|
+
*/
|
|
73
|
+
async function collectSourceFiles(
|
|
74
|
+
dir: string,
|
|
75
|
+
extensions: string[],
|
|
76
|
+
excludeDirs: string[],
|
|
77
|
+
): Promise<string[]> {
|
|
78
|
+
const results: string[] = [];
|
|
79
|
+
|
|
80
|
+
let entries;
|
|
81
|
+
try {
|
|
82
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
83
|
+
} catch {
|
|
84
|
+
return results;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
for (const entry of entries) {
|
|
88
|
+
const fullPath = join(dir, entry.name);
|
|
89
|
+
|
|
90
|
+
if (entry.isDirectory()) {
|
|
91
|
+
if (!excludeDirs.includes(entry.name)) {
|
|
92
|
+
const sub = await collectSourceFiles(fullPath, extensions, excludeDirs);
|
|
93
|
+
results.push(...sub);
|
|
94
|
+
}
|
|
95
|
+
} else if (entry.isFile()) {
|
|
96
|
+
const ext = extname(entry.name);
|
|
97
|
+
if (extensions.includes(ext)) {
|
|
98
|
+
results.push(fullPath);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return results;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Escape special regex characters in a string.
|
|
108
|
+
*/
|
|
109
|
+
function escapeRegex(str: string): string {
|
|
110
|
+
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Find the 1-based line number of the first occurrence of `pattern` in `content`.
|
|
115
|
+
*/
|
|
116
|
+
function findLineNumber(content: string, pattern: RegExp): number | null {
|
|
117
|
+
const match = content.match(pattern);
|
|
118
|
+
if (!match || match.index === undefined) return null;
|
|
119
|
+
return content.slice(0, match.index).split('\n').length;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ── Strategy: data-attribute match ───────────────────────────────────────
|
|
123
|
+
|
|
124
|
+
async function resolveByDataAttribute(
|
|
125
|
+
attrs: SelectorTokens['dataAttributes'],
|
|
126
|
+
sourceFiles: string[],
|
|
127
|
+
rootDir: string,
|
|
128
|
+
fileContents: Map<string, string>,
|
|
129
|
+
): Promise<FileMatch[]> {
|
|
130
|
+
const matches: FileMatch[] = [];
|
|
131
|
+
|
|
132
|
+
for (const attr of attrs) {
|
|
133
|
+
if (!attr.value) continue;
|
|
134
|
+
const searchPattern = new RegExp(
|
|
135
|
+
`${escapeRegex(attr.name)}\\s*=\\s*["']${escapeRegex(attr.value)}["']`,
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
for (const filePath of sourceFiles) {
|
|
139
|
+
const content = await getFileContent(filePath, fileContents);
|
|
140
|
+
if (!content) continue;
|
|
141
|
+
|
|
142
|
+
const lineNumber = findLineNumber(content, searchPattern);
|
|
143
|
+
if (lineNumber !== null) {
|
|
144
|
+
matches.push({
|
|
145
|
+
filePath: relative(rootDir, filePath),
|
|
146
|
+
lineNumber,
|
|
147
|
+
matchType: 'data-attr',
|
|
148
|
+
confidence: 'high',
|
|
149
|
+
matchedOn: `${attr.name}="${attr.value}"`,
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// data-testid is very high confidence; return immediately if found
|
|
155
|
+
if (matches.length > 0) return matches;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return matches;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// ── Strategy: Component name -> filename match ───────────────────────────
|
|
162
|
+
|
|
163
|
+
async function resolveByComponentFilename(
|
|
164
|
+
componentName: string,
|
|
165
|
+
sourceFiles: string[],
|
|
166
|
+
rootDir: string,
|
|
167
|
+
fileContents: Map<string, string>,
|
|
168
|
+
): Promise<FileMatch[]> {
|
|
169
|
+
const matches: FileMatch[] = [];
|
|
170
|
+
const normalizedName = componentName.toLowerCase();
|
|
171
|
+
|
|
172
|
+
for (const filePath of sourceFiles) {
|
|
173
|
+
const fileName = basename(filePath)
|
|
174
|
+
.replace(/\.[^.]+$/, '')
|
|
175
|
+
.toLowerCase();
|
|
176
|
+
|
|
177
|
+
if (fileName === normalizedName) {
|
|
178
|
+
// Try to find the exact component declaration for a line number
|
|
179
|
+
const content = await getFileContent(filePath, fileContents);
|
|
180
|
+
let lineNumber: number | null = null;
|
|
181
|
+
if (content) {
|
|
182
|
+
lineNumber = findComponentDeclarationLine(content, componentName);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
matches.push({
|
|
186
|
+
filePath: relative(rootDir, filePath),
|
|
187
|
+
lineNumber,
|
|
188
|
+
matchType: 'component',
|
|
189
|
+
confidence: 'high',
|
|
190
|
+
matchedOn: componentName,
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
return matches;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// ── Strategy: Component name -> export/function search ───────────────────
|
|
199
|
+
|
|
200
|
+
async function resolveByComponentDeclaration(
|
|
201
|
+
componentName: string,
|
|
202
|
+
sourceFiles: string[],
|
|
203
|
+
rootDir: string,
|
|
204
|
+
fileContents: Map<string, string>,
|
|
205
|
+
): Promise<FileMatch[]> {
|
|
206
|
+
const matches: FileMatch[] = [];
|
|
207
|
+
const escaped = escapeRegex(componentName);
|
|
208
|
+
|
|
209
|
+
const patterns = [
|
|
210
|
+
new RegExp(`(?:export\\s+)?function\\s+${escaped}\\b`),
|
|
211
|
+
new RegExp(`(?:export\\s+)?const\\s+${escaped}\\s*[=:]`),
|
|
212
|
+
new RegExp(`(?:export\\s+)?class\\s+${escaped}\\b`),
|
|
213
|
+
new RegExp(`export\\s+default\\s+${escaped}\\b`),
|
|
214
|
+
new RegExp(`displayName\\s*=\\s*['"]${escaped}['"]`),
|
|
215
|
+
];
|
|
216
|
+
|
|
217
|
+
for (const filePath of sourceFiles) {
|
|
218
|
+
const content = await getFileContent(filePath, fileContents);
|
|
219
|
+
if (!content) continue;
|
|
220
|
+
|
|
221
|
+
for (const pattern of patterns) {
|
|
222
|
+
const lineNumber = findLineNumber(content, pattern);
|
|
223
|
+
if (lineNumber !== null) {
|
|
224
|
+
matches.push({
|
|
225
|
+
filePath: relative(rootDir, filePath),
|
|
226
|
+
lineNumber,
|
|
227
|
+
matchType: 'component',
|
|
228
|
+
confidence: 'high',
|
|
229
|
+
matchedOn: componentName,
|
|
230
|
+
});
|
|
231
|
+
break; // One match per file is enough
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
return matches;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// ── Strategy: Semantic class name search ─────────────────────────────────
|
|
240
|
+
|
|
241
|
+
async function resolveBySemanticClasses(
|
|
242
|
+
classNames: string[],
|
|
243
|
+
sourceFiles: string[],
|
|
244
|
+
rootDir: string,
|
|
245
|
+
fileContents: Map<string, string>,
|
|
246
|
+
): Promise<FileMatch[]> {
|
|
247
|
+
const matches: FileMatch[] = [];
|
|
248
|
+
|
|
249
|
+
for (const cls of classNames) {
|
|
250
|
+
const pattern = new RegExp(escapeRegex(cls));
|
|
251
|
+
|
|
252
|
+
for (const filePath of sourceFiles) {
|
|
253
|
+
const content = await getFileContent(filePath, fileContents);
|
|
254
|
+
if (!content) continue;
|
|
255
|
+
|
|
256
|
+
const lineNumber = findLineNumber(content, pattern);
|
|
257
|
+
if (lineNumber !== null) {
|
|
258
|
+
matches.push({
|
|
259
|
+
filePath: relative(rootDir, filePath),
|
|
260
|
+
lineNumber,
|
|
261
|
+
matchType: 'selector',
|
|
262
|
+
confidence: 'high',
|
|
263
|
+
matchedOn: `.${cls}`,
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
return matches;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// ── Strategy: ID attribute search ────────────────────────────────────────
|
|
273
|
+
|
|
274
|
+
async function resolveByIds(
|
|
275
|
+
ids: string[],
|
|
276
|
+
sourceFiles: string[],
|
|
277
|
+
rootDir: string,
|
|
278
|
+
fileContents: Map<string, string>,
|
|
279
|
+
): Promise<FileMatch[]> {
|
|
280
|
+
const matches: FileMatch[] = [];
|
|
281
|
+
|
|
282
|
+
for (const id of ids) {
|
|
283
|
+
const pattern = new RegExp(`id\\s*=\\s*["']${escapeRegex(id)}["']`);
|
|
284
|
+
|
|
285
|
+
for (const filePath of sourceFiles) {
|
|
286
|
+
const content = await getFileContent(filePath, fileContents);
|
|
287
|
+
if (!content) continue;
|
|
288
|
+
|
|
289
|
+
const lineNumber = findLineNumber(content, pattern);
|
|
290
|
+
if (lineNumber !== null) {
|
|
291
|
+
matches.push({
|
|
292
|
+
filePath: relative(rootDir, filePath),
|
|
293
|
+
lineNumber,
|
|
294
|
+
matchType: 'id',
|
|
295
|
+
confidence: 'high',
|
|
296
|
+
matchedOn: `#${id}`,
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
return matches;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// ── Helpers ──────────────────────────────────────────────────────────────
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Read file content with caching within a single invocation.
|
|
309
|
+
*/
|
|
310
|
+
async function getFileContent(
|
|
311
|
+
filePath: string,
|
|
312
|
+
cache: Map<string, string>,
|
|
313
|
+
): Promise<string | null> {
|
|
314
|
+
if (cache.has(filePath)) return cache.get(filePath)!;
|
|
315
|
+
try {
|
|
316
|
+
const content = await readFile(filePath, 'utf-8');
|
|
317
|
+
cache.set(filePath, content);
|
|
318
|
+
return content;
|
|
319
|
+
} catch {
|
|
320
|
+
return null;
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Find the line number of a component function/const declaration.
|
|
326
|
+
*/
|
|
327
|
+
function findComponentDeclarationLine(
|
|
328
|
+
content: string,
|
|
329
|
+
componentName: string,
|
|
330
|
+
): number | null {
|
|
331
|
+
const escaped = escapeRegex(componentName);
|
|
332
|
+
const patterns = [
|
|
333
|
+
new RegExp(`(?:export\\s+)?function\\s+${escaped}\\b`),
|
|
334
|
+
new RegExp(`(?:export\\s+)?const\\s+${escaped}\\s*[=:]`),
|
|
335
|
+
new RegExp(`(?:export\\s+)?class\\s+${escaped}\\b`),
|
|
336
|
+
new RegExp(`export\\s+default\\s+${escaped}\\b`),
|
|
337
|
+
];
|
|
338
|
+
for (const p of patterns) {
|
|
339
|
+
const line = findLineNumber(content, p);
|
|
340
|
+
if (line !== null) return line;
|
|
341
|
+
}
|
|
342
|
+
return null;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Deduplicate file matches, keeping the highest-confidence match per file.
|
|
347
|
+
*/
|
|
348
|
+
function deduplicateMatches(matches: FileMatch[]): FileMatch[] {
|
|
349
|
+
const byFile = new Map<string, FileMatch>();
|
|
350
|
+
const confidenceRank = { high: 3, medium: 2, low: 1 };
|
|
351
|
+
|
|
352
|
+
for (const m of matches) {
|
|
353
|
+
const existing = byFile.get(m.filePath);
|
|
354
|
+
if (
|
|
355
|
+
!existing ||
|
|
356
|
+
confidenceRank[m.confidence] > confidenceRank[existing.confidence]
|
|
357
|
+
) {
|
|
358
|
+
byFile.set(m.filePath, m);
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
return Array.from(byFile.values());
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// ── Main entry point ─────────────────────────────────────────────────────
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Resolve a single note to source files.
|
|
369
|
+
*/
|
|
370
|
+
export async function resolveNoteToFiles(
|
|
371
|
+
note: NoteForResolution,
|
|
372
|
+
opts: ResolveOptions,
|
|
373
|
+
): Promise<ResolvedNote> {
|
|
374
|
+
const extensions = opts.extensions || DEFAULT_EXTENSIONS;
|
|
375
|
+
const excludeDirs = opts.excludeDirs || DEFAULT_EXCLUDE_DIRS;
|
|
376
|
+
const rootDir = opts.rootDir;
|
|
377
|
+
|
|
378
|
+
const sourceFiles = await collectSourceFiles(rootDir, extensions, excludeDirs);
|
|
379
|
+
const fileContents = new Map<string, string>();
|
|
380
|
+
const hasTailwind = detectTailwind(rootDir);
|
|
381
|
+
|
|
382
|
+
if (sourceFiles.length === 0) {
|
|
383
|
+
console.error(
|
|
384
|
+
`Warning: No source files found in ${rootDir}. Check --root-dir or resolve.rootDir in config.`,
|
|
385
|
+
);
|
|
386
|
+
return { noteId: note.id, files: [] };
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
let allMatches: FileMatch[] = [];
|
|
390
|
+
|
|
391
|
+
// Parse selector tokens if we have a selector
|
|
392
|
+
let tokens: SelectorTokens | null = null;
|
|
393
|
+
if (note.selector) {
|
|
394
|
+
tokens = parseSelector(note.selector);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// Strategy 1: data-attribute match (very high confidence, short-circuits)
|
|
398
|
+
if (tokens && tokens.dataAttributes.length > 0) {
|
|
399
|
+
const dataMatches = await resolveByDataAttribute(
|
|
400
|
+
tokens.dataAttributes,
|
|
401
|
+
sourceFiles,
|
|
402
|
+
rootDir,
|
|
403
|
+
fileContents,
|
|
404
|
+
);
|
|
405
|
+
if (dataMatches.length > 0) {
|
|
406
|
+
return { noteId: note.id, files: deduplicateMatches(dataMatches) };
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// Strategy 2: Component name -> filename match
|
|
411
|
+
if (note.component) {
|
|
412
|
+
const filenameMatches = await resolveByComponentFilename(
|
|
413
|
+
note.component,
|
|
414
|
+
sourceFiles,
|
|
415
|
+
rootDir,
|
|
416
|
+
fileContents,
|
|
417
|
+
);
|
|
418
|
+
allMatches.push(...filenameMatches);
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// Strategy 3: Component name -> export/function search (only if no filename match)
|
|
422
|
+
if (note.component && allMatches.length === 0) {
|
|
423
|
+
const declMatches = await resolveByComponentDeclaration(
|
|
424
|
+
note.component,
|
|
425
|
+
sourceFiles,
|
|
426
|
+
rootDir,
|
|
427
|
+
fileContents,
|
|
428
|
+
);
|
|
429
|
+
allMatches.push(...declMatches);
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// If we already have high-confidence component matches, return early
|
|
433
|
+
if (allMatches.length > 0) {
|
|
434
|
+
return { noteId: note.id, files: deduplicateMatches(allMatches) };
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// Strategy 4: Semantic class name search
|
|
438
|
+
if (tokens && tokens.classNames.length > 0) {
|
|
439
|
+
const semanticClasses = filterSemanticClasses(tokens.classNames);
|
|
440
|
+
if (semanticClasses.length > 0) {
|
|
441
|
+
const classMatches = await resolveBySemanticClasses(
|
|
442
|
+
semanticClasses,
|
|
443
|
+
sourceFiles,
|
|
444
|
+
rootDir,
|
|
445
|
+
fileContents,
|
|
446
|
+
);
|
|
447
|
+
allMatches.push(...classMatches);
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// Strategy 5: ID attribute search
|
|
452
|
+
if (tokens && tokens.ids.length > 0) {
|
|
453
|
+
const idMatches = await resolveByIds(
|
|
454
|
+
tokens.ids,
|
|
455
|
+
sourceFiles,
|
|
456
|
+
rootDir,
|
|
457
|
+
fileContents,
|
|
458
|
+
);
|
|
459
|
+
allMatches.push(...idMatches);
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
return { noteId: note.id, files: deduplicateMatches(allMatches) };
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* Resolve multiple notes in batch, sharing the file cache.
|
|
467
|
+
*/
|
|
468
|
+
export async function resolveNotesToFiles(
|
|
469
|
+
notes: NoteForResolution[],
|
|
470
|
+
opts: ResolveOptions,
|
|
471
|
+
): Promise<ResolvedNote[]> {
|
|
472
|
+
const results: ResolvedNote[] = [];
|
|
473
|
+
for (const note of notes) {
|
|
474
|
+
results.push(await resolveNoteToFiles(note, opts));
|
|
475
|
+
}
|
|
476
|
+
return results;
|
|
477
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CSS selector parser and Tailwind utility class filter.
|
|
3
|
+
* Extracts searchable tokens from CSS selectors captured by the UI Notes React package.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export interface SelectorTokens {
|
|
7
|
+
classNames: string[];
|
|
8
|
+
ids: string[];
|
|
9
|
+
tags: string[];
|
|
10
|
+
dataAttributes: Array<{ name: string; value: string | null }>;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Regex matching common Tailwind CSS utility class prefixes.
|
|
15
|
+
* Classes matching this pattern carry no semantic meaning for file resolution.
|
|
16
|
+
*/
|
|
17
|
+
const TAILWIND_UTILITY_RE =
|
|
18
|
+
/^(flex|grid|block|inline|hidden|visible|static|fixed|absolute|relative|sticky|p[xytblr]?-|m[xytblr]?-|text-|bg-|border-|rounded|w-|h-|min-w-|max-w-|gap-|space-|font-|leading-|tracking-|shadow-|opacity-|z-|overflow-|cursor-|transition-|duration-|ease-|animate-|ring-|outline-|placeholder-|divide-|sr-only|not-sr-only|items-|justify-|self-|place-|order-|col-|row-|float-|clear-|object-|aspect-|container|prose|truncate|break-|whitespace-|decoration-|underline|overline|line-through|no-underline|uppercase|lowercase|capitalize|normal-case|italic|not-italic|tabular-nums|oldstyle-nums|lining-nums|proportional-nums|ordinal|slashed-zero|diagonal-fractions|stacked-fractions|list-|align-|table-|border-collapse|border-separate|origin-|scale-|rotate-|translate-|skew-|transform|accent-|appearance-|caret-|scroll-|snap-|touch-|select-|will-change-|fill-|stroke-)/;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Check if a class name looks like a Tailwind utility class.
|
|
22
|
+
*/
|
|
23
|
+
export function isTailwindUtility(className: string): boolean {
|
|
24
|
+
return TAILWIND_UTILITY_RE.test(className);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Filter out Tailwind utility classes, returning only semantic class names.
|
|
29
|
+
*/
|
|
30
|
+
export function filterSemanticClasses(classNames: string[]): string[] {
|
|
31
|
+
return classNames.filter((c) => !isTailwindUtility(c));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Parse a CSS selector string into searchable tokens.
|
|
36
|
+
*
|
|
37
|
+
* Handles selectors like:
|
|
38
|
+
* [data-testid="sidebar"]
|
|
39
|
+
* #checkout-form
|
|
40
|
+
* nav.sidebar-nav > ul.nav-list
|
|
41
|
+
* div.flex > button.submit-btn
|
|
42
|
+
*/
|
|
43
|
+
export function parseSelector(selector: string): SelectorTokens {
|
|
44
|
+
const classNames: string[] = [];
|
|
45
|
+
const ids: string[] = [];
|
|
46
|
+
const tags: string[] = [];
|
|
47
|
+
const dataAttributes: Array<{ name: string; value: string | null }> = [];
|
|
48
|
+
|
|
49
|
+
// Extract data-* attribute selectors: [data-testid="value"] or [data-component="value"]
|
|
50
|
+
const dataMatches = selector.matchAll(
|
|
51
|
+
/\[(data-[\w-]+)(?:="([^"]*)")?\]/g,
|
|
52
|
+
);
|
|
53
|
+
for (const m of dataMatches) {
|
|
54
|
+
dataAttributes.push({ name: m[1], value: m[2] ?? null });
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Extract .class-names (handling CSS-escaped chars like \.)
|
|
58
|
+
const classMatches = selector.matchAll(/\.([a-zA-Z_][\w-]*)/g);
|
|
59
|
+
for (const m of classMatches) {
|
|
60
|
+
classNames.push(m[1]);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Extract #ids
|
|
64
|
+
const idMatches = selector.matchAll(/#([a-zA-Z_][\w-]*)/g);
|
|
65
|
+
for (const m of idMatches) {
|
|
66
|
+
ids.push(m[1]);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Extract tag names: bare words at start of selector or after combinators (>, +, ~, space)
|
|
70
|
+
const tagMatches = selector.matchAll(/(?:^|[\s>+~])([a-z][a-z0-9]*)\b/gi);
|
|
71
|
+
for (const m of tagMatches) {
|
|
72
|
+
const tag = m[1].toLowerCase();
|
|
73
|
+
// Exclude common non-tag tokens
|
|
74
|
+
if (tag !== 'data' && tag !== 'not') {
|
|
75
|
+
tags.push(tag);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return { classNames, ids, tags, dataAttributes };
|
|
80
|
+
}
|