Skip to content

feat: make less, sass, and stylus optional - #8

Merged
KyleJune merged 2 commits into
mainfrom
feat/optional-preprocessors
Jul 25, 2026
Merged

feat: make less, sass, and stylus optional#8
KyleJune merged 2 commits into
mainfrom
feat/optional-preprocessors

Conversation

@KyleJune

Copy link
Copy Markdown
Member

Summary

Makes less, sass, and stylus optional — what the split entry points were always meant to achieve — and switches releases to semantic-release.

Closes #7.

Why the entry-point split wasn't enough

postcss.ts imports no preprocessor and each wrapper imports exactly one, so the modules were already correct. But JSR resolves a package's dependencies as one flat set across every export, so that split never reduced what anyone installed. Two confirmations:

  • A scratch project importing only @udibo/esbuild-plugin-postcss still resolved all three.
  • JSR's own API returns them as package-level deps with no per-export split:
    {"kind":"npm","name":"less",...},{"kind":"npm","name":"sass",...},{"kind":"npm","name":"stylus",...}
    

Measured cost: a consumer importing only postCSSPlugin resolves 129 npm packages, 53 of them the less/sass/stylus subtree — 41% of the graph for features it never touches.

Splitting further can't fix it, and neither can a dynamic import() (JSR analyses the whole graph). The package has to stop referencing the preprocessors.

The change

The caller passes the module in:

import * as sass from "sass";
import { sassPreprocessor } from "@udibo/esbuild-plugin-postcss/sass";

postCSSPlugin({ preprocessors: [sassPreprocessor(sass)] });

Each wrapper declares the small structural interface it actually calls, which the real module satisfies. Options stay fully typed via the generic:

sassPreprocessor<sass.Options<"async">>(sass, { ... })

Breaking for anyone using a preprocessor — they add it to their own dependencies. That's the point: they pick the version, and plain-CSS projects carry none of it.

deno info now reports zero preprocessor deps in the graph of all four exports. The import map keeps them for the tests, which is safe: JSR derives deps from the published module graph, not the import map — visible today in that @std/assert, autoprefixer, and tailwindcss are all in the import map but absent from JSR's dep list.

Release automation

publish.yml ran npx jsr publish on every push to main, shipping whatever version was in deno.json. Replaced with juniper's setup: version and changelog derived from Conventional Commits, release gated behind CI. feat → minor, fix/perf/chore(deps) → patch, breaking → major. Adds the PR-title check, since a squash-merged title becomes the commit the version is computed from.

Versioning note

The feat: commit cuts 0.4.0, not 1.0.0 — I deliberately did not mark it feat!, since on 0.x semantic-release maps a breaking change to 1.0.0 and I didn't want to declare 1.0 on your behalf. The break is documented in the commit body, the changelog entry, and the README. Say the word if you'd rather this be 1.0.0 and I'll amend to feat!.

Testing

deno lint, deno fmt --check, and deno check clean. Tests: 5 passed, same as before the change.

One pre-existing failure, unchanged by this PR: tailwindcss 4 … should add tailwindcss to the css fails locally with an esbuild Object.prototype.__proto__ error. It fails identically on a pristine checkout and CI is green on main, so it's a newer-local-Deno issue rather than a regression — worth a separate look.

Before merging

The release job needs a DEPLOY_KEY repository secret so semantic-release can push the release commit back to main.

🤖 Generated with Claude Code

KyleJune and others added 2 commits July 25, 2026 15:26
The preprocessor wrappers already lived in separate modules so they would only
load when used, but that never reduced what consumers installed: JSR resolves a
package's dependencies as one flat set across every export. A project importing
only `postCSSPlugin` still resolved less, sass, and stylus — 53 of the 129 npm
packages in its graph, for features it never touched.

Splitting entry points further cannot fix that, and neither can a dynamic
import, since JSR analyses the whole module graph. The package has to stop
referencing the preprocessors, so the caller now passes the module in:

    import * as sass from "sass";
    sassPreprocessor(sass)

Each wrapper declares the small structural interface it actually calls, which
the real module satisfies. Annotate the options generic with the preprocessor's
own option type to keep full checking:
`sassPreprocessor<sass.Options<"async">>(sass, { ... })`.

This is a breaking change for anyone using a preprocessor. Consumers add the
preprocessor to their own dependencies — which is the point: they now pick the
version, and projects that build plain CSS carry none of it.

Also drops the brace-expansion advisory (CVE-2026-14257, reachable only through
stylus -> glob@10 -> minimatch@9) from every consumer that doesn't use Stylus.
Anyone who does use it inherits that choice explicitly, which is the right place
for it. Closes #7.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Replaces the publish workflow, which ran `npx jsr publish` on every push to
main and so shipped whatever version happened to be in deno.json, with the
semantic-release setup juniper uses: the version and changelog are derived from
Conventional Commit messages, and the release only runs after CI passes.

- `feat` cuts a minor, `fix`/`perf`/`revert` and `chore(deps)`/`build(deps)` a
  patch, and a breaking change a major.
- semantic-release runs on Node rather than Deno — semantic-release-jsr
  shell-spawns its own `deno publish`, whose args Deno's child_process compat
  layer mangles. Its npx packages are pinned exactly, since npx has no lockfile
  and the job holds `id-token: write`.
- Adds the PR-title check so a squash-merged PR title is a valid commit
  message, which is what the version is computed from.

No new repository secrets. Publishing is OIDC (`id-token: write` with the
package linked to this repo), and @semantic-release/git pushes the release
commit with the checkout-persisted GITHUB_TOKEN under `contents: write`. Unlike
juniper, this repo's `main` is unprotected, so no SSH deploy key is needed —
that only becomes necessary if `main` is protected later, since GITHUB_TOKEN
cannot push past branch protection.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@KyleJune
KyleJune force-pushed the feat/optional-preprocessors branch from adf096c to a2549b9 Compare July 25, 2026 19:34
@KyleJune
KyleJune merged commit f0c2bc7 into main Jul 25, 2026
7 checks passed
@github-actions

Copy link
Copy Markdown

🎉 This PR is included in version 0.4.0 🎉

The release is available on:

Your semantic-release bot 📦🚀

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Make less/sass/stylus optional — stylus drags in an unfixable brace-expansion advisory

1 participant