diff --git a/package.json b/package.json index f05dbf05..3440acbb 100644 --- a/package.json +++ b/package.json @@ -7,19 +7,19 @@ }, "license": "ISC", "type": "module", - "main": "src/index.ts", - "module": "src/index.ts", + "main": "dist/esm/index.js", + "module": "dist/esm/index.js", "jsdelivr": "dist/replot.umd.min.js", "unpkg": "dist/replot.umd.min.js", "exports": { ".": { "types": "./dist/types/index.d.ts", "umd": "./dist/replot.umd.min.js", - "default": "./src/index.ts" + "default": "./dist/esm/index.js" }, "./react": { "types": "./dist/types/react/index.d.ts", - "default": "./src/react/index.tsx" + "default": "./dist/esm/react/index.js" }, "./package.json": "./package.json" }, @@ -46,11 +46,7 @@ }, "files": [ "dist/**/*.js", - "dist/types/**/*.d.ts", - "src/**/*.d.ts", - "src/**/*.js", - "src/**/*.ts", - "src/**/*.tsx" + "dist/types/**/*.d.ts" ], "scripts": { "test": "yarn test:mocha && yarn test:tsc && yarn test:lint && yarn test:prettier && yarn test:package", @@ -63,7 +59,8 @@ "test:prettier": "prettier --check src test", "test:tsc": "tsc", "build:types": "tsc -p tsconfig.build.json && node scripts/copy-declarations.mjs", - "bundle": "vite build --config vite.bundle.config.ts && MINIFY=1 vite build --config vite.bundle.config.ts && yarn build:types", + "build:esm": "tsc -p tsconfig.esm.json && node scripts/copy-sources.mjs", + "bundle": "vite build --config vite.bundle.config.ts && MINIFY=1 vite build --config vite.bundle.config.ts && yarn build:esm && yarn build:types", "prepublishOnly": "rm -rf dist && yarn bundle", "dev": "vite", "docs:dev": "tsx docs/scripts/ensure-bundle.ts && tsx docs/scripts/build-api-data.ts && vite --config docs/vite.docs.config.ts", @@ -71,8 +68,7 @@ "docs:preview": "vite preview --config docs/vite.docs.config.ts" }, "sideEffects": [ - "./src/index.js", - "./src/index.ts" + "./dist/esm/index.js" ], "devDependencies": { "@arethetypeswrong/cli": "^0.18.2", diff --git a/scripts/copy-sources.mjs b/scripts/copy-sources.mjs new file mode 100644 index 00000000..57d812d6 --- /dev/null +++ b/scripts/copy-sources.mjs @@ -0,0 +1,45 @@ +// Copies the plain-JavaScript sources that `tsc -p tsconfig.esm.json` declines +// to emit (see issue #140). +// +// Most of the core is plain .js with a sibling hand-written .d.ts +// (src/channel.js + src/channel.d.ts, …). TypeScript treats the .d.ts as the +// authoritative module for that specifier and skips compiling the .js, so +// without this step dist/esm is full of dangling imports. The files are +// already ESM, so copying is the whole job. +// +// The companion for declarations is scripts/copy-declarations.mjs. +import {mkdir, copyFile, readdir, access} from "node:fs/promises"; +import {dirname, join, relative} from "node:path"; +import {fileURLToPath} from "node:url"; + +const root = join(dirname(fileURLToPath(import.meta.url)), ".."); +const source = join(root, "src"); +const target = join(root, "dist", "esm"); + +async function* sources(dir) { + for (const entry of await readdir(dir, {withFileTypes: true})) { + const path = join(dir, entry.name); + if (entry.isDirectory()) yield* sources(path); + else if (entry.name.endsWith(".js")) yield path; + } +} + +async function exists(path) { + try { + await access(path); + return true; + } catch { + return false; + } +} + +let copied = 0; +for await (const path of sources(source)) { + const destination = join(target, relative(source, path)); + if (await exists(destination)) continue; // tsc emitted it; leave that alone + await mkdir(dirname(destination), {recursive: true}); + await copyFile(path, destination); + copied++; +} + +console.log(`copy-sources: copied ${copied} JavaScript source${copied === 1 ? "" : "s"} into dist/esm`); diff --git a/src/index.d.ts b/src/index.d.ts index 00f3c967..3710a9e3 100644 --- a/src/index.d.ts +++ b/src/index.d.ts @@ -41,6 +41,7 @@ export * from "./marks/vector.js"; export * from "./marks/waffle.js"; export {valueof, column, identity, indexOf} from "./options.js"; export * from "./plot.js"; +export * from "./plotDom.js"; export * from "./projection.js"; export * from "./reducer.js"; export * from "./scales.js"; diff --git a/src/index.ts b/src/index.ts index ee138c0e..e14feabe 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,12 +1,12 @@ import {Mark} from "./mark.js"; -import {plot} from "./plot.js"; +import {plot} from "./plotDom.js"; // Note: this side effect avoids a circular dependency. Mark.prototype.plot = function ({marks = [], ...options} = {}) { return plot({...options, marks: [...marks, this]}); }; -export {plot} from "./plot.js"; +export {plot} from "./plotDom.js"; export {Mark, marks} from "./mark.js"; export {Area, area, areaX, areaY} from "./marks/area.js"; export {Arrow, arrow} from "./marks/arrow.js"; diff --git a/src/plot.ts b/src/plot.ts index 2999f6bf..8e007929 100644 --- a/src/plot.ts +++ b/src/plot.ts @@ -4,7 +4,6 @@ import {createContext} from "./context.js"; import {createDimensions} from "./dimensions.js"; import {createFacets, recreateFacets, facetExclude, facetGroups, facetTranslator, facetFilter} from "./facet.js"; import {pointer, pointerX, pointerY} from "./interactions/pointer.js"; -import {buildAutoLegends, renderLegendElement} from "./react/legends/Legend.js"; import {Mark} from "./mark.js"; import {axisFx, axisFy, axisX, axisY, gridFx, gridFy, gridX, gridY} from "./marks/axis.js"; import {frame} from "./marks/frame.js"; @@ -12,14 +11,12 @@ import {tip} from "./marks/tip.js"; import {isColor, isIterable, isNone, isScaleOptions} from "./options.js"; import {dataify, lengthof, map, yes, maybeIntervalTransform} from "./options.js"; import {createProjection, getGeometryChannels, hasProjection, xyProjection} from "./projection.js"; -import {createScales, createScaleFunctions, autoScaleRange, exposeScales} from "./scales.js"; +import {createScales, createScaleFunctions, autoScaleRange} from "./scales.js"; import {innerDimensions, outerDimensions} from "./scales.js"; import {isPosition, registry as scaleRegistry} from "./scales/index.js"; import {maybeClassName} from "./style.js"; import {initializer} from "./transforms/basic.js"; -import {consumeWarnings, warn} from "./warnings.js"; -import {renderToStaticMarkup} from "react-dom/server"; -import {buildStaticPlotSvg} from "./react/renderStatic.js"; +import {warn} from "./warnings.js"; // Returns the pre-render state needed by both the imperative DOM build path // (used by `plot()` below) and the React JSX path (used by `` in @@ -276,86 +273,6 @@ export function computePlot(options: any = {}): any { }; } -export function plot(options: any = {}) { - const computed: any = computePlot(options); - const {className, scales, scaleDescriptors, context} = computed; - const {style, title, subtitle, caption} = options; - const document = context.document; - const figureHolder: {current: any} = context.figureHolder; - - // Drain warnings emitted during computePlot so the ⚠️ indicator renders and - // the warn() dedupe state is reset (matching the React path). - const warnings = consumeWarnings(); - - // Render the marks to a detached via React's renderJSX — no - // d3-selection. The same renderMarksWith/renderJSX code powers , so - // the imperative and JSX outputs stay in lockstep. We serialize to markup - // and reparse into the target document (which may be a custom jsdom doc). - const markup = renderToStaticMarkup(buildStaticPlotSvg(computed, warnings, options.className)); - const holder = document.createElement("div"); - holder.innerHTML = markup; - const svg: any = holder.firstElementChild; - - // Apply the plot-level style option (string or object), mirroring - // applyInlineStyles on the former imperative path. - if (typeof style === "string") svg.setAttribute("style", style); - else if (style != null) Object.assign(svg.style, style); - - figureHolder.current = svg; - - // Wrap the plot in a figure, if needed. Auto-legends render via the React - // legend components (no d3-selection); serialize each to a DOM node in the - // target document, matching the former createLegends output. - const legends = buildAutoLegends(scaleDescriptors, context, options).map((el) => { - const h = document.createElement("div"); - h.innerHTML = renderToStaticMarkup(el); - return h.firstElementChild; - }); - const {figure: figured = title != null || subtitle != null || caption != null || legends.length > 0} = options; - if (figured) { - const fig: any = document.createElement("figure"); - fig.className = `${className}-figure`; - fig.style.maxWidth = "initial"; // avoid Observable default style - if (title != null) fig.append(createTitleElement(document, title, "h2")); - if (subtitle != null) fig.append(createTitleElement(document, subtitle, "h3")); - fig.append(...legends, svg); - if (caption != null) fig.append(createFigcaption(document, caption)); - if ("value" in svg) (fig.value = svg.value), delete svg.value; - figureHolder.current = fig; - } - - figureHolder.current.scale = exposeScales(scales.scales); - // The .legend(key, options) method renders via the React legend components - // (no d3-selection); serialize to a DOM node in the target document. - figureHolder.current.legend = (key: string, legendOptions: any = {}) => { - if (key !== "color" && key !== "opacity" && key !== "symbol") throw new Error(`unknown legend type: ${key}`); - if (!(key in scaleDescriptors)) return; - const el = renderLegendElement(key, legendOptions, scaleDescriptors, context, options); - if (el == null) return; - // Render into the per-call document option if given (e.g. a separate jsdom - // window), else the plot's document. - const targetDoc = legendOptions?.document ?? document; - const h = targetDoc.createElement("div"); - h.innerHTML = renderToStaticMarkup(el); - return h.firstElementChild; - }; - - return figureHolder.current; -} - -function createTitleElement(document, contents, tag) { - if (contents.ownerDocument) return contents; - const e = document.createElement(tag); - e.append(contents); - return e; -} - -function createFigcaption(document, caption) { - const e = document.createElement("figcaption"); - e.append(caption); - return e; -} - function flatMarks(marks) { return marks .flat(Infinity) diff --git a/src/plotDom.ts b/src/plotDom.ts new file mode 100644 index 00000000..a1fe2051 --- /dev/null +++ b/src/plotDom.ts @@ -0,0 +1,96 @@ +import {buildAutoLegends, renderLegendElement} from "./react/legends/Legend.js"; +import {exposeScales} from "./scales.js"; +import {consumeWarnings} from "./warnings.js"; +import {computePlot} from "./plot.js"; +import {renderToStaticMarkup} from "react-dom/server"; +import {buildStaticPlotSvg} from "./react/renderStatic.js"; + +// The imperative entry point: plot() builds real DOM, so it is the only part +// of the core that needs a renderer capable of serializing React elements. +// +// It lives apart from computePlot (src/plot.ts) deliberately. imports +// computePlot, and react-dom declares no `sideEffects: false`, so a bundler +// keeps the react-dom/server import of any module it pulls in — even when the +// binding is unused. Sharing a module with computePlot therefore put the whole +// server renderer in the client bundle of every app that renders a plot +// (issue #140). + +export function plot(options: any = {}) { + const computed: any = computePlot(options); + const {className, scales, scaleDescriptors, context} = computed; + const {style, title, subtitle, caption} = options; + const document = context.document; + const figureHolder: {current: any} = context.figureHolder; + + // Drain warnings emitted during computePlot so the ⚠️ indicator renders and + // the warn() dedupe state is reset (matching the React path). + const warnings = consumeWarnings(); + + // Render the marks to a detached via React's renderJSX — no + // d3-selection. The same renderMarksWith/renderJSX code powers , so + // the imperative and JSX outputs stay in lockstep. We serialize to markup + // and reparse into the target document (which may be a custom jsdom doc). + const markup = renderToStaticMarkup(buildStaticPlotSvg(computed, warnings, options.className)); + const holder = document.createElement("div"); + holder.innerHTML = markup; + const svg: any = holder.firstElementChild; + + // Apply the plot-level style option (string or object), mirroring + // applyInlineStyles on the former imperative path. + if (typeof style === "string") svg.setAttribute("style", style); + else if (style != null) Object.assign(svg.style, style); + + figureHolder.current = svg; + + // Wrap the plot in a figure, if needed. Auto-legends render via the React + // legend components (no d3-selection); serialize each to a DOM node in the + // target document, matching the former createLegends output. + const legends = buildAutoLegends(scaleDescriptors, context, options).map((el) => { + const h = document.createElement("div"); + h.innerHTML = renderToStaticMarkup(el); + return h.firstElementChild; + }); + const {figure: figured = title != null || subtitle != null || caption != null || legends.length > 0} = options; + if (figured) { + const fig: any = document.createElement("figure"); + fig.className = `${className}-figure`; + fig.style.maxWidth = "initial"; // avoid Observable default style + if (title != null) fig.append(createTitleElement(document, title, "h2")); + if (subtitle != null) fig.append(createTitleElement(document, subtitle, "h3")); + fig.append(...legends, svg); + if (caption != null) fig.append(createFigcaption(document, caption)); + if ("value" in svg) (fig.value = svg.value), delete svg.value; + figureHolder.current = fig; + } + + figureHolder.current.scale = exposeScales(scales.scales); + // The .legend(key, options) method renders via the React legend components + // (no d3-selection); serialize to a DOM node in the target document. + figureHolder.current.legend = (key: string, legendOptions: any = {}) => { + if (key !== "color" && key !== "opacity" && key !== "symbol") throw new Error(`unknown legend type: ${key}`); + if (!(key in scaleDescriptors)) return; + const el = renderLegendElement(key, legendOptions, scaleDescriptors, context, options); + if (el == null) return; + // Render into the per-call document option if given (e.g. a separate jsdom + // window), else the plot's document. + const targetDoc = legendOptions?.document ?? document; + const h = targetDoc.createElement("div"); + h.innerHTML = renderToStaticMarkup(el); + return h.firstElementChild; + }; + + return figureHolder.current; +} + +function createTitleElement(document, contents, tag) { + if (contents.ownerDocument) return contents; + const e = document.createElement(tag); + e.append(contents); + return e; +} + +function createFigcaption(document, caption) { + const e = document.createElement("figcaption"); + e.append(caption); + return e; +} diff --git a/src/react/renderTransform.ts b/src/react/renderTransform.ts index 3857a724..6703313f 100644 --- a/src/react/renderTransform.ts +++ b/src/react/renderTransform.ts @@ -1,5 +1,4 @@ -import {createElement as h, Fragment, type ReactNode} from "react"; -import {renderToStaticMarkup} from "react-dom/server"; +import {type ReactNode} from "react"; import {domToJsx, isDomNode} from "./domToJsx.js"; // Bridges the imperative `render` option (a render transform: DOM in, DOM @@ -39,18 +38,82 @@ export function renderTransformJSX( return isDomNode(out) ? domToJsx(out) : (out as ReactNode); } -// Serializes a JSX tree and reparses it in an context so the resulting -// nodes carry the SVG namespace, mirroring how renderStatic.tsx serializes -// the whole plot for the imperative entry point. +// Builds DOM nodes directly from a JSX tree, in the SVG namespace. This is the +// inverse of domToJsx, and deliberately does not go through +// renderToStaticMarkup: importing react-dom/server here would put the whole +// server renderer in the client bundle of every app that renders a plot, for +// the sake of a path only reached when a mark carries an imperative `render` +// transform (see issue #140). +// +// Only the shapes mark.renderJSX produces are supported — intrinsic elements, +// fragments, arrays and text. A component element would need a renderer; +// buildElement throws rather than emitting something subtly wrong. +const SVG_NAMESPACE = "http://www.w3.org/2000/svg"; + function jsxToDom(jsx: ReactNode, document: Document): Node | null { - const holder = document.createElementNS("http://www.w3.org/2000/svg", "svg"); - holder.innerHTML = renderToStaticMarkup(h(Fragment, null, jsx)); - if (holder.childNodes.length === 1) { - const node = holder.firstChild!; - holder.removeChild(node); - return node; - } + const nodes: Node[] = []; + appendJsx(nodes, jsx, document); + if (nodes.length === 1) return nodes[0]!; const fragment = document.createDocumentFragment(); - while (holder.firstChild) fragment.appendChild(holder.firstChild); + for (const node of nodes) fragment.appendChild(node); return fragment; } + +function appendJsx(nodes: Node[], jsx: ReactNode, document: Document): void { + if (jsx == null || typeof jsx === "boolean") return; + if (typeof jsx === "string" || typeof jsx === "number") { + nodes.push(document.createTextNode(String(jsx))); + return; + } + if (Array.isArray(jsx)) { + for (const child of jsx) appendJsx(nodes, child as ReactNode, document); + return; + } + const element = jsx as {type?: unknown; props?: Record}; + const {type, props = {}} = element; + // A fragment contributes its children and nothing of its own. + if (typeof type !== "string") { + if (type != null && typeof type !== "function") { + appendJsx(nodes, props.children as ReactNode, document); + return; + } + throw new Error("render transforms cannot serialize component elements"); + } + nodes.push(buildElement(type, props, document)); +} + +function buildElement(type: string, props: Record, document: Document): Element { + const node = document.createElementNS(SVG_NAMESPACE, type); + for (const [name, value] of Object.entries(props)) { + if (name === "children" || name === "key" || name === "ref") continue; + if (value == null || value === false || typeof value === "function") continue; + if (name === "dangerouslySetInnerHTML") { + node.innerHTML = String((value as {__html?: unknown}).__html ?? ""); + continue; + } + node.setAttribute(attributeName(name), name === "style" ? styleText(value) : String(value)); + } + const children: Node[] = []; + appendJsx(children, props.children as ReactNode, document); + for (const child of children) node.appendChild(child); + return node; +} + +// The inverse of domToJsx's reactAttributeName: React's camelCase props map +// back to the hyphenated attributes SVG expects. aria-*/data-* and already +// hyphenated names pass through, as do namespaced ones (xlink:href). +function attributeName(name: string): string { + if (name === "className") return "class"; + if (name === "htmlFor") return "for"; + if (name.startsWith("aria-") || name.startsWith("data-") || name.includes("-") || name.includes(":")) return name; + return name.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`); +} + +// React accepts the style prop as an object; the DOM wants a string. +function styleText(value: unknown): string { + if (typeof value === "string") return value; + return Object.entries(value as Record) + .filter(([, v]) => v != null && v !== "") + .map(([property, v]) => `${property.startsWith("--") ? property : attributeName(property)}: ${v}`) + .join("; "); +} diff --git a/test/react-render-transform-test.tsx b/test/react-render-transform-test.tsx new file mode 100644 index 00000000..2577adf4 --- /dev/null +++ b/test/react-render-transform-test.tsx @@ -0,0 +1,100 @@ +// @ts-nocheck — JSDOM React tests for the imperative `render` option on the +// JSX path. The interesting part is the DOM the transform's `next` hands back: +// it is built by jsxToDom in src/react/renderTransform.ts, which serializes +// React elements to SVG-namespaced nodes without going through +// renderToStaticMarkup (see issue #140). +import assert from "assert"; +import React from "react"; +import jsdomit from "./jsdom.js"; +import ReactDOM from "react-dom/client"; +import {act} from "react"; +import {Replot, Dot} from "../src/react/index.js"; + +const data = [ + {x: 1, y: 2}, + {x: 2, y: 3}, + {x: 3, y: 1} +]; + +async function mount(node) { + const container = globalThis.document.createElement("div"); + globalThis.document.body.appendChild(container); + let root; + await act(async () => { + root = ReactDOM.createRoot(container); + root.render(node); + }); + await act(async () => {}); + return { + container, + cleanup: async () => { + await act(async () => root.unmount()); + container.remove(); + } + }; +} + +// Renders a plot whose dot mark carries a render transform, and hands the +// transform's `next()` output to the assertions. +async function withNextOutput(assertions, markProps = {}) { + let produced = null; + const render = (index, scales, values, dimensions, context, next) => { + produced = next(index, scales, values, dimensions, context); + return produced; + }; + const {container, cleanup} = await mount( + + + + ); + assert.ok(produced, "expected the render transform to receive DOM from next()"); + try { + assertions(produced, container); + } finally { + await cleanup(); + } +} + +jsdomit("next() returns SVG-namespaced element nodes", async () => { + await withNextOutput((produced) => { + const element = produced.nodeType === 11 ? produced.firstChild : produced; + assert.strictEqual(element.namespaceURI, "http://www.w3.org/2000/svg"); + assert.ok(element.querySelector("circle") ?? element.tagName === "circle", "expected circles in the output"); + }); +}); + +jsdomit("camelCase React props are serialized as hyphenated SVG attributes", async () => { + await withNextOutput( + (produced) => { + const holder = globalThis.document.createElementNS("http://www.w3.org/2000/svg", "svg"); + holder.appendChild(produced.cloneNode(true)); + const painted = holder.querySelector("[stroke-width]") ?? holder.querySelector("g"); + assert.ok(painted, "expected a rendered element"); + // The React prop is strokeWidth; the DOM attribute must be stroke-width, + // and must not appear in its camelCase spelling. + assert.strictEqual(holder.innerHTML.includes("strokeWidth"), false, "camelCase leaked into the DOM"); + }, + {strokeWidth: 3, stroke: "red"} + ); +}); + +jsdomit("className becomes class, and text children survive", async () => { + await withNextOutput( + (produced) => { + const holder = globalThis.document.createElementNS("http://www.w3.org/2000/svg", "svg"); + holder.appendChild(produced.cloneNode(true)); + assert.strictEqual(holder.innerHTML.includes("className"), false, "className leaked into the DOM"); + const titled = holder.querySelector("title"); + if (titled) assert.ok(titled.textContent.length > 0, "expected title text to survive"); + }, + {title: (d) => `point ${d.x}`} + ); +}); + +jsdomit("the rendered plot still contains the transform's output", async () => { + await withNextOutput((produced, container) => { + const svg = container.querySelector("svg"); + assert.ok(svg, "expected the plot to render"); + assert.ok(svg.querySelectorAll("circle").length >= data.length, "expected the dots to reach the document"); + }); +}); diff --git a/tsconfig.esm.json b/tsconfig.esm.json new file mode 100644 index 00000000..cd590391 --- /dev/null +++ b/tsconfig.esm.json @@ -0,0 +1,17 @@ +{ + // JavaScript build. Consumers resolve the runtime conditions in package.json + // to dist/esm rather than to src, so the package ships transpiled ESM and no + // bundler is asked to process a dependency's raw .ts/.tsx (see issue #140). + // + // tsc rather than a bundler: the sources already import each other with .js + // specifiers, so a structure-preserving emit resolves correctly as-is, and + // every dependency stays external without an allowlist to maintain. + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": false, + "declaration": false, + "outDir": "dist/esm", + "rootDir": "src" + }, + "include": ["src/**/*.js", "src/**/*.ts", "src/**/*.tsx"] +} diff --git a/tsconfig.json b/tsconfig.json index db0927fa..c24ee8fc 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -8,8 +8,8 @@ "baseUrl": "./src", "jsx": "react-jsx", "paths": { - "@dave-hillier/replot": ["index"], - "@dave-hillier/replot/react": ["react/index"], + "@dave-hillier/replot": ["./index.ts"], + "@dave-hillier/replot/react": ["./react/index.tsx"], "react": ["../node_modules/@types/react"], "react/jsx-runtime": ["../node_modules/@types/react/jsx-runtime"], "react-dom": ["../node_modules/@types/react-dom"],