diff --git a/.github/workflows/www.yaml b/.github/workflows/www.yaml index ee93cf00e..303d5a020 100644 --- a/.github/workflows/www.yaml +++ b/.github/workflows/www.yaml @@ -10,6 +10,10 @@ on: permissions: contents: read +env: + # name of the netlify site, used to predict the url of a preview deploy + NETLIFY_SITE_NAME: effection + jobs: deploy-preview: if: github.event_name == 'pull_request' @@ -17,7 +21,9 @@ jobs: timeout-minutes: 15 environment: name: Preview - url: ${{ steps.netlify.outputs.unique-url }} + # the alias deploy, so that this link shares an origin with the urls + # baked into the build (see SITE_URL below) + url: ${{ steps.netlify.outputs.stable-url }} permissions: contents: read pull-requests: write @@ -33,6 +39,23 @@ jobs: with: deno-version: v2.9.1 + - name: Compute Site URL + id: site + env: + IS_FORK: ${{ github.event.pull_request.head.repo.full_name != github.repository }} + PR_NUMBER: ${{ github.event.pull_request.number }} + run: | + # an alias deploy lands on a predictable url, so a preview can point + # at itself. a fork deploys anonymously to a url that is not known + # until after the deploy, so it points at production instead. + if [[ "$IS_FORK" == "true" ]]; then + URL="https://${NETLIFY_SITE_NAME}.netlify.app" + else + URL="https://pr-${PR_NUMBER}--${NETLIFY_SITE_NAME}.netlify.app" + fi + echo "url=$URL" >> "$GITHUB_OUTPUT" + echo "Preview will be served from $URL" >> "$GITHUB_STEP_SUMMARY" + - name: Serve Website run: | deno run -A main.tsx & @@ -43,6 +66,9 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} JSR_API: ${{ secrets.JSR_API }} + # url embedded in resources that staticalize does not rewrite, such + # as llms.txt + SITE_URL: ${{ steps.site.outputs.url }} timeout-minutes: 5 working-directory: ./www @@ -90,6 +116,11 @@ jobs: DEPLOY_ID=$(echo "$DEPLOY_OUTPUT" | jq -er '.deploy_id') SITE_NAME=$(echo "$DEPLOY_OUTPUT" | jq -er '.site_name') + + if [[ "$SITE_NAME" != "$NETLIFY_SITE_NAME" ]]; then + echo "::warning::deployed to '$SITE_NAME' but urls were built for" \ + "'$NETLIFY_SITE_NAME'; update NETLIFY_SITE_NAME in this workflow" + fi UNIQUE_URL="https://${DEPLOY_ID}--${SITE_NAME}.netlify.app" STABLE_URL="https://pr-${PR_NUMBER}--${SITE_NAME}.netlify.app" fi @@ -139,6 +170,9 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} JSR_API: ${{ secrets.JSR_API }} + # url embedded in resources that staticalize does not rewrite, such + # as llms.txt + SITE_URL: https://frontside.com/effection timeout-minutes: 5 working-directory: ./www diff --git a/AGENTS.md b/AGENTS.md index 1fa101e5e..1e2516eab 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,474 +1,19 @@ -# AGENTS.md — Effection agent contract - -This file is the behavioral contract for AI agents working with the Effection -codebase. - -Agents must not invent APIs, must not infer semantics from other ecosystems, and -must ground claims in the public API and repository code. - -If you are unsure whether something exists, consult the API reference: -https://frontside.com/effection/api/ - -## Core invariants (do not violate) - -### Operations vs Promises - -- **Operations** are lazy. They execute only when interpreted (e.g. `yield*`, - `run()`, `Scope.run()`, `spawn()`). -- **Promises** are eager. Creating a promise (or calling an `async` function) - starts work; `await` only observes completion. -- You must not claim that a promise is "inert until awaited". -- You must not use `await` inside a generator function (`function*`). Use - `yield*` with an operation instead (e.g. `yield* until(promise)`). - -### Structured concurrency is scope-owned - -- Scope hierarchy is created automatically by the interpreter; application code - should not manage scopes manually. -- "Lexical" in Effection: scope hierarchy follows the lexical structure of - operation invocation sites (e.g. `yield*`, `spawn`, `Scope.run`), not where - references are stored or later used. -- Work is owned by **Scopes**. -- When a scope exits, all work created in that scope is halted. -- References do not extend lifetimes. Returning a `Task`, `Scope`, `Stream`, or - `AbortSignal` does not keep it alive. - -### Effects do not escape scopes - -- Values may escape scopes. -- Ongoing effects must not escape: tasks, resources, streams/subscriptions, and - context mutations must remain scope-bound. - -## Operations, Futures, Tasks - -### Operation - -- An `Operation` is a recipe for work. It does nothing by itself. -- Operations are typically created by invoking a generator function - (`function*`). - -### Future - -- A `Future` is both: - - an Effection operation (`yield* future`) - - a Promise (`await future`) - -### Task - -- A `Task` is a `Future` representing a concurrently running operation. -- A task does not own lifetime or context; its scope does. - -## Entry points and scope creation - -### `main()` - -- You should prefer `main()` when writing an entire program in Effection. -- Inside `main()`, prefer `yield* exit(status, message?)` for termination; do - not call `process.exit()` / `Deno.exit()` directly (it bypasses orderly - shutdown). - -### `exit()` - -- `exit()` is an operation intended to be used from within `main()` to initiate - shutdown. - -### `run()` - -- You may use `run()` to embed Effection into existing async code. -- `run()` starts execution immediately; awaiting the returned task only observes - completion. - -### `createScope()` - -- You must not use `createScope()` for normal Effection application code. -- You may use `createScope()` only for **integration** between Effection and - non-Effection lifecycle management (frameworks/hosts/embedders). -- You must observe `destroy()` (`await` / `yield*`) to complete teardown. - Calling `destroy()` without observation does not guarantee shutdown - completion. - -### `useScope()` - -- Use `yield* useScope()` to capture the current `Scope` for integration (e.g. - callbacks) and re-enter Effection with `scope.run(() => operation)`. - -## `spawn()` - -**Shape (canonical)** - -```ts -const op = spawn(myOperation); // returns an OPERATION -const task = yield * op; // returns a TASK (Future) and starts it -``` - -**Rules** - -- `spawn()` does not start work by itself. Yielding the spawn operation starts - work. -- Yielding `spawn()` does not guarantee that the child has reached any - particular point in its body before the parent continues. -- A spawned task must not outlive its parent scope. - -## `Task.halt()` - -**Rules** - -- `task.halt()` returns a `Future`. You must observe it (`await` / - `yield*` / `.then()`), or shutdown is not guaranteed to complete. -- `halt()` represents teardown. It can succeed even if the task failed. -- If a task is halted before completion, consuming its value (`yield* task` / - `await task`) fails with `Error("halted")`. - -## Scope vs Task (ownership) - -| Concept | Owns lifetime | Owns context | -| ------- | ------------: | -----------: | -| `Scope` | ✅ | ✅ | -| `Task` | ❌ | ❌ | - -## Context API (strict) - -**Valid APIs** - -- `createContext(name, defaultValue?)` -- `yield* Context.get()` -- `yield* Context.expect()` -- `yield* Context.set(value)` -- `yield* Context.delete()` -- `yield* Context.with(value, operation)` - -**Rules** - -- You must treat context as scope-local. Children inherit from parents; children - may override without mutating ancestors. -- You must not treat context as global mutable state. - -## `race()` - -**Rules** - -- `race()` accepts an array of operations. -- It returns the value of the first operation to complete. -- It halts all losing operations. - -## `all()` - -**Rules** - -- `all()` accepts an array of operations and evaluates them concurrently. -- It returns an array of results in input order. -- If any member errors, `all()` errors and halts the other members. -- If you need all results regardless of success or failure, use `allSettled()` - instead of wrapping each member in railway-style results. - -## `allSettled()` - -**Rules** - -- `allSettled()` accepts an array of operations and evaluates them concurrently. -- It returns an array of `Result` objects in input order - (`{ ok: true, value }` or `{ ok: false, error }`). -- It never short-circuits on error — all operations run to completion. -- It is analogous to `Promise.allSettled()`, but uses Effection's `Result` - shape. - -## `call()` - -**Rules** - -- `call()` invokes a function that returns a value, promise, or operation. -- `call()` does not create a scope boundary and does not delimit concurrency. -- If you need to report failures without throwing (e.g. so other work can - continue), catch errors and return a railway-style result object instead of - letting the error escape. - -## `lift()` - -**Rules** - -- `lift(fn)` returns a function that produces an `Operation` which calls `fn` - when interpreted (`yield*`), not when created. - -## `action()` - -**Rules** - -- Use `action()` to wrap callback-style APIs when you can provide a cleanup - function. -- You must not claim `action()` creates an error or concurrency boundary; it - does not. - -## `until()` - -**Rules** - -- `until(promise)` adapts an already-created `Promise` into an `Operation`. -- Prefer `until(promise)` over `call(() => promise)` when you have a promise—it - is shorter and clearer. -- It does not make the promise cancellable; for cancellable interop, prefer - `useAbortSignal()` with APIs that accept `AbortSignal`. - -## `scoped()` - -**Rules** - -- Use `scoped()` to create a boundary such that effects created inside do not - persist after it returns. -- You must use `scoped()` (not `call()`/`action()`) when you need boundary - semantics. - -## `resource()` - -**Shape (ordering matters)** - -```ts -// synchronous teardown -resource(function* (provide) { - try { - yield* provide(value); - } finally { - cleanup(); - } -}); - -// asynchronous teardown — ensure(), never finally -resource(function* (provide) { - yield* ensure(function* () { - yield* cleanup(); - }); - - yield* provide(value); -}); -``` - -**Rules** - -- Setup happens before `provide()`. -- Synchronous cleanup must be in `finally` (or after `provide()` guarded by - `finally`) so it runs on return/error/halt. -- Teardown can be asynchronous, but asynchronous teardown must go in `ensure()`. - Do not fire-and-forget cleanup. -- You must not `yield*` inside a `finally`. See the rationale under `ensure()`. - -## `ensure()` - -**Rules** - -- `ensure(fn)` registers cleanup to run when the current operation shuts down. -- `fn` may return `void` (sync cleanup) or an `Operation` (async cleanup). -- You should wrap sync cleanup bodies in braces so the function returns `void`. -- **Cleanup that needs `yield*` must use `ensure()`, not a `finally` block.** - -**Why `yield*` in a `finally` is unsafe** - -When a task is halted, a coroutine is unwound by calling `iterator.return()` on -its generator. If a `finally` block then yields, the generator suspends _inside_ -the finally and reports `{ done: false }`, so the routine resumes it with -`iterator.next()` — and that takes the frame out of return-mode. The frame is no -longer unwinding, so once cleanup finishes, execution continues past the -operation that was being halted. The halt is lost. - -This is the defect fixed inside `scoped()` in #1185: `iter.return()` yielded the -`destroy()` effects in scoped's finally, but the resume came back as -`iter.next()`. `scoped()` re-arms the unwind explicitly via `trap.exit()`. -Ordinary user code has no way to do that. - -`ensure()` is not affected. It is implemented as a `resource()`, so its -`finally` runs in its own task frame with nothing after it, and it is driven by -scope destruction — which `createTask` wraps in `critical()`, making it -non-interruptible. - -**Gotchas** - -- `ensure()` registers on the **current scope**, and `call()` does not create - one — it delegates to the target's iterator in the same coroutine frame. An - `ensure()` inside `call(function* () { ... })` attaches to the _enclosing - task's_ scope and fires far too late. Use `scoped()` or `spawn()` when you - need a boundary. Note that `scoped()`'s own async teardown was not correct - until 4.1, so code supporting older versions should prefer `spawn()`. -- Scope destructors run in **reverse order of registration**. Register the - `ensure()` where the `try {` would have been — after any `spawn()` calls its - cleanup depends on — so cleanup still runs while those children are alive. - -## `useAbortSignal()` - -**Rules** - -- `useAbortSignal()` is an interop escape hatch for non-Effection APIs that - accept `AbortSignal`. -- The returned signal is bound to the current scope and aborts when that scope - exits (return, error, or halt). -- You should pass the signal to a **leaf** async API call, not thread it through - a nested async stack. -- If the choice is "thread an AbortSignal through a nested async stack" vs - "rewrite in Effection", you should prefer rewriting in Effection. - -**Gotchas** - -- You must not assume AbortController provides structured-concurrency - guarantees. See: - https://frontside.com/blog/2025-08-04-the-heartbreaking-inadequacy-of-abort-controller/ - -## Streams, Subscriptions, Channels, Signals, Queues - -### Stream and Subscription - -- A `Stream` is an operation that yields a `Subscription`. -- A `Subscription` is stateful; values are observed via - `yield* subscription.next()`. - -### `on(target, name)` and `once(target, name)` (EventTarget adapters) - -**Rules** - -- `on()` creates a `Stream` of events from an `EventTarget`; listeners are - removed on scope exit. -- `once()` yields the next matching event as an `Operation` (it is equivalent to - subscribing to `on()` and taking one value). - -### `sleep()`, `interval()`, `suspend()` - -**Rules** - -- `sleep(ms)` is cancellable: if the surrounding scope exits, the timer is - cleared. -- `interval(ms)` is a `Stream` that ticks until the surrounding scope exits - (cleanup clears the interval). -- `suspend()` pauses indefinitely and only resumes when its enclosing scope is - destroyed. - -### `each(streamOrSubscription)` (loop consumption) - -**Rules** - -- `each()` accepts either a `Stream` or an existing `Subscription`, including a - `Queue`. -- Passing a `Stream` subscribes when `yield* each(stream)` is interpreted. -- Passing a `Subscription` to `each()` consumes that exact subscription; it does - not create another subscription. -- You must call `yield* each.next()` exactly once at the end of every loop - iteration. -- You must call `yield* each.next()` even if the iteration ends with `continue`. - -**Subscription readiness across `spawn()`** - -- If a spawned consumer must receive values sent immediately afterward, create - the subscription in the enclosing scope before spawning, then iterate that - subscription in the child. -- Do not use `yield* sleep(0)` after `spawn()` as a subscription-readiness - barrier. -- For `Channel` and `Signal`, values sent after `yield* stream` returns are - queued for that active subscription even if the child has not begun iterating. - Values sent before the subscription is active are still dropped. -- Passing a subscription to a child does not transfer or extend its lifetime. - The scope that created it must remain active for the consumer's full lifetime. -- Treat a subscription as a single consumer. For broadcast consumption, create - one subscription per consumer before sending values. - -**Gotchas** - -- If you do not call `each.next()`, the loop throws `IterationError` on the next - iteration. -- Leaving `each(subscription)` does not close that subscription. It remains - active until its owning scope exits. - -**Shape (ordering matters)** - -```ts -for (let value of yield * each(stream)) { - // ... - yield * each.next(); -} -``` - -**Shape (subscribe before spawning)** - -```ts -await main(function* () { - let channel = createChannel(); - let subscription = yield* channel; - - let consumer = yield* spawn(function* () { - for (let value of yield* each(subscription)) { - // ... - yield* each.next(); - } - }); - - // Safe immediately: the subscription is already active. - yield* channel.send("hello"); - yield* channel.close(); - yield* consumer; -}); -``` - -### Channel vs Signal vs Queue - -| Concept | Send from | Send API | Requires subscribers | Buffering | -| --------- | ------------------------------ | ------------------------- | ----------------------- | ------------------------------- | -| `Channel` | inside operations | `send(): Operation` | yes (otherwise dropped) | per-subscriber while subscribed | -| `Signal` | outside operations (callbacks) | `send(): void` | yes (otherwise no-op) | per-subscriber while subscribed | -| `Queue` | anywhere (single consumer) | `add(): void` | no | buffered (single subscription) | - -### `Channel` - -**Rules** - -- Use `createChannel()` to construct a `Channel`. -- Use `Channel` for communication between operations. -- You must `yield* channel.send(...)` / `yield* channel.close(...)`. -- You must assume sends are dropped when there are no active subscribers. - -### `Signal` - -**Rules** - -- Use `createSignal()` to construct a `Signal`. -- Use `Signal` only as a bridge from synchronous callbacks into an Effection - stream. -- You must not use `Signal` for in-operation messaging; use `Channel` instead. -- You must assume `signal.send(...)` is a no-op if nothing is subscribed. - -### `Queue` - -**Rules** - -- Use `createQueue()` to construct a `Queue`. -- You may use `Queue` when you need buffering independent of subscriber timing - (single consumer). -- A `Queue` is already a `Subscription`; consume it via `yield* queue.next()` or - iterate it with `each(queue)`. - -## `subscribe()` and `stream()` (async iterable adapters) - -**Rules** - -- Use `subscribe(asyncIterator)` to adapt an `AsyncIterator` to an Effection - `Subscription`. -- Use `stream(asyncIterable)` to adapt an `AsyncIterable` to an Effection - `Stream`. -- You must not treat JavaScript async iterables as Effection streams without - wrapping. -- You must not use `for await` inside a generator function. Use `stream()` to - adapt the async iterable, then `each()` to iterate. - -**Shape (async iterable consumption)** - -```ts -for (const item of yield * each(stream(asyncIterable))) { - // ... - yield * each.next(); -} -``` - -## `withResolvers()` - -**Rules** - -- `withResolvers()` creates an `operation` plus synchronous `resolve(value)` / - `reject(error)` functions. -- After resolve/reject, yielding the `operation` always produces the same - outcome; calling resolve/reject again has no effect. +# AGENTS.md — Effection repository contract + +This file is for AI agents contributing to the Effection repository. It adds +repository-only rules on top of the public behavioral contract, which it does +not repeat. + +Before you modify or reason about Effection code, read the public contract in +[`docs/agents.md`](docs/agents.md). That is the copy checked out on this branch, +and it is the same document the website publishes at +. It holds the invariants: operations +versus promises, scope ownership, tasks and halting, context, the concurrency +operations, promise interoperability, resources and cleanup, `ensure()`, +`useAbortSignal()`, and streams. + +Instructions scoped to a subdirectory, such as [`www/AGENTS.md`](www/AGENTS.md) +for the website, apply in addition to this file. ## Code style diff --git a/docs/agents.md b/docs/agents.md new file mode 100644 index 000000000..0bfe14a26 --- /dev/null +++ b/docs/agents.md @@ -0,0 +1,471 @@ +# AGENTS.md — Effection agent contract + +This file is the behavioral contract for AI agents writing applications with +Effection. + +Agents must not invent APIs, must not infer semantics from other ecosystems, and +must ground claims in the public API. + +If you are unsure whether something exists, consult the API reference: +https://frontside.com/effection/api/ + +## Core invariants (do not violate) + +### Operations vs Promises + +- **Operations** are lazy. They execute only when interpreted (e.g. `yield*`, + `run()`, `Scope.run()`, `spawn()`). +- **Promises** are eager. Creating a promise (or calling an `async` function) + starts work; `await` only observes completion. +- You must not claim that a promise is "inert until awaited". +- You must not use `await` inside a generator function (`function*`). Use + `yield*` with an operation instead (e.g. `yield* until(promise)`). + +### Structured concurrency is scope-owned + +- Scope hierarchy is created automatically by the interpreter; application code + should not manage scopes manually. +- "Lexical" in Effection: scope hierarchy follows the lexical structure of + operation invocation sites (e.g. `yield*`, `spawn`, `Scope.run`), not where + references are stored or later used. +- Work is owned by **Scopes**. +- When a scope exits, all work created in that scope is halted. +- References do not extend lifetimes. Returning a `Task`, `Scope`, `Stream`, or + `AbortSignal` does not keep it alive. + +### Effects do not escape scopes + +- Values may escape scopes. +- Ongoing effects must not escape: tasks, resources, streams/subscriptions, and + context mutations must remain scope-bound. + +## Operations, Futures, Tasks + +### Operation + +- An `Operation` is a recipe for work. It does nothing by itself. +- Operations are typically created by invoking a generator function + (`function*`). + +### Future + +- A `Future` is both: + - an Effection operation (`yield* future`) + - a Promise (`await future`) + +### Task + +- A `Task` is a `Future` representing a concurrently running operation. +- A task does not own lifetime or context; its scope does. + +## Entry points and scope creation + +### `main()` + +- You should prefer `main()` when writing an entire program in Effection. +- Inside `main()`, prefer `yield* exit(status, message?)` for termination; do + not call `process.exit()` / `Deno.exit()` directly (it bypasses orderly + shutdown). + +### `exit()` + +- `exit()` is an operation intended to be used from within `main()` to initiate + shutdown. + +### `run()` + +- You may use `run()` to embed Effection into existing async code. +- `run()` starts execution immediately; awaiting the returned task only observes + completion. + +### `createScope()` + +- You must not use `createScope()` for normal Effection application code. +- You may use `createScope()` only for **integration** between Effection and + non-Effection lifecycle management (frameworks/hosts/embedders). +- You must observe `destroy()` (`await` / `yield*`) to complete teardown. + Calling `destroy()` without observation does not guarantee shutdown + completion. + +### `useScope()` + +- Use `yield* useScope()` to capture the current `Scope` for integration (e.g. + callbacks) and re-enter Effection with `scope.run(() => operation)`. + +## `spawn()` + +**Shape (canonical)** + +```ts +const op = spawn(myOperation); // returns an OPERATION +const task = yield * op; // returns a TASK (Future) and starts it +``` + +**Rules** + +- `spawn()` does not start work by itself. Yielding the spawn operation starts + work. +- Yielding `spawn()` does not guarantee that the child has reached any + particular point in its body before the parent continues. +- A spawned task must not outlive its parent scope. + +## `Task.halt()` + +**Rules** + +- `task.halt()` returns a `Future`. You must observe it (`await` / + `yield*` / `.then()`), or shutdown is not guaranteed to complete. +- `halt()` represents teardown. It can succeed even if the task failed. +- If a task is halted before completion, consuming its value (`yield* task` / + `await task`) fails with `Error("halted")`. + +## Scope vs Task (ownership) + +| Concept | Owns lifetime | Owns context | +| ------- | ------------: | -----------: | +| `Scope` | ✅ | ✅ | +| `Task` | ❌ | ❌ | + +## Context API (strict) + +**Valid APIs** + +- `createContext(name, defaultValue?)` +- `yield* Context.get()` +- `yield* Context.expect()` +- `yield* Context.set(value)` +- `yield* Context.delete()` +- `yield* Context.with(value, operation)` + +**Rules** + +- You must treat context as scope-local. Children inherit from parents; children + may override without mutating ancestors. +- You must not treat context as global mutable state. + +## `race()` + +**Rules** + +- `race()` accepts an array of operations. +- It returns the value of the first operation to complete. +- It halts all losing operations. + +## `all()` + +**Rules** + +- `all()` accepts an array of operations and evaluates them concurrently. +- It returns an array of results in input order. +- If any member errors, `all()` errors and halts the other members. +- If you need all results regardless of success or failure, use `allSettled()` + instead of wrapping each member in railway-style results. + +## `allSettled()` + +**Rules** + +- `allSettled()` accepts an array of operations and evaluates them concurrently. +- It returns an array of `Result` objects in input order + (`{ ok: true, value }` or `{ ok: false, error }`). +- It never short-circuits on error — all operations run to completion. +- It is analogous to `Promise.allSettled()`, but uses Effection's `Result` + shape. + +## `call()` + +**Rules** + +- `call()` invokes a function that returns a value, promise, or operation. +- `call()` does not create a scope boundary and does not delimit concurrency. +- If you need to report failures without throwing (e.g. so other work can + continue), catch errors and return a railway-style result object instead of + letting the error escape. + +## `lift()` + +**Rules** + +- `lift(fn)` returns a function that produces an `Operation` which calls `fn` + when interpreted (`yield*`), not when created. + +## `action()` + +**Rules** + +- Use `action()` to wrap callback-style APIs when you can provide a cleanup + function. +- You must not claim `action()` creates an error or concurrency boundary; it + does not. + +## `until()` + +**Rules** + +- `until(promise)` adapts an already-created `Promise` into an `Operation`. +- Prefer `until(promise)` over `call(() => promise)` when you have a promise—it + is shorter and clearer. +- It does not make the promise cancellable; for cancellable interop, prefer + `useAbortSignal()` with APIs that accept `AbortSignal`. + +## `scoped()` + +**Rules** + +- Use `scoped()` to create a boundary such that effects created inside do not + persist after it returns. +- You must use `scoped()` (not `call()`/`action()`) when you need boundary + semantics. + +## `resource()` + +**Shape (ordering matters)** + +```ts +// synchronous teardown +resource(function* (provide) { + try { + yield* provide(value); + } finally { + cleanup(); + } +}); + +// asynchronous teardown — ensure(), never finally +resource(function* (provide) { + yield* ensure(function* () { + yield* cleanup(); + }); + + yield* provide(value); +}); +``` + +**Rules** + +- Setup happens before `provide()`. +- Synchronous cleanup must be in `finally` (or after `provide()` guarded by + `finally`) so it runs on return/error/halt. +- Teardown can be asynchronous, but asynchronous teardown must go in `ensure()`. + Do not fire-and-forget cleanup. +- You must not `yield*` inside a `finally`. See the rationale under `ensure()`. + +## `ensure()` + +**Rules** + +- `ensure(fn)` registers cleanup to run when the current operation shuts down. +- `fn` may return `void` (sync cleanup) or an `Operation` (async cleanup). +- You should wrap sync cleanup bodies in braces so the function returns `void`. +- **Cleanup that needs `yield*` must use `ensure()`, not a `finally` block.** + +**Why `yield*` in a `finally` is unsafe** + +When a task is halted, a coroutine is unwound by calling `iterator.return()` on +its generator. If a `finally` block then yields, the generator suspends _inside_ +the finally and reports `{ done: false }`, so the routine resumes it with +`iterator.next()` — and that takes the frame out of return-mode. The frame is no +longer unwinding, so once cleanup finishes, execution continues past the +operation that was being halted. The halt is lost. + +This is the defect fixed inside `scoped()` in #1185: `iter.return()` yielded the +`destroy()` effects in scoped's finally, but the resume came back as +`iter.next()`. `scoped()` re-arms the unwind explicitly via `trap.exit()`. +Ordinary user code has no way to do that. + +`ensure()` is not affected. It is implemented as a `resource()`, so its +`finally` runs in its own task frame with nothing after it, and it is driven by +scope destruction — which `createTask` wraps in `critical()`, making it +non-interruptible. + +**Gotchas** + +- `ensure()` registers on the **current scope**, and `call()` does not create + one — it delegates to the target's iterator in the same coroutine frame. An + `ensure()` inside `call(function* () { ... })` attaches to the _enclosing + task's_ scope and fires far too late. Use `scoped()` or `spawn()` when you + need a boundary. Note that `scoped()`'s own async teardown was not correct + until 4.1, so code supporting older versions should prefer `spawn()`. +- Scope destructors run in **reverse order of registration**. Register the + `ensure()` where the `try {` would have been — after any `spawn()` calls its + cleanup depends on — so cleanup still runs while those children are alive. + +## `useAbortSignal()` + +**Rules** + +- `useAbortSignal()` is an interop escape hatch for non-Effection APIs that + accept `AbortSignal`. +- The returned signal is bound to the current scope and aborts when that scope + exits (return, error, or halt). +- You should pass the signal to a **leaf** async API call, not thread it through + a nested async stack. +- If the choice is "thread an AbortSignal through a nested async stack" vs + "rewrite in Effection", you should prefer rewriting in Effection. + +**Gotchas** + +- You must not assume AbortController provides structured-concurrency + guarantees. See: + https://frontside.com/blog/2025-08-04-the-heartbreaking-inadequacy-of-abort-controller/ + +## Streams, Subscriptions, Channels, Signals, Queues + +### Stream and Subscription + +- A `Stream` is an operation that yields a `Subscription`. +- A `Subscription` is stateful; values are observed via + `yield* subscription.next()`. + +### `on(target, name)` and `once(target, name)` (EventTarget adapters) + +**Rules** + +- `on()` creates a `Stream` of events from an `EventTarget`; listeners are + removed on scope exit. +- `once()` yields the next matching event as an `Operation` (it is equivalent to + subscribing to `on()` and taking one value). + +### `sleep()`, `interval()`, `suspend()` + +**Rules** + +- `sleep(ms)` is cancellable: if the surrounding scope exits, the timer is + cleared. +- `interval(ms)` is a `Stream` that ticks until the surrounding scope exits + (cleanup clears the interval). +- `suspend()` pauses indefinitely and only resumes when its enclosing scope is + destroyed. + +### `each(streamOrSubscription)` (loop consumption) + +**Rules** + +- `each()` accepts either a `Stream` or an existing `Subscription`, including a + `Queue`. +- Passing a `Stream` subscribes when `yield* each(stream)` is interpreted. +- Passing a `Subscription` to `each()` consumes that exact subscription; it does + not create another subscription. +- You must call `yield* each.next()` exactly once at the end of every loop + iteration. +- You must call `yield* each.next()` even if the iteration ends with `continue`. + +**Subscription readiness across `spawn()`** + +- If a spawned consumer must receive values sent immediately afterward, create + the subscription in the enclosing scope before spawning, then iterate that + subscription in the child. +- Do not use `yield* sleep(0)` after `spawn()` as a subscription-readiness + barrier. +- For `Channel` and `Signal`, values sent after `yield* stream` returns are + queued for that active subscription even if the child has not begun iterating. + Values sent before the subscription is active are still dropped. +- Passing a subscription to a child does not transfer or extend its lifetime. + The scope that created it must remain active for the consumer's full lifetime. +- Treat a subscription as a single consumer. For broadcast consumption, create + one subscription per consumer before sending values. + +**Gotchas** + +- If you do not call `each.next()`, the loop throws `IterationError` on the next + iteration. +- Leaving `each(subscription)` does not close that subscription. It remains + active until its owning scope exits. + +**Shape (ordering matters)** + +```ts +for (let value of yield * each(stream)) { + // ... + yield * each.next(); +} +``` + +**Shape (subscribe before spawning)** + +```ts +await main(function* () { + let channel = createChannel(); + let subscription = yield* channel; + + let consumer = yield* spawn(function* () { + for (let value of yield* each(subscription)) { + // ... + yield* each.next(); + } + }); + + // Safe immediately: the subscription is already active. + yield* channel.send("hello"); + yield* channel.close(); + yield* consumer; +}); +``` + +### Channel vs Signal vs Queue + +| Concept | Send from | Send API | Requires subscribers | Buffering | +| --------- | ------------------------------ | ------------------------- | ----------------------- | ------------------------------- | +| `Channel` | inside operations | `send(): Operation` | yes (otherwise dropped) | per-subscriber while subscribed | +| `Signal` | outside operations (callbacks) | `send(): void` | yes (otherwise no-op) | per-subscriber while subscribed | +| `Queue` | anywhere (single consumer) | `add(): void` | no | buffered (single subscription) | + +### `Channel` + +**Rules** + +- Use `createChannel()` to construct a `Channel`. +- Use `Channel` for communication between operations. +- You must `yield* channel.send(...)` / `yield* channel.close(...)`. +- You must assume sends are dropped when there are no active subscribers. + +### `Signal` + +**Rules** + +- Use `createSignal()` to construct a `Signal`. +- Use `Signal` only as a bridge from synchronous callbacks into an Effection + stream. +- You must not use `Signal` for in-operation messaging; use `Channel` instead. +- You must assume `signal.send(...)` is a no-op if nothing is subscribed. + +### `Queue` + +**Rules** + +- Use `createQueue()` to construct a `Queue`. +- You may use `Queue` when you need buffering independent of subscriber timing + (single consumer). +- A `Queue` is already a `Subscription`; consume it via `yield* queue.next()` or + iterate it with `each(queue)`. + +## `subscribe()` and `stream()` (async iterable adapters) + +**Rules** + +- Use `subscribe(asyncIterator)` to adapt an `AsyncIterator` to an Effection + `Subscription`. +- Use `stream(asyncIterable)` to adapt an `AsyncIterable` to an Effection + `Stream`. +- You must not treat JavaScript async iterables as Effection streams without + wrapping. +- You must not use `for await` inside a generator function. Use `stream()` to + adapt the async iterable, then `each()` to iterate. + +**Shape (async iterable consumption)** + +```ts +for (const item of yield * each(stream(asyncIterable))) { + // ... + yield * each.next(); +} +``` + +## `withResolvers()` + +**Rules** + +- `withResolvers()` creates an `operation` plus synchronous `resolve(value)` / + `reject(error)` functions. +- After resolve/reject, yielding the `operation` always produces the same + outcome; calling resolve/reject again has no effect. diff --git a/www/AGENTS.md b/www/AGENTS.md index cbcdd1b13..8ddd0edb3 100644 --- a/www/AGENTS.md +++ b/www/AGENTS.md @@ -162,7 +162,8 @@ structured concurrency. Wrong examples teach wrong patterns. **Before writing any code example:** -- Consult the root `AGENTS.md` for API correctness constraints. +- Consult [`docs/agents.md`](../docs/agents.md), the Effection behavioral + contract, for API correctness constraints. - Do NOT use `await` inside a generator function. Use `yield*`. - Do NOT call `spawn()` without `yield*` — `spawn()` returns an Operation, not a Task. @@ -175,7 +176,7 @@ structured concurrency. Wrong examples teach wrong patterns. - Verify imports match the actual Effection public API. - Verify the example would actually work if pasted into a file and run. - Check that `try/finally` patterns match the `resource()` and `ensure()` - conventions documented in the root `AGENTS.md`. + conventions documented in [`docs/agents.md`](../docs/agents.md). ## Writing Checklist @@ -198,7 +199,7 @@ structured concurrency. Wrong examples teach wrong patterns. - [ ] Conclusion is brief and is NOT a summary - [ ] No marketing-speak crept in -- [ ] All code examples are correct per the root `AGENTS.md` +- [ ] All code examples are correct per [`docs/agents.md`](../docs/agents.md) - [ ] Frontmatter is complete: title, description, author, tags, image - [ ] File is at `www/blog/YYYY-MM-DD-slug/index.md` - [ ] Run `deno fmt` and `deno lint` diff --git a/www/components/footer.tsx b/www/components/footer.tsx index c01a7801c..2aeab9a93 100644 --- a/www/components/footer.tsx +++ b/www/components/footer.tsx @@ -38,10 +38,7 @@ export function Footer(): JSX.Element { llms.txt - + AGENTS.md diff --git a/www/deno.json b/www/deno.json index 873e5351b..e0de630a6 100644 --- a/www/deno.json +++ b/www/deno.json @@ -1,8 +1,8 @@ { "tasks": { - "dev": "deno run -A @effectionx/watch deno run -A main.tsx", + "dev": "SITE_URL=http://localhost:8000 deno run -A @effectionx/watch deno run -A main.tsx", "staticalize": "deno run -A jsr:@frontside/staticalize@0.2.2/cli --site http://localhost:8000 --output=built --base=http://localhost:8000", - "test": "deno test --allow-run --allow-write --allow-read" + "test": "deno test --allow-run --allow-write --allow-read --allow-env" }, "lint": { "exclude": [ @@ -54,6 +54,7 @@ "hast-util-from-html": "npm:hast-util-from-html@2.0.3", "hast-util-shift-heading": "npm:hast-util-shift-heading@4.0.0", "hast-util-to-html": "npm:hast-util-to-html@9.0.0", + "hast-util-to-text": "npm:hast-util-to-text@4.0.2", "mdast": "npm:mdast@^3.0.0", "mdx": "npm:mdx@^0.3.1", "octokit": "npm:octokit@4.0.3", diff --git a/www/lib/api-markdown.test.ts b/www/lib/api-markdown.test.ts new file mode 100644 index 000000000..1de1063e7 --- /dev/null +++ b/www/lib/api-markdown.test.ts @@ -0,0 +1,142 @@ +import { describe, it } from "../testing.ts"; +import { expect } from "expect"; + +import { CurrentRequest } from "../context/request.ts"; +import { useSiteUrl } from "../plugins/current-request.ts"; +import { + apiIndexMarkdown, + apiSymbolMarkdown, + apiSymbolPath, + type ApiVersion, +} from "./api-markdown.ts"; + +const VERSIONS: ApiVersion[] = [ + { + series: "v4", + version: "4.1.1", + symbols: [ + { name: "main" }, + { name: "run" }, + { name: "peek", experimental: true }, + ], + }, + { series: "v3", version: "3.6.1", symbols: [{ name: "main" }] }, +]; + +describe("apiSymbolPath", () => { + it("puts experimental symbols under their own segment, like their pages", function* () { + expect(apiSymbolPath("v4", { name: "main" })).toEqual("/api/v4/main.md"); + expect(apiSymbolPath("v4", { name: "peek", experimental: true })).toEqual( + "/api/v4/experimental/peek.md", + ); + }); +}); + +describe("apiIndexMarkdown", () => { + it("links every symbol to the markdown page of its own version", function* () { + yield* CurrentRequest.set(new Request("http://localhost:8000/api.md")); + Deno.env.delete("SITE_URL"); + + let index = apiIndexMarkdown(VERSIONS, yield* useSiteUrl()); + + expect(index).toContain("# API Reference"); + expect(index).toContain("## 4.1.1"); + expect(index).toContain("## 3.6.1"); + expect(index).toContain("- [main](http://localhost:8000/api/v4/main.md)"); + expect(index).toContain("- [main](http://localhost:8000/api/v3/main.md)"); + expect(index).toContain( + "- [peek](http://localhost:8000/api/v4/experimental/peek.md) (experimental)", + ); + }); + + it("links to the markdown page of every symbol it lists", function* () { + yield* CurrentRequest.set(new Request("http://localhost:8000/api.md")); + Deno.env.delete("SITE_URL"); + + let url = yield* useSiteUrl(); + let index = apiIndexMarkdown(VERSIONS, url); + + for (let { series, symbols } of VERSIONS) { + for (let symbol of symbols) { + // the same path the symbol routes serve, built by the same function + expect(index).toContain(`(${url(apiSymbolPath(series, symbol))})`); + } + } + }); + + it("keeps its links on the site that is serving it", function* () { + yield* CurrentRequest.set(new Request("http://127.0.0.1:8000/api.md")); + Deno.env.set("SITE_URL", "https://pr-42--effection.netlify.app"); + + try { + let index = apiIndexMarkdown(VERSIONS, yield* useSiteUrl()); + + expect(index).toContain( + "- [main](https://pr-42--effection.netlify.app/api/v4/main.md)", + ); + expect(index).not.toContain("127.0.0.1"); + expect(index).not.toContain("frontside.com"); + } finally { + Deno.env.delete("SITE_URL"); + } + }); + + it("keeps the production base path", function* () { + yield* CurrentRequest.set(new Request("http://127.0.0.1:8000/api.md")); + Deno.env.set("SITE_URL", "https://frontside.com/effection"); + + try { + let index = apiIndexMarkdown(VERSIONS, yield* useSiteUrl()); + + expect(index).toContain( + "- [main](https://frontside.com/effection/api/v4/main.md)", + ); + } finally { + Deno.env.delete("SITE_URL"); + } + }); +}); + +describe("apiSymbolMarkdown", () => { + it("writes the declaration, the documentation and where the code lives", function* () { + let page = apiSymbolMarkdown("main", [ + { + signature: + "async function main(body: (args: string[]) => Operation): Promise", + markdown: "Top-level entry point to programs written in Effection.\n", + source: + "https://github.com/thefrontside/effection/tree/effection-v4.1.1/lib/main.ts#L63", + }, + ]); + + expect(page).toEqual( + `# main + +\`\`\`ts +async function main(body: (args: string[]) => Operation): Promise +\`\`\` + +Top-level entry point to programs written in Effection. + +[View code](https://github.com/thefrontside/effection/tree/effection-v4.1.1/lib/main.ts#L63) +`, + ); + }); + + it("writes one block per declaration", function* () { + let page = apiSymbolMarkdown("call", [ + { + signature: "function call(fn: () => void): Operation", + markdown: "one", + }, + { + signature: "function call(promise: Promise): Operation", + markdown: "two", + }, + ]); + + expect(page.match(/```ts/g)).toHaveLength(2); + expect(page).toContain("one"); + expect(page).toContain("two"); + }); +}); diff --git a/www/lib/api-markdown.ts b/www/lib/api-markdown.ts new file mode 100644 index 000000000..e91acc7ca --- /dev/null +++ b/www/lib/api-markdown.ts @@ -0,0 +1,77 @@ +/** + * The markdown twin of the API reference: the same symbols the HTML index and + * symbol pages show, written out as markdown so that an agent following + * `llms.txt` never has to read a rendered page. + * + * These are the string builders. The routes in `routes/api-markdown-route.ts` + * supply the content, which comes from the same `pkg.docs()` the HTML routes + * render. + */ + +export interface ApiSymbol { + name: string; + /** exported from the package's `./experimental` entrypoint */ + experimental?: boolean; +} + +export interface ApiVersion { + /** series the symbols belong to, e.g. `v4` */ + series: string; + /** version of the release that series resolves to, e.g. `4.1.1` */ + version: string; + symbols: ApiSymbol[]; +} + +export interface ApiSection { + /** the declaration, as the symbol page shows it */ + signature: string; + /** the symbol's documentation */ + markdown: string; + /** where the declaration lives */ + source?: string; +} + +/** + * Path of a symbol's markdown page. Experimental symbols live under an + * `/experimental` segment, the same as their HTML pages. + */ +export function apiSymbolPath(series: string, symbol: ApiSymbol): string { + let namespace = symbol.experimental ? `${series}/experimental` : series; + + return `/api/${namespace}/${symbol.name}.md`; +} + +export function apiIndexMarkdown( + versions: ApiVersion[], + url: (path: string) => string, +): string { + let sections = versions.map(({ series, version, symbols }) => { + let entries = symbols.map((symbol) => { + let href = url(apiSymbolPath(series, symbol)); + let suffix = symbol.experimental ? " (experimental)" : ""; + + return `- [${symbol.name}](${href})${suffix}`; + }); + + return [`## ${version}`, "", ...entries].join("\n"); + }); + + return `${["# API Reference", ...sections].join("\n\n")}\n`; +} + +export function apiSymbolMarkdown( + name: string, + sections: ApiSection[], +): string { + let bodies = sections.map(({ signature, markdown, source }) => { + let body = ["```ts", signature, "```", "", markdown.trim()]; + + if (source) { + body.push("", `[View code](${source})`); + } + + return body.join("\n"); + }); + + return `${[`# ${name}`, ...bodies].join("\n\n")}\n`; +} diff --git a/www/lib/markdown-response.test.ts b/www/lib/markdown-response.test.ts new file mode 100644 index 000000000..4786069bd --- /dev/null +++ b/www/lib/markdown-response.test.ts @@ -0,0 +1,26 @@ +import { assertEquals } from "@std/assert"; + +import { markdown, notFound } from "./markdown-response.ts"; + +Deno.test("markdown() serves text a browser will not hold on to", async () => { + let response = markdown("# hello\n"); + + assertEquals(response.status, 200); + assertEquals( + response.headers.get("Content-Type"), + "text/markdown; charset=utf-8", + ); + assertEquals(response.headers.get("Cache-Control"), "no-cache"); + assertEquals(await response.text(), "# hello\n"); +}); + +Deno.test("notFound() says what was not found", async () => { + let response = notFound("there is no package called 'nope'"); + + assertEquals(response.status, 404); + assertEquals( + response.headers.get("Content-Type"), + "text/plain; charset=utf-8", + ); + assertEquals(await response.text(), "there is no package called 'nope'\n"); +}); diff --git a/www/lib/markdown-response.ts b/www/lib/markdown-response.ts new file mode 100644 index 000000000..0331a5d8a --- /dev/null +++ b/www/lib/markdown-response.ts @@ -0,0 +1,28 @@ +/** + * Responses for the markdown the site serves to agents: the behavioral + * contract, the guides, and package readmes. + * + * `no-cache` rather than a max age because the header only ever reaches a + * browser talking to the dev server — a static build copies the body and + * netlify supplies its own — and these documents are rewritten per + * environment, so a browser must not hold one environment's copy and show it + * in another. The etag plugin answers the revalidation with a 304. + */ +export function markdown(content: string): Response { + return new Response(content, { + headers: { + "Content-Type": "text/markdown; charset=utf-8", + "Cache-Control": "no-cache", + }, + }); +} + +export function notFound(message: string): Response { + return new Response(`${message}\n`, { + status: 404, + headers: { + "Content-Type": "text/plain; charset=utf-8", + "Cache-Control": "no-cache", + }, + }); +} diff --git a/www/main.tsx b/www/main.tsx index 77ff48642..efc3e2f5c 100644 --- a/www/main.tsx +++ b/www/main.tsx @@ -9,9 +9,11 @@ import { tailwindPlugin } from "./plugins/tailwind.ts"; import { apiReferenceRoute } from "./routes/api-reference-route.tsx"; import { assetsRoute } from "./routes/assets-route.ts"; import { firstPage, guidesRoute } from "./routes/guides-route.tsx"; +import { guidesMarkdownRoute } from "./routes/guides-markdown-route.ts"; import { indexRoute } from "./routes/index-route.tsx"; import { xIndexRedirect, xIndexRoute } from "./routes/x-index-route.tsx"; import { xPackageRedirect, xPackageRoute } from "./routes/x-package-route.tsx"; +import { xPackageMarkdownRoute } from "./routes/x-package-markdown-route.ts"; import { useConfig } from "./context/config.ts"; import { initFetch } from "./context/fetch.ts"; @@ -22,11 +24,16 @@ import { initBlog } from "./resources/blog.ts"; import { initFonts } from "./resources/fonts.ts"; import { initImageStore } from "./resources/image-store.ts"; import { apiIndexRoute } from "./routes/api-index-route.tsx"; +import { + apiIndexMarkdownRoute, + apiSymbolMarkdownRoute, +} from "./routes/api-markdown-route.ts"; import { blogIndexRoute } from "./routes/blog-index-route.tsx"; import { blogPostRoute } from "./routes/blog-post-route.tsx"; import { blogImageRoute } from "./routes/blog-image-route.ts"; import { blogTagRoute } from "./routes/blog-tag-route.tsx"; import { blogFeedRoute } from "./routes/blog-feed-route.tsx"; +import { agentsMdRoute } from "./routes/agents-md-route.ts"; import { llmsTxtRoute } from "./routes/llms-txt-route.ts"; import { pagefindRoute } from "./routes/pagefind-route.ts"; import { redirectDocsRoute } from "./routes/redirect-docs-route.tsx"; @@ -73,12 +80,27 @@ if (import.meta.main) { ...stableSeries.map((s) => route(`/guides/${s.name}`, redirectIndexRoute(firstPage(s.name))) ), + // before the page route, so that `.md` is a suffix and not a guide id + route("/guides/:series/:id.md", guidesMarkdownRoute()), route("/guides/:series/:id", guidesRoute({ search: true })), route("/contrib", xIndexRedirect()), route("/contrib/:workspacePath", xPackageRedirect()), route("/x", xIndexRoute({ search: true })), + // before the page route, so that `.md` is a suffix and not a package + route("/x/:workspacePath.md", xPackageMarkdownRoute()), route("/x/:workspacePath", xPackageRoute({ search: true })), route("/api", apiIndexRoute({ search: true })), + // before the page routes, so that `.md` is a suffix and not a symbol + route("/api.md", apiIndexMarkdownRoute()), + ...series.map((s) => + route(`/api/${s.name}/:symbol.md`, apiSymbolMarkdownRoute(s.name)) + ), + ...series.map((s) => + route( + `/api/${s.name}/experimental/:symbol.md`, + apiSymbolMarkdownRoute(s.name, { entrypoint: "./experimental" }), + ) + ), // API docs for all series including prereleases ...series.map((s) => route( @@ -100,6 +122,7 @@ if (import.meta.main) { route("/blog", blogIndexRoute({ search: true })), route("/blog/feed.xml", blogFeedRoute()), route("/llms.txt", llmsTxtRoute()), + route("/AGENTS.md", agentsMdRoute()), route("/blog/tags/:tag", blogTagRoute({ search: true })), route("/blog/:id", blogPostRoute({ search: true })), route("/blog/:id/:name.png", blogImageRoute()), diff --git a/www/plugins/current-request.test.ts b/www/plugins/current-request.test.ts new file mode 100644 index 000000000..98444f80b --- /dev/null +++ b/www/plugins/current-request.test.ts @@ -0,0 +1,47 @@ +import { describe, it } from "../testing.ts"; +import { expect } from "expect"; + +import { CurrentRequest } from "../context/request.ts"; +import { useSiteUrl } from "./current-request.ts"; + +describe("useSiteUrl", () => { + it("uses the origin of the current request by default", function* () { + yield* CurrentRequest.set(new Request("http://localhost:8000/llms.txt")); + Deno.env.delete("SITE_URL"); + + let url = yield* useSiteUrl(); + + expect(url("/x/")).toEqual("http://localhost:8000/x/"); + expect(url("/x/websocket")).toEqual("http://localhost:8000/x/websocket"); + }); + + it("uses SITE_URL when it has no path of its own", function* () { + yield* CurrentRequest.set(new Request("http://127.0.0.1:8000/llms.txt")); + Deno.env.set("SITE_URL", "https://pr-42--effection.netlify.app"); + + try { + let url = yield* useSiteUrl(); + + expect(url("/x/")).toEqual("https://pr-42--effection.netlify.app/x/"); + expect(url("/blog")).toEqual( + "https://pr-42--effection.netlify.app/blog", + ); + } finally { + Deno.env.delete("SITE_URL"); + } + }); + + it("uses SITE_URL when it is set, preserving its path", function* () { + yield* CurrentRequest.set(new Request("http://127.0.0.1:8000/llms.txt")); + Deno.env.set("SITE_URL", "https://frontside.com/effection"); + + try { + let url = yield* useSiteUrl(); + + expect(url("/x/")).toEqual("https://frontside.com/effection/x/"); + expect(url("/blog")).toEqual("https://frontside.com/effection/blog"); + } finally { + Deno.env.delete("SITE_URL"); + } + }); +}); diff --git a/www/plugins/current-request.ts b/www/plugins/current-request.ts index 6347074f7..27876e94f 100644 --- a/www/plugins/current-request.ts +++ b/www/plugins/current-request.ts @@ -45,3 +45,30 @@ export function* useCanonicalUrl(options: { base: string }): Operation { url.pathname = `${url.pathname}${req.pathname}`; return String(url); } + +/** + * Like {@link useAbsoluteUrlFactory}, except that it honors the `SITE_URL` + * of the published site when one is configured. + * + * Absolute urls in HTML are rewritten to the destination site by staticalize + * when the site is built, so they can just use the origin of the request. + * Urls inside non HTML resources such as `llms.txt` are copied verbatim, so + * they need to be published under `SITE_URL` instead of the loopback address + * that the build serves from. With no `SITE_URL`, they point at the dev + * server, same as every other absolute url. + */ +export function* useSiteUrl(): Operation<(path: string) => string> { + let siteUrl = Deno.env.get("SITE_URL"); + + if (!siteUrl) { + return yield* useAbsoluteUrlFactory(); + } + + let base = new URL(siteUrl); + + return (path) => { + let url = new URL(base); + url.pathname = posixNormalize(`${base.pathname}/${path}`); + return url.toString(); + }; +} diff --git a/www/resources/guides.ts b/www/resources/guides.ts index f5ab0ef33..b093d00cd 100644 --- a/www/resources/guides.ts +++ b/www/resources/guides.ts @@ -1,4 +1,4 @@ -import { basename } from "@std/path"; +import { basename, toFileUrl } from "@std/path"; import { all, createContext, @@ -98,7 +98,11 @@ export function loadGuides(dirpath: string): Operation { let loaders = new Map>(); let structureModule = yield* until( - import(`${dirpath}/docs/structure.json`, { with: { type: "json" } }), + // a path is not a module specifier on windows, where it starts with a + // drive letter that deno reads as an unsupported scheme + import(toFileUrl(`${dirpath}/docs/structure.json`).href, { + with: { type: "json" }, + }), ); let structure = Structure.parse(structureModule.default); diff --git a/www/routes/agents-md-route.test.ts b/www/routes/agents-md-route.test.ts new file mode 100644 index 000000000..df23f8e5b --- /dev/null +++ b/www/routes/agents-md-route.test.ts @@ -0,0 +1,147 @@ +import { describe, it } from "../testing.ts"; +import { expect } from "expect"; +import type { Operation } from "effection"; +import { until } from "effection"; +import { fromFileUrl } from "@std/path"; +import { toHtml } from "hast-util-to-html"; + +import { CurrentRequest } from "../context/request.ts"; +import { Footer } from "../components/footer.tsx"; +import { agentsMdRoute } from "./agents-md-route.ts"; + +const PUBLIC_CONTRACT = fromFileUrl( + import.meta.resolve("../../docs/agents.md"), +); +const REPOSITORY_CONTRACT = fromFileUrl(import.meta.resolve("../../AGENTS.md")); +const WWW_INSTRUCTIONS = fromFileUrl(import.meta.resolve("../AGENTS.md")); + +function* get(url: string): Operation { + let { handler } = agentsMdRoute(); + + return yield* handler(new Request(url), function* (): Operation { + throw new Error("the route handles the request itself"); + }); +} + +describe("agentsMdRoute", () => { + it("serves the behavioral contract as markdown", function* () { + yield* CurrentRequest.set(new Request("http://localhost:8000/AGENTS.md")); + Deno.env.delete("SITE_URL"); + + let response = yield* get("http://localhost:8000/AGENTS.md"); + + expect(response.status).toEqual(200); + expect(response.headers.get("Content-Type")).toEqual( + "text/markdown; charset=utf-8", + ); + // the contract is rewritten per environment, so a browser must not hold + // one environment's copy and show it in another + expect(response.headers.get("Cache-Control")).toEqual("no-cache"); + + let body = yield* until(response.text()); + + expect(body).toContain("### Operations vs Promises"); + expect(body).toContain("## `ensure()`"); + expect(body).toContain("## `useAbortSignal()`"); + }); + + it("leaves the repository's own rules out of it", function* () { + yield* CurrentRequest.set(new Request("http://localhost:8000/AGENTS.md")); + Deno.env.delete("SITE_URL"); + + let body = yield* until( + (yield* get("http://localhost:8000/AGENTS.md")).text(), + ); + + expect(body).not.toContain("## Commit and PR conventions"); + expect(body).not.toContain("## Pre-commit workflow"); + expect(body).not.toContain("gitmoji"); + }); + + it("points its own documentation links at the dev server", function* () { + yield* CurrentRequest.set(new Request("http://localhost:8000/AGENTS.md")); + Deno.env.delete("SITE_URL"); + + let body = yield* until( + (yield* get("http://localhost:8000/AGENTS.md")).text(), + ); + + expect(body).toContain("http://localhost:8000/api/"); + expect(body).not.toContain("https://frontside.com/effection/api/"); + expect(body).not.toContain("raw.githubusercontent.com"); + }); + + it("points them at the base path of the published site", function* () { + yield* CurrentRequest.set(new Request("http://127.0.0.1:8000/AGENTS.md")); + Deno.env.set("SITE_URL", "https://frontside.com/effection"); + + try { + let body = yield* until( + (yield* get("http://127.0.0.1:8000/AGENTS.md")).text(), + ); + + expect(body).toContain("https://frontside.com/effection/api/"); + expect(body).not.toContain("127.0.0.1"); + } finally { + Deno.env.delete("SITE_URL"); + } + }); + + it("leaves urls that are not part of this site alone", function* () { + yield* CurrentRequest.set(new Request("http://localhost:8000/AGENTS.md")); + Deno.env.delete("SITE_URL"); + + let body = yield* until( + (yield* get("http://localhost:8000/AGENTS.md")).text(), + ); + + expect(body).toContain( + "https://frontside.com/blog/2025-08-04-the-heartbreaking-inadequacy-of-abort-controller/", + ); + }); + + it("is in the sitemap, so that it is captured by a static build", function* () { + let { routemap } = agentsMdRoute(); + + let paths = yield* routemap!( + () => "/AGENTS.md", + new Request("http://localhost:8000/sitemap.xml"), + ); + + expect(paths).toEqual([{ pathname: "/AGENTS.md" }]); + }); +}); + +describe("agent instructions", () => { + it("keeps the repository's rules in the root AGENTS.md, pointing at the contract", function* () { + let root = yield* until(Deno.readTextFile(REPOSITORY_CONTRACT)); + + expect(root).toContain("docs/agents.md"); + expect(root).toContain("## Commit and PR conventions"); + expect(root).toContain("## Pre-commit workflow"); + expect(root).toContain("## Pull requests"); + expect(root).not.toContain("### Operations vs Promises"); + }); + + it("keeps www/AGENTS.md scoped to writing for the website", function* () { + let scoped = yield* until(Deno.readTextFile(WWW_INSTRUCTIONS)); + + expect(scoped).toContain("# Effection Blog — Writing Agent Guide"); + expect(scoped).toContain("docs/agents.md"); + expect(scoped).not.toContain("## Core invariants (do not violate)"); + }); + + it("keeps the contract itself out of the repository's root", function* () { + let contract = yield* until(Deno.readTextFile(PUBLIC_CONTRACT)); + + expect(contract).toContain("## Core invariants (do not violate)"); + expect(contract).not.toContain("## Pre-commit workflow"); + }); + + it("links to the hosted route from the footer", function* () { + let html = toHtml(Footer() as Parameters[0]); + + expect(html).toContain('href="/AGENTS.md"'); + expect(html).not.toContain("raw.githubusercontent.com"); + }); +}); diff --git a/www/routes/agents-md-route.ts b/www/routes/agents-md-route.ts new file mode 100644 index 000000000..09b0244c5 --- /dev/null +++ b/www/routes/agents-md-route.ts @@ -0,0 +1,62 @@ +import type { Operation } from "effection"; +import { until } from "effection"; +import { fromFileUrl } from "@std/path"; + +import type { SitemapRoute } from "../plugins/sitemap.ts"; +import { useSiteUrl } from "../plugins/current-request.ts"; +import { markdown } from "../lib/markdown-response.ts"; + +/** + * The site's canonical url, as documents in the repository spell it. Links + * that start with it are rewritten to the site serving them, the same way + * `llms.txt` builds its links. + */ +const CANONICAL_SITE_URL = "https://frontside.com/effection"; + +/** + * Matches the canonical url, but not a url that merely starts with it, so + * that `https://frontside.com/effectionx` is left alone. + */ +const CANONICAL_LINK = new RegExp( + `${CANONICAL_SITE_URL.replaceAll(".", "\\.")}(?=[/#?)\\s]|$)`, + "g", +); + +/** + * Serve the Effection behavioral contract that `llms.txt` sends agents to. + * + * `docs/agents.md` is the only copy: the file is read from the checkout on + * each request rather than duplicated here, and the root `AGENTS.md` points at + * that same file for anyone working in the repository. + * + * Its links to the documentation and the API reference are written as + * canonical urls, so a preview or a dev server rewrites them to itself and an + * agent reading them stays on the site it came from. Urls elsewhere, such as + * the Frontside blog, are left as they are. + */ +export function agentsMdRoute(): SitemapRoute { + // `.pathname` would yield `/C:/…` on Windows; `fromFileUrl` gives real paths. + let path = fromFileUrl(import.meta.resolve("../../docs/agents.md")); + + return { + *routemap(generate) { + return [{ pathname: generate() }]; + }, + *handler(): Operation { + let url = yield* useSiteUrl(); + let source = yield* until(Deno.readTextFile(path)); + + return markdown(rewriteSiteLinks(source, url)); + }, + }; +} + +export function rewriteSiteLinks( + content: string, + url: (path: string) => string, +): string { + // `url("/")` ends in the slash that each canonical link already carries + let site = url("/").replace(/\/$/, ""); + + return content.replaceAll(CANONICAL_LINK, site); +} diff --git a/www/routes/api-markdown-route.ts b/www/routes/api-markdown-route.ts new file mode 100644 index 000000000..235199ebc --- /dev/null +++ b/www/routes/api-markdown-route.ts @@ -0,0 +1,145 @@ +import { type Operation } from "effection"; +import { useParams } from "revolution"; +import { toText } from "hast-util-to-text"; +import type { Nodes } from "hast"; + +import { Type } from "../components/type/jsx.tsx"; +import { useConfig } from "../context/config.ts"; +import type { DocPage, LocalDocPage } from "../hooks/use-deno-doc.tsx"; +import { createJsDocSanitizer } from "../hooks/use-markdown.tsx"; +import { + apiIndexMarkdown, + type ApiSection, + apiSymbolMarkdown, + apiSymbolPath, + type ApiVersion, +} from "../lib/api-markdown.ts"; +import { markdown, notFound } from "../lib/markdown-response.ts"; +import { usePackage } from "../lib/package.ts"; +import { useSiteUrl } from "../plugins/current-request.ts"; +import type { RoutePath, SitemapRoute } from "../plugins/sitemap.ts"; + +/** + * Markdown index of the API reference. + * + * Lists what the HTML index at `/api` lists — every stable series, newest + * first, with its symbols — and links to each symbol's markdown page rather + * than its page. `llms.txt` points here, so that the catalog of symbols lives + * in one place instead of being copied into it. + */ +export function apiIndexMarkdownRoute(): SitemapRoute { + return { + *routemap(generate) { + return [{ pathname: generate() }]; + }, + *handler(): Operation { + let url = yield* useSiteUrl(); + + return markdown(apiIndexMarkdown(yield* apiVersions(), url)); + }, + }; +} + +/** + * Markdown page for one API symbol, from the same `pkg.docs()` the HTML page + * renders: the declaration as the page shows it, the symbol's documentation, + * and where the code lives. + */ +export function apiSymbolMarkdownRoute( + series: string, + { entrypoint = "." }: { entrypoint?: string } = {}, +): SitemapRoute { + return { + *routemap(generate): Operation { + let pages = yield* symbolPages(series, entrypoint); + + return pages.map((page) => ({ + pathname: generate({ symbol: page.name }), + })); + }, + *handler(): Operation { + let { symbol } = yield* useParams<{ symbol: string }>(); + + let pages = yield* symbolPages(series, entrypoint); + let page = pages.find((candidate) => candidate.name === symbol); + + if (!page) { + return notFound(`there is no ${series} api symbol called '${symbol}'`); + } + + let url = yield* useSiteUrl(); + let sanitize = createJsDocSanitizer(function* (name, connector, method) { + let target = pages.find((candidate) => candidate.name === name); + + if (!target) { + return [name, connector, method].filter(Boolean).join(""); + } + + let href = url(apiSymbolPath(series, target)); + + return `[${ + [name, connector, method].filter(Boolean).join("") + }](${href})`; + }); + + let sections: ApiSection[] = []; + + for (let section of page.sections) { + if (!section.markdown) { + continue; + } + + sections.push({ + signature: toText( + (yield* Type({ + declaration: section.declaration, + symbol: { name: page.name }, + })) as Nodes, + ), + markdown: yield* sanitize(section.markdown), + source: section.declaration.location?.url?.toString(), + }); + } + + return markdown(apiSymbolMarkdown(page.name, sections)); + }, + }; +} + +/** + * Every stable series, newest first, the way the HTML index orders them. + */ +function* apiVersions(): Operation { + let { series } = yield* useConfig(); + let versions: ApiVersion[] = []; + + for (let entry of series.filter((s) => !s.includePrerelease).reverse()) { + let pkg = yield* usePackage({ type: "worktree", series: entry.name }); + let docs = yield* pkg.docs(); + + versions.push({ + series: entry.name, + version: pkg.version, + symbols: [ + ...(docs["."] ?? []).map(symbolOf), + ...(docs["./experimental"] ?? []).map(symbolOf), + ], + }); + } + + return versions; +} + +function symbolOf(page: DocPage) { + return { name: page.name, experimental: page.experimental }; +} + +function* symbolPages( + series: string, + entrypoint: string, +): Operation { + let pkg = yield* usePackage({ type: "worktree", series }); + let docs = yield* pkg.docs(); + + return docs[entrypoint] ?? []; +} diff --git a/www/routes/blog-feed-route.tsx b/www/routes/blog-feed-route.tsx index 47921e54e..80ea4fec2 100644 --- a/www/routes/blog-feed-route.tsx +++ b/www/routes/blog-feed-route.tsx @@ -2,6 +2,7 @@ import type { Operation } from "effection"; import { stringify } from "@libs/xml"; import { useBlog } from "../resources/blog.ts"; +import { useSiteUrl } from "../plugins/current-request.ts"; /** * RSS 2.0 feed for the blog @@ -11,8 +12,7 @@ export function blogFeedRoute() { *handler(): Operation { let blog = yield* useBlog(); let posts = blog.getPosts(); - - let baseUrl = "https://frontside.com/effection"; + let url = yield* useSiteUrl(); let xml = stringify({ "@version": "1.0", @@ -22,18 +22,18 @@ export function blogFeedRoute() { "@xmlns:atom": "http://www.w3.org/2005/Atom", channel: { title: "Effection Blog", - link: `${baseUrl}/blog`, + link: url("/blog"), description: "Tutorials, announcements, and insights about structured concurrency in JavaScript with Effection.", language: "en-us", lastBuildDate: new Date().toUTCString(), "atom:link": { - "@href": `${baseUrl}/blog/feed.xml`, + "@href": url("/blog/feed.xml"), "@rel": "self", "@type": "application/rss+xml", }, item: posts.slice(0, 20).map((post) => { - let postUrl = `${baseUrl}/blog/${post.id}/`; + let postUrl = url(`/blog/${post.id}/`); return { title: post.title, link: postUrl, diff --git a/www/routes/guides-markdown-route.test.ts b/www/routes/guides-markdown-route.test.ts new file mode 100644 index 000000000..c9d001062 --- /dev/null +++ b/www/routes/guides-markdown-route.test.ts @@ -0,0 +1,67 @@ +import { describe, it } from "../testing.ts"; +import { expect } from "expect"; +import type { Operation } from "effection"; +import { until } from "effection"; +import { fromFileUrl } from "@std/path"; + +import { initConfig } from "../context/config.ts"; +import { initGuides } from "../resources/guides.ts"; +import { route, type SitemapExtension } from "../plugins/sitemap.ts"; +import { guidesMarkdownRoute } from "./guides-markdown-route.ts"; + +const OPERATIONS = fromFileUrl( + import.meta.resolve("../../docs/operations.mdx"), +); + +let middleware = route("/guides/:series/:id.md", guidesMarkdownRoute()); + +function* get(url: string): Operation { + return yield* middleware(new Request(url), function* (): Operation { + throw new Error("the route handles the request itself"); + }); +} + +/** + * Only the checked out series is on disk here; the dev server checks the + * others out into worktrees, so limit the config to the one we have. + */ +function* onlyThisCheckout(): Operation { + yield* initConfig({ series: [{ name: "v4", major: 4 }], current: "v4" }); + yield* initGuides({ current: "v4", worktrees: [] }); +} + +describe("guidesMarkdownRoute", () => { + it("serves a guide's markdown source", function* () { + yield* onlyThisCheckout(); + + let response = yield* get("http://localhost:8000/guides/v4/operations.md"); + + expect(response.status).toEqual(200); + expect(response.headers.get("Content-Type")).toEqual( + "text/markdown; charset=utf-8", + ); + expect(yield* until(response.text())).toEqual( + yield* until(Deno.readTextFile(OPERATIONS)), + ); + }); + + it("does not invent a guide that is not there", function* () { + yield* onlyThisCheckout(); + + let response = yield* get("http://localhost:8000/guides/v4/nope.md"); + + expect(response.status).toEqual(404); + }); + + it("lists every guide in the sitemap, so a static build captures them", function* () { + yield* onlyThisCheckout(); + + let paths = yield* (middleware as SitemapExtension).sitemapExtension!( + new Request("http://localhost:8000/sitemap.xml"), + ); + + expect(paths).toContainEqual({ pathname: "/guides/v4/operations.md" }); + expect(paths).toContainEqual({ pathname: "/guides/v4/scope.md" }); + expect(paths.every((path) => path.pathname.endsWith(".md"))).toBe(true); + }); +}); diff --git a/www/routes/guides-markdown-route.ts b/www/routes/guides-markdown-route.ts new file mode 100644 index 000000000..7b987176a --- /dev/null +++ b/www/routes/guides-markdown-route.ts @@ -0,0 +1,57 @@ +import { all, type Operation } from "effection"; +import { useParams } from "revolution"; + +import { useConfig } from "../context/config.ts"; +import { useGuides } from "../resources/guides.ts"; +import { markdown, notFound } from "../lib/markdown-response.ts"; +import type { RoutePath, SitemapRoute } from "../plugins/sitemap.ts"; + +/** + * Serve the markdown source of a guide. + * + * `llms.txt` sends agents to the guides, and an agent wants what the guide is + * written in rather than the page it is rendered into. Serving the source from + * the site keeps a dev server or a preview from sending them to GitHub for a + * copy of the docs that belongs to a different version of the site. + */ +export function guidesMarkdownRoute(): SitemapRoute { + return { + *routemap(generate): Operation { + let { series } = yield* useConfig(); + // guides only exist for stable series, the same ones the pages cover + let stable = series.filter((s) => !s.includePrerelease); + + let paths = stable.map(function* (s) { + let pages = yield* useGuides(s.name); + + return (yield* pages.all()).map((page) => ({ + pathname: generate({ id: page.id, series: s.name }), + })); + }); + + return (yield* all(paths)).flat(); + }, + *handler(): Operation { + let { series: allSeries, current } = yield* useConfig(); + let stable = allSeries.filter((s) => !s.includePrerelease); + + let { id, series = current } = yield* useParams<{ + id: string; + series: string | undefined; + }>(); + + if (!stable.some((s) => s.name === series)) { + return notFound(`there are no guides for '${series}'`); + } + + let pages = yield* useGuides(series); + let page = yield* pages.get(id); + + if (!page) { + return notFound(`there is no guide called '${id}' in ${series}`); + } + + return markdown(page.markdown); + }, + }; +} diff --git a/www/routes/llms-txt-route.test.ts b/www/routes/llms-txt-route.test.ts new file mode 100644 index 000000000..7588ae0d9 --- /dev/null +++ b/www/routes/llms-txt-route.test.ts @@ -0,0 +1,96 @@ +import { describe, it } from "../testing.ts"; +import { expect } from "expect"; + +import { CurrentRequest } from "../context/request.ts"; +import { useSiteUrl } from "../plugins/current-request.ts"; +import { LLMS_TXT_HEADER, llmsTxtFooter } from "./llms-txt-route.ts"; + +describe("llmsTxtFooter", () => { + it("points AGENTS.md at the dev server it is served from", function* () { + yield* CurrentRequest.set(new Request("http://localhost:8000/llms.txt")); + Deno.env.delete("SITE_URL"); + + let footer = llmsTxtFooter(yield* useSiteUrl(), "v4"); + + expect(footer).toContain("[AGENTS.md]: http://localhost:8000/AGENTS.md"); + expect(footer).toContain("[API]: http://localhost:8000/api.md"); + expect(footer).toContain( + "[Operations]: http://localhost:8000/guides/v4/operations.md", + ); + }); + + it("points AGENTS.md at the site's base path in production", function* () { + yield* CurrentRequest.set(new Request("http://127.0.0.1:8000/llms.txt")); + Deno.env.set("SITE_URL", "https://frontside.com/effection"); + + try { + let footer = llmsTxtFooter(yield* useSiteUrl(), "v4"); + + expect(footer).toContain( + "[AGENTS.md]: https://frontside.com/effection/AGENTS.md", + ); + expect(footer).toContain( + "[Operations]: https://frontside.com/effection/guides/v4/operations.md", + ); + } finally { + Deno.env.delete("SITE_URL"); + } + }); + + it("no longer sends agents to raw.githubusercontent.com at all", function* () { + yield* CurrentRequest.set(new Request("http://localhost:8000/llms.txt")); + Deno.env.delete("SITE_URL"); + + let footer = llmsTxtFooter(yield* useSiteUrl(), "v4"); + + expect(footer).not.toContain("raw.githubusercontent.com"); + expect(footer).not.toContain("github.com"); + + let definitions = footer.split("\n").filter((line) => + /^\[[^\]]+\]: /.test(line) + ); + + expect(definitions.length).toBeGreaterThan(0); + for (let definition of definitions) { + expect(definition).toContain("http://localhost:8000/"); + } + }); + + it("defines every reference it uses", function* () { + yield* CurrentRequest.set(new Request("http://localhost:8000/llms.txt")); + Deno.env.delete("SITE_URL"); + + let document = `${LLMS_TXT_HEADER}\n${ + llmsTxtFooter(yield* useSiteUrl(), "v4") + }`; + + let defined = new Set( + [...document.matchAll(/^\[([^\]]+)\]: /gm)].map(([, label]) => label), + ); + // the label of `[label]` and of `[text][label]`, but not `[text](url)`, + // not the text of `[text][label]`, and not a definition + let used = [...document.matchAll(/\[([^\]]+)\](?![([:])/g)] + .map(([, label]) => label); + + expect(used.length).toBeGreaterThan(0); + for (let label of used) { + expect({ label, defined: defined.has(label) }).toEqual({ + label, + defined: true, + }); + } + }); + + it("leaves the catalog of api symbols to the api index", function* () { + yield* CurrentRequest.set(new Request("http://localhost:8000/llms.txt")); + Deno.env.delete("SITE_URL"); + + let document = `${LLMS_TXT_HEADER}\n${ + llmsTxtFooter(yield* useSiteUrl(), "v4") + }`; + + expect(document).toContain("/api.md"); + // the index owns the list; llms.txt links to it rather than repeating it + expect(document).not.toContain("/api/v4/"); + }); +}); diff --git a/www/routes/llms-txt-route.ts b/www/routes/llms-txt-route.ts index dd7c1cd00..aa360ae3d 100644 --- a/www/routes/llms-txt-route.ts +++ b/www/routes/llms-txt-route.ts @@ -2,12 +2,14 @@ import type { Operation } from "effection"; import { all } from "effection"; import { useWorkspaces } from "../lib/workspaces/mod.ts"; import type { SitemapRoute } from "../plugins/sitemap.ts"; +import { useSiteUrl } from "../plugins/current-request.ts"; import type { Package } from "../lib/package/types.ts"; import { groupPackagesByCategory, type PackageSummary, } from "../lib/package/categories.ts"; import { useTaxonomy } from "../lib/package/taxonomy.ts"; +import { useConfig } from "../context/config.ts"; /** * Dynamic llms.txt route following the llmstxt.org standard. @@ -24,6 +26,8 @@ export function llmsTxtRoute(): SitemapRoute { return [{ pathname: generate() }]; }, *handler(): Operation { + let url = yield* useSiteUrl(); + let { current } = yield* useConfig(); let workspaces = yield* useWorkspaces("thefrontside/effectionx"); let categories = yield* useTaxonomy("thefrontside/effectionx"); let packages = yield* workspaces.getAllPackages(); @@ -52,7 +56,9 @@ export function llmsTxtRoute(): SitemapRoute { (category) => { let packageLines = category.packages.map((pkg) => { let shortDesc = truncateToFirstSentence(pkg.description, 120); - return `- [${pkg.name}](https://frontside.com/effection/x/${pkg.workspaceName}): ${shortDesc}`; + return `- [${pkg.name}](${ + url(`/x/${pkg.workspaceName}.md`) + }): ${shortDesc}`; }); return [ @@ -73,13 +79,13 @@ export function llmsTxtRoute(): SitemapRoute { "", ...categorizedContent, "", - LLMS_TXT_FOOTER, + llmsTxtFooter(url, current), ].join("\n"); return new Response(content, { headers: { "Content-Type": "text/plain; charset=utf-8", - "Cache-Control": "public, max-age=3600", + "Cache-Control": "no-cache", }, }); }, @@ -102,7 +108,8 @@ function truncateToFirstSentence(text: string, maxLength: number): string { return firstSentence; } -const LLMS_TXT_HEADER = `# Effection — Structured Concurrency for JavaScript +export const LLMS_TXT_HEADER = + `# Effection — Structured Concurrency for JavaScript > Effection is a JavaScript library for building reliable asynchronous and > concurrent programs using structured concurrency. @@ -141,26 +148,31 @@ If any other document conflicts with AGENTS.md, **AGENTS.md takes precedence**. - [Resources] - [Spawn] - [Collections] - - [Browse all guides][docs/] + - [Browse all guides][Guides] --- `; -const LLMS_TXT_FOOTER = `## Optional +export function llmsTxtFooter( + url: (path: string) => string, + series: string, +): string { + return `## Optional -- [Full EffectionX catalog with documentation](https://frontside.com/effection/x/) -- [Effection Blog](https://frontside.com/effection/blog) +- [Full EffectionX catalog with documentation](${url("/x/")}) +- [Effection Blog](${url("/blog")}) --- -[AGENTS.md]: https://raw.githubusercontent.com/thefrontside/effection/v4/AGENTS.md -[API]: https://frontside.com/effection/api/ -[Guides]: https://frontside.com/effection/guides/v4 -[Thinking in Effection]: https://raw.githubusercontent.com/thefrontside/effection/v4/docs/thinking-in-effection.mdx -[Async Rosetta Stone]: https://raw.githubusercontent.com/thefrontside/effection/v4/docs/async-rosetta-stone.mdx -[Operations]: https://raw.githubusercontent.com/thefrontside/effection/v4/docs/operations.mdx -[Scope]: https://raw.githubusercontent.com/thefrontside/effection/v4/docs/scope.mdx -[Resources]: https://raw.githubusercontent.com/thefrontside/effection/v4/docs/resources.mdx -[Spawn]: https://raw.githubusercontent.com/thefrontside/effection/v4/docs/spawn.mdx -[Collections]: https://raw.githubusercontent.com/thefrontside/effection/v4/docs/collections.mdx +[AGENTS.md]: ${url("/AGENTS.md")} +[API]: ${url("/api.md")} +[Guides]: ${url(`/guides/${series}`)} +[Thinking in Effection]: ${url(`/guides/${series}/thinking-in-effection.md`)} +[Async Rosetta Stone]: ${url(`/guides/${series}/async-rosetta-stone.md`)} +[Operations]: ${url(`/guides/${series}/operations.md`)} +[Scope]: ${url(`/guides/${series}/scope.md`)} +[Resources]: ${url(`/guides/${series}/resources.md`)} +[Spawn]: ${url(`/guides/${series}/spawn.md`)} +[Collections]: ${url(`/guides/${series}/collections.md`)} `; +} diff --git a/www/routes/x-package-markdown-route.test.ts b/www/routes/x-package-markdown-route.test.ts new file mode 100644 index 000000000..3a548b0f4 --- /dev/null +++ b/www/routes/x-package-markdown-route.test.ts @@ -0,0 +1,54 @@ +import { assertEquals, assertStringIncludes } from "@std/assert"; + +import { withInstallation } from "./x-package-markdown-route.ts"; + +Deno.test("withInstallation adds the npm command to a readme without one", () => { + let readme = "# Task Buffer\n\nLimits concurrent work.\n"; + + assertEquals( + withInstallation(readme, "@effectionx/task-buffer"), + `# Task Buffer + +Limits concurrent work. + +## Installation + +\`\`\`sh +npm install @effectionx/task-buffer +\`\`\` +`, + ); +}); + +Deno.test("withInstallation leaves a readme that already says how", () => { + let readme = `# BDD + +## Installation + +\`\`\`sh +npm install @effectionx/bdd +\`\`\` + +## Usage +`; + + assertEquals(withInstallation(readme, "@effectionx/bdd"), readme); +}); + +Deno.test("withInstallation counts a command that installs more than the package", () => { + // `npm install @effectionx/fetch effection` installs its peer too + let readme = + "# Fetch\n\n```bash\nnpm install @effectionx/fetch effection\n```\n"; + + assertEquals(withInstallation(readme, "@effectionx/fetch"), readme); +}); + +Deno.test("withInstallation does not mistake a readme that merely mentions npm", () => { + // `process` documents running `npm install` as a child process + let readme = + '# Process\n\n```ts\nlet process = yield* exec("npm install");\n```\n'; + + let result = withInstallation(readme, "@effectionx/process"); + + assertStringIncludes(result, "npm install @effectionx/process"); +}); diff --git a/www/routes/x-package-markdown-route.ts b/www/routes/x-package-markdown-route.ts new file mode 100644 index 000000000..260072e1d --- /dev/null +++ b/www/routes/x-package-markdown-route.ts @@ -0,0 +1,64 @@ +import type { Operation } from "effection"; +import { useParams } from "revolution"; + +import { useWorkspaces } from "../lib/workspaces/mod.ts"; +import { markdown, notFound } from "../lib/markdown-response.ts"; +import type { RoutePath, SitemapRoute } from "../plugins/sitemap.ts"; + +/** + * Serve a package's README.md, the source of the page at `/x/:workspacePath`. + * + * An agent following the package catalog in `llms.txt` wants what the package + * says about itself, not the page it is rendered into, and it should get it + * from the site it is already reading rather than from GitHub. + */ +export function xPackageMarkdownRoute(): SitemapRoute { + return { + *routemap(generate): Operation { + let workspaces = yield* useWorkspaces("thefrontside/effectionx"); + + return (yield* workspaces.listWorkspaces()).map((workspacePath) => ({ + pathname: generate({ workspacePath }), + })); + }, + *handler(): Operation { + let { workspacePath } = yield* useParams<{ workspacePath: string }>(); + + let workspaces = yield* useWorkspaces("thefrontside/effectionx"); + let pkg = yield* workspaces.getWorkspace(workspacePath); + + if (!pkg) { + return notFound(`there is no package called '${workspacePath}'`); + } + + return markdown( + withInstallation(yield* pkg.getReadme(), yield* pkg.getName()), + ); + }, + }; +} + +/** + * Append how to install the package from npm. + * + * A readme read on its own, away from the page that carries the install + * command beside it, otherwise leaves an agent to guess the package name. The + * readmes that already give the command are left as they are, so that the + * document never says it twice. + */ +export function withInstallation(readme: string, name: string): string { + let command = `npm install ${name}`; + + if (readme.includes(command)) { + return readme; + } + + return `${readme.trimEnd()} + +## Installation + +\`\`\`sh +${command} +\`\`\` +`; +}