+ );
+}
diff --git a/docs/jsx.md b/docs/jsx.md
new file mode 100644
index 000000000..df61f4814
--- /dev/null
+++ b/docs/jsx.md
@@ -0,0 +1,137 @@
+# JSX
+
+[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 (```jsx). For example, to define a `Greeting` component that accepts a `subject` prop:
+
+````md
+```jsx
+function Greeting({subject}) {
+ return
+}
+```
+
+Then call the built-in display function to render content:
+
+```jsx echo
+display();
+```
+
+You can combine React with Framework’s built-in [reactivity](./reactivity) by passing reactive values as props. Try changing the `name` below.
+
+```jsx echo
+display();
+```
+
+```js echo
+const name = view(Inputs.text({label: "Name", placeholder: "Anonymous"}));
+```
+
+You can use hooks such as [`useState`](https://react.dev/reference/react/useState), [`useEffect`](https://react.dev/reference/react/useEffect), and [`useRef`](https://react.dev/reference/react/useRef). The `Counter` component below counts the number of times you click the button.
+
+```jsx echo
+function Counter() {
+ const [count, setCount] = React.useState(0);
+ return (
+
+ );
+}
+```
+
+```jsx echo
+display();
+```
+
+React is available by default as `React` in Markdown, but you can import it explicitly like so:
+
+```js run=false
+import * as React from "npm:react";
+```
+
+If you prefer, you can import specific symbols, such as hooks:
+
+```js run=false
+import {useState} from "npm:react";
+```
+
+React DOM is also available as `ReactDOM` in Markdown, or can be imported as:
+
+```js run=false
+import * as ReactDOM from "npm:react-dom";
+```
+
+You can define components in JSX modules. For example, if this were `components/Card.jsx`:
+
+```jsx run=false
+export function Card({title, children} = {}) {
+ return (
+
+ {title ?
{title}
: null}
+ {children}
+
+ );
+}
+```
+
+You could then import the `Card` component as:
+
+```js echo
+import {Card} from "./components/Card.js";
+```
+
+
+
+Use the `.js` file extension when importing JSX (`.jsx`) modules; JSX is transpiled to JavaScript during build.
+
+
+
+And, as before, you can render a card using the display function:
+
+```jsx echo
+display(If you can read this, success!);
+```
+
+Within a JSX fenced code block, the [display function](./javascript#explicit-display) behaves a bit differently from a JavaScript fenced code block or inline expression:
+it replaces the previously-displayed content, if any. In addition, JSX fenced code blocks do not support implicit display; content can only be displayed explicitly.
+
+
+
+In the future we intend to support other JSX-compatible frameworks, such as Preact. We are also working on server-side rendering with client-side hydration; please upvote [#931](https://github.com/observablehq/framework/issues/931) if you are interested in this feature.
+
+
+
+## Inline expressions
+
+JSX is not currently supported in inline expression `${…}`; only JavaScript is allowed in inline expressions. However, you can declare a detached root using [`createRoot`](https://react.dev/reference/react-dom/client/createRoot):
+
+```js echo
+const node = document.createElement("SPAN");
+const root = ReactDOM.createRoot(node);
+```
+
+Then use a JSX code block to render the desired content into the root:
+
+```jsx echo
+root.render(<>Hello, {name || "anonymous"}!>);
+```
+
+Lastly, interpolate the root into the desired location with an inline expression:
+
+
+
Rendering into an inline expression
+ ${node}
+
+
+```md run=false
+
+
Rendering into an inline expression
+ ${node}
+
+```
diff --git a/observablehq.config.ts b/observablehq.config.ts
index 906cb9900..c9e611411 100644
--- a/observablehq.config.ts
+++ b/observablehq.config.ts
@@ -22,6 +22,7 @@ export default {
{name: "Markdown", path: "/markdown"},
{name: "JavaScript", path: "/javascript"},
{name: "Reactivity", path: "/reactivity"},
+ {name: "JSX", path: "/jsx"},
{name: "Imports", path: "/imports"},
{name: "Data loaders", path: "/loaders"},
{name: "Files", path: "/files"},
diff --git a/package.json b/package.json
index 3994ac21b..da900fea1 100644
--- a/package.json
+++ b/package.json
@@ -19,7 +19,7 @@
"observable": "dist/bin/observable.js"
},
"scripts": {
- "dev": "rimraf --glob docs/themes.md docs/theme/*.md && (tsx watch docs/theme/generate-themes.ts & tsx watch --no-warnings=ExperimentalWarning ./src/bin/observable.ts preview --no-open)",
+ "dev": "rimraf --glob docs/themes.md docs/theme/*.md && (tsx watch docs/theme/generate-themes.ts & tsx watch --ignore docs --no-warnings=ExperimentalWarning ./src/bin/observable.ts preview --no-open)",
"docs:themes": "rimraf --glob docs/themes.md docs/theme/*.md && tsx docs/theme/generate-themes.ts",
"docs:build": "yarn docs:themes && rimraf docs/.observablehq/dist && tsx --no-warnings=ExperimentalWarning ./src/bin/observable.ts build",
"docs:deploy": "yarn docs:themes && tsx --no-warnings=ExperimentalWarning ./src/bin/observable.ts deploy",
diff --git a/src/client/main.js b/src/client/main.js
index 60ab42de4..a75d47af5 100644
--- a/src/client/main.js
+++ b/src/client/main.js
@@ -23,7 +23,7 @@ const cellsById = new Map();
const rootsById = findRoots(document.body);
export function define(cell) {
- const {id, inline, inputs = [], outputs = [], body} = cell;
+ const {id, mode, inputs = [], outputs = [], body} = cell;
const variables = [];
cellsById.set(id, {cell, variables});
const root = rootsById.get(id);
@@ -35,7 +35,8 @@ export function define(cell) {
const v = main.variable({_node: root.parentNode, pending, rejected}, {shadow: {}}); // _node for visibility promise
if (inputs.includes("display") || inputs.includes("view")) {
let displayVersion = -1; // the variable._version of currently-displayed values
- const display = inline ? displayInline : displayBlock;
+ const predisplay = mode === "jsx" ? noop : clear; // jsx replaces previous display naturally
+ const display = mode === "inline" ? displayInline : mode === "jsx" ? displayJsx : displayBlock;
const vd = new v.constructor(2, v._module);
vd.define(
inputs.filter((i) => i !== "display" && i !== "view"),
@@ -43,7 +44,7 @@ export function define(cell) {
let version = v._version; // capture version on input change
return (value) => {
if (version < displayVersion) throw new Error("stale display");
- else if (version > displayVersion) clear(root);
+ else if (version > displayVersion) predisplay(root);
displayVersion = version;
display(root, value);
return value;
@@ -63,6 +64,13 @@ export function define(cell) {
for (const o of outputs) variables.push(main.variable(true).define(o, [`cell ${id}`], (exports) => exports[o]));
}
+function noop() {}
+
+function clear(root) {
+ for (const v of root._nodes) v.remove();
+ root._nodes.length = 0;
+}
+
// If the variable previously rejected, it will show an error even if it doesn’t
// normally display; we can’t rely on a subsequent display clearing the error,
// so we clear the error when the variable is pending. We also restore the
@@ -83,6 +91,19 @@ function reject(root, error) {
displayNode(root, inspectError(error));
}
+function displayJsx(root, value) {
+ return (root._root ??= import("npm:react-dom/client").then(({createRoot}) => {
+ const node = document.createElement("DIV");
+ return [node, createRoot(node)];
+ })).then(([node, client]) => {
+ if (!node.parentNode) {
+ root._nodes.push(node);
+ root.parentNode.insertBefore(node, root);
+ }
+ client.render(value);
+ });
+}
+
function displayNode(root, node) {
if (node.nodeType === 11) {
let child;
@@ -96,11 +117,6 @@ function displayNode(root, node) {
}
}
-function clear(root) {
- for (const v of root._nodes) v.remove();
- root._nodes.length = 0;
-}
-
function displayInline(root, value) {
if (isNode(value)) {
displayNode(root, value);
diff --git a/src/client/stdlib/recommendedLibraries.js b/src/client/stdlib/recommendedLibraries.js
index d700673c7..ebe5dea81 100644
--- a/src/client/stdlib/recommendedLibraries.js
+++ b/src/client/stdlib/recommendedLibraries.js
@@ -14,6 +14,8 @@ export const L = () => import("npm:leaflet");
export const mapboxgl = () => import("npm:mapbox-gl").then((module) => module.default);
export const mermaid = () => import("observablehq:stdlib/mermaid").then((mermaid) => mermaid.default);
export const Plot = () => import("npm:@observablehq/plot");
+export const React = () => import("npm:react");
+export const ReactDOM = () => import("npm:react-dom");
export const sql = () => import("observablehq:stdlib/duckdb").then((duckdb) => duckdb.sql);
export const SQLite = () => import("observablehq:stdlib/sqlite").then((sqlite) => sqlite.default);
export const SQLiteDatabaseClient = () => import("observablehq:stdlib/sqlite").then((sqlite) => sqlite.SQLiteDatabaseClient); // prettier-ignore
diff --git a/src/dataloader.ts b/src/dataloader.ts
index e0f3ce9b5..dc86d95a2 100644
--- a/src/dataloader.ts
+++ b/src/dataloader.ts
@@ -125,7 +125,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 (exactPath.endsWith(".js")) {
+ const jsxPath = exactPath + "x";
+ if (existsSync(jsxPath)) return jsxPath;
+ }
+ 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 a1af2417c..bf84f555e 100644
--- a/src/javascript/module.ts
+++ b/src/javascript/module.ts
@@ -1,7 +1,9 @@
import {createHash} from "node:crypto";
-import {accessSync, constants, readFileSync, statSync} from "node:fs";
+import {accessSync, constants, existsSync, readFileSync, statSync} from "node:fs";
+import {readFile} from "node:fs/promises";
import {join} from "node:path/posix";
import type {Program} from "acorn";
+import {transform, transformSync} from "esbuild";
import {resolvePath} from "../path.js";
import {findFiles} from "./files.js";
import {findImports} from "./imports.js";
@@ -73,7 +75,7 @@ export function getModuleInfo(root: string, path: string): ModuleInfo | undefine
const key = join(root, path);
let mtimeMs: number;
try {
- ({mtimeMs} = statSync(key));
+ ({mtimeMs} = statSync(resolveJsx(key) ?? key));
} catch {
moduleInfoCache.delete(key); // delete stale entry
return; // ignore missing file
@@ -83,7 +85,7 @@ export function getModuleInfo(root: string, path: string): ModuleInfo | undefine
let source: string;
let body: Program;
try {
- source = readFileSync(key, "utf-8");
+ source = readJavaScriptSync(key);
body = parseProgram(source);
} catch {
moduleInfoCache.delete(key); // delete stale entry
@@ -157,3 +159,37 @@ export function getFileInfo(root: string, path: string): FileInfo | undefined {
}
return entry;
}
+
+function resolveJsx(path: string): string | null {
+ return !existsSync(path) && path.endsWith(".js") && existsSync((path += "x")) ? path : null;
+}
+
+export async function readJavaScript(path: string): Promise {
+ const jsxPath = resolveJsx(path);
+ if (jsxPath !== null) {
+ const source = await readFile(jsxPath, "utf-8");
+ const {code} = await transform(source, {
+ loader: "jsx",
+ jsx: "automatic",
+ jsxImportSource: "npm:react",
+ sourcefile: jsxPath
+ });
+ return code;
+ }
+ return await readFile(path, "utf-8");
+}
+
+export function readJavaScriptSync(path: string): string {
+ const jsxPath = resolveJsx(path);
+ if (jsxPath !== null) {
+ const source = readFileSync(jsxPath, "utf-8");
+ const {code} = transformSync(source, {
+ loader: "jsx",
+ jsx: "automatic",
+ jsxImportSource: "npm:react",
+ sourcefile: jsxPath
+ });
+ return code;
+ }
+ return readFileSync(path, "utf-8");
+}
diff --git a/src/javascript/parse.ts b/src/javascript/parse.ts
index d93f2b8bd..5e16c68de 100644
--- a/src/javascript/parse.ts
+++ b/src/javascript/parse.ts
@@ -13,7 +13,7 @@ import {syntaxError} from "./syntaxError.js";
export interface ParseOptions {
/** The path to the source within the source root. */
path: string;
- /** If true, treat the input as an inline expression instead of a fenced code block. */
+ /** If true, require the input to be an expresssion. */
inline?: boolean;
}
@@ -30,7 +30,6 @@ export interface JavaScriptNode {
imports: ImportReference[];
expression: boolean; // is this an expression or a program cell?
async: boolean; // does this use top-level await?
- inline: boolean;
input: string;
}
@@ -57,7 +56,6 @@ export function parseJavaScript(input: string, options: ParseOptions): JavaScrip
imports: findImports(body, path, input),
expression: !!expression,
async: findAwaits(body).length > 0,
- inline,
input
};
}
diff --git a/src/javascript/transpile.ts b/src/javascript/transpile.ts
index d54a1af55..b264ca94f 100644
--- a/src/javascript/transpile.ts
+++ b/src/javascript/transpile.ts
@@ -17,10 +17,11 @@ import {getStringLiteralValue, isStringLiteral} from "./source.js";
export interface TranspileOptions {
id: string;
path: string;
+ mode?: string;
resolveImport?: (specifier: string) => string;
}
-export function transpileJavaScript(node: JavaScriptNode, {id, path, resolveImport}: TranspileOptions): string {
+export function transpileJavaScript(node: JavaScriptNode, {id, path, mode, resolveImport}: TranspileOptions): string {
let async = node.async;
const inputs = Array.from(new Set(node.references.map((r) => r.name)));
const outputs = Array.from(new Set(node.declarations?.map((r) => r.name)));
@@ -35,7 +36,7 @@ export function transpileJavaScript(node: JavaScriptNode, {id, path, resolveImpo
output.insertLeft(0, `, body: ${async ? "async " : ""}(${inputs}) => {\n`);
if (outputs.length) output.insertLeft(0, `, outputs: ${JSON.stringify(outputs)}`);
if (inputs.length) output.insertLeft(0, `, inputs: ${JSON.stringify(inputs)}`);
- if (node.inline) output.insertLeft(0, ", inline: true");
+ if (mode && mode !== "block") output.insertLeft(0, `, mode: ${JSON.stringify(mode)}`);
output.insertLeft(0, `define({id: ${JSON.stringify(id)}`);
if (outputs.length) output.insertRight(node.input.length, `\nreturn {${outputs}};`);
output.insertRight(node.input.length, "\n}});\n");
diff --git a/src/markdown.ts b/src/markdown.ts
index a4819ea38..ca182fccb 100644
--- a/src/markdown.ts
+++ b/src/markdown.ts
@@ -1,5 +1,6 @@
/* eslint-disable import/no-named-as-default-member */
import {createHash} from "node:crypto";
+import {transformSync} from "esbuild";
import he from "he";
import MarkdownIt from "markdown-it";
import type {Token} from "markdown-it";
@@ -25,6 +26,7 @@ import {red} from "./tty.js";
export interface MarkdownCode {
id: string;
node: JavaScriptNode;
+ mode: "inline" | "block" | "jsx";
}
export interface MarkdownPage {
@@ -57,9 +59,19 @@ function isFalse(attribute: string | undefined): boolean {
return attribute?.toLowerCase() === "false";
}
+function transformJsx(content: string): string {
+ try {
+ return transformSync(content, {loader: "jsx", jsx: "automatic", jsxImportSource: "npm:react"}).code;
+ } catch (error: any) {
+ throw new SyntaxError(error.message);
+ }
+}
+
function getLiveSource(content: string, tag: string, attributes: Record): string | undefined {
return tag === "js"
? content
+ : tag === "jsx"
+ ? transformJsx(content)
: tag === "tex"
? transpileTag(content, "tex.block", true)
: tag === "html"
@@ -107,7 +119,7 @@ function makeFenceRenderer(baseRenderer: RenderRule): RenderRule {
const id = uniqueCodeId(context, source);
// TODO const sourceLine = context.startLine + context.currentLine;
const node = parseJavaScript(source, {path});
- context.code.push({id, node});
+ context.code.push({id, node, mode: tag === "jsx" ? "jsx" : "block"});
html += `
${
node.expression ? "" : ""
}
\n`;
@@ -173,7 +185,7 @@ function makePlaceholderRenderer(): RenderRule {
try {
// TODO sourceLine: context.startLine + context.currentLine
const node = parseJavaScript(token.content, {path, inline: true});
- context.code.push({id, node});
+ context.code.push({id, node, mode: "inline"});
return ``;
} catch (error) {
if (!(error instanceof SyntaxError)) throw error;
diff --git a/src/preview.ts b/src/preview.ts
index 092454bed..336834fd0 100644
--- a/src/preview.ts
+++ b/src/preview.ts
@@ -21,6 +21,7 @@ import {HttpError, isEnoent, isHttpError, isSystemError} from "./error.js";
import {getClientPath} from "./files.js";
import type {FileWatchers} from "./fileWatchers.js";
import {isComment, isElement, isText, parseHtml, rewriteHtml} from "./html.js";
+import {readJavaScript} from "./javascript/module.js";
import {transpileJavaScript, transpileModule} from "./javascript/transpile.js";
import {parseMarkdown} from "./markdown.js";
import type {MarkdownCode, MarkdownPage} from "./markdown.js";
@@ -142,7 +143,7 @@ export class PreviewServer {
end(req, res, await bundleStyles({path: filepath}), "text/css");
return;
} else if (pathname.endsWith(".js")) {
- const input = await readFile(join(root, path), "utf-8");
+ const input = await readJavaScript(join(root, path));
const output = await transpileModule(input, {root, path});
end(req, res, output, "text/javascript");
return;
@@ -188,19 +189,14 @@ export class PreviewServer {
// If this path ends with a slash, then add an implicit /index to the
// end of the path. Otherwise, remove the .html extension (we use clean
// paths as the internal canonical representation; see normalizePage).
- let path = join(root, pathname);
- if (pathname.endsWith("/")) {
- pathname = join(pathname, "index");
- path = join(path, "index");
- } else {
- pathname = pathname.replace(/\.html$/, "");
- }
+ if (pathname.endsWith("/")) pathname = join(pathname, "index");
+ else pathname = pathname.replace(/\.html$/, "");
// Lastly, serve the corresponding Markdown file, if it exists.
// Anything else should 404; static files should be matched above.
try {
- const options = {path: pathname, ...config, preview: true};
- const source = await readFile(join(dirname(path), basename(path, ".html") + ".md"), "utf8");
+ const options = {...config, path: pathname, preview: true};
+ const source = await readFile(join(root, pathname + ".md"), "utf8");
const parse = parseMarkdown(source, options);
const html = await renderPage(parse, options);
end(req, res, html, "text/html");
@@ -218,7 +214,7 @@ export class PreviewServer {
}
if (req.method === "GET" && res.statusCode === 404) {
try {
- const options = {path: "/404", ...config, preview: true};
+ const options = {...config, path: "/404", preview: true};
const source = await readFile(join(root, "404.md"), "utf8");
const parse = parseMarkdown(source, options);
const html = await renderPage(parse, options);
@@ -459,10 +455,10 @@ function getCode({code}: MarkdownPage, resolvers: Resolvers): Map {
diff --git a/src/render.ts b/src/render.ts
index 8e9e0b129..6367cd811 100644
--- a/src/render.ts
+++ b/src/render.ts
@@ -73,7 +73,7 @@ import ${preview || page.code.length ? `{${preview ? "open, " : ""}define} from
: ""
}${data?.sql ? `\n${registerTables(data.sql, options)}` : ""}
${preview ? `\nopen({hash: ${JSON.stringify(resolvers.hash)}, eval: (body) => eval(body)});\n` : ""}${page.code
- .map(({node, id}) => `\n${transpileJavaScript(node, {id, path, resolveImport})}`)
+ .map(({node, id, mode}) => `\n${transpileJavaScript(node, {id, path, mode, resolveImport})}`)
.join("")}`)}
${sidebar ? html`\n${await renderSidebar(options, resolvers.resolveLink)}` : ""}${
toc.show ? html`\n${renderToc(findHeaders(page), toc.label)}` : ""
diff --git a/src/resolvers.ts b/src/resolvers.ts
index 9ca7abd7c..31dd09d50 100644
--- a/src/resolvers.ts
+++ b/src/resolvers.ts
@@ -170,6 +170,12 @@ export async function getResolvers(
globalImports.add(i);
}
+ // Add React for JSX blocks.
+ if (page.code.some((c) => c.mode === "jsx")) {
+ staticImports.add("npm:react-dom/client");
+ globalImports.add("npm:react-dom/client");
+ }
+
// Add transitive imports for built-in libraries.
for (const i of getImplicitDependencies(staticImports)) {
staticImports.add(i);
diff --git a/test/input/jsx.md b/test/input/jsx.md
new file mode 100644
index 000000000..f44c03183
--- /dev/null
+++ b/test/input/jsx.md
@@ -0,0 +1,9 @@
+```jsx
+function Greeting({subject}) {
+ return