@grailwind/eslint-plugin 0.0.0-stage → 0.1.0-beta.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 +21 -0
- package/README.md +151 -2
- package/package.json +51 -4
- package/src/blazon.js +167 -0
- package/src/index.js +32 -0
- package/src/rules/arrangement-needs-ordinary.js +37 -0
- package/src/rules/charges-in-container.js +32 -0
- package/src/rules/modifier-needs-target.js +80 -0
- package/src/rules/one-ordinary.js +69 -0
- package/src/rules/rule-of-tincture.js +152 -0
- package/src/tree.js +129 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Paul Firmin
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,152 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @grailwind/eslint-plugin
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
ESLint rules for [Grailwind](https://pafirmin.github.io/grailwind/) markup in JSX and HTML.
|
|
4
|
+
|
|
5
|
+
> **Beta.** Rules, their options and the `recommended` config may change between releases.
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
npm install --save-dev @grailwind/eslint-plugin@beta
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
It reads the tinctures, ordinaries and charges from your installed `grailwind`, so it needs
|
|
12
|
+
`grailwind` 0.4.x. For typos and other class errors, see
|
|
13
|
+
[Linting](https://github.com/pafirmin/grailwind#linting) in the Grailwind README.
|
|
14
|
+
|
|
15
|
+
## Setup
|
|
16
|
+
|
|
17
|
+
ESLint 9 or later, flat config only. `recommended` turns on every rule as an error.
|
|
18
|
+
|
|
19
|
+
JSX and TSX use ESLint's own parser:
|
|
20
|
+
|
|
21
|
+
```js
|
|
22
|
+
// eslint.config.js
|
|
23
|
+
import grailwind from "@grailwind/eslint-plugin";
|
|
24
|
+
|
|
25
|
+
export default [
|
|
26
|
+
{
|
|
27
|
+
files: ["**/*.{jsx,tsx}"],
|
|
28
|
+
languageOptions: { parserOptions: { ecmaFeatures: { jsx: true } } },
|
|
29
|
+
},
|
|
30
|
+
grailwind.configs.recommended,
|
|
31
|
+
];
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
HTML needs [`@html-eslint/parser`](https://html-eslint.org/):
|
|
35
|
+
|
|
36
|
+
```js
|
|
37
|
+
import grailwind from "@grailwind/eslint-plugin";
|
|
38
|
+
import html from "@html-eslint/parser";
|
|
39
|
+
|
|
40
|
+
export default [
|
|
41
|
+
{
|
|
42
|
+
files: ["**/*.html"],
|
|
43
|
+
languageOptions: { parser: html },
|
|
44
|
+
...grailwind.configs.recommended,
|
|
45
|
+
},
|
|
46
|
+
];
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## Rules
|
|
50
|
+
|
|
51
|
+
### `rule-of-tincture`
|
|
52
|
+
|
|
53
|
+
No metal (`or`, `argent`) on metal, and no colour on colour.
|
|
54
|
+
|
|
55
|
+
- An ordinary, chief or bordure is checked against the field on the same element:
|
|
56
|
+
`escutcheon or fess-argent` is metal on metal. So are engrailed and invected ones
|
|
57
|
+
(`fess-engrailed-argent`).
|
|
58
|
+
- A fimbriated ordinary is checked against its fimbriation, and the fimbriation against the
|
|
59
|
+
field: `or chevron-sable fimbriated-argent` is metal on metal (Argent on Or).
|
|
60
|
+
- Charges in a `charges` container are checked against the field.
|
|
61
|
+
- Charges in a `charges on` container are checked against the ordinary they lie on.
|
|
62
|
+
- Charges at `in-dexter-chief`, `in-chief` or `in-sinister-chief` are checked against the chief,
|
|
63
|
+
if there is one.
|
|
64
|
+
|
|
65
|
+
A divided field (`per-pale-or-gules`) has no single tincture, so nothing is checked against it.
|
|
66
|
+
Neither are `<img>` charges, charges with no tincture, or `bordure-*` with a non-heraldic colour.
|
|
67
|
+
Counterchanged ordinaries and charges take the field's tinctures swapped, so they're skipped too.
|
|
68
|
+
|
|
69
|
+
Furs count by their ground: `ermine` and `erminois` as metals, `ermines` and `pean` as colours.
|
|
70
|
+
`ermine fess-argent` is metal on metal. Authorities differ, so the `furs` option changes this:
|
|
71
|
+
|
|
72
|
+
```js
|
|
73
|
+
"grailwind/rule-of-tincture": ["error", { furs: "ground" }] // the default
|
|
74
|
+
"grailwind/rule-of-tincture": ["error", { furs: "exempt" }] // furs go with anything
|
|
75
|
+
"grailwind/rule-of-tincture": ["error", {
|
|
76
|
+
furs: { ermine: "metal", erminois: "exempt", ermines: "colour", pean: "colour" },
|
|
77
|
+
}]
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
A fur left out of the map counts by its ground.
|
|
81
|
+
|
|
82
|
+
### `charges-in-container`
|
|
83
|
+
|
|
84
|
+
Charges (`mullet`, `bezant`, `charge-[…]` and so on) must be direct children of a `charges`
|
|
85
|
+
container.
|
|
86
|
+
|
|
87
|
+
### `arrangement-needs-ordinary`
|
|
88
|
+
|
|
89
|
+
`charges between` and `charges on` take their positions from the ordinary on the shield. Without
|
|
90
|
+
one, other than a chief, they have nowhere to go.
|
|
91
|
+
|
|
92
|
+
### `one-ordinary`
|
|
93
|
+
|
|
94
|
+
An element draws one ordinary plus, optionally, a chief. With two, such as
|
|
95
|
+
`fess-azure chevron-gules`, only one shows. Two chiefs are flagged too. A `charges` container
|
|
96
|
+
takes one arrangement: `between`, `on`, `in-pale`, `in-fess`, `in-bend` or `in-bend-sinister`.
|
|
97
|
+
Counterchanged, engrailed, invected and fur ordinaries count as ordinaries.
|
|
98
|
+
|
|
99
|
+
A charge at a named point (`in-dexter-chief` and so on) is a separate group in the blazon, so it
|
|
100
|
+
goes in its own `charges` container. Among other charges, it still counts towards their number
|
|
101
|
+
and shifts their positions: _three mullets, in dexter chief a crescent_ in one container lays
|
|
102
|
+
the mullets out as four.
|
|
103
|
+
|
|
104
|
+
### `modifier-needs-target`
|
|
105
|
+
|
|
106
|
+
Modifiers that would otherwise do nothing:
|
|
107
|
+
|
|
108
|
+
- `chevron-counterchanged` (any `<ordinary>-counterchanged`) needs a divided field on the same
|
|
109
|
+
element, e.g. `per-pale-argent-gules`.
|
|
110
|
+
- `counterchanged` on `charges`, or on a charge, needs a divided field on the shield that holds
|
|
111
|
+
the container.
|
|
112
|
+
- `fimbriated-*` needs a plain tinctured ordinary. A chief, and an engrailed, invected, fur or
|
|
113
|
+
counterchanged ordinary, aren't fimbriated.
|
|
114
|
+
- `counterchanged` doesn't work on a charge with a transform (`rotate-*`, `scale-*`, `skew-*`,
|
|
115
|
+
`translate-*`): the charge shows its own small copy of the field instead.
|
|
116
|
+
|
|
117
|
+
## Exceptions
|
|
118
|
+
|
|
119
|
+
Some real arms break the rule of tincture on purpose, e.g. Jerusalem, gold crosses on silver.
|
|
120
|
+
Mark these with a disable comment and a reason:
|
|
121
|
+
|
|
122
|
+
```html
|
|
123
|
+
<!-- eslint-disable-next-line grailwind/rule-of-tincture -- armes à enquérir -->
|
|
124
|
+
<i class="charge-[url(cross-potent.svg)] or"></i>
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
```jsx
|
|
128
|
+
{
|
|
129
|
+
/* eslint-disable-next-line grailwind/rule-of-tincture -- armes à enquérir */
|
|
130
|
+
}
|
|
131
|
+
<i className="charge-[url(cross-potent.svg)] or" />;
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
To downgrade a rule instead, override it after `recommended`, e.g.
|
|
135
|
+
`{ rules: { "grailwind/rule-of-tincture": "warn" } }`.
|
|
136
|
+
|
|
137
|
+
## Limits
|
|
138
|
+
|
|
139
|
+
The rules read static class strings only: `class="…"`, `className="…"`, or a template literal
|
|
140
|
+
with no `${…}`. Anything dynamic, such as `clsx(…)` or a spread of props, is skipped rather than
|
|
141
|
+
guessed at. So are elements inside a component (`<Shield>…</Shield>`) or an expression
|
|
142
|
+
(`{items.map(…)}`), and classes with a variant (`md:gules`).
|
|
143
|
+
|
|
144
|
+
Two field tinctures on one element are a conflict, which this plugin leaves to
|
|
145
|
+
`better-tailwindcss/no-conflicting-classes`. It catches `or azure` and `ermine pean`, but not a
|
|
146
|
+
fur with a plain tincture (`ermine gules`) or with a division (`per-pale-ermine-gules
|
|
147
|
+
per-fess-or-azure`): their CSS properties differ, so it doesn't see them as conflicting
|
|
148
|
+
(eslint-plugin-better-tailwindcss 4.10.0).
|
|
149
|
+
|
|
150
|
+
The transform check knows Tailwind's transform utilities only. Other things that break
|
|
151
|
+
`counterchanged` the same way, such as `filter`, `perspective` or `will-change: transform`,
|
|
152
|
+
aren't flagged.
|
package/package.json
CHANGED
|
@@ -1,6 +1,53 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@grailwind/eslint-plugin",
|
|
3
|
-
"version": "0.0.0
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0-beta.0",
|
|
4
|
+
"description": "ESLint rules for Grailwind heraldry markup: the rule of tincture and charge structure",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./src/index.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./src/index.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"src"
|
|
12
|
+
],
|
|
13
|
+
"scripts": {
|
|
14
|
+
"test": "node --test test/*.test.js"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"eslint",
|
|
18
|
+
"eslintplugin",
|
|
19
|
+
"eslint-plugin",
|
|
20
|
+
"grailwind",
|
|
21
|
+
"heraldry",
|
|
22
|
+
"tailwindcss"
|
|
23
|
+
],
|
|
24
|
+
"author": "Paul Firmin",
|
|
25
|
+
"license": "MIT",
|
|
26
|
+
"peerDependencies": {
|
|
27
|
+
"@html-eslint/parser": "*",
|
|
28
|
+
"eslint": "^9.0.0 || ^10.0.0",
|
|
29
|
+
"grailwind": "^0.4.0"
|
|
30
|
+
},
|
|
31
|
+
"peerDependenciesMeta": {
|
|
32
|
+
"@html-eslint/parser": {
|
|
33
|
+
"optional": true
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
"devDependencies": {
|
|
37
|
+
"@html-eslint/parser": "^0.67.0",
|
|
38
|
+
"eslint": "^10.14.0",
|
|
39
|
+
"grailwind": "file:../.."
|
|
40
|
+
},
|
|
41
|
+
"publishConfig": {
|
|
42
|
+
"access": "public"
|
|
43
|
+
},
|
|
44
|
+
"homepage": "https://pafirmin.github.io/grailwind/",
|
|
45
|
+
"repository": {
|
|
46
|
+
"type": "git",
|
|
47
|
+
"url": "git+https://github.com/pafirmin/grailwind.git",
|
|
48
|
+
"directory": "packages/eslint-plugin"
|
|
49
|
+
},
|
|
50
|
+
"bugs": {
|
|
51
|
+
"url": "https://github.com/pafirmin/grailwind/issues"
|
|
52
|
+
}
|
|
53
|
+
}
|
package/src/blazon.js
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Reading heraldry from a set of class names.
|
|
3
|
+
*/
|
|
4
|
+
import {
|
|
5
|
+
ARRANGEMENTS,
|
|
6
|
+
CHARGES,
|
|
7
|
+
COLOURS,
|
|
8
|
+
DIVISIONS,
|
|
9
|
+
FURS,
|
|
10
|
+
LINES,
|
|
11
|
+
METALS,
|
|
12
|
+
ORDINARIES,
|
|
13
|
+
POINTS,
|
|
14
|
+
ROUNDELS,
|
|
15
|
+
TINCTURES,
|
|
16
|
+
} from "grailwind/data";
|
|
17
|
+
|
|
18
|
+
const METAL = new Set(METALS);
|
|
19
|
+
const COLOUR = new Set(COLOURS);
|
|
20
|
+
const FUR = new Set(Object.keys(FURS));
|
|
21
|
+
// Anything a field, ordinary or charge can be painted in.
|
|
22
|
+
const TINCTURE = new Set([...TINCTURES, ...FUR]);
|
|
23
|
+
const CHARGE = new Set([...CHARGES, ...Object.keys(ROUNDELS)]);
|
|
24
|
+
const FIMBRIATIONS = new Set(TINCTURES.map((t) => `fimbriated-${t}`));
|
|
25
|
+
const CHIEF_POINTS = new Set(["in-dexter-chief", "in-chief", "in-sinister-chief"]);
|
|
26
|
+
const POINT_CLASSES = new Set(Object.keys(POINTS).map((point) => `in-${point}`));
|
|
27
|
+
export const ALL_ARRANGEMENTS = ["between", "on", ...ARRANGEMENTS];
|
|
28
|
+
|
|
29
|
+
// Every ordinary class, e.g. `fess-or`, `fess-engrailed-ermine`, `fess-counterchanged` and
|
|
30
|
+
// `bordure-or`, mapped to its parts. A counterchanged ordinary has no tincture of its own.
|
|
31
|
+
const ORDINARY_CLASSES = new Map();
|
|
32
|
+
for (const ordinary of ORDINARIES) {
|
|
33
|
+
for (const tincture of TINCTURE) {
|
|
34
|
+
const fur = FUR.has(tincture);
|
|
35
|
+
ORDINARY_CLASSES.set(`${ordinary}-${tincture}`, { ordinary, tincture, fur });
|
|
36
|
+
for (const line of LINES) {
|
|
37
|
+
ORDINARY_CLASSES.set(`${ordinary}-${line}-${tincture}`, { ordinary, tincture, fur, line });
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
ORDINARY_CLASSES.set(`${ordinary}-counterchanged`, {
|
|
41
|
+
ordinary,
|
|
42
|
+
tincture: null,
|
|
43
|
+
counterchanged: true,
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
for (const tincture of TINCTURES) {
|
|
47
|
+
ORDINARY_CLASSES.set(`bordure-${tincture}`, { ordinary: "bordure", tincture });
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Two furs in one division aren't generated.
|
|
51
|
+
const DIVISION_CLASSES = new Set();
|
|
52
|
+
for (const division of DIVISIONS) {
|
|
53
|
+
for (const a of TINCTURE) {
|
|
54
|
+
for (const b of TINCTURE) {
|
|
55
|
+
if (a !== b && !(FUR.has(a) && FUR.has(b))) {
|
|
56
|
+
DIVISION_CLASSES.add(`${division}-${a}-${b}`);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Only a plain, tinctured ordinary is fimbriated (see ordinaries.js).
|
|
63
|
+
export const isFimbriable = (o) => !o.counterchanged && !o.line && !o.fur;
|
|
64
|
+
|
|
65
|
+
export const isFur = (tincture) => FUR.has(tincture);
|
|
66
|
+
|
|
67
|
+
const groundKind = (fur) => kind(FURS[fur][0]);
|
|
68
|
+
|
|
69
|
+
// What the rule's `furs` option makes of one fur: "metal", "colour" or "exempt". The rule's
|
|
70
|
+
// schema has already checked the option.
|
|
71
|
+
const furChoice = (fur, option) => {
|
|
72
|
+
if (option === "ground") {
|
|
73
|
+
return groundKind(fur);
|
|
74
|
+
}
|
|
75
|
+
if (option === "exempt") {
|
|
76
|
+
return "exempt";
|
|
77
|
+
}
|
|
78
|
+
// A map: a fur left out counts by its ground.
|
|
79
|
+
return option[fur] ?? groundKind(fur);
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
// Each fur's kind for kind() and sameKind(). Exempt is null, which they treat as "no check".
|
|
83
|
+
export function furKinds(option = "ground") {
|
|
84
|
+
const kinds = {};
|
|
85
|
+
for (const fur of Object.keys(FURS)) {
|
|
86
|
+
const choice = furChoice(fur, option);
|
|
87
|
+
kinds[fur] = choice === "exempt" ? null : choice;
|
|
88
|
+
}
|
|
89
|
+
return kinds;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// `furs` is the map from furKinds().
|
|
93
|
+
export const kind = (tincture, furs) => {
|
|
94
|
+
if (FUR.has(tincture)) {
|
|
95
|
+
return furs[tincture];
|
|
96
|
+
}
|
|
97
|
+
return METAL.has(tincture) ? "metal" : COLOUR.has(tincture) ? "colour" : null;
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
export const sameKind = (a, b, furs) => {
|
|
101
|
+
const k = a && b && kind(a, furs);
|
|
102
|
+
return Boolean(k) && k === kind(b, furs);
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
// The ordinaries on an element, as { cls, ordinary, tincture }, by kind.
|
|
106
|
+
export function ordinariesOf(classes) {
|
|
107
|
+
const found = { ordinaries: [], chiefs: [], bordures: [] };
|
|
108
|
+
for (const cls of classes) {
|
|
109
|
+
const match = ORDINARY_CLASSES.get(cls);
|
|
110
|
+
if (!match) {
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
const list =
|
|
114
|
+
match.ordinary === "chief"
|
|
115
|
+
? "chiefs"
|
|
116
|
+
: match.ordinary === "bordure"
|
|
117
|
+
? "bordures"
|
|
118
|
+
: "ordinaries";
|
|
119
|
+
found[list].push({ cls, ...match });
|
|
120
|
+
}
|
|
121
|
+
return found;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export const isDivided = (classes) => [...classes].some((cls) => DIVISION_CLASSES.has(cls));
|
|
125
|
+
|
|
126
|
+
// The single bare tincture of a plain field. A divided field, or none, has no tincture.
|
|
127
|
+
export function fieldTincture(classes) {
|
|
128
|
+
if (isDivided(classes)) {
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
const tinctures = [...classes].filter((cls) => TINCTURE.has(cls));
|
|
132
|
+
return tinctures.length === 1 ? tinctures[0] : null;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// A charge's tincture, and the class that gives it, e.g. `bezant` → or.
|
|
136
|
+
export function chargeTincture(classes) {
|
|
137
|
+
const roundel = [...classes].find((cls) => ROUNDELS[cls]);
|
|
138
|
+
if (roundel) {
|
|
139
|
+
return { cls: roundel, tincture: ROUNDELS[roundel] };
|
|
140
|
+
}
|
|
141
|
+
const tinctures = [...classes].filter((cls) => TINCTURE.has(cls));
|
|
142
|
+
return tinctures.length === 1 ? { cls: tinctures[0], tincture: tinctures[0] } : null;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// The element's fimbriation, e.g. `fimbriated-argent` → argent, or null.
|
|
146
|
+
export function fimbriationOf(classes) {
|
|
147
|
+
const found = [...classes].filter((cls) => FIMBRIATIONS.has(cls));
|
|
148
|
+
return found.length === 1
|
|
149
|
+
? { cls: found[0], tincture: found[0].slice("fimbriated-".length) }
|
|
150
|
+
: null;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export const chargeName = (classes) =>
|
|
154
|
+
[...classes].find((cls) => CHARGE.has(cls)) ??
|
|
155
|
+
([...classes].some((cls) => cls.startsWith("charge-[")) ? "charge" : null);
|
|
156
|
+
|
|
157
|
+
export const isCharge = (classes) => chargeName(classes) !== null;
|
|
158
|
+
|
|
159
|
+
export const pointOf = (classes) => [...classes].find((cls) => POINT_CLASSES.has(cls));
|
|
160
|
+
|
|
161
|
+
export const atChiefPoint = (classes) => [...classes].some((cls) => CHIEF_POINTS.has(cls));
|
|
162
|
+
|
|
163
|
+
export const arrangementsOf = (classes) => ALL_ARRANGEMENTS.filter((name) => classes.has(name));
|
|
164
|
+
|
|
165
|
+
export const titleCase = (tincture) => tincture[0].toUpperCase() + tincture.slice(1);
|
|
166
|
+
|
|
167
|
+
export const describe = (name) => name.replaceAll("-", " ");
|
package/src/index.js
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import arrangementNeedsOrdinary from "./rules/arrangement-needs-ordinary.js";
|
|
3
|
+
import chargesInContainer from "./rules/charges-in-container.js";
|
|
4
|
+
import modifierNeedsTarget from "./rules/modifier-needs-target.js";
|
|
5
|
+
import oneOrdinary from "./rules/one-ordinary.js";
|
|
6
|
+
import ruleOfTincture from "./rules/rule-of-tincture.js";
|
|
7
|
+
|
|
8
|
+
const { name, version } = JSON.parse(
|
|
9
|
+
readFileSync(new URL("../package.json", import.meta.url), "utf8"),
|
|
10
|
+
);
|
|
11
|
+
|
|
12
|
+
const plugin = {
|
|
13
|
+
meta: { name, version, namespace: "grailwind" },
|
|
14
|
+
rules: {
|
|
15
|
+
"rule-of-tincture": ruleOfTincture,
|
|
16
|
+
"charges-in-container": chargesInContainer,
|
|
17
|
+
"arrangement-needs-ordinary": arrangementNeedsOrdinary,
|
|
18
|
+
"one-ordinary": oneOrdinary,
|
|
19
|
+
"modifier-needs-target": modifierNeedsTarget,
|
|
20
|
+
},
|
|
21
|
+
configs: {},
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
plugin.configs.recommended = {
|
|
25
|
+
name: "grailwind/recommended",
|
|
26
|
+
plugins: { grailwind: plugin },
|
|
27
|
+
rules: Object.fromEntries(
|
|
28
|
+
Object.keys(plugin.rules).map((rule) => [`grailwind/${rule}`, "error"]),
|
|
29
|
+
),
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export default plugin;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { getElements, UNKNOWN } from "../tree.js";
|
|
2
|
+
import { ordinariesOf } from "../blazon.js";
|
|
3
|
+
|
|
4
|
+
export default {
|
|
5
|
+
meta: {
|
|
6
|
+
type: "problem",
|
|
7
|
+
docs: {
|
|
8
|
+
description: "Require an ordinary on the shield for `between` and `on`",
|
|
9
|
+
url: "https://github.com/pafirmin/grailwind/tree/main/packages/eslint-plugin#arrangement-needs-ordinary",
|
|
10
|
+
},
|
|
11
|
+
schema: [],
|
|
12
|
+
messages: {
|
|
13
|
+
// A chief publishes no `between` or `on` points, so it doesn't count.
|
|
14
|
+
missing: "`{{arrangement}}` needs an ordinary other than a chief on the shield",
|
|
15
|
+
},
|
|
16
|
+
},
|
|
17
|
+
|
|
18
|
+
create(context) {
|
|
19
|
+
return {
|
|
20
|
+
Program() {
|
|
21
|
+
for (const el of getElements(context.sourceCode)) {
|
|
22
|
+
if (!el.classes?.has("charges") || el.parent === UNKNOWN || !el.parent.classes) {
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
if (ordinariesOf(el.parent.classes).ordinaries.length) {
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
for (const arrangement of ["between", "on"]) {
|
|
29
|
+
if (el.classes.has(arrangement)) {
|
|
30
|
+
context.report({ node: el.reportNode, messageId: "missing", data: { arrangement } });
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
},
|
|
37
|
+
};
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { getElements, UNKNOWN } from "../tree.js";
|
|
2
|
+
import { chargeName } from "../blazon.js";
|
|
3
|
+
|
|
4
|
+
export default {
|
|
5
|
+
meta: {
|
|
6
|
+
type: "problem",
|
|
7
|
+
docs: {
|
|
8
|
+
description: "Require charges to be inside a `charges` container",
|
|
9
|
+
url: "https://github.com/pafirmin/grailwind/tree/main/packages/eslint-plugin#charges-in-container",
|
|
10
|
+
},
|
|
11
|
+
schema: [],
|
|
12
|
+
messages: {
|
|
13
|
+
outside: "`{{charge}}` must be inside a `charges` container",
|
|
14
|
+
},
|
|
15
|
+
},
|
|
16
|
+
|
|
17
|
+
create(context) {
|
|
18
|
+
return {
|
|
19
|
+
Program() {
|
|
20
|
+
for (const el of getElements(context.sourceCode)) {
|
|
21
|
+
if (!el.classes || el.parent === UNKNOWN || !el.parent.classes) {
|
|
22
|
+
continue;
|
|
23
|
+
}
|
|
24
|
+
const charge = chargeName(el.classes);
|
|
25
|
+
if (charge && !el.parent.classes.has("charges")) {
|
|
26
|
+
context.report({ node: el.reportNode, messageId: "outside", data: { charge } });
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
},
|
|
32
|
+
};
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { getElements, UNKNOWN } from "../tree.js";
|
|
2
|
+
import { fimbriationOf, isDivided, isFimbriable, ordinariesOf } from "../blazon.js";
|
|
3
|
+
|
|
4
|
+
// Any transform makes a charge the containing block for its fixed, swapped layer.
|
|
5
|
+
const TRANSFORM = /^-?(rotate|scale|skew|translate)(-|$)/;
|
|
6
|
+
|
|
7
|
+
export default {
|
|
8
|
+
meta: {
|
|
9
|
+
type: "problem",
|
|
10
|
+
docs: {
|
|
11
|
+
description: "Disallow counterchanged or fimbriated classes that have nothing to apply to",
|
|
12
|
+
url: "https://github.com/pafirmin/grailwind/tree/main/packages/eslint-plugin#modifier-needs-target",
|
|
13
|
+
},
|
|
14
|
+
schema: [],
|
|
15
|
+
messages: {
|
|
16
|
+
noDivision: "`{{cls}}` needs a divided field to swap, e.g. `per-pale-argent-gules`",
|
|
17
|
+
noOrdinary:
|
|
18
|
+
"`{{cls}}` needs a plain tinctured ordinary to edge: a chief, and an engrailed, invected, fur or counterchanged ordinary, aren't fimbriated",
|
|
19
|
+
transformed:
|
|
20
|
+
"`{{cls}}` breaks `counterchanged`: a transformed charge shows its own small copy of the field",
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
|
|
24
|
+
create(context) {
|
|
25
|
+
return {
|
|
26
|
+
Program() {
|
|
27
|
+
for (const el of getElements(context.sourceCode)) {
|
|
28
|
+
if (!el.classes) {
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
const report = (node, messageId, cls) =>
|
|
32
|
+
context.report({ node, messageId, data: { cls } });
|
|
33
|
+
|
|
34
|
+
// A counterchanged ordinary's ::before inherits the swap from its own element.
|
|
35
|
+
const { ordinaries, chiefs } = ordinariesOf(el.classes);
|
|
36
|
+
if (!isDivided(el.classes)) {
|
|
37
|
+
for (const { cls } of [...ordinaries, ...chiefs].filter((o) => o.counterchanged)) {
|
|
38
|
+
report(el.reportNode, "noDivision", cls);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const fimbriation = fimbriationOf(el.classes);
|
|
43
|
+
if (fimbriation && !ordinaries.some(isFimbriable)) {
|
|
44
|
+
report(el.reportNode, "noOrdinary", fimbriation.cls);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (el.classes.has("charges")) {
|
|
48
|
+
for (const charge of el.children) {
|
|
49
|
+
if (!charge.classes) {
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
const transform = [...charge.classes].find((cls) => TRANSFORM.test(cls));
|
|
53
|
+
if (
|
|
54
|
+
transform &&
|
|
55
|
+
(el.classes.has("counterchanged") || charge.classes.has("counterchanged"))
|
|
56
|
+
) {
|
|
57
|
+
report(charge.reportNode, "transformed", transform);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Counterchanged charges get the swap from the container's parent only.
|
|
63
|
+
if (el.classes.has("charges") && el.parent !== UNKNOWN && el.parent.classes) {
|
|
64
|
+
if (isDivided(el.parent.classes)) {
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
if (el.classes.has("counterchanged")) {
|
|
68
|
+
report(el.reportNode, "noDivision", "counterchanged");
|
|
69
|
+
}
|
|
70
|
+
for (const charge of el.children) {
|
|
71
|
+
if (charge.classes?.has("counterchanged")) {
|
|
72
|
+
report(charge.reportNode, "noDivision", "counterchanged");
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
},
|
|
80
|
+
};
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { getElements } from "../tree.js";
|
|
2
|
+
import { arrangementsOf, ordinariesOf, pointOf } from "../blazon.js";
|
|
3
|
+
|
|
4
|
+
const quote = (names) => names.map((n) => `\`${n}\``).join(" and ");
|
|
5
|
+
|
|
6
|
+
export default {
|
|
7
|
+
meta: {
|
|
8
|
+
type: "problem",
|
|
9
|
+
docs: {
|
|
10
|
+
description:
|
|
11
|
+
"Allow one ordinary plus a chief per element, and one arrangement per `charges` container",
|
|
12
|
+
url: "https://github.com/pafirmin/grailwind/tree/main/packages/eslint-plugin#one-ordinary",
|
|
13
|
+
},
|
|
14
|
+
schema: [],
|
|
15
|
+
messages: {
|
|
16
|
+
ordinaries: "{{classes}} both draw an ordinary; only one will show.",
|
|
17
|
+
chiefs: "{{classes}} both draw a chief; only one will show",
|
|
18
|
+
arrangements: "{{classes}} both place these charges",
|
|
19
|
+
pointed:
|
|
20
|
+
"`{{cls}}` sets this charge apart from the others in its container; give it a `charges` container of its own",
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
|
|
24
|
+
create(context) {
|
|
25
|
+
return {
|
|
26
|
+
Program() {
|
|
27
|
+
for (const el of getElements(context.sourceCode)) {
|
|
28
|
+
if (!el.classes) {
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
const { ordinaries, chiefs } = ordinariesOf(el.classes);
|
|
32
|
+
const report = (messageId, names) =>
|
|
33
|
+
context.report({ node: el.reportNode, messageId, data: { classes: quote(names) } });
|
|
34
|
+
|
|
35
|
+
if (ordinaries.length > 1) {
|
|
36
|
+
report(
|
|
37
|
+
"ordinaries",
|
|
38
|
+
ordinaries.map((o) => o.cls),
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
if (chiefs.length > 1) {
|
|
42
|
+
report(
|
|
43
|
+
"chiefs",
|
|
44
|
+
chiefs.map((o) => o.cls),
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
if (el.classes.has("charges")) {
|
|
48
|
+
const arrangements = arrangementsOf(el.classes);
|
|
49
|
+
if (arrangements.length > 1) {
|
|
50
|
+
report("arrangements", arrangements);
|
|
51
|
+
}
|
|
52
|
+
// A pointed charge still counts towards, and shifts, the others' positions.
|
|
53
|
+
const charges = el.children.filter((child) => child.classes);
|
|
54
|
+
const pointed = charges.filter((child) => pointOf(child.classes));
|
|
55
|
+
if (pointed.length && pointed.length < charges.length) {
|
|
56
|
+
for (const charge of pointed) {
|
|
57
|
+
context.report({
|
|
58
|
+
node: charge.reportNode,
|
|
59
|
+
messageId: "pointed",
|
|
60
|
+
data: { cls: pointOf(charge.classes) },
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
},
|
|
69
|
+
};
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { FURS } from "grailwind/data";
|
|
2
|
+
import { getElements } from "../tree.js";
|
|
3
|
+
import {
|
|
4
|
+
atChiefPoint,
|
|
5
|
+
chargeName,
|
|
6
|
+
chargeTincture,
|
|
7
|
+
describe,
|
|
8
|
+
fieldTincture,
|
|
9
|
+
fimbriationOf,
|
|
10
|
+
furKinds,
|
|
11
|
+
isFimbriable,
|
|
12
|
+
isFur,
|
|
13
|
+
kind,
|
|
14
|
+
ordinariesOf,
|
|
15
|
+
sameKind,
|
|
16
|
+
titleCase,
|
|
17
|
+
} from "../blazon.js";
|
|
18
|
+
|
|
19
|
+
const article = (thing) => (/^[aeiou]/.test(thing) ? `an ${thing}` : `a ${thing}`);
|
|
20
|
+
|
|
21
|
+
const KINDS = ["metal", "colour", "exempt"];
|
|
22
|
+
|
|
23
|
+
// e.g. "fess engrailed", as blazoned.
|
|
24
|
+
const name = ({ ordinary, line }) => describe(line ? `${ordinary} ${line}` : ordinary);
|
|
25
|
+
|
|
26
|
+
// e.g. " (ermine counts as a metal)", or " (ermine and erminois count as metals)".
|
|
27
|
+
const furNote = (tinctures, kind) => {
|
|
28
|
+
const furs = [...new Set(tinctures.filter(isFur))];
|
|
29
|
+
if (!furs.length) {
|
|
30
|
+
return "";
|
|
31
|
+
}
|
|
32
|
+
return furs.length === 1
|
|
33
|
+
? ` (${furs[0]} counts as a ${kind})`
|
|
34
|
+
: ` (${furs.join(" and ")} count as ${kind}s)`;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export default {
|
|
38
|
+
meta: {
|
|
39
|
+
type: "problem",
|
|
40
|
+
docs: {
|
|
41
|
+
description: "Disallow metal on metal and colour on colour",
|
|
42
|
+
url: "https://github.com/pafirmin/grailwind/tree/main/packages/eslint-plugin#rule-of-tincture",
|
|
43
|
+
},
|
|
44
|
+
schema: [
|
|
45
|
+
{
|
|
46
|
+
type: "object",
|
|
47
|
+
properties: {
|
|
48
|
+
furs: {
|
|
49
|
+
oneOf: [
|
|
50
|
+
{ enum: ["ground", "exempt"] },
|
|
51
|
+
{
|
|
52
|
+
type: "object",
|
|
53
|
+
properties: Object.fromEntries(
|
|
54
|
+
Object.keys(FURS).map((fur) => [fur, { enum: KINDS }]),
|
|
55
|
+
),
|
|
56
|
+
additionalProperties: false,
|
|
57
|
+
},
|
|
58
|
+
],
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
additionalProperties: false,
|
|
62
|
+
},
|
|
63
|
+
],
|
|
64
|
+
messages: {
|
|
65
|
+
violation:
|
|
66
|
+
"`{{cls}}` puts {{thing}} {{tincture}} on {{ground}}: {{kind}} on {{kind}}{{note}}",
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
|
|
70
|
+
create(context) {
|
|
71
|
+
const furs = furKinds(context.options[0]?.furs);
|
|
72
|
+
const check = (node, cls, thing, tincture, ground) => {
|
|
73
|
+
if (!sameKind(tincture, ground, furs)) {
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
context.report({
|
|
77
|
+
node,
|
|
78
|
+
messageId: "violation",
|
|
79
|
+
data: {
|
|
80
|
+
cls,
|
|
81
|
+
thing: article(thing),
|
|
82
|
+
tincture: titleCase(tincture),
|
|
83
|
+
ground: titleCase(ground),
|
|
84
|
+
kind: kind(tincture, furs),
|
|
85
|
+
note: furNote([ground, tincture], kind(tincture, furs)),
|
|
86
|
+
},
|
|
87
|
+
});
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
return {
|
|
91
|
+
Program() {
|
|
92
|
+
for (const el of getElements(context.sourceCode)) {
|
|
93
|
+
if (!el.classes) {
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
const field = fieldTincture(el.classes);
|
|
97
|
+
const { ordinaries, chiefs, bordures } = ordinariesOf(el.classes);
|
|
98
|
+
const fimbriation = fimbriationOf(el.classes);
|
|
99
|
+
const isFimbriated = fimbriation && ordinaries.some(isFimbriable);
|
|
100
|
+
|
|
101
|
+
if (isFimbriated) {
|
|
102
|
+
check(el.reportNode, fimbriation.cls, "fimbriation", fimbriation.tincture, field);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
for (const o of ordinaries) {
|
|
106
|
+
check(
|
|
107
|
+
el.reportNode,
|
|
108
|
+
o.cls,
|
|
109
|
+
name(o),
|
|
110
|
+
o.tincture,
|
|
111
|
+
isFimbriated && isFimbriable(o) ? fimbriation.tincture : field,
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// An ordinary abuts the chief rather than lying on it, so both are checked against the field.
|
|
116
|
+
for (const o of [...chiefs, ...bordures]) {
|
|
117
|
+
check(el.reportNode, o.cls, name(o), o.tincture, field);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
for (const container of el.children) {
|
|
121
|
+
if (!container.classes?.has("charges")) {
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
for (const charge of container.children) {
|
|
125
|
+
if (!charge.classes || charge.tag === "img") {
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
// Counterchanged charges take the swapped field, not a tincture of their own.
|
|
129
|
+
if (charge.classes.has("counterchanged") || container.classes.has("counterchanged")) {
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
const found = chargeTincture(charge.classes);
|
|
133
|
+
if (!found) {
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
let ground = field;
|
|
138
|
+
if (container.classes.has("on") && ordinaries.length) {
|
|
139
|
+
ground = ordinaries[0].tincture;
|
|
140
|
+
} else if (chiefs.length && atChiefPoint(charge.classes)) {
|
|
141
|
+
ground = chiefs[0].tincture;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const thing = describe(chargeName(charge.classes) ?? "charge");
|
|
145
|
+
check(charge.reportNode, found.cls, thing, found.tincture, ground);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
},
|
|
150
|
+
};
|
|
151
|
+
},
|
|
152
|
+
};
|
package/src/tree.js
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* One element tree for JSX and HTML. `classes` is null when they can't be known statically;
|
|
3
|
+
* `parent` is UNKNOWN when the element renders somewhere this file can't see.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export const UNKNOWN = Symbol("unknown parent");
|
|
7
|
+
|
|
8
|
+
const cache = new WeakMap();
|
|
9
|
+
|
|
10
|
+
// Variants (`md:`, `*:`) apply only sometimes, so they're ignored. `charge-[url(…)]` has a
|
|
11
|
+
// colon, but no variant.
|
|
12
|
+
export const parseClasses = (value) =>
|
|
13
|
+
new Set(value.split(/\s+/).filter((c) => c && (!c.includes(":") || c.startsWith("charge-["))));
|
|
14
|
+
|
|
15
|
+
export function getElements(sourceCode) {
|
|
16
|
+
let elements = cache.get(sourceCode.ast);
|
|
17
|
+
if (!elements) {
|
|
18
|
+
elements = [];
|
|
19
|
+
walk(sourceCode.ast, UNKNOWN, elements, sourceCode.visitorKeys);
|
|
20
|
+
cache.set(sourceCode.ast, elements);
|
|
21
|
+
}
|
|
22
|
+
return elements;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function element(node, tag, attribute, classes, parent, elements) {
|
|
26
|
+
const el = { node, reportNode: attribute ?? node, tag, classes, parent, children: [] };
|
|
27
|
+
if (parent !== UNKNOWN) {
|
|
28
|
+
parent.children.push(el);
|
|
29
|
+
}
|
|
30
|
+
elements.push(el);
|
|
31
|
+
return el;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function walk(node, context, elements, visitorKeys) {
|
|
35
|
+
if (!node || typeof node.type !== "string") {
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
switch (node.type) {
|
|
40
|
+
case "JSXElement": {
|
|
41
|
+
const { name, attributes } = node.openingElement;
|
|
42
|
+
const isComponent = name.type !== "JSXIdentifier" || /^[A-Z]/.test(name.name);
|
|
43
|
+
const attribute = attributes.find(
|
|
44
|
+
(a) =>
|
|
45
|
+
a.type === "JSXAttribute" && (a.name.name === "className" || a.name.name === "class"),
|
|
46
|
+
);
|
|
47
|
+
const hasSpread = attributes.some((a) => a.type === "JSXSpreadAttribute");
|
|
48
|
+
const classes = isComponent || hasSpread ? null : jsxClasses(attribute);
|
|
49
|
+
const el = element(
|
|
50
|
+
node,
|
|
51
|
+
isComponent ? null : name.name,
|
|
52
|
+
attribute,
|
|
53
|
+
classes,
|
|
54
|
+
context,
|
|
55
|
+
elements,
|
|
56
|
+
);
|
|
57
|
+
walkKeys(node.openingElement, UNKNOWN, elements, visitorKeys);
|
|
58
|
+
for (const child of node.children) {
|
|
59
|
+
walk(child, isComponent ? UNKNOWN : el, elements, visitorKeys);
|
|
60
|
+
}
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
// Fragments don't render an element, so their children belong to the parent.
|
|
64
|
+
case "JSXFragment":
|
|
65
|
+
for (const child of node.children) {
|
|
66
|
+
walk(child, context, elements, visitorKeys);
|
|
67
|
+
}
|
|
68
|
+
return;
|
|
69
|
+
case "Tag": {
|
|
70
|
+
const attribute = node.attributes.find((a) => a.key?.value.toLowerCase() === "class");
|
|
71
|
+
const el = element(
|
|
72
|
+
node,
|
|
73
|
+
node.name.toLowerCase(),
|
|
74
|
+
attribute,
|
|
75
|
+
htmlClasses(attribute),
|
|
76
|
+
context,
|
|
77
|
+
elements,
|
|
78
|
+
);
|
|
79
|
+
for (const child of node.children) {
|
|
80
|
+
walk(child, el, elements, visitorKeys);
|
|
81
|
+
}
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
default:
|
|
85
|
+
walkKeys(node, UNKNOWN, elements, visitorKeys);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function walkKeys(node, context, elements, visitorKeys) {
|
|
90
|
+
for (const key of visitorKeys[node.type] ?? []) {
|
|
91
|
+
const value = node[key];
|
|
92
|
+
if (Array.isArray(value)) {
|
|
93
|
+
value.forEach((child) => walk(child, context, elements, visitorKeys));
|
|
94
|
+
} else {
|
|
95
|
+
walk(value, context, elements, visitorKeys);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function jsxClasses(attribute) {
|
|
101
|
+
if (!attribute || attribute.value === null) {
|
|
102
|
+
return new Set();
|
|
103
|
+
}
|
|
104
|
+
let value = attribute.value;
|
|
105
|
+
if (value.type === "JSXExpressionContainer") {
|
|
106
|
+
value = value.expression;
|
|
107
|
+
}
|
|
108
|
+
if (value.type === "Literal" && typeof value.value === "string") {
|
|
109
|
+
return parseClasses(value.value);
|
|
110
|
+
}
|
|
111
|
+
if (value.type === "TemplateLiteral" && value.expressions.length === 0) {
|
|
112
|
+
return parseClasses(value.quasis[0].value.cooked);
|
|
113
|
+
}
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function htmlClasses(attribute) {
|
|
118
|
+
if (!attribute) {
|
|
119
|
+
return new Set();
|
|
120
|
+
}
|
|
121
|
+
if (!attribute.value) {
|
|
122
|
+
return new Set();
|
|
123
|
+
}
|
|
124
|
+
// Template engine syntax (`{{ x }}`) makes the value dynamic.
|
|
125
|
+
if (attribute.value.parts?.some((part) => part.type !== "Part")) {
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
return parseClasses(attribute.value.value);
|
|
129
|
+
}
|