diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 000000000..e7b01f15f --- /dev/null +++ b/.gitattributes @@ -0,0 +1,24 @@ +# Set default line ending behavior +* text=auto + +# Explicitly set line endings for text files +*.ts text eol=lf +*.tsx text eol=lf +*.mjs text eol=lf +*.js text eol=lf +*.jsx text eol=lf +*.json text eol=lf +*.md text eol=lf +*.yml text eol=lf +*.yaml text eol=lf +*.svg text eol=lf +*.css text eol=lf + +# Binary files +*.png binary +*.jpg binary +*.jpeg binary +*.gif binary +*.ico binary +*.woff binary +*.woff2 binary \ No newline at end of file diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 000000000..dad12b0f6 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,7 @@ +# Motivation + + + +# Approach + + diff --git a/.github/workflows/preview.yml b/.github/workflows/preview.yml new file mode 100644 index 000000000..bc55a81ef --- /dev/null +++ b/.github/workflows/preview.yml @@ -0,0 +1,37 @@ +name: preview + +on: [pull_request] + +permissions: + contents: read + +jobs: + preview: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: setup deno + uses: denoland/setup-deno@v2 + with: + deno-version: v2.x + + - name: Get Version + id: vars + # this will pull the last tag off the current branch + run: echo ::set-output name=version::$(git describe --abbrev=0 --tags | sed 's/^effection-v//')-pr+$(git rev-parse HEAD) + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 20.x + registry-url: https://registry.npmjs.com + + - name: Build NPM + run: deno task build:npm ${{steps.vars.outputs.version}} + + - name: Publish Preview Versions + run: npx pkg-pr-new publish './build/npm' diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index ef13ce2ed..d2442f9eb 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -27,19 +27,16 @@ jobs: run: echo ::set-output name=version::$(echo ${{github.ref_name}} | sed 's/^effection-v//') - name: Setup Node - uses: actions/setup-node@v4.1.0 + uses: actions/setup-node@v6 with: - node-version: 18.x - registry-url: https://registry.npmjs.com + node-version: 24 - name: Build NPM run: deno task build:npm ${{steps.vars.outputs.version}} - name: Publish NPM - run: npm publish --access=public --tag=next + run: npm publish --access=public --tag=latest working-directory: ./build/npm - env: - NODE_AUTH_TOKEN: ${{secrets.NPM_AUTH_TOKEN}} publish-jsr: runs-on: ubuntu-latest diff --git a/.github/workflows/verify.yaml b/.github/workflows/verify.yaml index 6c6b440d4..2dc1214ef 100644 --- a/.github/workflows/verify.yaml +++ b/.github/workflows/verify.yaml @@ -10,8 +10,11 @@ permissions: contents: read jobs: - test-deno: - runs-on: ubuntu-latest + test: + strategy: + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + runs-on: ${{ matrix.os }} steps: - name: checkout @@ -28,7 +31,13 @@ jobs: - name: lint run: deno lint - - name: test + - name: test (Windows) + if: runner.os == 'Windows' + run: deno task test + shell: cmd + + - name: test (Unix) + if: runner.os != 'Windows' run: deno task test test-node: diff --git a/.github/workflows/www.yaml b/.github/workflows/www.yaml new file mode 100644 index 000000000..d90df1cb4 --- /dev/null +++ b/.github/workflows/www.yaml @@ -0,0 +1,66 @@ +name: www +on: + workflow_dispatch: # Allows manual trigger + schedule: + - cron: "0 */8 * * *" # Runs every 8 hours + pull_request: + push: + branches: + - v4 + +jobs: + www: + runs-on: ubuntu-latest + timeout-minutes: 15 + + permissions: + id-token: write # Needed for auth with Deno Deploy + contents: read # Needed to clone the repository + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup Deno + uses: denoland/setup-deno@v2 + with: + deno-version: v2.6.1 + + - name: Serve Website + run: | + deno task dev & + until curl --output /dev/null --silent --head --fail http://127.0.0.1:8000; do + printf '.' + sleep 1 + done + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + JSR_API: ${{ secrets.JSR_API }} + timeout-minutes: 5 + working-directory: ./www + + - name: Download Staticalize + run: | + wget https://github.com/thefrontside/staticalize/releases/download/v0.2.2/staticalize-linux.tar.gz \ + -O /tmp/staticalize-linux.tar.gz + tar -xzf /tmp/staticalize-linux.tar.gz -C /usr/local/bin + chmod +x /usr/local/bin/staticalize-linux + + - name: Staticalize + run: | + staticalize-linux \ + --site=http://127.0.0.1:8000 \ + --output=www/built \ + --base=https://effection.deno.dev + + - run: npx pagefind --site built + working-directory: ./www + + - name: Upload to Deno Deploy + uses: denoland/deployctl@v1 + with: + project: effection + entrypoint: "jsr:@std/http/file-server" + root: www/built diff --git a/.gitignore b/.gitignore index 7883eb9be..0fa632abf 100755 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,7 @@ # Local Netlify folder .netlify -/build/ \ No newline at end of file +/build/ +/node_modules/ +/.env +/.aider* diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..e3e18e6f0 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,381 @@ +# 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. +- 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 operations either complete or error" (no fail-fast), wrap + each member to return a railway-style result (e.g. `{ ok: true, value }` / + `{ ok: false, error }`) instead of letting errors escape. + +## `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 +resource(function* (provide) { + try { + yield* provide(value); + } finally { + cleanup(); + } +}); +``` + +**Rules** + +- Setup happens before `provide()`. +- Cleanup must be in `finally` (or after `provide()` guarded by `finally`) so it + runs on return/error/halt. +- Teardown can be asynchronous. If cleanup needs async work, express it as an + `Operation` and `yield*` it inside `finally` (wait for teardown to finish)—do + not fire-and-forget cleanup. + +## `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`. + +## `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(stream)` (loop consumption) + +**Rules** + +- 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`. + +**Gotchas** + +- If you do not call `each.next()`, the loop throws `IterationError` on the next + iteration. + +**Shape (ordering matters)** + +```ts +for (let value of yield * each(stream)) { + // ... + yield * each.next(); +} +``` + +### 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). +- You must consume via `yield* queue.next()`. + +## `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. + +## Pull requests + +When creating a pull request, use the template at +`.github/pull_request_template.md`. The PR description must include: + +- **Motivation** — describe the problem or feature request the PR addresses. +- **Approach** — provide a brief summary of the changes made. diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index 65e348822..32fba281e 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -2,45 +2,73 @@ ## Our Pledge -In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation. +In the interest of fostering an open and welcoming environment, we as +contributors and maintainers pledge to making participation in our project and +our community a harassment-free experience for everyone, regardless of age, body +size, disability, ethnicity, gender identity and expression, level of +experience, nationality, personal appearance, race, religion, or sexual identity +and orientation. ## Our Standards -Examples of behavior that contributes to creating a positive environment include: +Examples of behavior that contributes to creating a positive environment +include: -* Using welcoming and inclusive language -* Being respectful of differing viewpoints and experiences -* Gracefully accepting constructive criticism -* Focusing on what is best for the community -* Showing empathy towards other community members +- Using welcoming and inclusive language +- Being respectful of differing viewpoints and experiences +- Gracefully accepting constructive criticism +- Focusing on what is best for the community +- Showing empathy towards other community members Examples of unacceptable behavior by participants include: -* The use of sexualized language or imagery and unwelcome sexual attention or advances -* Trolling, insulting/derogatory comments, and personal or political attacks -* Public or private harassment -* Publishing others' private information, such as a physical or electronic address, without explicit permission -* Other conduct which could reasonably be considered inappropriate in a professional setting +- The use of sexualized language or imagery and unwelcome sexual attention or + advances +- Trolling, insulting/derogatory comments, and personal or political attacks +- Public or private harassment +- Publishing others' private information, such as a physical or electronic + address, without explicit permission +- Other conduct which could reasonably be considered inappropriate in a + professional setting ## Our Responsibilities -Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. +Project maintainers are responsible for clarifying the standards of acceptable +behavior and are expected to take appropriate and fair corrective action in +response to any instances of unacceptable behavior. -Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. +Project maintainers have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, or to ban temporarily or permanently any +contributor for other behaviors that they deem inappropriate, threatening, +offensive, or harmful. ## Scope -This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. +This Code of Conduct applies both within project spaces and in public spaces +when an individual is representing the project or its community. Examples of +representing a project or community include using an official project e-mail +address, posting via an official social media account, or acting as an appointed +representative at an online or offline event. Representation of a project may be +further defined and clarified by project maintainers. ## Enforcement -Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at oss@frontside.com. The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported by contacting the project team at oss@frontside.com. The project team +will review and investigate all complaints, and will respond in a way that it +deems appropriate to the circumstances. The project team is obligated to +maintain confidentiality with regard to the reporter of an incident. Further +details of specific enforcement policies may be posted separately. -Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. +Project maintainers who do not follow or enforce the Code of Conduct in good +faith may face temporary or permanent repercussions as determined by other +members of the project's leadership. ## Attribution -This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at [http://contributor-covenant.org/version/1/4][version] +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 1.4, available at [http://contributor-covenant.org/version/1/4][version] [homepage]: http://contributor-covenant.org [version]: http://contributor-covenant.org/version/1/4/ diff --git a/Changelog.md b/Changelog.md index 90174b6af..289de7a67 100644 --- a/Changelog.md +++ b/Changelog.md @@ -1,5 +1,119 @@ # Changelog +## 4.0.2 + +- 🐛 fix regression in resource destruction order (#1091) + +## 4.0.1 + +- 🐛 fix: move all task finalization into the scope destructor (#1085) +- 🐛 fix: destroy task scope when halting to prevent memory leak (#1081) + +## 4.0.0 + +- Announcing Effection 4.0 + https://frontside.com/blog/2025-12-23-announcing-effection-v4/ +- run resources at the priority level of their caller + https://github.com/thefrontside/effection/pull/1017 +- properly propagate errors from nested scopes + https://github.com/thefrontside/effection/pull/1014 +- Add Effect.js benchmarks for performance comparison + https://github.com/thefrontside/effection/pull/979 +- Fix type definition for call operation + https://github.com/thefrontside/effection/pull/973 +- Fix missing tag issue https://github.com/thefrontside/effection/pull/970 +- Minor documentation improvements + https://github.com/thefrontside/effection/pull/971 +- Remove unnecessary www from deploy preview URLs + https://github.com/thefrontside/effection/pull/969 +- Add promise helpers https://github.com/thefrontside/effection/pull/968 +- Test against Node 16, 18, 20 versions + https://github.com/thefrontside/effection/pull/966 +- Add documentation for scope https://github.com/thefrontside/effection/pull/961 +- Add documentation for errors + https://github.com/thefrontside/effection/pull/960 +- Add documentation for call operation + https://github.com/thefrontside/effection/pull/954 +- Add documentation for suspend operation + https://github.com/thefrontside/effection/pull/957 +- Add documentation for constant operation + https://github.com/thefrontside/effection/pull/955 +- Add withResolvers test https://github.com/thefrontside/effection/pull/953 +- Fix typo in documentation https://github.com/thefrontside/effection/pull/952 +- Add documentation for action operation + https://github.com/thefrontside/effection/pull/951 +- Add contributors section to README + https://github.com/thefrontside/effection/pull/948 +- Add JSR publishing capability + https://github.com/thefrontside/effection/pull/947 +- Add withResolvers documentation + https://github.com/thefrontside/effection/pull/940 +- Remove v2 documentation https://github.com/thefrontside/effection/pull/938 +- Add dynamic import for Node.js main + https://github.com/thefrontside/effection/pull/936 +- Add scoped operation https://github.com/thefrontside/effection/pull/933 +- Export Result interface from API + https://github.com/thefrontside/effection/pull/920 +- Add benchmark suite https://github.com/thefrontside/effection/pull/919 +- Add do-effect helper https://github.com/thefrontside/effection/pull/918 +- Use Deno 2.0 https://github.com/thefrontside/effection/pull/917 +- Make Task implement Operation + https://github.com/thefrontside/effection/pull/915 +- Export async helpers in main API + https://github.com/thefrontside/effection/pull/913 +- Initial v4 release with delimited continuations + +## 3.6.1 + +- Do not bind SIGTERM in Windows + https://github.com/thefrontside/effection/pull/1031 + +## 3.6.0 + +- ✨ add interval() helper to consume a stream of intervals + https://github.com/thefrontside/effection/pull/1005 + +## 3.5.1 + +- 🐛 Fix Webpack compatibility through a specific comment to ignore the dynamic + `node:process` import https://github.com/thefrontside/effection/pull/1007 + +## 3.5.0 + +- 🚛 Backport createScope(parent) to v3 + https://github.com/thefrontside/effection/pull/996 +- 🚛 Backport Scope.spawn() to v3 + https://github.com/thefrontside/effection/pull/995 + +## 3.4.0 + +- Introducing until helper to replace call(promise) + https://github.com/thefrontside/effection/pull/988 + +## 3.3.0 + +- 🎒Backport Context.with() https://github.com/thefrontside/effection/pull/982 +- 🎒backport `each()` 🐛 fixes to v3 + https://github.com/thefrontside/effection/pull/980 +- 🎒backport advanced scope helpers from v4 -> v3 + https://github.com/thefrontside/effection/pull/967 + +## 3.2.0 + +- ✨ Backfill the `scoped()` API + https://github.com/thefrontside/effection/pull/964 +- ✨ Backport `withResolvers()` from v4 to v3 + https://github.com/thefrontside/effection/pull/963 + +## 3.1.0 + +- 🐛 dynamically import node process + https://github.com/thefrontside/effection/pull/935 +- Deprecate non-function invocations of call() + https://github.com/thefrontside/effection/pull/929 +- backport `Context#expect()` to v3 + https://github.com/thefrontside/effection/commit/4d3be6c1be3f4c3c7dfbfd5435d07d754023800a + ## 3.0.3 - this is just a placeholder release in order to workaround an issue. It is 100% @@ -31,7 +145,7 @@ also async functions and vanilla JavaScript functions https://github.com/thefrontside/effection/pull/832 - Task.halt() now succeeds whenever the shutdown succeeds, regardless of whether - the task itself failed https://github.com/thefrontside/pull/effection/837 + the task itself failed https://github.com/thefrontside/effection/pull/837 - ✨allow custom `Queue` impl whenever creating a `Signal` https://github.com/thefrontside/effection/pull/826 - 📄Represent `each` as a function, not a variable in the API docs @@ -76,9 +190,9 @@ https://github.com/thefrontside/effection/pull/729 - add `main()` method for setting up Effection to work properly in working in deno, browser, and node -- add `Context.set()` and `Context.get()` operations to make working w ith +- add `Context.set()` and `Context.get()` operations to make working with Context convenient -- convert `Subscription` interface from a bare operation to an "iterat or" style +- convert `Subscription` interface from a bare operation to an "iterator" style interface. Succintly: `yield* subscription` -> `yield* subscription.next()`. For details https://github.com/thefrontside/effection/issues/693 - add `on()` and `once()` operations for events and subscriptions to values that diff --git a/README.md b/README.md index f559a45ef..e8db3ba62 100755 --- a/README.md +++ b/README.md @@ -20,7 +20,8 @@ solid _at scale_, and it does all of this while feeling like normal JavaScript. ## Platforms Effection runs on all major JavaScript platforms including NodeJs, Browser, and -Deno. It is published on both [npm][npm-effection] and [deno.land][deno-land-effection]. +Deno. It is published on both [npm][npm-effection] and +[deno.land][deno-land-effection]. ## Contributing to Website @@ -28,7 +29,7 @@ Go to [website's readme](www) to learn how to contribute to the website. ## Development -[Deno][] is the primary tool used for development, testing, and packaging. +[Deno][Deno] is the primary tool used for development, testing, and packaging. ### Testing @@ -44,7 +45,7 @@ If you want to build a development version of the NPM package so that you can link it locally, you can use the `build:npm` script and passing it a version number. for example: -``` text +```text $ deno task build:npm 3.0.0-mydev-snapshot.0 Task build:npm deno run -A tasks/build-npm.ts "3.0.0-mydev-snapshot.0" [dnt] Transforming... @@ -61,6 +62,11 @@ found 0 vulnerabilities Now, the built npm package can be found in the `build/npm` directory. +## For AI Assistants + +- [llms.txt](llms.txt) — High-level overview +- [llms-full.txt](llms-full.txt) — Complete reference + [structured concurrency]: https://vorpus.org/blog/notes-on-structured-concurrency-or-go-statement-considered-harmful/ [discord]: https://discord.gg/Ug5nWH8 [Deno]: https://deno.land diff --git a/deno.json b/deno.json index 7b570a507..1c913fe6b 100644 --- a/deno.json +++ b/deno.json @@ -2,51 +2,36 @@ "name": "@effection/effection", "exports": "./mod.ts", "license": "ISC", - "publish": { - "include": ["lib", "mod.ts", "README.md"] - }, + "publish": { "include": ["lib", "mod.ts", "README.md"] }, "lock": false, "tasks": { - "test": "deno test --allow-run=deno --allow-read --allow-env", + "test": "deno test --allow-run --allow-read --allow-env --allow-ffi", "test:node": "deno run -A tasks/built-test.ts", "build:npm": "deno run -A tasks/build-npm.ts", "bench": "deno run -A tasks/bench.ts", "build:jsr": "deno run -A tasks/build-jsr.ts" }, "lint": { - "rules": { - "exclude": ["prefer-const", "require-yield"] - }, - "exclude": [ - "build", - "www" - ] - }, - "fmt": { - "exclude": [ - "build", - "www", - "CODE_OF_CONDUCT.md", - "README.md" - ] - }, - "test": { - "exclude": [ - "build" - ] + "rules": { "exclude": ["prefer-const", "require-yield"] }, + "exclude": ["build", "docs", "tasks"], + "plugins": ["lint/prefer-let.ts"] }, + "fmt": { "exclude": ["build"] }, + "test": { "exclude": ["build"] }, "compilerOptions": { - "lib": [ - "deno.ns", - "esnext", - "dom", - "dom.iterable" - ] + "lib": ["deno.ns", "esnext", "dom", "dom.iterable", "deno.unstable"] }, "imports": { "@std/testing": "jsr:@std/testing@1", "ts-expect": "npm:ts-expect@1.3.0", "@std/expect": "jsr:@std/expect@1", - "tinyexec": "npm:tinyexec@0.3.2" - } + "ctrlc-windows": "npm:ctrlc-windows@2.2.0", + "tinyexec": "npm:tinyexec@1.0.1", + "https://deno.land/x/path_to_regexp@v6.2.1/index.ts": "npm:path-to-regexp@8.2.0" + }, + "nodeModulesDir": "auto", + "workspace": [ + "./www" + ], + "version": "4.0.1" } diff --git a/docs/actions.mdx b/docs/actions.mdx index 0f2d8b225..76a181794 100644 --- a/docs/actions.mdx +++ b/docs/actions.mdx @@ -1,12 +1,10 @@ -In this section, we'll cover one of the most fundamental operations in -Effection: `action()`, and how we can use it as a safe alternative to +In this section, we'll cover how we can use `action` as a safe alternative to the [Promise constructor][promise-constructor]. ## Example: Sleep Let's revisit our sleep operation from the [introduction to -operations](./operations): - +operations](../operations): ```js export function sleep(duration: number): Operation { @@ -58,7 +56,8 @@ an abort signal was there without sacrificing any clarity in achieving it. ## Action Constructor -The [`action()`][action] function provides a callback based API to create Effection operations. You don't need it all that often, but when you do it functions almost exactly like the +The [`action()`][action] function provides a callback based API to create Effection operations. +You don't need it all that often, but when you do it functions almost exactly like the [promise constructor][promise-constructor]. To see this, let's use [one of the examples from MDN](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/Promise#examples) that uses promises to make a crude replica of the global [`fetch()`][fetch] @@ -90,7 +89,7 @@ function* fetch(url) { xhr.onload = () => resolve(xhr.responseText); xhr.onerror = () => reject(xhr.statusText); xhr.send(); - return () => { } // "finally" function place holder. + return () => {}; // "finally" function place holder. }); } ``` @@ -109,11 +108,11 @@ until _both_ requests are have received a response. ```js await Promise.race([ fetch("https://openweathermap.org"), - fetch("https://open-meteo.org") -]) + fetch("https://open-meteo.org"), +]); ``` -With Effection, this is easily fixed by calling `abort()` in the finally function to +With Effection, this is easily fixed by calling `abort()` in the finally function to make sure that the request is cancelled when it is either resolved, rejected, or passes out of scope. @@ -125,15 +124,16 @@ function* fetch(url) { xhr.onload = () => resolve(xhr.responseText); xhr.onerror = () => reject(xhr.statusText); xhr.send(); - return () => { xhr.abort(); }; // called in all cases + return () => { + xhr.abort(); + }; // called in all cases }); } ``` ->💡Almost every usage of the [promise concurrency primitives][promise-concurrency] +> 💡Almost every usage of the [promise concurrency primitives][promise-concurrency] > will contain bugs born of leaked effects. - [abort-signal]: https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal [fetch]: https://developer.mozilla.org/en-US/docs/Web/API/fetch [promise-constructor]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/Promise @@ -141,5 +141,5 @@ function* fetch(url) { [cleanup]: ./operations#cleanup [scope]: ./scope [spawn-suspend]: ./spawn#suspend -[action]: https://deno.land/x/effection/mod.ts?s=action -[all]: https://deno.land/x/effection/mod.ts?s=all +[action]: /api/v4/action +[all]: /api/v4/all diff --git a/docs/async-rosetta-stone.mdx b/docs/async-rosetta-stone.mdx index c34a5d92c..3784f2325 100644 --- a/docs/async-rosetta-stone.mdx +++ b/docs/async-rosetta-stone.mdx @@ -115,12 +115,12 @@ To use an operation: let result = yield* operation; ``` -To convert from a promise to an operation, use [`call()`][call] +To convert from a promise to an operation, use [`until()`][until] ```js -import { call } from 'effection'; +import { until } from 'effection'; -let operation = call(promise); +let operation = until(promise); ``` to convert from an operation to a promise, use [`run()`][run] or [`Scope.run`][scope-run] @@ -201,39 +201,6 @@ function* main() { }; ``` -## `Promise.withResolvers()` \<=> `withResolvers()` - -Both `Promise` and `Operation` can be constructed ahead of time without needing to begin the process that will resolve it. To do this with -a `Promise`, use the `Promise.withResolvers()` function: - -```ts -async function main() { - let { promise, resolve } = Promise.withResolvers(); - - setTimeout(resolve, 1000); - - await promise; - - console.log("done!") -} -``` - -In effection: - -```ts -import { withResolvers } from "effection"; - -function* main() { - let { operation, resolve } = withResolvers(); - - setTimeout(resolve, 1000); - - yield* operation; - - console.log("done!"); -}; -``` - ## `for await` \<=> `for yield* each` Loop over an AsyncIterable with `for await`: @@ -316,9 +283,10 @@ To convert an `AsyncIterator` to a `Subscription`, use the let subscription = subscribe(asyncIterator); ``` -[call]: https://deno.land/x/effection/mod.ts?s=call -[run]: https://deno.land/x/effection/mod.ts?s=run -[scope-run]: https://deno.land/x/effection/mod.ts?s=Scope#method_run_0 -[each]: https://deno.land/x/effection/mod.ts?s=each -[stream]: https://deno.land/x/effection/mod.ts?s=stream -[subscribe]: https://deno.land/x/effection/mod.ts?s=subscribe +[call]: /api/v4/call +[until]: /api/v4/until +[run]: /api/v4/run +[scope-run]: /api/v4/Scope#interface_Scope-methods +[each]: /api/v4/each +[stream]: /api/v4/stream +[subscribe]: /api/v4/subscribe diff --git a/docs/collections.mdx b/docs/collections.mdx index 656b936ee..cf1bd0689 100644 --- a/docs/collections.mdx +++ b/docs/collections.mdx @@ -387,14 +387,14 @@ targets in the next section. [async-iterator]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/AsyncIterator [async-iteration-protocols]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Iteration_protocols#the_async_iterator_and_async_iterable_protocols [asynchronous iterators]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/for-await...of -[operation]: https://deno.land/x/effection/mod.ts?s=Operation -[subscription]: https://deno.land/x/effection/mod.ts?s=Subscription -[stream]: https://deno.land/x/effection/mod.ts?s=Stream -[channel]: https://deno.land/x/effection/mod.ts?s=Channel -[signal]: https://deno.land/x/effection/mod.ts?s=Signal -[createSignal]: https://deno.land/x/effection/mod.ts?s=createSignal +[operation]: /api/v4/Operation +[subscription]: /api/v4/Subscription +[stream]: /api/v4/Stream +[channel]: /api/v4/Channel +[signal]: /api/v4/Signal +[createSignal]: /api/v4/createSignal [fp-ts.pipe]: https://gcanti.github.io/fp-ts/modules/function.ts.html#pipe [lodash.flow]: https://lodash.com/docs/4.17.15#flow [close-event]: https://developer.mozilla.org/en-US/docs/Web/API/CloseEvent [event-target]: https://developer.mozilla.org/en-US/docs/Web/API/EventTarget -[on]: https://deno.land/x/effection/mod.ts?s=on +[on]: /api/v4/on diff --git a/docs/errors.mdx b/docs/errors.mdx index 4ef28234b..739ae490a 100644 --- a/docs/errors.mdx +++ b/docs/errors.mdx @@ -167,4 +167,4 @@ await main(function*() { Now, when our spawned task throws an error, control will pass to our catch handler. [Resource]: ./resources -[task]: https://jsr.io/@effection/effection/doc/~/Task +[task]: /api/v4/Task diff --git a/docs/events.mdx b/docs/events.mdx index e163b9b6f..eaf7e3f77 100644 --- a/docs/events.mdx +++ b/docs/events.mdx @@ -72,6 +72,6 @@ await main(function*() { [wscode]: https://developer.mozilla.org/en-US/docs/Web/API/CloseEvent [childprocess]: https://nodejs.org/api/child_process.html#child_process_class_childprocess -[once]: https://deno.land/x/effection/mod.ts?s=once -[on]: https://deno.land/x/effection/mod.ts?s=on +[once]: /api/v4/once +[on]: /api/v4/on [event-target]: https://developer.mozilla.org/en-US/docs/Web/API/EventTarget diff --git a/docs/faq.mdx b/docs/faq.mdx index 3cbce1ff2..8ab1ba1b9 100644 --- a/docs/faq.mdx +++ b/docs/faq.mdx @@ -45,3 +45,100 @@ In Effection v3, it was possible to `yield* call(Promise.resolve(42))` but we re because we wanted to align call closer to `Function.prototype.call`. You can still use `yield* call(() => Promise.resolve(42))` and `yield* call(async function() {})`. In v4, we plan to ship a new function called `when` which will take a promise and wrap it in a constructor for convenience. + +## How can I chain operations together like I do with promises? + +In JS, it's common in a function that isn't async to chain promises together using `.then()` and `.catch()` + +The simplest way is to use the [Chain](https://frontside.com/effection/x/chain/) helper from [effectionx](https://frontside.com/effection/x) + +```typescript +import { Chain } from "@effectionx/chain"; + +Chain.from(operation) + .then(lift((val) => val * 2)), + .then(lift((val) => String(val)), + .then(lift((val) => val.toUpperCase())), +``` + +Note the usage of [`lift()`](../../api/v4/lift) to convert normal synchronous functions into operations. + +But composition is so simple, that rolling your own is almost trivial. +For example, you can make make a "pipeable" `then()` operation like +so: + +```typescript +function then(fn: (value: A) => Operation): (operation: Operation) => Operation { + return function*(operation) { + return yield* fn(yield* operation); + } +} +``` + +You can then take an off the shelf pipe function (they're a dime a +dozen from places like lodash, fp-ts, or even the code you have up your +own sleeve) and compose several `then()` statements together. + +```ts +pipe( + operation, + then(lift((val) => val * 2)), + then(lift((val) => String(val)), + then(lift((val) => val.toUpperCase())), +); +``` + +## How do I share the outcome of an operation in several places? + +A very common pattern is to share a promise in multiple places. For example: + +```ts +class Foo { + data: Promise; + + constructor() { + this.data = (async () => 5)(); + } + + async getData() { + return await this.data; + } +} + +const foo = new Foo(); +console.log(await foo.getData()); +``` + +However, this can't be done directly with Operation as they can only be computed once: + +```ts +const func = function* () { + console.log("compute"); + return 5; +} + +const started = func(); +``` + +If this is something you need, you can use the [`Chain.from`]() method from the [@effectionx/chain]() package which only evaluate its operation once and share it between all chains that are derived from it: + + +```ts +class Foo { + data: Promise; + + constructor() { + let operation = (function* () { + return 5; + })(); + this.data = Chain.from(operation); + } + + *getData() { + return yield* this.data; + } +} + +const foo = new Foo(); +console.log(yield * foo.getData()); +``` diff --git a/docs/installation.mdx b/docs/installation.mdx index 7eb794564..815591393 100644 --- a/docs/installation.mdx +++ b/docs/installation.mdx @@ -3,12 +3,6 @@ If you encounter obstacles integrating with your environment, please create a [G ## Node.js & Browser -

- Bundlephobia badge showing effection minified - Bundlephobia badge showing effection gzipped size - Bundlephobia badge showing it's treeshackable -

- Effection is available on [NPM][npm], as well as derived registries such as [Yarn][yarn] and [UNPKG][unpkg]. It comes with TypeScript types and can be consumed as both ESM and CommonJS. ```bash @@ -21,10 +15,10 @@ yarn add effection ## Deno -Effection has first class support for Deno because it is developed with [Deno](https://deno.land). Releases are published to [https://deno.land/x/effection](https://deno.land/x/effection). For example, to import the `main()` function: +Effection has first class support for Deno because it is developed with [Deno](https://deno.land). Releases are published to [JSR](https://jsr.io/@effection/effection) and [https://deno.land/x/effection](https://deno.land/x/effection). For example, to import the `main()` function: ```ts -import { main } from "https://deno.land/x/effection/mod.ts"; +import { main } from "jsr:@effection/effection@4.0.0"; ``` > 💡 If you're curious how we keep NPM/YARN and Deno packages in-sync, you can [checkout the blog post on how publish Deno packages to NPM.][deno-npm-publish]. diff --git a/docs/operations.mdx b/docs/operations.mdx index 7b15f7555..729dfe25c 100644 --- a/docs/operations.mdx +++ b/docs/operations.mdx @@ -252,6 +252,6 @@ the programmer free to not worry about it. [async function]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/async_function [promise]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise [spawn-suspend]: ./spawn#suspend -[run]: https://deno.land/x/effection/mod.ts?s=run -[suspend]: https://deno.land/x/effection/mod.ts?s=suspend -[ensure]: https://deno.land/x/effection/mod.ts?s=ensure +[run]: /api/v4/run +[suspend]: /api/v4/suspend +[ensure]: /api/v4/ensure diff --git a/docs/resources.mdx b/docs/resources.mdx index 30710b3c1..a5486635e 100644 --- a/docs/resources.mdx +++ b/docs/resources.mdx @@ -179,6 +179,6 @@ Resources allow us to create powerful, reusable abstractions which are also able to clean up after themselves. [tasks]: /docs/guides/tasks -[operation]: https://deno.land/x/effection/mod.ts?s=Operation -[resource]: https://deno.land/x/effection/mod.ts?s=resource -[ensure]: https://deno.land/x/effection/mod.ts?s=ensure +[operation]: /api/v4/Operation +[resource]: /api/v4/resource +[ensure]: /api/v4/ensure diff --git a/docs/scope.mdx b/docs/scope.mdx index 77fc46317..233bfe9f2 100644 --- a/docs/scope.mdx +++ b/docs/scope.mdx @@ -290,7 +290,7 @@ Effection, and so we have to explicitly connect each request to an operation. ```js -import { call, ensure, main, useScope, useAbortSignal, suspend } from "effection"; +import { until, ensure, main, useScope, useAbortSignal, suspend } from "effection"; import express from "express"; await main(function*() { @@ -301,8 +301,8 @@ await main(function*() { // use the scope to spawn an operation within it. return await scope.run(function*() { let signal = yield* useAbortSignal(); - let response = yield* call(() => fetch(`https://google.com?q=${req.params.q}`, { signal })); - res.send(yield* call(() => response.text())); + let response = yield* until(fetch(`https://google.com?q=${req.params.q}`, { signal })); + res.send(yield* until(response.text())); }); }); @@ -349,7 +349,7 @@ Whenever you use `createScope()`, it is important that you `await` the destruction operation when the scope is no longer needed since it will not be destroyed implicitly. -[spawn]: https://deno.land/x/effection/mod.ts?s=spawn -[use-abort-signal]: https://deno.land/x/effection/mod.ts?s=useAbortSignal -[scope]: https://deno.land/x/effection/mod.ts?s=Scope -[create-scope]: https://deno.land/x/effection/mod.ts?s=createScope +[spawn]: /api/v4/spawn +[use-abort-signal]: /api/v4/useAbortSignal +[scope]: /api/v4/Scope +[create-scope]: /api/v4/createScope diff --git a/docs/spawn.mdx b/docs/spawn.mdx index 101d0efd1..fabf72106 100644 --- a/docs/spawn.mdx +++ b/docs/spawn.mdx @@ -145,7 +145,8 @@ behaviour of errors is very clearly defined. An error in a child will also cause the parent to error, which in turn halts any siblings. This idea is called [structured concurrency], and it has profound effects on -the composability of concurrent code. +the composability of concurrent code. For more on why this matters specifically +for JavaScript, see [Why Structured Concurrency for JavaScript][sc-for-js]. ## Using combinators @@ -203,6 +204,7 @@ line **(8)**. You can learn more about this in the [scope guide](./scope). [structured concurrency]: https://vorpus.org/blog/notes-on-structured-concurrency-or-go-statement-considered-harmful/ +[sc-for-js]: /blog/2026-02-06-structured-concurrency-for-javascript [express]: https://expressjs.org -[scope.run]: https://deno.land/x/effection/mod.ts?s=Scope#method_run_0 -[Operation]: https://deno.land/x/effection/mod.ts?s=Operation +[scope.run]: /api/v4/Scope +[Operation]: /api/v4/Operation diff --git a/docs/structure.json b/docs/structure.json index 9e748998d..acea1b81c 100644 --- a/docs/structure.json +++ b/docs/structure.json @@ -4,17 +4,18 @@ ["typescript.mdx", "TypeScript"], ["thinking-in-effection.mdx", "Thinking in Effection"], ["async-rosetta-stone.mdx", "Async Rosetta Stone"], - ["tutorial.mdx", "Tutorial"] + ["tutorial.mdx", "Tutorial"], + ["upgrade.mdx", "Upgrading from V3"] ], "Learn Effection": [ ["operations.mdx", "Operations"], - ["actions.mdx", "Actions and Suspensions"], - ["resources.mdx", "Resources"], ["spawn.mdx", "Spawn"], + ["resources.mdx", "Resources"], ["collections.mdx", "Streams and Subscriptions"], ["events.mdx", "Events"], ["errors.mdx", "Error Handling"], - ["context.mdx", "Context"] + ["context.mdx", "Context"], + ["actions.mdx", "Actions"] ], "Advanced": [ ["faq.mdx", "FAQ"], diff --git a/docs/thinking-in-effection.mdx b/docs/thinking-in-effection.mdx index 6ab6b1c67..a52e042db 100644 --- a/docs/thinking-in-effection.mdx +++ b/docs/thinking-in-effection.mdx @@ -59,7 +59,7 @@ However, the same guarantee is not provided for async functions. ```js {5} showLineNumbers async function main() { try { - await new Promise((resolve) => setTimeout(resolve, 100,000)); + await new Promise((resolve) => setTimeout(resolve, 100000)); } finally { // code here is NOT GUARANTEED to run } @@ -82,7 +82,7 @@ import { main, action } from "effection"; await main(function*() { try { - yield* action(function*(resolve) { setTimeout(resolve, 100,000) }); + yield* action(function*(resolve) { setTimeout(resolve, 100000) }); } finally { // code here is GUARANTEED to run } @@ -101,7 +101,7 @@ leverage what they already know while gaining the guarantees of Structured Concu constructs in an Effection function and they'll behave as you'd expect. However, one of the constructs we avoid in Effection is the syntax and semantics of `async/await`. -This is because `async/await` is [not capable of modeling structured concurrency][await-event-horizon]. Instead, we make +This is because `async/await` is [not capable of modeling structured concurrency][await-event-horizon]. For the full picture of why JavaScript needs structured concurrency, see [Why Structured Concurrency for JavaScript][sc-for-js]. Instead, we make extensive use of [generator functions][generator-functions] which are a core feature of JavaScript and are supported by all browsers and JavaScript runtimes. @@ -109,5 +109,6 @@ Finally, we provide a handy Effection Rosetta Stone to show how _Async/Await_ co [generator-functions]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/function* [await-event-horizon]: https://frontside.com/blog/2023-12-11-await-event-horizon/ +[sc-for-js]: /blog/2026-02-06-structured-concurrency-for-javascript [delimited-continuation-repo]: https://github.com/thefrontside/continuation [eaddrinuse-error]: https://stackoverflow.com/questions/14790910/stop-all-instances-of-node-js-server diff --git a/docs/tutorial.mdx b/docs/tutorial.mdx index 26edccbf6..988110d92 100644 --- a/docs/tutorial.mdx +++ b/docs/tutorial.mdx @@ -1,14 +1,14 @@ Let's write our first program using Effection. ``` javascript -import { call, useAbortSignal } from "effection"; +import { until, useAbortSignal } from "effection"; export function* fetchWeekDay(timezone) { let signal = yield* useAbortSignal(); - let response = yield* call(fetch(`http://worldclockapi.com/api/json/${timezone}/now`, { signal })); + let response = yield* until(fetch(`http://worldclockapi.com/api/json/${timezone}/now`, { signal })); - let time = yield* call(response.json()); + let time = yield* until(response.json()); return time.dayOfTheWeek; } @@ -50,7 +50,7 @@ operations out of existing ones. For example, we can add a time out of for that matter) by wrapping it in a `withTimeout` operation. ```javascript -import { main, call, race } from 'effection'; +import { main, until, race } from 'effection'; import { fetchWeekDay } from './fetch-week-day'; await main(function*() { @@ -59,7 +59,7 @@ await main(function*() { }); function withTimeout(operation, delay) { - return race([operation, call(function*() { + return race([operation, until(function*() { yield* sleep(delay); throw new Error(`timeout!`); })]); diff --git a/docs/typescript.mdx b/docs/typescript.mdx index a2e7218d0..d4ffbe0c8 100644 --- a/docs/typescript.mdx +++ b/docs/typescript.mdx @@ -89,4 +89,4 @@ it and expect to receive a number. Now anybody using your operation won't have any doubt about what it is and how they can use it. -[operation]: https://deno.land/x/effection/mod.ts?s=Operation +[operation]: /api/v4/Operation diff --git a/docs/upgrade.mdx b/docs/upgrade.mdx new file mode 100644 index 000000000..66b7e3db4 --- /dev/null +++ b/docs/upgrade.mdx @@ -0,0 +1,225 @@ +For the most part, the changes between Effection versions 3 and 4 are +internal, and while the public API remains largely untouched , there +were some places where it was appropriate to make breaking changes. +However, in the cases where we did, it was guided by our simple +principle: to _embrace_ JavaScript, not fight against it. We asked +ourselvers: How can we make Effection APIs feel even _more_ natural +and familiar to JavaScript developers while providing "just enough" +functionality to bring all the wonderful benefits of structured +concurrency and effects to your applications. + +Among other things, this included the refinement of several key +functions that just did too much in v3. These changes make Effection +harmonize even more with the greate JavaScript ecosystem, but they do +require some attention during migration. + +## Simplifying `call()` + +In Effection, few APIs were as versatile as the [v3 `call()` +function][v3-call]. It could invoke functions, evaluate promises, +treat constants as operations, establish error boundaries, and even +manage concurrency to boot. But one piece of feedback that we +consistently got was "how does this relate to +`Function.prototype.call()." Sadly, the answer was: only tangentially. + +So in order to make using `call()` require no learning beyond how its +vanilla counterpart works, we've simplified it to what you would +expect from something named "call". It invokes functions as +operations, and that's it. + +When you're upgrading promise-based code, you'll now use the `until()` helper, which converts a promise into an operation: + +```js +let response = yield* call(fetch("https://frontside.com/effection")); +``` + +To do this in v4, use the [`until()`][until] utility function + +```js +let response = yield* until(fetch("https://frontside.com/effection")); +``` + +`v3` call also allowed for the rare cases where you need to evaluate a constant value as an operation. + +```js +let five = yield* call(5); +``` + +Now however, there's now a dedicated `constant()` helper: + +```js +let five = yield* constant(5); +``` + +`call()` could also be used in v3 to delimit a concurrency boundary which would terminate all children within its scope + +The most significant change involves how called operations are +delimited. In v3, `call()` automatically established both error +boundaries and concurrency boundaries around its body, meaning any +tasks spawned within a `call()` would be automatically cleaned up when +the call completed. In v4, `call()` no longer does this—it simply +invokes the function and returns its result. + +```js +// v4 - call() does NOT establish boundaries +yield* call(function*() { + // spawned tasks here are NOT terminated before call returns its value + yield* spawn(someBackgroundTask); + return "done"; +}); +``` + +If you need those boundaries—and you often will—use the new `scoped()` function: + +```js +// v4 - scoped() establishes boundaries +yield* scoped(function*() { + // spawned tasks here ARE terminated before the scoped body returns + yield* spawn(someBackgroundTask); + return "done"; +}); +``` + +## Rethinking `action()` + +The `action()` function has undergone a similar simplification. In our documentation, we describe `action()` as Effection's equivalent to `new Promise()`, and v4 makes this analogy much stronger. + +In v3, an action is written using an operation. For example, consider this implementation of a `sleep()` operation: + +```js +// v3 operation function +function sleep(milliseconds) { + return action(function*(resolve) { + let timeout = setTimeout(resolve, milliseconds); + try { + yield* suspend(); + } finally { + clearTimeout(timeout); + } + }); +} +``` + +The v4 equivalent looks much more like a Promise constructor: + +```js +// v4 - action strongly resembles Promise +function sleep(milliseconds) { + return action((resolve, reject) => { + let timeout = setTimeout(resolve, milliseconds); + return () => { clearTimeout(timeout); } + }); +} +``` + +Notice how the v4 version takes a synchronous function that receives +both `resolve` and `reject` callbacks, just like `new Promise()`. +Instead of using `try/finally` blocks for teardown, you just return a +synchronous cleanup function. + +Like the simplified `call()`, actions in v4 no longer establish their +own concurrency or error boundaries. If you need boundaries around +action usage, wrap the call with `scoped()`. + +## Task Execution Priority + +The most subtle but important change in v4 involves how tasks are +scheduled for execution. This change won't trigger any deprecation +warnings, but it can affect the behavior of your applications in ways +that might not be immediately obvious. + +In v3, tasks ran immediately whenever an event came in that caused it +to resume. A child task spawned in the background would start +executing immediately, even while its parent was still running +synchronous code. v4 changes this: a parent task always has priority +over its children. + +Consider this example: + +```js +await run(function* example() { + console.log('parent: start'); + yield* spawn(function*() { + console.log('child: start'); + yield* sleep(10); + console.log('child: end'); + }); + console.log('parent: middle'); + // Lots of synchronous work here + for (let i = 0; i < 1000; i++) { + // The child won't run during this loop in v4 + } + console.log('parent: before async'); + yield* sleep(100); // This is when the child finally gets to run + console.log('parent: end'); +}) +``` + +In v3, you would see output like this: + +``` +parent: start +child: start +parent: middle +parent: before async +parent: end +child: end +``` + +But in v4, the output changes to: + +``` +parent: start +parent: middle +parent: before async +child: start +parent: end +child: end +``` + +The key difference is that the child task doesn't get to run until the +parent yields control to a truly asynchronous operation—in this case, +`sleep(1)`. Purely synchronous operations, even when wrapped with +`yield* call()`, won't give child tasks a chance to execute. Furthermore, in cases where a single event schedules multiple tasks to resume, the parent task will resume first. + +This change makes task execution more predictable and allows parents +to always have the necessary priority required to supervise the +execution of their children. However, it does mean that if your v3 +code relied on child tasks starting immediately, you might need to +adjust your approach. + +## Migration Strategies + +Most of the changes from v3 to v4 will be caught by deprecation +warnings, making the upgrade process straightforward. The `call()` and +`action()` changes are mechanical—update the syntax, import the new +helpers, and you're done. + +The task execution priority change requires more thought. If your +application has timing-sensitive code where child tasks need to start +immediately, you have several approaches: + +You can restructure your parent tasks to yield control explicitly by +adding asynchronous operations where needed. Even `yield* sleep(0)` +will give child tasks a chance to start: + +```js +function* parent() { + yield* spawn(childTask); + yield* sleep(0); // Yields control to child immediately + // Continue with parent work +} +``` + +You can also reconsider your task hierarchy. Sometimes what you +thought needed to be a parent-child relationship might work better as +sibling tasks within a shared scope. + + +If you encounter issues during upgrade, please file an +[issue](https://github.com/thefrontside/effection/issues/new). Not +only will that allow us to lend a hand, but it will also give us an +opportunity to improve this migration guide! + +[v3-call]: https://frontside.com/effection/api/v3/call/ +[until]: https://frontside.com/effection/api/v4/until/ \ No newline at end of file diff --git a/lib/all.ts b/lib/all.ts index d386587fe..18c143a7b 100644 --- a/lib/all.ts +++ b/lib/all.ts @@ -1,6 +1,6 @@ import type { Operation, Task, Yielded } from "./types.ts"; import { spawn } from "./spawn.ts"; -import { encapsulate, trap } from "./task.ts"; +import { trap } from "./task.ts"; /** * Block and wait for all of the given operations to complete. Returns @@ -30,20 +30,26 @@ import { encapsulate, trap } from "./task.ts"; export function* all[] | []>( ops: T, ): Operation> { - return yield* trap(() => - encapsulate(function* (): Operation> { - let tasks: Task[] = []; - + let tasks: Task[] = []; + try { + return yield* trap(function* (): Operation> { for (let operation of ops) { - tasks.push(yield* spawn(() => operation)); + let member = () => operation; + tasks.push(yield* spawn(member)); } - let results = []; + let results: unknown[] = []; for (let task of tasks) { - results.push(yield* task); + let result = yield* task; + results.push(result); } return results as All; - }) - ); + }); + } catch (error) { + for (let task of tasks) { + yield* task.halt(); + } + throw error; + } } /** diff --git a/lib/box.ts b/lib/box.ts new file mode 100644 index 000000000..ac14f01de --- /dev/null +++ b/lib/box.ts @@ -0,0 +1,10 @@ +import { Err, Ok, type Result } from "./result.ts"; +import type { Operation } from "./types.ts"; + +export function* box(op: () => Operation): Operation> { + try { + return Ok(yield* op()); + } catch (error) { + return Err(error as Error); + } +} diff --git a/lib/callcc.ts b/lib/callcc.ts index 606fdd022..5d969c95a 100644 --- a/lib/callcc.ts +++ b/lib/callcc.ts @@ -3,7 +3,7 @@ import { Err, Ok, type Result, unbox } from "./result.ts"; import type { Operation } from "./types.ts"; import { withResolvers } from "./with-resolvers.ts"; import { spawn } from "./spawn.ts"; -import { encapsulate } from "./task.ts"; +import { encapsulate } from "./task-group.ts"; export function* callcc( op: ( diff --git a/lib/contexts.ts b/lib/contexts.ts index 7e3b80e47..c03cd1859 100644 --- a/lib/contexts.ts +++ b/lib/contexts.ts @@ -5,7 +5,7 @@ export const Routine = createContext>( "@effection/coroutine", ); -export const Generation = createContext( +export const Priority = createContext( "@effection/scope.generation", 0, ); diff --git a/lib/coroutine.ts b/lib/coroutine.ts index 6e5d3852d..f0c6a1bfa 100644 --- a/lib/coroutine.ts +++ b/lib/coroutine.ts @@ -1,7 +1,8 @@ -import { Generation } from "./contexts.ts"; +import { Priority } from "./contexts.ts"; +import { DelimiterContext } from "./delimiter.ts"; import { ReducerContext } from "./reducer.ts"; import { Ok } from "./result.ts"; -import type { Coroutine, Operation, Scope, Subscriber } from "./types.ts"; +import type { Coroutine, Operation, Scope } from "./types.ts"; export interface CoroutineOptions { scope: Scope; @@ -11,25 +12,12 @@ export interface CoroutineOptions { export function createCoroutine( { operation, scope }: CoroutineOptions, ): Coroutine { - let subscribers = new Set>(); - - let send: Subscriber = (item) => { - try { - for (let subscriber of subscribers) { - subscriber(item); - } - } finally { - if (item.done) { - subscribers.clear(); - } - } - }; - let reducer = scope.expect(ReducerContext); let iterator: Coroutine["data"]["iterator"] | undefined = undefined; let routine = { + runLevel: 0, scope, data: { get iterator() { @@ -38,42 +26,31 @@ export function createCoroutine( } return iterator; }, - discard: (resolve) => resolve(Ok()), + exit: (resolve) => resolve(Ok()), }, - next(result, subscriber) { - if (subscriber) { - subscribers.add(subscriber); - } - routine.data.discard((exit) => { - routine.data.discard = (resolve) => resolve(Ok()); + next(result) { + routine.data.exit((exitResult) => { + routine.data.exit = (didExit) => didExit(Ok()); reducer.reduce([ - scope.expect(Generation), + scope.expect(Priority), routine, - exit.ok ? result : exit, - send as Subscriber, + exitResult.ok ? result : exitResult, + scope.expect(DelimiterContext).validator, "next", ]); }); - - return () => subscriber && subscribers.delete(subscriber); }, - return(result, subscriber?: Subscriber) { - if (subscriber) { - subscribers.add(subscriber as Subscriber); - } - routine.data.discard((exit) => { - routine.data.discard = (resolve) => resolve(Ok()); + return(result) { + routine.data.exit((exitResult) => { + routine.data.exit = (didExit) => didExit(Ok()); reducer.reduce([ - scope.expect(Generation), + scope.expect(Priority), routine, - exit.ok ? result : exit, - send as Subscriber, + exitResult.ok ? result : exitResult, + scope.expect(DelimiterContext).validator, "return", ]); }); - - return () => - subscriber && subscribers.delete(subscriber as Subscriber); }, } as Coroutine; diff --git a/lib/delimiter.ts b/lib/delimiter.ts new file mode 100644 index 000000000..a262961e8 --- /dev/null +++ b/lib/delimiter.ts @@ -0,0 +1,112 @@ +// deno-lint-ignore-file no-unsafe-finally +import { createContext } from "./context.ts"; +import { useCoroutine } from "./coroutine.ts"; +import { Just, type Maybe, Nothing } from "./maybe.ts"; +import { Err, Ok, type Result } from "./result.ts"; +import type { Coroutine, Operation } from "./types.ts"; +import { withResolvers } from "./with-resolvers.ts"; + +export class Delimiter + implements Operation>>, ErrorBoundary { + level = 0; + finalized = false; + future = withResolvers>>(); + computed = false; + routine?: Coroutine; + outcome?: Maybe>; + + constructor( + public readonly operation: () => Operation, + public readonly parent?: Delimiter, + ) {} + + raise(error: Error): void { + let failure = Just(Err(error)); + if (this.finalized) { + this.parent?.exit(failure); + } else { + this.exit(failure); + } + } + + interrupt(): void { + this.exit(Nothing()); + } + + *close(): Operation { + let done = this.future.operation; + let interrupted = !this.computed; + + this.close = function* close() { + let outcome = yield* done; + if (interrupted && outcome.exists && !outcome.value.ok) { + throw outcome.value.error; + } + }; + if (!this.outcome) { + this.interrupt(); + yield* this.close(); + } else { + if (interrupted && this.outcome.exists && !this.outcome.value.ok) { + throw this.outcome.value.error; + } + } + } + + private exit(outcome: Maybe>): void { + if (this.finalized) { + return; + } + this.outcome = + (this.outcome && this.outcome.exists && !this.outcome.value.ok) + ? this.outcome + : outcome; + this.level++; + if (!this.routine) { + this.finalized = true; + this.future.resolve(this.outcome); + } else { + this.routine.return(Ok(this.outcome)); + } + } + + get validator(): () => boolean { + let { level } = this; + return () => !this.finalized && this.level === level; + } + + [Symbol.iterator] = function* delimiter(this: Delimiter) { + this.routine = yield* useCoroutine(); + + try { + let value = yield* this.operation(); + if (this.level === 0) { + this.computed = true; + this.outcome = Just(Ok(value)); + } + } catch (error) { + this.computed = true; + this.outcome = Just(Err(error as Error)); + } finally { + this.finalized = true; + this.outcome = this.outcome ?? Nothing(); + this.future.resolve(this.outcome); + return this.outcome; + } + }; +} + +export const DelimiterContext = createContext>( + "@effection/delimiter", +); + +export interface ErrorBoundary { + raise(error: Error): void; +} + +export const ErrorContext = createContext( + "@effection/boundary", + { + raise: () => {}, + }, +); diff --git a/lib/do.ts b/lib/do.ts index fe9da466f..900f34c0f 100644 --- a/lib/do.ts +++ b/lib/do.ts @@ -32,6 +32,9 @@ export function Do(effect: Effect): Operation { return { done: false, value: perform }; } }, + throw(error) { + throw error; + }, }; }, }; diff --git a/lib/drain.ts b/lib/drain.ts deleted file mode 100644 index c9e47e088..000000000 --- a/lib/drain.ts +++ /dev/null @@ -1,10 +0,0 @@ -import type { Result } from "./result.ts"; -import type { Subscriber } from "./types.ts"; - -export function drain(end: (result: Result) => void): Subscriber { - return (next) => { - if (next.done) { - end(next.value); - } - }; -} diff --git a/lib/each.ts b/lib/each.ts index a7e90f186..13b99fff7 100644 --- a/lib/each.ts +++ b/lib/each.ts @@ -1,9 +1,8 @@ -import type { Operation, Stream, Subscription } from "./types.ts"; import { createContext } from "./context.ts"; -import { race } from "./race.ts"; -import { resource } from "./resource.ts"; -import { withResolvers } from "./with-resolvers.ts"; import { useScope } from "./scope.ts"; +import { spawn } from "./spawn.ts"; +import type { Operation, Stream, Subscription } from "./types.ts"; +import { withResolvers } from "./with-resolvers.ts"; /** * Consume an effection stream using a simple for-of loop. @@ -36,9 +35,11 @@ export function each(stream: Stream): Operation> { if (!scope.hasOwn(EachStack)) { scope.set(EachStack, []); } - return yield* resource(function* (provide) { - let done = withResolvers(); + let done = withResolvers(); + let cxt = withResolvers>(); + + yield* spawn(function* () { let subscription = yield* stream; let current = yield* subscription.next(); @@ -56,7 +57,15 @@ export function each(stream: Stream): Operation> { stack.push(context); - let iterator: Iterator = { + cxt.resolve(context); + + yield* done.operation; + }); + + let context = yield* cxt.operation; + + return { + [Symbol.iterator]: () => ({ next() { if (context.stale) { let error = new Error( @@ -73,15 +82,8 @@ export function each(stream: Stream): Operation> { context.finish(); return { done: true, value: void 0 }; }, - }; - - yield* race([ - done.operation, - provide({ - [Symbol.iterator]: () => iterator, - }), - ]); - }); + }), + }; }, }; } diff --git a/lib/future.ts b/lib/future.ts new file mode 100644 index 000000000..b61517651 --- /dev/null +++ b/lib/future.ts @@ -0,0 +1,37 @@ +import { lazyPromiseWithResolvers } from "./lazy-promise.ts"; +import type { Future } from "./types.ts"; +import { withResolvers } from "./with-resolvers.ts"; + +export interface FutureWithResolvers { + future: Future; + resolve(value: T): void; + reject(error: Error): void; +} +export function createFuture(): FutureWithResolvers { + let promise = lazyPromiseWithResolvers(); + let operation = withResolvers(); + + let resolve = (value: T) => { + promise.resolve(value); + operation.resolve(value); + }; + + let reject = (error: Error) => { + promise.reject(error); + operation.reject(error); + }; + + let future = Object.defineProperties(promise.promise, { + [Symbol.iterator]: { + enumerable: false, + value: operation.operation[Symbol.iterator], + }, + [Symbol.toStringTag]: { + enumerable: false, + configurable: true, + value: "Future", + }, + }) as Future; + + return { future, resolve, reject }; +} diff --git a/lib/interval.ts b/lib/interval.ts new file mode 100644 index 000000000..28fb5227e --- /dev/null +++ b/lib/interval.ts @@ -0,0 +1,31 @@ +import { resource } from "./resource.ts"; +import { createSignal } from "./signal.ts"; +import type { Stream } from "./types.ts"; + +/** + * Consume an interval as an infinite stream. + * + * ```ts + * let startTime = Date.now(); + * + * for (let _ of yield* each(interval(10))) { + * let elapsed = Date.now() - startTime; + * console.log(`elapsed time: ${elapsed} ms`); + * yield* each.next(); + * } + * ``` + * @param milliseconds - how long to delay between each item in the stream + */ +export function interval(milliseconds: number): Stream { + return resource(function* (provide) { + let signal = createSignal(); + + let id = setInterval(signal.send, milliseconds); + + try { + yield* provide(yield* signal); + } finally { + clearInterval(id); + } + }); +} diff --git a/lib/main.ts b/lib/main.ts index aaafb69da..065d39caa 100644 --- a/lib/main.ts +++ b/lib/main.ts @@ -88,25 +88,38 @@ export async function main( hardexit = (status) => Deno.exit(status); try { Deno.addSignalListener("SIGINT", interrupt.SIGINT); - Deno.addSignalListener("SIGTERM", interrupt.SIGTERM); + /** + * Windows only supports ctrl-c (SIGINT), ctrl-break (SIGBREAK), and ctrl-close (SIGUP) + */ + if (Deno.build.os !== "windows") { + Deno.addSignalListener("SIGTERM", interrupt.SIGTERM); + } yield* body(Deno.args.slice()); } finally { Deno.removeSignalListener("SIGINT", interrupt.SIGINT); - Deno.removeSignalListener("SIGTERM", interrupt.SIGTERM); + if (Deno.build.os !== "windows") { + Deno.removeSignalListener("SIGTERM", interrupt.SIGTERM); + } } }, *node() { + // Annotate dynamic import so that webpack ignores it. + // See https://webpack.js.org/api/module-methods/#webpackignore let { default: process } = yield* call(() => - import("node:process") + import(/* webpackIgnore: true */ "node:process") ); hardexit = (status) => process.exit(status); try { process.on("SIGINT", interrupt.SIGINT); - process.on("SIGTERM", interrupt.SIGTERM); + if (process.platform !== "win32") { + process.on("SIGTERM", interrupt.SIGTERM); + } yield* body(process.argv.slice(2)); } finally { process.off("SIGINT", interrupt.SIGINT); - process.off("SIGTERM", interrupt.SIGINT); + if (process.platform !== "win32") { + process.off("SIGTERM", interrupt.SIGINT); + } } }, *browser() { @@ -167,7 +180,7 @@ function* withHost(op: HostOperation): Operation { // @see https://github.com/iliakan/detect-node/blob/master/index.js } else if ( Object.prototype.toString.call( - typeof globalThis.process !== "undefined" ? globalThis.process : 0, + typeof global.process !== "undefined" ? global.process : 0, ) === "[object process]" ) { return yield* op.node(); diff --git a/lib/mod.ts b/lib/mod.ts index 49d731784..e15e4d3c5 100644 --- a/lib/mod.ts +++ b/lib/mod.ts @@ -5,6 +5,7 @@ export * from "./context.ts"; export * from "./scope.ts"; export * from "./suspend.ts"; export * from "./sleep.ts"; +export * from "./interval.ts"; export * from "./run.ts"; export * from "./spawn.ts"; export * from "./resource.ts"; @@ -23,3 +24,4 @@ export * from "./main.ts"; export * from "./with-resolvers.ts"; export * from "./async.ts"; export * from "./scoped.ts"; +export * from "./until.ts"; diff --git a/lib/priority-queue.ts b/lib/priority-queue.ts new file mode 100644 index 000000000..cdd5545d2 --- /dev/null +++ b/lib/priority-queue.ts @@ -0,0 +1,91 @@ +export class PriorityQueue { + public tiers: Tier[] = []; + public heap: Tier[] = []; + + push(priority: number, item: T) { + let tier = this.tiers[priority]; + if (tier) { + tier.items.unshift(item); + } else { + let tier = new Tier(priority, [item]); + this.tiers[priority] = tier; + let current = this.heap.length; + this.heap.push(tier); + while (current > 0) { + let p = parentOf(current); + if (priority < this.heap[p].priority) { + this.heap[current] = this.heap[p]; + this.heap[p] = tier; + } + current = p; + } + } + } + + pop(): T | undefined { + let top = this.heap[0]; + if (!top) { + return; + } + let value = top.items.pop()!; + if (top.items.length === 0) { + delete this.tiers[top.priority]; + let tier = this.heap.pop()!; + if (this.heap.length > 0) { + let current = 0; + this.heap[0] = tier; + while (current < this.heap.length - 1) { + let left_i = leftOf(current); + let right_i = rightOf(current); + let left = this.heap[left_i]; + let right = this.heap[right_i]; + + if (left) { + if (right) { + if ( + (tier.priority > left.priority) || + (tier.priority > right.priority) + ) { + if (left.priority < right.priority) { + this.heap[current] = left; + this.heap[left_i] = tier; + current = left_i; + } else { + this.heap[current] = right; + this.heap[right_i] = tier; + current = right_i; + } + } else { + break; + } + } else if (left.priority < tier.priority) { + this.heap[current] = left; + this.heap[left_i] = tier; + current = left_i; + } else { + break; + } + } else { + break; + } + } + } + } + return value; + } +} + +class Tier { + constructor(public priority: number, public items: T[]) {} +} + +function parentOf(index: number): number { + return Math.floor((index - 1) / 2); +} +function leftOf(index: number) { + return index * 2 + 1; +} + +function rightOf(index: number) { + return index * 2 + 2; +} diff --git a/lib/race.ts b/lib/race.ts index df6be05ca..ebbf96b3d 100644 --- a/lib/race.ts +++ b/lib/race.ts @@ -1,10 +1,8 @@ import { spawn } from "./spawn.ts"; -import { encapsulate, trap } from "./task.ts"; +import { trap } from "./task.ts"; import type { Operation, Task, Yielded } from "./types.ts"; import { withResolvers } from "./with-resolvers.ts"; import { Err, Ok, type Result } from "./result.ts"; -//import { useScope } from "./scope.ts"; -//import { transfer } from "./scope.ts"; /** * Race the given operations against each other and return the value of @@ -33,33 +31,26 @@ import { Err, Ok, type Result } from "./result.ts"; export function* race>( operations: readonly T[], ): Operation> { - // let caller = yield* useScope(); let winner = withResolvers>>("await winner"); let tasks: Task[] = []; // encapsulate the race in a hermetic scope. - let result = yield* trap(() => - encapsulate(function* () { - for (let operation of operations.slice().reverse()) { - tasks.push( - yield* spawn(function* () { - // let contestant = yield* useScope(); - try { - let value = yield* operation; - - // Transfer the winner to the contestant - // transfer({ from: contestant, to: caller }); - winner.resolve(Ok(value as Yielded)); - } catch (error) { - winner.resolve(Err(error as Error)); - } - }), - ); - } - return yield* winner.operation; - }) - ); + let result = yield* trap(function* () { + for (let operation of operations.slice()) { + tasks.push( + yield* spawn(function* candidate() { + try { + let value = yield* operation; + winner.resolve(Ok(value as Yielded)); + } catch (error) { + winner.resolve(Err(error as Error)); + } + }), + ); + } + return yield* winner.operation; + }); let shutdown: Task[] = []; diff --git a/lib/reducer.ts b/lib/reducer.ts index 4753badee..45822a468 100644 --- a/lib/reducer.ts +++ b/lib/reducer.ts @@ -1,17 +1,18 @@ import { createContext } from "./context.ts"; -import { Err, Ok, type Result } from "./result.ts"; -import type { Coroutine, Subscriber } from "./types.ts"; +import { PriorityQueue } from "./priority-queue.ts"; +import { Err, type Result } from "./result.ts"; +import type { Coroutine } from "./types.ts"; export class Reducer { reducing = false; - readonly queue = createPriorityQueue(); + readonly queue = new InstructionQueue(); reduce = ( - thunk: Thunk, + instruction: Instruction, ) => { let { queue } = this; - queue.enqueue(thunk); + queue.enqueue(instruction); if (this.reducing) return; @@ -20,43 +21,34 @@ export class Reducer { let item = queue.dequeue(); while (item) { - let [, routine, result, notify, method = "next" as const] = item; + let [, routine, result, _, method = "next" as const] = item; try { - notify({ done: false, value: result }); - const iterator = routine.data.iterator; + let iterator = routine.data.iterator; if (result.ok) { if (method === "next") { let next = iterator.next(result.value); - if (next.done) { - notify({ done: true, value: Ok(next.value) }); - } else { + if (!next.done) { let action = next.value; - routine.data.discard = action.enter(routine.next, routine); + routine.data.exit = action.enter(routine.next, routine); } } else if (iterator.return) { let next = iterator.return(result.value); - if (next.done) { - notify({ done: true, value: Ok(result) }); - } else { + if (!next.done) { let action = next.value; - routine.data.discard = action.enter(routine.next, routine); + routine.data.exit = action.enter(routine.next, routine); } - } else { - notify({ done: true, value: result }); } } else if (iterator.throw) { let next = iterator.throw(result.error); - if (next.done) { - notify({ done: true, value: Ok(next.value) }); - } else { + if (!next.done) { let action = next.value; - routine.data.discard = action.enter(routine.next, routine); + routine.data.exit = action.enter(routine.next, routine); } } else { throw result.error; } } catch (error) { - notify({ done: true, value: Err(error as Error) }); + routine.next(Err(error as Error)); } item = queue.dequeue(); } @@ -66,37 +58,36 @@ export class Reducer { }; } -export const ReducerContext = createContext( - "@effection/reducer", - new Reducer(), -); - -type Thunk = [ +type Instruction = [ number, Coroutine, Result, - Subscriber, + () => boolean, "return" | "next", ]; -// This is a pretty hokey priority queue that uses an array for storage -// so enqueue is O(n). However, `n` is generally small. revisit. -function createPriorityQueue() { - let thunks: Thunk[] = []; - - return { - enqueue(thunk: Thunk): void { - let [priority] = thunk; - let index = thunks.findIndex(([p]) => p >= priority); - if (index === -1) { - thunks.push(thunk); +class InstructionQueue extends PriorityQueue { + enqueue(instruction: Instruction): void { + let [priority] = instruction; + this.push(priority, instruction); + } + dequeue(): Instruction | undefined { + while (true) { + let top = this.pop(); + if (!top) { + return undefined; } else { - thunks.splice(index, 0, thunk); + let validate = top[3]; + if (!validate()) { + continue; + } + return top; } - }, - - dequeue(): Thunk | undefined { - return thunks.shift(); - }, - }; + } + } } + +export const ReducerContext = createContext( + "@effection/reducer", + new Reducer(), +); diff --git a/lib/resource.ts b/lib/resource.ts index c82adabcc..d653319ce 100644 --- a/lib/resource.ts +++ b/lib/resource.ts @@ -1,9 +1,10 @@ import { suspend } from "./suspend.ts"; -import { spawn } from "./spawn.ts"; import type { Operation } from "./types.ts"; -import { trap } from "./task.ts"; +import { createTask, trap } from "./task.ts"; import { Ok } from "./result.ts"; import { useCoroutine } from "./coroutine.ts"; +import type { ScopeInternal } from "./scope-internal.ts"; +import { Priority } from "./contexts.ts"; /** * Define an Effection [resource](https://frontside.com/effection/docs/resources) @@ -57,7 +58,16 @@ export function resource( // establishing a control boundary lets us catch errors in // resource initializer return yield* trap(function* () { - yield* spawn(() => op(provide)); + let { scope, start } = createTask({ + owner: caller.scope as ScopeInternal, + operation: () => op(provide), + }); + + // a resource runs at the priority of its parent + scope.set(Priority, caller.scope.expect(Priority)); + + start(); + return (yield { description: "await resource", enter: () => (uninstalled) => uninstalled(Ok()), diff --git a/lib/scope-internal.ts b/lib/scope-internal.ts index 1fe2be493..030cc6f36 100644 --- a/lib/scope-internal.ts +++ b/lib/scope-internal.ts @@ -1,7 +1,8 @@ -import { Children, Generation } from "./contexts.ts"; +import { Children, Priority } from "./contexts.ts"; import { Err, Ok, unbox } from "./result.ts"; import { createTask } from "./task.ts"; import type { Context, Operation, Scope, Task } from "./types.ts"; +import { type WithResolvers, withResolvers } from "./with-resolvers.ts"; export function createScopeInternal( parent?: Scope, @@ -56,24 +57,39 @@ export function createScopeInternal( }, }); - scope.set(Generation, scope.expect(Generation) + 1); + scope.set(Priority, scope.expect(Priority) + 1); scope.set(Children, new Set()); parent?.expect(Children).add(scope); let unbind = parent ? (parent as ScopeInternal).ensure(destroy) : () => {}; + let destruction: WithResolvers | undefined = undefined; + function* destroy(): Operation { + if (destruction) { + return yield* destruction.operation; + } + destruction = withResolvers(); parent?.expect(Children).delete(scope); unbind(); let outcome = Ok(); - for (let destructor of [...destructors].reverse()) { - try { - destructors.delete(destructor); - yield* destructor(); - } catch (error) { - outcome = Err(error as Error); + try { + for (let destructor of destructors) { + try { + destructors.delete(destructor); + yield* destructor(); + } catch (error) { + outcome = Err(error as Error); + } + } + } finally { + if (outcome.ok) { + destruction.resolve(); + } else { + destruction.reject(outcome.error); } } + unbox(outcome); } diff --git a/lib/scoped.ts b/lib/scoped.ts index 3bd66731d..a60d1bdcb 100644 --- a/lib/scoped.ts +++ b/lib/scoped.ts @@ -27,7 +27,7 @@ import { createScopeInternal } from "./scope-internal.ts"; */ export function scoped(operation: () => Operation): Operation { return { - *[Symbol.iterator]() { + [Symbol.iterator]: function* scoped() { let routine = yield* useCoroutine(); let original = routine.scope; let [scope, destroy] = createScopeInternal(original); diff --git a/lib/spawn.ts b/lib/spawn.ts index 9d6bf0222..8996ab11a 100644 --- a/lib/spawn.ts +++ b/lib/spawn.ts @@ -1,7 +1,5 @@ -import { Ok } from "./result.ts"; -import type { ScopeInternal } from "./scope-internal.ts"; -import { createTask, type NewTask } from "./task.ts"; -import type { Effect, Operation, Task } from "./types.ts"; +import { useScope } from "./scope.ts"; +import type { Operation, Task } from "./types.ts"; /** * Run another operation concurrently as a child of the current one. @@ -30,20 +28,11 @@ import type { Effect, Operation, Task } from "./types.ts"; * @typeParam T the type that the spawned task evaluates to * @returns a {@link Task} representing a handle to the running operation */ -export function* spawn(op: () => Operation): Operation> { - let { task, start } = (yield Spawn(op)) as NewTask; - start(); - return task; -} - -function Spawn(operation: () => Operation): Effect> { +export function spawn(op: () => Operation): Operation> { return { - description: `spawn(${operation.name})`, - enter: (resolve, { scope }) => { - resolve(Ok(createTask({ owner: scope as ScopeInternal, operation }))); - return (done) => { - done(Ok()); - }; + *[Symbol.iterator]() { + let scope = yield* useScope(); + return scope.run(op); }, }; } diff --git a/lib/task-group.ts b/lib/task-group.ts new file mode 100644 index 000000000..cbabbc9b3 --- /dev/null +++ b/lib/task-group.ts @@ -0,0 +1,46 @@ +import { createContext } from "./context.ts"; +import { box } from "./box.ts"; +import { Ok, unbox } from "./result.ts"; +import type { Operation, Task } from "./types.ts"; + +export class TaskGroup { + tasks = new Set>(); + + add(task: Task) { + this.tasks.add(task); + } + + delete(task: Task) { + this.tasks.delete(task); + } + + *halt(): Operation { + let total = Ok(); + while (this.tasks.size > 0) { + let tasks = [...this.tasks].reverse(); + this.tasks.clear(); + for (let task of tasks) { + let result = yield* box(task.halt); + if (!result.ok) { + total = result; + } + } + } + unbox(total); + } +} + +export const TaskGroupContext = createContext( + "@effection/task-group", + new TaskGroup(), +); + +export function encapsulate(operation: () => Operation): Operation { + return TaskGroupContext.with(new TaskGroup(), function* (group) { + try { + return yield* operation(); + } finally { + yield* group.halt(); + } + }); +} diff --git a/lib/task.ts b/lib/task.ts index 6f3b5b370..b15e5130a 100644 --- a/lib/task.ts +++ b/lib/task.ts @@ -1,13 +1,13 @@ -import { createContext } from "./context.ts"; -import { Routine } from "./contexts.ts"; +// deno-lint-ignore-file no-unsafe-finally +import { DelimiterContext, ErrorContext } from "./delimiter.ts"; import { createCoroutine } from "./coroutine.ts"; -import { drain } from "./drain.ts"; -import { lazyPromise, lazyPromiseWithResolvers } from "./lazy-promise.ts"; -import { Just, type Maybe, Nothing } from "./maybe.ts"; -import { Err, Ok, type Result, unbox } from "./result.ts"; +import { Delimiter } from "./delimiter.ts"; +import { createFuture } from "./future.ts"; +import { Ok } from "./result.ts"; import { createScopeInternal, type ScopeInternal } from "./scope-internal.ts"; -import type { Coroutine, Operation, Resolve, Scope, Task } from "./types.ts"; -import { withResolvers } from "./with-resolvers.ts"; +import type { Coroutine, Operation, Scope, Task } from "./types.ts"; +import { encapsulate, TaskGroupContext } from "./task-group.ts"; +import { useScope } from "./scope.ts"; export interface TaskOptions { owner: ScopeInternal; @@ -16,7 +16,7 @@ export interface TaskOptions { export interface NewTask { scope: Scope; - routine: Coroutine>; + routine: Coroutine; task: Task; start(): void; } @@ -24,235 +24,122 @@ export interface NewTask { export function createTask(options: TaskOptions): NewTask { let { owner, operation } = options; let [scope, destroy] = createScopeInternal(owner); + let future = createFuture(); - TaskGroup.ensureOwn(scope); - - let routine = createCoroutine({ - scope, - operation: () => trapset(() => after(operation, destroy)), - }); - - let promise = lazyPromiseWithResolvers(); - let future = withResolvers(); - - let resolve = (value: T) => { - promise.resolve(value); - future.resolve(value); - }; - - let reject = (error: Error) => { - promise.reject(error); - future.reject(error); - }; - - let initiateHalt = (resolve: Resolve>) => { - if (scope.hasOwn(TrapContext)) { - let trap = scope.expect(TrapContext); - let current = routine.data.discard; - routine.data.discard = (exit) => - current((result) => { - if (!result.ok) { - trap.result = result; - } - exit(result); - }); - return routine.return( - trap.result = Ok(Nothing()), - drain((result) => resolve(result.ok ? Ok() : result)), - ); - } else { - return routine.return( - Ok(Nothing()), - drain((result) => resolve(result.ok ? Ok() : result)), - ); - } - }; - - let halt = lazyPromise((resolve, reject) => { - initiateHalt((result) => result.ok ? resolve() : reject(result.error)); - }); - - Object.defineProperty(halt, Symbol.iterator, { - enumerable: false, - *value(): Operation { - yield ({ - description: "halt", - enter: (resolve) => { - let unsubscribe = initiateHalt(resolve); - - return (done) => { - unsubscribe(); - done(Ok()); - }; - }, - }); - }, - }); - - let task = Object.defineProperties(promise.promise, { - [Symbol.toStringTag]: { + let task = Object.defineProperties(future.future, { + halt: { enumerable: false, - value: "Task", + value() { + return Object.defineProperties(Object.create(Promise.prototype), { + [Symbol.iterator]: { + enumerable: false, + value: destroy, + }, + then: { + enumerable: false, + value(...args: Parameters["then"]>) { + return owner.run(destroy).then(...args); + }, + }, + catch: { + enumerable: false, + value(...args: Parameters["catch"]>) { + return owner.run(destroy).catch(...args); + }, + }, + finally: { + enumerable: false, + value(...args: Parameters["finally"]>) { + return owner.run(destroy).finally(...args); + }, + }, + }); + }, }, [Symbol.iterator]: { enumerable: false, - value: future.operation[Symbol.iterator], + value: future.future[Symbol.iterator], }, - halt: { + [Symbol.toStringTag]: { enumerable: false, - value: () => halt, + value: "Task", }, }) as Task; - let group = TaskGroup.ensureOwn(owner); + let top = new Delimiter(() => encapsulate(operation)); + scope.set(DelimiterContext, top as Delimiter); - let link = group.link(owner, task); + let group = scope.expect(TaskGroupContext); + group.add(task); - scope.set(Routine, routine); + let boundary = owner.expect(ErrorContext); + scope.set(ErrorContext, top); - let start = () => - routine.next( - Ok(), - drain((result) => { - link.close(result); + scope.ensure(function* () { + try { + yield* top.close(); + } finally { + group.delete(task); + let { outcome } = top; + if (outcome!.exists) { + let result = outcome!.value; if (result.ok) { - if (result.value.exists) { - resolve(result.value.value); - } else { - reject(new Error("halted")); - } + future.resolve(result.value); } else { - reject(result.error); + let { error } = result; + future.reject(error); + boundary.raise(error); } - }), - ); - - return { task, scope, routine, start }; -} - -export const TaskGroupContext = createContext("@effection/tasks"); - -export function encapsulate(operation: () => Operation): Operation { - return TaskGroupContext.with(new TaskGroup(), function* (group) { - try { - return yield* operation(); - } finally { - yield* group.halt(); + } else { + future.reject(new Error("halted")); + } } }); -} -class TaskGroup { - static ensureOwn(scope: ScopeInternal): TaskGroup { - if (!scope.hasOwn(TaskGroupContext)) { - let group = scope.set(TaskGroupContext, new TaskGroup()); - scope.ensure(() => group.halt()); - } - return scope.expect(TaskGroupContext); - } - - links = new Set>(); - - link(owner: Scope, task: Task) { - return new TaskLink(owner, task, this.links); - } - - *halt() { - let links = [...this.links].reverse(); - links.forEach((link) => link.sever()); - let outcome = Ok(); - for (let link of links) { - let result = yield* box(link.task.halt); - if (!result.ok) { - outcome = result; + let routine = createCoroutine({ + scope, + *operation() { + try { + yield* top; + } finally { + yield* destroy(); } - } - return unbox(outcome); - } -} + }, + }); -class TaskLink { - constructor( - public owner: Scope, - public task: Task, - public links: Set>, - ) { - this.links.add(this); - } + let start = () => routine.next(Ok()); - close(result: Result>) { - this.links.delete(this); - if (!result.ok) { - let trap = this.owner.get(TrapContext); - if (trap) { - trap.result = result; - this.owner.get(Routine)?.return(trap.result); - } - } - } - - sever() { - this.links.delete(this); - this.close = () => {}; - } + return { scope, routine, task, start }; } -function* box(op: () => Operation): Operation> { - try { - return Ok(yield* op()); - } catch (error) { - return Err(error as Error); - } -} +export function* trap(operation: () => Operation): Operation { + let scope = yield* useScope(); -const TrapContext = createContext<{ result: Result> }>( - "@effection/trap", -); + let original = { + error: scope.expect(ErrorContext), + delimiter: scope.expect(DelimiterContext), + }; -function trapset(op: () => Operation): Operation> { - let result = Ok(Nothing()); - return TrapContext.with({ result }, function* (trap) { - try { - let value = yield* op(); - if (trap.result === result) { - trap.result = Ok(Just(value)); - } - } catch (error) { - trap.result = Err(error as Error); - } finally { - // deno-lint-ignore no-unsafe-finally - return unbox(trap.result) as Maybe; - } - }); -} + let delimiter = new Delimiter(operation, original.delimiter); -export function* trap(op: () => Operation): Operation { - let outcome = yield* trapset(op); - if (outcome.exists) { - return outcome.value; - } else { + scope.set(ErrorContext, delimiter); + scope.set(DelimiterContext, delimiter as Delimiter); + try { + yield* delimiter; + } finally { + scope.set(ErrorContext, original.error); + scope.set(DelimiterContext, original.delimiter); + let outcome = delimiter.outcome!; return (yield { - description: "propagate halt", - enter: (resolve, routine) => { - let trap = routine.scope.expect(TrapContext); - trap.result = Ok(Nothing()); - routine.return(trap.result); - resolve(Ok()); - return (resolve) => { - resolve(Ok()); - }; + description: "trap return", + enter(resolve) { + if (outcome.exists) { + resolve(outcome.value); + } else { + original.delimiter.interrupt(); + } + return (didExit) => didExit(Ok()); }, }) as T; } } - -function* after( - op: () => Operation, - epilogue: () => Operation, -): Operation { - try { - return yield* op(); - } finally { - yield* epilogue(); - } -} diff --git a/lib/types.ts b/lib/types.ts index 70d01edf3..34752bb6c 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -357,18 +357,11 @@ export interface Effect { export interface Coroutine { scope: Scope; data: { - discard(resolve: Resolve>): void; + exit(resolve: Resolve>): void; iterator: Iterator, T, unknown>; }; - next(result: Result, subscriber?: Subscriber): () => void; - return(result: Result, subcriber?: Subscriber): () => void; -} - -/** - * @ignore - */ -export interface Subscriber { - (result: IteratorResult, Result>): void; + next(result: Result): void; + return(result: Result): void; } /** diff --git a/lib/until.ts b/lib/until.ts new file mode 100644 index 000000000..6312bb8c1 --- /dev/null +++ b/lib/until.ts @@ -0,0 +1,20 @@ +import { action } from "./action.ts"; +import type { Operation } from "./types.ts"; + +/** + * Treat a promise as an {@link Operation} + * + * @example + * ```js + * let response = yield* until(fetch('https://google.com')); + * ``` + * @template {T} + * @param promise + * @returns {Operation} that succeeds or fails depending on the outcome of `promise` + */ +export function until(promise: Promise): Operation { + return action((resolve, reject) => { + promise.then(resolve).catch(reject); + return () => {}; + }); +} diff --git a/lint/prefer-let.ts b/lint/prefer-let.ts new file mode 100644 index 000000000..6c57aa29b --- /dev/null +++ b/lint/prefer-let.ts @@ -0,0 +1,65 @@ +type VariableDeclaration = Deno.lint.VariableDeclaration; + +const plugin: Deno.lint.Plugin = { + name: "effection", + rules: { + "prefer-let": { + create(context) { + function isTopLevelScope(node: VariableDeclaration): boolean { + // deno-lint-ignore no-explicit-any + let current: any = node.parent; + + // Walk up the tree until we find a function, block, or reach the program + + while (current) { + switch (current.type) { + // These create new scopes - if we hit one, we're not at top level + + case "FunctionDeclaration": + case "FunctionExpression": + case "ArrowFunctionExpression": + case "BlockStatement": + // Exception: BlockStatement that is direct child of Program is still top-level + + if ( + current.type === "BlockStatement" && + current.parent?.type === "Program" + ) { + current = current.parent; + + continue; + } + + return false; + + case "Program": + return true; + default: + current = current.parent; + } + } + + return false; + } + + return { + VariableDeclaration(node) { + if (node.kind === "var") { + context.report({ + message: "prefer `let` over `var` to declare value bindings", + node, + }); + } else if (node.kind === "const" && !isTopLevelScope(node)) { + context.report({ + message: "`const` declaration outside top-level scope", + node, + }); + } + }, + }; + }, + }, + }, +}; + +export default plugin; diff --git a/tasks/bench.ts b/tasks/bench.ts index 4bc1cb689..8393e8500 100644 --- a/tasks/bench.ts +++ b/tasks/bench.ts @@ -25,9 +25,7 @@ import { Cell, Row, Table } from "jsr:@cliffy/table@1.0.0-rc.7"; await main(function* (args) { let options = parser() .name("bench") - .description( - "Run Effection benchmarks", - ) + .description("Run Effection benchmarks") .version("0.0.0") .options({ include: { @@ -66,6 +64,7 @@ await main(function* (args) { let results = yield* all(tasks); let events = results.filter((result) => result.name.match("events")); + let startups = results.filter((result) => result.name.match("startup")); let recursion = results.filter((result) => result.name.match("recursion")); if (events.length == 0 && recursion.length === 0) { @@ -73,20 +72,29 @@ await main(function* (args) { return; } - let rows = []; + let rows: Row[] = []; + + let cells = ["Library", "Avg (ms)", "Avg Startup Time (ms)"]; + let cols = cells.length; if (recursion.length > 0) { - rows.push(Row.from([new Cell("Basic Recursion").colSpan(2).border()])); - rows.push(Row.from(["Library", "Avg (ms)"]).border()); + rows.push(Row.from([new Cell("Basic Recursion").colSpan(cols).border()])); + rows.push(Row.from(cells).border()); rows.push(...recursion.map((event) => Row.from(toTableRow(event)))); } if (events.length > 0) { - rows.push(Row.from([new Cell("Recursive Events").colSpan(2).border()])); - rows.push(Row.from(["Library", "Avg (ms)"]).border()); + rows.push(Row.from([new Cell("Recursive Events").colSpan(cols).border()])); + rows.push(Row.from(cells).border()); rows.push(...events.map((event) => Row.from(toTableRow(event)))); } + if (startups.length > 0) { + rows.push(Row.from([new Cell("Start-up Time").colSpan(cols).border()])); + rows.push(Row.from(cells).border()); + rows.push(...startups.map((event) => Row.from(toTableRow(event)))); + } + Table.from(rows).render(); }); @@ -150,8 +158,8 @@ function filter( function toTableRow(event: BenchmarkDoneEvent): string[] { let [name = event.name] = event.name.split("."); if (event.result.ok) { - let { avgTime } = event.result.value; - return [name, String(avgTime)]; + let { avgTime, avgStartupTime } = event.result.value; + return [name, String(avgTime), String(avgStartupTime)]; } else { return [name, "❌"]; } diff --git a/tasks/bench/scenarios.ts b/tasks/bench/scenarios.ts index 799c9e09e..fd57bb7af 100644 --- a/tasks/bench/scenarios.ts +++ b/tasks/bench/scenarios.ts @@ -8,4 +8,6 @@ export default [ "./scenarios/rxjs.events.ts", "./scenarios/add-event-listener.events.ts", "./scenarios/effect.events.ts", + "./scenarios/effection.startup.ts", + "./scenarios/effect.startup.ts", ].map((mod) => import.meta.resolve(mod)); diff --git a/tasks/bench/scenarios/add-event-listener.events.ts b/tasks/bench/scenarios/add-event-listener.events.ts index b5c425ca9..a189f42f9 100644 --- a/tasks/bench/scenarios/add-event-listener.events.ts +++ b/tasks/bench/scenarios/add-event-listener.events.ts @@ -1,7 +1,10 @@ import { call } from "../../../mod.ts"; import { scenario } from "./scenario.ts"; -await scenario("add-event-listener.events", (depth) => call(() => run(depth))); +await scenario( + "add-event-listener.events", + (depth, _exit) => call(() => run(depth)), +); export async function run(depth: number): Promise { const target = new EventTarget(); @@ -26,10 +29,10 @@ async function recurse( let resolve: (() => void) | undefined; let promise: Promise | undefined; function finalize() { - abort && (signal.removeEventListener("abort", abort), abort = undefined); + abort && (signal.removeEventListener("abort", abort), (abort = undefined)); handler && - (target.removeEventListener("foo", handler), handler = undefined); - resolve && (resolve(), resolve = undefined); + (target.removeEventListener("foo", handler), (handler = undefined)); + resolve && (resolve(), (resolve = undefined)); } if (depth > 0) { const subTarget = new EventTarget(); @@ -40,7 +43,7 @@ async function recurse( target.addEventListener("foo", handler); await subPromise; } else { - promise = new Promise((r) => resolve = r); + promise = new Promise((r) => (resolve = r)); abort = finalize; handler = function handler() { //probeMemory("bottom"); diff --git a/tasks/bench/scenarios/async+await.recursion.ts b/tasks/bench/scenarios/async+await.recursion.ts index 68d34b811..f37bed6e1 100644 --- a/tasks/bench/scenarios/async+await.recursion.ts +++ b/tasks/bench/scenarios/async+await.recursion.ts @@ -1,7 +1,10 @@ import { call } from "../../../mod.ts"; import { scenario } from "./scenario.ts"; -await scenario("async+await.recursion", (depth) => call(() => recurse(depth))); +await scenario( + "async+await.recursion", + (depth, _exit) => call(() => recurse(depth)), +); async function recurse(depth: number): Promise { if (depth > 1) { diff --git a/tasks/bench/scenarios/co.recursion.ts b/tasks/bench/scenarios/co.recursion.ts index 67044fcff..c5bb7c31c 100644 --- a/tasks/bench/scenarios/co.recursion.ts +++ b/tasks/bench/scenarios/co.recursion.ts @@ -1,8 +1,11 @@ +import co from "npm:co"; import { call } from "../../../mod.ts"; import { scenario } from "./scenario.ts"; -import co from "npm:co"; -await scenario("co.recursion", (depth) => call(() => co(recurse, depth))); +await scenario( + "co.recursion", + (depth, _exit) => call(() => co(recurse, depth)), +); function* recurse(depth: number): Generator { if (depth > 1) { diff --git a/tasks/bench/scenarios/effect.events.ts b/tasks/bench/scenarios/effect.events.ts index fcde4cd90..84d6b714e 100644 --- a/tasks/bench/scenarios/effect.events.ts +++ b/tasks/bench/scenarios/effect.events.ts @@ -4,7 +4,7 @@ import { scenario } from "./scenario.ts"; await scenario( "effect.events", - (depth) => call(() => Effect.runPromise(start(depth))), + (depth, _exit) => call(() => Effect.runPromise(start(depth))), ); export function start(depth: number) { diff --git a/tasks/bench/scenarios/effect.recursion.ts b/tasks/bench/scenarios/effect.recursion.ts index 0acff9ee2..87f12e027 100644 --- a/tasks/bench/scenarios/effect.recursion.ts +++ b/tasks/bench/scenarios/effect.recursion.ts @@ -4,7 +4,7 @@ import { scenario } from "./scenario.ts"; await scenario( "effect.recursion", - (depth) => call(() => Effect.runPromise(recurse(depth))), + (depth, _exit) => call(() => Effect.runPromise(recurse(depth))), ); function recurse(depth: number): Effect.Effect { diff --git a/tasks/bench/scenarios/effect.startup.ts b/tasks/bench/scenarios/effect.startup.ts new file mode 100644 index 000000000..99d154395 --- /dev/null +++ b/tasks/bench/scenarios/effect.startup.ts @@ -0,0 +1,20 @@ +import { Effect, Fiber } from "npm:effect"; +import { call, ensure } from "../../../mod.ts"; +import { scenario } from "./scenario.ts"; + +await scenario("effect.startup", function* (_, exit) { + let start = performance.now(); + + const startup = Effect.gen(function* () { + exit(performance.now() - start); + yield* Effect.promise(() => Promise.resolve()); + }); + + const fiber = Effect.runFork(startup); + + yield* ensure(function* () { + yield* call(() => Effect.runPromise(Fiber.interrupt(fiber))); + }); + + return yield* call(() => Effect.runPromise(Fiber.join(fiber))); +}); diff --git a/tasks/bench/scenarios/effection.events.ts b/tasks/bench/scenarios/effection.events.ts index 230736589..5faf56f19 100644 --- a/tasks/bench/scenarios/effection.events.ts +++ b/tasks/bench/scenarios/effection.events.ts @@ -1,9 +1,12 @@ -import { scenario } from "./scenario.ts"; import { each, on, type Operation, sleep, spawn } from "../../../mod.ts"; +import { scenario } from "./scenario.ts"; await scenario("effection.events", start); -export function* start(depth: number): Operation { +export function* start( + depth: number, + _exit: (time: number) => void, +): Operation { const target = new EventTarget(); const task = yield* spawn(() => recurse(target, depth)); for (let i = 0; i < 100; i++) { diff --git a/tasks/bench/scenarios/effection.recursion.ts b/tasks/bench/scenarios/effection.recursion.ts index f6c5dbf24..505ce21bb 100644 --- a/tasks/bench/scenarios/effection.recursion.ts +++ b/tasks/bench/scenarios/effection.recursion.ts @@ -3,9 +3,12 @@ import { scenario } from "./scenario.ts"; await scenario("effection.recursion", recurse); -function* recurse(depth: number): Operation { +function* recurse( + depth: number, + _exit?: (time: number) => void, +): Operation { if (depth > 1) { - yield* recurse(depth - 1); + yield* recurse(depth - 1, _exit); } else { for (let i = 0; i < 100; i++) { yield* call(() => Promise.resolve()); diff --git a/tasks/bench/scenarios/effection.startup.ts b/tasks/bench/scenarios/effection.startup.ts new file mode 100644 index 000000000..eb7e0d913 --- /dev/null +++ b/tasks/bench/scenarios/effection.startup.ts @@ -0,0 +1,13 @@ +import { call, type Operation } from "../../../mod.ts"; +import { scenario } from "./scenario.ts"; + +await scenario("effection.startup", function* (_, exit) { + let start = performance.now(); + + function* startup(): Operation { + exit(performance.now() - start); + yield* call(() => Promise.resolve()); + } + + return yield* startup(); +}); diff --git a/tasks/bench/scenarios/rxjs.events.ts b/tasks/bench/scenarios/rxjs.events.ts index 71c575060..9a03daff1 100644 --- a/tasks/bench/scenarios/rxjs.events.ts +++ b/tasks/bench/scenarios/rxjs.events.ts @@ -1,10 +1,10 @@ import { fromEvent, Observable, Subject, takeUntil } from "npm:rxjs"; -import { scenario } from "./scenario.ts"; import { action, type Operation, sleep, spawn } from "../../../mod.ts"; +import { scenario } from "./scenario.ts"; await scenario("rxjs.events", run); -function* run(depth: number): Operation { +function* run(depth: number, _exit: (time: number) => void): Operation { const target = new EventTarget(); const abort = new Subject(); const promised = yield* spawn(() => diff --git a/tasks/bench/scenarios/rxjs.recursion.ts b/tasks/bench/scenarios/rxjs.recursion.ts index a571ec78c..6f37e791a 100644 --- a/tasks/bench/scenarios/rxjs.recursion.ts +++ b/tasks/bench/scenarios/rxjs.recursion.ts @@ -1,17 +1,16 @@ import { defer, from, Observable, repeat } from "npm:rxjs"; -import { scenario } from "./scenario.ts"; import { action, type Operation } from "../../../mod.ts"; +import { scenario } from "./scenario.ts"; await scenario("rxjs.recursion", run); -function run(depth: number): Operation { +function run(depth: number, _exit: (time: number) => void): Operation { return action((resolve) => { - let observable = recurse(depth) - .subscribe({ - complete() { - resolve(); - }, - }); + let observable = recurse(depth).subscribe({ + complete() { + resolve(); + }, + }); return () => observable.unsubscribe(); }); } diff --git a/tasks/bench/scenarios/scenario.ts b/tasks/bench/scenarios/scenario.ts index ebfff2644..26791b848 100644 --- a/tasks/bench/scenarios/scenario.ts +++ b/tasks/bench/scenarios/scenario.ts @@ -1,12 +1,15 @@ import { callcc } from "../../../lib/callcc.ts"; import { Err, Ok } from "../../../lib/result.ts"; -import { encapsulate } from "../../../lib/task.ts"; +import { encapsulate } from "../../../lib/task-group.ts"; import { + call, createChannel, each, main, type Operation, + race, spawn, + withResolvers, } from "../../../mod.ts"; import type { BenchmarkOptions, @@ -21,46 +24,74 @@ const send = (event: BenchmarkWorkerEvent) => self.postMessage(event); export function scenario( name: string, - perform: (depth: number) => Operation, + perform: (depth: number, exit: (time: number) => void) => Operation, ) { return main(function* () { try { - yield* encapsulate(() => - callcc(function* (exit) { - let work = createChannel(); - yield* spawn(function* () { - for (let command of yield* each(commands)) { - if (command.type === "close") { - yield* exit(); - } else { - yield* work.send(command); - } - yield* each.next(); + yield* callcc(function* (exit) { + let work = createChannel(); + yield* spawn(function* () { + for (let command of yield* each(commands)) { + if (command.type === "close") { + yield* exit(); + } else { + yield* work.send(command); } - }); + yield* each.next(); + } + }); - for (let options of yield* each(work)) { - let times: number[] = []; - for (let i = 0; i < options.repeat; i++) { - let start = performance.now(); + for (let options of yield* each(work)) { + let times: number[] = []; + let entryTimes: number[] = []; - yield* encapsulate(() => perform(options.depth)); + for (let i = 0; i < options.repeat; i++) { + let start = performance.now(); - let time = performance.now() - start; - send({ type: "repeat", name, time, rep: i + 1 }); - times.push(time); - } + const exitResolver = withResolvers(); - let total = times.reduce((sum, time) => sum + time, 0); - let avgTime = total / times.length; - let result = Ok({ avgTime, reps: options.repeat }); + const task = yield* spawn(function* () { + yield* encapsulate(() => + perform(options.depth, exitResolver.resolve) + ); + }); - send({ type: "done", name, result }); + // Avoid blocking indefinitely because not all benchmarks need the exit function + yield* race([ + exitResolver.operation, + call(function* () { + yield* task; + exitResolver.resolve(null); + }), + ]); - yield* each.next(); + const entryTime = yield* exitResolver.operation; + + let time = performance.now() - start; + send({ type: "repeat", name, time, rep: i + 1 }); + times.push(time); + + if (entryTime) { + entryTimes.push(entryTime); + } } - }) - ); + + let total = times.reduce((sum, time) => sum + time, 0); + let avgTime = total / times.length; + + let startupTime = entryTimes.reduce((sum, time) => sum + time, 0); + + let avgStartupTime = entryTimes.length > 0 + ? startupTime / entryTimes.length + : 0; + + let result = Ok({ avgTime, avgStartupTime, reps: options.repeat }); + + send({ type: "done", name, result }); + + yield* each.next(); + } + }); } catch (error) { send({ type: "done", name, result: Err(error as Error) }); } finally { diff --git a/tasks/bench/types.ts b/tasks/bench/types.ts index 3bb530175..b0038beea 100644 --- a/tasks/bench/types.ts +++ b/tasks/bench/types.ts @@ -31,5 +31,6 @@ export interface BenchmarkDoneEvent { result: Result<{ reps: number; avgTime: number; + avgStartupTime: number; }>; } diff --git a/tasks/build-npm.ts b/tasks/build-npm.ts index 529938c6b..51ea9acff 100644 --- a/tasks/build-npm.ts +++ b/tasks/build-npm.ts @@ -27,7 +27,7 @@ await build({ name: "effection", version, description: "Structured concurrency and effects for JavaScript", - license: "ISC", + license: "MIT", author: "engineering@frontside.com", repository: { type: "git", diff --git a/tasks/built-test.ts b/tasks/built-test.ts index 09111202e..dd9fa1a9a 100644 --- a/tasks/built-test.ts +++ b/tasks/built-test.ts @@ -7,11 +7,6 @@ await emptyDir(outDir); const entryPoints = [ "./lib/mod.ts", ]; -for await (const entry of Deno.readDir("test")) { - if (entry.isFile) { - entryPoints.push(`./test/${entry.name}`); - } -} await build({ entryPoints, @@ -19,7 +14,11 @@ await build({ shims: { deno: true, }, + test: true, + testPattern: "test/**/*.test.ts", typeCheck: false, + scriptModule: false, + esModule: true, compilerOptions: { lib: ["ESNext", "DOM"], target: "ES2020", diff --git a/test/all.test.ts b/test/all.test.ts index dade99f81..4402ae439 100644 --- a/test/all.test.ts +++ b/test/all.test.ts @@ -10,7 +10,9 @@ import { syncResolve, } from "./suite.ts"; -import { all, call, type Operation, run, sleep } from "../mod.ts"; +import { all, call, type Operation, run, sleep, suspend } from "../mod.ts"; +import { box } from "../lib/box.ts"; +import assert from "node:assert"; describe("all()", () => { it("resolves when the given list is empty", async () => { @@ -47,7 +49,7 @@ describe("all()", () => { it("rejects when one of the given operations rejects asynchronously and another operation does not complete", async () => { let result = run(() => all([sleep(0), asyncReject(5, "bar")])); - await expect(result).rejects.toHaveProperty("message", "boom: bar"); + await expect(result).rejects.toMatchObject({ message: "boom: bar" }); }); it("resolves when all of the given operations resolve synchronously", async () => { @@ -104,4 +106,25 @@ describe("all()", () => { all([resolve("hello"), resolve(42)]), ); }); + + it("shuts down all tasks when anything fails", async () => { + let teardown = false; + await run(function* () { + let result = yield* box(() => + all([ + asyncReject(0, "foo"), + call(function* () { + try { + yield* suspend(); + } finally { + teardown = true; + } + }), + ]) + ); + + assert(!result.ok, "all should fail"); + expect(teardown).toEqual(true); + }); + }); }); diff --git a/test/channel.test.ts b/test/channel.test.ts index f85f39cc2..53537f94b 100644 --- a/test/channel.test.ts +++ b/test/channel.test.ts @@ -7,7 +7,13 @@ import { } from "./suite.ts"; import type { Channel, Operation } from "../mod.ts"; -import { createChannel, createScope, sleep, spawn } from "../mod.ts"; +import { + createChannel, + createScope, + sleep, + spawn, + withResolvers, +} from "../mod.ts"; let [scope, close] = createScope(); @@ -20,21 +26,31 @@ describe("Channel", () => { it("does not use the same event twice when serially subscribed to a channel", function* () { let input = createChannel(); + let proceed = withResolvers(); + let actual: string[] = []; function* channel() { - yield* sleep(10); yield* input.send("one"); + + yield* proceed.operation; + yield* input.send("two"); } function* root() { + // subscribe once + let subscription = yield* input; + yield* spawn(channel); - let subscription = yield* input; let result = yield* subscription.next(); actual.push(result.value as string); subscription = yield* input; + + // it's ok to emit the second output because we're listening for it + proceed.resolve(); + result = yield* subscription.next(); actual.push(result.value as string); } diff --git a/test/each.test.ts b/test/each.test.ts index 008730729..9a76188d2 100644 --- a/test/each.test.ts +++ b/test/each.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "./suite.ts"; import { - createQueue, each, + type Operation, resource, run, spawn, @@ -32,18 +32,14 @@ describe("each", () => { let outer = sequence("one", "two"); let inner = sequence("three", "four", "five"); - let consumer = yield* spawn(function* () { - for (let value of yield* each(outer)) { + for (let value of yield* each(outer)) { + actual.push(value); + for (let value of yield* each(inner)) { actual.push(value); - for (let value of yield* each(inner)) { - actual.push(value); - yield* each.next(); - } yield* each.next(); } - }); - - yield* consumer; + yield* each.next(); + } expect(actual).toEqual([ "one", @@ -113,12 +109,19 @@ describe("each", () => { }); function sequence(...values: string[]): Stream { - return resource(function* (provide) { - let q = createQueue(); - for (let value of values) { - q.add(value); - } - q.close(); - yield* provide(q); - }); + return { + *[Symbol.iterator]() { + let items = values.slice(); + return { + *next(): Operation> { + let value = items.shift(); + if (typeof value !== "undefined") { + return { done: false, value }; + } else { + return { done: true, value: undefined }; + } + }, + }; + }, + }; } diff --git a/test/ensure.test.ts b/test/ensure.test.ts index 5579f9b14..0827d95e3 100644 --- a/test/ensure.test.ts +++ b/test/ensure.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "./suite.ts"; -import { ensure, run, sleep } from "../mod.ts"; +import { ensure, run, sleep, spawn } from "../mod.ts"; describe("ensure", () => { it("runs the given operation at the end of the task", async () => { @@ -38,4 +38,18 @@ describe("ensure", () => { await root; expect(state).toEqual("completed"); }); + + it("can block a task conmpleting until a subtask completes", async () => { + let reached = false; + + await run(function* () { + let task = yield* spawn(function* () { + yield* sleep(0); + reached = true; + }); + yield* ensure(() => task); + }); + + expect(reached).toEqual(true); + }); }); diff --git a/test/events.test.ts b/test/events.test.ts index 4ed22810a..f96e746e6 100644 --- a/test/events.test.ts +++ b/test/events.test.ts @@ -4,8 +4,8 @@ import { describe, expectType, it } from "./suite.ts"; describe("events", () => { describe("types", () => { - const domElement = {} as HTMLElement; - const socket = {} as WebSocket; + let domElement = {} as HTMLElement; + let socket = {} as WebSocket; it("should find event from eventTarget", () => { expectType>(once(socket, "close")); diff --git a/test/interval.test.ts b/test/interval.test.ts new file mode 100644 index 000000000..dda79231f --- /dev/null +++ b/test/interval.test.ts @@ -0,0 +1,27 @@ +import { call, each, interval, race, run, sleep, spawn } from "../mod.ts"; +import { describe, expect, it } from "./suite.ts"; + +describe("interval", () => { + it("can be iterated", async () => { + await run(function* () { + let task = yield* spawn(function* () { + let total = 0; + for (let _ of yield* each(interval(1))) { + total++; + if (total == 10) { + return total; + } + yield* each.next(); + } + }); + let result = yield* race([ + task, + call(function* () { + yield* sleep(50); + return "interval not producing!"; + }), + ]); + expect(result).toEqual(10); + }); + }); +}); diff --git a/test/main.test.ts b/test/main.test.ts index 62c296c4f..5d8022d09 100644 --- a/test/main.test.ts +++ b/test/main.test.ts @@ -1,8 +1,8 @@ import { describe, expect, it, x } from "./suite.ts"; import { each, run, type Stream } from "../mod.ts"; -function* until(stream: Stream, text: string) { - for (const line of yield* each(stream)) { +function* detect(stream: Stream, text: string) { + for (let line of yield* each(stream)) { if (line.includes(text)) { return; } @@ -15,9 +15,9 @@ describe("main", () => { await run(function* () { let proc = yield* x("deno", ["run", "test/main/ok.daemon.ts"]); - yield* until(proc.lines, "started"); + yield* detect(proc.lines, "started"); - const { exitCode, stdout } = yield* proc.kill("SIGINT"); + let { exitCode, stdout } = yield* proc.kill("SIGINT"); expect(stdout).toContain("gracefully stopped"); @@ -25,26 +25,28 @@ describe("main", () => { }); }); - it("gracefully shuts down on SIGTERM", async () => { - await run(function* () { - let proc = yield* x("deno", ["run", "test/main/ok.daemon.ts"]); + if (Deno.build.os !== "windows") { + it("gracefully shuts down on SIGTERM", async () => { + await run(function* () { + let proc = yield* x("deno", ["run", "test/main/ok.daemon.ts"]); - yield* until(proc.lines, "started"); + yield* detect(proc.lines, "started"); - const { exitCode, stdout } = yield* proc.kill("SIGTERM"); + let { exitCode, stdout } = yield* proc.kill("SIGTERM"); - expect(stdout).toContain("gracefully stopped"); + expect(stdout).toContain("gracefully stopped"); - expect(exitCode).toBe(143); + expect(exitCode).toBe(143); + }); }); - }); + } it("exits gracefully on explicit exit()", async () => { await run(function* () { let proc = yield* x("deno", ["run", "test/main/ok.exit.ts"]); - yield* until(proc.lines, "goodbye."); - yield* until(proc.lines, "Ok, computer."); + yield* detect(proc.lines, "goodbye."); + yield* detect(proc.lines, "Ok, computer."); }); }); @@ -52,9 +54,9 @@ describe("main", () => { await run(function* () { let proc = yield* x("deno", ["run", "test/main/ok.implicit.ts"]); - yield* until(proc.lines, "goodbye."); + yield* detect(proc.lines, "goodbye."); - const { exitCode } = yield* proc; + let { exitCode } = yield* proc; expect(exitCode).toEqual(0); }); @@ -64,7 +66,7 @@ describe("main", () => { await run(function* () { let proc = yield* x("deno", ["run", "test/main/fail.exit.ts"]); - const { stderr, exitCode, stdout } = yield* proc; + let { stderr, exitCode, stdout } = yield* proc; expect(stdout).toContain("graceful goodbye"); expect(stderr).toContain("It all went horribly wrong"); @@ -76,7 +78,7 @@ describe("main", () => { await run(function* () { let proc = yield* x("deno", ["run", "test/main/fail.unexpected.ts"]); - const { stderr, stdout, exitCode } = yield* proc; + let { stderr, stdout, exitCode } = yield* proc; expect(stdout).toContain("graceful goodbye"); expect(stderr).toContain("Error: moo"); @@ -88,9 +90,9 @@ describe("main", () => { await run(function* () { let proc = yield* x("deno", ["run", "test/main/just.suspend.ts"]); - yield* until(proc.lines, "started"); + yield* detect(proc.lines, "started"); - const { exitCode, stdout } = yield* proc.kill("SIGINT"); + let { exitCode, stdout } = yield* proc.kill("SIGINT"); expect(exitCode).toBe(130); expect(stdout).toContain("gracefully stopped"); diff --git a/test/priority-queue.test.ts b/test/priority-queue.test.ts new file mode 100644 index 000000000..9c3e49277 --- /dev/null +++ b/test/priority-queue.test.ts @@ -0,0 +1,31 @@ +import { describe, it } from "node:test"; +import { PriorityQueue } from "../lib/priority-queue.ts"; +import { expect } from "./suite.ts"; + +describe("priority queue", () => { + it("gives priority to lower numbers", () => { + let q = new PriorityQueue(); + q.push(3, "!"); + q.push(2, "world"); + q.push(1, "hello"); + + expect(`${q.pop()} ${q.pop()}${q.pop()}`).toEqual("hello world!"); + }); + it("within a priority cohort, it is FIFO", () => { + let q = new PriorityQueue(); + q.push(0, "hello"); + q.push(0, "world"); + q.push(0, "!"); + + q.push(1, "fire"); + q.push(6, "disco"); + q.push(6, "ballz"); + + expect(`${q.pop()} ${q.pop()}${q.pop()}`).toEqual("hello world!"); + }); + + it("produces undefined when there is nothing on the queeu", () => { + let q = new PriorityQueue(); + expect(q.pop()).toBeUndefined(); + }); +}); diff --git a/test/resource.test.ts b/test/resource.test.ts index 54731712a..595559fd3 100644 --- a/test/resource.test.ts +++ b/test/resource.test.ts @@ -35,7 +35,7 @@ describe("resource", () => { }); it("can catch an error in init", async () => { - let task = run(function* () { + let result = await run(function* () { try { yield* resource(function* () { throw new Error("moo"); @@ -45,25 +45,25 @@ describe("resource", () => { } }); - await expect(task).resolves.toMatchObject({ message: "moo" }); + expect(result).toMatchObject({ message: "moo" }); }); it("raises an error if an error occurs after init", async () => { let task = run(function* () { yield* resource(function* (provide) { yield* spawn(function* () { - yield* sleep(5); + yield* sleep(1); throw new Error("moo"); }); yield* provide(); }); try { - yield* sleep(10); + yield* suspend(); } catch (error) { return error; } }); - await expect(task).rejects.toHaveProperty("message", "moo"); + await expect(task).rejects.toMatchObject({ message: "moo" }); }); it("terminates resource when task completes", async () => { @@ -84,6 +84,60 @@ describe("resource", () => { expect(state.status).toEqual("pending"); }); + + it("does not relinquish control when a resource initialized synchronously", async () => { + let sequence: number[] = []; + + await run(function* () { + sequence.push(1); + + let task = yield* spawn(function* () { + sequence.push(4); + }); + + yield* resource(function* (provide) { + sequence.push(2); + yield* provide(); + }); + + sequence.push(3); + + yield* task; + }); + + expect(sequence).toEqual([1, 2, 3, 4]); + }); + + it("is released in the reverse order from which it was acquired", async () => { + let sequence: string[] = []; + + await run(function* () { + yield* resource(function* (provide) { + try { + yield* provide(); + } finally { + sequence.push("first start"); + yield* sleep(5); + sequence.push("first done"); + } + }); + yield* resource(function* (provide) { + try { + yield* provide(); + } finally { + sequence.push("second start"); + yield* sleep(10); + sequence.push("second done"); + } + }); + }); + expect(sequence).toEqual([ + "second start", + "second done", + "first start", + "first done", + ]); + }); }); function createResource(container: State): Operation { @@ -93,7 +147,7 @@ function createResource(container: State): Operation { container.status = "active"; }); - yield* sleep(1); + yield* sleep(0); try { yield* provide(container); diff --git a/test/run.test.ts b/test/run.test.ts index 49215c3b9..8fb3d3f40 100644 --- a/test/run.test.ts +++ b/test/run.test.ts @@ -1,6 +1,19 @@ // deno-lint-ignore-file no-unsafe-finally -import { $await, blowUp, createNumber, describe, expect, it } from "./suite.ts"; -import { action, run, sleep, spawn, suspend, type Task } from "../mod.ts"; +import assert from "node:assert"; +import { Children } from "../lib/contexts.ts"; +import { + action, + createScope, + run, + type Scope, + sleep, + spawn, + suspend, + type Task, + until, + useScope, +} from "../mod.ts"; +import { blowUp, createNumber, describe, expect, it } from "./suite.ts"; describe("run()", () => { it("can run an operation", async () => { @@ -11,8 +24,8 @@ describe("run()", () => { it("can compose multiple promises via generator", async () => { let result = await run(function* () { - let one = yield* $await(Promise.resolve(12)); - let two = yield* $await(Promise.resolve(55)); + let one = yield* until(Promise.resolve(12)); + let two = yield* until(Promise.resolve(55)); return one + two; }); expect(result).toEqual(67); @@ -49,16 +62,16 @@ describe("run()", () => { it("can recover from errors in promise", async () => { let error = new Error("boom"); let task = run(function* () { - let one = yield* $await(Promise.resolve(12)); + let one = yield* until(Promise.resolve(12)); let two; try { - yield* $await(Promise.reject(error)); + yield* until(Promise.reject(error)); two = 9; } catch (_) { // swallow error and yield in catch block - two = yield* $await(Promise.resolve(8)); + two = yield* until(Promise.resolve(8)); } - let three = yield* $await(Promise.resolve(55)); + let three = yield* until(Promise.resolve(55)); return one + two + three; }); await expect(task).resolves.toEqual(75); @@ -66,16 +79,16 @@ describe("run()", () => { it("can recover from errors in operation", async () => { let task = run(function* () { - let one = yield* $await(Promise.resolve(12)); + let one = yield* until(Promise.resolve(12)); let two; try { yield* blowUp(); two = 9; } catch (_e) { // swallow error and yield in catch block - two = yield* $await(Promise.resolve(8)); + two = yield* until(Promise.resolve(8)); } - let three = yield* $await(Promise.resolve(55)); + let three = yield* until(Promise.resolve(55)); return one + two + three; }); await expect(task).resolves.toEqual(75); @@ -153,21 +166,6 @@ describe("run()", () => { expect(completed).toEqual(true); }); - // // it("cannot explicitly suspend in a finally block", async () => { - // // let done = false; - // // let task = run(function* () { - // // try { - // // yield* suspend(); - // // } finally { - // // yield* suspend(); - // // done = true; - // // } - // // }); - - // // await run(task.halt); - // // expect(done).toEqual(true); - // // }); - it("can suspend in yielded finally block", async () => { let things: string[] = []; @@ -308,7 +306,7 @@ describe("run()", () => { it("propagates errors from promises", async () => { try { await run(function* () { - yield* $await(Promise.reject(new Error("boom"))); + yield* until(Promise.reject(new Error("boom"))); }); throw new Error("expected error to propagate"); } catch (error) { @@ -324,4 +322,29 @@ describe("run()", () => { await expect(task).rejects.toHaveProperty("message", "boom!"); await expect(task.halt()).resolves.toBe(undefined); }); + + it("destroys its scope when halted", async () => { + let scope: Scope | undefined; + + let task = run(function* () { + scope = yield* useScope(); + createScope(scope); + yield* suspend(); + }); + + await task.halt(); + + assert(scope !== undefined); + expect(scope.expect(Children).size).toEqual(0); + }); + + it("destroys its scope when completing successfully", async () => { + let scope = await run(function* () { + let parent = yield* useScope(); + createScope(parent); + return parent; + }); + + expect(scope.expect(Children).size).toEqual(0); + }); }); diff --git a/test/scope.test.ts b/test/scope.test.ts index 42fdf9f3c..515c68530 100644 --- a/test/scope.test.ts +++ b/test/scope.test.ts @@ -38,19 +38,34 @@ describe("Scope", () => { await expect(close()).resolves.toBeUndefined(); }); + it("halts tasks that it contains when it is destroyed", async () => { + let [scope, destroy] = createScope(); + let halted = false; + scope.run(function* () { + try { + yield* suspend(); + } finally { + halted = true; + } + }); + + await expect(destroy()).resolves.toBeUndefined(); + expect(halted).toEqual(true); + }); + it("errors on close if there is an problem in teardown", async () => { let error = new Error("boom!"); let [scope, close] = createScope(); - run(() => - scope.spawn(function* () { - try { - yield* suspend(); - } finally { - // deno-lint-ignore no-unsafe-finally - throw error; - } - }) - ); + + scope.run(function* () { + try { + yield* suspend(); + } finally { + // deno-lint-ignore no-unsafe-finally + throw error; + } + }); + await expect(close()).rejects.toEqual(error); }); @@ -59,18 +74,15 @@ describe("Scope", () => { let [scope, close] = createScope(); let tester: Tester = {}; - run(() => - scope.spawn(function* () { - yield* useTester(tester); - yield* suspend(); - }) - ); + scope.run(function* () { + yield* useTester(tester); + yield* suspend(); + }); + + scope.run(function* () { + throw error; + }); - run(() => - scope.spawn(function* () { - throw error; - }) - ); await expect(close()).resolves.toBeUndefined(); expect(tester.status).toEqual("closed"); }); diff --git a/test/scoped.test.ts b/test/scoped.test.ts index 88a03c6fb..3d8df697b 100644 --- a/test/scoped.test.ts +++ b/test/scoped.test.ts @@ -1,3 +1,4 @@ +import { box } from "../lib/box.ts"; import { createContext, resource, @@ -57,8 +58,8 @@ describe("scoped", () => { } })); - it("delimits error boundaries", () => - run(function* () { + it("delimits error boundaries", async () => + await run(function* () { try { yield* scoped(function* () { yield* spawn(function* () { @@ -124,6 +125,7 @@ describe("scoped", () => { }); yield* provide(); }); + yield* suspend(); }); } catch (error) { @@ -155,4 +157,25 @@ describe("scoped", () => { } })); }); + + it("throws errors at the correct point when there are multiple nested scopes", async () => { + let task = run(function* () { + return yield* scoped(function* () { + yield* spawn(function* () { + yield* sleep(1); + throw new Error("boom!"); + }); + + yield* box(() => + scoped(function* () { + yield* scoped(function* () { + yield* suspend(); + }); + }) + ); + }); + }); + + await expect(task).rejects.toMatchObject({ message: "boom!" }); + }); }); diff --git a/test/spawn.test.ts b/test/spawn.test.ts index dea1c34c1..b04a61cf7 100644 --- a/test/spawn.test.ts +++ b/test/spawn.test.ts @@ -1,13 +1,13 @@ // deno-lint-ignore-file no-unsafe-finally -import { $await, describe, expect, it } from "./suite.ts"; -import { run, sleep, spawn, suspend } from "../mod.ts"; +import { describe, expect, it } from "./suite.ts"; +import { run, sleep, spawn, suspend, until } from "../mod.ts"; describe("spawn", () => { it("can spawn a new child task", async () => { let root = run(function* root() { let child = yield* spawn(function* child() { - let one = yield* $await(Promise.resolve(12)); - let two = yield* $await(Promise.resolve(55)); + let one = yield* until(Promise.resolve(12)); + let two = yield* until(Promise.resolve(55)); return one + two; }); return yield* child; @@ -37,6 +37,8 @@ describe("spawn", () => { yield* suspend(); }); + yield* sleep(0); + return 1; }); @@ -51,6 +53,8 @@ describe("spawn", () => { yield* suspend(); }); + yield* sleep(0); + throw new Error("boom"); }); @@ -87,9 +91,8 @@ describe("spawn", () => { }); it("rejects when child errors during completing", async () => { - let child; let root = run(function* root() { - child = yield* spawn(function* child() { + yield* spawn(function* child() { try { yield* suspend(); } finally { @@ -100,7 +103,6 @@ describe("spawn", () => { return "foo"; }); - await expect(child).rejects.toMatchObject({ message: "moo" }); await expect(root).rejects.toHaveProperty("message", "moo"); }); @@ -119,8 +121,7 @@ describe("spawn", () => { }); await expect(root.halt()).rejects.toHaveProperty("message", "moo"); - await expect(child).rejects.toHaveProperty("message", "moo"); - await expect(root.halt()).rejects.toHaveProperty("message", "moo"); + await expect(child!.halt()).rejects.toHaveProperty("message", "moo"); }); it("halts when child finishes during asynchronous halt", async () => { @@ -177,7 +178,7 @@ describe("spawn", () => { it("halts children on explicit halt", async () => { let child; - let root = run(function* () { + let value = await run(function* () { child = yield* spawn(function* () { yield* sleep(2); return "foo"; @@ -186,7 +187,7 @@ describe("spawn", () => { return 1; }); - await root.halt(); + expect(value).toEqual(1); await expect(child).rejects.toHaveProperty("message", "halted"); }); @@ -205,4 +206,53 @@ describe("spawn", () => { }); await expect(task).rejects.toHaveProperty("message", "moo"); }); + + it("executes tasks first in first out within a priority group", async () => { + let order: string[] = []; + await run(function* () { + yield* spawn(function* () { + order.push("one"); + }); + + yield* spawn(function* () { + order.push("two"); + }); + + order.push("zero"); + + yield* sleep(0); + }); + + expect(order).toEqual(["zero", "one", "two"]); + }); + + it("preserves all child tasks until after the parent operation passes out of scope", async () => { + let sequence: string[] = []; + + let task = run(function* () { + yield* spawn(function* () { + try { + yield* suspend(); + } finally { + sequence.push("first"); + } + }); + yield* spawn(function* () { + try { + yield* suspend(); + } finally { + sequence.push("second"); + } + }); + try { + yield* suspend(); + } finally { + sequence.push("parent"); + } + }); + + await task.halt(); + + expect(sequence).toEqual(["parent", "second", "first"]); + }); }); diff --git a/test/suite.ts b/test/suite.ts index ef1d87a43..a2cb95fa1 100644 --- a/test/suite.ts +++ b/test/suite.ts @@ -1,14 +1,12 @@ -export { expect } from "@std/expect"; +import { expect } from "@std/expect"; +import { ctrlc } from "ctrlc-windows"; +import { type KillSignal, type Options, type Output, x as $x } from "tinyexec"; export { afterEach, beforeEach, describe, it } from "@std/testing/bdd"; export { expectType } from "ts-expect"; -import { type KillSignal, type Options, type Output, x as $x } from "tinyexec"; +export { expect }; import type { Operation, Stream } from "../lib/types.ts"; -import { call, resource, sleep, spawn, stream } from "../mod.ts"; - -export function $await(promise: Promise): Operation { - return call(() => promise); -} +import { resource, sleep, spawn, stream, until } from "../mod.ts"; export function* createNumber(value: number): Operation { yield* sleep(1); @@ -58,6 +56,21 @@ export function* syncResolve(value: string): Operation { export function* syncReject(value: string): Operation { throw new Error(`boom: ${value}`); } +export interface TinyProcess extends Operation { + /** + * A stream of lines coming from both stdin and stdout. The stream + * will terminate when stdout and stderr are closed which usually + * corresponds to the process ending. + */ + lines: Stream; + + /** + * Send `signal` to this process + * @param signal - the OS signal to send to the process + * @returns void + */ + kill(signal?: KillSignal): Operation; +} export interface TinyProcess extends Operation { /** @@ -75,6 +88,17 @@ export interface TinyProcess extends Operation { kill(signal?: KillSignal): Operation; } +// POSIX conventional exit codes for signals (128 + signal number). +// Deno 2.6.9+ (denoland/deno#32081) sets ChildProcess.exitCode to null +// for signal-killed processes (matching Node.js semantics), so tinyexec +// returns exitCode: undefined. We derive the conventional code ourselves. +const SIGNAL_EXIT_CODES: Record = { + SIGHUP: 129, + SIGINT: 130, + SIGQUIT: 131, + SIGTERM: 143, +}; + export function x( cmd: string, args: string[] = [], @@ -85,7 +109,7 @@ export function x( let promise: Promise = tinyexec as unknown as Promise; - let output = call(() => promise); + let output = until(promise); let tinyproc: TinyProcess = { *[Symbol.iterator]() { @@ -94,7 +118,21 @@ export function x( lines: stream(tinyexec), *kill(signal) { tinyexec.kill(signal); - return yield* output; + if ( + Deno.build.os === "windows" && signal === "SIGINT" && tinyexec.pid + ) { + ctrlc(tinyexec.pid); + } + let result = yield* output; + + if (result.exitCode === undefined && signal) { + let code = SIGNAL_EXIT_CODES[signal]; + if (code !== undefined) { + return { ...result, exitCode: code }; + } + } + + return result; }, }; diff --git a/test/until.test.ts b/test/until.test.ts new file mode 100644 index 000000000..a15ca61a1 --- /dev/null +++ b/test/until.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "./suite.ts"; + +import { run, until } from "../mod.ts"; + +describe("until", () => { + it("resolves on success", async () => { + expect.assertions(1); + await run(function* () { + expect(yield* until(Promise.resolve(42))).toEqual(42); + }); + }); + it("throws on error", async () => { + expect.assertions(1); + await run(function* () { + try { + yield* until(Promise.reject("error")); + } catch (error) { + expect(error).toBe("error"); + } + }); + }); +}); diff --git a/www/.gitignore b/www/.gitignore new file mode 100644 index 000000000..87b042e67 --- /dev/null +++ b/www/.gitignore @@ -0,0 +1,5 @@ +/node_modules +pagefind +build +built +tailwind diff --git a/www/AGENTS.md b/www/AGENTS.md new file mode 100644 index 000000000..cbcdd1b13 --- /dev/null +++ b/www/AGENTS.md @@ -0,0 +1,204 @@ +# Effection Blog — Writing Agent Guide + +This file defines how to write blog posts for the Effection website +(`www/blog/`). It covers voice, structure, and technical accuracy constraints. + +The Effection blog is distinct from the Frontside company blog. Posts here are +shorter, more focused, and always grounded in what Effection actually does. + +All Effection blog posts inherit the **shared Frontside voice** defined in +[`frontside.com/AGENTS.md`](https://github.com/thefrontside/frontside.com/blob/main/AGENTS.md). +That guide covers sentence rhythm, verbal tics, humor/metaphor patterns, and +four voice profiles (Opinion, Tutorial, Consultative, Narrative). This file adds +Effection-specific constraints: technical accuracy rules, shorter post length +(500-800 words), and Taras as the default author voice. + +## File Conventions + +- **Directory pattern:** `www/blog/YYYY-MM-DD-slug/index.md` +- **Slug format:** lowercase, hyphen-separated, derived from title +- **Images:** placed in the same directory as the post's `index.md` +- **Frontmatter:** + +```yaml +--- +title: "The Title of the Post" +description: "A 1-2 sentence pitch that makes someone want to read the post." +author: "Author Name" +tags: ["tag1", "tag2"] +image: "featured-image.svg" +--- +``` + +- The title is NOT repeated as an `# H1` inside the post body. +- Tags are lowercase strings, typically 1-3. +- Image paths are relative to the post directory. + +## Featured Images + +- **Template:** `www/blog/blog-image-template.svg` +- **Dimensions:** 1200×630 (standard OG image size) +- **Color scheme:** Automatically adapts to system light/dark mode via + `@media (prefers-color-scheme: dark)` inside the SVG ` + + + + + + + + + + + + + + + + + + + + + diff --git a/www/assets/images/favicon-effection.png b/www/assets/images/favicon-effection.png new file mode 100644 index 000000000..e0c21d7f2 Binary files /dev/null and b/www/assets/images/favicon-effection.png differ diff --git a/www/assets/images/icon-effection.svg b/www/assets/images/icon-effection.svg new file mode 100644 index 000000000..7a63b9cbc --- /dev/null +++ b/www/assets/images/icon-effection.svg @@ -0,0 +1,82 @@ + + + + + + + + + + + + + diff --git a/www/assets/images/jsr-logo.svg b/www/assets/images/jsr-logo.svg new file mode 100644 index 000000000..abd8f5fd6 --- /dev/null +++ b/www/assets/images/jsr-logo.svg @@ -0,0 +1,14 @@ + diff --git a/www/assets/images/meta-effection.png b/www/assets/images/meta-effection.png new file mode 100644 index 000000000..6268ca9f6 Binary files /dev/null and b/www/assets/images/meta-effection.png differ diff --git a/www/assets/images/overriding-context.svg b/www/assets/images/overriding-context.svg new file mode 100644 index 000000000..d253d5232 --- /dev/null +++ b/www/assets/images/overriding-context.svg @@ -0,0 +1,410 @@ + + + + + + + yield* MyContext.set('A')yield* MyContext.set('B');yield* MyContext;BByield* MyContext;yield* MyContextBAyield* MyContext.set('C');yield* MyContext;CCA + diff --git a/www/assets/llms.txt b/www/assets/llms.txt new file mode 100644 index 000000000..91fca11bf --- /dev/null +++ b/www/assets/llms.txt @@ -0,0 +1,55 @@ +# Effection — Structured Concurrency for JavaScript + +Effection is a JavaScript library for building reliable asynchronous and +concurrent programs using **structured concurrency**. + +Effection models async work as **lazy operations** with explicit lifetimes, +deterministic cancellation, and guaranteed cleanup. It uses **generator +functions (`function*`)**, not `async` / `await`, to express long-lived, +cancellable work. + +--- + +## ⚠️ IMPORTANT FOR AI AGENTS + +If you will **write, modify, refactor, or debug Effection code**, you **must** +read [AGENTS.md] first. + +**AGENTS.md is the normative behavioral contract.** +- Do not invent APIs +- Do not infer semantics from Promises or other ecosystems +- Do not substitute primitives that “look equivalent” +- If information is missing or uncertain, consult the API reference + +If any other document conflicts with AGENTS.md, **AGENTS.md takes precedence**. + +--- + +## Where to look (routing) + +- **Behavioral rules & invariants (authoritative):** [AGENTS.md] +- **Public API reference (authoritative):** [API] +- **Conceptual guides & explanations (human-oriented):** [Guides] + - [Thinking in Effection] + - [Async Rosetta Stone] + - [Operations] + - [Scope] + - [Resources] + - [Spawn] + - [Collections] + - [Browse all guides][docs/] +- **Extension packages (process, fetch, websockets, WebWorkers):** [EffectionX] + +--- + +[AGENTS.md]: https://raw.githubusercontent.com/thefrontside/effection/v4/AGENTS.md +[API]: https://frontside.com/effection/api/ +[Guides]: https://frontside.com/effection/guides/v4 +[Thinking in Effection]: https://raw.githubusercontent.com/thefrontside/effection/v4/docs/thinking-in-effection.mdx +[Async Rosetta Stone]: https://raw.githubusercontent.com/thefrontside/effection/v4/docs/async-rosetta-stone.mdx +[Operations]: https://raw.githubusercontent.com/thefrontside/effection/v4/docs/operations.mdx +[Scope]: https://raw.githubusercontent.com/thefrontside/effection/v4/docs/scope.mdx +[Resources]: https://raw.githubusercontent.com/thefrontside/effection/v4/docs/resources.mdx +[Spawn]: https://raw.githubusercontent.com/thefrontside/effection/v4/docs/spawn.mdx +[Collections]: https://raw.githubusercontent.com/thefrontside/effection/v4/docs/collections.mdx +[EffectionX]: https://frontside.com/effection/x/ \ No newline at end of file diff --git a/www/assets/prism-atom-one-dark.css b/www/assets/prism-atom-one-dark.css new file mode 100644 index 000000000..653e01920 --- /dev/null +++ b/www/assets/prism-atom-one-dark.css @@ -0,0 +1,543 @@ +/** + * Added to make line numbering and higlight selection work: + * https://github.com/timlrx/rehype-prism-plus#styling + */ +pre { + overflow-x: auto; +} + +/** + * Inspired by gatsby remark prism - https://www.gatsbyjs.com/plugins/gatsby-remark-prismjs/ + * 1. Make the element just wide enough to fit its content. + * 2. Always fill the visible space in .code-highlight. + */ +.code-highlight { + float: left; /* 1 */ + min-width: 100%; /* 2 */ +} + +.code-line { + display: block; + padding-left: 16px; + padding-right: 16px; + margin-left: -16px; + margin-right: -16px; + border-left: 4px solid + rgba( + 0, + 0, + 0, + 0 + ); /* Set placeholder for highlight accent border color to transparent */ +} + +.code-line.inserted { + background-color: rgba(16, 185, 129, 0.2); /* Set inserted line (+) color */ +} + +.code-line.deleted { + background-color: rgba(239, 68, 68, 0.2); /* Set deleted line (-) color */ +} + +.highlight-line { + margin-left: -16px; + margin-right: -16px; + background-color: rgba(55, 65, 81, 0.5); /* Set highlight bg color */ + border-left: 4px solid + rgb(59, 130, 246); /* Set highlight accent border color */ +} + +.line-number::before { + display: inline-block; + width: 1rem; + text-align: right; + margin-right: 16px; + margin-left: -8px; + color: var(--prism-syntax-gutter); /* Line number color */ + content: attr(line); +} + +/** + * One Dark theme for prism.js + * Based on Atom's One Dark theme: https://github.com/atom/atom/tree/master/packages/one-dark-syntax + */ + +@media (prefers-color-scheme: light) { + :root { + /* One Light colours */ + --prism-mono-1: hsl(230, 8%, 24%); + --prism-mono-2: hsl(230, 6%, 44%); + --prism-mono-3: hsl(230, 4%, 64%); + --prism-hue-1: hsl(198, 99%, 37%); + --prism-hue-2: hsl(221, 87%, 60%); + --prism-hue-3: hsl(301, 63%, 32%); + --prism-hue-4: hsl(119, 34%, 47%); + --prism-hue-5: hsl(5, 74%, 59%); + --prism-hue-5-2: hsl(344, 84%, 43%); + --prism-hue-6: hsl(35, 99%, 36%); + --prism-hue-6-2: hsl(35, 99%, 40%); + --prism-syntax-fg: hsl(230, 8%, 24%); + --prism-syntax-bg: hsl(230, 1%, 98%); + --prism-syntax-gutter: hsl(230, 1%, 62%); + --prism-syntax-guide: hsla(230, 8%, 24%, 0.2); + --prism-syntax-accent: hsl(230, 100%, 66%); + --prism-syntax-selection-color: hsl(230, 1%, 90%); + --prism-syntax-gutter-background-color-selected: hsl(230, 1%, 92%); + --prism-syntax-cursor-line: hsla(230, 8%, 24%, 0.04); + } + + /* Global code block overrides for light mode */ + pre.grid, + code.code-highlight { + color: var(--prism-syntax-fg) !important; + background: var(--prism-syntax-bg) !important; + } +} + +@media (prefers-color-scheme: dark) { + :root { + /* One Dark colours (accurate as of commit 8ae45ca on 6 Sep 2018) */ + --prism-mono-1: hsl(220, 14%, 71%); + --prism-mono-2: hsl(220, 9%, 55%); + --prism-mono-3: hsl(220, 10%, 40%); + --prism-hue-1: hsl(187, 47%, 55%); + --prism-hue-2: hsl(207, 82%, 66%); + --prism-hue-3: hsl(286, 60%, 67%); + --prism-hue-4: hsl(95, 38%, 62%); + --prism-hue-5: hsl(355, 65%, 65%); + --prism-hue-5-2: hsl(5, 48%, 51%); + --prism-hue-6: hsl(29, 54%, 61%); + --prism-hue-6-2: hsl(39, 67%, 69%); + --prism-syntax-fg: hsl(220, 14%, 71%); + --prism-syntax-bg: hsl(220, 13%, 18%); + --prism-syntax-gutter: hsl(220, 14%, 45%); + --prism-syntax-guide: hsla(220, 14%, 71%, 0.15); + --prism-syntax-accent: hsl(220, 100%, 66%); + --prism-syntax-selection-color: hsl(220, 13%, 28%); + --prism-syntax-gutter-background-color-selected: hsl(220, 13%, 26%); + --prism-syntax-cursor-line: hsla(220, 55%, 36%, 0.04); + } + + /* Global code block overrides for dark mode */ + code, + code::before, + code::after { + color: var(--prism-syntax-fg) !important; + } +} + +code[class*="language-"], +pre[class*="language-"] { + background: var(--prism-syntax-bg); + color: var(--prism-syntax-fg); + text-shadow: 0 1px rgba(0, 0, 0, 0.3); + font-family: + "Fira Code", "Fira Mono", Menlo, Consolas, "DejaVu Sans Mono", monospace; + direction: ltr; + text-align: left; + white-space: pre; + word-spacing: normal; + word-break: normal; + line-height: 1.5; + -moz-tab-size: 2; + -o-tab-size: 2; + tab-size: 2; + -webkit-hyphens: none; + -moz-hyphens: none; + -ms-hyphens: none; + hyphens: none; +} + +/* Selection */ +code[class*="language-"]::-moz-selection, +code[class*="language-"] *::-moz-selection, +pre[class*="language-"] *::-moz-selection { + background: var(--prism-syntax-selection-color); + color: inherit; + text-shadow: none; +} + +code[class*="language-"]::selection, +code[class*="language-"] *::selection, +pre[class*="language-"] *::selection { + background: var(--prism-syntax-selection-color); + color: inherit; + text-shadow: none; +} + +/* Code blocks */ +pre[class*="language-"] { + margin: 0.5em 0; + overflow: auto; + border-radius: 0.3em; +} + +/* Inline code */ +:not(pre) > code[class*="language-"] { + padding: 0.2em 0.3em; + border-radius: 0.3em; + white-space: normal; +} + +/* Print */ +@media print { + code[class*="language-"], + pre[class*="language-"] { + text-shadow: none; + } +} + +.token.comment, +.token.prolog, +.token.cdata { + color: var(--prism-mono-3); +} + +.token.doctype, +.token.punctuation, +.token.entity { + color: var(--prism-mono-1); +} + +.token.attr-name, +.token.class-name, +.token.boolean, +.token.constant, +.token.number, +.token.atrule { + color: var(--prism-hue-6); +} + +.token.keyword { + color: var(--prism-hue-3); +} + +.token.property, +.token.tag, +.token.symbol, +.token.deleted, +.token.important { + color: var(--prism-hue-5); +} + +.token.selector, +.token.string, +.token.char, +.token.builtin, +.token.inserted, +.token.regex, +.token.attr-value, +.token.attr-value > .token.punctuation { + color: var(--prism-hue-4); +} + +.token.variable, +.token.operator, +.token.function { + color: var(--prism-hue-2); +} + +.token.url { + color: var(--prism-hue-1); +} + +/* HTML overrides */ +.token.attr-value > .token.punctuation.attr-equals, +.token.special-attr > .token.attr-value > .token.value.css { + color: var(--prism-mono-1); +} + +/* CSS overrides */ +.language-css .token.selector { + color: var(--prism-hue-5); +} + +.language-css .token.property { + color: var(--prism-mono-1); +} + +.language-css .token.function, +.language-css .token.url > .token.function { + color: var(--prism-hue-1); +} + +.language-css .token.url > .token.string.url { + color: var(--prism-hue-4); +} + +.language-css .token.important, +.language-css .token.atrule .token.rule { + color: var(--prism-hue-3); +} + +/* JS overrides */ +.language-javascript .token.operator { + color: var(--prism-hue-3); +} + +.language-javascript + .token.template-string + > .token.interpolation + > .token.interpolation-punctuation.punctuation { + color: var(--prism-hue-5-2); +} + +/* JSON overrides */ +.language-json .token.operator { + color: var(--prism-mono-1); +} + +.language-json .token.null.keyword { + color: var(--prism-hue-6); +} + +/* MD overrides */ +.language-markdown .token.url, +.language-markdown .token.url > .token.operator, +.language-markdown .token.url-reference.url > .token.string { + color: var(--prism-mono-1); +} + +.language-markdown .token.url > .token.content { + color: var(--prism-hue-2); +} + +.language-markdown .token.url > .token.url, +.language-markdown .token.url-reference.url { + color: var(--prism-hue-1); +} + +.language-markdown .token.blockquote.punctuation, +.language-markdown .token.hr.punctuation { + color: var(--prism-mono-3); + font-style: italic; +} + +.language-markdown .token.code-snippet { + color: var(--prism-hue-4); +} + +.language-markdown .token.bold .token.content { + color: var(--prism-hue-6); +} + +.language-markdown .token.italic .token.content { + color: var(--prism-hue-3); +} + +.language-markdown .token.strike .token.content, +.language-markdown .token.strike .token.punctuation, +.language-markdown .token.list.punctuation, +.language-markdown .token.title.important > .token.punctuation { + color: var(--prism-hue-5); +} + +/* General */ +.token.bold { + font-weight: bold; +} + +.token.comment, +.token.italic { + font-style: italic; +} + +.token.entity { + cursor: help; +} + +.token.namespace { + opacity: 0.8; +} + +/* Plugin overrides */ +/* Selectors should have higher specificity than those in the plugins' default stylesheets */ + +/* Show Invisibles plugin overrides */ +.token.token.tab:not(:empty):before, +.token.token.cr:before, +.token.token.lf:before, +.token.token.space:before { + color: var(--prism-syntax-guide); + text-shadow: none; +} + +/* Toolbar plugin overrides */ +/* Space out all buttons and move them away from the right edge of the code block */ +div.code-toolbar > .toolbar.toolbar > .toolbar-item { + margin-right: 0.4em; +} + +/* Styling the buttons */ +div.code-toolbar > .toolbar.toolbar > .toolbar-item > button, +div.code-toolbar > .toolbar.toolbar > .toolbar-item > a, +div.code-toolbar > .toolbar.toolbar > .toolbar-item > span { + background: var(--prism-syntax-gutter-background-color-selected); + color: var(--prism-mono-2); + padding: 0.1em 0.4em; + border-radius: 0.3em; +} + +div.code-toolbar > .toolbar.toolbar > .toolbar-item > button:hover, +div.code-toolbar > .toolbar.toolbar > .toolbar-item > button:focus, +div.code-toolbar > .toolbar.toolbar > .toolbar-item > a:hover, +div.code-toolbar > .toolbar.toolbar > .toolbar-item > a:focus, +div.code-toolbar > .toolbar.toolbar > .toolbar-item > span:hover, +div.code-toolbar > .toolbar.toolbar > .toolbar-item > span:focus { + background: var(--prism-syntax-selection-color); + color: var(--prism-mono-1); +} + +/* Line Highlight plugin overrides */ +/* The highlighted line itself */ +.line-highlight.line-highlight { + background: var(--prism-syntax-cursor-line); +} + +/* Default line numbers in Line Highlight plugin */ +.line-highlight.line-highlight:before, +.line-highlight.line-highlight[data-end]:after { + background: var(--prism-syntax-gutter-background-color-selected); + color: var(--prism-mono-1); + padding: 0.1em 0.6em; + border-radius: 0.3em; + box-shadow: 0 2px 0 0 rgba(0, 0, 0, 0.2); /* same as Toolbar plugin default */ +} + +/* Hovering over a linkable line number (in the gutter area) */ +/* Requires Line Numbers plugin as well */ +pre[id].linkable-line-numbers.linkable-line-numbers + span.line-numbers-rows + > span:hover:before { + background-color: var(--prism-syntax-cursor-line); +} + +/* Line Numbers and Command Line plugins overrides */ +/* Line separating gutter from coding area */ +.line-numbers.line-numbers .line-numbers-rows, +.command-line .command-line-prompt { + border-right-color: var(--prism-syntax-guide); +} + +/* Stuff in the gutter */ +.line-numbers .line-numbers-rows > span:before, +.command-line .command-line-prompt > span:before { + color: var(--prism-syntax-gutter); +} + +/* Match Braces plugin overrides */ +/* Note: Outline colour is inherited from the braces */ +.rainbow-braces .token.token.punctuation.brace-level-1, +.rainbow-braces .token.token.punctuation.brace-level-5, +.rainbow-braces .token.token.punctuation.brace-level-9 { + color: var(--prism-hue-5); +} + +.rainbow-braces .token.token.punctuation.brace-level-2, +.rainbow-braces .token.token.punctuation.brace-level-6, +.rainbow-braces .token.token.punctuation.brace-level-10 { + color: var(--prism-hue-4); +} + +.rainbow-braces .token.token.punctuation.brace-level-3, +.rainbow-braces .token.token.punctuation.brace-level-7, +.rainbow-braces .token.token.punctuation.brace-level-11 { + color: var(--prism-hue-2); +} + +.rainbow-braces .token.token.punctuation.brace-level-4, +.rainbow-braces .token.token.punctuation.brace-level-8, +.rainbow-braces .token.token.punctuation.brace-level-12 { + color: var(--prism-hue-3); +} + +/* Diff Highlight plugin overrides */ +/* Taken from https://github.com/atom/github/blob/master/styles/variables.less */ +pre.diff-highlight > code .token.token.deleted:not(.prefix), +pre > code.diff-highlight .token.token.deleted:not(.prefix) { + background-color: hsla(353, 100%, 66%, 0.15); +} + +pre.diff-highlight > code .token.token.deleted:not(.prefix)::-moz-selection, +pre.diff-highlight > code .token.token.deleted:not(.prefix) *::-moz-selection, +pre > code.diff-highlight .token.token.deleted:not(.prefix)::-moz-selection, +pre > code.diff-highlight .token.token.deleted:not(.prefix) *::-moz-selection { + background-color: hsla(353, 95%, 66%, 0.25); +} + +pre.diff-highlight > code .token.token.deleted:not(.prefix)::selection, +pre.diff-highlight > code .token.token.deleted:not(.prefix) *::selection, +pre > code.diff-highlight .token.token.deleted:not(.prefix)::selection, +pre > code.diff-highlight .token.token.deleted:not(.prefix) *::selection { + background-color: hsla(353, 95%, 66%, 0.25); +} + +pre.diff-highlight > code .token.token.inserted:not(.prefix), +pre > code.diff-highlight .token.token.inserted:not(.prefix) { + background-color: hsla(137, 100%, 55%, 0.15); +} + +pre.diff-highlight > code .token.token.inserted:not(.prefix)::-moz-selection, +pre.diff-highlight > code .token.token.inserted:not(.prefix) *::-moz-selection, +pre > code.diff-highlight .token.token.inserted:not(.prefix)::-moz-selection, +pre > code.diff-highlight .token.token.inserted:not(.prefix) *::-moz-selection { + background-color: hsla(135, 73%, 55%, 0.25); +} + +pre.diff-highlight > code .token.token.inserted:not(.prefix)::selection, +pre.diff-highlight > code .token.token.inserted:not(.prefix) *::selection, +pre > code.diff-highlight .token.token.inserted:not(.prefix)::selection, +pre > code.diff-highlight .token.token.inserted:not(.prefix) *::selection { + background-color: hsla(135, 73%, 55%, 0.25); +} + +/* Previewers plugin overrides */ +/* Based on https://github.com/atom-community/atom-ide-datatip/blob/master/styles/atom-ide-datatips.less and https://github.com/atom/atom/blob/master/packages/one-dark-ui */ +/* Border around popup */ +.prism-previewer.prism-previewer:before, +.prism-previewer-gradient.prism-previewer-gradient div { + border-color: hsl(224, 13%, 17%); +} + +/* Angle and time should remain as circles and are hence not included */ +.prism-previewer-color.prism-previewer-color:before, +.prism-previewer-gradient.prism-previewer-gradient div, +.prism-previewer-easing.prism-previewer-easing:before { + border-radius: 0.3em; +} + +/* Triangles pointing to the code */ +.prism-previewer.prism-previewer:after { + border-top-color: hsl(224, 13%, 17%); +} + +.prism-previewer-flipped.prism-previewer-flipped.after { + border-bottom-color: hsl(224, 13%, 17%); +} + +/* Background colour within the popup */ +.prism-previewer-angle.prism-previewer-angle:before, +.prism-previewer-time.prism-previewer-time:before, +.prism-previewer-easing.prism-previewer-easing { + background: hsl(219, 13%, 22%); +} + +/* For angle, this is the positive area (eg. 90deg will display one quadrant in this colour) */ +/* For time, this is the alternate colour */ +.prism-previewer-angle.prism-previewer-angle circle, +.prism-previewer-time.prism-previewer-time circle { + stroke: var(--prism-mono-1); + stroke-opacity: 1; +} + +/* Stroke colours of the handle, direction point, and vector itself */ +.prism-previewer-easing.prism-previewer-easing circle, +.prism-previewer-easing.prism-previewer-easing path, +.prism-previewer-easing.prism-previewer-easing line { + stroke: var(--prism-mono-1); +} + +/* Fill colour of the handle */ +.prism-previewer-easing.prism-previewer-easing circle { + fill: transparent; +} diff --git a/www/assets/search.js b/www/assets/search.js new file mode 100644 index 000000000..1ad2c91cb --- /dev/null +++ b/www/assets/search.js @@ -0,0 +1,98 @@ +import { + all, + createChannel, + each, + main, + on, + resource, + sleep, + spawn, + // deno-lint-ignore no-import-prefix +} from "https://esm.sh/effection@4.0.0-beta.3"; + +await main(function* () { + let input = document.getElementById("search"); + if (!input) { + console.log(`Search could not be setup because input was not found.`); + return; + } + + let label = input.closest("label"); + if (!label) { + console.log( + `Search could not be setup because label element was not found.`, + ); + return; + } + + let button = input.nextElementSibling; + if (!button) { + console.log( + `Search could not be setup because button element was not found.`, + ); + return; + } + + let events = yield* join([ + on(input, "focus"), + on(button, "focus"), + on(input, "blur"), + on(button, "blur"), + ]); + + /** @type {Task} */ + let lastBlur; + yield* spawn(function* () { + for (let event of yield* each(events)) { + if (event.type === "blur") { + lastBlur = yield* spawn(function* () { + yield* sleep(15); + input.value = ""; + input.setAttribute("placeholder", "⌘K"); + input.classList.remove("focused"); + }); + } else { + if (lastBlur) { + yield* lastBlur.halt(); + } + input.removeAttribute("placeholder"); + input.classList.add("focused"); + } + yield* each.next(); + } + }); + + for (let event of yield* each(on(document, "keydown"))) { + if (event.metaKey && event.key === "k") { + event.preventDefault(); + input.focus(); + } + if (event.key === "Escape") { + input.blur(); + } + yield* each.next(); + } +}); + +/** + * Combine multiple streams into a single stream + * @template {T} + * @param {Stream[]} streams + * @returns {Operation>} + */ +function join(streams) { + return resource(function* (provide) { + let channel = createChannel(); + + yield* spawn(function* () { + yield* all(streams.map(function* (stream) { + for (let event of yield* each(stream)) { + yield* channel.send(event); + yield* each.next(); + } + })); + }); + + yield* provide(channel); + }); +} diff --git a/www/blog/2025-02-02-welcome-to-effection-blog/index.md b/www/blog/2025-02-02-welcome-to-effection-blog/index.md new file mode 100644 index 000000000..d67369084 --- /dev/null +++ b/www/blog/2025-02-02-welcome-to-effection-blog/index.md @@ -0,0 +1,76 @@ +--- +title: "Welcome to the Effection Blog" +description: "Introducing the new Effection blog - your source for tutorials, release announcements, and insights about structured concurrency in JavaScript." +author: "Taras Mankovski" +tags: ["announcement", "effection"] +--- + +Welcome to the official Effection blog! This is where we'll share tutorials, +announcements, and deep dives into structured concurrency in JavaScript. + +## What to Expect + +We're planning to cover a variety of topics: + +- **Tutorials**: Step-by-step guides for using Effection in your projects +- **Release Announcements**: New version highlights and migration guides +- **Deep Dives**: Technical explorations of structured concurrency patterns +- **Ecosystem Updates**: News about integrations and community projects + +## Getting Started with Effection + +If you're new to Effection, here's a quick taste of what structured concurrency +looks like: + +```typescript +import { main, sleep, spawn } from "effection"; + +await main(function* () { + // Spawn concurrent tasks + yield* spawn(function* () { + yield* sleep(1000); + console.log("Task 1 complete"); + }); + + yield* spawn(function* () { + yield* sleep(500); + console.log("Task 2 complete"); + }); + + // Both tasks are automatically cleaned up when main exits + yield* sleep(2000); + console.log("All done!"); +}); +``` + +The key insight is that **all spawned tasks are owned by their parent scope**. +When the parent completes, all children are automatically cleaned up. No more +forgotten timers, dangling promises, or resource leaks. + +## Why Structured Concurrency? + +Traditional async/await in JavaScript has a fundamental problem: **promises are +eager and unstructured**. When you create a promise, work starts immediately and +continues even if nothing is listening for the result. + +Effection's operations are **lazy** - they only execute when you `yield*` them. +And they're **structured** - child operations cannot outlive their parent scope. + +This makes reasoning about concurrent code much easier: + +- Resources are always cleaned up +- Error handling is predictable +- Testing concurrent code is straightforward +- No more "fire and forget" mistakes + +## Join the Community + +We'd love to hear from you! Join us on: + +- [Discord](https://discord.gg/r6AvtnU) - Chat with the community +- [GitHub](https://github.com/thefrontside/effection) - Report issues and + contribute +- [API Documentation](/api) - Explore the full API + +Stay tuned for more posts. We're excited to share what we've learned about +building reliable concurrent applications in JavaScript! diff --git a/www/blog/2026-02-06-structured-concurrency-for-javascript/index.md b/www/blog/2026-02-06-structured-concurrency-for-javascript/index.md new file mode 100644 index 000000000..64120e600 --- /dev/null +++ b/www/blog/2026-02-06-structured-concurrency-for-javascript/index.md @@ -0,0 +1,156 @@ +--- +title: "Why JavaScript Needs Structured Concurrency" +description: "Structured programming tamed the chaos of early computing. Structured concurrency does the same for async — and Effection brings it to JavaScript." +author: "Taras Mankovski" +tags: ["structured concurrency", "javascript", "effection"] +image: "structured-concurrency-js.svg" +--- + +You hit Ctrl-C. The CLI exits. And yet the port is still bound. + +Or a component unmounts in your SPA, and the requests it started keep running +anyway — burning battery, holding sockets, and calling callbacks into code that +has already moved on. + +This is the part of JavaScript async we all learn to tolerate: work that +outlives the scope (the lifetime boundary) that started it. + +Structured programming was created to rein in a similar kind of chaos in the +70s. We take our structured constructs for granted now, but before them it was +the Wild West: crashes, leaks, infinite loops, and programs that were hard to +reason about. People reached for `goto`, control flow jumped across the page, +and the shape of the program stopped matching how it ran. Structured concurrency +is the re-application of that same knowledge to concurrency — binding the +lifetime of concurrent work to the structure of the program. + +For the longer historical perspective, Nathaniel J. Smith's +[Notes on structured concurrency (or: Go statement considered harmful)](https://vorpus.org/blog/notes-on-structured-concurrency-or-go-statement-considered-harmful/) +is the classic. + +Here's what I mean: if I start some concurrent work inside a block of code, that +work should have a clear owner and a natural lifetime, and it should reliably +clean up when that block is done. The picture at the top shows the difference: +on the left, work escapes the function boundary and leaks. On the right, +everything lives inside the scope that started it — and when that scope ends, +everything stops. + +Now here's where the shape of the program stops matching how it runs. Effection +is one way to bring that guarantee back to JavaScript — but first, it helps to +name the failure mode clearly. + +## Where Async Breaks JavaScript + +In synchronous JavaScript, lifetimes are boring in a good way: a function runs +to completion unless it throws, and `finally {}` runs when control leaves the +`try` block. When the function returns, the work is over. + +Async changes that. Once you start async work by creating a Promise, the caller +has two bad options: await it (possibly forever), or move on while the work +keeps running past the caller's lifetime boundary. Either way, there's nothing +built in that can halt it and force cleanup to run — unless you explicitly +thread cancellation through the call chain. + +Here's the shape of the problem in plain `async` code: + +```js +async function run() { + const server = startServer(); // spawns a child process that binds a port + + try { + await fetch("https://example.com/slow"); + } finally { + server.kill(); // only runs if run() unwinds + } +} + +// hard exit: parent dies, child keeps running +process.on("SIGINT", () => process.exit(0)); + +run(); +``` + +When `async/await` was standardized, it didn't come with parent-to-child control +— no built-in halt, no guaranteed cleanup — unless every function in the chain +opts in (e.g. via `AbortSignal`). In practice, `finally {}` stops being a +reliable place to put cleanup for the async work you kicked off — because that +work isn't bound to the scope that created it, and you can't force it to unwind. +Cancellation becomes a convention rather than a guarantee. You end up threading +cancellation signals through layers of code just to get something resembling +interruption. Leaked timers, ports, and listeners become common failure modes. +It's the Wild West of the 70s all over again — just async this time. + +This broken model has been with us for so long that most developers have learned +to live with it — accepting that closing a CLI leaves orphaned processes, that +async work keeps running in the browser long after it's needed, chipping away at +performance. Fixing it feels like it requires a whole different paradigm — +Observables, maybe — so we reach for workarounds and move on. + +For the deeper explanation, see +[The Await Event Horizon](https://frontside.com/blog/2023-12-11-await-event-horizon) +and +[The Heartbreaking Inadequacy of Abort Controller](https://frontside.com/blog/2025-08-04-the-heartbreaking-inadequacy-of-abort-controller/). + +The fix isn't more convention — it's the missing guarantee. + +## What Effection Changes + +Effection makes async code feel like it has the same structure that our +synchronous code has had for decades. The structured concurrency part comes down +to two guarantees: + +1. No operation runs longer than its parent. +2. Every operation exits fully (cleanup runs). + +That's the difference between "the port is still bound" and "cleanup actually +runs." + +It's quickly becoming the default shape of concurrency: Kotlin, Swift, Python +3.11, and +[Java 21](https://docs.oracle.com/en/java/javase/21/core/structured-concurrency.html) +all ship it, and Go has libraries like +[`conc`](https://github.com/sourcegraph/conc) that approximate it. + +Here's what that looks like: + +```js +import { main, sleep, spawn } from "effection"; + +await main(function* () { + yield* spawn(function* () { + try { + yield* sleep(30_000); // long-running timer + } finally { + console.log("timer cleaned up"); + } + }); + + yield* sleep(1000); + console.log("main done"); + // when main exits, the spawned task is halted + // and its finally {} block runs — guaranteed. +}); +``` + +And `main()` takes care of the ugly host integration: in Node/Deno it traps +SIGINT/SIGTERM, and in the browser it shuts down on `unload`, so your scopes +halt and `finally {}` blocks run instead of being skipped by hard exits. + +You still reach for `if`, `for`, `while`, and `try/catch/finally`. The main +difference is that where you would normally write `await`, you use `yield*` +inside a generator function. If you're coming from `async/await`, the mapping is +in the [Async Rosetta Stone](/docs/async-rosetta-stone). For the mental model, +see [Thinking in Effection](/docs/thinking-in-effection). For spawning +specifically, see [spawn](/docs/spawn). + +## Structured Concurrency for JavaScript + +Structured concurrency isn't so much new as it is overdue: it's the missing +guarantee that makes async behave like you already expect. Effection stays small +because it doesn't ask you to change how you write programs; it fills in what +the runtime doesn't guarantee by default so shutdown becomes normal control flow +instead of a special case. When the program ends — Ctrl-C, SIGTERM, navigation, +cancellation — your concurrent work halts cleanly instead of leaking past the +scope that started it. + +Effection is not a large library. It is small and simple by design, so that +async can be bulletproof and still feel normal. diff --git a/www/blog/2026-02-06-structured-concurrency-for-javascript/structured-concurrency-js.svg b/www/blog/2026-02-06-structured-concurrency-for-javascript/structured-concurrency-js.svg new file mode 100644 index 000000000..1a22176b9 --- /dev/null +++ b/www/blog/2026-02-06-structured-concurrency-for-javascript/structured-concurrency-js.svg @@ -0,0 +1,707 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Effection Blog + Why JavaScript + Needs Structured + Concurrency + Scope owns lifetime. + Cleanup is guaranteed. + + + + + + + + + + + + + + + + + Promises + + + + + + + + + async fn() + + + + + + + + + + fetch() + + + + + + + setTimeout() + + + + + + + server.listen() + + + + ● still pending + ● still ticking + ● port still bound + + + finally {} ? + + + + + Effection + + + + + + function*() + + + + + function*() + + + + + + spawned task + + + + + fetch() + + + + setTimeout() + + + + server.listen() + + + scope ends + + + + + + + ✓ halted + cleaned up + + + Async should just feel normal. + diff --git a/www/blog/blog-image-template.svg b/www/blog/blog-image-template.svg new file mode 100644 index 000000000..ddf3f8f24 --- /dev/null +++ b/www/blog/blog-image-template.svg @@ -0,0 +1,512 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Effection Blog + TITLE LINE 1 + TITLE LINE 2 + TITLE LINE 3 + Subtitle caption line 1. + Subtitle caption line 2. + + + + + + + + + + + + + diff --git a/www/components/alert.tsx b/www/components/alert.tsx new file mode 100644 index 000000000..ef1ad9164 --- /dev/null +++ b/www/components/alert.tsx @@ -0,0 +1,35 @@ +// @ts-nocheck Property 'role' does not exist on type 'HTMLElement'.deno-ts(2322) +import { JSXChild } from "revolution/jsx-runtime"; + +const ALERT_LEVELS = { + warning: + "bg-orange-100 border-orange-500 text-orange-700 dark:bg-orange-900 dark:border-orange-300 dark:text-orange-200", + error: + "bg-red-100 border-red-400 text-red-700 dark:bg-red-900 dark:border-red-300 dark:text-red-200", + info: + "bg-blue-100 border-blue-500 text-blue-700 dark:bg-blue-900 dark:border-blue-300 dark:text-blue-200", +} as const; + +export function Alert({ + title, + children, + class: className, + level, +}: { + title?: string; + level: "info" | "warning" | "error"; + children: JSXChild; + class?: string; +}) { + return ( +
p]:my-1`} + role="alert" + > + {title ?

{title}

: <>} + {children} +
+ ); +} diff --git a/www/components/api/api-page.tsx b/www/components/api/api-page.tsx new file mode 100644 index 000000000..1bb017f8a --- /dev/null +++ b/www/components/api/api-page.tsx @@ -0,0 +1,241 @@ +import { all } from "effection"; +import type { JSXElement } from "revolution"; +import { useConfig } from "../../context/config.ts"; +import { LocalDocPage } from "../../hooks/use-deno-doc.tsx"; +import { ResolveLinkFunction, useMarkdown } from "../../hooks/use-markdown.tsx"; +import { Package, usePackage } from "../../lib/package.ts"; +import { major } from "../../lib/semver.ts"; +import { createRootUrl, createSibling } from "../../lib/links-resolvers.ts"; +import { SourceCodeIcon } from "../icons/source-code.tsx"; +import { GithubPill } from "../package/source-link.tsx"; +import { Icon } from "../type/icon.tsx"; +import { Type } from "../type/jsx.tsx"; +import { Keyword } from "../type/tokens.tsx"; + +export function* ApiPage({ + pages, + current, + pkg, + externalLinkResolver, + banner, +}: { + current: string; + pages: LocalDocPage[]; + pkg: Package; + banner?: JSXElement; + externalLinkResolver: ResolveLinkFunction; +}) { + const page = pages.find((node) => node.name === current); + + if (!page) throw new Error(`Could not find a doc page for ${current}`); + + const linkResolver: ResolveLinkFunction = function* resolve( + symbol, + connector, + method, + ) { + const target = pages && + pages.find((page) => page.name === symbol && page.kind !== "import"); + + if (target) { + return `[${ + [symbol, connector, method].join( + "", + ) + }](${yield* externalLinkResolver(symbol, connector, method)})`; + } else { + return symbol; + } + }; + + return ( + <> + {yield* ApiReference({ + pages, + current, + pkg, + content: ( + <> + <>{banner} + {yield* SymbolHeader({ pkg, page })} + {yield* ApiBody({ page, linkResolver })} + + ), + linkResolver: createSibling, + versionToggle: yield* (function* () { + const { series: SERIES } = yield* useConfig(); + const currentSeries = `v${major(pkg.version)}`; + + const links = yield* all( + SERIES.map(function* (s) { + const seriesPkg = yield* usePackage({ + type: "worktree", + series: s, + }); + const seriesDocs = yield* seriesPkg.docs(); + const hasSymbol = seriesDocs["."].some((node) => + node.name === current + ); + + if (!hasSymbol) return null; + + return ( +
+ {seriesPkg.version} + + ); + }), + ); + return ( + + {...links.filter((link): link is JSXElement => link !== null)} + + ); + })(), + })} + + ); +} + +export function* ApiBody({ + page, + linkResolver, +}: { + page: LocalDocPage; + linkResolver: ResolveLinkFunction; +}) { + const elements: JSXElement[] = []; + + for (const [i, section] of Object.entries(page.sections)) { + if (section.markdown) { + elements.push( +
+
+

+ {yield* Type({ node: section.node })} +

+ + + +
+
+ {yield* useMarkdown(section.markdown, { + linkResolver, + slugPrefix: section.id, + })} +
+
, + ); + } + } + + return <>{elements}; +} + +export function* ApiReference({ + pkg, + content, + current, + pages, + linkResolver, + versionToggle, +}: { + pkg: Package; + content: JSXElement; + current: string; + pages: LocalDocPage[]; + linkResolver: ResolveLinkFunction; + versionToggle: JSXElement; +}) { + return ( +
+ +
+ {content} +
+
+ ); +} + +export function* SymbolHeader( + { page, pkg }: { page: LocalDocPage; pkg: Package }, +) { + return ( +
+

+ + {page.kind === "typeAlias" ? "type alias " : page.kind} + {" "} + {page.name} +

+ {yield* GithubPill({ + url: pkg.ref.url, + text: pkg.ref.nameWithOwner, + // url: pkg.source.toString(), + // text: pkg.ref.repository.nameWithOwner, + })} +
+ ); +} + +function* Menu({ + pages, + current, + linkResolver, +}: { + current: string; + pages: LocalDocPage[]; + linkResolver: ResolveLinkFunction; +}) { + const elements = []; + for (const page of pages.sort((a, b) => a.name.localeCompare(b.name))) { + elements.push( +
  • + {current === page.name + ? ( + + + {page.name} + + ) + : ( + + + {page.name} + + )} +
  • , + ); + } + return {elements}; +} diff --git a/www/components/blog/author-section.tsx b/www/components/blog/author-section.tsx new file mode 100644 index 000000000..72fc469c1 --- /dev/null +++ b/www/components/blog/author-section.tsx @@ -0,0 +1,35 @@ +export interface AuthorSectionProps { + author: string; + date: Date; + authorImage: string; +} + +export function AuthorSection({ + author, + date, + authorImage, +}: AuthorSectionProps) { + return ( +
    +
    + {`${author}'s +
    +
    +

    + {author} +

    +

    + {new Intl.DateTimeFormat("en-US", { + month: "long", + day: "numeric", + year: "numeric", + }).format(date)} +

    +
    +
    + ); +} diff --git a/www/components/footer.tsx b/www/components/footer.tsx new file mode 100644 index 000000000..ddf76a5f5 --- /dev/null +++ b/www/components/footer.tsx @@ -0,0 +1,71 @@ +import { IconExternal } from "./icons/external.tsx"; + +export function Footer(): JSX.Element { + return ( + + ); +} diff --git a/www/components/header.tsx b/www/components/header.tsx new file mode 100644 index 000000000..291b2369a --- /dev/null +++ b/www/components/header.tsx @@ -0,0 +1,107 @@ +import { useWorkspaces } from "../lib/workspaces/mod.ts"; +import { getStarCount } from "../lib/octokit.ts"; +import { IconDiscord } from "./icons/discord.tsx"; +import { IconGithub } from "./icons/github.tsx"; +import { StarIcon } from "./icons/star.tsx"; +import { Navburger } from "./navburger.tsx"; +import { SearchInput } from "./search-input.tsx"; + +export interface HeaderProps { + hasLeftSidebar?: boolean; +} + +export function* Header(props?: HeaderProps) { + let workspaces = yield* useWorkspaces("thefrontside/effectionx"); + let packages = yield* workspaces.getAllPackages(); + + return ( +
    +
    +
    + + Effection Logo + +
    + +
    +
    + ); +} diff --git a/www/components/icons/cartouche.tsx b/www/components/icons/cartouche.tsx new file mode 100644 index 000000000..6123cc270 --- /dev/null +++ b/www/components/icons/cartouche.tsx @@ -0,0 +1,18 @@ +// @ts-nocheck Property 'svg' does not exist on type 'JSX.IntrinsicElements'.deno-ts(2339) +export const IconCartouche = () => ( + + + + + + + + + +); diff --git a/www/components/icons/discord.tsx b/www/components/icons/discord.tsx new file mode 100644 index 000000000..9960366d8 --- /dev/null +++ b/www/components/icons/discord.tsx @@ -0,0 +1,16 @@ +// @ts-nocheck Property 'svg' does not exist on type 'JSX.IntrinsicElements'.deno-ts(2339) +export const IconDiscord = () => ( + +); diff --git a/www/components/icons/external.tsx b/www/components/icons/external.tsx new file mode 100644 index 000000000..0c7e828ec --- /dev/null +++ b/www/components/icons/external.tsx @@ -0,0 +1,18 @@ +// @ts-nocheck Property 'svg' does not exist on type 'JSX.IntrinsicElements'.deno-ts(2339) +import { JSXComponentProps } from "revolution/jsx-runtime"; + +export const IconExternal = (props: JSXComponentProps = {}) => ( + +); diff --git a/www/components/icons/github.tsx b/www/components/icons/github.tsx new file mode 100644 index 000000000..163c88430 --- /dev/null +++ b/www/components/icons/github.tsx @@ -0,0 +1,16 @@ +// @ts-nocheck Property 'svg' does not exist on type 'JSX.IntrinsicElements'.deno-ts(2339) +export const IconGithub = () => ( + +); diff --git a/www/components/icons/info.tsx b/www/components/icons/info.tsx new file mode 100644 index 000000000..cd7a45e5b --- /dev/null +++ b/www/components/icons/info.tsx @@ -0,0 +1,15 @@ +// @ts-nocheck Property 'svg' does not exist on type 'JSX.IntrinsicElements'.deno-ts(2339) + +export function InfoIcon() { + return ( + + ); +} diff --git a/www/components/icons/search.tsx b/www/components/icons/search.tsx new file mode 100644 index 000000000..ef94a2661 --- /dev/null +++ b/www/components/icons/search.tsx @@ -0,0 +1,19 @@ +// @ts-nocheck Property 'svg' does not exist on type 'JSX.IntrinsicElements'.deno-ts(2339) +export function SearchIcon(props) { + return ( + + + + + + ); +} diff --git a/www/components/icons/source-code.tsx b/www/components/icons/source-code.tsx new file mode 100644 index 000000000..70e08ba53 --- /dev/null +++ b/www/components/icons/source-code.tsx @@ -0,0 +1,31 @@ +// @ts-nocheck Property 'svg' does not exist on type 'JSX.IntrinsicElements'.deno-ts(2339) + +export function SourceCodeIcon(props) { + return ( + + + + + + + ); +} diff --git a/www/components/icons/star.tsx b/www/components/icons/star.tsx new file mode 100644 index 000000000..4dc7ae494 --- /dev/null +++ b/www/components/icons/star.tsx @@ -0,0 +1,17 @@ +// @ts-nocheck Property 'svg' does not exist on type 'JSX.IntrinsicElements'.deno-ts(2339) +export function StarIcon(props) { + return ( + + ); +} diff --git a/www/components/icons/typescript.tsx b/www/components/icons/typescript.tsx new file mode 100644 index 000000000..930ca7a31 --- /dev/null +++ b/www/components/icons/typescript.tsx @@ -0,0 +1,21 @@ +// @ts-nocheck Property 'svg' does not exist on type 'JSX.IntrinsicElements'.deno-ts(2339) +export function IconTSLogo() { + return ( + + + + + + ); +} diff --git a/www/components/navburger.tsx b/www/components/navburger.tsx new file mode 100644 index 000000000..f61168583 --- /dev/null +++ b/www/components/navburger.tsx @@ -0,0 +1,13 @@ +//@ts-nocheck hastx does not currently typecheck correctly +export function Navburger() { + return ( + + Mobile menu + + + ); +} diff --git a/www/components/package/cicle-score.tsx b/www/components/package/cicle-score.tsx new file mode 100644 index 000000000..9757cf901 --- /dev/null +++ b/www/components/package/cicle-score.tsx @@ -0,0 +1,34 @@ +import { PackageDetailsResult } from "../../resources/jsr-client.ts"; + +export function CircleScore({ details }: { details: PackageDetailsResult }) { + return ( + <> +

    + JSR Logo + Score +

    +
    + + {details.score}% + +
    + + ); +} + +/** @src https://github.com/jsr-io/jsr/blob/34603e996f56eb38e811619f8aebc6e5c4ad9fa7/frontend/utils/score_ring_color.ts */ +export function getScoreBgColorClass(score: number): string { + if (score >= 90) { + return "bg-green-500"; + } else if (score >= 60) { + return "bg-yellow-500"; + } + return "bg-red-500"; +} diff --git a/www/components/package/cross.tsx b/www/components/package/cross.tsx new file mode 100644 index 000000000..2477f1287 --- /dev/null +++ b/www/components/package/cross.tsx @@ -0,0 +1,15 @@ +; diff --git a/www/components/package/exports.tsx b/www/components/package/exports.tsx new file mode 100644 index 000000000..d2c3a53db --- /dev/null +++ b/www/components/package/exports.tsx @@ -0,0 +1,103 @@ +import { join } from "@std/path"; + +import { Keyword, Punctuation } from "../type/tokens.tsx"; +import { DocPage, DocsPages } from "../../hooks/use-deno-doc.tsx"; +import { Operation } from "effection"; +import { JSXChild, JSXElement } from "revolution/jsx-runtime"; +import { ResolveLinkFunction } from "../../hooks/use-markdown.tsx"; + +interface PackageExportsParams { + packageName: string; + docs: DocsPages; + linkResolver: ResolveLinkFunction; +} + +export function* PackageExports({ + packageName, + docs, + linkResolver, +}: PackageExportsParams) { + let elements: JSXElement[] = []; + + for (let [exportName, docPages] of Object.entries(docs)) { + if (docPages.filter((page) => page.kind !== "import").length > 0) { + elements.push( + yield* PackageExport({ + linkResolver, + packageName, + exportName, + docPages, + }), + ); + } + } + + return elements; +} + +interface PackageExportOptions { + packageName: string; + exportName: string; + docPages: Array; + linkResolver: ResolveLinkFunction; +} + +function* PackageExport({ + packageName, + exportName, + docPages, + linkResolver, +}: PackageExportOptions): Operation { + let exports: JSXChild[] = []; + + for ( + let page of docPages + .flatMap((page) => (page.kind === "import" ? [] : [page])) + .sort((a, b) => a.name.localeCompare(b.name)) + ) { + exports.push( + ...[ + + {["enum", "typeAlias", "namespace", "interface"].includes( + page.kind, + ) + ? {"type "} + : ( + "" + )} + {page.name} + , + ", ", + ], + ); + } + + return ( +
    +      
    +        import
    +        {" { "}
    +        
      + {chunk(exports.slice(0, -1)).map((chunk) => ( +
    • {chunk}
    • + ))} +
    + {"} "} + {"from "} + "{join(packageName, exportName)}" +
    +
    + ); +} + +function chunk(array: T[], chunkSize = 2): T[][] { + let chunks: T[][] = []; + for (let i = 0; i < array.length; i += chunkSize) { + let chunk = array.slice(i, i + chunkSize); + chunks.push(chunk); + } + return chunks; +} diff --git a/www/components/package/header.tsx b/www/components/package/header.tsx new file mode 100644 index 000000000..c6c655155 --- /dev/null +++ b/www/components/package/header.tsx @@ -0,0 +1,37 @@ +import type { Package } from "../../lib/package/types.ts"; +import { GithubPill } from "./source-link.tsx"; + +export function* PackageHeader(pkg: Package) { + let name = yield* pkg.getName(); + let version = yield* pkg.getVersion(); + let scopeName = yield* pkg.getScopeName(); + let shortName = name.includes("/") ? name.split("/")[1] : name; + + return ( +
    +
    + + + {scopeName ? `@${scopeName}` : ""} + {scopeName ? / : <>} + {shortName} + + v{version ? version : ""} + + {yield* GithubPill({ + class: "mt-2 xl:mt-0", + url: pkg.ref.url, + text: pkg.ref.nameWithOwner, + })} +
    +
    + + NPM Badge with published version + +
    +
    + ); +} diff --git a/www/components/package/icons.tsx b/www/components/package/icons.tsx new file mode 100644 index 000000000..d04b8d363 --- /dev/null +++ b/www/components/package/icons.tsx @@ -0,0 +1,715 @@ +// @ts-nocheck Property 'svg' does not exist on type 'JSX.IntrinsicElements'. + +export interface IconProps { + class?: string; + height?: string; + width?: string; + style?: string; +} + +export function Check() { + return ( + + ); +} + +export function Cross() { + return ( + + ); +} + +export function BrowserIcon(props: IconProps) { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +} + +export function BunIcon(props: IconProps) { + return ( + + + + + + + + + + + + + + + + ); +} + +export function CloudflareWorkersIcon(props: IconProps) { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +} + +export function DenoIcon(props: IconProps) { + return ( + + + + + + + + + + + + ); +} + +export function NodeIcon(props: IconProps) { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +} + +export function NPMIcon(props: IconProps) { + return ( + + + + + ); +} + +export function JSRIcon(props: IconProps) { + return ( + + + + + + + + + ); +} diff --git a/www/components/package/source-link.tsx b/www/components/package/source-link.tsx new file mode 100644 index 000000000..7911a23f1 --- /dev/null +++ b/www/components/package/source-link.tsx @@ -0,0 +1,25 @@ +import { IconExternal } from "../../components/icons/external.tsx"; +import { IconGithub } from "../../components/icons/github.tsx"; + +export function* GithubPill({ + url, + text, + ...props +}: { + url: string; + text: string; + class?: string; +}) { + return ( + + + {text} + + + ); +} diff --git a/www/components/project-select.tsx b/www/components/project-select.tsx new file mode 100644 index 000000000..b65d0f1c3 --- /dev/null +++ b/www/components/project-select.tsx @@ -0,0 +1,91 @@ +import { JSXComponentProps } from "revolution/jsx-runtime"; + +export function ProjectSelect(props: JSXComponentProps) { + let uuid = self.crypto.randomUUID(); + + let toggleId = `toggle-${uuid}`; + let openerId = `opener-${uuid}`; + let closerId = `closer-${uuid}`; + + return ( +
    + + + + +
    + ); +} + +const projects = [ + { + title: "Interactors", + description: "Page Objects for components libraries", + url: "https://frontside.com/interactors", + version: "v1", + img: + "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIzNCIgaGVpZ2h0PSIzNCIgdmlld0JveD0iMCAwIDM0IDM0Ij4KICA8ZGVmcz4KICAgIDxzdHlsZT4KICAgICAgLmEgewogICAgICAgIGZpbGw6ICNmZmY7CiAgICAgIH0KCiAgICAgIC5hLCAuYiwgLmMgewogICAgICAgIHN0cm9rZTogIzE0MzA1YzsKICAgICAgICBzdHJva2UtbWl0ZXJsaW1pdDogMTA7CiAgICAgICAgc3Ryb2tlLXdpZHRoOiAycHg7CiAgICAgIH0KCiAgICAgIC5iIHsKICAgICAgICBmaWxsOiAjMjZhYmU4OwogICAgICB9CgogICAgICAuYyB7CiAgICAgICAgZmlsbDogI2Y3NGQ3YjsKICAgICAgfQogICAgPC9zdHlsZT4KICA8L2RlZnM+CiAgPGc+CiAgICA8ZWxsaXBzZSBjbGFzcz0iYSIgY3g9IjI4LjQ1IiBjeT0iNi45MSIgcng9IjQiIHJ5PSIzLjg5Ii8+CiAgICA8cGF0aCBjbGFzcz0iYiIgZD0iTTI4LjQ1LDEwLjhhMy45NCwzLjk0LDAsMCwxLTQtMy44OSw0LDQsMCwwLDEsLjI1LTEuMzQsNi40NSw2LjQ1LDAsMCwwLTEuMzMtLjE0QTYuMyw2LjMsMCwwLDAsMTcsMTEuNjRhNi4zOCw2LjM4LDAsMCwwLDEyLjc1LDAsNi45MSw2LjkxLDAsMCwwLS4xLTFBNC4zLDQuMywwLDAsMSwyOC40NSwxMC44WiIvPgogICAgPHBhdGggY2xhc3M9ImMiIGQ9Ik0xNywxMS42NGE2LjI2LDYuMjYsMCwwLDEsLjEtMSwxMS4xMSwxMS4xMSwwLDAsMC00LjY5LTEsMTAuODIsMTAuODIsMCwwLDAtMTEsMTAuNjhBMTAuODIsMTAuODIsMCwwLDAsMTIuNDEsMzFhMTAuODMsMTAuODMsMCwwLDAsMTEtMTAuNjgsMTAuNjYsMTAuNjYsMCwwLDAtLjMxLTIuNDhBNi4yNyw2LjI3LDAsMCwxLDE3LDExLjY0WiIvPgogIDwvZz4KPC9zdmc+Cg==", + }, + { + title: "Auth0 Simulator", + description: "Enabling testing and local development", + url: "https://github.com/thefrontside/simulacrum/tree/v0/packages/auth0", + version: "v0", + img: + "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIzNCIgaGVpZ2h0PSIzNCIgdmlld0JveD0iMCAwIDM0IDM0Ij4KICA8ZGVmcz4KICAgIDxzdHlsZT4KICAgICAgLmEgewogICAgICAgIGZpbGw6ICMxNDMxNWQ7CiAgICAgIH0KCiAgICAgIC5iIHsKICAgICAgICBmaWxsOiAjMjZhYmU4OwogICAgICB9CgogICAgICAuYyB7CiAgICAgICAgZmlsbDogI2Y3NGQ3YjsKICAgICAgICBmaWxsLXJ1bGU6IGV2ZW5vZGQ7CiAgICAgIH0KICAgIDwvc3R5bGU+CiAgPC9kZWZzPgogIDxnPgogICAgPHBhdGggY2xhc3M9ImEiIGQ9Ik0xNyw1LjE2bDEwLjMzLDUuOTVWMjIuODlMMTcsMjguODQsNi42NywyMi44OVYxMS4xMUwxNyw1LjE2TTE3LC4yOCwyLjQ1LDguNjZWMjUuMzRMMTcsMzMuNzJsMTQuNTUtOC4zOFY4LjY2TDE3LC4yOFoiLz4KICAgIDxwYXRoIGNsYXNzPSJiIiBkPSJNMTcsNy42OWw4LjEyLDQuNjd2OS4yOEwxNywyNi4zMSw4Ljg4LDIxLjY0VjEyLjM2TDE3LDcuNjltMC0zLjI1TDYuMDYsMTAuNzRWMjMuMjZMMTcsMjkuNTZsMTAuOTQtNi4zVjEwLjc0TDE3LDQuNDRaIi8+CiAgICA8cG9seWdvbiBjbGFzcz0iYyIgcG9pbnRzPSIxNyAxMy44OCAxNC4yOCAxNS40NCAxNC4yOCAxOC41NiAxNyAyMC4xMiAxOS43MiAxOC41NiAxOS43MiAxNS40NCAxNyAxMy44OCIvPgogIDwvZz4KPC9zdmc+Cg==", + }, +]; diff --git a/www/components/rehype.tsx b/www/components/rehype.tsx new file mode 100644 index 000000000..6b99e0810 --- /dev/null +++ b/www/components/rehype.tsx @@ -0,0 +1,23 @@ +import { type PluggableList, unified } from "unified"; + +export interface RehypeOptions { + children: JSX.Element; + plugins: PluggableList; +} + +export function Rehype(options: RehypeOptions): JSX.Element { + let { children, plugins } = options; + let pipeline = unified().use(plugins); + + let result = pipeline.runSync(children); + if ( + result.type === "text" || result.type === "element" || + result.type === "root" + ) { + return result as JSX.Element; + } else { + throw new Error( + `rehype plugin stack: {options.plugins} did not return a HAST Element`, + ); + } +} diff --git a/www/components/search-input.tsx b/www/components/search-input.tsx new file mode 100644 index 000000000..43724a05d --- /dev/null +++ b/www/components/search-input.tsx @@ -0,0 +1,24 @@ +import { SearchIcon } from "./icons/search.tsx"; + +export function SearchInput() { + return ( +
    + +
    + ); +} diff --git a/www/components/transform.tsx b/www/components/transform.tsx new file mode 100644 index 000000000..44478d05f --- /dev/null +++ b/www/components/transform.tsx @@ -0,0 +1,44 @@ +import type { JSXChild, JSXElement } from "revolution"; + +export interface Transformer { + (node: JSXElement): JSXElement; +} + +export interface TransformOptions { + fn: Transformer; + children: JSXChild | JSXChild[]; +} + +export function Transform(options: TransformOptions): JSX.Element { + let { children, fn } = options; + + if (Array.isArray(children)) { + return { + type: "root", + //@ts-expect-error dem hast types! + children: children.map((child) => transform(child, fn)), + }; + } else { + return transform(children, fn); + } +} + +export function transform(child: JSXChild, fn: Transformer): JSX.Element { + switch (typeof child) { + case "string": + case "number": + case "boolean": + return fn({ type: "text", value: String(child) }); + default: + switch (child.type) { + case "text": + case "element": + return fn(child); + default: { + let children = child.children as unknown as JSXElement[]; + //@ts-expect-error dem hast types! + return { type: "root", children: children.map(fn) }; + } + } + } +} diff --git a/www/components/type/icon.tsx b/www/components/type/icon.tsx new file mode 100644 index 000000000..9f8dd3ba6 --- /dev/null +++ b/www/components/type/icon.tsx @@ -0,0 +1,46 @@ +export function Icon(props: { kind: string; class?: string }) { + switch (props.kind) { + case "function": + return ( + + f + + ); + case "interface": + return ( + + I + + ); + case "typeAlias": + return ( + + T + + ); + case "variable": { + return ( + + v + + ); + } + } + return <>; +} diff --git a/www/components/type/jsx.tsx b/www/components/type/jsx.tsx new file mode 100644 index 000000000..fb4bcd773 --- /dev/null +++ b/www/components/type/jsx.tsx @@ -0,0 +1,415 @@ +import { JSXElement } from "revolution/jsx-runtime"; +import { type Operation } from "effection"; +import type { + DocNode, + ParamDef, + TsTypeDef, + TsTypeParamDef, + TsTypeRefDef, + VariableDef, +} from "@deno/doc"; +import { + Builtin, + ClassName, + Keyword, + Operator, + Optional, + Punctuation, +} from "./tokens.tsx"; + +interface TypeProps { + node: DocNode; +} + +export function* Type(props: TypeProps): Operation { + let { node } = props; + + switch (node.kind) { + case "function": + return ( + + {node.functionDef.isAsync + ? {"async "} + : <>} + {node.kind} + {node.functionDef.isGenerator ? * : <>} + {" "} + {node.name} + {node.functionDef.typeParams.length > 0 + ? + : <>} + ( + + ): {node.functionDef.returnType + ? + : <>} + + ); + case "class": + return ( + + {node.kind} {node.name} + {node.classDef.extends + ? ( + <> + {" extends "} + {node.classDef.extends} + + ) + : <>} + {node.classDef.implements + ? ( + <> + {" implements "} + <> + {node.classDef.implements + .flatMap((typeDef) => [, ", "]) + .slice(0, -1)} + + + ) + : <>} + + ); + case "interface": + return ( + + {node.kind} {node.name} + {node.interfaceDef.typeParams.length > 0 + ? + : <>} + {node.interfaceDef.extends.length > 0 + ? ( + <> + {" extends "} + <> + {node.interfaceDef.extends + .flatMap((typeDef) => [, ", "]) + .slice(0, -1)} + + + ) + : <>} + + ); + case "variable": + return ( + + + + ); + case "typeAlias": + return ( + + {"type "} + {node.name} + {" = "} + + + ); + case "enum": + case "import": + case "moduleDoc": + case "namespace": + default: + console.log(" unimplemented", node.kind); + return ( + + {node.kind} {node.name} + + ); + } +} + +function TSVariableDef({ + variableDef, + name, +}: { + variableDef: VariableDef; + name: string; +}) { + return ( + <> + {variableDef.kind} {name} + :{" "} + {variableDef.tsType ? : <>} + + ); +} + +function FunctionParams({ params }: { params: ParamDef[] }) { + return ( + <> + {params + .flatMap((param) => [, ", "]) + .slice(0, -1)} + + ); +} + +function TSParam({ param }: { param: ParamDef }) { + switch (param.kind) { + case "identifier": { + return ( + <> + {param.name} + + {": "} + {param.tsType ? : <>} + + ); + } + case "rest": { + return ( + <> + + + {param.tsType ? : <>} + + ); + } + case "assign": { + return ( + <> + + {" = "} + {param.tsType ? : <>} + {param.right === "[UNSUPPORTED]" ? "{}" : <>} + + ); + } + default: + console.log(" unimplemented:", param); + } + return <>; +} + +export function TypeDef({ typeDef }: { typeDef: TsTypeDef }) { + switch (typeDef.kind) { + case "literal": + switch (typeDef.literal.kind) { + case "string": + return "{typeDef.repr}"; + case "number": + return {typeDef.repr}; + case "boolean": + return {typeDef.repr}; + case "bigInt": + return {typeDef.repr}; + default: + // TODO(taras): implement template + return <>; + } + case "keyword": + if (["number", "string", "boolean", "bigint"].includes(typeDef.keyword)) { + return {typeDef.keyword}; + } else { + return {typeDef.keyword}; + } + case "typeRef": + return ; + case "union": + return ; + case "fnOrConstructor": + if (typeDef.fnOrConstructor.constructor) { + console.log(` unimplemeneted`, typeDef.fnOrConstructor); + // TODO(taras): implement + return <>; + } else { + return ( + <> + ( + + ) + {" => "} + + + ); + } + case "indexedAccess": + return ( + <> + + [ + + ] + + ); + case "tuple": + return ( + <> + [ + <> + {typeDef.tuple + .flatMap((tp) => [, ", "]) + .slice(0, -1)} + + ] + + ); + case "array": + return ( + <> + + [] + + ); + case "typeOperator": + return ( + <> + {typeDef.typeOperator.operator}{" "} + + + ); + case "parenthesized": { + return ( + <> + ( + + ) + + ); + } + case "intersection": { + return ( + <> + {typeDef.intersection + .flatMap((tp) => [ + , + {" & "}, + ]) + .slice(0, -1)} + + ); + } + case "typeLiteral": { + // todo(taras): this is incomplete + return ( + <> + { + } + + ); + } + case "conditional": { + return ( + <> + + {" extends "} + + {" ? "} + + {" : "} + + + ); + } + case "infer": { + return ( + <> + {"infer "} + {typeDef.infer.typeParam.name} + + ); + } + case "mapped": + return ( + <> + [ + {typeDef.mappedType.typeParam.name} + {` in `} + {typeDef.mappedType.typeParam.constraint + ? + : <>} + ] + {" : "} + {typeDef.mappedType.tsType + ? + : <>} + + ); + case "importType": + case "optional": + case "rest": + case "this": + case "typePredicate": + case "typeQuery": + console.log(" unimplemented", typeDef); + } + return <>; +} + +function TypeDefUnion({ union }: { union: TsTypeDef[] }) { + return ( + <> + {union.flatMap((typeDef, index) => ( + <> + + {index + 1 < union.length ? {" | "} : <>} + + ))} + + ); +} + +function TypeRef({ typeRef }: { typeRef: TsTypeRefDef }) { + return ( + <> + {typeRef.typeName} + {typeRef.typeParams + ? ( + <> + {"<"} + <> + {typeRef.typeParams + .flatMap((tp) => [, ", "]) + .slice(0, -1)} + + {">"} + + ) + : <>} + + ); +} + +function InterfaceTypeParams({ + typeParams, +}: { + typeParams: TsTypeParamDef[]; +}): JSXElement { + return ( + <> + {"<"} + <> + {typeParams + .flatMap((param) => { + return [ + <> + {param.name} + {param.constraint + ? ( + <> + {" extends "} + + + ) + : <>} + {param.default + ? ( + <> + {" = "} + + + ) + : <>} + , + ", ", + ]; + }) + .slice(0, -1)} + + {">"} + + ); +} diff --git a/www/components/type/markdown.tsx b/www/components/type/markdown.tsx new file mode 100644 index 000000000..7ccdf85f0 --- /dev/null +++ b/www/components/type/markdown.tsx @@ -0,0 +1,409 @@ +import { Operation } from "effection"; +import type { + ClassMethodDef, + DocNode, + ParamDef, + TsTypeDef, + TsTypeParamDef, +} from "@deno/doc"; +import { toHtml } from "hast-util-to-html"; +import { DocPage } from "../../hooks/use-deno-doc.tsx"; +import { Icon } from "./icon.tsx"; + +const NEW = + `new`; +const OPTIONAL = + `optional`; +const READONLY = + `readonly`; + +export const NO_DOCS_AVAILABLE = "*No documentation available.*"; + +export function* extract( + node: DocNode, +): Operation<{ markdown: string; ignore: boolean; pages: DocPage[] }> { + let lines = []; + let pages: DocPage[] = []; + + let ignore = false; + + if (node.jsDoc && node.jsDoc.doc) { + lines.push(node.jsDoc.doc); + } + + let deprecated = node.jsDoc && + node.jsDoc.tags?.flatMap((tag) => (tag.kind === "deprecated" ? [tag] : [])); + if (deprecated && deprecated.length > 0) { + lines.push(``); + for (let warning of deprecated) { + if (warning.doc) { + lines.push( + `
    + Deprecated + + ${warning.doc} + +
    + `, + ); + } + } + } + + let examples = node.jsDoc && + node.jsDoc.tags?.flatMap((tag) => (tag.kind === "example" ? [tag] : [])); + if (examples && examples?.length > 0) { + lines.push("### Examples"); + let i = 1; + for (let example of examples) { + lines.push(`#### Example ${i++}`, example.doc, "---"); + } + } + + if (node.kind === "class") { + if (node.classDef.constructors.length > 0) { + lines.push(`### Constructors`, "
    "); + for (let constructor of node.classDef.constructors) { + lines.push( + `
    ${NEW} **${node.name}**(${ + constructor.params + .map(Param) + .join(", ") + })
    `, + `
    `, + constructor.jsDoc, + `
    `, + ); + } + lines.push("
    "); + } + + let nonStatic = node.classDef.methods.filter( + (method) => !method.isStatic, + ); + if (nonStatic.length > 0) { + lines.push("### Methods", `
    `, ...methodList(nonStatic), "
    "); + } + + let staticMethods = node.classDef.methods.filter( + (method) => method.isStatic, + ); + if (staticMethods.length > 0) { + lines.push( + "### Static Methods", + "
    ", + ...methodList(staticMethods), + "
    ", + ); + } + } + + if (node.kind === "namespace") { + let variables = node.namespaceDef.elements.flatMap((node) => + node.kind === "variable" ? [node] : [] + ) ?? []; + if (variables.length > 0) { + lines.push("### Variables"); + lines.push("
    "); + for (let variable of variables) { + let name = `${node.name}.${variable.name}`; + let section = yield* extract(variable); + let description = variable.jsDoc?.doc || NO_DOCS_AVAILABLE; + pages.push({ + name, + kind: variable.kind, + description, + dependencies: [], + sections: [ + { + id: exportHash(variable, 0), + node: variable, + markdown: section.markdown, + ignore: section.ignore, + }, + ], + }); + lines.push( + `
    `, + toHtml(), + `[${name}](${name})`, + `
    `, + ); + lines.push(`
    `, description, `
    `); + } + lines.push("
    "); + } + } + + if (node.kind === "interface") { + if (node.name === "Completed") console.log(node); + + lines.push("\n", ...TypeParams(node.interfaceDef.typeParams, node)); + + if (node.interfaceDef.properties.length > 0) { + lines.push("### Properties", "
    "); + for (let property of node.interfaceDef.properties) { + let typeDef = property.tsType ? TypeDef(property.tsType) : ""; + let description = property.jsDoc?.doc || NO_DOCS_AVAILABLE; + lines.push( + `
    **${property.name}**${ + property.readonly ? READONLY : "" + }${property.optional ? OPTIONAL : ""}: ${typeDef}
    `, + `
    `, + description, + "
    ", + ); + } + lines.push("
    "); + } + + if (node.interfaceDef.methods.length > 0) { + lines.push("### Methods", "
    "); + for (let method of node.interfaceDef.methods) { + let typeParams = method.typeParams.map(TypeParam).join(", "); + let params = method.params.map(Param).join(", "); + let returnType = method.returnType ? TypeDef(method.returnType) : ""; + let description = method.jsDoc?.doc || NO_DOCS_AVAILABLE; + lines.push( + `

    ${method.name}

    ${ + typeParams ? `<${typeParams}>` : "" + }(${params}): ${returnType}
    `, + `
    `, + description, + "
    ", + ); + } + lines.push("
    "); + } + } + + if (node.kind === "typeAlias") { + lines.push("\n", ...TypeParams(node.typeAliasDef.typeParams, node)); + } + + if (node.kind === "function") { + lines.push(...TypeParams(node.functionDef.typeParams, node)); + + let { params } = node.functionDef; + if (params.length > 0) { + lines.push("### Parameters"); + let jsDocs = node.jsDoc?.tags?.flatMap((tag) => + tag.kind === "param" ? [tag] : [] + ) ?? []; + let i = 0; + for (let param of params) { + lines.push("\n", Param(param)); + if (jsDocs[i] && jsDocs[i].doc) { + lines.push("\n", jsDocs[i].doc); + } + i++; + } + } + + if (node.functionDef.returnType) { + lines.push("### Return Type", "\n", TypeDef(node.functionDef.returnType)); + let jsDocs = node.jsDoc?.tags?.find((tag) => tag.kind === "return"); + if (jsDocs && jsDocs.doc) { + lines.push("\n", jsDocs.doc); + } + } + } + + if (node.kind === "variable" && node.variableDef.tsType) { + lines.push("### Type", "\n", TypeDef(node.variableDef.tsType)); + } + + let see: string[] = []; + if (node.jsDoc && node.jsDoc.tags) { + for (let tag of node.jsDoc.tags) { + switch (tag.kind) { + case "ignore": { + ignore = true; + break; + } + case "see": { + see.push(tag.doc); + } + } + } + } + if (see.length > 0) { + lines.push("\n", "### See", ...see.map((item) => `* ${item}`)); + } + + let markdown = lines.join("\n"); + + return { + markdown, + ignore, + pages, + }; +} + +export function exportHash(node: DocNode, index: number): string { + return [node.kind, node.name, index].filter(Boolean).join("_"); +} + +export function TypeParams(typeParams: TsTypeParamDef[], node: DocNode) { + let lines = []; + if (typeParams.length > 0) { + lines.push("### Type Parameters"); + let jsDocs = node.jsDoc?.tags?.flatMap((tag) => + tag.kind === "template" ? [tag] : [] + ) ?? []; + let i = 0; + for (let typeParam of typeParams) { + lines.push(TypeParam(typeParam)); + if (jsDocs[i]) { + lines.push(jsDocs[i].doc); + } + lines.push("\n"); + i++; + } + } + return lines; +} + +export function TypeDef(typeDef: TsTypeDef): string { + switch (typeDef.kind) { + case "fnOrConstructor": { + let params = typeDef.fnOrConstructor.params.map(Param).join(", "); + let tparams = typeDef.fnOrConstructor.typeParams + .map(TypeParam) + .join(", "); + return `${tparams.length > 0 ? `<${tparams}>` : ""}(${params}) => ${ + TypeDef( + typeDef.fnOrConstructor.tsType, + ) + }`; + } + case "typeRef": { + let tparams = typeDef.typeRef.typeParams?.map(TypeDef).join(", "); + return `{@link ${typeDef.typeRef.typeName}}${ + tparams && tparams?.length > 0 ? `<${tparams}>` : "" + }`; + } + case "keyword": { + return typeDef.keyword; + } + case "union": { + return typeDef.union.map(TypeDef).join(" | "); + } + case "array": { + return `${TypeDef(typeDef.array)}[]`; + } + case "typeOperator": { + return `${typeDef.typeOperator.operator} ${ + TypeDef( + typeDef.typeOperator.tsType, + ) + }`; + } + case "tuple": { + return `[${typeDef.tuple.map(TypeDef).join(", ")}]`; + } + case "parenthesized": { + return TypeDef(typeDef.parenthesized); + } + case "intersection": { + return typeDef.intersection.map(TypeDef).join(" & "); + } + case "typeLiteral": { + // todo(taras): this is incomplete + return `{}`; + } + case "mapped": { + return `[${TypeParam(typeDef.mappedType.typeParam)}]: ${ + typeDef.mappedType.tsType ? TypeDef(typeDef.mappedType.tsType) : "" + }`; + } + case "conditional": { + return `${TypeDef(typeDef.conditionalType.checkType)} extends ${ + TypeDef( + typeDef.conditionalType.extendsType, + ) + } ? ${ + TypeDef( + typeDef.conditionalType.trueType, + ) + } : ${TypeDef(typeDef.conditionalType.falseType)}`; + } + case "indexedAccess": { + return `${TypeDef(typeDef.indexedAccess.objType)}[${ + TypeDef( + typeDef.indexedAccess.indexType, + ) + }]`; + } + case "literal": { + return `*${typeDef.repr}*`; + } + case "importType": + case "infer": + case "optional": + case "rest": + case "this": + case "typePredicate": + case "typeQuery": + console.log("TypeDef: unimplemented", typeDef); + } + return ""; +} + +function TypeParam(paramDef: TsTypeParamDef) { + let parts = [`{@link ${paramDef.name}}`]; + if (paramDef.constraint) { + if ( + paramDef.constraint.kind === "typeOperator" && + paramDef.constraint.typeOperator.operator === "keyof" + ) { + parts.push(`in ${TypeDef(paramDef.constraint)}`); + } else { + parts.push(`extends ${TypeDef(paramDef.constraint)}`); + } + } + if (paramDef.default) { + parts.push(`= ${TypeDef(paramDef.default)}`); + } + return parts.join(" "); +} + +function Param(paramDef: ParamDef): string { + switch (paramDef.kind) { + case "identifier": { + return `**${paramDef.name}**${paramDef.optional ? OPTIONAL : ""}: ${ + paramDef.tsType ? TypeDef(paramDef.tsType) : "" + }`; + } + case "rest": { + return `...${Param(paramDef.arg)} ${ + paramDef.tsType ? TypeDef(paramDef.tsType) : "" + }`; + } + case "array": + case "object": + console.log("Param: unimplemented", paramDef); + } + return ""; +} + +export function methodList(methods: ClassMethodDef[]) { + let lines = []; + for (let method of methods) { + let typeParams = method.functionDef.typeParams.map(TypeParam).join(", "); + let params = method.functionDef.params.map(Param).join(", "); + let returnType = method.functionDef.returnType + ? TypeDef(method.functionDef.returnType) + : ""; + let description = method.jsDoc?.doc || NO_DOCS_AVAILABLE; + lines.push( + `
    **${method.name}**${ + typeParams ? `<${typeParams}>` : "" + }(${params}): ${returnType}
    `, + `
    `, + description, + "
    ", + ); + } + return lines; +} diff --git a/www/components/type/tokens.tsx b/www/components/type/tokens.tsx new file mode 100644 index 000000000..bfad59672 --- /dev/null +++ b/www/components/type/tokens.tsx @@ -0,0 +1,37 @@ +import type { JSXChild, JSXElement } from "revolution"; + +export function ClassName({ children }: { children: JSXChild }): JSXElement { + return {children}; +} + +export function Punctuation( + { children, classes, style }: { + children: JSXChild; + classes?: string; + style?: string; + }, +): JSXElement { + return ( + {children} + ); +} + +export function Operator({ children }: { children: JSXChild }): JSXElement { + return {children}; +} + +export function Keyword({ children }: { children: JSXChild }): JSXElement { + return {children}; +} + +export function Builtin({ children }: { children: JSXChild }): JSXElement { + return {children}; +} + +export function Optional({ optional }: { optional: boolean }): JSXElement { + if (optional) { + return ?; + } else { + return <>; + } +} diff --git a/www/context/config.ts b/www/context/config.ts new file mode 100644 index 000000000..09c2f3965 --- /dev/null +++ b/www/context/config.ts @@ -0,0 +1,21 @@ +import { createContext, type Operation } from "effection"; + +export interface SiteConfig { + series: T[]; + current: NoInfer; +} + +const ConfigContext = createContext("site-config", { + series: ["v3", "v4"], + current: "v4", +}); + +export function* initConfig( + config: SiteConfig, +): Operation { + yield* ConfigContext.set(config); +} + +export function* useConfig(): Operation { + return yield* ConfigContext.expect(); +} diff --git a/www/context/context-api.ts b/www/context/context-api.ts new file mode 100644 index 000000000..4b49ae08f --- /dev/null +++ b/www/context/context-api.ts @@ -0,0 +1,73 @@ +// deno-lint-ignore-file no-explicit-any ban-types +import { createContext, type Operation } from "effection"; + +export type Around = { + [K in keyof Operations]: A[K] extends + (...args: infer TArgs) => infer TReturn ? Middleware + : Middleware<[], A[K]>; +}; + +export interface Middleware { + (args: TArgs, next: (...args: TArgs) => TReturn): TReturn; +} + +export interface Api { + operations: Operations; + around: (around: Partial>) => Operation; +} + +export type Operations = { + [K in keyof T]: T[K] extends ((...args: infer TArgs) => infer TReturn) + ? (...args: TArgs) => TReturn + : T[K] extends Operation ? Operation + : never; +}; + +export function createApi(name: string, handler: A): Api { + let fields = Object.keys(handler) as (keyof A)[]; + + let middleware: Around = fields.reduce((sum, field) => { + return Object.assign(sum, { + [field]: (args: any, next: any) => next(...args), + }); + }, {} as Around); + + let context = createContext>(`$api:${name}`, middleware); + + let operations = fields.reduce((api, field) => { + let handle = handler[field]; + if (typeof handle === "function") { + return Object.assign(api, { + [field]: function* (...args: any[]) { + let around = yield* context.expect(); + let middleware = around[field] as Function; + return yield* middleware(args, handle); + }, + }); + } else { + return Object.assign(api, { + [field]: { + *[Symbol.iterator]() { + let around = yield* context.expect(); + let middleware = around[field] as Function; + return yield* middleware([], () => handle); + }, + }, + }); + } + }, {} as Operations); + + function* around(around: Partial>): Operation { + let current = yield* context.expect(); + yield* context.set(fields.reduce((sum, field) => { + let prior = current[field] as Middleware; + let middleware = around[field] as Middleware; + return Object.assign(sum, { + [field]: (args: any, next: any) => + middleware(args, (...args) => prior(args, next)), + }); + }, Object.assign({}, current))); + } + + return { operations, around }; +} diff --git a/www/context/doc-page.ts b/www/context/doc-page.ts new file mode 100644 index 000000000..6282ba7e8 --- /dev/null +++ b/www/context/doc-page.ts @@ -0,0 +1,4 @@ +import { createContext } from "effection"; +import type { LocalDocPage } from "../hooks/use-deno-doc.tsx"; + +export const DocPageContext = createContext("doc-page"); diff --git a/www/context/fetch.ts b/www/context/fetch.ts new file mode 100644 index 000000000..479c20336 --- /dev/null +++ b/www/context/fetch.ts @@ -0,0 +1,72 @@ +import { Operation, until } from "effection"; +import { createApi } from "./context-api.ts"; +import { rewrite } from "./url-rewrite.ts"; +import { log } from "./logging.ts"; + +interface FetchApi { + fetch(input: RequestInfo | URL, init?: RequestInit): Operation; +} + +export const fetchApi = createApi("fetch", { + *fetch(input, init) { + return yield* until(globalThis.fetch(input, init)); + }, +}); + +export const { operations } = fetchApi; + +export function* initFetch() { + let cache = yield* until(caches.open("local-cache")); + + yield* fetchApi.around({ + *fetch([input, init], next) { + let request = input instanceof Request ? input : new Request(input, init); + if (request.method === "GET") { + let response = yield* until(cache.match(request)); + if (response) { + return response; + } else { + let response = yield* next(input, init); + yield* until(cache.put(request, response.clone())); + return response; + } + } + return yield* next(input, init); + }, + }); + + yield* fetchApi.around({ + *fetch([input, init], next) { + let url = input instanceof Request ? new URL(input.url) : new URL(input); + + if (url.protocol === "file:") { + yield* log.debug(`Reading file system file from ${url}`); + try { + let file = yield* until(Deno.open(url.pathname)); + return new Response(file.readable); + } catch (error) { + if (error instanceof Deno.errors.NotFound) { + return new Response(`File not found ${url.pathname}`, { + status: 404, + }); + } + console.error(`Error reading file ${url.pathname}:`, error); + return new Response("Internal server error", { status: 500 }); + } + } else { + return yield* next(input, init); + } + }, + }); + + yield* fetchApi.around({ + *fetch([input, init], next) { + let url = input instanceof Request ? new URL(input.url) : new URL(input); + let newUrl = yield* rewrite(url, input, init); + if (url !== newUrl) { + yield* log.debug(`Rewrite ${url} to ${newUrl}`); + } + return yield* next(newUrl, init); + }, + }); +} diff --git a/www/context/jsr.ts b/www/context/jsr.ts new file mode 100644 index 000000000..27e0fdea1 --- /dev/null +++ b/www/context/jsr.ts @@ -0,0 +1,19 @@ +import { createContext, type Operation } from "effection"; +import { createJSRClient, JSRClient } from "../resources/jsr-client.ts"; + +const JSRClientContext = createContext("jsr-client"); + +export function* initJSRClient() { + let token = Deno.env.get("JSR_API") ?? ""; + if (token === "") { + console.log("Missing JSR API token; expect score card not to load."); + } + + let client = yield* createJSRClient(token); + + return yield* JSRClientContext.set(client); +} + +export function* useJSRClient(): Operation { + return yield* JSRClientContext.expect(); +} diff --git a/www/context/logging.ts b/www/context/logging.ts new file mode 100644 index 000000000..5dcf9beed --- /dev/null +++ b/www/context/logging.ts @@ -0,0 +1,91 @@ +import type { Operation } from "effection"; +import { createApi } from "./context-api.ts"; + +export interface Logger { + info: (message: string, ...args: unknown[]) => Operation; + debug: (message: string, ...args: unknown[]) => Operation; + warn: (message: string, ...args: unknown[]) => Operation; + error: (message: string, ...args: unknown[]) => Operation; +} + +export const colors = { + reset: "\x1b[0m", + red: "\x1b[31m", + yellow: "\x1b[33m", + blue: "\x1b[34m", + gray: "\x1b[90m", + green: "\x1b[32m", +}; + +const consoleLogger: Logger = { + *info(message: string, ...args: unknown[]) { + console.log(`${colors.blue}[INFO]${colors.reset} ${message}`, ...args); + }, + *debug(message: string, ...args: unknown[]) { + console.log(`${colors.gray}[DEBUG]${colors.reset} ${message}`, ...args); + }, + *warn(message: string, ...args: unknown[]) { + console.warn(`${colors.yellow}[WARN]${colors.reset} ${message}`, ...args); + }, + *error(message: string, ...args: unknown[]) { + console.error(`${colors.red}[ERROR]${colors.reset} ${message}`, ...args); + }, +}; + +export const loggerApi = createApi("logger", consoleLogger); +export const log = loggerApi.operations; + +export function* verboseLogging(verbose: boolean) { + yield* loggerApi.around({ + *info(args, next) { + yield* next(...args); + }, + *warn(args, next) { + if (verbose) { + yield* next(...args); + } + }, + *debug(args, next) { + if (verbose) { + yield* next(...args); + } + }, + *error(args, next) { + yield* next(...args); + }, + }); +} + +export function* namespace(namespace: string) { + yield* loggerApi.around({ + *info(args, next) { + yield* next(`[${namespace}] ${args[0]}`, ...args.slice(1)); + }, + *warn(args, next) { + yield* next(`[${namespace}] ${args[0]}`, ...args.slice(1)); + }, + *debug(args, next) { + yield* next(`[${namespace}] ${args[0]}`, ...args.slice(1)); + }, + *error(args, next) { + yield* next(`[${namespace}] ${args[0]}`, ...args.slice(1)); + }, + }); +} + +export function* indent() { + yield* loggerApi.around({ + *info(args, next) { + yield* next(` ${args[0]}`, ...args.slice(1)); + }, + *warn(args, next) { + yield* next(` ${args[0]}`, ...args.slice(1)); + }, + *debug(args, next) { + yield* next(` ${args[0]}`, ...args.slice(1)); + }, + *error(args, next) { + yield* next(` ${args[0]}`, ...args.slice(1)); + }, + }); +} diff --git a/www/context/process.ts b/www/context/process.ts new file mode 100644 index 000000000..50cfb5064 --- /dev/null +++ b/www/context/process.ts @@ -0,0 +1,160 @@ +import type { Operation, Stream } from "effection"; +import { each, spawn, until, withResolvers } from "effection"; +import md5 from "md5"; +import { regex } from "arktype"; +import { createApi } from "./context-api.ts"; +import { exec, ExecOptions, ProcessResult } from "@effectionx/process"; +import { log } from "./logging.ts"; +import { cwd, useCwd } from "./shell.ts"; +import { fileURLToPath } from "node:url"; + +export interface ProcessApi { + useProcess(command: string, options?: ExecOptions): Operation; +} + +export const processApi = createApi("process", { + *useProcess(command: string, options): Operation { + let cwd = yield* useCwd(); + return yield* exec(command, { + cwd, + ...options, + }).expect(); + }, +}); + +export let { useProcess } = processApi.operations; + +export function* drain(source: Stream): Operation { + let complete = withResolvers(); + yield* spawn(function* () { + let chunks = ""; + for (let chunk of yield* each(source)) { + chunks += chunk; + yield* each.next(); + } + complete.resolve(chunks); + }); + + return yield* complete.operation; +} + +export function urlFromCommand(command: string): URL { + return new URL(`https://cache.local/${md5(command)}`); +} + +export function* ProcessOutputCache(patterns: RegExp[]): Operation { + let cache = yield* until(caches.open("command-cache")); + + yield* processApi.around({ + *useProcess([command], next) { + // Check if command matches any of the patterns + let shouldCache = patterns.some((pattern) => pattern.test(command)); + + if (!shouldCache) { + return yield* next(command); + } + + let url = urlFromCommand(command); + + // Check if we have cached result + let cachedResponse = yield* until(cache.match(url)); + if (cachedResponse) { + // Return cached process with cached output + return yield* createCachedProcess(cachedResponse); + } + + // Execute the process normally + let process = yield* next(command); + + yield* until(cache.put(url, new Response(process.stdout))); + + // Fallback to original process if caching failed + return process; + }, + }); +} + +function* createCachedProcess( + cachedResponse: Response, +): Operation { + return { + stdout: yield* until(cachedResponse.text()), + stderr: "", + code: 0, + }; +} + +// Pattern for git show commands with named capture groups +export let gitShowPattern = regex( + "^git show (?[^/]+)/(?[^/]+)/(?[^:]+):(?.+)$", +); + +/** + * Check if the current HEAD is a descendant of a given branch/ref + */ +function* isDescendantOf(ref: string): Operation { + try { + let result = yield* exec( + `git merge-base --is-ancestor ${ref} HEAD`, + ).expect(); + return result.code === 0; + } catch { + return false; + } +} + +/** + * Middleware that intercepts git show commands and reads from filesystem instead + * when the current origin matches and HEAD descends from the target branch + */ +export function* ProcessFileSystemRead( + pattern: typeof gitShowPattern, +): Operation { + yield* processApi.around({ + *useProcess([command], next) { + let match = pattern.exec(command); + + if (!match) { + return yield* next(command); + } + + let { owner, repo, branch, path: filePath } = match.groups; + let repoPath = `${owner}/${repo}`; + let remote = `${owner}/${repo}/${branch}`; + + // Check if origin matches the repository + let originResult = yield* exec("git remote get-url origin").expect(); + let originUrl = originResult.stdout.trim(); + + if (!originUrl.includes(repoPath)) { + yield* log.debug( + `Origin ${originUrl} does not match repository ${repoPath}, executing command normally`, + ); + return yield* next(command); + } + + let isDescendant = yield* isDescendantOf(remote); + + if (!isDescendant) { + yield* log.debug( + `Current HEAD is not a descendant of ${remote}, executing command normally`, + ); + return yield* next(command); + } + + try { + yield* log.debug( + `Reading ${filePath} from filesystem instead of executing: ${command}`, + ); + let basePath = fileURLToPath(new URL("../../", import.meta.url)); + let [process] = yield* cwd(basePath, [next(`cat ${filePath}`)]); + return process; + } catch (error) { + yield* log.debug( + `Failed to read ${filePath}, falling back to command execution: ${error}`, + ); + return yield* next(command); + } + }, + }); +} diff --git a/www/context/request.ts b/www/context/request.ts new file mode 100644 index 000000000..32a657e32 --- /dev/null +++ b/www/context/request.ts @@ -0,0 +1,3 @@ +import { createContext } from "effection"; + +export const CurrentRequest = createContext("Request"); diff --git a/www/context/shell.ts b/www/context/shell.ts new file mode 100644 index 000000000..8611e81e1 --- /dev/null +++ b/www/context/shell.ts @@ -0,0 +1,47 @@ +import { createContext, type Operation, scoped, until } from "effection"; +import { ProcessResult } from "@effectionx/process"; +import { indent, log } from "./logging.ts"; +import { join } from "@std/path"; +import { useProcess } from "./process.ts"; + +const CwdContext = createContext("cwd", Deno.cwd()); + +export function useCwd() { + return CwdContext.expect(); +} + +export function* $(command: string): Operation { + yield* log.debug(`$ ${command}`); + return yield* useProcess(command); +} + +export function* cwd[]>( + directory: string, + ops: T, +): Operation<{ [K in keyof T]: T[K] extends Operation ? R : never }> { + return yield* scoped(function* () { + yield* log.debug(`cwd: ${directory}`); + let result = yield* CwdContext.with(directory, function* () { + yield* indent(); + let results = []; + for (let op of ops) { + results.push(yield* op); + } + // deno-lint-ignore no-explicit-any + return results as any; + }); + return result; + }); +} + +export function* $echo( + data: string | ReadableStream, + filename: string | URL, +): Operation { + let cwd = yield* CwdContext.expect(); + if (typeof filename === "string") { + yield* until(Deno.writeTextFile(join(cwd, filename), data)); + return; + } + yield* until(Deno.writeTextFile(new URL(filename, cwd), data)); +} diff --git a/www/context/url-rewrite.ts b/www/context/url-rewrite.ts new file mode 100644 index 000000000..dbaa782ef --- /dev/null +++ b/www/context/url-rewrite.ts @@ -0,0 +1,18 @@ +import { Operation } from "effection"; +import { createApi } from "./context-api.ts"; + +interface UrlRewrite { + rewrite( + url: URL, + input: RequestInfo | URL, + init?: RequestInit, + ): Operation; +} + +export const urlRewriteApi = createApi("url-rewrite", { + *rewrite(url) { + return url; + }, +}); + +export const { rewrite } = urlRewriteApi.operations; diff --git a/www/deno.json b/www/deno.json new file mode 100644 index 000000000..cf9792517 --- /dev/null +++ b/www/deno.json @@ -0,0 +1,99 @@ +{ + "tasks": { + "dev": "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", + "pagefind": "npx pagefind --site built", + "test": "deno test --allow-run --allow-write --allow-read" + }, + "lint": { + "exclude": [ + "docs/esm", + "build", + "built", + "pagefind", + "routes/guides-route.tsx", + "components/api/api-page.tsx" + ], + "rules": { + "exclude": [ + "prefer-const", + "require-yield", + "jsx-curly-braces", + "jsx-key", + "jsx-no-useless-fragment" + ] + } + }, + "fmt": { + "exclude": [ + "assets/images", + "docs/esm", + "build", + "built", + "pagefind", + "tailwind" + ] + }, + "compilerOptions": { + "lib": ["deno.ns", "dom.iterable", "dom"], + "jsx": "react-jsx", + "jsxImportSource": "revolution" + }, + "imports": { + "@effectionx/watch": "jsr:@effectionx/watch@^0.3.1", + "@frontside/staticalize": "jsr:@frontside/staticalize@0.2.0", + "@jsdevtools/rehype-toc": "npm:@jsdevtools/rehype-toc@3.0.2", + "@libs/xml": "jsr:@libs/xml@^7.0.3", + "@std/assert": "jsr:@std/assert@^1.0.16", + "@std/crypto": "jsr:@std/crypto@^1.0.5", + "@std/encoding": "jsr:@std/encoding@^1.0.10", + "@std/http": "jsr:@std/http@^1.0.22", + "@types/hast": "npm:@types/hast@3.0.4", + "effection": "npm:effection@^3.6.1", + "@effectionx/deno-deploy": "jsr:@effectionx/deno-deploy@^0.3.1", + "@effectionx/process": "jsr:@effectionx/process@^0.6.2", + "hast": "npm:hast@^1.0.0", + "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", + "mdast": "npm:mdast@^3.0.0", + "mdx": "npm:mdx@^0.3.1", + "octokit": "npm:octokit@4.0.3", + "pagefind": "npm:pagefind@1.3.0", + "path-to-regexp": "npm:path-to-regexp@8.2.0", + "rehype-add-classes": "npm:rehype-add-classes@1.0.0", + "rehype-autolink-headings": "npm:rehype-autolink-headings@7.1.0", + "rehype-infer-description-meta": "npm:rehype-infer-description-meta@2.0.0", + "rehype-infer-title-meta": "npm:rehype-infer-title-meta@2.0.0", + "rehype-prism-plus": "npm:rehype-prism-plus@2.0.0", + "rehype-slug": "npm:rehype-slug@6.0.0", + "rehype-stringify": "npm:rehype-stringify@10.0.1", + "remark": "npm:remark@^15.0.1", + "remark-frontmatter": "npm:remark-frontmatter@5.0.0", + "remark-gfm": "npm:remark-gfm@4.0.0", + "remark-mdx-frontmatter": "npm:remark-mdx-frontmatter@5.0.0", + "remark-parse": "npm:remark-parse@11.0.0", + "remark-rehype": "npm:remark-rehype@11.1.1", + "unified": "npm:unified@11.0.4", + "revolution": "https://deno.land/x/revolution@0.6.1/mod.ts", + "revolution/jsx-runtime": "https://deno.land/x/revolution@0.6.1/jsx-runtime.ts", + "@tailwindcss/typography": "npm:@tailwindcss/typography@^0.5.16", + "tailwindcss": "npm:tailwindcss@^4.1.0", + "semver": "npm:semver@7.6.3", + "@deno/doc": "jsr:@deno/doc@0.188.0", + "@deno/graph": "jsr:@deno/graph@0.89.0", + "git-url-parse": "npm:git-url-parse@16.1.0", + "@std/fs": "jsr:@std/fs@1", + "@std/path": "jsr:@std/path@1", + "@std/yaml": "jsr:@std/yaml@1", + "@std/testing": "jsr:@std/testing@1", + "md5": "npm:md5@^2.3.0", + "expect": "jsr:@std/expect@^1", + "arktype": "npm:arktype@^2", + "_posixNormalize": "https://deno.land/std@0.203.0/path/_normalize.ts", + "hast-util-select": "npm:hast-util-select@6.0.1", + "unist-util-visit": "npm:unist-util-visit@5.0.0", + "vfile": "npm:vfile@6.0.3", + "zod": "npm:zod@3.23.8" + } +} diff --git a/www/e4.ts b/www/e4.ts new file mode 100644 index 000000000..eee069af8 --- /dev/null +++ b/www/e4.ts @@ -0,0 +1,111 @@ +import { + call, + type Operation, + race, + resource, + run, + sleep, + // deno-lint-ignore no-import-prefix +} from "npm:effection@4.0.0-alpha.4"; + +import { + close, + createIndex, + PagefindIndex, + PagefindServiceConfig, + SiteDirectory, + WriteOptions, +} from "pagefind"; +import { staticalize } from "@frontside/staticalize"; +import * as fs from "@std/fs"; + +function exists(path: string | URL, options?: fs.ExistsOptions) { + return call(() => fs.exists(path, options)); +} + +type GenerateOptions = { + host: URL; + publicDir: string; + pagefindDir: string; +} & PagefindServiceConfig; + +const log = (first: unknown, ...args: unknown[]) => + console.log(`💪: ${first}`, ...args); + +export function generate( + { host, publicDir, pagefindDir, ...indexOptions }: GenerateOptions, +) { + return async function () { + return await run(function* () { + let built = new URL(publicDir, import.meta.url); + + if (yield* exists(built, { isDirectory: true })) { + log(`Reusing existing staticalized ${built.pathname} directory`); + } else { + log(`Staticalizing: ${host} to ${built.pathname}`); + + yield* race([ + staticalize({ + host, + base: host, + dir: built.pathname, + }), + sleep(60000), + ]); + } + + log("Adding index"); + + let index = yield* createPagefindIndex(indexOptions); + + log(`Adding directory: ${built.pathname}`); + + let added = yield* index.addDirectory({ path: built.pathname }); + + log(`Addedd ${added} pages from ${built.pathname}`); + + log(`Writing files ${pagefindDir}`); + return yield* index.writeFiles({ outputPath: pagefindDir }); + }); + }; +} + +export class EPagefindIndex { + constructor(private readonly index: PagefindIndex) {} + + *addDirectory(path: SiteDirectory): Operation { + let response = yield* call(() => this.index.addDirectory(path)); + if (response.errors.length > 0) { + console.error( + `Encountered errors while adding ${path.path}: ${response.errors.join()}`, + ); + } + return response.page_count; + } + + *writeFiles(options?: WriteOptions): Operation { + let response = yield* call(() => this.index.writeFiles(options)); + if (response.errors.length > 0) { + console.error( + `Encountered errors while writing to ${options?.outputPath}: ${response.errors.join()}`, + ); + } + return response.outputPath; + } +} + +export function createPagefindIndex(config?: PagefindServiceConfig) { + return resource(function* (provide) { + let { errors, index } = yield* call(() => createIndex(config)); + + if (!index) { + throw new Error(`Failed to create an index: ${errors.join()}`); + } + + try { + yield* provide(new EPagefindIndex(index)); + } finally { + yield* call(() => close()); + } + }); +} diff --git a/www/hooks/use-deno-doc.tsx b/www/hooks/use-deno-doc.tsx new file mode 100644 index 000000000..68752ee07 --- /dev/null +++ b/www/hooks/use-deno-doc.tsx @@ -0,0 +1,324 @@ +import { + CacheSetting, + doc, + type DocNode, + type DocOptions, + LoadResponse, + Location, +} from "@deno/doc"; +import { call, type Operation, until, useScope } from "effection"; +import { createGraph } from "@deno/graph"; +import { regex } from "arktype"; + +import { exportHash, extract } from "../components/type/markdown.tsx"; +import { operations } from "../context/fetch.ts"; +import { DenoJsonSchema } from "../lib/package/deno.ts"; +import { useDescription } from "./use-description-parse.tsx"; + +// Matches npm/jsr specifiers like @std/testing/bdd or lodash/fp +export const npmSpecifierPattern = regex( + "^(?:(?@[^/]+)/)?(?[^/]+)(?/.*)?$", +); + +export type { DocNode }; + +export function* useDenoDoc( + specifiers: string[], + docOptions?: DocOptions, +): Operation> { + let docs = yield* until(doc(specifiers, docOptions)); + return docs; +} + +export interface Dependency { + source: string; + name: string; + version: string; +} + +export interface DocPage { + name: string; + sections: DocPageSection[]; + description: string; + kind: DocNode["kind"]; + dependencies: Dependency[]; +} + +export interface DocPageSection { + id: string; + + node: DocNode; + + markdown?: string; + + ignore: boolean; +} + +export type DocsPages = Record; + +export function* useDocPages( + specifier: string, + imports?: Record, +): Operation { + let scope = yield* useScope(); + + let loader = (specifier: string) => scope.run(docLoader(specifier)); + + // If imports not provided, try to extract from deno.json + let resolvedImports = imports ?? (yield* extractImports( + new URL("./deno.json", specifier).toString(), + loader, + )); + + let resolve = resolvedImports + ? (specifier: string, referrer: string) => { + let resolved: string = specifier; + if (specifier in resolvedImports) { + resolved = resolvedImports[specifier]; + } else if (specifier.startsWith(".")) { + resolved = new URL(specifier, referrer).toString(); + } else if (specifier.startsWith("node:")) { + resolved = `npm:@types/node@^22.13.5`; + } else { + let match = npmSpecifierPattern.exec(specifier); + if (match) { + let { scope, package: pkg, subpath } = match.groups; + let baseKey = scope ? `${scope}/${pkg}` : pkg; + if (baseKey in resolvedImports) { + let baseUrl = resolvedImports[baseKey]; + resolved = subpath ? `${baseUrl}${subpath}` : baseUrl; + } + } + } + return resolved; + } + : undefined; + + let graph = yield* call(() => + createGraph([specifier], { + load: loader, + resolve, + }) + ); + + let externalDependencies: Dependency[] = graph.modules.flatMap((module) => { + if (module.kind === "external") { + let parts = module.specifier.match(/(.*):(.*)@(.*)/); + if (parts) { + let [, source, name, version] = parts; + return [ + { + source, + name, + version, + }, + ]; + } + } + return []; + }); + + let docs = yield* useDenoDoc([specifier], { + load: loader, + resolve, + }); + + let entrypoints: Record = {}; + + for (let [url, all] of Object.entries(docs)) { + let pages: DocPage[] = []; + for ( + let [symbol, nodes] of Object.entries( + Object.groupBy(all, (node) => node.name), + ) + ) { + if (nodes) { + let sections: DocPageSection[] = []; + for (let node of nodes) { + let { markdown, ignore, pages: _pages } = yield* extract(node); + sections.push({ + id: exportHash(node, sections.length), + node, + markdown, + ignore, + }); + pages.push( + ..._pages.map((page) => ({ + ...page, + dependencies: externalDependencies, + })), + ); + } + + let markdown = sections + .map((s) => s.markdown) + .filter((m) => m) + .join(""); + + let description = yield* useDescription(markdown); + + pages.push({ + name: symbol, + kind: nodes?.at(0)?.kind!, + description, + sections, + dependencies: externalDependencies, + }); + } + } + + entrypoints[url] = pages; + } + + return entrypoints; +} + +function docLoader( + specifier: string, + _isDynamic?: boolean, + _cacheSetting?: CacheSetting, + _checksum?: string, +): () => Operation { + return function* downloadDocModules() { + let url = URL.parse(specifier); + + if (url?.protocol.startsWith("file")) { + let content = yield* until(Deno.readTextFile(url.pathname)); + return { + kind: "module", + specifier, + content, + }; + } + + if (url?.host && ["github.com", "jsr.io"].includes(url.host)) { + let response = yield* operations.fetch(specifier); + let content = yield* until(response.text()); + if (response.ok) { + return { + kind: "module", + specifier, + content, + }; + } else { + throw new Error(`Could not parse ${specifier} as Github URL`, { + cause: response, + }); + } + } else { + console.log(`Ignoring ${url} while reading docs`); + } + }; +} + +export function isDocsPages(value: unknown): value is DocsPages { + if (typeof value !== "object" || value === null) { + return false; + } + + // Check if each key is a string and value is an array of DocPage objects + for (let key in value) { + if (typeof key !== "string") { + return false; + } + + let pages = (value as Record)[key]; + + if (!Array.isArray(pages)) { + return false; + } + + // Check if each item in the array is a valid DocPage + for (let page of pages) { + if (!isDocPage(page)) { + return false; + } + } + } + + return true; +} + +function isDocPage(value: unknown): value is DocPage { + if (typeof value !== "object" || value === null) { + return false; + } + + let page = value as DocPage; + + return ( + typeof page.name === "string" && + Array.isArray(page.sections) && + page.sections.every(isDocPageSection) && + typeof page.description === "string" && + typeof page.kind === "string" && + Array.isArray(page.dependencies) && + page.dependencies.every(isDependency) + ); +} + +function isDocPageSection(value: unknown): value is DocPageSection { + if (typeof value !== "object" || value === null) { + return false; + } + + let section = value as DocPageSection; + + return ( + typeof section.id === "string" && + typeof section.node === "object" && + section.node !== null && // You might need a guard for DocNode if it's complex + (typeof section.markdown === "undefined" || + typeof section.markdown === "string") && + typeof section.ignore === "boolean" + ); +} + +function isDependency(value: unknown): value is Dependency { + if (typeof value !== "object" || value === null) { + return false; + } + + let dependency = value as Dependency; + + return ( + typeof dependency.source === "string" && + typeof dependency.name === "string" && + typeof dependency.version === "string" + ); +} + +function* extractImports( + url: string, + loader: (specifier: string) => Operation, +) { + let module = yield* loader(url); + if (!module) return; + let content = module.kind === "module" + ? JSON.parse(`${module.content}`) + : undefined; + let { imports } = DenoJsonSchema.parse(content); + + return imports; +} + +/** + * LocalDocsPages are DocNodes that are stored locally + * but they represent symbols hosted on GitHub. They + * have LocalDocNode locations that include URLs to GitHub. + */ +export type LocalDocsPages = Record; + +export type LocalDocPage = DocPage & { sections: LocalDocPageSection[] }; + +export type LocalDocPageSection = DocPageSection & { + node: LocalDocNode; +}; + +export type LocalDocNode = DocNode & { + location: LocalLocation; +}; + +export type LocalLocation = Location & { + url: URL; +}; diff --git a/www/hooks/use-description-parse.tsx b/www/hooks/use-description-parse.tsx new file mode 100644 index 000000000..8900d2529 --- /dev/null +++ b/www/hooks/use-description-parse.tsx @@ -0,0 +1,35 @@ +import { call, type Operation } from "effection"; +import { unified } from "unified"; +import type { VFile } from "vfile"; +import rehypeInferDescriptionMeta from "rehype-infer-description-meta"; +import rehypeInferTitleMeta from "rehype-infer-title-meta"; +import rehypeStringify from "rehype-stringify"; +import remarkParse from "remark-parse"; +import remarkRehype from "remark-rehype"; +import { trimAfterHR } from "../lib/trim-after-hr.ts"; + +export function* useDescription(markdown: string): Operation { + let file = yield* useMarkdownFile(markdown); + return file.data?.meta?.description ?? ""; +} + +export function* useTitle(markdown: string): Operation { + let file = yield* useMarkdownFile(markdown); + return file.data?.meta?.title ?? ""; +} + +export function* useMarkdownFile(markdown: string): Operation { + return yield* call(() => + unified() + .use(remarkParse) + .use(remarkRehype) + .use(rehypeStringify) + .use(trimAfterHR) + .use(rehypeInferTitleMeta) + .use(rehypeInferDescriptionMeta, { + inferDescriptionHast: true, + truncateSize: 200, + }) + .process(markdown) + ); +} diff --git a/www/hooks/use-markdown.test.ts b/www/hooks/use-markdown.test.ts new file mode 100644 index 000000000..456234cdc --- /dev/null +++ b/www/hooks/use-markdown.test.ts @@ -0,0 +1,28 @@ +import { assertEquals } from "@std/assert"; +import { run } from "effection"; +import { createJsDocSanitizer } from "./use-markdown.tsx"; + +const sanitizer = createJsDocSanitizer(); + +function sanitizedEquals(a: string, b: string) { + Deno.test(`${a} => ${b}`, async function () { + let result = await run(function* () { + return yield* sanitizer(a); + }); + assertEquals(result, b); + }); +} + +sanitizedEquals("{@link Context}", "[Context](Context)"); +sanitizedEquals("@{link Scope}", "[Scope](Scope)"); +sanitizedEquals("{@link spawn()}", "[spawn](spawn)"); +sanitizedEquals("{@link Scope.run}", "[Scope.run](Scope.run)"); +sanitizedEquals("{@link Scope#run}", "[Scope#run](Scope#run)"); +sanitizedEquals( + "{@link * establish error boundaries https://frontside.com/effection/docs/errors | error boundaries}", + "", +); +sanitizedEquals( + "{@link Operation}<{@link T}>", + "[Operation](Operation)<[T](T)>", +); diff --git a/www/hooks/use-markdown.tsx b/www/hooks/use-markdown.tsx new file mode 100644 index 000000000..f035efd13 --- /dev/null +++ b/www/hooks/use-markdown.tsx @@ -0,0 +1,118 @@ +import { call, type Operation } from "effection"; +import rehypeAddClasses from "rehype-add-classes"; +import rehypePrismPlus from "rehype-prism-plus"; +import rehypeSlug from "rehype-slug"; +import remarkGfm from "remark-gfm"; +import rehypeAutolinkHeadings from "rehype-autolink-headings"; +import { JSXElement } from "revolution/jsx-runtime"; +import { removeDescriptionHR } from "../lib/remove-description-hr.ts"; +import { replaceAll } from "../lib/replace-all.ts"; +import { useMDX, UseMDXOptions } from "./use-mdx.tsx"; + +export function* defaultLinkResolver( + symbol: string, + connector?: string, + method?: string, +) { + let parts = [symbol]; + if (symbol && connector && method) { + parts.push(connector, method); + } + let name = parts.filter(Boolean).join(""); + if (name) { + return `[${name}](${name})`; + } + return ""; +} + +export type ResolveLinkFunction = ( + symbol: string, + connector?: string, + method?: string, +) => Operation; + +export type UseMarkdownOptions = UseMDXOptions & { + linkResolver?: ResolveLinkFunction; + slugPrefix?: string; +}; + +export function* useMarkdown( + markdown: string, + options?: UseMarkdownOptions, +): Operation { + /** + * I'm doing this pre-processing here because MDX throws a parse error when it encounteres `{@link }`. + * I can't use a remark/rehype plugin to change this because they are applied after MDX parses is successful. + */ + let sanitize = createJsDocSanitizer( + options?.linkResolver ?? defaultLinkResolver, + ); + let sanitized = yield* sanitize(markdown); + + let mod = yield* useMDX(sanitized, { + remarkPlugins: [remarkGfm, ...(options?.remarkPlugins ?? [])], + rehypePlugins: [ + [removeDescriptionHR], + [ + rehypePrismPlus, + { + showLineNumbers: true, + }, + ], + [ + rehypeSlug, + { + prefix: options?.slugPrefix ? `${options.slugPrefix}-` : undefined, + }, + ], + [ + rehypeAutolinkHeadings, + { + behavior: "append", + properties: { + className: + "opacity-0 group-hover:opacity-100 after:content-['#'] after:ml-1.5 no-underline", + }, + }, + ], + [ + rehypeAddClasses, + { + "h1[id],h2[id],h3[id],h4[id],h5[id],h6[id]": + "group scroll-mt-[100px] grow", + pre: "grid", + }, + ], + ...(options?.rehypePlugins ?? []), + ], + remarkRehypeOptions: options?.remarkRehypeOptions, + }); + + return yield* call(async () => { + try { + let result = await mod.default(); + return result; + } catch (e) { + console.error( + `Failed to convert markdown to JSXElement for ${markdown}`, + e, + ); + return <>; + } + }); +} + +export function createJsDocSanitizer( + resolver: ResolveLinkFunction = defaultLinkResolver, +) { + return function* sanitizeJsDoc(doc: string) { + return yield* replaceAll( + doc, + /@?{@?link\s*(\w*)([^\w}])?(\w*)?([^}]*)?}/gm, + function* (match) { + let [, symbol, connector, method] = match; + return yield* resolver(symbol, connector, method); + }, + ); + }; +} diff --git a/www/hooks/use-mdx.tsx b/www/hooks/use-mdx.tsx new file mode 100644 index 000000000..1fe75f5b1 --- /dev/null +++ b/www/hooks/use-mdx.tsx @@ -0,0 +1,45 @@ +import { call, type Operation } from "effection"; +// deno-lint-ignore no-import-prefix +import { evaluate } from "npm:@mdx-js/mdx@3.1.0"; +// deno-lint-ignore no-import-prefix +import type { MDXModule } from "npm:@types/mdx@2.0.13"; +import { Fragment, jsx, jsxs } from "revolution/jsx-runtime"; +import { PluggableList } from "unified"; +// deno-lint-ignore no-import-prefix +import type { Options as RemarkRehypeOptions } from "npm:remark-rehype@11.1.1"; + +export interface UseMDXOptions { + remarkPlugins?: PluggableList | null | undefined; + /** + * List of rehype plugins (optional). + */ + rehypePlugins?: PluggableList | null | undefined; + /** + * Options to pass through to `remark-rehype` (optional); + * the option `allowDangerousHtml` will always be set to `true` and the MDX + * nodes (see `nodeTypes`) are passed through; + * In particular, you might want to pass configuration for footnotes if your + * content is not in English. + */ + remarkRehypeOptions?: Readonly | null | undefined; +} + +export function* useMDX( + markdown: string, + options?: UseMDXOptions, +): Operation { + return yield* call(async function () { + try { + return await evaluate(markdown, { + jsx, + jsxs, + jsxDEV: jsx, + Fragment, + ...options, + }); + } catch (e) { + console.log(`Failed to convert markdown to MDX: ${markdown}`); + throw e; + } + }); +} diff --git a/www/lib/author-config.ts b/www/lib/author-config.ts new file mode 100644 index 000000000..f2aa6d8d2 --- /dev/null +++ b/www/lib/author-config.ts @@ -0,0 +1,9 @@ +/** + * List of author first names (lowercase) that have avatar images. + * Avatar images should be stored at /assets/images/authors/{name}.webp + */ +export const authorsWithImage = [ + "charles", + "jacob", + "taras", +]; diff --git a/www/lib/clones.ts b/www/lib/clones.ts new file mode 100644 index 000000000..2b5ef4ca4 --- /dev/null +++ b/www/lib/clones.ts @@ -0,0 +1,21 @@ +import { createContext, type Operation } from "effection"; +import { $ } from "../context/shell.ts"; +import { existsSync } from "node:fs"; +import { resolve } from "node:path"; + +const Clones = createContext("clones"); + +export function* initClones(path: string): Operation { + yield* $(`rm -rf ${path}`); + yield* $(`mkdir -p ${path}`); + yield* Clones.set(path); +} + +export function* useClone(nameWithOwner: string): Operation { + let basepath = yield* Clones.expect(); + let dirpath = resolve(`${basepath}/${nameWithOwner}`); + if (!existsSync(dirpath)) { + yield* $(`git clone https://github.com/${nameWithOwner} ${dirpath}`); + } + return dirpath; +} diff --git a/www/lib/command-parser.test.ts b/www/lib/command-parser.test.ts new file mode 100644 index 000000000..74c09c3e3 --- /dev/null +++ b/www/lib/command-parser.test.ts @@ -0,0 +1,158 @@ +import { describe, it } from "../testing.ts"; +import { expect } from "expect"; +import { parseCommand, splitCommand } from "./command-parser.ts"; + +describe("parseCommand", () => { + // Test cases from minimist-string README + it("solves the main minimist-string problem", function* () { + let result = parseCommand('foo --bar "Hello world!"'); + expect(result).toEqual({ + _: ["foo"], + bar: "Hello world!", + }); + }); + + it("handles escaped quotes correctly", function* () { + let result = parseCommand('foo --bar "Hello \\"world\\"!"'); + expect(result).toEqual({ + _: ["foo"], + bar: 'Hello "world"!', + }); + }); + + it("handles simple quoted string", function* () { + let result = parseCommand('foo --bar "Hello!"'); + expect(result).toEqual({ + _: ["foo"], + bar: "Hello!", + }); + }); + + // Additional comprehensive tests + it("handles simple command without quotes", function* () { + let result = parseCommand("foo --bar hello"); + expect(result).toEqual({ + _: ["foo"], + bar: "hello", + }); + }); + + it("handles multiple arguments", function* () { + let result = parseCommand("command arg1 arg2 --flag --option value"); + expect(result).toEqual({ + _: ["command", "arg1", "arg2"], + flag: true, + option: "value", + }); + }); + + it("handles equals syntax for options", function* () { + let result = parseCommand("command --option=value --flag"); + expect(result).toEqual({ + _: ["command"], + option: "value", + flag: true, + }); + }); + + it("handles short flags", function* () { + let result = parseCommand("command -f -abc value"); + expect(result).toEqual({ + _: ["command"], + f: true, + a: true, + b: true, + c: "value", + }); + }); + + it("handles mixed quotes", function* () { + let result = parseCommand( + `command --single 'Hello world' --double "Hello world"`, + ); + expect(result).toEqual({ + _: ["command"], + single: "Hello world", + double: "Hello world", + }); + }); + + it("handles complex git command", function* () { + let result = parseCommand('git commit -m "Initial commit with spaces"'); + expect(result).toEqual({ + _: ["git", "commit"], + m: "Initial commit with spaces", + }); + }); + + it("handles empty quotes", function* () { + let result = parseCommand('command --empty ""'); + expect(result).toEqual({ + _: ["command"], + empty: "", + }); + }); + + it("handles boolean flags", function* () { + let result = parseCommand("command --verbose --quiet"); + expect(result).toEqual({ + _: ["command"], + verbose: true, + quiet: true, + }); + }); + + it("handles hyphenated options", function* () { + let result = parseCommand("command --dry-run --output-dir /tmp"); + expect(result).toEqual({ + _: ["command"], + "dry-run": true, + "output-dir": "/tmp", + }); + }); +}); + +describe("splitCommand", () => { + it("splits simple command without quotes", function* () { + let result = splitCommand("git status --porcelain"); + expect(result).toEqual(["git", "status", "--porcelain"]); + }); + + it("preserves quoted strings with spaces", function* () { + let result = splitCommand('git commit -m "Initial commit with spaces"'); + expect(result).toEqual([ + "git", + "commit", + "-m", + "Initial commit with spaces", + ]); + }); + + it("handles escaped quotes", function* () { + let result = splitCommand('echo "Hello \\"world\\""'); + expect(result).toEqual(["echo", 'Hello "world"']); + }); + + it("handles mixed quotes", function* () { + let result = splitCommand( + `command --single 'Hello world' --double "Hello world"`, + ); + expect(result).toEqual([ + "command", + "--single", + "Hello world", + "--double", + "Hello world", + ]); + }); + + it("handles empty quotes", function* () { + let result = splitCommand('command --empty ""'); + expect(result).toEqual(["command", "--empty", ""]); + }); + + it("handles multiple spaces", function* () { + let result = splitCommand(" command arg1 arg2 "); + expect(result).toEqual(["command", "arg1", "arg2"]); + }); +}); diff --git a/www/lib/command-parser.ts b/www/lib/command-parser.ts new file mode 100644 index 000000000..21ceb3398 --- /dev/null +++ b/www/lib/command-parser.ts @@ -0,0 +1,211 @@ +/** + * Parses a command string into arguments and options + * Modern JavaScript version of minimist-string without external dependencies + */ +export interface ParsedCommand { + _: string[]; + [key: string]: string | true | string[]; +} + +/** + * Splits a command string into an array of arguments, preserving quoted strings + * @param input The command string to split + * @returns Array of command arguments + */ +export function splitCommand(input: string): string[] { + if (!input.includes('"') && !input.includes("'")) { + return input.trim().split(/\s+/); + } + + let wrongPieces = input.split(" "); + let goodPieces = solveQuotes(wrongPieces, '"'); + goodPieces = solveQuotes(goodPieces, "'"); + + // Remove outer quotes but preserve escaped quotes + let regexQuotes = /["']/g; + for (let i = 0; i < goodPieces.length; i++) { + goodPieces[i] = goodPieces[i].replace(/(\\\')/g, "%%%SINGLEQUOTE%%%"); + goodPieces[i] = goodPieces[i].replace(/(\\\")/g, "%%%DOUBLEQUOTE%%%"); + goodPieces[i] = goodPieces[i].replace(regexQuotes, ""); + goodPieces[i] = goodPieces[i].replace(/(%%%SINGLEQUOTE%%%)/g, "'"); + goodPieces[i] = goodPieces[i].replace(/(%%%DOUBLEQUOTE%%%)/g, '"'); + } + + return goodPieces; +} + +/** + * Parses a command string into arguments and options + * @param input The command string to parse + * @returns Parsed command with arguments and options + */ +export function parseCommand(input: string): ParsedCommand { + if (!input.includes('"') && !input.includes("'")) { + // Simple case - no quotes, just split by spaces + return parseSimple(input); + } + + // Complex case - handle quotes + return parseWithQuotes(input); +} + +function parseSimple(input: string): ParsedCommand { + let pieces = input.trim().split(/\s+/); + return parseTokens(pieces); +} + +function parseWithQuotes(input: string): ParsedCommand { + let wrongPieces = input.split(" "); + + let goodPieces = solveQuotes(wrongPieces, '"'); + goodPieces = solveQuotes(goodPieces, "'"); + + // Remove outer quotes but preserve escaped quotes + let regexQuotes = /["']/g; + for (let i = 0; i < goodPieces.length; i++) { + goodPieces[i] = goodPieces[i].replace(/(\\\')/g, "%%%SINGLEQUOTE%%%"); + goodPieces[i] = goodPieces[i].replace(/(\\\")/g, "%%%DOUBLEQUOTE%%%"); + goodPieces[i] = goodPieces[i].replace(regexQuotes, ""); + goodPieces[i] = goodPieces[i].replace(/(%%%SINGLEQUOTE%%%)/g, "'"); + goodPieces[i] = goodPieces[i].replace(/(%%%DOUBLEQUOTE%%%)/g, '"'); + } + + return parseTokens(goodPieces); +} + +function countQuotes(piece: string, quoteChar: string): number { + let regex = new RegExp(`[^${quoteChar}\\\\]`, "g"); + let replaced = piece.replace(regex, ""); + return replaced + .replace(new RegExp(`(\\\\${quoteChar})`, "g"), "") + .replace(/\\/g, "").length; +} + +function hasQuote(piece: string, quoteChar: string): boolean { + return countQuotes(piece, quoteChar) > 0; +} + +function getFirstQuote(piece: string, quoteChar: string, position = 0): number { + let i = position - 1; + do { + i = piece.indexOf(quoteChar, i + 1); + } while (piece.charAt(i - 1) === "\\"); + return i; +} + +function splitPiece(piece: string, quoteChar: string): [string, string] { + let firstQIndex = getFirstQuote(piece, quoteChar); + let secondQIndex = getFirstQuote(piece, quoteChar, firstQIndex + 1); + + let firstPart = piece.substring(0, secondQIndex + 1); + let secondPart = piece.substring(secondQIndex + 1); + + return [firstPart, secondPart]; +} + +function solveQuotes(pieces: string[], quoteChar: string): string[] { + let unclosedQuote = false; + let result: string[] = []; + + for (let i = 0; i < pieces.length; i++) { + if (unclosedQuote) { + if (hasQuote(pieces[i], quoteChar)) { + let qIndex = getFirstQuote(pieces[i], quoteChar); + if (qIndex !== pieces[i].length - 1) { + // Closing quote is not the last character + pieces[i + 1] = pieces[i].substring(qIndex + 1) + + (pieces[i + 1] !== undefined ? pieces[i + 1] : ""); + pieces[i] = pieces[i].substring(0, qIndex + 1); + } + + result[result.length - 1] = result[result.length - 1] + " " + pieces[i]; + unclosedQuote = false; + } else { + result[result.length - 1] = result[result.length - 1] + " " + pieces[i]; + } + } else { + if (hasQuote(pieces[i], quoteChar)) { + let quoteCount = countQuotes(pieces[i], quoteChar); + + if (quoteCount === 1) { + result.push(pieces[i]); + unclosedQuote = true; + } else if (quoteCount === 2) { + let split = splitPiece(pieces[i], quoteChar); + result.push(split[0]); + if (split[1] !== "") result.push(split[1]); + } else { + let next = pieces[i]; + do { + let split = splitPiece(next, quoteChar); + result.push(split[0]); + next = split[1]; + } while (countQuotes(next, quoteChar) > 2); + + if (countQuotes(next, quoteChar) === 1) { + result.push(next); + unclosedQuote = true; + } else if (countQuotes(next, quoteChar) === 2) { + result.push(next); + } else { + throw new Error( + "Unexpected behavior in command parsing. Please report this bug.", + ); + } + } + } else { + result.push(pieces[i]); + } + } + } + return result; +} + +function parseTokens(tokens: string[]): ParsedCommand { + let result: ParsedCommand = { _: [] }; + + for (let i = 0; i < tokens.length; i++) { + let token = tokens[i]; + + if (token.startsWith("--")) { + // Long option + let equalIndex = token.indexOf("="); + if (equalIndex !== -1) { + let key = token.substring(2, equalIndex); + let value = token.substring(equalIndex + 1); + result[key] = value; + } else { + let key = token.substring(2); + // Check if next token is a value + if (i + 1 < tokens.length && !tokens[i + 1].startsWith("-")) { + result[key] = tokens[++i]; + } else { + result[key] = true; + } + } + } else if (token.startsWith("-") && token.length > 1) { + // Short option(s) + let flags = token.substring(1); + + for (let j = 0; j < flags.length; j++) { + let flag = flags[j]; + + if (j === flags.length - 1) { + // Last flag - might have a value + if (i + 1 < tokens.length && !tokens[i + 1].startsWith("-")) { + result[flag] = tokens[++i]; + } else { + result[flag] = true; + } + } else { + result[flag] = true; + } + } + } else { + // Regular argument + result._.push(token); + } + } + + return result; +} diff --git a/www/lib/deno-json.ts b/www/lib/deno-json.ts new file mode 100644 index 000000000..81ab70133 --- /dev/null +++ b/www/lib/deno-json.ts @@ -0,0 +1,21 @@ +import z from "zod"; +import { Operation, until } from "effection"; + +export const DenoJsonSchema = z.object({ + name: z.string().optional(), + version: z.string().optional(), + exports: z.union([z.record(z.string()), z.string()]).optional(), + license: z.string().optional(), + workspace: z.array(z.string()).optional(), + imports: z.record(z.string()).optional(), +}); + +export type DenoJson = z.infer; + +export function* useDenoJson(path: string): Operation { + let { default: json } = yield* until( + import(path, { with: { type: "json" } }), + ); + + return DenoJsonSchema.parse(json); +} diff --git a/www/lib/ensure-trailing-slash.test.ts b/www/lib/ensure-trailing-slash.test.ts new file mode 100644 index 000000000..b6d00f62b --- /dev/null +++ b/www/lib/ensure-trailing-slash.test.ts @@ -0,0 +1,17 @@ +import { assertEquals } from "@std/assert"; +import { ensureTrailingSlash } from "./ensure-trailing-slash.ts"; + +Deno.test("ensureTrailingSlash adds trailing slash to each URL", () => { + assertEquals( + ensureTrailingSlash(new URL("http://example.com/dir")).toString(), + "http://example.com/dir/", + ); + assertEquals( + ensureTrailingSlash(new URL("http://example.com/dir/")).toString(), + "http://example.com/dir/", + ); + assertEquals( + ensureTrailingSlash(new URL("http://example.com/file.json")).toString(), + "http://example.com/file.json", + ); +}); diff --git a/www/lib/ensure-trailing-slash.ts b/www/lib/ensure-trailing-slash.ts new file mode 100644 index 000000000..7702c4d94 --- /dev/null +++ b/www/lib/ensure-trailing-slash.ts @@ -0,0 +1,7 @@ +export function ensureTrailingSlash(url: URL) { + let isFile = url.pathname.split("/").at(-1)?.includes("."); + if (isFile || url.pathname.endsWith("/")) { + return url; + } + return new URL(`${url.toString()}/`); +} diff --git a/www/lib/fs.ts b/www/lib/fs.ts new file mode 100644 index 000000000..f08cab7d9 --- /dev/null +++ b/www/lib/fs.ts @@ -0,0 +1,10 @@ +import { Operation, until } from "effection"; + +import * as fs from "@std/fs"; + +export function exists( + path: string | URL, + options?: fs.ExistsOptions, +): Operation { + return until(fs.exists(path, options)); +} diff --git a/www/lib/get-author-image.ts b/www/lib/get-author-image.ts new file mode 100644 index 000000000..61c159d05 --- /dev/null +++ b/www/lib/get-author-image.ts @@ -0,0 +1,13 @@ +import { authorsWithImage } from "./author-config.ts"; + +/** + * Get the avatar image URL for an author. + * Extracts the first name, lowercases it, and checks if an avatar exists. + * Falls back to the Effection logo if no avatar is found. + */ +export function getAuthorImage(author: string): string { + let firstName = author.split(" ")[0].toLowerCase(); + return authorsWithImage.includes(firstName) + ? `/assets/images/authors/${firstName}.webp` + : "/assets/images/icon-effection.svg"; +} diff --git a/www/lib/links-resolvers.ts b/www/lib/links-resolvers.ts new file mode 100644 index 000000000..bdb74edbf --- /dev/null +++ b/www/lib/links-resolvers.ts @@ -0,0 +1,39 @@ +import { Operation } from "effection"; +import { CurrentRequest } from "../context/request.ts"; +import { dirname, join } from "@std/path"; +import { ResolveLinkFunction } from "../hooks/use-markdown.tsx"; + +export const createSibling: ResolveLinkFunction = function* ( + pathname, + connector, + method, +): Operation { + let request = yield* CurrentRequest.expect(); + let url = new URL(request.url); + url.pathname = join(dirname(url.pathname), pathname); + if (connector && method) { + url.hash = `#${method}`; + } + return url.toString().replace(url.origin, ""); +}; + +export function createChildURL(prefix?: string) { + return function* (pathname: string): Operation { + let request = yield* CurrentRequest.expect(); + let url = new URL(request.url); + url.pathname = join( + "", + ...[url.pathname, prefix, pathname].flatMap((s) => s ? [s] : []), + ); + return url.toString().replace(url.origin, ""); + }; +} + +export function createRootUrl(prefix?: string) { + return function* (pathname: string): Operation { + let request = yield* CurrentRequest.expect(); + let url = new URL(request.url); + url.pathname = join("", ...[prefix, pathname].flatMap((s) => s ? [s] : [])); + return url.toString().replace(url.origin, ""); + }; +} diff --git a/www/lib/octokit.ts b/www/lib/octokit.ts new file mode 100644 index 000000000..257fdb30a --- /dev/null +++ b/www/lib/octokit.ts @@ -0,0 +1,37 @@ +import { createContext, Operation, until, useScope } from "effection"; +import { Octokit } from "octokit"; +import { operations } from "../context/fetch.ts"; + +const OctokitContext = createContext("github-client"); + +export function* initOctokitContext() { + let token = Deno.env.get("GITHUB_TOKEN"); + + let scope = yield* useScope(); + + let octokit = new Octokit({ + auth: token, + request: { + fetch: (url: string, init?: RequestInit) => { + return scope.run(() => operations.fetch(url, init)); + }, + }, + }); + + return yield* OctokitContext.set(octokit); +} + +/** + * Get star count for a repository using Octokit + */ +export function* getStarCount(nameWithOwner: string): Operation { + let github = yield* OctokitContext.expect(); + let [owner, name] = nameWithOwner.split("/"); + let response = yield* until( + github.rest.repos.get({ + repo: name, + owner: owner, + }), + ); + return response.data.stargazers_count; +} diff --git a/www/lib/package.ts b/www/lib/package.ts new file mode 100644 index 000000000..9ccb61d64 --- /dev/null +++ b/www/lib/package.ts @@ -0,0 +1,238 @@ +import { all, Operation, until } from "effection"; +import { relative } from "node:path"; +import { fileURLToPath } from "node:url"; + +import z from "zod"; +import { SiteConfig } from "../context/config.ts"; +import { useJSRClient } from "../context/jsr.ts"; +import { LocalDocsPages, useDocPages } from "../hooks/use-deno-doc.tsx"; +import { useDescription, useTitle } from "../hooks/use-description-parse.tsx"; +import { useMDX } from "../hooks/use-mdx.tsx"; +import { + PackageDetailsResult, + PackageScoreResult, +} from "../resources/jsr-client.ts"; +import { DenoJson, useDenoJson } from "./deno-json.ts"; +import { createRepo, Ref } from "./repo.ts"; +import { extractVersion, findLatestSemverTag } from "./semver.ts"; +import { useWorktree } from "./worktrees.ts"; + +export type WorkTreePackageOptions = { + type: "worktree"; + series: SiteConfig["series"][number]; +}; + +export type ClonePackageOptions = { + type: "clone"; + name?: string; + path: string; + workspacePath: string; + ref: Ref; +}; + +export type PackageOptions = WorkTreePackageOptions | ClonePackageOptions; + +export interface Package { + name: string; + scopeName: string; + version: string; + workspacePath: string; + ref: Ref; + exports: Record; + entrypoints: Record; + docs: () => Operation; + workspaces: string[]; + jsrPackageDetails: () => Operation< + [ + z.SafeParseReturnType | null, + z.SafeParseReturnType | null, + ] + >; + jsr: URL; + /** + * URL of the package on JSR + */ + jsrBadge: URL; + /** + * URL of package on npm + */ + npm: URL; + /** + * URL of badge for version published on npm + */ + npmVersionBadge: URL; + MDXContent: () => Operation; + title: () => Operation; + description: () => Operation; + readme(): Operation; +} + +//TODO: cache package +export function* usePackage(options: PackageOptions): Operation { + if (options.type === "worktree") { + let repo = createRepo({ name: "effection", owner: "thefrontside" }); + + let tags = yield* repo.tags(new RegExp(`effection-${options.series}.*`)); + + let ref = findLatestSemverTag(tags); + + if (!ref) { + throw new Error(`unable to find package ref for ${options.series}`); + } + + let path = yield* useWorktree(ref.name); + + let denoJson = yield* useDenoJson(`${path}/deno.json`); + + let version = extractVersion(ref.name); + + return yield* initPackage("effection", path, ".", version, ref, denoJson); + } else { + let { path, workspacePath, ref } = options; + let denoJson = yield* useDenoJson(`${path}/deno.json`); + + let name = options.name || denoJson.name || "UNKNOWN_PACKAGE"; + + let version = denoJson.version ?? "main"; + + return yield* initPackage( + name, + path, + workspacePath, + version, + ref, + denoJson, + ); + } +} + +function* initPackage( + name: string, + path: string, + workspacePath: string, + version: string, + ref: Ref, + denoJson: DenoJson, +): Operation { + let [, scope] = denoJson?.name?.match(/@(.*)\/(.*)/) ?? []; + let pkg: Package = { + name, + scopeName: scope, + workspacePath, + version, + ref, + get exports() { + if (typeof denoJson.exports === "string") { + return { ["."]: denoJson.exports }; + } else if (denoJson.exports === undefined) { + return { ["."]: "./mod.ts" }; + } else { + return denoJson.exports; + } + }, + get entrypoints() { + let entrypoints: Record = {}; + for (let key of Object.keys(pkg.exports)) { + entrypoints[key] = new URL(pkg.exports[key], `file://${path}/`); + } + return entrypoints; + }, + *docs() { + let docs: LocalDocsPages = {}; + + for (let [entrypoint, url] of Object.entries(pkg.entrypoints)) { + let pages = yield* useDocPages(`${url}`); + + docs[entrypoint] = pages[`${url}`].map((page) => { + return { + ...page, + sections: page.sections.map((section) => ({ + ...section, + node: { + ...section.node, + location: { + ...section.node.location, + url: new URL( + `${ + relative( + path, + fileURLToPath(section.node.location.filename), + ) + }#L${section.node.location.line}`, + `${ref.url}/`, + ), + }, + }, + })), + }; + }); + } + + return docs; + }, + get workspaces() { + return denoJson.workspace ?? []; + }, + jsr: new URL(`./${denoJson.name}/`, "https://jsr.io/"), + jsrBadge: new URL(`./${denoJson.name}`, "https://jsr.io/badges/"), + npm: new URL(`./${denoJson.name}`, "https://www.npmjs.com/package/"), + npmVersionBadge: new URL( + `./${denoJson.name}`, + "https://img.shields.io/npm/v/", + ), + *jsrPackageDetails(): Operation< + [ + z.SafeParseReturnType | null, + z.SafeParseReturnType | null, + ] + > { + let [, packageName] = name.split("/"); + let client = yield* useJSRClient(); + try { + let [details, score] = yield* all([ + client.getPackageDetails({ scope, package: packageName }), + client.getPackageScore({ scope, package: packageName }), + ]); + + if (!details.success) { + console.info( + `JSR package details response failed validation`, + details.error.format(), + ); + } + + if (!score.success) { + console.info( + `JSR score response failed validation`, + score.error.format(), + ); + } + + return [details, score]; + } catch (e) { + console.error(e); + } + + return [null, null]; + }, + + readme: () => until(Deno.readTextFile(`${path}/README.md`)), + + *MDXContent(): Operation { + let readme = yield* pkg.readme(); + let mod = yield* useMDX(readme); + + return mod.default({}); + }, + *title(): Operation { + let readme = yield* pkg.readme(); + return yield* useTitle(readme); + }, + *description(): Operation { + let readme = yield* pkg.readme(); + return yield* useDescription(readme); + }, + }; + + return pkg; +} diff --git a/www/lib/package/deno.ts b/www/lib/package/deno.ts new file mode 100644 index 000000000..9904cba6b --- /dev/null +++ b/www/lib/package/deno.ts @@ -0,0 +1,202 @@ +import z from "zod"; +import type { Operation } from "effection"; +import { until } from "effection"; +import { relative } from "@std/path"; +import { fileURLToPath } from "node:url"; +import type { Package, PackageManifest, Ref } from "./types.ts"; +import type { LocalDocsPages } from "../../hooks/use-deno-doc.tsx"; +import { registries } from "../registries/mod.ts"; +import { useDocPages } from "../../hooks/use-deno-doc.tsx"; +import { useMDX } from "../../hooks/use-mdx.tsx"; +import { + useDescription, + useTitle, +} from "../../hooks/use-description-parse.tsx"; + +/** + * Zod schema for deno.json files. + */ +export const DenoJsonSchema = z.object({ + name: z.string().optional(), + version: z.string().optional(), + exports: z.union([z.record(z.string()), z.string()]).optional(), + license: z.string().optional(), + workspace: z.array(z.string()).optional(), + imports: z.record(z.string()).optional(), +}); + +export type DenoJson = z.infer; + +/** + * Normalize Deno exports to Record. + */ +function normalizeExports( + exports: DenoJson["exports"], +): Record { + if (typeof exports === "string") { + return { ".": exports }; + } + if (exports === undefined) { + return { ".": "./mod.ts" }; + } + return exports; +} + +/** + * Parse scope from package name (e.g., "@effectionx/process" -> "effectionx") + */ +function parseScope(name: string | undefined): string | undefined { + if (!name) return undefined; + let match = name.match(/@([^/]+)\//); + return match ? match[1] : undefined; +} + +/** + * Create a Package for a Deno project. + * + * @param path - Local file path to the package directory + * @param workspaceName - Directory name within the workspace (e.g., "process") + * @param workspacePath - Relative path from monorepo root (e.g., "packages/process") + * @param ref - Git ref information for GitHub links + */ +export function createDenoPackage( + path: string, + workspaceName: string, + workspacePath: string, + ref: Ref, +): Package { + let manifestUrl = new URL(`${path}/deno.json`, "file://"); + + // We'll compute these lazily from the manifest + let cachedName: string | undefined; + + let pkg: Package = { + manifestUrl, + path, + workspaceName, + workspacePath, + ref, + deno: true, + node: false, + registries, + + // Registry URLs - will use the cached name once loaded + get npm() { + let name = cachedName ?? `@effectionx/${workspaceName}`; + return new URL(`./${name}`, "https://www.npmjs.com/package/"); + }, + get npmVersionBadge() { + let name = cachedName ?? `@effectionx/${workspaceName}`; + return new URL(`./${name}`, "https://img.shields.io/npm/v/"); + }, + + *getManifest(): Operation { + let content = yield* until(Deno.readTextFile(`${path}/deno.json`)); + let denoJson = DenoJsonSchema.parse(JSON.parse(content)); + + // Cache the name for URL getters + if (denoJson.name) { + cachedName = denoJson.name; + } + + return { + name: denoJson.name, + version: denoJson.version, + exports: normalizeExports(denoJson.exports), + license: denoJson.license, + imports: denoJson.imports ?? {}, + }; + }, + + *getName(): Operation { + let manifest = yield* this.getManifest(); + return manifest.name ?? workspaceName; + }, + + *getVersion(): Operation { + let manifest = yield* this.getManifest(); + return manifest.version ?? "0.0.0"; + }, + + *getScopeName(): Operation { + let manifest = yield* this.getManifest(); + return parseScope(manifest.name); + }, + + *getExports(): Operation> { + let manifest = yield* this.getManifest(); + return manifest.exports; + }, + + *getImports(): Operation> { + let manifest = yield* this.getManifest(); + return manifest.imports; + }, + + *getEntrypoints(): Operation> { + let manifest = yield* this.getManifest(); + let entrypoints: Record = {}; + for (let [key, value] of Object.entries(manifest.exports)) { + entrypoints[key] = new URL(value, `file://${path}/`); + } + return entrypoints; + }, + + *getDocs(): Operation { + let entrypoints = yield* this.getEntrypoints(); + let imports = yield* this.getImports(); + + let docs: LocalDocsPages = {}; + + for (let [entrypoint, url] of Object.entries(entrypoints)) { + let pages = yield* useDocPages(`${url}`, imports); + + docs[entrypoint] = pages[`${url}`].map((page) => ({ + ...page, + sections: page.sections.map((section) => ({ + ...section, + node: { + ...section.node, + location: { + ...section.node.location, + url: new URL( + `${ + relative( + path, + fileURLToPath(section.node.location.filename), + ) + }#L${section.node.location.line}`, + `${ref.url}/`, + ), + }, + }, + })), + })); + } + + return docs; + }, + + *getReadme(): Operation { + return yield* until(Deno.readTextFile(`${path}/README.md`)); + }, + + *getMDXContent(): Operation { + let readme = yield* this.getReadme(); + let mod = yield* useMDX(readme); + return mod.default({}); + }, + + *getTitle(): Operation { + let readme = yield* this.getReadme(); + return yield* useTitle(readme); + }, + + *getDescription(): Operation { + let readme = yield* this.getReadme(); + return yield* useDescription(readme); + }, + }; + + return pkg; +} diff --git a/www/lib/package/mod.ts b/www/lib/package/mod.ts new file mode 100644 index 000000000..1cba3b05e --- /dev/null +++ b/www/lib/package/mod.ts @@ -0,0 +1,7 @@ +export type { Package, PackageManifest, Ref } from "./types.ts"; +export { createDenoPackage, type DenoJson, DenoJsonSchema } from "./deno.ts"; +export { + createNodePackage, + type PackageJson, + PackageJsonSchema, +} from "./node.ts"; diff --git a/www/lib/package/node.ts b/www/lib/package/node.ts new file mode 100644 index 000000000..f48cd4662 --- /dev/null +++ b/www/lib/package/node.ts @@ -0,0 +1,293 @@ +import z from "zod"; +import type { Operation } from "effection"; +import { until } from "effection"; +import { relative } from "@std/path"; +import { fileURLToPath } from "node:url"; +import type { Package, PackageManifest, Ref } from "./types.ts"; +import type { LocalDocsPages } from "../../hooks/use-deno-doc.tsx"; +import { registries } from "../registries/mod.ts"; +import { useDocPages } from "../../hooks/use-deno-doc.tsx"; +import { useMDX } from "../../hooks/use-mdx.tsx"; +import { + useDescription, + useTitle, +} from "../../hooks/use-description-parse.tsx"; + +/** + * Zod schema for Node exports field conditions. + * Supports conditional exports with "development" and "default" conditions. + */ +const ExportConditionsSchema = z.object({ + development: z.string().optional(), + default: z.string().optional(), +}); + +/** + * Zod schema for package.json exports field. + * Can be a string, conditional object, or a record of entrypoints. + * Note: z.record must come before ExportConditionsSchema because + * ExportConditionsSchema with all optional fields would match any object. + */ +const ExportsSchema = z.union([ + z.string(), + z.record(z.union([z.string(), ExportConditionsSchema])), + ExportConditionsSchema, +]); + +/** + * Zod schema for package.json files. + */ +export const PackageJsonSchema = z.object({ + name: z.string().optional(), + version: z.string().optional(), + exports: ExportsSchema.optional(), + license: z.string().optional(), + dependencies: z.record(z.string()).optional(), + devDependencies: z.record(z.string()).optional(), + peerDependencies: z.record(z.string()).optional(), +}); + +export type PackageJson = z.infer; + +/** + * Resolve an export value to a string. + * Prefers "development" condition, falls back to "default", then string value. + */ +function resolveExportValue( + value: string | z.infer, +): string | undefined { + if (typeof value === "string") { + return value; + } + return value.development ?? value.default; +} + +/** + * Normalize Node exports to Record. + * Uses "development" condition when available. + */ +function normalizeExports( + exports: PackageJson["exports"], +): Record { + if (exports === undefined) { + return { ".": "./src/index.ts" }; + } + + if (typeof exports === "string") { + return { ".": exports }; + } + + // Check if it's a conditional export object (has development/default keys) + if ("development" in exports || "default" in exports) { + let resolved = resolveExportValue( + exports as z.infer, + ); + return resolved ? { ".": resolved } : { ".": "./src/index.ts" }; + } + + // It's a record of entrypoints + let result: Record = {}; + for (let [key, value] of Object.entries(exports)) { + let resolved = resolveExportValue(value); + if (resolved) { + result[key] = resolved; + } + } + + return Object.keys(result).length > 0 ? result : { ".": "./src/index.ts" }; +} + +/** + * Sanitize a semver version range for use in npm: specifiers. + * Handles cases like "^3 || ^4" by taking the first valid part. + */ +function sanitizeVersion(version: string): string { + // Handle OR ranges (e.g., "^3 || ^4") - take the first part + if (version.includes("||")) { + return version.split("||")[0].trim(); + } + // Handle workspace protocol + if (version.startsWith("workspace:")) { + return "*"; + } + return version; +} + +/** + * Build imports map from dependencies. + * Converts npm package names to npm: specifiers. + */ +function buildImports(packageJson: PackageJson): Record { + let imports: Record = {}; + + let allDeps = { + ...packageJson.dependencies, + ...packageJson.peerDependencies, + }; + + for (let [name, version] of Object.entries(allDeps)) { + let sanitizedVersion = sanitizeVersion(version); + imports[name] = `npm:${name}@${sanitizedVersion}`; + } + + return imports; +} + +/** + * Parse scope from package name (e.g., "@effectionx/process" -> "effectionx") + */ +function parseScope(name: string | undefined): string | undefined { + if (!name) return undefined; + let match = name.match(/@([^/]+)\//); + return match ? match[1] : undefined; +} + +/** + * Create a Package for a Node/PNPM project. + * + * @param path - Local file path to the package directory + * @param workspaceName - Directory name within the workspace (e.g., "process") + * @param workspacePath - Relative path from monorepo root (e.g., "packages/process") + * @param ref - Git ref information for GitHub links + */ +export function createNodePackage( + path: string, + workspaceName: string, + workspacePath: string, + ref: Ref, +): Package { + let manifestUrl = new URL(`${path}/package.json`, "file://"); + + // We'll compute these lazily from the manifest + let cachedName: string | undefined; + + let pkg: Package = { + manifestUrl, + path, + workspaceName, + workspacePath, + ref, + deno: false, + node: true, + registries, + + // Registry URLs - will use the cached name once loaded + get npm() { + let name = cachedName ?? `@effectionx/${workspaceName}`; + return new URL(`./${name}`, "https://www.npmjs.com/package/"); + }, + get npmVersionBadge() { + let name = cachedName ?? `@effectionx/${workspaceName}`; + return new URL(`./${name}`, "https://img.shields.io/npm/v/"); + }, + + *getManifest(): Operation { + let content = yield* until(Deno.readTextFile(`${path}/package.json`)); + let packageJson = PackageJsonSchema.parse(JSON.parse(content)); + + // Cache the name for URL getters + if (packageJson.name) { + cachedName = packageJson.name; + } + + return { + name: packageJson.name, + version: packageJson.version, + exports: normalizeExports(packageJson.exports), + license: packageJson.license, + imports: buildImports(packageJson), + }; + }, + + *getName(): Operation { + let manifest = yield* this.getManifest(); + return manifest.name ?? workspaceName; + }, + + *getVersion(): Operation { + let manifest = yield* this.getManifest(); + return manifest.version ?? "0.0.0"; + }, + + *getScopeName(): Operation { + let manifest = yield* this.getManifest(); + return parseScope(manifest.name); + }, + + *getExports(): Operation> { + let manifest = yield* this.getManifest(); + return manifest.exports; + }, + + *getImports(): Operation> { + let manifest = yield* this.getManifest(); + return manifest.imports; + }, + + *getEntrypoints(): Operation> { + let manifest = yield* this.getManifest(); + let entrypoints: Record = {}; + for (let [key, value] of Object.entries(manifest.exports)) { + entrypoints[key] = new URL(value, `file://${path}/`); + } + return entrypoints; + }, + + *getDocs(): Operation { + let entrypoints = yield* this.getEntrypoints(); + let imports = yield* this.getImports(); + + let docs: LocalDocsPages = {}; + + for (let [entrypoint, url] of Object.entries(entrypoints)) { + let pages = yield* useDocPages(`${url}`, imports); + + docs[entrypoint] = pages[`${url}`].map((page) => ({ + ...page, + sections: page.sections.map((section) => ({ + ...section, + node: { + ...section.node, + location: { + ...section.node.location, + url: new URL( + `${ + relative( + path, + fileURLToPath(section.node.location.filename), + ) + }#L${section.node.location.line}`, + `${ref.url}/`, + ), + }, + }, + })), + })); + } + + return docs; + }, + + *getReadme(): Operation { + return yield* until(Deno.readTextFile(`${path}/README.md`)); + }, + + *getMDXContent(): Operation { + let readme = yield* this.getReadme(); + let mod = yield* useMDX(readme); + return mod.default({}); + }, + + *getTitle(): Operation { + let readme = yield* this.getReadme(); + return yield* useTitle(readme); + }, + + *getDescription(): Operation { + let readme = yield* this.getReadme(); + return yield* useDescription(readme); + }, + }; + + return pkg; +} diff --git a/www/lib/package/types.ts b/www/lib/package/types.ts new file mode 100644 index 000000000..8423a4112 --- /dev/null +++ b/www/lib/package/types.ts @@ -0,0 +1,120 @@ +import type { Operation } from "effection"; +import type { Registries } from "../registries/types.ts"; +import type { LocalDocsPages } from "../../hooks/use-deno-doc.tsx"; + +/** + * Git ref information for linking to source on GitHub. + */ +export interface Ref { + /** Ref name (e.g., "main", "v4.0.0") */ + name: string; + + /** GitHub owner/repo (e.g., "thefrontside/effectionx") */ + nameWithOwner: string; + + /** URL to this ref on GitHub (e.g., "https://github.com/.../tree/main/process") */ + url: string; +} + +/** + * Normalized package manifest. + */ +export interface PackageManifest { + /** Package name (e.g., "@effectionx/process") */ + name?: string; + + /** Package version */ + version?: string; + + /** + * Normalized exports - always Record. + * For Node packages, uses the "development" condition. + */ + exports: Record; + + /** License identifier */ + license?: string; + + /** + * Import map for dependency resolution. + * For Deno: from `imports` field + * For Node: merged dependencies + peerDependencies + */ + imports: Record; +} + +/** + * Represents a single package in a workspace. + */ +export interface Package { + /** URL to the manifest file (deno.json or package.json) */ + manifestUrl: URL; + + /** + * Local file path to the package directory. + */ + path: string; + + /** Workspace directory name (e.g., "process") */ + workspaceName: string; + + /** + * Workspace path relative to root (e.g., "packages/process"). + * For compatibility with old API. + */ + workspacePath: string; + + /** Git ref for this package (for GitHub links) */ + ref: Ref; + + /** Get the parsed manifest */ + getManifest(): Operation; + + /** Get package name from manifest */ + getName(): Operation; + + /** Get package version from manifest */ + getVersion(): Operation; + + /** Get the scope name (e.g., "effectionx" from "@effectionx/process") */ + getScopeName(): Operation; + + /** Get normalized exports map */ + getExports(): Operation>; + + /** Get imports for doc generation */ + getImports(): Operation>; + + /** Get entrypoints as URLs (from exports) */ + getEntrypoints(): Operation>; + + /** Generate API documentation for all entrypoints */ + getDocs(): Operation; + + /** Read README.md content */ + getReadme(): Operation; + + /** Parse README as MDX and return rendered content */ + getMDXContent(): Operation; + + /** Extract title from README */ + getTitle(): Operation; + + /** Extract description from README */ + getDescription(): Operation; + + /** Is this a Deno package (has deno.json) */ + deno: boolean; + + /** Is this a Node package (has package.json) */ + node: boolean; + + /** Available registries */ + registries: Registries; + + /** URL to npm package page */ + npm: URL; + + /** URL to npm version badge */ + npmVersionBadge: URL; +} diff --git a/www/lib/registries/jsr.ts b/www/lib/registries/jsr.ts new file mode 100644 index 000000000..b2eb320bf --- /dev/null +++ b/www/lib/registries/jsr.ts @@ -0,0 +1,30 @@ +import type { Operation } from "effection"; +import type { PackageDetails, PackageScore, Registry } from "./types.ts"; + +export const jsr: Registry = { + name: "JSR", + + packageUrl(packageName: string): URL { + return new URL(`./${packageName}/`, "https://jsr.io/"); + }, + + versionBadgeUrl(packageName: string): URL { + return new URL(`./${packageName}`, "https://jsr.io/badges/"); + }, + + configUrl(packageName: string): string { + return `https://jsr.io/${packageName}/deno.json`; + }, + + *getPackageDetails(_packageName: string): Operation { + // TODO: Implement JSR API call + // const [, scope, name] = packageName.match(/@(.*)\/(.*)/) ?? []; + // fetch from https://api.jsr.io/scopes/{scope}/packages/{name} + return null; + }, + + *getPackageScore(_packageName: string): Operation { + // TODO: Implement JSR API call + return null; + }, +}; diff --git a/www/lib/registries/mod.ts b/www/lib/registries/mod.ts new file mode 100644 index 000000000..4fa561ff7 --- /dev/null +++ b/www/lib/registries/mod.ts @@ -0,0 +1,14 @@ +export type { + PackageDetails, + PackageScore, + Registries, + Registry, +} from "./types.ts"; +export { jsr } from "./jsr.ts"; +export { npm } from "./npm.ts"; + +import { jsr } from "./jsr.ts"; +import { npm } from "./npm.ts"; +import type { Registries } from "./types.ts"; + +export const registries: Registries = { jsr, npm }; diff --git a/www/lib/registries/npm.ts b/www/lib/registries/npm.ts new file mode 100644 index 000000000..9939cfa04 --- /dev/null +++ b/www/lib/registries/npm.ts @@ -0,0 +1,29 @@ +import type { Operation } from "effection"; +import type { PackageDetails, PackageScore, Registry } from "./types.ts"; + +export const npm: Registry = { + name: "npm", + + packageUrl(packageName: string): URL { + return new URL(`./${packageName}`, "https://www.npmjs.com/package/"); + }, + + versionBadgeUrl(packageName: string): URL { + return new URL(`./${packageName}`, "https://img.shields.io/npm/v/"); + }, + + configUrl(packageName: string): string { + return `https://unpkg.com/${packageName}/package.json`; + }, + + *getPackageDetails(_packageName: string): Operation { + // TODO: Implement npm API call + // fetch from https://registry.npmjs.org/{packageName} + return null; + }, + + *getPackageScore(_packageName: string): Operation { + // npm doesn't have a score API like JSR + return null; + }, +}; diff --git a/www/lib/registries/types.ts b/www/lib/registries/types.ts new file mode 100644 index 000000000..e0f6461a6 --- /dev/null +++ b/www/lib/registries/types.ts @@ -0,0 +1,47 @@ +import type { Operation } from "effection"; + +/** + * Package details from a registry API. + */ +export interface PackageDetails { + name: string; + description?: string; +} + +/** + * Package score/quality metrics from a registry. + */ +export interface PackageScore { + score?: number; +} + +/** + * Interface for a package registry (JSR, npm, etc.) + */ +export interface Registry { + /** Registry name for display */ + name: string; + + /** Get the URL to the package page on the registry */ + packageUrl(packageName: string): URL; + + /** Get the URL for the version badge */ + versionBadgeUrl(packageName: string): URL; + + /** URL to the config file for a package (for fetching imports) */ + configUrl(packageName: string): string; + + /** Fetch package details from registry API */ + getPackageDetails(packageName: string): Operation; + + /** Fetch package score/quality metrics */ + getPackageScore(packageName: string): Operation; +} + +/** + * Available registries. + */ +export interface Registries { + jsr: Registry; + npm: Registry; +} diff --git a/www/lib/remove-description-hr.ts b/www/lib/remove-description-hr.ts new file mode 100644 index 000000000..961577917 --- /dev/null +++ b/www/lib/remove-description-hr.ts @@ -0,0 +1,35 @@ +import type { Nodes } from "hast"; +import { EXIT, visit } from "unist-util-visit"; + +/** + * Remove the HR element used to define the end of the description. + */ +export function removeDescriptionHR() { + return function (tree: Nodes) { + return visit(tree, (node, index, parent) => { + if ( + node.type === "element" && node.tagName === "hr" && + parent?.type === "root" + ) { + let beforeHR = parent.children + .slice(0, index) + .filter((node: Nodes) => + !(node.type === "text" && node.value === "\n") + ); + + // assume this hr is for a description if there are only two elements and + // second element is a paragraph. + if ( + beforeHR.length === 2 && beforeHR[1].type === "element" && + beforeHR[1].tagName === "p" + ) { + parent.children = parent.children.filter((child: Nodes) => + child !== node + ); + } + + return EXIT; + } + }); + }; +} diff --git a/www/lib/replace-all.ts b/www/lib/replace-all.ts new file mode 100644 index 000000000..0b9cab193 --- /dev/null +++ b/www/lib/replace-all.ts @@ -0,0 +1,41 @@ +import { Operation } from "effection"; + +export function* replaceAll( + input: string, + regex: RegExp, + replacement: (match: RegExpMatchArray) => Operation, +): Operation { + // replace all implies global, so append if it is missing + let addGlobal = !regex.flags.includes("g"); + let flags = regex.flags; + if (addGlobal) flags += "g"; + + // get matches + let matcher = new RegExp(regex.source, flags); + let matches = Array.from(input.matchAll(matcher)); + + if (matches.length == 0) return input; + + // letruct all replacements + let replacements: Array; + replacements = new Array(); + for (let m of matches) { + let r = yield* replacement(m); + replacements.push(r); + } + + // change capturing groups into non-capturing groups for split + // (because capturing groups are added to the parts array + let source = regex.source.replace(/(?; + latest(matching: RegExp): Operation; +} + +export interface Ref { + name: string; + nameWithOwner: string; + url: string; +} + +export interface RepoOptions { + name: string; + owner: string; +} +export function createRepo(options: RepoOptions): Repo { + let { name, owner } = options; + let repo: Repo = { + name, + owner, + *tags(matching) { + let result = yield* $(`git tag`); + let names = result.stdout.trim().split(/\s+/).filter((tag) => + matching.test(tag) + ); + return names.map((tagname) => ({ + name: tagname, + nameWithOwner: `${owner}/${name}`, + url: `https://github.com/${owner}/${name}/tree/${tagname}`, + })); + }, + *latest(matching) { + let tags = yield* repo.tags(matching); + let latest = findLatestSemverTag(tags); + + if (!latest) { + throw new Error(`Could not retrieve latest tag matching ${matching}`); + } + + return tags.find((tag) => tag.name === latest.name)!; + }, + }; + return repo; +} diff --git a/www/lib/semver.ts b/www/lib/semver.ts new file mode 100644 index 000000000..d3a18f592 --- /dev/null +++ b/www/lib/semver.ts @@ -0,0 +1,27 @@ +export { compare, major, minor, rsort } from "semver"; + +import { rsort } from "semver"; + +export function extractVersion(input: string) { + let parts = input.match( + // @source: https://semver.org/#is-there-a-suggested-regular-expression-regex-to-check-a-semver-string + /(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?/, + ); + if (parts) { + return parts[0]; + } else { + return "0.0.0"; + } +} + +/** + * Find the latest Semver tag from an array of tags + * @param tags - Array of tag objects with name property + * @returns Latest semver tag if found, undefined otherwise + */ +export function findLatestSemverTag( + tags: T[], +): T | undefined { + let [latest] = rsort(tags.map((tag) => tag.name).map(extractVersion)); + return tags.find((tag) => tag.name.endsWith(latest)); +} diff --git a/www/lib/shift-headings.ts b/www/lib/shift-headings.ts new file mode 100644 index 000000000..fede94315 --- /dev/null +++ b/www/lib/shift-headings.ts @@ -0,0 +1,20 @@ +// deno-lint-ignore no-import-prefix +import type { Nodes, Root } from "npm:@types/mdast@4.0.4"; +import { visit } from "unist-util-visit"; + +export function shiftHeadings(amount: number) { + return (tree: Root) => { + visit(tree, (node: Nodes) => { + if (node.type === "heading") { + let depth = node.depth + amount; + if (depth < 1) { + node.depth = 1; + } else if (depth > 6) { + node.depth = 6; + } else { + node.depth = depth as 1 | 2 | 3 | 4 | 5 | 6; + } + } + }); + }; +} diff --git a/www/lib/toc.ts b/www/lib/toc.ts new file mode 100644 index 000000000..fda27d4c2 --- /dev/null +++ b/www/lib/toc.ts @@ -0,0 +1,32 @@ +import { Options } from "@jsdevtools/rehype-toc"; +import { createTOC } from "@jsdevtools/rehype-toc/lib/create-toc.js"; +import { customizationHooks } from "@jsdevtools/rehype-toc/lib/customization-hooks.js"; +import { findHeadings } from "@jsdevtools/rehype-toc/lib/fiind-headings.js"; +import { findMainNode } from "@jsdevtools/rehype-toc/lib/find-main-node.js"; +import { NormalizedOptions } from "@jsdevtools/rehype-toc/lib/options.js"; +import type { Nodes } from "hast"; +import { JSXElement } from "revolution/jsx-runtime"; + +export function createToc(root: Nodes, options?: Options): JSXElement { + let _options = new NormalizedOptions( + options ?? { + cssClasses: { + toc: + "hidden text-sm font-light tracking-wide leading-loose lg:block relative pt-2", + link: "hover:underline hover:underline-offset-2", + }, + }, + ); + + // Find the
    or element + let [mainNode] = findMainNode(root); + + // Find all heading elements + let headings = findHeadings(mainNode, _options); + + // Create the table of contents + let tocNode = createTOC(headings, _options); + + // Allow the user to customize the table of contents before we add it to the page + return customizationHooks(tocNode, _options) as unknown as JSXElement; +} diff --git a/www/lib/trim-after-hr.ts b/www/lib/trim-after-hr.ts new file mode 100644 index 000000000..fb1b77ba9 --- /dev/null +++ b/www/lib/trim-after-hr.ts @@ -0,0 +1,24 @@ +import type { Nodes } from "hast"; +import { EXIT, visit } from "unist-util-visit"; + +/** + * Removes all content after
    in the root element. + * This is used to restrict the length of the description by eliminating everything after
    + * @returns + */ +export function trimAfterHR() { + return function (tree: Nodes) { + visit( + tree, + (node: Nodes, index: number | undefined, parent: Nodes | undefined) => { + if ( + node.type === "element" && node.tagName === "hr" && + parent?.type === "root" + ) { + parent.children = parent.children.slice(0, index); + return EXIT; + } + }, + ); + }; +} diff --git a/www/lib/workspaces/mod.ts b/www/lib/workspaces/mod.ts new file mode 100644 index 000000000..e62716a93 --- /dev/null +++ b/www/lib/workspaces/mod.ts @@ -0,0 +1,215 @@ +import type { Operation } from "effection"; +import { until } from "effection"; +import { resolve } from "@std/path"; +import { parse as parseYaml } from "@std/yaml"; +import z from "zod"; +import { existsSync } from "node:fs"; + +import type { Workspaces } from "./types.ts"; +import type { Package, Ref } from "../package/types.ts"; +import { createDenoPackage, DenoJsonSchema } from "../package/deno.ts"; +import { createNodePackage } from "../package/node.ts"; +import { useClone } from "../clones.ts"; + +export type { Workspaces } from "./types.ts"; + +/** + * Schema for pnpm-workspace.yaml files. + */ +const PnpmWorkspaceSchema = z.object({ + packages: z.array(z.string()), +}); + +/** + * Detect workspace type by checking for deno.json or pnpm-workspace.yaml. + */ +function detectWorkspaceType( + rootPath: string, +): "deno" | "node" | null { + if (existsSync(`${rootPath}/deno.json`)) { + return "deno"; + } + if (existsSync(`${rootPath}/pnpm-workspace.yaml`)) { + return "node"; + } + return null; +} + +/** + * Check if a path represents a hidden/internal package that should be excluded. + * Hidden packages start with "." (e.g., ".internal") + */ +function isHiddenPackage(pathOrName: string): boolean { + let name = pathOrName.split("/").pop() ?? pathOrName; + return name.startsWith("."); +} + +/** + * Expand glob patterns to actual directory paths. + * Simple implementation that handles patterns like "packages/*". + * Excludes hidden directories (starting with ".") as they are internal packages. + */ +function* expandPatterns( + rootPath: string, + patterns: string[], +): Operation { + let dirs: string[] = []; + + for (let pattern of patterns) { + // Skip hidden/internal package patterns + if (isHiddenPackage(pattern)) { + continue; + } + + if (pattern.endsWith("/*")) { + // Simple glob: packages/* -> list directories in packages/ + let basePath = pattern.slice(0, -2); + let fullPath = resolve(rootPath, basePath); + + try { + for (let entry of Deno.readDirSync(fullPath)) { + // Skip hidden directories (internal packages) + if (entry.isDirectory && !entry.name.startsWith(".")) { + dirs.push(`${basePath}/${entry.name}`); + } + } + } catch { + // Directory doesn't exist, skip + } + } else if (!pattern.includes("*")) { + // Literal path - only include if not hidden + dirs.push(pattern); + } else { + // Other glob patterns not supported yet + console.warn(`Unsupported glob pattern: ${pattern}`); + } + } + + return dirs; +} + +/** + * Get workspace patterns from a Deno monorepo (from deno.json workspace field). + */ +function* getDenoPatterns(rootPath: string): Operation { + let content = yield* until(Deno.readTextFile(`${rootPath}/deno.json`)); + let denoJson = DenoJsonSchema.parse(JSON.parse(content)); + return denoJson.workspace ?? []; +} + +/** + * Get workspace patterns from a Node/PNPM monorepo. + */ +function* getNodePatterns(rootPath: string): Operation { + let content = yield* until( + Deno.readTextFile(`${rootPath}/pnpm-workspace.yaml`), + ); + let parsed = parseYaml(content); + let workspace = PnpmWorkspaceSchema.parse(parsed); + return workspace.packages; +} + +/** + * Create a Workspaces instance for a given repository. + * + * @param nameWithOwner - GitHub repo in "owner/repo" format + */ +export function* useWorkspaces(nameWithOwner: string): Operation { + let rootPath = yield* useClone(nameWithOwner); + let type = detectWorkspaceType(rootPath); + + if (!type) { + throw new Error( + `Could not detect workspace type for ${nameWithOwner}. ` + + `Expected deno.json or pnpm-workspace.yaml at root.`, + ); + } + + let url = `https://github.com/${nameWithOwner}`; + let refName = "main"; + + // Get workspace patterns based on type + let patterns = type === "deno" + ? yield* getDenoPatterns(rootPath) + : yield* getNodePatterns(rootPath); + + // Expand patterns to actual directories + let workspaceDirs = yield* expandPatterns(rootPath, patterns); + + // Create ref builder + let createRef = (workspacePath: string): Ref => ({ + name: refName, + nameWithOwner, + url: `${url}/tree/${refName}/${workspacePath}`, + }); + + // Create package factory based on type + let createPackage = type === "deno" + ? (path: string, name: string, workspacePath: string, ref: Ref) => + createDenoPackage(path, name, workspacePath, ref) + : (path: string, name: string, workspacePath: string, ref: Ref) => + createNodePackage(path, name, workspacePath, ref); + + // Build lookup caches lazily + let packagesByWorkspace: Map | undefined; + let packagesByName: Map | undefined; + + function* ensureCaches(): Operation { + if (packagesByWorkspace && packagesByName) return; + + packagesByWorkspace = new Map(); + packagesByName = new Map(); + + for (let workspacePath of workspaceDirs) { + let fullPath = resolve(rootPath, workspacePath); + let workspaceName = workspacePath.split("/").pop()!; + let ref = createRef(workspacePath); + + let pkg = createPackage(fullPath, workspaceName, workspacePath, ref); + packagesByWorkspace.set(workspaceName, pkg); + + // Get package name for the name lookup + try { + let manifest = yield* pkg.getManifest(); + if (manifest.name) { + packagesByName.set(manifest.name, pkg); + } + } catch { + // Package might not have a valid manifest, skip name lookup + } + } + } + + let workspaces: Workspaces = { + url, + nameWithOwner, + workspacePatterns: patterns, + + *getWorkspace(name: string): Operation { + yield* ensureCaches(); + return packagesByWorkspace!.get(name); + }, + + *getPackage(name: string): Operation { + yield* ensureCaches(); + return packagesByName!.get(name); + }, + + *listWorkspaces(): Operation { + yield* ensureCaches(); + return [...packagesByWorkspace!.keys()]; + }, + + *listPackages(): Operation { + yield* ensureCaches(); + return [...packagesByName!.keys()]; + }, + + *getAllPackages(): Operation { + yield* ensureCaches(); + return [...packagesByWorkspace!.values()]; + }, + }; + + return workspaces; +} diff --git a/www/lib/workspaces/types.ts b/www/lib/workspaces/types.ts new file mode 100644 index 000000000..0b35eb10b --- /dev/null +++ b/www/lib/workspaces/types.ts @@ -0,0 +1,63 @@ +import type { Operation } from "effection"; +import type { Package } from "../package/types.ts"; + +/** + * A Workspaces instance provides access to all packages in a monorepo. + */ +export interface Workspaces { + /** GitHub URL of the repository */ + url: string; + + /** GitHub owner/repo (e.g., "thefrontside/effectionx") */ + nameWithOwner: string; + + /** Workspace path patterns from config (e.g., ["packages/*"]) */ + workspacePatterns: string[]; + + /** + * Get a package by workspace directory name. + * @example workspaces.getWorkspace("process") // returns Package for ./packages/process + */ + getWorkspace(name: string): Operation; + + /** + * Get a package by its npm/jsr package name. + * @example workspaces.getPackage("@effectionx/process") + */ + getPackage(name: string): Operation; + + /** + * List all workspace directory names. + */ + listWorkspaces(): Operation; + + /** + * List all package names. + */ + listPackages(): Operation; + + /** + * Get all packages as an array. + */ + getAllPackages(): Operation; +} + +/** + * Workspace configuration for different monorepo types. + */ +export interface WorkspaceConfig { + /** Type of workspace (deno or node/pnpm) */ + type: "deno" | "node"; + + /** Root path of the repository clone */ + rootPath: string; + + /** Git ref info for GitHub links */ + ref: { + name: string; + nameWithOwner: string; + }; + + /** Workspace patterns (e.g., ["packages/*"]) */ + patterns: string[]; +} diff --git a/www/lib/worktrees.ts b/www/lib/worktrees.ts new file mode 100644 index 000000000..9d38bda0a --- /dev/null +++ b/www/lib/worktrees.ts @@ -0,0 +1,21 @@ +import { createContext, type Operation } from "effection"; +import { $ } from "../context/shell.ts"; +import { existsSync } from "node:fs"; +import { resolve } from "node:path"; + +const Worktrees = createContext("worktrees"); + +export function* initWorktrees(path: string): Operation { + yield* $(`rm -rf ${path}`); + yield* $(`mkdir -p ${path}`); + yield* Worktrees.set(path); +} + +export function* useWorktree(refname: string): Operation { + let basepath = yield* Worktrees.expect(); + let checkout = resolve(`${basepath}/${refname}`); + if (!existsSync(checkout)) { + yield* $(`git worktree add --force ${checkout} ${refname}`); + } + return checkout; +} diff --git a/www/main.css b/www/main.css new file mode 100644 index 000000000..cf0725291 --- /dev/null +++ b/www/main.css @@ -0,0 +1,164 @@ +@import "tailwindcss"; +@plugin "@tailwindcss/typography"; + +/* Tailwind-based Pagefind UI styling */ +@layer components { + .pagefind-ui { + @apply w-full text-gray-900 dark:text-gray-200; + } + + .pagefind-ui__drawer { + @apply flex flex-row; + } + + .pagefind-ui__form { + @apply relative; + } + + .pagefind-ui__form:before { + @apply absolute left-4 top-1/2 w-6 h-6 bg-current; + content: ""; + mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath stroke='none' d='M0 0h24v24H0z' fill='none'%3E%3C/path%3E%3Cpath d='M10 10m-7 0a7 7 0 1 0 14 0a7 7 0 1 0 -14 0'%3E%3C/path%3E%3Cpath d='M21 21l-6 -6'%3E%3C/path%3E%3C/svg%3E"); + width: 18px; + height: 18px; + top: 23px; + left: 20px; + mask-repeat: no-repeat; + mask-size: 100%; + z-index: 9; + pointer-events: none; + display: block; + opacity: 0.7; + } + + .pagefind-ui__search-input { + @apply w-full h-16 px-14 py-4 bg-white dark:bg-gray-800 border-2 + border-gray-200 dark:border-gray-600 rounded-lg text-xl font-bold + placeholder:opacity-20 focus:outline-none focus:border-sky-500 + focus:ring-2 focus:ring-sky-500; + } + + .pagefind-ui__search-clear { + @apply absolute top-1 right-1 h-14 px-4 py-1 text-gray-900 + dark:text-gray-200 text-sm cursor-pointer bg-white dark:bg-gray-800 + rounded-lg; + } + + .pagefind-ui__results { + @apply pl-0 px-0! my-0; + padding-inline-start: 0 !important; + } + + .pagefind-ui__results-area { + @apply flex-1 mt-5 pl-5; + } + + .pagefind-ui__message { + @apply flex items-center h-6 py-2 text-base font-bold mt-0; + } + + .pagefind-ui__result { + @apply list-none flex items-start gap-10 border-t-1 border-gray-200 + dark:border-gray-600; + } + + .pagefind-ui__result:last-of-type { + @apply border-b-1 border-gray-200 dark:border-gray-600; + } + + .pagefind-ui__result-inner { + @apply flex-1 flex flex-col items-start mt-2 py-2; + } + + .pagefind-ui__result-title { + @apply font-bold text-xl my-0!; + } + + .pagefind-ui__result-nested { + @apply pl-5 mb-2; + } + + .pagefind-ui__result-nested:nth-of-type(1) { + @apply mt-2; + } + + .pagefind-ui__result-nested .pagefind-ui__result-title { + @apply text-base; + } + + .pagefind-ui__result-link { + @apply relative text-gray-900 dark:text-gray-200 no-underline! + hover:underline!; + } + + .pagefind-ui__result-nested .pagefind-ui__result-link:before { + content: "\2937 "; + position: absolute; + top: -2; + right: calc(100% + 0.1em); + } + + .pagefind-ui__result-excerpt { + @apply font-normal text-sm mt-1! mb-0!; + } + + .pagefind-ui__result-tags { + @apply list-none p-0 flex gap-5 flex-wrap mt-5; + } + + .pagefind-ui__result-tag { + @apply px-2 py-1 text-sm rounded bg-gray-200 dark:bg-gray-600; + } + + .pagefind-ui__filter-panel { + @apply flex-1 flex flex-col mt-5 max-w-60; + } + + .pagefind-ui__filter-panel-label { + @apply hidden; + } + + .pagefind-ui__filter-block { + @apply block border-b-1 border-gray-200 dark:border-gray-600 py-5; + } + + .pagefind-ui__filter-name { + @apply text-base relative flex items-center list-none font-bold + cursor-pointer h-6; + } + + .pagefind-ui__filter-group { + @apply flex flex-col gap-5 pt-8; + } + + .pagefind-ui__filter-group-label { + @apply hidden; + } + + .pagefind-ui__filter-value { + @apply relative flex items-center gap-2; + } + + .pagefind-ui__filter-checkbox { + @apply m-0 w-4 h-4 border border-gray-200 dark:border-gray-600 + appearance-none rounded bg-white dark:bg-gray-800 cursor-pointer + checked:bg-sky-500 checked:border-sky-500; + } + + .pagefind-ui__filter-label { + @apply cursor-pointer text-base font-normal; + } + + .pagefind-ui__button { + @apply mt-10 w-full h-12 px-3 text-base text-gray-900 dark:text-sky-500 + bg-white dark:bg-gray-800 border-2 border-gray-200 dark:border-gray-600 + rounded font-bold cursor-pointer text-center hover:border-sky-500 + hover:text-sky-500; + } + + /* Loading skeleton styles */ + .pagefind-ui__loading { + @apply text-gray-900 dark:text-gray-200 bg-gray-900 dark:bg-gray-200 rounded + opacity-10 pointer-events-none; + } +} diff --git a/www/main.tsx b/www/main.tsx new file mode 100644 index 000000000..782046a4c --- /dev/null +++ b/www/main.tsx @@ -0,0 +1,109 @@ +import { main, suspend } from "effection"; +import { createRevolution, ServerInfo } from "revolution"; + +import { etagPlugin } from "./plugins/etag.ts"; +import { route, sitemapPlugin } from "./plugins/sitemap.ts"; +import { inlineSvgPlugin } from "./plugins/inline-svg.ts"; +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 { 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 { useConfig } from "./context/config.ts"; +import { initFetch } from "./context/fetch.ts"; +import { initJSRClient } from "./context/jsr.ts"; +import { initWorktrees } from "./lib/worktrees.ts"; +import { initGuides } from "./resources/guides.ts"; +import { initBlog } from "./resources/blog.ts"; +import { apiIndexRoute } from "./routes/api-index-route.tsx"; +import { blogIndexRoute } from "./routes/blog-index-route.tsx"; +import { blogPostRoute } from "./routes/blog-post-route.tsx"; +import { blogTagRoute } from "./routes/blog-tag-route.tsx"; +import { blogFeedRoute } from "./routes/blog-feed-route.tsx"; +import { pagefindRoute } from "./routes/pagefind-route.ts"; +import { redirectDocsRoute } from "./routes/redirect-docs-route.tsx"; +import { redirectIndexRoute } from "./routes/redirect-index-route.tsx"; +import { searchRoute } from "./routes/search-route.tsx"; +import { initClones } from "./lib/clones.ts"; +import { initOctokitContext } from "./lib/octokit.ts"; +import { currentRequestPlugin } from "./plugins/current-request.ts"; + +// Learn more at https://docs.deno.com/runtime/manual/examples/module_metadata#concepts +if (import.meta.main) { + await main(function* () { + let { current, series } = yield* useConfig(); + + yield* initClones("build/clones"); + yield* initWorktrees("build/worktrees"); + yield* initGuides({ + current, + worktrees: series.filter((s) => s !== current), + }); + + yield* initBlog(); + + yield* initJSRClient(); + yield* initFetch(); + + // configures Octokit client + yield* initOctokitContext(); + + let revolution = createRevolution({ + app: [ + route("/", indexRoute()), + route("/search", searchRoute()), + route("/docs", redirectIndexRoute(firstPage(current))), + route("/docs/:id", redirectDocsRoute(current)), + ...series.map((s) => + route(`/guides/${s}`, redirectIndexRoute(firstPage(s))) + ), + route("/guides/:series/:id", guidesRoute({ search: true })), + route("/contrib", xIndexRedirect()), + route("/contrib/:workspacePath", xPackageRedirect()), + route("/x", xIndexRoute({ search: true })), + route("/x/:workspacePath", xPackageRoute({ search: true })), + route("/api", apiIndexRoute({ search: true })), + ...series.map((s) => + route(`/api/${s}/:symbol`, apiReferenceRoute(s, { search: true })) + ), + route("/blog", blogIndexRoute({ search: true })), + route("/blog/feed.xml", blogFeedRoute()), + route("/blog/tags/:tag", blogTagRoute({ search: true })), + route("/blog/:id", blogPostRoute({ search: true })), + route("/blog{/*path}", assetsRoute("blog")), + route( + "/pagefind{/*path}", + pagefindRoute({ pagefindDir: "pagefind", publicDir: "./built/" }), + ), + route("/assets/*path", assetsRoute("assets")), + ], + plugins: [ + yield* tailwindPlugin({ input: "main.css", outdir: "tailwind" }), + inlineSvgPlugin({ + basedir: new URL(".", import.meta.url).pathname, + }), + currentRequestPlugin(), + etagPlugin(), + sitemapPlugin(), + ], + }); + + let server = yield* revolution.start(); + console.log(`www -> ${urlFromServer(server)}`); + + yield* suspend(); + }); +} + +function urlFromServer(server: ServerInfo) { + return new URL( + "/", + `http://${ + server.hostname === "0.0.0.0" ? "localhost" : server.hostname + }:${server.port}`, + ); +} diff --git a/www/plugins/current-request.ts b/www/plugins/current-request.ts new file mode 100644 index 000000000..a606744cf --- /dev/null +++ b/www/plugins/current-request.ts @@ -0,0 +1,51 @@ +import type { RevolutionPlugin } from "revolution"; +import { posixNormalize } from "_posixNormalize"; +import { type Operation } from "effection"; +import { CurrentRequest } from "../context/request.ts"; + +export function currentRequestPlugin(): RevolutionPlugin { + return { + *http(request, next) { + yield* CurrentRequest.set(request); + return yield* next(request); + }, + }; +} + +/** + * Convert a non fully qualified url into a fully qualified url, complete + * with protocol. + */ +export function* useAbsoluteUrl(path: string = "/"): Operation { + let absolute = yield* useAbsoluteUrlFactory(); + + return absolute(path); +} + +export function* useAbsoluteUrlFactory(): Operation<(path: string) => string> { + let request = yield* CurrentRequest.expect(); + + return (path) => { + let normalizedPath = posixNormalize(path); + if (normalizedPath.startsWith("/")) { + let url = new URL(request.url); + url.pathname = normalizedPath; + url.search = ""; + return url.toString(); + } else { + return new URL(path, request.url).toString(); + } + }; +} + +/** + * Get the canonical url for the current path. + */ +export function* useCanonicalUrl(options: { base: string }): Operation { + let request = yield* CurrentRequest.expect(); + + let req = new URL(request.url); + let url = new URL(options.base); + url.pathname = `${url.pathname}${req.pathname}`; + return String(url); +} diff --git a/www/plugins/etag.ts b/www/plugins/etag.ts new file mode 100644 index 000000000..f00051621 --- /dev/null +++ b/www/plugins/etag.ts @@ -0,0 +1,40 @@ +import { RevolutionPlugin } from "revolution"; +import { encodeBase64 } from "@std/encoding/base64"; + +const DEPLOYMENT_ID = + // The same deployment will be shared by the many isolates that serve it + // but because pages do not change, we can use this id as the ETAG + Deno.env.get("DENO_DEPLOYMENT_ID") || + // For local development, just create a new id every time the module is + // reloaded i.e. whenever the dev server restarts. + crypto.randomUUID(); + +const DEPLOYMENT_ID_HASH = await crypto.subtle.digest( + "SHA-1", + new TextEncoder().encode(DEPLOYMENT_ID), +); + +const ETAG = `"${encodeBase64(DEPLOYMENT_ID_HASH)}"`; +const WEAK_ETAG = `W/"${encodeBase64(DEPLOYMENT_ID_HASH)}"`; + +export function etagPlugin(): RevolutionPlugin { + return { + *http(request, next) { + let ifNoneMatch = request.headers.get("if-none-match"); + if (ifNoneMatch === ETAG || ifNoneMatch === WEAK_ETAG) { + return new Response(null, { + status: 304, + statusText: "Not Modified", + }); + } else { + let response = yield* next(request); + if (!response.headers.get("etag")) { + let tagged = new Response(response.body, response); + tagged.headers.set("etag", ETAG); + return tagged; + } + return response; + } + }, + }; +} diff --git a/www/plugins/inline-svg.ts b/www/plugins/inline-svg.ts new file mode 100644 index 000000000..00321042f --- /dev/null +++ b/www/plugins/inline-svg.ts @@ -0,0 +1,142 @@ +import { exists } from "@std/fs"; +import { join } from "@std/path"; +import { until } from "effection"; +import { fromHtml } from "hast-util-from-html"; +import type { RevolutionPlugin } from "revolution"; +import { visit } from "unist-util-visit"; +import type { Element, Parent, RootContent } from "hast"; + +export interface InlineSvgOptions { + /** Base directory to resolve SVG file paths from */ + readonly basedir: string; +} + +/** + * Revolution plugin that inlines SVG images into the HTML output. + * + * Any `` element with a `data-inline-svg` attribute and a `.svg` + * src will be replaced with the actual SVG markup, wrapped in a `
    ` + * that carries the original `` classes. + * + * Parsed SVGs are cached for the lifetime of the server. + */ +export function inlineSvgPlugin(options: InlineSvgOptions): RevolutionPlugin { + let cache = new Map(); + + return { + *html(request, next) { + let html = yield* next(request); + + // Phase 1: synchronous walk to collect replacement targets + let replacements: { parent: Parent; index: number; src: string }[] = []; + + visit(html, "element", (node: Element, index, parent) => { + if ( + node.tagName === "img" && + typeof node.properties?.src === "string" && + node.properties.src.endsWith(".svg") && + hasDataInlineSvg(node) && + parent && + typeof index === "number" + ) { + replacements.push({ + parent: parent as Parent, + index, + src: node.properties.src as string, + }); + } + }); + + // Phase 2: async I/O for uncached SVGs, then replace nodes + for (let { parent, index, src } of replacements) { + let imgNode = parent.children[index] as Element; + let filepath = resolveSvgPath(src, request.url, options.basedir); + + let svgElement = cache.get(filepath); + + if (!svgElement) { + if (yield* until(exists(filepath))) { + let svgString = yield* until(Deno.readTextFile(filepath)); + let svgTree = fromHtml(svgString, { + fragment: true, + space: "svg", + }); + let found = svgTree.children.find( + (c: RootContent) => c.type === "element" && c.tagName === "svg", + ) as Element | undefined; + + if (found) { + found.properties = { + ...found.properties, + style: "width: 100%; height: auto;", + }; + delete found.properties.width; + delete found.properties.height; + svgElement = found; + cache.set(filepath, svgElement); + } + } + } + + if (svgElement) { + let imgClasses = normalizeClassName(imgNode); + + parent.children[index] = { + type: "element", + tagName: "div", + properties: { className: imgClasses }, + children: [structuredClone(svgElement)], + }; + } + } + + return html; + }, + }; +} + +/** + * Check for the data-inline-svg attribute. HAST may store data-* + * attributes as either camelCase or literal kebab-case depending + * on how the HTML was produced. + */ +function hasDataInlineSvg(node: Element): boolean { + if (!node.properties) return false; + return ( + node.properties["dataInlineSvg"] !== undefined || + node.properties["data-inline-svg"] !== undefined + ); +} + +/** + * Extract className from an element, normalizing to a string. + * HAST stores className as string[] in some contexts. + */ +function normalizeClassName(node: Element): string { + let value = node.properties?.className ?? node.properties?.class ?? ""; + if (Array.isArray(value)) { + return value.join(" "); + } + return String(value); +} + +/** + * Resolve an SVG src (from the tag) to a filesystem path. + * + * Handles both absolute paths (`/blog/2026-02-06-.../image.svg`) + * and relative paths (`image.svg`, resolved against the request URL). + */ +function resolveSvgPath( + src: string, + requestUrl: string, + basedir: string, +): string { + if (src.startsWith("/")) { + // Absolute path — join with basedir + return join(basedir, src); + } + // Relative path — resolve against request URL directory + let url = new URL(requestUrl); + let dir = url.pathname.replace(/[^/]*$/, ""); + return join(basedir, dir, src); +} diff --git a/www/plugins/sitemap.ts b/www/plugins/sitemap.ts new file mode 100644 index 000000000..fc1a0c459 --- /dev/null +++ b/www/plugins/sitemap.ts @@ -0,0 +1,125 @@ +import type { Middleware, RevolutionPlugin } from "revolution"; +import { route as revolutionRoute, useRevolutionOptions } from "revolution"; +import type { Operation } from "effection"; +import { stringify } from "@libs/xml"; +import { compile } from "path-to-regexp"; +import { useAbsoluteUrlFactory } from "./current-request.ts"; + +export function sitemapPlugin(): RevolutionPlugin { + return { + *http(request, next) { + let options = yield* useRevolutionOptions(); + let url = new URL(request.url); + + if (url.pathname === "/sitemap.xml") { + let app = options.app ?? []; + let paths: RoutePath[] = []; + for (let middleware of app) { + let ext = middleware as SitemapExtension; + if (ext.sitemapExtension) { + paths = paths.concat(yield* ext.sitemapExtension(request)); + } + } + + let absolute = yield* useAbsoluteUrlFactory(); + + let xml = stringify({ + "@version": "1.0", + "@encoding": "UTF-8", + urlset: { + "@xmlns": "http://www.sitemaps.org/schemas/sitemap/0.9", + url: paths.map((path) => { + let { pathname, ...entry } = path; + + return { + loc: absolute(pathname), + ...entry, + }; + }), + }, + }); + + return new Response(xml, { + status: 200, + headers: { + "Content-Type": "application/xml", + }, + }); + } + return yield* next(request); + }, + }; +} + +export interface SitemapExtension { + sitemapExtension?(request: Request): Operation; +} + +export interface RoutePath { + pathname: string; + lastmod?: string; + changefreq?: + | "always" + | "hourly" + | "daily" + | "weekly" + | "monthly" + | "yearly" + | "never"; + priority?: number; +} + +/** + * Just like a route, but generates a sitemap for all the urls + */ +export interface SitemapRoute { + /** + * The HTTP or HTML handler for this route + */ + handler: Middleware; + + /** + * Generate a list of paths for this route. It will be passed a function which + * will substitute in the parameters of the route to generate the path as a string. + * For example: + * + * ```ts + * // assuming a route pattern: "/users/:username" + * generate({ username: 'cowboyd' }) //=> "/users/cowboyd" + * ``` + * @param generate - a function to generate a single pathname + * @param request - the request for the sitemap + * @returns a list of `RoutePath` values + */ + routemap?( + generate: (params?: Record) => string, + request: Request, + ): Operation; +} + +export function route( + pattern: string, + middleware: Middleware | SitemapRoute, +): Middleware { + if (isSitemapRoute(middleware)) { + let handler = revolutionRoute(pattern, middleware.handler); + if (middleware.routemap) { + let { routemap } = middleware; + Object.defineProperty(handler, "sitemapExtension", { + value(request: Request) { + let generate = compile(pattern); + return routemap(generate, request); + }, + }); + } + return handler; + } else { + return revolutionRoute(pattern, middleware); + } +} + +function isSitemapRoute( + o: Middleware | SitemapRoute, +): o is SitemapRoute { + return !!(o as SitemapRoute).handler; +} diff --git a/www/plugins/tailwind.ts b/www/plugins/tailwind.ts new file mode 100644 index 000000000..333950332 --- /dev/null +++ b/www/plugins/tailwind.ts @@ -0,0 +1,77 @@ +import { crypto } from "@std/crypto"; +import { encodeHex } from "@std/encoding/hex"; +import { emptyDir, exists } from "@std/fs"; +import { serveFile } from "@std/http"; +import { join } from "@std/path"; +import { Operation, until } from "effection"; +import { select } from "hast-util-select"; +import type { RevolutionPlugin } from "revolution"; +import { $ } from "../context/shell.ts"; + +export interface TailwindOptions { + readonly input: string; + readonly outdir: string; +} + +export function* tailwindPlugin( + options: TailwindOptions, +): Operation { + yield* until(emptyDir(options.outdir)); + + let css = yield* compileCSS(options); + + return { + *html(request, next) { + let html = yield* next(request); + let head = select("head", html); + head?.children.push({ + type: "element", + tagName: "link", + properties: { rel: "stylesheet", href: css.href }, + children: [], + }); + return html; + }, + http(request, next) { + let url = new URL(request.url); + if (url.pathname === css.csspath) { + return until(serveFile(request, css.filepath)); + } else { + return next(request); + } + }, + }; +} + +interface CSS { + filepath: string; + csspath: string; + href: string; +} + +function* compileCSS(options: TailwindOptions): Operation { + let { input, outdir } = options; + let output = join(outdir, input); + + yield* $( + `deno run -A \ +--unstable-detect-cjs \ +npm:@tailwindcss/cli@^4.0.0 \ +--config tailwind.config.ts \ +--input ${input} \ +--output ${output}`, + ); + + if (yield* until(exists(output))) { + let content = yield* until(Deno.readFile(output)); + let buffer = yield* until(crypto.subtle.digest("SHA-256", content)); + let hash = encodeHex(buffer); + return { + filepath: output, + csspath: `/${output}`, + href: `/${output}?${hash}`, + }; + } + + throw new Error(`failed to generate ${output}`); +} diff --git a/www/resources/blog.ts b/www/resources/blog.ts new file mode 100644 index 000000000..da17a117c --- /dev/null +++ b/www/resources/blog.ts @@ -0,0 +1,172 @@ +import { call, createContext, type Operation, resource } from "effection"; +import { existsSync } from "@std/fs"; +import { Fragment, jsx, jsxs } from "revolution/jsx-runtime"; +import { type JSXElement } from "revolution/jsx-runtime"; + +// deno-lint-ignore no-import-prefix +import { evaluate } from "npm:@mdx-js/mdx@3.1.0"; +import remarkFrontmatter from "remark-frontmatter"; +import remarkMdxFrontmatter from "remark-mdx-frontmatter"; +import remarkGfm from "remark-gfm"; +import rehypePrismPlus from "rehype-prism-plus"; +import rehypeSlug from "rehype-slug"; +import rehypeAddClasses from "rehype-add-classes"; +import rehypeAutolinkHeadings from "rehype-autolink-headings"; + +export interface Blog { + get(id: string): BlogPost | undefined; + getPosts(): BlogPost[]; + getPostsByTag(tag: string): BlogPost[]; +} + +export interface BlogPost { + id: string; + title: string; + description: string; + image: string | undefined; + date: Date; + author: string; + tags: string[]; + content: () => JSXElement; +} + +interface Frontmatter { + title: string; + description: string; + author: string; + tags: string[]; + image?: string; +} + +const BlogContext = createContext("blog"); + +export function* useBlog(): Operation { + return yield* BlogContext.expect(); +} + +export function* initBlog(): Operation { + let blog = yield* loadBlog(); + yield* BlogContext.set(blog); +} + +function* loadBlog(): Operation { + return yield* resource(function* (provide) { + // Blog posts are in the www/blog directory + let directory = new URL(import.meta.resolve("../blog/")).pathname; + + // Check if directory exists + if (!existsSync(directory)) { + // Return empty blog if no posts exist yet + yield* provide({ + get: () => undefined, + getPosts: () => [], + getPostsByTag: () => [], + }); + return; + } + + let entries = Deno.readDirSync(directory); + + // Find all blog post directories matching date pattern + let matches = [...entries].flatMap((entry) => { + let markdownfile = `${directory}${entry.name}/index.md`; + if (entry.isDirectory && existsSync(markdownfile)) { + let [match] = [...entry.name.matchAll(/(\d{4})-(\d{2})-(\d{2})-.*/g)]; + if (match) { + let [dirname, yearstring, monthstring, daystring] = match; + let date = new Date( + Number(yearstring), + Number(monthstring) - 1, + Number(daystring), + ); + return [{ markdownfile, dirname, date, id: dirname }]; + } + } + return []; + }); + + let posts = new Map(); + let tags = new Map(); + + // Process each post + for (let match of matches.toReversed()) { + let { date, id, markdownfile } = match; + let source = yield* call(() => Deno.readTextFile(markdownfile)); + + let mod = yield* call(() => + evaluate(source, { + jsx, + jsxs, + jsxDEV: jsx, + Fragment, + remarkPlugins: [ + remarkFrontmatter, + remarkMdxFrontmatter, + remarkGfm, + ], + rehypePlugins: [ + rehypeSlug, + [rehypePrismPlus, { showLineNumbers: true }], + [ + rehypeAutolinkHeadings, + { + behavior: "append", + properties: { + className: + "opacity-0 group-hover:opacity-100 after:content-['#'] after:ml-1.5 no-underline", + }, + }, + ], + [ + rehypeAddClasses, + { + "h1[id],h2[id],h3[id],h4[id],h5[id],h6[id]": + "group scroll-mt-[100px] grow", + pre: "grid", + }, + ], + ], + }) + ); + + let frontmatter = mod.frontmatter as Frontmatter; + let post: BlogPost = { + id, + date, + title: frontmatter.title, + description: frontmatter.description, + author: frontmatter.author, + tags: frontmatter.tags ?? [], + image: frontmatter.image, + content: () => mod.default({}) as JSXElement, + }; + + posts.set(id, post); + + // Index by tags + for (let tag of post.tags) { + let key = normalizeTag(tag); + if (tags.has(key)) { + tags.get(key)!.push(post); + } else { + tags.set(key, [post]); + } + } + } + + // Sort by date descending (newest first) + let values = [...posts.values()].sort( + (a, b) => b.date.getTime() - a.date.getTime(), + ); + + yield* provide({ + get: (id: string) => posts.get(id), + getPosts: () => values, + getPostsByTag: (tag: string) => tags.get(normalizeTag(tag)) ?? [], + }); + }); +} + +function normalizeTag(tag: string): string { + return tag.toLocaleUpperCase().replaceAll(/\W/g, "-"); +} diff --git a/www/resources/guides.ts b/www/resources/guides.ts new file mode 100644 index 000000000..f5ab0ef33 --- /dev/null +++ b/www/resources/guides.ts @@ -0,0 +1,168 @@ +import { basename } from "@std/path"; +import { + all, + createContext, + type Operation, + resource, + type Task, + until, + useScope, +} from "effection"; +import { JSXElement } from "revolution/jsx-runtime"; +import { z } from "zod"; + +import { useMarkdown } from "../hooks/use-markdown.tsx"; +import { createToc } from "../lib/toc.ts"; +import { useWorktree } from "../lib/worktrees.ts"; +import { $ } from "../context/shell.ts"; + +export interface DocModule { + default: () => JSX.Element; + frontmatter: { + id: string; + title: string; + }; +} + +export interface Guides { + all(): Operation; + get(id?: string): Operation; + first(): Operation; +} + +export interface Topic { + name: string; + items: GuidesMeta[]; +} + +export interface GuidesMeta { + id: string; + title: string; + filename: string; + topics: Topic[]; + next?: GuidesMeta; + prev?: GuidesMeta; +} + +export interface GuidesPage extends GuidesMeta { + content: JSXElement; + toc: JSXElement; + markdown: string; +} + +const Structure = z.record( + z.string(), + z.array(z.tuple([z.string(), z.string()])), +); + +export type StructureJson = z.infer; + +const GuidesContext = createContext>("guides"); + +export type GuidesOptions = { + current: string; + worktrees: string[]; +}; + +export function* initGuides(options: GuidesOptions): Operation { + let guides = new Map(); + + let path = yield* useGitRoot(); + guides.set(options.current, yield* loadGuides(path)); + + for (let series of options.worktrees) { + let path = yield* useWorktree(series); + guides.set(series, yield* loadGuides(path)); + } + + yield* GuidesContext.set(guides); +} + +export function* useGitRoot() { + let result = yield* $(`git rev-parse --show-toplevel`); + return result.stdout.trim(); +} + +export function* useGuides(series: string): Operation { + let guidesBySeries = yield* GuidesContext.expect(); + let guides = guidesBySeries.get(series); + if (!guides) { + throw new Error(`guides not found for series '${series}'`); + } + return guides; +} + +export function loadGuides(dirpath: string): Operation { + return resource(function* (provide) { + let scope = yield* useScope(); + let loaders = new Map>(); + + let structureModule = yield* until( + import(`${dirpath}/docs/structure.json`, { with: { type: "json" } }), + ); + + let structure = Structure.parse(structureModule.default); + + let entries = Object.entries(structure); + + let topics: Topic[] = []; + + for (let [name, contents] of entries) { + let topic: Topic = { name, items: [] }; + topics.push(topic); + + let current: GuidesMeta | undefined = void (0); + for (let i = 0; i < contents.length; i++) { + let prev: GuidesMeta | undefined = current; + let [filename, title] = contents[i]; + let meta: GuidesMeta = current = { + id: basename(filename, ".mdx"), + title, + filename: `docs/${filename}`, + topics, + prev, + }; + if (prev) { + prev.next = current; + } + topic.items.push(current); + + loaders.set( + meta.id, + scope.run(function* () { + let source = yield* until( + Deno.readTextFile(`${dirpath}/${meta.filename}`), + ); + + let content = yield* useMarkdown(source); + + return { + ...meta, + markdown: source, + content, + toc: createToc(content), + }; + }), + ); + } + } + + yield* provide({ + *first() { + let [[_id, task]] = loaders.entries(); + return yield* task; + }, + *all() { + return yield* all([...loaders.values()]); + }, + *get(id) { + if (id) { + let task = loaders.get(id); + if (task) { + return yield* task; + } + } + }, + }); + }); +} diff --git a/www/resources/jsr-client.ts b/www/resources/jsr-client.ts new file mode 100644 index 000000000..d674141fe --- /dev/null +++ b/www/resources/jsr-client.ts @@ -0,0 +1,110 @@ +import { call, type Operation, resource } from "effection"; +import { z } from "zod"; + +interface GetPackageDetailsParams { + scope: string; + package: string; +} + +const PackageScore = z.object({ + hasReadme: z.boolean(), + hasReadmeExamples: z.boolean(), + allEntrypointsDocs: z.boolean(), + allFastCheck: z.boolean(), + hasProvenance: z.boolean(), + hasDescription: z.boolean(), + atLeastOneRuntimeCompatible: z.boolean(), + multipleRuntimesCompatible: z.boolean(), + percentageDocumentedSymbols: z.number().min(0).max(1), + total: z.number(), +}); + +const PackageDetails = z.object({ + scope: z.string(), + name: z.string(), + description: z.string(), + runtimeCompat: z.object({ + browser: z.boolean().optional(), + deno: z.boolean().optional(), + node: z.boolean().optional(), + bun: z.boolean().optional(), + workerd: z.boolean().optional(), + }), + createdAt: z.string().datetime(), + updatedAt: z.string().datetime(), + githubRepository: z.object({ + id: z.number(), + owner: z.string(), + name: z.string(), + createdAt: z.string().datetime(), + updatedAt: z.string().datetime(), + }).nullable(), + score: z.number().min(0).max(100).nullable(), +}); + +export type PackageScoreResult = z.infer; +export type PackageDetailsResult = z.infer; + +export interface JSRClient { + getPackageScore: ( + params: GetPackageDetailsParams, + ) => Operation>; + getPackageDetails: ( + params: GetPackageDetailsParams, + ) => Operation>; +} + +export function createJSRClient(token: string): Operation { + return resource(function* (provide) { + yield* provide({ + *getPackageScore(params) { + let response = yield* call(() => + fetch( + `https://api.jsr.io/scopes/${params.scope}/packages/${params.package}/score`, + { + headers: { + Authorization: `Bearer ${token}`, + }, + }, + ) + ); + + if (response.ok) { + let json = yield* call(() => response.json()); + return yield* call(() => PackageScore.safeParseAsync(json)); + } + + throw new Error( + `Could not get package score for @${params.scope}/${params.package}`, + { + cause: `${response.status}: ${response.statusText}`, + }, + ); + }, + *getPackageDetails(params) { + let response = yield* call(() => + fetch( + `https://api.jsr.io/scopes/${params.scope}/packages/${params.package}`, + { + headers: { + Authorization: `Bearer ${token}`, + }, + }, + ) + ); + + if (response.ok) { + let json = yield* call(() => response.json()); + return yield* call(() => PackageDetails.safeParseAsync(json)); + } + + throw new Error( + `Could not get package details for @${params.scope}/${params.package}`, + { + cause: `${response.status}: ${response.statusText}`, + }, + ); + }, + }); + }); +} diff --git a/www/routes/api-index-route.tsx b/www/routes/api-index-route.tsx new file mode 100644 index 000000000..06fecb79c --- /dev/null +++ b/www/routes/api-index-route.tsx @@ -0,0 +1,106 @@ +import { type JSXElement } from "revolution"; + +import { Icon } from "../components/type/icon.tsx"; +import { DocPage } from "../hooks/use-deno-doc.tsx"; +import { ResolveLinkFunction } from "../hooks/use-markdown.tsx"; +import { usePackage } from "../lib/package.ts"; +import { SitemapRoute } from "../plugins/sitemap.ts"; +import { useAppHtml } from "./app.html.tsx"; +import { createChildURL } from "../lib/links-resolvers.ts"; + +export function apiIndexRoute( + { search }: { search: boolean }, +): SitemapRoute { + return { + *routemap(gen) { + return [{ pathname: gen() }]; + }, + handler: function* () { + let v3 = yield* usePackage({ + type: "worktree", + series: "v3", + }); + + let v4 = yield* usePackage({ + type: "worktree", + series: "v4", + }); + + let docs = { + v3: yield* v3.docs(), + v4: yield* v4.docs(), + }; + + let AppHtml = yield* useAppHtml({ + title: `API Reference | Effection`, + description: `API Reference for Effection`, + }); + + return ( + +
    +

    API Reference

    +
    +

    + {v4.version} + + + +

    +
      + {yield* listPages({ + pages: docs.v4["."], + linkResolver: createChildURL("v4"), + })} +
    +
    +
    +
    +

    + {v3.version} + + + +

    +
      + {yield* listPages({ + pages: docs.v3["."], + linkResolver: createChildURL("v3"), + })} +
    +
    +
    + + ); + }, + }; +} + +function* listPages({ + pages, + linkResolver, +}: { + pages: DocPage[]; + linkResolver: ResolveLinkFunction; +}) { + let elements = []; + + for (let page of pages.sort((a, b) => a.name.localeCompare(b.name))) { + let link = yield* linkResolver(page.name); + elements.push( +
  • + + + {page.name} + +
  • , + ); + } + return <>{elements}; +} diff --git a/www/routes/api-reference-route.tsx b/www/routes/api-reference-route.tsx new file mode 100644 index 000000000..343f4174d --- /dev/null +++ b/www/routes/api-reference-route.tsx @@ -0,0 +1,68 @@ +import { type JSXElement, useParams } from "revolution"; + +import { SitemapRoute } from "../plugins/sitemap.ts"; +import { useAppHtml } from "./app.html.tsx"; + +import { ApiPage } from "../components/api/api-page.tsx"; +import { usePackage } from "../lib/package.ts"; +import { createSibling } from "../lib/links-resolvers.ts"; +import { SiteConfig } from "../context/config.ts"; + +export function apiReferenceRoute(series: SiteConfig["series"][number], { + search, +}: { + search: boolean; +}): SitemapRoute { + return { + *routemap(generate) { + let pkg = yield* usePackage({ + type: "worktree", + series, + }); + + let docs = yield* pkg.docs(); + + return docs["."] + .map((node) => node.name) + .flatMap((symbol) => { + return [ + { + pathname: generate({ symbol }), + }, + ]; + }); + }, + handler: function* () { + let { symbol } = yield* useParams<{ symbol: string }>(); + + let pkg = yield* usePackage({ + type: "worktree", + series, + }); + + let docs = yield* pkg.docs(); + + let pages = docs["."]; + + let page = pages.find((node) => node.name === symbol); + + if (!page) throw new Error(`Could not find a doc page for ${symbol}`); + + let AppHtml = yield* useAppHtml({ + title: `${symbol} | API Reference | Effection`, + description: page.description, + }); + + return ( + + {yield* ApiPage({ + pages, + current: symbol, + pkg, + externalLinkResolver: createSibling, + })} + + ); + }, + }; +} diff --git a/www/routes/app.html.tsx b/www/routes/app.html.tsx new file mode 100644 index 000000000..99c0f4139 --- /dev/null +++ b/www/routes/app.html.tsx @@ -0,0 +1,98 @@ +import type { Operation } from "effection"; +import type { JSXChild } from "revolution"; + +import { Footer } from "../components/footer.tsx"; +import { Header, type HeaderProps } from "../components/header.tsx"; +import { useAbsoluteUrl, useCanonicalUrl } from "../plugins/current-request.ts"; +import { JSXElement } from "revolution/jsx-runtime"; + +export type Options = { + title: string; + description: string; + head?: JSXElement; +} & HeaderProps; + +export interface AppHtmlProps { + children: JSXChild; + search?: boolean; +} + +export function* useAppHtml({ + title, + description, + hasLeftSidebar, + head, +}: Options): Operation<({ children, search }: AppHtmlProps) => JSX.Element> { + let twitterImageURL = yield* useAbsoluteUrl( + "/assets/images/meta-effection.png", + ); + + let canonicalURL = yield* useCanonicalUrl({ + base: "https://frontside.com/effection", + }); + + let header = yield* Header({ hasLeftSidebar }); + + return ({ children, search }) => ( + + + + {title} + + + + + + + + + + + + + + + + {/* @ts-expect-error Custom element is-land from @11ty/is-land */} + + + {/* @ts-expect-error Custom element is-land from @11ty/is-land */} + + + + ); + }, + }; +} diff --git a/www/routes/x-index-route.tsx b/www/routes/x-index-route.tsx new file mode 100644 index 000000000..631ad787a --- /dev/null +++ b/www/routes/x-index-route.tsx @@ -0,0 +1,115 @@ +import { all } from "effection"; +import type { JSXElement } from "revolution"; +import { GithubPill } from "../components/package/source-link.tsx"; +import { useWorkspaces } from "../lib/workspaces/mod.ts"; +import type { SitemapRoute } from "../plugins/sitemap.ts"; +import { useAppHtml } from "./app.html.tsx"; +import { createChildURL, createSibling } from "../lib/links-resolvers.ts"; +import { softRedirect } from "./redirect.tsx"; + +export function xIndexRedirect(): SitemapRoute { + return { + *routemap(pathname) { + return [{ pathname: pathname() }]; + }, + *handler(req) { + return yield* softRedirect(req, yield* createSibling("x")); + }, + }; +} + +export function xIndexRoute({ + search, +}: { + search: boolean; +}): SitemapRoute { + return { + *routemap(gen) { + return [{ pathname: gen() }]; + }, + *handler() { + let workspaces = yield* useWorkspaces("thefrontside/effectionx"); + let packages = yield* workspaces.getAllPackages(); + + let AppHTML = yield* useAppHtml({ + title: "Extensions | Effection", + description: + "List of community contributed modules that represent emerging consensus on how to do common JavaScript tasks with Effection.", + }); + + let makeChildUrl = createChildURL(); + + return ( + +
    +
    +

    + Effection Extensions +

    + {yield* GithubPill({ + url: workspaces.url, + text: workspaces.nameWithOwner, + class: + "flex flex-row w-fit h-10 items-center rounded-full bg-gray-200 dark:bg-gray-800 px-2 py-1 text-gray-900 dark:text-gray-100", + })} +
    +

    + A collection of reusable, community-created extensions - ranging + from small packages to complete frameworks - that show the best + practices for handling common JavaScript tasks with Effection. +

    +
    +

    + Frameworks +

    + +
    +
    +

    + Packages +

    +
      + {yield* all( + packages.map(function* (pkg) { + let title = yield* pkg.getName(); + let description = yield* pkg.getDescription(); + + return ( +
    • + + + {title} + + + {description} + + +
    • + ); + }), + )} +
    +
    +
    +
    + ); + }, + }; +} diff --git a/www/routes/x-package-route.tsx b/www/routes/x-package-route.tsx new file mode 100644 index 000000000..9387dddf1 --- /dev/null +++ b/www/routes/x-package-route.tsx @@ -0,0 +1,241 @@ +import { call } from "effection"; +import { shiftHeading } from "hast-util-shift-heading"; +import type { Nodes } from "hast/"; +import { + type JSXElement, // @ts-types="revolution" + respondNotFound, + useParams, +} from "revolution"; + +import { select } from "hast-util-select"; +import { ApiBody } from "../components/api/api-page.tsx"; +import { PackageExports } from "../components/package/exports.tsx"; +import { PackageHeader } from "../components/package/header.tsx"; + +import { Icon } from "../components/type/icon.tsx"; +import { DocPageContext } from "../context/doc-page.ts"; +import { useMarkdown } from "../hooks/use-markdown.tsx"; +import { createToc } from "../lib/toc.ts"; +import { useWorkspaces } from "../lib/workspaces/mod.ts"; +import type { RoutePath, SitemapRoute } from "../plugins/sitemap.ts"; +import { useAppHtml } from "./app.html.tsx"; +import { createSibling } from "../lib/links-resolvers.ts"; +import { softRedirect } from "./redirect.tsx"; + +interface XPackageRouteParams { + search: boolean; +} + +function routemap(): SitemapRoute["routemap"] { + return function* (pathname) { + let paths: RoutePath[] = []; + + let workspaces = yield* useWorkspaces("thefrontside/effectionx"); + let workspaceNames = yield* workspaces.listWorkspaces(); + + for (let workspaceName of workspaceNames) { + paths.push({ + pathname: pathname({ + workspacePath: workspaceName, + }), + }); + } + + return paths; + }; +} + +export function xPackageRedirect(): SitemapRoute { + return { + routemap: routemap(), + *handler(req) { + let params = yield* useParams<{ workspacePath: string }>(); + return yield* softRedirect( + req, + yield* createSibling(params.workspacePath), + ); + }, + }; +} + +export function xPackageRoute({ + search, +}: XPackageRouteParams): SitemapRoute { + return { + routemap: routemap(), + *handler() { + let params = yield* useParams<{ workspacePath: string }>(); + + let workspaces = yield* useWorkspaces("thefrontside/effectionx"); + + let pkg = yield* workspaces.getWorkspace(params.workspacePath); + + if (!pkg) { + return yield* respondNotFound(); + } + + try { + let docs = yield* pkg.getDocs(); + let pkgName = yield* pkg.getName(); + let pkgDescription = yield* pkg.getDescription(); + + let AppHTML = yield* useAppHtml({ + title: `${pkgName} | Extensions | Effection`, + description: pkgDescription, + }); + + let linkResolver = function* ( + symbol: string, + connector?: string, + method?: string, + ) { + let internal = `#${symbol}_${method}`; + if (connector === "_") { + return internal; + } + let page = docs["."].find( + (page) => page.name === symbol && page.kind !== "import", + ); + + if (page) { + // get internal link + return `[${symbol}](#${page.kind}_${page.name})`; + } + + return symbol; + }; + + let apiReference = []; + + let entrypoints = Object.entries(docs); + + for (let [entrypoint, pages] of entrypoints) { + let sections = []; + for (let page of pages) { + let content = yield* call(function* () { + yield* DocPageContext.set(page); + return yield* ApiBody({ page, linkResolver }); + }); + sections.push(content); + } + if (entrypoint.length === 1 && entrypoint === ".") { + apiReference.push( +
    + <>{sections} +
    , + ); + } else if (pages.length > 0) { + apiReference.push( +
    +

    {entrypoint}

    + <>{sections} +
    , + ); + } + } + + apiReference.forEach((section) => shiftHeading(section, 1)); + + let content = ( + <> + {yield* useMarkdown(yield* pkg.getReadme(), { linkResolver })} +

    API Reference

    + <>{apiReference} + + ); + + let toc = createToc(content, { + headings: ["h2", "h3"], + cssClasses: { + toc: + "hidden text-sm font-light tracking-wide leading-loose lg:block relative", + link: "flex flex-row items-center", + }, + customizeTOCItem(item, heading) { + heading.properties.class = [ + heading.properties.class, + `group grow scroll-mt-[100px]`, + ] + .filter(Boolean) + .join(""); + + let ol = select("ol.toc-level-2, ol.toc-level-3", item as Nodes); + if (ol) { + ol.properties.className = `${ol.properties.className} ml-6`; + } + if ( + heading.properties["data-kind"] && + heading.properties["data-name"] + ) { + item.properties.className += " mb-1"; + let a = select("a", item as Nodes); + if (a) { + // deno-lint-ignore no-explicit-any + (a as any).children = [ + , + + {heading.properties["data-name"]} + , + ]; + } + } else { + let a = select("a", item as Nodes); + if (a) { + a.properties.className = + `hover:underline hover:underline-offset-2`; + } + } + return item; + }, + }); + + return ( + + <> +
    +
    + {yield* PackageHeader(pkg)} +
    +
    + {yield* PackageExports({ + packageName: pkgName, + docs, + linkResolver, + })} +
    + {content} +
    +
    + +
    + +
    + ); + } catch (e) { + console.error(e); + let AppHTML = yield* useAppHtml({ + title: `${params.workspacePath} not found`, + description: `Failed to load ${params.workspacePath} due to error.`, + }); + return ( + +

    Failed to load {params.workspacePath} due to error.

    +
    + ); + } + }, + }; +} diff --git a/www/tailwind.config.ts b/www/tailwind.config.ts new file mode 100644 index 000000000..a7d299e0a --- /dev/null +++ b/www/tailwind.config.ts @@ -0,0 +1,26 @@ +import { Config } from "tailwindcss"; + +export default { + content: [ + "./src/**/*.{js,ts,jsx,tsx,mdx}", + "./pages/**/*.{js,ts,jsx,tsx,mdx}", + "./components/**/*.{js,ts,jsx,tsx,mdx}", + ], + theme: { + fontFamily: { + sans: ["Proxima Nova", "proxima-nova", "sans-serif"], + inter: ["Inter", "inter", "san-serif"], + }, + extend: { + colors: { + "blue-primary": "#14315D", + "blue-secondary": "#26ABE8", + "pink-secondary": "#F74D7B", + "typescript-blue": "#3178c6", + }, + screens: { + sm: { max: "540px" }, + }, + }, + }, +} satisfies Config; diff --git a/www/testing.ts b/www/testing.ts new file mode 100644 index 000000000..d7188e256 --- /dev/null +++ b/www/testing.ts @@ -0,0 +1,62 @@ +import type { Operation } from "effection"; +import { + afterAll as $afterAll, + describe as $describe, + it as $it, +} from "@std/testing/bdd"; +import { createTestAdapter, type TestAdapter } from "./testing/adapter.ts"; + +let current: TestAdapter | undefined; + +export function describe(name: string, body: () => void) { + let isTop = !current; + let original = current; + try { + let child = current = createTestAdapter({ name, parent: original }); + if (isTop) { + // + } + + $describe(name, () => { + $afterAll(() => child.destroy()); + body(); + }); + } finally { + current = original; + } +} + +describe.skip = $describe.skip; +describe.only = $describe.only; + +export function beforeEach(body: () => Operation) { + current?.addSetup(body); +} + +export function it(desc: string, body?: () => Operation): void { + let adapter = current!; + if (!body) { + return $it.skip(desc, () => {}); + } + $it(desc, async () => { + let result = await adapter.runTest(body); + if (!result.ok) { + throw result.error; + } + }); +} + +it.skip = (...args: Parameters): ReturnType => { + let [desc] = args; + $it.skip(desc, () => {}); +}; + +it.only = (desc: string, body: () => Operation): void => { + let adapter = current!; + $it.only(desc, async () => { + let result = await adapter.runTest(body); + if (!result.ok) { + throw result.error; + } + }); +}; diff --git a/www/testing/adapter.ts b/www/testing/adapter.ts new file mode 100644 index 000000000..d5a58f952 --- /dev/null +++ b/www/testing/adapter.ts @@ -0,0 +1,134 @@ +import type { Future, Operation, Result, Scope } from "effection"; +import { createScope, Err, Ok } from "effection"; + +export interface TestOperation { + (): Operation; +} + +export interface TestAdapter { + /** + * The parent of this adapter. All of the setup from this adapter will be + * run in addition to the setup of this adapter during `runTest()` + */ + readonly parent?: TestAdapter; + + /** + * The name of this adapter which is mostly useful for debugging purposes + */ + readonly name: string; + + /** + * A qualified name that contains not only the name of this adapter, but of all its + * ancestors. E.g. `All Tests > File System > write` + */ + readonly fullname: string; + + /** + * Every test adapter has its own Effection `Scope` which holds the resources necessary + * to run this test. + */ + readonly scope: Scope; + + /** + * A list of this test adapter and every adapter that it descends from. + */ + readonly lineage: Array; + + /** + * The setup operations that will be run by this test adapter. It only includes those + * setups that are associated with this adapter, not those of its ancestors. + */ + readonly setups: TestOperation[]; + + /** + * Add a setup operation to every test that is part of this adapter. In BDD integrations, + * this is usually called by `beforEach()` + */ + addSetup(op: TestOperation): void; + + /** + * Actually run a test. This evaluates all setup operations, and then after those have completed + * it runs the body of the test itself. + */ + runTest(body: TestOperation): Future>; + + /** + * Teardown this test adapter and all of the task and resources that are running inside it. + * This basically destroys the Effection `Scope` associated with this adapter. + */ + destroy(): Future; +} + +export interface TestAdapterOptions { + /** + * The name of this test adapter which is handy for debugging. + * Usually, you'll give this the same name as the current test + * context. For example, when integrating with BDD, this would be + * the same as + */ + name?: string; + /** + * The parent test adapter. All of the setup from this adapter will be + * run in addition to the setup of this adapter during `runTest()` + */ + parent?: TestAdapter; +} + +const anonymousNames: Iterator = (function* () { + let count = 1; + while (true) { + yield `anonymous test adapter ${count++}`; + } +})(); + +/** + * Create a new test adapter with the given options. + */ +export function createTestAdapter( + options: TestAdapterOptions = {}, +): TestAdapter { + let setups: TestOperation[] = []; + let { parent, name = anonymousNames.next().value } = options; + + let [scope, destroy] = createScope(parent?.scope); + + let adapter: TestAdapter = { + parent, + name, + scope, + setups, + get lineage() { + let lineage = [adapter]; + for (let current = parent; current; current = current.parent) { + lineage.unshift(current); + } + return lineage; + }, + get fullname() { + return adapter.lineage.map((adapter) => adapter.name).join(" > "); + }, + addSetup(op) { + setups.push(op); + }, + runTest(op) { + return scope.run(function* () { + let allSetups = adapter.lineage.reduce( + (all, adapter) => all.concat(adapter.setups), + [] as TestOperation[], + ); + try { + for (let setup of allSetups) { + yield* setup(); + } + yield* op(); + return Ok(void 0); + } catch (error) { + return Err(error as Error); + } + }); + }, + destroy, + }; + + return adapter; +} diff --git a/www/testing/helpers.ts b/www/testing/helpers.ts new file mode 100644 index 000000000..f7614967d --- /dev/null +++ b/www/testing/helpers.ts @@ -0,0 +1,51 @@ +import { type Operation, until } from "effection"; +import { $ } from "../context/shell.ts"; +import * as fs from "@std/fs"; + +export function ensureDir(dir: string | URL) { + return until(fs.ensureDir(dir)); +} + +export function writeTextFile( + path: string | URL, + data: string | ReadableStream, + options?: Deno.WriteFileOptions, +) { + return until(Deno.writeTextFile(path, data, options)); +} + +export interface GitCommit { + sha: string; + message: string; + tags: string[]; +} + +/** + * Gets git commit history from current directory in chronological order with detailed info + * @returns Array of commits with sha, message, and tags in chronological order (oldest first) + */ +export function* getGitHistory(): Operation { + // Get commit history with hash and message + let historyResult = yield* $(`git log --format="%H|%s" --reverse`); + + let lines = historyResult.stdout.split("\n").filter((line) => + line.length > 0 + ); + let commits: GitCommit[] = []; + + for (let line of lines) { + let [sha, message] = line.split("|"); + + // Get tags for this commit using $ shell utility + try { + let tagsResult = yield* $(`git tag --points-at ${sha}`); + let tags = tagsResult.stdout.split("\n").filter((tag) => tag.length > 0); + commits.push({ sha, message, tags }); + } catch { + // No tags for this commit + commits.push({ sha, message, tags: [] }); + } + } + + return commits; +} diff --git a/www/testing/logging.ts b/www/testing/logging.ts new file mode 100644 index 000000000..ee732ab58 --- /dev/null +++ b/www/testing/logging.ts @@ -0,0 +1,62 @@ +import { createQueue, type Queue, resource } from "effection"; +import { loggerApi } from "../context/logging.ts"; + +const levels = ["info", "warn", "debug", "error"] as const; + +type LogEvent = + | { type: "info"; args: unknown[] } + | { type: "warn"; args: unknown[] } + | { type: "debug"; args: unknown[] } + | { type: "error"; args: unknown[] }; + +export function* setupLogging(level: (typeof levels)[number] | false) { + let queue = yield* resource>(function* (provide) { + let queue = createQueue(); + try { + yield* provide(queue); + } finally { + queue.close(); + } + }); + + if (level === false) { + yield* loggerApi.around({ + *info() {}, + *debug() {}, + *warn() {}, + *error() {}, + }); + return; + } + yield* loggerApi.around({ + *info(args, next) { + if (level === "info") { + queue.add({ type: "info", args }); + return yield* next(...args); + } + }, + *warn(args, next) { + if (level === "info" || level === "warn") { + queue.add({ type: "warn", args }); + return yield* next(...args); + } + }, + *debug(args, next) { + if (level === "info" || level === "warn" || level === "debug") { + queue.add({ type: "debug", args }); + return yield* next(...args); + } + }, + *error(args, next) { + if ( + level === "info" || level === "warn" || level === "debug" || + level === "error" + ) { + queue.add({ type: "error", args }); + return yield* next(...args); + } + }, + }); + + return queue; +} diff --git a/www/testing/temp-dir.ts b/www/testing/temp-dir.ts new file mode 100644 index 000000000..503236bb6 --- /dev/null +++ b/www/testing/temp-dir.ts @@ -0,0 +1,71 @@ +import { type Operation, resource, until } from "effection"; +import { ensureDir, ensureFile } from "@std/fs"; +import { join } from "@std/path"; + +function* writeFiles( + dir: string, + files: Record, +): Operation { + for (let [path, content] of Object.entries(files)) { + yield* until(ensureFile(join(dir, path))); + yield* until(Deno.writeTextFile(join(dir, path), content)); + } +} + +export interface TempDir { + withFiles(files: Record): Operation; + withWorkspace( + workspace: string, + files: Record, + ): Operation; + path: string; +} + +interface CreateTempDirParams { + autoClean?: boolean; + baseDir?: string; +} + +export function createTempDir( + params?: CreateTempDirParams, +): Operation { + return resource(function* (provide) { + let { + baseDir, + autoClean, + } = params || {}; + let dir: string; + + if (baseDir) { + // Create directory in specified base directory + yield* until(ensureDir(baseDir)); + let timestamp = Date.now().toString(36); + let randomSuffix = Math.random().toString(36).substring(2, 8); + let dirName = `${timestamp}-${randomSuffix}`; + dir = join(baseDir, dirName); + yield* until(ensureDir(dir)); + } else { + // Fall back to system temp directory + dir = yield* until(Deno.makeTempDir()); + } + + try { + yield* provide({ + get path() { + return dir; + }, + *withFiles(files: Record) { + yield* writeFiles(dir, files); + }, + *withWorkspace(workspace: string, files: Record) { + yield* writeFiles(join(dir, workspace), files); + }, + }); + } finally { + // Only remove if we created it (not if it's in a managed base directory) + if (autoClean) { + yield* until(Deno.remove(dir, { recursive: true })); + } + } + }); +}