Skip to content

Fellow fork: css-modules webpack-parity hashing + GH Packages publishing#2

Merged
sciyoshi merged 10 commits intomasterfrom
feat/fellowapp-publish
May 5, 2026
Merged

Fellow fork: css-modules webpack-parity hashing + GH Packages publishing#2
sciyoshi merged 10 commits intomasterfrom
feat/fellowapp-publish

Conversation

@sciyoshi
Copy link
Copy Markdown

@sciyoshi sciyoshi commented May 5, 2026

Summary

Forks lightningcss to add four CSS-modules scoped-name hashing features needed for byte-parity with webpack/css-loader/postcss-modules, and packages the result for distribution via GitHub Packages as @fellowapp/lightningcss.

The first 4 commits are upstreamable as-is and will be sent to parcel-bundler/lightningcss separately (see branch feat/css-modules-hash-algorithms). The remaining commits are Fellow-specific.

CSS modules changes (commits 1-8)

  • feat(css-modules): add HashAlgorithm and DigestType + hash_with_options helper (md4, xxhash64; hex, standard base64)
  • feat(css-modules): extend Segment::Hash with optional algo, digest, length (struct variant)
  • feat(css-modules): parse webpack-style [<algo>:hash:<digest>:<length>] syntax
  • feat(css-modules): add Config::hash_prefix (Vite/postcss-modules' hashPrefix; matches css-loader tier-0 salt)
  • feat(css-modules): add Config::hash_local_name (per-(file, local) hashing)
  • feat(css-modules): add Config::escape_scoped_names (post-process pipeline making +/ safe and prefixing leading-digit/-- with _)
  • test(css-modules): add Vite scoped-name byte-parity end-to-end test (12 captured cases reproduce byte-for-byte)

All defaults preserve existing lightningcss output: each Fellow-specific feature is an opt-in flag.

Packaging changes (commits 9-10)

  • chore(npm): rename package to @fellowapp/lightningcss for GH Packages — scoped names + GH Packages registry + dropped CLI publishing
  • ci: add release-fellowapp workflow to publish to GitHub Packagesworkflow_dispatch-triggered build matrix (10 platform targets) authenticating with GITHUB_TOKEN

Test plan

  • cargo test --no-default-features — 135 pass (only pre-existing platform-dependent test_dependencies failure remains, unrelated to this work)
  • End-to-end byte-parity test reproduces 12 captured Vite scoped names byte-for-byte
  • Run release-fellowapp workflow end-to-end and confirm @fellowapp/lightningcss@1.32.0-fellow.0 resolves from GH Packages
  • Smoke-test the published package from a downstream Vite consumer with pnpm.overrides

sciyoshi added 10 commits May 4, 2026 20:41
These will back new HashAlgorithm options in the next commit, enabling
webpack/css-loader-compatible scoped-name hashes.
…tions helper

Introduces a parameterized hash-and-encode helper for CSS module names, with
md4 and xxhash64 algorithms and standard-base64 / hex digests. The default
[hash] / [content-hash] code path is unchanged; this commit only adds the new
types and helper. Pattern parsing and Segment wiring follow in subsequent
commits.

Tested against ground-truth output captured from a Vite/postcss-modules build
to confirm md4+base64+truncate behaves identically to webpack's
loader-utils.getHashDigest.
…nd length

Segment::Hash becomes a struct variant carrying optional HashAlgorithm,
DigestType, and length fields. When all are None the legacy siphash +
custom-base64 path is preserved (existing test snapshots unchanged); when any
is Some, hashing dispatches through hash_with_options.

Hash computation moves from CssModule::new to Pattern::write so each segment
can apply its own options. CssModule::hashes is renamed to hash_inputs and
now stores the raw source-relative path string instead of a precomputed
hash. Pattern::write computes the hash per segment from this raw input.

Segment::ContentHash remains a unit variant in this commit; per-segment
options on [content-hash] would require additional plumbing through the
bundler's content-hash precomputation and is left for a follow-up.
…] syntax

The parser now recognizes webpack-compatible scoped-name hash placeholders.
Each of algo, digest, and length is optional; the position of the literal
\`hash\` keyword distinguishes algo (before) from digest+length (after).
Examples:

  [hash]                  -> legacy lightningcss hash, unchanged
  [hash:base64]           -> default algo, base64 digest, full length
  [hash:5]                -> default algo, hex digest, 5 chars
  [md4:hash:base64:5]     -> md4 + base64 + 5 chars (matches webpack)
  [xxhash64:hash:hex:12]  -> xxhash64 + hex + 12 chars

Bare \`[hash]\` keeps the legacy code path so existing snapshots remain byte-
identical. Recognized algorithms: md4, xxhash64. Recognized digests: hex,
base64. Parsing is case-insensitive. Unknown algos/digests, missing \`hash\`
keyword, and extra parts before \`hash\` all return UnknownPlaceholder.

Tests cover each form, case-insensitivity, rejection of bad input, and a
round-trip integration test against ground-truth output captured from a
Vite/postcss-modules build (digest only; the css-loader content composition
and post-processing pipeline live downstream of this commit).
Adds a hash_prefix field to css_modules::Config that prepends a string to
every hash input. This matches Vite/postcss-modules' hashPrefix option and,
when set to '\x00\x00\x00\x00', reproduces css-loader's tier-0 salt input
byte-for-byte — a necessary piece for Vite-to-webpack scoped-name parity.

Default is None, leaving lightningcss output unchanged for existing users.

Per-local hash composition and post-processing (also required for full Vite
parity) follow in subsequent commits.
Adds a hash_local_name flag to css_modules::Config. When true, the local
class/ident name is appended to the hash input separated by a NUL byte:
<prefix><relative-path>\0<local>. This matches the per-local hashing done
by css-loader and postcss-modules — without it every export from a given
file shares one hash.

Composition happens via a new CssModule::hash_input_for helper, called from
each Pattern::write call site (add_local, add_dashed, reference,
reference_dashed, handle_composes, plus printer's write_ident and
write_dashed_ident). Each call allocates a small string when hash_local_name
is enabled; otherwise it returns Cow::Borrowed against the existing buffer.

Default is false, preserving lightningcss's per-file hashing for users who
don't opt in. Combined with hash_prefix from the previous commit, the bytes
fed to the hash now match css-loader's input bytes exactly.

A test asserts that Md4+Base64+5 over the composed input reproduces the
captured Vite digest 'YTbdH' for src/styles/Alpha.module.css#foo.
Adds an escape_scoped_names config flag and an escape_scoped_name helper
that mirrors css-loader/postcss-modules' genericNames post-processing:

  s.replace(/[^a-zA-Z0-9\\-_\\u00A0-\\uFFFF]/g, '-')
   .replace(/^((-?[0-9])|--)/, '_\$1')

The pipeline runs after Pattern::write_to_string at every scoped-name call
site (CssModule::add_local/add_dashed/reference/reference_dashed/handle_composes
plus Printer::write_ident/write_dashed_ident). For dashed (custom property)
idents the leading '--' is excluded — the rendered pattern is escaped
without the prefix and '--' is prepended afterwards, so '--' on a custom
property never triggers the leading-double-dash rule.

write_to_string is also promoted from private to pub(crate) so the printer
can call it directly (replacing the previous streaming Pattern::write +
serialize_identifier/serialize_name closure pattern). Output is unchanged
for the legacy (no-escape) path: serialize_identifier on a full string
produces the same bytes as serialize_identifier on the head + serialize_name
on the tail.

Eight unit tests cover the four post-process branches captured from the
real Vite/postcss-modules build (digit, +, /, leading -digit, leading --,
leading -letter unchanged, clean input, unicode above U+00A0).
Integrates the four hashing features (md4/xxhash64 algos, hash_prefix,
hash_local_name, escape_scoped_names) and asserts the rendered scoped names
match a Vite/postcss-modules build's output byte-for-byte, using the
generateScopedName='[name]__[local]__[md4:hash:base64:5]' and
hashPrefix='\\0\\0\\0\\0' configuration captured from a real project.

Twelve cases across two source files cover every post-processing branch:
clean digest, +/-/-cleanup-mid-name, leading-digit prefix on rendered output,
leading -digit prefix, leading -- prefix, and leading -letter (no prefix).

If this test breaks, lightningcss has diverged from webpack/css-loader
hashing and Vite migrations relying on hash stability will produce different
class names than they did pre-migration.
GitHub Packages requires scoped names matching the publishing org. Rename
the top-level package and the eleven platform-specific binary packages from
'lightningcss[-platform]' to '@fellowapp/lightningcss[-platform]', update
node/index.js to require the scoped platform package at runtime, and point
publishConfig.registry at https://npm.pkg.github.com.

Drop CLI packaging from build-npm.js — the fork is consumed as a library
(via Vite/lightningcss-napi), not as a CLI, and the per-platform CLI
bundles are dead weight.

Version bumped to 1.32.0-fellow.0 so the fork doesn't shadow upstream.
Adapted from .github/workflows/release.yml. Builds the same matrix of
platform-specific .node binaries (10 targets: macOS x64/arm64, Windows
x64/arm64, Linux x64/arm64 gnu+musl, Linux armv7, Android arm64) and
publishes them to GitHub Packages as @fellowapp/lightningcss-<platform>,
plus the main @fellowapp/lightningcss package.

Differences from the upstream workflow:
- Triggered on workflow_dispatch only (no automated tag-based release).
- Drops FreeBSD and wasm targets (not needed for the Vite-via-napi use case).
- Drops CLI build/publish (the fork is consumed as a library).
- Authenticates with GITHUB_TOKEN against npm.pkg.github.com via
  setup-node's registry-url + scope inputs.
- Requires permissions: { packages: write, contents: read } on the
  release job.
@sciyoshi sciyoshi merged commit 3a85fb7 into master May 5, 2026
1 of 3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant