Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 1 addition & 1 deletion deno.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "veryfront",
"version": "0.1.1203",
"version": "0.1.1204",
"license": "Apache-2.0",
"nodeModulesDir": "auto",
"minimumDependencyAge": {
Expand Down
7 changes: 5 additions & 2 deletions extensions/ext-parser-babel/src/parser-only.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@ export interface BabelParseOnlyParserContract {

function pickPlugins(filePath?: string): parser.ParserPlugin[] {
const normalizedPath = filePath?.toLowerCase() ?? "";
const isTypeScript = /\.(?:tsx?|[cm]ts)$/.test(normalizedPath);
const supportsJsx = !filePath ||
/\.(?:tsx|jsx|js|mjs|cjs)$/.test(normalizedPath);
const plugins: parser.ParserPlugin[] = [
Expand All @@ -33,8 +32,12 @@ function pickPlugins(filePath?: string): parser.ParserPlugin[] {
"dynamicImport",
"importAttributes",
"topLevelAwait",
// Hosted configs are authored in TypeScript but can arrive named `.js`, so
// the extension cannot decide the dialect. TypeScript is a superset, so
// enabling it always only widens what parses.
"typescript",
];
if (isTypeScript || !filePath) plugins.push("typescript");
// JSX stays extension-driven so `.ts` keeps `<T>x` as a type assertion.
if (supportsJsx) plugins.push("jsx");
return plugins;
}
Expand Down
47 changes: 47 additions & 0 deletions src/config/declarative-evaluator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,53 @@ export default defineConfig({
assertEquals(reachableNpmNames.has("@redis/client"), false);
});

it("accepts TypeScript syntax in a config named .js or .mjs", async () => {
// Regression: hosted configs are authored in TypeScript but can be served
// under a .js name. The parser chooses its plugins from the file extension,
// so passing the name parsed `as const` as plain JavaScript and rejected
// valid config with "Hosted configuration rejected (syntax-error:
// syntax-error)", which took customer sites down.
//
// The suite never caught it because DeclarativeConfigFileName defaults to
// veryfront.config.ts, so every other test here implicitly picked the one
// extension that works.
const source = `
import { defineConfig } from "veryfront";

const router = "pages" as const;

export default defineConfig({
title: "TS syntax under a JS name",
router,
});
`;

const asJs = await evaluateDeclarativeConfig({
...DEFAULT_OPTIONS,
fileName: "veryfront.config.js",
source,
});
assertEquals(asJs.router, "pages", "veryfront.config.js must parse TypeScript syntax");

const asMjs = await evaluateDeclarativeConfig({
...DEFAULT_OPTIONS,
fileName: "veryfront.config.mjs",
source,
});
assertEquals(asMjs.router, "pages", "veryfront.config.mjs must parse TypeScript syntax");
});

it("still allows angle-bracket type assertions in a .ts config", async () => {
// Withholding filePath would put every config in TSX mode, where `<T>x` is
// an unclosed JSX element rather than a type assertion.
const snapshot = await evaluateDeclarativeConfig({
...DEFAULT_OPTIONS,
fileName: "veryfront.config.ts",
source: 'const router = <string> "pages";\nexport default { router };',
});
assertEquals(snapshot.router, "pages");
});

it("supports helper aliases, safe spreads, environment branching, templates, and TS wrappers", async () => {
const snapshot = await evaluateDeclarativeConfig({
source: `
Expand Down
8 changes: 8 additions & 0 deletions src/config/declarative-evaluator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3598,6 +3598,14 @@ async function evaluateCapturedInput(
const { source, fileName, preparedState } = input;
let parsedAst: unknown;
try {
// The name is not reliably the file we are holding: VERYFRONT_CONFIG_FILES
// is ordered `.js, .ts, .mjs`, and in production a project's
// veryfront.config.ts was evaluated under the `.js` name. pickPlugins now
// enables TypeScript regardless of extension, so the name only selects JSX.
//
// The mislabeling itself is a separate defect and is still unfixed:
// readHostedConfigSource returns the candidate it actually loaded, so the
// substitution happens downstream of it on the API-backed path.
parsedAst = await parser.parse({
code: source,
filePath: fileName,
Expand Down

Large diffs are not rendered by default.

87 changes: 87 additions & 0 deletions src/html/styles-builder/css-import-extraction.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,93 @@ describe("html/styles-builder/css-import-extraction", () => {
]);
});

it("ignores identifiers that merely contain the word import", () => {
// Without a word boundary, `important` reads as an import statement. In a
// release-asset build a bogus specifier becomes a fatal coverage gap, so
// a false positive here fails the whole release.
assertEquals(extractCssImportSpecifiers('const important = "./styles.css";'), []);
assertEquals(extractCssImportSpecifiers('let unimportant = "./a.css";'), []);
// The real thing still matches, including with no space before the quote.
assertEquals(extractCssImportSpecifiers('import"./styles.css";'), ["./styles.css"]);
});

it("over-matches commented and quoted imports, which is the contract", () => {
// Not an oversight. Callers skip what they cannot resolve, so a phantom
// specifier costs nothing. An earlier revision blanked these regions
// because the release build had made this output fatal; that fix kept
// finding new holes, and an unpaired `/*` or backtick blanked across real
// code and silently dropped a genuine import. Looseness is the safer
// failure: an extra specifier is ignored, a missing one loses a stylesheet.
assertEquals(extractCssImportSpecifiers('// import "./legacy.css";'), ["./legacy.css"]);
assertEquals(extractCssImportSpecifiers('const t = `import "./legacy.css"`;'), [
"./legacy.css",
]);
});

it("never loses a real import to an unpaired comment or backtick", () => {
// The regression the blanking introduced: `/*` inside a line comment
// paired with a later real `*/`, and a stray backtick in prose paired
// with the next one, blanking the real import in between. A build that
// ships a page without its stylesheet is worse than one that over-matches.
assertEquals(
extractCssImportSpecifiers('// TODO drop /* legacy\nimport "./real.css";\nconst a = 1;'),
["./real.css"],
);
assertEquals(
extractCssImportSpecifiers('Use the ` char.\n\nimport "./real.css";\n\n`Button`'),
["./real.css"],
);
});

it("does not treat import.meta as an import statement", () => {
// `import` followed by a `.css` string later in the same statement used to
// match, because nothing required the keyword to begin a declaration.
assertEquals(
extractCssImportSpecifiers('console.log(import.meta.url, "./styles.css");'),
[],
);
assertEquals(
extractCssImportSpecifiers('const u = import.meta.resolve("./a.css");'),
[],
);
});

it("matches dynamic imports, which are real CSS imports", () => {
// Pinned deliberately. `import("./theme.css")` loads that stylesheet at
// runtime, so dropping it would leave the compiled stylesheet missing CSS
// the page uses. A dynamic specifier naming a file that does not exist is
// a broken reference, not a false positive -- same as a static one.
assertEquals(
extractCssImportSpecifiers('const load = () => import("./theme.css");'),
["./theme.css"],
);
assertEquals(extractCssImportSpecifiers('await import("./a.css");'), ["./a.css"]);
// Still excluded, because that is a property access rather than an import.
assertEquals(extractCssImportSpecifiers('import.meta.resolve("./a.css");'), []);
});

it("finds every real import in a mixed file", () => {
const source = [
'// import "./commented.css";',
'import "./real.css";',
'import styles from "./mod.module.css";',
].join("\n");
// The commented one comes along too; what matters is that neither real
// import is lost.
assertEquals(extractCssImportSpecifiers(source), [
"./commented.css",
"./real.css",
"./mod.module.css",
]);
});

it("keeps a URL in a string from reading as a comment", () => {
assertEquals(
extractCssImportSpecifiers('const cdn = "https://x.dev";\nimport "./real.css";'),
["./real.css"],
);
});

it("does not match specifiers across statement boundaries", () => {
const source = 'const a = 1; import { b } from "./b.ts"; const s = "x.css";';
assertEquals(extractCssImportSpecifiers(source), []);
Expand Down
33 changes: 29 additions & 4 deletions src/html/styles-builder/css-import-extraction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,14 +25,39 @@ import { isWithinDirectory, normalizePath } from "#veryfront/utils/path-utils.ts
export const CSS_IMPORTING_SOURCE_EXTENSIONS = [".tsx", ".jsx", ".mdx", ".ts", ".js"];

/**
* Static ESM import statements whose specifier ends in `.css`:
* ESM imports whose specifier ends in `.css`:
* import "./styles.css";
* import styles from "./button.module.css";
* `[^'";]*` keeps the match from crossing statement boundaries.
* import("./theme.css")
*
* Dynamic imports are matched on purpose, despite this once being described as
* static-only. `import("./theme.css")` loads that stylesheet at runtime, so
* leaving it out means the compiled stylesheet is missing CSS the page actually
* uses. A dynamic specifier pointing at a file that does not exist is a broken
* reference, not a false positive -- exactly as a static one would be.
* `[^'";]*` keeps the match from crossing statement boundaries, and `\bimport\b`
* keeps identifiers that merely contain the word out of it -- without it,
* `const important = "./styles.css"` reads as an import. That matters more here
* than it looks: release-asset builds turn a bogus specifier into a fatal
* coverage gap, so a false positive fails the release.
*/
const CSS_IMPORT_RE = /import[^'";]*['"]([^'"]+\.css)['"]/g;
const CSS_IMPORT_RE = /\bimport\b(?!\s*\.)[^'";]*['"]([^'"]+\.css)['"]/g;
Comment thread
kojiwakayama marked this conversation as resolved.

/** Extract the raw specifiers of all static CSS imports in a source file. */
/**
* Extract the raw specifiers of all CSS imports in a source file.
*
* Deliberately loose, per this module's contract: over-matching is harmless
* because unresolvable specifiers are skipped downstream. A commented-out or
* quoted `import "./x.css"` will be reported, and that is fine.
*
* An earlier revision blanked comments, template literals and fenced blocks
* before matching, because the release-asset build had made this function's
* output fatal. That was the wrong layer to fix it: telling code from prose
* with a regex kept finding new holes, and worse, an unpaired `/*` or backtick
* blanked across intervening real code and silently dropped a genuine import --
* trading a loud failure for a page shipped without its stylesheet. The build
* no longer gaps on what it cannot resolve, so the looseness costs nothing.
Comment on lines +49 to +59

@coderabbitai coderabbitai Bot Aug 6, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

Do not merge resolvable false positives.

A commented or quoted import "./disabled.css" is returned by this extractor. If disabled.css exists in the project, mergeModuleCssImports resolves and merges it into the release stylesheet.

Restore code-aware import detection. Add a release-build regression with a commented or quoted import and an existing stylesheet. Ensure the stylesheet is not merged. As per coding guidelines, behavior changes must have focused tests.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/html/styles-builder/css-import-extraction.ts` around lines 49 - 59,
Replace the deliberately loose matching in the import-extraction flow with
code-aware detection so commented and quoted imports are excluded even when
their stylesheet exists. Update the extractor used by mergeModuleCssImports, and
add a focused release-build regression covering a commented or quoted import
plus an existing stylesheet, asserting that the stylesheet is not merged.

Source: Coding guidelines

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.

Valid, and deliberately left open — this one is not obsolete and is not fixed.

You found the gap in my reasoning. I justified the looseness with "unresolvable specifiers are skipped downstream, so over-matching is harmless", and that holds only for unresolvable ones. A commented or quoted import "./disabled.css" where disabled.css still exists resolves fine and gets merged, so dead CSS lands in the release stylesheet. Not a blocked release, but wrong output, and unintended rules applying to a page can break it visually.

Weighing it against what it replaced: shipping extra CSS is a smaller failure than refusing every release for a project with a JSX component, which is where main stands today, and smaller than silently dropping a real stylesheet, which is what the blanking did. So this PR is still a strict improvement — but it is not a clean bill of health, and I do not want the thread closed implying otherwise.

The real fix is the one kwakayama independently proposed: genuine CSS-import detection belongs on the resolved module graph (collectProjectModuleImports, over transformed code where the lexer is trustworthy), not on a text scan. That removes both the false positives you describe here and the need for any code-vs-prose heuristic. A lexical scanner would also work but is more machinery for a weaker result.

Out of scope for this PR, which is an incident fix. Tracking it as the follow-up rather than attempting it at the end of a long session — the last four rounds on this file each produced a new hole, and this one deserves to be done deliberately.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

@kojiwakayama, acknowledged. The finding remains valid and open.

The current text scan can merge a stylesheet from a comment or quoted string when that stylesheet exists. Deferring the fix is reasonable for this incident-focused PR.

Moving CSS import detection to collectProjectModuleImports over transformed code is the correct follow-up. It removes code-versus-prose false positives and avoids extending the regex with more incomplete parsing rules.

🐇🔍

You are interacting with an AI system.

*/
export function extractCssImportSpecifiers(source: string): string[] {
const specifiers: string[] = [];
for (const match of source.matchAll(CSS_IMPORT_RE)) {
Expand Down
75 changes: 66 additions & 9 deletions src/release-assets/build-executor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2345,6 +2345,57 @@ export default defineConfig({ react: { version: "19.2.1" } });`,
);
});

it("merges module CSS from sources containing real JSX", async () => {
// Regression: the CSS import scan fed project source to es-module-lexer,
// which parses neither JSX nor TypeScript. Every .tsx file with a tag threw,
// each throw recorded a coverage gap, and gaps are fatal, so no project with
// a JSX component could publish a release.
//
// The suite missed it because every fixture put plain JavaScript inside
// .tsx files. These bodies are the shapes that actually broke in production:
// a closing component tag, a self-closing tag, and a nested element.
const rec: Recorded = { began: false, uploads: [], manifest: null, states: [] };
const files = [
{ path: "globals.css", content: ":root { --brand: blue; }" },
{ path: "app/styles.css", content: ".calc { background: #191919; }" },
{
path: "app/layout.tsx",
content: 'import "./styles.css";\n' +
"export default ({ children }) => (\n" +
" <html><head><title>Assistant</title></head><body>{children}</body></html>\n" +
");",
},
{
path: "app/markdown-renderer.tsx",
content: 'import ReactMarkdown from "react-markdown";\n' +
"export const R = ({ source }) => <ReactMarkdown>{source}</ReactMarkdown>;",
},
{
path: "pages/index.tsx",
content: 'import { Chat } from "veryfront/chat";\n' +
"export default () => <Provider><Chat /></Provider>;",
},
];
let seenStylesheet: string | undefined;
const client = makeClient(files, rec, {
compileProjectCss: (_candidates, stylesheet) => {
seenStylesheet = stylesheet;
return Promise.resolve(compiledCss(".calc{background:#191919}"));
},
});
const transform = () => Promise.resolve("export default null;");

// The build completing at all is the assertion that matters: a parse gap
// here aborts it with "Release asset coverage is incomplete".
await runReleaseAssetBuild(baseInput(client, transform), await tmp());

assertExists(seenStylesheet);
assert(
seenStylesheet!.includes(".calc"),
"CSS imported from a JSX-bearing layout must still be merged",
);
});

it("does not duplicate the resolved stylesheet when a module imports it directly", async () => {
const rec: Recorded = { began: false, uploads: [], manifest: null, states: [] };
const files = [
Expand Down Expand Up @@ -2445,7 +2496,7 @@ export default defineConfig({ react: { version: "19.2.1" } });`,
assertEquals(first.css, second.css);
});

it("fails closed when an imported stylesheet is missing or unsupported", async () => {
it("still publishes when an imported stylesheet cannot be resolved", async () => {
for (
const specifier of ["./missing.css", "theme-package/theme.css", "https://cdn.test/x.css"]
) {
Expand All @@ -2470,14 +2521,20 @@ export default defineConfig({ react: { version: "19.2.1" } });`,
await tmp(),
);

assertCoverageFailure(
result,
rec,
specifier.startsWith("./")
? "stylesheet-import-missing:pages/missing.css"
: "stylesheet-import-unsupported:pages/index.tsx",
);
assertEquals(compileCalls, 0, specifier);
// Assert success explicitly. runReleaseAssetBuild returns a failed result
// rather than throwing, so awaiting it proves nothing on its own -- an
// earlier revision of this test said otherwise and was wrong.
assertEquals(result.success, true, specifier);

// Used to fail the release. It no longer does, and that is the point: a
// text match is not knowledge that the build needs the file. The same
// check could not tell a real import from one inside a comment, a string
// or an MDX fence, so ordinary source could block a project's releases.
// Unresolvable means the CSS is not merged, not that the release is
// refused. Genuine missing-CSS detection belongs on the resolved module
// graph, over transformed code, where the lexer can be trusted.
assertExists(rec.manifest, specifier);
assertEquals(compileCalls > 0, true, specifier);
}
});

Expand Down
Loading