diff --git a/docs/sum.ts b/docs/sum.ts new file mode 100644 index 000000000..7372ed029 --- /dev/null +++ b/docs/sum.ts @@ -0,0 +1,3 @@ +export function sum(a: number, b: number): number { + return a + b; +} diff --git a/docs/typescript.md b/docs/typescript.md new file mode 100644 index 000000000..92505fcb7 --- /dev/null +++ b/docs/typescript.md @@ -0,0 +1,65 @@ +# TypeScript + +[Observable Markdown](./markdown) supports TypeScript — an extension of JavaScript adding types to the language — in fenced code blocks and inline expressions. + +`ts` code blocks use [esbuild](https://esbuild.github.io/) to parse TypeScript syntax and discard the type annotations. Inline expressions that fail to parse with JavaScript are likewise passed to esbuild, and replaced with the transform if it succeeds. + +Note however that this support is limited to parsing the code and converting it to JavaScript; esbuild does not do any type checking. + +TypeScript is also supported in [data loaders](./loaders). + +````md +```ts +const message: string = "Hello, world!"; +``` +```` + +try this one: + +```ts echo +const file: FileAttachment = FileAttachment("javascript/hello.txt"); +``` + +```ts echo +file.text() +``` + +```ts echo +function add(a: number, b: number): number { + return a + b; +} +``` + +The resulting function can be called from js cells as well as other ts cells: + +```js echo +add(1, 3) +``` + +```ts echo +add(1 as number, 3) +``` + +Inline expressions are also converted when they appear to be written in TypeScript: + +1 + 2 = ${add(1 as number, 2)}. + +```md echo +1 + 2 = ${add(1 as number, 2)}. +``` + +Syntax errors are shown as expected: + +```ts echo +function bad() ::: { + return a + b; +} +``` + +Imports are transpiled too (here `sum.js` refers to `sum.ts`; by convention, TypeScript files have the `.ts` extension but are imported using `.js`): + +```ts echo +import {sum} from "./sum.js"; + +display(sum(1, 2)); +``` diff --git a/src/build.ts b/src/build.ts index 968e37abe..da1c94909 100644 --- a/src/build.ts +++ b/src/build.ts @@ -20,6 +20,7 @@ import {bundleStyles, rollupClient} from "./rollup.js"; import {searchIndex} from "./search.js"; import {Telemetry} from "./telemetry.js"; import {faint, yellow} from "./tty.js"; +import {transpileTypeScript} from "./typescript.js"; export interface BuildOptions { config: Config; @@ -188,7 +189,7 @@ export async function build( const resolveImportAlias = (path: string): string => { const hash = getModuleHash(root, path).slice(0, 8); const ext = extname(path); - return `/${join("_import", dirname(path), basename(path, ext))}.${hash}${ext}`; + return `/${join("_import", dirname(path), basename(path, ext))}.${hash}${ext === "ts" ? "js" : ext}`; }; for (const path of localImports) { const sourcePath = join(root, path); @@ -198,7 +199,8 @@ export async function build( } effects.output.write(`${faint("copy")} ${sourcePath} ${faint("→")} `); const resolveImport = getModuleResolver(root, path); - const input = await readFile(sourcePath, "utf-8"); + let input = await readFile(sourcePath, "utf-8"); + if (path.endsWith(".ts")) input = transpileTypeScript(input); const contents = await transpileModule(input, { root, path, diff --git a/src/dataloader.ts b/src/dataloader.ts index 18e41b674..15b762b5a 100644 --- a/src/dataloader.ts +++ b/src/dataloader.ts @@ -10,6 +10,7 @@ import {FileWatchers} from "./fileWatchers.js"; import {getFileHash} from "./javascript/module.js"; import type {Logger, Writer} from "./logger.js"; import {cyan, faint, green, red, yellow} from "./tty.js"; +import {getTypeScriptPath} from "./typescript.js"; const runningCommands = new Map>(); @@ -122,7 +123,12 @@ export class LoaderResolver { getWatchPath(path: string): string | undefined { const exactPath = join(this.root, path); - return existsSync(exactPath) ? exactPath : this.find(path)?.path; + if (existsSync(exactPath)) return exactPath; + if (path.endsWith(".js")) { + const tspath = getTypeScriptPath(exactPath); + if (existsSync(tspath)) return tspath; + } + return this.find(path)?.path; } watchFiles(path: string, watchPaths: Iterable, callback: (name: string) => void) { diff --git a/src/javascript/module.ts b/src/javascript/module.ts index 67842176a..4ef100961 100644 --- a/src/javascript/module.ts +++ b/src/javascript/module.ts @@ -1,8 +1,9 @@ import {createHash} from "node:crypto"; -import {readFileSync, statSync} from "node:fs"; +import {existsSync, readFileSync, statSync} from "node:fs"; import {join} from "node:path/posix"; import type {Program} from "acorn"; import {resolvePath} from "../path.js"; +import {getTypeScriptPath, transpileTypeScript} from "../typescript.js"; import {findFiles} from "./files.js"; import {findImports} from "./imports.js"; import {parseProgram} from "./parse.js"; @@ -70,7 +71,11 @@ export function getModuleHash(root: string, path: string): string { * source root, or undefined if the module does not exist or has invalid syntax. */ export function getModuleInfo(root: string, path: string): ModuleInfo | undefined { - const key = join(root, path); + let key = join(root, path); + if (!existsSync(key) && path.endsWith(".js")) { + const tskey = getTypeScriptPath(key); + if (existsSync(tskey)) key = tskey; + } let mtimeMs: number; try { ({mtimeMs} = statSync(key)); @@ -84,6 +89,7 @@ export function getModuleInfo(root: string, path: string): ModuleInfo | undefine let body: Program; try { source = readFileSync(key, "utf-8"); + if (key.endsWith(".ts")) source = transpileTypeScript(source); body = parseProgram(source); } catch { moduleInfoCache.delete(key); // delete stale entry diff --git a/src/markdown.ts b/src/markdown.ts index 01020f4c2..b6f8cf9fe 100644 --- a/src/markdown.ts +++ b/src/markdown.ts @@ -18,6 +18,7 @@ import {transpileSql} from "./sql.js"; import {transpileTag} from "./tag.js"; import {InvalidThemeError} from "./theme.js"; import {red} from "./tty.js"; +import {transpileTypeScript} from "./typescript.js"; export interface MarkdownCode { id: string; @@ -54,6 +55,8 @@ function isFalse(attribute: string | undefined): boolean { function getLiveSource(content: string, tag: string, attributes: Record): string | undefined { return tag === "js" ? content + : tag === "ts" + ? transpileTypeScript(content) : tag === "tex" ? transpileTag(content, "tex.block", true) : tag === "html" @@ -100,6 +103,7 @@ function makeFenceRenderer(baseRenderer: RenderRule): RenderRule { if (source != null) { const id = uniqueCodeId(context, source); // TODO const sourceLine = context.startLine + context.currentLine; + if (tag === "ts") source = transpileTypeScript(source); const node = parseJavaScript(source, {path}); context.code.push({id, node}); html += `
`; } catch (error) { diff --git a/src/preview.ts b/src/preview.ts index 9bb800d25..5d85c444e 100644 --- a/src/preview.ts +++ b/src/preview.ts @@ -1,5 +1,5 @@ import {createHash} from "node:crypto"; -import {watch} from "node:fs"; +import {existsSync, watch} from "node:fs"; import type {FSWatcher, WatchEventType} from "node:fs"; import {access, constants, readFile} from "node:fs/promises"; import {createServer} from "node:http"; @@ -30,6 +30,7 @@ import {bundleStyles, rollupClient} from "./rollup.js"; import {searchIndex} from "./search.js"; import {Telemetry} from "./telemetry.js"; import {bold, faint, green, link} from "./tty.js"; +import {getTypeScriptPath, transpileTypeScript} from "./typescript.js"; export interface PreviewOptions { config: Config; @@ -111,14 +112,19 @@ export class PreviewServer { send(req, pathname, {root: join(root, ".observablehq", "cache")}).pipe(res); } else if (pathname.startsWith("/_import/")) { const path = pathname.slice("/_import".length); - const filepath = join(root, path); + let filepath = join(root, path); try { if (pathname.endsWith(".css")) { await access(filepath, constants.R_OK); end(req, res, await bundleStyles({path: filepath}), "text/css"); return; } else if (pathname.endsWith(".js")) { - const input = await readFile(join(root, path), "utf-8"); + if (!existsSync(filepath)) { + const tspath = getTypeScriptPath(filepath); + if (existsSync(tspath)) filepath = tspath; + } + let input = await readFile(filepath, "utf-8"); + if (filepath.endsWith(".ts")) input = transpileTypeScript(input); const output = await transpileModule(input, {root, path}); end(req, res, output, "text/javascript"); return; diff --git a/src/typescript.ts b/src/typescript.ts new file mode 100644 index 000000000..ba7c3f890 --- /dev/null +++ b/src/typescript.ts @@ -0,0 +1,19 @@ +import {transformSync} from "esbuild"; + +export function transpileTypeScript(input: string): string { + try { + const js = transformSync(input, { + loader: "ts", + tsconfigRaw: '{"compilerOptions": {"verbatimModuleSyntax": true}}' + }).code; + // preserve the absence of a trailing semi-colon, to display + return input.trim().at(-1) !== ";" ? js.replace(/;[\s\n]*$/, "") : js; + } catch { + return input; + } +} + +export function getTypeScriptPath(path: string): string { + if (!path.endsWith(".js")) throw new Error(`expected .js: ${path}`); + return path.slice(0, -".js".length) + ".ts"; +} diff --git a/test/input/build/typescript/sum.ts b/test/input/build/typescript/sum.ts new file mode 100644 index 000000000..7372ed029 --- /dev/null +++ b/test/input/build/typescript/sum.ts @@ -0,0 +1,3 @@ +export function sum(a: number, b: number): number { + return a + b; +} diff --git a/test/input/build/typescript/typescript.md b/test/input/build/typescript/typescript.md new file mode 100644 index 000000000..d8847a823 --- /dev/null +++ b/test/input/build/typescript/typescript.md @@ -0,0 +1,22 @@ +```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.ts"; +``` + +```ts echo +sum(1, 2) +``` + diff --git a/test/output/build/typescript/_import/sum.fd55756b.ts b/test/output/build/typescript/_import/sum.fd55756b.ts new file mode 100644 index 000000000..506a41343 --- /dev/null +++ b/test/output/build/typescript/_import/sum.fd55756b.ts @@ -0,0 +1,3 @@ +export function sum(a, b) { + return a + b; +} diff --git a/test/output/build/typescript/typescript.html b/test/output/build/typescript/typescript.html new file mode 100644 index 000000000..8f9105a58 --- /dev/null +++ b/test/output/build/typescript/typescript.html @@ -0,0 +1,89 @@ + + + + + + + + + + + + + + + + + + +
+
+
+
function add(a: number, b: number): number {
+  return a + b;
+}
+
+
+
add(1, 3)
+
+
+
add(1 as number, 3)
+
+
+
import {sum} from "./sum.ts";
+
+
+
sum(1, 2)
+
+
+ +
diff --git a/test/output/single-quote-expression.md.json b/test/output/single-quote-expression.md.json index 570b9e859..e2190b887 100644 --- a/test/output/single-quote-expression.md.json +++ b/test/output/single-quote-expression.md.json @@ -9,9 +9,9 @@ "body": { "type": "Literal", "start": 0, - "end": 4, + "end": 5, "value": "}\"", - "raw": "'}\"'" + "raw": "\"}\\\"\"" }, "declarations": null, "references": [], @@ -20,7 +20,7 @@ "expression": true, "async": false, "inline": true, - "input": "'}\"'" + "input": "\"}\\\"\"" } } ] diff --git a/test/typescript-test.ts b/test/typescript-test.ts new file mode 100644 index 000000000..7999a13d8 --- /dev/null +++ b/test/typescript-test.ts @@ -0,0 +1,50 @@ +import assert from "node:assert"; +import {getTypeScriptPath, transpileTypeScript} from "../src/typescript.js"; + +describe("transpileTypeScript(input)", () => { + it("transpiles an arthimetic expression", () => { + assert.strictEqual(transpileTypeScript("1 + 2 as number"), "1 + 2"); + }); + it("transpiles an arthimetic expression with whitespace", () => { + assert.strictEqual(transpileTypeScript("1 + 2 as number "), "1 + 2"); + }); + it("transpiles an arthimetic expression with comment", () => { + assert.strictEqual(transpileTypeScript("1 + 2 as number // comment"), "1 + 2"); + }); + it("preserves the trailing semicolon", () => { + assert.strictEqual(transpileTypeScript("1 + 2 as number;"), "1 + 2;\n"); + }); + it("preserves the trailing semicolon with whitepsace", () => { + assert.strictEqual(transpileTypeScript("1 + 2 as number; "), "1 + 2;\n"); + }); + it.skip("preserves the trailing semicolon with comment", () => { + assert.strictEqual(transpileTypeScript("1 + 2 as number; // comment"), "1 + 2;\n"); + }); + it("transpiles empty input", () => { + assert.strictEqual(transpileTypeScript(""), ""); + }); + it("transpiles a function call", () => { + assert.strictEqual(transpileTypeScript("sum(1, 2 as number)"), "sum(1, 2)"); + }); + it("transpiles an import", () => { + assert.strictEqual(transpileTypeScript('import {sum} from "./sum.js";'), 'import { sum } from "./sum.js";\n'); + }); + it("transpiles a type import", () => { + assert.strictEqual(transpileTypeScript('import type {sum} from "./sum.js";'), ""); + }); + it("transpiles an import and statement", () => { + assert.strictEqual( + transpileTypeScript('import {sum} from "./sum.js"; sum(1, 2);'), + 'import { sum } from "./sum.js";\nsum(1, 2);\n' + ); + }); +}); + +describe("getTypeScriptPath(path)", () => { + it("replaces .js with .ts", () => { + assert.strictEqual(getTypeScriptPath("test.js"), "test.ts"); + }); + it("throws if the path does not end with .js", () => { + assert.throws(() => getTypeScriptPath("test.csv"), /expected \.js/); + }); +});