gemcss 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,24 @@
1
+ This is free and unencumbered software released into the public domain.
2
+
3
+ Anyone is free to copy, modify, publish, use, compile, sell, or
4
+ distribute this software, either in source code form or as a compiled
5
+ binary, for any purpose, commercial or non-commercial, and by any
6
+ means.
7
+
8
+ In jurisdictions that recognize copyright laws, the author or authors
9
+ of this software dedicate any and all copyright interest in the
10
+ software to the public domain. We make this dedication for the benefit
11
+ of the public at large and to the detriment of our heirs and
12
+ successors. We intend this dedication to be an overt act of
13
+ relinquishment in perpetuity of all present and future rights to this
14
+ software under copyright law.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
17
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
18
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
19
+ IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR
20
+ OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
21
+ ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
22
+ OTHER DEALINGS IN THE SOFTWARE.
23
+
24
+ For more information, please refer to <https://unlicense.org>
package/README.md ADDED
@@ -0,0 +1,178 @@
1
+ # gemcss
2
+
3
+ [![npm](https://img.shields.io/npm/v/gemcss.svg)](https://www.npmjs.com/package/gemcss)
4
+ [![CI](https://github.com/vkalinichev/gemcss/actions/workflows/ci.yml/badge.svg)](https://github.com/vkalinichev/gemcss/actions/workflows/ci.yml)
5
+
6
+ Type-safe BEM classes from CSS modules. Write a plain `*.module.css` in BEM
7
+ notation and get back an object where every block is a modifier-combinator
8
+ function, with types inferred from the CSS itself.
9
+
10
+ `button.module.css`
11
+
12
+ ```css
13
+ .button { border-radius: 6px; padding: 8px 14px; }
14
+ .button_disabled { opacity: 0.5; pointer-events: none; }
15
+ .button_variant_primary { background: #2563eb; color: #fff; }
16
+ .button_variant_ghost { background: transparent; color: #2563eb; }
17
+ .button_size_s { padding: 4px 10px; font-size: 13px; }
18
+ .button_size_m { padding: 8px 14px; font-size: 14px; }
19
+ .button_size_l { padding: 12px 20px; font-size: 16px; }
20
+ ```
21
+
22
+ `button.tsx`
23
+
24
+ ```tsx
25
+ import styles from './button.module.css';
26
+ // styles.button: (props?: {
27
+ // disabled?: boolean;
28
+ // variant?: 'primary' | 'ghost';
29
+ // size?: 's' | 'm' | 'l';
30
+ // }) => string
31
+
32
+ <button className={styles.button({ variant: 'primary', size: 'l' })} />;
33
+ ```
34
+
35
+ `styles.button()` returns the base class, `styles.button({ disabled: true })`
36
+ appends the modifier class. Class names are scoped (CSS modules), types come
37
+ from the CSS. A block with no modifiers is a no-arg call: `styles.root()`.
38
+
39
+ ## Naming convention
40
+
41
+ The separator is a single `_`.
42
+
43
+ | Class | Meaning |
44
+ | ------------------------- | ------------------------------------------- |
45
+ | `.button` | block `button` |
46
+ | `.button_disabled` | boolean modifier: `disabled?: boolean` |
47
+ | `.button_variant_primary` | enum modifier: `variant?: 'primary' \| ...` |
48
+
49
+ Rules:
50
+
51
+ - The first segment before `_` is the block name; a block cannot contain `_`.
52
+ - After the block comes exactly one modifier per class: 1 segment → boolean,
53
+ 2 segments → a `key_value` pair. More than that is a build error.
54
+ - The values of one enum key are collected from all classes of the block.
55
+
56
+ > Every export is a function. The base class is a no-arg call: `styles.root()`,
57
+ > `styles.button()`.
58
+
59
+ ## Install
60
+
61
+ One package for everything:
62
+
63
+ ```sh
64
+ pnpm add -D gemcss
65
+ ```
66
+
67
+ | Entry point | Purpose |
68
+ | ------------------ | ------------------------------------------------------------ |
69
+ | `gemcss/vite` | Vite plugin |
70
+ | `gemcss/webpack` | Webpack loader |
71
+ | `gemcss/ts-plugin` | TS language service plugin (types in the IDE) |
72
+ | `gemcss` (bin) | CLI: generates `.d.css.ts` for `tsc` / CI |
73
+ | `gemcss/runtime` | `createGems` — the tiny runtime the bundlers wire in for you |
74
+
75
+ `vite`, `webpack` and `typescript` are optional peer dependencies — you only
76
+ need the one whose entry point you actually use.
77
+
78
+ ## Setup
79
+
80
+ ### vite
81
+
82
+ ```ts
83
+ // vite.config.ts
84
+ import { defineConfig } from 'vite';
85
+ import { gemcss } from 'gemcss/vite';
86
+
87
+ export default defineConfig({
88
+ plugins: [gemcss()],
89
+ });
90
+ ```
91
+
92
+ ### webpack
93
+
94
+ `gemcss/webpack` is the loader itself:
95
+
96
+ ```js
97
+ // webpack.config.mjs
98
+ export default {
99
+ module: {
100
+ rules: [{ test: /\.module\.css$/, use: 'gemcss/webpack', type: 'javascript/auto' }],
101
+ },
102
+ };
103
+ ```
104
+
105
+ The rule replaces the `css-loader` + `style-loader` pair for `*.module.css` —
106
+ do not combine them on the same test: the CSS is injected into `document.head`
107
+ as a `<style data-gemcss="...">` tag.
108
+
109
+ ### Options (vite and webpack)
110
+
111
+ | Option | Type | Default | What it does |
112
+ | -------------------- | -------- | ------------------------------------ | -------------------------- |
113
+ | `generateScopedName` | `string` | `'[name]__[local]__[hash:base64:5]'` | Scoped class name template |
114
+
115
+ Vite: `gemcss({ generateScopedName: '…' })`. Webpack:
116
+ `use: { loader: 'gemcss/webpack', options: { generateScopedName: '…' } }`.
117
+
118
+ ### typescript (language server)
119
+
120
+ Types for `*.module.css` in the IDE, with no declaration files on disk:
121
+
122
+ ```jsonc
123
+ // tsconfig.json
124
+ {
125
+ "compilerOptions": {
126
+ "plugins": [{ "name": "gemcss/ts-plugin" }],
127
+ },
128
+ }
129
+ ```
130
+
131
+ In VS Code pick "Use Workspace Version" for TypeScript, otherwise the editor
132
+ runs its own tsserver and never loads the plugin.
133
+
134
+ For `tsc` and CI — where language service plugins do not run — generate the
135
+ `.d.css.ts` declarations and enable `allowArbitraryExtensions`:
136
+
137
+ ```jsonc
138
+ {
139
+ "compilerOptions": {
140
+ "allowArbitraryExtensions": true,
141
+ },
142
+ }
143
+ ```
144
+
145
+ ```sh
146
+ gemcss # generate *.module.d.css.ts next to every CSS module
147
+ gemcss "src/**/*.module.css" # only the given patterns
148
+ gemcss --watch # and regenerate on change
149
+ ```
150
+
151
+ | Option | What it does |
152
+ | ------------- | ----------------------------------------------- |
153
+ | `-w, --watch` | watch `*.module.css` under cwd and regenerate |
154
+ | `--cwd <dir>` | working directory (defaults to `process.cwd()`) |
155
+ | `-h, --help` | show help |
156
+
157
+ `node_modules` is always ignored. The `.d.css.ts` files are build artifacts —
158
+ add them to `.gitignore`. On a naming convention violation the CLI fails with a
159
+ `GemcssParseError` and a non-zero exit code, which makes it a usable CI check.
160
+
161
+ Both type mechanisms coexist: in the IDE the plugin wins (it intercepts the
162
+ `*.module.css` resolution), while compilation reads the generated `.d.css.ts`.
163
+
164
+ ## Development
165
+
166
+ ```sh
167
+ pnpm install
168
+ pnpm test # vitest
169
+ pnpm build # tsup + builds both examples (e2e)
170
+ pnpm check:exports # publint + are-the-types-wrong, against the real tarball
171
+ ```
172
+
173
+ Working examples: `examples/vite-react` (React), `examples/webpack` (vanilla
174
+ TS). Requires Node >= 22.14.
175
+
176
+ Releases go through [changesets](https://github.com/changesets/changesets): run
177
+ `pnpm changeset` alongside a change, and merging the generated Release PR
178
+ publishes to npm.
@@ -0,0 +1,89 @@
1
+ // src/core/parser.ts
2
+ import postcss from "postcss";
3
+ import selectorParser from "postcss-selector-parser";
4
+ var GemcssParseError = class extends Error {
5
+ constructor(message) {
6
+ super(message);
7
+ this.name = "GemcssParseError";
8
+ }
9
+ };
10
+ function ctx(from) {
11
+ return from ? ` (in ${from})` : "";
12
+ }
13
+ function parseCss(css, opts = {}) {
14
+ const root = postcss.parse(css, { from: opts.from });
15
+ const classNames = [];
16
+ const seen = /* @__PURE__ */ new Set();
17
+ root.walkRules((rule) => {
18
+ selectorParser((selectors) => {
19
+ selectors.walkClasses((cls) => {
20
+ if (!seen.has(cls.value)) {
21
+ seen.add(cls.value);
22
+ classNames.push(cls.value);
23
+ }
24
+ });
25
+ }).processSync(rule.selector);
26
+ });
27
+ return buildModule(classNames, opts.from);
28
+ }
29
+ function buildModule(classNames, from) {
30
+ const blocks = {};
31
+ for (const raw of classNames) {
32
+ const segments = raw.split("_");
33
+ const blockName = segments[0];
34
+ if (!blockName) {
35
+ throw new GemcssParseError(`Empty block name in class ".${raw}"${ctx(from)}`);
36
+ }
37
+ const block = blocks[blockName] ??= {
38
+ name: blockName,
39
+ modifiers: {}
40
+ };
41
+ const tail = segments.slice(1);
42
+ if (tail.length === 0) continue;
43
+ if (tail.length === 1) {
44
+ const key = tail[0];
45
+ if (!key) throw new GemcssParseError(`Empty modifier in class ".${raw}"${ctx(from)}`);
46
+ assignBool(block, key, raw, from);
47
+ continue;
48
+ }
49
+ if (tail.length === 2) {
50
+ const key = tail[0];
51
+ const value = tail[1];
52
+ if (!key || !value) {
53
+ throw new GemcssParseError(`Empty modifier key/value in class ".${raw}"${ctx(from)}`);
54
+ }
55
+ assignEnum(block, key, value, raw, from);
56
+ continue;
57
+ }
58
+ throw new GemcssParseError(
59
+ `Class ".${raw}" has more than one modifier segment after block "${blockName}". Allowed: block, block_bool, block_key_value.${ctx(from)}`
60
+ );
61
+ }
62
+ return { blocks };
63
+ }
64
+ function assignBool(block, key, raw, from) {
65
+ const existing = block.modifiers[key];
66
+ if (existing && existing.kind === "enum") {
67
+ throw new GemcssParseError(
68
+ `Modifier "${key}" of block "${block.name}" used as both boolean (".${raw}") and enum.${ctx(from)}`
69
+ );
70
+ }
71
+ block.modifiers[key] = { kind: "bool" };
72
+ }
73
+ function assignEnum(block, key, value, raw, from) {
74
+ const existing = block.modifiers[key];
75
+ if (existing && existing.kind === "bool") {
76
+ throw new GemcssParseError(
77
+ `Modifier "${key}" of block "${block.name}" used as both enum (".${raw}") and boolean.${ctx(from)}`
78
+ );
79
+ }
80
+ const mod = existing && existing.kind === "enum" ? existing : block.modifiers[key] = { kind: "enum", values: [] };
81
+ if (!mod.values.includes(value)) {
82
+ mod.values.push(value);
83
+ }
84
+ }
85
+
86
+ export {
87
+ GemcssParseError,
88
+ parseCss
89
+ };
@@ -0,0 +1,31 @@
1
+ import {
2
+ parseCss
3
+ } from "./chunk-CNXDOGPX.js";
4
+
5
+ // src/core/compile.ts
6
+ import postcss from "postcss";
7
+ import postcssModules from "postcss-modules";
8
+ async function compile(code, id, opts = {}) {
9
+ let scoped = {};
10
+ const result = await postcss([
11
+ postcssModules({
12
+ generateScopedName: opts.generateScopedName ?? "[name]__[local]__[hash:base64:5]",
13
+ getJSON: (_file, json) => {
14
+ scoped = json;
15
+ }
16
+ })
17
+ ]).process(code, { from: id });
18
+ const module = parseCss(code, { from: id });
19
+ return { css: result.css, scoped, module };
20
+ }
21
+
22
+ // src/core/runtime-path.ts
23
+ import { createRequire } from "module";
24
+ function runtimePath() {
25
+ return createRequire(import.meta.url).resolve("gemcss/runtime");
26
+ }
27
+
28
+ export {
29
+ compile,
30
+ runtimePath
31
+ };
package/dist/cli.d.ts ADDED
@@ -0,0 +1 @@
1
+ #!/usr/bin/env node
package/dist/cli.js ADDED
@@ -0,0 +1,137 @@
1
+ #!/usr/bin/env node
2
+ import {
3
+ GemcssParseError,
4
+ parseCss
5
+ } from "./chunk-CNXDOGPX.js";
6
+
7
+ // src/cli.ts
8
+ import { watch as fsWatch } from "fs";
9
+ import { stat } from "fs/promises";
10
+ import { resolve as resolve2 } from "path";
11
+ import { parseArgs } from "util";
12
+
13
+ // src/cli/gen.ts
14
+ import { glob, readFile, rm, writeFile } from "fs/promises";
15
+ import { resolve } from "path";
16
+
17
+ // src/core/dts.ts
18
+ var IDENT = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
19
+ function propKey(name) {
20
+ return IDENT.test(name) ? name : JSON.stringify(name);
21
+ }
22
+ function strLit(value) {
23
+ return `'${value.replace(/\\/g, "\\\\").replace(/'/g, "\\'")}'`;
24
+ }
25
+ function propsParam(block) {
26
+ const keys = Object.keys(block.modifiers);
27
+ if (keys.length === 0) return "";
28
+ const fields = keys.map((k) => {
29
+ const mod = block.modifiers[k];
30
+ const type = mod.kind === "bool" ? "boolean" : mod.values.map(strLit).join(" | ");
31
+ return `${propKey(k)}?: ${type}`;
32
+ });
33
+ return `props?: { ${fields.join("; ")} }`;
34
+ }
35
+ function generateDts(module) {
36
+ const lines = ["declare const gems: {"];
37
+ for (const block of Object.values(module.blocks)) {
38
+ lines.push(` ${propKey(block.name)}(${propsParam(block)}): string;`);
39
+ }
40
+ lines.push("};");
41
+ lines.push("export default gems;");
42
+ lines.push("");
43
+ return lines.join("\n");
44
+ }
45
+
46
+ // src/cli/gen.ts
47
+ var DEFAULT_PATTERNS = ["**/*.module.css"];
48
+ function dtsPathFor(cssFile) {
49
+ return cssFile.replace(/\.css$/, ".d.css.ts");
50
+ }
51
+ async function generateOne(cssFile) {
52
+ const css = await readFile(cssFile, "utf8");
53
+ const dts = generateDts(parseCss(css, { from: cssFile }));
54
+ const out = dtsPathFor(cssFile);
55
+ await writeFile(out, dts);
56
+ return out;
57
+ }
58
+ async function removeOne(cssFile) {
59
+ await rm(dtsPathFor(cssFile), { force: true });
60
+ }
61
+ async function run(patterns, opts = {}) {
62
+ const cwd = opts.cwd ?? process.cwd();
63
+ const files = glob(patterns.length ? patterns : DEFAULT_PATTERNS, {
64
+ cwd,
65
+ exclude: ["**/node_modules/**"]
66
+ });
67
+ const written = [];
68
+ for await (const file of files) {
69
+ written.push(await generateOne(resolve(cwd, file)));
70
+ }
71
+ return written;
72
+ }
73
+
74
+ // src/cli.ts
75
+ var USAGE = `gemcss \u2014 generate .d.css.ts declarations for CSS modules
76
+
77
+ Usage:
78
+ gemcss [patterns...] [options]
79
+
80
+ Options:
81
+ -w, --watch watch and regenerate on change
82
+ --cwd <dir> working directory (default: process.cwd())
83
+ -h, --help show this help
84
+
85
+ Default pattern: ${DEFAULT_PATTERNS.join(" ")}
86
+ Watch mode follows every *.module.css under cwd (node_modules ignored).`;
87
+ async function main() {
88
+ const { values, positionals } = parseArgs({
89
+ allowPositionals: true,
90
+ options: {
91
+ watch: { type: "boolean", short: "w" },
92
+ cwd: { type: "string" },
93
+ help: { type: "boolean", short: "h" }
94
+ }
95
+ });
96
+ if (values.help) {
97
+ console.log(USAGE);
98
+ return;
99
+ }
100
+ const cwd = resolve2(values.cwd ?? process.cwd());
101
+ try {
102
+ const written = await run(positionals, { cwd });
103
+ for (const file of written) console.log(`generated ${file}`);
104
+ console.log(`gemcss: ${written.length} file(s)`);
105
+ } catch (err) {
106
+ fail(err);
107
+ if (!values.watch) process.exit(1);
108
+ }
109
+ if (values.watch) watch(cwd);
110
+ }
111
+ function watch(cwd) {
112
+ fsWatch(cwd, { recursive: true }, (_event, rel) => {
113
+ if (!rel || !rel.endsWith(".module.css") || rel.includes("node_modules")) return;
114
+ void onChange(resolve2(cwd, rel));
115
+ });
116
+ console.log(`gemcss: watching ${cwd}`);
117
+ }
118
+ async function onChange(file) {
119
+ try {
120
+ await stat(file);
121
+ } catch {
122
+ return removeOne(file);
123
+ }
124
+ try {
125
+ console.log(`generated ${await generateOne(file)}`);
126
+ } catch (err) {
127
+ fail(err);
128
+ }
129
+ }
130
+ function fail(err) {
131
+ if (err instanceof GemcssParseError) {
132
+ console.error(`gemcss: ${err.message}`);
133
+ } else {
134
+ console.error(err);
135
+ }
136
+ }
137
+ void main();
@@ -0,0 +1,6 @@
1
+ interface CompileOptions {
2
+ /** Scoped name template for postcss-modules. */
3
+ generateScopedName?: string;
4
+ }
5
+
6
+ export type { CompileOptions as C };
@@ -0,0 +1,12 @@
1
+ /** Props passed to a gem function: boolean flags and enum values. */
2
+ type GemProps = Record<string, string | boolean | undefined>;
3
+ /** A block's class combinator function. */
4
+ type Gem = (props?: GemProps) => string;
5
+ /**
6
+ * Build the `gems` object from a list of block names and a `raw → scoped` map.
7
+ * Every block is a function: called with no arguments it returns the base class,
8
+ * called with props it appends the classes of the active modifiers.
9
+ */
10
+ declare function createGems(blocks: string[], scoped: Record<string, string>): Record<string, Gem>;
11
+
12
+ export { type Gem, type GemProps, createGems };
@@ -0,0 +1,24 @@
1
+ // src/runtime.ts
2
+ function createGems(blocks, scoped) {
3
+ const gems = {};
4
+ for (const name of blocks) {
5
+ const base = scoped[name] ?? name;
6
+ gems[name] = (props) => {
7
+ if (!props) return base;
8
+ let out = base;
9
+ for (const key in props) {
10
+ const v = props[key];
11
+ if (v === true) {
12
+ out += " " + (scoped[`${name}_${key}`] ?? `${name}_${key}`);
13
+ } else if (v != null && v !== false) {
14
+ out += " " + (scoped[`${name}_${key}_${v}`] ?? `${name}_${key}_${v}`);
15
+ }
16
+ }
17
+ return out;
18
+ };
19
+ }
20
+ return gems;
21
+ }
22
+ export {
23
+ createGems
24
+ };
@@ -0,0 +1,206 @@
1
+ "use strict";
2
+ var __create = Object.create;
3
+ var __defProp = Object.defineProperty;
4
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
5
+ var __getOwnPropNames = Object.getOwnPropertyNames;
6
+ var __getProtoOf = Object.getPrototypeOf;
7
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
8
+ var __copyProps = (to, from, except, desc) => {
9
+ if (from && typeof from === "object" || typeof from === "function") {
10
+ for (let key of __getOwnPropNames(from))
11
+ if (!__hasOwnProp.call(to, key) && key !== except)
12
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
13
+ }
14
+ return to;
15
+ };
16
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
17
+ // If the importer is in node compatibility mode or this is not an ESM
18
+ // file that has been converted to a CommonJS file using a Babel-
19
+ // compatible transform (i.e. "__esModule" has not been set), then set
20
+ // "default" to the CommonJS "module.exports" for node compatibility.
21
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
22
+ mod
23
+ ));
24
+
25
+ // src/ts-plugin.ts
26
+ var import_node_path = require("path");
27
+
28
+ // src/ts-plugin/generate-dts.ts
29
+ var import_node_fs = require("fs");
30
+
31
+ // src/core/dts.ts
32
+ var IDENT = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
33
+ function propKey(name) {
34
+ return IDENT.test(name) ? name : JSON.stringify(name);
35
+ }
36
+ function strLit(value) {
37
+ return `'${value.replace(/\\/g, "\\\\").replace(/'/g, "\\'")}'`;
38
+ }
39
+ function propsParam(block) {
40
+ const keys = Object.keys(block.modifiers);
41
+ if (keys.length === 0) return "";
42
+ const fields = keys.map((k) => {
43
+ const mod = block.modifiers[k];
44
+ const type = mod.kind === "bool" ? "boolean" : mod.values.map(strLit).join(" | ");
45
+ return `${propKey(k)}?: ${type}`;
46
+ });
47
+ return `props?: { ${fields.join("; ")} }`;
48
+ }
49
+ function generateDts(module2) {
50
+ const lines = ["declare const gems: {"];
51
+ for (const block of Object.values(module2.blocks)) {
52
+ lines.push(` ${propKey(block.name)}(${propsParam(block)}): string;`);
53
+ }
54
+ lines.push("};");
55
+ lines.push("export default gems;");
56
+ lines.push("");
57
+ return lines.join("\n");
58
+ }
59
+
60
+ // src/core/parser.ts
61
+ var import_postcss = __toESM(require("postcss"), 1);
62
+ var import_postcss_selector_parser = __toESM(require("postcss-selector-parser"), 1);
63
+ var GemcssParseError = class extends Error {
64
+ constructor(message) {
65
+ super(message);
66
+ this.name = "GemcssParseError";
67
+ }
68
+ };
69
+ function ctx(from) {
70
+ return from ? ` (in ${from})` : "";
71
+ }
72
+ function parseCss(css, opts = {}) {
73
+ const root = import_postcss.default.parse(css, { from: opts.from });
74
+ const classNames = [];
75
+ const seen = /* @__PURE__ */ new Set();
76
+ root.walkRules((rule) => {
77
+ (0, import_postcss_selector_parser.default)((selectors) => {
78
+ selectors.walkClasses((cls) => {
79
+ if (!seen.has(cls.value)) {
80
+ seen.add(cls.value);
81
+ classNames.push(cls.value);
82
+ }
83
+ });
84
+ }).processSync(rule.selector);
85
+ });
86
+ return buildModule(classNames, opts.from);
87
+ }
88
+ function buildModule(classNames, from) {
89
+ const blocks = {};
90
+ for (const raw of classNames) {
91
+ const segments = raw.split("_");
92
+ const blockName = segments[0];
93
+ if (!blockName) {
94
+ throw new GemcssParseError(`Empty block name in class ".${raw}"${ctx(from)}`);
95
+ }
96
+ const block = blocks[blockName] ??= {
97
+ name: blockName,
98
+ modifiers: {}
99
+ };
100
+ const tail = segments.slice(1);
101
+ if (tail.length === 0) continue;
102
+ if (tail.length === 1) {
103
+ const key = tail[0];
104
+ if (!key) throw new GemcssParseError(`Empty modifier in class ".${raw}"${ctx(from)}`);
105
+ assignBool(block, key, raw, from);
106
+ continue;
107
+ }
108
+ if (tail.length === 2) {
109
+ const key = tail[0];
110
+ const value = tail[1];
111
+ if (!key || !value) {
112
+ throw new GemcssParseError(`Empty modifier key/value in class ".${raw}"${ctx(from)}`);
113
+ }
114
+ assignEnum(block, key, value, raw, from);
115
+ continue;
116
+ }
117
+ throw new GemcssParseError(
118
+ `Class ".${raw}" has more than one modifier segment after block "${blockName}". Allowed: block, block_bool, block_key_value.${ctx(from)}`
119
+ );
120
+ }
121
+ return { blocks };
122
+ }
123
+ function assignBool(block, key, raw, from) {
124
+ const existing = block.modifiers[key];
125
+ if (existing && existing.kind === "enum") {
126
+ throw new GemcssParseError(
127
+ `Modifier "${key}" of block "${block.name}" used as both boolean (".${raw}") and enum.${ctx(from)}`
128
+ );
129
+ }
130
+ block.modifiers[key] = { kind: "bool" };
131
+ }
132
+ function assignEnum(block, key, value, raw, from) {
133
+ const existing = block.modifiers[key];
134
+ if (existing && existing.kind === "bool") {
135
+ throw new GemcssParseError(
136
+ `Modifier "${key}" of block "${block.name}" used as both enum (".${raw}") and boolean.${ctx(from)}`
137
+ );
138
+ }
139
+ const mod = existing && existing.kind === "enum" ? existing : block.modifiers[key] = { kind: "enum", values: [] };
140
+ if (!mod.values.includes(value)) {
141
+ mod.values.push(value);
142
+ }
143
+ }
144
+
145
+ // src/ts-plugin/generate-dts.ts
146
+ var FALLBACK = "declare const gems: Record<string, (props?: Record<string, string | boolean>) => string>;\nexport default gems;\n";
147
+ function dtsForFile(fileName, read = defaultRead) {
148
+ const css = read(fileName);
149
+ if (css == null) return FALLBACK;
150
+ try {
151
+ return generateDts(parseCss(css, { from: fileName }));
152
+ } catch {
153
+ return FALLBACK;
154
+ }
155
+ }
156
+ function defaultRead(f) {
157
+ try {
158
+ return (0, import_node_fs.readFileSync)(f, "utf8");
159
+ } catch {
160
+ return void 0;
161
+ }
162
+ }
163
+
164
+ // src/ts-plugin.ts
165
+ var MODULE_CSS = /\.module\.css$/;
166
+ function init({ typescript: ts }) {
167
+ function create(info) {
168
+ const host = info.languageServiceHost;
169
+ const read = (f) => host.readFile?.(f);
170
+ const origKind = host.getScriptKind?.bind(host);
171
+ host.getScriptKind = (fileName) => {
172
+ if (MODULE_CSS.test(fileName)) return ts.ScriptKind.TS;
173
+ return origKind ? origKind(fileName) : ts.ScriptKind.Unknown;
174
+ };
175
+ const origSnapshot = host.getScriptSnapshot.bind(host);
176
+ host.getScriptSnapshot = (fileName) => {
177
+ if (MODULE_CSS.test(fileName)) {
178
+ return ts.ScriptSnapshot.fromString(dtsForFile(fileName, read));
179
+ }
180
+ return origSnapshot(fileName);
181
+ };
182
+ if (host.resolveModuleNameLiterals) {
183
+ const origResolve = host.resolveModuleNameLiterals.bind(host);
184
+ host.resolveModuleNameLiterals = (literals, containingFile, ...rest) => {
185
+ const resolved = origResolve(literals, containingFile, ...rest);
186
+ return literals.map((literal, i) => {
187
+ const name = literal.text;
188
+ if (MODULE_CSS.test(name)) {
189
+ const fileName = name.startsWith(".") ? (0, import_node_path.resolve)((0, import_node_path.dirname)(containingFile), name) : name;
190
+ return {
191
+ resolvedModule: {
192
+ resolvedFileName: fileName,
193
+ extension: ts.Extension.Dts,
194
+ isExternalLibraryImport: false
195
+ }
196
+ };
197
+ }
198
+ return resolved[i];
199
+ });
200
+ };
201
+ }
202
+ return info.languageService;
203
+ }
204
+ return { create };
205
+ }
206
+ module.exports = init;
@@ -0,0 +1,15 @@
1
+ import tsModule from 'typescript/lib/tsserverlibrary';
2
+
3
+ interface InitArgs {
4
+ typescript: typeof tsModule;
5
+ }
6
+ /**
7
+ * TS language service plugin: types for `import gems from './x.module.css'`
8
+ * without declaration files on disk. Patches the languageServiceHost:
9
+ * - getScriptKind -> TS for `*.module.css`
10
+ * - getScriptSnapshot -> the generated `.d.ts`
11
+ * - resolveModuleNameLiterals -> resolves the import to that virtual Dts
12
+ */
13
+ declare function init({ typescript: ts }: InitArgs): tsModule.server.PluginModule;
14
+
15
+ export = init;
package/dist/vite.d.ts ADDED
@@ -0,0 +1,20 @@
1
+ import { Plugin } from 'vite';
2
+ import { C as CompileOptions } from './compile-Br7cHtI1.js';
3
+
4
+ /**
5
+ * Compile a CSS module into a gems JS module plus a virtual CSS id.
6
+ * A pure function — testable without Vite.
7
+ */
8
+ declare function buildGemsModule(file: string, code: string, options?: CompileOptions, runtimeImport?: string): Promise<{
9
+ js: string;
10
+ cssId: string;
11
+ css: string;
12
+ }>;
13
+ /**
14
+ * gemcss Vite plugin: `*.module.css` -> a typed `gems` object.
15
+ * The import resolves to a virtual JS module; the CSS goes back into the Vite
16
+ * pipeline as a separate virtual `.css` import so it still gets injected.
17
+ */
18
+ declare function gemcss(options?: CompileOptions): Plugin;
19
+
20
+ export { buildGemsModule, gemcss as default, gemcss };
package/dist/vite.js ADDED
@@ -0,0 +1,60 @@
1
+ import {
2
+ compile,
3
+ runtimePath
4
+ } from "./chunk-DMYDRHTS.js";
5
+ import "./chunk-CNXDOGPX.js";
6
+
7
+ // src/vite.ts
8
+ import { readFile } from "fs/promises";
9
+ var MODULE_CSS_RE = /\.module\.css$/;
10
+ var VIRTUAL_JS = "\0gemcss-js:";
11
+ var VIRTUAL_CSS = "\0gemcss-css:";
12
+ function isModuleCss(source) {
13
+ return MODULE_CSS_RE.test(source.split("?", 1)[0] ?? source);
14
+ }
15
+ var enc = (s) => Buffer.from(s).toString("base64url");
16
+ var dec = (s) => Buffer.from(s, "base64url").toString("utf8");
17
+ async function buildGemsModule(file, code, options = {}, runtimeImport = "gemcss/runtime") {
18
+ const { css, scoped, module } = await compile(code, file, options);
19
+ const cssId = `${VIRTUAL_CSS}${enc(file)}.css`;
20
+ const js = `import ${JSON.stringify(cssId)};
21
+ import { createGems } from ${JSON.stringify(runtimeImport)};
22
+ export default createGems(${JSON.stringify(Object.keys(module.blocks))}, ${JSON.stringify(scoped)});
23
+ `;
24
+ return { js, cssId, css };
25
+ }
26
+ function gemcss(options = {}) {
27
+ const cssStore = /* @__PURE__ */ new Map();
28
+ return {
29
+ name: "gemcss",
30
+ enforce: "pre",
31
+ async resolveId(source, importer) {
32
+ if (source.startsWith(VIRTUAL_JS) || source.startsWith(VIRTUAL_CSS)) return source;
33
+ if (isModuleCss(source)) {
34
+ const resolved = await this.resolve(source, importer, { skipSelf: true });
35
+ if (resolved) return VIRTUAL_JS + enc(resolved.id);
36
+ }
37
+ return null;
38
+ },
39
+ async load(id) {
40
+ if (id.startsWith(VIRTUAL_CSS)) {
41
+ return cssStore.get(id) ?? "";
42
+ }
43
+ if (id.startsWith(VIRTUAL_JS)) {
44
+ const file = dec(id.slice(VIRTUAL_JS.length));
45
+ this.addWatchFile(file);
46
+ const code = await readFile(file, "utf8");
47
+ const { js, cssId, css } = await buildGemsModule(file, code, options, runtimePath());
48
+ cssStore.set(cssId, css);
49
+ return js;
50
+ }
51
+ return null;
52
+ }
53
+ };
54
+ }
55
+ var vite_default = gemcss;
56
+ export {
57
+ buildGemsModule,
58
+ vite_default as default,
59
+ gemcss
60
+ };
@@ -0,0 +1,13 @@
1
+ import { LoaderContext } from 'webpack';
2
+ import { C as CompileOptions } from './compile-Br7cHtI1.js';
3
+
4
+ type GemcssLoaderOptions = CompileOptions;
5
+ /**
6
+ * Build the JS module for webpack: runtime CSS injection + a `gems` default export.
7
+ * A pure function — testable without webpack.
8
+ */
9
+ declare function buildGemsModule(code: string, resourcePath: string, options?: CompileOptions, runtimeImport?: string): Promise<string>;
10
+ /** Webpack loader: `*.module.css` -> a `gems` JS module that injects its CSS at runtime. */
11
+ declare function gemcssLoader(this: LoaderContext<GemcssLoaderOptions>, source: string): void;
12
+
13
+ export { type GemcssLoaderOptions, buildGemsModule, gemcssLoader as default };
@@ -0,0 +1,32 @@
1
+ import {
2
+ compile,
3
+ runtimePath
4
+ } from "./chunk-DMYDRHTS.js";
5
+ import "./chunk-CNXDOGPX.js";
6
+
7
+ // src/webpack.ts
8
+ async function buildGemsModule(code, resourcePath, options = {}, runtimeImport = runtimePath()) {
9
+ const { css, scoped, module } = await compile(code, resourcePath, options);
10
+ return `import { createGems } from ${JSON.stringify(runtimeImport)};
11
+ const __css = ${JSON.stringify(css)};
12
+ if (typeof document !== 'undefined') {
13
+ const __el = document.createElement('style');
14
+ __el.setAttribute('data-gemcss', ${JSON.stringify(resourcePath)});
15
+ __el.textContent = __css;
16
+ document.head.appendChild(__el);
17
+ }
18
+ export default createGems(${JSON.stringify(Object.keys(module.blocks))}, ${JSON.stringify(scoped)});
19
+ `;
20
+ }
21
+ function gemcssLoader(source) {
22
+ const callback = this.async();
23
+ const options = this.getOptions?.() ?? {};
24
+ buildGemsModule(source, this.resourcePath, options).then(
25
+ (js) => callback(null, js),
26
+ (err) => callback(err instanceof Error ? err : new Error(String(err)))
27
+ );
28
+ }
29
+ export {
30
+ buildGemsModule,
31
+ gemcssLoader as default
32
+ };
package/package.json ADDED
@@ -0,0 +1,101 @@
1
+ {
2
+ "name": "gemcss",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "Typed BEM classes from CSS modules — every block becomes a modifier-combinator function, with types inferred from the CSS",
6
+ "keywords": [
7
+ "css-modules",
8
+ "bem",
9
+ "typescript",
10
+ "vite-plugin",
11
+ "webpack-loader",
12
+ "css",
13
+ "typed-css",
14
+ "classnames"
15
+ ],
16
+ "license": "Unlicense",
17
+ "author": "Vladimir Kalinichev <wrumly@gmail.com>",
18
+ "repository": {
19
+ "type": "git",
20
+ "url": "git+https://github.com/vkalinichev/gemcss.git"
21
+ },
22
+ "bugs": "https://github.com/vkalinichev/gemcss/issues",
23
+ "homepage": "https://github.com/vkalinichev/gemcss#readme",
24
+ "packageManager": "pnpm@11.12.0",
25
+ "engines": {
26
+ "node": ">=22.14"
27
+ },
28
+ "bin": {
29
+ "gemcss": "./dist/cli.js"
30
+ },
31
+ "exports": {
32
+ "./runtime": {
33
+ "types": "./dist/runtime.d.ts",
34
+ "default": "./dist/runtime.js"
35
+ },
36
+ "./vite": {
37
+ "types": "./dist/vite.d.ts",
38
+ "default": "./dist/vite.js"
39
+ },
40
+ "./webpack": {
41
+ "types": "./dist/webpack.d.ts",
42
+ "default": "./dist/webpack.js"
43
+ },
44
+ "./ts-plugin": {
45
+ "types": "./dist/ts-plugin.d.cts",
46
+ "default": "./dist/ts-plugin.cjs"
47
+ }
48
+ },
49
+ "files": [
50
+ "dist",
51
+ "ts-plugin"
52
+ ],
53
+ "scripts": {
54
+ "build": "pnpm build:lib && pnpm -r --filter 'example-*' build",
55
+ "build:lib": "pnpm clean && tsup && node scripts/fix-ts-plugin-dts.mjs",
56
+ "clean": "node -e \"fs.rmSync('dist',{recursive:true,force:true})\"",
57
+ "test": "vitest run",
58
+ "test:watch": "vitest",
59
+ "typecheck": "tsc --noEmit",
60
+ "lint": "eslint .",
61
+ "format": "prettier --write .",
62
+ "check:exports": "publint --strict && attw --pack . --profile esm-only",
63
+ "release": "changeset publish",
64
+ "prepublishOnly": "pnpm build:lib"
65
+ },
66
+ "dependencies": {
67
+ "postcss": "^8.4.49",
68
+ "postcss-modules": "^6.0.1",
69
+ "postcss-selector-parser": "^7.0.0"
70
+ },
71
+ "peerDependencies": {
72
+ "typescript": "^5",
73
+ "vite": "^5 || ^6 || ^7",
74
+ "webpack": "^5"
75
+ },
76
+ "peerDependenciesMeta": {
77
+ "typescript": {
78
+ "optional": true
79
+ },
80
+ "vite": {
81
+ "optional": true
82
+ },
83
+ "webpack": {
84
+ "optional": true
85
+ }
86
+ },
87
+ "devDependencies": {
88
+ "@arethetypeswrong/cli": "0.18.5",
89
+ "@changesets/cli": "2.31.0",
90
+ "@types/node": "^22.10.2",
91
+ "eslint": "^9.17.0",
92
+ "prettier": "^3.4.2",
93
+ "publint": "0.3.21",
94
+ "tsup": "^8.3.5",
95
+ "typescript": "^5.7.2",
96
+ "typescript-eslint": "^8.18.1",
97
+ "vite": "^6.0.0",
98
+ "vitest": "^2.1.8",
99
+ "webpack": "^5.97.1"
100
+ }
101
+ }
@@ -0,0 +1,4 @@
1
+ {
2
+ "main": "../dist/ts-plugin.cjs",
3
+ "types": "../dist/ts-plugin.d.cts"
4
+ }