@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.
Files changed (178) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/README.md +5 -1
  3. package/dist/brand-values.d.ts +28 -0
  4. package/dist/brand-values.js +172 -0
  5. package/dist/button-intent.d.ts +11 -0
  6. package/dist/button-intent.js +361 -0
  7. package/dist/callback-shape.d.ts +7 -0
  8. package/dist/callback-shape.js +396 -0
  9. package/dist/class-names.d.ts +19 -0
  10. package/dist/class-names.js +170 -0
  11. package/dist/client-boundary.d.ts +42 -0
  12. package/dist/client-boundary.js +404 -0
  13. package/dist/comments.d.ts +47 -0
  14. package/dist/comments.js +156 -0
  15. package/dist/control-height.d.ts +7 -0
  16. package/dist/control-height.js +83 -0
  17. package/dist/drawing.d.ts +31 -0
  18. package/dist/drawing.js +376 -0
  19. package/dist/env-owner.d.ts +22 -0
  20. package/dist/env-owner.js +291 -0
  21. package/dist/fe-stack.d.ts +10 -0
  22. package/dist/fe-stack.js +73 -0
  23. package/dist/file-layout.d.ts +71 -0
  24. package/dist/file-layout.js +465 -0
  25. package/dist/formatting.d.ts +25 -0
  26. package/dist/formatting.js +104 -0
  27. package/dist/function-budget.d.ts +9 -0
  28. package/dist/function-budget.js +48 -0
  29. package/dist/grammar-shim.d.ts +5 -0
  30. package/dist/grammar-shim.js +126 -0
  31. package/dist/hooks-folder.d.ts +34 -0
  32. package/dist/hooks-folder.js +320 -0
  33. package/dist/hygiene.d.ts +39 -0
  34. package/dist/hygiene.js +540 -0
  35. package/dist/index.d.ts +105 -0
  36. package/dist/index.js +310 -0
  37. package/{landmark.mjs → dist/landmark.d.ts} +4 -6
  38. package/dist/landmark.js +6 -0
  39. package/dist/lib/ast.d.ts +36 -0
  40. package/dist/lib/ast.js +94 -0
  41. package/dist/lib/bindings.d.ts +84 -0
  42. package/dist/lib/bindings.js +168 -0
  43. package/dist/lib/class-text.d.ts +3 -0
  44. package/dist/lib/class-text.js +30 -0
  45. package/dist/lib/config.d.ts +81 -0
  46. package/dist/lib/config.js +141 -0
  47. package/dist/lib/drawing-scope.d.ts +5 -0
  48. package/{lib/drawing-scope.mjs → dist/lib/drawing-scope.js} +6 -11
  49. package/dist/lib/eslint-boundary.d.ts +23 -0
  50. package/dist/lib/eslint-boundary.js +6 -0
  51. package/dist/lib/fe-stack.d.ts +49 -0
  52. package/dist/lib/fe-stack.js +98 -0
  53. package/dist/lib/hfs-view.d.ts +74 -0
  54. package/dist/lib/hfs-view.js +6 -0
  55. package/dist/lib/hfs.d.ts +34 -0
  56. package/dist/lib/hfs.js +72 -0
  57. package/dist/lib/next.d.ts +4 -0
  58. package/dist/lib/next.js +10 -0
  59. package/dist/lib/own-hook-origin.d.ts +17 -0
  60. package/dist/lib/own-hook-origin.js +52 -0
  61. package/dist/lib/package-root.d.ts +4 -0
  62. package/dist/lib/package-root.js +10 -0
  63. package/dist/lib/path.d.ts +2 -0
  64. package/{lib/path.mjs → dist/lib/path.js} +1 -1
  65. package/dist/lib/platform.d.ts +26 -0
  66. package/{lib/platform.mjs → dist/lib/platform.js} +38 -35
  67. package/dist/lib/program-for.d.ts +5 -0
  68. package/dist/lib/program-for.js +23 -0
  69. package/dist/lib/pure-owner-helper.d.ts +10 -0
  70. package/dist/lib/pure-owner-helper.js +156 -0
  71. package/dist/lib/react.d.ts +16 -0
  72. package/dist/lib/react.js +22 -0
  73. package/dist/lib/redux-hook-origin.d.ts +4 -0
  74. package/dist/lib/redux-hook-origin.js +50 -0
  75. package/dist/lib/rule.d.ts +28 -0
  76. package/dist/lib/rule.js +5 -0
  77. package/dist/lib/scope.d.ts +88 -0
  78. package/{lib/scope.mjs → dist/lib/scope.js} +83 -93
  79. package/dist/lib/typed-class-values.d.ts +13 -0
  80. package/dist/lib/typed-class-values.js +92 -0
  81. package/dist/lib/types.d.ts +44 -0
  82. package/dist/lib/types.js +64 -0
  83. package/dist/lib/why.d.ts +350 -0
  84. package/dist/lib/why.js +350 -0
  85. package/dist/lint-escape-hatch.d.ts +24 -0
  86. package/{lint-escape-hatch.mjs → dist/lint-escape-hatch.js} +38 -45
  87. package/dist/lists.d.ts +31 -0
  88. package/dist/lists.js +202 -0
  89. package/dist/loading.d.ts +34 -0
  90. package/dist/loading.js +157 -0
  91. package/dist/naming.d.ts +33 -0
  92. package/dist/naming.js +176 -0
  93. package/dist/native-controls.d.ts +24 -0
  94. package/dist/native-controls.js +105 -0
  95. package/dist/next-conventions.d.ts +48 -0
  96. package/dist/next-conventions.js +452 -0
  97. package/dist/project-graph.d.ts +5 -0
  98. package/{project-graph.mjs → dist/project-graph.js} +8 -12
  99. package/dist/props-and-slots.d.ts +19 -0
  100. package/dist/props-and-slots.js +320 -0
  101. package/dist/served-locale.d.ts +38 -0
  102. package/dist/served-locale.js +263 -0
  103. package/dist/shape-slot.d.ts +18 -0
  104. package/dist/shape-slot.js +324 -0
  105. package/dist/size-and-state-budget.d.ts +46 -0
  106. package/dist/size-and-state-budget.js +424 -0
  107. package/dist/size-growth.d.ts +24 -0
  108. package/dist/size-growth.js +64 -0
  109. package/dist/sonar-parity.d.ts +5 -0
  110. package/{sonar-parity.mjs → dist/sonar-parity.js} +11 -7
  111. package/dist/surface-rules.d.ts +18 -0
  112. package/dist/surface-rules.js +491 -0
  113. package/dist/swr-family.d.ts +15 -0
  114. package/dist/swr-family.js +419 -0
  115. package/dist/the-split.d.ts +27 -0
  116. package/dist/the-split.js +157 -0
  117. package/dist/tokens.d.ts +9 -0
  118. package/dist/tokens.js +35 -0
  119. package/dist/translation.d.ts +51 -0
  120. package/dist/translation.js +403 -0
  121. package/dist/transport.d.ts +73 -0
  122. package/dist/transport.js +885 -0
  123. package/dist/type-safety.d.ts +43 -0
  124. package/dist/type-safety.js +139 -0
  125. package/dist/ui-rulings.d.ts +11 -0
  126. package/dist/ui-rulings.js +121 -0
  127. package/package.json +31 -9
  128. package/runtime/knowledge/hfs/canon-pins.yaml +22 -14
  129. package/runtime/scripts/lib/package-at.mjs +5 -5
  130. package/brand-values.mjs +0 -172
  131. package/button-intent.mjs +0 -320
  132. package/callback-shape.mjs +0 -332
  133. package/class-names.mjs +0 -149
  134. package/client-boundary.mjs +0 -400
  135. package/comments.mjs +0 -159
  136. package/control-height.mjs +0 -77
  137. package/drawing.mjs +0 -315
  138. package/env-owner.mjs +0 -244
  139. package/fe-stack.mjs +0 -86
  140. package/file-layout.mjs +0 -472
  141. package/formatting.mjs +0 -110
  142. package/function-budget.mjs +0 -49
  143. package/grammar-shim.mjs +0 -123
  144. package/hooks-folder.mjs +0 -300
  145. package/hygiene.mjs +0 -513
  146. package/index.mjs +0 -317
  147. package/lib/ast.mjs +0 -97
  148. package/lib/bindings.mjs +0 -159
  149. package/lib/class-text.mjs +0 -29
  150. package/lib/config.mjs +0 -142
  151. package/lib/fe-stack.mjs +0 -77
  152. package/lib/hfs.mjs +0 -49
  153. package/lib/next.mjs +0 -6
  154. package/lib/own-hook-origin.mjs +0 -53
  155. package/lib/program-for.mjs +0 -22
  156. package/lib/pure-owner-helper.mjs +0 -148
  157. package/lib/react.mjs +0 -28
  158. package/lib/redux-hook-origin.mjs +0 -44
  159. package/lib/typed-class-values.mjs +0 -81
  160. package/lib/types.mjs +0 -63
  161. package/lib/why.mjs +0 -352
  162. package/lists.mjs +0 -193
  163. package/loading.mjs +0 -156
  164. package/naming.mjs +0 -185
  165. package/native-controls.mjs +0 -108
  166. package/next-conventions.mjs +0 -447
  167. package/props-and-slots.mjs +0 -276
  168. package/served-locale.mjs +0 -243
  169. package/size-and-state-budget.mjs +0 -376
  170. package/size-growth.mjs +0 -62
  171. package/surface-rules.mjs +0 -443
  172. package/swr-family.mjs +0 -326
  173. package/the-split.mjs +0 -155
  174. package/tokens.mjs +0 -39
  175. package/translation.mjs +0 -392
  176. package/transport.mjs +0 -830
  177. package/type-safety.mjs +0 -147
  178. package/ui-rulings.mjs +0 -134
@@ -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"]))