@ilha/router 0.10.1 → 0.10.2

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 (2) hide show
  1. package/oxlint.cjs +428 -0
  2. package/package.json +5 -3
package/oxlint.cjs ADDED
@@ -0,0 +1,428 @@
1
+ const PASCAL_CASE = /^[A-Z][a-zA-Z0-9]*$/;
2
+ const PRIMITIVES = new Set(["state", "derived", "action", "effect", "onError"]);
3
+ const ACTION_STATUS = new Set(["pending", "data", "error"]);
4
+ const CONDITIONAL = new Set([
5
+ "IfStatement",
6
+ "ForStatement",
7
+ "ForInStatement",
8
+ "ForOfStatement",
9
+ "WhileStatement",
10
+ "DoWhileStatement",
11
+ "SwitchStatement",
12
+ "ConditionalExpression",
13
+ "LogicalExpression",
14
+ ]);
15
+ const FN_TYPES = new Set(["FunctionDeclaration", "FunctionExpression", "ArrowFunctionExpression"]);
16
+
17
+ function bindings() {
18
+ return { ilha: new Set(["ilha"]), prim: new Map() };
19
+ }
20
+
21
+ function takeImport(b, node) {
22
+ if (node.source.value !== "ilha") return;
23
+ for (const s of node.specifiers) {
24
+ if (s.type !== "ImportSpecifier") continue;
25
+ const imported = s.imported.name;
26
+ const local = s.local.name;
27
+ if (imported === "ilha") {
28
+ b.ilha.add(local);
29
+ }
30
+ if (PRIMITIVES.has(imported)) b.prim.set(local, imported);
31
+ }
32
+ }
33
+
34
+ function isIlhaCall(node, b) {
35
+ return (
36
+ node.type === "CallExpression" &&
37
+ node.callee.type === "Identifier" &&
38
+ b.ilha.has(node.callee.name)
39
+ );
40
+ }
41
+
42
+ function primitiveName(node, b) {
43
+ if (node.type !== "CallExpression") return null;
44
+ const c = node.callee;
45
+ if (c.type === "Identifier") return b.prim.get(c.name) ?? null;
46
+ const effectLocal = [...b.prim].find(([, n]) => n === "effect")?.[0];
47
+ if (
48
+ effectLocal &&
49
+ c.type === "MemberExpression" &&
50
+ !c.computed &&
51
+ c.object.type === "Identifier" &&
52
+ c.object.name === effectLocal &&
53
+ c.property.type === "Identifier" &&
54
+ c.property.name === "once"
55
+ ) {
56
+ return "effect.once";
57
+ }
58
+ return null;
59
+ }
60
+
61
+ function fnName(node, parent) {
62
+ if (node.id && node.id.type === "Identifier") return node.id.name;
63
+ if (parent?.type === "VariableDeclarator" && parent.id.type === "Identifier") {
64
+ return parent.id.name;
65
+ }
66
+ return "";
67
+ }
68
+
69
+ function isComponentFn(node, parent, ancestors, b) {
70
+ const name = fnName(node, parent);
71
+ if (PASCAL_CASE.test(name)) return true;
72
+ if (parent?.type === "CallExpression" && isIlhaCall(parent, b)) return true;
73
+ const grand = ancestors?.[ancestors.length - 2];
74
+ return !!(grand && isIlhaCall(grand, b));
75
+ }
76
+
77
+ function ancestorsOf(context, node) {
78
+ const sc = context.sourceCode;
79
+ if (sc && typeof sc.getAncestors === "function") return sc.getAncestors(node);
80
+ return [];
81
+ }
82
+
83
+ function parentOf(context, node) {
84
+ const a = ancestorsOf(context, node);
85
+ return a[a.length - 1];
86
+ }
87
+
88
+ function toPascalCase(name) {
89
+ return name.charAt(0).toUpperCase() + name.slice(1);
90
+ }
91
+
92
+ function calleeName(node) {
93
+ return node.type === "Identifier" ? node.name : null;
94
+ }
95
+
96
+ function collectIslands(b) {
97
+ const names = new Set();
98
+ return {
99
+ names,
100
+ VariableDeclarator(node) {
101
+ if (node.id.type === "Identifier" && node.init && isIlhaCall(node.init, b)) {
102
+ names.add(node.id.name);
103
+ }
104
+ },
105
+ };
106
+ }
107
+
108
+ function isInsideComponent(context, node, b) {
109
+ const ancestors = ancestorsOf(context, node);
110
+ for (let i = ancestors.length - 1; i >= 0; i--) {
111
+ const a = ancestors[i];
112
+ if (!FN_TYPES.has(a.type)) continue;
113
+ const parent = i > 0 ? ancestors[i - 1] : null;
114
+ if (isComponentFn(a, parent, ancestors.slice(0, i), b)) return true;
115
+ }
116
+ return false;
117
+ }
118
+
119
+ function islandCallee(names, callee) {
120
+ if (callee.type === "Identifier" && names.has(callee.name)) return callee.name;
121
+ return null;
122
+ }
123
+
124
+ const pascalCase = {
125
+ meta: {
126
+ type: "suggestion",
127
+ fixable: "code",
128
+ docs: { description: "Enforce PascalCase for ilha island variable names" },
129
+ messages: {
130
+ notPascalCase: 'Island variable "{{name}}" must be PascalCase (e.g. "{{suggested}}").',
131
+ },
132
+ schema: [],
133
+ },
134
+ create(context) {
135
+ const b = bindings();
136
+ return {
137
+ ImportDeclaration(node) {
138
+ takeImport(b, node);
139
+ },
140
+ VariableDeclarator(node) {
141
+ if (node.id.type === "Identifier" && node.init && isIlhaCall(node.init, b)) {
142
+ const name = node.id.name;
143
+ if (!PASCAL_CASE.test(name)) {
144
+ const suggested = toPascalCase(name);
145
+ context.report({
146
+ node: node.id,
147
+ messageId: "notPascalCase",
148
+ data: { name, suggested },
149
+ fix(fixer) {
150
+ return fixer.replaceText(node.id, suggested);
151
+ },
152
+ });
153
+ }
154
+ }
155
+ },
156
+ };
157
+ },
158
+ };
159
+
160
+ const noConditionalPrimitive = {
161
+ meta: {
162
+ type: "problem",
163
+ docs: { description: "Disallow primitives inside conditionals or loops" },
164
+ messages: {
165
+ conditional:
166
+ "Do not call {{name}}() inside a condition or loop. Put the branch inside the primitive.",
167
+ },
168
+ schema: [],
169
+ },
170
+ create(context) {
171
+ const b = bindings();
172
+ return {
173
+ ImportDeclaration(node) {
174
+ takeImport(b, node);
175
+ },
176
+ CallExpression(node) {
177
+ const name = primitiveName(node, b);
178
+ if (!name) return;
179
+ const ancestors = ancestorsOf(context, node);
180
+ for (let i = ancestors.length - 1; i >= 0; i--) {
181
+ const a = ancestors[i];
182
+ if (FN_TYPES.has(a.type)) return;
183
+ if (CONDITIONAL.has(a.type)) {
184
+ context.report({ node, messageId: "conditional", data: { name } });
185
+ return;
186
+ }
187
+ }
188
+ },
189
+ };
190
+ },
191
+ };
192
+
193
+ const noPrimitiveOutsideIsland = {
194
+ meta: {
195
+ type: "problem",
196
+ docs: { description: "Only call primitives in an island render or PascalCase component" },
197
+ messages: {
198
+ outside:
199
+ "Call {{name}}() only in an ilha() render or a PascalCase component. Put setup in effect.once().",
200
+ },
201
+ schema: [],
202
+ },
203
+ create(context) {
204
+ const b = bindings();
205
+ return {
206
+ ImportDeclaration(node) {
207
+ takeImport(b, node);
208
+ },
209
+ CallExpression(node) {
210
+ const name = primitiveName(node, b);
211
+ if (!name) return;
212
+ const ancestors = ancestorsOf(context, node);
213
+ const fns = ancestors.filter((a) => FN_TYPES.has(a.type));
214
+ if (fns.length === 0) {
215
+ context.report({ node, messageId: "outside", data: { name } });
216
+ return;
217
+ }
218
+ const enclosing = fns[fns.length - 1];
219
+ const idx = ancestors.indexOf(enclosing);
220
+ const parent = idx > 0 ? ancestors[idx - 1] : parentOf(context, enclosing);
221
+ if (isComponentFn(enclosing, parent, ancestors.slice(0, idx), b)) return;
222
+ context.report({ node, messageId: "outside", data: { name } });
223
+ },
224
+ };
225
+ },
226
+ };
227
+
228
+ const preferPlainHandler = {
229
+ meta: {
230
+ type: "suggestion",
231
+ docs: { description: "Use a plain function unless action status or cancellation is used" },
232
+ messages: {
233
+ unused:
234
+ "action() is unused as a status object. Use a plain function unless you read .pending, .data, or .error.",
235
+ },
236
+ schema: [],
237
+ },
238
+ create(context) {
239
+ const b = bindings();
240
+ const found = new Map();
241
+ return {
242
+ ImportDeclaration(node) {
243
+ takeImport(b, node);
244
+ },
245
+ VariableDeclarator(node) {
246
+ if (node.id.type !== "Identifier" || !node.init) return;
247
+ if (primitiveName(node.init, b) !== "action") return;
248
+ found.set(node.id.name, { node: node.init, used: false });
249
+ },
250
+ MemberExpression(node) {
251
+ if (node.computed || node.object.type !== "Identifier") return;
252
+ if (node.property.type !== "Identifier" || !ACTION_STATUS.has(node.property.name)) return;
253
+ const rec = found.get(node.object.name);
254
+ if (rec) rec.used = true;
255
+ },
256
+ "Program:exit"() {
257
+ for (const rec of found.values()) {
258
+ if (!rec.used) context.report({ node: rec.node, messageId: "unused" });
259
+ }
260
+ },
261
+ };
262
+ },
263
+ };
264
+
265
+ const preferLowercaseEvents = {
266
+ meta: {
267
+ type: "problem",
268
+ docs: { description: "Use lowercase DOM event props (onclick, not onClick)" },
269
+ messages: {
270
+ camel: 'Use lowercase event "{{suggested}}" instead of "{{name}}".',
271
+ },
272
+ schema: [],
273
+ },
274
+ create(context) {
275
+ function checkName(node, name) {
276
+ if (/^on[A-Z]/.test(name)) {
277
+ context.report({
278
+ node,
279
+ messageId: "camel",
280
+ data: { name, suggested: "on" + name.slice(2).toLowerCase() },
281
+ });
282
+ }
283
+ }
284
+ return {
285
+ JSXAttribute(node) {
286
+ const parent = parentOf(context, node);
287
+ const tag = parent && parent.name;
288
+ if (tag && tag.type === "JSXIdentifier" && PASCAL_CASE.test(tag.name)) return;
289
+ if (node.name.type === "JSXIdentifier") checkName(node.name, node.name.name);
290
+ if (node.name.type === "JSXNamespacedName") {
291
+ checkName(node.name.namespace, node.name.namespace.name);
292
+ }
293
+ },
294
+ TaggedTemplateExpression(node) {
295
+ if (node.tag.type !== "Identifier" || node.tag.name !== "html") return;
296
+ for (const q of node.quasi.quasis) {
297
+ const text = q.value.cooked ?? q.value.raw;
298
+ const re = /\bon[A-Z][A-Za-z]*/g;
299
+ let m;
300
+ while ((m = re.exec(text))) checkName(node, m[0]);
301
+ }
302
+ },
303
+ };
304
+ },
305
+ };
306
+
307
+ const noDirectIslandCall = {
308
+ meta: {
309
+ type: "problem",
310
+ docs: { description: "Only call islands as children inside another island render" },
311
+ messages: {
312
+ direct:
313
+ "Do not call {{name}}() here. Nest it inside another island, or use {{name}}.toString / toStringAsync / hydratable.",
314
+ },
315
+ schema: [],
316
+ },
317
+ create(context) {
318
+ const b = bindings();
319
+ const { names, VariableDeclarator } = collectIslands(b);
320
+ return {
321
+ ImportDeclaration(node) {
322
+ takeImport(b, node);
323
+ },
324
+ VariableDeclarator,
325
+ CallExpression(node) {
326
+ const name = islandCallee(names, node.callee);
327
+ if (!name) return;
328
+ if (isInsideComponent(context, node, b)) return;
329
+ context.report({ node, messageId: "direct", data: { name } });
330
+ },
331
+ };
332
+ },
333
+ };
334
+
335
+ const requireSsrApi = {
336
+ meta: {
337
+ type: "problem",
338
+ docs: { description: "Use Island.toString / toStringAsync / hydratable for SSR" },
339
+ messages: {
340
+ awaitCall:
341
+ "Do not await {{name}}(). Use await {{name}}.toStringAsync() or await {{name}}.hydratable().",
342
+ },
343
+ schema: [],
344
+ },
345
+ create(context) {
346
+ const b = bindings();
347
+ const { names, VariableDeclarator } = collectIslands(b);
348
+ return {
349
+ ImportDeclaration(node) {
350
+ takeImport(b, node);
351
+ },
352
+ VariableDeclarator,
353
+ AwaitExpression(node) {
354
+ const arg = node.argument;
355
+ if (arg.type !== "CallExpression") return;
356
+ const name = islandCallee(names, arg.callee);
357
+ if (name) context.report({ node, messageId: "awaitCall", data: { name } });
358
+ },
359
+ };
360
+ },
361
+ };
362
+
363
+ const functionInState = {
364
+ meta: {
365
+ type: "problem",
366
+ docs: { description: "Do not pass a function value to state() or a state setter" },
367
+ messages: {
368
+ init: "state({{name}}) treats the function as a lazy initializer. Wrap it: state(() => {{name}}).",
369
+ set: "{{setter}}({{name}}) runs as an updater. Store a function with {{setter}}(() => {{name}}).",
370
+ },
371
+ schema: [],
372
+ },
373
+ create(context) {
374
+ const b = bindings();
375
+ const fns = new Set();
376
+ const states = new Set();
377
+ return {
378
+ ImportDeclaration(node) {
379
+ takeImport(b, node);
380
+ },
381
+ FunctionDeclaration(node) {
382
+ if (node.id) fns.add(node.id.name);
383
+ },
384
+ VariableDeclarator(node) {
385
+ if (node.id.type !== "Identifier") return;
386
+ const init = node.init;
387
+ if (
388
+ init &&
389
+ (init.type === "FunctionExpression" || init.type === "ArrowFunctionExpression")
390
+ ) {
391
+ fns.add(node.id.name);
392
+ }
393
+ if (init && primitiveName(init, b) === "state") states.add(node.id.name);
394
+ },
395
+ CallExpression(node) {
396
+ if (node.arguments.length === 0) return;
397
+ const arg = node.arguments[0];
398
+ if (arg.type !== "Identifier" || !fns.has(arg.name)) return;
399
+ if (primitiveName(node, b) === "state") {
400
+ context.report({ node: arg, messageId: "init", data: { name: arg.name } });
401
+ return;
402
+ }
403
+ const setter = calleeName(node.callee);
404
+ if (setter && states.has(setter)) {
405
+ context.report({
406
+ node: arg,
407
+ messageId: "set",
408
+ data: { setter, name: arg.name },
409
+ });
410
+ }
411
+ },
412
+ };
413
+ },
414
+ };
415
+
416
+ module.exports = {
417
+ meta: { name: "oxlint-plugin-ilha" },
418
+ rules: {
419
+ "pascal-case": pascalCase,
420
+ "no-conditional-primitive": noConditionalPrimitive,
421
+ "no-primitive-outside-island": noPrimitiveOutsideIsland,
422
+ "prefer-plain-handler": preferPlainHandler,
423
+ "prefer-lowercase-events": preferLowercaseEvents,
424
+ "no-direct-island-call": noDirectIslandCall,
425
+ "require-ssr-api": requireSsrApi,
426
+ "function-in-state": functionInState,
427
+ },
428
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ilha/router",
3
- "version": "0.10.1",
3
+ "version": "0.10.2",
4
4
  "description": "A tiny SPA router for Ilha",
5
5
  "keywords": [
6
6
  "frontend",
@@ -26,7 +26,8 @@
26
26
  "url": "git+https://github.com/ilhajs/ilha.git"
27
27
  },
28
28
  "files": [
29
- "dist"
29
+ "dist",
30
+ "oxlint.cjs"
30
31
  ],
31
32
  "type": "module",
32
33
  "sideEffects": false,
@@ -59,7 +60,8 @@
59
60
  "./rsbuild": {
60
61
  "types": "./dist/rsbuild.d.ts",
61
62
  "import": "./dist/rsbuild.js"
62
- }
63
+ },
64
+ "./oxlint": "./oxlint.cjs"
63
65
  },
64
66
  "publishConfig": {
65
67
  "access": "public"