@stencil/angular-output-target
Version:
Angular output target for @stencil/core components.
335 lines (334 loc) • 16 kB
JavaScript
import { Project, SyntaxKind } from 'ts-morph';
import { access, mkdir, readFile, writeFile } from 'node:fs/promises';
import { join, relative, dirname } from 'node:path';
// ---------------------------------------------------------------------------
// ts-morph helpers
// ---------------------------------------------------------------------------
function amendStencilConfig(configPath, targetCode) {
const project = new Project({ skipAddingFilesFromTsConfig: true });
const src = project.addSourceFileAtPath(configPath);
if (!src.getImportDeclaration('@stencil/angular-output-target')) {
src.addImportDeclaration({
moduleSpecifier: '@stencil/angular-output-target',
namedImports: ['angularOutputTarget'],
});
}
const existingProp = src
.getDescendantsOfKind(SyntaxKind.PropertyAssignment)
.find((p) => p.getName() === 'outputTargets');
if (!existingProp) {
const configObj = src.getVariableDeclaration('config')?.getInitializerIfKind(SyntaxKind.ObjectLiteralExpression) ??
src
.getDescendantsOfKind(SyntaxKind.ObjectLiteralExpression)
.find((obj) => obj.getProperty('namespace') !== undefined);
if (!configObj)
throw new Error('Could not find Stencil config object in stencil.config.ts');
configObj.addPropertyAssignment({ name: 'outputTargets', initializer: '[]' });
}
const prop = src.getDescendantsOfKind(SyntaxKind.PropertyAssignment).find((p) => p.getName() === 'outputTargets');
const arr = prop.getInitializerIfKind(SyntaxKind.ArrayLiteralExpression);
if (!arr)
throw new Error('outputTargets is not an array literal in stencil.config.ts');
const existing = arr.getText();
const alreadyAdded = existing.includes('angularOutputTarget(');
// Filter out any existing angularOutputTarget call so it can be replaced cleanly
const elements = arr
.getElements()
.map((e) => e.getText().trim())
.filter((e) => !e.includes('angularOutputTarget('));
const hasStandalone = existing.includes("type: 'standalone'") ||
existing.includes('type: "standalone"') ||
existing.includes("type: 'dist-custom-elements'") ||
existing.includes('type: "dist-custom-elements"');
if (!hasStandalone)
elements.push("{ type: 'standalone' }");
elements.push(targetCode);
prop.setInitializer(`[\n${elements.map((e) => ` ${e}`).join(',\n')},\n]`);
src.formatText();
src.saveSync();
return !alreadyAdded;
}
// ---------------------------------------------------------------------------
// pnpm build approval
// ---------------------------------------------------------------------------
async function allowPnpmBuild(pkg, rootDir, workspaceRoot) {
const base = workspaceRoot ?? rootDir;
const workspaceYaml = join(base, 'pnpm-workspace.yaml');
if (await pathExists(workspaceYaml)) {
let content = await readFile(workspaceYaml, 'utf8');
// onlyBuiltDependencies (list form)
if (!content.includes(`- '${pkg}'`) && !content.includes(`- "${pkg}"`) && !content.includes(`- ${pkg}\n`)) {
if (content.includes('onlyBuiltDependencies:')) {
content = content.replace(/^(onlyBuiltDependencies:[ \t]*\n)/m, `$1 - ${pkg}\n`);
}
else {
content += `\nonlyBuiltDependencies:\n - ${pkg}\n`;
}
}
// allowBuilds (map form, pnpm 11+)
if (!content.includes(`${pkg}: true`)) {
if (content.includes('allowBuilds:')) {
content = content.replace(/^(allowBuilds:[ \t]*\n)/m, `$1 ${pkg}: true\n`);
}
else {
content += `\nallowBuilds:\n ${pkg}: true\n`;
}
}
await writeFile(workspaceYaml, content, 'utf8');
return;
}
// Non-workspace: amend root package.json pnpm field
const rootPkgPath = join(base, 'package.json');
try {
const rootPkg = JSON.parse(await readFile(rootPkgPath, 'utf8'));
const existingList = rootPkg?.pnpm?.onlyBuiltDependencies ?? [];
const existingMap = rootPkg?.pnpm?.allowBuilds ?? {};
rootPkg.pnpm = {
...rootPkg.pnpm,
onlyBuiltDependencies: existingList.includes(pkg) ? existingList : [...existingList, pkg],
allowBuilds: { ...existingMap, [pkg]: true },
};
await writeFile(rootPkgPath, JSON.stringify(rootPkg, null, 2) + '\n', 'utf8');
}
catch {
// no root package.json — nothing to amend
}
}
// ---------------------------------------------------------------------------
// Wrapper package scaffolding
// ---------------------------------------------------------------------------
async function pathExists(p) {
try {
await access(p);
return true;
}
catch {
return false;
}
}
function toPascalCase(s) {
return s
.split(/[-_]/)
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join('');
}
async function scaffoldWrapperPackage(wrapperDir, packageName, corePackageName, corePkgVersion, namespace, outputType) {
const libDir = join(wrapperDir, 'src', 'lib');
await mkdir(libDir, { recursive: true });
const className = toPascalCase(namespace);
const pkgJson = {
name: packageName,
version: '0.0.1',
scripts: { build: 'ng-packagr -p ng-package.json' },
peerDependencies: {
'@angular/common': '>=19',
'@angular/core': '>=19',
},
dependencies: { [corePackageName]: corePkgVersion },
devDependencies: {
'@angular/common': '>=19',
'@angular/core': '>=19',
'@angular/compiler': '>=19',
'@angular/compiler-cli': '>=19',
'ng-packagr': '>=19',
rxjs: '>=6',
tslib: '>=2',
typescript: '>=4 <7',
},
};
const ngPackage = {
lib: { entryFile: 'src/index.ts' },
deleteDestPath: false,
allowedNonPeerDependencies: [corePackageName],
};
const tsConfig = {
compilerOptions: {
target: 'ES2022',
module: 'ES2022',
moduleResolution: 'bundler',
experimentalDecorators: true,
strict: true,
skipLibCheck: true,
},
include: ['src/**/*.ts'],
};
const tsConfigLib = {
extends: './tsconfig.json',
compilerOptions: { declaration: false, inlineSources: true },
angularCompilerOptions: { compilationMode: 'partial' },
};
// NgModule file — only needed for 'component' outputType
let ngModuleTs;
if (outputType === 'component') {
ngModuleTs =
`import { NgModule } from '@angular/core';\n` +
`import { DIRECTIVES } from './directives.array';\n\n` +
`@NgModule({\n` +
` declarations: [...DIRECTIVES],\n` +
` exports: [...DIRECTIVES],\n` +
`})\n` +
`export class ${className}Module {}\n`;
}
// src/index.ts — public API surface
// directives.ts (the directivesProxyFile) sits at src/lib/directives.ts
let indexTs = `// Public API — re-exports generated by @stencil/angular-output-target.\n`;
indexTs += `export * from './lib/directives';\n`;
if (outputType === 'component') {
indexTs += `export * from './lib/${namespace}.module';\n`;
}
await Promise.all([
writeFile(join(wrapperDir, '.gitignore'), 'dist/\nnode_modules/\n.angular/\n', 'utf8'),
writeFile(join(wrapperDir, 'package.json'), JSON.stringify(pkgJson, null, 2) + '\n', 'utf8'),
writeFile(join(wrapperDir, 'ng-package.json'), JSON.stringify(ngPackage, null, 2) + '\n', 'utf8'),
writeFile(join(wrapperDir, 'tsconfig.json'), JSON.stringify(tsConfig, null, 2) + '\n', 'utf8'),
writeFile(join(wrapperDir, 'tsconfig.lib.json'), JSON.stringify(tsConfigLib, null, 2) + '\n', 'utf8'),
writeFile(join(wrapperDir, 'src', 'index.ts'), indexTs, 'utf8'),
...(ngModuleTs ? [writeFile(join(libDir, `${namespace}.module.ts`), ngModuleTs, 'utf8')] : []),
]);
}
// ---------------------------------------------------------------------------
// Wizard
// ---------------------------------------------------------------------------
export const wizard = {
init: {
id: '@stencil/angular-output-target',
displayName: 'Angular',
description: 'Angular component wrappers for your Stencil components',
async run({ config, workspaceRoot, prompts, nypm }) {
const { intro, outro, text, select, confirm, spinner, isCancel, cancel, log } = prompts;
intro('Angular output target');
const stencilConfigPath = join(config.rootDir, 'stencil.config.ts');
// Guard: already configured?
const guardProject = new Project({ skipAddingFilesFromTsConfig: true });
const alreadyConfigured = guardProject
.addSourceFileAtPath(stencilConfigPath)
.getDescendantsOfKind(SyntaxKind.PropertyAssignment)
.find((p) => p.getName() === 'outputTargets')
?.getInitializerIfKind(SyntaxKind.ArrayLiteralExpression)
?.getText()
.includes('angularOutputTarget(');
if (alreadyConfigured) {
const redo = await confirm({
message: 'angularOutputTarget is already in stencil.config.ts — reconfigure?',
initialValue: false,
});
if (isCancel(redo) || !redo) {
cancel('Skipping Angular setup.');
return;
}
}
// Where should the wrapper package live?
let wrapperDir;
const defaultName = `${config.fsNamespace}-angular`;
if (workspaceRoot) {
const rawName = await text({
message: 'Wrapper package name?',
placeholder: defaultName,
defaultValue: defaultName,
});
if (isCancel(rawName)) {
cancel('Setup cancelled.');
return;
}
wrapperDir = join(dirname(config.rootDir), rawName);
}
else {
const defaultRel = `../${config.fsNamespace}-angular`;
const rawDir = await text({
message: 'Wrapper package directory? (relative to stencil.config.ts)',
placeholder: defaultRel,
defaultValue: defaultRel,
});
if (isCancel(rawDir)) {
cancel('Setup cancelled.');
return;
}
wrapperDir = join(config.rootDir, rawDir);
}
// Angular component type — drives what we scaffold
const outputType = await select({
message: 'Angular component type?',
options: [
{ value: 'standalone', label: 'Standalone', hint: 'Modern Angular (v19+) — no NgModule' },
{ value: 'component', label: 'NgModule', hint: 'Classic — all components in one module' },
{ value: 'scam', label: 'SCAM', hint: 'One NgModule per component' },
],
});
if (isCancel(outputType)) {
cancel('Setup cancelled.');
return;
}
const selectedOutputType = outputType;
// Derive stencil.config-relative paths from the wrapper location
const directivesProxyFile = relative(config.rootDir, join(wrapperDir, 'src', 'lib', 'directives.ts')).replace(/\\/g, '/');
const directivesArrayFile = selectedOutputType === 'component'
? relative(config.rootDir, join(wrapperDir, 'src', 'lib', 'directives.array.ts')).replace(/\\/g, '/')
: undefined;
const componentCorePackage = config.fsNamespace;
// Scaffold wrapper package if the directory doesn't exist yet
const shouldScaffold = !(await pathExists(wrapperDir));
if (shouldScaffold) {
const s = spinner();
s.start(`Scaffolding wrapper package at ${relative(workspaceRoot ?? config.rootDir, wrapperDir)}`);
try {
const corePkgVersion = workspaceRoot ? 'workspace:*' : `file:${relative(wrapperDir, config.rootDir)}`;
await scaffoldWrapperPackage(wrapperDir, `${config.fsNamespace}-angular`, componentCorePackage, corePkgVersion, config.fsNamespace, selectedOutputType);
s.stop('Wrapper package scaffolded');
}
catch (e) {
s.stop('Scaffolding failed — continuing');
log.warn(`Could not scaffold wrapper package: ${e}`);
}
}
// Ensure esbuild (used by ng-packagr) is allowed to run install scripts in pnpm workspaces
try {
await allowPnpmBuild('esbuild', config.rootDir, workspaceRoot);
}
catch {
// non-fatal — user can run `pnpm approve-builds` manually
}
// Derive customElementsDir from an existing standalone target, or use the v5 default
const standaloneTarget = config.outputTargets?.find((o) => o.type === 'standalone');
const customElementsDir = standaloneTarget?.dir
? relative(config.rootDir, standaloneTarget.dir).replace(/\\/g, '/')
: 'dist/standalone';
// Build target config
const lines = [
`componentCorePackage: '${componentCorePackage}'`,
`customElementsDir: '${customElementsDir}'`,
`directivesProxyFile: '${directivesProxyFile}'`,
...(directivesArrayFile ? [`directivesArrayFile: '${directivesArrayFile}'`] : []),
...(selectedOutputType !== 'standalone' ? [`outputType: '${selectedOutputType}'`] : []),
];
const targetCode = `angularOutputTarget({\n ${lines.join(',\n ')},\n})`;
// Amend stencil.config.ts in one pass so formatText() sees the final array
try {
const added = amendStencilConfig(stencilConfigPath, targetCode);
log.success(added ? 'stencil.config.ts updated' : 'angularOutputTarget already present — no changes made');
}
catch (e) {
log.warn(`Could not automatically update stencil.config.ts (${e}). Add manually:\n\n` +
`import { angularOutputTarget } from '@stencil/angular-output-target';\n` +
`// in outputTargets:\n${targetCode}`);
}
// Install @stencil/angular-output-target as a devDep of the core package
log.info('Installing @stencil/angular-output-target...');
await nypm.addDependency(['@stencil/angular-output-target'], { cwd: config.rootDir, dev: true });
log.success('Installed @stencil/angular-output-target');
if (shouldScaffold && (await pathExists(wrapperDir))) {
log.info('Installing Angular dependencies in wrapper package...');
await nypm.addDependency([
'@angular/core',
'@angular/common',
'@angular/compiler',
'@angular/compiler-cli',
'ng-packagr',
'rxjs',
'tslib',
], { cwd: wrapperDir, dev: true });
log.success('Angular dependencies installed');
}
outro('Angular output target configured');
},
},
};