@qwik-custom-elements/core 1.0.0 → 1.0.2

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 CHANGED
@@ -1,6 +1,20 @@
1
1
  # @qwik-custom-elements/core
2
2
 
3
- Deterministic generation orchestration for Qwik custom-element wrappers.
3
+ Qwik does not natively provide server-side rendering support for custom elements. Without additional tooling, Web Components render as empty tags on the server, lose SSR benefits, and require manual boilerplate to integrate typed props and events. `@qwik-custom-elements/core` is the CLI and orchestration layer that generates typed, SSR-capable Qwik wrapper components from a Custom Elements Manifest — giving you correct server output, full type safety, and automatic event wiring, with no per-component boilerplate.
4
+
5
+ `core` does not contain framework-specific logic. Instead, it delegates generated output to an adapter that understands your component library (Stencil, Lit, etc.). You configure a project once; the CLI does the rest.
6
+
7
+ ## Ecosystem
8
+
9
+ This package is part of the `qwik-custom-elements` toolchain:
10
+
11
+ | Package | Role |
12
+ | --------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ |
13
+ | **`@qwik-custom-elements/core`** _(this package)_ | CLI entry point and adapter-agnostic orchestration: config loading, CEM parsing, write planning, diagnostics |
14
+ | [**`@qwik-custom-elements/adapter-stencil`**](../adapter-stencil/README.md) | Stencil-specific generation: SSR/CSR bridge components, typed Qwik wrappers, hydrate runtime integration |
15
+ | [**`@qwik-custom-elements/adapter-lit`**](../adapter-lit/README.md) | Lit-specific generation: SSR/CSR bridge components, typed Qwik wrappers, Declarative Shadow DOM hydration |
16
+
17
+ Install `core` alongside whichever adapter matches your component library. You will interact with `core` only through the config file and the CLI — all framework-specific behavior lives in the adapter.
4
18
 
5
19
  ## Install
6
20
 
@@ -77,6 +91,6 @@ Adapters are responsible for returning the generated file set for their framewor
77
91
 
78
92
  Changes that alter core or adapter ownership boundaries should be reflected in:
79
93
 
80
- - `docs/SYSTEM/decisions.md`
81
- - `docs/SYSTEM/findings-log.md`
94
+ - an ADR in `docs/adr/` (system-wide) or the affected package's `docs/adr/` (context-specific)
95
+ - the affected `CONTEXT.md` if new domain terms are introduced
82
96
  - the package README files for any affected packages
@@ -1,9 +1,25 @@
1
1
  import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
2
2
  import os from 'node:os';
3
3
  import path from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
4
5
  import { afterEach, describe, expect, it, vi } from 'vitest';
5
6
  import { parseCliArgs, runCli } from '../cli.js';
6
7
  import { ConfigValidationError, loadGeneratorConfig } from '../config.js';
8
+ async function readVersionFromPackageJson(packageJsonPath) {
9
+ try {
10
+ const packageJsonText = await readFile(packageJsonPath, 'utf8');
11
+ const packageJson = JSON.parse(packageJsonText);
12
+ if (packageJson != null &&
13
+ typeof packageJson.version === 'string' &&
14
+ packageJson.version.length > 0) {
15
+ return packageJson.version;
16
+ }
17
+ return 'unknown';
18
+ }
19
+ catch {
20
+ return 'unknown';
21
+ }
22
+ }
7
23
  async function withTempDir(run) {
8
24
  const tempDir = await mkdtemp(path.join(os.tmpdir(), 'qce-core-'));
9
25
  try {
@@ -519,10 +535,15 @@ describe('runCli', () => {
519
535
  supportsSsrProbe: true,
520
536
  ssrRuntimeSubpath: './ssr',
521
537
  });
522
- expect(summary.projects[0].resolvedCoreVersion).toBe('1.0.0');
523
- expect(summary.projects[1].resolvedCoreVersion).toBe('1.0.0');
524
- expect(summary.projects[0].resolvedAdapterVersion).toBe('1.0.0');
525
- expect(summary.projects[1].resolvedAdapterVersion).toBe('1.0.0');
538
+ // Read actual versions from package.json to make test resilient to version bumps
539
+ const corePackageJsonPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..', 'package.json');
540
+ const adapterPackageJsonPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..', '..', 'adapter-stencil', 'package.json');
541
+ const expectedCoreVersion = await readVersionFromPackageJson(corePackageJsonPath);
542
+ const expectedAdapterVersion = await readVersionFromPackageJson(adapterPackageJsonPath);
543
+ expect(summary.projects[0].resolvedCoreVersion).toBe(expectedCoreVersion);
544
+ expect(summary.projects[1].resolvedCoreVersion).toBe(expectedCoreVersion);
545
+ expect(summary.projects[0].resolvedAdapterVersion).toBe(expectedAdapterVersion);
546
+ expect(summary.projects[1].resolvedAdapterVersion).toBe(expectedAdapterVersion);
526
547
  }
527
548
  finally {
528
549
  process.chdir(previousCwd);
@@ -945,6 +945,37 @@ describe('generateFromConfig', () => {
945
945
  });
946
946
  });
947
947
  });
948
+ it('includes adapter-provided guidance in QCE_PACKAGE_NAME_CEM_NOT_FOUND when hook returns a hint', async () => {
949
+ await withTempDir(async (tempDir) => {
950
+ const packageName = '@demo/stencil-no-cem';
951
+ const _packageRoot = await createFixturePackage(tempDir, packageName);
952
+ // Mock adapter with the hint hook
953
+ await writeFile(path.join(tempDir, 'adapter-with-hint-hook.mjs'), [
954
+ 'export const metadata = {',
955
+ " supportedSourceTypes: ['PACKAGE_NAME'],",
956
+ '};',
957
+ 'export function buildMissingCemHint({ packageRoot }) {',
958
+ ' return " Hint from adapter! ";',
959
+ '}',
960
+ ].join('\n'), 'utf8');
961
+ const config = {
962
+ dryRun: true,
963
+ projects: [
964
+ {
965
+ id: 'stencil-demo',
966
+ adapter: 'stencil',
967
+ adapterPackage: './adapter-with-hint-hook.mjs',
968
+ source: { type: 'PACKAGE_NAME', packageName },
969
+ outDir: './src/generated',
970
+ },
971
+ ],
972
+ };
973
+ await expect(generateFromConfig(config, { cwd: tempDir })).rejects.toMatchObject({
974
+ code: 'QCE_PACKAGE_NAME_CEM_NOT_FOUND',
975
+ message: expect.stringContaining('Hint from adapter!'),
976
+ });
977
+ });
978
+ });
948
979
  it('fails deterministically when PACKAGE_NAME CEM discovery is ambiguous', async () => {
949
980
  await withTempDir(async (tempDir) => {
950
981
  const packageName = '@demo/components';
@@ -1329,3 +1360,46 @@ describe('generateFromConfig', () => {
1329
1360
  });
1330
1361
  });
1331
1362
  });
1363
+ it('resolves package root for PACKAGE_NAME source with strict exports map (no ./package.json)', async () => {
1364
+ await withTempDir(async (tempDir) => {
1365
+ const packageName = '@demo/strict-exports-lib';
1366
+ const packageRoot = path.join(tempDir, 'node_modules', '@demo', 'strict-exports-lib');
1367
+ await mkdir(path.join(packageRoot, 'dist'), { recursive: true });
1368
+ // package.json has strict exports — no ./package.json export
1369
+ await writeFile(path.join(packageRoot, 'package.json'), JSON.stringify({
1370
+ name: packageName,
1371
+ version: '1.0.0',
1372
+ exports: {
1373
+ '.': './dist/index.js',
1374
+ },
1375
+ }), 'utf8');
1376
+ // A real CJS-style file so require.resolve can find it
1377
+ await writeFile(path.join(packageRoot, 'dist', 'index.js'), 'exports.noop = () => undefined;\n', 'utf8');
1378
+ await writeFile(path.join(packageRoot, 'custom-elements.json'), JSON.stringify({
1379
+ modules: [{ declarations: [{ tagName: 'strict-button' }] }],
1380
+ }), 'utf8');
1381
+ await writeFile(path.join(tempDir, 'adapter-minimal.mjs'), [
1382
+ 'export const metadata = {',
1383
+ " supportedSourceTypes: ['PACKAGE_NAME'],",
1384
+ '};',
1385
+ 'export function createGeneratedOutput() { return []; }',
1386
+ '',
1387
+ ].join('\n'), 'utf8');
1388
+ const config = {
1389
+ dryRun: true,
1390
+ projects: [
1391
+ {
1392
+ id: 'demo',
1393
+ adapter: 'stencil',
1394
+ adapterPackage: './adapter-minimal.mjs',
1395
+ source: { type: 'PACKAGE_NAME', packageName },
1396
+ outDir: './src/generated',
1397
+ },
1398
+ ],
1399
+ };
1400
+ const result = await generateFromConfig(config, { cwd: tempDir });
1401
+ expect(result.projects[0].status).toBe('success');
1402
+ expect(result.projects[0].componentTags).toEqual(['strict-button']);
1403
+ expect(result.projects[0].sourcePath).toBe(path.join(packageRoot, 'custom-elements.json'));
1404
+ });
1405
+ });
package/dist/generator.js CHANGED
@@ -1,4 +1,4 @@
1
- import { existsSync } from 'node:fs';
1
+ import { existsSync, readFileSync } from 'node:fs';
2
2
  import { mkdir, readFile, writeFile } from 'node:fs/promises';
3
3
  import { createRequire } from 'node:module';
4
4
  import path from 'node:path';
@@ -87,7 +87,7 @@ async function generateProject(project, cwd, dryRun) {
87
87
  await validateAdapterProject(project, adapterModule);
88
88
  const runtimeImportResult = await resolveAdapterRuntimeImports(project, adapterModule, cwd);
89
89
  const { runtimeImports } = runtimeImportResult;
90
- const sourcePath = resolveProjectSourcePath(project.id, project.source, cwd);
90
+ const sourcePath = resolveProjectSourcePath(project.id, project.source, cwd, adapterModule);
91
91
  const outDirPath = path.resolve(cwd, project.outDir);
92
92
  const componentDefinitions = await readComponentDefinitionsFromCem(sourcePath);
93
93
  const augmentedComponentDefinitions = await augmentAdapterComponentDefinitions(componentDefinitions, adapterModule, runtimeImports, outDirPath, cwd);
@@ -423,7 +423,7 @@ function resolveSkippedProjectSourcePath(source, cwd) {
423
423
  ? `package:${source.packageName}`
424
424
  : `package:${source.packageName}#${source.cemPath}`;
425
425
  }
426
- function resolveProjectSourcePath(projectId, source, cwd) {
426
+ function resolveProjectSourcePath(projectId, source, cwd, adapterModule) {
427
427
  if (source.type === 'CEM') {
428
428
  return path.resolve(cwd, source.path);
429
429
  }
@@ -431,7 +431,7 @@ function resolveProjectSourcePath(projectId, source, cwd) {
431
431
  if (source.cemPath != null) {
432
432
  return resolvePackageNameOverrideCemPath(projectId, source, packageRoot);
433
433
  }
434
- return discoverPackageNameCemPath(projectId, source, packageRoot);
434
+ return discoverPackageNameCemPath(projectId, source, packageRoot, adapterModule);
435
435
  }
436
436
  function resolvePackageRootForProject(projectId, source, cwd) {
437
437
  const packageSpecifier = `${source.packageName}/package.json`;
@@ -439,8 +439,37 @@ function resolvePackageRootForProject(projectId, source, cwd) {
439
439
  const packageJsonPath = require.resolve(packageSpecifier, { paths: [cwd] });
440
440
  return path.dirname(packageJsonPath);
441
441
  }
442
- catch (error) {
443
- throw new GenerationError('QCE_PACKAGE_NAME_RESOLVE_FAILED', `Project "${projectId}" could not resolve source package "${source.packageName}" from ${cwd}: ${toErrorMessage(error)}`);
442
+ catch (primaryError) {
443
+ // Fallback for packages with strict exports maps that don't expose ./package.json.
444
+ // Resolve via the main entry point, then walk up the directory tree to find
445
+ // the ancestor directory whose package.json `name` matches the package name.
446
+ // This approach is robust across npm, pnpm (virtual store), and Yarn PnP because
447
+ // it matches on the canonical `name` field rather than path segments.
448
+ try {
449
+ const mainEntry = require.resolve(source.packageName, { paths: [cwd] });
450
+ let dir = path.dirname(mainEntry);
451
+ while (true) {
452
+ const pkgJsonPath = path.join(dir, 'package.json');
453
+ if (existsSync(pkgJsonPath)) {
454
+ try {
455
+ const pkg = JSON.parse(readFileSync(pkgJsonPath, 'utf8'));
456
+ if (pkg.name === source.packageName)
457
+ return dir;
458
+ }
459
+ catch {
460
+ // Not a valid JSON package.json — keep walking up
461
+ }
462
+ }
463
+ const parent = path.dirname(dir);
464
+ if (parent === dir)
465
+ break; // Reached filesystem root with no match
466
+ dir = parent;
467
+ }
468
+ }
469
+ catch {
470
+ // Main entry also not resolvable — fall through to the original error
471
+ }
472
+ throw new GenerationError('QCE_PACKAGE_NAME_RESOLVE_FAILED', `Project "${projectId}" could not resolve source package "${source.packageName}" from ${cwd}: ${toErrorMessage(primaryError)}`);
444
473
  }
445
474
  }
446
475
  function resolvePackageNameOverrideCemPath(projectId, source, packageRoot) {
@@ -458,7 +487,7 @@ function resolvePackageNameOverrideCemPath(projectId, source, packageRoot) {
458
487
  }
459
488
  return resolvedCemPath;
460
489
  }
461
- function discoverPackageNameCemPath(projectId, source, packageRoot) {
490
+ function discoverPackageNameCemPath(projectId, source, packageRoot, adapterModule) {
462
491
  const candidatePaths = PACKAGE_NAME_CEM_DISCOVERY_CANDIDATES.map((candidate) => path.resolve(packageRoot, candidate));
463
492
  const existingCandidates = [];
464
493
  for (const candidatePath of candidatePaths) {
@@ -470,10 +499,25 @@ function discoverPackageNameCemPath(projectId, source, packageRoot) {
470
499
  return existingCandidates[0];
471
500
  }
472
501
  if (existingCandidates.length === 0) {
473
- throw new GenerationError('QCE_PACKAGE_NAME_CEM_NOT_FOUND', `Project "${projectId}" could not discover a CEM file for source package "${source.packageName}". Checked: ${PACKAGE_NAME_CEM_DISCOVERY_CANDIDATES.join(', ')}. Set source.cemPath to the manifest path relative to the package root.`);
502
+ const hint = tryAdapterMissingCemHintHook(adapterModule, packageRoot);
503
+ throw new GenerationError('QCE_PACKAGE_NAME_CEM_NOT_FOUND', `Project "${projectId}" could not discover a CEM file for source package "${source.packageName}". Checked: ${PACKAGE_NAME_CEM_DISCOVERY_CANDIDATES.join(', ')}. Set source.cemPath to the manifest path relative to the package root.${hint}`);
474
504
  }
475
505
  throw new GenerationError('QCE_PACKAGE_NAME_CEM_AMBIGUOUS', `Project "${projectId}" discovered multiple CEM candidates for source package "${source.packageName}": ${existingCandidates.join(', ')}. Set source.cemPath to disambiguate.`);
476
506
  }
507
+ function tryAdapterMissingCemHintHook(adapterModule, packageRoot) {
508
+ const hook = adapterModule.buildMissingCemHint;
509
+ if (typeof hook !== 'function') {
510
+ return '';
511
+ }
512
+ try {
513
+ const result = hook({ packageRoot });
514
+ return typeof result === 'string' ? result : '';
515
+ }
516
+ catch {
517
+ // Hook errors are non-fatal
518
+ return '';
519
+ }
520
+ }
477
521
  function resolveRuntimeImportSpecifier(specifier, cwd, packageRoot) {
478
522
  const resolver = specifier.startsWith('.') && packageRoot != null
479
523
  ? createRequire(path.join(packageRoot, 'package.json'))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@qwik-custom-elements/core",
3
- "version": "1.0.0",
3
+ "version": "1.0.2",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": {
@@ -26,6 +26,35 @@
26
26
  "publishConfig": {
27
27
  "access": "public"
28
28
  },
29
+ "description": "Generate typed, SSR-capable Qwik components from Web Component libraries (CLI + orchestration)",
30
+ "keywords": [
31
+ "qwik",
32
+ "web-components",
33
+ "custom-elements",
34
+ "ssr",
35
+ "server-side-rendering",
36
+ "wrapper",
37
+ "manifest",
38
+ "cli",
39
+ "generator",
40
+ "code-generation"
41
+ ],
42
+ "author": "Dmitry A. Efimenko (https://github.com/DmitryEfimenko)",
43
+ "license": "MIT",
44
+ "repository": {
45
+ "type": "git",
46
+ "url": "https://github.com/DmitryEfimenko/qwik-custom-elements.git",
47
+ "directory": "packages/core"
48
+ },
49
+ "homepage": "https://github.com/DmitryEfimenko/qwik-custom-elements#readme",
50
+ "bugs": {
51
+ "url": "https://github.com/DmitryEfimenko/qwik-custom-elements/issues"
52
+ },
53
+ "engines": {
54
+ "node": ">=20"
55
+ },
56
+ "funding": "https://github.com/sponsors/DmitryEfimenko",
57
+ "sideEffects": false,
29
58
  "scripts": {
30
59
  "build": "tsc -p tsconfig.json",
31
60
  "check-types": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.test.json --noEmit",