@abide/abide 0.41.0 → 0.42.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/AGENTS.md +333 -326
  2. package/CHANGELOG.md +92 -0
  3. package/README.md +91 -83
  4. package/package.json +1 -1
  5. package/src/abideLsp.ts +46 -8
  6. package/src/abideResolverPlugin.ts +3 -5
  7. package/src/lib/server/runtime/devClientFingerprint.ts +2 -1
  8. package/src/lib/shared/escapeRegex.ts +9 -0
  9. package/src/lib/shared/fileName.ts +9 -0
  10. package/src/lib/shared/fileStem.ts +3 -1
  11. package/src/lib/shared/programNameForPackage.ts +3 -1
  12. package/src/lib/shared/stripImport.ts +3 -1
  13. package/src/lib/ui/compile/ABIDE_SEMANTIC_TOKENS_LEGEND.ts +45 -0
  14. package/src/lib/ui/compile/SSR_ESCAPE.ts +3 -1
  15. package/src/lib/ui/compile/abideUiPlugin.ts +2 -1
  16. package/src/lib/ui/compile/compileShadow.ts +46 -9
  17. package/src/lib/ui/compile/createShadowLanguageService.ts +48 -0
  18. package/src/lib/ui/compile/encodeSemanticTokens.ts +37 -0
  19. package/src/lib/ui/compile/generateBuild.ts +13 -6
  20. package/src/lib/ui/compile/generateSSR.ts +23 -14
  21. package/src/lib/ui/compile/makeVarNamer.ts +10 -0
  22. package/src/lib/ui/compile/offsetToPosition.ts +9 -0
  23. package/src/lib/ui/compile/parseTemplate.ts +524 -104
  24. package/src/lib/ui/compile/skeletonContext.ts +67 -79
  25. package/src/lib/ui/compile/structuralBlockTokens.ts +101 -0
  26. package/src/lib/ui/compile/templateAnchorAdapter.ts +61 -0
  27. package/src/lib/ui/compile/templateElementAdapter.ts +44 -0
  28. package/src/lib/ui/compile/types/SemanticToken.ts +11 -0
  29. package/src/lib/ui/compile/types/TemplateNode.ts +9 -0
  30. package/src/lib/ui/compile/walkAnchorOrder.ts +57 -0
  31. package/src/lib/ui/compile/walkElementOrder.ts +60 -0
  32. package/src/lib/ui/dom/appendSnippet.ts +9 -8
  33. package/src/lib/ui/dom/appendText.ts +1 -5
  34. package/src/lib/ui/dom/applyResolved.ts +4 -9
  35. package/src/lib/ui/dom/awaitBlock.ts +37 -31
  36. package/src/lib/ui/dom/buildDetachedRange.ts +30 -0
  37. package/src/lib/ui/dom/depthZeroNodes.ts +34 -0
  38. package/src/lib/ui/dom/domAnchorAdapter.ts +29 -0
  39. package/src/lib/ui/dom/domElementAdapter.ts +20 -0
  40. package/src/lib/ui/dom/each.ts +7 -11
  41. package/src/lib/ui/dom/eachAsync.ts +12 -17
  42. package/src/lib/ui/dom/isComment.ts +6 -0
  43. package/src/lib/ui/dom/isElement.ts +6 -0
  44. package/src/lib/ui/dom/markerDepthDelta.ts +19 -0
  45. package/src/lib/ui/dom/mountRange.ts +4 -3
  46. package/src/lib/ui/dom/mountSlot.ts +4 -3
  47. package/src/lib/ui/dom/on.ts +6 -1
  48. package/src/lib/ui/dom/replaceRange.ts +24 -0
  49. package/src/lib/ui/dom/skeleton.ts +35 -92
  50. package/src/lib/ui/dom/switchBlock.ts +13 -10
  51. package/src/lib/ui/dom/when.ts +13 -10
  52. package/src/lib/ui/runtime/RANGE_MARKER.ts +16 -0
  53. package/src/lib/ui/runtime/batch.ts +22 -0
  54. package/src/lib/ui/runtime/clientPage.ts +3 -8
  55. package/src/lib/ui/runtime/createDoc.ts +12 -16
  56. package/src/lib/ui/runtime/pathSegments.ts +10 -0
  57. package/src/lib/ui/seedResolved.ts +28 -0
  58. package/src/lib/ui/startClient.ts +6 -4
  59. package/src/lib/ui/types/ResolvedFrame.ts +15 -0
  60. package/template/.zed/settings.json +4 -0
  61. package/template/src/ui/pages/page.abide +4 -4
package/CHANGELOG.md CHANGED
@@ -1,5 +1,97 @@
1
1
  # abide
2
2
 
3
+ ## 0.42.0
4
+
5
+ ### Minor Changes
6
+
7
+ - [`568956c`](https://github.com/briancray/abide/commit/568956c88ff1d32ca79a2aa8a1a0ffffeb25afe3) - reject stray {:…}/{/…} tokens outside a block ([`1b5d54e`](https://github.com/briancray/abide/commit/1b5d54e7798acfa7c9b15ce6b65b0c2b6a8c2c07))
8
+
9
+ - [`568956c`](https://github.com/briancray/abide/commit/568956c88ff1d32ca79a2aa8a1a0ffffeb25afe3) - parse {#switch}/{:case}/{:default} blocks to the switch AST ([`448da38`](https://github.com/briancray/abide/commit/448da3840c120ac3b15d239c090f123c25527fec))
10
+
11
+ - [`568956c`](https://github.com/briancray/abide/commit/568956c88ff1d32ca79a2aa8a1a0ffffeb25afe3) - parse {#for … of … by …} blocks to the each AST ([`7bb2ac8`](https://github.com/briancray/abide/commit/7bb2ac8f3012e3d40cfcbe8ddf277d6a8732ee93))
12
+
13
+ - [`568956c`](https://github.com/briancray/abide/commit/568956c88ff1d32ca79a2aa8a1a0ffffeb25afe3) - LSP semantic-token encoder + extract offsetToPosition ([`7daeb92`](https://github.com/briancray/abide/commit/7daeb926f045058aa452b5f539ddd75b0d8efa67))
14
+
15
+ - [`568956c`](https://github.com/briancray/abide/commit/568956c88ff1d32ca79a2aa8a1a0ffffeb25afe3) - semantic-token legend + TS-classification mapping for abide lsp ([`892f97b`](https://github.com/briancray/abide/commit/892f97bbc6dfd8af9301297276995f7060fe3f9a))
16
+
17
+ - [`568956c`](https://github.com/briancray/abide/commit/568956c88ff1d32ca79a2aa8a1a0ffffeb25afe3) - type-aware semanticClassifications on the shadow language service ([`9377dd2`](https://github.com/briancray/abide/commit/9377dd24d855cde790d960fceef00255904ee926))
18
+
19
+ - [`568956c`](https://github.com/briancray/abide/commit/568956c88ff1d32ca79a2aa8a1a0ffffeb25afe3) - block control-flow grammar formatting + shared helpers ([`b39c0a7`](https://github.com/briancray/abide/commit/b39c0a70696b031b63ef157c67360c2da0fd60b0))
20
+
21
+ - [`568956c`](https://github.com/briancray/abide/commit/568956c88ff1d32ca79a2aa8a1a0ffffeb25afe3) - zed onboarding — auto-install extension + enable semantic tokens ([`b47acc7`](https://github.com/briancray/abide/commit/b47acc7cc106956f115c70b110c29ce017a71cd0))
22
+
23
+ - [`568956c`](https://github.com/briancray/abide/commit/568956c88ff1d32ca79a2aa8a1a0ffffeb25afe3) - parse {#if}/{:else if}/{:else} blocks to the if AST ([`b662397`](https://github.com/briancray/abide/commit/b66239765bae2ec7a24d70bc7b2edbcbe400614c))
24
+
25
+ - [`568956c`](https://github.com/briancray/abide/commit/568956c88ff1d32ca79a2aa8a1a0ffffeb25afe3) - parse {#try}/{:catch}/{:finally} blocks to the try AST ([`b82c3dd`](https://github.com/briancray/abide/commit/b82c3ddaaec3db3941639df952e568f017d6c767))
26
+
27
+ - [`568956c`](https://github.com/briancray/abide/commit/568956c88ff1d32ca79a2aa8a1a0ffffeb25afe3) - Breaking: remove <template> control-flow directives in favor of {#…} blocks ([`bf5cf89`](https://github.com/briancray/abide/commit/bf5cf89813a8dcbc967e1303bd2509e18e701761))
28
+
29
+ - [`568956c`](https://github.com/briancray/abide/commit/568956c88ff1d32ca79a2aa8a1a0ffffeb25afe3) - color of/by connectors in {#for} block heads ([`c958614`](https://github.com/briancray/abide/commit/c9586147f8196e9e6301b14cdb08986b36da2751))
30
+
31
+ - [`568956c`](https://github.com/briancray/abide/commit/568956c88ff1d32ca79a2aa8a1a0ffffeb25afe3) - parse {#await} streaming + blocking blocks to the await AST ([`cfd7ff0`](https://github.com/briancray/abide/commit/cfd7ff0102b2be2ac241a6c0b1965949aa6621bf))
32
+
33
+ - [`568956c`](https://github.com/briancray/abide/commit/568956c88ff1d32ca79a2aa8a1a0ffffeb25afe3) - serve textDocument/semanticTokens/full for .abide ([`e152663`](https://github.com/briancray/abide/commit/e152663dae9e60e13d16f0edf4b0a140a99b1bfb))
34
+
35
+ - [`568956c`](https://github.com/briancray/abide/commit/568956c88ff1d32ca79a2aa8a1a0ffffeb25afe3) - structural {#…} block framing tokenizer ([`e1e85e8`](https://github.com/briancray/abide/commit/e1e85e847e9442f8c6140df5346b1d37f8cfad54))
36
+
37
+ ### Patch Changes
38
+
39
+ - [`568956c`](https://github.com/briancray/abide/commit/568956c88ff1d32ca79a2aa8a1a0ffffeb25afe3) - point block-construct loc at its primary expression (shadow source-map fidelity) ([`0fb0efa`](https://github.com/briancray/abide/commit/0fb0efab43639331c856969fdb6a7b83bd7ab604))
40
+
41
+ - [`568956c`](https://github.com/briancray/abide/commit/568956c88ff1d32ca79a2aa8a1a0ffffeb25afe3) - unify block/branch child-scan loops behind a shared helper ([`4683098`](https://github.com/briancray/abide/commit/4683098df96edd0321dc66d200372e0fbedce2e2))
42
+
43
+ - [`568956c`](https://github.com/briancray/abide/commit/568956c88ff1d32ca79a2aa8a1a0ffffeb25afe3) - document {#…} control-flow blocks in the AGENTS surface map ([`68fe96f`](https://github.com/briancray/abide/commit/68fe96f3294a228b91eef5cde37c0300673eb88a))
44
+
45
+ - [`568956c`](https://github.com/briancray/abide/commit/568956c88ff1d32ca79a2aa8a1a0ffffeb25afe3) - map {#for}/{:then}/{:catch} bindings + by-key to source so hover & semantic tokens land on them ([`e8447e8`](https://github.com/briancray/abide/commit/e8447e8d54934737b80c2f49d4f4d1b134786531))
46
+
47
+ - [`568956c`](https://github.com/briancray/abide/commit/568956c88ff1d32ca79a2aa8a1a0ffffeb25afe3) - map <template args> snippet param + await/try {:catch} bindings (+ index/catch/snippet tests) ([`f724d10`](https://github.com/briancray/abide/commit/f724d101f5baed79de2d39a1ab9598eb9d1eb818))
48
+
49
+ - [`568956c`](https://github.com/briancray/abide/commit/568956c88ff1d32ca79a2aa8a1a0ffffeb25afe3) - satisfy noUncheckedIndexedAccess in block-token + shadow paths ([`fbb6e44`](https://github.com/briancray/abide/commit/fbb6e4443932e92277fb332b639c746663c7759b))
50
+
51
+ ## 0.41.1
52
+
53
+ ### Patch Changes
54
+
55
+ - [`74f5312`](https://github.com/briancray/abide/commit/74f531260e17f48162397142fb19b3df9e081b2c) - shared replaceRange region-update seam ([`184cf41`](https://github.com/briancray/abide/commit/184cf41157ef0a5340a4a916d6cfc4f4e09b6c71))
56
+
57
+ - [`74f5312`](https://github.com/briancray/abide/commit/74f531260e17f48162397142fb19b3df9e081b2c) - single-source the marker-depth and isElement helpers ([`188c6ff`](https://github.com/briancray/abide/commit/188c6ff29fcaff2c9c206b971a2f04daf9a18cb3))
58
+
59
+ - [`74f5312`](https://github.com/briancray/abide/commit/74f531260e17f48162397142fb19b3df9e081b2c) - guard against double-dispose on reentrant branch swap ([`40600c7`](https://github.com/briancray/abide/commit/40600c7921c9fee9b6be947472c770af795f60c6))
60
+
61
+ - [`74f5312`](https://github.com/briancray/abide/commit/74f531260e17f48162397142fb19b3df9e081b2c) - await block on marker ranges, not a node array ([`5d12e83`](https://github.com/briancray/abide/commit/5d12e83b7313a04a925df3464ef5c7669bcbc1e6))
62
+
63
+ - [`74f5312`](https://github.com/briancray/abide/commit/74f531260e17f48162397142fb19b3df9e081b2c) - refresh AGENTS surface map, README, and kitchen-sink example ([`6cd7a3f`](https://github.com/briancray/abide/commit/6cd7a3f5686a69f4dd5faf87026d7f1ab0362bdb))
64
+
65
+ - [`74f5312`](https://github.com/briancray/abide/commit/74f531260e17f48162397142fb19b3df9e081b2c) - single client warm-seed intake (seedResolved) ([`8c3c19e`](https://github.com/briancray/abide/commit/8c3c19ed0a4d4244cb2634c025ef9ff88b7c9670))
66
+
67
+ - [`74f5312`](https://github.com/briancray/abide/commit/74f531260e17f48162397142fb19b3df9e081b2c) - centralize comment-marker sentinels and detached-range builder ([`99d4499`](https://github.com/briancray/abide/commit/99d44991c499b96fc016c3d204cb4dafa340cbe1))
68
+
69
+ - [`74f5312`](https://github.com/briancray/abide/commit/74f531260e17f48162397142fb19b3df9e081b2c) - unify skeleton positional model on shared walks ([`cbb9daa`](https://github.com/briancray/abide/commit/cbb9daaa6f31532331df44136dd029f3e32cdfd7))
70
+
71
+ - [`f3e0a13`](https://github.com/briancray/abide/commit/f3e0a132f9f9fbb020a8c783a0b54d3b37457e39) - Coalesce reactive writes inside event handlers
72
+
73
+ Event handlers now batch their writes: a handler that sets several signals re-runs
74
+ each dependent effect/computed/DOM-binding once on the handler's exit instead of once
75
+ per write (the previous default flushed eagerly per write). The change stays fully
76
+ synchronous — the flush runs at handler-end before it returns — so the causal stack
77
+ (`dispatch → handler → effects`) is intact and server/client scheduling stays identical.
78
+
79
+ Factored the batch idiom `createDoc`/`clientPage` inlined into a shared, nesting-safe
80
+ `batch()` (flushes only on the depth-0 exit) and migrated both onto it, so a handler
81
+ that triggers a doc patch now coalesces end-to-end rather than flushing mid-handler.
82
+
83
+ A handler writing N fields cuts dependent re-runs N× (bench: an 8-field form handler
84
+ drops aggregate re-runs 8× and runs ~4.8× faster). Single-write handlers and navigation
85
+ (already batched) are unchanged. New contract, pinned by tests: a handler that writes
86
+ then synchronously reads the bound DOM sees the pre-write value until it returns; signal
87
+ reads stay current.
88
+
89
+ - [`f26cba2`](https://github.com/briancray/abide/commit/f26cba22b92683f7316cc7212c5fe10500bdad13) - `abide check`: declare the `each` `index` binding in the type-check shadow
90
+
91
+ `index="i"` (0.41.0) was bound in the build and SSR passes but not in the shadow the
92
+ type-checker reads, so `{i}` in a row body false-positived "Cannot find name 'i'". The
93
+ shadow now declares the index as a `number` inside the loop body, matching the runtime.
94
+
3
95
  ## 0.41.0
4
96
 
5
97
  ### Minor Changes
package/README.md CHANGED
@@ -1,24 +1,24 @@
1
1
  # abide
2
2
 
3
- **One typed declaration fans out to HTTP, a CLI, an MCP tool, and an OpenAPI
4
- spec — and the bundler swaps the runtime per side.**
3
+ **One typed declaration fans out to HTTP, a CLI, an MCP tool, and an
4
+ OpenAPI spec — the bundler swaps the runtime per side.**
5
5
 
6
- abide is an isomorphic framework on Bun and web standards: you declare a verb,
7
- a socket, or a component once, and the same callable runs server-side, in the
8
- browser, from the terminal, and from an agent the bundler decides which
9
- runtime each side gets. Built for humans *and* machines.
6
+ abide is an isomorphic framework on Bun where you write a function once and
7
+ it serves every consumer: a browser fetch, an in-process SSR call, a CLI
8
+ subcommand, an MCP tool, an OpenAPI operation. The same callable keeps its
9
+ name and behaviour on both sides the bundler decides whether it runs the
10
+ real handler or a network proxy. Built for humans _and_ machines.
10
11
 
11
- - One direct dependency the TypeScript compiler, used by `abide check`.
12
- Tailwind is an optional peer. Everything runs on a single Bun runtime
13
- (≥ 1.3.0); no second toolchain.
12
+ - One direct dependency (`typescript`); `tailwindcss` + `bun-plugin-tailwind`
13
+ are optional peers. Single runtime: Bun 1.3.0.
14
14
 
15
15
  ## Quick start
16
16
 
17
17
  ```sh
18
- bunx abide scaffold my-app # copies the template, installs, starts dev
18
+ bunx abide scaffold my-app # scaffolds, installs deps, and starts dev
19
19
  ```
20
20
 
21
- Or clone the kitchen-sink and run it:
21
+ Or read the full feature tour in the kitchen-sink example:
22
22
 
23
23
  ```sh
24
24
  git clone https://github.com/briancray/abide
@@ -29,134 +29,142 @@ bun run dev
29
29
 
30
30
  ## RPCs
31
31
 
32
- An RPC is one export per file under `src/server/rpc/`. **The file path is the
33
- URL.** The schema validates arguments and projects the MCP tool, the CLI flags,
34
- and the OpenAPI operation. The contract is Standard Schema zod, valibot, or
35
- arktype, unadapted.
32
+ An RPC is one export per file under `src/server/rpc/`. The file path is the
33
+ URL; the export name is the verb. A Standard Schema (zod / valibot / arktype,
34
+ unadapted) validates the args and projects the same shape into the MCP tool,
35
+ the CLI flags, and the OpenAPI operation.
36
36
 
37
37
  ```ts
38
- // src/server/rpc/getMessages.ts — file path is the route: GET /getMessages
38
+ // src/server/rpc/getMessages.ts
39
39
  import { GET } from '@abide/abide/server/GET'
40
40
  import { json } from '@abide/abide/server/json'
41
41
  import { z } from 'zod'
42
+ import { recent } from '../../chatState.ts'
42
43
 
43
- const inputSchema = z.object({ room: z.string() })
44
+ const inputSchema = z.object({ room: z.string(), limit: z.coerce.number().default(20) })
44
45
 
45
- export const getMessages = GET(({ room }) => json(board(room)), { inputSchema })
46
+ export const getMessages = GET(({ room, limit }) => json(recent(room).slice(-limit)), {
47
+ inputSchema,
48
+ })
46
49
  ```
47
50
 
48
- One declaration, five surfaces:
51
+ One declaration, every surface:
49
52
 
50
53
  ```text
51
54
  getMessages = GET(fn, { inputSchema })
52
55
 
53
- ┌───────────┬─────────────┼────────────┬──────────────┐
54
- SSR call browser fetch MCP tool CLI command OpenAPI op
55
- cache(fn)() typed proxy() (read-only) abide ... cli /openapi.json
56
+ ┌─────────────┬─────────┼──────────┬──────────────┐
57
+ ▼ ▼ ▼ ▼ ▼
58
+ SSR call browser MCP tool CLI sub- OpenAPI
59
+ cache(fn)() fetch (read) command operation
60
+ proxy
56
61
  ```
57
62
 
58
- A schema unlocks the CLI everywhere and MCP for read-only verbs; a mutating
59
- verb never auto-exposes to MCP — it needs explicit `clients: { mcp: true }`.
60
- Consume the verb four ways: `cache(getMessages)({ room })` resolves in-process
61
- during SSR, the same call hits a swapped `fetch` in the browser,
62
- `getMessages.raw(args)` returns the undecoded `Response`, and
63
- `getMessages.stream(args)` is an iterable view of the body.
63
+ A schema unlocks the CLI for every verb and MCP for read-only verbs (`GET` /
64
+ `HEAD`); a mutating verb never auto-exposes to MCP — it needs an explicit
65
+ `clients: { mcp: true }`. Consume the verb four ways: `cache(getMessages)(args)`
66
+ in-process (warm SSR hydration), the swapped `fetch` proxy in the browser,
67
+ `getMessages.raw(args)` for the untouched `Response`, and
68
+ `getMessages.stream(args)` to iterate a `jsonl`/`sse` body.
64
69
 
65
- > Query args travel as strings — validate with `z.coerce.*`. The per-verb
66
- > `timeout` (504 on every surface) is distinct from `ABIDE_CLIENT_TIMEOUT`.
70
+ > Query args arrive as strings — wrap numeric/boolean fields in `z.coerce.*`.
71
+ > The per-verb `timeout` (504 on every surface) is distinct from the
72
+ > client-side `ABIDE_CLIENT_TIMEOUT`.
67
73
 
68
74
  ## Sockets
69
75
 
70
76
  A socket is one broadcast topic per file under `src/server/sockets/`. A
71
- `Socket<T>` is an isomorphic `AsyncIterable<T>`, and every socket multiplexes
72
- onto one websocket at `/__abide/sockets`.
77
+ `Socket<T>` is an isomorphic `AsyncIterable<T>`; every socket multiplexes onto
78
+ one WebSocket at `/__abide/sockets`.
73
79
 
74
80
  ```ts
75
- // src/server/sockets/messages.ts — topic name = file name: `messages`
81
+ // src/server/sockets/chat.ts
76
82
  import { socket } from '@abide/abide/server/socket'
77
83
  import { z } from 'zod'
78
84
 
79
- const schema = z.object({ id: z.string(), room: z.string(), from: z.string(), text: z.string() })
85
+ const schema = z.object({ id: z.string(), from: z.string(), text: z.string(), at: z.number() })
80
86
 
81
- // retain the last 50 frames; evict any older than an hour
82
- export const messages = socket({ schema, tail: 50, ttl: 3_600_000 })
83
- export type Message = z.infer<typeof schema>
87
+ // retain the last 100 frames; evict any older than an hour
88
+ export const chat = socket({ schema, tail: 100, ttl: 3_600_000 })
89
+ export type ChatMessage = z.infer<typeof schema>
84
90
  ```
85
91
 
86
- The socket also has an HTTP face at `/__abide/sockets/messages`, for clients
87
- that can't speak the ws multiplex: `GET` returns the retained tail, `POST`
88
- publishes — gated by `clientPublish` (off by default, so the POST 403s).
92
+ It also has an HTTP face for clients that can't speak the multiplex (the CLI
93
+ and MCP): `GET /__abide/sockets/chat` returns the retained tail, and
94
+ `POST /__abide/sockets/chat` publishes — gated by `clientPublish` (default off,
95
+ so browsers publish through a validating verb instead).
89
96
 
90
- ## Components
97
+ ## Components — the full template
91
98
 
92
- A `.abide` component is the payoff: it imports the verb and the socket above
93
- and ties them together with the native `<template>` grammar. Reactive state is
94
- reached only through `scope()` `scope().state()` is writable,
95
- `scope().computed()` is read-only. `props()` and `effect()` are in-scope, no
96
- import.
99
+ A `.abide` component pulls the verb and the socket above into one page and
100
+ exercises the template grammar. `scope`, `props`, `effect`, `html`, and
101
+ `snippet` are ambientno import needed.
97
102
 
103
+ <!-- prettier-ignore -->
98
104
  ```html
99
105
  <script>
100
106
  import { cache } from '@abide/abide/shared/cache'
101
107
  import { tail } from '@abide/abide/ui/tail'
102
108
  import { getMessages } from '$server/rpc/getMessages.ts'
103
- import { postMessage } from '$server/rpc/postMessage.ts'
104
- import { messages } from '$server/sockets/messages.ts'
109
+ import { publishChat } from '$server/rpc/publishChat.ts'
110
+ import { chat } from '$server/sockets/chat.ts'
105
111
  import Avatar from '$ui/Avatar.abide'
106
112
 
107
- const { room = 'lobby' } = props<{ room?: string }>()
113
+ const { room } = props()
108
114
 
109
- // SSR-warm history; live frames then stream over the ws
110
- const seed = cache(getMessages)({ room })
111
- const live = scope().computed(() => tail(messages, { last: 50 }))
115
+ // warm on the server, live on the client
116
+ const history = scope().computed(() => cache(getMessages)({ room }))
117
+ const latest = scope().computed(() => tail(chat))
112
118
 
113
119
  let from = scope().state('alice')
114
120
  let text = scope().state('')
115
121
  let pinned = scope().state(false)
116
- let sort = scope().state('newest')
122
+ let view = scope().state('all')
117
123
 
118
124
  async function send() {
119
- await postMessage({ room, from, text })
125
+ await publishChat({ from, text })
120
126
  text = ''
121
127
  }
122
128
  </script>
123
129
 
124
- <template name="bubble" args={msg}>
125
- <li class="flex gap-2"><Avatar name={msg.from} /> <b>{msg.from}</b> {msg.text}</li>
130
+ <template name="line" args={message}>
131
+ <li><Avatar name={message.from} /> <b>{message.from}</b>: {message.text}</li>
126
132
  </template>
127
133
 
128
- <form onsubmit={send} class="flex gap-2">
129
- <input bind:value={from} class="border px-2" />
130
- <input bind:value={text} placeholder="message" class="flex-1 border px-2" />
134
+ <form onsubmit={send}>
135
+ <input bind:value={from} placeholder="name" />
136
+ <input bind:value={text} placeholder="message" />
131
137
  <label><input type="checkbox" bind:checked={pinned} /> pin</label>
132
- <label><input type="radio" bind:group={sort} value="newest" /> newest</label>
133
- <button disabled={!text} class="border px-3">send</button>
138
+ <label><input type="radio" bind:group={view} value="all" /> all</label>
139
+ <label><input type="radio" bind:group={view} value="mine" /> mine</label>
140
+ <button disabled={!text}>send</button>
134
141
  </form>
135
142
 
136
- <template if={pinned}>
137
- <p class="text-xs text-amber-700">room pinned</p>
138
- <template else><p class="text-xs text-slate-400">not pinned</p></template>
139
- </template>
140
-
141
- <template switch={sort}>
142
- <template case={'newest'}><p class="text-xs">newest first</p></template>
143
- <template default><p class="text-xs">oldest first</p></template>
144
- </template>
145
-
146
- <template await={seed}>
147
- <p class="text-xs text-slate-500">loading…</p>
148
- <template then="history">
149
- <ul class="mt-3 space-y-1">
150
- <template each={live ?? history} as="msg" key="msg.id">
151
- {bubble(msg)}
152
- </template>
153
- </ul>
154
- </template>
155
- <template catch="err"><p class="text-rose-700">{err.message}</p></template>
156
- </template>
143
+ {#if latest}
144
+ <p>latest from {latest.from}</p>
145
+ {:else}
146
+ <p>no messages yet</p>
147
+ {/if}
148
+
149
+ {#switch view}
150
+ {:case 'mine'}<p>showing your messages</p>
151
+ {:default}<p>showing every message</p>
152
+ {/switch}
153
+
154
+ {#await history}
155
+ <p>loading…</p>
156
+ {:then data}
157
+ <ul>
158
+ {#for message, i of data by message.id}
159
+ {i}. {line(message)}
160
+ {/for}
161
+ </ul>
162
+ {:catch reason}
163
+ <p>failed: {reason.message}</p>
164
+ {/await}
157
165
 
158
166
  <style>
159
- li { font-size: 0.875rem; }
167
+ form { display: flex; gap: 0.5rem; }
160
168
  </style>
161
169
  ```
162
170
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@abide/abide",
3
- "version": "0.41.0",
3
+ "version": "0.42.0",
4
4
  "type": "module",
5
5
  "sideEffects": false,
6
6
  "description": "Isomorphic multimodal HTTP framework built for humans and machines in a single Bun runtime",
package/src/abideLsp.ts CHANGED
@@ -1,11 +1,15 @@
1
1
  import { fileURLToPath, pathToFileURL } from 'node:url'
2
2
  import ts from 'typescript'
3
+ import { ABIDE_SEMANTIC_TOKENS_LEGEND } from './lib/ui/compile/ABIDE_SEMANTIC_TOKENS_LEGEND.ts'
3
4
  import type {
4
5
  ShadowLanguageService,
5
6
  ShadowQuickInfo,
6
7
  } from './lib/ui/compile/createShadowLanguageService.ts'
7
8
  import { createShadowLanguageService } from './lib/ui/compile/createShadowLanguageService.ts'
9
+ import { encodeSemanticTokens } from './lib/ui/compile/encodeSemanticTokens.ts'
8
10
  import { nearestProjectRoot } from './lib/ui/compile/nearestProjectRoot.ts'
11
+ import { offsetToPosition } from './lib/ui/compile/offsetToPosition.ts'
12
+ import { structuralBlockTokens } from './lib/ui/compile/structuralBlockTokens.ts'
9
13
  import type { AbideDiagnostic } from './lib/ui/compile/types/AbideDiagnostic.ts'
10
14
 
11
15
  /*
@@ -19,6 +23,28 @@ unsaved text as overlays. Each document routes to a shadow service for its
19
23
  nearest tsconfig, so files in a monorepo opened at its root are checked against
20
24
  their own project — matching `abide check` run from that package.
21
25
  */
26
+ /*
27
+ The semantic-tokens `data` array for one component: the structural `{#…}` framing
28
+ merged with the shadow's type-aware expression tokens, encoded to the LSP wire
29
+ format. Never throws — on any internal failure it yields an empty stream so the
30
+ editor falls back to tree-sitter highlighting.
31
+ */
32
+ export function componentSemanticTokens(
33
+ service: ShadowLanguageService,
34
+ abidePath: string,
35
+ text: string,
36
+ ): number[] {
37
+ try {
38
+ const tokens = [
39
+ ...structuralBlockTokens(text),
40
+ ...service.semanticClassifications(abidePath),
41
+ ]
42
+ return encodeSemanticTokens(text, tokens)
43
+ } catch {
44
+ return []
45
+ }
46
+ }
47
+
22
48
  export async function abideLsp({ cwd }: { cwd: string }): Promise<void> {
23
49
  const documentText = new Map<string, string>()
24
50
 
@@ -64,7 +90,14 @@ export async function abideLsp({ cwd }: { cwd: string }): Promise<void> {
64
90
  jsonrpc: '2.0',
65
91
  id: message.id,
66
92
  result: {
67
- capabilities: { textDocumentSync: 1, hoverProvider: true },
93
+ capabilities: {
94
+ textDocumentSync: 1,
95
+ hoverProvider: true,
96
+ semanticTokensProvider: {
97
+ legend: ABIDE_SEMANTIC_TOKENS_LEGEND,
98
+ full: true,
99
+ },
100
+ },
68
101
  serverInfo: { name: 'abide-lsp' },
69
102
  },
70
103
  })
@@ -115,6 +148,18 @@ export async function abideLsp({ cwd }: { cwd: string }): Promise<void> {
115
148
  })
116
149
  return
117
150
  }
151
+ case 'textDocument/semanticTokens/full': {
152
+ const { uri } = message.params.textDocument
153
+ const data = isAbide(uri)
154
+ ? componentSemanticTokens(
155
+ serviceFor(fileURLToPath(uri)),
156
+ fileURLToPath(uri),
157
+ documentText.get(fileURLToPath(uri)) ?? '',
158
+ )
159
+ : []
160
+ send({ jsonrpc: '2.0', id: message.id, result: { data } })
161
+ return
162
+ }
118
163
  case 'textDocument/didClose': {
119
164
  const { uri } = message.params.textDocument
120
165
  if (isAbide(uri)) {
@@ -194,13 +239,6 @@ function toLspHover(text: string, info: ShadowQuickInfo): object {
194
239
  }
195
240
  }
196
241
 
197
- /* An absolute offset → LSP `{ line, character }` (0-based, UTF-16 code units). */
198
- function offsetToPosition(text: string, offset: number): { line: number; character: number } {
199
- const before = text.slice(0, offset)
200
- const line = before.split('\n').length - 1
201
- return { line, character: offset - (before.lastIndexOf('\n') + 1) }
202
- }
203
-
204
242
  /* An LSP `{ line, character }` (0-based) → absolute offset in `text`. */
205
243
  function positionToOffset(text: string, position: { line: number; character: number }): number {
206
244
  const lineStart = text
@@ -4,6 +4,8 @@ import type { BunPlugin } from 'bun'
4
4
  import { Glob } from 'bun'
5
5
  import { abideImportName } from './lib/shared/abideImportName.ts'
6
6
  import { abideLog } from './lib/shared/abideLog.ts'
7
+ import { escapeRegex } from './lib/shared/escapeRegex.ts'
8
+ import { fileName } from './lib/shared/fileName.ts'
7
9
  import { fileStem } from './lib/shared/fileStem.ts'
8
10
  import { jsonSchemaForPromptArguments } from './lib/shared/jsonSchemaForPromptArguments.ts'
9
11
  import { manifestModule } from './lib/shared/manifestModule.ts'
@@ -65,10 +67,6 @@ function resolveExtensionUncached(path: string): string {
65
67
 
66
68
  const NS = 'abide-virtual'
67
69
 
68
- function escapeRegex(value: string): string {
69
- return value.replace(/[\\^$.*+?()[\]{}|]/g, '\\$&')
70
- }
71
-
72
70
  /* Memoises a zero-arg async producer so repeat calls reuse the first in-flight promise. */
73
71
  function once<T>(produce: () => Promise<T>): () => Promise<T> {
74
72
  let promise: Promise<T> | undefined
@@ -791,7 +789,7 @@ async function scanPages(pagesDir: string): Promise<PagesScan> {
791
789
  return { pageFiles: [], layoutFiles: [] }
792
790
  }
793
791
  const allFiles = await Array.fromAsync(new Glob('**/*.abide').scan({ cwd: pagesDir }))
794
- const leafIs = (name: string) => (file: string) => (file.split('/').pop() ?? '') === name
792
+ const leafIs = (name: string) => (file: string) => fileName(file) === name
795
793
  return {
796
794
  pageFiles: allFiles.filter(leafIs('page.abide')),
797
795
  layoutFiles: allFiles.filter(leafIs('layout.abide')),
@@ -1,4 +1,5 @@
1
1
  import { relative } from 'node:path'
2
+ import { fileName } from '../../shared/fileName.ts'
2
3
  import { analyzeComponent } from '../../ui/compile/analyzeComponent.ts'
3
4
  import { compileComponent } from '../../ui/compile/compileComponent.ts'
4
5
  import { nearestProjectRoot } from '../../ui/compile/nearestProjectRoot.ts'
@@ -19,7 +20,7 @@ const GENERATED = /(^|\/)\.abide\//
19
20
  // page.abide / layout.abide are router-mounted, not `mountChild`-tracked, so they
20
21
  // can't hot-swap — they fold into `structure` (a reload) instead.
21
22
  function isPageOrLayout(moduleId: string): boolean {
22
- const file = moduleId.split('/').pop() ?? ''
23
+ const file = fileName(moduleId)
23
24
  return file === 'page.abide' || file === 'layout.abide'
24
25
  }
25
26
 
@@ -0,0 +1,9 @@
1
+ /*
2
+ Escapes the regex metacharacters in `value` so it can be embedded literally
3
+ inside a `new RegExp(...)` pattern. Shared by the $rpc/$sockets import stripper,
4
+ the resolver plugin's virtual-namespace matcher, and the SSR snippet-call
5
+ rewriter so the same escaping is applied one way everywhere.
6
+ */
7
+ export function escapeRegex(value: string): string {
8
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
9
+ }
@@ -0,0 +1,9 @@
1
+ /*
2
+ The bare leaf filename of a path — directory stripped, extension kept —
3
+ e.g. `users/list.ts` → `list.ts`. The extension-stripping `fileStem` builds on
4
+ this; call sites that match a full filename (`layout.abide`, `page.abide`) use
5
+ it directly so the leaf-grab is written one way.
6
+ */
7
+ export function fileName(path: string): string {
8
+ return path.split('/').pop() ?? ''
9
+ }
@@ -1,3 +1,5 @@
1
+ import { fileName } from './fileName.ts'
2
+
1
3
  /*
2
4
  The bare filename of a path, with directory and trailing extension stripped —
3
5
  e.g. `users/list.ts` → `list`, `/_virtual/mcp-resources.ts` → `mcp-resources`.
@@ -5,5 +7,5 @@ Used to derive a virtual-module name from its path and to check an $rpc /
5
7
  $sockets module's single export name against its file stem.
6
8
  */
7
9
  export function fileStem(path: string): string {
8
- return (path.split('/').pop() ?? '').replace(/\.[^.]+$/, '')
10
+ return fileName(path).replace(/\.[^.]+$/, '')
9
11
  }
@@ -1,3 +1,5 @@
1
+ import { fileName } from './fileName.ts'
2
+
1
3
  /*
2
4
  Derives the CLI program/binary name from a package.json `name` field.
3
5
  Scoped names (`@scope/tool`) keep only the final segment so the value is
@@ -10,5 +12,5 @@ export function programNameForPackage(name: string | undefined): string {
10
12
  if (name === undefined || name === '') {
11
13
  return 'app'
12
14
  }
13
- return name.split('/').pop() || 'app'
15
+ return fileName(name) || 'app'
14
16
  }
@@ -1,3 +1,5 @@
1
+ import { escapeRegex } from './escapeRegex.ts'
2
+
1
3
  /*
2
4
  Strips the user's `import { … } from '<moduleName>'` declaration from a
3
5
  module source. Used by the $rpc / $sockets rewriters to remove the
@@ -18,7 +20,7 @@ includes newlines, so multi-line braced imports like
18
20
  still match — the body just can't contain another `}` to bound it.
19
21
  */
20
22
  export function stripImport(source: string, moduleName: string): string {
21
- const escaped = moduleName.replace(/[\\^$.*+?()[\]{}|]/g, '\\$&')
23
+ const escaped = escapeRegex(moduleName)
22
24
  const pattern = new RegExp(
23
25
  `^\\s*import\\s*\\{[^}]*\\}\\s*from\\s*['"]${escaped}['"]\\s*;?\\s*$`,
24
26
  'gm',
@@ -0,0 +1,45 @@
1
+ /*
2
+ The LSP semantic-tokens legend abide lsp advertises, and the decoder from
3
+ TypeScript's encoded classifications to legend names. TypeScript encodes a
4
+ classification as `((tokenType + 1) << 8) + modifierBitset`; its TokenType and
5
+ TokenModifier enums fix the orders below. `keyword`/`operator` carry the `{#…}`
6
+ block framing the structural tokenizer emits.
7
+ */
8
+
9
+ /* TS TokenType order (class=0 … member=11) → LSP token-type name. */
10
+ const TS_TYPE_TO_LSP = [
11
+ 'class',
12
+ 'enum',
13
+ 'interface',
14
+ 'namespace',
15
+ 'typeParameter',
16
+ 'type',
17
+ 'parameter',
18
+ 'variable',
19
+ 'enumMember',
20
+ 'property',
21
+ 'function',
22
+ 'method',
23
+ ]
24
+
25
+ /* TS TokenModifier order (declaration=bit 0 … local=bit 5). */
26
+ const TS_MODIFIERS = ['declaration', 'static', 'async', 'readonly', 'defaultLibrary', 'local']
27
+
28
+ export const ABIDE_SEMANTIC_TOKENS_LEGEND = {
29
+ tokenTypes: [...TS_TYPE_TO_LSP, 'keyword', 'operator'],
30
+ tokenModifiers: TS_MODIFIERS,
31
+ }
32
+
33
+ /* Decodes one TypeScript encoded classification into legend names. */
34
+ export function mapTsClassification(
35
+ classification: number,
36
+ ): { type: string; modifiers: string[] } | undefined {
37
+ const tokenType = (classification >> 8) - 1
38
+ const type = TS_TYPE_TO_LSP[tokenType]
39
+ if (type === undefined) {
40
+ return undefined
41
+ }
42
+ const modifierSet = classification & 255
43
+ const modifiers = TS_MODIFIERS.filter((_, bit) => (modifierSet & (1 << bit)) !== 0)
44
+ return { type, modifiers }
45
+ }
@@ -1,3 +1,5 @@
1
+ import { SNIPPET_CLOSE, SNIPPET_OPEN } from '../runtime/RANGE_MARKER.ts'
2
+
1
3
  /*
2
4
  Source text for the `$esc` / `$attr` / `$spread` / `$text` / `$snip` helpers injected
3
5
  into every SSR render body. `$esc` escapes the five HTML-significant characters. `$attr`
@@ -30,6 +32,6 @@ export const SSR_ESCAPE =
30
32
  "const $SNIP = Symbol.for('abide.snippet');\n" +
31
33
  'const $snip = (s) => ({ [$SNIP]: s });\n' +
32
34
  'const $text = (v) => (v !== null && typeof v === "object" && $SNIP in v) ' +
33
- "? ('<!--abide:snippet-->' + v[$SNIP] + '<!--/abide:snippet-->') " +
35
+ `? ('<!--${SNIPPET_OPEN}-->' + v[$SNIP] + '<!--${SNIPPET_CLOSE}-->') ` +
34
36
  ': (v !== null && typeof v === "object" && $RAW in v) ' +
35
37
  "? ('<!--abide:html-->' + v[$RAW] + '<!--/abide:html-->') : $esc(v);"