From e69b73f88b978669050acba1d196d6a3d1f2c5cc Mon Sep 17 00:00:00 2001 From: Dustin Lacewell Date: Sat, 11 Jul 2026 16:08:55 -0500 Subject: [PATCH] fix: fall back to a Node scan when grep is unavailable --- .changeset/grep-fallback-scan.md | 5 ++ libs/qwikdev-astro/src/scan.ts | 53 +++++++++++++++++----- libs/qwikdev-astro/tests/unit/scan.test.ts | 47 ++++++++++++++++++- 3 files changed, 91 insertions(+), 14 deletions(-) create mode 100644 .changeset/grep-fallback-scan.md diff --git a/.changeset/grep-fallback-scan.md b/.changeset/grep-fallback-scan.md new file mode 100644 index 00000000..9cd09176 --- /dev/null +++ b/.changeset/grep-fallback-scan.md @@ -0,0 +1,5 @@ +--- +"@qwik.dev/astro": patch +--- + +Fix entrypoint scanning on hosts without grep (e.g. Windows). The scanner now converts the project root URL to a filesystem path and falls back to a dependency-free Node directory walk when grep is unavailable, instead of silently finding no entrypoints and failing every prerendered page with Q14. diff --git a/libs/qwikdev-astro/src/scan.ts b/libs/qwikdev-astro/src/scan.ts index d60ff1f3..9e5e7a85 100644 --- a/libs/qwikdev-astro/src/scan.ts +++ b/libs/qwikdev-astro/src/scan.ts @@ -1,5 +1,7 @@ import { execFile } from "node:child_process"; -import { relative, resolve } from "node:path"; +import { readFile, readdir } from "node:fs/promises"; +import { join, relative, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; import { promisify } from "node:util"; import type { AstroConfig } from "astro"; import { QWIK_ENTRYPOINT_PATTERN, SCAN_EXTENSIONS } from "./constants"; @@ -47,15 +49,12 @@ export async function scanQwikEntrypoints( filter: (id: string) => boolean, debug?: boolean ): Promise> { - const rootDir = config.root.pathname; - const stdout = await grepQwikFiles(rootDir); - if (!stdout) return new Set(); + const rootDir = fileURLToPath(config.root); + const files = (await grepQwikFiles(rootDir)) ?? (await walkQwikFiles(rootDir)); - const files = stdout.split("\n").sort(); const entrypoints = new Set(); - for (const relativePath of files) { - const absolutePath = resolve(rootDir, relativePath); - if (relativePath.includes("node_modules") || !filter(absolutePath)) continue; + for (const absolutePath of files.sort()) { + if (absolutePath.includes("node_modules") || !filter(absolutePath)) continue; entrypoints.add(absolutePath); if (debug) console.debug(`[qwikdev/astro] Found Qwik entrypoint: ${absolutePath}`); } @@ -63,7 +62,8 @@ export async function scanQwikEntrypoints( return entrypoints; } -async function grepQwikFiles(cwd: string): Promise { +/** Lists files matching the Qwik entrypoint pattern with grep, or null when grep is unavailable. */ +async function grepQwikFiles(cwd: string): Promise { try { const result = await execFileAsync( "grep", @@ -76,8 +76,37 @@ async function grepQwikFiles(cwd: string): Promise { ], { cwd, encoding: "utf-8" } ); - return result.stdout.trim(); - } catch { - return ""; + const stdout = result.stdout.trim(); + if (!stdout) return []; + return stdout.split("\n").map((relativePath) => resolve(cwd, relativePath)); + } catch (error) { + // grep exits with 1 when no files match + if ((error as { code?: number | string }).code === 1) return []; + return null; } } + +/** Fallback for hosts without grep (e.g. Windows): recursively scans for files matching the Qwik entrypoint pattern. */ +async function walkQwikFiles(rootDir: string): Promise { + const extensions = SCAN_EXTENSIONS.map((ext) => ext.slice(1)); + const files: string[] = []; + + async function walk(dir: string): Promise { + const entries = await readdir(dir, { withFileTypes: true }).catch(() => []); + await Promise.all( + entries.map(async (entry) => { + const path = join(dir, entry.name); + if (entry.isDirectory()) { + if (entry.name === "node_modules" || entry.name === ".git") return; + return walk(path); + } + if (!extensions.some((ext) => entry.name.endsWith(ext))) return; + const content = await readFile(path, "utf-8").catch(() => ""); + if (QWIK_ENTRYPOINT_PATTERN.test(content)) files.push(path); + }) + ); + } + + await walk(rootDir); + return files; +} diff --git a/libs/qwikdev-astro/tests/unit/scan.test.ts b/libs/qwikdev-astro/tests/unit/scan.test.ts index 318822cb..8ee7d4b3 100644 --- a/libs/qwikdev-astro/tests/unit/scan.test.ts +++ b/libs/qwikdev-astro/tests/unit/scan.test.ts @@ -1,5 +1,13 @@ -import { describe, expect, it } from "vitest"; -import { createQwikFileFilter, resolveQwikPaths } from "../../src/scan"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { pathToFileURL } from "node:url"; +import { afterEach, describe, expect, it } from "vitest"; +import { + createQwikFileFilter, + resolveQwikPaths, + scanQwikEntrypoints +} from "../../src/scan"; describe("resolveQwikPaths", () => { function makeConfig(overrides: { adapter?: { name: string } } = {}) { @@ -33,6 +41,41 @@ describe("resolveQwikPaths", () => { }); }); +describe("scanQwikEntrypoints", () => { + let dir: string; + + afterEach(async () => { + await rm(dir, { recursive: true, force: true }); + }); + + async function makeProject(files: Record) { + dir = await mkdtemp(join(tmpdir(), "qwik-scan-")); + for (const [path, content] of Object.entries(files)) { + await mkdir(join(dir, ...path.split("/").slice(0, -1)), { recursive: true }); + await writeFile(join(dir, ...path.split("/")), content); + } + return { root: pathToFileURL(`${dir}/`) } as any; + } + + it("finds files importing qwik", async () => { + const config = await makeProject({ + "src/counter.tsx": `import { component$ } from "@qwik.dev/core";`, + "src/plain.tsx": `export const plain = true;` + }); + const entrypoints = await scanQwikEntrypoints(config, () => true); + expect([...entrypoints]).toEqual([join(dir, "src", "counter.tsx")]); + }); + + it("ignores node_modules and respects the filter", async () => { + const config = await makeProject({ + "node_modules/lib/index.ts": `import { component$ } from "@qwik.dev/core";`, + "src/excluded.tsx": `import { component$ } from "@qwik.dev/core";` + }); + const entrypoints = await scanQwikEntrypoints(config, () => false); + expect(entrypoints.size).toBe(0); + }); +}); + describe("createQwikFileFilter", () => { it("always returns true for non-transform hooks", () => { const filter = createQwikFileFilter(() => true);