Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

TypeScript #1632

Merged
merged 4 commits into from
Sep 3, 2024
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
2 changes: 1 addition & 1 deletion docs/imports.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ Imports from `node_modules` are cached in `.observablehq/cache/_node` within you

## Local imports

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.
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.

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

Expand Down
18 changes: 17 additions & 1 deletion docs/javascript.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ html`<span style=${{color: `hsl(${(now / 10) % 360} 100% 50%)`}}>Rainbow text!</

## Inline expressions

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.
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.

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

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

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.

## TypeScript <a href="/~https://github.com/observablehq/framework/pull/1632" class="observablehq-version-badge" data-version="prerelease" title="Added in #1632"></a>

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.

<div class="warning">

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).

</div>

<div class="note">

TypeScript fenced code blocks do not currently support [implicit display](#implicit-display), and TypeScript is not currently allowed in [inline expressions](#inline-expressions).

</div>

## Explicit display

The built-in [`display` function](#display-value) displays the specified value.
Expand Down
4 changes: 3 additions & 1 deletion docs/jsx.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
# 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>

[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:
[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).

For example, to define a `Greeting` component that accepts a `subject` prop:

````md
```jsx
Expand Down
73 changes: 56 additions & 17 deletions src/javascript/module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {accessSync, constants, readFileSync, statSync} from "node:fs";
import {readFile} from "node:fs/promises";
import {extname, join} from "node:path/posix";
import type {Program} from "acorn";
import type {TransformOptions} from "esbuild";
import {transform, transformSync} from "esbuild";
import {resolveNodeImport} from "../node.js";
import {resolveNpmImport} from "../npm.js";
Expand Down Expand Up @@ -221,34 +222,72 @@ export function findModule(root: string, path: string): RouteResult | undefined
const ext = extname(path);
if (!ext) throw new Error(`empty extension: ${path}`);
const exts = [ext];
if (ext === ".js") exts.push(".jsx");
if (ext === ".js") exts.push(".ts", ".jsx", ".tsx");
return route(root, path.slice(0, -ext.length), exts);
}

export async function readJavaScript(sourcePath: string): Promise<string> {
const source = await readFile(sourcePath, "utf-8");
if (sourcePath.endsWith(".jsx")) {
const {code} = await transform(source, {
loader: "jsx",
jsx: "automatic",
jsxImportSource: "npm:react",
sourcefile: sourcePath
});
return code;
switch (extname(sourcePath)) {
case ".ts":
return transformJavaScript(source, "ts", sourcePath);
case ".jsx":
return transformJavaScript(source, "jsx", sourcePath);
case ".tsx":
return transformJavaScript(source, "tsx", sourcePath);
}
return source;
}

export function readJavaScriptSync(sourcePath: string): string {
const source = readFileSync(sourcePath, "utf-8");
if (sourcePath.endsWith(".jsx")) {
const {code} = transformSync(source, {
loader: "jsx",
jsx: "automatic",
jsxImportSource: "npm:react",
sourcefile: sourcePath
});
return code;
switch (extname(sourcePath)) {
case ".ts":
return transformJavaScriptSync(source, "ts", sourcePath);
case ".jsx":
return transformJavaScriptSync(source, "jsx", sourcePath);
case ".tsx":
return transformJavaScriptSync(source, "tsx", sourcePath);
}
return source;
}

export async function transformJavaScript(
source: string,
loader: "ts" | "jsx" | "tsx",
sourcePath?: string
): Promise<string> {
return (await transform(source, getTransformOptions(loader, sourcePath))).code;
}

export function transformJavaScriptSync(source: string, loader: "ts" | "jsx" | "tsx", sourcePath?: string): string {
return transformSync(source, getTransformOptions(loader, sourcePath)).code;
}

function getTransformOptions(loader: "ts" | "jsx" | "tsx", sourcePath?: string): TransformOptions {
switch (loader) {
case "ts":
return {
loader,
sourcefile: sourcePath,
tsconfigRaw: '{"compilerOptions": {"verbatimModuleSyntax": true}}'
};
case "jsx":
return {
loader,
jsx: "automatic",
jsxImportSource: "npm:react",
sourcefile: sourcePath
};
case "tsx":
return {
loader,
jsx: "automatic",
jsxImportSource: "npm:react",
sourcefile: sourcePath,
tsconfigRaw: '{"compilerOptions": {"verbatimModuleSyntax": true}}'
};
default:
throw new Error(`unknown loader: ${loader}`);
}
}
7 changes: 5 additions & 2 deletions src/loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -256,8 +256,11 @@ export class LoaderResolver {
const exactPath = join(this.root, path);
if (existsSync(exactPath)) return exactPath;
if (exactPath.endsWith(".js")) {
const jsxPath = exactPath + "x";
if (existsSync(jsxPath)) return jsxPath;
const basePath = exactPath.slice(0, -".js".length);
for (const ext of [".ts", ".jsx", ".tsx"]) {
const extPath = basePath + ext;
if (existsSync(extPath)) return extPath;
}
return; // loaders aren’t supported for .js
}
const foundPath = this.find(path)?.path;
Expand Down
13 changes: 7 additions & 6 deletions src/markdown.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
/* eslint-disable import/no-named-as-default-member */
import {createHash} from "node:crypto";
import slugify from "@sindresorhus/slugify";
import {transformSync} from "esbuild";
import he from "he";
import MarkdownIt from "markdown-it";
import type {Token} from "markdown-it";
Expand All @@ -15,6 +14,7 @@ import type {FrontMatter} from "./frontMatter.js";
import {readFrontMatter} from "./frontMatter.js";
import {html, rewriteHtmlPaths} from "./html.js";
import {parseInfo} from "./info.js";
import {transformJavaScriptSync} from "./javascript/module.js";
import type {JavaScriptNode} from "./javascript/parse.js";
import {parseJavaScript} from "./javascript/parse.js";
import {isAssetPath, relativePath} from "./path.js";
Expand Down Expand Up @@ -63,9 +63,9 @@ function isFalse(attribute: string | undefined): boolean {
return attribute?.toLowerCase() === "false";
}

function transformJsx(content: string): string {
function transpileJavaScript(content: string, tag: "ts" | "jsx" | "tsx"): string {
try {
return transformSync(content, {loader: "jsx", jsx: "automatic", jsxImportSource: "npm:react"}).code;
return transformJavaScriptSync(content, tag);
} catch (error: any) {
throw new SyntaxError(error.message);
}
Expand All @@ -74,8 +74,8 @@ function transformJsx(content: string): string {
function getLiveSource(content: string, tag: string, attributes: Record<string, string>): string | undefined {
return tag === "js"
? content
: tag === "jsx"
? transformJsx(content)
: tag === "ts" || tag === "jsx" || tag === "tsx"
? transpileJavaScript(content, tag)
: tag === "tex"
? transpileTag(content, "tex.block", true)
: tag === "html"
Expand Down Expand Up @@ -123,7 +123,7 @@ function makeFenceRenderer(baseRenderer: RenderRule): RenderRule {
const id = uniqueCodeId(context, source);
// TODO const sourceLine = context.startLine + context.currentLine;
const node = parseJavaScript(source, {path, params});
context.code.push({id, node, mode: tag === "jsx" ? "jsx" : "block"});
context.code.push({id, node, mode: tag === "jsx" || tag === "tsx" ? "jsx" : "block"});
html += `<div class="observablehq observablehq--block">${
node.expression ? "<observablehq-loading></observablehq-loading>" : ""
}<!--:${id}:--></div>\n`;
Expand Down Expand Up @@ -188,6 +188,7 @@ function makePlaceholderRenderer(): RenderRule {
const id = uniqueCodeId(context, token.content);
try {
// TODO sourceLine: context.startLine + context.currentLine
// TODO allow TypeScript?
const node = parseJavaScript(token.content, {path, params, inline: true});
context.code.push({id, node, mode: "inline"});
return `<observablehq-loading></observablehq-loading><!--:${id}:-->`;
Expand Down
21 changes: 21 additions & 0 deletions test/input/build/typescript/index.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
```ts echo
function add(a: number, b: number): number {
return a + b;
}
```

```js echo
add(1, 3)
```

```ts echo
add(1 as number, 3)
```

```ts echo
import {sum} from "./sum.js";
```

```ts echo
sum(1, 2)
```
3 changes: 3 additions & 0 deletions test/input/build/typescript/sum.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export function sum(a: number, b: number): number {
return a + b;
}
3 changes: 3 additions & 0 deletions test/output/build/typescript/_import/sum.fd55756b.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export function sum(a, b) {
return a + b;
}
Empty file.
Empty file.
Empty file.
73 changes: 73 additions & 0 deletions test/output/build/typescript/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
<!DOCTYPE html>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
<meta name="generator" content="Observable Framework v1.0.0-test">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="preload" as="style" href="https://fonts.googleapis.com/css2?family=Source+Serif+4:ital,opsz,wght@0,8..60,200..900;1,8..60,200..900&amp;display=swap" crossorigin>
<link rel="preload" as="style" href="./_observablehq/theme-air,near-midnight.00000004.css">
<link rel="stylesheet" type="text/css" href="https://fonts.googleapis.com/css2?family=Source+Serif+4:ital,opsz,wght@0,8..60,200..900;1,8..60,200..900&amp;display=swap" crossorigin>
<link rel="stylesheet" type="text/css" href="./_observablehq/theme-air,near-midnight.00000004.css">
<link rel="modulepreload" href="./_observablehq/client.00000001.js">
<link rel="modulepreload" href="./_observablehq/runtime.00000002.js">
<link rel="modulepreload" href="./_observablehq/stdlib.00000003.js">
<link rel="modulepreload" href="./_import/sum.fd55756b.js">
<script type="module">

import {define} from "./_observablehq/client.00000001.js";

define({id: "fe9e095e", outputs: ["add"], body: () => {
function add(a, b) {
return a + b;
}
return {add};
}});

define({id: "b2e42312", inputs: ["add","display"], body: async (add,display) => {
display(await(
add(1, 3)
))
}});

define({id: "9aa97703", inputs: ["add"], body: (add) => {
add(1, 3);
}});

define({id: "5a5e524f", outputs: ["sum"], body: async () => {
const {sum} = await import("./_import/sum.fd55756b.js");

return {sum};
}});

define({id: "83afa0a9", inputs: ["sum"], body: (sum) => {
sum(1, 2);
}});

</script>
<aside id="observablehq-toc" data-selector="h1:not(:first-of-type)[id], h2:first-child[id], :not(h1) + h2[id]">
<nav>
</nav>
</aside>
<div id="observablehq-center">
<main id="observablehq-main" class="observablehq">
<div class="observablehq observablehq--block"><!--:fe9e095e:--></div>
<pre data-language="ts"><code class="language-ts"><span class="hljs-keyword">function</span> <span class="hljs-title function_">add</span>(<span class="hljs-params"><span class="hljs-attr">a</span>: <span class="hljs-built_in">number</span>, <span class="hljs-attr">b</span>: <span class="hljs-built_in">number</span></span>): <span class="hljs-built_in">number</span> {
<span class="hljs-keyword">return</span> a + b;
}
</code></pre>
<div class="observablehq observablehq--block"><observablehq-loading></observablehq-loading><!--:b2e42312:--></div>
<pre data-language="js"><code class="language-js"><span class="hljs-title function_">add</span>(<span class="hljs-number">1</span>, <span class="hljs-number">3</span>)
</code></pre>
<div class="observablehq observablehq--block"><!--:9aa97703:--></div>
<pre data-language="ts"><code class="language-ts"><span class="hljs-title function_">add</span>(<span class="hljs-number">1</span> <span class="hljs-keyword">as</span> <span class="hljs-built_in">number</span>, <span class="hljs-number">3</span>)
</code></pre>
<div class="observablehq observablehq--block"><!--:5a5e524f:--></div>
<pre data-language="ts"><code class="language-ts"><span class="hljs-keyword">import</span> {sum} <span class="hljs-keyword">from</span> <span class="hljs-string">"./sum.js"</span>;
</code></pre>
<div class="observablehq observablehq--block"><!--:83afa0a9:--></div>
<pre data-language="ts"><code class="language-ts"><span class="hljs-title function_">sum</span>(<span class="hljs-number">1</span>, <span class="hljs-number">2</span>)
</code></pre>
</main>
<footer id="observablehq-footer">
<div>Built with <a href="https://observablehq.com/" target="_blank" rel="noopener noreferrer">Observable</a> on <a title="2024-01-10T16:00:00">Jan 10, 2024</a>.</div>
</footer>
</div>