From 99d72f7afc354abb66ed0e4ffb020bede2781286 Mon Sep 17 00:00:00 2001 From: Fran Dios Date: Thu, 4 Apr 2024 12:25:56 +0200 Subject: [PATCH] Make `h2 setup vite` command stable (#1915) * Throw if project is already using Vite * Remove css-bundle dependency * Support vanilla-extract * Support CSS modules * Minor fixes * Move remix.config properties to vite.config * Notify about MDX support * Cleanup * Changesets * Remove some promise nesting * Improve support for Tailwind --- .changeset/weak-moons-call.md | 5 + .../cli/src/commands/hydrogen/setup/vite.ts | 479 ++++++++++++------ packages/cli/src/lib/remix-config.ts | 14 +- packages/cli/src/lib/vite-config.ts | 6 + .../cli/src/setup-assets/vite/package.json | 1 - .../cli/src/setup-assets/vite/vite.config.js | 9 +- 6 files changed, 347 insertions(+), 167 deletions(-) create mode 100644 .changeset/weak-moons-call.md diff --git a/.changeset/weak-moons-call.md b/.changeset/weak-moons-call.md new file mode 100644 index 0000000000..181a94434f --- /dev/null +++ b/.changeset/weak-moons-call.md @@ -0,0 +1,5 @@ +--- +'@shopify/cli-hydrogen': patch +--- + +Improve `h2 setup vite` command to cover more migration steps (e.g. vanilla-extract, css-modules, etc.) and keep Remix future flags. diff --git a/packages/cli/src/commands/hydrogen/setup/vite.ts b/packages/cli/src/commands/hydrogen/setup/vite.ts index 88efc84be8..c28afa92ec 100644 --- a/packages/cli/src/commands/hydrogen/setup/vite.ts +++ b/packages/cli/src/commands/hydrogen/setup/vite.ts @@ -1,26 +1,27 @@ -import {resolvePath} from '@shopify/cli-kit/node/path'; +import {joinPath, resolvePath} from '@shopify/cli-kit/node/path'; import Command from '@shopify/cli-kit/node/base-command'; +import {renderSuccess, renderTasks} from '@shopify/cli-kit/node/ui'; import { - renderConfirmationPrompt, - renderSuccess, - renderTasks, -} from '@shopify/cli-kit/node/ui'; -import { - copyFile, + fileExists, moveFile, + readFile, removeFile, writeFile, } from '@shopify/cli-kit/node/fs'; import { getPackageManager, installNodeModules, + readAndParsePackageJson, } from '@shopify/cli-kit/node/node-package-manager'; import {commonFlags, flagsToCamelObject} from '../../../lib/flags.js'; -import {getRemixConfig} from '../../../lib/remix-config.js'; +import {getRawRemixConfig} from '../../../lib/remix-config.js'; import {mergePackageJson, replaceFileContent} from '../../../lib/file.js'; import {importLangAstGrep} from '../../../lib/ast.js'; import {getAssetDir} from '../../../lib/build.js'; -import {getCodeFormatOptions} from '../../../lib/format-code.js'; +import {formatCode, getCodeFormatOptions} from '../../../lib/format-code.js'; +import {hasViteConfig} from '../../../lib/vite-config.js'; +import {AbortError} from '@shopify/cli-kit/node/error'; +import {outputNewline} from '@shopify/cli-kit/node/output'; export default class SetupVite extends Command { static description = 'EXPERIMENTAL: Upgrades the project to use Vite.'; @@ -40,19 +41,40 @@ export default class SetupVite extends Command { } } +const tailwindPostCSSConfig = `export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; +`; + export async function runSetupVite({directory}: {directory: string}) { - const remixConfigPromise = getRemixConfig(directory); + outputNewline(); + if (await hasViteConfig(directory)) { + throw new AbortError('This project already has a Vite config file.'); + } - const shouldContinue = await renderConfirmationPrompt({ - message: - 'Are you sure you want to upgrade to Vite?\nThis is still an experimental feature and may not work as expected', - }); + const [rawRemixConfig, pkgJson, formatOptions] = await Promise.all([ + getRawRemixConfig(directory), + readAndParsePackageJson(joinPath(directory, 'package.json')), + getCodeFormatOptions(directory), + ]); - if (!shouldContinue) return; + const serverEntry = rawRemixConfig.server || 'server.js'; + const isTS = serverEntry.endsWith('.ts'); + const fileExt = isTS ? 'tsx' : 'jsx'; + const viteAssets = getAssetDir('vite'); + const usesTailwind = !!rawRemixConfig.tailwind; + const postCssConfigPath = resolvePath(directory, 'postcss.config.js'); const handlePartialIssue = () => {}; const backgroundWorkPromise = Promise.all([ + removeFile(resolvePath(directory, 'remix.config.js')).catch( + handlePartialIssue, + ), + moveFile( resolvePath(directory, 'remix.env.d.ts'), resolvePath(directory, 'env.d.ts'), @@ -66,166 +88,291 @@ export async function runSetupVite({directory}: {directory: string}) { ), ) .catch(handlePartialIssue), + moveFile( resolvePath(directory, '.eslintrc.js'), resolvePath(directory, '.eslintrc.cjs'), ).catch(handlePartialIssue), - moveFile( - resolvePath(directory, 'postcss.config.js'), - resolvePath(directory, 'postcss.config.cjs'), - ).catch(handlePartialIssue), - remixConfigPromise.then((config) => { - const serverEntry = config.serverEntryPoint || 'server.js'; - const isTS = serverEntry.endsWith('.ts'); - const fileExt = isTS ? 'tsx' : 'jsx'; - const viteAssets = getAssetDir('vite'); - - return Promise.all([ - removeFile(resolvePath(directory, 'remix.config.js')).catch( - handlePartialIssue, - ), - copyFile( - resolvePath(viteAssets, 'vite.config.js'), - resolvePath(directory, 'vite.config.' + fileExt.slice(0, 2)), - ), - mergePackageJson(viteAssets, directory), - replaceFileContent( - resolvePath(directory, serverEntry), - false, - (content) => - (isTS ? '// @ts-ignore\n' : '') + - content.replace( - '@remix-run/dev/server-build', - 'virtual:remix/server-build', + + // Adjust PostCSS configuration: + readFile(resolvePath(directory, 'postcss.config.js')) + .then(async (postCssContent) => { + // Classic Remix supported tailwind without adding it + // to the postcss.config.js file. We need to add it here now: + const hasTailwindPlugin = postCssContent.includes('tailwindcss'); + if (!hasTailwindPlugin && usesTailwind) { + postCssContent = await formatCode( + postCssContent.replace( + /(plugins:\s+{)/, + '$1\n tailwindcss: {},', ), - ), - importLangAstGrep(fileExt).then(async (astGrep) => { - const codeFormatOpt = getCodeFormatOptions(directory); - const rootFilepath = resolvePath( - config.appDirectory, - 'root.' + fileExt, + formatOptions, ); + } - // Add ?url to all CSS imports: - await new Promise(async (resolve, reject) => { - const fileNumber = await astGrep.findInFiles( - { - paths: [ - rootFilepath, - resolvePath(config.appDirectory, 'routes'), - ], - matcher: { - rule: { - kind: 'string_fragment', - regex: '\\.css$', - inside: { - kind: 'import_statement', - stopBy: 'end', - }, - }, - }, - }, - async (err, nodes) => { - if (err) reject(err); - const nodeMap = {} as Record< - string, - {content: string; positions: number[]} - >; - - nodes.forEach((node) => { - const filename = node.getRoot().filename(); - const content = node.getRoot().root().text(); - const position = node.range().end.index; - - const tmp = nodeMap[filename] || {content, positions: []}; - tmp.positions.push(position); - nodeMap[filename] = tmp; - }); - - await Promise.all( - Object.entries(nodeMap).flatMap( - ([filename, {content, positions}]) => { - // Start from the end to avoid moving character indexes: - positions.reverse(); - - for (const position of positions) { - content = - content.slice(0, position) + - '?url' + - content.slice(position); - } - - return writeFile(filename, content); - }, - ), - ); - - resolve(null); - }, + const isCJS = + postCssContent.includes('module.exports') || + postCssContent.includes('exports.') || + postCssContent.includes('require('); + + return writeFile( + isCJS ? postCssConfigPath.replace('.js', '.cjs') : postCssConfigPath, + postCssContent, + ); + }) + .catch(async () => { + // PostCSS file not found + if ( + usesTailwind && + !(await fileExists(postCssConfigPath)) && + !(await fileExists(postCssConfigPath.replace('.js', '.cjs'))) + ) { + return writeFile(postCssConfigPath, tailwindPostCSSConfig); + } + }) + .catch(handlePartialIssue), + + // Adjust dependencies: + mergePackageJson(viteAssets, directory, { + onResult(pkgJson) { + if (pkgJson.dependencies) { + // This dependency is not needed in Vite projects: + delete pkgJson.dependencies['@remix-run/css-bundle']; + } + + if (pkgJson.devDependencies) { + if (pkgJson.devDependencies['@vanilla-extract/css']) { + // Required vanilla-extract dependency for Vite + pkgJson.devDependencies['@vanilla-extract/vite-plugin'] = '^4.0.0'; + + // Sort dependencies: + pkgJson.devDependencies = Object.keys(pkgJson.devDependencies) + .sort() + .reduce((acc, key) => { + acc[key] = pkgJson.devDependencies?.[key]!; + return acc; + }, {} as Record); + } + } + + return pkgJson; + }, + }), + + // Build `vite.config.js` for the project: + readFile(resolvePath(viteAssets, 'vite.config.js')).then( + async (viteConfigContent) => { + const hasVanillaExtract = + !!pkgJson.devDependencies?.['@vanilla-extract/css']; + + if (hasVanillaExtract) { + viteConfigContent = viteConfigContent + .replace( + /\n\n/g, + `\nimport {vanillaExtractPlugin} from '@vanilla-extract/vite-plugin';\n\n`, + ) + .replace(/^(\s+)\],/m, '$1 vanillaExtractPlugin(),\n$1],'); + } + + const {future, appDirectory, ignoredRouteFiles, routes} = + rawRemixConfig; + + // Future flags: + for (const flag of [ + 'v3_fetcherPersist', + 'v3_throwAbortReason', + 'v3_relativeSplatPath', + ] as const) { + if (!future?.[flag]) { + viteConfigContent = viteConfigContent.replace( + `${flag}: true`, + `${flag}: false`, ); + } + } - if (fileNumber === 0) resolve(null); - }); - - // Remove the LiveReload import and usage: - await replaceFileContent( - rootFilepath, - await codeFormatOpt, - (content) => { - const root = astGrep.parse(content).root(); - const liveReloadRegex = 'LiveReload'; - const hasLiveReloadRule = { - kind: 'identifier', - regex: liveReloadRegex, - }; - - const liveReloadImport = root.find({ - rule: { - kind: 'import_specifier', - regex: liveReloadRegex, - inside: { - kind: 'import_statement', - stopBy: 'end', - }, - }, - }); - - const liveReloadElements = root.findAll({ - rule: { - any: [ - { - kind: 'jsx_self_closing_element', - has: hasLiveReloadRule, - }, - { - kind: 'jsx_element', - has: { - kind: 'jsx_opening_element', - has: hasLiveReloadRule, - }, - }, - ], + if (appDirectory && appDirectory !== 'app') { + viteConfigContent = viteConfigContent.replace( + /^(\s+)(future:)/m, + `$1appDirectory: '${appDirectory}',\n$1$2`, + ); + } + + if (ignoredRouteFiles) { + viteConfigContent = viteConfigContent.replace( + /^(\s+)(future:)/m, + `$1ignoredRouteFiles: ${JSON.stringify(ignoredRouteFiles)},\n$1$2`, + ); + } + + if (routes) { + viteConfigContent = viteConfigContent.replace( + /^(\s+)(future:)/m, + `$1routes: ${routes.toString()},\n$1$2`, + ); + } + + const viteConfigPath = resolvePath( + directory, + 'vite.config.' + fileExt.slice(0, 2), + ); + + viteConfigContent = await formatCode( + viteConfigContent, + formatOptions, + viteConfigPath, + ); + + return writeFile(viteConfigPath, viteConfigContent); + }, + ), + + // Adjust server entry: + replaceFileContent( + resolvePath(directory, serverEntry), + false, + (content) => + (isTS ? '// @ts-ignore\n' : '') + + content.replace( + '@remix-run/dev/server-build', + 'virtual:remix/server-build', + ), + ), + + // Adjust CSS imports in the project routes: + importLangAstGrep(fileExt).then(async (astGrep) => { + const appDirectory = resolvePath( + directory, + rawRemixConfig.appDirectory ?? 'app', + ); + + const rootFilepath = joinPath(appDirectory, 'root.' + fileExt); + + // Add ?url to all CSS imports: + await new Promise(async (resolve, reject) => { + const fileNumber = await astGrep.findInFiles( + { + paths: [rootFilepath, joinPath(appDirectory, 'routes')], + matcher: { + rule: { + kind: 'string_fragment', + regex: '\\.css$', + inside: { + kind: 'import_statement', + stopBy: 'end', }, - }); - - for (const node of [ - // From bottom to top - ...liveReloadElements.reverse(), - liveReloadImport, - ]) { - if (!node) continue; - - const {start, end} = node.range(); - content = - content.slice(0, start.index) + content.slice(end.index); + }, + }, + }, + async (err, nodes) => { + if (err) reject(err); + const nodeMap = {} as Record< + string, + {content: string; positions: number[]} + >; + + nodes.forEach((node) => { + if (node.text().endsWith('.module.css')) { + // Skip for CSS modules + return; } - // Remove the trailing comma from the import statement: - return content.replace(/,\s*,/g, ','); + const filename = node.getRoot().filename(); + const content = node.getRoot().root().text(); + const position = node.range().end.index; + + const tmp = nodeMap[filename] || {content, positions: []}; + tmp.positions.push(position); + nodeMap[filename] = tmp; + }); + + await Promise.all( + Object.entries(nodeMap).flatMap( + ([filename, {content, positions}]) => { + // Start from the end to avoid moving character indexes: + positions.reverse(); + + for (const position of positions) { + content = + content.slice(0, position) + + '?url' + + content.slice(position); + } + + return writeFile(filename, content); + }, + ), + ); + + resolve(null); + }, + ); + + if (fileNumber === 0) resolve(null); + }); + + // Remove the deprecated LiveReload and cssBundleHref: + await replaceFileContent(rootFilepath, formatOptions, (content) => { + const root = astGrep.parse(content).root(); + const liveReloadRegex = 'LiveReload'; + const hasLiveReloadRule = { + kind: 'identifier', + regex: liveReloadRegex, + }; + + const liveReloadImport = root.find({ + rule: { + kind: 'import_specifier', + regex: liveReloadRegex, + inside: { + kind: 'import_statement', + stopBy: 'end', }, - ); - }), - ]); + }, + }); + + const liveReloadElements = root.findAll({ + rule: { + any: [ + { + kind: 'jsx_self_closing_element', + has: hasLiveReloadRule, + }, + { + kind: 'jsx_element', + has: { + kind: 'jsx_opening_element', + has: hasLiveReloadRule, + }, + }, + ], + }, + }); + + for (const node of [ + // From bottom to top + ...liveReloadElements.reverse(), + liveReloadImport, + ]) { + if (!node) continue; + + const {start, end} = node.range(); + content = content.slice(0, start.index) + content.slice(end.index); + } + + return ( + content + // Remove the trailing comma from the import statement: + .replace(/,\s*,/g, ',') + // Remove cssBundleHref import + .replace( + /import\s+{\s+cssBundleHref\s+}\s+from\s+['"]@remix-run\/css-bundle['"];?\n/, + '', + ) + // Remove cssBundleHref usage + .replace(/\.\.\.\(\s*cssBundleHref[^)]+\),?/, '') + ); + }); }), ]); @@ -250,6 +397,12 @@ export async function runSetupVite({directory}: {directory: string}) { renderSuccess({ headline: `Your Vite project is ready!`, - body: `We've modified your project to use Vite experimental.\nPlease use git to review the changes.`, + body: `We've modified your project to use Vite.\nPlease use Git to review the changes.`, + nextSteps: [ + rawRemixConfig.mdx + ? 'Setup MDX support in Vite: https://remix.run/docs/en/main/future/vite#add-mdx-plugin' + : '', + `See more information about Vite in Remix at https://remix.run/docs/en/main/future/vite`, + ].filter(Boolean), }); } diff --git a/packages/cli/src/lib/remix-config.ts b/packages/cli/src/lib/remix-config.ts index 16bb529031..a3912370f2 100644 --- a/packages/cli/src/lib/remix-config.ts +++ b/packages/cli/src/lib/remix-config.ts @@ -3,14 +3,17 @@ import {fileURLToPath} from 'node:url'; import path from 'node:path'; import {readdir} from 'node:fs/promises'; import type {ServerMode} from '@remix-run/dev/dist/config/serverModes.js'; -import type {RemixConfig} from '@remix-run/dev/dist/config.js'; +import type {RemixConfig, AppConfig} from '@remix-run/dev/dist/config.js'; import {AbortError} from '@shopify/cli-kit/node/error'; import {outputWarn} from '@shopify/cli-kit/node/output'; import {fileExists} from '@shopify/cli-kit/node/fs'; import {muteRemixLogs} from './log.js'; import {getRequiredRemixVersion} from './remix-version-check.js'; +import {findFileWithExtension} from './file.js'; -export type {RemixConfig, ServerMode}; +type RawRemixConfig = AppConfig; + +export type {RemixConfig, ServerMode, RawRemixConfig}; const BUILD_DIR = 'dist'; // Hardcoded in Oxygen const CLIENT_SUBDIR = 'client'; @@ -43,6 +46,13 @@ export function handleRemixImportFail(): never { ); } +export function getRawRemixConfig(root: string) { + return findFileWithExtension(root, 'remix.config').then(({filepath}) => { + if (!filepath) throw new AbortError('No remix.config.js file found.'); + return createRequire(import.meta.url)(filepath) as RawRemixConfig; + }); +} + export async function getRemixConfig( root: string, mode = process.env.NODE_ENV as ServerMode, diff --git a/packages/cli/src/lib/vite-config.ts b/packages/cli/src/lib/vite-config.ts index 1a6c55989d..9bb2109bfd 100644 --- a/packages/cli/src/lib/vite-config.ts +++ b/packages/cli/src/lib/vite-config.ts @@ -1,5 +1,11 @@ import {joinPath} from '@shopify/cli-kit/node/path'; import type {RemixPluginContext} from '@remix-run/dev/dist/vite/plugin.js'; +import {findFileWithExtension} from './file.js'; + +export async function hasViteConfig(root: string) { + const result = await findFileWithExtension(root, 'vite.config'); + return !!result.filepath; +} export async function getViteConfig(root: string) { const vite = await import('vite'); diff --git a/packages/cli/src/setup-assets/vite/package.json b/packages/cli/src/setup-assets/vite/package.json index f4e25c02f7..e865abd67c 100644 --- a/packages/cli/src/setup-assets/vite/package.json +++ b/packages/cli/src/setup-assets/vite/package.json @@ -5,7 +5,6 @@ "dev": "shopify hydrogen dev-vite --codegen" }, "dependencies": { - "@remix-run/node": "^2.8.0", "isbot": "^3.8.0" }, "devDependencies": { diff --git a/packages/cli/src/setup-assets/vite/vite.config.js b/packages/cli/src/setup-assets/vite/vite.config.js index dadb879b48..654277cbd9 100644 --- a/packages/cli/src/setup-assets/vite/vite.config.js +++ b/packages/cli/src/setup-assets/vite/vite.config.js @@ -7,7 +7,14 @@ export default defineConfig({ plugins: [ hydrogen(), oxygen(), - remix({buildDirectory: 'dist'}), + remix({ + buildDirectory: 'dist', + future: { + v3_fetcherPersist: true, + v3_relativeSplatPath: true, + v3_throwAbortReason: true, + }, + }), tsconfigPaths(), ], });