@starci/eslint-canon-fe 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/comments.mjs +190 -0
- package/contract.mjs +1199 -0
- package/contracts.ts +312 -0
- package/file-layout.mjs +310 -0
- package/icon.mjs +309 -0
- package/index.mjs +118 -0
- package/landmark.mjs +242 -0
- package/lint-adoption.mjs +116 -0
- package/lint-escape-hatch.mjs +56 -0
- package/loading.mjs +164 -0
- package/naming.mjs +150 -0
- package/package.json +38 -0
- package/props-and-slots.mjs +188 -0
- package/props.ts +145 -0
- package/served-locale.mjs +161 -0
- package/the-split.mjs +146 -0
- package/tokens.mjs +268 -0
- package/translation.mjs +122 -0
- package/type-safety.mjs +74 -0
- package/typography.mjs +83 -0
- package/vendor-boundary.mjs +526 -0
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Repository-level audit for effective StarCi FE lint adoption.
|
|
3
|
+
*
|
|
4
|
+
* An ESLint config can import a plugin, copy a plugin, or call itself StarCi while still leaving
|
|
5
|
+
* canonical rules switched off. The only useful evidence is the effective config ESLint applies
|
|
6
|
+
* to a real production file. This audit compares that printed config with this canon.
|
|
7
|
+
*/
|
|
8
|
+
/**
|
|
9
|
+
* Where the component tree sits, per supported repository shape.
|
|
10
|
+
*
|
|
11
|
+
* ONE BRANCH, IN ONE PLACE. The difference between a monorepo and a single app is not a difference
|
|
12
|
+
* of law - the rules, their severities and the inline-config refusal are identical. It is only
|
|
13
|
+
* WHERE the law applies. Written out in each repository's config, that fact became two hand-kept
|
|
14
|
+
* lists, and the lists did what hand-kept lists do: one enabled 43 rules, the other 53, and canon
|
|
15
|
+
* had 49. Neither repository was wrong on purpose; nothing told either of them.
|
|
16
|
+
*
|
|
17
|
+
* The candidate path is included deliberately. A Preview candidate is the exact source Apply later
|
|
18
|
+
* ports into production, and leaving it ungoverned means the one file that becomes production was
|
|
19
|
+
* judged by nothing.
|
|
20
|
+
*/
|
|
21
|
+
const LAYOUT_GLOBS = {
|
|
22
|
+
monorepo: [
|
|
23
|
+
"packages/ui/src/**/*.{ts,tsx}",
|
|
24
|
+
"apps/*/src/**/*.{ts,tsx}",
|
|
25
|
+
"**/candidate/src/**/*.{ts,tsx}",
|
|
26
|
+
],
|
|
27
|
+
"single-app": [
|
|
28
|
+
"src/**/*.{ts,tsx}",
|
|
29
|
+
"**/candidate/src/**/*.{ts,tsx}",
|
|
30
|
+
],
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The repository shapes this canon knows how to govern. */
|
|
34
|
+
export const LAYOUTS = Object.keys(LAYOUT_GLOBS)
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The one flat-config block that attaches this canon to a repository.
|
|
38
|
+
*
|
|
39
|
+
* A consuming `eslint.config.mjs` spreads the result and owns nothing else about the rules: not
|
|
40
|
+
* which are on, not their severity, not whether an inline comment may switch one off. Its own
|
|
41
|
+
* plugins, its own ignores and its own language options stay its own business - what it may not
|
|
42
|
+
* keep is a second opinion about the law.
|
|
43
|
+
*
|
|
44
|
+
* @param {object} input - Attachment options.
|
|
45
|
+
* @param {"monorepo" | "single-app"} input.layout - Which shape this repository has.
|
|
46
|
+
* @param {object} input.plugin - The plugin object, imported from this canon.
|
|
47
|
+
* @param {object} input.recommended - The recommendation, imported from this canon.
|
|
48
|
+
* @param {object} input.linterOptions - The linter options, imported from this canon.
|
|
49
|
+
* @returns {object} One flat-config block.
|
|
50
|
+
*/
|
|
51
|
+
export const starciFeConfig = ({ layout, plugin, recommended, linterOptions }) => {
|
|
52
|
+
const files = LAYOUT_GLOBS[layout]
|
|
53
|
+
if (!files) {
|
|
54
|
+
throw new Error(`unknown layout "${layout}" - expected one of: ${LAYOUTS.join(", ")}`)
|
|
55
|
+
}
|
|
56
|
+
/*
|
|
57
|
+
* `recommended` is a FLAT rule map here, not a config object with a `rules` key - and reaching
|
|
58
|
+
* for `.rules` on it is the mistake this line exists to stop repeating. Spread that way it
|
|
59
|
+
* yields `undefined`, the block ends up with zero rules, and the repository lints clean while
|
|
60
|
+
* being governed by nothing. That is the worst possible failure for this file: green, silent,
|
|
61
|
+
* and indistinguishable from success. Both spellings are accepted so neither shape can produce
|
|
62
|
+
* an empty block.
|
|
63
|
+
*/
|
|
64
|
+
const rules = recommended?.rules ?? recommended
|
|
65
|
+
if (!rules || Object.keys(rules).length === 0) {
|
|
66
|
+
throw new Error("starciFeConfig received an empty recommendation - a config with no rules is not adoption")
|
|
67
|
+
}
|
|
68
|
+
return {
|
|
69
|
+
files,
|
|
70
|
+
linterOptions,
|
|
71
|
+
plugins: { "starci-fe": plugin },
|
|
72
|
+
rules: { ...rules },
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Convert every flat-config severity spelling to its numeric meaning. */
|
|
77
|
+
const severityOf = (setting) => {
|
|
78
|
+
const severity = Array.isArray(setting) ? setting[0] : setting
|
|
79
|
+
if (severity === "error") return 2
|
|
80
|
+
if (severity === "warn") return 1
|
|
81
|
+
if (severity === "off") return 0
|
|
82
|
+
return severity
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Compare an `eslint --print-config` result with the canonical recommendation.
|
|
87
|
+
*
|
|
88
|
+
* @param {object} printedConfig - Parsed effective ESLint config for one production probe file.
|
|
89
|
+
* @param {Record<string, string | number | unknown[]>} expectedRules - Canonical rule levels.
|
|
90
|
+
*/
|
|
91
|
+
export const auditLintAdoption = (printedConfig, expectedRules) => {
|
|
92
|
+
const actualRules = printedConfig?.rules ?? {}
|
|
93
|
+
const missing = []
|
|
94
|
+
const nonError = []
|
|
95
|
+
|
|
96
|
+
for (const name of Object.keys(expectedRules).sort()) {
|
|
97
|
+
if (actualRules[name] === undefined) {
|
|
98
|
+
missing.push(name)
|
|
99
|
+
continue
|
|
100
|
+
}
|
|
101
|
+
if (severityOf(actualRules[name]) !== 2) nonError.push(name)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const refusesInlineConfig = printedConfig?.linterOptions?.noInlineConfig === true
|
|
105
|
+
return {
|
|
106
|
+
ok: missing.length === 0 && nonError.length === 0 && refusesInlineConfig,
|
|
107
|
+
missing,
|
|
108
|
+
nonError,
|
|
109
|
+
refusesInlineConfig,
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Repository audits are gathered beside rules, but are not ESLint AST rules themselves. */
|
|
114
|
+
export const audits = { "effective-config": auditLintAdoption }
|
|
115
|
+
export const rules = {}
|
|
116
|
+
export const recommended = {}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/** The rule and flat-config fence that hold `lint-escape-hatch.md`. */
|
|
2
|
+
|
|
3
|
+
/** Forward-slash form of a filename, so Windows paths compare like every other path. */
|
|
4
|
+
const normalizePath = (filename) => String(filename || "").replace(/\\/g, "/")
|
|
5
|
+
|
|
6
|
+
/** Product source is governed; canon tests deliberately construct forbidden directives. */
|
|
7
|
+
const isProductSource = (filename) => normalizePath(filename).includes("/src/")
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Any directive that changes ESLint's active rule set inside a source file.
|
|
11
|
+
*
|
|
12
|
+
* ANCHORED AT THE START OF THE COMMENT, because that is the only place ESLint itself honours one:
|
|
13
|
+
* a directive is recognised from the first non-space character of the comment body and nowhere else.
|
|
14
|
+
* An unanchored pattern matched the WORD instead of the directive, so a comment explaining why a
|
|
15
|
+
* file carries no `eslint-disable` was reported as carrying one - and the only way to silence that
|
|
16
|
+
* was to stop writing the explanation, which is the opposite of what this law wants. Under-catching
|
|
17
|
+
* is not the trade: a directive ESLint would obey always sits at the start.
|
|
18
|
+
*/
|
|
19
|
+
const INLINE_DIRECTIVE = /^\s*eslint-(?:disable(?:-next-line|-line)?|enable)\b/
|
|
20
|
+
|
|
21
|
+
/** Inline lint configuration is repository policy, never a file-local choice. */
|
|
22
|
+
export const noInlineLintConfig = {
|
|
23
|
+
meta: {
|
|
24
|
+
type: "problem",
|
|
25
|
+
docs: { description: "Product source cannot change its own ESLint policy." },
|
|
26
|
+
schema: [],
|
|
27
|
+
messages: {
|
|
28
|
+
directive:
|
|
29
|
+
"Inline ESLint configuration makes this file the author of whether repository law applies. Remove the directive and fix the code or the shared rule; there is no local exception path.",
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
create(context) {
|
|
33
|
+
if (!isProductSource(context.filename || context.getFilename())) return {}
|
|
34
|
+
const source = context.sourceCode || context.getSourceCode()
|
|
35
|
+
return {
|
|
36
|
+
Program() {
|
|
37
|
+
for (const comment of source.getAllComments()) {
|
|
38
|
+
if (INLINE_DIRECTIVE.test(comment.value)) context.report({ node: comment, messageId: "directive" })
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
}
|
|
42
|
+
},
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The rules this law contributes to the plugin. */
|
|
46
|
+
export const rules = {
|
|
47
|
+
"no-inline-lint-config": noInlineLintConfig,
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Every lint escape is a build error. */
|
|
51
|
+
export const recommended = {
|
|
52
|
+
"starci-fe/no-inline-lint-config": "error",
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Consuming flat configs apply this beside the recommended rules. */
|
|
56
|
+
export const linterOptions = Object.freeze({ noInlineConfig: true })
|
package/loading.mjs
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The rules that hold `loading.md`.
|
|
3
|
+
*
|
|
4
|
+
* Three shapes of the same mistake: a second tree describing the first. A twin component, a
|
|
5
|
+
* ready-made placeholder handed in as a prop, and a ternary picking between two different
|
|
6
|
+
* components at a call site. Each is correct on the day it is written and wrong the first time the
|
|
7
|
+
* real shape changes.
|
|
8
|
+
*
|
|
9
|
+
* WHY THIS NEEDS RULES AT ALL, when so much of this canon is held by types. A resting shape has no
|
|
10
|
+
* assertion to fail: it is not wrong in a way a compiler or a test can see, it is wrong on screen,
|
|
11
|
+
* and only for the second somebody happens to be watching. That is exactly the class of defect a
|
|
12
|
+
* lint rule is for - the one nothing else will ever report.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** Forward-slash form of a filename, so Windows paths compare like every other path. */
|
|
16
|
+
const normalizePath = (filename) => String(filename || "").replace(/\\/g, "/")
|
|
17
|
+
|
|
18
|
+
/** The component tree; a placeholder in a test fixture is a fixture, not a second tree. */
|
|
19
|
+
const isComponentFile = (filename) => normalizePath(filename).includes("/src/components/")
|
|
20
|
+
|
|
21
|
+
/** A twin test may build a resting shape by hand to assert against it. */
|
|
22
|
+
const isTestFile = (filename) => /\.(?:test|spec)\.(?:ts|tsx)$/.test(normalizePath(filename))
|
|
23
|
+
|
|
24
|
+
/** Product component source these rules govern. */
|
|
25
|
+
const isGoverned = (filename) => isComponentFile(filename) && !isTestFile(filename)
|
|
26
|
+
|
|
27
|
+
/** A flag that means "waiting", in the spellings this codebase uses. */
|
|
28
|
+
const WAITING_FLAG = /\bis(?:Loading|Skeleton|Pending)\b/
|
|
29
|
+
|
|
30
|
+
/** The name of a component whose whole job is to mirror another one's shape. */
|
|
31
|
+
const TWIN_NAME = /^[A-Za-z0-9]*Skeleton$/
|
|
32
|
+
|
|
33
|
+
/** JSX element name, including `Foo.Bar`. */
|
|
34
|
+
const jsxElementName = (opening) => {
|
|
35
|
+
const name = opening && opening.name
|
|
36
|
+
if (!name) return null
|
|
37
|
+
if (name.type === "JSXIdentifier") return name.name
|
|
38
|
+
if (name.type === "JSXMemberExpression" && name.object && name.property) {
|
|
39
|
+
return `${name.object.name}.${name.property.name}`
|
|
40
|
+
}
|
|
41
|
+
return null
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// -- LOADING-1 -------------------------------------------------------------------------------------
|
|
45
|
+
|
|
46
|
+
/** No component exists whose job is to mirror another component's shape. */
|
|
47
|
+
export const noRestingTwinComponent = {
|
|
48
|
+
meta: {
|
|
49
|
+
type: "problem",
|
|
50
|
+
docs: { description: "No component whose whole job is to mirror another one's shape." },
|
|
51
|
+
schema: [],
|
|
52
|
+
messages: {
|
|
53
|
+
twin:
|
|
54
|
+
"`{{name}}` is a hand-mirrored twin: a second description of a shape that already has one. It cannot be kept in step - it can only be noticed after it has already drifted, and a resting shape has no assertion to fail, so nothing will report the drift. Give the component it mirrors a resting state and let it rest as ITSELF.",
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
create(context) {
|
|
58
|
+
const file = normalizePath(context.filename || context.getFilename())
|
|
59
|
+
if (!isGoverned(file)) return {}
|
|
60
|
+
const hit = file.match(/\/([A-Za-z0-9]*Skeleton)\/index\.tsx?$/) || file.match(/\/([A-Za-z0-9]*Skeleton)\.tsx?$/)
|
|
61
|
+
if (!hit || !TWIN_NAME.test(hit[1])) return {}
|
|
62
|
+
return {
|
|
63
|
+
Program(node) {
|
|
64
|
+
context.report({ node, messageId: "twin", data: { name: hit[1] } })
|
|
65
|
+
},
|
|
66
|
+
}
|
|
67
|
+
},
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// -- LOADING-1, handed in from outside --------------------------------------------------------------
|
|
71
|
+
|
|
72
|
+
/** A ready-made placeholder is the same second tree, passed as a prop. */
|
|
73
|
+
export const noPlaceholderProp = {
|
|
74
|
+
meta: {
|
|
75
|
+
type: "problem",
|
|
76
|
+
docs: { description: "No prop hands a component a ready-made resting shape to render." },
|
|
77
|
+
schema: [],
|
|
78
|
+
messages: {
|
|
79
|
+
prop:
|
|
80
|
+
"A resting shape handed in as markup is a second description of a tree that already has one, and it sits further from the shape it copies than a twin does - the component it describes cannot even see it. Pass the waiting flag down instead, and let each part rest as itself.",
|
|
81
|
+
import:
|
|
82
|
+
"`{{name}}` is a hand-kept resting tree imported from beside its subject. It drifts the first time the real shape changes, and nothing reports it. Thread the waiting flag down instead.",
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
create(context) {
|
|
86
|
+
if (!isGoverned(context.filename || context.getFilename())) return {}
|
|
87
|
+
return {
|
|
88
|
+
JSXAttribute(node) {
|
|
89
|
+
const name = node.name && node.name.type === "JSXIdentifier" ? node.name.name : null
|
|
90
|
+
if (name !== "skeleton" && name !== "placeholder" && name !== "fallback") return
|
|
91
|
+
const value = node.value
|
|
92
|
+
if (!value || value.type !== "JSXExpressionContainer") return
|
|
93
|
+
const expression = value.expression
|
|
94
|
+
if (expression.type === "JSXElement" || expression.type === "JSXFragment") {
|
|
95
|
+
context.report({ node, messageId: "prop" })
|
|
96
|
+
}
|
|
97
|
+
},
|
|
98
|
+
ImportDeclaration(node) {
|
|
99
|
+
const source = node.source && node.source.value
|
|
100
|
+
if (typeof source !== "string" || !/^\.\.?\//.test(source)) return
|
|
101
|
+
for (const specifier of node.specifiers || []) {
|
|
102
|
+
const local = specifier.local && specifier.local.name
|
|
103
|
+
// A bare `Skeleton` is the primitive a component rests WITH, not a twin of one.
|
|
104
|
+
if (!local || local === "Skeleton" || !TWIN_NAME.test(local)) continue
|
|
105
|
+
context.report({ node: specifier, messageId: "import", data: { name: local } })
|
|
106
|
+
}
|
|
107
|
+
},
|
|
108
|
+
}
|
|
109
|
+
},
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// -- LOADING-2 -------------------------------------------------------------------------------------
|
|
113
|
+
|
|
114
|
+
/** A caller never picks between a resting shape and a real one. */
|
|
115
|
+
export const noRestingBranchAtCallSite = {
|
|
116
|
+
meta: {
|
|
117
|
+
type: "problem",
|
|
118
|
+
docs: { description: "A waiting flag must not choose between two different components." },
|
|
119
|
+
schema: [],
|
|
120
|
+
messages: {
|
|
121
|
+
branch:
|
|
122
|
+
"`{{flag}}` picks between two DIFFERENT elements, which is a resting shape written at the call site - and it drifts from the real one the first time the real one changes. Give the component below a waiting flag and pass it down; let it rest as ITSELF. The same component on both sides is the honest form: one description, two states.",
|
|
123
|
+
},
|
|
124
|
+
},
|
|
125
|
+
create(context) {
|
|
126
|
+
if (!isGoverned(context.filename || context.getFilename())) return {}
|
|
127
|
+
const source = context.sourceCode || context.getSourceCode()
|
|
128
|
+
/** Root element name of one arm, or null when the arm is not an element. */
|
|
129
|
+
const armName = (expression) => {
|
|
130
|
+
if (!expression) return null
|
|
131
|
+
if (expression.type === "JSXElement") return jsxElementName(expression.openingElement)
|
|
132
|
+
if (expression.type === "JSXFragment") return "<>"
|
|
133
|
+
return null
|
|
134
|
+
}
|
|
135
|
+
return {
|
|
136
|
+
ConditionalExpression(node) {
|
|
137
|
+
const test = source.getText(node.test)
|
|
138
|
+
if (!WAITING_FLAG.test(test)) return
|
|
139
|
+
const left = armName(node.consequent)
|
|
140
|
+
const right = armName(node.alternate)
|
|
141
|
+
// Both arms must be real elements AND differ. `null` on one side is LOADING-5 - a control
|
|
142
|
+
// that has nowhere to go yet - and that is correct rather than a second tree.
|
|
143
|
+
if (!left || !right || left === right) return
|
|
144
|
+
context.report({ node, messageId: "branch", data: { flag: test.trim().slice(0, 40) } })
|
|
145
|
+
},
|
|
146
|
+
}
|
|
147
|
+
},
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** The rules this law contributes to the plugin. */
|
|
151
|
+
export const rules = {
|
|
152
|
+
"no-resting-twin-component": noRestingTwinComponent,
|
|
153
|
+
"no-placeholder-prop": noPlaceholderProp,
|
|
154
|
+
"no-resting-branch-at-call-site": noRestingBranchAtCallSite,
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* The level this law asks for, as the plugin's own opinion.
|
|
159
|
+
*
|
|
160
|
+
* A repository adopting these with history should expect the twin rule to report a folder at a
|
|
161
|
+
* time: hand-kept placeholder trees arrive in families, one per screen, and each is a real shape
|
|
162
|
+
* that has to be folded back into the component it copies rather than deleted.
|
|
163
|
+
*/
|
|
164
|
+
export const recommended = Object.fromEntries(Object.keys(rules).map((name) => [`starci-fe/${name}`, "error"]))
|
package/naming.mjs
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The rules that hold `naming.md`.
|
|
3
|
+
*
|
|
4
|
+
* Two spellings, both of which work, which is exactly why they are rules: nothing corrects the
|
|
5
|
+
* second one, so a file written on a Tuesday reads differently from its neighbour and every diff
|
|
6
|
+
* afterwards carries noise that has nothing to do with the change.
|
|
7
|
+
*
|
|
8
|
+
* Neither rule can see intent, and both are deliberately narrow because of it. NAMING-1 fires on
|
|
9
|
+
* shape alone - a module-level `function` - which is exact. NAMING-2 fires on the `handle` prefix
|
|
10
|
+
* alone, which catches the rename-at-the-boundary case and nothing else; a name that merely
|
|
11
|
+
* describes an action is not its business.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** Parents that make a function declaration module-level rather than nested. */
|
|
15
|
+
const MODULE_LEVEL_PARENTS = new Set(["Program", "ExportNamedDeclaration", "ExportDefaultDeclaration"])
|
|
16
|
+
|
|
17
|
+
// -- NAMING-1 --------------------------------------------------------------------------------------
|
|
18
|
+
|
|
19
|
+
/** A module-level function is an arrow const, so nothing exists before the line that declares it. */
|
|
20
|
+
export const preferArrowExport = {
|
|
21
|
+
meta: {
|
|
22
|
+
type: "suggestion",
|
|
23
|
+
docs: { description: "Module-level functions use an arrow const, not a `function` declaration." },
|
|
24
|
+
schema: [],
|
|
25
|
+
messages: {
|
|
26
|
+
fn: "`function {{name}}` is hoisted, so it exists before the line that declares it and the order of this file stops promising anything about what is defined when. Write `const {{name}} = (...) => {...}` - a const cannot be used before it exists, so the file reads in the order it runs.",
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
create(context) {
|
|
30
|
+
return {
|
|
31
|
+
FunctionDeclaration(node) {
|
|
32
|
+
const parent = node.parent && node.parent.type
|
|
33
|
+
if (!MODULE_LEVEL_PARENTS.has(parent)) return
|
|
34
|
+
context.report({
|
|
35
|
+
node: node.id || node,
|
|
36
|
+
messageId: "fn",
|
|
37
|
+
data: { name: (node.id && node.id.name) || "default" },
|
|
38
|
+
})
|
|
39
|
+
},
|
|
40
|
+
}
|
|
41
|
+
},
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// -- NAMING-2 --------------------------------------------------------------------------------------
|
|
45
|
+
|
|
46
|
+
/** Something a reader triggers is named `onX`, so the name survives being passed. */
|
|
47
|
+
export const handlerOnPrefix = {
|
|
48
|
+
meta: {
|
|
49
|
+
type: "suggestion",
|
|
50
|
+
docs: { description: "Handlers are named `onX`, not `handleX`." },
|
|
51
|
+
schema: [],
|
|
52
|
+
messages: {
|
|
53
|
+
handle: "`{{name}}` is renamed at every boundary it crosses - the slot is `on`, the DOM attribute is `onClick`, and the props type says `on{{rest}}`. Call it `on{{rest}}` at birth and the name is the same at the declaration, at the call site and in the type.",
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
create(context) {
|
|
57
|
+
const flag = (node, name) => {
|
|
58
|
+
if (!name || !/^handle[A-Z]/.test(name)) return
|
|
59
|
+
context.report({ node, messageId: "handle", data: { name, rest: name.slice("handle".length) } })
|
|
60
|
+
}
|
|
61
|
+
return {
|
|
62
|
+
VariableDeclarator(node) {
|
|
63
|
+
if (node.id && node.id.type === "Identifier") flag(node.id, node.id.name)
|
|
64
|
+
},
|
|
65
|
+
JSXAttribute(node) {
|
|
66
|
+
if (node.name) flag(node.name, node.name.name)
|
|
67
|
+
},
|
|
68
|
+
TSPropertySignature(node) {
|
|
69
|
+
if (node.key && node.key.type === "Identifier") flag(node.key, node.key.name)
|
|
70
|
+
},
|
|
71
|
+
}
|
|
72
|
+
},
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** The rules this law contributes to the plugin. */
|
|
76
|
+
// -- NAMING-3 --------------------------------------------------------------------------------------
|
|
77
|
+
|
|
78
|
+
/** Letters that exist in Vietnamese and not in English, in the forms a path can carry. */
|
|
79
|
+
const SECOND_LANGUAGE_PATH = /[àáâãèéêìíòóôõùúýăđĩũơưạảấầẩẫậắằẳẵặẹẻẽếềểễệỉịọỏốồổỗộớờởỡợụủứừửữựỳỵỷỹ]/i
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Second-language words spelled in ASCII, as a route or folder segment writes them.
|
|
83
|
+
*
|
|
84
|
+
* A path cannot carry diacritics, so `cấp phát` reaches the filesystem as `cap-phat` and every
|
|
85
|
+
* accent-based check passes it. The segments below are the ones this product actually produced.
|
|
86
|
+
* A list is the honest instrument here: guessing at Vietnamese-shaped ASCII would refuse `dang`
|
|
87
|
+
* in `dangerous` and `cap` in `capacity`, and a rule that fires on English words is one a
|
|
88
|
+
* repository turns off.
|
|
89
|
+
*/
|
|
90
|
+
const ROMANISED = [
|
|
91
|
+
"dang-nhap", "dang-ky", "dang-xuat", "cap-phat", "khoa-hoc", "hoc-vien", "gioi-thieu",
|
|
92
|
+
"lien-he", "tai-khoan", "thanh-toan", "gio-hang", "tin-tuc", "san-pham", "bang-gia",
|
|
93
|
+
"dieu-khoan", "chinh-sach", "trang-chu", "quan-ly", "cai-dat", "ho-so",
|
|
94
|
+
]
|
|
95
|
+
|
|
96
|
+
/** A route segment or folder name, lowercased, from a normalized path. */
|
|
97
|
+
const segmentsOf = (filename) =>
|
|
98
|
+
String(filename || "").replace(/\\/g, "/").toLowerCase().split("/").filter(Boolean)
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* A path names things in one language: the one every reader of this repository shares.
|
|
102
|
+
*
|
|
103
|
+
* WHY A PATH AND NOT ONLY THE SOURCE. `no-second-language-in-source` reads identifiers, comments
|
|
104
|
+
* and strings, and cannot see the file it is reading. So a route could be `app/cap-phat/page.tsx`
|
|
105
|
+
* with every identifier inside it in English, and nothing said a word - while the URL, the import
|
|
106
|
+
* specifier, the folder in every editor sidebar and the path in every stack trace stayed in a
|
|
107
|
+
* language half the readers do not have.
|
|
108
|
+
*
|
|
109
|
+
* A ROUTE SEGMENT IS ALSO A PUBLIC NAME. It is the address a customer sees and a support ticket
|
|
110
|
+
* quotes, so this is not only an authoring question: the product's own URLs stop being readable to
|
|
111
|
+
* anybody outside one language.
|
|
112
|
+
*/
|
|
113
|
+
export const noSecondLanguageInPath = {
|
|
114
|
+
meta: {
|
|
115
|
+
type: "problem",
|
|
116
|
+
docs: { description: "File and route names are written in the repository's one shared language." },
|
|
117
|
+
schema: [],
|
|
118
|
+
messages: {
|
|
119
|
+
path:
|
|
120
|
+
"`{{segment}}` names this file in a second language. A path is read by more people than the code inside it - it is the URL a customer quotes, the folder every editor shows, and the specifier every import repeats - and unlike a comment it cannot be skimmed past. Rename the segment; the words a person READS belong in the locale catalogue, never in the address.",
|
|
121
|
+
},
|
|
122
|
+
},
|
|
123
|
+
create(context) {
|
|
124
|
+
const filename = context.filename || context.getFilename()
|
|
125
|
+
const offending = segmentsOf(filename).find(
|
|
126
|
+
(segment) => SECOND_LANGUAGE_PATH.test(segment) || ROMANISED.includes(segment.replace(/[()[\]]/g, "")),
|
|
127
|
+
)
|
|
128
|
+
if (!offending) return {}
|
|
129
|
+
return {
|
|
130
|
+
Program(node) {
|
|
131
|
+
context.report({ node, messageId: "path", data: { segment: offending } })
|
|
132
|
+
},
|
|
133
|
+
}
|
|
134
|
+
},
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export const rules = {
|
|
138
|
+
"prefer-arrow-export": preferArrowExport,
|
|
139
|
+
"handler-on-prefix": handlerOnPrefix,
|
|
140
|
+
"no-second-language-in-path": noSecondLanguageInPath,
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* The level this law asks for, as the plugin's own opinion.
|
|
145
|
+
*
|
|
146
|
+
* A consuming repository's `eslint.config.mjs` stays the authority on what is actually switched on.
|
|
147
|
+
* Both rules here are mechanical and auto-correctable by hand in seconds, so a repository adopting
|
|
148
|
+
* them with existing debt should burn the count down rather than live at `warn`.
|
|
149
|
+
*/
|
|
150
|
+
export const recommended = Object.fromEntries(Object.keys(rules).map((name) => [`starci-fe/${name}`, "error"]))
|
package/package.json
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@starci/eslint-canon-fe",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "The StarCi front-end lint canon: every rule its laws publish, gathered as one flat-config plugin.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "UNLICENSED",
|
|
7
|
+
"private": false,
|
|
8
|
+
"exports": {
|
|
9
|
+
".": "./index.mjs",
|
|
10
|
+
"./contracts": "./contracts.ts",
|
|
11
|
+
"./props": "./props.ts"
|
|
12
|
+
},
|
|
13
|
+
"main": "./index.mjs",
|
|
14
|
+
"files": [
|
|
15
|
+
"*.mjs",
|
|
16
|
+
"*.ts",
|
|
17
|
+
"!*.test.mjs",
|
|
18
|
+
"README.md"
|
|
19
|
+
],
|
|
20
|
+
"engines": {
|
|
21
|
+
"node": ">=20.9.0"
|
|
22
|
+
},
|
|
23
|
+
"peerDependencies": {
|
|
24
|
+
"eslint": ">=9"
|
|
25
|
+
},
|
|
26
|
+
"publishConfig": {
|
|
27
|
+
"access": "public"
|
|
28
|
+
},
|
|
29
|
+
"scripts": {
|
|
30
|
+
"test": "node --test \"*.test.mjs\""
|
|
31
|
+
},
|
|
32
|
+
"keywords": [
|
|
33
|
+
"eslint",
|
|
34
|
+
"eslintplugin",
|
|
35
|
+
"starci",
|
|
36
|
+
"canon"
|
|
37
|
+
]
|
|
38
|
+
}
|