@starci/eslint-canon-fe 4.0.0 → 6.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.
Files changed (81) hide show
  1. package/CHANGELOG.md +111 -0
  2. package/README.md +33 -16
  3. package/brand-values.mjs +172 -0
  4. package/class-names.mjs +9 -9
  5. package/client-boundary.mjs +287 -0
  6. package/comments.mjs +137 -190
  7. package/docs/brand-values.md +30 -0
  8. package/docs/client-boundary.md +124 -0
  9. package/docs/e2e-shape.md +161 -0
  10. package/docs/env-owner.md +52 -0
  11. package/docs/formatting.md +30 -0
  12. package/docs/grammar-boundary.md +33 -0
  13. package/docs/hooks-folder.md +29 -0
  14. package/docs/hygiene.md +122 -0
  15. package/docs/lint-escape-hatch.md +34 -0
  16. package/docs/lists.md +74 -0
  17. package/docs/native-controls.md +73 -0
  18. package/docs/next-conventions.md +232 -0
  19. package/docs/size-and-state-budget.md +73 -0
  20. package/docs/size-growth.md +29 -0
  21. package/docs/spec-quality.md +147 -0
  22. package/docs/status-colors.md +32 -0
  23. package/docs/translation.md +54 -0
  24. package/docs/transport.md +195 -0
  25. package/docs/type-safety.md +95 -0
  26. package/e2e-shape.mjs +345 -0
  27. package/env-owner.mjs +134 -0
  28. package/file-layout.mjs +105 -104
  29. package/formatting.mjs +104 -0
  30. package/grammar-boundary.mjs +85 -9
  31. package/hooks-folder.mjs +120 -0
  32. package/hygiene.mjs +513 -0
  33. package/icon.mjs +212 -212
  34. package/index.mjs +243 -142
  35. package/landmark.mjs +0 -11
  36. package/lib/ast.mjs +94 -0
  37. package/lib/bindings.mjs +144 -0
  38. package/lib/config.mjs +79 -0
  39. package/lib/hfs.mjs +48 -0
  40. package/lib/next.mjs +6 -0
  41. package/lib/path.mjs +2 -0
  42. package/lib/platform.mjs +66 -0
  43. package/lib/react.mjs +28 -0
  44. package/lib/scope.mjs +142 -0
  45. package/lib/status-tones.generated.mjs +9 -0
  46. package/lib/types.mjs +63 -0
  47. package/lib/why.mjs +334 -0
  48. package/lint-escape-hatch.mjs +93 -56
  49. package/lists.mjs +197 -0
  50. package/loading.mjs +156 -164
  51. package/naming.mjs +149 -143
  52. package/native-controls.mjs +112 -0
  53. package/next-conventions.mjs +430 -0
  54. package/package.json +15 -6
  55. package/props-and-slots.mjs +19 -13
  56. package/runtime/engine/plain-object.mjs +5 -0
  57. package/runtime/engine/runtime-root.mjs +32 -0
  58. package/runtime/engine/yaml.mjs +161 -0
  59. package/runtime/knowledge/hfs/slots.yaml +1072 -0
  60. package/runtime/scripts/lib/git.mjs +53 -0
  61. package/runtime/scripts/lib/glob.mjs +23 -0
  62. package/runtime/scripts/lib/hfs-allows.mjs +42 -0
  63. package/runtime/scripts/lib/hfs-slots.mjs +722 -0
  64. package/runtime/scripts/lib/hfs-view.mjs +68 -0
  65. package/runtime/scripts/lib/next-contract.mjs +14 -0
  66. package/runtime/scripts/lib/path-key.mjs +17 -0
  67. package/runtime/scripts/lib/recorded-lines.mjs +27 -0
  68. package/served-locale.mjs +174 -160
  69. package/shape-slot.mjs +22 -21
  70. package/size-and-state-budget.mjs +214 -0
  71. package/size-growth.mjs +58 -0
  72. package/spec-quality.mjs +318 -0
  73. package/status-colors.mjs +139 -0
  74. package/the-split.mjs +133 -134
  75. package/tokens.mjs +259 -268
  76. package/translation.mjs +295 -122
  77. package/transport.mjs +752 -0
  78. package/type-safety.mjs +147 -74
  79. package/typography.mjs +78 -83
  80. package/vendor-boundary.mjs +5 -27
  81. package/lint-adoption.mjs +0 -116
@@ -0,0 +1,287 @@
1
+ /**
2
+ * The rule that holds `client-boundary.md` (HFS R55, `FE_CLIENT_BOUNDARY`).
3
+ *
4
+ * SERVER FIRST. A route file is a server component; `"use client"` is a cost the app pays where a
5
+ * reader interacts, and it is paid once, at the top of the interactive subtree, not sprinkled
6
+ * wherever a hook happened to be needed. The measured alternative: 155 client files in one app,
7
+ * a client layout that shipped a 132 KB message catalog to every page, against 3 client files in a
8
+ * sibling app that did the same job server-first.
9
+ *
10
+ * The directive is allowed in exactly the places the framework or the interaction requires:
11
+ * - the `index.tsx` of an interactive block (or overlay), which is the connected half;
12
+ * - a leaf, whose interactivity is intrinsic (a menu, a tooltip trigger);
13
+ * - `error.tsx` and `global-error.tsx`, which Next requires to be client components.
14
+ * Everything below a client block is already on the client and needs no directive; everything
15
+ * above it is a server component and must not have one.
16
+ */
17
+
18
+ import ts from "typescript"
19
+ import { classifyImport, fileOf, isProductSource, isSpecFile, kindOfFile, roleOfFile, slotOfFile, tierOfFile } from "./lib/scope.mjs"
20
+ import { typed } from "./lib/types.mjs"
21
+
22
+ /** The route roles that are client-only by the framework's own rule: `error` and `global-error`. */
23
+ const FRAMEWORK_CLIENT_ROLES = new Set(["error", "global-error"])
24
+
25
+ /** True when the program opens with the `"use client"` directive. */
26
+ const clientDirective = (program) =>
27
+ program.body.find(
28
+ (statement) => statement.type === "ExpressionStatement" && statement.directive === "use client",
29
+ ) ?? null
30
+
31
+ /** The layers whose files are legitimately client: a block's entry, a leaf (its interaction is intrinsic) and, in a shared package, the interactive layers (branches and leaves; a composite is presentation). */
32
+ const isInteractionBoundary = (context) => {
33
+ const slot = slotOfFile(context)
34
+ const kind = kindOfFile(context)
35
+ const role = roleOfFile(context)
36
+ if (slot === "fe.components") return kind === "leaves" || (kind === "blocks" && role === "entry")
37
+ if (slot === "fe.feature") return kind === "overlays" && role === "entry"
38
+ if (slot === "fe.package.ui") return kind === "branches" || kind === "leaves"
39
+ return false
40
+ }
41
+
42
+ /** Where the directive is a legitimate boundary. */
43
+ const isBoundary = (context) => (slotOfFile(context) === "fe.route" && FRAMEWORK_CLIENT_ROLES.has(roleOfFile(context))) || isInteractionBoundary(context)
44
+
45
+ /** The slot a file fills in the route tree (its role in `fe.route`, or the page or layout owner of a feature), when it fills one. */
46
+ const routeFill = (context) => {
47
+ if (slotOfFile(context) === "fe.route" && roleOfFile(context) !== null) return roleOfFile(context)
48
+ if (slotOfFile(context) === "fe.feature" && kindOfFile(context) === "pages") return "page owner"
49
+ if (slotOfFile(context) === "fe.feature" && kindOfFile(context) === "layouts") return "layout owner"
50
+ return null
51
+ }
52
+
53
+ /** `"use client"` only at an interaction boundary; pages and layouts stay server components. */
54
+ export const useClientOnlyAtBoundary = {
55
+ meta: {
56
+ type: "problem",
57
+ docs: { description: "`\"use client\"` appears only at a block index, a leaf, or a Next error boundary." },
58
+ schema: [],
59
+ messages: {
60
+ slot:
61
+ "`\"use client\"` on a {{slot}}. Routes, pages and layouts are server components: making one a client component ships its whole subtree, including the message catalog, to every reader and stops it reading data on the server. Move the interaction into a block below it and put the directive on that block's `index.tsx`.",
62
+ elsewhere:
63
+ "`\"use client\"` here is not at an interaction boundary. The directive belongs on the `index.tsx` of the interactive block (or overlay), on a leaf whose interaction is intrinsic, or on `error.tsx`/`global-error.tsx`. Below a client block the file is already client; above one it must stay a server component.",
64
+ },
65
+ },
66
+ create(context) {
67
+ if (isSpecFile(fileOf(context)) || isBoundary(context)) return {}
68
+ const slot = routeFill(context)
69
+ return {
70
+ Program(program) {
71
+ const directive = clientDirective(program)
72
+ if (!directive) return
73
+ if (slot) context.report({ node: directive, messageId: "slot", data: { slot } })
74
+ else context.report({ node: directive, messageId: "elsewhere" })
75
+ },
76
+ }
77
+ },
78
+ }
79
+
80
+ // -- FE-CLIENT-2 -----------------------------------------------------------------------------------
81
+
82
+ /** Node built-ins a browser bundle cannot carry. */
83
+ const NODE_BUILTINS = /^(?:node:.+|fs|fs\/promises|path|os|net|tls|http|https|http2|child_process|cluster|crypto|stream|zlib|dns|worker_threads)$/
84
+
85
+ /** A server API: the request-scoped Next APIs, the server half of next-intl, a Node built-in; null for anything else. */
86
+ const serverApiReason = (source) => {
87
+ if (source === "next/headers") return "`next/headers`"
88
+ if (source === "next/server") return "`next/server`"
89
+ if (source === "next-intl/server") return "`next-intl/server`"
90
+ if (NODE_BUILTINS.test(source)) return `\`${source}\``
91
+ return null
92
+ }
93
+
94
+ /** A server-only source: the marker package, a server API, a server reader (role `reader` of slot `fe.modules.api`). */
95
+ const serverOnlyReason = (context, source) => {
96
+ if (source === "server-only") return "`server-only`"
97
+ const api = serverApiReason(source)
98
+ if (api) return api
99
+ const target = classifyImport(context, source)
100
+ if (target?.slot === "fe.modules.api" && target.role === "reader") return `the server reader \`${source}\``
101
+ return null
102
+ }
103
+
104
+ /** A client component imports nothing that only exists on the server. */
105
+ export const clientNoServerImport = {
106
+ meta: {
107
+ type: "problem",
108
+ docs: { description: "A `\"use client\"` file does not import `server-only`, `next/headers`, a Node built-in or a server reader." },
109
+ schema: [],
110
+ messages: {
111
+ server:
112
+ "This client component imports {{what}}, which exists only on the server. The build either fails or, worse, pulls server code and its secrets toward the browser bundle. Read the data in a server component or a server reader and pass the result down as props, or call the app client through SWR.",
113
+ },
114
+ },
115
+ create(context) {
116
+ if (isSpecFile(fileOf(context))) return {}
117
+ let client = false
118
+ return {
119
+ Program(program) {
120
+ client = clientDirective(program) !== null
121
+ },
122
+ ImportDeclaration(node) {
123
+ if (!client || node.importKind === "type") return
124
+ const what = serverOnlyReason(context, String(node.source.value))
125
+ if (what) context.report({ node, messageId: "server", data: { what } })
126
+ },
127
+ }
128
+ },
129
+ }
130
+
131
+ // -- FE-CLIENT-2b ----------------------------------------------------------------------------------
132
+
133
+ /** The slots whose files are Next route files: server components by construction, so the framework decides, not a marker. */
134
+ const ROUTE_FILE_SLOTS = new Set(["fe.route", "fe.source-root-pinned"])
135
+
136
+ /** True when a statement is `import "server-only"`: an import with no bindings from the marker package. */
137
+ const isMarkerStatement = (statement) =>
138
+ statement?.type === "ImportDeclaration" && statement.specifiers.length === 0 && statement.source.value === "server-only"
139
+
140
+ /** True when a TypeScript source file opens with `import "server-only"`. */
141
+ const tsFileIsMarked = (file) => {
142
+ const first = file.statements[0]
143
+ return Boolean(first) && ts.isImportDeclaration(first) && !first.importClause && ts.isStringLiteral(first.moduleSpecifier) && first.moduleSpecifier.text === "server-only"
144
+ }
145
+
146
+ /**
147
+ * The source file an import specifier resolves to: the checker's module symbol, so path aliases, extensions and index
148
+ * files resolve as the compiler resolves them. Null for a declaration file, a package or an unresolved specifier.
149
+ */
150
+ const resolvedSourceFile = (context, specifier) => {
151
+ const { checker, toTs } = typed(context)
152
+ const tsNode = toTs(specifier)
153
+ if (!tsNode) return null
154
+ const symbol = checker.getSymbolAtLocation(tsNode)
155
+ const declaration = symbol?.valueDeclaration ?? symbol?.declarations?.[0]
156
+ return declaration && ts.isSourceFile(declaration) && !declaration.isDeclarationFile ? declaration : null
157
+ }
158
+
159
+ /** A module that uses a server-only API, or imports a module that is itself server-only, opens with `import "server-only"`. */
160
+ /** Component tiers: a component is judged by where client code reaches it, not by a marker. */
161
+ const COMPONENT_TIERS = new Set(["components", "feature"])
162
+
163
+ export const serverModuleMarksServerOnly = {
164
+ meta: {
165
+ type: "problem",
166
+ docs: { description: "A module importing `next/headers`, `next/server`, `next-intl/server`, a Node built-in or a server-only module starts with `import \"server-only\"`." },
167
+ schema: [],
168
+ messages: {
169
+ mark:
170
+ "This module imports {{what}}, which exists only on the server, but it does not start with `import \"server-only\"`. Without the marker nothing stops a client component from importing this module (directly or through a hook): the build then fails far from the cause, or ships server code toward the browser. Make `import \"server-only\"` the first statement of the file. Route files (`page`, `layout`, `route`, `proxy`) are server components by construction and need no marker.",
171
+ },
172
+ },
173
+ create(context) {
174
+ if (isSpecFile(fileOf(context))) return {}
175
+ const slot = slotOfFile(context)
176
+ if (!slot || ROUTE_FILE_SLOTS.has(slot)) return {}
177
+ // A block or a page is a server or client COMPONENT, not a server module: whether client code reaches the server through it is
178
+ // the machine's multi-hop question (FE_CLIENT_REACHES_SERVER). The marker belongs to the modules that hold server work.
179
+ if (COMPONENT_TIERS.has(tierOfFile(context))) return {}
180
+ const reader = roleOfFile(context) === "reader"
181
+ let client = false
182
+ let marked = false
183
+ const uses = []
184
+ return {
185
+ Program(program) {
186
+ client = clientDirective(program) !== null
187
+ marked = isMarkerStatement(program.body[0])
188
+ },
189
+ "ImportDeclaration, ExportNamedDeclaration, ExportAllDeclaration"(node) {
190
+ if (!node.source || node.importKind === "type" || node.exportKind === "type") return
191
+ const onlyTypes = node.specifiers?.length > 0 && node.specifiers.every((specifier) => specifier.importKind === "type" || specifier.exportKind === "type")
192
+ if (onlyTypes) return
193
+ const specifier = String(node.source.value)
194
+ if (specifier === "server-only") return
195
+ const api = serverApiReason(specifier)
196
+ if (api) {
197
+ uses.push({ node, what: api })
198
+ return
199
+ }
200
+ const target = resolvedSourceFile(context, node.source)
201
+ if (target && tsFileIsMarked(target)) uses.push({ node, what: `\`${specifier}\` (a module that is itself server-only)` })
202
+ },
203
+ "Program:exit"() {
204
+ // a "use client" file that imports the server is client-no-server-import's finding, not a missing marker
205
+ if (client || marked) return
206
+ // A server reader (`read-*.ts`, role `reader` of the api slot) is server-only by its role, whatever it imports.
207
+ if (reader && uses.length === 0) context.report({ node: context.sourceCode.ast, messageId: "mark", data: { what: "the request (it is a server reader)" } })
208
+ for (const use of uses) context.report({ node: use.node, messageId: "mark", data: { what: use.what } })
209
+ },
210
+ }
211
+ },
212
+ }
213
+
214
+ // -- FE-CLIENT-3 -----------------------------------------------------------------------------------
215
+
216
+ /** Web storage exists only in the browser and only under `modules/`, which guards it. */
217
+ const STORAGE = new Set(["localStorage", "sessionStorage"])
218
+
219
+ /** Web storage is read and written only inside `modules/`. */
220
+ export const webStorageOnlyInModules = {
221
+ meta: {
222
+ type: "problem",
223
+ docs: { description: "`localStorage` and `sessionStorage` are touched only inside `modules/`." },
224
+ schema: [],
225
+ messages: {
226
+ storage:
227
+ "`{{name}}` here, outside `modules/`. Storage does not exist on the server and throws in private windows and when the quota is full, so a block that reads it during render breaks server rendering and hydrates differently from the HTML it received. Put the read and write behind a hook or a module that guards both (`typeof window`, try/catch) and let the block call that.",
228
+ },
229
+ },
230
+ create(context) {
231
+ // Product source outside the module tiers (foundation modules, the other modules, the api transport), which are where storage is guarded.
232
+ if (!isProductSource(context) || ["foundation", "modules", "transport"].includes(tierOfFile(context))) return {}
233
+ return {
234
+ MemberExpression(node) {
235
+ if (node.computed || node.property.type !== "Identifier" || !STORAGE.has(node.property.name)) return
236
+ if (node.object.type !== "Identifier" || (node.object.name !== "window" && node.object.name !== "globalThis")) return
237
+ context.report({ node, messageId: "storage", data: { name: node.property.name } })
238
+ },
239
+ Identifier(node) {
240
+ if (!STORAGE.has(node.name)) return
241
+ const parent = node.parent
242
+ if (parent.type === "MemberExpression" && parent.property === node) return
243
+ if (parent.type === "Property" && parent.key === node && !parent.shorthand) return
244
+ if (parent.type === "TSPropertySignature" || parent.type === "TSTypeAnnotation") return
245
+ context.report({ node, messageId: "storage", data: { name: node.name } })
246
+ },
247
+ }
248
+ },
249
+ }
250
+
251
+ // -- FE-CLIENT-4 -----------------------------------------------------------------------------------
252
+
253
+ /** Raw HTML is set only on a `<script>`, where the framework has no other way to inline JSON-LD or the theme bootstrap. */
254
+ export const noDangerousHtml = {
255
+ meta: {
256
+ type: "problem",
257
+ docs: { description: "`dangerouslySetInnerHTML` only on `<script>`; never on a visible element." },
258
+ schema: [],
259
+ messages: {
260
+ html:
261
+ "`dangerouslySetInnerHTML` on `<{{tag}}>` writes a string into the page as markup, so any text from a user, a provider or a message catalogue becomes script. Render the content as elements. The one legitimate use is a `<script>` carrying JSON-LD or the theme bootstrap, from a constant this app wrote.",
262
+ },
263
+ },
264
+ create(context) {
265
+ if (isSpecFile(context.filename || context.getFilename())) return {}
266
+ return {
267
+ JSXAttribute(node) {
268
+ if (node.name.type !== "JSXIdentifier" || node.name.name !== "dangerouslySetInnerHTML") return
269
+ const opening = node.parent
270
+ const tag = opening.name.type === "JSXIdentifier" ? opening.name.name : "element"
271
+ if (tag !== "script") context.report({ node, messageId: "html", data: { tag } })
272
+ },
273
+ }
274
+ },
275
+ }
276
+
277
+ /** The rules this law contributes to the plugin. */
278
+ export const rules = {
279
+ "use-client-only-at-boundary": useClientOnlyAtBoundary,
280
+ "client-no-server-import": clientNoServerImport,
281
+ "server-module-marks-server-only": serverModuleMarksServerOnly,
282
+ "web-storage-only-in-modules": webStorageOnlyInModules,
283
+ "no-dangerous-html": noDangerousHtml,
284
+ }
285
+
286
+ /** Every rule is an error. */
287
+ export const recommended = Object.fromEntries(Object.keys(rules).map((name) => [`starci-fe/${name}`, "error"]))
package/comments.mjs CHANGED
@@ -1,190 +1,137 @@
1
- /**
2
- * The rules that hold `comments.md`.
3
- *
4
- * All three reach further than their names suggest, and deliberately. A language rule that read
5
- * only comments would leave the same sentence legal one line lower as an identifier or a diagnostic
6
- * string; an emoji rule that read only JSX would miss the one in a log message. So each walks
7
- * comments, identifiers, string literals, template chunks and JSX text alike - everywhere prose can
8
- * hide in a source file.
9
- *
10
- * The exceptions are paths, not judgements, and that is on purpose: a locale dictionary IS the other
11
- * language, and a fixture reproducing a real string has to reproduce it exactly. A judgement-based
12
- * exception would be argued per file forever.
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 letters that mark the second language this source is kept free of. */
19
- export const SECOND_LANGUAGE_LETTER = /[À-ÃÈ-ÊÌÍÒ-ÕÙÚÝà-ãè-êìíò-õùúýĂăĐđĨĩŨũƠơƯưẠ-ỿ]/
20
-
21
- /** The endonym a language picker must be able to render in its own script. */
22
- const ENDONYM = /Tiếng Việt/
23
-
24
- /** A functional literal, marked with the reason it stays. */
25
- const OK_PRAGMA = /\bvn-ok:/
26
-
27
- /**
28
- * Extended pictographs, or a regional-indicator pair.
29
- *
30
- * Two tests rather than one character class, so the class stays free of joiners and combining marks
31
- * - a single class covering both trips the misleading-character-class rule, and a rule that has to
32
- * be silenced to exist is one nobody trusts.
33
- */
34
- export const hasEmoji = (text) =>
35
- typeof text === "string" &&
36
- (/\p{Extended_Pictographic}/u.test(text) || /[\u{1F1E6}-\u{1F1FF}]{2}/u.test(text))
37
-
38
- /**
39
- * Paths whose second-language text or emoji is CONTENT rather than authoring.
40
- *
41
- * Two kinds only: the locale dictionaries, which are the product's other language, and fixtures,
42
- * which reproduce real strings and would be testing something else if they translated them.
43
- */
44
- export const CONTENT_PATHS = [
45
- /\/messages\/[a-z-]+\.json$/i,
46
- /\/src\/resources\//,
47
- /\/__fixtures?__\//,
48
- /\/fixtures?\//,
49
- /\.fixture\.(ts|tsx|js|mjs|cjs)$/i,
50
- /\.test\.(ts|tsx|js|mjs|cjs)$/i,
51
- /\.spec\.(ts|tsx|js|mjs|cjs)$/i,
52
- ]
53
-
54
- /** True when a file holds content rather than authoring. */
55
- export const isContentFile = (filename) => {
56
- const file = normalizePath(filename)
57
- return CONTENT_PATHS.some((pattern) => pattern.test(file))
58
- }
59
-
60
- /** Walk every place prose can hide, and hand each to one check. */
61
- const proseVisitors = (context, report) => {
62
- const source = context.sourceCode || context.getSourceCode()
63
- return {
64
- Program() {
65
- for (const comment of source.getAllComments()) report(comment, comment.value)
66
- },
67
- Identifier(node) {
68
- report(node, node.name)
69
- },
70
- Literal(node) {
71
- if (typeof node.value === "string") report(node, node.value)
72
- },
73
- TemplateElement(node) {
74
- report(node, node.value && node.value.cooked)
75
- },
76
- JSXText(node) {
77
- report(node, node.value)
78
- },
79
- }
80
- }
81
-
82
- // -- COMMENTS-1 ------------------------------------------------------------------------------------
83
-
84
- /** Every export opens with a documentation block. */
85
- export const requireExportJsdoc = {
86
- meta: {
87
- type: "suggestion",
88
- docs: { description: "Exported declarations open with a documentation block." },
89
- schema: [],
90
- messages: {
91
- jsdoc:
92
- "`{{name}}` is exported with no documentation block. An export is read far more often than it is written, and by people who never open the body - name the ROLE it plays, not the signature, which the signature already states.",
93
- },
94
- },
95
- create(context) {
96
- const source = context.sourceCode || context.getSourceCode()
97
- const hasBlock = (node) =>
98
- source.getCommentsBefore(node).some((comment) => comment.type === "Block" && comment.value.startsWith("*"))
99
- const check = (node) => {
100
- const declaration = node.declaration
101
- if (!declaration) return
102
- const kinds = ["VariableDeclaration", "TSInterfaceDeclaration", "FunctionDeclaration", "TSTypeAliasDeclaration"]
103
- if (!kinds.includes(declaration.type) || hasBlock(node)) return
104
- const id =
105
- declaration.id || (declaration.declarations && declaration.declarations[0] && declaration.declarations[0].id)
106
- context.report({ node: id || declaration, messageId: "jsdoc", data: { name: (id && id.name) || "this export" } })
107
- }
108
- return { ExportNamedDeclaration: check, ExportDefaultDeclaration: check }
109
- },
110
- }
111
-
112
- // -- COMMENTS-2 · COMMENTS-3 -----------------------------------------------------------------------
113
-
114
- /** The source is English, and the exceptions are paths plus one marked pragma. */
115
- export const noSecondLanguageInSource = {
116
- meta: {
117
- type: "problem",
118
- docs: { description: "Source authoring is English; locale content and marked literals are exempt." },
119
- schema: [],
120
- messages: {
121
- second:
122
- "Second-language prose in source authoring. The bar is a stranger who joins in a year and does not share the first language of whoever wrote this line - a codebase with two languages has two populations of readers, and the smaller one stops reading the parts it cannot. If this is a literal the running program matches on or emits, keep it and mark the line with `vn-ok: <reason>`.",
123
- },
124
- },
125
- create(context) {
126
- if (isContentFile(context.filename || context.getFilename())) return {}
127
- const source = context.sourceCode || context.getSourceCode()
128
- /*
129
- * Lines carrying the pragma, gathered once.
130
- *
131
- * The message tells the author to "mark the LINE", and until this existed
132
- * that was not true of a literal: the exemption tested the node's OWN text,
133
- * which for a string is its value, so `"Học viện Mộc" // vn-ok: ...` still
134
- * reported and no phrasing could satisfy it. The rule's own valid fixture
135
- * only passed because its string carried no diacritics at all.
136
- *
137
- * A marked line exempts the node ON it and the statement BELOW it, which are
138
- * the two placements the message's wording admits.
139
- */
140
- const marked = new Set()
141
- for (const comment of source.getAllComments()) {
142
- if (!OK_PRAGMA.test(comment.value)) continue
143
- marked.add(comment.loc.start.line)
144
- marked.add(comment.loc.end.line + 1)
145
- }
146
- return proseVisitors(context, (node, text) => {
147
- if (!text || !SECOND_LANGUAGE_LETTER.test(text)) return
148
- if (ENDONYM.test(text) || OK_PRAGMA.test(text)) return
149
- if (marked.has(node.loc.start.line)) return
150
- context.report({ node, messageId: "second" })
151
- })
152
- },
153
- }
154
-
155
- // -- COMMENTS-4 ------------------------------------------------------------------------------------
156
-
157
- /** No Unicode emoji in source: stable product reactions use attributed checked-in SVG artwork. */
158
- export const noEmojiInSource = {
159
- meta: {
160
- type: "problem",
161
- docs: { description: "No emoji in source authoring." },
162
- schema: [],
163
- messages: {
164
- emoji:
165
- "Unicode emoji in source authoring. It renders differently on every platform, sorts unpredictably, breaks a terminal that is not expecting it, and does not mean the same thing in two countries. Generic marks belong to the icon vocabulary; product reactions use the attributed checked-in SVG artwork through the reaction leaf.",
166
- },
167
- },
168
- create(context) {
169
- if (isContentFile(context.filename || context.getFilename())) return {}
170
- return proseVisitors(context, (node, text) => {
171
- if (hasEmoji(text)) context.report({ node, messageId: "emoji" })
172
- })
173
- },
174
- }
175
-
176
- /** The rules this law contributes to the plugin. */
177
- export const rules = {
178
- "require-export-jsdoc": requireExportJsdoc,
179
- "no-second-language-in-source": noSecondLanguageInSource,
180
- "no-emoji-in-source": noEmojiInSource,
181
- }
182
-
183
- /**
184
- * The level this law asks for, as the plugin's own opinion.
185
- *
186
- * `require-export-jsdoc` is the one a repository adopting this with history should expect a count
187
- * from - every undocumented export in the tree reports at once. The other two are usually already
188
- * at zero, because a second language in source is noticed by readers long before a rule arrives.
189
- */
190
- export const recommended = Object.fromEntries(Object.keys(rules).map((name) => [`starci-fe/${name}`, "error"]))
1
+ /**
2
+ * The rules that hold `comments.md`.
3
+ *
4
+ * Both reach further than their names suggest, and deliberately. An emoji rule that read only JSX
5
+ * would miss the one in a log message, so it walks comments, identifiers, string literals, template
6
+ * chunks and JSX text alike - everywhere prose can hide in a source file.
7
+ *
8
+ * THE SECOND-LANGUAGE RULE IS GONE. It carried a `vn-ok: <reason>` pragma, an escape hatch that was
9
+ * used 490 times in one repository, and one escape is enough to turn a rule into a comment
10
+ * convention. Its job is now held by `no-hardcoded-copy` in `translation.mjs`, which has no
11
+ * pragma: user-facing text comes from a `next-intl` catalogue, and the catalogue is the only place
12
+ * a second language is content.
13
+ *
14
+ * The exceptions that remain are placements, not judgements, and that is on purpose: a locale dictionary
15
+ * IS the other language, and a fixture reproducing a real string has to reproduce it exactly. A
16
+ * judgement-based exception would be argued per file forever.
17
+ */
18
+
19
+ import { fileOf, inSlot, isSpecFile } from "./lib/scope.mjs"
20
+
21
+ /** The letters that mark the second language a source string must not carry outside a catalogue. */
22
+ export const SECOND_LANGUAGE_LETTER = /[À-ÃÈ-ÊÌÍÒ-ÕÙÚÝà-ãè-êìíò-õùúýĂăĐđĨĩŨũƠơƯưẠ-ỿ]/
23
+
24
+ /**
25
+ * Extended pictographs, or a regional-indicator pair.
26
+ *
27
+ * Two tests rather than one character class, so the class stays free of joiners and combining marks
28
+ * - a single class covering both trips the misleading-character-class rule, and a rule that has to
29
+ * be silenced to exist is one nobody trusts.
30
+ */
31
+ export const hasEmoji = (text) =>
32
+ typeof text === "string" &&
33
+ (/\p{Extended_Pictographic}/u.test(text) || /[\u{1F1E6}-\u{1F1FF}]{2}/u.test(text))
34
+
35
+ /**
36
+ * Files whose second-language text or emoji is CONTENT rather than authoring, decided by what the file IS.
37
+ *
38
+ * Three kinds only: the locale dictionaries (a `.json` file of an i18n slot, `fe.modules.i18n` or `fe.package.i18n`),
39
+ * which are the product's other language; the e2e support tree (slot `fe.e2e-support`: fixtures and support),
40
+ * and specs, which reproduce real strings and would be testing something else if they translated them. There is no
41
+ * "copy module": a `resources/` folder of strings is copy that skipped the catalogue.
42
+ *
43
+ * @param {object} context - The ESLint rule context.
44
+ * @returns {boolean} True when the linted file holds content rather than authoring.
45
+ */
46
+ export const isContentFile = (context) => {
47
+ const file = fileOf(context)
48
+ return isSpecFile(file) || inSlot(context, "fe.e2e-support") || (inSlot(context, "fe.modules.i18n", "fe.package.i18n") && file.endsWith(".json"))
49
+ }
50
+
51
+ /** Walk every place prose can hide, and hand each to one check. */
52
+ const proseVisitors = (context, report) => {
53
+ const source = context.sourceCode || context.getSourceCode()
54
+ return {
55
+ Program() {
56
+ for (const comment of source.getAllComments()) report(comment, comment.value)
57
+ },
58
+ Identifier(node) {
59
+ report(node, node.name)
60
+ },
61
+ Literal(node) {
62
+ if (typeof node.value === "string") report(node, node.value)
63
+ },
64
+ TemplateElement(node) {
65
+ report(node, node.value && node.value.cooked)
66
+ },
67
+ JSXText(node) {
68
+ report(node, node.value)
69
+ },
70
+ }
71
+ }
72
+
73
+ // -- COMMENTS-1 ------------------------------------------------------------------------------------
74
+
75
+ /** Every export opens with a documentation block. */
76
+ export const requireExportJsdoc = {
77
+ meta: {
78
+ type: "suggestion",
79
+ docs: { description: "Exported declarations open with a documentation block." },
80
+ schema: [],
81
+ messages: {
82
+ jsdoc:
83
+ "`{{name}}` is exported with no documentation block. An export is read far more often than it is written, and by people who never open the body - name the ROLE it plays, not the signature, which the signature already states.",
84
+ },
85
+ },
86
+ create(context) {
87
+ const source = context.sourceCode || context.getSourceCode()
88
+ const hasBlock = (node) =>
89
+ source.getCommentsBefore(node).some((comment) => comment.type === "Block" && comment.value.startsWith("*"))
90
+ const check = (node) => {
91
+ const declaration = node.declaration
92
+ if (!declaration) return
93
+ const kinds = ["VariableDeclaration", "TSInterfaceDeclaration", "FunctionDeclaration", "TSTypeAliasDeclaration"]
94
+ if (!kinds.includes(declaration.type) || hasBlock(node)) return
95
+ const id =
96
+ declaration.id || (declaration.declarations && declaration.declarations[0] && declaration.declarations[0].id)
97
+ context.report({ node: id || declaration, messageId: "jsdoc", data: { name: (id && id.name) || "this export" } })
98
+ }
99
+ return { ExportNamedDeclaration: check, ExportDefaultDeclaration: check }
100
+ },
101
+ }
102
+
103
+ // -- COMMENTS-4 ------------------------------------------------------------------------------------
104
+
105
+ /** No Unicode emoji in source: stable product reactions use attributed checked-in SVG artwork. */
106
+ export const noEmojiInSource = {
107
+ meta: {
108
+ type: "problem",
109
+ docs: { description: "No emoji in source authoring." },
110
+ schema: [],
111
+ messages: {
112
+ emoji:
113
+ "Unicode emoji in source authoring. It renders differently on every platform, sorts unpredictably, breaks a terminal that is not expecting it, and does not mean the same thing in two countries. Generic marks belong to the icon vocabulary; product reactions use the attributed checked-in SVG artwork through the reaction leaf.",
114
+ },
115
+ },
116
+ create(context) {
117
+ if (isContentFile(context)) return {}
118
+ return proseVisitors(context, (node, text) => {
119
+ if (hasEmoji(text)) context.report({ node, messageId: "emoji" })
120
+ })
121
+ },
122
+ }
123
+
124
+ /** The rules this law contributes to the plugin. */
125
+ export const rules = {
126
+ "require-export-jsdoc": requireExportJsdoc,
127
+ "no-emoji-in-source": noEmojiInSource,
128
+ }
129
+
130
+ /**
131
+ * The level this law asks for, as the plugin's own opinion.
132
+ *
133
+ * `require-export-jsdoc` is the one a repository adopting this with history should expect a count
134
+ * from - every undocumented export in the tree reports at once. The other two are usually already
135
+ * at zero, because a second language in source is noticed by readers long before a rule arrives.
136
+ */
137
+ export const recommended = Object.fromEntries(Object.keys(rules).map((name) => [`starci-fe/${name}`, "error"]))