Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 7 additions & 14 deletions .github/workflows/elixir.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,26 +13,19 @@ on:
workflow_call:
jobs:
ash-ci:
strategy:
fail-fast: false
matrix:
include:
- elixir-version: "1.17.0-otp-26"
erlang-version: "26.0"
primary: false
- elixir-version: "default"
erlang-version: "default"
primary: true
uses: ash-project/ash/.github/workflows/ash-ci.yml@main
permissions:
contents: write
pages: write
id-token: write
security-events: write
with:
spark-formatter: false
spark-cheat-sheets: false
sobelow: false
elixir-version: ${{ matrix.elixir-version }}
erlang-version: ${{ matrix.erlang-version }}
igniter-upgrade: false
publish-docs: ${{ matrix.primary }}
release: false
rustler-precompiled-module: IgniterCss.Native
rust-crate-dir: native/igniter_css
reuse: true
secrets:
HEX_API_KEY: ${{ secrets.HEX_API_KEY }}
7 changes: 5 additions & 2 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -29,5 +29,8 @@ igniter_css-*.tar
.DS_Store

.elixir_ls
.ropeproject
/plibs/css_tools/src/css_tools/__pycache__

# Compiled NIF artifacts. The precompiled ones are attached to a release and
# fetched by rustler_precompiled; local builds land here.
/priv/native/
/native/igniter_css/target/
5 changes: 3 additions & 2 deletions .tool-versions
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
erlang 27.1.3
elixir 1.18.3-otp-27
erlang 28.0.2
elixir 1.18.4-otp-28
rust 1.97.1
pipx 1.8.0
2 changes: 1 addition & 1 deletion .tool-versions.license
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
SPDX-FileCopyrightText: 2025 igniter_css contributors <https://github.com/ash-project/igniter_css/graphs.contributors>
SPDX-FileCopyrightText: 2025 igniter_css contributors <https://github.com/ash-project/igniter_css/graphs/contributors>

SPDX-License-Identifier: MIT
46 changes: 45 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,9 +1,53 @@
<!--
SPDX-FileCopyrightText: 2025 igniter_css contributors <https://github.com/ash-project/igniter_css/graphs.contributors>
SPDX-FileCopyrightText: 2025 igniter_css contributors <https://github.com/ash-project/igniter_css/graphs/contributors>

SPDX-License-Identifier: MIT
-->

# Changelog for IgniterCss 0.2.0

### Breaking changes:

- The Python/`tinycss2` implementation is gone, along with the `pythonx`
dependency, `priv/python`, `plibs/` and `rebuild_wheel.sh`. Everything is now
a precompiled Rust NIF built on Biome's lossless CSS CST — no Python, no Node,
no external process.
- `IgniterCss.CSS.CssProcessor` has been removed. Its pipeline mixed codemods
with whole-file rewriting, which the new design keeps deliberately separate:
use `IgniterCss` for patching and `IgniterCss.Transform` for build-time
output.
- `IgniterCss.Parsers.Parser` keeps its function names and its
`{:ok, :function_name, result}` shape, but the mutating functions are now
diff-minimal rather than reprinting the stylesheet, and selector matching is
strict: an ambiguous selector is an error instead of an arbitrary choice.

### Features:

- New `IgniterCss` API: `ensure_at_rule/3`, `ensure_rule/4`,
`set_declaration/5`, `remove_declaration/4`, `remove_rule/3`,
`replace_rule_body/4`, `append_raw_to_rule/4`, `add_vendor_prefixes/4`,
`sort_properties/2`, `remove_duplicates/2`, plus read-only queries and
analysis.
- New `IgniterCss.Codemods` — Igniter-facing wrappers that take and return an
`Igniter` struct, so callers get the normal diff preview and confirmation
flow.
- New `IgniterCss.Transform` for whole-file `minify/2`, `beautify/2` and
`merge_stylesheets/2`, held separate from the codemods.
- Tailwind v4 support: `@theme`, `@plugin` (with and without a block),
`@source`, `@custom-variant`, `@variant`, `@utility`, `@apply`, `@reference`.

### Improvements:

- Comments are preserved by construction: operations splice byte ranges into the
original source instead of reprinting the tree, so text outside an edit cannot
change.
- Every operation is idempotent and reports `changed: false` on a re-run.
- Files that cannot be patched safely — an unbalanced brace, a parse that does
not reproduce the input byte for byte — are refused rather than half-edited.
- Inserted text follows the file's own newline style, indent unit and trailing
newline; BOM and CRLF files round-trip.
- Precompiled NIFs, so end users need no Rust toolchain.

# Changelog for IgniterCss 0.1.1

### Improvements:
Expand Down
168 changes: 157 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
<!--
SPDX-FileCopyrightText: 2025 Shahryar Tavakkoli
SPDX-FileCopyrightText: 2025 igniter_css contributors <https://github.com/ash-project/igniter_css/graphs.contributors>
SPDX-FileCopyrightText: 2025 igniter_css contributors <https://github.com/ash-project/igniter_css/graphs/contributors>

SPDX-License-Identifier: MIT
-->
Expand All @@ -16,21 +15,168 @@ SPDX-License-Identifier: MIT

# IgniterCss

IgniterCss is CSS patching functionality for [Igniter](https://hexdocs.pm/igniter)
Semantic patches for CSS files that a user owns, for
[Igniter](https://hexdocs.pm/igniter). Powered by a Rust parser (Biome's
lossless CSS CST) integrated via NIFs.

This is a **codemod** tool, not a formatter, minifier or bundler. It exists to
change the two lines you meant to change in somebody's `app.css` and nothing
else.

## Installation

IgniterCss can be added to an existing elixir project by adding it to your dependencies:
```elixir
{:igniter_css, "~> 0.2.0", only: [:dev, :test]}
```

Precompiled NIFs ship for the standard target matrix, so no Rust toolchain is
needed. Set `IGNITERCSS_BUILD=1` to force a local build.

## Guarantees

1. **Comments are never lost.** Not mostly preserved — never lost.
2. **Diffs are minimal.** `git diff` shows only the lines the codemod meant to
change. No whole-file reformatting, ever.
3. **Everything is idempotent.** Installers get re-run; the second run reports
`changed: false` and produces identical bytes.
4. **Input is never destroyed.** A file that cannot be understood well enough to
patch safely comes back untouched with `{:error, reason}`.

These are not aspirations. Guarantees 1–4 are asserted for every operation
against every fixture in the test corpus — a real Phoenix `app.css`, Tailwind v4
syntax, comments in awkward places, CRLF, a BOM, no trailing newline, minified
vendor CSS, non-ASCII content, and files that are simply broken — plus property
tests over generated and deliberately malformed input.

The mechanism is what makes them cheap: the parse is lossless, operations locate
**byte ranges** and splice text into the original string, and the tree is never
reprinted. Text outside an edit cannot change because nothing ever rewrites it.

## Usage

```elixir
css = """
@import "tailwindcss";
@source "../js";

.btn {
color: red; /* brand */
}
"""

{:ok, out} = IgniterCss.ensure_at_rule(css, ~s|@plugin "daisyui";|)
{:ok, out} = IgniterCss.set_declaration(out.source, ".btn", "color", "var(--brand)")

out.source
# @import "tailwindcss";
# @source "../js";
# @plugin "daisyui";
#
# .btn {
# color: var(--brand); /* brand */
# }
```

The `@plugin` line lands after the at-rule prologue rather than at the top of
the file, the inline comment survives, and running the same two calls again
changes nothing.

### Inside an Igniter installer

```elixir
{:igniter_css, "~> 0.1.1", only: [:dev, :test]}
def install(igniter, _opts) do
path = "assets/css/app.css"

igniter
|> IgniterCss.Codemods.ensure_at_rule(path, ~s|@plugin "daisyui";|)
|> IgniterCss.Codemods.ensure_rule(path, ".hide-scrollbar")
|> IgniterCss.Codemods.set_declaration(path, ".hide-scrollbar", "scrollbar-width", "none")
end
```

Callers get Igniter's normal diff preview and confirmation flow.

## Operations

**Codemods** (`IgniterCss`) — diff-minimal, idempotent:

| | |
|---|---|
| `ensure_at_rule/3`, `remove_at_rule/4` | `@import`, `@plugin`, `@source`, `@layer`, … |
| `add_import/4`, `remove_import/3` | `@import` convenience wrappers |
| `ensure_rule/4`, `remove_rule/3` | whole rules |
| `replace_rule_body/4`, `append_raw_to_rule/4` | rule bodies |
| `set_declaration/5`, `remove_declaration/4` | declarations |
| `add_vendor_prefixes/4` | prefixed copies of a property |
| `sort_properties/2`, `remove_duplicates/2` | tidying, by moving and deleting whole lines |

**Queries** — read-only: `has_rule?/3`, `has_declaration?/4`, `has_at_rule?/3`,
`get_declaration/4`, `get_rule_declarations/3`, `list_selectors/2`, `analyze/2`,
`validate/2`, `extract_colors/2`, `extract_media_queries/2`,
`extract_animations/2`.

**Whole-file transforms** (`IgniterCss.Transform`) — `minify/2`, `beautify/2`,
`merge_stylesheets/2`. These rewrite every byte by design and are kept out of
the codemod API deliberately. Do not use them to patch a file a user maintains.

`IgniterCss.Parsers.Parser` offers the same functionality on the
`{:ok, :function_name, result}` convention shared with `igniter_js`, and accepts
a file path as well as content.

## Selector matching

Matching is strict, because guessing is how a codemod produces a surprising
diff:

- **top-level rules only** — `.b` inside `@media print` is not matched;
- selectors compare on a normalised form (`.a>.b` matches `.a > .b`), never on
raw equality and never on a substring or fuzzy basis;
- a selector list matches as a whole — `.a` does not match `.a, .b`;
- **more than one match is an error**, not an arbitrary choice.

## Comment ownership on delete

When a codemod removes a declaration or a rule:

```css
/* ===== Layout ===== */ <- KEPT (reads as a section header)

/* used by the sidebar */ <- KEPT (blank line separates it from the target)

/* brand color */ <- DELETED (adjacent, on its own line)
color: red; /* legacy */ <- DELETED (the target and its trailing comment)
```

## Status
A section header is a comment spanning several lines, or one containing a rule
of three or more repeated `= - * # ~ _` characters.

## Tailwind v4

`@import`, `@plugin` (with and without a block), `@source`, `@theme`,
`@custom-variant`, `@variant`, `@utility`, `@apply`, `@layer` and `@reference`
all parse cleanly and are covered by the fixture corpus. Where a construct is
not in the grammar, Biome's error tolerance turns it into a node that still
carries its original text, so patching around it stays safe.

We are still working on getting this ready for an initial release.
## Development

```
mix test # Elixir suite
cd native/igniter_css && cargo test # Rust suite
mix check # format, credo, dialyzer, reuse
```

### Releasing

The cross-compile matrix in CI attaches a NIF per target to the GitHub release.
Once those artifacts exist, generate the checksum file — the package will not
work without it — and verify the tarball before publishing:

```
mix rustler_precompiled.download IgniterCss.Native --all --print
mix hex.build --unpack
```

The initial codemods will be limited to specific transformations. This is not intended to
be a toolkit (yet) for writing any arbitrary transformation like `Igniter` is for `Elixir`.
We will likely provide a way to do this by the user providing rust code and using our tools
to hook it up to igniter.
`checksum-Elixir.IgniterCss.Native.exs` is listed in `files:` in `mix.exs` and
is deliberately not committed: it is only meaningful once the release artifacts
it hashes exist.
Loading
Loading