From 54f0467712f8c329b7d14adfc288af95290fa11d Mon Sep 17 00:00:00 2001 From: Taras Mankovski <74687+taras@users.noreply.github.com> Date: Sun, 20 Sep 2026 09:38:30 -0400 Subject: [PATCH 01/10] =?UTF-8?q?=F0=9F=90=9B=20Point=20llms.txt=20and=20f?= =?UTF-8?q?eed.xml=20at=20the=20site=20they=20are=20served=20from?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit staticalize only rewrites self referencing absolute urls inside HTML, so these two resources hardcoded https://frontside.com/effection and told every reader to go to production no matter where they were served from. Locally, llms.txt sent agents away from the dev server they were reading. Add `useSiteUrl()`, which builds urls from `SITE_URL` when it is configured and falls back to `useAbsoluteUrlFactory()` otherwise, and use it for both routes. The www workflow sets `SITE_URL` per deploy, so a preview advertises its own alias url and production advertises the canonical one, and the dev task sets it to localhost so development exercises the same code path as a deploy. --- .github/workflows/www.yaml | 36 +++++++++++++++++++++- www/deno.json | 4 +-- www/plugins/current-request.test.ts | 47 +++++++++++++++++++++++++++++ www/plugins/current-request.ts | 27 +++++++++++++++++ www/routes/blog-feed-route.tsx | 10 +++--- www/routes/llms-txt-route.ts | 20 +++++++----- 6 files changed, 129 insertions(+), 15 deletions(-) create mode 100644 www/plugins/current-request.test.ts 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/www/deno.json b/www/deno.json index 873e5351b..0a4afa658 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": [ 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/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/llms-txt-route.ts b/www/routes/llms-txt-route.ts index dd7c1cd00..29a5c0cd6 100644 --- a/www/routes/llms-txt-route.ts +++ b/www/routes/llms-txt-route.ts @@ -2,6 +2,7 @@ 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, @@ -24,6 +25,7 @@ export function llmsTxtRoute(): SitemapRoute { return [{ pathname: generate() }]; }, *handler(): Operation { + let url = yield* useSiteUrl(); let workspaces = yield* useWorkspaces("thefrontside/effectionx"); let categories = yield* useTaxonomy("thefrontside/effectionx"); let packages = yield* workspaces.getAllPackages(); @@ -52,7 +54,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}`) + }): ${shortDesc}`; }); return [ @@ -73,7 +77,7 @@ export function llmsTxtRoute(): SitemapRoute { "", ...categorizedContent, "", - LLMS_TXT_FOOTER, + llmsTxtFooter(url), ].join("\n"); return new Response(content, { @@ -146,16 +150,17 @@ If any other document conflicts with AGENTS.md, **AGENTS.md takes precedence**. --- `; -const LLMS_TXT_FOOTER = `## Optional +function llmsTxtFooter(url: (path: string) => 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 +[API]: ${url("/api/")} +[Guides]: ${url("/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 @@ -164,3 +169,4 @@ const LLMS_TXT_FOOTER = `## Optional [Spawn]: https://raw.githubusercontent.com/thefrontside/effection/v4/docs/spawn.mdx [Collections]: https://raw.githubusercontent.com/thefrontside/effection/v4/docs/collections.mdx `; +} From d6a7290ec0e9131235b459078f870bb6afc16cfa Mon Sep 17 00:00:00 2001 From: Taras Mankovski <74687+taras@users.noreply.github.com> Date: Sun, 20 Sep 2026 12:37:14 -0400 Subject: [PATCH 02/10] =?UTF-8?q?=F0=9F=A4=96=20Serve=20AGENTS.md=20from?= =?UTF-8?q?=20the=20website?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit llms.txt sent agents to raw.githubusercontent.com for the behavioral contract, so the one document that governs how they write Effection code came from a different origin than the documentation it describes, and from whatever `v4` happened to be rather than the snapshot the rest of the site was built from. Serve the repository's root AGENTS.md at `/AGENTS.md` instead. The route reads the file from the checkout on each request, so the repository root stays the only copy, and it is in the sitemap, so a static build captures it like any other resource. llms.txt and the footer link now point there through the site's own url generation, which keeps the production base path. --- www/components/footer.tsx | 5 +--- www/main.tsx | 2 ++ www/routes/agents-md-route.test.ts | 41 +++++++++++++++++++++++++ www/routes/agents-md-route.ts | 34 +++++++++++++++++++++ www/routes/llms-txt-route.test.ts | 48 ++++++++++++++++++++++++++++++ www/routes/llms-txt-route.ts | 4 +-- 6 files changed, 128 insertions(+), 6 deletions(-) create mode 100644 www/routes/agents-md-route.test.ts create mode 100644 www/routes/agents-md-route.ts create mode 100644 www/routes/llms-txt-route.test.ts 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/main.tsx b/www/main.tsx index 77ff48642..9259ea7fb 100644 --- a/www/main.tsx +++ b/www/main.tsx @@ -27,6 +27,7 @@ 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"; @@ -100,6 +101,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/routes/agents-md-route.test.ts b/www/routes/agents-md-route.test.ts new file mode 100644 index 000000000..7031abb3d --- /dev/null +++ b/www/routes/agents-md-route.test.ts @@ -0,0 +1,41 @@ +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 { agentsMdRoute } from "./agents-md-route.ts"; + +const AGENTS_MD = fromFileUrl(import.meta.resolve("../../AGENTS.md")); + +describe("agentsMdRoute", () => { + it("serves the repository's AGENTS.md as markdown", function* () { + let { handler } = agentsMdRoute(); + + let response = yield* handler( + new Request("http://localhost:8000/AGENTS.md"), + function* (): Operation { + throw new Error("the route handles the request itself"); + }, + ); + + 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(AGENTS_MD)), + ); + }); + + 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" }]); + }); +}); diff --git a/www/routes/agents-md-route.ts b/www/routes/agents-md-route.ts new file mode 100644 index 000000000..401ebaee6 --- /dev/null +++ b/www/routes/agents-md-route.ts @@ -0,0 +1,34 @@ +import type { Operation } from "effection"; +import { until } from "effection"; +import { fromFileUrl } from "@std/path"; + +import type { SitemapRoute } from "../plugins/sitemap.ts"; + +/** + * Serve the repository's root `AGENTS.md`. + * + * `llms.txt` sends agents here for the behavioral contract, so the site hosts + * it on the same origin as the documentation it describes rather than sending + * them to GitHub. The file is read from the checkout on each request, which + * keeps the repository root the only copy of it. + */ +export function agentsMdRoute(): SitemapRoute { + // `.pathname` would yield `/C:/…` on Windows; `fromFileUrl` gives real paths. + let path = fromFileUrl(import.meta.resolve("../../AGENTS.md")); + + return { + *routemap(generate) { + return [{ pathname: generate() }]; + }, + *handler(): Operation { + let content = yield* until(Deno.readTextFile(path)); + + return new Response(content, { + headers: { + "Content-Type": "text/markdown; charset=utf-8", + "Cache-Control": "public, max-age=3600", + }, + }); + }, + }; +} diff --git a/www/routes/llms-txt-route.test.ts b/www/routes/llms-txt-route.test.ts new file mode 100644 index 000000000..b64ebc253 --- /dev/null +++ b/www/routes/llms-txt-route.test.ts @@ -0,0 +1,48 @@ +import { describe, it } from "../testing.ts"; +import { expect } from "expect"; + +import { CurrentRequest } from "../context/request.ts"; +import { useSiteUrl } from "../plugins/current-request.ts"; +import { 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()); + + expect(footer).toContain( + "[AGENTS.md]: http://localhost:8000/AGENTS.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()); + + expect(footer).toContain( + "[AGENTS.md]: https://frontside.com/effection/AGENTS.md", + ); + } finally { + Deno.env.delete("SITE_URL"); + } + }); + + it("no longer sends agents to raw.githubusercontent.com for AGENTS.md", function* () { + yield* CurrentRequest.set(new Request("http://localhost:8000/llms.txt")); + Deno.env.delete("SITE_URL"); + + let footer = llmsTxtFooter(yield* useSiteUrl()); + + let agentsLinks = footer.split("\n").filter((line) => + line.includes("AGENTS.md") + ); + + expect(agentsLinks).toHaveLength(1); + expect(agentsLinks[0]).not.toContain("raw.githubusercontent.com"); + }); +}); diff --git a/www/routes/llms-txt-route.ts b/www/routes/llms-txt-route.ts index 29a5c0cd6..f805ec56e 100644 --- a/www/routes/llms-txt-route.ts +++ b/www/routes/llms-txt-route.ts @@ -150,7 +150,7 @@ If any other document conflicts with AGENTS.md, **AGENTS.md takes precedence**. --- `; -function llmsTxtFooter(url: (path: string) => string): string { +export function llmsTxtFooter(url: (path: string) => string): string { return `## Optional - [Full EffectionX catalog with documentation](${url("/x/")}) @@ -158,7 +158,7 @@ function llmsTxtFooter(url: (path: string) => string): string { --- -[AGENTS.md]: https://raw.githubusercontent.com/thefrontside/effection/v4/AGENTS.md +[AGENTS.md]: ${url("/AGENTS.md")} [API]: ${url("/api/")} [Guides]: ${url("/guides/v4")} [Thinking in Effection]: https://raw.githubusercontent.com/thefrontside/effection/v4/docs/thinking-in-effection.mdx From 45c263f0b8cf3f172f658697cbb14c60d2395d43 Mon Sep 17 00:00:00 2001 From: Taras Mankovski <74687+taras@users.noreply.github.com> Date: Sun, 20 Sep 2026 12:57:15 -0400 Subject: [PATCH 03/10] =?UTF-8?q?=F0=9F=A4=96=20Separate=20the=20public=20?= =?UTF-8?q?agent=20contract=20from=20the=20repository's=20rules?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The root AGENTS.md mixed two audiences: the behavioral contract that governs how anyone writes Effection code, and the rules for contributing to this repository. An agent writing an application had to read past gitmoji and pre-commit commands to reach the invariants, and the website served the whole thing. Move the contract to docs/agents.md, alongside the other public documentation sources, and leave the root file with code style, commit and PR conventions, pre-commit workflow and the pull request template. The root file now opens by sending repository agents to the contract, both as the copy checked out on their branch and as its published url, so the two audiences stay distinct without a second copy of the rules. The route serves docs/agents.md and rewrites the canonical links in it to whichever site is answering, through the same `useSiteUrl()` that llms.txt uses, so a preview or a dev server keeps agents on itself rather than sending them to production. Urls that are not part of this site, such as the Frontside blog, are left alone. --- AGENTS.md | 487 +---------------------------- docs/agents.md | 471 ++++++++++++++++++++++++++++ www/AGENTS.md | 7 +- www/routes/agents-md-route.test.ts | 127 +++++++- www/routes/agents-md-route.ts | 48 ++- 5 files changed, 646 insertions(+), 494 deletions(-) create mode 100644 docs/agents.md 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/routes/agents-md-route.test.ts b/www/routes/agents-md-route.test.ts index 7031abb3d..59030414a 100644 --- a/www/routes/agents-md-route.test.ts +++ b/www/routes/agents-md-route.test.ts @@ -3,28 +3,97 @@ 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 AGENTS_MD = fromFileUrl(import.meta.resolve("../../AGENTS.md")); +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 repository's AGENTS.md as markdown", function* () { - let { handler } = agentsMdRoute(); - - let response = yield* handler( - new Request("http://localhost:8000/AGENTS.md"), - function* (): Operation { - throw new Error("the route handles the request itself"); - }, - ); + 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", ); - expect(yield* until(response.text())).toEqual( - yield* until(Deno.readTextFile(AGENTS_MD)), + + 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/", ); }); @@ -39,3 +108,37 @@ describe("agentsMdRoute", () => { 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 index 401ebaee6..7226c1d38 100644 --- a/www/routes/agents-md-route.ts +++ b/www/routes/agents-md-route.ts @@ -3,27 +3,49 @@ import { until } from "effection"; import { fromFileUrl } from "@std/path"; import type { SitemapRoute } from "../plugins/sitemap.ts"; +import { useSiteUrl } from "../plugins/current-request.ts"; /** - * Serve the repository's root `AGENTS.md`. + * 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. * - * `llms.txt` sends agents here for the behavioral contract, so the site hosts - * it on the same origin as the documentation it describes rather than sending - * them to GitHub. The file is read from the checkout on each request, which - * keeps the repository root the only copy of it. + * 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("../../AGENTS.md")); + let path = fromFileUrl(import.meta.resolve("../../docs/agents.md")); return { *routemap(generate) { return [{ pathname: generate() }]; }, *handler(): Operation { - let content = yield* until(Deno.readTextFile(path)); + let url = yield* useSiteUrl(); + let source = yield* until(Deno.readTextFile(path)); - return new Response(content, { + return new Response(rewriteSiteLinks(source, url), { headers: { "Content-Type": "text/markdown; charset=utf-8", "Cache-Control": "public, max-age=3600", @@ -32,3 +54,13 @@ export function agentsMdRoute(): SitemapRoute { }, }; } + +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); +} From 56c42ef00e684b0840065415f8cff48e1bc2a4a5 Mon Sep 17 00:00:00 2001 From: Taras Mankovski <74687+taras@users.noreply.github.com> Date: Sun, 20 Sep 2026 13:14:17 -0400 Subject: [PATCH 04/10] =?UTF-8?q?=F0=9F=A4=96=20Revalidate=20the=20agent?= =?UTF-8?q?=20documents=20instead=20of=20caching=20them=20for=20an=20hour?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit These responses are rewritten per environment, and the header only ever reaches a browser talking to the dev server: a static build copies the body and netlify supplies its own caching headers. An hour of max-age meant an editor who had opened /AGENTS.md or /llms.txt before a change kept being shown the copy from before it, links and all. The etag plugin already answers a revalidation with a 304. --- www/routes/agents-md-route.test.ts | 3 +++ www/routes/agents-md-route.ts | 5 ++++- www/routes/llms-txt-route.ts | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/www/routes/agents-md-route.test.ts b/www/routes/agents-md-route.test.ts index 59030414a..df23f8e5b 100644 --- a/www/routes/agents-md-route.test.ts +++ b/www/routes/agents-md-route.test.ts @@ -34,6 +34,9 @@ describe("agentsMdRoute", () => { 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()); diff --git a/www/routes/agents-md-route.ts b/www/routes/agents-md-route.ts index 7226c1d38..7085faf31 100644 --- a/www/routes/agents-md-route.ts +++ b/www/routes/agents-md-route.ts @@ -48,7 +48,10 @@ export function agentsMdRoute(): SitemapRoute { return new Response(rewriteSiteLinks(source, url), { headers: { "Content-Type": "text/markdown; charset=utf-8", - "Cache-Control": "public, max-age=3600", + // only the dev server sends this header — a static build copies the + // body and netlify supplies its own — so revalidate rather than let + // an editor's browser hold a stale contract for an hour + "Cache-Control": "no-cache", }, }); }, diff --git a/www/routes/llms-txt-route.ts b/www/routes/llms-txt-route.ts index f805ec56e..65ad79148 100644 --- a/www/routes/llms-txt-route.ts +++ b/www/routes/llms-txt-route.ts @@ -83,7 +83,7 @@ export function llmsTxtRoute(): SitemapRoute { return new Response(content, { headers: { "Content-Type": "text/plain; charset=utf-8", - "Cache-Control": "public, max-age=3600", + "Cache-Control": "no-cache", }, }); }, From 863018a1179e6d5e8267ff7fd4476fa571196165 Mon Sep 17 00:00:00 2001 From: Taras Mankovski <74687+taras@users.noreply.github.com> Date: Sun, 20 Sep 2026 13:31:51 -0400 Subject: [PATCH 05/10] =?UTF-8?q?=F0=9F=A4=96=20Serve=20the=20guides=20as?= =?UTF-8?q?=20markdown,=20and=20link=20llms.txt=20to=20them?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The last seven links in llms.txt pointed at raw.githubusercontent.com, pinned to the v4 branch. An agent reading a preview or a dev server was sent to GitHub for a copy of the docs that had nothing to do with the site it was reading, and the version it got was whatever v4 happened to be. Serve the source of each guide at `/guides/:series/:id.md`, from the same worktree the rendered pages are built from, and point llms.txt there. Agents still get markdown, now from the site they came from and matching the pages beside it. The route is registered before the page route so that `.md` is read as a suffix rather than part of a guide id, and it lists every guide in the sitemap so a static build captures them. The series comes from the site config rather than a hardcoded `v4`, which also settles the `[Guides]` link above it. --- www/main.tsx | 3 ++ www/routes/guides-markdown-route.test.ts | 67 +++++++++++++++++++++++ www/routes/guides-markdown-route.ts | 68 ++++++++++++++++++++++++ www/routes/llms-txt-route.test.ts | 27 ++++++---- www/routes/llms-txt-route.ts | 25 +++++---- 5 files changed, 171 insertions(+), 19 deletions(-) create mode 100644 www/routes/guides-markdown-route.test.ts create mode 100644 www/routes/guides-markdown-route.ts diff --git a/www/main.tsx b/www/main.tsx index 9259ea7fb..f9db47548 100644 --- a/www/main.tsx +++ b/www/main.tsx @@ -9,6 +9,7 @@ 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"; @@ -74,6 +75,8 @@ 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()), 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..8b7e8fca2 --- /dev/null +++ b/www/routes/guides-markdown-route.ts @@ -0,0 +1,68 @@ +import { all, type Operation } from "effection"; +import { useParams } from "revolution"; + +import { useConfig } from "../context/config.ts"; +import { useGuides } from "../resources/guides.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 new Response(page.markdown, { + headers: { + "Content-Type": "text/markdown; charset=utf-8", + "Cache-Control": "no-cache", + }, + }); + }, + }; +} + +function notFound(message: string): Response { + return new Response(`${message}\n`, { + status: 404, + headers: { "Content-Type": "text/plain; charset=utf-8" }, + }); +} diff --git a/www/routes/llms-txt-route.test.ts b/www/routes/llms-txt-route.test.ts index b64ebc253..20c12c6c9 100644 --- a/www/routes/llms-txt-route.test.ts +++ b/www/routes/llms-txt-route.test.ts @@ -10,10 +10,11 @@ describe("llmsTxtFooter", () => { yield* CurrentRequest.set(new Request("http://localhost:8000/llms.txt")); Deno.env.delete("SITE_URL"); - let footer = llmsTxtFooter(yield* useSiteUrl()); + let footer = llmsTxtFooter(yield* useSiteUrl(), "v4"); + expect(footer).toContain("[AGENTS.md]: http://localhost:8000/AGENTS.md"); expect(footer).toContain( - "[AGENTS.md]: http://localhost:8000/AGENTS.md", + "[Operations]: http://localhost:8000/guides/v4/operations.md", ); }); @@ -22,27 +23,35 @@ describe("llmsTxtFooter", () => { Deno.env.set("SITE_URL", "https://frontside.com/effection"); try { - let footer = llmsTxtFooter(yield* useSiteUrl()); + 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 for AGENTS.md", function* () { + 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()); + let footer = llmsTxtFooter(yield* useSiteUrl(), "v4"); - let agentsLinks = footer.split("\n").filter((line) => - line.includes("AGENTS.md") + expect(footer).not.toContain("raw.githubusercontent.com"); + expect(footer).not.toContain("github.com"); + + let definitions = footer.split("\n").filter((line) => + /^\[[^\]]+\]: /.test(line) ); - expect(agentsLinks).toHaveLength(1); - expect(agentsLinks[0]).not.toContain("raw.githubusercontent.com"); + expect(definitions.length).toBeGreaterThan(0); + for (let definition of definitions) { + expect(definition).toContain("http://localhost:8000/"); + } }); }); diff --git a/www/routes/llms-txt-route.ts b/www/routes/llms-txt-route.ts index 65ad79148..db8591d2f 100644 --- a/www/routes/llms-txt-route.ts +++ b/www/routes/llms-txt-route.ts @@ -9,6 +9,7 @@ import { 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. @@ -26,6 +27,7 @@ export function llmsTxtRoute(): SitemapRoute { }, *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(); @@ -77,7 +79,7 @@ export function llmsTxtRoute(): SitemapRoute { "", ...categorizedContent, "", - llmsTxtFooter(url), + llmsTxtFooter(url, current), ].join("\n"); return new Response(content, { @@ -150,7 +152,10 @@ If any other document conflicts with AGENTS.md, **AGENTS.md takes precedence**. --- `; -export function llmsTxtFooter(url: (path: string) => string): string { +export function llmsTxtFooter( + url: (path: string) => string, + series: string, +): string { return `## Optional - [Full EffectionX catalog with documentation](${url("/x/")}) @@ -160,13 +165,13 @@ export function llmsTxtFooter(url: (path: string) => string): string { [AGENTS.md]: ${url("/AGENTS.md")} [API]: ${url("/api/")} -[Guides]: ${url("/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 +[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`)} `; } From 874d269da03f9f1f1c1886925463bca3f170a101 Mon Sep 17 00:00:00 2001 From: Taras Mankovski <74687+taras@users.noreply.github.com> Date: Sun, 20 Sep 2026 13:34:49 -0400 Subject: [PATCH 06/10] =?UTF-8?q?=F0=9F=A4=96=20Resolve=20the=20dangling?= =?UTF-8?q?=20guides=20reference=20in=20llms.txt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `[Browse all guides][docs/]` had no matching definition, so an agent reading llms.txt got the literal text instead of a link. The guides index is already defined as `[Guides]` two lines above it, so point the bullet there rather than define the same url twice. The new test collects the labels the document uses and the ones it defines and compares them, so the next reference that loses its definition fails rather than shipping as literal text. --- www/routes/llms-txt-route.test.ts | 27 ++++++++++++++++++++++++++- www/routes/llms-txt-route.ts | 5 +++-- 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/www/routes/llms-txt-route.test.ts b/www/routes/llms-txt-route.test.ts index 20c12c6c9..903db66c3 100644 --- a/www/routes/llms-txt-route.test.ts +++ b/www/routes/llms-txt-route.test.ts @@ -3,7 +3,7 @@ import { expect } from "expect"; import { CurrentRequest } from "../context/request.ts"; import { useSiteUrl } from "../plugins/current-request.ts"; -import { llmsTxtFooter } from "./llms-txt-route.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* () { @@ -54,4 +54,29 @@ describe("llmsTxtFooter", () => { 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, + }); + } + }); }); diff --git a/www/routes/llms-txt-route.ts b/www/routes/llms-txt-route.ts index db8591d2f..c977dcf49 100644 --- a/www/routes/llms-txt-route.ts +++ b/www/routes/llms-txt-route.ts @@ -108,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. @@ -147,7 +148,7 @@ If any other document conflicts with AGENTS.md, **AGENTS.md takes precedence**. - [Resources] - [Spawn] - [Collections] - - [Browse all guides][docs/] + - [Browse all guides][Guides] --- `; From e9b3009d0821938d6bf2dd8e8a001423c398b248 Mon Sep 17 00:00:00 2001 From: Taras Mankovski <74687+taras@users.noreply.github.com> Date: Sun, 20 Sep 2026 13:59:09 -0400 Subject: [PATCH 07/10] =?UTF-8?q?=F0=9F=A4=96=20Serve=20each=20x=20package?= =?UTF-8?q?'s=20README=20as=20markdown?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The guides are readable as markdown now, but the packages were not: an agent that followed the catalog in llms.txt to /x/task-buffer got a rendered page, and the readme it was built from was only on GitHub. Serve it at /x/:workspacePath.md, from the same checkout the page is built from, through the getReadme() the page already uses. Registered before the page route so that `.md` is read as a suffix rather than part of a package name, and every package is listed in the sitemap so a static build captures them. The three markdown endpoints — the contract, the guides and now the readmes — build their responses through one helper, so the content type and the no-cache decision live in one place rather than three. --- www/lib/markdown-response.test.ts | 26 ++++++++++++++++++ www/lib/markdown-response.ts | 28 +++++++++++++++++++ www/main.tsx | 3 +++ www/routes/agents-md-route.ts | 11 ++------ www/routes/guides-markdown-route.ts | 15 ++--------- www/routes/x-package-markdown-route.ts | 37 ++++++++++++++++++++++++++ 6 files changed, 98 insertions(+), 22 deletions(-) create mode 100644 www/lib/markdown-response.test.ts create mode 100644 www/lib/markdown-response.ts create mode 100644 www/routes/x-package-markdown-route.ts 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 f9db47548..2253966cf 100644 --- a/www/main.tsx +++ b/www/main.tsx @@ -13,6 +13,7 @@ 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"; @@ -81,6 +82,8 @@ if (import.meta.main) { 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 })), // API docs for all series including prereleases diff --git a/www/routes/agents-md-route.ts b/www/routes/agents-md-route.ts index 7085faf31..09b0244c5 100644 --- a/www/routes/agents-md-route.ts +++ b/www/routes/agents-md-route.ts @@ -4,6 +4,7 @@ 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 @@ -45,15 +46,7 @@ export function agentsMdRoute(): SitemapRoute { let url = yield* useSiteUrl(); let source = yield* until(Deno.readTextFile(path)); - return new Response(rewriteSiteLinks(source, url), { - headers: { - "Content-Type": "text/markdown; charset=utf-8", - // only the dev server sends this header — a static build copies the - // body and netlify supplies its own — so revalidate rather than let - // an editor's browser hold a stale contract for an hour - "Cache-Control": "no-cache", - }, - }); + return markdown(rewriteSiteLinks(source, url)); }, }; } diff --git a/www/routes/guides-markdown-route.ts b/www/routes/guides-markdown-route.ts index 8b7e8fca2..7b987176a 100644 --- a/www/routes/guides-markdown-route.ts +++ b/www/routes/guides-markdown-route.ts @@ -3,6 +3,7 @@ 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"; /** @@ -50,19 +51,7 @@ export function guidesMarkdownRoute(): SitemapRoute { return notFound(`there is no guide called '${id}' in ${series}`); } - return new Response(page.markdown, { - headers: { - "Content-Type": "text/markdown; charset=utf-8", - "Cache-Control": "no-cache", - }, - }); + return markdown(page.markdown); }, }; } - -function notFound(message: string): Response { - return new Response(`${message}\n`, { - status: 404, - headers: { "Content-Type": "text/plain; charset=utf-8" }, - }); -} diff --git a/www/routes/x-package-markdown-route.ts b/www/routes/x-package-markdown-route.ts new file mode 100644 index 000000000..073ad2f45 --- /dev/null +++ b/www/routes/x-package-markdown-route.ts @@ -0,0 +1,37 @@ +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(yield* pkg.getReadme()); + }, + }; +} From d79fd9c13cc8ab80870e2c9963600eadfbb7d283 Mon Sep 17 00:00:00 2001 From: Taras Mankovski <74687+taras@users.noreply.github.com> Date: Sun, 20 Sep 2026 14:10:09 -0400 Subject: [PATCH 08/10] =?UTF-8?q?=F0=9F=A4=96=20Point=20the=20llms.txt=20c?= =?UTF-8?q?atalog=20at=20the=20readmes,=20and=20say=20how=20to=20install?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The catalog sent agents to the rendered package pages while everything else in llms.txt now leads to markdown. Point it at `/x/.md`. A readme read on its own is missing what the page carries beside it: the package name to install. Append the npm command to the served markdown, unless the readme already gives it — six of them do, and the document should not say it twice. --- www/routes/llms-txt-route.ts | 2 +- www/routes/x-package-markdown-route.test.ts | 54 +++++++++++++++++++++ www/routes/x-package-markdown-route.ts | 29 ++++++++++- 3 files changed, 83 insertions(+), 2 deletions(-) create mode 100644 www/routes/x-package-markdown-route.test.ts diff --git a/www/routes/llms-txt-route.ts b/www/routes/llms-txt-route.ts index c977dcf49..8fb699b8c 100644 --- a/www/routes/llms-txt-route.ts +++ b/www/routes/llms-txt-route.ts @@ -57,7 +57,7 @@ export function llmsTxtRoute(): SitemapRoute { let packageLines = category.packages.map((pkg) => { let shortDesc = truncateToFirstSentence(pkg.description, 120); return `- [${pkg.name}](${ - url(`/x/${pkg.workspaceName}`) + url(`/x/${pkg.workspaceName}.md`) }): ${shortDesc}`; }); 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 index 073ad2f45..260072e1d 100644 --- a/www/routes/x-package-markdown-route.ts +++ b/www/routes/x-package-markdown-route.ts @@ -31,7 +31,34 @@ export function xPackageMarkdownRoute(): SitemapRoute { return notFound(`there is no package called '${workspacePath}'`); } - return markdown(yield* pkg.getReadme()); + 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} +\`\`\` +`; +} From 09d3bd2c686396fde73ea9ef7bed2adab9b44003 Mon Sep 17 00:00:00 2001 From: Taras Mankovski <74687+taras@users.noreply.github.com> Date: Sun, 20 Sep 2026 18:20:42 -0400 Subject: [PATCH 09/10] =?UTF-8?q?=F0=9F=A4=96=20Serve=20the=20API=20refere?= =?UTF-8?q?nce=20as=20markdown?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit llms.txt led agents to markdown everywhere except the API, where [API] pointed at the rendered index and every symbol behind it was HTML. Serve the index at /api.md and each symbol at /api//.md, experimental symbols under their own segment as their pages already are. Both read the same pkg.docs() the HTML routes render: a symbol page is its declarations, each one rendered through the existing Type component and taken as text so the signature cannot drift from the page, its documentation with {@link} resolved to markdown pages, and where the code lives. The index lists what the HTML index lists, newest series first, and llms.txt now points there rather than repeating the catalog. An unknown symbol answers 404, where the page route throws. --- www/deno.json | 1 + www/lib/api-markdown.test.ts | 142 +++++++++++++++++++++++++++++ www/lib/api-markdown.ts | 77 ++++++++++++++++ www/main.tsx | 15 ++++ www/routes/api-markdown-route.ts | 145 ++++++++++++++++++++++++++++++ www/routes/llms-txt-route.test.ts | 14 +++ www/routes/llms-txt-route.ts | 2 +- 7 files changed, 395 insertions(+), 1 deletion(-) create mode 100644 www/lib/api-markdown.test.ts create mode 100644 www/lib/api-markdown.ts create mode 100644 www/routes/api-markdown-route.ts diff --git a/www/deno.json b/www/deno.json index 0a4afa658..e0de630a6 100644 --- a/www/deno.json +++ b/www/deno.json @@ -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/main.tsx b/www/main.tsx index 2253966cf..efc3e2f5c 100644 --- a/www/main.tsx +++ b/www/main.tsx @@ -24,6 +24,10 @@ 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"; @@ -86,6 +90,17 @@ if (import.meta.main) { 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( 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/llms-txt-route.test.ts b/www/routes/llms-txt-route.test.ts index 903db66c3..7588ae0d9 100644 --- a/www/routes/llms-txt-route.test.ts +++ b/www/routes/llms-txt-route.test.ts @@ -13,6 +13,7 @@ describe("llmsTxtFooter", () => { 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", ); @@ -79,4 +80,17 @@ describe("llmsTxtFooter", () => { }); } }); + + 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 8fb699b8c..aa360ae3d 100644 --- a/www/routes/llms-txt-route.ts +++ b/www/routes/llms-txt-route.ts @@ -165,7 +165,7 @@ export function llmsTxtFooter( --- [AGENTS.md]: ${url("/AGENTS.md")} -[API]: ${url("/api/")} +[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`)} From 8c10ac95fd2aed96fae7e725bdd06cb97455ec45 Mon Sep 17 00:00:00 2001 From: Taras Mankovski <74687+taras@users.noreply.github.com> Date: Mon, 21 Sep 2026 08:05:57 -0400 Subject: [PATCH 10/10] =?UTF-8?q?=F0=9F=90=9B=20Load=20the=20guide=20struc?= =?UTF-8?q?ture=20through=20a=20file=20url?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `import("d:/a/effection/effection/docs/structure.json")` fails on windows, where deno reads the drive letter as an unsupported scheme. The dev server never hits it because nobody runs the site from windows, but the guides markdown test loads the structure, so it turned up on the windows leg of the test matrix. --- www/resources/guides.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) 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);