@gravitee/graphene-core 3.10.0 → 3.11.0-fix-found-211-ajv-unknown-format.338f8cb
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/USAGE_GUIDE.md +58 -0
- package/dist/composed/JsonSchemaForm/form-bindings/jsonSchemaResolver.d.ts.map +1 -1
- package/dist/index.js +4 -2
- package/dist/styles/_probe-preset.css +19 -0
- package/dist/styles/theme-directives.css +84 -0
- package/package.json +7 -1
- package/scripts/check-classes.mjs +409 -0
package/USAGE_GUIDE.md
CHANGED
|
@@ -17,6 +17,7 @@ This project uses the Graphene design system (`@gravitee/graphene-core`). Follow
|
|
|
17
17
|
| `@gravitee/graphene-core/tailwind-theme` | Constrained Tailwind v4 theme: resets default palette/spacing/type and maps utilities to Graphene tokens. Use only if the app runs Tailwind for its own code. |
|
|
18
18
|
| `@gravitee/graphene-core/testing` | `renderWithGraphene` plus per-component harnesses (`buttonHarness`, `inputHarness`, …) for tests. See the **Testing** section below. |
|
|
19
19
|
| `@gravitee/graphene-core/eslint` / `@gravitee/graphene-core/tsconfig` | Shared tooling configs |
|
|
20
|
+
| `graphene-check-classes` (bin) | Fails if your source uses a class the pre-built bundle does not contain. See **Verifying class coverage** below. |
|
|
20
21
|
|
|
21
22
|
## Dependencies (what the host needs)
|
|
22
23
|
|
|
@@ -116,6 +117,63 @@ import graphene from '@gravitee/graphene-core/eslint';
|
|
|
116
117
|
export default [...graphene, { settings: { 'better-tailwindcss': { entryPoint: './src/app.css' } } }];
|
|
117
118
|
```
|
|
118
119
|
|
|
120
|
+
### Verifying class coverage (`graphene-check-classes`)
|
|
121
|
+
|
|
122
|
+
```bash
|
|
123
|
+
npx graphene-check-classes src
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
Exits non-zero listing every class your source uses that is not in the pre-built bundle, and the files each one
|
|
127
|
+
came from. Nothing to configure — the checker brings its own Tailwind. Run it in CI next to your typecheck.
|
|
128
|
+
|
|
129
|
+
```
|
|
130
|
+
graphene-check-classes <source>... directories or globs; each is reported separately.
|
|
131
|
+
A path that matches nothing is an error, not a pass
|
|
132
|
+
--advisory <src> report but never fail the run
|
|
133
|
+
--exclude <glob> extra exclusion, relative to the current directory
|
|
134
|
+
(stories, tests and markdown are always excluded)
|
|
135
|
+
--ignore <class> never report this class; repeatable or comma-separated
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
Exit codes: `0` clean, `1` the check failed, `2` the tool could not run — so CI can tell a broken class from a
|
|
139
|
+
broken install.
|
|
140
|
+
|
|
141
|
+
**Why this needs a tool.** ESLint validates against Tailwind's _generator_; what renders depends on the
|
|
142
|
+
_emitted artifact_. A class can be perfectly valid and still be absent from the bundle, in which case it
|
|
143
|
+
renders as nothing — no error, no warning, a clean typecheck. Two ways that happens:
|
|
144
|
+
|
|
145
|
+
1. **Outside Graphene's theme entirely** — `bg-yellow-500`, `w-52`. Tailwind's default palette and most of its
|
|
146
|
+
spacing scale do not exist here (see **Token reference** above), so nothing was ever generated for them.
|
|
147
|
+
2. **A valid Graphene class no Graphene component happens to use**, so nothing ever compiled it. Sizing
|
|
148
|
+
utilities and arbitrary values are the usual casualties: whether one ships depends on whether some unrelated
|
|
149
|
+
component reached for the same value. Not worth reasoning about, and not worth keeping a list of — the last
|
|
150
|
+
list drifted and shipped a regression.
|
|
151
|
+
|
|
152
|
+
**How it decides.** It compiles the sources you point it at with Tailwind and diffs the emitted class set
|
|
153
|
+
against the bundle. Both sides are real Tailwind output, so there are no extraction heuristics and no guessing
|
|
154
|
+
about dynamic strings Tailwind would not have emitted either. The bundle is resolved from `node_modules`, so the
|
|
155
|
+
answer is correct for the version you have pinned rather than for whatever is on `main`.
|
|
156
|
+
|
|
157
|
+
**What it cannot see.** Classes assembled at runtime from fragments (`` `bg-${tone}-500` ``) — Tailwind cannot
|
|
158
|
+
see those either, in any pipeline, which is why they are already discouraged. Everything written out in full,
|
|
159
|
+
including inside `cva` variants and `clsx` conditionals, is covered.
|
|
160
|
+
|
|
161
|
+
**Where it over-reports, and the escape hatch.** Tailwind's scanner reads your files as text, so a class name
|
|
162
|
+
that appears anywhere is discovered — including in a comment. Documenting that `bg-yellow-500` is dead is enough
|
|
163
|
+
to make it a finding. Suppress those with `--ignore bg-yellow-500`; an `--ignore` that stops matching anything
|
|
164
|
+
fails the run, so the list cannot quietly become a set of excused regressions the way a hand-maintained list of
|
|
165
|
+
known-dead classes does.
|
|
166
|
+
|
|
167
|
+
### Which tier should run it
|
|
168
|
+
|
|
169
|
+
| | |
|
|
170
|
+
| --- | --- |
|
|
171
|
+
| **Tier 1** — no Tailwind of your own | Run it over all your source. Every class you write has to already be in the bundle, which is exactly what this measures. |
|
|
172
|
+
| **Tier 2** — your own Tailwind pipeline | Do not run it over all your source: your pipeline compiles your classes, so reason 2 does not apply to you and a full run reports classes that render fine. Reason 1 still applies — `tailwind-theme` is the same constrained theme, so `bg-yellow-500` and `w-52` are ungeneratable in your build too — but `better-tailwindcss/no-unknown-classes` from the shared ESLint config above already flags those at the point of use, which is the better place. Reach for this tool only for source your own Tailwind does not scan. |
|
|
173
|
+
|
|
174
|
+
The one thing neither tier has to check is Graphene's own components: the bundle is compiled from Graphene's
|
|
175
|
+
source, so it cannot be missing a class Graphene uses.
|
|
176
|
+
|
|
119
177
|
### Colors (semantic)
|
|
120
178
|
|
|
121
179
|
Maps to CSS variables from Graphene themes. Use utilities like `bg-*`, `text-*`, `border-*`:
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"jsonSchemaResolver.d.ts","sourceRoot":"","sources":["../../../../src/composed/JsonSchemaForm/form-bindings/jsonSchemaResolver.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAA2B,QAAQ,EAAE,MAAM,iBAAiB,CAAC;AACzE,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,6BAA6B,CAAC;AAC9D,OAAO,KAAK,EAAE,yBAAyB,EAAE,MAAM,oCAAoC,CAAC;
|
|
1
|
+
{"version":3,"file":"jsonSchemaResolver.d.ts","sourceRoot":"","sources":["../../../../src/composed/JsonSchemaForm/form-bindings/jsonSchemaResolver.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAA2B,QAAQ,EAAE,MAAM,iBAAiB,CAAC;AACzE,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,6BAA6B,CAAC;AAC9D,OAAO,KAAK,EAAE,yBAAyB,EAAE,MAAM,oCAAoC,CAAC;AAmCpF,iBAAS,kBAAkB,CAAC,YAAY,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAChG,MAAM,EAAE,UAAU,EAClB,OAAO,GAAE,yBAA8B,GACtC,QAAQ,CAAC,YAAY,CAAC,CA+DxB;AA2LD,OAAO,EAAE,kBAAkB,EAAE,CAAC"}
|
package/dist/index.js
CHANGED
|
@@ -21698,7 +21698,7 @@ var $z = [
|
|
|
21698
21698
|
"[&_ol]:mb-2 [&_ol]:list-decimal [&_ol]:pl-5",
|
|
21699
21699
|
"[&_li]:mb-0.5",
|
|
21700
21700
|
"[&_a]:text-primary [&_a]:underline [&_a]:underline-offset-2",
|
|
21701
|
-
"[&_code]:rounded [&_code]:bg-muted [&_code]:px-1 [&_code]:py-0.5 [&_code]:font-mono [&_code]:text-xs",
|
|
21701
|
+
"[&_code]:rounded-sm [&_code]:bg-muted [&_code]:px-1 [&_code]:py-0.5 [&_code]:font-mono [&_code]:text-xs",
|
|
21702
21702
|
"[&_pre]:mb-3 [&_pre]:overflow-x-auto [&_pre]:rounded-md [&_pre]:bg-muted [&_pre]:p-3",
|
|
21703
21703
|
"[&_pre_code]:bg-transparent [&_pre_code]:p-0 [&_pre_code]:text-xs",
|
|
21704
21704
|
"[&_table]:mb-3 [&_table]:w-full [&_table]:text-xs",
|
|
@@ -34772,7 +34772,9 @@ var fZ = new nZ.default({
|
|
|
34772
34772
|
strict: !1,
|
|
34773
34773
|
allErrors: !0
|
|
34774
34774
|
});
|
|
34775
|
-
(0, rZ.default)(fZ)
|
|
34775
|
+
(0, rZ.default)(fZ);
|
|
34776
|
+
for (let e of ["text", "gio-code-editor"]) fZ.addFormat(e, !0);
|
|
34777
|
+
fZ.removeKeyword("id"), fZ.addKeyword({
|
|
34776
34778
|
keyword: "id",
|
|
34777
34779
|
validate: () => !0
|
|
34778
34780
|
});
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/* Theme surface the class-portability checker compiles against, shipped as
|
|
2
|
+
* `dist/styles/_probe-preset.css`. Internal to `graphene-check-classes`; no `exports` mapping.
|
|
3
|
+
*
|
|
4
|
+
* Must declare everything that decides whether a utility can be *generated* — variants, `@utility`,
|
|
5
|
+
* every `@theme` key — and nothing else. Token values and `base-layer` are omitted because they
|
|
6
|
+
* cannot change the class set: Tailwind emits `w-2` because `--spacing-2` is declared, not because
|
|
7
|
+
* the var resolves.
|
|
8
|
+
*
|
|
9
|
+
* Tracks `_dist-entry.css` — what the bundle is actually compiled from — and `preset.css`, its one
|
|
10
|
+
* import that expands into further theme surface. Drift is NOT self-correcting. Over-declaring here
|
|
11
|
+
* is caught by `yarn test:tier1`: `packages/core` starts emitting a class its own bundle lacks.
|
|
12
|
+
* Under-declaring is silent — a theme key those files gain and this one does not simply stops being
|
|
13
|
+
* discoverable, and every enforced target still passes, because `packages/core` passes by
|
|
14
|
+
* construction. `assertProbeParity` in `scripts/check-tier1-classes.mjs` is what holds the three
|
|
15
|
+
* files together; keep it in sync when the import list here changes. */
|
|
16
|
+
|
|
17
|
+
@import 'tw-animate-css';
|
|
18
|
+
@import './theme-directives.css';
|
|
19
|
+
@import './tailwind-theme.css';
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
@import './custom-variants.css';
|
|
2
|
+
|
|
3
|
+
@theme inline {
|
|
4
|
+
/* ── Map semantic tokens to Tailwind utilities ──── */
|
|
5
|
+
--color-background: var(--background);
|
|
6
|
+
--color-foreground: var(--foreground);
|
|
7
|
+
--color-card: var(--card);
|
|
8
|
+
--color-card-foreground: var(--card-foreground);
|
|
9
|
+
--color-popover: var(--popover);
|
|
10
|
+
--color-popover-foreground: var(--popover-foreground);
|
|
11
|
+
--color-primary: var(--primary);
|
|
12
|
+
--color-primary-foreground: var(--primary-foreground);
|
|
13
|
+
--color-secondary: var(--secondary);
|
|
14
|
+
--color-secondary-foreground: var(--secondary-foreground);
|
|
15
|
+
--color-muted: var(--muted);
|
|
16
|
+
--color-muted-foreground: var(--muted-foreground);
|
|
17
|
+
--color-accent: var(--accent);
|
|
18
|
+
--color-accent-foreground: var(--accent-foreground);
|
|
19
|
+
--color-destructive: var(--destructive);
|
|
20
|
+
--color-destructive-foreground: var(--destructive-foreground);
|
|
21
|
+
--color-success: var(--success);
|
|
22
|
+
--color-success-foreground: var(--success-foreground);
|
|
23
|
+
--color-warning: var(--warning);
|
|
24
|
+
--color-warning-foreground: var(--warning-foreground);
|
|
25
|
+
--color-highlight: var(--highlight);
|
|
26
|
+
--color-highlight-foreground: var(--highlight-foreground);
|
|
27
|
+
--color-badge-default-foreground: var(--badge-default-foreground);
|
|
28
|
+
--color-badge-destructive-foreground: var(--badge-destructive-foreground);
|
|
29
|
+
--color-badge-success-foreground: var(--badge-success-foreground);
|
|
30
|
+
--color-badge-warning-foreground: var(--badge-warning-foreground);
|
|
31
|
+
--color-badge-highlight-foreground: var(--badge-highlight-foreground);
|
|
32
|
+
--color-border: var(--border);
|
|
33
|
+
--color-input: var(--input);
|
|
34
|
+
--color-ring: var(--ring);
|
|
35
|
+
|
|
36
|
+
/* ── Sidebar colors ──────────────────────────────── */
|
|
37
|
+
--color-sidebar: var(--sidebar-background);
|
|
38
|
+
--color-sidebar-foreground: var(--sidebar-foreground);
|
|
39
|
+
--color-sidebar-border: var(--sidebar-border);
|
|
40
|
+
--color-sidebar-accent: var(--sidebar-accent);
|
|
41
|
+
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
|
42
|
+
--color-sidebar-primary: var(--sidebar-primary);
|
|
43
|
+
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
|
44
|
+
--color-sidebar-ring: var(--sidebar-ring);
|
|
45
|
+
|
|
46
|
+
/* ── Typography ─────────────────────────────────── */
|
|
47
|
+
--font-sans: var(--graphene-font-sans);
|
|
48
|
+
|
|
49
|
+
/* ── Layout dimensions ──────────────────────────── */
|
|
50
|
+
--spacing-topnav: var(--topnav-height);
|
|
51
|
+
--spacing-content: var(--content-padding);
|
|
52
|
+
--spacing-sidebar-height: var(--sidebar-height);
|
|
53
|
+
--container-content: var(--content-max-width);
|
|
54
|
+
--max-width-content: var(--content-max-width);
|
|
55
|
+
--max-width-focused: var(--content-focused-width);
|
|
56
|
+
|
|
57
|
+
/* ── Animations ──────────────────────────────────── */
|
|
58
|
+
--animate-accordion-down: accordion-down 0.2s ease-out;
|
|
59
|
+
--animate-accordion-up: accordion-up 0.2s ease-out;
|
|
60
|
+
|
|
61
|
+
/* ── Radius ──────────────────────────────────────── */
|
|
62
|
+
--radius-sm: calc(var(--radius) - 0.125rem);
|
|
63
|
+
--radius-md: var(--radius);
|
|
64
|
+
--radius-lg: calc(var(--radius) + 0.125rem);
|
|
65
|
+
--radius-xl: calc(var(--radius) + 0.375rem);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
@keyframes accordion-down {
|
|
69
|
+
from {
|
|
70
|
+
height: 0;
|
|
71
|
+
}
|
|
72
|
+
to {
|
|
73
|
+
height: var(--radix-accordion-content-height);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
@keyframes accordion-up {
|
|
78
|
+
from {
|
|
79
|
+
height: var(--radix-accordion-content-height);
|
|
80
|
+
}
|
|
81
|
+
to {
|
|
82
|
+
height: 0;
|
|
83
|
+
}
|
|
84
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gravitee/graphene-core",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.11.0-fix-found-211-ajv-unknown-format.338f8cb",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Gravitee's Graphene design system: accessible React components, design tokens, icons, and shared ESLint/TypeScript configs. Built on shadcn/ui and Tailwind.",
|
|
6
6
|
"keywords": [
|
|
@@ -53,6 +53,7 @@
|
|
|
53
53
|
"./tsconfig": "./tsconfig.shared.json"
|
|
54
54
|
},
|
|
55
55
|
"bin": {
|
|
56
|
+
"graphene-check-classes": "./scripts/check-classes.mjs",
|
|
56
57
|
"graphene-sync-ai-rules": "./scripts/sync-ai-rules.mjs"
|
|
57
58
|
},
|
|
58
59
|
"files": [
|
|
@@ -61,6 +62,7 @@
|
|
|
61
62
|
"eslint.config.shared.mjs",
|
|
62
63
|
"eslint.legacy.shared.cjs",
|
|
63
64
|
"tsconfig.shared.json",
|
|
65
|
+
"scripts/check-classes.mjs",
|
|
64
66
|
"scripts/sync-ai-rules.mjs",
|
|
65
67
|
"USAGE_GUIDE.md"
|
|
66
68
|
],
|
|
@@ -77,6 +79,7 @@
|
|
|
77
79
|
},
|
|
78
80
|
"dependencies": {
|
|
79
81
|
"@base-ui/react": "1.4.1",
|
|
82
|
+
"@tailwindcss/cli": "4.3.0",
|
|
80
83
|
"@types/json-schema": "7.0.15",
|
|
81
84
|
"ajv": "8.20.0",
|
|
82
85
|
"ajv-formats": "3.0.1",
|
|
@@ -85,16 +88,19 @@
|
|
|
85
88
|
"cmdk": "1.1.1",
|
|
86
89
|
"date-fns": "4.1.0",
|
|
87
90
|
"lucide-react": "1.14.0",
|
|
91
|
+
"postcss": "8.5.26",
|
|
88
92
|
"radix-ui": "1.4.3",
|
|
89
93
|
"react-day-picker": "9.14.0",
|
|
90
94
|
"react-dropzone": "15.0.0",
|
|
91
95
|
"react-resizable-panels": "4.11.1",
|
|
92
96
|
"sonner": "2.0.7",
|
|
93
97
|
"tailwind-merge": "3.6.0",
|
|
98
|
+
"tw-animate-css": "1.4.0",
|
|
94
99
|
"vaul": "1.1.2"
|
|
95
100
|
},
|
|
96
101
|
"peerDependencies": {
|
|
97
102
|
"@fontsource/dm-sans": ">=5.0.0",
|
|
103
|
+
"@gravitee/graphene-core": "3.11.0-fix-found-211-ajv-unknown-format.338f8cb",
|
|
98
104
|
"@monaco-editor/react": "^4.7.0",
|
|
99
105
|
"@tanstack/react-table": "^8.0.0",
|
|
100
106
|
"@testing-library/dom": ">=10.0.0",
|
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Fails if a class the given source uses is absent from the pre-built
|
|
4
|
+
* `@gravitee/graphene-core/styles` bundle, where it would silently render as nothing.
|
|
5
|
+
*
|
|
6
|
+
* Rationale and limits: USAGE_GUIDE.md → "Verifying class coverage".
|
|
7
|
+
*
|
|
8
|
+
* Compiles the source with Tailwind and diffs the emitted class set against the bundle's — real
|
|
9
|
+
* Tailwind output on both sides, so no extraction heuristics. Twice per target, unioned:
|
|
10
|
+
*
|
|
11
|
+
* 1. Graphene's theme, the one the bundle was built with. Finds classes the bundle didn't ship.
|
|
12
|
+
* 2. Tailwind's stock theme, plus Graphene's variants so `data-open:` still parses. Finds classes
|
|
13
|
+
* Graphene's theme cannot generate at all. Pass 1 is blind to these — under `--color-*:
|
|
14
|
+
* initial` there is no `yellow-500`, so `bg-yellow-500` emits nothing and the diff has nothing
|
|
15
|
+
* to compare — yet they are just as dead in the bundle, and just as silent.
|
|
16
|
+
*
|
|
17
|
+
* Both passes only *discover* candidate names; bundle membership is the sole test. What discovery
|
|
18
|
+
* cannot narrow is Tailwind's own scan, which reads comments and string literals as readily as
|
|
19
|
+
* `className` — so a class named only in prose is still a finding. `--ignore` is the escape hatch,
|
|
20
|
+
* and it is checked for staleness so it cannot quietly rot into a list of excused regressions.
|
|
21
|
+
*
|
|
22
|
+
* Requires the installed `@gravitee/graphene-core` to have a built `dist/` — always true for a
|
|
23
|
+
* published install; in this repo, run `yarn build` first.
|
|
24
|
+
*
|
|
25
|
+
* Exit codes: 0 clean, 1 the check failed, 2 the tool could not run.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { execFileSync } from 'node:child_process';
|
|
29
|
+
import { mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
30
|
+
import { createRequire } from 'node:module';
|
|
31
|
+
import { dirname, isAbsolute, join, resolve, sep } from 'node:path';
|
|
32
|
+
import { fileURLToPath } from 'node:url';
|
|
33
|
+
import postcss from 'postcss';
|
|
34
|
+
|
|
35
|
+
const require = createRequire(import.meta.url);
|
|
36
|
+
|
|
37
|
+
// Resolved from this file, not the caller's cwd, so the answer is always about the copy of Graphene
|
|
38
|
+
// the caller actually has installed.
|
|
39
|
+
const PKG_ROOT = fileURLToPath(new URL('..', import.meta.url));
|
|
40
|
+
const PKG = JSON.parse(readFileSync(join(PKG_ROOT, 'package.json'), 'utf8'));
|
|
41
|
+
const fromExports = (subpath) => {
|
|
42
|
+
const target = PKG.exports[subpath];
|
|
43
|
+
// Conditional exports would need a resolver; these two entries are plain strings by design.
|
|
44
|
+
if (typeof target !== 'string') fail(`export '${subpath}' is not a plain path. This build cannot be probed.`);
|
|
45
|
+
return join(PKG_ROOT, target);
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const CORE_BUNDLE = fromExports('./styles');
|
|
49
|
+
const STYLES_DIR = dirname(fromExports('./tailwind-theme'));
|
|
50
|
+
const PROBE_PRESET = join(STYLES_DIR, '_probe-preset.css');
|
|
51
|
+
const CUSTOM_VARIANTS = join(STYLES_DIR, 'custom-variants.css');
|
|
52
|
+
|
|
53
|
+
// Not shipped, so not part of the contract. Bare directories are handed to Tailwind for its own
|
|
54
|
+
// file detection rather than an extension list, which is why markdown has to be excluded here: a
|
|
55
|
+
// docs page demoing `h-[200px]` in a code block is not a component that will render unstyled.
|
|
56
|
+
const DEFAULT_EXCLUDES = [
|
|
57
|
+
'**/*.stories.{ts,tsx,js,jsx,mdx}',
|
|
58
|
+
'**/*.{test,spec}.{ts,tsx,js,jsx}',
|
|
59
|
+
'**/__test__/**',
|
|
60
|
+
'**/__tests__/**',
|
|
61
|
+
'**/*.{md,mdx}',
|
|
62
|
+
];
|
|
63
|
+
|
|
64
|
+
const HELP = `Usage: graphene-check-classes <source>... [options]
|
|
65
|
+
|
|
66
|
+
Fails if any class your source uses is absent from the pre-built
|
|
67
|
+
@gravitee/graphene-core/styles bundle, where it would silently render as nothing.
|
|
68
|
+
|
|
69
|
+
<source> A directory or glob to check. Repeatable; reported separately.
|
|
70
|
+
Must exist — a path that resolves to nothing is an error, not a pass.
|
|
71
|
+
--advisory <source> Same, but never fails the run. Repeatable.
|
|
72
|
+
--advisory-note <text> Printed alongside advisory results (e.g. a tracking issue URL).
|
|
73
|
+
--exclude <glob> Extra exclusion, relative to the current directory. Repeatable.
|
|
74
|
+
Stories, tests and markdown are always excluded — they are not shipped.
|
|
75
|
+
--ignore <class> Never report this class. Repeatable, or comma-separated. An ignore
|
|
76
|
+
that stops matching anything fails the run, so the list cannot rot.
|
|
77
|
+
|
|
78
|
+
Example:
|
|
79
|
+
graphene-check-classes src --advisory src/legacy --ignore bg-yellow-500`;
|
|
80
|
+
|
|
81
|
+
function fail(message) {
|
|
82
|
+
console.error(`graphene-check-classes: ${message}`);
|
|
83
|
+
process.exit(2);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function parseArgs(argv) {
|
|
87
|
+
const enforced = [];
|
|
88
|
+
const advisory = [];
|
|
89
|
+
const excludes = [];
|
|
90
|
+
const ignores = new Set();
|
|
91
|
+
let advisoryNote = '';
|
|
92
|
+
|
|
93
|
+
for (let i = 0; i < argv.length; i++) {
|
|
94
|
+
const arg = argv[i];
|
|
95
|
+
const value = () => {
|
|
96
|
+
const next = argv[++i];
|
|
97
|
+
if (next === undefined) fail(`${arg} requires a value`);
|
|
98
|
+
return next;
|
|
99
|
+
};
|
|
100
|
+
if (arg === '--advisory') advisory.push(value());
|
|
101
|
+
else if (arg === '--advisory-note') advisoryNote = value();
|
|
102
|
+
// Resolved here rather than passed through: Tailwind resolves `@source not` against the probe
|
|
103
|
+
// stylesheet, which lives in node_modules, so a relative glob would silently match nothing.
|
|
104
|
+
else if (arg === '--exclude') excludes.push(toAbsoluteGlob(value()));
|
|
105
|
+
else if (arg === '--ignore') {
|
|
106
|
+
for (const name of value().split(',')) {
|
|
107
|
+
if (name.trim()) ignores.add(name.trim());
|
|
108
|
+
}
|
|
109
|
+
} else if (arg === '--help' || arg === '-h') {
|
|
110
|
+
console.log(HELP);
|
|
111
|
+
process.exit(0);
|
|
112
|
+
} else if (arg.startsWith('-')) fail(`unknown option ${arg}`);
|
|
113
|
+
else enforced.push(arg);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (enforced.length === 0 && advisory.length === 0) {
|
|
117
|
+
fail('no sources given. Usage: graphene-check-classes <source>... (see --help)');
|
|
118
|
+
}
|
|
119
|
+
return { advisory, advisoryNote, enforced, excludes, ignores };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* The CLI, plus a directory to compile from. Tailwind resolves `@import 'tailwindcss'` relative to
|
|
124
|
+
* the stylesheet, so the probe cannot live in the OS temp dir; writing it inside the `node_modules`
|
|
125
|
+
* that provides `tailwindcss` makes that resolution work wherever the package manager put it.
|
|
126
|
+
*/
|
|
127
|
+
function resolveTailwind() {
|
|
128
|
+
let cliPkgPath;
|
|
129
|
+
try {
|
|
130
|
+
cliPkgPath = require.resolve('@tailwindcss/cli/package.json');
|
|
131
|
+
} catch {
|
|
132
|
+
fail("could not resolve '@tailwindcss/cli'. Reinstall @gravitee/graphene-core, or add it yourself.");
|
|
133
|
+
}
|
|
134
|
+
const cliPkg = JSON.parse(readFileSync(cliPkgPath, 'utf8'));
|
|
135
|
+
const bin = typeof cliPkg.bin === 'string' ? cliPkg.bin : cliPkg.bin.tailwindcss;
|
|
136
|
+
const cli = join(dirname(cliPkgPath), bin);
|
|
137
|
+
|
|
138
|
+
// Prefer whatever `tailwindcss` this script sees; fall back to the CLI's own copy when a strict
|
|
139
|
+
// package manager has nested it out of reach.
|
|
140
|
+
let tailwindPkgPath;
|
|
141
|
+
for (const from of [require, createRequire(cliPkgPath)]) {
|
|
142
|
+
try {
|
|
143
|
+
tailwindPkgPath = from.resolve('tailwindcss/package.json');
|
|
144
|
+
break;
|
|
145
|
+
} catch {
|
|
146
|
+
/* try the next resolution root */
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
if (!tailwindPkgPath) fail("could not resolve 'tailwindcss'. Reinstall @gravitee/graphene-core.");
|
|
150
|
+
|
|
151
|
+
// `<…>/node_modules/tailwindcss/package.json` → `<…>/node_modules`
|
|
152
|
+
return { cli, scratchParent: dirname(dirname(tailwindPkgPath)) };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const { cli: TAILWIND_CLI, scratchParent: SCRATCH_PARENT } = resolveTailwind();
|
|
156
|
+
|
|
157
|
+
/** Consume `.` then class-name characters, treating `\x` as one literal character. */
|
|
158
|
+
const CLASS_TOKEN = /\.((?:\\.|[^\s.{},:>+~()[\]"'\\])+)/g;
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* The utility each rule was generated for, unescaped to match what an author writes in `className`:
|
|
162
|
+
* `.bg-primary\/\[0\.08\]` → `bg-primary/[0.08]`. Walks rule nodes rather than regexing the file, so
|
|
163
|
+
* decimals in declaration values (`0.375rem`) are never read as class selectors.
|
|
164
|
+
*
|
|
165
|
+
* Only the *leftmost* class in each selector counts. Every other one is a class the utility is
|
|
166
|
+
* aimed at rather than a class being asked for: the `.group\/row` in `:is(:where(.group\/row):hover
|
|
167
|
+
* *)`, the `svg.lucide-check` in `[&>svg.lucide-check]:hidden`. Those are never utilities, carry no
|
|
168
|
+
* CSS of their own, and can only be in the bundle if some Graphene component happens to target the
|
|
169
|
+
* same name — so counting them reports classes nobody can act on. Verified against the shipped
|
|
170
|
+
* bundle: leftmost-only drops exactly 26 names, all of them `group`/`peer` markers or third-party
|
|
171
|
+
* hooks (`rdp-button_next`), and no real utility. Applied to both sides, so it stays symmetric.
|
|
172
|
+
*
|
|
173
|
+
* Rules nested under another rule are skipped entirely. Tailwind writes variants as nested CSS
|
|
174
|
+
* (`.group-has-\[\>svg\]\/alert\:col-start-2 { &:is(:where(.group\/alert):has(>svg) *) { … } }`) and
|
|
175
|
+
* only flattens them when minifying, so the inner rule is the same utility seen twice — with the
|
|
176
|
+
* targeted class now leftmost. Without this the probe and the minified bundle disagree about their
|
|
177
|
+
* own output. The test has to walk the whole ancestor chain rather than check the immediate parent:
|
|
178
|
+
* a responsive variant nests rule → `@media` → rule, which is how bare `peer` got through.
|
|
179
|
+
*/
|
|
180
|
+
function emittedClasses(css) {
|
|
181
|
+
const found = new Set();
|
|
182
|
+
postcss.parse(css).walkRules((rule) => {
|
|
183
|
+
for (let node = rule.parent; node; node = node.parent) {
|
|
184
|
+
if (node.type === 'rule') return;
|
|
185
|
+
}
|
|
186
|
+
for (const selector of rule.selectors) {
|
|
187
|
+
const first = selector.match(CLASS_TOKEN);
|
|
188
|
+
if (first) found.add(first[0].slice(1).replace(/\\(.)/g, '$1'));
|
|
189
|
+
}
|
|
190
|
+
});
|
|
191
|
+
return found;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Compile `sourceGlobs` under `themeImports` and return the classes Tailwind emits. */
|
|
195
|
+
function compile(themeImports, sourceGlobs, excludes) {
|
|
196
|
+
const work = mkdtempSync(join(SCRATCH_PARENT, '.graphene-classes-'));
|
|
197
|
+
let tailwindError;
|
|
198
|
+
try {
|
|
199
|
+
const entry = join(work, 'probe.css');
|
|
200
|
+
writeFileSync(
|
|
201
|
+
entry,
|
|
202
|
+
[
|
|
203
|
+
// `source(none)` disables Tailwind's automatic source detection, which would otherwise walk
|
|
204
|
+
// out to the project root and pull in code the caller did not ask about.
|
|
205
|
+
`@import 'tailwindcss' source(none);`,
|
|
206
|
+
...themeImports.map((file) => `@import '${file}';`),
|
|
207
|
+
...sourceGlobs.map((glob) => `@source '${glob}';`),
|
|
208
|
+
...excludes.map((glob) => `@source not '${glob}';`),
|
|
209
|
+
'',
|
|
210
|
+
].join('\n'),
|
|
211
|
+
);
|
|
212
|
+
const out = join(work, 'probe.out.css');
|
|
213
|
+
try {
|
|
214
|
+
execFileSync(process.execPath, [TAILWIND_CLI, '-i', entry, '-o', out], { stdio: 'pipe' });
|
|
215
|
+
} catch (error) {
|
|
216
|
+
tailwindError = error;
|
|
217
|
+
}
|
|
218
|
+
if (!tailwindError) return emittedClasses(readFileSync(out, 'utf8'));
|
|
219
|
+
} finally {
|
|
220
|
+
rmSync(work, { recursive: true, force: true });
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Reported out here, not from the `catch` above: `fail()` calls process.exit(), which skips
|
|
224
|
+
// pending `finally` blocks, so failing inline would strand the scratch directory in the caller's
|
|
225
|
+
// node_modules — the one place it has to live and the last place they would look. A Tailwind
|
|
226
|
+
// failure is the tool not running, not a verdict about the caller's classes; hence exit 2.
|
|
227
|
+
fail(`Tailwind could not compile the probe for ${sourceGlobs.join(', ')}:\n${tailwindError.message}`);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const GLOB_CHARS = /[*?[\]{}]/;
|
|
231
|
+
|
|
232
|
+
/** Anchor a caller-supplied glob to the current directory; `@source not` resolves from node_modules. */
|
|
233
|
+
function toAbsoluteGlob(glob) {
|
|
234
|
+
return isAbsolute(glob) ? glob : join(process.cwd(), glob);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** Absolute `@source` glob plus the exclusions that apply to it. */
|
|
238
|
+
function sourceSpec(target, extraExcludes) {
|
|
239
|
+
const abs = isAbsolute(target) ? target : resolve(process.cwd(), target);
|
|
240
|
+
const literal = abs.slice(0, GLOB_CHARS.test(abs) ? abs.search(GLOB_CHARS) : abs.length);
|
|
241
|
+
// A bare directory is handed to Tailwind as-is so its own file detection applies; anything with
|
|
242
|
+
// glob syntax is the caller's business and passes through untouched. `dirname` alone would climb
|
|
243
|
+
// one level too far for the common `src/**/*.tsx` shape, whose literal prefix is already a
|
|
244
|
+
// directory — that only skews `locate`'s search root, but it skews it wider than the target.
|
|
245
|
+
const base = !GLOB_CHARS.test(abs) ? abs : literal.endsWith(sep) ? literal.slice(0, -1) : dirname(literal);
|
|
246
|
+
return {
|
|
247
|
+
base,
|
|
248
|
+
excludes: [...DEFAULT_EXCLUDES.map((glob) => join(base, glob)), ...extraExcludes],
|
|
249
|
+
glob: abs,
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* A target that resolves to nothing must be an error. Returning "0 classes, all present" for a
|
|
255
|
+
* renamed directory turns the CI gate into a green light that checks nothing — the exact shape of
|
|
256
|
+
* silent regression this tool exists to catch.
|
|
257
|
+
*/
|
|
258
|
+
function assertTargetExists(target, spec) {
|
|
259
|
+
try {
|
|
260
|
+
statSync(spec.base);
|
|
261
|
+
} catch {
|
|
262
|
+
fail(`source '${target}' does not exist (looked for ${spec.base}).`);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** Characters that can continue a Tailwind class, so a match touching one is part of a longer name. */
|
|
267
|
+
const CLASS_CHAR = /[\w\-./:%[\]]/;
|
|
268
|
+
|
|
269
|
+
/** Does `className` appear in `text` as a whole class rather than as a prefix of a longer one? */
|
|
270
|
+
function containsClass(text, className) {
|
|
271
|
+
const escaped = className.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&');
|
|
272
|
+
return new RegExp(`(?<!${CLASS_CHAR.source})${escaped}(?!${CLASS_CHAR.source})`).test(text);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** Where a missing class is written. Best-effort: returns nothing outside a git repository. */
|
|
276
|
+
function locate(base, className) {
|
|
277
|
+
let hits;
|
|
278
|
+
try {
|
|
279
|
+
// `--untracked` matters more than it looks: the file you just created and have not added yet is
|
|
280
|
+
// exactly when you most need to be told where the dead class is.
|
|
281
|
+
hits = execFileSync('git', ['grep', '-l', '-F', '--untracked', '-e', className, '--', base], {
|
|
282
|
+
encoding: 'utf8',
|
|
283
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
284
|
+
});
|
|
285
|
+
} catch {
|
|
286
|
+
return [];
|
|
287
|
+
}
|
|
288
|
+
return (
|
|
289
|
+
hits
|
|
290
|
+
.split('\n')
|
|
291
|
+
.filter(Boolean)
|
|
292
|
+
.filter((f) => !f.includes('.stories.') && !f.includes('.test.') && !f.includes('.spec.'))
|
|
293
|
+
.filter((f) => !/(^|\/)__tests?__\//.test(f))
|
|
294
|
+
// git's own `-w` bounds on word characters, which does not bound a Tailwind class: it happily
|
|
295
|
+
// matches `[&_code]:rounded` inside `[&_code]:rounded-sm`, and `gap-x` inside `gap-x-4`. Confirm
|
|
296
|
+
// each candidate here instead, where the boundary can be spelled correctly.
|
|
297
|
+
.filter((f) => {
|
|
298
|
+
try {
|
|
299
|
+
return containsClass(readFileSync(f, 'utf8'), className);
|
|
300
|
+
} catch {
|
|
301
|
+
return false;
|
|
302
|
+
}
|
|
303
|
+
})
|
|
304
|
+
);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/** Every class the source needs, from both discovery passes (see header). */
|
|
308
|
+
function classesRequiredBy(spec) {
|
|
309
|
+
const constrained = compile([PROBE_PRESET], [spec.glob], spec.excludes);
|
|
310
|
+
const stockTheme = compile([CUSTOM_VARIANTS], [spec.glob], spec.excludes);
|
|
311
|
+
return new Set([...constrained, ...stockTheme]);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const { advisory, advisoryNote, enforced, excludes, ignores } = parseArgs(process.argv.slice(2));
|
|
315
|
+
|
|
316
|
+
for (const [label, path] of [
|
|
317
|
+
['bundle', CORE_BUNDLE],
|
|
318
|
+
['probe preset', PROBE_PRESET],
|
|
319
|
+
]) {
|
|
320
|
+
try {
|
|
321
|
+
statSync(path);
|
|
322
|
+
} catch {
|
|
323
|
+
fail(`${label} not found at ${path}. @gravitee/graphene-core@${PKG.version} looks unbuilt — run \`yarn build\`.`);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const shipped = emittedClasses(readFileSync(CORE_BUNDLE, 'utf8'));
|
|
328
|
+
console.log(`@gravitee/graphene-core@${PKG.version} — ${shipped.size} classes emitted by its /styles bundle\n`);
|
|
329
|
+
|
|
330
|
+
const usedIgnores = new Set();
|
|
331
|
+
|
|
332
|
+
/** Compile one target and split what it needs into reported misses and ignored ones. */
|
|
333
|
+
function evaluate(target) {
|
|
334
|
+
const spec = sourceSpec(target, excludes);
|
|
335
|
+
assertTargetExists(target, spec);
|
|
336
|
+
const required = classesRequiredBy(spec);
|
|
337
|
+
const absent = [...required].filter((c) => !shipped.has(c)).sort();
|
|
338
|
+
for (const className of absent) {
|
|
339
|
+
if (ignores.has(className)) usedIgnores.add(className);
|
|
340
|
+
}
|
|
341
|
+
return {
|
|
342
|
+
ignored: absent.filter((c) => ignores.has(c)),
|
|
343
|
+
missing: absent.filter((c) => !ignores.has(c)),
|
|
344
|
+
required,
|
|
345
|
+
spec,
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
const suffix = (ignored) => (ignored.length ? ` (${ignored.length} ignored)` : '');
|
|
350
|
+
|
|
351
|
+
let failed = false;
|
|
352
|
+
|
|
353
|
+
for (const target of enforced) {
|
|
354
|
+
const { ignored, missing, required, spec } = evaluate(target);
|
|
355
|
+
|
|
356
|
+
if (missing.length === 0) {
|
|
357
|
+
console.log(` ✓ ${target} — ${required.size} classes, all present in the bundle${suffix(ignored)}`);
|
|
358
|
+
continue;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
failed = true;
|
|
362
|
+
console.log(` ✗ ${target} — ${missing.length} class(es) absent from the bundle${suffix(ignored)}:\n`);
|
|
363
|
+
for (const className of missing) {
|
|
364
|
+
console.log(` ${className}`);
|
|
365
|
+
for (const file of locate(spec.base, className)) console.log(` ${file}`);
|
|
366
|
+
}
|
|
367
|
+
console.log('');
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// A bare count, never fatal, so the enforced results above stay readable in CI output.
|
|
371
|
+
for (const target of advisory) {
|
|
372
|
+
const { ignored, missing } = evaluate(target);
|
|
373
|
+
const status = missing.length === 0 ? `✓ ${target} — 0 missing` : `! ${target} — ${missing.length} missing`;
|
|
374
|
+
console.log(` ${status}${suffix(ignored)} (advisory, not enforced${advisoryNote ? ` — ${advisoryNote}` : ''})`);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// An ignore list nobody prunes is the "documented list of known-dead classes" this check replaced.
|
|
378
|
+
// Once a class is genuinely fixed or genuinely gone, its ignore has to go with it.
|
|
379
|
+
const staleIgnores = [...ignores].filter((c) => !usedIgnores.has(c)).sort();
|
|
380
|
+
if (staleIgnores.length > 0) {
|
|
381
|
+
failed = true;
|
|
382
|
+
const noun = staleIgnores.length === 1 ? 'entry' : 'entries';
|
|
383
|
+
console.log(`\n ✗ ${staleIgnores.length} --ignore ${noun} matched nothing and should be dropped:\n`);
|
|
384
|
+
for (const className of staleIgnores) console.log(` ${className}`);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
if (failed) {
|
|
388
|
+
console.log(
|
|
389
|
+
[
|
|
390
|
+
'',
|
|
391
|
+
'These classes render as no-ops for anyone importing the pre-built',
|
|
392
|
+
'@gravitee/graphene-core/styles without running Tailwind over Graphene themselves.',
|
|
393
|
+
'',
|
|
394
|
+
"Fix by preference order (Graphene's spacing scale is sparse — 0 0.5 1 1.5 2 2.5 3 3.5 4 5 6",
|
|
395
|
+
'7 8 9 10 12 16 20 24 32 40 44 48 56 64 72 80 96 px — so other two-digit steps (w-52, w-60,',
|
|
396
|
+
'and friends) do NOT exist here):',
|
|
397
|
+
' 1. Use a standard utility that is actually on that scale (w-[2.5rem] → w-10).',
|
|
398
|
+
' 2. Move one-off structural values into an inline `style`.',
|
|
399
|
+
' 3. If the utility is genuinely common, ask for it in the bundle:',
|
|
400
|
+
` ${PKG.bugs.url}`,
|
|
401
|
+
'',
|
|
402
|
+
'If the name is not a class at all — prose in a comment, a string that happens to look like',
|
|
403
|
+
'one — `--ignore <class>` suppresses it, and fails later if it ever stops matching.',
|
|
404
|
+
].join('\n'),
|
|
405
|
+
);
|
|
406
|
+
process.exit(1);
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
console.log('\nAll enforced sources are portable against this bundle.');
|