diff --git a/code/frameworks/nextjs/package.json b/code/frameworks/nextjs/package.json index 9a301b00d0a3..f829c29af0bb 100644 --- a/code/frameworks/nextjs/package.json +++ b/code/frameworks/nextjs/package.json @@ -80,7 +80,11 @@ "tsconfig-paths-webpack-plugin": "^3.5.2" }, "devDependencies": { + "@babel/core": "^7.20.5", + "@babel/types": "^7.20.5", + "@next/font": "^13.0.6", "@storybook/addon-actions": "7.0.0-beta.10", + "@types/babel__core": "^7", "next": "^13.0.5", "typescript": "^4.9.3", "webpack": "^5.65.0" diff --git a/code/frameworks/nextjs/src/font/babel.helpers.ts b/code/frameworks/nextjs/src/font/babel.helpers.ts new file mode 100644 index 000000000000..0e2180464c70 --- /dev/null +++ b/code/frameworks/nextjs/src/font/babel.helpers.ts @@ -0,0 +1,280 @@ +import type * as BabelTypesNamespace from '@babel/types'; +import type * as BabelCoreNamespace from '@babel/core'; + +type BabelTypes = typeof BabelTypesNamespace; +type PrimaryTypes = Record | string | number | boolean | undefined | null; + +export type JSReturnValue = PrimaryTypes | Array; + +export type VariableMeta = { + /** + * Variable Declaration name of the assigned function call + * @example + * import { Roboto } from '@next/font/google' + * const robotoName = Roboto({ + * weight: '400' + * }) + * + * // identifierName = 'robotName' + */ + identifierName: string; + /** + * Properties of the assigned function call + * @example + * import { Roboto } from '@next/font/google' + * const robotoName = Roboto({ + * weight: '400' + * }) + * + * // properties = { weight: '400' } + */ + properties: JSReturnValue; + /** + * Function name of the imported @next/font/google function + * @example + * import { Roboto } from '@next/font/google' + * const robotoName = Roboto({ + * weight: '400' + * }) + * + * // functionName = Roboto + */ + functionName: string; +}; + +function convertNodeToJSON(types: BabelTypes, node: any): JSReturnValue { + if (types.isBooleanLiteral(node) || types.isStringLiteral(node) || types.isNumericLiteral(node)) { + return node.value; + } + + if (node.name === 'undefined' && !node.value) { + return undefined; + } + + if (types.isNullLiteral(node)) { + return null; + } + + if (types.isObjectExpression(node)) { + return computeProps(types, node.properties); + } + + if (types.isArrayExpression(node)) { + return node.elements.reduce( + (acc, element) => [ + ...acc, + ...(element?.type === 'SpreadElement' + ? (convertNodeToJSON(types, element.argument) as PrimaryTypes[]) + : [convertNodeToJSON(types, element)]), + ], + [] as PrimaryTypes[] + ); + } + + return {}; +} + +function computeProps( + types: BabelTypes, + props: ( + | BabelTypesNamespace.ObjectMethod + | BabelTypesNamespace.ObjectProperty + | BabelTypesNamespace.SpreadElement + )[] +) { + return props.reduce((acc, prop) => { + if (prop.type === 'SpreadElement') { + return { + ...acc, + ...(convertNodeToJSON(types, prop.argument) as Record), + }; + } + if (prop.type !== 'ObjectMethod') { + const val = convertNodeToJSON(types, prop.value); + if (val !== undefined && types.isIdentifier(prop.key)) { + return { + ...acc, + [prop.key.name]: val, + }; + } + } + return acc; + }, {}); +} + +export function isDefined(value: T): value is Exclude { + return value !== undefined; +} + +/** + * Removes transformed variable declarations, which were already replaced with parameterized imports + * @example + * // AST + * import { Roboto, Inter } from '@next/font/google' + * const interName = Inter({ + * subsets: ['latin'], + * }) + * const robotoName = Roboto({ + * weight: '400' + * }) + * + * // Result + * import { Roboto, Inter } from '@next/font/google' + * + * // Variable declarations are removed + */ +export function removeTransformedVariableDeclarations( + path: BabelCoreNamespace.NodePath, + types: BabelTypes, + metas: VariableMeta[] +) { + path.parentPath.traverse({ + VariableDeclarator(declaratorPath) { + if (!declaratorPath.parentPath.parentPath?.isProgram()) { + return; + } + + if ( + metas.some( + (meta) => + types.isIdentifier(declaratorPath.node.id) && + meta.identifierName === declaratorPath.node.id.name + ) + ) { + declaratorPath.remove(); + } + }, + }); +} + +/** + * Replaces `@next/font` import with a parameterized import + * @example + * // AST + * import { Roboto, Inter } from '@next/font/google' + * const interName = Inter({ + * subsets: ['latin'], + * }) + * const robotoName = Roboto({ + * weight: '400' + * }) + * + * // Result + * import interName from '@next/font/google?Inter;{"subsets":["latin"]}' + * import robotoName from '@next/font/google?Roboto;{"weight":"400"}' + * + * // Following code will be removed from removeUnusedVariableDeclarations function + * const interName = Inter({ + * subsets: ['latin'], + * }) + * + * const robotoName = Roboto({ + * weight: '400' + * }) + */ +export function replaceImportWithParamterImport( + path: BabelCoreNamespace.NodePath, + types: BabelTypes, + source: BabelCoreNamespace.types.StringLiteral, + metas: Array +) { + // Add an import for each specifier with parameters + path.replaceWithMultiple([ + ...metas.map((meta) => { + return types.importDeclaration( + [types.importDefaultSpecifier(types.identifier(meta.identifierName))], + types.stringLiteral( + // TODO + `${source.value}?${meta.functionName};${JSON.stringify(meta.properties).replace( + '\\"', + "'" + )}` + ) + ); + }), + ]); +} + +/** + * Get meta information for the provided import specifier + * @example + * // AST + * import { Roboto, Inter } from '@next/font/google' + * const interName = Inter({ + * subsets: ['latin'], + * }) + * const robotoName = Roboto({ + * weight: '400' + * }) + * + * // Return value + * const variableMetas = [{ + * identifierName: 'interName', + * properties: { subsets: ['latin'] }, + * functionName: 'Inter' + * }, { + * identifierName: 'robotoName', + * properties: { weight: '400' }, + * functionName: 'Roboto' + * }] + */ +export function getVariableMetasBySpecifier( + program: BabelCoreNamespace.NodePath, + types: BabelTypes, + specifier: + | BabelCoreNamespace.types.ImportDefaultSpecifier + | BabelCoreNamespace.types.ImportNamespaceSpecifier + | BabelCoreNamespace.types.ImportSpecifier +) { + return program.node.body + .map((statement) => { + if (!types.isVariableDeclaration(statement)) { + return undefined; + } + + const declaration = statement.declarations[0]; + + if (!types.isIdentifier(declaration.id)) { + return undefined; + } + + if (!types.isCallExpression(declaration.init)) { + return undefined; + } + + if ( + (!types.isIdentifier(declaration.init.callee) || + specifier.type !== 'ImportSpecifier' || + specifier.imported.type !== 'Identifier' || + declaration.init.callee.name !== specifier.imported.name) && + (!types.isIdentifier(declaration.init.callee) || + specifier.type !== 'ImportDefaultSpecifier' || + declaration.init.callee.name !== specifier.local.name) + ) { + return undefined; + } + + const options = declaration.init.arguments[0]; + + if (!types.isObjectExpression(options)) { + throw program.buildCodeFrameError( + 'Please pass an options object to the call expression of @next/font functions' + ); + } + + options.properties.forEach((property) => { + if (types.isSpreadElement(property)) { + throw program.buildCodeFrameError( + 'Please do not use spread elements in the options object in @next/font function calls' + ); + } + }); + + const identifierName = declaration.id.name; + const properties = convertNodeToJSON(types, options); + const functionName = declaration.init.callee.name; + + return { identifierName, properties, functionName }; + }) + .filter(isDefined); +} diff --git a/code/frameworks/nextjs/src/font/babel.test.ts b/code/frameworks/nextjs/src/font/babel.test.ts new file mode 100644 index 000000000000..460b27bfdfd9 --- /dev/null +++ b/code/frameworks/nextjs/src/font/babel.test.ts @@ -0,0 +1,30 @@ +/* eslint-disable @typescript-eslint/no-non-null-assertion */ +import { transform } from '@babel/core'; +import TransformFontImports from './babel'; + +const example = ` +import { Inter, Roboto } from '@next/font/google' +import localFont from '@next/font/local' + +const myFont = localFont({ src: './my-font.woff2' }) + +const roboto = Roboto({ + weight: '400', +}) + +const inter = Inter({ + subsets: ['latin'], +}); + +const randomObj = {} +`; + +it('should transform AST properly', () => { + const { code } = transform(example, { plugins: [TransformFontImports] })!; + expect(code).toMatchInlineSnapshot(` + "import inter from \\"@next/font/google?Inter;{\\\\\\"subsets\\\\\\":[\\\\\\"latin\\\\\\"]}\\"; + import roboto from \\"@next/font/google?Roboto;{\\\\\\"weight\\\\\\":\\\\\\"400\\\\\\"}\\"; + import myFont from \\"@next/font/local?localFont;{\\\\\\"src\\\\\\":\\\\\\"./my-font.woff2\\\\\\"}\\"; + const randomObj = {};" + `); +}); diff --git a/code/frameworks/nextjs/src/font/babel.ts b/code/frameworks/nextjs/src/font/babel.ts new file mode 100644 index 000000000000..4a8cae89d0be --- /dev/null +++ b/code/frameworks/nextjs/src/font/babel.ts @@ -0,0 +1,83 @@ +/* eslint-disable import/no-duplicates */ +import type * as BabelCoreNamespace from '@babel/core'; +import { + getVariableMetasBySpecifier, + isDefined, + removeTransformedVariableDeclarations, + replaceImportWithParamterImport, +} from './babel.helpers'; + +type Babel = typeof BabelCoreNamespace; + +/** + * Transforms "@next/font" imports and usages to a webpack loader friendly format with parameters + * @example + * // Turns this code: + * import { Inter, Roboto } from '@next/font/google' + * import localFont from '@next/font/local' + * + * const myFont = localFont({ src: './my-font.woff2' }) + * const roboto = Roboto({ + * weight: '400', + * }) + * + * const inter = Inter({ + * subsets: ['latin'], + * }); + * + * // Into this code: + * import inter from "@next/font/google?Inter;{\"subsets\":[\"latin\"]}"; + * import roboto from "@next/font/google?Roboto;{\"weight\":\"400\"}"; + * import myFont from "@next/font/local?localFont;{\"src\":\"./my-font.woff2\"}"; + * + * This Plugin tries to adopt the functionality which is provided by the nextjs swc plugin + * https://github.com/vercel/next.js/pull/40221 + */ +export default function TransformFontImports({ types }: Babel): BabelCoreNamespace.PluginObj { + return { + name: 'storybook-nextjs-font-imports', + visitor: { + ImportDeclaration(path) { + const { node } = path; + const { source } = node; + + if (source.value === '@next/font/local') { + const { specifiers } = node; + + // @next/font/local only provides a default export + const specifier = specifiers[0]; + + if (!path.parentPath.isProgram()) { + return; + } + + const program = path.parentPath; + + const variableMetas = getVariableMetasBySpecifier(program, types, specifier); + + removeTransformedVariableDeclarations(path, types, variableMetas); + replaceImportWithParamterImport(path, types, source, variableMetas); + } + + if (source.value === '@next/font/google') { + const { specifiers } = node; + + const variableMetas = specifiers + .flatMap((specifier) => { + if (!path.parentPath.isProgram()) { + return []; + } + + const program = path.parentPath; + + return getVariableMetasBySpecifier(program, types, specifier); + }) + .filter(isDefined); + + removeTransformedVariableDeclarations(path, types, variableMetas); + replaceImportWithParamterImport(path, types, source, variableMetas); + } + }, + }, + }; +} diff --git a/code/frameworks/nextjs/src/preset.ts b/code/frameworks/nextjs/src/preset.ts index d479189ae343..8441868dc2e0 100644 --- a/code/frameworks/nextjs/src/preset.ts +++ b/code/frameworks/nextjs/src/preset.ts @@ -13,6 +13,7 @@ import { configureImages } from './images/webpack'; import { configureRuntimeNextjsVersionResolution } from './utils'; import type { FrameworkOptions, StorybookConfig } from './types'; import { configureNextImport } from './nextImport/webpack'; +import TransformFontImports from './font/babel'; export const addons: PresetProperty<'addons', StorybookConfig> = [ dirname(require.resolve(join('@storybook/preset-react-webpack', 'package.json'))), @@ -103,8 +104,11 @@ export const babel = async (baseConfig: TransformOptions): Promise

Google Inter Latin

; + +export default { + component: Component, +}; + +export const Default = {}; diff --git a/code/yarn.lock b/code/yarn.lock index d39d7009dae3..332cf0bb2c16 100644 --- a/code/yarn.lock +++ b/code/yarn.lock @@ -431,7 +431,7 @@ __metadata: languageName: node linkType: hard -"@babel/core@npm:^7.11.6, @babel/core@npm:^7.12.0, @babel/core@npm:^7.12.10, @babel/core@npm:^7.12.3, @babel/core@npm:^7.13.16, @babel/core@npm:^7.17.2, @babel/core@npm:^7.17.5, @babel/core@npm:^7.19.6, @babel/core@npm:^7.20.2, @babel/core@npm:^7.3.4, @babel/core@npm:^7.7.5": +"@babel/core@npm:^7.11.6, @babel/core@npm:^7.12.0, @babel/core@npm:^7.12.10, @babel/core@npm:^7.12.3, @babel/core@npm:^7.13.16, @babel/core@npm:^7.17.2, @babel/core@npm:^7.17.5, @babel/core@npm:^7.19.6, @babel/core@npm:^7.20.2, @babel/core@npm:^7.20.5, @babel/core@npm:^7.3.4, @babel/core@npm:^7.7.5": version: 7.20.5 resolution: "@babel/core@npm:7.20.5" dependencies: @@ -4429,6 +4429,13 @@ __metadata: languageName: node linkType: hard +"@next/font@npm:^13.0.6": + version: 13.0.6 + resolution: "@next/font@npm:13.0.6" + checksum: 45d3722c2ae7724f74df29ba0ca59eb2766e6e68453b963f0c8b5bbb63646cf32bd1bac6ee847ac7dde3ac87b0dd3569a98e94a09778eae76c9f454df91cedad + languageName: node + linkType: hard + "@next/swc-android-arm-eabi@npm:13.0.7": version: 13.0.7 resolution: "@next/swc-android-arm-eabi@npm:13.0.7" @@ -6765,6 +6772,9 @@ __metadata: version: 0.0.0-use.local resolution: "@storybook/nextjs@workspace:frameworks/nextjs" dependencies: + "@babel/core": ^7.20.5 + "@babel/types": ^7.20.5 + "@next/font": ^13.0.6 "@storybook/addon-actions": 7.0.0-beta.10 "@storybook/builder-webpack5": 7.0.0-beta.10 "@storybook/core-common": 7.0.0-beta.10 @@ -6772,6 +6782,7 @@ __metadata: "@storybook/preset-react-webpack": 7.0.0-beta.10 "@storybook/preview-api": 7.0.0-beta.10 "@storybook/react": 7.0.0-beta.10 + "@types/babel__core": ^7 "@types/node": ^16.0.0 find-up: ^5.0.0 fs-extra: ^9.0.1 @@ -8183,7 +8194,7 @@ __metadata: languageName: node linkType: hard -"@types/babel__core@npm:^7.0.0, @types/babel__core@npm:^7.1.14, @types/babel__core@npm:^7.1.20": +"@types/babel__core@npm:^7, @types/babel__core@npm:^7.0.0, @types/babel__core@npm:^7.1.14, @types/babel__core@npm:^7.1.20": version: 7.1.20 resolution: "@types/babel__core@npm:7.1.20" dependencies: