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
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ pnpm workspace (`packages/*`), structured like app-compose:

- **Root** — private `@grlt-hub/react-slots-dev`: shared toolchain in devDependencies (typescript, tsdown, vitest + @vitest/browser + @vitest/browser-playwright + playwright, knip, oxlint, oxfmt), base `tsconfig.json`, `.oxfmtrc.json`, scripts delegate via `pnpm -r`.
- **`packages/react-slots`** — the published library: own runtime/peer deps plus package-specific devDeps (react, react-dom, happy-dom, @types/\*), own `tsconfig.json` (extends root), `tsdown.config.ts`, `vitest.config.ts`, `knip.json`, `.oxlintrc.json`, README, CHANGELOG, LICENSE (a copy of the root one — npm auto-includes LICENSE only from the package dir; CHANGELOG ships via `files`). `tsdown.config.ts` prepends a `"use client"` banner to both JS bundles (RSC boundary — a react-server build has no `useSyncExternalStore`, so hooks/Root would otherwise die deep in the shim; audit F6); the banner must NOT leak into the d.ts.
- **`packages/eslint-plugin`** — published `@grlt-hub/eslint-plugin-react-slots`, skeleton mirrors app-compose's eslint-plugin (`shared/{create,constants,tag}`, `setup.ts` RuleTester wiring, `ruleset.ts` recommended preset, `src/rules/<name>/<name>.ts` + test side by side). One rule: `insert-options-order` (`filter -> mapProps -> Component -> order`, autofixable, `warn` in recommended). Identification is **type-aware** (insert is an api method, not an import — see the package CLAUDE.md for the gate design and its test caveats); tests need `packages/react-slots/dist` built first. devDep `@typescript-eslint/rule-tester` must stay version-aligned with the `@typescript-eslint/utils` resolution.

## Commands

Expand Down
8 changes: 7 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,10 +1,16 @@
{
"name": "@grlt-hub/react-slots-dev",
"version": "4.0.0-beta.0",
"version": "4.0.0-beta.1",
"private": true,
"homepage": "https://github.com/grlt-hub/react-slots",
"license": "MIT",
"author": "Viktor Pasynok",
"contributors": [
{
"name": "Viktor Pasynok",
"url": "https://github.com/binjospookie"
}
],
"repository": {
"type": "git",
"url": "git+https://github.com/grlt-hub/react-slots.git"
Expand Down
8 changes: 8 additions & 0 deletions packages/eslint-plugin/.oxlintrc.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"$schema": "../../node_modules/oxlint/configuration_schema.json",
"plugins": ["eslint", "typescript", "unicorn", "oxc"],
"rules": {
"typescript/consistent-type-imports": ["error", { "fixStyle": "inline-type-imports" }],
"typescript/no-import-type-side-effects": "error"
}
}
10 changes: 10 additions & 0 deletions packages/eslint-plugin/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# Changelog

The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](http://semver.org).

## Unreleased

### Added

- Initial release with the `insert-options-order` rule: options of `insert` must be ordered `filter -> mapProps -> Component -> order` (auto-fixable).
20 changes: 20 additions & 0 deletions packages/eslint-plugin/CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# CLAUDE.md

This file provides context for AI assistants working on the ESLint plugin (`@grlt-hub/eslint-plugin-react-slots`). It extends the root `CLAUDE.md`.

## Rule conventions

- One directory per rule: `src/rules/<rule-name>/` holds `<rule-name>.ts` and `<rule-name>.test.ts` side by side (no `__tests__/` here).
- Rules are built with `createRule` from `@/shared/create` and match nodes via esquery selectors. Symbol and package names come from `UNITS` / `PACKAGE_NAME` in `@/shared/constants` — never hard-code them.
- `insert` is a method on the slot `api`, not a named import — the app-compose approach (tracking `ImportSpecifier` locals) cannot identify it, and the primary use case is cross-module (a plugin inserting into a slot imported from elsewhere), which no syntactic data-flow can follow. Identification is therefore **type-aware**: `services.getTypeAtLocation(callee).aliasSymbol` must be named `InsertWithProps` / `InsertWithoutProps` (the `payload.ts` aliases, preserved in the rolled-up d.ts) AND declared in a file whose path contains `/react-slots/`. This catches every alias by construction — member call, destructured `insert`, renamed destructuring, re-export — and rejects same-shape `insert`s from other libraries (pinned by the two "shape twin" valid cases; verified RED by removing the gate).
- The path check is `/react-slots/`, NOT `@grlt-hub/react-slots`: TS resolves pnpm workspace symlinks to realpaths (`packages/react-slots/dist/index.d.ts` in this repo's tests), so the scope is not reliably in the path. In consumer installs (npm flat, pnpm `.pnpm`) the path still contains `/react-slots/`.
- Check ordering in the handler is load-bearing for performance: all syntactic guards first (one object-literal argument, no spread/computed keys, key set ⊆ `{filter, mapProps, Component, order}` with `Component` required), then `isCorrectOrder` — and only for misordered shape-matching candidates the type gate. Clean files never touch the checker.
- The shape guard doubles as the false-positive firewall before the type gate and as fixer safety (unknown keys would sort wrong); both bail silently.
- The type gate means consumers MUST enable typed linting (`parserOptions.projectService`); without it `getParserServices` throws the standard typescript-eslint error. Same trade-off as app-compose's type-aware rules.
- Tests use `RuleTester` from `@typescript-eslint/rule-tester` with `projectService.allowDefaultProject` + `tsconfigRootDir: import.meta.dirname`; code samples resolve the real `@grlt-hub/react-slots` (workspace devDep), so its `dist/` must be built before tests run — CI builds first; a fresh clone running only `pnpm test` will fail in this package.
- Code samples are written with the `ts` template tag from `@/shared/tag`. Tag caveat: it dedents every line by the FIRST line's indent, so a multi-line string interpolated into a sample (the app-compose `${commonCode}` pattern) gets its inner lines mangled. Write each sample self-contained at one uniform indent.
- In `invalid` case outputs, the fixer emits properties at column 0 (`{\n<prop>,\n<prop>\n}`) — write expected output property lines at the template's base indent. Formatting the result is the consumer's formatter's job, same as app-compose.
- A new rule must be registered in both `src/index.ts` (`rules`) and `src/ruleset.ts` (`recommended`).
- Rule messages are user-facing docs copy: plain English; the same text appears in the package README — keep them in sync.
- Exception to "no default exports": rule modules and the plugin entry default-export, as the ESLint plugin contract expects.
- `@typescript-eslint/rule-tester` (exact-pinned devDep) must stay aligned with what `@typescript-eslint/utils` (caret dep) resolves to — a patch-level skew installs two copies of the types and `tsc --noEmit` fails on a `RuleModule` private-member mismatch.
21 changes: 21 additions & 0 deletions packages/eslint-plugin/LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
MIT License

Copyright (c) 2026 The grlt-hub Authors

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
70 changes: 70 additions & 0 deletions packages/eslint-plugin/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
# @grlt-hub/eslint-plugin-react-slots

ESLint plugin that enforces [React-Slots](https://github.com/grlt-hub/react-slots) conventions in TypeScript code.

[![npm version](https://img.shields.io/npm/v/%40grlt-hub%2Feslint-plugin-react-slots?color=orange)](https://www.npmjs.com/package/@grlt-hub/eslint-plugin-react-slots)
![npm license](https://img.shields.io/npm/l/%40grlt-hub%2Feslint-plugin-react-slots?color=blue)
[![npm provenance](https://img.shields.io/badge/provenance-yes-brightgreen?logo=npm)](https://www.npmjs.com/package/@grlt-hub/eslint-plugin-react-slots)

[React-Slots](https://github.com/grlt-hub/react-slots)

## Installation

```bash
npm install --save-dev --save-exact @grlt-hub/eslint-plugin-react-slots
```

Requires ESLint 9+, TypeScript 5+, and [typed linting](https://typescript-eslint.io/getting-started/typed-linting/) (`parserOptions.projectService`) — the rule identifies `insert` by its type, so it works through any alias: a member call, a destructured `insert`, or a slot imported from another module.

## Usage

Add the recommended preset to your flat config:

```js
import reactSlots from "@grlt-hub/eslint-plugin-react-slots"
import tseslint from "typescript-eslint"

export default tseslint.config(reactSlots.configs.recommended)
```

Or wire the plugin manually:

```js
{
plugins: { "react-slots": reactSlots },
rules: {
"react-slots/insert-options-order": "warn",
},
}
```

## Rules

- ⚠️ — set to `warn` in the `recommended` config
- 🔧 — auto-fixable

| Name | Description | ⚠️ | 🔧 |
| --------------------------------------------- | ---------------------------------- | --- | --- |
| [insert-options-order](#insert-options-order) | Enforce options order for `insert` | ⚠️ | 🔧 |

### insert-options-order

Options of `insert` must be ordered `filter -> mapProps -> Component -> order`. Missing options are fine — the rule only checks the relative order of the options that are present.

```js
// ✗ wrong
slots.Header.insert({
Component: (props) => <UserBadge name={props.userName} />,
mapProps: (slotProps) => ({ userName: getUserName(slotProps.userId) }),
})

// ✓ correct
slots.Header.insert({
mapProps: (slotProps) => ({ userName: getUserName(slotProps.userId) }),
Component: (props) => <UserBadge name={props.userName} />,
})
```

The order is load-bearing for types, not just style: with `mapProps` written above `filter`, TypeScript's type-predicate inference for `filter` does not fire — the call silently falls into the boolean overload and narrowing is lost without any error.

The rule is type-aware: it only fires on `insert` that comes from a slot created via `createSlot` — an `insert` from any other library is left alone.
13 changes: 13 additions & 0 deletions packages/eslint-plugin/knip.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"$schema": "https://unpkg.com/knip@6/schema.json",

"ignore": ["*.config.ts"],

"entry": ["./src/index.ts!"],

"ignoreBinaries": ["tsdown", "knip", "oxlint", "vitest"],

"ignoreDependencies": ["@grlt-hub/react-slots", "vitest"],

"vitest": { "config": "vitest.config.ts", "entry": "src/**/*.test.ts" }
}
70 changes: 70 additions & 0 deletions packages/eslint-plugin/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
{
"name": "@grlt-hub/eslint-plugin-react-slots",
"version": "4.0.0-beta.1",
"private": false,
"description": "Enforcing best practices for @grlt-hub/react-slots",
"keywords": [
"eslint",
"eslint-plugin",
"eslintplugin",
"grlt",
"grlt-hub",
"react-slots"
],
"homepage": "https://github.com/grlt-hub/react-slots",
"license": "MIT",
"author": "Viktor Pasynok",
"contributors": [
{
"name": "Viktor Pasynok",
"url": "https://github.com/binjospookie"
}
],
"repository": {
"type": "git",
"url": "git+https://github.com/grlt-hub/react-slots.git"
},
"files": [
"dist"
],
"type": "module",
"main": "./dist/index.cjs",
"module": "./dist/index.mjs",
"types": "./dist/index.d.cts",
"exports": {
".": {
"import": {
"types": "./dist/index.d.mts",
"default": "./dist/index.mjs"
},
"require": {
"types": "./dist/index.d.cts",
"default": "./dist/index.cjs"
}
},
"./package.json": "./package.json"
},
"publishConfig": {
"access": "public"
},
"scripts": {
"build": "tsc --noEmit -p tsconfig.json && tsdown",
"dev": "tsdown --watch",
"prepack": "pnpm build",
"test": "vitest run",
"lint": "knip && oxlint ./src"
},
"dependencies": {
"@typescript-eslint/utils": "^8.60.0"
},
"devDependencies": {
"@grlt-hub/react-slots": "workspace:*",
"@typescript-eslint/rule-tester": "8.60.1",
"eslint": "10.4.1"
},
"peerDependencies": {
"eslint": "^9.0.0 || ^10.0.0",
"typescript": "^5.0.0 || ^6.0.0"
},
"packageManager": "pnpm@10.24.0+sha512.01ff8ae71b4419903b65c60fb2dc9d34cf8bb6e06d03bde112ef38f7a34d6904c424ba66bea5cdcf12890230bf39f9580473140ed9c946fef328b6e5238a345a"
}
21 changes: 21 additions & 0 deletions packages/eslint-plugin/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import type { TSESLint } from "@typescript-eslint/utils"
import { name, version } from "../package.json"
import insertOptionsOrder from "./rules/insert-options-order/insert-options-order"
import { ruleset } from "./ruleset"

const base = {
meta: { name, version, namespace: "react-slots" },
rules: {
"insert-options-order": insertOptionsOrder,
},
}

const configs = {
recommended: { plugins: { "react-slots": base as TSESLint.FlatConfig.Plugin }, rules: ruleset.recommended },
}

const plugin = base as typeof base & { configs: typeof configs }

plugin.configs = configs

export default plugin
Loading
Loading