@ilha/router 0.10.4 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/oxlint.cjs CHANGED
@@ -1,6 +1,6 @@
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", "when", "watch", "wait"]);
2
+ // when/watch/wait return Instructions — they are only consumable by a generator frame.
3
+ const INSTRUCTIONS = new Set(["when", "watch", "wait"]);
4
4
  const CONDITIONAL = new Set([
5
5
  "IfStatement",
6
6
  "ForStatement",
@@ -15,63 +15,21 @@ const CONDITIONAL = new Set([
15
15
  const FN_TYPES = new Set(["FunctionDeclaration", "FunctionExpression", "ArrowFunctionExpression"]);
16
16
 
17
17
  function bindings() {
18
- return { ilha: new Set(["ilha"]), prim: new Map() };
18
+ return { prim: new Map() };
19
19
  }
20
20
 
21
21
  function takeImport(b, node) {
22
22
  if (node.source.value !== "ilha") return;
23
23
  for (const s of node.specifiers) {
24
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);
25
+ if (PRIMITIVES.has(s.imported.name)) b.prim.set(s.local.name, s.imported.name);
31
26
  }
32
27
  }
33
28
 
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
29
  function primitiveName(node, b) {
43
30
  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));
31
+ if (node.callee.type !== "Identifier") return null;
32
+ return b.prim.get(node.callee.name) ?? null;
75
33
  }
76
34
 
77
35
  function ancestorsOf(context, node) {
@@ -80,90 +38,21 @@ function ancestorsOf(context, node) {
80
38
  return [];
81
39
  }
82
40
 
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
41
  function calleeName(node) {
93
42
  return node.type === "Identifier" ? node.name : null;
94
43
  }
95
44
 
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
- };
45
+ function enclosingFunctions(ancestors) {
46
+ return ancestors.filter((a) => FN_TYPES.has(a.type));
106
47
  }
107
48
 
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
49
  const noConditionalPrimitive = {
161
50
  meta: {
162
51
  type: "problem",
163
- docs: { description: "Disallow primitives inside conditionals or loops" },
52
+ docs: { description: "Disallow atom/when/watch/wait inside conditionals or loops" },
164
53
  messages: {
165
54
  conditional:
166
- "Do not call {{name}}() inside a condition or loop. Put the branch inside the primitive.",
55
+ "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
56
  },
168
57
  schema: [],
169
58
  },
@@ -190,13 +79,15 @@ const noConditionalPrimitive = {
190
79
  },
191
80
  };
192
81
 
193
- const noPrimitiveOutsideIsland = {
82
+ const noPrimitiveOutsideComponent = {
194
83
  meta: {
195
84
  type: "problem",
196
- docs: { description: "Only call primitives in an island render or PascalCase component" },
85
+ docs: {
86
+ description: "Call atom/when/watch/wait inside a component, never at module top level",
87
+ },
197
88
  messages: {
198
89
  outside:
199
- "Call {{name}}() only in an ilha() render or a PascalCase component. Put setup in effect.once().",
90
+ "Call {{name}}() inside a component. There is no fiber at module scope, so this throws at runtime.",
200
91
  },
201
92
  schema: [],
202
93
  },
@@ -210,52 +101,51 @@ const noPrimitiveOutsideIsland = {
210
101
  const name = primitiveName(node, b);
211
102
  if (!name) return;
212
103
  const ancestors = ancestorsOf(context, node);
213
- const fns = ancestors.filter((a) => FN_TYPES.has(a.type));
214
- if (fns.length === 0) {
104
+ if (enclosingFunctions(ancestors).length === 0) {
215
105
  context.report({ node, messageId: "outside", data: { name } });
216
- return;
217
106
  }
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
107
  },
224
108
  };
225
109
  },
226
110
  };
227
111
 
228
- const preferPlainHandler = {
112
+ const noInstructionOutsideGenerator = {
229
113
  meta: {
230
- type: "suggestion",
231
- docs: { description: "Use a plain function unless action status or cancellation is used" },
114
+ type: "problem",
115
+ docs: { description: "Use when/watch/wait only inside a generator, with yield*" },
232
116
  messages: {
233
- unused:
234
- "action() is unused as a status object. Use a plain function unless you read .pending, .data, or .error.",
117
+ notGenerator:
118
+ "{{name}}() returns an instruction that only a generator frame can run. Make the enclosing component a generator and yield* it.",
119
+ notYielded: "Call yield* with {{name}}() — calling it directly discards the instruction.",
235
120
  },
236
121
  schema: [],
237
122
  },
238
123
  create(context) {
239
124
  const b = bindings();
240
- const found = new Map();
241
125
  return {
242
126
  ImportDeclaration(node) {
243
127
  takeImport(b, node);
244
128
  },
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" });
129
+ CallExpression(node) {
130
+ const name = primitiveName(node, b);
131
+ if (!name || !INSTRUCTIONS.has(name)) return;
132
+ // yield* wraps the CallExpression in a YieldExpression.
133
+ const parent = ancestorsOf(context, node).at(-1);
134
+ if (parent?.type === "YieldExpression") return;
135
+ const ancestors = ancestorsOf(context, node);
136
+ const fns = enclosingFunctions(ancestors);
137
+ const gen = fns.findLast((f) => f.generator === true);
138
+ if (!gen) {
139
+ context.report({ node, messageId: "notGenerator", data: { name } });
140
+ return;
141
+ }
142
+ // Inside a generator body but not under a yield — the nearest function
143
+ // between the call and the generator must not be a plain callback that
144
+ // escapes the frame. If the call sits directly in the generator, require yield*.
145
+ const genIdx = ancestors.indexOf(gen);
146
+ const between = ancestors.slice(genIdx + 1).filter((a) => FN_TYPES.has(a.type));
147
+ if (between.length === 0) {
148
+ context.report({ node, messageId: "notYielded", data: { name } });
259
149
  }
260
150
  },
261
151
  };
@@ -277,103 +167,37 @@ const preferLowercaseEvents = {
277
167
  context.report({
278
168
  node,
279
169
  messageId: "camel",
280
- data: { name, suggested: "on" + name.slice(2).toLowerCase() },
170
+ data: { name, suggested: `on${name.slice(2).toLowerCase()}` },
281
171
  });
282
172
  }
283
173
  }
284
174
  return {
285
175
  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
176
  if (node.name.type === "JSXIdentifier") checkName(node.name, node.name.name);
290
177
  if (node.name.type === "JSXNamespacedName") {
291
178
  checkName(node.name.namespace, node.name.namespace.name);
292
179
  }
293
180
  },
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
- },
181
+ "Property[value.type=/Function(Expression|Declaration)$/], Property[value.type='ArrowFunctionExpression']"() {},
359
182
  };
360
183
  },
361
184
  };
362
185
 
363
- const functionInState = {
186
+ const functionInAtom = {
364
187
  meta: {
365
188
  type: "problem",
366
- docs: { description: "Do not pass a function value to state() or a state setter" },
189
+ docs: { description: "atom(fn) is a computed initializer — wrap function values" },
367
190
  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}}).",
191
+ init: "atom({{name}}) treats the function as a computed initializer. Wrap it: atom(() => {{name}}).",
192
+ set: "{{setter}}({{name}}) is fine as a value, but computed atoms reject set(). Store a function with a plain wrapper if you really mean it.",
370
193
  },
371
194
  schema: [],
372
195
  },
373
196
  create(context) {
374
197
  const b = bindings();
375
198
  const fns = new Set();
376
- const states = new Set();
199
+ const atoms = new Set();
200
+ const computed = new Set();
377
201
  return {
378
202
  ImportDeclaration(node) {
379
203
  takeImport(b, node);
@@ -390,23 +214,24 @@ const functionInState = {
390
214
  ) {
391
215
  fns.add(node.id.name);
392
216
  }
393
- if (init && primitiveName(init, b) === "state") states.add(node.id.name);
217
+ if (!init || primitiveName(init, b) !== "atom") return;
218
+ atoms.add(node.id.name);
219
+ // atom(() => …) or atom(function…) — computed; flag set() on it.
220
+ if (init.arguments[0] && FN_TYPES.has(init.arguments[0].type)) computed.add(node.id.name);
394
221
  },
395
222
  CallExpression(node) {
396
223
  if (node.arguments.length === 0) return;
397
224
  const arg = node.arguments[0];
398
225
  if (arg.type !== "Identifier" || !fns.has(arg.name)) return;
399
- if (primitiveName(node, b) === "state") {
226
+ if (primitiveName(node, b) === "atom") {
400
227
  context.report({ node: arg, messageId: "init", data: { name: arg.name } });
401
228
  return;
402
229
  }
403
230
  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
- });
231
+ // set(fn) on a computed atom never makes sense; on a writable atom it
232
+ // is the classic "I meant to store this function" mistake.
233
+ if (setter && atoms.has(setter) && computed.has(setter)) {
234
+ context.report({ node: arg, messageId: "set", data: { setter, name: arg.name } });
410
235
  }
411
236
  },
412
237
  };
@@ -416,13 +241,10 @@ const functionInState = {
416
241
  module.exports = {
417
242
  meta: { name: "oxlint-plugin-ilha" },
418
243
  rules: {
419
- "pascal-case": pascalCase,
420
244
  "no-conditional-primitive": noConditionalPrimitive,
421
- "no-primitive-outside-island": noPrimitiveOutsideIsland,
422
- "prefer-plain-handler": preferPlainHandler,
245
+ "no-primitive-outside-component": noPrimitiveOutsideComponent,
246
+ "no-instruction-outside-generator": noInstructionOutsideGenerator,
423
247
  "prefer-lowercase-events": preferLowercaseEvents,
424
- "no-direct-island-call": noDirectIslandCall,
425
- "require-ssr-api": requireSsrApi,
426
- "function-in-state": functionInState,
248
+ "function-in-atom": functionInAtom,
427
249
  },
428
250
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ilha/router",
3
- "version": "0.10.4",
3
+ "version": "0.11.0",
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.0",
79
77
  "vite": "^8.2.2"
80
78
  },
81
79
  "peerDependencies": {
82
- "ilha": ">=0.13.0"
80
+ "ilha": ">=0.14.0"
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 };