@ilha/router 0.10.4 → 0.11.1

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/oxlint.cjs CHANGED
@@ -1,6 +1,5 @@
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"]);
1
+ const PRIMITIVES = new Set(["atom", "watch", "when"]);
2
+ const INSTRUCTIONS = new Set(["when"]);
4
3
  const CONDITIONAL = new Set([
5
4
  "IfStatement",
6
5
  "ForStatement",
@@ -15,63 +14,21 @@ const CONDITIONAL = new Set([
15
14
  const FN_TYPES = new Set(["FunctionDeclaration", "FunctionExpression", "ArrowFunctionExpression"]);
16
15
 
17
16
  function bindings() {
18
- return { ilha: new Set(["ilha"]), prim: new Map() };
17
+ return { prim: new Map() };
19
18
  }
20
19
 
21
20
  function takeImport(b, node) {
22
21
  if (node.source.value !== "ilha") return;
23
22
  for (const s of node.specifiers) {
24
23
  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);
24
+ if (PRIMITIVES.has(s.imported.name)) b.prim.set(s.local.name, s.imported.name);
31
25
  }
32
26
  }
33
27
 
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
28
  function primitiveName(node, b) {
43
29
  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));
30
+ if (node.callee.type !== "Identifier") return null;
31
+ return b.prim.get(node.callee.name) ?? null;
75
32
  }
76
33
 
77
34
  function ancestorsOf(context, node) {
@@ -80,90 +37,61 @@ function ancestorsOf(context, node) {
80
37
  return [];
81
38
  }
82
39
 
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
- };
40
+ function blockBody(node) {
41
+ if (node.type === "BlockStatement") return node.body;
42
+ if (
43
+ node.type === "FunctionDeclaration" ||
44
+ node.type === "FunctionExpression" ||
45
+ node.type === "ArrowFunctionExpression"
46
+ ) {
47
+ const body = node.body;
48
+ if (body?.type === "BlockStatement") return body.body;
49
+ }
50
+ return null;
106
51
  }
107
52
 
108
- function isInsideComponent(context, node, b) {
109
- const ancestors = ancestorsOf(context, node);
53
+ function bindingInitType(name, ancestors) {
110
54
  for (let i = ancestors.length - 1; i >= 0; i--) {
111
55
  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;
56
+ if (a.type === "VariableDeclarator" && a.id?.type === "Identifier" && a.id.name === name) {
57
+ return a.init?.type ?? "none";
58
+ }
59
+ if (a.type === "FunctionDeclaration" && a.id?.name === name) {
60
+ return "FunctionDeclaration";
61
+ }
62
+ const stmts = blockBody(a);
63
+ if (stmts) {
64
+ for (const stmt of stmts) {
65
+ if (stmt.type !== "VariableDeclaration") continue;
66
+ for (const decl of stmt.declarations) {
67
+ if (decl.id?.type === "Identifier" && decl.id.name === name) {
68
+ return decl.init?.type ?? "none";
69
+ }
70
+ }
71
+ }
72
+ }
115
73
  }
116
- return false;
74
+ return null;
117
75
  }
118
76
 
119
- function islandCallee(names, callee) {
120
- if (callee.type === "Identifier" && names.has(callee.name)) return callee.name;
121
- return null;
77
+ function isFunctionBinding(name, ancestors, moduleFns) {
78
+ const local = bindingInitType(name, ancestors);
79
+ if (local === "FunctionDeclaration") return true;
80
+ if (local !== null) return FN_TYPES.has(local);
81
+ return moduleFns.has(name);
122
82
  }
123
83
 
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
- };
84
+ function enclosingFunctions(ancestors) {
85
+ return ancestors.filter((a) => FN_TYPES.has(a.type));
86
+ }
159
87
 
160
88
  const noConditionalPrimitive = {
161
89
  meta: {
162
90
  type: "problem",
163
- docs: { description: "Disallow primitives inside conditionals or loops" },
91
+ docs: { description: "Disallow atom/watch/when inside conditionals or loops" },
164
92
  messages: {
165
93
  conditional:
166
- "Do not call {{name}}() inside a condition or loop. Put the branch inside the primitive.",
94
+ "Do not call {{name}}() inside a condition or loop. Primitive registration order must be stable — put the branch inside the primitive or the view.",
167
95
  },
168
96
  schema: [],
169
97
  },
@@ -190,13 +118,15 @@ const noConditionalPrimitive = {
190
118
  },
191
119
  };
192
120
 
193
- const noPrimitiveOutsideIsland = {
121
+ const noPrimitiveOutsideComponent = {
194
122
  meta: {
195
123
  type: "problem",
196
- docs: { description: "Only call primitives in an island render or PascalCase component" },
124
+ docs: {
125
+ description: "Call atom/watch/when inside a component, never at module top level",
126
+ },
197
127
  messages: {
198
128
  outside:
199
- "Call {{name}}() only in an ilha() render or a PascalCase component. Put setup in effect.once().",
129
+ "Call {{name}}() inside a component. There is no fiber at module scope, so this throws at runtime.",
200
130
  },
201
131
  schema: [],
202
132
  },
@@ -210,52 +140,51 @@ const noPrimitiveOutsideIsland = {
210
140
  const name = primitiveName(node, b);
211
141
  if (!name) return;
212
142
  const ancestors = ancestorsOf(context, node);
213
- const fns = ancestors.filter((a) => FN_TYPES.has(a.type));
214
- if (fns.length === 0) {
143
+ if (enclosingFunctions(ancestors).length === 0) {
215
144
  context.report({ node, messageId: "outside", data: { name } });
216
- return;
217
145
  }
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
146
  },
224
147
  };
225
148
  },
226
149
  };
227
150
 
228
- const preferPlainHandler = {
151
+ const noInstructionOutsideGenerator = {
229
152
  meta: {
230
- type: "suggestion",
231
- docs: { description: "Use a plain function unless action status or cancellation is used" },
153
+ type: "problem",
154
+ docs: { description: "Use when only inside a generator, with yield*" },
232
155
  messages: {
233
- unused:
234
- "action() is unused as a status object. Use a plain function unless you read .pending, .data, or .error.",
156
+ notGenerator:
157
+ "{{name}}() returns an instruction that only a generator frame can run. Make the enclosing component a generator and yield* it.",
158
+ notYielded: "Call yield* with {{name}}() — calling it directly discards the instruction.",
235
159
  },
236
160
  schema: [],
237
161
  },
238
162
  create(context) {
239
163
  const b = bindings();
240
- const found = new Map();
241
164
  return {
242
165
  ImportDeclaration(node) {
243
166
  takeImport(b, node);
244
167
  },
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" });
168
+ CallExpression(node) {
169
+ const name = primitiveName(node, b);
170
+ if (!name || !INSTRUCTIONS.has(name)) return;
171
+ // yield* wraps the CallExpression in a YieldExpression.
172
+ const parent = ancestorsOf(context, node).at(-1);
173
+ if (parent?.type === "YieldExpression") return;
174
+ const ancestors = ancestorsOf(context, node);
175
+ const fns = enclosingFunctions(ancestors);
176
+ const gen = fns.findLast((f) => f.generator === true);
177
+ if (!gen) {
178
+ context.report({ node, messageId: "notGenerator", data: { name } });
179
+ return;
180
+ }
181
+ // Inside a generator body but not under a yield — the nearest function
182
+ // between the call and the generator must not be a plain callback that
183
+ // escapes the frame. If the call sits directly in the generator, require yield*.
184
+ const genIdx = ancestors.indexOf(gen);
185
+ const between = ancestors.slice(genIdx + 1).filter((a) => FN_TYPES.has(a.type));
186
+ if (between.length === 0) {
187
+ context.report({ node, messageId: "notYielded", data: { name } });
259
188
  }
260
189
  },
261
190
  };
@@ -277,103 +206,39 @@ const preferLowercaseEvents = {
277
206
  context.report({
278
207
  node,
279
208
  messageId: "camel",
280
- data: { name, suggested: "on" + name.slice(2).toLowerCase() },
209
+ data: { name, suggested: `on${name.slice(2).toLowerCase()}` },
281
210
  });
282
211
  }
283
212
  }
284
213
  return {
285
214
  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
215
  if (node.name.type === "JSXIdentifier") checkName(node.name, node.name.name);
290
216
  if (node.name.type === "JSXNamespacedName") {
291
217
  checkName(node.name.namespace, node.name.namespace.name);
292
218
  }
293
219
  },
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
- },
220
+ "Property[value.type=/Function(Expression|Declaration)$/], Property[value.type='ArrowFunctionExpression']"() {},
331
221
  };
332
222
  },
333
223
  };
334
224
 
335
- const requireSsrApi = {
225
+ const functionInAtom = {
336
226
  meta: {
337
227
  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().",
228
+ docs: {
229
+ description:
230
+ "atom(fn) and atom(() => …) are invalid — use atom.lazy, Atom.map, or Atom.transform",
342
231
  },
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
232
  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}}).",
233
+ init: "atom({{name}}) is not valid. Use atom.lazy(() => {{name}}) to store a function value.",
234
+ derived:
235
+ "atom(() => …) is not supported. Use atom(Atom.map(...)) or atom(Atom.transform(...)) for derived values, or atom.lazy() for one-time init.",
370
236
  },
371
237
  schema: [],
372
238
  },
373
239
  create(context) {
374
240
  const b = bindings();
375
241
  const fns = new Set();
376
- const states = new Set();
377
242
  return {
378
243
  ImportDeclaration(node) {
379
244
  takeImport(b, node);
@@ -390,23 +255,27 @@ const functionInState = {
390
255
  ) {
391
256
  fns.add(node.id.name);
392
257
  }
393
- if (init && primitiveName(init, b) === "state") states.add(node.id.name);
258
+ if (!init || primitiveName(init, b) !== "atom") return;
259
+ const arg = init.arguments[0];
260
+ if (arg && FN_TYPES.has(arg.type)) {
261
+ context.report({ node: arg, messageId: "derived" });
262
+ }
263
+ if (
264
+ arg?.type === "Identifier" &&
265
+ isFunctionBinding(arg.name, ancestorsOf(context, arg), fns)
266
+ ) {
267
+ context.report({ node: arg, messageId: "init", data: { name: arg.name } });
268
+ }
394
269
  },
395
270
  CallExpression(node) {
396
271
  if (node.arguments.length === 0) return;
397
272
  const arg = node.arguments[0];
398
- if (arg.type !== "Identifier" || !fns.has(arg.name)) return;
399
- if (primitiveName(node, b) === "state") {
273
+ if (arg.type !== "Identifier") return;
274
+ if (
275
+ primitiveName(node, b) === "atom" &&
276
+ isFunctionBinding(arg.name, ancestorsOf(context, arg), fns)
277
+ ) {
400
278
  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
279
  }
411
280
  },
412
281
  };
@@ -416,13 +285,10 @@ const functionInState = {
416
285
  module.exports = {
417
286
  meta: { name: "oxlint-plugin-ilha" },
418
287
  rules: {
419
- "pascal-case": pascalCase,
420
288
  "no-conditional-primitive": noConditionalPrimitive,
421
- "no-primitive-outside-island": noPrimitiveOutsideIsland,
422
- "prefer-plain-handler": preferPlainHandler,
289
+ "no-primitive-outside-component": noPrimitiveOutsideComponent,
290
+ "no-instruction-outside-generator": noInstructionOutsideGenerator,
423
291
  "prefer-lowercase-events": preferLowercaseEvents,
424
- "no-direct-island-call": noDirectIslandCall,
425
- "require-ssr-api": requireSsrApi,
426
- "function-in-state": functionInState,
292
+ "function-in-atom": functionInAtom,
427
293
  },
428
294
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ilha/router",
3
- "version": "0.10.4",
3
+ "version": "0.11.1",
4
4
  "description": "A tiny SPA router for Ilha",
5
5
  "keywords": [
6
6
  "frontend",
@@ -31,6 +31,7 @@
31
31
  ],
32
32
  "type": "module",
33
33
  "sideEffects": false,
34
+ "main": "./dist/index.js",
34
35
  "module": "./dist/index.js",
35
36
  "browser": {
36
37
  "process": false
@@ -41,10 +42,6 @@
41
42
  "types": "./dist/index.d.ts",
42
43
  "import": "./dist/index.js"
43
44
  },
44
- "./server": {
45
- "types": "./dist/server.d.ts",
46
- "import": "./dist/server.js"
47
- },
48
45
  "./server-island": {
49
46
  "types": "./dist/server-island.d.ts",
50
47
  "import": "./dist/server-island.js"
@@ -72,13 +69,14 @@
72
69
  "test": "bun test"
73
70
  },
74
71
  "dependencies": {
72
+ "effect": "^4.0.0-rc.112",
75
73
  "unplugin": "3.3.0"
76
74
  },
77
75
  "devDependencies": {
78
- "ilha": "0.13.0",
76
+ "ilha": "0.14.1",
79
77
  "vite": "^8.2.2"
80
78
  },
81
79
  "peerDependencies": {
82
- "ilha": ">=0.13.0"
80
+ "ilha": ">=0.14.1"
83
81
  }
84
82
  }
package/dist/server.d.ts DELETED
@@ -1,8 +0,0 @@
1
- import "ilha";
2
- import { type ServerAction } from "ilha/internal";
3
- /**
4
- * Declare an Oxide RPC action that can also be passed directly to server-island
5
- * event props or bound to a serializable payload with `.with()`.
6
- */
7
- export declare function action<A extends unknown[], R>(fn: (...args: A) => R): ServerAction<A, R>;
8
- export type { ServerAction } from "ilha/internal";
package/dist/server.js DELETED
@@ -1,14 +0,0 @@
1
- import "ilha";
2
- import { bindServerAction } from "ilha/internal";
3
-
4
- //#region src/server.ts
5
- /**
6
- * Declare an Oxide RPC action that can also be passed directly to server-island
7
- * event props or bound to a serializable payload with `.with()`.
8
- */
9
- function action(fn) {
10
- return bindServerAction(fn);
11
- }
12
-
13
- //#endregion
14
- export { action };