@markuplint/pretenders 5.0.0-dev.5 → 5.0.0-rc.1
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/CHANGELOG.md +29 -0
- package/README.md +207 -62
- package/lib/cli.js +2 -3
- package/lib/component-scanner.d.ts +67 -0
- package/lib/component-scanner.js +9 -0
- package/lib/dependency-mapper.d.ts +18 -35
- package/lib/dependency-mapper.js +40 -50
- package/lib/import-resolver/extract-script-source.d.ts +59 -0
- package/lib/import-resolver/extract-script-source.js +143 -0
- package/lib/import-resolver/index.d.ts +55 -0
- package/lib/import-resolver/index.js +152 -0
- package/lib/import-resolver/parse-imports.d.ts +19 -0
- package/lib/import-resolver/parse-imports.js +194 -0
- package/lib/import-resolver/resolve-barrel.d.ts +19 -0
- package/lib/import-resolver/resolve-barrel.js +113 -0
- package/lib/import-resolver/types.d.ts +34 -0
- package/lib/import-resolver/types.js +1 -0
- package/lib/index.d.ts +27 -1
- package/lib/index.js +24 -1
- package/lib/jsx/create-identify.d.ts +3 -4
- package/lib/jsx/create-identify.js +7 -22
- package/lib/jsx/get-children.d.ts +12 -6
- package/lib/jsx/get-children.js +64 -8
- package/lib/jsx/index.d.ts +4 -0
- package/lib/jsx/index.js +13 -5
- package/lib/pretender-director.d.ts +14 -5
- package/lib/pretender-director.js +15 -6
- package/lib/scan.d.ts +20 -0
- package/lib/scan.js +24 -0
- package/lib/scanner-loader.d.ts +8 -0
- package/lib/scanner-loader.js +56 -0
- package/lib/template/derive-name.d.ts +12 -0
- package/lib/template/derive-name.js +27 -0
- package/lib/template/detect-slots.d.ts +14 -0
- package/lib/template/detect-slots.js +23 -0
- package/lib/template/extract-attrs.d.ts +13 -0
- package/lib/template/extract-attrs.js +26 -0
- package/lib/template/extract-root.d.ts +11 -0
- package/lib/template/extract-root.js +17 -0
- package/lib/template/index.d.ts +13 -0
- package/lib/template/index.js +57 -0
- package/lib/template/parse-component.d.ts +22 -0
- package/lib/template/parse-component.js +74 -0
- package/lib/template/types.d.ts +6 -0
- package/lib/template/types.js +1 -0
- package/lib/types.d.ts +7 -0
- package/package.json +12 -5
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module parse-imports
|
|
3
|
+
*
|
|
4
|
+
* Extracts import bindings from ESM source text using es-module-lexer.
|
|
5
|
+
* Since es-module-lexer provides specifier positions but not local binding names,
|
|
6
|
+
* regex parsing on the statement slices supplements the extraction.
|
|
7
|
+
*/
|
|
8
|
+
import { init, parse } from 'es-module-lexer';
|
|
9
|
+
/** Matches `import type` — TypeScript type-only import (no runtime binding) */
|
|
10
|
+
const RE_TYPE_ONLY_IMPORT = /^import\s+type\s/;
|
|
11
|
+
/** Matches `import X from` — default import */
|
|
12
|
+
const RE_DEFAULT_IMPORT = /import\s+(\w+)\s+from/;
|
|
13
|
+
/** Matches `import { ... } from` — named imports */
|
|
14
|
+
const RE_NAMED_IMPORTS = /import\s*\{([^}]+)\}\s*from/;
|
|
15
|
+
/** Matches `import * as X from` — namespace import */
|
|
16
|
+
const RE_NAMESPACE_IMPORT = /import\s*\*\s*as\s+(\w+)\s+from/;
|
|
17
|
+
/** Matches `import X, { ... } from` — default + named imports */
|
|
18
|
+
const RE_DEFAULT_AND_NAMED = /import\s+(\w+)\s*,\s*\{([^}]+)\}\s*from/;
|
|
19
|
+
/** Matches `import X, * as Y from` — default + namespace imports */
|
|
20
|
+
const RE_DEFAULT_AND_NAMESPACE = /import\s+(\w+)\s*,\s*\*\s*as\s+(\w+)\s+from/;
|
|
21
|
+
let initPromise = null;
|
|
22
|
+
/**
|
|
23
|
+
* Ensures the es-module-lexer WASM module is initialized.
|
|
24
|
+
* Safe to call multiple times; initialization only happens once.
|
|
25
|
+
*/
|
|
26
|
+
async function ensureInit() {
|
|
27
|
+
initPromise ??= init;
|
|
28
|
+
await initPromise;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Parses named import entries from a comma-separated string inside `{ ... }`.
|
|
32
|
+
* Handles `as` aliases (e.g., `Foo as Bar`) and whitespace/trailing commas.
|
|
33
|
+
*
|
|
34
|
+
* @param raw - The raw string between braces, e.g., `"Foo, Bar as Baz"`
|
|
35
|
+
* @returns An array of import bindings with type `'named'`
|
|
36
|
+
*/
|
|
37
|
+
function parseNamedEntries(raw, source) {
|
|
38
|
+
const bindings = [];
|
|
39
|
+
for (const entry of raw.split(',')) {
|
|
40
|
+
const trimmed = entry.trim();
|
|
41
|
+
if (!trimmed) {
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
// Skip inline type-only imports: `import { type Foo } from '...'`
|
|
45
|
+
if (/^type\s+/.test(trimmed)) {
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
const asParts = trimmed.split(/\s+as\s+/);
|
|
49
|
+
if (asParts.length === 2 && asParts[0] && asParts[1]) {
|
|
50
|
+
bindings.push({
|
|
51
|
+
localName: asParts[1].trim(),
|
|
52
|
+
importedName: asParts[0].trim(),
|
|
53
|
+
source,
|
|
54
|
+
type: 'named',
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
else {
|
|
58
|
+
bindings.push({
|
|
59
|
+
localName: trimmed,
|
|
60
|
+
importedName: trimmed,
|
|
61
|
+
source,
|
|
62
|
+
type: 'named',
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return bindings;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Extracts import bindings from a single import statement slice.
|
|
70
|
+
* Applies regex patterns to determine the import shape (default, named, namespace,
|
|
71
|
+
* or combinations thereof).
|
|
72
|
+
*
|
|
73
|
+
* @param statementText - The full import statement text (from `ss` to `se`)
|
|
74
|
+
* @param source - The resolved module specifier from es-module-lexer
|
|
75
|
+
* @returns An array of import bindings found in this statement
|
|
76
|
+
*/
|
|
77
|
+
function extractBindingsFromStatement(statementText, source) {
|
|
78
|
+
// TypeScript `import type { ... }` — no runtime binding, skip entirely
|
|
79
|
+
if (RE_TYPE_ONLY_IMPORT.test(statementText)) {
|
|
80
|
+
return [];
|
|
81
|
+
}
|
|
82
|
+
// Try default + named: `import X, { A, B } from '...'`
|
|
83
|
+
const defaultAndNamed = RE_DEFAULT_AND_NAMED.exec(statementText);
|
|
84
|
+
if (defaultAndNamed?.[1] && defaultAndNamed[2] !== undefined) {
|
|
85
|
+
return [
|
|
86
|
+
{
|
|
87
|
+
localName: defaultAndNamed[1],
|
|
88
|
+
importedName: 'default',
|
|
89
|
+
source,
|
|
90
|
+
type: 'default',
|
|
91
|
+
},
|
|
92
|
+
...parseNamedEntries(defaultAndNamed[2], source),
|
|
93
|
+
];
|
|
94
|
+
}
|
|
95
|
+
// Try default + namespace: `import X, * as Y from '...'`
|
|
96
|
+
const defaultAndNamespace = RE_DEFAULT_AND_NAMESPACE.exec(statementText);
|
|
97
|
+
if (defaultAndNamespace?.[1] && defaultAndNamespace[2]) {
|
|
98
|
+
return [
|
|
99
|
+
{
|
|
100
|
+
localName: defaultAndNamespace[1],
|
|
101
|
+
importedName: 'default',
|
|
102
|
+
source,
|
|
103
|
+
type: 'default',
|
|
104
|
+
},
|
|
105
|
+
{
|
|
106
|
+
localName: defaultAndNamespace[2],
|
|
107
|
+
importedName: '*',
|
|
108
|
+
source,
|
|
109
|
+
type: 'namespace',
|
|
110
|
+
},
|
|
111
|
+
];
|
|
112
|
+
}
|
|
113
|
+
// Try namespace: `import * as X from '...'`
|
|
114
|
+
const namespace = RE_NAMESPACE_IMPORT.exec(statementText);
|
|
115
|
+
if (namespace?.[1]) {
|
|
116
|
+
return [
|
|
117
|
+
{
|
|
118
|
+
localName: namespace[1],
|
|
119
|
+
importedName: '*',
|
|
120
|
+
source,
|
|
121
|
+
type: 'namespace',
|
|
122
|
+
},
|
|
123
|
+
];
|
|
124
|
+
}
|
|
125
|
+
// Try named: `import { A, B as C } from '...'`
|
|
126
|
+
const named = RE_NAMED_IMPORTS.exec(statementText);
|
|
127
|
+
if (named?.[1] !== undefined) {
|
|
128
|
+
return parseNamedEntries(named[1], source);
|
|
129
|
+
}
|
|
130
|
+
// Try default: `import X from '...'`
|
|
131
|
+
const defaultImport = RE_DEFAULT_IMPORT.exec(statementText);
|
|
132
|
+
if (defaultImport?.[1]) {
|
|
133
|
+
return [
|
|
134
|
+
{
|
|
135
|
+
localName: defaultImport[1],
|
|
136
|
+
importedName: 'default',
|
|
137
|
+
source,
|
|
138
|
+
type: 'default',
|
|
139
|
+
},
|
|
140
|
+
];
|
|
141
|
+
}
|
|
142
|
+
// Side-effect import (`import '...'`) — no bindings to extract
|
|
143
|
+
return [];
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Analyzes source text and extracts import bindings using es-module-lexer.
|
|
147
|
+
*
|
|
148
|
+
* Processes static imports and dynamic imports with string literal specifiers.
|
|
149
|
+
* `import.meta` references and dynamic imports with non-literal specifiers
|
|
150
|
+
* (template literals, variables) are excluded.
|
|
151
|
+
*
|
|
152
|
+
* @param source - The source text to analyze (e.g., content of a `<script setup>` block)
|
|
153
|
+
* @returns An array of all import bindings found in the source
|
|
154
|
+
*/
|
|
155
|
+
export async function parseImports(source) {
|
|
156
|
+
await ensureInit();
|
|
157
|
+
let imports;
|
|
158
|
+
try {
|
|
159
|
+
[imports] = parse(source);
|
|
160
|
+
}
|
|
161
|
+
catch {
|
|
162
|
+
// Source may contain non-JS content (e.g., MDX with markdown).
|
|
163
|
+
// Return empty bindings rather than propagating the parse error.
|
|
164
|
+
return [];
|
|
165
|
+
}
|
|
166
|
+
const bindings = [];
|
|
167
|
+
for (const imp of imports) {
|
|
168
|
+
// import.meta (d === -2) — always skip
|
|
169
|
+
if (imp.d === -2) {
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
// n = normalized module specifier (absent for template-literal dynamic imports)
|
|
173
|
+
if (!imp.n) {
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
if (imp.d >= 0) {
|
|
177
|
+
// Dynamic import with a string literal specifier (e.g., `import('./Foo.vue')`)
|
|
178
|
+
// Dynamic imports have no local binding name, so we use '*' as a sentinel.
|
|
179
|
+
// Consumers should check `type === 'dynamic'` to distinguish from namespace imports.
|
|
180
|
+
bindings.push({
|
|
181
|
+
localName: '*',
|
|
182
|
+
importedName: '*',
|
|
183
|
+
source: imp.n,
|
|
184
|
+
type: 'dynamic',
|
|
185
|
+
});
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
// Static imports (d === -1)
|
|
189
|
+
// ss/se = statement start/end positions
|
|
190
|
+
const statementText = source.slice(imp.ss, imp.se);
|
|
191
|
+
bindings.push(...extractBindingsFromStatement(statementText, imp.n));
|
|
192
|
+
}
|
|
193
|
+
return bindings;
|
|
194
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module resolve-barrel
|
|
3
|
+
*
|
|
4
|
+
* Resolves barrel file (index.ts/index.js) re-exports to their original source.
|
|
5
|
+
* Only handles single-level barrel resolution — nested barrel chains are not followed.
|
|
6
|
+
*
|
|
7
|
+
* Given a specifier like `'./components'`, checks if it is a directory with an
|
|
8
|
+
* index file, parses that index file's export statements, and maps the requested
|
|
9
|
+
* binding name back to the original module.
|
|
10
|
+
*/
|
|
11
|
+
/**
|
|
12
|
+
* Resolves a barrel file re-export to the original source module path.
|
|
13
|
+
*
|
|
14
|
+
* @param specifier - The import specifier (e.g., `'./components'`)
|
|
15
|
+
* @param importedName - The name being imported (e.g., `'Button'`)
|
|
16
|
+
* @param importerPath - The absolute path of the file containing the import
|
|
17
|
+
* @returns The relative source path from the barrel file, or `null` if not a barrel or name not found
|
|
18
|
+
*/
|
|
19
|
+
export declare function resolveBarrelExport(specifier: string, importedName: string, importerPath: string): string | null;
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module resolve-barrel
|
|
3
|
+
*
|
|
4
|
+
* Resolves barrel file (index.ts/index.js) re-exports to their original source.
|
|
5
|
+
* Only handles single-level barrel resolution — nested barrel chains are not followed.
|
|
6
|
+
*
|
|
7
|
+
* Given a specifier like `'./components'`, checks if it is a directory with an
|
|
8
|
+
* index file, parses that index file's export statements, and maps the requested
|
|
9
|
+
* binding name back to the original module.
|
|
10
|
+
*/
|
|
11
|
+
import fs from 'node:fs';
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
/** Pattern to match `export { Name } from './source'` or `export { default as Name } from './source'` */
|
|
14
|
+
const RE_NAMED_REEXPORT = /export\s*\{([^}]+)\}\s*from\s*['"]([^'"]+)['"]/;
|
|
15
|
+
/** Index file names to check, in priority order */
|
|
16
|
+
const INDEX_FILES = ['index.ts', 'index.js', 'index.mts', 'index.mjs'];
|
|
17
|
+
/**
|
|
18
|
+
* Resolves a barrel file re-export to the original source module path.
|
|
19
|
+
*
|
|
20
|
+
* @param specifier - The import specifier (e.g., `'./components'`)
|
|
21
|
+
* @param importedName - The name being imported (e.g., `'Button'`)
|
|
22
|
+
* @param importerPath - The absolute path of the file containing the import
|
|
23
|
+
* @returns The relative source path from the barrel file, or `null` if not a barrel or name not found
|
|
24
|
+
*/
|
|
25
|
+
export function resolveBarrelExport(specifier, importedName, importerPath) {
|
|
26
|
+
// Only handle relative specifiers
|
|
27
|
+
if (!specifier.startsWith('.')) {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
const importerDir = path.dirname(importerPath);
|
|
31
|
+
const resolved = path.resolve(importerDir, specifier);
|
|
32
|
+
// If the specifier points to an existing file, it's not a barrel
|
|
33
|
+
if (isFile(resolved)) {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
// Check if the resolved path is a directory with an index file
|
|
37
|
+
const indexPath = findIndexFile(resolved);
|
|
38
|
+
if (!indexPath) {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
let indexSource;
|
|
42
|
+
try {
|
|
43
|
+
indexSource = fs.readFileSync(indexPath, 'utf8');
|
|
44
|
+
}
|
|
45
|
+
catch (error) {
|
|
46
|
+
// eslint-disable-next-line no-console
|
|
47
|
+
console.warn(`Failed to read barrel file: ${indexPath}`, error instanceof Error ? error.message : error);
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
return matchExportedName(indexSource, importedName);
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Finds the first matching index file in the given directory.
|
|
54
|
+
*/
|
|
55
|
+
function findIndexFile(dirPath) {
|
|
56
|
+
if (!isDirectory(dirPath)) {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
for (const name of INDEX_FILES) {
|
|
60
|
+
const candidate = path.join(dirPath, name);
|
|
61
|
+
if (isFile(candidate)) {
|
|
62
|
+
return candidate;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Parses export statements in a barrel file and returns the source path
|
|
69
|
+
* for the given exported name.
|
|
70
|
+
*/
|
|
71
|
+
function matchExportedName(indexSource, targetName) {
|
|
72
|
+
// Parse named re-exports: `export { X } from '...'` and `export { default as X } from '...'`
|
|
73
|
+
let match;
|
|
74
|
+
const namedRe = new RegExp(RE_NAMED_REEXPORT.source, 'g');
|
|
75
|
+
while ((match = namedRe.exec(indexSource)) !== null) {
|
|
76
|
+
const entriesRaw = match[1];
|
|
77
|
+
const source = match[2];
|
|
78
|
+
for (const entry of entriesRaw.split(',')) {
|
|
79
|
+
const trimmed = entry.trim();
|
|
80
|
+
if (!trimmed) {
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
const asParts = trimmed.split(/\s+as\s+/);
|
|
84
|
+
// `default as Button` → exported name is `Button`
|
|
85
|
+
// `Button` → exported name is `Button`
|
|
86
|
+
// `Button as Btn` → exported name is `Btn`
|
|
87
|
+
const exportedName = asParts.length === 2 ? asParts[1].trim() : trimmed;
|
|
88
|
+
if (exportedName === targetName) {
|
|
89
|
+
return source;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
// Star re-exports: `export * from '...'` — we can't statically verify the name,
|
|
94
|
+
// but for single-level resolution we do not traverse further.
|
|
95
|
+
// Return null since we can't confirm the name exists in the star export.
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
function isFile(p) {
|
|
99
|
+
try {
|
|
100
|
+
return fs.statSync(p).isFile();
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
function isDirectory(p) {
|
|
107
|
+
try {
|
|
108
|
+
return fs.statSync(p).isDirectory();
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
return false;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Represents a single import binding extracted from a source file.
|
|
3
|
+
* Links a local name (used in template/code) to its source module path.
|
|
4
|
+
*/
|
|
5
|
+
export interface ImportBinding {
|
|
6
|
+
/**
|
|
7
|
+
* The local name used in the file (e.g., `MyButton`, `default as Btn` → `Btn`).
|
|
8
|
+
*
|
|
9
|
+
* For dynamic imports (`type === 'dynamic'`), this is set to `'*'` as a sentinel
|
|
10
|
+
* value because dynamic imports have no local binding name. Use the `type` field
|
|
11
|
+
* to distinguish dynamic imports from namespace imports (which also use `'*'`
|
|
12
|
+
* for `importedName`).
|
|
13
|
+
*/
|
|
14
|
+
readonly localName: string;
|
|
15
|
+
/** The imported name from the source module (e.g., `default`, `MyButton`, or `*` for namespace/dynamic) */
|
|
16
|
+
readonly importedName: string;
|
|
17
|
+
/** The module specifier string (e.g., `./components/Button.vue`, `@/lib/utils`) */
|
|
18
|
+
readonly source: string;
|
|
19
|
+
/**
|
|
20
|
+
* The type of import binding:
|
|
21
|
+
* - `'default'` — `import X from '...'`
|
|
22
|
+
* - `'named'` — `import { X } from '...'`
|
|
23
|
+
* - `'namespace'` — `import * as X from '...'`
|
|
24
|
+
* - `'dynamic'` — `import('...')` with a string literal specifier
|
|
25
|
+
*/
|
|
26
|
+
readonly type: 'default' | 'named' | 'namespace' | 'dynamic';
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* The result of analyzing imports in a source block.
|
|
30
|
+
*/
|
|
31
|
+
export interface ImportAnalysisResult {
|
|
32
|
+
/** All import bindings found in the source */
|
|
33
|
+
readonly bindings: readonly ImportBinding[];
|
|
34
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/lib/index.d.ts
CHANGED
|
@@ -2,8 +2,34 @@
|
|
|
2
2
|
* @module @markuplint/pretenders
|
|
3
3
|
*
|
|
4
4
|
* Provides scanning utilities for detecting component-to-element mappings (pretenders)
|
|
5
|
-
* in
|
|
5
|
+
* in source files. Pretenders allow markuplint to understand which native HTML
|
|
6
6
|
* elements a component renders, enabling accurate linting of component-based code.
|
|
7
|
+
*
|
|
8
|
+
* ## Scanning
|
|
9
|
+
*
|
|
10
|
+
* - {@link scan} — Unified entry point that dispatches to the appropriate scanner by file extension
|
|
11
|
+
* - {@link jsxScanner} — Scans JSX/TSX files using the TypeScript compiler API
|
|
12
|
+
* - {@link templateScanner} — Scans Vue, Svelte, and Astro SFC files using markuplint's parsers
|
|
13
|
+
*
|
|
14
|
+
* ## Import resolution
|
|
15
|
+
*
|
|
16
|
+
* - {@link analyzeImports} — Extracts import bindings from component script blocks
|
|
17
|
+
* (Vue `<script setup>`, Vue Options API, Svelte, Astro, MDX)
|
|
18
|
+
* - {@link resolveComponentImport} — Resolves a component name to its import binding,
|
|
19
|
+
* including Vue kebab-case → PascalCase normalization
|
|
20
|
+
* - {@link resolveBarrelExport} — Resolves barrel file (`index.ts`/`index.js`) re-exports
|
|
21
|
+
* to original source modules. Call this separately after `analyzeImports` when an import
|
|
22
|
+
* source points to a directory with a barrel index.
|
|
23
|
+
*
|
|
24
|
+
* Dynamic imports (`import('./path')`) are included in bindings with `type: 'dynamic'`
|
|
25
|
+
* and `localName: '*'` as a sentinel. Check `type === 'dynamic'` to distinguish from
|
|
26
|
+
* namespace imports.
|
|
7
27
|
*/
|
|
8
28
|
export { jsxScanner } from './jsx/index.js';
|
|
29
|
+
export { templateScanner } from './template/index.js';
|
|
30
|
+
export { scan } from './scan.js';
|
|
31
|
+
export type { ScanOptions } from './scan.js';
|
|
32
|
+
export { analyzeImports, resolveComponentImport, resolveBarrelExport } from './import-resolver/index.js';
|
|
33
|
+
export type { ImportBinding, ImportAnalysisResult } from './import-resolver/types.js';
|
|
9
34
|
export type * from './types.js';
|
|
35
|
+
export type { ComponentScanner, ComponentScanResult, ComponentScanAttr, ComponentScanScriptSource, } from './component-scanner.js';
|
package/lib/index.js
CHANGED
|
@@ -2,7 +2,30 @@
|
|
|
2
2
|
* @module @markuplint/pretenders
|
|
3
3
|
*
|
|
4
4
|
* Provides scanning utilities for detecting component-to-element mappings (pretenders)
|
|
5
|
-
* in
|
|
5
|
+
* in source files. Pretenders allow markuplint to understand which native HTML
|
|
6
6
|
* elements a component renders, enabling accurate linting of component-based code.
|
|
7
|
+
*
|
|
8
|
+
* ## Scanning
|
|
9
|
+
*
|
|
10
|
+
* - {@link scan} — Unified entry point that dispatches to the appropriate scanner by file extension
|
|
11
|
+
* - {@link jsxScanner} — Scans JSX/TSX files using the TypeScript compiler API
|
|
12
|
+
* - {@link templateScanner} — Scans Vue, Svelte, and Astro SFC files using markuplint's parsers
|
|
13
|
+
*
|
|
14
|
+
* ## Import resolution
|
|
15
|
+
*
|
|
16
|
+
* - {@link analyzeImports} — Extracts import bindings from component script blocks
|
|
17
|
+
* (Vue `<script setup>`, Vue Options API, Svelte, Astro, MDX)
|
|
18
|
+
* - {@link resolveComponentImport} — Resolves a component name to its import binding,
|
|
19
|
+
* including Vue kebab-case → PascalCase normalization
|
|
20
|
+
* - {@link resolveBarrelExport} — Resolves barrel file (`index.ts`/`index.js`) re-exports
|
|
21
|
+
* to original source modules. Call this separately after `analyzeImports` when an import
|
|
22
|
+
* source points to a directory with a barrel index.
|
|
23
|
+
*
|
|
24
|
+
* Dynamic imports (`import('./path')`) are included in bindings with `type: 'dynamic'`
|
|
25
|
+
* and `localName: '*'` as a sentinel. Check `type === 'dynamic'` to distinguish from
|
|
26
|
+
* namespace imports.
|
|
7
27
|
*/
|
|
8
28
|
export { jsxScanner } from './jsx/index.js';
|
|
29
|
+
export { templateScanner } from './template/index.js';
|
|
30
|
+
export { scan } from './scan.js';
|
|
31
|
+
export { analyzeImports, resolveComponentImport, resolveBarrelExport } from './import-resolver/index.js';
|
|
@@ -1,14 +1,13 @@
|
|
|
1
1
|
import type { Attr } from '../types.js';
|
|
2
|
-
import type { Slot } from '@markuplint/ml-config';
|
|
3
2
|
/**
|
|
4
3
|
* Creates a pretender identity from a JSX element's tag name, attributes, and slots.
|
|
5
|
-
* If the element has no attributes, returns just the tag name string.
|
|
4
|
+
* If the element has no attributes and no slots, returns just the tag name string.
|
|
6
5
|
* Otherwise, returns a detailed identity object including attributes, slots,
|
|
7
6
|
* and whether the element inherits spread attributes.
|
|
8
7
|
*
|
|
9
8
|
* @param tagName - The HTML element or component tag name
|
|
10
9
|
* @param attrs - The attributes discovered on the JSX element
|
|
11
|
-
* @param slots -
|
|
10
|
+
* @param slots - Whether the component accepts children (`true`) or not (`null`)
|
|
12
11
|
* @returns A simple tag name string or a detailed Identity object
|
|
13
12
|
*/
|
|
14
|
-
export declare function
|
|
13
|
+
export declare function createIdentity(tagName: string, attrs: readonly Attr[], slots: null | true): string | import("@markuplint/ml-config").OriginalNode;
|
|
@@ -1,41 +1,26 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Creates a pretender identity from a JSX element's tag name, attributes, and slots.
|
|
3
|
-
* If the element has no attributes, returns just the tag name string.
|
|
3
|
+
* If the element has no attributes and no slots, returns just the tag name string.
|
|
4
4
|
* Otherwise, returns a detailed identity object including attributes, slots,
|
|
5
5
|
* and whether the element inherits spread attributes.
|
|
6
6
|
*
|
|
7
7
|
* @param tagName - The HTML element or component tag name
|
|
8
8
|
* @param attrs - The attributes discovered on the JSX element
|
|
9
|
-
* @param slots -
|
|
9
|
+
* @param slots - Whether the component accepts children (`true`) or not (`null`)
|
|
10
10
|
* @returns A simple tag name string or a detailed Identity object
|
|
11
11
|
*/
|
|
12
|
-
export function
|
|
13
|
-
if (attrs.length === 0) {
|
|
12
|
+
export function createIdentity(tagName, attrs, slots) {
|
|
13
|
+
if (attrs.length === 0 && slots !== true) {
|
|
14
14
|
return tagName;
|
|
15
15
|
}
|
|
16
16
|
const availableAttrs = attrs.filter(attr => attr.nodeType !== 'spread');
|
|
17
17
|
const hasSpread = attrs.some(attr => attr.nodeType === 'spread');
|
|
18
|
-
const pretenderAttrs = availableAttrs.map(attr => {
|
|
19
|
-
const pretenderAttr = {
|
|
20
|
-
name: attr.name,
|
|
21
|
-
};
|
|
22
|
-
if (attr.nodeType === 'static' && attr.value) {
|
|
23
|
-
// @ts-ignore initialize readonly property
|
|
24
|
-
pretenderAttr.value = attr.value;
|
|
25
|
-
}
|
|
26
|
-
return pretenderAttr;
|
|
27
|
-
});
|
|
18
|
+
const pretenderAttrs = availableAttrs.map(attr => attr.nodeType === 'static' && attr.value ? { name: attr.name, value: attr.value } : { name: attr.name });
|
|
28
19
|
const identify = {
|
|
29
20
|
element: tagName,
|
|
30
21
|
slots,
|
|
22
|
+
...(pretenderAttrs.length > 0 ? { attrs: pretenderAttrs } : {}),
|
|
23
|
+
...(hasSpread ? { inheritAttrs: true } : {}),
|
|
31
24
|
};
|
|
32
|
-
if (pretenderAttrs.length > 0) {
|
|
33
|
-
// @ts-ignore initialize readonly property
|
|
34
|
-
identify.attrs = pretenderAttrs;
|
|
35
|
-
}
|
|
36
|
-
if (hasSpread) {
|
|
37
|
-
// @ts-ignore initialize readonly property
|
|
38
|
-
identify.inheritAttrs = true;
|
|
39
|
-
}
|
|
40
25
|
return identify;
|
|
41
26
|
}
|
|
@@ -1,11 +1,17 @@
|
|
|
1
|
-
import type { Slot } from '@markuplint/ml-config';
|
|
2
1
|
import type { JsxOpeningElement, JsxSelfClosingElement, SourceFile } from 'typescript';
|
|
3
2
|
/**
|
|
4
|
-
*
|
|
5
|
-
*
|
|
3
|
+
* Detects whether a JSX element accepts children by searching for
|
|
4
|
+
* `{children}` or `{props.children}` expressions in its content subtree.
|
|
6
5
|
*
|
|
7
|
-
*
|
|
6
|
+
* Only searches content children (text, expressions, nested elements),
|
|
7
|
+
* NOT attribute values on the element or nested elements.
|
|
8
|
+
*
|
|
9
|
+
* - Self-closing elements (`<Foo />`) cannot have children → returns `null`
|
|
10
|
+
* - Opening elements with `{children}` or `{props.children}` in content → returns `true`
|
|
11
|
+
* - Opening elements without children expressions → returns `null`
|
|
12
|
+
*
|
|
13
|
+
* @param el - The JSX opening or self-closing element to inspect
|
|
8
14
|
* @param sourceFile - The TypeScript source file containing the element
|
|
9
|
-
* @returns
|
|
15
|
+
* @returns `true` if children are accepted, `null` otherwise
|
|
10
16
|
*/
|
|
11
|
-
export declare function getChildren(el: JsxOpeningElement | JsxSelfClosingElement, sourceFile: SourceFile):
|
|
17
|
+
export declare function getChildren(el: JsxOpeningElement | JsxSelfClosingElement, sourceFile: SourceFile): null | true;
|
package/lib/jsx/get-children.js
CHANGED
|
@@ -1,18 +1,74 @@
|
|
|
1
|
-
|
|
1
|
+
import ts from 'typescript';
|
|
2
|
+
const { forEachChild, isIdentifier, isJsxAttributes, isJsxElement, isJsxSelfClosingElement, isPropertyAccessExpression, } = ts;
|
|
2
3
|
/**
|
|
3
|
-
*
|
|
4
|
-
*
|
|
4
|
+
* Detects whether a JSX element accepts children by searching for
|
|
5
|
+
* `{children}` or `{props.children}` expressions in its content subtree.
|
|
5
6
|
*
|
|
6
|
-
*
|
|
7
|
+
* Only searches content children (text, expressions, nested elements),
|
|
8
|
+
* NOT attribute values on the element or nested elements.
|
|
9
|
+
*
|
|
10
|
+
* - Self-closing elements (`<Foo />`) cannot have children → returns `null`
|
|
11
|
+
* - Opening elements with `{children}` or `{props.children}` in content → returns `true`
|
|
12
|
+
* - Opening elements without children expressions → returns `null`
|
|
13
|
+
*
|
|
14
|
+
* @param el - The JSX opening or self-closing element to inspect
|
|
7
15
|
* @param sourceFile - The TypeScript source file containing the element
|
|
8
|
-
* @returns
|
|
16
|
+
* @returns `true` if children are accepted, `null` otherwise
|
|
9
17
|
*/
|
|
10
18
|
export function getChildren(
|
|
11
19
|
// eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
|
|
12
20
|
el,
|
|
13
21
|
// eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
|
|
14
22
|
sourceFile) {
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
23
|
+
if (isJsxSelfClosingElement(el)) {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
const parent = el.parent;
|
|
27
|
+
if (!isJsxElement(parent)) {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
// Search only through content children (JsxChild[]),
|
|
31
|
+
// which excludes opening/closing tags and their attributes.
|
|
32
|
+
for (const child of parent.children) {
|
|
33
|
+
if (hasChildrenIdentifier(child, sourceFile)) {
|
|
34
|
+
return true;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Recursively checks whether a node or its descendants contain a
|
|
41
|
+
* `children` identifier or `props.children` property access.
|
|
42
|
+
*
|
|
43
|
+
* Skips JsxAttributes nodes to avoid false positives from
|
|
44
|
+
* `{children}` used as attribute values on nested elements.
|
|
45
|
+
*/
|
|
46
|
+
function hasChildrenIdentifier(
|
|
47
|
+
// eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
|
|
48
|
+
node,
|
|
49
|
+
// eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
|
|
50
|
+
sourceFile) {
|
|
51
|
+
// Skip attribute containers — {children} in attrs is not a slot indicator
|
|
52
|
+
if (isJsxAttributes(node)) {
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
// {children}
|
|
56
|
+
if (isIdentifier(node) && node.getText(sourceFile) === 'children') {
|
|
57
|
+
return true;
|
|
58
|
+
}
|
|
59
|
+
// {props.children}
|
|
60
|
+
if (isPropertyAccessExpression(node) &&
|
|
61
|
+
isIdentifier(node.name) &&
|
|
62
|
+
node.name.getText(sourceFile) === 'children' &&
|
|
63
|
+
isIdentifier(node.expression) &&
|
|
64
|
+
node.expression.getText(sourceFile) === 'props') {
|
|
65
|
+
return true;
|
|
66
|
+
}
|
|
67
|
+
let found = false;
|
|
68
|
+
forEachChild(node, child => {
|
|
69
|
+
if (!found && hasChildrenIdentifier(child, sourceFile)) {
|
|
70
|
+
found = true;
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
return found;
|
|
18
74
|
}
|
package/lib/jsx/index.d.ts
CHANGED
|
@@ -19,5 +19,9 @@ import type { Pretender } from '@markuplint/ml-config';
|
|
|
19
19
|
* - HOC / wrapper function patterns
|
|
20
20
|
* - Fragment and provider component transparency
|
|
21
21
|
* - `@pretends null` JSDoc tag to opt out a component
|
|
22
|
+
*
|
|
23
|
+
* @param files - Absolute file paths to scan (relative paths cause a `ReferenceError`)
|
|
24
|
+
* @param options - JSX scanner configuration (fragment patterns, styled-components, wrappers, etc.)
|
|
25
|
+
* @returns Discovered pretender mappings for all components found in the given files
|
|
22
26
|
*/
|
|
23
27
|
export declare const jsxScanner: (files: readonly string[], options?: PretenderScanJSXOptions | undefined) => Promise<Pretender[]>;
|