kerfjs 5.0.0-beta.7 → 5.0.0-beta.71
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/CHANGELOG.md +2488 -117
- package/README.md +96 -37
- package/ai/cursorrules +110 -61
- package/ai/manifest.json +16 -7
- package/ai/skill.md +120 -75
- package/dist/actions.d.ts +1 -1
- package/dist/actions.js +10 -6
- package/dist/actions.js.map +1 -1
- package/dist/array-signal.js +1 -1
- package/dist/async.js +22 -19
- package/dist/async.js.map +1 -1
- package/dist/attach.js +1 -66
- package/dist/attach.js.map +1 -1
- package/dist/{chunk-HW7KSM2Y.js → chunk-3C6F6NM4.js} +1 -1
- package/dist/{chunk-HW7KSM2Y.js.map → chunk-3C6F6NM4.js.map} +1 -1
- package/dist/{chunk-GY4XV2UV.js → chunk-4TX6YJDM.js} +10 -3
- package/dist/chunk-4TX6YJDM.js.map +1 -0
- package/dist/{chunk-QIP723L4.js → chunk-5WRGJZV6.js} +3 -2
- package/dist/chunk-5WRGJZV6.js.map +1 -0
- package/dist/{chunk-KZJXHFIB.js → chunk-BI4HGICV.js} +3 -3
- package/dist/chunk-BI4HGICV.js.map +1 -0
- package/dist/chunk-FM6N2B4N.js +70 -0
- package/dist/chunk-FM6N2B4N.js.map +1 -0
- package/dist/{chunk-LVH3GC6B.js → chunk-H7WTRM6F.js} +3 -3
- package/dist/chunk-H7WTRM6F.js.map +1 -0
- package/dist/{chunk-U6FK33SG.js → chunk-IHMDBBUJ.js} +2 -2
- package/dist/chunk-IHMDBBUJ.js.map +1 -0
- package/dist/chunk-JN7EJM3B.js +86 -0
- package/dist/chunk-JN7EJM3B.js.map +1 -0
- package/dist/{chunk-UZJ6I4T6.js → chunk-MK42GLPV.js} +1 -1
- package/dist/chunk-MK42GLPV.js.map +1 -0
- package/dist/{chunk-SVATPF5R.js → chunk-TLL3KXT6.js} +249 -160
- package/dist/chunk-TLL3KXT6.js.map +1 -0
- package/dist/{chunk-ZDCJZCNO.js → chunk-UGNPDLNI.js} +78 -45
- package/dist/chunk-UGNPDLNI.js.map +1 -0
- package/dist/{chunk-V46JKE44.js → chunk-WKIPLNVO.js} +1 -1
- package/dist/chunk-WKIPLNVO.js.map +1 -0
- package/dist/{delegate-CL9VTZFb.d.ts → delegate--CrfgYtC.d.ts} +0 -34
- package/dist/dev.d.ts +31 -13
- package/dist/dev.js +101 -35
- package/dist/dev.js.map +1 -1
- package/dist/html.js +5 -4
- package/dist/html.js.map +1 -1
- package/dist/index.d.ts +14 -9
- package/dist/index.js +29 -20
- package/dist/index.js.map +1 -1
- package/dist/jsx-runtime.d.ts +13 -5
- package/dist/jsx-runtime.js +1 -1
- package/dist/list.d.ts +7 -3
- package/dist/list.js +126 -28
- package/dist/list.js.map +1 -1
- package/dist/overlay.d.ts +25 -28
- package/dist/overlay.js +580 -275
- package/dist/overlay.js.map +1 -1
- package/dist/remount.js +2 -2
- package/dist/router.d.ts +13 -4
- package/dist/router.js +37 -10
- package/dist/router.js.map +1 -1
- package/dist/scope.d.ts +35 -8
- package/dist/scope.js +61 -30
- package/dist/scope.js.map +1 -1
- package/dist/testing.js +1 -1
- package/dist/timing.d.ts +3 -2
- package/dist/timing.js +1 -1
- package/dist/timing.js.map +1 -1
- package/llms.txt +11 -4
- package/package.json +32 -7
- package/setup/cli.mjs +90 -0
- package/setup/index.d.mts +37 -0
- package/setup/index.mjs +1347 -0
- package/setup/jsonc.mjs +201 -0
- package/setup/state.schema.json +56 -0
- package/dist/chunk-GY4XV2UV.js.map +0 -1
- package/dist/chunk-KPXIOG2C.js +0 -54
- package/dist/chunk-KPXIOG2C.js.map +0 -1
- package/dist/chunk-KZJXHFIB.js.map +0 -1
- package/dist/chunk-LVH3GC6B.js.map +0 -1
- package/dist/chunk-QIP723L4.js.map +0 -1
- package/dist/chunk-SVATPF5R.js.map +0 -1
- package/dist/chunk-U6FK33SG.js.map +0 -1
- package/dist/chunk-UZJ6I4T6.js.map +0 -1
- package/dist/chunk-V46JKE44.js.map +0 -1
- package/dist/chunk-ZDCJZCNO.js.map +0 -1
package/dist/router.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/router.ts"],"names":["base"],"mappings":"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","file":"router.js","sourcesContent":["/**\n * `kerfjs/router` — the \"postcard router\": the smallest client-side router that\n * is still a router. The kerf *core* stays router-free (docs/1 \"Not a router\" is\n * about the runtime); this is an opt-in, tree-shakeable subpath, on the same\n * footing as `kerfjs/list` / `kerfjs/overlay` — it adds nothing to the main\n * barrel until you import it.\n *\n * import { createRouter } from 'kerfjs/router';\n *\n * const router = createRouter({\n * routes: [\n * { path: '/', component: () => <Home /> },\n * { path: '/users/:id', component: ({ id }) => <User id={id} /> },\n * { path: '*', component: () => <NotFound /> }, // catch-all\n * ],\n * });\n *\n * mount(app, () => (\n * <div>\n * <nav> … in-app links here are auto-intercepted … </nav>\n * {router.outlet()} // renders the matched route's component\n * </div>\n * ));\n *\n * The whole model is three moving parts kerf already has: a `signal` for the\n * current route, `delegate()` for link interception, and the keyed morph for the\n * outlet (a route change swaps the page wholesale; a param change updates in\n * place). Everything a full framework's router adds — nested layouts, data\n * loaders, lazy routes, guards, SSR matching — is deliberately OUT of scope;\n * compose those with kerf primitives (an `effect` on `router.route`, a `resource`\n * from `kerfjs/async`) when you need them.\n */\nimport { delegate } from './delegate.js';\nimport { jsx } from './jsx-runtime.js';\nimport { type MountResult } from './mount.js';\nimport { computed, type ReadonlySignal, signal } from './reactive.js';\n\n/** The reactive current-route snapshot (`router.route.value`). */\nexport interface RouteState {\n /** The matched pathname, base stripped (history mode) or the hash body (hash mode). Always starts with `/`. */\n path: string;\n /** Path parameters from the matched pattern — `/users/:id` on `/users/7` → `{ id: '7' }`. */\n params: Record<string, string>;\n /** The parsed query string (`?a=1` → `URLSearchParams`). Empty when there is none. */\n query: URLSearchParams;\n /** The raw location hash including `#` (history mode), or `''`. In hash mode the hash IS the route, so this is `''`. */\n hash: string;\n}\n\n/** A route's view: receives the matched `params` and the full `route` snapshot, returns kerf content. */\nexport type RouteComponent = (params: Record<string, string>, route: RouteState) => MountResult;\n\n/** One route in the table. `path` is a pattern: `/`, `/users/:id`, `/files/*rest`, or `*` (catch-all). */\nexport interface RouteDef {\n /** Pattern: static segments, `:param` captures, a trailing `*rest` wildcard, or `*` (matches anything — put last). */\n path: string;\n /** The view rendered in the outlet when this route matches. */\n component: RouteComponent;\n}\n\n/** Options for {@link navigate}. */\nexport interface NavigateOptions {\n /** Replace the current history entry instead of pushing a new one. Default `false`. */\n replace?: boolean;\n /** Arbitrary state stored on the history entry (readable via `history.state`). */\n state?: unknown;\n}\n\n/** Options for {@link createRouter}. */\nexport interface RouterOptions {\n /** The route table, tried in order; the first match wins. Include a `path: '*'` entry last for a fallback. */\n routes: readonly RouteDef[];\n /**\n * `'history'` (default) uses the real pathname (`/users/7`) via the History\n * API; `'hash'` keeps the route after `#` (`#/users/7`) for static hosts with\n * no server rewrite.\n */\n mode?: 'history' | 'hash';\n /** History mode only: a base path every route sits under (`/app`), matched at a segment boundary, stripped from `route.path`, and prepended on navigation. */\n base?: string;\n /**\n * Auto-intercept clicks on in-app `<a href>` links (same-origin, left-click, no\n * modifier keys / `target` / `download`) and route them instead of reloading.\n * Opt a single link out with `data-router-ignore` or `rel=\"external\"`. Default\n * `true`; set `false` to wire navigation entirely yourself.\n */\n interceptLinks?: boolean;\n}\n\n/** The handle {@link createRouter} returns. Holds no module-global state — it's a closure. */\nexport interface RouterHandle {\n /** The reactive current route. Read `.value` (tracked) in a render / `computed` / `effect`. */\n route: ReadonlySignal<RouteState>;\n /** Navigate to `path` (may include `?query` / `#hash`). Pushes history (or replaces, per options). */\n navigate: (path: string, options?: NavigateOptions) => void;\n /** History back — `history.back()`. */\n back: () => void;\n /** History forward — `history.forward()`. */\n forward: () => void;\n /**\n * A reactive \"is this path active?\" — true when the current path equals\n * `pattern` or is nested under it (`match('/users')` is true on `/users/7`).\n * `match('/')` is exact (only true on `/`). Bind it for active-nav styling.\n */\n match: (pattern: string) => ReadonlySignal<boolean>;\n /** Convenience: a bound class signal — `className` while {@link match}`(pattern)` is active, else `''`. */\n activeClass: (pattern: string, className: string) => ReadonlySignal<string>;\n /**\n * The routed view. Call it inside a `mount()` render: it renders the matched\n * route's component in a keyed wrapper, so a route change swaps the page\n * wholesale (fresh DOM) while a param change updates it in place.\n */\n outlet: () => MountResult;\n /** Tear down the popstate / link listeners. Idempotent. */\n dispose: () => void;\n}\n\n/**\n * Match `path` against a route `pattern`. Returns the captured params on a match,\n * or `null` on no match. `*` matches anything; a trailing `*name` captures the\n * remaining segments joined by `/`; `:name` captures one segment.\n */\nfunction matchPattern(pattern: string, path: string): Record<string, string> | null {\n if (pattern === '*') return {};\n const patternSegments = pattern.split('/').filter(Boolean);\n const pathSegments = path.split('/').filter(Boolean);\n const params: Record<string, string> = {};\n for (let i = 0; i < patternSegments.length; i++) {\n const patternSegment = patternSegments[i];\n if (patternSegment.startsWith('*')) {\n // Wildcard rest — consumes every remaining segment.\n const name = patternSegment.slice(1);\n if (name.length > 0) {\n const decoded: string[] = [];\n for (const part of pathSegments.slice(i)) {\n const value = decodePathSegment(part);\n if (value === null) return null;\n decoded.push(value);\n }\n params[name] = decoded.join('/');\n }\n return params;\n }\n if (i >= pathSegments.length) return null;\n if (patternSegment.startsWith(':')) {\n const value = decodePathSegment(pathSegments[i]);\n if (value === null) return null;\n params[patternSegment.slice(1)] = value;\n continue;\n }\n if (patternSegment !== pathSegments[i]) return null;\n }\n // No wildcard matched, so the segment counts must be exactly equal.\n return pathSegments.length === patternSegments.length ? params : null;\n}\n\n/** Decode a route parameter, treating malformed percent escapes as no match. */\nfunction decodePathSegment(segment: string): string | null {\n try {\n return decodeURIComponent(segment);\n } catch {\n return null;\n }\n}\n\n/** Whether `path` is exactly `base` or starts with it at a segment boundary. */\nfunction isWithinBase(path: string, base: string): boolean {\n return base.length === 0 || path === base || path.startsWith(base + '/');\n}\n\n/**\n * Create a router bound to the browser history. Reads the current location\n * immediately (so `route.value` is correct before first paint), installs a\n * `popstate` listener (+ `hashchange` in hash mode) and, unless disabled, a\n * single delegated link interceptor. See {@link RouterOptions} / {@link RouterHandle}.\n */\nexport function createRouter(options: RouterOptions): RouterHandle {\n const { routes, mode = 'history', base = '', interceptLinks = true } = options;\n // Normalize base to '' or '/foo' (no trailing slash), so `base + path` is clean.\n const normBase = base === '/' ? '' : base.replace(/\\/$/, '');\n\n // The current matched route def, kept in step with the signal so `outlet()`\n // doesn't have to re-match — it reads `route.value` only to subscribe.\n let matched: { def: RouteDef; params: Record<string, string> } | null = null;\n\n const readLocation = (): RouteState => {\n let path: string;\n let query: URLSearchParams;\n let hash: string;\n if (mode === 'hash') {\n // Everything after '#': '#/users/7?a=1' → path '/users/7', query 'a=1'.\n const raw = location.hash.slice(1) || '/';\n const qIndex = raw.indexOf('?');\n path = qIndex === -1 ? raw : raw.slice(0, qIndex);\n query = new URLSearchParams(qIndex === -1 ? '' : raw.slice(qIndex + 1));\n hash = '';\n } else {\n path = location.pathname;\n if (normBase.length > 0 && isWithinBase(path, normBase)) {\n path = path.slice(normBase.length) || '/';\n }\n query = new URLSearchParams(location.search);\n hash = location.hash;\n }\n if (!path.startsWith('/')) path = '/' + path;\n return { path, params: {}, query, hash };\n };\n\n const resolve = (state: RouteState): RouteState => {\n for (const def of routes) {\n const params = matchPattern(def.path, state.path);\n if (params !== null) {\n matched = { def, params };\n return { ...state, params };\n }\n }\n matched = null;\n return state;\n };\n\n const route = signal<RouteState>(resolve(readLocation()));\n\n // Re-read location → re-match → publish. Only writes when the path actually\n // changed, so a doubled popstate/hashchange is a harmless no-op.\n const sync = (): void => {\n const next = resolve(readLocation());\n if (next.path !== route.value.path || next.query.toString() !== route.value.query.toString()\n || next.hash !== route.value.hash) {\n route.value = next;\n }\n };\n\n const navigate = (path: string, opts: NavigateOptions = {}): void => {\n const url = mode === 'hash'\n ? '#' + (path.startsWith('/') ? path : '/' + path)\n : normBase + (path.startsWith('/') ? path : '/' + path);\n // pushState/replaceState do NOT fire popstate, so publish the new route ourselves.\n history[opts.replace === true ? 'replaceState' : 'pushState'](opts.state ?? null, '', url);\n route.value = resolve(readLocation());\n };\n\n const match = (pattern: string): ReadonlySignal<boolean> =>\n computed(() => {\n const p = route.value.path;\n if (pattern === '/') return p === '/';\n const base = pattern.replace(/\\/$/, '');\n return p === base || p.startsWith(base + '/');\n });\n\n const activeClass = (pattern: string, className: string): ReadonlySignal<string> => {\n const active = match(pattern);\n return computed(() => (active.value ? className : ''));\n };\n\n const outlet = (): MountResult => {\n void route.value; // tracked read — subscribes the enclosing mount to navigation\n if (matched === null) return null;\n // Keyed by the route PATTERN: a different pattern → the morph replaces the\n // wrapper wholesale (fresh DOM for the new page); the same pattern (only the\n // params changed) → the morph reconciles the children in place.\n return jsx('div', {\n 'data-router-outlet': '',\n 'data-key': matched.def.path,\n children: matched.def.component(matched.params, route.value),\n });\n };\n\n // --- Listeners ---------------------------------------------------------\n const removers: Array<() => void> = [];\n globalThis.addEventListener('popstate', sync);\n removers.push(() => globalThis.removeEventListener('popstate', sync));\n if (mode === 'hash') {\n globalThis.addEventListener('hashchange', sync);\n removers.push(() => globalThis.removeEventListener('hashchange', sync));\n }\n\n if (interceptLinks && typeof document !== 'undefined') {\n const onClick = (event: Event, anchor: HTMLAnchorElement): void => {\n const mouseEvent = event as MouseEvent;\n // Let the browser handle anything that isn't a plain left-click navigation.\n if (mouseEvent.defaultPrevented || mouseEvent.button !== 0 || mouseEvent.metaKey\n || mouseEvent.ctrlKey || mouseEvent.shiftKey || mouseEvent.altKey) return;\n if (anchor.hasAttribute('download') || anchor.hasAttribute('data-router-ignore')) return;\n const target = anchor.getAttribute('target');\n if (target !== null && target !== '' && target !== '_self') return;\n const rel = anchor.getAttribute('rel');\n if (rel !== null && /\\bexternal\\b/.test(rel)) return;\n const url = new URL(anchor.href, location.href);\n if (url.origin !== location.origin) return;\n if (mode === 'history') {\n if (!isWithinBase(url.pathname, normBase)) return; // outside the app's base\n event.preventDefault();\n navigate(url.pathname.slice(normBase.length) + url.search + url.hash);\n } else {\n // Hash mode: only intercept in-app hash links (`#/...`), leave others alone.\n if (url.pathname !== location.pathname || !url.hash.startsWith('#/')) return;\n event.preventDefault();\n navigate(url.hash.slice(1));\n }\n };\n removers.push(delegate<HTMLAnchorElement>(document.body, 'click', 'a[href]', onClick));\n }\n\n let disposed = false;\n const dispose = (): void => {\n if (disposed) return;\n disposed = true;\n for (const remove of removers) remove();\n };\n\n return { route, navigate, back: () => history.back(), forward: () => history.forward(), match, activeClass, outlet, dispose };\n}\n"]}
|
|
1
|
+
{"version":3,"sources":["../src/router.ts"],"names":["base"],"mappings":"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","file":"router.js","sourcesContent":["/**\n * `kerfjs/router` — the \"postcard router\": the smallest client-side router that\n * is still a router. The kerf *core* stays router-free (docs/1 \"Not a router\" is\n * about the runtime); this is an opt-in, tree-shakeable subpath, on the same\n * footing as `kerfjs/list` / `kerfjs/overlay` — it adds nothing to the main\n * barrel until you import it.\n *\n * import { createRouter } from 'kerfjs/router';\n *\n * const router = createRouter({\n * routes: [\n * { path: '/', component: () => <Home /> },\n * { path: '/users/:id', component: ({ id }) => <User id={id} /> },\n * { path: '*', component: () => <NotFound /> }, // catch-all\n * ],\n * });\n *\n * mount(app, () => (\n * <div>\n * <nav> … in-app links here are auto-intercepted … </nav>\n * {router.outlet()} // renders the matched route's component\n * </div>\n * ));\n *\n * The whole model is three moving parts kerf already has: a `signal` for the\n * current route, `delegate()` for link interception, and the keyed morph for the\n * outlet (a route change swaps the page wholesale; a param change updates in\n * place). Everything a full framework's router adds — nested layouts, data\n * loaders, lazy routes, guards, SSR matching — is deliberately OUT of scope;\n * compose those with kerf primitives (an `effect` on `router.route`, a `resource`\n * from `kerfjs/async`) when you need them.\n */\nimport { delegate } from './delegate.js';\nimport { jsx } from './jsx-runtime.js';\nimport { type MountResult } from './mount.js';\nimport { computed, type ReadonlySignal, signal } from './reactive.js';\n\n/** The reactive current-route snapshot (`router.route.value`). */\nexport interface RouteState {\n /** The matched pathname, base stripped (history mode) or the hash body (hash mode). Always starts with `/`. */\n path: string;\n /** Path parameters from the matched pattern — `/users/:id` on `/users/7` → `{ id: '7' }`. */\n params: Record<string, string>;\n /** The parsed query string (`?a=1` → `URLSearchParams`). Empty when there is none. */\n query: URLSearchParams;\n /** The raw location hash including `#` (history mode), or `''`. In hash mode the hash IS the route, so this is `''`. */\n hash: string;\n}\n\n/** A route's view: receives the matched `params` and the full `route` snapshot, returns kerf content. */\nexport type RouteComponent = (\n params: Record<string, string>,\n route: RouteState,\n) => MountResult;\n\n/** One route in the table. `path` is a pattern: `/`, `/users/:id`, `/files/*rest`, or `*` (catch-all). */\nexport interface RouteDef {\n /** Pattern: static segments, `:param` captures, a trailing `*rest` wildcard, or `*` (matches anything — put last). */\n path: string;\n /** The view rendered in the outlet when this route matches. */\n component: RouteComponent;\n}\n\n/** Options for {@link navigate}. */\nexport interface NavigateOptions {\n /** Replace the current history entry instead of pushing a new one. Default `false`. */\n replace?: boolean;\n /** Arbitrary state stored on the history entry (readable via `history.state`). */\n state?: unknown;\n}\n\n/** Options for {@link createRouter}. */\nexport interface RouterOptions {\n /** The route table, tried in order; the first match wins. Include a `path: '*'` entry last for a fallback. */\n routes: readonly RouteDef[];\n /**\n * `'history'` (default) uses the real pathname (`/users/7`) via the History\n * API; `'hash'` keeps the route after `#` (`#/users/7`) for static hosts with\n * no server rewrite.\n */\n mode?: 'history' | 'hash';\n /** History mode only: a base path every route sits under (`/app`), matched at a segment boundary, stripped from `route.path`, and prepended on navigation. */\n base?: string;\n /**\n * Auto-intercept clicks on in-app `<a href>` links (same-origin, left-click, no\n * modifier keys / `download` / `target` other than `_self`) and route them\n * instead of reloading.\n * Opt a single link out with `data-router-ignore` or `rel=\"external\"`. Default\n * `true`; set `false` to wire navigation entirely yourself.\n */\n interceptLinks?: boolean;\n}\n\n/** The handle {@link createRouter} returns. Holds no module-global state — it's a closure. */\nexport interface RouterHandle {\n /** The reactive current route. Read `.value` (tracked) in a render / `computed` / `effect`. */\n route: ReadonlySignal<RouteState>;\n /** Navigate to `path` (may include `?query` / `#hash`). Pushes history (or replaces, per options). */\n navigate: (path: string, options?: NavigateOptions) => void;\n /** History back — `history.back()`. */\n back: () => void;\n /** History forward — `history.forward()`. */\n forward: () => void;\n /**\n * A reactive \"is this path active?\" — a literal path-prefix comparison: true\n * when the current path equals `pattern` or continues it at a `/` boundary\n * (`match('/users')` is true on `/users/7`). `match('/')` is exact (only true\n * on `/`). `pattern` is NOT run through the route matcher, so `:param` / `*`\n * segments compare as literal text (`match('/users/:id')` is never true) —\n * pass the static prefix. Bind it for active-nav styling.\n */\n match: (pattern: string) => ReadonlySignal<boolean>;\n /** Convenience: a bound class signal — `className` while {@link match}`(pattern)` is active, else `''`. */\n activeClass: (pattern: string, className: string) => ReadonlySignal<string>;\n /**\n * The routed view. Call it inside a `mount()` render: it renders the matched\n * route's component in a keyed wrapper, so a route change swaps the page\n * wholesale (fresh DOM) while a param change updates it in place.\n */\n outlet: () => MountResult;\n /** Tear down the popstate / link listeners. Idempotent. */\n dispose: () => void;\n}\n\n/**\n * Match `path` against a route `pattern`. Returns the captured params on a match,\n * or `null` on no match. `*` matches anything; a trailing `*name` captures the\n * remaining segments joined by `/`; `:name` captures one segment.\n */\nfunction matchPattern(\n pattern: string,\n path: string,\n): Record<string, string> | null {\n if (pattern === '*') return {};\n const patternSegments = pattern.split('/').filter(Boolean);\n const pathSegments = path.split('/').filter(Boolean);\n const params: Record<string, string> = {};\n for (let i = 0; i < patternSegments.length; i++) {\n const patternSegment = patternSegments[i];\n if (patternSegment.startsWith('*')) {\n // Wildcard rest — consumes every remaining segment.\n const name = patternSegment.slice(1);\n if (name.length > 0) {\n const decoded: string[] = [];\n for (const part of pathSegments.slice(i)) {\n const value = decodePathSegment(part);\n if (value === null) return null;\n decoded.push(value);\n }\n params[name] = decoded.join('/');\n }\n return params;\n }\n if (i >= pathSegments.length) return null;\n if (patternSegment.startsWith(':')) {\n const value = decodePathSegment(pathSegments[i]);\n if (value === null) return null;\n params[patternSegment.slice(1)] = value;\n continue;\n }\n if (patternSegment !== pathSegments[i]) return null;\n }\n // No wildcard matched, so the segment counts must be exactly equal.\n return pathSegments.length === patternSegments.length ? params : null;\n}\n\n/** Decode a route parameter, treating malformed percent escapes as no match. */\nfunction decodePathSegment(segment: string): string | null {\n try {\n return decodeURIComponent(segment);\n } catch {\n return null;\n }\n}\n\n/** Whether `path` is exactly `base` or starts with it at a segment boundary. */\nfunction isWithinBase(path: string, base: string): boolean {\n return base.length === 0 || path === base || path.startsWith(base + '/');\n}\n\n/**\n * Create a router bound to the browser history. Reads the current location\n * immediately (so `route.value` is correct before first paint), installs a\n * `popstate` listener (+ `hashchange` in hash mode) and, unless disabled, a\n * single delegated link interceptor. See {@link RouterOptions} / {@link RouterHandle}.\n *\n * One router per document is the supported shape: a second router follows\n * link clicks and `popstate`, but not another router's programmatic\n * `navigate()` (pushState fires no popstate; there is no cross-router\n * broadcasting).\n */\nexport function createRouter(options: RouterOptions): RouterHandle {\n const {\n routes,\n mode = 'history',\n base = '',\n interceptLinks = true,\n } = options;\n // Normalize base to '' or '/foo' (no trailing slash), so `base + path` is clean.\n const normBase = base === '/' ? '' : base.replace(/\\/$/, '');\n\n // The current matched route def, kept in step with the signal so `outlet()`\n // doesn't have to re-match — it reads `route.value` only to subscribe.\n let matched: { def: RouteDef; params: Record<string, string> } | null = null;\n\n const readLocation = (): RouteState => {\n let path: string;\n let query: URLSearchParams;\n let hash: string;\n if (mode === 'hash') {\n // Everything after '#': '#/users/7?a=1' → path '/users/7', query 'a=1'.\n const raw = location.hash.slice(1) || '/';\n const qIndex = raw.indexOf('?');\n path = qIndex === -1 ? raw : raw.slice(0, qIndex);\n query = new URLSearchParams(qIndex === -1 ? '' : raw.slice(qIndex + 1));\n hash = '';\n } else {\n path = location.pathname;\n if (normBase.length > 0 && isWithinBase(path, normBase)) {\n path = path.slice(normBase.length) || '/';\n }\n query = new URLSearchParams(location.search);\n hash = location.hash;\n }\n if (!path.startsWith('/')) path = '/' + path;\n return { path, params: {}, query, hash };\n };\n\n const resolve = (state: RouteState): RouteState => {\n for (const def of routes) {\n const params = matchPattern(def.path, state.path);\n if (params !== null) {\n matched = { def, params };\n return { ...state, params };\n }\n }\n matched = null;\n return state;\n };\n\n const route = signal<RouteState>(resolve(readLocation()));\n\n // Re-read location → re-match → publish. Only writes when the path actually\n // changed, so a doubled popstate/hashchange is a harmless no-op.\n const sync = (): void => {\n const next = resolve(readLocation());\n if (\n next.path !== route.value.path ||\n next.query.toString() !== route.value.query.toString() ||\n next.hash !== route.value.hash\n ) {\n route.value = next;\n }\n };\n\n const navigate = (path: string, opts: NavigateOptions = {}): void => {\n const url =\n mode === 'hash'\n ? '#' + (path.startsWith('/') ? path : '/' + path)\n : normBase + (path.startsWith('/') ? path : '/' + path);\n // pushState/replaceState do NOT fire popstate, so publish the new route ourselves.\n history[opts.replace === true ? 'replaceState' : 'pushState'](\n opts.state ?? null,\n '',\n url,\n );\n route.value = resolve(readLocation());\n };\n\n const match = (pattern: string): ReadonlySignal<boolean> =>\n computed(() => {\n const p = route.value.path;\n if (pattern === '/') return p === '/';\n const base = pattern.replace(/\\/$/, '');\n return p === base || p.startsWith(base + '/');\n });\n\n const activeClass = (\n pattern: string,\n className: string,\n ): ReadonlySignal<string> => {\n const active = match(pattern);\n return computed(() => (active.value ? className : ''));\n };\n\n const outlet = (): MountResult => {\n void route.value; // tracked read — subscribes the enclosing mount to navigation\n if (matched === null) return null;\n // Keyed by the route PATTERN: a different pattern → the morph replaces the\n // wrapper wholesale (fresh DOM for the new page); the same pattern (only the\n // params changed) → the morph reconciles the children in place.\n return jsx('div', {\n 'data-router-outlet': '',\n 'data-key': matched.def.path,\n children: matched.def.component(matched.params, route.value),\n });\n };\n\n // --- Listeners ---------------------------------------------------------\n const removers: Array<() => void> = [];\n globalThis.addEventListener('popstate', sync);\n removers.push(() => globalThis.removeEventListener('popstate', sync));\n if (mode === 'hash') {\n globalThis.addEventListener('hashchange', sync);\n removers.push(() => globalThis.removeEventListener('hashchange', sync));\n }\n\n if (interceptLinks && typeof document !== 'undefined') {\n const onClick = (event: Event, anchor: HTMLAnchorElement): void => {\n const mouseEvent = event as MouseEvent;\n // Already handled — by the app, or by ANOTHER router on this document\n // that navigated with pushState (which fires no popstate). Re-read the\n // location so this router follows instead of diverging; when nothing\n // navigated, sync() is a no-op (KF-XW1RE9: two routers on one document).\n if (mouseEvent.defaultPrevented) {\n sync();\n return;\n }\n // Let the browser handle anything that isn't a plain left-click navigation.\n if (\n mouseEvent.button !== 0 ||\n mouseEvent.metaKey ||\n mouseEvent.ctrlKey ||\n mouseEvent.shiftKey ||\n mouseEvent.altKey\n )\n return;\n if (\n anchor.hasAttribute('download') ||\n anchor.hasAttribute('data-router-ignore')\n )\n return;\n const target = anchor.getAttribute('target');\n if (target !== null && target !== '' && target !== '_self') return;\n const rel = anchor.getAttribute('rel');\n if (rel !== null && /\\bexternal\\b/.test(rel)) return;\n const url = new URL(anchor.href, location.href);\n if (url.origin !== location.origin) return;\n if (mode === 'history') {\n if (!isWithinBase(url.pathname, normBase)) return; // outside the app's base\n event.preventDefault();\n navigate(url.pathname.slice(normBase.length) + url.search + url.hash);\n } else {\n // Hash mode: only intercept in-app hash links (`#/...`), leave others alone.\n if (url.pathname !== location.pathname || !url.hash.startsWith('#/'))\n return;\n event.preventDefault();\n navigate(url.hash.slice(1));\n }\n };\n removers.push(\n delegate<HTMLAnchorElement>(document.body, 'click', 'a[href]', onClick),\n );\n }\n\n let disposed = false;\n const dispose = (): void => {\n if (disposed) return;\n disposed = true;\n for (const remove of removers) remove();\n };\n\n return {\n route,\n navigate,\n back: () => history.back(),\n forward: () => history.forward(),\n match,\n activeClass,\n outlet,\n dispose,\n };\n}\n"]}
|
package/dist/scope.d.ts
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
import { D as DelegateOptions } from './delegate
|
|
1
|
+
import { D as DelegateOptions } from './delegate--CrfgYtC.js';
|
|
2
2
|
import { M as MountResult } from './mount-Bo2qOx25.js';
|
|
3
3
|
import './jsx-runtime.js';
|
|
4
4
|
import '@preact/signals-core';
|
|
5
5
|
import './bindings-CYwoJpQb.js';
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
|
-
* `kerfjs/scope` —
|
|
8
|
+
* `kerfjs/scope` — group disposers with or without a DOM owner.
|
|
9
9
|
*
|
|
10
10
|
* kerf hands out disposers (`mount()` / `effect()` / `delegate()` all return
|
|
11
11
|
* `() => void`), but nothing scopes them to a subtree's lifetime — so an
|
|
@@ -13,7 +13,7 @@ import './bindings-CYwoJpQb.js';
|
|
|
13
13
|
* effects, listeners, and observers. Every such app hand-rolls the same
|
|
14
14
|
* `WeakMap<Element, disposers[]>` swept on removal. This subpath blesses it.
|
|
15
15
|
*
|
|
16
|
-
* import { disposeScope, disposeSubtree, observeRemovals } from 'kerfjs/scope';
|
|
16
|
+
* import { createScope, disposeScope, disposeSubtree, observeRemovals } from 'kerfjs/scope';
|
|
17
17
|
*
|
|
18
18
|
* const s = disposeScope(card);
|
|
19
19
|
* s.mount(card, renderCard); // mounts AND registers its disposer
|
|
@@ -27,10 +27,29 @@ import './bindings-CYwoJpQb.js';
|
|
|
27
27
|
* Or install one observer and let removals auto-dispose:
|
|
28
28
|
* observeRemovals(document.body);
|
|
29
29
|
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
30
|
+
* Use `createScope()` for a reusable group spanning several event targets.
|
|
31
|
+
* Its `signal` also removes native listeners when the group is disposed.
|
|
32
|
+
*
|
|
33
|
+
* No module-level mutable state: element scopes live in a `WeakMap` (GC-tied),
|
|
34
|
+
* standalone scopes use closure-local state, and `disposeSubtree` walks the DOM.
|
|
32
35
|
*/
|
|
33
36
|
|
|
37
|
+
/** A reusable disposer group with no DOM owner. */
|
|
38
|
+
interface DisposerScope {
|
|
39
|
+
/** Register a disposer in the current generation and return it. */
|
|
40
|
+
add(dispose: () => void): () => void;
|
|
41
|
+
/** Run the current generation newest first, then abort its signal. */
|
|
42
|
+
readonly dispose: () => void;
|
|
43
|
+
/** Signal for native listeners in the current generation. */
|
|
44
|
+
readonly signal: AbortSignal;
|
|
45
|
+
/** Number of registered disposers in the current generation. */
|
|
46
|
+
readonly size: number;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Create an element-free scope for listeners and other disposers spanning
|
|
50
|
+
* several targets. After disposal, the same scope accepts a new generation.
|
|
51
|
+
*/
|
|
52
|
+
declare function createScope(): DisposerScope;
|
|
34
53
|
/** A per-element teardown scope. Calling `disposeScope(el)` again returns the SAME scope. */
|
|
35
54
|
interface Scope {
|
|
36
55
|
/** Register any `() => void` disposer (a `mount`/`effect`/`delegate` return, a listener remover, …). Returns it. */
|
|
@@ -41,13 +60,21 @@ interface Scope {
|
|
|
41
60
|
effect(fn: () => void | (() => void)): () => void;
|
|
42
61
|
/** `delegate(...)` and register its disposer in one step. Returns the disposer. */
|
|
43
62
|
delegate<T extends Element = Element>(root: HTMLElement, type: string, selector: string, handler: (event: Event, target: T) => void, options?: DelegateOptions): () => void;
|
|
44
|
-
/**
|
|
63
|
+
/**
|
|
64
|
+
* Run every registered disposer (best-effort — a throwing one won't strand the
|
|
65
|
+
* rest). Idempotent. The handle is then dead: a later `add` / `mount` /
|
|
66
|
+
* `effect` / `delegate` on it (including one made by a disposer while this
|
|
67
|
+
* runs) tears down at once — `add(fn)` calls `fn` immediately — so nothing
|
|
68
|
+
* leaks into a scope that will never run again. `disposeScope(el)` hands out
|
|
69
|
+
* a fresh scope.
|
|
70
|
+
*/
|
|
45
71
|
dispose(): void;
|
|
46
72
|
}
|
|
47
73
|
/**
|
|
48
74
|
* Get (or create) the teardown {@link Scope} for `el`. Repeated calls for the
|
|
49
75
|
* same element return the same scope, so disparate code paths can register into
|
|
50
|
-
* one place. After `dispose()`, a later `disposeScope(el)` starts fresh
|
|
76
|
+
* one place. After `dispose()`, a later `disposeScope(el)` starts fresh, and
|
|
77
|
+
* registrations on the old (disposed) handle tear down immediately.
|
|
51
78
|
*/
|
|
52
79
|
declare function disposeScope(el: Element): Scope;
|
|
53
80
|
/**
|
|
@@ -65,4 +92,4 @@ declare function disposeSubtree(root: Element): void;
|
|
|
65
92
|
*/
|
|
66
93
|
declare function observeRemovals(root: Element): () => void;
|
|
67
94
|
|
|
68
|
-
export { type Scope, disposeScope, disposeSubtree, observeRemovals };
|
|
95
|
+
export { type DisposerScope, type Scope, createScope, disposeScope, disposeSubtree, observeRemovals };
|
package/dist/scope.js
CHANGED
|
@@ -1,48 +1,78 @@
|
|
|
1
|
-
import { mount } from './chunk-
|
|
2
|
-
import { delegate } from './chunk-
|
|
1
|
+
import { mount } from './chunk-TLL3KXT6.js';
|
|
2
|
+
import { delegate } from './chunk-JN7EJM3B.js';
|
|
3
3
|
|
|
4
4
|
|
|
5
5
|
|
|
6
|
-
import { effect } from './chunk-
|
|
6
|
+
import { effect } from './chunk-IHMDBBUJ.js';
|
|
7
7
|
|
|
8
8
|
|
|
9
9
|
|
|
10
10
|
// src/scope.ts
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
11
|
+
function createScope() {
|
|
12
|
+
let disposers = [];
|
|
13
|
+
let controller = new AbortController();
|
|
14
|
+
let active = true;
|
|
15
|
+
let disposing = false;
|
|
16
|
+
return {
|
|
17
17
|
add(dispose) {
|
|
18
18
|
disposers.push(dispose);
|
|
19
|
+
active = true;
|
|
19
20
|
return dispose;
|
|
20
21
|
},
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
return dispose;
|
|
25
|
-
},
|
|
26
|
-
effect(fn) {
|
|
27
|
-
const dispose = effect(fn);
|
|
28
|
-
disposers.push(dispose);
|
|
29
|
-
return dispose;
|
|
22
|
+
get signal() {
|
|
23
|
+
active = true;
|
|
24
|
+
return controller.signal;
|
|
30
25
|
},
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
disposers.push(dispose);
|
|
34
|
-
return dispose;
|
|
26
|
+
get size() {
|
|
27
|
+
return disposers.length;
|
|
35
28
|
},
|
|
36
29
|
dispose() {
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
30
|
+
if (!active || disposing) return;
|
|
31
|
+
active = false;
|
|
32
|
+
disposing = true;
|
|
33
|
+
const pending = disposers;
|
|
34
|
+
const oldController = controller;
|
|
35
|
+
disposers = [];
|
|
36
|
+
controller = new AbortController();
|
|
37
|
+
try {
|
|
38
|
+
for (let index = pending.length - 1; index >= 0; index--)
|
|
39
|
+
runBestEffort(pending[index]);
|
|
40
|
+
} finally {
|
|
41
|
+
oldController.abort();
|
|
42
|
+
disposing = false;
|
|
43
43
|
}
|
|
44
44
|
}
|
|
45
45
|
};
|
|
46
|
+
}
|
|
47
|
+
var scopes = /* @__PURE__ */ new WeakMap();
|
|
48
|
+
function runBestEffort(dispose) {
|
|
49
|
+
try {
|
|
50
|
+
dispose();
|
|
51
|
+
} catch {
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
function disposeScope(el) {
|
|
55
|
+
const existing = scopes.get(el);
|
|
56
|
+
if (existing !== void 0) return existing.scope;
|
|
57
|
+
const disposers = [];
|
|
58
|
+
let disposed = false;
|
|
59
|
+
const register = (dispose) => {
|
|
60
|
+
if (disposed) runBestEffort(dispose);
|
|
61
|
+
else disposers.push(dispose);
|
|
62
|
+
return dispose;
|
|
63
|
+
};
|
|
64
|
+
const scope = {
|
|
65
|
+
add: register,
|
|
66
|
+
mount: (target, render) => register(mount(target, render)),
|
|
67
|
+
effect: (fn) => register(effect(fn)),
|
|
68
|
+
delegate: (root, type, selector, handler, options) => register(delegate(root, type, selector, handler, options)),
|
|
69
|
+
dispose() {
|
|
70
|
+
if (disposed) return;
|
|
71
|
+
disposed = true;
|
|
72
|
+
scopes.delete(el);
|
|
73
|
+
for (const d of disposers.splice(0)) runBestEffort(d);
|
|
74
|
+
}
|
|
75
|
+
};
|
|
46
76
|
scopes.set(el, { scope, disposers });
|
|
47
77
|
return scope;
|
|
48
78
|
}
|
|
@@ -58,7 +88,8 @@ function observeRemovals(root) {
|
|
|
58
88
|
const observer = new MutationObserver((records) => {
|
|
59
89
|
for (const record of records) {
|
|
60
90
|
for (const node of record.removedNodes) {
|
|
61
|
-
if (node instanceof Element && !root.contains(node))
|
|
91
|
+
if (node instanceof Element && !root.contains(node))
|
|
92
|
+
disposeSubtree(node);
|
|
62
93
|
}
|
|
63
94
|
}
|
|
64
95
|
});
|
|
@@ -66,6 +97,6 @@ function observeRemovals(root) {
|
|
|
66
97
|
return () => observer.disconnect();
|
|
67
98
|
}
|
|
68
99
|
|
|
69
|
-
export { disposeScope, disposeSubtree, observeRemovals };
|
|
100
|
+
export { createScope, disposeScope, disposeSubtree, observeRemovals };
|
|
70
101
|
|
|
71
102
|
//# sourceMappingURL=scope.js.map
|
package/dist/scope.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/scope.ts"],"names":[],"mappings":";;;;;;;;;;AAyDA,IAAM,MAAA,uBAAa,OAAA,EAA6B;AAOzC,SAAS,aAAa,EAAA,EAAoB;AAC/C,EAAA,MAAM,QAAA,GAAW,MAAA,CAAO,GAAA,CAAI,EAAE,CAAA;AAC9B,EAAA,IAAI,QAAA,KAAa,MAAA,EAAW,OAAO,QAAA,CAAS,KAAA;AAE5C,EAAA,MAAM,YAA+B,EAAC;AACtC,EAAA,MAAM,KAAA,GAAe;AAAA,IACnB,IAAI,OAAA,EAAS;AACX,MAAA,SAAA,CAAU,KAAK,OAAO,CAAA;AACtB,MAAA,OAAO,OAAA;AAAA,IACT,CAAA;AAAA,IACA,KAAA,CAAM,QAAQ,MAAA,EAAQ;AACpB,MAAA,MAAM,OAAA,GAAU,KAAA,CAAM,MAAA,EAAQ,MAAM,CAAA;AACpC,MAAA,SAAA,CAAU,KAAK,OAAO,CAAA;AACtB,MAAA,OAAO,OAAA;AAAA,IACT,CAAA;AAAA,IACA,OAAO,EAAA,EAAI;AACT,MAAA,MAAM,OAAA,GAAU,OAAO,EAAE,CAAA;AACzB,MAAA,SAAA,CAAU,KAAK,OAAO,CAAA;AACtB,MAAA,OAAO,OAAA;AAAA,IACT,CAAA;AAAA,IACA,QAAA,CAAS,IAAA,EAAM,IAAA,EAAM,QAAA,EAAU,SAAS,OAAA,EAAS;AAC/C,MAAA,MAAM,UAAU,QAAA,CAAS,IAAA,EAAM,IAAA,EAAM,QAAA,EAAU,SAAS,OAAO,CAAA;AAC/D,MAAA,SAAA,CAAU,KAAK,OAAO,CAAA;AACtB,MAAA,OAAO,OAAA;AAAA,IACT,CAAA;AAAA,IACA,OAAA,GAAU;AACR,MAAA,MAAA,CAAO,OAAO,EAAE,CAAA;AAEhB,MAAA,KAAA,MAAW,CAAA,IAAK,SAAA,CAAU,MAAA,CAAO,CAAC,CAAA,EAAG;AACnC,QAAA,IAAI;AACF,UAAA,CAAA,EAAE;AAAA,QACJ,CAAA,CAAA,MAAQ;AAAA,QAER;AAAA,MACF;AAAA,IACF;AAAA,GACF;AACA,EAAA,MAAA,CAAO,GAAA,CAAI,EAAA,EAAI,EAAE,KAAA,EAAO,WAAW,CAAA;AACnC,EAAA,OAAO,KAAA;AACT;AAOO,SAAS,eAAe,IAAA,EAAqB;AAGlD,EAAA,MAAM,GAAA,GAAM,MAAA,CAAO,GAAA,CAAI,IAAI,CAAA;AAC3B,EAAA,IAAI,GAAA,KAAQ,MAAA,EAAW,GAAA,CAAI,KAAA,CAAM,OAAA,EAAQ;AACzC,EAAA,KAAA,MAAW,EAAA,IAAM,IAAA,CAAK,gBAAA,CAAiB,GAAG,CAAA,EAAG;AAC3C,IAAA,MAAM,KAAA,GAAQ,MAAA,CAAO,GAAA,CAAI,EAAE,CAAA;AAC3B,IAAA,IAAI,KAAA,KAAU,MAAA,EAAW,KAAA,CAAM,KAAA,CAAM,OAAA,EAAQ;AAAA,EAC/C;AACF;AASO,SAAS,gBAAgB,IAAA,EAA2B;AACzD,EAAA,MAAM,QAAA,GAAW,IAAI,gBAAA,CAAiB,CAAC,OAAA,KAAY;AACjD,IAAA,KAAA,MAAW,UAAU,OAAA,EAAS;AAC5B,MAAA,KAAA,MAAW,IAAA,IAAQ,OAAO,YAAA,EAAc;AACtC,QAAA,IAAI,IAAA,YAAgB,WAAW,CAAC,IAAA,CAAK,SAAS,IAAI,CAAA,iBAAkB,IAAI,CAAA;AAAA,MAC1E;AAAA,IACF;AAAA,EACF,CAAC,CAAA;AACD,EAAA,QAAA,CAAS,QAAQ,IAAA,EAAM,EAAE,WAAW,IAAA,EAAM,OAAA,EAAS,MAAM,CAAA;AACzD,EAAA,OAAO,MAAM,SAAS,UAAA,EAAW;AACnC","file":"scope.js","sourcesContent":["/**\n * `kerfjs/scope` — tie a set of disposers to a DOM element's lifetime.\n *\n * kerf hands out disposers (`mount()` / `effect()` / `delegate()` all return\n * `() => void`), but nothing scopes them to a subtree's lifetime — so an\n * append-heavy app (a feed, a list of cards) leaks detached-but-subscribed\n * effects, listeners, and observers. Every such app hand-rolls the same\n * `WeakMap<Element, disposers[]>` swept on removal. This subpath blesses it.\n *\n * import { disposeScope, disposeSubtree, observeRemovals } from 'kerfjs/scope';\n *\n * const s = disposeScope(card);\n * s.mount(card, renderCard); // mounts AND registers its disposer\n * s.effect(() => syncCard(card));\n * s.delegate(card, 'click', '.del', del);\n * s.add(() => observer.disconnect()); // any () => void disposer\n * // …when the card goes away:\n * disposeSubtree(feed); // runs every scope in feed (incl. feed)\n * feed.remove();\n *\n * Or install one observer and let removals auto-dispose:\n * observeRemovals(document.body);\n *\n * No module-level mutable state: scopes live in a `WeakMap` (GC-tied, keyed by\n * element), and `disposeSubtree` finds them by walking the subtree.\n */\nimport { delegate, type DelegateOptions } from './delegate.js';\nimport { mount, type MountResult } from './mount.js';\nimport { effect } from './reactive.js';\n\n/** A per-element teardown scope. Calling `disposeScope(el)` again returns the SAME scope. */\nexport interface Scope {\n /** Register any `() => void` disposer (a `mount`/`effect`/`delegate` return, a listener remover, …). Returns it. */\n add(dispose: () => void): () => void;\n /** `mount()` into `el` and register its disposer in one step. Returns the disposer. */\n mount(el: HTMLElement, render: () => MountResult): () => void;\n /** `effect(fn)` and register its disposer in one step. Returns the disposer. */\n effect(fn: () => void | (() => void)): () => void;\n /** `delegate(...)` and register its disposer in one step. Returns the disposer. */\n delegate<T extends Element = Element>(\n root: HTMLElement,\n type: string,\n selector: string,\n handler: (event: Event, target: T) => void,\n options?: DelegateOptions,\n ): () => void;\n /** Run every registered disposer (best-effort — a throwing one won't strand the rest) and reset. Idempotent. */\n dispose(): void;\n}\n\ninterface ScopeState {\n scope: Scope;\n disposers: Array<() => void>;\n}\n\n// GC-tied cache keyed by element — const + WeakMap, so it is exempt from the\n// \"no module-level mutable state\" rule (like bindings.ts:insertedTextNodes).\nconst scopes = new WeakMap<Element, ScopeState>();\n\n/**\n * Get (or create) the teardown {@link Scope} for `el`. Repeated calls for the\n * same element return the same scope, so disparate code paths can register into\n * one place. After `dispose()`, a later `disposeScope(el)` starts fresh.\n */\nexport function disposeScope(el: Element): Scope {\n const existing = scopes.get(el);\n if (existing !== undefined) return existing.scope;\n\n const disposers: Array<() => void> = [];\n const scope: Scope = {\n add(dispose) {\n disposers.push(dispose);\n return dispose;\n },\n mount(target, render) {\n const dispose = mount(target, render);\n disposers.push(dispose);\n return dispose;\n },\n effect(fn) {\n const dispose = effect(fn);\n disposers.push(dispose);\n return dispose;\n },\n delegate(root, type, selector, handler, options) {\n const dispose = delegate(root, type, selector, handler, options);\n disposers.push(dispose);\n return dispose;\n },\n dispose() {\n scopes.delete(el);\n // splice() empties the array AND makes a second dispose() a no-op.\n for (const d of disposers.splice(0)) {\n try {\n d();\n } catch {\n /* best-effort: a throwing disposer must not strand the rest */\n }\n }\n },\n };\n scopes.set(el, { scope, disposers });\n return scope;\n}\n\n/**\n * Dispose every scope within `root` (including `root`'s own), then leave the DOM\n * to you. Call it right before removing a subtree. Finds scopes by walking the\n * subtree against the `WeakMap` — no marker attributes are added to your DOM.\n */\nexport function disposeSubtree(root: Element): void {\n // Static NodeList snapshot — safe to dispose (which deletes WeakMap entries)\n // while iterating. Root first, then descendants in document order.\n const own = scopes.get(root);\n if (own !== undefined) own.scope.dispose();\n for (const el of root.querySelectorAll('*')) {\n const state = scopes.get(el);\n if (state !== undefined) state.scope.dispose();\n }\n}\n\n/**\n * Install a `MutationObserver` on `root` that auto-disposes a node's scope when\n * that node (or an ancestor) is permanently removed from the subtree. A node\n * moved or reordered within `root` remains live. One observer covers the whole\n * tree. Returns a disconnect function. Note: `MutationObserver` fires\n * asynchronously, so final containment and disposal run after the mutation.\n */\nexport function observeRemovals(root: Element): () => void {\n const observer = new MutationObserver((records) => {\n for (const record of records) {\n for (const node of record.removedNodes) {\n if (node instanceof Element && !root.contains(node)) disposeSubtree(node);\n }\n }\n });\n observer.observe(root, { childList: true, subtree: true });\n return () => observer.disconnect();\n}\n"]}
|
|
1
|
+
{"version":3,"sources":["../src/scope.ts"],"names":[],"mappings":";;;;;;;;;;AAiDO,SAAS,WAAA,GAA6B;AAC3C,EAAA,IAAI,YAA+B,EAAC;AACpC,EAAA,IAAI,UAAA,GAAa,IAAI,eAAA,EAAgB;AACrC,EAAA,IAAI,MAAA,GAAS,IAAA;AACb,EAAA,IAAI,SAAA,GAAY,KAAA;AAChB,EAAA,OAAO;AAAA,IACL,IAAI,OAAA,EAAS;AACX,MAAA,SAAA,CAAU,KAAK,OAAO,CAAA;AACtB,MAAA,MAAA,GAAS,IAAA;AACT,MAAA,OAAO,OAAA;AAAA,IACT,CAAA;AAAA,IACA,IAAI,MAAA,GAAS;AACX,MAAA,MAAA,GAAS,IAAA;AACT,MAAA,OAAO,UAAA,CAAW,MAAA;AAAA,IACpB,CAAA;AAAA,IACA,IAAI,IAAA,GAAO;AACT,MAAA,OAAO,SAAA,CAAU,MAAA;AAAA,IACnB,CAAA;AAAA,IACA,OAAA,GAAU;AACR,MAAA,IAAI,CAAC,UAAU,SAAA,EAAW;AAC1B,MAAA,MAAA,GAAS,KAAA;AACT,MAAA,SAAA,GAAY,IAAA;AACZ,MAAA,MAAM,OAAA,GAAU,SAAA;AAChB,MAAA,MAAM,aAAA,GAAgB,UAAA;AACtB,MAAA,SAAA,GAAY,EAAC;AACb,MAAA,UAAA,GAAa,IAAI,eAAA,EAAgB;AACjC,MAAA,IAAI;AACF,QAAA,KAAA,IAAS,KAAA,GAAQ,OAAA,CAAQ,MAAA,GAAS,CAAA,EAAG,SAAS,CAAA,EAAG,KAAA,EAAA;AAC/C,UAAA,aAAA,CAAc,OAAA,CAAQ,KAAK,CAAC,CAAA;AAAA,MAChC,CAAA,SAAE;AACA,QAAA,aAAA,CAAc,KAAA,EAAM;AACpB,QAAA,SAAA,GAAY,KAAA;AAAA,MACd;AAAA,IACF;AAAA,GACF;AACF;AAoCA,IAAM,MAAA,uBAAa,OAAA,EAA6B;AAGhD,SAAS,cAAc,OAAA,EAA2B;AAChD,EAAA,IAAI;AACF,IAAA,OAAA,EAAQ;AAAA,EACV,CAAA,CAAA,MAAQ;AAAA,EAER;AACF;AAQO,SAAS,aAAa,EAAA,EAAoB;AAC/C,EAAA,MAAM,QAAA,GAAW,MAAA,CAAO,GAAA,CAAI,EAAE,CAAA;AAC9B,EAAA,IAAI,QAAA,KAAa,MAAA,EAAW,OAAO,QAAA,CAAS,KAAA;AAE5C,EAAA,MAAM,YAA+B,EAAC;AAItC,EAAA,IAAI,QAAA,GAAW,KAAA;AACf,EAAA,MAAM,QAAA,GAAW,CAAC,OAAA,KAAsC;AACtD,IAAA,IAAI,QAAA,gBAAwB,OAAO,CAAA;AAAA,SAC9B,SAAA,CAAU,KAAK,OAAO,CAAA;AAC3B,IAAA,OAAO,OAAA;AAAA,EACT,CAAA;AACA,EAAA,MAAM,KAAA,GAAe;AAAA,IACnB,GAAA,EAAK,QAAA;AAAA,IACL,KAAA,EAAO,CAAC,MAAA,EAAQ,MAAA,KAAW,SAAS,KAAA,CAAM,MAAA,EAAQ,MAAM,CAAC,CAAA;AAAA,IACzD,QAAQ,CAAC,EAAA,KAAO,QAAA,CAAS,MAAA,CAAO,EAAE,CAAC,CAAA;AAAA,IACnC,QAAA,EAAU,CAAC,IAAA,EAAM,IAAA,EAAM,UAAU,OAAA,EAAS,OAAA,KACxC,QAAA,CAAS,QAAA,CAAS,IAAA,EAAM,IAAA,EAAM,QAAA,EAAU,OAAA,EAAS,OAAO,CAAC,CAAA;AAAA,IAC3D,OAAA,GAAU;AACR,MAAA,IAAI,QAAA,EAAU;AACd,MAAA,QAAA,GAAW,IAAA;AACX,MAAA,MAAA,CAAO,OAAO,EAAE,CAAA;AAChB,MAAA,KAAA,MAAW,KAAK,SAAA,CAAU,MAAA,CAAO,CAAC,CAAA,gBAAiB,CAAC,CAAA;AAAA,IACtD;AAAA,GACF;AACA,EAAA,MAAA,CAAO,GAAA,CAAI,EAAA,EAAI,EAAE,KAAA,EAAO,WAAW,CAAA;AACnC,EAAA,OAAO,KAAA;AACT;AAOO,SAAS,eAAe,IAAA,EAAqB;AAGlD,EAAA,MAAM,GAAA,GAAM,MAAA,CAAO,GAAA,CAAI,IAAI,CAAA;AAC3B,EAAA,IAAI,GAAA,KAAQ,MAAA,EAAW,GAAA,CAAI,KAAA,CAAM,OAAA,EAAQ;AACzC,EAAA,KAAA,MAAW,EAAA,IAAM,IAAA,CAAK,gBAAA,CAAiB,GAAG,CAAA,EAAG;AAC3C,IAAA,MAAM,KAAA,GAAQ,MAAA,CAAO,GAAA,CAAI,EAAE,CAAA;AAC3B,IAAA,IAAI,KAAA,KAAU,MAAA,EAAW,KAAA,CAAM,KAAA,CAAM,OAAA,EAAQ;AAAA,EAC/C;AACF;AASO,SAAS,gBAAgB,IAAA,EAA2B;AACzD,EAAA,MAAM,QAAA,GAAW,IAAI,gBAAA,CAAiB,CAAC,OAAA,KAAY;AACjD,IAAA,KAAA,MAAW,UAAU,OAAA,EAAS;AAC5B,MAAA,KAAA,MAAW,IAAA,IAAQ,OAAO,YAAA,EAAc;AACtC,QAAA,IAAI,IAAA,YAAgB,OAAA,IAAW,CAAC,IAAA,CAAK,SAAS,IAAI,CAAA;AAChD,UAAA,cAAA,CAAe,IAAI,CAAA;AAAA,MACvB;AAAA,IACF;AAAA,EACF,CAAC,CAAA;AACD,EAAA,QAAA,CAAS,QAAQ,IAAA,EAAM,EAAE,WAAW,IAAA,EAAM,OAAA,EAAS,MAAM,CAAA;AACzD,EAAA,OAAO,MAAM,SAAS,UAAA,EAAW;AACnC","file":"scope.js","sourcesContent":["/**\n * `kerfjs/scope` — group disposers with or without a DOM owner.\n *\n * kerf hands out disposers (`mount()` / `effect()` / `delegate()` all return\n * `() => void`), but nothing scopes them to a subtree's lifetime — so an\n * append-heavy app (a feed, a list of cards) leaks detached-but-subscribed\n * effects, listeners, and observers. Every such app hand-rolls the same\n * `WeakMap<Element, disposers[]>` swept on removal. This subpath blesses it.\n *\n * import { createScope, disposeScope, disposeSubtree, observeRemovals } from 'kerfjs/scope';\n *\n * const s = disposeScope(card);\n * s.mount(card, renderCard); // mounts AND registers its disposer\n * s.effect(() => syncCard(card));\n * s.delegate(card, 'click', '.del', del);\n * s.add(() => observer.disconnect()); // any () => void disposer\n * // …when the card goes away:\n * disposeSubtree(feed); // runs every scope in feed (incl. feed)\n * feed.remove();\n *\n * Or install one observer and let removals auto-dispose:\n * observeRemovals(document.body);\n *\n * Use `createScope()` for a reusable group spanning several event targets.\n * Its `signal` also removes native listeners when the group is disposed.\n *\n * No module-level mutable state: element scopes live in a `WeakMap` (GC-tied),\n * standalone scopes use closure-local state, and `disposeSubtree` walks the DOM.\n */\nimport { delegate, type DelegateOptions } from './delegate.js';\nimport { mount, type MountResult } from './mount.js';\nimport { effect } from './reactive.js';\n\n/** A reusable disposer group with no DOM owner. */\nexport interface DisposerScope {\n /** Register a disposer in the current generation and return it. */\n add(dispose: () => void): () => void;\n /** Run the current generation newest first, then abort its signal. */\n readonly dispose: () => void;\n /** Signal for native listeners in the current generation. */\n readonly signal: AbortSignal;\n /** Number of registered disposers in the current generation. */\n readonly size: number;\n}\n\n/**\n * Create an element-free scope for listeners and other disposers spanning\n * several targets. After disposal, the same scope accepts a new generation.\n */\nexport function createScope(): DisposerScope {\n let disposers: Array<() => void> = [];\n let controller = new AbortController();\n let active = true;\n let disposing = false;\n return {\n add(dispose) {\n disposers.push(dispose);\n active = true;\n return dispose;\n },\n get signal() {\n active = true;\n return controller.signal;\n },\n get size() {\n return disposers.length;\n },\n dispose() {\n if (!active || disposing) return;\n active = false;\n disposing = true;\n const pending = disposers;\n const oldController = controller;\n disposers = [];\n controller = new AbortController();\n try {\n for (let index = pending.length - 1; index >= 0; index--)\n runBestEffort(pending[index]);\n } finally {\n oldController.abort();\n disposing = false;\n }\n },\n };\n}\n\n/** A per-element teardown scope. Calling `disposeScope(el)` again returns the SAME scope. */\nexport interface Scope {\n /** Register any `() => void` disposer (a `mount`/`effect`/`delegate` return, a listener remover, …). Returns it. */\n add(dispose: () => void): () => void;\n /** `mount()` into `el` and register its disposer in one step. Returns the disposer. */\n mount(el: HTMLElement, render: () => MountResult): () => void;\n /** `effect(fn)` and register its disposer in one step. Returns the disposer. */\n effect(fn: () => void | (() => void)): () => void;\n /** `delegate(...)` and register its disposer in one step. Returns the disposer. */\n delegate<T extends Element = Element>(\n root: HTMLElement,\n type: string,\n selector: string,\n handler: (event: Event, target: T) => void,\n options?: DelegateOptions,\n ): () => void;\n /**\n * Run every registered disposer (best-effort — a throwing one won't strand the\n * rest). Idempotent. The handle is then dead: a later `add` / `mount` /\n * `effect` / `delegate` on it (including one made by a disposer while this\n * runs) tears down at once — `add(fn)` calls `fn` immediately — so nothing\n * leaks into a scope that will never run again. `disposeScope(el)` hands out\n * a fresh scope.\n */\n dispose(): void;\n}\n\ninterface ScopeState {\n scope: Scope;\n disposers: Array<() => void>;\n}\n\n// GC-tied cache keyed by element — const + WeakMap, so it is exempt from the\n// \"no module-level mutable state\" rule (like bindings.ts:insertedTextNodes).\nconst scopes = new WeakMap<Element, ScopeState>();\n\n/** Run a disposer; a throwing one must not strand the rest. */\nfunction runBestEffort(dispose: () => void): void {\n try {\n dispose();\n } catch {\n /* best-effort */\n }\n}\n\n/**\n * Get (or create) the teardown {@link Scope} for `el`. Repeated calls for the\n * same element return the same scope, so disparate code paths can register into\n * one place. After `dispose()`, a later `disposeScope(el)` starts fresh, and\n * registrations on the old (disposed) handle tear down immediately.\n */\nexport function disposeScope(el: Element): Scope {\n const existing = scopes.get(el);\n if (existing !== undefined) return existing.scope;\n\n const disposers: Array<() => void> = [];\n // Once disposed (including DURING dispose(), from a disposer), the handle is\n // dead: a registration tears down at once instead of landing in an orphaned\n // array nothing will ever run — a leak (KF-DFGD1R).\n let disposed = false;\n const register = (dispose: () => void): (() => void) => {\n if (disposed) runBestEffort(dispose);\n else disposers.push(dispose);\n return dispose;\n };\n const scope: Scope = {\n add: register,\n mount: (target, render) => register(mount(target, render)),\n effect: (fn) => register(effect(fn)),\n delegate: (root, type, selector, handler, options) =>\n register(delegate(root, type, selector, handler, options)),\n dispose() {\n if (disposed) return;\n disposed = true;\n scopes.delete(el);\n for (const d of disposers.splice(0)) runBestEffort(d);\n },\n };\n scopes.set(el, { scope, disposers });\n return scope;\n}\n\n/**\n * Dispose every scope within `root` (including `root`'s own), then leave the DOM\n * to you. Call it right before removing a subtree. Finds scopes by walking the\n * subtree against the `WeakMap` — no marker attributes are added to your DOM.\n */\nexport function disposeSubtree(root: Element): void {\n // Static NodeList snapshot — safe to dispose (which deletes WeakMap entries)\n // while iterating. Root first, then descendants in document order.\n const own = scopes.get(root);\n if (own !== undefined) own.scope.dispose();\n for (const el of root.querySelectorAll('*')) {\n const state = scopes.get(el);\n if (state !== undefined) state.scope.dispose();\n }\n}\n\n/**\n * Install a `MutationObserver` on `root` that auto-disposes a node's scope when\n * that node (or an ancestor) is permanently removed from the subtree. A node\n * moved or reordered within `root` remains live. One observer covers the whole\n * tree. Returns a disconnect function. Note: `MutationObserver` fires\n * asynchronously, so final containment and disposal run after the mutation.\n */\nexport function observeRemovals(root: Element): () => void {\n const observer = new MutationObserver((records) => {\n for (const record of records) {\n for (const node of record.removedNodes) {\n if (node instanceof Element && !root.contains(node))\n disposeSubtree(node);\n }\n }\n });\n observer.observe(root, { childList: true, subtree: true });\n return () => observer.disconnect();\n}\n"]}
|
package/dist/testing.js
CHANGED
package/dist/timing.d.ts
CHANGED
|
@@ -35,7 +35,7 @@ interface Throttled<A extends unknown[]> {
|
|
|
35
35
|
(...args: A): void;
|
|
36
36
|
/** Drop any pending trailing call and reset the rate window, including from inside `fn`. */
|
|
37
37
|
cancel(): void;
|
|
38
|
-
/** Invoke the pending trailing call now (if any). */
|
|
38
|
+
/** Invoke the pending trailing call now (if any); the current cooldown keeps running. */
|
|
39
39
|
flush(): void;
|
|
40
40
|
}
|
|
41
41
|
/**
|
|
@@ -49,7 +49,8 @@ declare function debounce<A extends unknown[]>(fn: (...args: A) => void, ms: num
|
|
|
49
49
|
* at most once per `ms`. Calls during a cooldown collapse to a single trailing
|
|
50
50
|
* call at the window's end (with the latest arguments). `cancel()` drops a
|
|
51
51
|
* pending trailing call and resets the window, even when called by a leading
|
|
52
|
-
* or trailing callback; `flush()` runs a pending trailing call now
|
|
52
|
+
* or trailing callback; `flush()` runs a pending trailing call now but leaves
|
|
53
|
+
* the current cooldown running (unlike `debounce`'s `flush()`).
|
|
53
54
|
*/
|
|
54
55
|
declare function throttle<A extends unknown[]>(fn: (...args: A) => void, ms: number): Throttled<A>;
|
|
55
56
|
/**
|
package/dist/timing.js
CHANGED
package/dist/timing.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/timing.ts"],"names":[],"mappings":";;;;AA8CO,SAAS,QAAA,
|
|
1
|
+
{"version":3,"sources":["../src/timing.ts"],"names":[],"mappings":";;;;AA8CO,SAAS,QAAA,CACd,IACA,EAAA,EACc;AACd,EAAA,IAAI,KAAA;AACJ,EAAA,IAAI,QAAA;AAEJ,EAAA,MAAM,SAAS,MAAY;AACzB,IAAA,KAAA,GAAQ,MAAA;AACR,IAAA,MAAM,IAAA,GAAO,QAAA;AACb,IAAA,QAAA,GAAW,MAAA;AACX,IAAA,EAAA,CAAG,GAAG,IAAI,CAAA;AAAA,EACZ,CAAA;AAEA,EAAA,MAAM,SAAA,IAAa,IAAI,IAAA,KAAkB;AACvC,IAAA,QAAA,GAAW,IAAA;AACX,IAAA,IAAI,KAAA,KAAU,MAAA,EAAW,YAAA,CAAa,KAAK,CAAA;AAC3C,IAAA,KAAA,GAAQ,UAAA,CAAW,QAAQ,EAAE,CAAA;AAAA,EAC/B,CAAA,CAAA;AAEA,EAAA,SAAA,CAAU,SAAS,MAAY;AAC7B,IAAA,IAAI,KAAA,KAAU,MAAA,EAAW,YAAA,CAAa,KAAK,CAAA;AAC3C,IAAA,KAAA,GAAQ,MAAA;AACR,IAAA,QAAA,GAAW,MAAA;AAAA,EACb,CAAA;AAEA,EAAA,SAAA,CAAU,QAAQ,MAAY;AAC5B,IAAA,IAAI,UAAU,MAAA,EAAW;AACvB,MAAA,YAAA,CAAa,KAAK,CAAA;AAClB,MAAA,MAAA,EAAO;AAAA,IACT;AAAA,EACF,CAAA;AAEA,EAAA,OAAO,SAAA;AACT;AAUO,SAAS,QAAA,CACd,IACA,EAAA,EACc;AACd,EAAA,IAAI,KAAA;AACJ,EAAA,IAAI,YAAA;AAEJ,EAAA,MAAM,cAAc,MAAY;AAC9B,IAAA,MAAM,IAAA,GAAO,YAAA;AACb,IAAA,YAAA,GAAe,MAAA;AACf,IAAA,EAAA,CAAG,GAAG,IAAI,CAAA;AAAA,EACZ,CAAA;AAEA,EAAA,MAAM,gBAAgB,MAAY;AAChC,IAAA,KAAA,GAAQ,WAAW,MAAM;AACvB,MAAA,KAAA,GAAQ,MAAA;AACR,MAAA,IAAI,iBAAiB,MAAA,EAAW;AAI9B,QAAA,aAAA,EAAc;AACd,QAAA,WAAA,EAAY;AAAA,MACd;AAAA,IACF,GAAG,EAAE,CAAA;AAAA,EACP,CAAA;AAEA,EAAA,MAAM,SAAA,IAAa,IAAI,IAAA,KAAkB;AACvC,IAAA,IAAI,UAAU,MAAA,EAAW;AAGvB,MAAA,aAAA,EAAc;AACd,MAAA,EAAA,CAAG,GAAG,IAAI,CAAA;AAAA,IACZ,CAAA,MAAO;AACL,MAAA,YAAA,GAAe,IAAA;AAAA,IACjB;AAAA,EACF,CAAA,CAAA;AAEA,EAAA,SAAA,CAAU,SAAS,MAAY;AAC7B,IAAA,IAAI,KAAA,KAAU,MAAA,EAAW,YAAA,CAAa,KAAK,CAAA;AAC3C,IAAA,KAAA,GAAQ,MAAA;AACR,IAAA,YAAA,GAAe,MAAA;AAAA,EACjB,CAAA;AAEA,EAAA,SAAA,CAAU,QAAQ,MAAY;AAC5B,IAAA,IAAI,YAAA,KAAiB,QAAW,WAAA,EAAY;AAAA,EAC9C,CAAA;AAEA,EAAA,OAAO,SAAA;AACT;AAWO,SAAS,eAAA,CACd,QACA,EAAA,EACmB;AACnB,EAAA,MAAM,GAAA,GAAM,MAAA,CAAO,MAAA,CAAO,KAAK,CAAA;AAC/B,EAAA,MAAM,KAAA,GAAQ,QAAA,CAAS,CAAC,KAAA,KAAa;AACnC,IAAA,GAAA,CAAI,KAAA,GAAQ,KAAA;AAAA,EACd,GAAG,EAAE,CAAA;AACL,EAAA,MAAA,CAAO,MAAM;AACX,IAAA,KAAA,CAAM,OAAO,KAAK,CAAA;AAAA,EACpB,CAAC,CAAA;AACD,EAAA,OAAO,GAAA;AACT","file":"timing.js","sourcesContent":["/**\n * `kerfjs/timing` — the small timing primitives every app hand-rolls.\n *\n * kerf already replaced most imperative bookkeeping — `delegate` for listeners,\n * `mount`/`effect` for render, `defineStore` for state — but debouncing and\n * throttling still get written by hand as `let timer; clearTimeout(timer);\n * timer = setTimeout(fn, ms)`. This subpath blesses that with disposer-shaped\n * ergonomics (`.cancel()` / `.flush()`), plus `debouncedSignal` so a trailing\n * value composes inside the reactive graph instead of beside it.\n *\n * import { debounce, throttle, debouncedSignal } from 'kerfjs/timing';\n *\n * const save = debounce(() => persist(state), 300);\n * input.addEventListener('input', save); // save.cancel() on teardown\n *\n * const query = signal('');\n * const debouncedQuery = debouncedSignal(query, 250); // trails query by 250ms\n *\n * Tree-shakeable and tiny — `debounce`/`throttle` are dependency-free; only\n * `debouncedSignal` pulls in signals (no render core).\n */\nimport { effect, type ReadonlySignal, signal } from './reactive.js';\n\n/** A debounced function: call it like the original, plus `cancel()` / `flush()`. */\nexport interface Debounced<A extends unknown[]> {\n (...args: A): void;\n /** Drop any pending trailing call without invoking it. */\n cancel(): void;\n /** Invoke the pending trailing call now (if any) and clear the timer. */\n flush(): void;\n}\n\n/** A throttled function: call it like the original, plus `cancel()` / `flush()`. */\nexport interface Throttled<A extends unknown[]> {\n (...args: A): void;\n /** Drop any pending trailing call and reset the rate window, including from inside `fn`. */\n cancel(): void;\n /** Invoke the pending trailing call now (if any); the current cooldown keeps running. */\n flush(): void;\n}\n\n/**\n * Trailing-edge debounce: `fn` runs `ms` after calls STOP, with the most recent\n * arguments. Every call within the quiet window resets the timer. `cancel()`\n * drops a pending call; `flush()` runs it immediately.\n */\nexport function debounce<A extends unknown[]>(\n fn: (...args: A) => void,\n ms: number,\n): Debounced<A> {\n let timer: ReturnType<typeof setTimeout> | undefined;\n let lastArgs: A | undefined;\n\n const invoke = (): void => {\n timer = undefined;\n const args = lastArgs as A;\n lastArgs = undefined;\n fn(...args);\n };\n\n const debounced = ((...args: A): void => {\n lastArgs = args;\n if (timer !== undefined) clearTimeout(timer);\n timer = setTimeout(invoke, ms);\n }) as Debounced<A>;\n\n debounced.cancel = (): void => {\n if (timer !== undefined) clearTimeout(timer);\n timer = undefined;\n lastArgs = undefined;\n };\n\n debounced.flush = (): void => {\n if (timer !== undefined) {\n clearTimeout(timer);\n invoke();\n }\n };\n\n return debounced;\n}\n\n/**\n * Leading-plus-trailing throttle: `fn` runs immediately on the first call, then\n * at most once per `ms`. Calls during a cooldown collapse to a single trailing\n * call at the window's end (with the latest arguments). `cancel()` drops a\n * pending trailing call and resets the window, even when called by a leading\n * or trailing callback; `flush()` runs a pending trailing call now but leaves\n * the current cooldown running (unlike `debounce`'s `flush()`).\n */\nexport function throttle<A extends unknown[]>(\n fn: (...args: A) => void,\n ms: number,\n): Throttled<A> {\n let timer: ReturnType<typeof setTimeout> | undefined;\n let trailingArgs: A | undefined;\n\n const runTrailing = (): void => {\n const args = trailingArgs as A;\n trailingArgs = undefined;\n fn(...args);\n };\n\n const startCooldown = (): void => {\n timer = setTimeout(() => {\n timer = undefined;\n if (trailingArgs !== undefined) {\n // Install the next window before invoking user code. A trailing\n // callback can therefore cancel that window, and reentrant calls stay\n // throttled unless the callback explicitly resets it first.\n startCooldown();\n runTrailing();\n }\n }, ms);\n };\n\n const throttled = ((...args: A): void => {\n if (timer === undefined) {\n // Make the pending window visible before invoking user code so\n // `cancel()` from inside a leading callback remains effective.\n startCooldown();\n fn(...args); // leading edge\n } else {\n trailingArgs = args; // collapse into one trailing call\n }\n }) as Throttled<A>;\n\n throttled.cancel = (): void => {\n if (timer !== undefined) clearTimeout(timer);\n timer = undefined;\n trailingArgs = undefined;\n };\n\n throttled.flush = (): void => {\n if (trailingArgs !== undefined) runTrailing();\n };\n\n return throttled;\n}\n\n/**\n * A read-only signal that trails `source` by `ms` (trailing-edge). Writes to\n * `source` reschedule; the derived value updates once writes go quiet, so it\n * composes with `computed()`/`effect()`/`mount()` like any signal.\n *\n * Holds a live subscription to `source` for its lifetime (like a module-scope\n * `effect`) — intended for app-lifetime signals, not throwaway ones. For a\n * disposable variant, drive your own `effect` with {@link debounce}.\n */\nexport function debouncedSignal<T>(\n source: ReadonlySignal<T>,\n ms: number,\n): ReadonlySignal<T> {\n const out = signal(source.value);\n const write = debounce((value: T) => {\n out.value = value;\n }, ms);\n effect(() => {\n write(source.value); // tracks source; reschedules on every change\n });\n return out;\n}\n"]}
|
package/llms.txt
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# kerf
|
|
2
2
|
|
|
3
|
-
> A tiny (~
|
|
3
|
+
> A tiny (~13 KB minified + gzipped including its one runtime dependency `@preact/signals-core`; still ~13 KB with `arraySignal`) reactive UI framework — fine-grained signals + DOM morphing + JSX. No virtual DOM, no compiler. Apply the smallest possible cut to update your DOM.
|
|
4
4
|
|
|
5
5
|
kerf renders JSX to a structured `SafeHtml` (string for static content; tagged "list"/"mixed" segments where `each(...)` was used) and reconciles it against the live tree with a custom segment-aware morph. Static surrounds go through a general-purpose tree-morph; list contents go through a keyed reconciler that operates directly on live children. Snapshot reconciliation scans O(rows) but limits rendering and DOM mutations to cache misses and structural changes; eligible `arraySignal` patch delivery runs in O(patches). Reactivity is provided by [@preact/signals-core](https://github.com/preactjs/signals). It pairs well with server-rendered HTML, embedded widgets, and any UI where preserving focus / selection across re-renders matters. Public API is one import: `signal`, `computed`, `effect`, `batch`, `defineStore`, `resetAllStores`, `mount`, `morph`, `each`, `attr`, `delegate`, `delegateCapture`, `toElement`, `renderDocument`, `SafeHtml`, `isSafeHtml`, `raw`, `Fragment`. (Two more subpaths: `kerfjs/testing` exposes `clearStoreRegistry` for unit-test isolation; `kerfjs/jsx-runtime` exposes the typed JSX building blocks for declaration-merging custom-element types.) An optional subpath at `kerfjs/array-signal` adds `arraySignal()` — a granular keyed-list signal whose patch events let `each()` reconcile in O(patches) instead of O(N). An optional subpath at `kerfjs/dev` installs the development diagnostics — kerf does NOT infer dev mode, so you import it behind your own build's dev flag (`if (import.meta.env.DEV) await import('kerfjs/dev');`); omitting it is production and sheds ~4.7 KB min+gzip. Another optional subpath at `kerfjs/html` adds the `html` tagged template — JSX-identical runtime semantics with no JSX transform, so CDN/importmap projects can author kerf UIs with literally no build step. A family of optional, tree-shakeable **companion-utility** subpaths cover patterns real apps hand-roll: `kerfjs/list` (`bindList` — a keyed list with per-row fine-grained mounts and fixed / declared / measured-height viewport virtualization, plus `observeRowHeights`), `kerfjs/router` (`createRouter` — the opt-in "postcard router": route matching + `navigate` + `<a>` link interception + a keyed outlet, core stays router-free), `kerfjs/overlay` (`overlay` / `confirm` / `prompt` / `form` / `choice` / `popover` / `tooltip` / `toast` + `positionAnchored` / `autoReposition`; `native: true` top-layer backing via `<dialog>` / the Popover API applies to the seven overlay/dialog/popover/tooltip surfaces), `kerfjs/scope` (`disposeScope` / `disposeSubtree` / `observeRemovals`), `kerfjs/async` (`resource` async-state with a stale-response guard + SWR cache), `kerfjs/timing` (`debounce` / `throttle` / `debouncedSignal`), `kerfjs/remount` (`remountOn` — key-driven wholesale subtree replacement), `kerfjs/attach` (`attach` — bind a non-kerf widget's lifecycle to one node), and `kerfjs/actions` (`action` / `delegateActions` — the delegated `data-action` table idiom).
|
|
6
6
|
|
|
@@ -10,13 +10,17 @@ kerf renders JSX to a structured `SafeHtml` (string for static content; tagged "
|
|
|
10
10
|
|
|
11
11
|
## For AI assistants
|
|
12
12
|
|
|
13
|
+
- One-step project setup: run `npx kerfjs setup` for a safe dry-run, then
|
|
14
|
+
`npx kerfjs setup --write --yes`; see
|
|
15
|
+
[AI-first setup](https://github.com/brianwestphal/kerf/blob/main/docs/24-ai-first-setup.md).
|
|
16
|
+
|
|
13
17
|
- [AI usage guide](https://github.com/brianwestphal/kerf/blob/main/docs/ai/usage-guide.md): when to recommend kerf, the full public API, the four core patterns, hard rules, and a common-errors table.
|
|
14
18
|
- [Code summary](https://github.com/brianwestphal/kerf/blob/main/docs/ai/code-summary.md): directory tree + reverse index of every public export.
|
|
15
19
|
- [Requirements summary](https://github.com/brianwestphal/kerf/blob/main/docs/ai/requirements-summary.md): synthesized view of the numbered design docs.
|
|
16
20
|
- [`kerf.cursorrules`](https://github.com/brianwestphal/kerf/blob/main/kerf.cursorrules): drop-in [Cursor](https://cursor.sh) rules for projects that import `kerfjs`. Copy into your project as `.cursorrules` — or use the bundled mirror at `node_modules/kerfjs/ai/cursorrules` once you've `npm install`ed kerfjs.
|
|
17
21
|
- [`kerf.claude-skill.md`](https://github.com/brianwestphal/kerf/blob/main/kerf.claude-skill.md): drop-in [Claude Code](https://claude.com/claude-code) skill. Copy into `~/.claude/skills/kerf-app/SKILL.md` (or your project's `.claude/skills/kerf-app/SKILL.md`) — or use the bundled mirror at `node_modules/kerfjs/ai/skill.md` once you've `npm install`ed kerfjs.
|
|
18
|
-
- [`eslint-plugin-kerfjs`](https://github.com/brianwestphal/kerf/blob/main/eslint-plugin/README.md): companion ESLint plugin enforcing the hard rules at edit time — eight rules
|
|
19
|
-
- [`create-kerf-component`](https://github.com/brianwestphal/kerf/blob/main/create-kerf-component/README.md): companion initializer that scaffolds a publishable kerf component package with the hard packaging rules
|
|
22
|
+
- [`eslint-plugin-kerfjs`](https://github.com/brianwestphal/kerf/blob/main/eslint-plugin/README.md): companion ESLint plugin enforcing the hard rules at edit time — eight AST-only core rules in `kerfjs.configs.recommended`: `no-inline-jsx-event-handlers`, `require-data-key-in-each`, `no-nested-mount`, `prefer-module-jsx-augmentation` (error) plus `require-delegate-disposer`, `prefer-attr-selector`, `no-raw-with-dynamic-arg`, `ai-assistant-configs` (warn), with no `parserServices` dependency; plus six `@kerfjs/ui` catalog-contract rules (`ui-component-ownership`, `ui-composition`, `ui-css-values`, `ui-preferences`, `ui-public-boundaries`, `ui-wiring`) in the `recommended-ui` / `strict-ui` configs. Install with `npm install --save-dev eslint-plugin-kerfjs` and add `kerfjs.configs.recommended` to your eslint config. Recommended when authoring kerf code with an AI assistant — eslint feedback surfaces in the IDE before `tsc` or runtime warns ever run.
|
|
23
|
+
- [`create-kerf-component`](https://github.com/brianwestphal/kerf/blob/main/create-kerf-component/README.md): companion initializer that scaffolds a publishable kerf component package with the hard packaging rules, an example component, and explicit `kerf.components.json` source metadata plus deterministic `component-composition.json` generation/checking. Run `npm create kerf-component@latest <dir>`.
|
|
20
24
|
- [`@kerfjs/ui` AI guide](https://github.com/brianwestphal/kerf/blob/main/ui/ai/skill.md): component-selection, styling, accessibility, and event-wiring contract for the optional first-party UI package.
|
|
21
25
|
|
|
22
26
|
## Reference docs
|
|
@@ -34,7 +38,7 @@ kerf renders JSX to a structured `SafeHtml` (string for static content; tagged "
|
|
|
34
38
|
- [Dev-mode warnings](https://github.com/brianwestphal/kerf/blob/main/docs/11-dev-warnings.md): the opt-in, switch-gated `KERF_DEV_WARN_*` family (environment variables in Node/CI or `enableWarnings()` in browser builds), plus the always-on dev guards and `KERF_DEV_INVARIANTS` structural checks; diagnostics are installed through `kerfjs/dev`, never inferred from the environment.
|
|
35
39
|
- [AI-assistant configs](https://github.com/brianwestphal/kerf/blob/main/docs/12-ai-assistant-configs.md): how the drop-in Claude Code skill + Cursor rules ship inside the `kerfjs` npm package at `ai/skill.md` / `ai/cursorrules` / `ai/manifest.json`, the version + marker contract for customization preservation, and the `kerfjs/ai-assistant-configs` ESLint rule that surfaces drift on every lint pass.
|
|
36
40
|
- [Component packages](https://github.com/brianwestphal/kerf/blob/main/docs/13-component-packages.md): building and publishing reusable kerf components as npm packages — the no-instance component model, per-instance state via factories, event/cleanup patterns, and `kerfjs`-as-peer-dependency packaging modeled on `eslint-plugin-kerfjs`. Scaffold one with `npm create kerf-component@latest <dir>` (the `create-kerf-component` initializer).
|
|
37
|
-
- [Feature coverage](https://github.com/brianwestphal/kerf/blob/main/docs/14-feature-coverage.md): the per-behavior coverage axis orthogonal to line coverage — an index mapping each behavior (especially list-reconciler
|
|
41
|
+
- [Feature coverage](https://github.com/brianwestphal/kerf/blob/main/docs/14-feature-coverage.md): the per-behavior coverage axis orthogonal to line coverage — an index mapping each behavior (especially list-reconciler _state transitions_) to its guarding test, enforced by `npm run check:features`.
|
|
38
42
|
- [No-build example](https://github.com/brianwestphal/kerf/blob/main/docs/15-no-build-example.md): the served-as-source `live-poll` example app — plain JS + importmap + the `html` tagged template, zero tooling — and the vendor-copy contract that ships it.
|
|
39
43
|
- [List identity](https://github.com/brianwestphal/kerf/blob/main/docs/16-list-identity.md): why an `each()` list's call-order identity is not stable, what the source guard fixes and what it doesn't, the five constraints any scheme must survive, and the explicit-key recommendation now shipped as `each(items, render, { key })`.
|
|
40
44
|
- [List virtualization](https://github.com/brianwestphal/kerf/blob/main/docs/17-list-virtualization.md): `bindList`'s virtualization — the `window` (default) height models (fixed `number`, app-declared `(item, index) => number`, and measured `{ estimate }` + `setHeight`) with kerf owning the cumulative-offset math and scroll anchoring while the app owns measurement (the `observeRowHeights` helper), the `minRows` render-all threshold and container/resize ergonomics, and the `content-visibility` mode that keeps every row in the DOM (full find-in-page / a11y) while the browser skips off-screen layout, plus the findability/a11y tradeoff of the default `window` mode.
|
|
@@ -43,6 +47,9 @@ kerf renders JSX to a structured `SafeHtml` (string for static content; tagged "
|
|
|
43
47
|
- [Router](https://github.com/brianwestphal/kerf/blob/main/docs/20-router.md): the opt-in, tree-shakeable `kerfjs/router` subpath — the "postcard router". `createRouter({ routes, mode?, base?, interceptLinks? })` → a reactive `route` signal, `navigate`/`back`/`forward`, `match`/`activeClass` active-link helpers, a keyed `outlet()` (a route change swaps the page wholesale, a same-route param change morphs in place), and `dispose()`. Route matching (`:param` / `*rest` / `*`), `delegate()`-based `<a href>` interception, history + hash modes, optional base. The core stays router-free (docs/1's "Not a router" is about the runtime); deliberately excludes nested layouts / loaders / lazy routes / guards / SSR.
|
|
44
48
|
- [`@kerfjs/ui`](https://github.com/brianwestphal/kerf/blob/main/docs/21-ui-package.md): the first-party component package, semantic CSS tokens, accessibility and keyboard contracts, explicit Web Awesome registration, UX catalog, AI surfaces, and lockstep release model.
|
|
45
49
|
- [UI CSS authoring](https://github.com/brianwestphal/kerf/blob/main/docs/22-ui-css-authoring.md): pixel-first `remify(<px>)` source syntax, fixed 16px conversion baseline, compiled `rem` package output, and Vite HMR behavior.
|
|
50
|
+
- [App and dialog layouts](https://github.com/brianwestphal/kerf/blob/main/docs/23-app-layouts.md): responsive whole-screen `@kerfjs/ui` layouts, the shared device-class foundation, controlled state model, and layout-selection guidance.
|
|
51
|
+
- [AI-first project setup](https://github.com/brianwestphal/kerf/blob/main/docs/24-ai-first-setup.md): dry-run-first `npx kerfjs setup`, deterministic workspace discovery, explicit conflict resolution, transactional writes, and the automation API.
|
|
52
|
+
- [Ticket phase timing](https://github.com/brianwestphal/kerf/blob/main/docs/25-ticket-timing.md): structured Hot Sheet phase intervals and process-review summaries without raw output, secrets, or machine-specific paths.
|
|
46
53
|
|
|
47
54
|
## Examples
|
|
48
55
|
|