Skip to content

Commit a7b474a

Browse files
mbostockFil
andauthored
TypeScript (#1632)
* TypeScript * verbatimModuleSyntax * TypeScript docs * JavaScript inline expressions --------- Co-authored-by: Philippe Rivière <fil@rezo.net>
1 parent 173dd3c commit a7b474a

14 files changed

Lines changed: 189 additions & 28 deletions

File tree

docs/imports.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,7 @@ Imports from `node_modules` are cached in `.observablehq/cache/_node` within you
101101

102102
## Local imports
103103

104-
You can import JavaScript modules from local files. This is useful for organizing your code into modules that can be imported across multiple pages. You can also unit test your code and share code with other web applications.
104+
You can import [JavaScript](./javascript) and [TypeScript](./javascript#type-script) modules from local files. This is useful for organizing your code into modules that can be imported across multiple pages. You can also unit test your code and share code with other web applications.
105105

106106
For example, if this is `foo.js`:
107107

docs/javascript.md

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@ html`<span style=${{color: `hsl(${(now / 10) % 360} 100% 50%)`}}>Rainbow text!</
8686

8787
## Inline expressions
8888

89-
Inline expressions <code>$\{…}</code> interpolate values into Markdown. They are typically used to display numbers such as metrics, or to arrange visual elements such as charts into rich HTML layouts.
89+
JavaScript inline expressions <code>$\{…}</code> interpolate values into Markdown. They are typically used to display numbers such as metrics, or to arrange visual elements such as charts into rich HTML layouts.
9090

9191
For example, this paragraph simulates rolling a 20-sided dice:
9292

@@ -127,6 +127,22 @@ const number = Generators.input(numberInput);
127127

128128
Expressions cannot declare top-level reactive variables. To declare a variable, use a code block instead. You can declare a variable in a code block (without displaying it) and then display it somewhere else using an inline expression.
129129

130+
## TypeScript <a href="https://github.com/observablehq/framework/pull/1632" class="observablehq-version-badge" data-version="prerelease" title="Added in #1632"></a>
131+
132+
TypeScript fenced code blocks (<code>```ts</code>) allow TypeScript to be used in place of JavaScript. You can also import TypeScript modules (`.ts`). Use the `.js` file extension when importing TypeScript modules; TypeScript is transpiled to JavaScript during build.
133+
134+
<div class="warning">
135+
136+
Framework does not perform type checking during preview or build. If you want the additional safety of type checks, considering using [`tsc`](https://www.typescriptlang.org/docs/handbook/compiler-options.html).
137+
138+
</div>
139+
140+
<div class="note">
141+
142+
TypeScript fenced code blocks do not currently support [implicit display](#implicit-display), and TypeScript is not currently allowed in [inline expressions](#inline-expressions).
143+
144+
</div>
145+
130146
## Explicit display
131147

132148
The built-in [`display` function](#display-value) displays the specified value.

docs/jsx.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
# JSX <a href="https://github.com/observablehq/framework/releases/tag/v1.9.0" class="observablehq-version-badge" data-version="^1.9.0" title="Added in 1.9.0"></a>
22

3-
[React](https://react.dev/) is a popular and powerful library for building interactive interfaces. React is typically written in [JSX](https://react.dev/learn/writing-markup-with-jsx), an extension of JavaScript that allows HTML-like markup. To use JSX and React, declare a JSX fenced code block (<code>```jsx</code>). For example, to define a `Greeting` component that accepts a `subject` prop:
3+
[React](https://react.dev/) is a popular and powerful library for building interactive interfaces. React is typically written in [JSX](https://react.dev/learn/writing-markup-with-jsx), an extension of JavaScript that allows HTML-like markup. To use JSX and React, declare a JSX fenced code block (<code>\```jsx</code>). You can alternatively use a TSX fenced code block (<code>\```tsx</code>) if using JSX with [TypeScript](./javascript#type-script).
4+
5+
For example, to define a `Greeting` component that accepts a `subject` prop:
46

57
````md
68
```jsx

src/javascript/module.ts

Lines changed: 56 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {accessSync, constants, readFileSync, statSync} from "node:fs";
44
import {readFile} from "node:fs/promises";
55
import {extname, join} from "node:path/posix";
66
import type {Program} from "acorn";
7+
import type {TransformOptions} from "esbuild";
78
import {transform, transformSync} from "esbuild";
89
import {resolveNodeImport} from "../node.js";
910
import {resolveNpmImport} from "../npm.js";
@@ -221,34 +222,72 @@ export function findModule(root: string, path: string): RouteResult | undefined
221222
const ext = extname(path);
222223
if (!ext) throw new Error(`empty extension: ${path}`);
223224
const exts = [ext];
224-
if (ext === ".js") exts.push(".jsx");
225+
if (ext === ".js") exts.push(".ts", ".jsx", ".tsx");
225226
return route(root, path.slice(0, -ext.length), exts);
226227
}
227228

228229
export async function readJavaScript(sourcePath: string): Promise<string> {
229230
const source = await readFile(sourcePath, "utf-8");
230-
if (sourcePath.endsWith(".jsx")) {
231-
const {code} = await transform(source, {
232-
loader: "jsx",
233-
jsx: "automatic",
234-
jsxImportSource: "npm:react",
235-
sourcefile: sourcePath
236-
});
237-
return code;
231+
switch (extname(sourcePath)) {
232+
case ".ts":
233+
return transformJavaScript(source, "ts", sourcePath);
234+
case ".jsx":
235+
return transformJavaScript(source, "jsx", sourcePath);
236+
case ".tsx":
237+
return transformJavaScript(source, "tsx", sourcePath);
238238
}
239239
return source;
240240
}
241241

242242
export function readJavaScriptSync(sourcePath: string): string {
243243
const source = readFileSync(sourcePath, "utf-8");
244-
if (sourcePath.endsWith(".jsx")) {
245-
const {code} = transformSync(source, {
246-
loader: "jsx",
247-
jsx: "automatic",
248-
jsxImportSource: "npm:react",
249-
sourcefile: sourcePath
250-
});
251-
return code;
244+
switch (extname(sourcePath)) {
245+
case ".ts":
246+
return transformJavaScriptSync(source, "ts", sourcePath);
247+
case ".jsx":
248+
return transformJavaScriptSync(source, "jsx", sourcePath);
249+
case ".tsx":
250+
return transformJavaScriptSync(source, "tsx", sourcePath);
252251
}
253252
return source;
254253
}
254+
255+
export async function transformJavaScript(
256+
source: string,
257+
loader: "ts" | "jsx" | "tsx",
258+
sourcePath?: string
259+
): Promise<string> {
260+
return (await transform(source, getTransformOptions(loader, sourcePath))).code;
261+
}
262+
263+
export function transformJavaScriptSync(source: string, loader: "ts" | "jsx" | "tsx", sourcePath?: string): string {
264+
return transformSync(source, getTransformOptions(loader, sourcePath)).code;
265+
}
266+
267+
function getTransformOptions(loader: "ts" | "jsx" | "tsx", sourcePath?: string): TransformOptions {
268+
switch (loader) {
269+
case "ts":
270+
return {
271+
loader,
272+
sourcefile: sourcePath,
273+
tsconfigRaw: '{"compilerOptions": {"verbatimModuleSyntax": true}}'
274+
};
275+
case "jsx":
276+
return {
277+
loader,
278+
jsx: "automatic",
279+
jsxImportSource: "npm:react",
280+
sourcefile: sourcePath
281+
};
282+
case "tsx":
283+
return {
284+
loader,
285+
jsx: "automatic",
286+
jsxImportSource: "npm:react",
287+
sourcefile: sourcePath,
288+
tsconfigRaw: '{"compilerOptions": {"verbatimModuleSyntax": true}}'
289+
};
290+
default:
291+
throw new Error(`unknown loader: ${loader}`);
292+
}
293+
}

src/loader.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -256,8 +256,11 @@ export class LoaderResolver {
256256
const exactPath = join(this.root, path);
257257
if (existsSync(exactPath)) return exactPath;
258258
if (exactPath.endsWith(".js")) {
259-
const jsxPath = exactPath + "x";
260-
if (existsSync(jsxPath)) return jsxPath;
259+
const basePath = exactPath.slice(0, -".js".length);
260+
for (const ext of [".ts", ".jsx", ".tsx"]) {
261+
const extPath = basePath + ext;
262+
if (existsSync(extPath)) return extPath;
263+
}
261264
return; // loaders aren’t supported for .js
262265
}
263266
const foundPath = this.find(path)?.path;

src/markdown.ts

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
/* eslint-disable import/no-named-as-default-member */
22
import {createHash} from "node:crypto";
33
import slugify from "@sindresorhus/slugify";
4-
import {transformSync} from "esbuild";
54
import he from "he";
65
import MarkdownIt from "markdown-it";
76
import type {Token} from "markdown-it";
@@ -15,6 +14,7 @@ import type {FrontMatter} from "./frontMatter.js";
1514
import {readFrontMatter} from "./frontMatter.js";
1615
import {html, rewriteHtmlPaths} from "./html.js";
1716
import {parseInfo} from "./info.js";
17+
import {transformJavaScriptSync} from "./javascript/module.js";
1818
import type {JavaScriptNode} from "./javascript/parse.js";
1919
import {parseJavaScript} from "./javascript/parse.js";
2020
import {isAssetPath, relativePath} from "./path.js";
@@ -63,9 +63,9 @@ function isFalse(attribute: string | undefined): boolean {
6363
return attribute?.toLowerCase() === "false";
6464
}
6565

66-
function transformJsx(content: string): string {
66+
function transpileJavaScript(content: string, tag: "ts" | "jsx" | "tsx"): string {
6767
try {
68-
return transformSync(content, {loader: "jsx", jsx: "automatic", jsxImportSource: "npm:react"}).code;
68+
return transformJavaScriptSync(content, tag);
6969
} catch (error: any) {
7070
throw new SyntaxError(error.message);
7171
}
@@ -74,8 +74,8 @@ function transformJsx(content: string): string {
7474
function getLiveSource(content: string, tag: string, attributes: Record<string, string>): string | undefined {
7575
return tag === "js"
7676
? content
77-
: tag === "jsx"
78-
? transformJsx(content)
77+
: tag === "ts" || tag === "jsx" || tag === "tsx"
78+
? transpileJavaScript(content, tag)
7979
: tag === "tex"
8080
? transpileTag(content, "tex.block", true)
8181
: tag === "html"
@@ -123,7 +123,7 @@ function makeFenceRenderer(baseRenderer: RenderRule): RenderRule {
123123
const id = uniqueCodeId(context, source);
124124
// TODO const sourceLine = context.startLine + context.currentLine;
125125
const node = parseJavaScript(source, {path, params});
126-
context.code.push({id, node, mode: tag === "jsx" ? "jsx" : "block"});
126+
context.code.push({id, node, mode: tag === "jsx" || tag === "tsx" ? "jsx" : "block"});
127127
html += `<div class="observablehq observablehq--block">${
128128
node.expression ? "<observablehq-loading></observablehq-loading>" : ""
129129
}<!--:${id}:--></div>\n`;
@@ -188,6 +188,7 @@ function makePlaceholderRenderer(): RenderRule {
188188
const id = uniqueCodeId(context, token.content);
189189
try {
190190
// TODO sourceLine: context.startLine + context.currentLine
191+
// TODO allow TypeScript?
191192
const node = parseJavaScript(token.content, {path, params, inline: true});
192193
context.code.push({id, node, mode: "inline"});
193194
return `<observablehq-loading></observablehq-loading><!--:${id}:-->`;
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
```ts echo
2+
function add(a: number, b: number): number {
3+
return a + b;
4+
}
5+
```
6+
7+
```js echo
8+
add(1, 3)
9+
```
10+
11+
```ts echo
12+
add(1 as number, 3)
13+
```
14+
15+
```ts echo
16+
import {sum} from "./sum.js";
17+
```
18+
19+
```ts echo
20+
sum(1, 2)
21+
```

test/input/build/typescript/sum.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export function sum(a: number, b: number): number {
2+
return a + b;
3+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export function sum(a, b) {
2+
return a + b;
3+
}

test/output/build/typescript/_observablehq/client.00000001.js

Whitespace-only changes.

0 commit comments

Comments
 (0)