Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
51 commits
Select commit Hold shift + click to select a range
a13dacb
feat: astro2tsx with biome
Princesseuh May 1, 2026
699817d
chore: merge origin/main into feat/biome-tsx
Princesseuh Aug 11, 2026
8765acf
chore(astro2tsx): satisfy fmt, clippy, and biome checks
Princesseuh Aug 11, 2026
e756b92
test(astro2tsx): assert corpus parity instead of only reporting it
Princesseuh Aug 11, 2026
02f6b17
build(astro2tsx): build against the biome fork on rust 1.96.1
Princesseuh Aug 11, 2026
9d20f56
test(astro2tsx): port behavioral, error, and sourcemap coverage
Princesseuh Aug 11, 2026
ef2bd0b
ci: run the astro2tsx test suites
Princesseuh Aug 11, 2026
40384d5
chore(astro2tsx): mark the package private
Princesseuh Aug 11, 2026
1a01107
build(astro2tsx): pin the biome fork to an exact rev
Princesseuh Aug 11, 2026
f914b48
fix(astro2tsx): keep unclosed raw-text content and always pair spread…
Princesseuh Aug 11, 2026
fc2c2c9
docs(astro2tsx): prune comments to load-bearing rationale
Princesseuh Aug 11, 2026
bfcb0cc
test(astro2tsx): cover the spread separator with an entry that emits …
Princesseuh Aug 11, 2026
de5501b
fix(astro2tsx): detect exported getStaticPaths from the syntax tree
Princesseuh Aug 11, 2026
858c629
test(astro2tsx): drop malformed-input recovery tests
Princesseuh Aug 11, 2026
a37a11d
build(astro2tsx): bump the biome fork pin to 2532865
Princesseuh Aug 12, 2026
18641df
refactor(astro2tsx): locate expression markup by parsing instead of s…
Princesseuh Aug 12, 2026
721b1d6
fix(astro2tsx): emit html comments inside expressions as jsx comments
Princesseuh Aug 12, 2026
cb796b2
test(astro2tsx): add snapshot fixtures mirroring the codegen harness
Princesseuh Aug 12, 2026
4ffa79e
test(astro2tsx): move output-shape assertions to snapshots
Princesseuh Aug 12, 2026
f4c1e23
test(astro2tsx): capture the full convert result in snapshots
Princesseuh Aug 12, 2026
1328da4
feat(astro2tsx): encode the mappings as a source map v3
Princesseuh Aug 12, 2026
9a948ed
test(astro2tsx): verify the encoded source map with an independent de…
Princesseuh Aug 12, 2026
1c70a19
feat(astro2tsx): expose the source map through the napi binding
Princesseuh Aug 12, 2026
d4a4fb6
test(astro2tsx): include the encoded source map in snapshots
Princesseuh Aug 12, 2026
b40909f
feat(astro2tsx): render the source map as an inline data URI comment
Princesseuh Aug 12, 2026
0fc4119
test(astro2tsx): append the inline source map to snapshots
Princesseuh Aug 12, 2026
df892dd
test(astro2tsx): move result metadata into the insta info header
Princesseuh Aug 12, 2026
676eb02
feat(astro2tsx): append the inline source map comment unless sourcema…
Princesseuh Aug 12, 2026
a2f5469
test(astro2tsx): snapshot the emitted code verbatim
Princesseuh Aug 12, 2026
76dd40e
feat(astro2tsx)!: exclude script and style bodies from the emitted TSX
Princesseuh Aug 12, 2026
5ebed8b
build: consolidate biome fork pins in workspace dependencies
Princesseuh Aug 12, 2026
4292fc5
fix(astro2tsx): drive props, comments, and separators from the parsed…
Princesseuh Aug 12, 2026
8b5a7a4
perf(astro2tsx): encode source maps with oxc_sourcemap and pack napi …
Princesseuh Aug 12, 2026
fae6a78
test(astro2tsx): replace the ported corpus with fixtures and targeted…
Princesseuh Aug 12, 2026
147783e
fix(astro2tsx): flag expression bodies that fail to parse
Princesseuh Aug 12, 2026
8e2fc04
feat(astro2tsx): normalize expression markup through a JSX tree walk
Princesseuh Aug 12, 2026
79c7a7c
feat(astro2tsx): parse astro attribute and comment syntax inside expr…
Princesseuh Aug 12, 2026
e436557
feat(astro2tsx): keep is:raw bodies unparsed and place comments by po…
Princesseuh Aug 12, 2026
620718f
perf(astro2tsx): emit run-length mappings instead of per-character pairs
Princesseuh Aug 12, 2026
66c55ce
build: track the latest biome fork revision
Princesseuh Aug 12, 2026
b9db75c
feat(astro2tsx): expose mappings as volar-shaped run arrays
Princesseuh Aug 12, 2026
0770f3f
chore(astro2tsx): add conversion benchmarks
Princesseuh Aug 12, 2026
a44d7c8
feat(astro2tsx): expose source ranges, diagnostics, and utf16 offsets…
Princesseuh Aug 12, 2026
701fde8
fix(astro2tsx): match reference behavior for script types, comments, …
Princesseuh Aug 12, 2026
993e261
feat(astro2tsx): type extracted scripts and styles by their kind
Princesseuh Aug 12, 2026
d3fd442
perf(astro2tsx): build the source map only when asked for it
Princesseuh Aug 13, 2026
0f22b27
feat(astro2tsx): let callers skip the source map with sourcemap: false
Princesseuh Aug 13, 2026
e29cb77
test(astro2tsx): separate the node and rust test directories
Princesseuh Aug 13, 2026
66330e2
chore(astro2tsx): move the benchmarks into the crate
Princesseuh Aug 13, 2026
5ef10ee
fix(astro2tsx): correct attribute value positions and component tag h…
Princesseuh Aug 13, 2026
f05fcc6
refactor(astro2tsx): use oxc and biome helpers for identifiers and ca…
Princesseuh Aug 13, 2026
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
7 changes: 7 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,13 @@ jobs:
run: pnpm test
working-directory: crates/astro_napi

- name: Build astro2tsx binding
run: pnpm --filter ./crates/astro2tsx run build-dev

- name: Test astro2tsx binding
run: pnpm test
working-directory: crates/astro2tsx

- name: Build compiler package
run: pnpm run build:compiler

Expand Down
11 changes: 11 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,17 @@ oxc_syntax = { git = "https://github.com/withastro/oxc", rev = "8bb526fc0c20beb4
oxc_transformer = { git = "https://github.com/withastro/oxc", rev = "8bb526fc0c20beb4649b223d3ac39851505caa5a" }
oxc_sourcemap = "6.0.1"

# Biome dependencies via git, all pinned to the same fork revision
biome_diagnostics = { git = "https://github.com/Princesseuh/biome", rev = "e0a22c5f5b6fd09abe5e93ec0b5416f0ffbb73cb" }
biome_html_parser = { git = "https://github.com/Princesseuh/biome", rev = "e0a22c5f5b6fd09abe5e93ec0b5416f0ffbb73cb" }
biome_html_syntax = { git = "https://github.com/Princesseuh/biome", rev = "e0a22c5f5b6fd09abe5e93ec0b5416f0ffbb73cb" }
biome_js_parser = { git = "https://github.com/Princesseuh/biome", rev = "e0a22c5f5b6fd09abe5e93ec0b5416f0ffbb73cb" }
biome_parser = { git = "https://github.com/Princesseuh/biome", rev = "e0a22c5f5b6fd09abe5e93ec0b5416f0ffbb73cb" }
biome_js_syntax = { git = "https://github.com/Princesseuh/biome", rev = "e0a22c5f5b6fd09abe5e93ec0b5416f0ffbb73cb" }
biome_languages = { git = "https://github.com/Princesseuh/biome", rev = "e0a22c5f5b6fd09abe5e93ec0b5416f0ffbb73cb" }
biome_rowan = { git = "https://github.com/Princesseuh/biome", rev = "e0a22c5f5b6fd09abe5e93ec0b5416f0ffbb73cb" }
biome_string_case = { git = "https://github.com/Princesseuh/biome", rev = "e0a22c5f5b6fd09abe5e93ec0b5416f0ffbb73cb" }

# NAPI
napi = { version = "3", features = ["tokio_rt"] }
napi-build = "2"
Expand Down
5 changes: 4 additions & 1 deletion biome.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,10 @@
"!crates/astro_napi/*.cjs",
"!crates/astro_napi/*.mjs",
"!crates/astro_napi/index.d.ts",
"!crates/astro_napi/npm"
"!crates/astro_napi/npm",
"!crates/astro2tsx/*.js",
"!crates/astro2tsx/index.d.ts",
"!crates/astro2tsx/npm"
]
},
"vcs": {
Expand Down
44 changes: 44 additions & 0 deletions crates/astro2tsx/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
[package]
name = "astro2tsx"
version = "0.1.0"
authors.workspace = true
edition.workspace = true
license.workspace = true
repository.workspace = true
rust-version.workspace = true
publish = false
description = "Converts .astro files to .tsx for tsserver intellisense, using Biome's HTML CST"

[lib]
crate-type = ["cdylib", "lib"]
test = true
doctest = false

[dependencies]
biome_diagnostics = { workspace = true }
biome_html_parser = { workspace = true }
biome_html_syntax = { workspace = true }
biome_js_parser = { workspace = true }
biome_parser = { workspace = true }
biome_js_syntax = { workspace = true }
biome_languages = { workspace = true }
biome_rowan = { workspace = true }
biome_string_case = { workspace = true }

napi = { workspace = true }
napi-derive = { workspace = true }
oxc_sourcemap = { workspace = true }
oxc_syntax = { workspace = true }

[dev-dependencies]
divan = { workspace = true }
insta = { workspace = true, features = ["glob", "serde"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"

[build-dependencies]
napi-build = { workspace = true }

[[bench]]
name = "convert"
harness = false
209 changes: 209 additions & 0 deletions crates/astro2tsx/__test__/astro2tsx.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
import { readdirSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import { strict as assert } from 'node:assert';
import ts from 'typescript';
import { test } from 'node:test';
import { convertToTsx } from '../index.js';

test('emits the TSX prefix and a Fragment-wrapped body', () => {
const result = convertToTsx('<h1>Hello {value}</h1>');
assert.ok(result.code.startsWith('/* @jsxImportSource astro */'));
assert.match(result.code, /<Fragment>[\s\S]*<h1>[\s\S]*<\/h1>[\s\S]*<\/Fragment>/);
});

test('rewrites top-level returns to throws', () => {
const result = convertToTsx("---\nif (cond) {\n\treturn Astro.redirect('/x');\n}\n---\n");
assert.match(result.code, /throw\s+Astro\.redirect/);
assert.doesNotMatch(result.code, /return Astro\.redirect/);
});

test('detects `Props` interface and emits the Astro global declaration', () => {
const input = '---\ninterface Props {}\n---\n<div></div>';
const result = convertToTsx(input, { filename: 'Index.astro' });
assert.match(result.code, /_props: Props/);
assert.match(
result.code,
/declare const Astro: Readonly<import\('astro'\)\.AstroGlobal<Props,\s+typeof Index__AstroComponent_>>/,
);
});

test('reports parse errors but still produces output', () => {
const result = convertToTsx('<div'); // unclosed tag
assert.equal(result.hasParseErrors, true);
assert.ok(result.code.length > 0);
});

test('records frontmatter and body byte ranges', () => {
const result = convertToTsx('---\nlet x = 1;\n---\n<p></p>');
assert.ok(result.frontmatter.end > result.frontmatter.start);
assert.ok(result.body.end > result.body.start);
const frontmatterSlice = result.code.slice(result.frontmatter.start, result.frontmatter.end);
assert.match(frontmatterSlice, /let x = 1;/);
});

test('mapped runs interpolate to exact source positions, Volar-style', () => {
const source =
'---\nconst x = 1;\n---\n<article id="main" class=plain data-thing={x}>hello <b>world</b></article>';
const result = convertToTsx(source, { sourcemap: 'external' });
const { generatedOffsets, sourceOffsets, lengths } = result;

assert.ok(generatedOffsets instanceof Uint32Array);
assert.equal(generatedOffsets.length, sourceOffsets.length);
assert.equal(generatedOffsets.length, lengths.length);
assert.ok(generatedOffsets.length > 0);
for (let i = 0; i < generatedOffsets.length; i++) {
assert.ok(lengths[i] > 0, `run ${i} is empty`);
if (i > 0) {
assert.ok(
generatedOffsets[i] >= generatedOffsets[i - 1] + lengths[i - 1],
`run ${i} overlaps its predecessor`,
);
}
}

const originalAt = (generated) => {
for (let i = generatedOffsets.length - 1; i >= 0; i--) {
const delta = generated - generatedOffsets[i];
if (delta >= 0 && delta < lengths[i]) return sourceOffsets[i] + delta;
}
return null;
};

// Hover-style probes, deliberately mid-run rather than at run starts.
for (const probe of ['id="main"', 'data-thing', 'hello', 'world', 'const x = 1;']) {
const generated = result.code.indexOf(probe);
assert.notEqual(generated, -1, `${probe} not in output`);
const original = originalAt(generated);
assert.notEqual(original, null, `${probe} is unmapped`);
assert.equal(
source.slice(original, original + probe.length),
probe,
`${probe} resolves to the wrong source text`,
);
}

// Synthetic output stays unmapped instead of resolving somewhere misleading.
assert.equal(originalAt(result.code.indexOf('<Fragment>')), null);
assert.equal(originalAt(result.code.indexOf('export default function')), null);
});

test('returns a self-contained source map v3', () => {
const input = '---\nlet x = 1;\n---\n<p>Hi</p>';
const map = JSON.parse(convertToTsx(input).map!);
assert.equal(map.version, 3);
assert.deepEqual(map.sources, ['input.astro']);
assert.deepEqual(map.sourcesContent, [input]);
assert.deepEqual(map.names, []);
assert.ok(map.mappings.length > 0);
});

test('names the source after the filename option', () => {
const map = JSON.parse(convertToTsx('<p></p>', { filename: 'Index.astro' }).map!);
assert.deepEqual(map.sources, ['Index.astro']);
});

test('appends the inline source map comment by default', () => {
const { code, map } = convertToTsx('<p>Hi</p>');
const marker = '\n//# sourceMappingURL=data:application/json;charset=utf-8;base64,';
assert.ok(code.includes(marker));
const blob = code.slice(code.indexOf(marker) + marker.length);
assert.deepEqual(JSON.parse(Buffer.from(blob, 'base64').toString('utf8')), JSON.parse(map!));
});

test("sourcemap: 'external' leaves the code without the comment", () => {
const inline = convertToTsx('<p>Hi</p>');
const external = convertToTsx('<p>Hi</p>', { sourcemap: 'external' });
assert.doesNotMatch(external.code, /sourceMappingURL/);
assert.ok(inline.code.startsWith(external.code));
});

test('every clean-parse fixture emits syntactically valid TSX', async () => {
// Frontmatter is user JS emitted verbatim, so a fixture whose frontmatter is
// itself invalid TS legitimately produces invalid output.
const invalidUserCode = new Set(['props_generic_invalid']);

const dir = join(import.meta.dirname, '../tests/fixtures');
let checked = 0;
for (const file of readdirSync(dir)) {
if (!file.endsWith('.astro')) continue;
const name = file.slice(0, -'.astro'.length);
if (invalidUserCode.has(name)) continue;

let source = readFileSync(join(dir, file), 'utf8');
while (source.startsWith('// @config ')) {
source = source.slice(source.indexOf('\n') + 1);
}

const result = convertToTsx(source, { filename: `${name}.astro` });
if (result.hasParseErrors) continue;

const sourceFile = ts.createSourceFile(
`${name}.tsx`,
result.code,
ts.ScriptTarget.Latest,
false,
ts.ScriptKind.TSX,
);
const diagnostics = (
sourceFile as unknown as { parseDiagnostics: { messageText: unknown; start: number }[] }
).parseDiagnostics;
assert.deepEqual(
diagnostics.map((d) => `${name}: ${JSON.stringify(d.messageText)} at ${d.start}`),
[],
`invalid TSX emitted for ${name}:\n${result.code}`,
);
checked++;
}
assert.ok(checked > 50, `expected to check most fixtures, checked ${checked}`);
});

test('offsets are UTF-16 code units, not bytes', () => {
const source = '---\nconst \u{1f984} = 1;\n---\n<style>.a{color:red}</style>';
const result = convertToTsx(source, { sourcemap: 'external' });

// The unicorn is four bytes but two UTF-16 units, so a byte offset would overshoot.
const style = result.styles[0];
assert.equal(source.slice(style.position.start, style.position.end), '.a{color:red}');
assert.equal(
source.slice(result.frontmatterSource.start, result.frontmatterSource.end).at(-1),
'-',
);

const { generatedOffsets, sourceOffsets, lengths } = result;
for (let i = 0; i < generatedOffsets.length; i++) {
assert.equal(
result.code.slice(generatedOffsets[i], generatedOffsets[i] + lengths[i]),
source.slice(sourceOffsets[i], sourceOffsets[i] + lengths[i]),
`run ${i} is not verbatim`,
);
}
});

test('reports frontmatter status and positioned diagnostics', () => {
assert.equal(convertToTsx('---\nlet x = 1;\n---\n<p/>').frontmatterStatus, 'closed');
assert.equal(convertToTsx('---\nlet x = 1;\n').frontmatterStatus, 'open');
assert.equal(convertToTsx('<p/>').frontmatterStatus, 'doesnt-exist');

const broken = convertToTsx('<div>{x ==}</div>');
assert.ok(broken.hasParseErrors);
assert.ok(broken.diagnostics.length > 0);
for (const diagnostic of broken.diagnostics) {
assert.ok(diagnostic.message.length > 0);
assert.equal(diagnostic.severity, 1);
assert.ok(diagnostic.position.end >= diagnostic.position.start);
}
});

test('sourcemap: false skips building the map', () => {
const source = '---\nconst x = 1;\n---\n<p>{x}</p>';
const skipped = convertToTsx(source, { sourcemap: false });
assert.equal(skipped.map, undefined);
assert.doesNotMatch(skipped.code, /sourceMappingURL/);

// Opting out must not change the code or the offsets editors navigate by.
const external = convertToTsx(source, { sourcemap: 'external' });
assert.equal(skipped.code, external.code);
assert.deepEqual(Array.from(skipped.generatedOffsets), Array.from(external.generatedOffsets));
assert.deepEqual(Array.from(skipped.lengths), Array.from(external.lengths));
assert.ok(external.map);
});
Loading
Loading