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
8 changes: 8 additions & 0 deletions .changeset/add-astro-plugin.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
"@rrlab/astro-plugin": minor
"@rrlab/cli": minor
---

Add `@rrlab/astro-plugin`, which provides the `typecheck` capability through `astro check`.

In an Astro project, `rr tsc` and `rr check` now type-check `.astro` files along with the rest of the project, so they replace a separate `astro check` step. `rr plugins add astro` installs it.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
"@biomejs/biome": "^2.4.4",
"@changesets/changelog-github": "1.0.1",
"@changesets/cli": "3.0.3",
"@rrlab/astro-plugin": "workspace:*",
"@rrlab/biome-config": "workspace:*",
"@rrlab/biome-plugin": "workspace:*",
"@rrlab/cli": "workspace:*",
Expand Down
2,520 changes: 2,500 additions & 20 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions run-run/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ If you find yourself tempted to add a `pnpm <script>` that exists only to call `
| Path | npm name | Role |
|---|---|---|
| `cli/` | `@rrlab/cli` | The kernel. Commander program, plugin registry, `rr` bin. Deep-dive in `cli/CLAUDE.md`. |
| `astro-plugin/` | `@rrlab/astro-plugin` | `tsc` via `astro check`, which covers `.astro` and `.ts` files alike. |
| `biome-plugin/` | `@rrlab/biome-plugin` | `lint` + `format` + `jsc` via Biome. |
| `oxc-plugin/` | `@rrlab/oxc-plugin` | `lint` (oxlint) + `format` (oxfmt) + `tsc` (oxlint type-aware). `jsc` is composed by the kernel. |
| `ts-plugin/` | `@rrlab/ts-plugin` | `tsc` (TypeScript). Has `install()` that scaffolds `tsconfig.json` from a preset. |
Expand Down
31 changes: 31 additions & 0 deletions run-run/astro-plugin/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# @rrlab/astro-plugin

Astro plugin for [`@rrlab/cli`](https://npmjs.com/package/@rrlab/cli). Provides the `typecheck` capability backed by [`astro check`](https://docs.astro.build/en/reference/cli-reference/#astro-check), so `rr tsc` and `rr check` type-check `.astro` files along with the rest of the project.

## Install

```sh
rr plugins add astro
```

`rr plugins add` installs `@rrlab/astro-plugin` and adds `@astrojs/check` and `typescript` as `devDependencies`: `astro check` needs both. It scaffolds no config: your Astro project already has its `tsconfig.json`, and `astro` itself stays your project's own dependency.

## What it provides

| Capability | Surface |
|---|---|
| `typecheck` | `rr tsc` (workspace-aware in monorepos), `rr check`, `rr tsc doctor` |

`rr tsc` runs `astro check` in each package with a `tsconfig.json`. It reports the same diagnostics as `astro check`, for `.astro` files and plain `.ts` files alike, and fails when `astro check` exits non-zero (any error, by default).

`astro check` also checks your `.ts` files, so in an Astro project it replaces `ts`. Configure one `typecheck` provider. If another plugin also provides one (`ts`, or `oxc`), narrow it, e.g. `oxc({ only: ["lint", "format"] })`.

If `@astrojs/check` or `typescript` is missing, `rr tsc` fails and names it. Left alone, `astro check` would prompt to install them instead of checking anything.

## Removal

```sh
rr plugins remove astro
```

Removes `@astrojs/check` and `typescript` from `package.json` and drops the `astro()` entry from `run-run.config.{ts,mts}`. `astro` and your `tsconfig.json` are left untouched.
61 changes: 61 additions & 0 deletions run-run/astro-plugin/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
{
"name": "@rrlab/astro-plugin",
"version": "0.0.0",
"description": "Astro plugin for @rrlab/cli — provides the typecheck capability via astro check.",
"homepage": "https://github.com/withvariablelab/dx/tree/main/run-run/astro-plugin#readme",
"bugs": {
"url": "https://github.com/withvariablelab/dx/issues"
},
"repository": {
"type": "git",
"url": "git+https://github.com/withvariablelab/dx.git",
"directory": "run-run/astro-plugin"
},
"license": "MIT",
"author": "rcrd <ricardo@variable.land>",
"type": "module",
"exports": {
".": "./src/index.ts"
},
"files": [
"dist",
"src",
"!src/**/__tests__",
"!src/**/*.test.*"
],
"publishConfig": {
"access": "public",
"exports": {
".": {
"types": "./dist/index.d.mts",
"default": "./dist/index.mjs"
}
}
},
"engines": {
"node": ">=22.18.0"
},
"scripts": {
"build": "tsdown",
"test": "vitest run",
"prepublishOnly": "pnpm build"
},
"dependencies": {
"@variablelab/clibuddy": "workspace:*"
},
"peerDependencies": {
"@astrojs/check": ">=0.9.0",
"@rrlab/cli": "workspace:^",
"astro": ">=5.0.0",
"typescript": "^5.0.0 || ^6.0.0"
},
"devDependencies": {
"@astrojs/check": "0.9.10",
"@rrlab/cli": "workspace:*",
"@rrlab/tsdown-config": "workspace:^",
"@types/semver": "^7.7.1",
"astro": "7.4.0",
"semver": "^7.8.1",
"typescript": "6.0.3"
}
}
126 changes: 126 additions & 0 deletions run-run/astro-plugin/src/__tests__/check.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import { readFileSync } from "node:fs";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { createShellService } from "@variablelab/clibuddy";
import { satisfies } from "semver";
import { afterEach, describe, expect, it } from "vitest";
import { AstroCheckService } from "../index.ts";

// These run the real `astro check` against a tmp Astro project, so they are slow.
const TIMEOUT = 60_000;
const PLUGIN_NODE_MODULES = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../node_modules");

// astro refuses to start on a Node older than its own `engines.node` (astro 7 needs a
// newer one than this repo's floor), so the tests that run it are skipped there. The
// missing-dependency tests never start astro, so they run everywhere.
const astroPkg = JSON.parse(readFileSync(path.join(PLUGIN_NODE_MODULES, "astro/package.json"), "utf8")) as {
engines: { node: string };
};
const ASTRO_RUNS = satisfies(process.versions.node, astroPkg.engines.node);

const CLEAN_PAGE = `---\nconst answer: number = 42;\n---\n<p>{answer}</p>\n`;
const BROKEN_PAGE = `---\nconst answer: number = "forty-two";\n---\n<p>{answer}</p>\n`;
const CLEAN_TS = "export const ok: number = 1;\n";
const BROKEN_TS = 'export const bad: number = "not a number";\n';

let tmpDir: string | undefined;

afterEach(async () => {
if (tmpDir) await fs.rm(tmpDir, { recursive: true, force: true });
tmpDir = undefined;
});

/**
* A minimal Astro project shaped like www (the `astro/tsconfigs/strict` preset,
* `.astro/types.d.ts` included). Its node_modules links the given packages from
* this plugin's own devDependencies, so `astro check` resolves them from the
* project root as it would in a host project.
*/
async function makeProject(files: Record<string, string>, deps = ["astro", "@astrojs/check", "typescript"]) {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "rr-astro-plugin-"));
const all = {
"package.json": `${JSON.stringify({ name: "astro-fixture", private: true, type: "module" })}\n`,
"tsconfig.json": `${JSON.stringify({ extends: "astro/tsconfigs/strict", include: [".astro/types.d.ts", "**/*"] })}\n`,
...files,
};
for (const [rel, content] of Object.entries(all)) {
const abs = path.join(tmpDir, rel);
await fs.mkdir(path.dirname(abs), { recursive: true });
await fs.writeFile(abs, content);
}
for (const dep of deps) {
const link = path.join(tmpDir, "node_modules", dep);
await fs.mkdir(path.dirname(link), { recursive: true });
await fs.symlink(await fs.realpath(path.join(PLUGIN_NODE_MODULES, dep)), link, "dir");
}
return tmpDir;
}

function service(cwd: string) {
return new AstroCheckService(createShellService({ cwd }), cwd);
}

describe("AstroCheckService.check()", () => {
it.runIf(ASTRO_RUNS)(
"passes on a project without type errors",
async () => {
const cwd = await makeProject({ "src/pages/index.astro": CLEAN_PAGE, "src/lib/ok.ts": CLEAN_TS });
const report = await service(cwd).check({ cwd });
expect(report.ok, report.output).toBe(true);
expect(report.output).toContain("$ astro check");
},
TIMEOUT,
);

it.runIf(ASTRO_RUNS)(
"fails with astro check's diagnostic for a type error in a .astro file",
async () => {
const cwd = await makeProject({ "src/pages/index.astro": BROKEN_PAGE, "src/lib/ok.ts": CLEAN_TS });
const report = await service(cwd).check({ cwd });
expect(report.ok).toBe(false);
expect(report.output).toContain("src/pages/index.astro");
expect(report.output).toMatch(/Type 'string' is not assignable to type 'number'/);
},
TIMEOUT,
);

it.runIf(ASTRO_RUNS)(
"fails for a type error in a plain .ts file too",
async () => {
const cwd = await makeProject({ "src/pages/index.astro": CLEAN_PAGE, "src/lib/bad.ts": BROKEN_TS });
const report = await service(cwd).check({ cwd });
expect(report.ok).toBe(false);
expect(report.output).toContain("src/lib/bad.ts");
expect(report.output).toMatch(/Type 'string' is not assignable to type 'number'/);
},
TIMEOUT,
);

it("fails without running astro when @astrojs/check is missing", async () => {
// Left to itself, astro would prompt to install it: a hang under capture, a false pass without stdin.
const cwd = await makeProject({ "src/pages/index.astro": BROKEN_PAGE }, ["astro", "typescript"]);
const report = await service(cwd).check({ cwd });
expect(report.ok).toBe(false);
expect(report.output).not.toContain("$ astro check");
expect(report.output).toMatchInlineSnapshot(
`"@rrlab/astro-plugin requires @astrojs/check to be installed in the host project. Run: rr plugins add astro (or: pnpm add -D @astrojs/check)"`,
);
});
});

describe("AstroCheckService.doctor()", () => {
it.runIf(ASTRO_RUNS)("reports healthy when astro check's dependencies resolve", async () => {
const cwd = await makeProject({});
const report = await service(cwd).doctor();
expect(report.ok, report.output).toBe(true);
});

it("fails when they don't", async () => {
const cwd = await makeProject({}, ["astro"]);
const report = await service(cwd).doctor();
expect(report.ok).toBe(false);
expect(report.output).toContain("requires @astrojs/check, typescript to be installed");
});
});
33 changes: 33 additions & 0 deletions run-run/astro-plugin/src/__tests__/install.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import type { InstallContext, UninstallContext } from "@rrlab/cli/plugin";
import { describe, expect, it } from "vitest";
import { install, TOOL_VERSIONS, uninstall } from "../index.ts";

// The astro plugin scaffolds no config (an Astro project already has its
// tsconfig.json): install/uninstall only add/remove what `astro check` needs.
// The hooks ignore their context, so a bare cast is enough at the test boundary.
const noopInstallCtx = {} as InstallContext;
const noopUninstallCtx = {} as UninstallContext;

describe("@rrlab/astro-plugin install()", () => {
it("adds @astrojs/check and typescript as devDependencies and scaffolds no files", async () => {
const result = await install(noopInstallCtx);
expect(result.devDependencies).toEqual({
"@astrojs/check": TOOL_VERSIONS["@astrojs/check"].install,
typescript: TOOL_VERSIONS.typescript.install,
});
expect(result.files).toBeUndefined();
});

it("leaves astro alone: the host project owns its framework version", async () => {
const result = await install(noopInstallCtx);
expect(result.devDependencies).not.toHaveProperty("astro");
});
});

describe("@rrlab/astro-plugin uninstall()", () => {
it("removes @astrojs/check and typescript and touches no files", async () => {
const result = await uninstall(noopUninstallCtx);
expect(result.removeDependencies).toEqual(["@astrojs/check", "typescript"]);
expect(result.files).toBeUndefined();
});
});
34 changes: 34 additions & 0 deletions run-run/astro-plugin/src/__tests__/setup.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import type { PluginContext } from "@rrlab/cli/plugin";
import type { Pkg, ShellService } from "@variablelab/clibuddy";
import { describe, expect, it } from "vitest";
import astro from "../index.ts";

function ctx(): PluginContext {
return {
shell: {} as ShellService,
// biome-ignore lint/suspicious/noExplicitAny: minimal stub
logger: {} as any,
appPkg: { dirPath: process.cwd() } as Pkg,
binPkg: { dirPath: process.cwd() } as Pkg,
cwd: process.cwd(),
};
}

describe("@rrlab/astro-plugin services()", () => {
it("returns the typecheck capability when no `only` is supplied", async () => {
const caps = await astro().services(ctx());
expect(Object.keys(caps)).toEqual(["typecheck"]);
});

it("returns the typecheck capability when `only: ['typecheck']` is supplied", async () => {
const caps = await astro({ only: ["typecheck"] }).services(ctx());
expect(Object.keys(caps)).toEqual(["typecheck"]);
});

it("throws when `only` references an unknown capability", async () => {
await expect(
// biome-ignore lint/suspicious/noExplicitAny: bypassing the TS guard to exercise the runtime check
astro({ only: ["lint"] as any }).services(ctx()),
).rejects.toThrow(/unknown capability 'lint'/);
});
});
23 changes: 23 additions & 0 deletions run-run/astro-plugin/src/__tests__/tool-versions.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { readFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { minVersion, satisfies, subset } from "semver";
import { describe, expect, it } from "vitest";
import { TOOL_VERSIONS } from "../tool-versions.ts";

const here = path.dirname(fileURLToPath(import.meta.url));
const pkg = JSON.parse(readFileSync(path.resolve(here, "../../package.json"), "utf8")) as {
peerDependencies?: Record<string, string>;
};

describe("TOOL_VERSIONS coherence with this plugin's package.json", () => {
for (const [name, entry] of Object.entries(TOOL_VERSIONS)) {
const peerRange = pkg.peerDependencies?.[name];
if (!peerRange) continue;

it(`${name}: install range fits within the declared peer range`, () => {
const ok = subset(entry.install, peerRange) || satisfies(minVersion(entry.install)?.version ?? "", peerRange);
expect(ok, `install=${entry.install} does not fit peer=${peerRange}`).toBe(true);
});
}
});
Loading
Loading