rip-lang 3.16.0 → 3.16.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/README.md CHANGED
@@ -9,7 +9,7 @@
9
9
  </p>
10
10
 
11
11
  <p align="center">
12
- <a href="https://github.com/shreeve/rip-lang/commits/main"><img src="https://img.shields.io/badge/version-3.16.0-blue.svg" alt="Version"></a>
12
+ <a href="https://github.com/shreeve/rip-lang/commits/main"><img src="https://img.shields.io/badge/version-3.16.1-blue.svg" alt="Version"></a>
13
13
  <a href="#zero-dependencies"><img src="https://img.shields.io/badge/dependencies-ZERO-brightgreen.svg" alt="Dependencies"></a>
14
14
  <a href="#"><img src="https://img.shields.io/badge/tests-1%2C436%2F1%2C436-brightgreen.svg" alt="Tests"></a>
15
15
  <a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-green.svg" alt="License"></a>
package/bin/rip CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env bun
2
2
 
3
- import { readFileSync, readdirSync, writeFileSync, existsSync, statSync, unlinkSync } from 'fs';
3
+ import { readFileSync, readdirSync, writeFileSync, existsSync, statSync, unlinkSync, lstatSync, readlinkSync, rmSync, mkdirSync, symlinkSync, realpathSync } from 'fs';
4
4
  import { execSync, spawnSync, spawn } from 'child_process';
5
5
  import { fileURLToPath } from 'url';
6
6
  import { dirname, basename, join } from 'path';
@@ -60,7 +60,12 @@ Options:
60
60
 
61
61
  Subcommands:
62
62
  rip check [dir] Type-check all .rip files in directory
63
- rip check --strict [dir] Require type annotations in all .rip files
63
+ rip check --audit [pkgDir] Audit a typed package's public API surface for 'any' leaks
64
+ rip check --sourcemap [dir] Verify source-map round-trip for every identifier
65
+ (hover/go-to-def integrity). Useful for compiler work.
66
+ rip link [--quiet] After 'bun install', symlink @rip-lang/* deps to
67
+ local source (opt-in, for framework devs;
68
+ undo with 'bun install --force')
64
69
  rip <name> [args] Run rip-<name> (repo bin/, node_modules, or PATH)
65
70
 
66
71
  Configure via rip.json or package.json:
@@ -95,6 +100,121 @@ Shebang support:
95
100
  `);
96
101
  }
97
102
 
103
+ // `rip link` — opt-in override for framework maintainers, layered on top of a
104
+ // normal install. The canonical setup for any rip project is `bun install`
105
+ // (published packages, lockfile, transitive deps); `rip link` then redirects
106
+ // the @rip-lang/* deps to this CLI's own source checkout, by symlinking them
107
+ // into ./node_modules/@rip-lang/* (plus matching .bin shims). The `rip-lang`
108
+ // compiler/toolchain is pointed at source too, so the loader and VS Code LSP
109
+ // load the matching toolchain instead of a shadowing published tarball. Lets
110
+ // you edit framework source live without publishing.
111
+ //
112
+ // Requires `bun install` first — it overrides an existing install, it is not a
113
+ // substitute for one. Reversible: the symlinks live in node_modules (gitignored)
114
+ // and package.json stays clean semver, so `bun install --force` restores the
115
+ // published packages (a plain `bun install` sees the lockfile as satisfied and
116
+ // leaves the symlinks in place). No-op when no source tree is found (e.g. `rip`
117
+ // installed from npm without a dev checkout) — projects then just use installed
118
+ // packages.
119
+ //
120
+ // Source is this CLI's repo root (so it Just Works after `link-global`),
121
+ // overridable via RIP_SRC.
122
+ function ripLink(args) {
123
+ const quiet = args.includes('--quiet');
124
+ const log = (msg) => { if (!quiet) console.log(`[rip] link: ${msg}`); };
125
+
126
+ const rawSrc = process.env.RIP_SRC || join(__dirname, '..');
127
+ if (!existsSync(join(rawSrc, 'packages'))) {
128
+ log(`no rip-lang source at ${rawSrc} — using installed packages (set RIP_SRC to override)`);
129
+ return;
130
+ }
131
+ const src = realpathSync(rawSrc);
132
+
133
+ const cwd = process.cwd();
134
+ const pkgPath = join(cwd, 'package.json');
135
+ if (!existsSync(pkgPath)) {
136
+ console.error('[rip] link: no package.json in current directory');
137
+ process.exit(1);
138
+ }
139
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
140
+ const deps = Object.keys(pkg.dependencies || {}).filter((d) => d.startsWith('@rip-lang/'));
141
+ if (deps.length === 0) {
142
+ log('no @rip-lang/* dependencies declared — nothing to link');
143
+ return;
144
+ }
145
+
146
+ const nodeModules = join(cwd, 'node_modules');
147
+ if (!existsSync(nodeModules)) {
148
+ console.error('[rip] link: no node_modules found — run `bun install` first, then `rip link`.');
149
+ process.exit(1);
150
+ }
151
+ const binDir = join(nodeModules, '.bin');
152
+
153
+ const linkTo = (path, target, type = 'dir') => {
154
+ try {
155
+ if (lstatSync(path).isSymbolicLink() && readlinkSync(path) === target) return false;
156
+ rmSync(path, { recursive: true, force: true });
157
+ } catch {}
158
+ mkdirSync(dirname(path), { recursive: true });
159
+ symlinkSync(target, path, type);
160
+ return true;
161
+ };
162
+
163
+ const linked = []; // { dep, bins: [names] } for each resolved package
164
+ let changed = false;
165
+
166
+ for (const dep of deps) {
167
+ const short = dep.slice('@rip-lang/'.length);
168
+ const target = join(src, 'packages', short);
169
+ if (!existsSync(target)) { log(`skip ${dep} — not found at ${target}`); continue; }
170
+ if (linkTo(join(nodeModules, '@rip-lang', short), target)) changed = true;
171
+
172
+ // Re-create .bin shims from the package's own `bin` field so `rip server`
173
+ // and friends resolve source binaries from this project's node_modules.
174
+ let meta = {};
175
+ try { meta = JSON.parse(readFileSync(join(target, 'package.json'), 'utf-8')); } catch {}
176
+ const binMap = typeof meta.bin === 'string' ? { [short]: meta.bin } : (meta.bin || {});
177
+ const bins = [];
178
+ for (const [name, rel] of Object.entries(binMap)) {
179
+ const binTarget = join(target, rel);
180
+ if (!existsSync(binTarget)) continue;
181
+ if (linkTo(join(binDir, name), binTarget, 'file')) changed = true;
182
+ bins.push(name);
183
+ }
184
+ linked.push({ dep, bins });
185
+ }
186
+
187
+ // The compiler/toolchain itself. Every @rip-lang/* package depends on it,
188
+ // and the loader + VS Code LSP resolve it from the project's node_modules —
189
+ // so it must point at source too, otherwise a published `rip-lang` tarball
190
+ // shadows it (e.g. the LSP loading a typecheck.js without the latest API).
191
+ if (existsSync(join(nodeModules, 'rip-lang'))) {
192
+ if (linkTo(join(nodeModules, 'rip-lang'), src)) changed = true;
193
+ linked.push({ dep: 'rip-lang', bins: [], label: 'compiler' });
194
+ }
195
+
196
+ if (quiet || linked.length === 0) {
197
+ if (linked.length === 0) log('no @rip-lang/* packages resolved to source');
198
+ return;
199
+ }
200
+
201
+ const home = process.env.HOME || process.env.USERPROFILE || '';
202
+ const tilde = (p) => home && p.startsWith(home) ? '~' + p.slice(home.length) : p;
203
+ const tty = process.stdout.isTTY;
204
+ const dim = (s) => tty ? `\x1b[2m${s}\x1b[0m` : s;
205
+ const bold = (s) => tty ? `\x1b[1m${s}\x1b[0m` : s;
206
+
207
+ const n = linked.length;
208
+ const verb = changed ? 'linked' : 'already linked';
209
+ console.log(`${bold('rip link')} ${dim('·')} ${verb} ${n} package${n === 1 ? '' : 's'} ${dim('→ ' + tilde(src))}`);
210
+ const width = Math.max(...linked.map((l) => l.dep.length));
211
+ for (const { dep, bins, label } of linked) {
212
+ const tag = label || (bins.length ? `bin: ${bins.join(', ')}` : '');
213
+ const line = tag ? `${dep.padEnd(width)}${dim(` ${tag}`)}` : dep;
214
+ console.log(` ${dim('•')} ${line}`);
215
+ }
216
+ }
217
+
98
218
  async function main() {
99
219
  const args = process.argv.slice(2);
100
220
 
@@ -140,14 +260,27 @@ async function main() {
140
260
  // --- Built-in subcommands ---
141
261
 
142
262
  if (args[0] === 'check') {
143
- const { runCheck } = await import('../src/typecheck.js');
144
263
  const checkArgs = args.slice(1);
264
+ const VALID_FLAGS = new Set(['--audit', '--sourcemap']);
265
+ const unknown = checkArgs.filter(a => a.startsWith('-') && !VALID_FLAGS.has(a));
266
+ if (unknown.length > 0) {
267
+ console.error(`rip check: unknown flag${unknown.length === 1 ? '' : 's'}: ${unknown.join(', ')}`);
268
+ console.error(`Valid flags: ${[...VALID_FLAGS].join(', ')}`);
269
+ process.exit(2);
270
+ }
145
271
  const dir = checkArgs.find(a => !a.startsWith('-')) || '.';
146
- const exitCode = await runCheck(dir, {
147
- quiet: checkArgs.includes('-q') || checkArgs.includes('--quiet'),
148
- strict: checkArgs.includes('--strict'),
149
- });
150
- process.exit(exitCode);
272
+ const wantAudit = checkArgs.includes('--audit');
273
+ const wantSourcemap = checkArgs.includes('--sourcemap');
274
+ const { runCheck, runAudit } = await import('../src/typecheck.js');
275
+ const checkCode = await runCheck(dir, { sourceMapAudit: wantSourcemap });
276
+ if (!wantAudit) process.exit(checkCode);
277
+ const auditCode = await runAudit(dir);
278
+ process.exit(checkCode || auditCode);
279
+ }
280
+
281
+ if (args[0] === 'link') {
282
+ ripLink(args.slice(1));
283
+ process.exit(0);
151
284
  }
152
285
 
153
286
  // --- Subcommand dispatch: rip <name> → rip-<name> ---
@@ -197,13 +330,32 @@ async function main() {
197
330
  }
198
331
  }
199
332
 
200
- // 5. Global PATH: rip-<name>
333
+ // 5. Nearest node_modules/.bin walking up from the cwd. `getRepoRoot`
334
+ // uses `git rev-parse --show-toplevel`, which returns the OUTERMOST git
335
+ // repo — wrong when a project lives in a subdirectory of a larger git
336
+ // repo: step 4 then probes the outer repo's node_modules and misses the
337
+ // project's own declared bins. Resolve the bin the way Node resolves a
338
+ // dependency — nearest node_modules up the tree from the cwd — which is
339
+ // correct for nested projects and standalone consumers alike.
340
+ let nmDir = process.cwd();
341
+ while (true) {
342
+ const nmBin = join(nmDir, 'node_modules', '.bin', `rip-${name}`);
343
+ if (existsSync(nmBin)) {
344
+ const r = spawnSync(nmBin, subArgs, { stdio: 'inherit', env: process.env });
345
+ process.exit(r.status ?? 1);
346
+ }
347
+ const parent = dirname(nmDir);
348
+ if (parent === nmDir) break;
349
+ nmDir = parent;
350
+ }
351
+
352
+ // 6. Global PATH: rip-<name>
201
353
  const pathResult = spawnSync(`rip-${name}`, subArgs, { stdio: 'inherit', env: process.env });
202
354
  if (pathResult.error?.code !== 'ENOENT') {
203
355
  process.exit(pathResult.status ?? 1);
204
356
  }
205
357
 
206
- // 6. Not found
358
+ // 7. Not found
207
359
  console.error(`rip: unknown command '${name}'\n\nRun 'rip --help' for usage.`);
208
360
  process.exit(1);
209
361
  }
package/docs/AGENTS.md CHANGED
@@ -38,6 +38,6 @@ App.mount()
38
38
  - `demo.html` and `charts.html` — dashboard demos
39
39
  - `sierpinski.html` — CDN demo
40
40
  - `example/index.html` and `results/index.html` — app launchers / examples. `example/index.json` is generated from `docs/demo/` via `bun run bundle:demo` (the source-of-truth lives in `docs/demo/`, the JSON is the deployable artifact).
41
- - `ui/index.html` — widget gallery. `ui/bundle.json` is generated from `packages/ui/browser/components/` via `bun run bundle:ui` (auto-runs as part of `bun run build`). The source-of-truth is the workspace package; the JSON is the deployable artifact. The gallery loads the bundle at boot via `data-src="bundle.json"` and reads view-source text synchronously from the in-memory components store (`window.__RIP__.components.read("components/<id>.rip")`) — no per-component fetches.
41
+ - `ui/index.html` — widget gallery. `ui/bundle.json` is generated from `packages/ui/browser/components/` via `bun run bundle:ui` (auto-runs as part of `bun run build`). The source-of-truth is the workspace package; the JSON is the deployable artifact. The gallery loads the bundle at boot via `data-src="bundle.json"` and reads view-source text synchronously from the in-memory components store (`window.__RIP__.components.read("_pkg/ui/<id>.rip")`) — no per-component fetches.
42
42
 
43
43
  Static demos can be opened via `file://`. The playground and example app require `bun run serve`.
package/docs/RIP-APP.md CHANGED
@@ -24,21 +24,37 @@ shaped the way it is, and how to use it well.
24
24
 
25
25
  # Contents
26
26
 
27
- 1. [The four-layer architecture](#1-the-four-layer-architecture)
28
- 2. [Quick start — the 30-second wow](#2-quick-start)
29
- 3. [The subsystems](#3-the-subsystems)
30
- - [Stash](#stash)
31
- - [createResource](#createresource)
32
- - [Timing helpers](#timing-helpers)
33
- - [Components store](#components-store)
34
- - [createRouter](#createrouter)
35
- - [createRenderer](#createrenderer)
36
- - [launch](#launch)
37
- - [ARIA helpers](#aria-helpers)
38
- 4. [Lifecycle invariants — what fires when, what owns what](#4-lifecycle-invariants)
39
- 5. [Async effects — `getEffectSignal` and cancellation](#5-async-effects)
40
- 6. [Gotchas — things that have bitten us before](#6-gotchas)
41
- 7. [When NOT to use Rip App](#7-when-not-to-use)
27
+ - [Rip App — Application Framework](#rip-app--application-framework)
28
+ - [Contents](#contents)
29
+ - [1. The four-layer architecture](#1-the-four-layer-architecture)
30
+ - [2. Quick start](#2-quick-start)
31
+ - [Real apps: bundles + file-based routing](#real-apps-bundles--file-based-routing)
32
+ - [3. The subsystems](#3-the-subsystems)
33
+ - [Stash](#stash)
34
+ - [createResource](#createresource)
35
+ - [Timing helpers](#timing-helpers)
36
+ - [Components store](#components-store)
37
+ - [createRouter](#createrouter)
38
+ - [createRenderer](#createrenderer)
39
+ - [launch](#launch)
40
+ - [ARIA helpers](#aria-helpers)
41
+ - [4. Lifecycle invariants](#4-lifecycle-invariants)
42
+ - [Component lifecycle order](#component-lifecycle-order)
43
+ - [User hooks](#user-hooks)
44
+ - [Effect ownership](#effect-ownership)
45
+ - [Effect cleanup-on-rerun](#effect-cleanup-on-rerun)
46
+ - [Parent chain (for context)](#parent-chain-for-context)
47
+ - [Layout and page parentage](#layout-and-page-parentage)
48
+ - [Factory blocks (for/if in render)](#factory-blocks-forif-in-render)
49
+ - [Keyed list reconciliation](#keyed-list-reconciliation)
50
+ - [5. Async effects](#5-async-effects)
51
+ - [6. Gotchas](#6-gotchas)
52
+ - [The bundle boundary matters](#the-bundle-boundary-matters)
53
+ - [Render-template name shadowing (fixed)](#render-template-name-shadowing-fixed)
54
+ - [Nested `for` loops can both name `i` (fixed)](#nested-for-loops-can-both-name-i-fixed)
55
+ - [Snapshot tests are brittle](#snapshot-tests-are-brittle)
56
+ - [No browser e2e tests](#no-browser-e2e-tests)
57
+ - [7. When NOT to use Rip App](#7-when-not-to-use-rip-app)
42
58
 
43
59
  ---
44
60
 
@@ -185,7 +201,7 @@ A deep reactive proxy with path navigation. Single-app state,
185
201
  JSON-persistable, fine-grained signal subscription per key.
186
202
 
187
203
  ```rip
188
- app = stash
204
+ app = createStash
189
205
  user:
190
206
  name: "Alice"
191
207
  prefs:
@@ -205,7 +221,7 @@ app.keys 'user' # → ['name', 'prefs']
205
221
  app.join 'user', email: "..." # shallow merge
206
222
 
207
223
  # Use raw object underneath
208
- plain = raw(app) # back to a plain JS object
224
+ plain = unwrapStash(app) # back to a plain JS object
209
225
  ```
210
226
 
211
227
  **Single-stash policy**: Rip App assumes one stash per page (the one
@@ -291,6 +307,7 @@ router = createRouter components,
291
307
 
292
308
  router.push '/users/42?tab=settings'
293
309
  router.replace '/login'
310
+ router.push '/cart', noScroll: true # don't reset scroll on this nav
294
311
  router.back()
295
312
  router.forward()
296
313
 
@@ -316,6 +333,65 @@ The renderer uses `router.current` to drive its mount effect. Each
316
333
  field is also a separate signal so subscribers can track only what
317
334
  they care about.
318
335
 
336
+ #### Anchor opt-outs and active-link styling
337
+
338
+ The router intercepts plain `<a>` clicks at the document level. Two
339
+ per-anchor attributes adjust that behavior:
340
+
341
+ | Attribute | Effect |
342
+ | ----------------------- | ------------------------------------------------------------------------------------- |
343
+ | `data-router-ignore` | Skip SPA interception entirely. The browser performs a full navigation. |
344
+ | `data-router-noscroll` | Take the SPA navigation, but don't reset scroll to `(0, 0)`. |
345
+
346
+ Anchors with `target="_blank"`, `[download]`, cross-origin hrefs, or
347
+ hrefs outside `base` are also skipped automatically.
348
+
349
+ **Active-link highlighting.** On every navigation the router walks
350
+ in-document anchors and sets `aria-current` on those that match the
351
+ current path:
352
+
353
+ - exact match → `aria-current="page"`
354
+ - prefix match (`/blog` on `/blog/123`) → `aria-current="true"`
355
+ - otherwise → attribute removed (only if the router set it)
356
+
357
+ Style it with attribute selectors — no per-link boilerplate needed:
358
+
359
+ ```css
360
+ nav a[aria-current="page"] { color: red; font-weight: bold; }
361
+ nav a[aria-current="true"] { color: red; }
362
+ ```
363
+
364
+ Setting `aria-current` manually on an anchor wins — the router only
365
+ touches values it set itself.
366
+
367
+ **Scroll restoration.** New navigations (`push` or a link click)
368
+ reset scroll to `(0, 0)`. Back/forward (`popstate`) restores the
369
+ scroll position the page had when you left it. Same-document fragment
370
+ links (`#section`) defer to the browser.
371
+
372
+ **Typed routes (compile-time).** In a typed project (one with
373
+ `rip.strict: true` or `::` annotations), `rip check` synthesizes a
374
+ `__RipRoutes` union from the file tree under `app/routes/` and
375
+ threads it through three places:
376
+
377
+ | Place | Type | Catches |
378
+ | ---------------------------------- | --------------------------------------------------------------------- | ---------------------------------- |
379
+ | `<a href: "...">` in render blocks | `__RipRoutes` for `/`-prefixed literals; any string otherwise | Typos in known routes |
380
+ | `router.push url, opts?` | `__RipRoutes` (replaces base `string`) | Typos in programmatic navigation |
381
+ | `@params` in `routes/[id].rip` | `{ id: string }` (replaces `Record<string, string>`) | Typos like `@params.bogus` |
382
+
383
+ Anchor `href` uses a `const`-generic conditional: a literal starting
384
+ with `/` must satisfy `__RipRoutes`, while external schemes
385
+ (`https://`, `mailto:`, `tel:`), fragments (`#anchor`), and dynamic
386
+ `string` values fall through unchecked. Typos like `<a href: "/crat">`
387
+ produce a single-line error naming the valid routes.
388
+ `router.replace` is deliberately left at `string` — it's commonly
389
+ used to mutate the current URL with query strings, where the built
390
+ value can't satisfy a literal-route union. Catch-all routes
391
+ (`[...rest].rip`) are excluded from `__RipRoutes` — they're runtime
392
+ 404 fallbacks, not navigation targets, so including them as
393
+ `/${string}` would defeat typo-catching for every other route.
394
+
319
395
  ### createRenderer
320
396
 
321
397
  The render loop. Subscribes to `router.current`, mounts/unmounts
@@ -422,6 +498,8 @@ mount(target) ← only the renderer calls this directly
422
498
  Per-child push wrap: each child's _create runs with
423
499
  child as current, so the child's reactive bindings
424
500
  register on child._disposers, not parent's.
501
+ beforeMount() ← user hook; signals/state ready, DOM not yet in tree
502
+ effects created here auto-register on this component
425
503
  _setup() ← post-creation effects (rare; most go in _init)
426
504
  mounted() ← user hook; DOM is in the tree now
427
505
  __popComponent
@@ -436,6 +514,20 @@ unmount({ removeDOM = true }) ← idempotent (_unmounted flag short-circuits se
436
514
  DOM removal (if requested)
437
515
  ```
438
516
 
517
+ ### User hooks
518
+
519
+ The framework recognizes these hook names on any component. All are
520
+ optional; the runtime calls each only if defined.
521
+
522
+ | Hook | When it fires | Notes |
523
+ | --------------- | ---------------------------------------------------------- | -------------------------------------------------------------------------------- |
524
+ | `beforeMount` | After `_create`, before DOM is attached | Effects created here auto-register on the component |
525
+ | `mounted` | After DOM attached | Runs once per visit |
526
+ | `beforeUnmount` | Before children unmount and disposers fire | Signals/effects still live |
527
+ | `unmounted` | After disposers fire and DOM is removed | Final notification; runs once per visit |
528
+ | `onError` | A throw escapes any component method (render, hook, event) | Receives `{ status?, message?, error?, path? }`; the renderer walks the layout chain to find the nearest defining component |
529
+
530
+
439
531
  ### Effect ownership
440
532
 
441
533
  Every `__effect(fn)` call automatically registers its disposer with
package/docs/RIP-LANG.md CHANGED
@@ -1593,12 +1593,11 @@ App = component
1593
1593
 
1594
1594
  ```coffee
1595
1595
  App = component
1596
- beforeMount = -> p "about to mount"
1597
- mounted = -> p "mounted"
1598
- updated = -> p "updated"
1596
+ beforeMount = -> p "about to mount"
1597
+ mounted = -> p "mounted"
1599
1598
  beforeUnmount = -> p "about to unmount"
1600
- unmounted = -> p "unmounted"
1601
- onError = (err, comp) -> p "caught: #{err.message}"
1599
+ unmounted = -> p "unmounted"
1600
+ onError = (err) -> p "caught: #{err.message}"
1602
1601
  ```
1603
1602
 
1604
1603
  **Effects:**