From 689dffdf6f52b5859cb49910cc133b287621d7af Mon Sep 17 00:00:00 2001 From: Kyle June Date: Sat, 25 Jul 2026 15:26:19 -0400 Subject: [PATCH 1/2] feat: make less, sass, and stylus optional MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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, { ... })`. 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 --- README.md | 24 ++++++++++++++++-------- less.test.ts | 6 ++++-- less.ts | 32 +++++++++++++++++++++++++++----- sass.test.ts | 10 ++++++---- sass.ts | 30 ++++++++++++++++++++++++++---- stylus.test.ts | 6 ++++-- stylus.ts | 33 ++++++++++++++++++++++++++++----- 7 files changed, 111 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index b3bb6f1..8322298 100644 --- a/README.md +++ b/README.md @@ -417,26 +417,34 @@ The 3 preprocessors available are: - Less: A backwards-compatible language extension for CSS. - Stylus: An expressive, dynamic, and robust CSS language. -To use a preprocessor, you will have to import the wrapper for the preprocessor -from this esbuild plugin. These wrappers are stored in separate files so that -the preprocessors are only imported when you use them. +To use a preprocessor, import its wrapper from this plugin **and add the +preprocessor itself to your own dependencies**, then pass the module in: - `@udibo/esbuild-plugin-postcss/sass`: Exports the `sassPreprocessor` function. - `@udibo/esbuild-plugin-postcss/less`: Exports the `lessPreprocessor` function. - `@udibo/esbuild-plugin-postcss/stylus`: Exports the `stylusPreprocessor` function. -Each of these functions take a single argument, which is the options for the -preprocessor. To learn more about the preprocessors and the options for them, -see the [Sass documentation](https://sass-lang.com/documentation/js-api), +`sass`, `less`, and `stylus` are deliberately **not** dependencies of this +package. JSR resolves a package's dependencies as one flat set across every +export, so importing them here would install all three for everyone — including +projects that only build plain CSS. Passing the module in keeps them optional: +you depend on exactly the preprocessors you use, at whatever version you choose. + +Each function takes the preprocessor module first, then its options. To learn +more about the preprocessors and their options, see the +[Sass documentation](https://sass-lang.com/documentation/js-api), [Less documentation](https://lessjs.org/api/), and -[Stylus documentation](https://stylus-lang.com/docs/js-api). +[Stylus documentation](https://stylus-lang.com/docs/js-api). Annotate `options` +with the preprocessor's own option type to keep full type checking, for example +`sassPreprocessor>(sass, { ... })`. You can use one or more of these preprocessors in the preprocessors array. Below is an example of using the Sass preprocessor with the default options for it. ```ts import esbuild from "esbuild"; +import * as sass from "sass"; import { postCSSPlugin } from "@udibo/esbuild-plugin-postcss"; import { sassPreprocessor } from "@udibo/esbuild-plugin-postcss/sass"; @@ -444,7 +452,7 @@ esbuild.build({ plugins: [ postCSSPlugin({ preprocessors: [ - sassPreprocessor(), + sassPreprocessor(sass), ], }), ], diff --git a/less.test.ts b/less.test.ts index 877e30c..6f2525b 100644 --- a/less.test.ts +++ b/less.test.ts @@ -6,6 +6,8 @@ import { import * as path from "@std/path"; import { describe, it } from "@std/testing/bdd"; +import less from "less"; + import { lessPreprocessor } from "./less.ts"; import { build } from "./test-utils.ts"; import { postCSSPlugin } from "./postcss.ts"; @@ -19,7 +21,7 @@ describe("less", () => { ["./main.less"], { plugins: [postCSSPlugin({ - preprocessors: [lessPreprocessor()], + preprocessors: [lessPreprocessor(less)], })], }, ); @@ -46,7 +48,7 @@ describe("less", () => { ["./main.ts"], { plugins: [postCSSPlugin({ - preprocessors: [lessPreprocessor()], + preprocessors: [lessPreprocessor(less)], })], bundle: true, }, diff --git a/less.ts b/less.ts index d9c4646..7014e45 100644 --- a/less.ts +++ b/less.ts @@ -1,23 +1,42 @@ /** * The Less preprocessor for the esbuild PostCSS Plugin. * + * `less` is deliberately **not** a dependency of this package — you pass the + * module in. JSR resolves a package's dependencies as one flat set across every + * export, so a `less` import here would be installed by every consumer, + * including those that only ever build plain CSS. + * * @module */ import type { Preprocessor, PreprocessorResults } from "./postcss.ts"; -import less from "less"; + +/** + * The part of the `less` module this preprocessor calls. The real module + * satisfies it — `import less from "less"` and hand it over. + */ +export interface LessModule { + render( + input: string, + options: object, + ): Promise<{ css: string }>; +} /** * Creates a Less preprocessor for the esbuild PostCSS Plugin. * + * Add `less` to your own dependencies and pass it in. Type `options` with + * Less's own option type to keep full checking. + * * ```ts * import esbuild from "esbuild"; + * import less from "less"; * import { postCSSPlugin } from "@udibo/esbuild-plugin-postcss"; * import { lessPreprocessor } from "@udibo/esbuild-plugin-postcss/less"; * * esbuild.build({ * plugins: [postCSSPlugin({ - * preprocessors: [lessPreprocessor()], + * preprocessors: [lessPreprocessor(less)], * })], * entryPoints: ["./src/index.less"], * outdir: "./dist", @@ -25,11 +44,14 @@ import less from "less"; * }); * ``` * - * @param options - The options for the less preprocessor. + * @param less - The `less` module. + * @param options - The options for the less preprocessor. `filename` is set per + * file, so supplying it has no effect. * @returns The less preprocessor. */ -export function lessPreprocessor( - options?: Omit, +export function lessPreprocessor( + less: LessModule, + options?: Options, ): Preprocessor { return { filter: /\.less$/, diff --git a/sass.test.ts b/sass.test.ts index 19eb2f0..5e21728 100644 --- a/sass.test.ts +++ b/sass.test.ts @@ -6,6 +6,8 @@ import { import * as path from "@std/path"; import { describe, it } from "@std/testing/bdd"; +import * as sass from "sass"; + import { sassPreprocessor } from "./sass.ts"; import { build } from "./test-utils.ts"; import { postCSSPlugin } from "./postcss.ts"; @@ -20,7 +22,7 @@ describe("sass", () => { ["./a.scss"], { plugins: [postCSSPlugin({ - preprocessors: [sassPreprocessor()], + preprocessors: [sassPreprocessor(sass)], })], }, ); @@ -47,7 +49,7 @@ describe("sass", () => { ["./a.ts"], { plugins: [postCSSPlugin({ - preprocessors: [sassPreprocessor()], + preprocessors: [sassPreprocessor(sass)], })], bundle: true, }, @@ -77,7 +79,7 @@ describe("sass", () => { ["./b.sass"], { plugins: [postCSSPlugin({ - preprocessors: [sassPreprocessor()], + preprocessors: [sassPreprocessor(sass)], })], }, ); @@ -104,7 +106,7 @@ describe("sass", () => { ["./b.ts"], { plugins: [postCSSPlugin({ - preprocessors: [sassPreprocessor()], + preprocessors: [sassPreprocessor(sass)], })], bundle: true, }, diff --git a/sass.ts b/sass.ts index 1fa1bf2..f5d41c6 100644 --- a/sass.ts +++ b/sass.ts @@ -1,22 +1,42 @@ /** * The Sass preprocessor for the esbuild PostCSS Plugin. * + * `sass` is deliberately **not** a dependency of this package — you pass the + * module in. JSR resolves a package's dependencies as one flat set across every + * export, so a `sass` import here would be installed by every consumer, + * including those that only ever build plain CSS. + * * @module */ + import type { Preprocessor, PreprocessorResults } from "./postcss.ts"; -import * as sass from "sass"; + +/** + * The part of the `sass` module this preprocessor calls. The real module + * satisfies it — `import * as sass from "sass"` and hand it over. + */ +export interface SassModule { + compileAsync( + path: string, + options?: object, + ): Promise<{ css: string; loadedUrls: URL[] }>; +} /** * Creates a Sass preprocessor for the esbuild PostCSS Plugin. * + * Add `sass` to your own dependencies and pass it in. Type `options` with + * Sass's own option type to keep full checking. + * * ```ts * import esbuild from "esbuild"; + * import * as sass from "sass"; * import { postCSSPlugin } from "@udibo/esbuild-plugin-postcss"; * import { sassPreprocessor } from "@udibo/esbuild-plugin-postcss/sass"; * * esbuild.build({ * plugins: [postCSSPlugin({ - * preprocessors: [sassPreprocessor()], + * preprocessors: [sassPreprocessor(sass)], * })], * entryPoints: ["./src/index.scss"], * outdir: "./dist", @@ -24,11 +44,13 @@ import * as sass from "sass"; * }); * ``` * + * @param sass - The `sass` module. * @param options - The options for the sass preprocessor. * @returns The sass preprocessor. */ -export function sassPreprocessor( - options?: sass.Options<"async">, +export function sassPreprocessor( + sass: SassModule, + options?: Options, ): Preprocessor { return { filter: /\.(sass|scss)$/, diff --git a/stylus.test.ts b/stylus.test.ts index e765502..048fdf5 100644 --- a/stylus.test.ts +++ b/stylus.test.ts @@ -6,6 +6,8 @@ import { import * as path from "@std/path"; import { describe, it } from "@std/testing/bdd"; +import stylus from "stylus"; + import { stylusPreprocessor } from "./stylus.ts"; import { build } from "./test-utils.ts"; import { postCSSPlugin } from "./postcss.ts"; @@ -19,7 +21,7 @@ describe("stylus", () => { ["./main.styl"], { plugins: [postCSSPlugin({ - preprocessors: [stylusPreprocessor()], + preprocessors: [stylusPreprocessor(stylus)], })], }, ); @@ -46,7 +48,7 @@ describe("stylus", () => { ["./main.ts"], { plugins: [postCSSPlugin({ - preprocessors: [stylusPreprocessor()], + preprocessors: [stylusPreprocessor(stylus)], })], bundle: true, }, diff --git a/stylus.ts b/stylus.ts index 340d7d5..d6e0399 100644 --- a/stylus.ts +++ b/stylus.ts @@ -1,23 +1,43 @@ /** * The Stylus preprocessor for the esbuild PostCSS Plugin. * + * `stylus` is deliberately **not** a dependency of this package — you pass the + * module in. JSR resolves a package's dependencies as one flat set across every + * export, so a `stylus` import here would be installed by every consumer, + * including those that only ever build plain CSS. + * * @module */ import type { Preprocessor, PreprocessorResults } from "./postcss.ts"; -import stylus from "stylus"; + +/** + * The part of the `stylus` module this preprocessor calls. The real module + * satisfies it — `import stylus from "stylus"` and hand it over. + */ +export interface StylusModule { + render( + input: string, + options: object, + callback: (error: Error, css: string, js: string) => void, + ): void; +} /** * Creates a Stylus preprocessor for the esbuild PostCSS Plugin. * + * Add `stylus` to your own dependencies and pass it in. Type `options` with + * Stylus's own option type to keep full checking. + * * ```ts * import esbuild from "esbuild"; + * import stylus from "stylus"; * import { postCSSPlugin } from "@udibo/esbuild-plugin-postcss"; * import { stylusPreprocessor } from "@udibo/esbuild-plugin-postcss/stylus"; * * esbuild.build({ * plugins: [postCSSPlugin({ - * preprocessors: [stylusPreprocessor()], + * preprocessors: [stylusPreprocessor(stylus)], * })], * entryPoints: ["./src/index.styl"], * outdir: "./dist", @@ -25,11 +45,14 @@ import stylus from "stylus"; * }); * ``` * - * @param options - The options for the stylus preprocessor. + * @param stylus - The `stylus` module. + * @param options - The options for the stylus preprocessor. `filename` is set + * per file, so supplying it has no effect. * @returns The stylus preprocessor. */ -export function stylusPreprocessor( - options?: Omit, +export function stylusPreprocessor( + stylus: StylusModule, + options?: Options, ): Preprocessor { return { filter: /\.styl$/, From a2549b979d29d7d8c150d44ac2e55ccb7d1b2db1 Mon Sep 17 00:00:00 2001 From: Kyle June Date: Sat, 25 Jul 2026 15:26:19 -0400 Subject: [PATCH 2/2] ci: release automatically from commit messages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .github/workflows/main.yml | 56 +++++++++++++++++++++++++++++ .github/workflows/pr-title.yml | 49 ++++++++++++++++++++++++++ .github/workflows/publish.yml | 16 --------- .releaserc.json | 64 ++++++++++++++++++++++++++++++++++ 4 files changed, 169 insertions(+), 16 deletions(-) create mode 100644 .github/workflows/pr-title.yml delete mode 100644 .github/workflows/publish.yml create mode 100644 .releaserc.json diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 643073e..850a304 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -10,3 +10,59 @@ jobs: uses: ./.github/workflows/ci.yml secrets: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + release: + name: Release + needs: [ci] + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + permissions: + id-token: write + contents: write + issues: write + pull-requests: write + steps: + - name: Clone repository + # No deploy key: publishing to JSR is OIDC (id-token: write, package + # linked to this repo), and @semantic-release/git pushes the release + # commit with the checkout-persisted GITHUB_TOKEN, which `contents: + # write` covers. That push only needs an SSH deploy key if `main` + # becomes a protected branch — GITHUB_TOKEN cannot push past protection. + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + fetch-depth: 0 + # semantic-release runs on Node, not Deno: @sebbo2002/semantic-release-jsr + # shell-spawns its own downloaded `deno publish`, and that spawn's args are + # mangled by Deno's child_process Node-compat layer (the publish fails with + # "unrecognized subcommand"). Under real Node the plugin works as designed. + - name: Setup Node.js + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: 22 + - name: Setup Deno + uses: denoland/setup-deno@667a34cdef165d8d2b2e98dde39547c9daac7282 # v2 + with: + deno-version: v2.x + # No shared dependency cache on the publish job: it is the only job + # with id-token: write, so it must not restore a cache another job + # could have populated. + cache: false + - name: Install dependencies + run: deno ci + - name: Run semantic-release + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # Every package is pinned to an exact version so a malicious new release + # of a popular package can't execute here while id-token: write is active + # — npx has no lockfile, so the version IS the pin. @sebbo2002/semantic- + # release-jsr stays 3.2.1 because 4.0.0 ships a CJS bundle semantic-release + # rejects as EPLUGINSCONF (https://github.com/sebbo2002/semantic-release-jsr/issues/153). + # Bump these deliberately. + run: >- + npx + --package semantic-release@25.0.5 + --package @semantic-release/changelog@6.0.3 + --package @semantic-release/exec@7.1.0 + --package @semantic-release/git@10.0.1 + --package @semantic-release/github@12.0.8 + --package @sebbo2002/semantic-release-jsr@3.2.1 + semantic-release diff --git a/.github/workflows/pr-title.yml b/.github/workflows/pr-title.yml new file mode 100644 index 0000000..8560b8a --- /dev/null +++ b/.github/workflows/pr-title.yml @@ -0,0 +1,49 @@ +name: PR Title + +on: + # pull_request_target runs in the base-repo context (needed to read fork PR + # titles), but this workflow checks out NO code, writes NO cache, and holds + # only pull-requests: read — so it can't be used to poison a cache or reach + # secrets. Keep it that way: never add actions/checkout or a build step here. + pull_request_target: + types: + - opened + - edited + - synchronize + +permissions: + pull-requests: read + +jobs: + validate: + name: Validate PR title + runs-on: ubuntu-latest + steps: + - name: Check PR title follows Conventional Commits + uses: amannn/action-semantic-pull-request@e32d7e603df1aa1ba07e981f2a23455dee596825 # v5 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + types: | + feat + fix + docs + style + refactor + perf + test + build + ci + chore + requireScope: false + subjectPattern: ^.+$ + subjectPatternError: | + PR title must follow Conventional Commits format: + (): + + Examples: + feat: add server-side caching + fix(router): handle undefined params + feat!: breaking change + + See CONTRIBUTING.md for details. diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml deleted file mode 100644 index 9e7cc8a..0000000 --- a/.github/workflows/publish.yml +++ /dev/null @@ -1,16 +0,0 @@ -name: publish - -on: - push: - branches: - - main - -jobs: - publish: - runs-on: ubuntu-latest - permissions: - contents: read - id-token: write - steps: - - uses: actions/checkout@v4 - - run: npx jsr publish diff --git a/.releaserc.json b/.releaserc.json new file mode 100644 index 0000000..996de7b --- /dev/null +++ b/.releaserc.json @@ -0,0 +1,64 @@ +{ + "branches": ["main"], + "tagFormat": "${version}", + "plugins": [ + [ + "@semantic-release/commit-analyzer", + { + "preset": "angular", + "releaseRules": [ + { "breaking": true, "release": "major" }, + { "revert": true, "release": "patch" }, + { "type": "feat", "release": "minor" }, + { "type": "fix", "release": "patch" }, + { "type": "perf", "release": "patch" }, + { "type": "revert", "release": "patch" }, + { "type": "build", "scope": "deps", "release": "patch" }, + { "type": "chore", "scope": "deps", "release": "patch" } + ] + } + ], + [ + "@semantic-release/release-notes-generator", + { + "preset": "angular", + "writerOpts": { + "commitsSort": ["subject", "scope"] + } + } + ], + [ + "@semantic-release/changelog", + { + "changelogFile": "CHANGELOG.md" + } + ], + [ + "@semantic-release/exec", + { + "prepareCmd": "deno fmt CHANGELOG.md" + } + ], + [ + "@sebbo2002/semantic-release-jsr", + { + "configFile": "deno.json", + "allowDirty": true, + "publishArgs": ["--no-check"] + } + ], + [ + "@semantic-release/github", + { + "assets": [] + } + ], + [ + "@semantic-release/git", + { + "assets": ["CHANGELOG.md", "deno.lock", "deno.json"], + "message": "chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}" + } + ] + ] +}