Skip to content

Commit bcd065b

Browse files
committed
docs: add skills/module-tsx usage guides
1 parent e4e9420 commit bcd065b

3 files changed

Lines changed: 240 additions & 50 deletions

File tree

skills/module-tsx/SKILL.md

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
---
2+
name: module-tsx
3+
description: >
4+
Use this skill whenever a user wants to run TypeScript, TSX, or React in the
5+
browser without a build step, or asks about module-tsx. Triggers on: "run
6+
TypeScript in the browser", "no build step", "inline TSX", "module-tsx",
7+
"script type module-tsx", "React without bundler", "write a page that uses
8+
React with TypeScript", "browser TypeScript", "build-free React", or any
9+
request to write an HTML page that imports or renders TypeScript/TSX code.
10+
Always use this skill when writing HTML that needs to execute TypeScript or
11+
JSX in the browser.
12+
---
13+
14+
# module-tsx skill
15+
16+
module-tsx runs TypeScript and TSX directly in the browser — no build step, no
17+
bundler. It transpiles code on the fly and rewrites bare specifiers (like
18+
`"react"`) to `https://esm.sh/<pkg>` automatically.
19+
20+
## How to load it
21+
22+
```html
23+
<script type="module" src="https://esm.sh/module-tsx"></script>
24+
```
25+
26+
Load this **before** any `<script type="module-tsx">` tags.
27+
28+
## Writing code
29+
30+
Use `<script type="module-tsx">` like `<script type="module">` but with full
31+
TypeScript and JSX support:
32+
33+
```html
34+
<script type="module-tsx">
35+
const greet = (name: string) => `Hello, ${name}!`;
36+
console.log(greet("world"));
37+
</script>
38+
```
39+
40+
Or load an external file:
41+
42+
```html
43+
<script type="module-tsx" src="./main.tsx"></script>
44+
```
45+
46+
## React / TSX — zero config
47+
48+
Bare specifiers resolve to esm.sh automatically, so React just works with no
49+
import map needed. JSX without an explicit `import React` is fine too — it's
50+
auto-injected:
51+
52+
```html
53+
<div id="root"></div>
54+
<script type="module-tsx">
55+
import { createRoot } from "react-dom/client";
56+
57+
function App() {
58+
return <h1>Hello!</h1>; // React auto-injected, no import needed
59+
}
60+
61+
createRoot(document.getElementById("root")!).render(<App />);
62+
</script>
63+
```
64+
65+
## Minimal complete page
66+
67+
```html
68+
<!doctype html>
69+
<html lang="en">
70+
<head>
71+
<meta charset="UTF-8" />
72+
<title>My App</title>
73+
<script type="module" src="https://esm.sh/module-tsx"></script>
74+
</head>
75+
<body>
76+
<div id="root"></div>
77+
<script type="module-tsx">
78+
import { createRoot } from "react-dom/client";
79+
80+
interface Props { name: string }
81+
82+
function Greeting({ name }: Props) {
83+
return <h1>Hello, {name}!</h1>;
84+
}
85+
86+
createRoot(document.getElementById("root")!).render(
87+
<Greeting name="world" />
88+
);
89+
</script>
90+
</body>
91+
</html>
92+
```
93+
94+
## Import maps — only when you need them
95+
96+
An import map is optional. Use one only when you need to:
97+
- **Pin a specific version** (e.g. `react@18` instead of latest)
98+
- **Deduplicate peer dependencies** across packages
99+
100+
The import map must be declared **before** the module-tsx script tag, because
101+
module-tsx reads it at startup:
102+
103+
```html
104+
<script type="importmap">
105+
{
106+
"imports": {
107+
"react": "https://esm.sh/react@18",
108+
"react-dom/client": "https://esm.sh/react-dom@18/client"
109+
}
110+
}
111+
</script>
112+
<script type="module" src="https://esm.sh/module-tsx"></script>
113+
```
114+
115+
### Peer dependency deduplication
116+
117+
When a UI library (like `@radix-ui/themes`) depends on the same React as your
118+
app, both must resolve to the exact same URL or you get "Invalid hook call"
119+
crashes. Use `?deps=` to tell the CDN which React to bundle against:
120+
121+
```html
122+
<script type="importmap">
123+
{
124+
"imports": {
125+
"react": "https://esm.sh/react@18",
126+
"react-dom": "https://esm.sh/react-dom@18",
127+
"@radix-ui/themes": "https://esm.sh/@radix-ui/themes?deps=react@18,react-dom@18"
128+
}
129+
}
130+
</script>
131+
<script type="module" src="https://esm.sh/module-tsx"></script>
132+
```
133+
134+
## Relative imports
135+
136+
`.ts` and `.tsx` files can be imported relatively — fetched and transpiled on the fly:
137+
138+
```tsx
139+
import { Button } from "./components/Button.tsx";
140+
```
141+
142+
## CSS imports
143+
144+
```tsx
145+
import "./style.css"; // injects a <style> tag into <head>
146+
import styles from "./button.module.css"; // returns { root: "abc123_root", ... }
147+
```
148+
149+
## Common mistakes
150+
151+
- **`defer` attribute**: not supported. Use `async` or no attribute.
152+
- **Dual React**: if a package depends on React as a peer, use `?deps=react@18`
153+
so it shares the same instance — otherwise you'll get hook errors.
154+
- **Import map ordering**: if you use an import map, it must come before the
155+
module-tsx `<script>` tag.

smoke/radix-themes.html

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
<!doctype html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="UTF-8" />
5+
<title>@radix-ui/themes</title>
6+
<script type="module" src="../dist/index.mjs"></script>
7+
</head>
8+
<body>
9+
<div id="root"></div>
10+
<script type="importmap">
11+
{
12+
"imports": {
13+
"react": "https://esm.sh/react@18",
14+
"react-dom/": "https://esm.sh/react-dom@18/",
15+
"@radix-ui/themes": "https://esm.sh/@radix-ui/themes?deps=react@18,react-dom@18",
16+
"@radix-ui/themes/": "https://esm.sh/@radix-ui/themes/"
17+
}
18+
}
19+
</script>
20+
<script type="module-tsx">
21+
import React from "react";
22+
import { createRoot } from "react-dom/client";
23+
import { Theme, Button } from "@radix-ui/themes";
24+
import "@radix-ui/themes/styles.css";
25+
26+
createRoot(document.getElementById("root")!).render(
27+
<Theme>
28+
<Button>Click me</Button>
29+
</Theme>
30+
);
31+
</script>
32+
</body>
33+
</html>

src/module-tsx.ts

Lines changed: 52 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -189,68 +189,70 @@ export class ModuleTSX extends EventTarget implements IModuleTSX {
189189
}
190190
}
191191

192+
private async resolveLocalUrl(fullUrl: string): Promise<string> {
193+
const { pathname } = new URL(fullUrl);
194+
if (pathname.endsWith(".module.css")) {
195+
return this.transformSourceModule("css-module", fullUrl, await this.fetchText(fullUrl));
196+
}
197+
if (pathname.endsWith(".css")) {
198+
return this.transformSourceModule("css", fullUrl, await this.fetchText(fullUrl));
199+
}
200+
if (pathname.endsWith(".wasm")) {
201+
// wasm will be handled natively by the browser
202+
// so we just return the original full URL
203+
return fullUrl;
204+
}
205+
// If this URL is already being transformed (circular import), return the raw URL.
206+
// The browser handles circular ESM natively; we just need to avoid deadlocking.
207+
if (this.sourceTracker.isInFlight("esm", fullUrl)) {
208+
return fullUrl;
209+
}
210+
//! ^ transformSourceModule is recursive ^
211+
return this.transformSourceModule("esm", fullUrl, await this.fetchText(fullUrl));
212+
}
213+
192214
private async resolveSpecifier(specifier: string, sourceUrl: string): Promise<string> {
193215
const resolved = ImportMap.resolve(specifier, this.importMap, sourceUrl);
194216
if (resolved) {
195217
this.resolvedModuleSet.push(resolved.record);
218+
// CSS resolved via import map still needs to be injected as a <style> tag
219+
const { pathname } = new URL(resolved.url);
220+
if (pathname.endsWith(".module.css")) {
221+
return this.transformSourceModule("css-module", resolved.url, await this.fetchText(resolved.url));
222+
}
223+
if (pathname.endsWith(".css")) {
224+
return this.transformSourceModule("css", resolved.url, await this.fetchText(resolved.url));
225+
}
196226
return resolved.url;
197227
}
198-
const getCssUrl = async (fullURL: string) => {
199-
// const code = this.cssStrategy === "link" ? "" : await this.fetchCode(url);
200-
return await this.transformSourceModule("css", fullURL, await this.fetchText(fullURL));
201-
};
202-
const toCDNUrl = (specifier: string) => {
203-
// this avoid we accidentally convert a package named xxx.css to a css file on esm.sh
204-
const subpath = specifier.startsWith("@")
205-
? // @scope/pkg/subpath -> /subpath
206-
specifier.split("/").slice(2).join("/")
207-
: // pkg/subpath -> /subpath
208-
specifier.split("/").slice(1).join("/");
209228

210-
const url = this.resolveBareSpecifier(specifier);
229+
if (isRelativeSpecifier(specifier)) {
230+
// local file, we fetch and transform it, then return the blob URL
231+
return this.resolveLocalUrl(new URL(specifier, sourceUrl).href);
232+
}
233+
234+
if (specifier.startsWith("node:")) {
235+
return `https://raw.esm.sh/@jspm/core/nodelibs/browser/${specifier.slice(5)}.js`;
236+
}
237+
238+
const bareSpecifier = specifier.startsWith("npm:") ? specifier.slice(4) : specifier;
239+
if (specifier.startsWith("npm:") || isBareSpecifier(specifier)) {
240+
// this avoid we accidentally convert a package named xxx.css to a css file on esm.sh
241+
const subpath = bareSpecifier.startsWith("@")
242+
? // @scope/pkg/subpath -> subpath
243+
bareSpecifier.split("/").slice(2).join("/")
244+
: // pkg/subpath -> subpath
245+
bareSpecifier.split("/").slice(1).join("/");
246+
const url = this.resolveBareSpecifier(bareSpecifier);
211247
if (subpath.endsWith(".css")) {
212248
// if the subpath (not the package name) ends with .css, we treat it as a css file
213-
return getCssUrl(url);
249+
return this.transformSourceModule("css", url, await this.fetchText(url));
214250
}
215251
return url;
216-
};
217-
218-
if (isRelativeSpecifier(specifier)) {
219-
const targetUrl = new URL(specifier, sourceUrl);
220-
// local file, we fetch and transform it, then return the blob URL
221-
if (targetUrl.pathname.endsWith(".module.css")) {
222-
const blobUrl = await this.transformSourceModule(
223-
"css-module",
224-
targetUrl.href,
225-
await this.fetchText(targetUrl.href),
226-
);
227-
return blobUrl;
228-
} else if (targetUrl.pathname.endsWith(".css")) {
229-
return getCssUrl(targetUrl.href);
230-
} else if (targetUrl.pathname.endsWith(".wasm")) {
231-
// wasm will be handled natively by the browser
232-
// so we just return the original full URL
233-
return targetUrl.href;
234-
} else {
235-
// If this URL is already being transformed (circular import), return the raw URL.
236-
// The browser handles circular ESM natively; we just need to avoid deadlocking.
237-
if (this.sourceTracker.isInFlight("esm", targetUrl.href)) {
238-
return targetUrl.href;
239-
}
240-
const blobUrl = await this.transformSourceModule("esm", targetUrl.href, await this.fetchText(targetUrl.href));
241-
//! ^ transformSourceModule is recursive ^
242-
return blobUrl;
243-
}
244-
} else if (specifier.startsWith("node:")) {
245-
return `https://raw.esm.sh/@jspm/core/nodelibs/browser/${specifier.slice(5)}.js`;
246-
} else if (specifier.startsWith("npm:")) {
247-
return toCDNUrl(specifier.slice(4));
248-
} else if (isBareSpecifier(specifier)) {
249-
return toCDNUrl(specifier);
250-
} else {
251-
// Fallback: return the original specifier
252-
return specifier;
253252
}
253+
254+
// Fallback: return the original specifier
255+
return specifier;
254256
}
255257

256258
private async resolveSpecifiers(specifiers: Set<string>, sourceUrl: string): Promise<Map<string, string>> {

0 commit comments

Comments
 (0)