@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.
Files changed (47) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/README.md +207 -62
  3. package/lib/cli.js +2 -3
  4. package/lib/component-scanner.d.ts +67 -0
  5. package/lib/component-scanner.js +9 -0
  6. package/lib/dependency-mapper.d.ts +18 -35
  7. package/lib/dependency-mapper.js +40 -50
  8. package/lib/import-resolver/extract-script-source.d.ts +59 -0
  9. package/lib/import-resolver/extract-script-source.js +143 -0
  10. package/lib/import-resolver/index.d.ts +55 -0
  11. package/lib/import-resolver/index.js +152 -0
  12. package/lib/import-resolver/parse-imports.d.ts +19 -0
  13. package/lib/import-resolver/parse-imports.js +194 -0
  14. package/lib/import-resolver/resolve-barrel.d.ts +19 -0
  15. package/lib/import-resolver/resolve-barrel.js +113 -0
  16. package/lib/import-resolver/types.d.ts +34 -0
  17. package/lib/import-resolver/types.js +1 -0
  18. package/lib/index.d.ts +27 -1
  19. package/lib/index.js +24 -1
  20. package/lib/jsx/create-identify.d.ts +3 -4
  21. package/lib/jsx/create-identify.js +7 -22
  22. package/lib/jsx/get-children.d.ts +12 -6
  23. package/lib/jsx/get-children.js +64 -8
  24. package/lib/jsx/index.d.ts +4 -0
  25. package/lib/jsx/index.js +13 -5
  26. package/lib/pretender-director.d.ts +14 -5
  27. package/lib/pretender-director.js +15 -6
  28. package/lib/scan.d.ts +20 -0
  29. package/lib/scan.js +24 -0
  30. package/lib/scanner-loader.d.ts +8 -0
  31. package/lib/scanner-loader.js +56 -0
  32. package/lib/template/derive-name.d.ts +12 -0
  33. package/lib/template/derive-name.js +27 -0
  34. package/lib/template/detect-slots.d.ts +14 -0
  35. package/lib/template/detect-slots.js +23 -0
  36. package/lib/template/extract-attrs.d.ts +13 -0
  37. package/lib/template/extract-attrs.js +26 -0
  38. package/lib/template/extract-root.d.ts +11 -0
  39. package/lib/template/extract-root.js +17 -0
  40. package/lib/template/index.d.ts +13 -0
  41. package/lib/template/index.js +57 -0
  42. package/lib/template/parse-component.d.ts +22 -0
  43. package/lib/template/parse-component.js +74 -0
  44. package/lib/template/types.d.ts +6 -0
  45. package/lib/template/types.js +1 -0
  46. package/lib/types.d.ts +7 -0
  47. 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 JSX/TSX source files. Pretenders allow markuplint to understand which native HTML
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 JSX/TSX source files. Pretenders allow markuplint to understand which native HTML
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 - The child slots discovered within the JSX element
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 createIndentity(tagName: string, attrs: readonly Attr[], slots: readonly Slot[]): string | import("@markuplint/ml-config").OriginalNode;
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 - The child slots discovered within the JSX element
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 createIndentity(tagName, attrs, slots) {
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
- * Extracts child slot information from a JSX element.
5
- * Currently returns an empty array as child slot extraction is not yet implemented.
3
+ * Detects whether a JSX element accepts children by searching for
4
+ * `{children}` or `{props.children}` expressions in its content subtree.
6
5
  *
7
- * @param el - The JSX opening or self-closing element to extract children from
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 An array of Slot objects representing discovered child slots
15
+ * @returns `true` if children are accepted, `null` otherwise
10
16
  */
11
- export declare function getChildren(el: JsxOpeningElement | JsxSelfClosingElement, sourceFile: SourceFile): Slot[];
17
+ export declare function getChildren(el: JsxOpeningElement | JsxSelfClosingElement, sourceFile: SourceFile): null | true;
@@ -1,18 +1,74 @@
1
- // import { finder } from './finder.js';
1
+ import ts from 'typescript';
2
+ const { forEachChild, isIdentifier, isJsxAttributes, isJsxElement, isJsxSelfClosingElement, isPropertyAccessExpression, } = ts;
2
3
  /**
3
- * Extracts child slot information from a JSX element.
4
- * Currently returns an empty array as child slot extraction is not yet implemented.
4
+ * Detects whether a JSX element accepts children by searching for
5
+ * `{children}` or `{props.children}` expressions in its content subtree.
5
6
  *
6
- * @param el - The JSX opening or self-closing element to extract children from
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 An array of Slot objects representing discovered child slots
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
- const children = [];
16
- // const find = finder(sourceFile);
17
- return children;
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
  }
@@ -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[]>;