Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/grep-fallback-scan.md
Original file line number Diff line number Diff line change
@@ -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.
53 changes: 41 additions & 12 deletions libs/qwikdev-astro/src/scan.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -47,23 +49,21 @@ export async function scanQwikEntrypoints(
filter: (id: string) => boolean,
debug?: boolean
): Promise<Set<string>> {
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<string>();
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}`);
}

return entrypoints;
}

async function grepQwikFiles(cwd: string): Promise<string> {
/** Lists files matching the Qwik entrypoint pattern with grep, or null when grep is unavailable. */
async function grepQwikFiles(cwd: string): Promise<string[] | null> {
try {
const result = await execFileAsync(
"grep",
Expand All @@ -76,8 +76,37 @@ async function grepQwikFiles(cwd: string): Promise<string> {
],
{ 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<string[]> {
const extensions = SCAN_EXTENSIONS.map((ext) => ext.slice(1));
const files: string[] = [];

async function walk(dir: string): Promise<void> {
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;
}
47 changes: 45 additions & 2 deletions libs/qwikdev-astro/tests/unit/scan.test.ts
Original file line number Diff line number Diff line change
@@ -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 } } = {}) {
Expand Down Expand Up @@ -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<string, string>) {
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);
Expand Down