Skip to content
Open
Show file tree
Hide file tree
Changes from 12 commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 35 additions & 9 deletions packages/vinext/src/check.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
*/

import { detectPackageManager } from "./utils/project.js";
import { findPostcssConfig } from "./plugins/postcss.js";
import fs from "node:fs";
import path from "node:path";

Expand Down Expand Up @@ -193,6 +194,11 @@ const CONFIG_SUPPORT: Record<string, { status: Status; detail?: string }> = {
status: "unsupported",
detail: "Vite replaces webpack — custom webpack configs need migration",
},
"turbopack.rules": {
status: "partial",
detail:
"generic Turbopack loader rules are not translated; the known Tailwind CSS loader is handled",
},
enablePrerenderSourceMaps: {
status: "supported",
detail: "sourcemap-resolved stack traces during prerender",
Expand Down Expand Up @@ -448,12 +454,29 @@ export function analyzeConfig(root: string): CheckItem[] {
}
}

if (hasTailwindTurbopackCssRule(content)) {
items.push({
name: "Tailwind Turbopack CSS loader",
status: "partial",
detail:
'vinext translates "@tailwindcss/webpack" CSS rules to "@tailwindcss/postcss" when needed',
});
}

// Sort: unsupported first
items.sort(compareByStatus);

return items;
}

function hasTailwindTurbopackCssRule(content: string): boolean {
return (
/\bturbopack\s*:\s*\{/.test(content) &&
/\brules\s*:\s*\{/.test(content) &&
/['"]@tailwindcss\/webpack['"]/.test(content)
);
}

/**
* Check package.json dependencies for known libraries.
*/
Expand Down Expand Up @@ -638,19 +661,23 @@ export function checkConventions(root: string): CheckItem[] {
});
}

// Check PostCSS config for string-form plugins
const postcssConfigs = ["postcss.config.mjs", "postcss.config.js", "postcss.config.cjs"];
for (const configFile of postcssConfigs) {
const configPath = path.join(root, configFile);
if (fs.existsSync(configPath)) {
const content = fs.readFileSync(configPath, "utf-8");
const postcssConfig = findPostcssConfig(root);
if (postcssConfig) {
const configFile = path.basename(postcssConfig.configPath);
const content = fs.readFileSync(postcssConfig.configPath, "utf-8");
if (configFile === "postcss.config.json") {
items.push({
name: `PostCSS config (${configFile})`,
status: "supported",
detail:
"postcss.config.json is supported by Next.js and vinext injects it into Vite automatically",
});
} else {
// Detect string-form plugins: plugins: ["..."] or plugins: ['...']
const stringPluginRegex = /plugins\s*:\s*\[[\s\S]*?(['"][^'"]+['"])[\s\S]*?\]/;
const match = stringPluginRegex.exec(content);
if (match) {
// Check it's not require() or import() form — just bare string literals in the array
const pluginsBlock = match[0];
// If plugins array contains string literals not wrapped in require()
if (/plugins\s*:\s*\[[\s\n]*['"]/.test(pluginsBlock)) {
items.push({
name: `PostCSS string-form plugins (${configFile})`,
Expand All @@ -660,7 +687,6 @@ export function checkConventions(root: string): CheckItem[] {
});
}
}
break; // Only check the first config file found
}
}

Expand Down
94 changes: 93 additions & 1 deletion packages/vinext/src/config/next-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,11 @@ export type NextConfig = {
serverExternalPackages?: string[];
/** Webpack config (ignored — we use Vite) */
webpack?: unknown;
/** Turbopack config. vinext translates a small set of compatibility cases. */
turbopack?: {
rules?: Record<string, unknown>;
resolveAlias?: Record<string, unknown>;
};
/**
* Path to a custom cache handler module (e.g., KV, Redis, DynamoDB).
* Accepts relative paths, absolute paths, or file:// URLs from import.meta.resolve().
Expand Down Expand Up @@ -296,6 +301,8 @@ export type ResolvedNextConfig = {
* change without modifying source — useful for cache-busting after CDN poisoning.
*/
hashSalt: string;
/** True when next.config uses the known Tailwind Turbopack CSS webpack loader shape. */
tailwindTurbopackCssLoader: boolean;
};

const CONFIG_FILES = ["next.config.ts", "next.config.mjs", "next.config.js", "next.config.cjs"];
Expand All @@ -318,6 +325,10 @@ function isCjsError(e: unknown): boolean {
);
}

function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === "object" && !Array.isArray(value);
}

// Dev-server phase is the safe default for config loading: it enables all
// optional config sections (headers, redirects, rewrites) without triggering
// build-only behaviour. Used in two default parameter values below to avoid
Expand Down Expand Up @@ -551,6 +562,7 @@ export async function resolveNextConfig(
enablePrerenderSourceMaps: true,
hashSalt: process.env.NEXT_HASH_SALT ?? "",
buildId,
tailwindTurbopackCssLoader: false,
deploymentId,
};
detectNextIntlConfig(root, resolved);
Expand Down Expand Up @@ -617,6 +629,8 @@ export async function resolveNextConfig(
...extractTurboAliases(config, root),
...webpackProbe.aliases,
};
const tailwindTurbopackCssLoader = hasTailwindTurbopackCssLoader(config);
const hasUnsupportedTurbopackRules = hasUnsupportedConfiguredTurbopackRules(config);

const allowedDevOrigins = Array.isArray(config.allowedDevOrigins) ? config.allowedDevOrigins : [];

Expand Down Expand Up @@ -675,6 +689,13 @@ export async function resolveNextConfig(
}
}

if (hasUnsupportedTurbopackRules) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This warning fires when any rule doesn't match the supported Tailwind-only shape, regardless of whether a Tailwind rule is also present. This is the correct behavior for mixed configs — the Codex review suggestion that this suppresses the warning when any Tailwind loader is detected is incorrect.

One readability nit: the message says "is not generally supported and will be ignored", which is accurate for the non-Tailwind rules but could be read as applying to all rules including the Tailwind one. Consider:

Suggested change
if (hasUnsupportedTurbopackRules) {
console.warn(
'[vinext] next.config option "turbopack.rules" contains rules that are not supported and will be ignored. ' +
"The known Tailwind CSS loader shape is translated automatically.",
);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed by tightening the warning wording so it applies to unsupported rules specifically while preserving the Tailwind translation note.

console.warn(
'[vinext] next.config option "turbopack.rules" is not generally supported and will be ignored. ' +
"The known Tailwind CSS loader shape is translated automatically.",
);
}

const output = config.output ?? "";
if (output && output !== "export" && output !== "standalone") {
console.warn(`[vinext] Unknown output mode "${output as string}", ignoring`);
Expand Down Expand Up @@ -733,6 +754,7 @@ export async function resolveNextConfig(
enablePrerenderSourceMaps: config.enablePrerenderSourceMaps ?? true,
hashSalt,
buildId,
tailwindTurbopackCssLoader,
deploymentId,
};

Expand Down Expand Up @@ -760,7 +782,7 @@ function normalizeAliasEntries(
function extractTurboAliases(config: NextConfig, root: string): Record<string, string> {
const experimental = config.experimental as Record<string, unknown> | undefined;
const experimentalTurbo = experimental?.turbo as Record<string, unknown> | undefined;
const topLevelTurbopack = config.turbopack as Record<string, unknown> | undefined;
const topLevelTurbopack = config.turbopack;

return {
...normalizeAliasEntries(
Expand All @@ -774,6 +796,76 @@ function extractTurboAliases(config: NextConfig, root: string): Record<string, s
};
}

function getTurbopackRuleRecords(config: NextConfig): Record<string, unknown>[] {
const records: Record<string, unknown>[] = [];
const experimental = config.experimental as Record<string, unknown> | undefined;
const experimentalTurbo = experimental?.turbo;
if (isRecord(experimentalTurbo) && isRecord(experimentalTurbo.rules)) {
records.push(experimentalTurbo.rules);
}
if (isRecord(config.turbopack?.rules)) {
records.push(config.turbopack.rules);
}
return records;
}

function isTailwindWebpackLoader(loader: unknown): boolean {
if (typeof loader === "string") {
return loader === "@tailwindcss/webpack";
}
if (isRecord(loader) && typeof loader.loader === "string") {
return loader.loader === "@tailwindcss/webpack";
}
return false;
}

function turbopackRuleHasTailwindLoader(rule: unknown): boolean {
if (Array.isArray(rule)) {
return rule.some((item) => {
if (isTailwindWebpackLoader(item)) return true;
return isRecord(item) && turbopackRuleHasTailwindLoader(item);
});
}

if (!isRecord(rule)) return false;

if (Array.isArray(rule.loaders)) {
return rule.loaders.some(isTailwindWebpackLoader);
}

return false;
}

function isSupportedTailwindTurbopackCssRule(rule: unknown): boolean {
if (Array.isArray(rule)) {
return (
rule.length > 0 &&
rule.every((item) => {
if (isTailwindWebpackLoader(item)) return true;
return isRecord(item) && isSupportedTailwindTurbopackCssRule(item);
})
);
}

if (!isRecord(rule) || !Array.isArray(rule.loaders)) {
return false;
}

return rule.loaders.length > 0 && rule.loaders.every(isTailwindWebpackLoader);
}

function hasTailwindTurbopackCssLoader(config: NextConfig): boolean {
return getTurbopackRuleRecords(config).some((rules) =>
Object.values(rules).some((rule) => turbopackRuleHasTailwindLoader(rule)),
);
}

function hasUnsupportedConfiguredTurbopackRules(config: NextConfig): boolean {
return getTurbopackRuleRecords(config).some((rules) =>
Object.values(rules).some((rule) => !isSupportedTailwindTurbopackCssRule(rule)),
);
}

async function probeWebpackConfig(
config: NextConfig,
root: string,
Expand Down
10 changes: 9 additions & 1 deletion packages/vinext/src/entries/app-rsc-entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,8 @@ type AppRouterConfig = {
* `virtual:vinext-server-entry` when this flag is set.
*/
hasPagesDir?: boolean;
/** Dev-only source stylesheet hrefs, aligned by route index. */
devCssStylesByRoute?: readonly (readonly string[])[];
/** Exact public/ file routes, using normalized leading-slash pathnames. */
publicFiles?: string[];
};
Expand Down Expand Up @@ -144,7 +146,12 @@ export function generateRscEntry(
const i18nConfig = config?.i18n ?? null;
const hasPagesDir = config?.hasPagesDir ?? false;
const publicFiles = config?.publicFiles ?? [];
const manifestCode = buildAppRscManifestCode({ routes, metadataRoutes, globalErrorPath });
const manifestCode = buildAppRscManifestCode({
routes,
metadataRoutes,
globalErrorPath,
devCssStylesByRoute: config?.devCssStylesByRoute,
});
const {
imports,
routeEntries,
Expand Down Expand Up @@ -395,6 +402,7 @@ async function buildPageElements(route, params, routePath, pageRequest) {
rootForbiddenModule: ${rootForbiddenVar ? rootForbiddenVar : "null"},
rootUnauthorizedModule: ${rootUnauthorizedVar ? rootUnauthorizedVar : "null"},
metadataRoutes,
basePath: __basePath,
});
}

Expand Down
Loading
Loading