Merge pull request #26524 from backstage/hhogg/dynamic-metadata

Add ` backstage` field to export in package.json
This commit is contained in:
Harrison Hogg
2024-09-23 13:32:02 +01:00
committed by GitHub
7 changed files with 463 additions and 1 deletions
+1
View File
@@ -147,6 +147,7 @@
"swc-loader": "^0.2.3",
"tar": "^6.1.12",
"terser-webpack-plugin": "^5.1.3",
"ts-morph": "^23.0.0",
"util": "^0.12.3",
"webpack": "^5.70.0",
"webpack-dev-server": "^5.0.0",
@@ -0,0 +1,145 @@
/*
* Copyright 2024 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { PackageRole } from '@backstage/cli-node';
import { Project } from 'ts-morph';
import { BackstagePackageFeatureType } from '../typeDistProject';
const mockEntryPoint = 'dist/index.d.ts';
type CreateFeatureEnvironmentOptions = {
$$type?: BackstagePackageFeatureType;
format?:
| 'DefaultExportAssignment'
| 'DefaultExportFromFile'
| 'DefaultExportFromFileAsDefault'
| 'DefaultExportFromFileWithSibling';
role?: PackageRole;
};
type FeatureEnvironment = {
project: Project;
role: PackageRole;
dir: string;
entryPoint: string;
};
type File = {
path: string;
content: string;
};
const createTestType = ($$type: BackstagePackageFeatureType): File[] => [
{
path: './dist/createTestType.d.ts',
content: `
export interface TestType {
readonly $$type: '${$$type}';
};
export function createTestType(): TestType {
return {
$$type: '${$$type}',
};
};
`,
},
];
const createMockDefaultExportAssignment = (): File[] => [
{
path: mockEntryPoint,
content: `
declare const _default: import("./createTestType").TestType;
export default _default;
`,
},
];
const createMockDefaultExportFromFile = (): File[] => [
{
path: mockEntryPoint,
content: `export { default } from './linked';`,
},
{
path: './dist/linked.d.ts',
content: `
declare const _default: import("./createTestType").TestType;
export default _default;
`,
},
];
const createMockDefaultExportFromFileAsDefault = (): File[] => [
{
path: mockEntryPoint,
content: `export { test as default } from './linked';`,
},
{
path: './dist/linked.d.ts',
content: `
export declare const test: import("./createTestType").TestType;
`,
},
];
const createMockDefaultExportFromFileWithSibling = (): File[] => [
{
path: mockEntryPoint,
content: `export { default, test } from './linked';`,
},
{
path: './dist/linked.d.ts',
content: `
import { createTestType } from './createTestType';
export declare const test: import("./createTestType").TestType;
declare const _default: import("./createTestType").TestType;
export default _default;
`,
},
];
const formatToFiles = {
DefaultExportAssignment: createMockDefaultExportAssignment,
DefaultExportFromFile: createMockDefaultExportFromFile,
DefaultExportFromFileAsDefault: createMockDefaultExportFromFileAsDefault,
DefaultExportFromFileWithSibling: createMockDefaultExportFromFileWithSibling,
};
export default function createFeatureEnvironment(
options?: CreateFeatureEnvironmentOptions,
): FeatureEnvironment {
const {
$$type = '@backstage/BackendFeature',
format = 'DefaultExportAssignment',
role = 'backend-plugin',
} = options ?? {};
const project = new Project();
const files = [...createTestType($$type), ...formatToFiles[format]()];
for (const file of files) {
project.createSourceFile(file.path, file.content);
}
return {
project,
role,
dir: project.getFileSystem().getCurrentDirectory(),
entryPoint: mockEntryPoint,
};
}
@@ -19,6 +19,10 @@ import npmPackList from 'npm-packlist';
import { resolve as resolvePath, posix as posixPath } from 'path';
import { BackstagePackageJson } from '@backstage/cli-node';
import { readEntryPoints } from '../entryPoints';
import {
createTypeDistProject,
getEntryPointDefaultFeatureType,
} from '../typeDistProject';
const PKG_PATH = 'package.json';
const PKG_BACKUP_PATH = 'package.json-prepack';
@@ -147,20 +151,44 @@ async function prepareExportsEntryPoints(
>();
const entryPoints = readEntryPoints(pkg);
const project = await createTypeDistProject();
for (const entryPoint of entryPoints) {
if (!SCRIPT_EXTS.includes(entryPoint.ext)) {
outputExports[entryPoint.mount] = entryPoint.path;
continue;
}
const exp = {} as Record<string, string>;
let exp = {} as Record<string, string>;
for (const [key, ext] of Object.entries(EXPORT_MAP)) {
const name = `${entryPoint.name}${ext}`;
if (distFiles.includes(name)) {
exp[key] = `./${posixPath.join(`dist`, name)}`;
}
}
exp.default = exp.require ?? exp.import;
// Find the default export type for the entry point
if (exp.types) {
const defaultFeatureType =
pkg.backstage?.role &&
getEntryPointDefaultFeatureType(
pkg.backstage?.role,
packageDir,
project,
exp.types,
);
if (defaultFeatureType) {
// This ensures that the `backstage` field is at the top of the
// `exports` field in the package.json because order is important.
// https://nodejs.org/docs/latest-v20.x/api/packages.html#conditional-exports
exp = { backstage: defaultFeatureType, ...exp };
}
}
// This creates a directory with a lone package.json for backwards compatibility
if (entryPoint.mount === '.') {
if (exp.default) {
@@ -0,0 +1,131 @@
/*
* Copyright 2024 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { PackageRole } from '@backstage/cli-node';
import createFeatureEnvironment from './__testUtils__/createFeatureEnvironment';
import {
getEntryPointDefaultFeatureType,
BackstagePackageFeatureType,
} from './typeDistProject';
describe('typeDistProject', () => {
describe('for package role', () => {
// This Record makes sure we're checking all package roles
const packageRoles: Record<PackageRole, boolean> = {
// Allowed
'backend-plugin': true,
'backend-plugin-module': true,
'frontend-plugin': true,
'frontend-plugin-module': true,
'web-library': true,
'node-library': true,
// Disallowed
frontend: false,
backend: false,
cli: false,
'common-library': false,
};
const allowedPackageRoles = Object.keys(packageRoles).filter(
role => packageRoles[role as PackageRole],
);
const disallowedPackageRoles = Object.keys(packageRoles).filter(
role => !packageRoles[role as PackageRole],
);
it.each(allowedPackageRoles)(`returns features for %s`, r => {
const { project, role, dir, entryPoint } = createFeatureEnvironment({
role: r as PackageRole,
});
expect(
getEntryPointDefaultFeatureType(role, dir, project, entryPoint),
).toEqual('@backstage/BackendFeature');
});
it.each(disallowedPackageRoles)(`does not return features for %s`, r => {
const { project, role, dir, entryPoint } = createFeatureEnvironment({
role: r as PackageRole,
});
expect(
getEntryPointDefaultFeatureType(role, dir, project, entryPoint),
).toEqual(null);
});
});
describe('for feature $$type', () => {
// This Record makes sure we're checking all feature types
const featureTypes: Record<BackstagePackageFeatureType | string, boolean> =
{
// Allowed
'@backstage/BackendFeature': true,
'@backstage/BackstagePlugin': true,
'@backstage/FrontendPlugin': true,
'@backstage/FrontendModule': true,
// Disallowed
'@backstage/Extension': false,
'@backstage/RouteRef': false,
};
const allowedFeatureTypes = Object.keys(featureTypes).filter(
$$type => featureTypes[$$type as BackstagePackageFeatureType],
);
const disallowedFeatureTypes = Object.keys(featureTypes).filter(
$$type => !featureTypes[$$type as BackstagePackageFeatureType],
);
it.each(allowedFeatureTypes)(`returns features for "%s" $$type`, $$type => {
const { project, role, dir, entryPoint } = createFeatureEnvironment({
$$type: $$type as BackstagePackageFeatureType,
});
expect(
getEntryPointDefaultFeatureType(role, dir, project, entryPoint),
).toEqual($$type);
});
it.each(disallowedFeatureTypes)(
`does not return features for "%s" $$type`,
$$type => {
const { project, role, dir, entryPoint } = createFeatureEnvironment({
$$type: $$type as BackstagePackageFeatureType,
});
expect(
getEntryPointDefaultFeatureType(role, dir, project, entryPoint),
).toEqual(null);
},
);
});
it.each([
'DefaultExportAssignment',
'DefaultExportFromFile',
'DefaultExportFromFileAsDefault',
'DefaultExportFromFileWithSibling',
] as const)('returns features for format "%s"', format => {
const { project, role, dir, entryPoint } = createFeatureEnvironment({
format,
});
expect(
getEntryPointDefaultFeatureType(role, dir, project, entryPoint),
).toEqual('@backstage/BackendFeature');
});
});
+151
View File
@@ -0,0 +1,151 @@
/*
* Copyright 2024 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { PackageRole } from '@backstage/cli-node';
import { resolve as resolvePath } from 'path';
import { Project, SourceFile, SyntaxKind, ts, Type } from 'ts-morph';
import { paths } from './paths';
export const createTypeDistProject = async () => {
return new Project({
tsConfigFilePath: paths.resolveTargetRoot('tsconfig.json'),
skipAddingFilesFromTsConfig: true,
});
};
// A list of the package roles we want to extract features for
const targetPackageRoles: PackageRole[] = [
'backend-plugin',
'backend-plugin-module',
'frontend-plugin',
'frontend-plugin-module',
'web-library',
'node-library',
];
// A list of the feature types we want to extract from the project
// and include in the metadata
const targetFeatureTypes = [
'@backstage/BackendFeature',
'@backstage/BackstagePlugin',
'@backstage/FrontendPlugin',
'@backstage/FrontendModule',
] as const;
export type BackstagePackageFeatureType = (typeof targetFeatureTypes)[number];
export const getEntryPointDefaultFeatureType = (
role: PackageRole,
packageDir: string,
project: Project,
entryPoint: string,
): BackstagePackageFeatureType | null => {
if (isTargetPackageRole(role)) {
const distPath = resolvePath(packageDir, entryPoint);
try {
const defaultFeatureType = getSourceFileDefaultFeatureType(
project.addSourceFileAtPath(distPath),
);
if (defaultFeatureType) {
return defaultFeatureType;
}
} catch (error) {
console.error(
`Failed to extract default feature type from ${distPath}, ${error}. ` +
'Your package will publish fine but it may be missing metadata about its default feature.',
);
}
}
return null;
};
// Returns all exports (default and named) from an entry point
// that are valid Backstage package features
function getSourceFileDefaultFeatureType(
sourceFile: SourceFile,
): BackstagePackageFeatureType | null {
for (const exportSymbol of sourceFile.getExportSymbols()) {
const declaration = exportSymbol.getDeclarations()[0];
const exportName = declaration.getSymbol()?.getName();
if (exportName !== 'default') {
continue;
}
let exportType: Type<ts.Type> | undefined;
if (declaration) {
if (declaration.isKind(SyntaxKind.ExportAssignment)) {
exportType = declaration.getExpression().getType();
} else if (declaration.isKind(SyntaxKind.ExportSpecifier)) {
if (!declaration.isTypeOnly()) {
exportType = declaration.getType();
}
} else if (declaration.isKind(SyntaxKind.VariableDeclaration)) {
exportType = declaration.getType();
}
}
if (exportName && exportType) {
const $$type = getBackstagePackageFeature$$TypeFromType(exportType);
if ($$type) {
return $$type;
}
}
}
return null;
}
// Given a TS type, returns the Backstage package feature $$type value
function getBackstagePackageFeature$$TypeFromType(
type: Type,
): BackstagePackageFeatureType | null {
// Returns the concrete type of a generic type
const exportType = type.getTargetType() ?? type;
for (const property of exportType.getProperties()) {
if (property.getName() === '$$type') {
const $$type = property
.getValueDeclaration()
?.getText()
.match(/(\$\$type: '(?<type>.+)')/)?.groups?.type;
if ($$type && isTargetFeatureType($$type)) {
return $$type;
}
}
}
return null;
}
// Condition for a package role matches a target package role
function isTargetPackageRole(role: PackageRole): boolean {
return !!role && targetPackageRoles.includes(role);
}
// Returns whether an export is a valid Backstage package feature type
function isTargetFeatureType(
type: string | BackstagePackageFeatureType,
): type is BackstagePackageFeatureType {
return (
!!type && targetFeatureTypes.includes(type as BackstagePackageFeatureType)
);
}