diff --git a/packages/next-codemod/bin/cli.ts b/packages/next-codemod/bin/cli.ts index 76278a7bafaf0..82a53c768fed4 100644 --- a/packages/next-codemod/bin/cli.ts +++ b/packages/next-codemod/bin/cli.ts @@ -8,18 +8,18 @@ // Based on https://github.com/reactjs/react-codemod/blob/dd8671c9a470a2c342b221ec903c574cf31e9f57/bin/cli.js // @next/codemod optional-name-of-transform optional/path/to/src [...options] -const globby = require('globby') -const inquirer = require('inquirer') -const meow = require('meow') -const path = require('path') -const execa = require('execa') -const chalk = require('chalk') -const isGitClean = require('is-git-clean') - -const transformerDirectory = path.join(__dirname, '../', 'transforms') -const jscodeshiftExecutable = require.resolve('.bin/jscodeshift') - -function checkGitStatus(force) { +import globby from 'globby' +import inquirer from 'inquirer' +import meow from 'meow' +import path from 'path' +import execa from 'execa' +import chalk from 'chalk' +import isGitClean from 'is-git-clean' + +export const jscodeshiftExecutable = require.resolve('.bin/jscodeshift') +export const transformerDirectory = path.join(__dirname, '../', 'transforms') + +export function checkGitStatus(force) { let clean = false let errorMessage = 'Unable to determine if git directory is clean' try { @@ -49,12 +49,17 @@ function checkGitStatus(force) { } } -function runTransform({ files, flags, transformer }) { +export function runTransform({ files, flags, transformer }) { const transformerPath = path.join(transformerDirectory, `${transformer}.js`) + if (transformer === 'cra-to-next') { + // cra-to-next transform doesn't use jscodeshift directly + return require(transformerPath).default(files, flags) + } + let args = [] - const { dry, print } = flags + const { dry, print, runInBand } = flags if (dry) { args.push('--dry') @@ -62,6 +67,9 @@ function runTransform({ files, flags, transformer }) { if (print) { args.push('--print') } + if (runInBand) { + args.push('--run-in-band') + } args.push('--verbose=2') @@ -83,11 +91,11 @@ function runTransform({ files, flags, transformer }) { const result = execa.sync(jscodeshiftExecutable, args, { stdio: 'inherit', - stripEof: false, + stripFinalNewline: false, }) - if (result.error) { - throw result.error + if (result.failed) { + throw new Error(`jscodeshift exited with code ${result.exitCode}`) } } @@ -112,6 +120,11 @@ const TRANSFORMER_INQUIRER_CHOICES = [ 'url-to-withrouter: Transforms the deprecated automatically injected url property on top level pages to using withRouter', value: 'url-to-withrouter', }, + { + name: + 'cra-to-next (experimental): automatically migrates a Create React App project to Next.js', + value: 'cra-to-next', + }, ] function expandFilePathsIfNeeded(filesBeforeExpansion) { @@ -123,11 +136,10 @@ function expandFilePathsIfNeeded(filesBeforeExpansion) { : filesBeforeExpansion } -function run() { - const cli = meow( - { - description: 'Codemods for updating Next.js apps.', - help: ` +export function run() { + const cli = meow({ + description: 'Codemods for updating Next.js apps.', + help: ` Usage $ npx @next/codemod <...options> transform One of the choices from https://github.com/vercel/next.js/tree/canary/packages/next-codemod @@ -138,15 +150,14 @@ function run() { --print Print transformed files to your terminal --jscodeshift (Advanced) Pass options directly to jscodeshift `, - }, - { + flags: { boolean: ['force', 'dry', 'print', 'help'], string: ['_'], alias: { h: 'help', }, - } - ) + }, + } as meow.Options) if (!cli.flags.dry) { checkGitStatus(cli.flags.force) @@ -203,11 +214,3 @@ function run() { }) }) } - -module.exports = { - run: run, - runTransform: runTransform, - checkGitStatus: checkGitStatus, - jscodeshiftExecutable: jscodeshiftExecutable, - transformerDirectory: transformerDirectory, -} diff --git a/packages/next-codemod/lib/cra-to-next/gitignore b/packages/next-codemod/lib/cra-to-next/gitignore new file mode 100644 index 0000000000000..1437c53f70bc2 --- /dev/null +++ b/packages/next-codemod/lib/cra-to-next/gitignore @@ -0,0 +1,34 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# local env files +.env.local +.env.development.local +.env.test.local +.env.production.local + +# vercel +.vercel diff --git a/packages/next-codemod/lib/cra-to-next/global-css-transform.ts b/packages/next-codemod/lib/cra-to-next/global-css-transform.ts new file mode 100644 index 0000000000000..2aadefbd5d96b --- /dev/null +++ b/packages/next-codemod/lib/cra-to-next/global-css-transform.ts @@ -0,0 +1,65 @@ +import nodePath from 'path' +import { API, FileInfo, Options } from 'jscodeshift' + +export const globalCssContext = { + cssImports: new Set(), + reactSvgImports: new Set(), +} +const globalStylesRegex = /(? { + const { + node: { + source: { value }, + }, + } = path + + if (typeof value === 'string') { + if (globalStylesRegex.test(value)) { + let resolvedPath = value + + if (value.startsWith('.')) { + resolvedPath = nodePath.resolve(nodePath.dirname(file.path), value) + } + globalCssContext.cssImports.add(resolvedPath) + + const { start, end } = path.node as any + + if (!path.parentPath.node.comments) { + path.parentPath.node.comments = [] + } + + path.parentPath.node.comments = [ + j.commentLine(' ' + file.source.substring(start, end)), + ] + hasModifications = true + return true + } else if (value.endsWith('.svg')) { + const isComponentImport = path.node.specifiers.some((specifier) => { + return (specifier as any).imported?.name === 'ReactComponent' + }) + + if (isComponentImport) { + globalCssContext.reactSvgImports.add(file.path) + } + } + } + return false + }) + .remove() + + return hasModifications && globalCssContext.reactSvgImports.size === 0 + ? root.toSource(options) + : null +} diff --git a/packages/next-codemod/lib/cra-to-next/index-to-component.ts b/packages/next-codemod/lib/cra-to-next/index-to-component.ts new file mode 100644 index 0000000000000..24bd5fd4e559b --- /dev/null +++ b/packages/next-codemod/lib/cra-to-next/index-to-component.ts @@ -0,0 +1,101 @@ +import { API, FileInfo, JSXElement, Options } from 'jscodeshift' + +export const indexContext = { + multipleRenderRoots: false, + nestedRender: false, +} + +export default function transformer( + file: FileInfo, + api: API, + options: Options +) { + const j = api.jscodeshift + const root = j(file.source) + let hasModifications = false + let foundReactRender = 0 + let hasRenderImport = false + let defaultReactDomImport: string | undefined + + root.find(j.ImportDeclaration).forEach((path) => { + if (path.node.source.value === 'react-dom') { + return path.node.specifiers.forEach((specifier) => { + if (specifier.local.name === 'render') { + hasRenderImport = true + } + if (specifier.type === 'ImportDefaultSpecifier') { + defaultReactDomImport = specifier.local.name + } + }) + } + return false + }) + + root + .find(j.CallExpression) + .filter((path) => { + const { node } = path + let found = false + + if ( + defaultReactDomImport && + node.callee.type === 'MemberExpression' && + (node.callee.object as any).name === defaultReactDomImport && + (node.callee.property as any).name === 'render' + ) { + found = true + } + + if (hasRenderImport && (node.callee as any).name === 'render') { + found = true + } + + if (found) { + foundReactRender++ + hasModifications = true + + if (!Array.isArray(path.parentPath?.parentPath?.value)) { + indexContext.nestedRender = true + return false + } + + const newNode = j.exportDefaultDeclaration( + j.functionDeclaration( + j.identifier('NextIndexWrapper'), + [], + j.blockStatement([ + j.returnStatement( + // TODO: remove React.StrictMode wrapper and use + // next.config.js option instead? + path.node.arguments.find( + (a) => a.type === 'JSXElement' + ) as JSXElement + ), + ]) + ) + ) + + path.parentPath.insertBefore(newNode) + return true + } + return false + }) + .remove() + + indexContext.multipleRenderRoots = foundReactRender > 1 + hasModifications = + hasModifications && + !indexContext.nestedRender && + !indexContext.multipleRenderRoots + + // TODO: move function passed to reportWebVitals if present to + // _app reportWebVitals and massage values to expected shape + + // root.find(j.CallExpression, { + // callee: { + // name: 'reportWebVitals' + // } + // }).remove() + + return hasModifications ? root.toSource(options) : null +} diff --git a/packages/next-codemod/lib/html-to-react-attributes.ts b/packages/next-codemod/lib/html-to-react-attributes.ts new file mode 100644 index 0000000000000..08560f28a87b2 --- /dev/null +++ b/packages/next-codemod/lib/html-to-react-attributes.ts @@ -0,0 +1,502 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +// When adding attributes to the HTML or SVG allowed attribute list, be sure to +// also add them to this module to ensure casing and incorrect name +// warnings. + +// Pulled from https://github.com/facebook/react/blob/master/packages/react-dom/src/shared/possibleStandardNames.js +const possibleStandardNames = { + // HTML + accept: 'accept', + acceptcharset: 'acceptCharset', + 'accept-charset': 'acceptCharset', + accesskey: 'accessKey', + action: 'action', + allowfullscreen: 'allowFullScreen', + alt: 'alt', + as: 'as', + async: 'async', + autocapitalize: 'autoCapitalize', + autocomplete: 'autoComplete', + autocorrect: 'autoCorrect', + autofocus: 'autoFocus', + autoplay: 'autoPlay', + autosave: 'autoSave', + capture: 'capture', + cellpadding: 'cellPadding', + cellspacing: 'cellSpacing', + challenge: 'challenge', + charset: 'charSet', + checked: 'checked', + children: 'children', + cite: 'cite', + class: 'className', + classid: 'classID', + classname: 'className', + cols: 'cols', + colspan: 'colSpan', + content: 'content', + contenteditable: 'contentEditable', + contextmenu: 'contextMenu', + controls: 'controls', + controlslist: 'controlsList', + coords: 'coords', + crossorigin: 'crossOrigin', + dangerouslysetinnerhtml: 'dangerouslySetInnerHTML', + data: 'data', + datetime: 'dateTime', + default: 'default', + defaultchecked: 'defaultChecked', + defaultvalue: 'defaultValue', + defer: 'defer', + dir: 'dir', + disabled: 'disabled', + disablepictureinpicture: 'disablePictureInPicture', + disableremoteplayback: 'disableRemotePlayback', + download: 'download', + draggable: 'draggable', + enctype: 'encType', + enterkeyhint: 'enterKeyHint', + for: 'htmlFor', + form: 'form', + formmethod: 'formMethod', + formaction: 'formAction', + formenctype: 'formEncType', + formnovalidate: 'formNoValidate', + formtarget: 'formTarget', + frameborder: 'frameBorder', + headers: 'headers', + height: 'height', + hidden: 'hidden', + high: 'high', + href: 'href', + hreflang: 'hrefLang', + htmlfor: 'htmlFor', + httpequiv: 'httpEquiv', + 'http-equiv': 'httpEquiv', + icon: 'icon', + id: 'id', + innerhtml: 'innerHTML', + inputmode: 'inputMode', + integrity: 'integrity', + is: 'is', + itemid: 'itemID', + itemprop: 'itemProp', + itemref: 'itemRef', + itemscope: 'itemScope', + itemtype: 'itemType', + keyparams: 'keyParams', + keytype: 'keyType', + kind: 'kind', + label: 'label', + lang: 'lang', + list: 'list', + loop: 'loop', + low: 'low', + manifest: 'manifest', + marginwidth: 'marginWidth', + marginheight: 'marginHeight', + max: 'max', + maxlength: 'maxLength', + media: 'media', + mediagroup: 'mediaGroup', + method: 'method', + min: 'min', + minlength: 'minLength', + multiple: 'multiple', + muted: 'muted', + name: 'name', + nomodule: 'noModule', + nonce: 'nonce', + novalidate: 'noValidate', + open: 'open', + optimum: 'optimum', + pattern: 'pattern', + placeholder: 'placeholder', + playsinline: 'playsInline', + poster: 'poster', + preload: 'preload', + profile: 'profile', + radiogroup: 'radioGroup', + readonly: 'readOnly', + referrerpolicy: 'referrerPolicy', + rel: 'rel', + required: 'required', + reversed: 'reversed', + role: 'role', + rows: 'rows', + rowspan: 'rowSpan', + sandbox: 'sandbox', + scope: 'scope', + scoped: 'scoped', + scrolling: 'scrolling', + seamless: 'seamless', + selected: 'selected', + shape: 'shape', + size: 'size', + sizes: 'sizes', + span: 'span', + spellcheck: 'spellCheck', + src: 'src', + srcdoc: 'srcDoc', + srclang: 'srcLang', + srcset: 'srcSet', + start: 'start', + step: 'step', + style: 'style', + summary: 'summary', + tabindex: 'tabIndex', + target: 'target', + title: 'title', + type: 'type', + usemap: 'useMap', + value: 'value', + width: 'width', + wmode: 'wmode', + wrap: 'wrap', + + // SVG + about: 'about', + accentheight: 'accentHeight', + 'accent-height': 'accentHeight', + accumulate: 'accumulate', + additive: 'additive', + alignmentbaseline: 'alignmentBaseline', + 'alignment-baseline': 'alignmentBaseline', + allowreorder: 'allowReorder', + alphabetic: 'alphabetic', + amplitude: 'amplitude', + arabicform: 'arabicForm', + 'arabic-form': 'arabicForm', + ascent: 'ascent', + attributename: 'attributeName', + attributetype: 'attributeType', + autoreverse: 'autoReverse', + azimuth: 'azimuth', + basefrequency: 'baseFrequency', + baselineshift: 'baselineShift', + 'baseline-shift': 'baselineShift', + baseprofile: 'baseProfile', + bbox: 'bbox', + begin: 'begin', + bias: 'bias', + by: 'by', + calcmode: 'calcMode', + capheight: 'capHeight', + 'cap-height': 'capHeight', + clip: 'clip', + clippath: 'clipPath', + 'clip-path': 'clipPath', + clippathunits: 'clipPathUnits', + cliprule: 'clipRule', + 'clip-rule': 'clipRule', + color: 'color', + colorinterpolation: 'colorInterpolation', + 'color-interpolation': 'colorInterpolation', + colorinterpolationfilters: 'colorInterpolationFilters', + 'color-interpolation-filters': 'colorInterpolationFilters', + colorprofile: 'colorProfile', + 'color-profile': 'colorProfile', + colorrendering: 'colorRendering', + 'color-rendering': 'colorRendering', + contentscripttype: 'contentScriptType', + contentstyletype: 'contentStyleType', + cursor: 'cursor', + cx: 'cx', + cy: 'cy', + d: 'd', + datatype: 'datatype', + decelerate: 'decelerate', + descent: 'descent', + diffuseconstant: 'diffuseConstant', + direction: 'direction', + display: 'display', + divisor: 'divisor', + dominantbaseline: 'dominantBaseline', + 'dominant-baseline': 'dominantBaseline', + dur: 'dur', + dx: 'dx', + dy: 'dy', + edgemode: 'edgeMode', + elevation: 'elevation', + enablebackground: 'enableBackground', + 'enable-background': 'enableBackground', + end: 'end', + exponent: 'exponent', + externalresourcesrequired: 'externalResourcesRequired', + fill: 'fill', + fillopacity: 'fillOpacity', + 'fill-opacity': 'fillOpacity', + fillrule: 'fillRule', + 'fill-rule': 'fillRule', + filter: 'filter', + filterres: 'filterRes', + filterunits: 'filterUnits', + floodopacity: 'floodOpacity', + 'flood-opacity': 'floodOpacity', + floodcolor: 'floodColor', + 'flood-color': 'floodColor', + focusable: 'focusable', + fontfamily: 'fontFamily', + 'font-family': 'fontFamily', + fontsize: 'fontSize', + 'font-size': 'fontSize', + fontsizeadjust: 'fontSizeAdjust', + 'font-size-adjust': 'fontSizeAdjust', + fontstretch: 'fontStretch', + 'font-stretch': 'fontStretch', + fontstyle: 'fontStyle', + 'font-style': 'fontStyle', + fontvariant: 'fontVariant', + 'font-variant': 'fontVariant', + fontweight: 'fontWeight', + 'font-weight': 'fontWeight', + format: 'format', + from: 'from', + fx: 'fx', + fy: 'fy', + g1: 'g1', + g2: 'g2', + glyphname: 'glyphName', + 'glyph-name': 'glyphName', + glyphorientationhorizontal: 'glyphOrientationHorizontal', + 'glyph-orientation-horizontal': 'glyphOrientationHorizontal', + glyphorientationvertical: 'glyphOrientationVertical', + 'glyph-orientation-vertical': 'glyphOrientationVertical', + glyphref: 'glyphRef', + gradienttransform: 'gradientTransform', + gradientunits: 'gradientUnits', + hanging: 'hanging', + horizadvx: 'horizAdvX', + 'horiz-adv-x': 'horizAdvX', + horizoriginx: 'horizOriginX', + 'horiz-origin-x': 'horizOriginX', + ideographic: 'ideographic', + imagerendering: 'imageRendering', + 'image-rendering': 'imageRendering', + in2: 'in2', + in: 'in', + inlist: 'inlist', + intercept: 'intercept', + k1: 'k1', + k2: 'k2', + k3: 'k3', + k4: 'k4', + k: 'k', + kernelmatrix: 'kernelMatrix', + kernelunitlength: 'kernelUnitLength', + kerning: 'kerning', + keypoints: 'keyPoints', + keysplines: 'keySplines', + keytimes: 'keyTimes', + lengthadjust: 'lengthAdjust', + letterspacing: 'letterSpacing', + 'letter-spacing': 'letterSpacing', + lightingcolor: 'lightingColor', + 'lighting-color': 'lightingColor', + limitingconeangle: 'limitingConeAngle', + local: 'local', + markerend: 'markerEnd', + 'marker-end': 'markerEnd', + markerheight: 'markerHeight', + markermid: 'markerMid', + 'marker-mid': 'markerMid', + markerstart: 'markerStart', + 'marker-start': 'markerStart', + markerunits: 'markerUnits', + markerwidth: 'markerWidth', + mask: 'mask', + maskcontentunits: 'maskContentUnits', + maskunits: 'maskUnits', + mathematical: 'mathematical', + mode: 'mode', + numoctaves: 'numOctaves', + offset: 'offset', + opacity: 'opacity', + operator: 'operator', + order: 'order', + orient: 'orient', + orientation: 'orientation', + origin: 'origin', + overflow: 'overflow', + overlineposition: 'overlinePosition', + 'overline-position': 'overlinePosition', + overlinethickness: 'overlineThickness', + 'overline-thickness': 'overlineThickness', + paintorder: 'paintOrder', + 'paint-order': 'paintOrder', + panose1: 'panose1', + 'panose-1': 'panose1', + pathlength: 'pathLength', + patterncontentunits: 'patternContentUnits', + patterntransform: 'patternTransform', + patternunits: 'patternUnits', + pointerevents: 'pointerEvents', + 'pointer-events': 'pointerEvents', + points: 'points', + pointsatx: 'pointsAtX', + pointsaty: 'pointsAtY', + pointsatz: 'pointsAtZ', + prefix: 'prefix', + preservealpha: 'preserveAlpha', + preserveaspectratio: 'preserveAspectRatio', + primitiveunits: 'primitiveUnits', + property: 'property', + r: 'r', + radius: 'radius', + refx: 'refX', + refy: 'refY', + renderingintent: 'renderingIntent', + 'rendering-intent': 'renderingIntent', + repeatcount: 'repeatCount', + repeatdur: 'repeatDur', + requiredextensions: 'requiredExtensions', + requiredfeatures: 'requiredFeatures', + resource: 'resource', + restart: 'restart', + result: 'result', + results: 'results', + rotate: 'rotate', + rx: 'rx', + ry: 'ry', + scale: 'scale', + security: 'security', + seed: 'seed', + shaperendering: 'shapeRendering', + 'shape-rendering': 'shapeRendering', + slope: 'slope', + spacing: 'spacing', + specularconstant: 'specularConstant', + specularexponent: 'specularExponent', + speed: 'speed', + spreadmethod: 'spreadMethod', + startoffset: 'startOffset', + stddeviation: 'stdDeviation', + stemh: 'stemh', + stemv: 'stemv', + stitchtiles: 'stitchTiles', + stopcolor: 'stopColor', + 'stop-color': 'stopColor', + stopopacity: 'stopOpacity', + 'stop-opacity': 'stopOpacity', + strikethroughposition: 'strikethroughPosition', + 'strikethrough-position': 'strikethroughPosition', + strikethroughthickness: 'strikethroughThickness', + 'strikethrough-thickness': 'strikethroughThickness', + string: 'string', + stroke: 'stroke', + strokedasharray: 'strokeDasharray', + 'stroke-dasharray': 'strokeDasharray', + strokedashoffset: 'strokeDashoffset', + 'stroke-dashoffset': 'strokeDashoffset', + strokelinecap: 'strokeLinecap', + 'stroke-linecap': 'strokeLinecap', + strokelinejoin: 'strokeLinejoin', + 'stroke-linejoin': 'strokeLinejoin', + strokemiterlimit: 'strokeMiterlimit', + 'stroke-miterlimit': 'strokeMiterlimit', + strokewidth: 'strokeWidth', + 'stroke-width': 'strokeWidth', + strokeopacity: 'strokeOpacity', + 'stroke-opacity': 'strokeOpacity', + suppresscontenteditablewarning: 'suppressContentEditableWarning', + suppresshydrationwarning: 'suppressHydrationWarning', + surfacescale: 'surfaceScale', + systemlanguage: 'systemLanguage', + tablevalues: 'tableValues', + targetx: 'targetX', + targety: 'targetY', + textanchor: 'textAnchor', + 'text-anchor': 'textAnchor', + textdecoration: 'textDecoration', + 'text-decoration': 'textDecoration', + textlength: 'textLength', + textrendering: 'textRendering', + 'text-rendering': 'textRendering', + to: 'to', + transform: 'transform', + typeof: 'typeof', + u1: 'u1', + u2: 'u2', + underlineposition: 'underlinePosition', + 'underline-position': 'underlinePosition', + underlinethickness: 'underlineThickness', + 'underline-thickness': 'underlineThickness', + unicode: 'unicode', + unicodebidi: 'unicodeBidi', + 'unicode-bidi': 'unicodeBidi', + unicoderange: 'unicodeRange', + 'unicode-range': 'unicodeRange', + unitsperem: 'unitsPerEm', + 'units-per-em': 'unitsPerEm', + unselectable: 'unselectable', + valphabetic: 'vAlphabetic', + 'v-alphabetic': 'vAlphabetic', + values: 'values', + vectoreffect: 'vectorEffect', + 'vector-effect': 'vectorEffect', + version: 'version', + vertadvy: 'vertAdvY', + 'vert-adv-y': 'vertAdvY', + vertoriginx: 'vertOriginX', + 'vert-origin-x': 'vertOriginX', + vertoriginy: 'vertOriginY', + 'vert-origin-y': 'vertOriginY', + vhanging: 'vHanging', + 'v-hanging': 'vHanging', + videographic: 'vIdeographic', + 'v-ideographic': 'vIdeographic', + viewbox: 'viewBox', + viewtarget: 'viewTarget', + visibility: 'visibility', + vmathematical: 'vMathematical', + 'v-mathematical': 'vMathematical', + vocab: 'vocab', + widths: 'widths', + wordspacing: 'wordSpacing', + 'word-spacing': 'wordSpacing', + writingmode: 'writingMode', + 'writing-mode': 'writingMode', + x1: 'x1', + x2: 'x2', + x: 'x', + xchannelselector: 'xChannelSelector', + xheight: 'xHeight', + 'x-height': 'xHeight', + xlinkactuate: 'xlinkActuate', + 'xlink:actuate': 'xlinkActuate', + xlinkarcrole: 'xlinkArcrole', + 'xlink:arcrole': 'xlinkArcrole', + xlinkhref: 'xlinkHref', + 'xlink:href': 'xlinkHref', + xlinkrole: 'xlinkRole', + 'xlink:role': 'xlinkRole', + xlinkshow: 'xlinkShow', + 'xlink:show': 'xlinkShow', + xlinktitle: 'xlinkTitle', + 'xlink:title': 'xlinkTitle', + xlinktype: 'xlinkType', + 'xlink:type': 'xlinkType', + xmlbase: 'xmlBase', + 'xml:base': 'xmlBase', + xmllang: 'xmlLang', + 'xml:lang': 'xmlLang', + xmlns: 'xmlns', + 'xml:space': 'xmlSpace', + xmlnsxlink: 'xmlnsXlink', + 'xmlns:xlink': 'xmlnsXlink', + xmlspace: 'xmlSpace', + y1: 'y1', + y2: 'y2', + y: 'y', + ychannelselector: 'yChannelSelector', + z: 'z', + zoomandpan: 'zoomAndPan', +} + +export default possibleStandardNames diff --git a/packages/next-codemod/lib/install.ts b/packages/next-codemod/lib/install.ts new file mode 100644 index 0000000000000..f1c814f58d92a --- /dev/null +++ b/packages/next-codemod/lib/install.ts @@ -0,0 +1,109 @@ +/* eslint-disable import/no-extraneous-dependencies */ +import chalk from 'chalk' +import spawn from 'cross-spawn' + +interface InstallArgs { + /** + * Indicate whether to install packages using Yarn. + */ + useYarn: boolean + /** + * Indicate whether there is an active Internet connection. + */ + isOnline: boolean + /** + * Indicate whether the given dependencies are devDependencies. + */ + devDependencies?: boolean +} + +/** + * Spawn a package manager installation with either Yarn or NPM. + * + * @returns A Promise that resolves once the installation is finished. + */ +export function install( + root: string, + dependencies: string[] | null, + { useYarn, isOnline, devDependencies }: InstallArgs +): Promise { + /** + * NPM-specific command-line flags. + */ + const npmFlags: string[] = ['--logLevel', 'error'] + /** + * Yarn-specific command-line flags. + */ + const yarnFlags: string[] = [] + /** + * Return a Promise that resolves once the installation is finished. + */ + return new Promise((resolve, reject) => { + let args: string[] + let command: string = useYarn ? 'yarnpkg' : 'npm' + + if (dependencies && dependencies.length) { + /** + * If there are dependencies, run a variation of `{displayCommand} add`. + */ + if (useYarn) { + /** + * Call `yarn add --exact (--offline)? (-D)? ...`. + */ + args = ['add', '--exact'] + if (!isOnline) args.push('--offline') + args.push('--cwd', root) + if (devDependencies) args.push('--dev') + args.push(...dependencies) + } else { + /** + * Call `npm install [--save|--save-dev] ...`. + */ + args = ['install', '--save-exact'] + args.push(devDependencies ? '--save-dev' : '--save') + args.push(...dependencies) + } + } else { + /** + * If there are no dependencies, run a variation of `{displayCommand} + * install`. + */ + args = ['install'] + if (useYarn) { + if (!isOnline) { + console.log(chalk.yellow('You appear to be offline.')) + console.log(chalk.yellow('Falling back to the local Yarn cache.')) + console.log() + args.push('--offline') + } + } else { + if (!isOnline) { + console.log(chalk.yellow('You appear to be offline.')) + console.log() + } + } + } + /** + * Add any package manager-specific flags. + */ + if (useYarn) { + args.push(...yarnFlags) + } else { + args.push(...npmFlags) + } + /** + * Spawn the installation process. + */ + const child = spawn(command, args, { + stdio: 'inherit', + env: { ...process.env, ADBLOCK: '1', DISABLE_OPENCOLLECTIVE: '1' }, + }) + child.on('close', (code) => { + if (code !== 0) { + reject({ command: `${command} ${args.join(' ')}` }) + return + } + resolve() + }) + }) +} diff --git a/packages/next-codemod/lib/run-jscodeshift.ts b/packages/next-codemod/lib/run-jscodeshift.ts new file mode 100644 index 0000000000000..a9608d6564c48 --- /dev/null +++ b/packages/next-codemod/lib/run-jscodeshift.ts @@ -0,0 +1,19 @@ +// @ts-ignore internal module +import Runner from 'jscodeshift/src/runner' + +export default function runJscodeshift( + transformerPath: string, + flags: { [key: string]: any }, + files: string[] +) { + // we run jscodeshift in the same process to be able to + // share state between the main CRA transform and sub-transforms + return Runner.run(transformerPath, files, { + ignorePattern: ['**/node_modules/**', '**/.next/**', '**/build/**'], + extensions: 'tsx,ts,jsx,js', + parser: 'tsx', + verbose: 2, + runInBand: true, + ...flags, + }) +} diff --git a/packages/next-codemod/package.json b/packages/next-codemod/package.json index 25a4132a66073..c35905a83559e 100644 --- a/packages/next-codemod/package.json +++ b/packages/next-codemod/package.json @@ -4,6 +4,7 @@ "license": "MIT", "dependencies": { "chalk": "4.1.0", + "cheerio": "1.0.0-rc.9", "execa": "4.0.3", "globby": "11.0.1", "inquirer": "7.3.3", @@ -20,5 +21,8 @@ "dev": "yarn tsc -d -w -p tsconfig.json", "test": "jest" }, - "bin": "./bin/next-codemod.js" + "bin": "./bin/next-codemod.js", + "devDependencies": { + "@types/jscodeshift": "0.11.0" + } } diff --git a/packages/next-codemod/transforms/add-missing-react-import.ts b/packages/next-codemod/transforms/add-missing-react-import.ts index f545778001d87..5c43ffa270ce3 100644 --- a/packages/next-codemod/transforms/add-missing-react-import.ts +++ b/packages/next-codemod/transforms/add-missing-react-import.ts @@ -1,4 +1,6 @@ -function addReactImport(j, root) { +import { API, Collection, FileInfo, JSCodeshift, Options } from 'jscodeshift' + +function addReactImport(j: JSCodeshift, root: Collection) { // We create an import specifier, this is the value of an import, eg: // import React from 'react' // The specifier would be `React` @@ -39,7 +41,11 @@ function addReactImport(j, root) { }) } -export default function transformer(file, api, options) { +export default function transformer( + file: FileInfo, + api: API, + options: Options +) { const j = api.jscodeshift const root = j(file.source) diff --git a/packages/next-codemod/transforms/cra-to-next.ts b/packages/next-codemod/transforms/cra-to-next.ts new file mode 100644 index 0000000000000..7f218fb8d0605 --- /dev/null +++ b/packages/next-codemod/transforms/cra-to-next.ts @@ -0,0 +1,611 @@ +import fs from 'fs' +import path from 'path' +import execa from 'execa' +import globby from 'globby' +import cheerio from 'cheerio' +import { install } from '../lib/install' +import runJscodeshift from '../lib/run-jscodeshift' +import htmlToReactAttributes from '../lib/html-to-react-attributes' +import { indexContext } from '../lib/cra-to-next/index-to-component' +import { globalCssContext } from '../lib/cra-to-next/global-css-transform' + +const feedbackMessage = `Please share any feedback on the migration here: https://github.com/vercel/next.js/discussions/25858` + +// log error and exit without new stacktrace +function fatalMessage(...logs) { + console.error(...logs, `\n${feedbackMessage}`) + process.exit(1) +} + +const craTransformsPath = path.join('../lib/cra-to-next') + +const globalCssTransformPath = require.resolve( + path.join(craTransformsPath, 'global-css-transform.js') +) +const indexTransformPath = require.resolve( + path.join(craTransformsPath, 'index-to-component.js') +) + +class CraTransform { + private appDir: string + private pagesDir: string + private isVite: boolean + private isCra: boolean + private isDryRun: boolean + private indexPage: string + private installClient: string + private shouldLogInfo: boolean + private packageJsonPath: string + private shouldUseTypeScript: boolean + private packageJsonData: { [key: string]: any } + private jscodeShiftFlags: { [key: string]: boolean } + + constructor(files: string[], flags: { [key: string]: boolean }) { + this.isDryRun = flags.dry + this.jscodeShiftFlags = flags + this.appDir = this.validateAppDir(files) + this.packageJsonPath = path.join(this.appDir, 'package.json') + this.packageJsonData = this.loadPackageJson() + this.shouldLogInfo = flags.print || flags.dry + this.pagesDir = this.getPagesDir() + this.installClient = this.checkForYarn() ? 'yarn' : 'npm' + + const { dependencies, devDependencies } = this.packageJsonData + const hasDep = (dep) => dependencies?.[dep] || devDependencies?.[dep] + + this.isCra = hasDep('react-scripts') + this.isVite = !this.isCra && hasDep('vite') + + if (!this.isCra && !this.isVite) { + fatalMessage( + `Error: react-scripts was not detected, is this a CRA project?` + ) + } + + this.shouldUseTypeScript = + fs.existsSync(path.join(this.appDir, 'tsconfig.json')) || + globby.sync('src/**/*.{ts,tsx}', { + cwd: path.join(this.appDir, 'src'), + }).length > 0 + + this.indexPage = globby.sync( + [`${this.isCra ? 'index' : 'main'}.{js,jsx,ts,tsx}`], + { + cwd: path.join(this.appDir, 'src'), + } + )[0] + + if (!this.indexPage) { + fatalMessage('Error: unable to find `src/index`') + } + } + + public async transform() { + console.log('Transforming CRA project at:', this.appDir) + + // convert src/index.js to a react component to render + // inside of Next.js instead of the custom render root + const indexTransformRes = await runJscodeshift( + indexTransformPath, + { ...this.jscodeShiftFlags, silent: true, verbose: 0 }, + [path.join(this.appDir, 'src', this.indexPage)] + ) + + if (indexTransformRes.error > 0) { + fatalMessage( + `Error: failed to apply transforms for src/${this.indexPage}, please check for syntax errors to continue` + ) + } + + if (indexContext.multipleRenderRoots) { + fatalMessage( + `Error: multiple ReactDOM.render roots in src/${this.indexPage}, migrate additional render roots to use portals instead to continue.\n` + + `See here for more info: https://reactjs.org/docs/portals.html` + ) + } + + if (indexContext.nestedRender) { + fatalMessage( + `Error: nested ReactDOM.render found in src/${this.indexPage}, please migrate this to a top-level render (no wrapping functions) to continue` + ) + } + + // comment out global style imports and collect them + // so that we can add them to _app + const globalCssRes = await runJscodeshift( + globalCssTransformPath, + { ...this.jscodeShiftFlags }, + [this.appDir] + ) + + if (globalCssRes.error > 0) { + fatalMessage( + `Error: failed to apply transforms for src/${this.indexPage}, please check for syntax errors to continue` + ) + } + + if (!this.isDryRun) { + await fs.promises.mkdir(path.join(this.appDir, this.pagesDir)) + } + this.logCreate(this.pagesDir) + + if (globalCssContext.reactSvgImports.size > 0) { + // This de-opts webpack 5 since svg/webpack doesn't support webpack 5 yet, + // so we don't support this automatically + fatalMessage( + `Error: import {ReactComponent} from './logo.svg' is not supported, please use normal SVG imports to continue.\n` + + `React SVG imports found in:\n${[ + ...globalCssContext.reactSvgImports, + ].join('\n')}` + ) + } + await this.updatePackageJson() + await this.createNextConfig() + await this.updateGitIgnore() + await this.createPages() + } + + private checkForYarn() { + try { + const userAgent = process.env.npm_config_user_agent + if (userAgent) { + return Boolean(userAgent && userAgent.startsWith('yarn')) + } + execa.sync('yarnpkg', ['--version'], { stdio: 'ignore' }) + return true + } catch (e) { + console.log('error', e) + return false + } + } + + private logCreate(...args: any[]) { + if (this.shouldLogInfo) { + console.log('Created:', ...args) + } + } + + private logModify(...args: any[]) { + if (this.shouldLogInfo) { + console.log('Modified:', ...args) + } + } + + private logInfo(...args: any[]) { + if (this.shouldLogInfo) { + console.log(...args) + } + } + + private async createPages() { + // load public/index.html and add tags to _document + const htmlContent = await fs.promises.readFile( + path.join(this.appDir, `${this.isCra ? 'public/' : ''}index.html`), + 'utf8' + ) + const $ = cheerio.load(htmlContent) + // note: title tag and meta[viewport] needs to be placed in _app + // not _document + const titleTag = $('title')[0] + const metaViewport = $('meta[name="viewport"]')[0] + const headTags = $('head').children() + const bodyTags = $('body').children() + + const pageExt = this.shouldUseTypeScript ? 'tsx' : 'js' + const appPage = path.join(this.pagesDir, `_app.${pageExt}`) + const documentPage = path.join(this.pagesDir, `_document.${pageExt}`) + const catchAllPage = path.join(this.pagesDir, `[[...slug]].${pageExt}`) + + const gatherTextChildren = (children: CheerioElement[]) => { + return children + .map((child) => { + if (child.type === 'text') { + return child.data + } + return '' + }) + .join('') + } + + const serializeAttrs = (attrs: CheerioElement['attribs']) => { + const attrStr = Object.keys(attrs || {}) + .map((name) => { + const reactName = htmlToReactAttributes[name] || name + const value = attrs[name] + + // allow process.env access to work dynamically still + if (value.match(/%([a-zA-Z0-9_]{0,})%/)) { + return `${reactName}={\`${value.replace( + /%([a-zA-Z0-9_]{0,})%/g, + (subStr) => { + return `\${process.env.${subStr.substr(1, subStr.length - 2)}}` + } + )}\`}` + } + return `${reactName}="${value}"` + }) + .join(' ') + + return attrStr.length > 0 ? ` ${attrStr}` : '' + } + const serializedHeadTags: string[] = [] + const serializedBodyTags: string[] = [] + + headTags.map((_index, element) => { + if ( + element.tagName === 'title' || + (element.tagName === 'meta' && element.attribs.name === 'viewport') + ) { + return element + } + let hasChildren = element.children.length > 0 + let serializedAttrs = serializeAttrs(element.attribs) + + if (element.tagName === 'script' || element.tagName === 'style') { + hasChildren = false + serializedAttrs += ` dangerouslySetInnerHTML={{ __html: \`${gatherTextChildren( + element.children + ).replace(/`/g, '\\`')}\` }}` + } + + serializedHeadTags.push( + hasChildren + ? `<${element.tagName}${serializedAttrs}>${gatherTextChildren( + element.children + )}` + : `<${element.tagName}${serializedAttrs} />` + ) + + return element + }) + + bodyTags.map((_index, element) => { + if (element.tagName === 'div' && element.attribs.id === 'root') { + return element + } + let hasChildren = element.children.length > 0 + let serializedAttrs = serializeAttrs(element.attribs) + + if (element.tagName === 'script' || element.tagName === 'style') { + hasChildren = false + serializedAttrs += ` dangerouslySetInnerHTML={{ __html: \`${gatherTextChildren( + element.children + ).replace(/`/g, '\\`')}\` }}` + } + + serializedHeadTags.push( + hasChildren + ? `<${element.tagName}${serializedAttrs}>${gatherTextChildren( + element.children + )}` + : `<${element.tagName}${serializedAttrs} />` + ) + + return element + }) + + if (!this.isDryRun) { + await fs.promises.writeFile( + path.join(this.appDir, appPage), + `${ + globalCssContext.cssImports.size === 0 + ? '' + : [...globalCssContext.cssImports] + .map((file) => { + if (!this.isCra) { + file = file.startsWith('/') ? file.substr(1) : file + } + + return `import '${ + file.startsWith('/') + ? path.relative( + path.join(this.appDir, this.pagesDir), + file + ) + : file + }'` + }) + .join('\n') + '\n' + }${titleTag ? `import Head from 'next/head'` : ''} + +export default function MyApp({ Component, pageProps}) { + ${ + titleTag || metaViewport + ? `return ( + <> + + ${ + titleTag + ? `${gatherTextChildren( + titleTag.children + )}` + : '' + } + ${metaViewport ? `` : ''} + + + + + )` + : 'return ' + } +} +` + ) + + await fs.promises.writeFile( + path.join(this.appDir, documentPage), + `import Document, { Html, Head, Main, NextScript } from 'next/document' + +class MyDocument extends Document { + render() { + return ( + + + ${serializedHeadTags.join('\n ')} + + + +
+ + ${serializedBodyTags.join('\n ')} + + + ) + } +} + +export default MyDocument +` + ) + + const relativeIndexPath = path.relative( + path.join(this.appDir, this.pagesDir), + path.join(this.appDir, 'src', this.isCra ? '' : 'main') + ) + + // TODO: should we default to ssr: true below and recommend they + // set it to false if they encounter errors or prefer the more safe + // option to prevent their first start from having any errors? + await fs.promises.writeFile( + path.join(this.appDir, catchAllPage), + `// import NextIndexWrapper from '${relativeIndexPath}' + +// next/dynamic is used to prevent breaking incompatibilities +// with SSR from window.SOME_VAR usage, if this is not used +// next/dynamic can be removed to take advantage of SSR/prerendering +import dynamic from 'next/dynamic' + +// try changing "ssr" to true below to test for incompatibilities, if +// no errors occur the above static import can be used instead and the +// below removed +const NextIndexWrapper = dynamic(() => import('${relativeIndexPath}'), { ssr: false }) + +export default function Page(props) { + return +} +` + ) + } + this.logCreate(appPage) + this.logCreate(documentPage) + this.logCreate(catchAllPage) + } + + private async updatePackageJson() { + // rename react-scripts -> next and react-scripts test -> jest + // add needed dependencies for webpack compatibility + const newDependencies: Array<{ + name: string + version: string + }> = [ + // TODO: do we want to install jest automatically? + { + name: 'next', + version: 'latest', + }, + ] + const packageName = this.isCra ? 'react-scripts' : 'vite' + const packagesToRemove = { + [packageName]: undefined, + } + const neededDependencies: string[] = [] + const { devDependencies, dependencies, scripts } = this.packageJsonData + + for (const dep of newDependencies) { + if (!devDependencies?.[dep.name] && !dependencies?.[dep.name]) { + neededDependencies.push(`${dep.name}@${dep.version}`) + } + } + + this.logInfo( + `Installing ${neededDependencies.join(' ')} with ${this.installClient}` + ) + + if (!this.isDryRun) { + await fs.promises.writeFile( + this.packageJsonPath, + JSON.stringify( + { + ...this.packageJsonData, + scripts: Object.keys(scripts).reduce((prev, cur) => { + const command = scripts[cur] + prev[cur] = command + + if (command === packageName) { + prev[cur] = 'next dev' + } + + if (command.includes(`${packageName} `)) { + prev[cur] = command.replace( + `${packageName} `, + command.includes(`${packageName} test`) ? 'jest ' : 'next ' + ) + } + if (cur === 'eject') { + prev[cur] = undefined + } + // TODO: do we want to map start -> next start instead of CRA's + // default of mapping starting to dev mode? + if (cur === 'start') { + prev[cur] = prev[cur].replace('next start', 'next dev') + prev['start-production'] = 'next start' + } + return prev + }, {} as { [key: string]: string }), + dependencies: { + ...dependencies, + ...packagesToRemove, + }, + devDependencies: { + ...devDependencies, + ...packagesToRemove, + }, + }, + null, + 2 + ) + ) + + await install(this.appDir, neededDependencies, { + useYarn: this.installClient === 'yarn', + // do we want to detect offline as well? they might not + // have next in the local cache already + isOnline: true, + }) + } + } + + private async updateGitIgnore() { + // add Next.js specific items to .gitignore e.g. '.next' + const gitignorePath = path.join(this.appDir, '.gitignore') + let ignoreContent = await fs.promises.readFile(gitignorePath, 'utf8') + const nextIgnores = ( + await fs.promises.readFile( + path.join(path.dirname(globalCssTransformPath), 'gitignore'), + 'utf8' + ) + ).split('\n') + + if (!this.isDryRun) { + for (const ignore of nextIgnores) { + if (!ignoreContent.includes(ignore)) { + ignoreContent += `\n${ignore}` + } + } + + await fs.promises.writeFile(gitignorePath, ignoreContent) + } + this.logModify('.gitignore') + } + + private async createNextConfig() { + if (!this.isDryRun) { + const { proxy, homepage } = this.packageJsonData + const homepagePath = new URL(homepage || '/', 'http://example.com') + .pathname + + await fs.promises.writeFile( + path.join(this.appDir, 'next.config.js'), + `module.exports = {${ + proxy + ? ` + async rewrites() { + return { + fallback: [ + { + source: '/:path*', + destination: '${proxy}' + } + ] + } + },` + : '' + } + env: { + PUBLIC_URL: '${homepagePath === '/' ? '' : homepagePath || ''}' + }, + experimental: { + craCompat: true, + }, + // Remove this to leverage Next.js' static image handling + // read more here: https://nextjs.org/docs/api-reference/next/image + images: { + disableStaticImages: true + } +} +` + ) + } + this.logCreate('next.config.js') + } + + private getPagesDir() { + // prefer src/pages as CRA uses the src dir by default + // and attempt falling back to top-level pages dir + let pagesDir = 'src/pages' + + if (fs.existsSync(path.join(this.appDir, pagesDir))) { + pagesDir = 'pages' + } + + if (fs.existsSync(path.join(this.appDir, pagesDir))) { + fatalMessage( + `Error: a "./pages" directory already exists, please rename to continue` + ) + } + return pagesDir + } + + private loadPackageJson() { + let packageJsonData + + try { + packageJsonData = JSON.parse( + fs.readFileSync(this.packageJsonPath, 'utf8') + ) + } catch (err) { + fatalMessage( + `Error: failed to load package.json from ${this.packageJsonPath}, ensure provided directory is root of CRA project` + ) + } + + return packageJsonData + } + + private validateAppDir(files: string[]) { + if (files.length > 1) { + fatalMessage( + `Error: only one directory should be provided for the cra-to-next transform, received ${files.join( + ', ' + )}` + ) + } + const appDir = path.join(process.cwd(), files[0]) + let isValidDirectory = false + + try { + isValidDirectory = fs.lstatSync(appDir).isDirectory() + } catch (err) { + // not a valid directory + } + + if (!isValidDirectory) { + fatalMessage( + `Error: invalid directory provided for the cra-to-next transform, received ${appDir}` + ) + } + return appDir + } +} + +export default async function transformer(files, flags) { + try { + const craTransform = new CraTransform(files, flags) + await craTransform.transform() + + console.log(`CRA to Next.js migration complete`, `\n${feedbackMessage}`) + } catch (err) { + fatalMessage(`Error: failed to complete transform`, err) + } +} diff --git a/packages/next-codemod/transforms/name-default-component.ts b/packages/next-codemod/transforms/name-default-component.ts index bcbf7c34202a5..ef81c9a6799fc 100644 --- a/packages/next-codemod/transforms/name-default-component.ts +++ b/packages/next-codemod/transforms/name-default-component.ts @@ -1,3 +1,12 @@ +import { + API, + ArrowFunctionExpression, + ASTPath, + ExportDefaultDeclaration, + FileInfo, + FunctionDeclaration, + Options, +} from 'jscodeshift' import { basename, extname } from 'path' const camelCase = (value: string): string => { @@ -10,16 +19,11 @@ const camelCase = (value: string): string => { const isValidIdentifier = (value: string): boolean => /^[a-zA-ZÀ-ÿ][0-9a-zA-ZÀ-ÿ]+$/.test(value) -type Node = { - type: string - declaration: { - type: string - body: any - id?: any - } -} - -export default function transformer(file, api, options) { +export default function transformer( + file: FileInfo, + api: API, + options: Options +) { const j = api.jscodeshift const root = j(file.source) @@ -37,8 +41,10 @@ export default function transformer(file, api, options) { return !program || program?.value?.type === 'Program' } - const nameFunctionComponent = (path): void => { - const node: Node = path.value + const nameFunctionComponent = ( + path: ASTPath + ): void => { + const node = path.value if (!node.declaration) { return @@ -77,14 +83,17 @@ export default function transformer(file, api, options) { if (isArrowFunction) { path.insertBefore( j.variableDeclaration('const', [ - j.variableDeclarator(j.identifier(name), node.declaration), + j.variableDeclarator( + j.identifier(name), + node.declaration as ArrowFunctionExpression + ), ]) ) node.declaration = j.identifier(name) } else { // Anonymous Function - node.declaration.id = j.identifier(name) + ;(node.declaration as FunctionDeclaration).id = j.identifier(name) } } diff --git a/packages/next/build/webpack-config.ts b/packages/next/build/webpack-config.ts index c1ddc1ce1234e..04ced8c3e50bb 100644 --- a/packages/next/build/webpack-config.ts +++ b/packages/next/build/webpack-config.ts @@ -1419,6 +1419,7 @@ export default async function getBaseWebpackConfig( sassOptions: config.sassOptions, productionBrowserSourceMaps: config.productionBrowserSourceMaps, future: config.future, + isCraCompat: config.experimental.craCompat, }) let originalDevtool = webpackConfig.devtool @@ -1453,6 +1454,72 @@ export default async function getBaseWebpackConfig( } } + if ( + config.experimental.craCompat && + webpackConfig.module?.rules && + webpackConfig.plugins + ) { + // CRA prevents loading all locales by default + // https://github.com/facebook/create-react-app/blob/fddce8a9e21bf68f37054586deb0c8636a45f50b/packages/react-scripts/config/webpack.config.js#L721 + webpackConfig.plugins.push( + new webpack.IgnorePlugin(/^\.\/locale$/, /moment$/) + ) + + // CRA allows importing non-webpack handled files with file-loader + // these need to be the last rule to prevent catching other items + // https://github.com/facebook/create-react-app/blob/fddce8a9e21bf68f37054586deb0c8636a45f50b/packages/react-scripts/config/webpack.config.js#L594 + const fileLoaderExclude = [/\.(js|mjs|jsx|ts|tsx|json)$/] + const fileLoader = isWebpack5 + ? { + exclude: fileLoaderExclude, + issuer: fileLoaderExclude, + type: 'asset/resource', + generator: { + publicPath: '/_next/', + filename: 'static/media/[name].[hash:8].[ext]', + }, + } + : { + loader: require.resolve('next/dist/compiled/file-loader'), + // Exclude `js` files to keep "css" loader working as it injects + // its runtime that would otherwise be processed through "file" loader. + // Also exclude `html` and `json` extensions so they get processed + // by webpacks internal loaders. + exclude: fileLoaderExclude, + issuer: fileLoaderExclude, + options: { + publicPath: '/_next/static/media', + outputPath: 'static/media', + name: '[name].[hash:8].[ext]', + }, + } + + const topRules = [] + const innerRules = [] + + for (const rule of webpackConfig.module.rules) { + if (rule.resolve) { + topRules.push(rule) + } else { + if ( + rule.oneOf && + !(rule.test || rule.exclude || rule.resource || rule.issuer) + ) { + rule.oneOf.forEach((r) => innerRules.push(r)) + } else { + innerRules.push(rule) + } + } + } + + webpackConfig.module.rules = [ + ...(topRules as any), + { + oneOf: [...innerRules, fileLoader], + }, + ] + } + // Backwards compat with webpack-dev-middleware options object if (typeof config.webpackDevMiddleware === 'function') { const options = config.webpackDevMiddleware({ diff --git a/packages/next/build/webpack/config/blocks/css/index.ts b/packages/next/build/webpack/config/blocks/css/index.ts index b4a60cc021282..02f44cf5c70a5 100644 --- a/packages/next/build/webpack/config/blocks/css/index.ts +++ b/packages/next/build/webpack/config/blocks/css/index.ts @@ -198,10 +198,12 @@ export const css = curry(async function css( include: { and: [/node_modules/] }, // Global CSS is only supported in the user's application, not in // node_modules. - issuer: { - and: [ctx.rootDirectory], - not: [/node_modules/], - }, + issuer: ctx.isCraCompat + ? undefined + : { + and: [ctx.rootDirectory], + not: [/node_modules/], + }, use: getGlobalCssLoader(ctx, postCssPlugins), }, ], @@ -245,22 +247,24 @@ export const css = curry(async function css( } // Throw an error for Global CSS used inside of `node_modules` - fns.push( - loader({ - oneOf: [ - { - test: [regexCssGlobal, regexSassGlobal], - issuer: { and: [/node_modules/] }, - use: { - loader: 'error-loader', - options: { - reason: getGlobalModuleImportError(), + if (!ctx.isCraCompat) { + fns.push( + loader({ + oneOf: [ + { + test: [regexCssGlobal, regexSassGlobal], + issuer: { and: [/node_modules/] }, + use: { + loader: 'error-loader', + options: { + reason: getGlobalModuleImportError(), + }, }, }, - }, - ], - }) - ) + ], + }) + ) + } // Throw an error for Global CSS used outside of our custom file fns.push( diff --git a/packages/next/build/webpack/config/index.ts b/packages/next/build/webpack/config/index.ts index 28e29348162f4..16ee6224fe1c8 100644 --- a/packages/next/build/webpack/config/index.ts +++ b/packages/next/build/webpack/config/index.ts @@ -15,6 +15,7 @@ export async function build( sassOptions, productionBrowserSourceMaps, future, + isCraCompat, }: { rootDirectory: string customAppFile: string | null @@ -24,6 +25,7 @@ export async function build( sassOptions: any productionBrowserSourceMaps: boolean future: NextConfig['future'] + isCraCompat?: boolean } ): Promise { const ctx: ConfigurationContext = { @@ -41,6 +43,7 @@ export async function build( sassOptions, productionBrowserSourceMaps, future, + isCraCompat, } const fn = pipe(base(ctx), css(ctx)) diff --git a/packages/next/build/webpack/config/utils.ts b/packages/next/build/webpack/config/utils.ts index 6a2e32da10037..574e3d802261e 100644 --- a/packages/next/build/webpack/config/utils.ts +++ b/packages/next/build/webpack/config/utils.ts @@ -17,6 +17,8 @@ export type ConfigurationContext = { productionBrowserSourceMaps: boolean future: NextConfig['future'] + + isCraCompat?: boolean } export type ConfigurationFn = ( diff --git a/packages/next/next-server/server/config-shared.ts b/packages/next/next-server/server/config-shared.ts index 5f7c52c1ad757..3b94c9d8b854f 100644 --- a/packages/next/next-server/server/config-shared.ts +++ b/packages/next/next-server/server/config-shared.ts @@ -60,6 +60,7 @@ export type NextConfig = { [key: string]: any } & { reactRoot?: boolean disableOptimizedLoading?: boolean gzipSize?: boolean + craCompat?: boolean } } @@ -114,6 +115,7 @@ export const defaultConfig: NextConfig = { reactRoot: Number(process.env.NEXT_PRIVATE_REACT_ROOT) > 0, disableOptimizedLoading: true, gzipSize: true, + craCompat: false, }, webpack5: Number(process.env.NEXT_PRIVATE_TEST_WEBPACK4_MODE) > 0 ? false : undefined, diff --git a/yarn.lock b/yarn.lock index ad0e9d82206f9..beefdc3089286 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3046,6 +3046,14 @@ dependencies: "@types/jest-diff" "*" +"@types/jscodeshift@0.11.0": + version "0.11.0" + resolved "https://registry.yarnpkg.com/@types/jscodeshift/-/jscodeshift-0.11.0.tgz#7224cf1a4d0383b4fb2694ffed52f57b45c3325b" + integrity sha512-OcJgr5GznWCEx2Oeg4eMUZYwssTHFj/tU8grrNCKdFQtAEAa0ezDiPHbCdSkyWrRSurXrYbNbHdhxbbB76pXNg== + dependencies: + ast-types "^0.14.1" + recast "^0.20.3" + "@types/json-schema@*": version "7.0.7" resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.7.tgz#98a993516c859eb0d5c4c8f098317a9ea68db9ad" @@ -4158,6 +4166,13 @@ ast-types@0.13.2: version "0.13.2" resolved "https://registry.yarnpkg.com/ast-types/-/ast-types-0.13.2.tgz#df39b677a911a83f3a049644fb74fdded23cea48" +ast-types@0.14.2, ast-types@^0.14.1: + version "0.14.2" + resolved "https://registry.yarnpkg.com/ast-types/-/ast-types-0.14.2.tgz#600b882df8583e3cd4f2df5fa20fa83759d4bdfd" + integrity sha512-O0yuUDnZeQDL+ncNGlJ78BiO4jnYI3bvMsD5prT0/nsgijG/LpNBIr63gTjVTNsiGkgQhiyCShTgxt8oXOrklA== + dependencies: + tslib "^2.0.1" + astral-regex@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/astral-regex/-/astral-regex-2.0.0.tgz#483143c567aeed4785759c0865786dc77d7d2e31" @@ -5029,6 +5044,17 @@ check-types@^11.1.1: resolved "https://registry.yarnpkg.com/check-types/-/check-types-11.1.2.tgz#86a7c12bf5539f6324eb0e70ca8896c0e38f3e2f" integrity sha512-tzWzvgePgLORb9/3a0YenggReLKAIb2owL03H2Xdoe5pKcUyWRSEQ8xfCar8t2SIAuEDwtmx2da1YB52YuHQMQ== +cheerio-select@^1.4.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/cheerio-select/-/cheerio-select-1.4.0.tgz#3a16f21e37a2ef0f211d6d1aa4eff054bb22cdc9" + integrity sha512-sobR3Yqz27L553Qa7cK6rtJlMDbiKPdNywtR95Sj/YgfpLfy0u6CGJuaBKe5YE/vTc23SCRKxWSdlon/w6I/Ew== + dependencies: + css-select "^4.1.2" + css-what "^5.0.0" + domelementtype "^2.2.0" + domhandler "^4.2.0" + domutils "^2.6.0" + cheerio@0.22.0: version "0.22.0" resolved "https://registry.yarnpkg.com/cheerio/-/cheerio-0.22.0.tgz#a9baa860a3f9b595a6b81b1a86873121ed3a269e" @@ -5050,6 +5076,19 @@ cheerio@0.22.0: lodash.reject "^4.4.0" lodash.some "^4.4.0" +cheerio@1.0.0-rc.9: + version "1.0.0-rc.9" + resolved "https://registry.yarnpkg.com/cheerio/-/cheerio-1.0.0-rc.9.tgz#a3ae6b7ce7af80675302ff836f628e7cb786a67f" + integrity sha512-QF6XVdrLONO6DXRF5iaolY+odmhj2CLj+xzNod7INPWMi/x9X4SOylH0S/vaPpX+AUU6t04s34SQNh7DbkuCng== + dependencies: + cheerio-select "^1.4.0" + dom-serializer "^1.3.1" + domhandler "^4.2.0" + htmlparser2 "^6.1.0" + parse5 "^6.0.1" + parse5-htmlparser2-tree-adapter "^6.0.1" + tslib "^2.2.0" + child-process-promise@^2.1.3: version "2.2.1" resolved "https://registry.yarnpkg.com/child-process-promise/-/child-process-promise-2.2.1.tgz#4730a11ef610fad450b8f223c79d31d7bdad8074" @@ -5967,6 +6006,17 @@ css-select@^2.0.0: domutils "^1.7.0" nth-check "^1.0.2" +css-select@^4.1.2: + version "4.1.2" + resolved "https://registry.yarnpkg.com/css-select/-/css-select-4.1.2.tgz#8b52b6714ed3a80d8221ec971c543f3b12653286" + integrity sha512-nu5ye2Hg/4ISq4XqdLY2bEatAcLIdt3OYGFc9Tm9n7VSlFBcfRv0gBNksHRgSdUDQGtN3XrZ94ztW+NfzkFSUw== + dependencies: + boolbase "^1.0.0" + css-what "^5.0.0" + domhandler "^4.2.0" + domutils "^2.6.0" + nth-check "^2.0.0" + css-select@~1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/css-select/-/css-select-1.2.0.tgz#2b3a110539c5355f1cd8d314623e870b121ec858" @@ -6011,6 +6061,11 @@ css-what@^3.2.1: version "3.2.1" resolved "https://registry.yarnpkg.com/css-what/-/css-what-3.2.1.tgz#f4a8f12421064621b456755e34a03a2c22df5da1" +css-what@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/css-what/-/css-what-5.0.0.tgz#f0bf4f8bac07582722346ab243f6a35b512cfc47" + integrity sha512-qxyKHQvgKwzwDWC/rGbT821eJalfupxYW2qbSJSAtdSTimsr/MlaGONoNLllaUPZWf8QnbcKM/kPVYUQuEKAFA== + css.escape@1.5.1: version "1.5.1" resolved "https://registry.yarnpkg.com/css.escape/-/css.escape-1.5.1.tgz#42e27d4fa04ae32f931a4b4d4191fa9cddee97cb" @@ -6489,6 +6544,15 @@ dom-serializer@1.1.0, dom-serializer@^1.0.1: domhandler "^3.0.0" entities "^2.0.0" +dom-serializer@^1.3.1: + version "1.3.1" + resolved "https://registry.yarnpkg.com/dom-serializer/-/dom-serializer-1.3.1.tgz#d845a1565d7c041a95e5dab62184ab41e3a519be" + integrity sha512-Pv2ZluG5ife96udGgEDovOOOA5UELkltfJpnIExPrAk1LTvecolUGn6lIaoLh86d83GiB86CjzciMd9BuRB71Q== + dependencies: + domelementtype "^2.0.1" + domhandler "^4.0.0" + entities "^2.0.0" + dom-serializer@~0.1.0: version "0.1.1" resolved "https://registry.yarnpkg.com/dom-serializer/-/dom-serializer-0.1.1.tgz#1ec4059e284babed36eec2941d4a970a189ce7c0" @@ -6518,6 +6582,11 @@ domelementtype@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/domelementtype/-/domelementtype-2.0.1.tgz#1f8bdfe91f5a78063274e803b4bdcedf6e94f94d" +domelementtype@^2.2.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/domelementtype/-/domelementtype-2.2.0.tgz#9a0b6c2782ed6a1c7323d42267183df9bd8b1d57" + integrity sha512-DtBMo82pv1dFtUmHyr48beiuq792Sxohr+8Hm9zoxklYPfa6n0Z3Byjj2IV7bmr2IyqClnqEQhfgHJJ5QF0R5A== + domexception@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/domexception/-/domexception-2.0.1.tgz#fb44aefba793e1574b0af6aed2801d057529f304" @@ -6537,6 +6606,13 @@ domhandler@^2.3.0: dependencies: domelementtype "1" +domhandler@^4.0.0, domhandler@^4.2.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/domhandler/-/domhandler-4.2.0.tgz#f9768a5f034be60a89a27c2e4d0f74eba0d8b059" + integrity sha512-zk7sgt970kzPks2Bf+dwT/PLzghLnsivb9CcxkvR8Mzr66Olr0Ofd8neSbglHJHaHa2MadfoSdNlKYAaafmWfA== + dependencies: + domelementtype "^2.2.0" + domutils@1.5.1: version "1.5.1" resolved "https://registry.yarnpkg.com/domutils/-/domutils-1.5.1.tgz#dcd8488a26f563d61079e48c9f7b7e32373682cf" @@ -6560,6 +6636,15 @@ domutils@^1.5.1, domutils@^1.7.0: dom-serializer "0" domelementtype "1" +domutils@^2.5.2, domutils@^2.6.0: + version "2.6.0" + resolved "https://registry.yarnpkg.com/domutils/-/domutils-2.6.0.tgz#2e15c04185d43fb16ae7057cb76433c6edb938b7" + integrity sha512-y0BezHuy4MDYxh6OvolXYsH+1EMGmFbwv5FKW7ovwMG6zTPWqNPq3WF9ayZssFq+UlKdffGLbOEaghNdaOm1WA== + dependencies: + dom-serializer "^1.0.1" + domelementtype "^2.2.0" + domhandler "^4.2.0" + dot-case@^2.1.0: version "2.1.1" resolved "https://registry.yarnpkg.com/dot-case/-/dot-case-2.1.1.tgz#34dcf37f50a8e93c2b3bca8bb7fb9155c7da3bee" @@ -8540,6 +8625,16 @@ htmlparser2@^3.9.1: inherits "^2.0.1" readable-stream "^3.1.1" +htmlparser2@^6.1.0: + version "6.1.0" + resolved "https://registry.yarnpkg.com/htmlparser2/-/htmlparser2-6.1.0.tgz#c4d762b6c3371a05dbe65e94ae43a9f845fb8fb7" + integrity sha512-gyyPk6rgonLFEDGoeRgQNaEUvdJ4ktTmmUh/h2t7s+M8oPpIPxgNACWa+6ESR57kXstwqPiCut0V8NRpcwgU7A== + dependencies: + domelementtype "^2.0.1" + domhandler "^4.0.0" + domutils "^2.5.2" + entities "^2.0.0" + http-cache-semantics@^4.0.0: version "4.0.3" resolved "https://registry.yarnpkg.com/http-cache-semantics/-/http-cache-semantics-4.0.3.tgz#495704773277eeef6e43f9ab2c2c7d259dda25c5" @@ -11856,6 +11951,13 @@ nth-check@^1.0.2, nth-check@~1.0.1: dependencies: boolbase "~1.0.0" +nth-check@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/nth-check/-/nth-check-2.0.0.tgz#1bb4f6dac70072fc313e8c9cd1417b5074c0a125" + integrity sha512-i4sc/Kj8htBrAiH1viZ0TgU8Y5XqCaV/FziYK6TBczxmeKm3AEFWqqF3195yKudrarqy7Zu80Ra5dobFjn9X/Q== + dependencies: + boolbase "^1.0.0" + num2fraction@^1.2.2: version "1.2.2" resolved "https://registry.yarnpkg.com/num2fraction/-/num2fraction-1.2.2.tgz#6f682b6a027a4e9ddfa4564cd2589d1d4e669ede" @@ -14036,6 +14138,16 @@ recast@^0.16.1: private "~0.1.5" source-map "~0.6.1" +recast@^0.20.3: + version "0.20.4" + resolved "https://registry.yarnpkg.com/recast/-/recast-0.20.4.tgz#db55983eac70c46b3fff96c8e467d65ffb4a7abc" + integrity sha512-6qLIBGGRcwjrTZGIiBpJVC/NeuXpogXNyRQpqU1zWPUigCphvApoCs9KIwDYh1eDuJ6dAFlQoi/QUyE5KQ6RBQ== + dependencies: + ast-types "0.14.2" + esprima "~4.0.0" + source-map "~0.6.1" + tslib "^2.0.1" + rechoir@^0.6.2: version "0.6.2" resolved "https://registry.yarnpkg.com/rechoir/-/rechoir-0.6.2.tgz#85204b54dba82d5742e28c96756ef43af50e3384" @@ -16214,6 +16326,11 @@ tslib@2.0.1: resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.0.1.tgz#410eb0d113e5b6356490eec749603725b021b43e" integrity sha512-SgIkNheinmEBgx1IUNirK0TUD4X9yjjBRTqqjggWCU3pUEqIk3/Uwl3yRixYKT6WjQuGiwDv4NomL3wqRCj+CQ== +tslib@^2.0.1, tslib@^2.2.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.2.0.tgz#fb2c475977e35e241311ede2693cee1ec6698f5c" + integrity sha512-gS9GVHRU+RGn5KQM2rllAlR3dU6m7AcpJKdtH8gFvQiC4Otgk98XnmMU+nZenHt/+VhnBPWwgrJsyrdcw6i23w== + tslib@^2.0.3: version "2.0.3" resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.0.3.tgz#8e0741ac45fc0c226e58a17bfc3e64b9bc6ca61c"