@sprlab/wccompiler 0.11.9 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +37 -0
- package/bin/wcc.js +87 -3
- package/lib/codegen.js +13 -2
- package/lib/compiler.js +39 -72
- package/lib/import-resolver.js +160 -0
- package/lib/template-normalizer.js +109 -0
- package/lib/types.js +3 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -897,6 +897,43 @@ No configuration needed:
|
|
|
897
897
|
</wcc-list>
|
|
898
898
|
```
|
|
899
899
|
|
|
900
|
+
### TypeScript Types for Frameworks
|
|
901
|
+
|
|
902
|
+
`wcc build` auto-generates typed stubs for each framework in the `dist/` folder:
|
|
903
|
+
|
|
904
|
+
```
|
|
905
|
+
dist/
|
|
906
|
+
├── wcc-vue.d.ts ← Vue/Volar prop autocompletion
|
|
907
|
+
├── wcc-vue.js ← Vue component stubs
|
|
908
|
+
├── wcc-react.d.ts ← React compound component types
|
|
909
|
+
├── wcc-react.js ← React component stubs
|
|
910
|
+
└── ...
|
|
911
|
+
```
|
|
912
|
+
|
|
913
|
+
**Vue (Volar autocompletion)**
|
|
914
|
+
|
|
915
|
+
Add `dist/wcc-vue.d.ts` to your tsconfig to get prop/event autocompletion in `.vue` templates:
|
|
916
|
+
|
|
917
|
+
```json
|
|
918
|
+
// tsconfig.json
|
|
919
|
+
{
|
|
920
|
+
"include": ["src/**/*", "dist/wcc-vue.d.ts"]
|
|
921
|
+
}
|
|
922
|
+
```
|
|
923
|
+
|
|
924
|
+
After this, Volar provides:
|
|
925
|
+
- Prop autocompletion: `<wcc-counter :la|` → suggests `label`
|
|
926
|
+
- Type-checking: `<wcc-counter :count="'string'">` → type error (expects number)
|
|
927
|
+
- Event types on hover
|
|
928
|
+
|
|
929
|
+
**React**
|
|
930
|
+
|
|
931
|
+
React 19 treats custom elements (hyphenated tags) as `any` in JSX — this is by React's design. No additional type setup needed. Compound component stubs (`WccCard.Header`) are typed via `dist/wcc-react.d.ts` and work when imported directly.
|
|
932
|
+
|
|
933
|
+
**Angular**
|
|
934
|
+
|
|
935
|
+
Angular's `CUSTOM_ELEMENTS_SCHEMA` disables all type-checking on custom elements. No additional type setup possible from the library side.
|
|
936
|
+
|
|
900
937
|
## Editor Support
|
|
901
938
|
|
|
902
939
|
The **wcCompiler (.wcc) Language Support** extension is available on the VS Code Marketplace. It provides syntax highlighting, completions, and diagnostics for `.wcc` files.
|
package/bin/wcc.js
CHANGED
|
@@ -77,11 +77,21 @@ async function build(config, cwd) {
|
|
|
77
77
|
*/
|
|
78
78
|
function generateFrameworkStubs(outputDir) {
|
|
79
79
|
|
|
80
|
-
const files =
|
|
80
|
+
const files = [];
|
|
81
|
+
function collectJsFiles(dir) {
|
|
82
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
83
|
+
if (entry.isDirectory()) {
|
|
84
|
+
collectJsFiles(join(dir, entry.name));
|
|
85
|
+
} else if (entry.isFile() && entry.name.endsWith('.js') && !entry.name.startsWith('__') && entry.name !== 'wcc-runtime.js' && entry.name !== 'wcc-react.js' && entry.name !== 'wcc-vue.js') {
|
|
86
|
+
files.push(join(dir, entry.name));
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
collectJsFiles(outputDir);
|
|
81
91
|
const components = [];
|
|
82
92
|
|
|
83
93
|
for (const file of files) {
|
|
84
|
-
const content = readFileSync(
|
|
94
|
+
const content = readFileSync(file, 'utf-8');
|
|
85
95
|
// Match static __meta = { ... }; with balanced braces
|
|
86
96
|
const metaStart = content.indexOf('static __meta = {');
|
|
87
97
|
if (metaStart === -1) continue;
|
|
@@ -156,7 +166,8 @@ function generateFrameworkStubs(outputDir) {
|
|
|
156
166
|
let vueJs = '// Auto-generated by wcc build — Vue component stubs\n';
|
|
157
167
|
vueJs += '// Import these in your Vue SFC for type safety and IDE support.\n\n';
|
|
158
168
|
|
|
159
|
-
let vueDts = '// Auto-generated by wcc build — Vue component types\n
|
|
169
|
+
let vueDts = '// Auto-generated by wcc build — Vue component types\n';
|
|
170
|
+
vueDts += '// Include this file in your tsconfig.json for Volar autocompletion.\n\n';
|
|
160
171
|
|
|
161
172
|
for (const comp of components) {
|
|
162
173
|
const props = comp.meta.props || [];
|
|
@@ -189,8 +200,81 @@ function generateFrameworkStubs(outputDir) {
|
|
|
189
200
|
vueDts += '\n';
|
|
190
201
|
}
|
|
191
202
|
|
|
203
|
+
// ── Vue GlobalComponents (Volar autocompletion in templates) ──
|
|
204
|
+
vueDts += '// ── Volar Global Component Types ──────────────────────────────────\n';
|
|
205
|
+
vueDts += '// Add this file to tsconfig.json "include" for template autocompletion.\n\n';
|
|
206
|
+
vueDts += "declare module 'vue' {\n";
|
|
207
|
+
vueDts += ' export interface GlobalComponents {\n';
|
|
208
|
+
|
|
209
|
+
for (const comp of components) {
|
|
210
|
+
const props = comp.meta.props || [];
|
|
211
|
+
const models = comp.meta.models || [];
|
|
212
|
+
const events = comp.meta.events || [];
|
|
213
|
+
|
|
214
|
+
// Build $props type inline
|
|
215
|
+
const allProps = [
|
|
216
|
+
...props.map(p => {
|
|
217
|
+
const def = String(p.default);
|
|
218
|
+
const type = def === 'true' || def === 'false' ? 'boolean'
|
|
219
|
+
: /^-?\d+(\.\d+)?$/.test(def) ? 'number'
|
|
220
|
+
: 'string';
|
|
221
|
+
return `${p.name}?: ${type}`;
|
|
222
|
+
}),
|
|
223
|
+
...models.map(m => `${m}?: any`),
|
|
224
|
+
];
|
|
225
|
+
|
|
226
|
+
const propsStr = allProps.length > 0
|
|
227
|
+
? `{ ${allProps.join('; ')} }`
|
|
228
|
+
: '{}';
|
|
229
|
+
|
|
230
|
+
// Build $emit type inline
|
|
231
|
+
const emitEntries = [
|
|
232
|
+
...events.map(e => `(e: '${e}', value: any): void`),
|
|
233
|
+
...models.map(m => `(e: '${m}-changed', value: any): void`),
|
|
234
|
+
];
|
|
235
|
+
|
|
236
|
+
if (emitEntries.length > 0) {
|
|
237
|
+
vueDts += ` '${comp.meta.tag}': new () => { $props: ${propsStr}; $emit: { ${emitEntries.join('; ')} } };\n`;
|
|
238
|
+
} else {
|
|
239
|
+
vueDts += ` '${comp.meta.tag}': new () => { $props: ${propsStr} };\n`;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
vueDts += ' }\n';
|
|
244
|
+
vueDts += '}\n\n';
|
|
245
|
+
vueDts += 'export {}\n';
|
|
246
|
+
|
|
192
247
|
writeFileSync(join(outputDir, 'wcc-vue.js'), vueJs);
|
|
193
248
|
writeFileSync(join(outputDir, 'wcc-vue.d.ts'), vueDts);
|
|
249
|
+
|
|
250
|
+
// ── HTML Custom Data (for VS Code / Kiro HTML intellisense) ──
|
|
251
|
+
const htmlData = {
|
|
252
|
+
version: 1.1,
|
|
253
|
+
tags: components.map(comp => {
|
|
254
|
+
const props = comp.meta.props || [];
|
|
255
|
+
const events = comp.meta.events || [];
|
|
256
|
+
const models = comp.meta.models || [];
|
|
257
|
+
|
|
258
|
+
const attributes = [
|
|
259
|
+
...props.map(p => {
|
|
260
|
+
const def = String(p.default);
|
|
261
|
+
const type = def === 'true' || def === 'false' ? 'boolean'
|
|
262
|
+
: /^-?\d+(\.\d+)?$/.test(def) ? 'number' : 'string';
|
|
263
|
+
return { name: `:${p.name}`, description: `(prop) ${type}` };
|
|
264
|
+
}),
|
|
265
|
+
...models.map(m => ({ name: `:${m}`, description: `(model) two-way binding` })),
|
|
266
|
+
...events.map(e => ({ name: `@${e}`, description: `(event)` })),
|
|
267
|
+
];
|
|
268
|
+
|
|
269
|
+
return {
|
|
270
|
+
name: comp.meta.tag,
|
|
271
|
+
description: `WCC Component: ${comp.meta.tag}`,
|
|
272
|
+
attributes,
|
|
273
|
+
};
|
|
274
|
+
}),
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
writeFileSync(join(outputDir, 'wcc-html-data.json'), JSON.stringify(htmlData, null, 2));
|
|
194
278
|
}
|
|
195
279
|
|
|
196
280
|
function discoverFiles(dir) {
|
package/lib/codegen.js
CHANGED
|
@@ -923,7 +923,14 @@ export function generateComponent(parseResult, options = {}) {
|
|
|
923
923
|
|
|
924
924
|
// ── 1b. Child component imports ──
|
|
925
925
|
for (const ci of childImports) {
|
|
926
|
-
|
|
926
|
+
if (ci.sideEffect) {
|
|
927
|
+
// Side-effect import: no identifier, child self-registers
|
|
928
|
+
lines.push(`import '${ci.importPath}';`);
|
|
929
|
+
} else {
|
|
930
|
+
// Named import with guarded registration
|
|
931
|
+
lines.push(`import ${ci.identifier} from '${ci.importPath}';`);
|
|
932
|
+
lines.push(`if (!customElements.get(${ci.identifier}.__meta.tag)) customElements.define(${ci.identifier}.__meta.tag, ${ci.identifier});`);
|
|
933
|
+
}
|
|
927
934
|
}
|
|
928
935
|
if (childImports.length > 0) {
|
|
929
936
|
lines.push('');
|
|
@@ -2012,7 +2019,11 @@ export function generateComponent(parseResult, options = {}) {
|
|
|
2012
2019
|
lines.push('');
|
|
2013
2020
|
|
|
2014
2021
|
// ── 5. Custom element registration ──
|
|
2015
|
-
lines.push(`customElements.define('${tagName}', ${className});`);
|
|
2022
|
+
lines.push(`if (!customElements.get('${tagName}')) customElements.define('${tagName}', ${className});`);
|
|
2023
|
+
lines.push('');
|
|
2024
|
+
|
|
2025
|
+
// ── 6. Default export (enables named imports from parent components) ──
|
|
2026
|
+
lines.push(`export default ${className};`);
|
|
2016
2027
|
|
|
2017
2028
|
return lines.join('\n');
|
|
2018
2029
|
}
|
package/lib/compiler.js
CHANGED
|
@@ -7,8 +7,8 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { parseHTML } from 'linkedom';
|
|
10
|
-
import { readFileSync
|
|
11
|
-
import {
|
|
10
|
+
import { readFileSync } from 'node:fs';
|
|
11
|
+
import { basename } from 'node:path';
|
|
12
12
|
import { walkTree, processIfChains, processForBlocks, recomputeAnchorPath, detectRefs } from './tree-walker.js';
|
|
13
13
|
import { generateComponent } from './codegen.js';
|
|
14
14
|
import { parseSFC } from './sfc-parser.js';
|
|
@@ -43,29 +43,8 @@ import {
|
|
|
43
43
|
validateUndeclaredEmits,
|
|
44
44
|
} from './parser-extractors.js';
|
|
45
45
|
import { stripTypes } from './parser.js';
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
* Resolve a child component's source file path by tag name.
|
|
49
|
-
*
|
|
50
|
-
* Searches for a file named after the tag in the source directory,
|
|
51
|
-
* trying extensions in priority order: .wcc, .js, .ts
|
|
52
|
-
*
|
|
53
|
-
* @param {string} tag — The custom element tag name (e.g., 'wcc-child')
|
|
54
|
-
* @param {string} sourceDir — Directory of the parent component
|
|
55
|
-
* @param {object} [config] — Optional config (reserved for future use)
|
|
56
|
-
* @returns {string | null} Relative import path (e.g., './wcc-child.js') or null if not found
|
|
57
|
-
*/
|
|
58
|
-
function resolveChildComponent(tag, sourceDir, config) {
|
|
59
|
-
const extensions = ['.wcc', '.js', '.ts'];
|
|
60
|
-
for (const ext of extensions) {
|
|
61
|
-
const candidate = resolve(sourceDir, `${tag}${ext}`);
|
|
62
|
-
if (existsSync(candidate)) {
|
|
63
|
-
// Return as a relative .js import path (compiled output)
|
|
64
|
-
return `./${tag}.js`;
|
|
65
|
-
}
|
|
66
|
-
}
|
|
67
|
-
return null;
|
|
68
|
-
}
|
|
46
|
+
import { normalizeTemplate, pascalToKebab } from './template-normalizer.js';
|
|
47
|
+
import { extractWccImports } from './import-resolver.js';
|
|
69
48
|
|
|
70
49
|
/**
|
|
71
50
|
* Compile a single .wcc SFC file into a self-contained JS component.
|
|
@@ -87,14 +66,38 @@ async function compileSFC(filePath, config) {
|
|
|
87
66
|
// 2. Process script block — mirrors parser.js logic
|
|
88
67
|
let source = stripMacroImport(descriptor.script);
|
|
89
68
|
|
|
90
|
-
// 2b. Extract
|
|
91
|
-
const
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
69
|
+
// 2b. Extract .wcc imports using the import resolver
|
|
70
|
+
const wccImports = extractWccImports(source, fileName);
|
|
71
|
+
|
|
72
|
+
// Build importMap: PascalCase identifier → kebab-case tag
|
|
73
|
+
/** @type {Map<string, string>} */
|
|
74
|
+
const importMap = new Map();
|
|
75
|
+
for (const imp of wccImports.named) {
|
|
76
|
+
importMap.set(imp.identifier, pascalToKebab(imp.identifier));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Build childImports from extracted imports
|
|
80
|
+
/** @type {import('./types.js').ChildComponentImport[]} */
|
|
81
|
+
const childImports = [];
|
|
82
|
+
for (const imp of wccImports.named) {
|
|
83
|
+
childImports.push({
|
|
84
|
+
tag: pascalToKebab(imp.identifier),
|
|
85
|
+
identifier: imp.identifier,
|
|
86
|
+
importPath: imp.compiledPath,
|
|
87
|
+
sideEffect: false,
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
for (const imp of wccImports.sideEffect) {
|
|
91
|
+
childImports.push({
|
|
92
|
+
tag: '',
|
|
93
|
+
identifier: '',
|
|
94
|
+
importPath: imp.compiledPath,
|
|
95
|
+
sideEffect: true,
|
|
96
|
+
});
|
|
96
97
|
}
|
|
97
|
-
|
|
98
|
+
|
|
99
|
+
// Use strippedSource (with .wcc imports removed) for subsequent extraction steps
|
|
100
|
+
source = wccImports.strippedSource;
|
|
98
101
|
|
|
99
102
|
// 3. Extract props/emits from generic forms BEFORE type stripping
|
|
100
103
|
const propsFromGeneric = extractPropsGeneric(source);
|
|
@@ -290,7 +293,8 @@ async function compileSFC(filePath, config) {
|
|
|
290
293
|
};
|
|
291
294
|
|
|
292
295
|
// 16. Process template through linkedom → tree-walker → codegen
|
|
293
|
-
const
|
|
296
|
+
const normalizedTemplate = normalizeTemplate(template, { importMap, fileName });
|
|
297
|
+
const { document } = parseHTML(`<div id="__root">${normalizedTemplate}</div>`);
|
|
294
298
|
const rootEl = document.getElementById('__root');
|
|
295
299
|
|
|
296
300
|
const signalNames = new Set(signals.map(s => s.name));
|
|
@@ -393,39 +397,8 @@ async function compileSFC(filePath, config) {
|
|
|
393
397
|
}
|
|
394
398
|
}
|
|
395
399
|
|
|
396
|
-
// 18.
|
|
397
|
-
|
|
398
|
-
const childImports = [];
|
|
399
|
-
const allChildTags = new Set(childComponents.map(c => c.tag));
|
|
400
|
-
|
|
401
|
-
// Collect child tags from if branches
|
|
402
|
-
for (const ifBlock of ifBlocks) {
|
|
403
|
-
for (const branch of ifBlock.branches) {
|
|
404
|
-
if (branch.childComponents) {
|
|
405
|
-
for (const cc of branch.childComponents) allChildTags.add(cc.tag);
|
|
406
|
-
}
|
|
407
|
-
}
|
|
408
|
-
}
|
|
409
|
-
|
|
410
|
-
// Collect child tags from each blocks
|
|
411
|
-
for (const forBlock of forBlocks) {
|
|
412
|
-
if (forBlock.childComponents) {
|
|
413
|
-
for (const cc of forBlock.childComponents) allChildTags.add(cc.tag);
|
|
414
|
-
}
|
|
415
|
-
}
|
|
416
|
-
|
|
417
|
-
if (allChildTags.size > 0) {
|
|
418
|
-
const sourceDir = dirname(filePath);
|
|
419
|
-
|
|
420
|
-
for (const tag of allChildTags) {
|
|
421
|
-
const resolved = resolveChildComponent(tag, sourceDir, config);
|
|
422
|
-
if (resolved) {
|
|
423
|
-
childImports.push({ tag, importPath: resolved });
|
|
424
|
-
} else {
|
|
425
|
-
console.warn(`Warning: child component <${tag}> used in template but source file not found`);
|
|
426
|
-
}
|
|
427
|
-
}
|
|
428
|
-
}
|
|
400
|
+
// 18. Child imports already built from extractWccImports (step 2b)
|
|
401
|
+
// No filesystem scanning needed — imports are explicit
|
|
429
402
|
|
|
430
403
|
// 19. Merge tree-walker results into ParseResult
|
|
431
404
|
parseResult.bindings = bindings;
|
|
@@ -440,12 +413,6 @@ async function compileSFC(filePath, config) {
|
|
|
440
413
|
parseResult.refBindings = refBindings;
|
|
441
414
|
parseResult.childComponents = childComponents;
|
|
442
415
|
|
|
443
|
-
// Add manual .wcc imports from script block
|
|
444
|
-
for (const imp of manualImports) {
|
|
445
|
-
if (!childImports.find(ci => ci.importPath === imp)) {
|
|
446
|
-
childImports.push({ tag: '', importPath: imp });
|
|
447
|
-
}
|
|
448
|
-
}
|
|
449
416
|
parseResult.childImports = childImports;
|
|
450
417
|
parseResult.processedTemplate = rootEl.innerHTML;
|
|
451
418
|
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Import resolver for .wcc component imports.
|
|
3
|
+
*
|
|
4
|
+
* Extracts and validates `.wcc` import statements from the script block,
|
|
5
|
+
* producing a structured representation of named default imports and
|
|
6
|
+
* side-effect imports. Rejects invalid import forms (namespace, named exports).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
// ── Types ────────────────────────────────────────────────────────────
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* @typedef {Object} WccNamedImport
|
|
13
|
+
* @property {string} identifier — The import name (e.g., 'WccBadge', 'MyButton')
|
|
14
|
+
* @property {string} sourcePath — Original .wcc path (e.g., './wcc-badge.wcc')
|
|
15
|
+
* @property {string} compiledPath — Rewritten .js path (e.g., './wcc-badge.js')
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* @typedef {Object} WccSideEffectImport
|
|
20
|
+
* @property {string} sourcePath — Original .wcc path (e.g., './child.wcc')
|
|
21
|
+
* @property {string} compiledPath — Rewritten .js path (e.g., './child.js')
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* @typedef {Object} WccImportResult
|
|
26
|
+
* @property {WccNamedImport[]} named — Named default imports
|
|
27
|
+
* @property {WccSideEffectImport[]} sideEffect — Side-effect imports
|
|
28
|
+
* @property {string} strippedSource — Script source with .wcc imports removed
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
// ── Regex patterns ───────────────────────────────────────────────────
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Matches any import statement that references a .wcc file.
|
|
35
|
+
* Captures the full import line for removal and classification.
|
|
36
|
+
*
|
|
37
|
+
* Groups:
|
|
38
|
+
* - Full match: the entire import statement line
|
|
39
|
+
*
|
|
40
|
+
* We use individual patterns below for classification.
|
|
41
|
+
*/
|
|
42
|
+
const WCC_IMPORT_LINE_RE = /^[ \t]*import\s+.*?['"]([^'"]+\.wcc)['"]\s*;?[ \t]*$/gm;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Named default import: import Identifier from './path.wcc'
|
|
46
|
+
*/
|
|
47
|
+
const NAMED_DEFAULT_RE = /^[ \t]*import\s+([$\w]+)\s+from\s+['"]([^'"]+\.wcc)['"]\s*;?[ \t]*$/;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Side-effect import: import './path.wcc'
|
|
51
|
+
*/
|
|
52
|
+
const SIDE_EFFECT_RE = /^[ \t]*import\s+['"]([^'"]+\.wcc)['"]\s*;?[ \t]*$/;
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Namespace import: import * as Foo from './path.wcc'
|
|
56
|
+
*/
|
|
57
|
+
const NAMESPACE_RE = /^[ \t]*import\s+\*\s+as\s+\w+\s+from\s+['"][^'"]+\.wcc['"]\s*;?[ \t]*$/;
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Named exports import: import { Foo } from './path.wcc'
|
|
61
|
+
* Also matches: import { Foo, Bar } from './path.wcc'
|
|
62
|
+
* Also matches: import { Foo as Bar } from './path.wcc'
|
|
63
|
+
*/
|
|
64
|
+
const NAMED_EXPORTS_RE = /^[ \t]*import\s+\{[^}]*\}\s+from\s+['"][^'"]+\.wcc['"]\s*;?[ \t]*$/;
|
|
65
|
+
|
|
66
|
+
// ── Helpers ──────────────────────────────────────────────────────────
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Rewrite a .wcc path to .js by replacing the extension.
|
|
70
|
+
* Preserves all relative path segments.
|
|
71
|
+
*
|
|
72
|
+
* @param {string} wccPath — e.g., '../shared/wcc-button.wcc'
|
|
73
|
+
* @returns {string} — e.g., '../shared/wcc-button.js'
|
|
74
|
+
*/
|
|
75
|
+
function rewriteExtension(wccPath) {
|
|
76
|
+
return wccPath.replace(/\.wcc$/, '.js');
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ── Main export ──────────────────────────────────────────────────────
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Extract all .wcc imports from a script source string.
|
|
83
|
+
* Validates import forms and rejects invalid patterns.
|
|
84
|
+
*
|
|
85
|
+
* @param {string} source — Script block content
|
|
86
|
+
* @param {string} fileName — Source file name for error messages
|
|
87
|
+
* @returns {WccImportResult}
|
|
88
|
+
* @throws {Error} with code 'INVALID_WCC_IMPORT' for namespace/named exports
|
|
89
|
+
*/
|
|
90
|
+
export function extractWccImports(source, fileName) {
|
|
91
|
+
/** @type {WccNamedImport[]} */
|
|
92
|
+
const named = [];
|
|
93
|
+
/** @type {WccSideEffectImport[]} */
|
|
94
|
+
const sideEffect = [];
|
|
95
|
+
|
|
96
|
+
// Collect all .wcc import lines for processing
|
|
97
|
+
const lines = source.split('\n');
|
|
98
|
+
/** @type {Set<number>} */
|
|
99
|
+
const linesToRemove = new Set();
|
|
100
|
+
|
|
101
|
+
for (let i = 0; i < lines.length; i++) {
|
|
102
|
+
const line = lines[i];
|
|
103
|
+
|
|
104
|
+
// Check if this line contains a .wcc import
|
|
105
|
+
if (!line.match(/import\s.*\.wcc['"]/) && !line.match(/import\s+['"][^'"]+\.wcc['"]/)) {
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Check for invalid forms first
|
|
110
|
+
if (NAMESPACE_RE.test(line)) {
|
|
111
|
+
const error = new Error(
|
|
112
|
+
`Invalid import form in '${fileName}': .wcc files only support default imports (import Foo from './foo.wcc') or side-effect imports (import './foo.wcc')`
|
|
113
|
+
);
|
|
114
|
+
/** @ts-expect-error — custom error code for programmatic handling */
|
|
115
|
+
error.code = 'INVALID_WCC_IMPORT';
|
|
116
|
+
throw error;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (NAMED_EXPORTS_RE.test(line)) {
|
|
120
|
+
const error = new Error(
|
|
121
|
+
`Invalid import form in '${fileName}': .wcc files only support default imports (import Foo from './foo.wcc') or side-effect imports (import './foo.wcc')`
|
|
122
|
+
);
|
|
123
|
+
/** @ts-expect-error — custom error code for programmatic handling */
|
|
124
|
+
error.code = 'INVALID_WCC_IMPORT';
|
|
125
|
+
throw error;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Check for named default import
|
|
129
|
+
const namedMatch = line.match(NAMED_DEFAULT_RE);
|
|
130
|
+
if (namedMatch) {
|
|
131
|
+
const identifier = namedMatch[1];
|
|
132
|
+
const sourcePath = namedMatch[2];
|
|
133
|
+
named.push({
|
|
134
|
+
identifier,
|
|
135
|
+
sourcePath,
|
|
136
|
+
compiledPath: rewriteExtension(sourcePath),
|
|
137
|
+
});
|
|
138
|
+
linesToRemove.add(i);
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Check for side-effect import
|
|
143
|
+
const sideEffectMatch = line.match(SIDE_EFFECT_RE);
|
|
144
|
+
if (sideEffectMatch) {
|
|
145
|
+
const sourcePath = sideEffectMatch[1];
|
|
146
|
+
sideEffect.push({
|
|
147
|
+
sourcePath,
|
|
148
|
+
compiledPath: rewriteExtension(sourcePath),
|
|
149
|
+
});
|
|
150
|
+
linesToRemove.add(i);
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Build stripped source by removing .wcc import lines
|
|
156
|
+
const strippedLines = lines.filter((_, i) => !linesToRemove.has(i));
|
|
157
|
+
const strippedSource = strippedLines.join('\n');
|
|
158
|
+
|
|
159
|
+
return { named, sideEffect, strippedSource };
|
|
160
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Template Normalizer — pre-processes WCC template HTML before DOM parsing.
|
|
3
|
+
*
|
|
4
|
+
* Handles two transformations:
|
|
5
|
+
* 1. PascalCase tags → kebab-case (e.g. <WccBadge> → <wcc-badge>)
|
|
6
|
+
* 2. Self-closing custom elements → open+close (e.g. <wcc-badge /> → <wcc-badge></wcc-badge>)
|
|
7
|
+
*
|
|
8
|
+
* Must run BEFORE linkedom/jsdom parsing because HTML parsers:
|
|
9
|
+
* - Lowercase all tag names (losing PascalCase word boundaries)
|
|
10
|
+
* - Don't recognize self-closing syntax for non-void elements
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
// HTML void elements that are legitimately self-closing
|
|
14
|
+
const VOID_ELEMENTS = new Set([
|
|
15
|
+
'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input',
|
|
16
|
+
'link', 'meta', 'param', 'source', 'track', 'wbr',
|
|
17
|
+
]);
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Convert a PascalCase tag name to kebab-case.
|
|
21
|
+
* e.g. "WccBadge" → "wcc-badge", "WccCardHeader" → "wcc-card-header"
|
|
22
|
+
*
|
|
23
|
+
* Only converts if the name starts with an uppercase letter (PascalCase).
|
|
24
|
+
*
|
|
25
|
+
* @param {string} name
|
|
26
|
+
* @returns {string}
|
|
27
|
+
*/
|
|
28
|
+
export function pascalToKebab(name) {
|
|
29
|
+
// Insert hyphen before each uppercase letter that follows a lowercase letter or digit
|
|
30
|
+
return name
|
|
31
|
+
.replace(/([a-z0-9])([A-Z])/g, '$1-$2')
|
|
32
|
+
.toLowerCase();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Check if a tag name is PascalCase (starts with uppercase, has at least
|
|
37
|
+
* one more uppercase letter indicating a word boundary).
|
|
38
|
+
*
|
|
39
|
+
* @param {string} name
|
|
40
|
+
* @returns {boolean}
|
|
41
|
+
*/
|
|
42
|
+
export function isPascalCase(name) {
|
|
43
|
+
return /^[A-Z][a-zA-Z0-9]*[A-Z][a-zA-Z0-9]*$/.test(name);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Normalize a WCC template string:
|
|
48
|
+
* 1. Convert PascalCase tags to kebab-case (validated against importMap if provided)
|
|
49
|
+
* 2. Expand self-closing custom elements to open+close pairs
|
|
50
|
+
*
|
|
51
|
+
* @param {string} html — Raw template HTML
|
|
52
|
+
* @param {object} [options]
|
|
53
|
+
* @param {Map<string, string>} [options.importMap] — PascalCase identifier → kebab-case tag
|
|
54
|
+
* @param {string} [options.fileName] — Source file for error messages
|
|
55
|
+
* @returns {string} — Normalized HTML ready for DOM parsing
|
|
56
|
+
* @throws {Error} with code 'UNRESOLVED_COMPONENT' if PascalCase tag has no matching import
|
|
57
|
+
*/
|
|
58
|
+
export function normalizeTemplate(html, options) {
|
|
59
|
+
const { importMap, fileName } = options || {};
|
|
60
|
+
|
|
61
|
+
// Match opening tags (including self-closing): <TagName ...> or <TagName ... />
|
|
62
|
+
// Also match closing tags: </TagName>
|
|
63
|
+
//
|
|
64
|
+
// Regex breakdown:
|
|
65
|
+
// < — opening angle bracket
|
|
66
|
+
// (\/?)? — optional slash (closing tag)
|
|
67
|
+
// ([A-Za-z][\w-]*) — tag name
|
|
68
|
+
// ((?:\s[^>]*)?) — attributes (anything that's not >)
|
|
69
|
+
// (\s*\/)? — optional self-closing slash
|
|
70
|
+
// > — closing angle bracket
|
|
71
|
+
const TAG_RE = /<(\/?)([A-Za-z][\w-]*)((?:\s[^>]*?)?)(\/?)>/g;
|
|
72
|
+
|
|
73
|
+
return html.replace(TAG_RE, (match, closingSlash, tagName, attrs, selfClosing) => {
|
|
74
|
+
let normalizedTag = tagName;
|
|
75
|
+
|
|
76
|
+
// Step 1: Convert PascalCase to kebab-case
|
|
77
|
+
if (isPascalCase(tagName)) {
|
|
78
|
+
if (importMap) {
|
|
79
|
+
// Validate against the import map
|
|
80
|
+
if (importMap.has(tagName)) {
|
|
81
|
+
normalizedTag = importMap.get(tagName);
|
|
82
|
+
} else {
|
|
83
|
+
const error = new Error(
|
|
84
|
+
`Unresolved component '<${tagName}>' in '${fileName || 'unknown'}'. Did you forget to import it?`
|
|
85
|
+
);
|
|
86
|
+
error.code = 'UNRESOLVED_COMPONENT';
|
|
87
|
+
throw error;
|
|
88
|
+
}
|
|
89
|
+
} else {
|
|
90
|
+
// Backward compatible: convert all PascalCase to kebab-case
|
|
91
|
+
normalizedTag = pascalToKebab(tagName);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Step 2: Handle self-closing tags
|
|
96
|
+
if (selfClosing === '/') {
|
|
97
|
+
// If it's a void element, keep it self-closing
|
|
98
|
+
if (VOID_ELEMENTS.has(normalizedTag.toLowerCase())) {
|
|
99
|
+
return `<${closingSlash}${normalizedTag}${attrs} />`;
|
|
100
|
+
}
|
|
101
|
+
// Otherwise expand to open+close pair (trim trailing whitespace from attrs)
|
|
102
|
+
const trimmedAttrs = attrs.trimEnd();
|
|
103
|
+
return `<${normalizedTag}${trimmedAttrs}></${normalizedTag}>`;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Regular open or close tag — just replace the name
|
|
107
|
+
return `<${closingSlash}${normalizedTag}${attrs}>`;
|
|
108
|
+
});
|
|
109
|
+
}
|
package/lib/types.js
CHANGED
|
@@ -221,8 +221,10 @@
|
|
|
221
221
|
|
|
222
222
|
/**
|
|
223
223
|
* @typedef {Object} ChildComponentImport
|
|
224
|
-
* @property {string} tag — Child component tag name
|
|
224
|
+
* @property {string} tag — Child component tag name (kebab-case, used in template)
|
|
225
|
+
* @property {string} identifier — Import identifier (PascalCase, e.g., 'WccBadge')
|
|
225
226
|
* @property {string} importPath — Relative import path (e.g., './wcc-badge.js')
|
|
227
|
+
* @property {boolean} sideEffect — true if this is a side-effect import (no identifier)
|
|
226
228
|
*/
|
|
227
229
|
|
|
228
230
|
/**
|
package/package.json
CHANGED