diff --git a/.github/actions/setup/action.yml b/.github/actions/setup/action.yml new file mode 100644 index 000000000..31a55217b --- /dev/null +++ b/.github/actions/setup/action.yml @@ -0,0 +1,21 @@ +name: Setup +description: Node 24 + pnpm, root install, and optional extension installs + +inputs: + extensions: + description: Space-separated extension dirs under extensions/ to also install + default: '' + +runs: + using: composite + steps: + - uses: actions/setup-node@v6 + with: + node-version: 24 + - shell: bash + env: + EXTENSIONS: ${{ inputs.extensions }} + run: | + corepack enable + pnpm install + for ext in $EXTENSIONS; do pnpm -C "extensions/$ext" install; done diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 31f25aa3c..6b7bd07af 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -10,36 +10,19 @@ jobs: prek: runs-on: ubuntu-latest steps: - - name: Check out repo - uses: actions/checkout@v6 - - - uses: actions/setup-node@v6 + - uses: actions/checkout@v6 + # install extension deps too: vscode is type-aware linted by root oxlint (needs + # e.g. @types/vscode), anywidget is checked by root svelte-check (needs its + # matterviz/* subpath imports to resolve, else TS2882) + - uses: ./.github/actions/setup with: - node-version: 24 - - - run: corepack enable && pnpm install - - # vscode extension is type-aware linted by root oxlint; install its deps - # (e.g. @types/vscode) so module resolution succeeds in CI. - - run: pnpm -C extensions/vscode install - - # anywidget extension is checked by root svelte-check; install its deps so - # its matterviz/* subpath imports resolve in CI (otherwise TS2882). - - run: pnpm -C extensions/anywidget install - + extensions: vscode anywidget - uses: j178/prek-action@v2 knip: runs-on: ubuntu-latest steps: - - name: Check out repo - uses: actions/checkout@v6 - - - uses: actions/setup-node@v6 - with: - node-version: 24 - - - run: corepack enable && pnpm install - + - uses: actions/checkout@v6 + - uses: ./.github/actions/setup - name: Check for unused dependencies run: pnpm run knip diff --git a/.github/workflows/publish-anywidget.yml b/.github/workflows/publish-anywidget.yml deleted file mode 100644 index cc1f7c745..000000000 --- a/.github/workflows/publish-anywidget.yml +++ /dev/null @@ -1,83 +0,0 @@ -name: Publish matterviz-anywidget to npm - -# Builds the prebuilt anywidget bundle (extensions/anywidget) and publishes it to -# npm as `matterviz-anywidget`. The bundle is then served with CORS + a JS MIME -# type via jsDelivr/unpkg. `build/` stays gitignored -- nothing enters git. - -on: - release: - types: [published] - workflow_dispatch: - inputs: - version: - description: Version to publish (e.g. 0.3.7) - required: true - type: string - -permissions: - contents: read - id-token: write # required for `npm publish --provenance` - -jobs: - publish: - runs-on: ubuntu-latest - steps: - - name: Checkout code - uses: actions/checkout@v6 - with: - persist-credentials: false # publish to npm only; no git writes need the token - - - name: Setup Node + pnpm - uses: actions/setup-node@v6 - with: - node-version: 24 - registry-url: https://registry.npmjs.org - - - name: 'Build MatterViz component library (resolves the file: dependency)' - run: corepack enable && pnpm install && pnpm package:dist - - - name: Resolve version - id: version - # pass workflow inputs via env (not direct ${{ }} interpolation) to avoid - # shell injection, then validate before using downstream - env: - INPUT_VERSION: ${{ github.event.inputs.version }} - run: | - if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then - version="$INPUT_VERSION" - else - version="${GITHUB_REF#refs/tags/}" - fi - version="${version#v}" # strip optional leading v from tags or manual input alike - [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z.-]+)?$ ]] || { - echo "Invalid version: $version" - exit 1 - } - echo "version=$version" >> "$GITHUB_OUTPUT" - shell: bash - - - name: Build + publish extension - working-directory: extensions/anywidget - run: | - pnpm install - npm version "$PUBLISH_VERSION" --no-git-tag-version --allow-same-version - pnpm run build - # sanity-gate the build before publishing (catches empty/broken output; - # runtime render coverage lives in pymatviz's test suite) - test -s build/matterviz.js && test -s build/matterviz.css - js_bytes=$(wc -c < build/matterviz.js) - # lower bound: a healthy bundle (all viz components) is well over 1 MB; - # anything smaller means a broken/partial build - test "$js_bytes" -gt 1000000 || { echo "bundle too small ($js_bytes B)"; exit 1; } - # upper bound guards the WASM exclusions (see vite.config.ts): if h5wasm - # or moyo WASM creep back in, the bundle balloons -- fail loudly instead - # of shipping it (~3.4 MB normally; either WASM back pushes it over 5 MB) - test "$js_bytes" -lt 5000000 || { echo "bundle too large ($js_bytes B) -- did h5wasm/moyo WASM get re-bundled?"; exit 1; } - # npm (not pnpm) for publish: only `npm publish --provenance` emits npm - # provenance attestations -- pnpm exposes no equivalent direct flag - # build already ran + was size-gated above; --ignore-scripts skips the - # redundant prepublishOnly rebuild (kept for safety on manual publishes) - npm publish --ignore-scripts --provenance --access public - env: - PUBLISH_VERSION: ${{ steps.version.outputs.version }} - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 000000000..12d8f606c --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,230 @@ +name: Publish + +# On a new vX.Y.Z tag: gate on lint + unit tests, then publish in parallel the +# `matterviz` npm package, the `matterviz-anywidget` npm bundle, and the VS Code extension +# to Open VSX (the VS Code Marketplace is uploaded manually -- see below). workflow_dispatch +# (re)publishes a given version by building that version's tag -- use it to publish an +# already-pushed tag that never reached a registry (run from the default branch so this +# workflow file is current). +# +# npm publishes via OIDC trusted publishing (no token) -- configure a Trusted Publisher for +# both `matterviz` and `matterviz-anywidget` on npmjs.com pointing at this repo + publish.yml. +# The VS Code Marketplace is NOT auto-published (the publisher's Azure org can't issue a +# publish PAT/identity): the vscode job uploads a `matterviz-vsix` artifact to upload by hand +# at https://marketplace.visualstudio.com/manage. +# Required repo secrets: +# OVSX_PAT - Open VSX token (namespace once: `npx ovsx create-namespace janosh -p `) + +on: + push: + tags: ['v*.*.*'] + workflow_dispatch: + inputs: + version: + description: Version to (re)publish, e.g. 0.4.0 (its tag must already be pushed unless dry_run) + required: true + type: string + dry_run: + description: Dry run -- build, package & validate everything, but do not publish + type: boolean + default: true + +permissions: + contents: read + +# one publish per version, but never cancel an in-flight one (a half-published release is +# worse). Normalize to `v` so a tag push (ref_name `v0.4.0`) and a manual run +# (input `0.4.0`) for the same release share one group. +concurrency: + group: publish-${{ github.event.inputs.version && format('v{0}', github.event.inputs.version) || github.ref_name }} + cancel-in-progress: false + +jobs: + version: + runs-on: ubuntu-latest + outputs: + version: ${{ steps.resolve.outputs.version }} + ref: ${{ steps.resolve.outputs.ref }} # exact commit every other job checks out + steps: + - id: resolve + # input via env (not ${{ }}) to avoid shell injection; validate before use + env: + INPUT_VERSION: ${{ github.event.inputs.version }} + DRY_RUN: ${{ inputs.dry_run }} + shell: bash + run: | + if [ "${{ github.event_name }}" = workflow_dispatch ]; then + version="$INPUT_VERSION" + else + version="${GITHUB_REF#refs/tags/}" + fi + version="${version#v}" # strip optional leading v from tags / manual input alike + # plain X.Y.Z only: a prerelease would land on the npm 'latest' tag (no --tag passed) + [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || { + echo "Invalid version: $version (expected X.Y.Z)"; exit 1; } + # Build & publish the exact tagged commit (vX.Y.Z), so a manual run (re)publishes an + # already-pushed tag rather than whatever the workflow ran from. Fall back to the + # triggering ref for a dry run of a not-yet-tagged version. + tag="refs/tags/v$version" + if git ls-remote --exit-code "https://github.com/$GITHUB_REPOSITORY" "$tag" >/dev/null 2>&1; then + ref="$tag" + elif [ "$DRY_RUN" = true ]; then + ref="$GITHUB_REF" + echo "::notice::tag $tag not found -- dry-run building $GITHUB_REF" + else + echo "::error::tag $tag does not exist -- push it first, or run with dry_run=true"; exit 1 + fi + echo "version=$version" >> "$GITHUB_OUTPUT" + echo "ref=$ref" >> "$GITHUB_OUTPUT" + + # === Gate: publish only if lint + unit tests pass for the published commit === + lint: + needs: version + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + with: + ref: ${{ needs.version.outputs.ref }} + # ext deps so type-aware lint / svelte-check resolve their imports in CI + - uses: ./.github/actions/setup + with: + extensions: vscode anywidget + - uses: j178/prek-action@v2 + + test: + needs: version + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + with: + ref: ${{ needs.version.outputs.ref }} + - uses: ./.github/actions/setup + - name: Unit tests + run: pnpm exec vp test --run + + # === Gate: no package uploads unless the release version matches its tag === + # A single job all publishers depend on, so a version/tag mismatch blocks every upload + # (not just the one whose own check failed). anywidget injects its version, so only the + # bumped manifests are checked here; gating anywidget on this still blocks it on a mismatch. + verify: + needs: version + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + with: + ref: ${{ needs.version.outputs.ref }} + # skip on dry runs: a not-yet-tagged dry run builds a ref whose version differs + - name: Verify package versions match the tag + if: ${{ !inputs.dry_run }} + env: + EXPECTED: ${{ needs.version.outputs.version }} + run: | + for pkg in package.json extensions/vscode/package.json; do + actual=$(node -p "require('./$pkg').version") + [ "$actual" = "$EXPECTED" ] || { echo "$pkg ($actual) != release ($EXPECTED) -- bump it in the release commit"; exit 1; } + done + + # === Publish: matterviz component library to npm === + npm: + needs: [version, verify, lint, test] + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write # npm OIDC trusted publishing + provenance + steps: + - uses: actions/checkout@v6 + with: + ref: ${{ needs.version.outputs.ref }} + persist-credentials: false + - uses: ./.github/actions/setup + - name: Build component library + run: pnpm package:dist + # OIDC trusted publishing (no token): npm >= 11.5.1 + id-token:write authenticate via + # the npmjs Trusted Publisher configured for this repo/workflow; provenance is automatic + - name: Publish to npm + env: + DRY_RUN: ${{ inputs.dry_run }} + run: | + npm install -g npm@latest # ensure OIDC-capable npm (>= 11.5.1) + if [ "$DRY_RUN" = true ]; then + npm publish --ignore-scripts --access public --dry-run + else + npm publish --ignore-scripts --access public + fi + + # === Publish: VS Code extension to Open VSX (Marketplace upload is manual) === + vscode: + needs: [version, verify, lint, test] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + with: + ref: ${{ needs.version.outputs.ref }} + persist-credentials: false + # root install too: the webview build bundles ../../src/lib (three/d3/etc.) + - uses: ./.github/actions/setup + with: + extensions: vscode + - name: Build extension + run: pnpm -C extensions/vscode run build + - name: Package VSIX + working-directory: extensions/vscode + run: npx @vscode/vsce package --no-dependencies -o matterviz.vsix + # the Marketplace needs a publish PAT/Azure identity the publisher's org can't issue, + # so upload the .vsix for manual upload at https://marketplace.visualstudio.com/manage + - name: Upload VSIX artifact + uses: actions/upload-artifact@v4 + with: + name: matterviz-vsix + path: extensions/vscode/matterviz.vsix + # ovsx has no dry-run; the package step above exercises the build, so a dry run skips this + - name: Publish to Open VSX + if: ${{ !inputs.dry_run }} + working-directory: extensions/vscode + run: npx ovsx publish matterviz.vsix --pat "$OVSX_PAT" + env: + OVSX_PAT: ${{ secrets.OVSX_PAT }} + + # === Publish: prebuilt matterviz-anywidget bundle to npm === + anywidget: + needs: [version, verify, lint, test] + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write # npm OIDC trusted publishing + provenance + steps: + - uses: actions/checkout@v6 + with: + ref: ${{ needs.version.outputs.ref }} + persist-credentials: false + - uses: ./.github/actions/setup + with: + extensions: anywidget + - name: 'Build component library (resolves the file: dependency)' + run: pnpm package:dist + - name: Build + size-gate bundle + working-directory: extensions/anywidget + env: + PUBLISH_VERSION: ${{ needs.version.outputs.version }} + run: | + # version isn't bumped by the release commit, so set it here + npm version "$PUBLISH_VERSION" --no-git-tag-version --allow-same-version + pnpm run build + # sanity-gate before publishing (runtime coverage lives in pymatviz's tests) + test -s build/matterviz.js && test -s build/matterviz.css + js_bytes=$(wc -c < build/matterviz.js) + # >1MB: all components present; <5MB: h5wasm/moyo WASM didn't creep back in + test "$js_bytes" -gt 1000000 || { echo "bundle too small ($js_bytes B)"; exit 1; } + test "$js_bytes" -lt 5000000 || { echo "bundle too large ($js_bytes B) -- WASM re-bundled?"; exit 1; } + # OIDC trusted publishing (needs its own Trusted Publisher config for matterviz-anywidget) + - name: Publish to npm + working-directory: extensions/anywidget + env: + DRY_RUN: ${{ inputs.dry_run }} + run: | + npm install -g npm@latest # ensure OIDC-capable npm (>= 11.5.1) + if [ "$DRY_RUN" = true ]; then + npm publish --ignore-scripts --access public --dry-run + else + npm publish --ignore-scripts --access public + fi diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 28a6bc9d7..970239ebe 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -16,10 +16,7 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - - uses: actions/setup-node@v6 - with: - node-version: 24 - - run: corepack enable && pnpm install + - uses: ./.github/actions/setup - name: Unit tests run: pnpm exec vp test --run @@ -31,10 +28,7 @@ jobs: shard: [1, 2, 3, 4] steps: - uses: actions/checkout@v6 - - uses: actions/setup-node@v6 - with: - node-version: 24 - - run: corepack enable && pnpm install + - uses: ./.github/actions/setup - name: Install Playwright run: npx playwright install chromium - name: E2E tests (shard ${{ matrix.shard }}/4) diff --git a/extensions/vscode/package.json b/extensions/vscode/package.json index 20087386c..30cdf724e 100644 --- a/extensions/vscode/package.json +++ b/extensions/vscode/package.json @@ -872,6 +872,337 @@ "default": true, "description": "Show Y-axis zero reference line" }, + "matterviz.box.whisker_mode": { + "type": "string", + "default": "tukey", + "description": "How whiskers are computed: 'tukey' (1.5*IQR), 'minmax' (data extremes), 'percentile' (5th/95th), or 'std' (mean ± std)", + "enum": [ + "tukey", + "minmax", + "percentile", + "std" + ] + }, + "matterviz.box.box_width": { + "type": "number", + "default": 0.8, + "description": "Box width as a fraction of the category slot", + "minimum": 0.1, + "maximum": 1 + }, + "matterviz.box.show_outliers": { + "type": "boolean", + "default": true, + "description": "Show outlier points beyond the whiskers" + }, + "matterviz.box.show_mean": { + "type": "boolean", + "default": false, + "description": "Show the mean marker inside each box" + }, + "matterviz.box.kind": { + "type": "string", + "default": "box", + "description": "Glyph to draw per series: box, violin (KDE density), or both", + "enum": [ + "box", + "violin", + "violin+box" + ] + }, + "matterviz.box.side": { + "type": "string", + "default": "both", + "description": "Which half of the slot a violin occupies (one-sided / split violins)", + "enum": [ + "both", + "positive", + "negative" + ] + }, + "matterviz.box.bandwidth": { + "type": "string", + "default": "silverman", + "description": "KDE bandwidth rule for violins", + "enum": [ + "silverman", + "scott" + ] + }, + "matterviz.box.violin_width": { + "type": "number", + "default": 0.9, + "description": "Violin width as a fraction of the category slot", + "minimum": 0.1, + "maximum": 1 + }, + "matterviz.box.violin_box_width": { + "type": "number", + "default": 0.2, + "description": "Inner box width (fraction of slot) when a box is drawn inside a violin", + "minimum": 0.05, + "maximum": 1 + }, + "matterviz.box.box.color": { + "type": "string", + "default": "#4A9EFF", + "description": "Box fill color" + }, + "matterviz.box.box.opacity": { + "type": "number", + "default": 0.6, + "description": "Box fill opacity", + "minimum": 0, + "maximum": 1 + }, + "matterviz.box.box.stroke_width": { + "type": "number", + "default": 0.5, + "description": "Box outline width", + "minimum": 0, + "maximum": 5 + }, + "matterviz.box.box.stroke_color": { + "type": "string", + "default": "#000000", + "description": "Box outline color" + }, + "matterviz.box.box.border_radius": { + "type": "number", + "default": 0, + "description": "Corner radius for boxes (px)", + "minimum": 0, + "maximum": 10 + }, + "matterviz.box.whisker.width": { + "type": "number", + "default": 1, + "description": "Whisker line width", + "minimum": 0.5, + "maximum": 5 + }, + "matterviz.box.whisker.color": { + "type": "string", + "default": "#000000", + "description": "Whisker line color" + }, + "matterviz.box.whisker.cap_fraction": { + "type": "number", + "default": 0.3, + "description": "Whisker cap width as a fraction of the box width", + "minimum": 0, + "maximum": 1 + }, + "matterviz.box.median.width": { + "type": "number", + "default": 1.5, + "description": "Median line width", + "minimum": 0.5, + "maximum": 6 + }, + "matterviz.box.median.color": { + "type": "string", + "default": "#000000", + "description": "Median line color" + }, + "matterviz.box.outlier.radius": { + "type": "number", + "default": 2.5, + "description": "Outlier point radius (px)", + "minimum": 0.5, + "maximum": 10 + }, + "matterviz.box.outlier.opacity": { + "type": "number", + "default": 0.6, + "description": "Outlier point opacity", + "minimum": 0, + "maximum": 1 + }, + "matterviz.box.outlier.stroke_width": { + "type": "number", + "default": 0, + "description": "Outlier point stroke width", + "minimum": 0, + "maximum": 3 + }, + "matterviz.box.violin.opacity": { + "type": "number", + "default": 0.5, + "description": "Violin fill opacity", + "minimum": 0, + "maximum": 1 + }, + "matterviz.box.violin.stroke_width": { + "type": "number", + "default": 1, + "description": "Violin outline width", + "minimum": 0, + "maximum": 5 + }, + "matterviz.box.display.x_grid": { + "type": "boolean", + "default": true, + "description": "Show X-axis grid lines" + }, + "matterviz.box.display.y_grid": { + "type": "boolean", + "default": true, + "description": "Show Y-axis grid lines" + }, + "matterviz.box.display.y2_grid": { + "type": "boolean", + "default": false, + "description": "Show Y2-axis grid lines" + }, + "matterviz.box.display.x_zero_line": { + "type": "boolean", + "default": true, + "description": "Show X-axis zero reference line" + }, + "matterviz.box.display.y_zero_line": { + "type": "boolean", + "default": true, + "description": "Show Y-axis zero reference line" + }, + "matterviz.sankey.orientation": { + "type": "string", + "default": "horizontal", + "description": "Flow direction of the Sankey diagram", + "enum": [ + "horizontal", + "vertical" + ] + }, + "matterviz.sankey.node_align": { + "type": "string", + "default": "justify", + "description": "How nodes are aligned across columns (maps to d3-sankey alignment)", + "enum": [ + "justify", + "left", + "right", + "center" + ] + }, + "matterviz.sankey.node_width": { + "type": "number", + "default": 24, + "description": "Node thickness in pixels", + "minimum": 4, + "maximum": 60 + }, + "matterviz.sankey.node_padding": { + "type": "number", + "default": 12, + "description": "Vertical gap in pixels between nodes sharing a column", + "minimum": 0, + "maximum": 40 + }, + "matterviz.sankey.link_opacity": { + "type": "number", + "default": 0.5, + "description": "Opacity of link ribbons", + "minimum": 0.05, + "maximum": 1 + }, + "matterviz.sankey.show_node_labels": { + "type": "boolean", + "default": true, + "description": "Show node labels next to each node" + }, + "matterviz.sankey.iterations": { + "type": "number", + "default": 6, + "description": "Number of d3-sankey relaxation iterations for node positioning", + "minimum": 0, + "maximum": 64 + }, + "matterviz.sunburst.shape": { + "type": "string", + "default": "sunburst", + "description": "Chart geometry: polar rings (sunburst) or stacked rows (icicle)", + "enum": [ + "sunburst", + "icicle" + ] + }, + "matterviz.sunburst.value_mode": { + "type": "string", + "default": "leaf-sum", + "description": "How node values are interpreted (plotly branchvalues semantics): leaf-sum ignores parent values, total treats every value as authoritative, remainder adds a node's own value on top of its children", + "enum": [ + "leaf-sum", + "total", + "remainder" + ] + }, + "matterviz.sunburst.max_depth": { + "type": "number", + "default": 0, + "description": "Number of rings shown below the current zoom root (0 = all)", + "minimum": 0, + "maximum": 10 + }, + "matterviz.sunburst.inner_radius": { + "type": "number", + "default": 0.25, + "description": "Center hole size as fraction of the outer radius", + "minimum": 0, + "maximum": 0.8 + }, + "matterviz.sunburst.pad_angle": { + "type": "number", + "default": 0.1, + "description": "Angular gap in degrees between sibling arcs", + "minimum": 0, + "maximum": 4 + }, + "matterviz.sunburst.min_fraction": { + "type": "number", + "default": 0, + "description": "Group sibling arcs smaller than this fraction of the total into one 'Other' slice per parent (0 = off)", + "minimum": 0, + "maximum": 0.2 + }, + "matterviz.sunburst.show_labels": { + "type": "boolean", + "default": true, + "description": "Show labels on arcs large enough to fit them" + }, + "matterviz.sunburst.label_rotation": { + "type": "string", + "default": "auto", + "description": "Arc label orientation (auto picks radial/tangential per arc)", + "enum": [ + "auto", + "radial", + "tangential", + "horizontal" + ] + }, + "matterviz.sunburst.label_text": { + "type": "string", + "default": "label", + "description": "What arc labels display (percent is of the root total)", + "enum": [ + "label", + "value", + "percent", + "label+value", + "label+percent" + ] + }, + "matterviz.sunburst.zoom_on_click": { + "type": "boolean", + "default": true, + "description": "Clicking a branch arc zooms into that subtree (center circle zooms out)" + }, + "matterviz.sunburst.show_breadcrumbs": { + "type": "boolean", + "default": true, + "description": "Show a clickable trail of ancestors when zoomed into a subtree" + }, "matterviz.composition.display_mode": { "type": "string", "default": "pie", diff --git a/package.json b/package.json index 3231967d8..d6a7b015b 100644 --- a/package.json +++ b/package.json @@ -109,6 +109,10 @@ "types": "./dist/plot/index.d.ts", "default": "./dist/plot/index.js" }, + "./plot/*": { + "types": "./dist/plot/*/index.d.ts", + "default": "./dist/plot/*/index.js" + }, "./rdf": { "types": "./dist/rdf/index.d.ts", "default": "./dist/rdf/index.js" @@ -189,7 +193,7 @@ "d3-scale-chromatic": "^3.1.0", "d3-shape": "^3.2.0", "d3-time-format": "^4.1.0", - "dompurify": "3.4.8", + "dompurify": "3.4.7", "fflate": "^0.8.3", "h5wasm": "^0.10.2", "js-yaml": "^4.2.0", diff --git a/src/lib/sanitize.ts b/src/lib/sanitize.ts index e48b6671e..69f7e7ba2 100644 --- a/src/lib/sanitize.ts +++ b/src/lib/sanitize.ts @@ -75,20 +75,30 @@ const stringify_html_input = (html: unknown): string => { } } +// Memoize by input: two DOMPurify passes per call are costly when a component re-sanitizes +// many cells on every render (e.g. HeatmapTable). Sanitization is deterministic for the +// fixed config, so caching is output-identical. +const sanitize_cache = new Map() + // Sanitize HTML string, allowing only safe formatting tags and links. // Two-pass: happy-dom promotes dangerous children when a non-allowed parent is // stripped (e.g.
). The first // pass explicitly removes dangerous tags so they can't survive promotion. export function sanitize_html(html: unknown): string { const str = stringify_html_input(html) + const cached = sanitize_cache.get(str) + if (cached !== undefined) return cached const dp = get_purify() - if (!dp) return str + if (!dp) return str // no DOM (SSR): return as-is, don't cache // oxfmt-ignore const safe = dp.sanitize(str, { ADD_ATTR: [`target`], FORBID_TAGS: [ `script`, `style`, `iframe`, `object`, `embed`, `form`, `input`, `textarea`, `select`, `button`, `meta`, `link`, `base`, `template`, `noscript`, ] }) - return dp.sanitize(safe, { ALLOWED_TAGS: SAFE_TAGS, ALLOWED_ATTR: SAFE_ATTRS }) + const result = dp.sanitize(safe, { ALLOWED_TAGS: SAFE_TAGS, ALLOWED_ATTR: SAFE_ATTRS }) + if (sanitize_cache.size >= 4096) sanitize_cache.clear() // bound memory (rarely hit) + sanitize_cache.set(str, result) + return result } export const compact_formula = (formula: string): string => formula.replaceAll(/\s+/g, ``) diff --git a/tests/vitest/package-exports.test.ts b/tests/vitest/package-exports.test.ts index d5edec754..90756a8d9 100644 --- a/tests/vitest/package-exports.test.ts +++ b/tests/vitest/package-exports.test.ts @@ -18,6 +18,18 @@ function source_candidates(dist_target: string): string[] { const rel = dist_target.replace(/^\.\/dist\//, ``).replace(/\.d\.ts$/, ``) if (/\.(css|json)$/.test(rel)) return [join(lib_dir, rel)] // assets copied verbatim const base = rel.replace(/\.(js|mjs|cjs)$/, ``) + // Subpath-pattern export (e.g. plot/*/index): expand the single `*` segment against the + // real subdirectories so the wildcard is validated to point at >= 1 source file. + if (base.includes(`*`)) { + const [prefix, suffix] = base.split(`*`) + const parent = join(lib_dir, prefix) + if (!existsSync(parent)) return [] + return readdirSync(parent, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .flatMap((entry) => + source_extensions.map((ext) => join(lib_dir, `${prefix}${entry.name}${suffix}${ext}`)), + ) + } return source_extensions.map((ext) => join(lib_dir, `${base}${ext}`)) }