@teamblind-chorus/ui 1.0.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 +112 -0
- package/agents/AGENTS.md +143 -0
- package/agents/DESIGN.md +1311 -0
- package/agents/LOVABLE.md +472 -0
- package/agents/anti-patterns.md +533 -0
- package/agents/catalog.md +232 -0
- package/agents/components/avatar-rail/avatar-rail.family.json +46 -0
- package/agents/components/avatar-rail/avatar-rail.md +103 -0
- package/agents/components/avatar-rail/avatar-rail.spec.json +160 -0
- package/agents/components/badge/badge.family.json +45 -0
- package/agents/components/badge/badge.md +10 -0
- package/agents/components/badge/role.md +100 -0
- package/agents/components/badge/role.spec.json +75 -0
- package/agents/components/badge/update.md +132 -0
- package/agents/components/badge/update.spec.json +114 -0
- package/agents/components/banner/banner.family.json +28 -0
- package/agents/components/banner/banner.md +136 -0
- package/agents/components/banner/banner.spec.json +136 -0
- package/agents/components/bottom-sheet/bottom-sheet.family.json +29 -0
- package/agents/components/bottom-sheet/bottom-sheet.md +176 -0
- package/agents/components/bottom-sheet/bottom-sheet.spec.json +168 -0
- package/agents/components/bubble/bubble.family.json +29 -0
- package/agents/components/bubble/bubble.md +134 -0
- package/agents/components/bubble/bubble.spec.json +91 -0
- package/agents/components/button/button.family.json +76 -0
- package/agents/components/button/button.md +31 -0
- package/agents/components/button/check.md +138 -0
- package/agents/components/button/check.spec.json +161 -0
- package/agents/components/button/fab.md +161 -0
- package/agents/components/button/fab.spec.json +106 -0
- package/agents/components/button/icon.md +141 -0
- package/agents/components/button/icon.spec.json +164 -0
- package/agents/components/button/standard.md +219 -0
- package/agents/components/button/standard.spec.json +205 -0
- package/agents/components/button/text.md +186 -0
- package/agents/components/button/text.spec.json +215 -0
- package/agents/components/button/toggle.md +108 -0
- package/agents/components/button/toggle.spec.json +124 -0
- package/agents/components/button/toolbar.md +189 -0
- package/agents/components/button/toolbar.spec.json +109 -0
- package/agents/components/carousel/carousel.family.json +41 -0
- package/agents/components/carousel/carousel.md +40 -0
- package/agents/components/carousel/post.md +148 -0
- package/agents/components/carousel/post.spec.json +229 -0
- package/agents/components/carousel/profile.md +184 -0
- package/agents/components/carousel/profile.spec.json +219 -0
- package/agents/components/chip/chip.family.json +37 -0
- package/agents/components/chip/chip.md +10 -0
- package/agents/components/chip/filter.md +212 -0
- package/agents/components/chip/filter.spec.json +124 -0
- package/agents/components/chip/tag.md +137 -0
- package/agents/components/chip/tag.spec.json +104 -0
- package/agents/components/dialog/dialog.family.json +29 -0
- package/agents/components/dialog/dialog.md +113 -0
- package/agents/components/dialog/dialog.spec.json +156 -0
- package/agents/components/directory-list/directory-list.family.json +46 -0
- package/agents/components/directory-list/directory-list.md +87 -0
- package/agents/components/directory-list/directory-list.spec.json +104 -0
- package/agents/components/divider/divider.family.json +28 -0
- package/agents/components/divider/divider.md +78 -0
- package/agents/components/divider/divider.spec.json +51 -0
- package/agents/components/feed/ad.md +108 -0
- package/agents/components/feed/ad.spec.json +187 -0
- package/agents/components/feed/feed.family.json +48 -0
- package/agents/components/feed/feed.md +30 -0
- package/agents/components/feed/post.md +240 -0
- package/agents/components/feed/post.spec.json +361 -0
- package/agents/components/form-field/form-field.family.json +50 -0
- package/agents/components/form-field/form-field.md +11 -0
- package/agents/components/form-field/input.md +198 -0
- package/agents/components/form-field/input.spec.json +202 -0
- package/agents/components/form-field/search.md +81 -0
- package/agents/components/form-field/search.spec.json +135 -0
- package/agents/components/form-field/select.md +101 -0
- package/agents/components/form-field/select.spec.json +194 -0
- package/agents/components/form-field/textarea.md +89 -0
- package/agents/components/form-field/textarea.spec.json +176 -0
- package/agents/components/header/header.family.json +43 -0
- package/agents/components/header/header.md +18 -0
- package/agents/components/header/main.md +101 -0
- package/agents/components/header/main.spec.json +117 -0
- package/agents/components/header/sub.md +129 -0
- package/agents/components/header/sub.spec.json +81 -0
- package/agents/components/list/accordion.md +183 -0
- package/agents/components/list/accordion.spec.json +201 -0
- package/agents/components/list/entry.md +280 -0
- package/agents/components/list/entry.spec.json +237 -0
- package/agents/components/list/list.family.json +75 -0
- package/agents/components/list/list.md +24 -0
- package/agents/components/list/radio.md +144 -0
- package/agents/components/list/radio.spec.json +186 -0
- package/agents/components/list/standard.md +262 -0
- package/agents/components/list/standard.spec.json +221 -0
- package/agents/components/metadata/compact.md +69 -0
- package/agents/components/metadata/compact.spec.json +69 -0
- package/agents/components/metadata/metadata.family.json +42 -0
- package/agents/components/metadata/metadata.md +26 -0
- package/agents/components/metadata/standard.md +104 -0
- package/agents/components/metadata/standard.spec.json +152 -0
- package/agents/components/nav-card/nav-card.family.json +29 -0
- package/agents/components/nav-card/nav-card.md +179 -0
- package/agents/components/nav-card/nav-card.spec.json +161 -0
- package/agents/components/nav-list/nav-list.family.json +46 -0
- package/agents/components/nav-list/nav-list.md +91 -0
- package/agents/components/nav-list/nav-list.spec.json +107 -0
- package/agents/components/navigation-bar/main.md +201 -0
- package/agents/components/navigation-bar/main.spec.json +109 -0
- package/agents/components/navigation-bar/navigation-bar.family.json +44 -0
- package/agents/components/navigation-bar/navigation-bar.md +21 -0
- package/agents/components/navigation-bar/search.md +96 -0
- package/agents/components/navigation-bar/search.spec.json +142 -0
- package/agents/components/navigation-bar/sub.md +174 -0
- package/agents/components/navigation-bar/sub.spec.json +123 -0
- package/agents/components/page-shell/page-shell.family.json +22 -0
- package/agents/components/page-shell/page-shell.md +51 -0
- package/agents/components/profile-header/profile-header.family.json +29 -0
- package/agents/components/profile-header/profile-header.md +149 -0
- package/agents/components/profile-header/profile-header.spec.json +200 -0
- package/agents/components/progress/progress.family.json +27 -0
- package/agents/components/progress/progress.md +38 -0
- package/agents/components/progress/progress.spec.json +67 -0
- package/agents/components/side-sheet/side-sheet.family.json +30 -0
- package/agents/components/side-sheet/side-sheet.md +154 -0
- package/agents/components/side-sheet/side-sheet.spec.json +109 -0
- package/agents/components/skeleton/skeleton.family.json +28 -0
- package/agents/components/skeleton/skeleton.md +123 -0
- package/agents/components/skeleton/skeleton.spec.json +73 -0
- package/agents/components/status-tag/status-tag.family.json +26 -0
- package/agents/components/status-tag/status-tag.md +114 -0
- package/agents/components/status-tag/status-tag.spec.json +69 -0
- package/agents/components/suggestion-list/suggestion-list.family.json +46 -0
- package/agents/components/suggestion-list/suggestion-list.md +91 -0
- package/agents/components/suggestion-list/suggestion-list.spec.json +178 -0
- package/agents/components/switch/switch.family.json +27 -0
- package/agents/components/switch/switch.md +114 -0
- package/agents/components/switch/switch.spec.json +123 -0
- package/agents/components/tab-bar/tab-bar.family.json +27 -0
- package/agents/components/tab-bar/tab-bar.md +178 -0
- package/agents/components/tab-bar/tab-bar.spec.json +184 -0
- package/agents/components/tabs/rounded.md +150 -0
- package/agents/components/tabs/rounded.spec.json +140 -0
- package/agents/components/tabs/segmented.md +114 -0
- package/agents/components/tabs/segmented.spec.json +100 -0
- package/agents/components/tabs/tabs.family.json +59 -0
- package/agents/components/tabs/tabs.md +18 -0
- package/agents/components/tabs/underline.md +147 -0
- package/agents/components/tabs/underline.spec.json +139 -0
- package/agents/components/thumbnail/thumbnail.family.json +28 -0
- package/agents/components/thumbnail/thumbnail.md +152 -0
- package/agents/components/thumbnail/thumbnail.spec.json +172 -0
- package/agents/components/toast/toast.family.json +28 -0
- package/agents/components/toast/toast.md +133 -0
- package/agents/components/toast/toast.spec.json +89 -0
- package/agents/components/tooltip/tooltip.family.json +29 -0
- package/agents/components/tooltip/tooltip.md +139 -0
- package/agents/components/tooltip/tooltip.spec.json +110 -0
- package/agents/compose.md +240 -0
- package/agents/icons.json +831 -0
- package/agents/images.md +66 -0
- package/agents/manifest.json +87 -0
- package/agents/patterns/README.md +59 -0
- package/agents/patterns/actions.md +50 -0
- package/agents/patterns/browsing.md +52 -0
- package/agents/patterns/communications.md +56 -0
- package/agents/patterns/layout.md +72 -0
- package/agents/patterns/modals.md +50 -0
- package/agents/patterns/visual.md +55 -0
- package/agents/reconstruct.md +55 -0
- package/agents/scoped-adoption.md +111 -0
- package/agents/tokens.usage.json +1657 -0
- package/agents/usage.json +422 -0
- package/dist/icons/index.cjs +1332 -0
- package/dist/icons/index.cjs.map +1 -0
- package/dist/icons/index.d.cts +228 -0
- package/dist/icons/index.d.ts +228 -0
- package/dist/icons/index.js +1114 -0
- package/dist/icons/index.js.map +1 -0
- package/dist/index.cjs +5905 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +896 -0
- package/dist/index.d.ts +896 -0
- package/dist/index.js +5847 -0
- package/dist/index.js.map +1 -0
- package/dist/styles.css +5765 -0
- package/eslint/README.md +79 -0
- package/eslint/index.js +78 -0
- package/eslint/rules.js +472 -0
- package/eslint/test.mjs +135 -0
- package/package.json +96 -0
- package/placeholder.png +0 -0
package/eslint/README.md
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# `@teamblind-chorus/ui/eslint` — Chorus lint preset
|
|
2
|
+
|
|
3
|
+
The **deterministic backstop for "Chorus First."** LOVABLE.md and the other agent
|
|
4
|
+
guides are prose — they can be skipped, truncated past the paste cap, or diluted
|
|
5
|
+
as a chat grows, after which the model drifts back to its shadcn/Tailwind prior.
|
|
6
|
+
This preset runs in the **consumer project's own toolchain**, so it flags
|
|
7
|
+
design-system violations regardless of whether the agent read or remembered the
|
|
8
|
+
guide. An AI builder's lint-error fix loop reads these errors and self-corrects.
|
|
9
|
+
|
|
10
|
+
## What it catches
|
|
11
|
+
|
|
12
|
+
| Rule | Severity | Catches | "Chorus First" failure it guards |
|
|
13
|
+
| :--- | :--- | :--- | :--- |
|
|
14
|
+
| `chorus/no-raw-hex` | error | `'#1A1A1A'`, `#fff` in any string (style, className, props) | ignoring color tokens |
|
|
15
|
+
| `chorus/no-tailwind-color` | error | `bg-white`, `text-black`, `border-gray-200`, `hover:bg-blue-500` in `className` | ignoring color tokens |
|
|
16
|
+
| `chorus/no-raw-cta` | error | raw `<button>`; `<a>` used as an action (`onClick` / no `href`) | escaping to non-Chorus |
|
|
17
|
+
| `chorus/no-offscale-space` | warn | `fontSize: 13`, `gap: 6`, `padding: '10px 12px'`, `borderRadius: 6` in inline `style` | ignoring spacing/typo/radius tokens |
|
|
18
|
+
| `no-restricted-imports` (built-in) | error | `@/components/ui/*` (shadcn), `@/components/chorus/*` (legacy mirror) | escaping to non-Chorus |
|
|
19
|
+
|
|
20
|
+
Rules report only on **statically-known** values — runtime-computed expressions
|
|
21
|
+
(`var(...)`, `calc(...)`, identifiers, conditionals) are left alone, so false
|
|
22
|
+
positives stay near zero.
|
|
23
|
+
|
|
24
|
+
## Setup
|
|
25
|
+
|
|
26
|
+
Requires ESLint 9 (flat config).
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
npm install -D eslint
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
### JavaScript / JSX projects (turnkey)
|
|
33
|
+
|
|
34
|
+
```js
|
|
35
|
+
// eslint.config.js
|
|
36
|
+
import chorus from "@teamblind-chorus/ui/eslint";
|
|
37
|
+
|
|
38
|
+
export default [
|
|
39
|
+
...chorus,
|
|
40
|
+
];
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
### TypeScript / TSX projects
|
|
44
|
+
|
|
45
|
+
The preset is parser-agnostic — layer its rules onto your TypeScript parser so
|
|
46
|
+
they also run on `.ts` / `.tsx`:
|
|
47
|
+
|
|
48
|
+
```js
|
|
49
|
+
// eslint.config.js
|
|
50
|
+
import tseslint from "typescript-eslint";
|
|
51
|
+
import { plugin as chorus, recommendedRules, restrictedImports } from "@teamblind-chorus/ui/eslint";
|
|
52
|
+
|
|
53
|
+
export default [
|
|
54
|
+
...tseslint.configs.recommended,
|
|
55
|
+
{
|
|
56
|
+
files: ["**/*.{ts,tsx}"],
|
|
57
|
+
plugins: { chorus },
|
|
58
|
+
rules: {
|
|
59
|
+
...recommendedRules,
|
|
60
|
+
"no-restricted-imports": restrictedImports,
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
];
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## Escape hatch
|
|
67
|
+
|
|
68
|
+
These are hard rules, not preferences — a hit means regenerate, not suppress.
|
|
69
|
+
For the rare legitimate exception (a real external `<a href>`, an intentional
|
|
70
|
+
non-token literal authorized in `DESIGN.md § Adapting Chorus`), disable on the
|
|
71
|
+
single line and say why:
|
|
72
|
+
|
|
73
|
+
```jsx
|
|
74
|
+
{/* eslint-disable-next-line chorus/no-offscale-space -- intrinsic slot geometry per DESIGN.md */}
|
|
75
|
+
<div style={{ width: 48 }} />
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
If a value has no token and isn't an authorized exception, that's a **Chorus
|
|
79
|
+
gap** to report — not a license to disable the rule.
|
package/eslint/index.js
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// Chorus ESLint preset — the deterministic backstop for "Chorus First".
|
|
2
|
+
//
|
|
3
|
+
// Prose guardrails in LOVABLE.md can be skipped, truncated, or diluted as a
|
|
4
|
+
// session grows; this preset runs in the consumer project's own toolchain, so
|
|
5
|
+
// it catches design-system violations whether or not the agent read (or
|
|
6
|
+
// remembered) the guide. An AI builder's lint-error fix loop sees these errors
|
|
7
|
+
// and self-corrects.
|
|
8
|
+
//
|
|
9
|
+
// Flat config (ESLint 9+). Two ways to consume:
|
|
10
|
+
//
|
|
11
|
+
// // eslint.config.js — JavaScript / JSX projects (turnkey)
|
|
12
|
+
// import chorus from "@teamblind-chorus/ui/eslint";
|
|
13
|
+
// export default [ ...chorus ];
|
|
14
|
+
//
|
|
15
|
+
// // eslint.config.js — TypeScript / TSX projects (layer onto your TS parser)
|
|
16
|
+
// import tseslint from "typescript-eslint";
|
|
17
|
+
// import { plugin as chorus, recommendedRules } from "@teamblind-chorus/ui/eslint";
|
|
18
|
+
// export default [
|
|
19
|
+
// ...tseslint.configs.recommended,
|
|
20
|
+
// { files: ["**/*.{ts,tsx}"], plugins: { chorus }, rules: recommendedRules },
|
|
21
|
+
// ];
|
|
22
|
+
|
|
23
|
+
import { rules } from "./rules.js";
|
|
24
|
+
|
|
25
|
+
export const plugin = {
|
|
26
|
+
meta: { name: "@teamblind-chorus/ui/eslint", version: "0.1.0" },
|
|
27
|
+
rules,
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
// The Chorus rules. Color/import/CTA/link violations are hard errors (they
|
|
31
|
+
// produce visibly off-brand or broken UI); off-scale spacing and full-bleed
|
|
32
|
+
// wrapping are warnings — real, but the lowest-severity drift and the highest
|
|
33
|
+
// false-positive risk (spatial heuristics over statically-known padding).
|
|
34
|
+
export const recommendedRules = {
|
|
35
|
+
"chorus/no-raw-hex": "error",
|
|
36
|
+
"chorus/no-tailwind-color": "error",
|
|
37
|
+
"chorus/no-raw-cta": "error",
|
|
38
|
+
"chorus/no-offscale-space": "warn",
|
|
39
|
+
"chorus/no-fullbleed-wrapper": "warn",
|
|
40
|
+
"chorus/no-redundant-link": "error",
|
|
41
|
+
"chorus/no-reinvented-family": "warn",
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
// Escaping to shadcn / the removed legacy mirror is caught by a built-in rule —
|
|
45
|
+
// no custom AST needed.
|
|
46
|
+
export const restrictedImports = [
|
|
47
|
+
"error",
|
|
48
|
+
{
|
|
49
|
+
patterns: [
|
|
50
|
+
{
|
|
51
|
+
group: ["@/components/ui", "@/components/ui/*"],
|
|
52
|
+
message: "shadcn UI is forbidden under Chorus First — import the equivalent from @teamblind-chorus/ui.",
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
group: ["@/components/chorus", "@/components/chorus/*"],
|
|
56
|
+
message: "The legacy Chorus mirror was removed — import from the @teamblind-chorus/ui package.",
|
|
57
|
+
},
|
|
58
|
+
],
|
|
59
|
+
},
|
|
60
|
+
];
|
|
61
|
+
|
|
62
|
+
const recommended = {
|
|
63
|
+
name: "chorus/recommended",
|
|
64
|
+
files: ["**/*.{js,jsx,mjs,cjs}"],
|
|
65
|
+
languageOptions: {
|
|
66
|
+
ecmaVersion: "latest",
|
|
67
|
+
sourceType: "module",
|
|
68
|
+
parserOptions: { ecmaFeatures: { jsx: true } },
|
|
69
|
+
},
|
|
70
|
+
plugins: { chorus: plugin },
|
|
71
|
+
rules: {
|
|
72
|
+
...recommendedRules,
|
|
73
|
+
"no-restricted-imports": restrictedImports,
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
// Default export: a flat-config array. Spread it into eslint.config.js.
|
|
78
|
+
export default [recommended];
|
package/eslint/rules.js
ADDED
|
@@ -0,0 +1,472 @@
|
|
|
1
|
+
// Custom ESLint rules that enforce Chorus design-system invariants in CONSUMER
|
|
2
|
+
// code (the app a Lovable/AI agent generates against @teamblind-chorus/ui). These are
|
|
3
|
+
// the deterministic backstop for the four "Chorus First" failure modes that
|
|
4
|
+
// prose guardrails in LOVABLE.md cannot guarantee:
|
|
5
|
+
//
|
|
6
|
+
// 1. escaping to non-Chorus → no-shadcn-import (built-in), no-raw-cta
|
|
7
|
+
// 2. ignoring tokens (color) → no-raw-hex, no-tailwind-color
|
|
8
|
+
// 3. ignoring tokens (spacing/typo) → no-offscale-space
|
|
9
|
+
//
|
|
10
|
+
// Plain ESM, no build step. AST-based (ESTree + JSX) so they work under espree
|
|
11
|
+
// (.js/.jsx) and typescript-eslint (.ts/.tsx) alike — any parser that emits the
|
|
12
|
+
// standard JSX nodes. Rules report only on statically-known violations to keep
|
|
13
|
+
// false positives near zero; runtime-computed values are left alone.
|
|
14
|
+
|
|
15
|
+
const HEX_COLOR = /#(?:[0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})\b/;
|
|
16
|
+
|
|
17
|
+
// Tailwind color utilities: <prefix>-<palette>-<shade> | <prefix>-{white,black,…}
|
|
18
|
+
// Matches `bg-white`, `text-black`, `border-gray-200`, and state/responsive
|
|
19
|
+
// variants like `hover:bg-blue-500`, `dark:text-white/80`.
|
|
20
|
+
const TW_PALETTE =
|
|
21
|
+
'slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose';
|
|
22
|
+
const TW_PREFIX =
|
|
23
|
+
'bg|text|border|ring|ring-offset|from|via|to|fill|stroke|divide|outline|decoration|caret|accent|placeholder|shadow';
|
|
24
|
+
const TW_COLOR = new RegExp(
|
|
25
|
+
`(?:^|[\\s:])(?:${TW_PREFIX})-(?:(?:${TW_PALETTE})-\\d{2,3}|white|black|transparent|current|inherit)(?:\\/\\d{1,3})?\\b`,
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
// Style props where every value must resolve to a token (var/calc) or a
|
|
29
|
+
// structural keyword. A raw number or px/rem string here is off-scale drift.
|
|
30
|
+
const TOKEN_ONLY_PROPS = new Set([
|
|
31
|
+
'gap', 'rowGap', 'columnGap',
|
|
32
|
+
'padding', 'paddingTop', 'paddingRight', 'paddingBottom', 'paddingLeft',
|
|
33
|
+
'paddingInline', 'paddingBlock', 'paddingInlineStart', 'paddingInlineEnd',
|
|
34
|
+
'margin', 'marginTop', 'marginRight', 'marginBottom', 'marginLeft',
|
|
35
|
+
'marginInline', 'marginBlock',
|
|
36
|
+
'fontSize', 'lineHeight', 'letterSpacing', 'fontWeight',
|
|
37
|
+
'borderRadius', 'borderWidth',
|
|
38
|
+
]);
|
|
39
|
+
|
|
40
|
+
const STRUCTURAL_VALUE = /^(0|auto|100%|inherit|initial|unset|none|normal)$/;
|
|
41
|
+
|
|
42
|
+
function stringIsTokenLike(raw) {
|
|
43
|
+
const s = String(raw).trim();
|
|
44
|
+
if (STRUCTURAL_VALUE.test(s)) return true;
|
|
45
|
+
if (s.includes('var(') || s.includes('calc(') || s.includes('clamp(')) return true;
|
|
46
|
+
if (/^-?\d+(\.\d+)?%$/.test(s)) return true; // bare percentage
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Collect every statically-known string from a JSX attribute value:
|
|
51
|
+
// plain string literals, the static quasis of template literals, and string
|
|
52
|
+
// arguments to helpers like clsx()/cn()/classNames(). Dynamic parts are skipped.
|
|
53
|
+
function collectStaticStrings(node, out) {
|
|
54
|
+
if (!node) return;
|
|
55
|
+
switch (node.type) {
|
|
56
|
+
case 'Literal':
|
|
57
|
+
if (typeof node.value === 'string') out.push(node.value);
|
|
58
|
+
break;
|
|
59
|
+
case 'TemplateLiteral':
|
|
60
|
+
for (const q of node.quasis) out.push(q.value.cooked ?? q.value.raw);
|
|
61
|
+
for (const ex of node.expressions) collectStaticStrings(ex, out);
|
|
62
|
+
break;
|
|
63
|
+
case 'JSXExpressionContainer':
|
|
64
|
+
collectStaticStrings(node.expression, out);
|
|
65
|
+
break;
|
|
66
|
+
case 'CallExpression':
|
|
67
|
+
for (const arg of node.arguments) collectStaticStrings(arg, out);
|
|
68
|
+
break;
|
|
69
|
+
case 'ArrayExpression':
|
|
70
|
+
for (const el of node.elements) collectStaticStrings(el, out);
|
|
71
|
+
break;
|
|
72
|
+
case 'ObjectExpression':
|
|
73
|
+
// clsx({ 'bg-white': cond }) — keys carry the class names
|
|
74
|
+
for (const prop of node.properties) {
|
|
75
|
+
if (prop.type === 'Property' && !prop.computed) {
|
|
76
|
+
if (prop.key.type === 'Literal' && typeof prop.key.value === 'string') out.push(prop.key.value);
|
|
77
|
+
else if (prop.key.type === 'Identifier') out.push(prop.key.name);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
break;
|
|
81
|
+
case 'ConditionalExpression':
|
|
82
|
+
collectStaticStrings(node.consequent, out);
|
|
83
|
+
collectStaticStrings(node.alternate, out);
|
|
84
|
+
break;
|
|
85
|
+
case 'LogicalExpression':
|
|
86
|
+
collectStaticStrings(node.left, out);
|
|
87
|
+
collectStaticStrings(node.right, out);
|
|
88
|
+
break;
|
|
89
|
+
default:
|
|
90
|
+
break;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function jsxAttrName(attr) {
|
|
95
|
+
return attr && attr.type === 'JSXAttribute' && attr.name && attr.name.type === 'JSXIdentifier'
|
|
96
|
+
? attr.name.name
|
|
97
|
+
: null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Chorus families that own their page-rail inline padding internally
|
|
101
|
+
// (layoutInset="full-bleed"). Wrapping one in a padded element, passing it
|
|
102
|
+
// inline padding, or making the whole card a navigation link all re-pay the
|
|
103
|
+
// page gutter and misalign the component with sibling rails (NavigationBar /
|
|
104
|
+
// TabBar / section headings). Mirrors the runtime useFullBleedGuard.
|
|
105
|
+
const FULL_BLEED = new Set([
|
|
106
|
+
'Feed', 'FeedGroup', 'FeedAd', 'Section', 'Carousel', 'PostCarousel', 'ProfileCarousel',
|
|
107
|
+
'NavigationBar', 'TabBar', 'BottomNav', 'Tabs', 'List', 'NavList',
|
|
108
|
+
'DirectoryList', 'SuggestionList', 'AvatarRail', 'ProfileHeader', 'SubHeader',
|
|
109
|
+
]);
|
|
110
|
+
|
|
111
|
+
// Generic HTML containers that, when padded around a full-bleed child, cause
|
|
112
|
+
// the double-pay. Chorus full-bleed hosts (the Uppercase names above) are the
|
|
113
|
+
// documented embedded-composition parents and are intentionally NOT flagged.
|
|
114
|
+
const HTML_CONTAINERS = new Set([
|
|
115
|
+
'div', 'section', 'main', 'article', 'span', 'header', 'footer', 'aside', 'nav', 'form', 'ul', 'ol', 'li',
|
|
116
|
+
]);
|
|
117
|
+
|
|
118
|
+
// Inline-axis padding only — padding-block / py-* do not touch the page rail.
|
|
119
|
+
const INLINE_PAD_STYLE = new Set([
|
|
120
|
+
'padding', 'paddingInline', 'paddingLeft', 'paddingRight', 'paddingInlineStart', 'paddingInlineEnd',
|
|
121
|
+
]);
|
|
122
|
+
const INLINE_MARGIN_STYLE = new Set([
|
|
123
|
+
'margin', 'marginInline', 'marginLeft', 'marginRight', 'marginInlineStart', 'marginInlineEnd',
|
|
124
|
+
]);
|
|
125
|
+
// px-* / pl-* / pr-* / ps-* / pe-* / p-* (inline or shorthand), non-zero value.
|
|
126
|
+
const TW_INLINE_PAD = /(?:^|\s)p(?:x|l|r|s|e)?-(?!0(?:\s|$|\/))/;
|
|
127
|
+
|
|
128
|
+
function getOpeningName(opening) {
|
|
129
|
+
return opening && opening.name && opening.name.type === 'JSXIdentifier' ? opening.name.name : null;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function getStyleObject(opening) {
|
|
133
|
+
for (const attr of opening.attributes) {
|
|
134
|
+
if (jsxAttrName(attr) === 'style') {
|
|
135
|
+
const v = attr.value;
|
|
136
|
+
if (v && v.type === 'JSXExpressionContainer' && v.expression.type === 'ObjectExpression') {
|
|
137
|
+
return v.expression;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function getClassNameStrings(opening) {
|
|
145
|
+
const out = [];
|
|
146
|
+
for (const attr of opening.attributes) {
|
|
147
|
+
const n = jsxAttrName(attr);
|
|
148
|
+
if (n === 'className' || n === 'class') collectStaticStrings(attr.value, out);
|
|
149
|
+
}
|
|
150
|
+
return out;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// A style prop in `nameSet` is "set" unless it's an explicit zero / none.
|
|
154
|
+
function objHasNonzeroProp(obj, nameSet) {
|
|
155
|
+
for (const prop of obj.properties) {
|
|
156
|
+
if (prop.type !== 'Property' || prop.computed) continue;
|
|
157
|
+
const key = prop.key.type === 'Identifier' ? prop.key.name
|
|
158
|
+
: prop.key.type === 'Literal' ? String(prop.key.value) : null;
|
|
159
|
+
if (!key || !nameSet.has(key)) continue;
|
|
160
|
+
const v = prop.value;
|
|
161
|
+
if (v.type === 'Literal' && (v.value === 0 || v.value === '0' || v.value === 'none')) continue;
|
|
162
|
+
return true;
|
|
163
|
+
}
|
|
164
|
+
return false;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// The negative-margin opt-out idiom that legitimately negates a bounded
|
|
168
|
+
// surface's padding (marginInline: calc(-1 * ...) / a leading '-').
|
|
169
|
+
function styleHasNegativeInlineMargin(obj) {
|
|
170
|
+
if (!obj) return false;
|
|
171
|
+
for (const prop of obj.properties) {
|
|
172
|
+
if (prop.type !== 'Property' || prop.computed) continue;
|
|
173
|
+
const key = prop.key.type === 'Identifier' ? prop.key.name
|
|
174
|
+
: prop.key.type === 'Literal' ? String(prop.key.value) : null;
|
|
175
|
+
if (!key || !INLINE_MARGIN_STYLE.has(key)) continue;
|
|
176
|
+
const raw = prop.value.type === 'Literal' ? String(prop.value.value)
|
|
177
|
+
: prop.value.type === 'TemplateLiteral'
|
|
178
|
+
? prop.value.quasis.map((q) => q.value.cooked ?? q.value.raw).join('')
|
|
179
|
+
: '';
|
|
180
|
+
if (raw.includes('-') || raw.includes('calc(')) return true;
|
|
181
|
+
}
|
|
182
|
+
return false;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Reinvented-family heuristics. An identity header / author row / entity row
|
|
186
|
+
// hand-built from primitives matches a family's anatomy and should BE the
|
|
187
|
+
// family (ProfileHeader / Metadata / List entry).
|
|
188
|
+
const HEADING_TAGS = new Set(['h1', 'h2', 'h3']);
|
|
189
|
+
const PLAIN_BLOCK_TAGS = new Set(['div', 'section', 'header', 'article', 'a', 'li']);
|
|
190
|
+
const IDENTITY_LEADING = new Set(['Thumbnail', 'Avatar']);
|
|
191
|
+
const META_SEPARATORS = new Set(['·', '•', '・', '∙']);
|
|
192
|
+
|
|
193
|
+
// Does `node`'s JSX subtree contain an element whose tag is in `names`?
|
|
194
|
+
function subtreeHasComponent(node, names) {
|
|
195
|
+
for (const c of node.children ?? []) {
|
|
196
|
+
if (c.type === 'JSXElement') {
|
|
197
|
+
if (names.has(getOpeningName(c.openingElement))) return true;
|
|
198
|
+
if (subtreeHasComponent(c, names)) return true;
|
|
199
|
+
} else if (c.type === 'JSXFragment') {
|
|
200
|
+
if (subtreeHasComponent(c, names)) return true;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
return false;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export const rules = {
|
|
207
|
+
// ───────────────────────────────────────────────────────────────────────
|
|
208
|
+
'no-raw-hex': {
|
|
209
|
+
meta: {
|
|
210
|
+
type: 'problem',
|
|
211
|
+
docs: { description: 'Disallow raw hex colors; every color must resolve to a Chorus token (var(--sys-*) / var(--ref-*)).' },
|
|
212
|
+
schema: [],
|
|
213
|
+
messages: {
|
|
214
|
+
rawHex: 'Raw hex color "{{ hex }}" — use a Chorus token: var(--sys-color-*) (or var(--ref-*) if no semantic alias). See LOVABLE.md § Token strictness.',
|
|
215
|
+
},
|
|
216
|
+
},
|
|
217
|
+
create(context) {
|
|
218
|
+
function check(node, str) {
|
|
219
|
+
const m = HEX_COLOR.exec(str);
|
|
220
|
+
if (m) context.report({ node, messageId: 'rawHex', data: { hex: m[0] } });
|
|
221
|
+
}
|
|
222
|
+
return {
|
|
223
|
+
Literal(node) {
|
|
224
|
+
if (typeof node.value === 'string') check(node, node.value);
|
|
225
|
+
},
|
|
226
|
+
TemplateElement(node) {
|
|
227
|
+
check(node, node.value.cooked ?? node.value.raw ?? '');
|
|
228
|
+
},
|
|
229
|
+
};
|
|
230
|
+
},
|
|
231
|
+
},
|
|
232
|
+
|
|
233
|
+
// ───────────────────────────────────────────────────────────────────────
|
|
234
|
+
'no-tailwind-color': {
|
|
235
|
+
meta: {
|
|
236
|
+
type: 'problem',
|
|
237
|
+
docs: { description: 'Disallow Tailwind color utilities in className; color comes from Chorus tokens via styles.css, not utility classes.' },
|
|
238
|
+
schema: [],
|
|
239
|
+
messages: {
|
|
240
|
+
twColor: 'Tailwind color utility "{{ cls }}" — Chorus color comes from tokens, not utilities. Drop it; surfaces/text inherit from @teamblind-chorus/tokens. See anti-patterns.md.',
|
|
241
|
+
},
|
|
242
|
+
},
|
|
243
|
+
create(context) {
|
|
244
|
+
return {
|
|
245
|
+
JSXAttribute(node) {
|
|
246
|
+
const name = jsxAttrName(node);
|
|
247
|
+
if (name !== 'className' && name !== 'class') return;
|
|
248
|
+
const strings = [];
|
|
249
|
+
collectStaticStrings(node.value, strings);
|
|
250
|
+
for (const s of strings) {
|
|
251
|
+
const m = TW_COLOR.exec(s);
|
|
252
|
+
if (m) {
|
|
253
|
+
context.report({ node, messageId: 'twColor', data: { cls: m[0].trim() } });
|
|
254
|
+
break;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
},
|
|
258
|
+
};
|
|
259
|
+
},
|
|
260
|
+
},
|
|
261
|
+
|
|
262
|
+
// ───────────────────────────────────────────────────────────────────────
|
|
263
|
+
'no-raw-cta': {
|
|
264
|
+
meta: {
|
|
265
|
+
type: 'problem',
|
|
266
|
+
docs: { description: 'Disallow raw <button>, and <a> used as an action; commits go through the @teamblind-chorus/ui Button family.' },
|
|
267
|
+
schema: [],
|
|
268
|
+
messages: {
|
|
269
|
+
rawButton: 'Raw <button> — use <Button> from @teamblind-chorus/ui (variant standard/text/icon/fab). See compose.md § CTAs.',
|
|
270
|
+
anchorAsButton: '<a> used as an action (onClick / no href) — use <Button variant="text" appearance="accent"> from @teamblind-chorus/ui.',
|
|
271
|
+
},
|
|
272
|
+
},
|
|
273
|
+
create(context) {
|
|
274
|
+
return {
|
|
275
|
+
JSXOpeningElement(node) {
|
|
276
|
+
if (node.name.type !== 'JSXIdentifier') return;
|
|
277
|
+
const tag = node.name.name;
|
|
278
|
+
if (tag === 'button') {
|
|
279
|
+
context.report({ node, messageId: 'rawButton' });
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
if (tag === 'a') {
|
|
283
|
+
let hasOnClick = false;
|
|
284
|
+
let hasHref = false;
|
|
285
|
+
for (const attr of node.attributes) {
|
|
286
|
+
const n = jsxAttrName(attr);
|
|
287
|
+
if (n === 'onClick') hasOnClick = true;
|
|
288
|
+
if (n === 'href') hasHref = true;
|
|
289
|
+
}
|
|
290
|
+
// Flag only anchors behaving like buttons; leave real navigation links.
|
|
291
|
+
if (hasOnClick || !hasHref) context.report({ node, messageId: 'anchorAsButton' });
|
|
292
|
+
}
|
|
293
|
+
},
|
|
294
|
+
};
|
|
295
|
+
},
|
|
296
|
+
},
|
|
297
|
+
|
|
298
|
+
// ───────────────────────────────────────────────────────────────────────
|
|
299
|
+
'no-offscale-space': {
|
|
300
|
+
meta: {
|
|
301
|
+
type: 'suggestion',
|
|
302
|
+
docs: { description: 'Disallow off-scale numeric/px spacing, typography, and radius in inline style; values must resolve to sys.layout.* / sys.radius.* / sys.typo.* tokens.' },
|
|
303
|
+
schema: [],
|
|
304
|
+
messages: {
|
|
305
|
+
offScale: 'Off-scale "{{ prop }}: {{ value }}" — resolve to a Chorus token (e.g. var(--sys-layout-*), var(--sys-radius-*)). For typography use className="sys-typo-<role>-<rung>". See compose.md § raw → token map.',
|
|
306
|
+
},
|
|
307
|
+
},
|
|
308
|
+
create(context) {
|
|
309
|
+
function checkObject(obj) {
|
|
310
|
+
for (const prop of obj.properties) {
|
|
311
|
+
if (prop.type !== 'Property' || prop.computed) continue;
|
|
312
|
+
const key = prop.key.type === 'Identifier' ? prop.key.name
|
|
313
|
+
: prop.key.type === 'Literal' ? String(prop.key.value) : null;
|
|
314
|
+
if (!key || !TOKEN_ONLY_PROPS.has(key)) continue;
|
|
315
|
+
const v = prop.value;
|
|
316
|
+
if (v.type === 'Literal') {
|
|
317
|
+
if (typeof v.value === 'number' && v.value !== 0) {
|
|
318
|
+
context.report({ node: v, messageId: 'offScale', data: { prop: key, value: String(v.value) } });
|
|
319
|
+
} else if (typeof v.value === 'string' && !stringIsTokenLike(v.value)) {
|
|
320
|
+
context.report({ node: v, messageId: 'offScale', data: { prop: key, value: v.value } });
|
|
321
|
+
}
|
|
322
|
+
} else if (v.type === 'TemplateLiteral') {
|
|
323
|
+
const raw = v.quasis.map((q) => q.value.cooked ?? q.value.raw).join('');
|
|
324
|
+
if (!stringIsTokenLike(raw)) {
|
|
325
|
+
context.report({ node: v, messageId: 'offScale', data: { prop: key, value: raw || '…' } });
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
return {
|
|
331
|
+
JSXAttribute(node) {
|
|
332
|
+
if (jsxAttrName(node) !== 'style') return;
|
|
333
|
+
const val = node.value;
|
|
334
|
+
if (val && val.type === 'JSXExpressionContainer' && val.expression.type === 'ObjectExpression') {
|
|
335
|
+
checkObject(val.expression);
|
|
336
|
+
}
|
|
337
|
+
},
|
|
338
|
+
};
|
|
339
|
+
},
|
|
340
|
+
},
|
|
341
|
+
|
|
342
|
+
// ───────────────────────────────────────────────────────────────────────
|
|
343
|
+
'no-fullbleed-wrapper': {
|
|
344
|
+
meta: {
|
|
345
|
+
type: 'suggestion',
|
|
346
|
+
docs: { description: 'Disallow wrapping a full-bleed Chorus component in a padded element, or passing it inline padding; the page rail is paid once and a full-bleed child reaches the container edges.' },
|
|
347
|
+
schema: [],
|
|
348
|
+
messages: {
|
|
349
|
+
directPadding: '<{{ name }}> is full-bleed and owns its page-rail padding internally — drop the inline padding (paddingInline / padding / px-* / pl-* / pr-*) on it. A full-bleed child reaches its container edges; the gutter is paid once at the shell. See LOVABLE.md §★ Layout-Type & Padding Contract.',
|
|
350
|
+
wrappedInPadding: '<{{ name }}> (full-bleed) wrapped in a padded <{{ wrapper }}> — the page rail is double-paid, so section headings, list-row leading content, and feed author blocks land at different rails. Remove the wrapper\'s inline padding (the shell pays the gutter once), or — inside a bounded surface — apply the negative-margin opt-out on <{{ name }}>. See anti-patterns.md § Rail self-diagnostic.',
|
|
351
|
+
},
|
|
352
|
+
},
|
|
353
|
+
create(context) {
|
|
354
|
+
return {
|
|
355
|
+
JSXElement(node) {
|
|
356
|
+
const opening = node.openingElement;
|
|
357
|
+
const name = getOpeningName(opening);
|
|
358
|
+
if (!name || !FULL_BLEED.has(name)) return;
|
|
359
|
+
|
|
360
|
+
// (a) inline padding on the full-bleed component itself.
|
|
361
|
+
const ownStyle = getStyleObject(opening);
|
|
362
|
+
const ownClasses = getClassNameStrings(opening);
|
|
363
|
+
if ((ownStyle && objHasNonzeroProp(ownStyle, INLINE_PAD_STYLE)) ||
|
|
364
|
+
ownClasses.some((s) => TW_INLINE_PAD.test(s))) {
|
|
365
|
+
context.report({ node: opening, messageId: 'directPadding', data: { name } });
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// (b) wrapped in a padded plain HTML container (gutter double-paid),
|
|
370
|
+
// unless this instance opts out with a negative inline margin.
|
|
371
|
+
const parent = node.parent;
|
|
372
|
+
if (parent && parent.type === 'JSXElement') {
|
|
373
|
+
const pName = getOpeningName(parent.openingElement);
|
|
374
|
+
if (pName && HTML_CONTAINERS.has(pName)) {
|
|
375
|
+
const pStyle = getStyleObject(parent.openingElement);
|
|
376
|
+
const pClasses = getClassNameStrings(parent.openingElement);
|
|
377
|
+
const padded = (pStyle && objHasNonzeroProp(pStyle, INLINE_PAD_STYLE)) ||
|
|
378
|
+
pClasses.some((s) => TW_INLINE_PAD.test(s));
|
|
379
|
+
if (padded && !styleHasNegativeInlineMargin(ownStyle)) {
|
|
380
|
+
context.report({ node: opening, messageId: 'wrappedInPadding', data: { name, wrapper: pName } });
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
},
|
|
385
|
+
};
|
|
386
|
+
},
|
|
387
|
+
},
|
|
388
|
+
|
|
389
|
+
// ───────────────────────────────────────────────────────────────────────
|
|
390
|
+
'no-redundant-link': {
|
|
391
|
+
meta: {
|
|
392
|
+
type: 'problem',
|
|
393
|
+
docs: { description: 'Disallow wrapping a full-bleed Chorus component in a navigation <Link>/<a>; pass the component\'s own onClick instead — an outer link re-pays the page rail as a gutter and nests the card\'s own anchors.' },
|
|
394
|
+
schema: [],
|
|
395
|
+
messages: {
|
|
396
|
+
redundantLink: '<{{ name }}> wrapped in <{{ tag }}> for navigation — pass <{{ name }}>\'s own onClick (e.g. router.push) instead. An outer link re-pays the page rail as a gutter (misaligning it with NavigationBar / TabBar) and nests the card\'s own anchors (channel link / citation / mention), which breaks DOM parsing. See feed/post.spec.json § forbidden.',
|
|
397
|
+
},
|
|
398
|
+
},
|
|
399
|
+
create(context) {
|
|
400
|
+
return {
|
|
401
|
+
JSXElement(node) {
|
|
402
|
+
const opening = node.openingElement;
|
|
403
|
+
const tag = getOpeningName(opening);
|
|
404
|
+
if (tag !== 'Link' && tag !== 'a') return;
|
|
405
|
+
// Only real navigation links: <Link>, or <a href>. A bare <a onClick>
|
|
406
|
+
// is already covered by no-raw-cta.
|
|
407
|
+
if (tag === 'a' && !opening.attributes.some((at) => jsxAttrName(at) === 'href')) return;
|
|
408
|
+
|
|
409
|
+
for (const child of node.children) {
|
|
410
|
+
if (child.type !== 'JSXElement') continue;
|
|
411
|
+
const cName = getOpeningName(child.openingElement);
|
|
412
|
+
if (cName && FULL_BLEED.has(cName)) {
|
|
413
|
+
context.report({ node: opening, messageId: 'redundantLink', data: { name: cName, tag } });
|
|
414
|
+
break;
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
},
|
|
418
|
+
};
|
|
419
|
+
},
|
|
420
|
+
},
|
|
421
|
+
|
|
422
|
+
// ───────────────────────────────────────────────────────────────────────
|
|
423
|
+
'no-reinvented-family': {
|
|
424
|
+
meta: {
|
|
425
|
+
type: 'suggestion',
|
|
426
|
+
docs: { description: 'Flag identity headers / meta rows hand-assembled from primitives (Thumbnail + heading, or a manual middot separator) — use the ProfileHeader / Metadata / List families instead of reinventing them.' },
|
|
427
|
+
schema: [],
|
|
428
|
+
messages: {
|
|
429
|
+
reinventedIdentity: 'Identity block hand-assembled from primitives (Thumbnail/Avatar + heading) — use a Chorus family: <ProfileHeader> for a profile/channel header, <Metadata> for a card-head author row, or <List variant="entry"> for an entity row. Resolve the exact usage in usage.json; never reinvent a family from token primitives. See anti-patterns.md §18.',
|
|
430
|
+
manualMetaSeparator: 'Hand-rolled "{{ sep }}" separator between meta spans — the <Metadata> family owns this row (name · subhandle · timestamp, middot-separated). Use <Metadata> instead of assembling it from spans. See anti-patterns.md §18.',
|
|
431
|
+
},
|
|
432
|
+
},
|
|
433
|
+
create(context) {
|
|
434
|
+
return {
|
|
435
|
+
JSXElement(node) {
|
|
436
|
+
const tag = getOpeningName(node.openingElement);
|
|
437
|
+
if (!tag || !HEADING_TAGS.has(tag)) return;
|
|
438
|
+
// Walk up a few levels: if a nearby plain container also holds a
|
|
439
|
+
// Thumbnail/Avatar, this is a reinvented identity block. Stop at any
|
|
440
|
+
// Chorus (Uppercase) ancestor — a heading legitimately inside a
|
|
441
|
+
// family is not a reinvention.
|
|
442
|
+
let cur = node.parent;
|
|
443
|
+
for (let hops = 0; cur && hops < 4; hops += 1) {
|
|
444
|
+
if (cur.type === 'JSXElement') {
|
|
445
|
+
const pName = getOpeningName(cur.openingElement);
|
|
446
|
+
if (pName && /^[A-Z]/.test(pName)) return;
|
|
447
|
+
if (pName && PLAIN_BLOCK_TAGS.has(pName) && subtreeHasComponent(cur, IDENTITY_LEADING)) {
|
|
448
|
+
context.report({ node, messageId: 'reinventedIdentity' });
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
cur = cur.parent;
|
|
453
|
+
}
|
|
454
|
+
},
|
|
455
|
+
JSXText(node) {
|
|
456
|
+
const t = (node.value ?? '').trim();
|
|
457
|
+
if (!META_SEPARATORS.has(t)) return;
|
|
458
|
+
const parent = node.parent;
|
|
459
|
+
if (!parent || !parent.children) return;
|
|
460
|
+
// Only a separator *between* sibling elements, not middot inside a
|
|
461
|
+
// single text run (e.g. "공개 · 팔로우" stays one JSXText, untrimmed-equal).
|
|
462
|
+
const elementSiblings = parent.children.filter((c) => c.type === 'JSXElement').length;
|
|
463
|
+
if (elementSiblings >= 2) {
|
|
464
|
+
context.report({ node, messageId: 'manualMetaSeparator', data: { sep: t } });
|
|
465
|
+
}
|
|
466
|
+
},
|
|
467
|
+
};
|
|
468
|
+
},
|
|
469
|
+
},
|
|
470
|
+
};
|
|
471
|
+
|
|
472
|
+
export default rules;
|