@starci/eslint-canon-fe 9.2.0 → 9.4.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/CHANGELOG.md +14 -0
- package/README.md +5 -1
- package/dist/brand-values.d.ts +28 -0
- package/dist/brand-values.js +172 -0
- package/dist/button-intent.d.ts +11 -0
- package/dist/button-intent.js +361 -0
- package/dist/callback-shape.d.ts +7 -0
- package/dist/callback-shape.js +396 -0
- package/dist/class-names.d.ts +19 -0
- package/dist/class-names.js +170 -0
- package/dist/client-boundary.d.ts +42 -0
- package/dist/client-boundary.js +404 -0
- package/dist/comments.d.ts +47 -0
- package/dist/comments.js +156 -0
- package/dist/control-height.d.ts +7 -0
- package/dist/control-height.js +83 -0
- package/dist/drawing.d.ts +31 -0
- package/dist/drawing.js +376 -0
- package/dist/env-owner.d.ts +22 -0
- package/dist/env-owner.js +291 -0
- package/dist/fe-stack.d.ts +10 -0
- package/dist/fe-stack.js +73 -0
- package/dist/file-layout.d.ts +71 -0
- package/dist/file-layout.js +465 -0
- package/dist/formatting.d.ts +25 -0
- package/dist/formatting.js +104 -0
- package/dist/function-budget.d.ts +9 -0
- package/dist/function-budget.js +48 -0
- package/dist/grammar-shim.d.ts +5 -0
- package/dist/grammar-shim.js +126 -0
- package/dist/hooks-folder.d.ts +34 -0
- package/dist/hooks-folder.js +320 -0
- package/dist/hygiene.d.ts +39 -0
- package/dist/hygiene.js +540 -0
- package/dist/index.d.ts +105 -0
- package/dist/index.js +310 -0
- package/{landmark.mjs → dist/landmark.d.ts} +4 -6
- package/dist/landmark.js +6 -0
- package/dist/lib/ast.d.ts +36 -0
- package/dist/lib/ast.js +94 -0
- package/dist/lib/bindings.d.ts +84 -0
- package/dist/lib/bindings.js +168 -0
- package/dist/lib/class-text.d.ts +3 -0
- package/dist/lib/class-text.js +30 -0
- package/dist/lib/config.d.ts +81 -0
- package/dist/lib/config.js +141 -0
- package/dist/lib/drawing-scope.d.ts +5 -0
- package/{lib/drawing-scope.mjs → dist/lib/drawing-scope.js} +6 -11
- package/dist/lib/eslint-boundary.d.ts +23 -0
- package/dist/lib/eslint-boundary.js +6 -0
- package/dist/lib/fe-stack.d.ts +49 -0
- package/dist/lib/fe-stack.js +98 -0
- package/dist/lib/hfs-view.d.ts +74 -0
- package/dist/lib/hfs-view.js +6 -0
- package/dist/lib/hfs.d.ts +34 -0
- package/dist/lib/hfs.js +72 -0
- package/dist/lib/next.d.ts +4 -0
- package/dist/lib/next.js +10 -0
- package/dist/lib/own-hook-origin.d.ts +17 -0
- package/dist/lib/own-hook-origin.js +52 -0
- package/dist/lib/package-root.d.ts +4 -0
- package/dist/lib/package-root.js +10 -0
- package/dist/lib/path.d.ts +2 -0
- package/{lib/path.mjs → dist/lib/path.js} +1 -1
- package/dist/lib/platform.d.ts +26 -0
- package/{lib/platform.mjs → dist/lib/platform.js} +38 -35
- package/dist/lib/program-for.d.ts +5 -0
- package/dist/lib/program-for.js +23 -0
- package/dist/lib/pure-owner-helper.d.ts +10 -0
- package/dist/lib/pure-owner-helper.js +156 -0
- package/dist/lib/react.d.ts +16 -0
- package/dist/lib/react.js +22 -0
- package/dist/lib/redux-hook-origin.d.ts +4 -0
- package/dist/lib/redux-hook-origin.js +50 -0
- package/dist/lib/rule.d.ts +28 -0
- package/dist/lib/rule.js +5 -0
- package/dist/lib/scope.d.ts +88 -0
- package/{lib/scope.mjs → dist/lib/scope.js} +83 -93
- package/dist/lib/typed-class-values.d.ts +13 -0
- package/dist/lib/typed-class-values.js +92 -0
- package/dist/lib/types.d.ts +44 -0
- package/dist/lib/types.js +64 -0
- package/dist/lib/why.d.ts +350 -0
- package/dist/lib/why.js +350 -0
- package/dist/lint-escape-hatch.d.ts +24 -0
- package/{lint-escape-hatch.mjs → dist/lint-escape-hatch.js} +38 -45
- package/dist/lists.d.ts +31 -0
- package/dist/lists.js +202 -0
- package/dist/loading.d.ts +34 -0
- package/dist/loading.js +157 -0
- package/dist/naming.d.ts +33 -0
- package/dist/naming.js +176 -0
- package/dist/native-controls.d.ts +24 -0
- package/dist/native-controls.js +105 -0
- package/dist/next-conventions.d.ts +48 -0
- package/dist/next-conventions.js +452 -0
- package/dist/project-graph.d.ts +5 -0
- package/{project-graph.mjs → dist/project-graph.js} +8 -12
- package/dist/props-and-slots.d.ts +19 -0
- package/dist/props-and-slots.js +320 -0
- package/dist/served-locale.d.ts +38 -0
- package/dist/served-locale.js +263 -0
- package/dist/shape-slot.d.ts +18 -0
- package/dist/shape-slot.js +324 -0
- package/dist/size-and-state-budget.d.ts +46 -0
- package/dist/size-and-state-budget.js +424 -0
- package/dist/size-growth.d.ts +24 -0
- package/dist/size-growth.js +64 -0
- package/dist/sonar-parity.d.ts +5 -0
- package/{sonar-parity.mjs → dist/sonar-parity.js} +11 -7
- package/dist/surface-rules.d.ts +18 -0
- package/dist/surface-rules.js +491 -0
- package/dist/swr-family.d.ts +15 -0
- package/dist/swr-family.js +419 -0
- package/dist/the-split.d.ts +27 -0
- package/dist/the-split.js +157 -0
- package/dist/tokens.d.ts +9 -0
- package/dist/tokens.js +35 -0
- package/dist/translation.d.ts +51 -0
- package/dist/translation.js +403 -0
- package/dist/transport.d.ts +73 -0
- package/dist/transport.js +885 -0
- package/dist/type-safety.d.ts +43 -0
- package/dist/type-safety.js +139 -0
- package/dist/ui-rulings.d.ts +11 -0
- package/dist/ui-rulings.js +121 -0
- package/package.json +31 -9
- package/runtime/knowledge/hfs/canon-pins.yaml +22 -14
- package/runtime/scripts/lib/package-at.mjs +5 -5
- package/brand-values.mjs +0 -172
- package/button-intent.mjs +0 -320
- package/callback-shape.mjs +0 -332
- package/class-names.mjs +0 -149
- package/client-boundary.mjs +0 -400
- package/comments.mjs +0 -159
- package/control-height.mjs +0 -77
- package/drawing.mjs +0 -315
- package/env-owner.mjs +0 -244
- package/fe-stack.mjs +0 -86
- package/file-layout.mjs +0 -472
- package/formatting.mjs +0 -110
- package/function-budget.mjs +0 -49
- package/grammar-shim.mjs +0 -123
- package/hooks-folder.mjs +0 -300
- package/hygiene.mjs +0 -513
- package/index.mjs +0 -317
- package/lib/ast.mjs +0 -97
- package/lib/bindings.mjs +0 -159
- package/lib/class-text.mjs +0 -29
- package/lib/config.mjs +0 -142
- package/lib/fe-stack.mjs +0 -77
- package/lib/hfs.mjs +0 -49
- package/lib/next.mjs +0 -6
- package/lib/own-hook-origin.mjs +0 -53
- package/lib/program-for.mjs +0 -22
- package/lib/pure-owner-helper.mjs +0 -148
- package/lib/react.mjs +0 -28
- package/lib/redux-hook-origin.mjs +0 -44
- package/lib/typed-class-values.mjs +0 -81
- package/lib/types.mjs +0 -63
- package/lib/why.mjs +0 -352
- package/lists.mjs +0 -193
- package/loading.mjs +0 -156
- package/naming.mjs +0 -185
- package/native-controls.mjs +0 -108
- package/next-conventions.mjs +0 -447
- package/props-and-slots.mjs +0 -276
- package/served-locale.mjs +0 -243
- package/size-and-state-budget.mjs +0 -376
- package/size-growth.mjs +0 -62
- package/surface-rules.mjs +0 -443
- package/swr-family.mjs +0 -326
- package/the-split.mjs +0 -155
- package/tokens.mjs +0 -39
- package/translation.mjs +0 -392
- package/transport.mjs +0 -830
- package/type-safety.mjs +0 -147
- package/ui-rulings.mjs +0 -134
package/next-conventions.mjs
DELETED
|
@@ -1,447 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The rules that hold `next-conventions.md` (HFS `FE_NEXT_CONVENTIONS`, with the lead's locale
|
|
3
|
-
* decision: ONE locale pattern everywhere - `next-intl`, a `[locale]` segment, default `vi`,
|
|
4
|
-
* `localePrefix: "as-needed"`, and `proxy.ts` on Next 16).
|
|
5
|
-
*
|
|
6
|
-
* WHY ONE PATTERN. Three repositories held three spellings of the same thing: a `[lang]` segment in
|
|
7
|
-
* the standard's own example, two i18n stacks in one app so that `/` redirected into a 404, and a
|
|
8
|
-
* `middleware.ts` that Next 16 no longer accepts under that name. Each is invisible to a type
|
|
9
|
-
* check and each is found by a reader hitting the wrong page. The rules below hold the spellings a
|
|
10
|
-
* single file can show; whether `routing.ts` carries `localePrefix: "as-needed"` is the
|
|
11
|
-
* architecture machine's read of `modules/i18n`.
|
|
12
|
-
*/
|
|
13
|
-
|
|
14
|
-
import { attribute, attributeValue, calleeName, leadingTextOf, stringOf } from "./lib/ast.mjs"
|
|
15
|
-
import { variableOf } from "./lib/bindings.mjs"
|
|
16
|
-
import { forbiddingConcern } from "./lib/fe-stack.mjs"
|
|
17
|
-
import { hfsOf } from "./lib/hfs.mjs"
|
|
18
|
-
import { baseName, classOf, classifyImport, fileOf, inSlot, roleOfFile, slotOfFile } from "./lib/scope.mjs"
|
|
19
|
-
|
|
20
|
-
/** Another i18n stack: a second place that decides which language a reader sees (one the fe-stack catalog forbids by name is fe-stack-import's finding). */
|
|
21
|
-
const OTHER_I18N = /^(?:react-i18next|i18next|next-i18next|react-intl|next-translate|typesafe-i18n|rosetta)(?:\/.*)?$|^@lingui\/.+$/
|
|
22
|
-
|
|
23
|
-
// -- FE-NEXT-1 -------------------------------------------------------------------------------------
|
|
24
|
-
|
|
25
|
-
/** Next 16 names the request interceptor `proxy.ts`; `middleware.ts` is forbidden. */
|
|
26
|
-
export const noMiddlewareFile = {
|
|
27
|
-
meta: {
|
|
28
|
-
type: "problem",
|
|
29
|
-
docs: { description: "`middleware.ts` is forbidden: the request interceptor is `proxy.ts` and exports `proxy`." },
|
|
30
|
-
schema: [],
|
|
31
|
-
messages: {
|
|
32
|
-
file:
|
|
33
|
-
"`{{name}}` is not the request interceptor file: Next 16 reads `proxy.ts`. Rename the file and export the handler as `proxy`.",
|
|
34
|
-
export:
|
|
35
|
-
"The handler in `proxy.ts` is exported as `middleware`, but Next 16 reads `proxy`. Export it as `proxy`.",
|
|
36
|
-
},
|
|
37
|
-
},
|
|
38
|
-
create(context) {
|
|
39
|
-
const file = fileOf(context)
|
|
40
|
-
// `middleware.ts` has a slot of its own (`fe.source-root-middleware`, forbidden); the interceptor is the `proxy` role of `fe.source-root-pinned`.
|
|
41
|
-
if (inSlot(context, "fe.source-root-middleware")) {
|
|
42
|
-
return { Program: (node) => context.report({ node, messageId: "file", data: { name: baseName(file) } }) }
|
|
43
|
-
}
|
|
44
|
-
if (!inSlot(context, "fe.source-root-pinned") || roleOfFile(context) !== "proxy") return {}
|
|
45
|
-
const check = (node) => {
|
|
46
|
-
const names = []
|
|
47
|
-
const declaration = node.declaration
|
|
48
|
-
if (declaration && declaration.id) names.push(declaration.id.name)
|
|
49
|
-
if (declaration && declaration.type === "VariableDeclaration") {
|
|
50
|
-
for (const d of declaration.declarations) if (d.id.type === "Identifier") names.push(d.id.name)
|
|
51
|
-
}
|
|
52
|
-
for (const specifier of node.specifiers ?? []) names.push(specifier.exported.name)
|
|
53
|
-
if (names.includes("middleware")) context.report({ node, messageId: "export" })
|
|
54
|
-
}
|
|
55
|
-
return { ExportNamedDeclaration: check, ExportDefaultDeclaration: check }
|
|
56
|
-
},
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
// -- FE-NEXT-2 -------------------------------------------------------------------------------------
|
|
60
|
-
|
|
61
|
-
/** The locale segment is `[locale]`. */
|
|
62
|
-
export const localeSegmentIsLocale = {
|
|
63
|
-
meta: {
|
|
64
|
-
type: "problem",
|
|
65
|
-
docs: { description: "The locale route segment is `[locale]`, never `[lang]`." },
|
|
66
|
-
schema: [],
|
|
67
|
-
messages: {
|
|
68
|
-
segment:
|
|
69
|
-
"This file sits under a `[{{name}}]` segment. The one locale pattern is `[locale]` (next-intl, default `vi`, `localePrefix: \"as-needed\"`); a second spelling means a second routing table.",
|
|
70
|
-
},
|
|
71
|
-
},
|
|
72
|
-
create(context) {
|
|
73
|
-
// A route folder is a segment of slot `fe.route`; the locale one is `[locale]`, never another spelling.
|
|
74
|
-
if (!inSlot(context, "fe.route")) return {}
|
|
75
|
-
const hfs = hfsOf(context)
|
|
76
|
-
const file = fileOf(context)
|
|
77
|
-
const below = hfs.relative(file).slice(classOf(context).root.length + 1).split("/")
|
|
78
|
-
const name = below.slice(0, -1).map((segment) => /^\[(lang|lng|language|i18n)\]$/.exec(segment)).find(Boolean)?.[1]
|
|
79
|
-
if (!name) return {}
|
|
80
|
-
return { Program: (node) => context.report({ node, messageId: "segment", data: { name } }) }
|
|
81
|
-
},
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
// -- FE-NEXT-3 -------------------------------------------------------------------------------------
|
|
85
|
-
|
|
86
|
-
/** Exactly one i18n stack: next-intl. */
|
|
87
|
-
export const noSecondI18nStack = {
|
|
88
|
-
meta: {
|
|
89
|
-
type: "problem",
|
|
90
|
-
docs: { description: "`next-intl` is the only i18n library." },
|
|
91
|
-
schema: [],
|
|
92
|
-
messages: {
|
|
93
|
-
stack:
|
|
94
|
-
"`{{name}}` is a second i18n stack. Two stacks decide the language twice and disagree at the edges (one app redirected `/` into a 404 that way). Use `next-intl` through `modules/i18n`.",
|
|
95
|
-
},
|
|
96
|
-
},
|
|
97
|
-
create(context) {
|
|
98
|
-
return {
|
|
99
|
-
ImportDeclaration(node) {
|
|
100
|
-
const source = String(node.source.value)
|
|
101
|
-
if (OTHER_I18N.test(source) && !forbiddingConcern(source)) context.report({ node, messageId: "stack", data: { name: source } })
|
|
102
|
-
},
|
|
103
|
-
}
|
|
104
|
-
},
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
// -- FE-NEXT-4 -------------------------------------------------------------------------------------
|
|
108
|
-
|
|
109
|
-
/** `<html lang>` follows the `[locale]` segment. */
|
|
110
|
-
export const htmlLangFromLocale = {
|
|
111
|
-
meta: {
|
|
112
|
-
type: "problem",
|
|
113
|
-
docs: { description: "`<html lang>` is taken from the `[locale]` segment, never a literal." },
|
|
114
|
-
schema: [],
|
|
115
|
-
messages: {
|
|
116
|
-
lang:
|
|
117
|
-
"`<html lang=\"...\">` is a literal. A Vietnamese product that ships `lang=\"en\"` mislabels every page for screen readers, translation prompts and search engines. Set it from the `[locale]` param: `lang={locale}`.",
|
|
118
|
-
},
|
|
119
|
-
},
|
|
120
|
-
create(context) {
|
|
121
|
-
return {
|
|
122
|
-
JSXOpeningElement(node) {
|
|
123
|
-
if (node.name.type !== "JSXIdentifier" || node.name.name !== "html") return
|
|
124
|
-
for (const attribute of node.attributes) {
|
|
125
|
-
if (attribute.type !== "JSXAttribute" || attribute.name.name !== "lang" || !attribute.value) continue
|
|
126
|
-
const value = attribute.value
|
|
127
|
-
const literal =
|
|
128
|
-
value.type === "Literal" ||
|
|
129
|
-
(value.type === "JSXExpressionContainer" &&
|
|
130
|
-
(value.expression.type === "Literal" ||
|
|
131
|
-
(value.expression.type === "TemplateLiteral" && value.expression.expressions.length === 0)))
|
|
132
|
-
if (literal) context.report({ node: attribute, messageId: "lang" })
|
|
133
|
-
}
|
|
134
|
-
},
|
|
135
|
-
}
|
|
136
|
-
},
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
// -- FE-NEXT-5 -------------------------------------------------------------------------------------
|
|
140
|
-
|
|
141
|
-
/** The names one named-export statement introduces. */
|
|
142
|
-
const statementExportNames = (statement) => {
|
|
143
|
-
if (statement.type !== "ExportNamedDeclaration") return []
|
|
144
|
-
const declaration = statement.declaration
|
|
145
|
-
const names = (statement.specifiers ?? []).map((specifier) => specifier.exported.name)
|
|
146
|
-
if (declaration?.id) names.push(declaration.id.name)
|
|
147
|
-
if (declaration?.type === "VariableDeclaration") {
|
|
148
|
-
for (const entry of declaration.declarations) {
|
|
149
|
-
if (entry.id.type === "Identifier") names.push(entry.id.name)
|
|
150
|
-
}
|
|
151
|
-
}
|
|
152
|
-
return names
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
/** The names a module exports, from declarations and specifiers. */
|
|
156
|
-
const exportedNames = (program) => new Set(program.body.flatMap(statementExportNames))
|
|
157
|
-
|
|
158
|
-
/** Every page names itself: `metadata` or `generateMetadata`. */
|
|
159
|
-
export const pageExportsMetadata = {
|
|
160
|
-
meta: {
|
|
161
|
-
type: "problem",
|
|
162
|
-
docs: { description: "Every `page.tsx` exports `metadata` or `generateMetadata`." },
|
|
163
|
-
schema: [],
|
|
164
|
-
messages: {
|
|
165
|
-
metadata:
|
|
166
|
-
"This page exports neither `metadata` nor `generateMetadata`, so it ships with whatever title its layout happens to have: every page of the section is called the same in the tab, in the history, in a shared link and in a search result. Export `metadata` (or `generateMetadata` when the title comes from data), with the title and description taken from the message catalogue.",
|
|
167
|
-
},
|
|
168
|
-
},
|
|
169
|
-
create(context) {
|
|
170
|
-
// A page is the file the slot gives the role `page` (spec files carry no role).
|
|
171
|
-
if (!inSlot(context, "fe.route") || roleOfFile(context) !== "page") return {}
|
|
172
|
-
return {
|
|
173
|
-
"Program:exit"(program) {
|
|
174
|
-
const names = exportedNames(program)
|
|
175
|
-
if (!names.has("metadata") && !names.has("generateMetadata")) context.report({ node: program, messageId: "metadata" })
|
|
176
|
-
},
|
|
177
|
-
}
|
|
178
|
-
},
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
// -- FE-NEXT-6 -------------------------------------------------------------------------------------
|
|
182
|
-
|
|
183
|
-
/** A Suspense boundary shows something while it waits. */
|
|
184
|
-
export const noNullSuspenseFallback = {
|
|
185
|
-
meta: {
|
|
186
|
-
type: "problem",
|
|
187
|
-
docs: { description: "`<Suspense>` has a fallback that renders something; `fallback={null}` is forbidden." },
|
|
188
|
-
schema: [],
|
|
189
|
-
messages: {
|
|
190
|
-
nullFallback:
|
|
191
|
-
"`<Suspense>` with a `null` (or missing) fallback: while the subtree loads the reader sees a hole where content will jump in, with no sign anything is happening. Render the loading state of the thing that is loading (the block's skeleton).",
|
|
192
|
-
},
|
|
193
|
-
},
|
|
194
|
-
create(context) {
|
|
195
|
-
return {
|
|
196
|
-
JSXOpeningElement(node) {
|
|
197
|
-
const name = node.name
|
|
198
|
-
const isSuspense =
|
|
199
|
-
(name.type === "JSXIdentifier" && name.name === "Suspense") ||
|
|
200
|
-
(name.type === "JSXMemberExpression" && name.property.name === "Suspense")
|
|
201
|
-
if (!isSuspense) return
|
|
202
|
-
const fallback = attribute(node, "fallback")
|
|
203
|
-
const value = attributeValue(fallback)
|
|
204
|
-
const empty =
|
|
205
|
-
!fallback ||
|
|
206
|
-
!value ||
|
|
207
|
-
(value.type === "Literal" && value.value === null) ||
|
|
208
|
-
(value.type === "Identifier" && value.name === "undefined")
|
|
209
|
-
if (empty) context.report({ node, messageId: "nullFallback" })
|
|
210
|
-
},
|
|
211
|
-
}
|
|
212
|
-
},
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
// -- FE-NEXT-7 -------------------------------------------------------------------------------------
|
|
216
|
-
|
|
217
|
-
/** The locale-aware navigation owner and framework boundaries outside its provider. */
|
|
218
|
-
const isNavigationOwner = (context) =>
|
|
219
|
-
inSlot(context, "fe.package.i18n") ||
|
|
220
|
-
(inSlot(context, "fe.modules.routes") && hfsOf(context).apps.length === 1) ||
|
|
221
|
-
(inSlot(context, "fe.source-root-pinned") && roleOfFile(context) === "proxy") ||
|
|
222
|
-
(inSlot(context, "fe.route") && roleOfFile(context) === "global-error")
|
|
223
|
-
|
|
224
|
-
/** The router helpers that are locale-blind in `next/navigation`. */
|
|
225
|
-
const LOCALE_BLIND = new Set(["useRouter", "usePathname", "redirect", "permanentRedirect"])
|
|
226
|
-
|
|
227
|
-
/** Navigation helpers come from the locale-aware public routes or shared i18n entry. */
|
|
228
|
-
export const navigationFromIntl = {
|
|
229
|
-
meta: {
|
|
230
|
-
type: "problem",
|
|
231
|
-
docs: { description: "`Link`, `useRouter`, `usePathname` and `redirect` come from the locale-aware routes or shared i18n public entry, not from Next." },
|
|
232
|
-
schema: [],
|
|
233
|
-
messages: {
|
|
234
|
-
link:
|
|
235
|
-
"`next/link` does not know the locale: a link built with it drops the `[locale]` prefix. Import Link from the locale-aware public routes entry or shared i18n package, built from the one routing configuration.",
|
|
236
|
-
helper:
|
|
237
|
-
"`{{name}}` from `next/navigation` does not know the locale. Import it from the locale-aware public routes entry or shared i18n package, built from the one routing configuration.",
|
|
238
|
-
},
|
|
239
|
-
},
|
|
240
|
-
create(context) {
|
|
241
|
-
if (isNavigationOwner(context)) return {}
|
|
242
|
-
return {
|
|
243
|
-
ImportDeclaration(node) {
|
|
244
|
-
if (node.importKind === "type") return
|
|
245
|
-
const source = String(node.source.value)
|
|
246
|
-
if (source === "next/link") return context.report({ node, messageId: "link" })
|
|
247
|
-
if (source !== "next/navigation") return
|
|
248
|
-
for (const specifier of node.specifiers) {
|
|
249
|
-
if (specifier.type === "ImportSpecifier" && specifier.importKind !== "type" && LOCALE_BLIND.has(specifier.imported.name)) {
|
|
250
|
-
context.report({ node: specifier, messageId: "helper", data: { name: specifier.imported.name } })
|
|
251
|
-
}
|
|
252
|
-
}
|
|
253
|
-
},
|
|
254
|
-
}
|
|
255
|
-
},
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
// -- FE-NEXT-8 -------------------------------------------------------------------------------------
|
|
259
|
-
|
|
260
|
-
/** An href that leaves the app: a scheme, a protocol-relative URL, or an in-page anchor. */
|
|
261
|
-
const isExternalHref = (text) => /^(?:[a-z][a-z0-9+.-]*:|\/\/|#)/i.test(text)
|
|
262
|
-
|
|
263
|
-
/** No hand-drawn `<a>` for an internal route; an external one opens safely. */
|
|
264
|
-
export const noNativeAnchor = {
|
|
265
|
-
meta: {
|
|
266
|
-
type: "problem",
|
|
267
|
-
docs: { description: "An internal link uses the locale-aware public navigation entry; an external `_blank` link carries `rel`." },
|
|
268
|
-
schema: [],
|
|
269
|
-
messages: {
|
|
270
|
-
internal:
|
|
271
|
-
"`<a href=\"{{href}}\">` for a route of this app reloads the page, skips prefetch and drops the locale prefix. Use the locale-aware Link with an href built by modules/routes.",
|
|
272
|
-
rel:
|
|
273
|
-
"`target=\"_blank\"` with no `rel`. The opened page can reach back through `window.opener`. Add `rel=\"noopener noreferrer\"`.",
|
|
274
|
-
},
|
|
275
|
-
},
|
|
276
|
-
create(context) {
|
|
277
|
-
return {
|
|
278
|
-
JSXOpeningElement(node) {
|
|
279
|
-
if (node.name.type !== "JSXIdentifier" || node.name.name !== "a") return
|
|
280
|
-
const href = stringOf(attributeValue(attribute(node, "href")))
|
|
281
|
-
if (href !== null && href !== "" && !isExternalHref(href)) context.report({ node, messageId: "internal", data: { href } })
|
|
282
|
-
const target = stringOf(attributeValue(attribute(node, "target")))
|
|
283
|
-
const rel = attribute(node, "rel")
|
|
284
|
-
if (target === "_blank" && !rel) context.report({ node, messageId: "rel" })
|
|
285
|
-
},
|
|
286
|
-
}
|
|
287
|
-
},
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
// -- FE-NEXT-9 -------------------------------------------------------------------------------------
|
|
291
|
-
|
|
292
|
-
/** The functions whose first argument is a route: the router's methods and the two redirects. */
|
|
293
|
-
const isNavigation = (name) =>
|
|
294
|
-
name === "redirect" || name === "permanentRedirect" || /(?:^|\.)router\.(?:push|replace|prefetch)$/.test(name)
|
|
295
|
-
|
|
296
|
-
/** True for `/x`, `/x/y`; false for `//host` and for `/` alone, which every app owns. */
|
|
297
|
-
const isInternalPath = (text) => text.startsWith("/") && !text.startsWith("//") && text.length > 1
|
|
298
|
-
|
|
299
|
-
/** The places that may write a route: the routes module (`fe.modules.routes`) and the i18n module (`fe.modules.i18n`). */
|
|
300
|
-
const isRoutesModule = (context) => inSlot(context, "fe.modules.routes", "fe.modules.i18n")
|
|
301
|
-
|
|
302
|
-
/** No route written by hand outside `modules/routes`. */
|
|
303
|
-
export const noHardcodedRoute = {
|
|
304
|
-
meta: {
|
|
305
|
-
type: "problem",
|
|
306
|
-
docs: { description: "A route path is built by `modules/routes`, never written as a literal at a link or a navigation call." },
|
|
307
|
-
schema: [],
|
|
308
|
-
messages: {
|
|
309
|
-
route:
|
|
310
|
-
"`{{path}}` is a route written by hand. When the route moves, every copy of it silently points at a 404, and nothing type-checks the difference. Build it with the href builder in `modules/routes` and use that one function everywhere.",
|
|
311
|
-
},
|
|
312
|
-
},
|
|
313
|
-
create(context) {
|
|
314
|
-
if (isRoutesModule(context)) return {}
|
|
315
|
-
const source = context.sourceCode ?? context.getSourceCode()
|
|
316
|
-
const report = (node, text) => context.report({ node, messageId: "route", data: { path: text } })
|
|
317
|
-
return {
|
|
318
|
-
JSXAttribute(node) {
|
|
319
|
-
if (node.name.type !== "JSXIdentifier" || node.name.name !== "href") return
|
|
320
|
-
const value = attributeValue(node)
|
|
321
|
-
const lead = leadingTextOf(value)
|
|
322
|
-
if (lead !== null && isInternalPath(lead)) report(node, source.getText(value))
|
|
323
|
-
},
|
|
324
|
-
CallExpression(node) {
|
|
325
|
-
const name = calleeName(node.callee)
|
|
326
|
-
if (!name || !isNavigation(name)) return
|
|
327
|
-
const first = node.arguments[0]
|
|
328
|
-
const lead = leadingTextOf(first)
|
|
329
|
-
if (lead !== null && isInternalPath(lead)) report(first, source.getText(first))
|
|
330
|
-
},
|
|
331
|
-
}
|
|
332
|
-
},
|
|
333
|
-
}
|
|
334
|
-
|
|
335
|
-
// -- FE-NEXT-10 -----------------------------------------------------------------------------------
|
|
336
|
-
|
|
337
|
-
/** The next-intl stack factories: module specifier -> the export that builds one layer of the stack (`default` for a default export). */
|
|
338
|
-
const STACK_FACTORIES = new Map([
|
|
339
|
-
["next-intl/routing", new Set(["defineRouting"])],
|
|
340
|
-
["next-intl/navigation", new Set(["createNavigation"])],
|
|
341
|
-
["next-intl/server", new Set(["getRequestConfig"])],
|
|
342
|
-
["next-intl/middleware", new Set(["default"])],
|
|
343
|
-
])
|
|
344
|
-
|
|
345
|
-
/** The public name selected by a named or default import. */
|
|
346
|
-
const importedName = (specifier) => {
|
|
347
|
-
if (specifier.type === "ImportSpecifier") return specifier.imported.name ?? specifier.imported.value
|
|
348
|
-
if (specifier.type === "ImportDefaultSpecifier") return "default"
|
|
349
|
-
return null
|
|
350
|
-
}
|
|
351
|
-
|
|
352
|
-
/** The known next-intl factory selected from an import. */
|
|
353
|
-
const stackFactory = (definition, exported) => {
|
|
354
|
-
const source = String(definition.parent.source.value)
|
|
355
|
-
if (!STACK_FACTORIES.get(source)?.has(exported)) return null
|
|
356
|
-
return { source, name: exported === "default" ? "createMiddleware" : exported }
|
|
357
|
-
}
|
|
358
|
-
|
|
359
|
-
/** A factory called through a namespace import, else null. */
|
|
360
|
-
const namespaceFactory = (context, callee) => {
|
|
361
|
-
if (callee.type !== "MemberExpression" || callee.computed || callee.object.type !== "Identifier" || callee.property.type !== "Identifier") return null
|
|
362
|
-
const definition = variableOf(context, callee.object)?.defs[0] ?? null
|
|
363
|
-
if (definition?.type !== "ImportBinding" || definition.node.type !== "ImportNamespaceSpecifier") return null
|
|
364
|
-
return stackFactory(definition, callee.property.name)
|
|
365
|
-
}
|
|
366
|
-
|
|
367
|
-
/** The name a stack factory has where the call reads it: "defineRouting" (next-intl/routing), else null. */
|
|
368
|
-
const stackFactoryOf = (context, callee) => {
|
|
369
|
-
if (callee.type !== "Identifier") return namespaceFactory(context, callee)
|
|
370
|
-
const definition = variableOf(context, callee)?.defs[0] ?? null
|
|
371
|
-
if (definition?.type !== "ImportBinding") return null
|
|
372
|
-
return stackFactory(definition, importedName(definition.node))
|
|
373
|
-
}
|
|
374
|
-
|
|
375
|
-
/**
|
|
376
|
-
* The next-intl stack is written once per repository.
|
|
377
|
-
*
|
|
378
|
-
* `defineRouting` (`next-intl/routing`), `createNavigation` (`next-intl/navigation`), `getRequestConfig` (`next-intl/server`) and
|
|
379
|
-
* `createMiddleware` (`next-intl/middleware`), resolved by the import that binds the called name (a renamed import or a namespace
|
|
380
|
-
* member counts), have one owner. A shared i18n package owns its stack. In a one-app repository, routing and request
|
|
381
|
-
* config belong to modules/i18n while createNavigation belongs to modules/routes. The proxy imports routing without
|
|
382
|
-
* evaluating navigation: next-intl's navigation module selects React runtime exports unavailable in the proxy.
|
|
383
|
-
*
|
|
384
|
-
* It does not replace `no-second-i18n-stack`: that rule refuses another i18n LIBRARY at its import, this one refuses a second copy of
|
|
385
|
-
* next-intl's own stack. Different evidence, different remedy.
|
|
386
|
-
*/
|
|
387
|
-
export const i18nStackInOneModule = {
|
|
388
|
-
meta: {
|
|
389
|
-
type: "problem",
|
|
390
|
-
docs: { description: "A shared i18n package owns the stack; one app keeps routing/request config in i18n and navigation in routes, with a routing-only proxy entry." },
|
|
391
|
-
schema: [],
|
|
392
|
-
messages: {
|
|
393
|
-
stack:
|
|
394
|
-
"`{{name}}` builds the next-intl stack outside its owner. Keep {{where}} and import the owned result through its public entry.",
|
|
395
|
-
proxyNavigation:
|
|
396
|
-
"The proxy imports navigation runtime through {{source}}. Import routing from the i18n public entry, or use the shared package's routing-only proxy export; the proxy must not evaluate the routes/navigation barrel.",
|
|
397
|
-
},
|
|
398
|
-
},
|
|
399
|
-
create(context) {
|
|
400
|
-
const hfs = hfsOf(context)
|
|
401
|
-
const slot = slotOfFile(context)
|
|
402
|
-
const single = hfs.apps.length === 1
|
|
403
|
-
const proxy = single && slot === "fe.source-root-pinned" && roleOfFile(context) === "proxy"
|
|
404
|
-
const proxyFile = slot === "fe.source-root-pinned" && roleOfFile(context) === "proxy"
|
|
405
|
-
const where = single ? "routing/request config in modules/i18n and navigation in modules/routes" : "the stack in the shared i18n package that every app calls"
|
|
406
|
-
const safeProxyImport = (node) => {
|
|
407
|
-
if (!proxyFile || !node.source || node.importKind === "type" || node.exportKind === "type") return
|
|
408
|
-
if (node.specifiers?.length && node.specifiers.every((entry) => entry.importKind === "type" || entry.exportKind === "type")) return
|
|
409
|
-
const source = String(node.source.value)
|
|
410
|
-
if (source === "next-intl/navigation" || classifyImport(context, source)?.slot === "fe.modules.routes") {
|
|
411
|
-
context.report({ node, messageId: "proxyNavigation", data: { source } })
|
|
412
|
-
}
|
|
413
|
-
}
|
|
414
|
-
return {
|
|
415
|
-
ImportDeclaration: safeProxyImport,
|
|
416
|
-
ExportNamedDeclaration: safeProxyImport,
|
|
417
|
-
ExportAllDeclaration: safeProxyImport,
|
|
418
|
-
CallExpression(node) {
|
|
419
|
-
const factory = stackFactoryOf(context, node.callee)
|
|
420
|
-
// In a one-app repository the proxy (role `proxy`) mounts the locale middleware built from the one routing; a shared repository
|
|
421
|
-
// builds it in the i18n package and the proxy re-exports it.
|
|
422
|
-
if (factory?.name === "createMiddleware" && proxy) return
|
|
423
|
-
if (slot === "fe.package.i18n") return
|
|
424
|
-
if (single && factory?.name === "createNavigation" && slot === "fe.modules.routes") return
|
|
425
|
-
if (single && factory?.name !== "createNavigation" && slot === "fe.modules.i18n") return
|
|
426
|
-
if (factory) context.report({ node, messageId: "stack", data: { name: factory.name, where } })
|
|
427
|
-
},
|
|
428
|
-
}
|
|
429
|
-
},
|
|
430
|
-
}
|
|
431
|
-
|
|
432
|
-
/** The rules this law contributes to the plugin. */
|
|
433
|
-
export const rules = {
|
|
434
|
-
"no-middleware-file": noMiddlewareFile,
|
|
435
|
-
"locale-segment-is-locale": localeSegmentIsLocale,
|
|
436
|
-
"no-second-i18n-stack": noSecondI18nStack,
|
|
437
|
-
"html-lang-from-locale": htmlLangFromLocale,
|
|
438
|
-
"page-exports-metadata": pageExportsMetadata,
|
|
439
|
-
"no-null-suspense-fallback": noNullSuspenseFallback,
|
|
440
|
-
"navigation-from-intl": navigationFromIntl,
|
|
441
|
-
"no-native-anchor": noNativeAnchor,
|
|
442
|
-
"no-hardcoded-route": noHardcodedRoute,
|
|
443
|
-
"i18n-stack-in-one-module": i18nStackInOneModule,
|
|
444
|
-
}
|
|
445
|
-
|
|
446
|
-
/** Every rule is an error. */
|
|
447
|
-
export const recommended = Object.fromEntries(Object.keys(rules).map((name) => [`starci-fe/${name}`, "error"]))
|