diff --git a/.depot/actions/setup/action.yml b/.depot/actions/setup/action.yml new file mode 100644 index 0000000..5b0a099 --- /dev/null +++ b/.depot/actions/setup/action.yml @@ -0,0 +1,16 @@ +# THE SETUP OF A JOB ON DEPOT'S STOCK IMAGE, after its checkout: `uses: ./.depot/actions/setup`. +# Node, pnpm and the Doppler CLI (scripts/ci/toolchain.sh), then the install. Docs' install takes +# seconds from the npm registry, so no store is cached. +name: Setup +description: Node, pnpm and the Doppler CLI, and the install +runs: + using: composite + steps: + - name: Toolchain + shell: bash + run: | + bash scripts/ci/toolchain.sh start + bash scripts/ci/toolchain.sh wait + - name: Install dependencies + shell: bash + run: pnpm install --frozen-lockfile diff --git a/.depot/workflows/ci.yml b/.depot/workflows/ci.yml new file mode 100644 index 0000000..29d6c04 --- /dev/null +++ b/.depot/workflows/ci.yml @@ -0,0 +1,45 @@ +name: CI + +# The checks every pull request and main push runs: typecheck, lint, format, and the Vitest suites +# (@iterate-com/docs' processors and the CI scripts). +permissions: + contents: read + +on: + push: + branches: + - main + pull_request: + workflow_dispatch: + +concurrency: + group: ci-${{ github.head_ref || github.ref_name }} + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} + +jobs: + check: + name: Typecheck, lint and test + runs-on: depot-ubuntu-24.04 + timeout-minutes: 15 + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + persist-credentials: false + ref: ${{ github.sha }} + - name: Setup + uses: ./.depot/actions/setup + - name: Typecheck + run: pnpm typecheck + - name: Lint + if: ${{ !cancelled() }} + run: pnpm lint + - name: Format + if: ${{ !cancelled() }} + run: pnpm format:check + - name: Test + if: ${{ !cancelled() }} + run: pnpm test + - name: Build the Docs app + if: ${{ !cancelled() }} + run: pnpm --dir packages/docs-app build diff --git a/.depot/workflows/deploy.yml b/.depot/workflows/deploy.yml new file mode 100644 index 0000000..c1fde45 --- /dev/null +++ b/.depot/workflows/deploy.yml @@ -0,0 +1,80 @@ +name: Deploy + +# DOCS AT MAIN: every main push that changes what the Worker ships deploys `preview` +# (docs.iterate-dev-preview.workers.dev) and then, once that answers, `prd` (docs.iterate.workers.dev, +# which the iterate project's `docs` slug and docs.iterate.com route to), both from envs.ts. A red run +# pages. +permissions: + contents: read + +concurrency: + group: deploy-main + cancel-in-progress: false + +on: + push: + branches: + - main + paths: + - "**" + - "!**/*.md" + - "!.github/**" + - "!scripts/ci/npm-publish*" + workflow_dispatch: + +jobs: + preview: + name: Deploy preview + runs-on: depot-ubuntu-24.04 + timeout-minutes: 20 + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + persist-credentials: false + ref: ${{ github.sha }} + - name: Setup + uses: ./.depot/actions/setup + - name: Deploy + env: + DOPPLER_TOKEN: ${{ secrets.DOPPLER_TOKEN }} + run: pnpm --dir packages/docs-app run-script deploy --env preview + + # only after preview answered: a deploy that fails there never reaches production + prd: + name: Deploy prd + needs: preview + runs-on: depot-ubuntu-24.04 + timeout-minutes: 20 + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + persist-credentials: false + ref: ${{ github.sha }} + - name: Setup + uses: ./.depot/actions/setup + - name: Deploy + env: + DOPPLER_TOKEN: ${{ secrets.DOPPLER_TOKEN }} + run: pnpm --dir packages/docs-app run-script deploy --env prd + + page: + needs: + - preview + - prd + if: always() && github.ref == 'refs/heads/main' && contains(needs.*.result, 'failure') + runs-on: depot-ubuntu-24.04 + timeout-minutes: 10 + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + persist-credentials: false + - name: Toolchain + run: bash scripts/ci/toolchain.sh start && bash scripts/ci/toolchain.sh wait + - name: Page #error-pulse + env: + DOPPLER_TOKEN: ${{ secrets.DOPPLER_TOKEN }} + NEEDS: ${{ toJSON(needs) }} + run: node scripts/ci/page.ts diff --git a/.github/workflows/npm-publish.yml b/.github/workflows/npm-publish.yml new file mode 100644 index 0000000..b12784f --- /dev/null +++ b/.github/workflows/npm-publish.yml @@ -0,0 +1,69 @@ +name: npm +# Every main commit publishes this repo's packages whose packed contents changed to npm, under the +# `main` dist-tag, and points each package's `latest` at the same version (scripts/ci/npm-publish.ts +# says how); any other ref, under `canary`, which leaves `latest` alone. Pull requests' builds stay +# on pkg.pr.new (pkg-pr-new.yml): npm is public and permanent, so only reviewed code goes there. +# +# A GitHub Actions workflow, not Depot's: npm's trusted publishing takes an OIDC token from GitHub's +# own runners. Each package's settings on npmjs.com name this repository and this file. +# +# A pull request that can change which packages main publishes, or how, publishes nothing. Its +# `check` job fails until npm is ready for main to publish each package, and says what sets up one +# that is not (README.md "A new package on npm"). The two jobs share no step and never run +# together; they share this file because npm trades a token only for the file a package trusts, so +# a check in a workflow of its own could not ask npm as the one that publishes. +on: + push: + branches: + - main + pull_request: + paths: + # which packages: pkg-pr-new.yml's list of folders and the name in each folder's manifest + # (scripts/ci/npm-publish.test.ts holds every listed one to these) + - .github/workflows/pkg-pr-new.yml + - packages/*/package.json + # how + - .github/workflows/npm-publish.yml + - scripts/ci/npm-publish.ts + workflow_dispatch: {} +# One publish at a time, in push order, so `main` never moves back to an older commit's version. A +# run queued behind another is replaced by a newer push's, which publishes whatever both changed. A +# pull request's run has a group of its own, so that it replaces no publish. +concurrency: + group: ${{ github.event_name == 'pull_request' && format('npm-check-{0}', github.event.pull_request.number) || 'npm-publish' }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} +jobs: + publish: + if: github.event_name != 'pull_request' + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 1 + - uses: pnpm/action-setup@v6 + - uses: actions/setup-node@v7 + with: + node-version-file: .nvmrc + # trusted publishing needs npm 11.5.1 or later + - run: npm install --global npm@11 + - run: pnpm install --frozen-lockfile --filter iterate-docs --filter @iterate-com/docs... + - run: node scripts/ci/npm-publish.ts publish --dist-tag ${{ github.ref == 'refs/heads/main' && 'main' || 'canary' }} + check: + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 1 + - uses: pnpm/action-setup@v6 + - uses: actions/setup-node@v7 + with: + node-version-file: .nvmrc + - run: pnpm install --frozen-lockfile --filter iterate-docs + - run: node scripts/ci/npm-publish.ts check diff --git a/.github/workflows/pkg-pr-new.yml b/.github/workflows/pkg-pr-new.yml new file mode 100644 index 0000000..02f6870 --- /dev/null +++ b/.github/workflows/pkg-pr-new.yml @@ -0,0 +1,33 @@ +name: pkg.pr.new +# Every main commit and every pull request's head publishes @iterate-com/docs to pkg.pr.new, so any +# of them is installable: pkg.pr.new/iterate/docs/@iterate-com/docs@. A project pins one to try +# a pull request's processors, and the Docs app a per-commit deployment builds installs its own +# commit's (projects.$slug.$repo.$.tsx `docsBuild`). main's npm publish (npm-publish.yml) publishes +# the same packages, by this list. +# +# A GitHub Actions workflow, not Depot's: pkg.pr.new's GitHub App authenticates the run by its +# GitHub token. +on: + push: + branches: + - main + pull_request: +jobs: + publish: + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 1 + - uses: pnpm/action-setup@v6 + - uses: actions/setup-node@v7 + with: + node-version-file: .nvmrc + - run: pnpm install --frozen-lockfile --filter iterate-docs --filter @iterate-com/docs... + - run: pnpm --dir packages/docs build + # --pnpm: pack with `pnpm pack` so publishConfig.exports (dist remap) is applied and `catalog:` + # becomes a version; `npm pack` would ship the dev exports pointing at src/. npm-publish.ts + # reads the packages off this line. + - run: pnpm exec pkg-pr-new publish --pnpm ./packages/docs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..68d7900 --- /dev/null +++ b/.gitignore @@ -0,0 +1,21 @@ +.DS_Store +node_modules +*.tsbuildinfo + +# TanStack and Vite +.tanstack +dist + +# Cloudflare +.mf +.wrangler +.dev.vars* +!.dev.vars.example +.env* +!.env.example + +__pycache__/ + +# ad-hoc ignored files/paths +*ignoreme* +*.log diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..a45fd52 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +24 diff --git a/.oxfmtrc.json b/.oxfmtrc.json new file mode 100644 index 0000000..741fbe7 --- /dev/null +++ b/.oxfmtrc.json @@ -0,0 +1,26 @@ +{ + "$schema": "./node_modules/oxfmt/configuration_schema.json", + "printWidth": 100, + "tabWidth": 2, + "useTabs": false, + "semi": true, + "singleQuote": false, + "quoteProps": "as-needed", + "trailingComma": "all", + "bracketSpacing": true, + "bracketSameLine": false, + "arrowParens": "always", + "endOfLine": "lf", + "embeddedLanguageFormatting": "auto", + "sortPackageJson": false, + "ignorePatterns": [ + "node_modules/", + "dist/", + ".wrangler/", + "routeTree.gen.ts", + "pnpm-lock.yaml", + "pnpm-workspace.yaml", + "packages/docs-app/src/components/ui/", + "packages/docs-app/src/hooks/use-mobile.ts" + ] +} diff --git a/.oxlintrc.json b/.oxlintrc.json new file mode 100644 index 0000000..55929b2 --- /dev/null +++ b/.oxlintrc.json @@ -0,0 +1,131 @@ +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "plugins": ["typescript", "react", "import"], + "categories": { + "correctness": "off", + "suspicious": "off", + "pedantic": "off", + "perf": "off", + "style": "off", + "restriction": "off", + "nursery": "off" + }, + "env": { + "builtin": true, + "node": true + }, + "ignorePatterns": [ + "**/src/components/ui/*.tsx", + "**/routeTree.gen.ts", + "**/*.d.ts", + "**/node_modules/", + "**/dist/" + ], + "rules": { + "constructor-super": "error", + "for-direction": "error", + "no-async-promise-executor": "error", + "no-case-declarations": "error", + "no-class-assign": "error", + "no-compare-neg-zero": "error", + "no-cond-assign": "error", + "no-const-assign": "error", + "no-constant-binary-expression": "error", + "no-constant-condition": "error", + "no-control-regex": "error", + "no-debugger": "error", + "no-delete-var": "error", + "no-dupe-class-members": "error", + "no-dupe-else-if": "error", + "no-dupe-keys": "error", + "no-duplicate-case": "error", + "no-empty-character-class": "error", + "no-empty-pattern": "error", + "no-empty-static-block": "error", + "no-ex-assign": "error", + "no-extra-boolean-cast": "error", + "no-fallthrough": "error", + "no-func-assign": "error", + "no-global-assign": "error", + "no-import-assign": "error", + "no-invalid-regexp": "error", + "no-irregular-whitespace": "error", + "no-loss-of-precision": "error", + "no-misleading-character-class": "error", + "no-new-native-nonconstructor": "error", + "no-nonoctal-decimal-escape": "error", + "no-obj-calls": "error", + "no-prototype-builtins": "error", + "no-regex-spaces": "error", + "no-self-assign": "error", + "no-setter-return": "error", + "no-shadow-restricted-names": "error", + "no-sparse-arrays": "error", + "no-this-before-super": "error", + "no-unexpected-multiline": "error", + "no-unsafe-finally": "error", + "no-unsafe-negation": "error", + "no-unsafe-optional-chaining": "error", + "no-unused-labels": "error", + "no-unused-private-class-members": "error", + "no-unused-vars": "error", + "no-useless-backreference": "error", + "no-useless-catch": "error", + "no-useless-escape": "error", + "no-with": "error", + "require-yield": "error", + "use-isnan": "error", + "valid-typeof": "error", + "prefer-const": "off" + }, + "overrides": [ + { + "files": ["**/*.ts", "**/*.tsx", "**/*.js", "**/*.mjs"], + "plugins": ["typescript", "import", "react", "jsx-a11y"], + "rules": { + "no-unused-vars": [ + "warn", + { + "args": "all", + "argsIgnorePattern": "^_", + "caughtErrors": "all", + "caughtErrorsIgnorePattern": "^_", + "destructuredArrayIgnorePattern": "^_", + "varsIgnorePattern": "^_", + "ignoreRestSiblings": true + } + ], + "no-var": "error", + "@typescript-eslint/prefer-literal-enum-member": "error", + "@typescript-eslint/prefer-as-const": "error", + "@typescript-eslint/prefer-enum-initializers": "error", + "@typescript-eslint/no-inferrable-types": "error", + "default-param-last": "error", + "import/extensions": ["error", "ignorePackages"], + "no-sequences": "error", + "prefer-numeric-literals": "error", + "no-new-func": "error", + "react/only-export-components": [ + "warn", + { + "allowConstantExport": true + } + ] + } + }, + { + "files": ["**/*.tsx"], + "plugins": ["react"], + "rules": { + "react-hooks/rules-of-hooks": "error", + "react-hooks/exhaustive-deps": "warn" + } + }, + { + "files": ["**/src/routes/**", "**/src/components/**", "**/src/app/**"], + "rules": { + "react/only-export-components": "off" + } + } + ] +} diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..69f7ece --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,18 @@ +# iterate Docs + +`@iterate-com/docs` (the processors a project installs) and the Docs app (a Worker). Read +[README.md](README.md) first, then the package's own README. `packages/docs/AGENTS.md` is for agents +working on a project's docs through the platform, not on this repo. + +- Before pushing: `pnpm typecheck && pnpm lint && pnpm format && pnpm test`. +- Never hand-edit `packages/docs-app/src/routeTree.gen.ts`: `pnpm --dir packages/docs-app routes:generate`. +- `envs.ts` owns the deployments; Doppler project `_shared` holds the deploy credentials. Main + deploys itself (`.depot/workflows/deploy.yml`). Workers are never deleted. +- The repository stays public: projects install `@iterate-com/docs` from it, and agents read + `packages/docs/AGENTS.md` from its main. +- `packages/docs-app/src/components/ui/` is shadcn's, byte for byte: re-add a component with + `pnpm exec shadcn add ` instead of editing it. iterate's own components come from + `iterate/packages/` the same way. +- Code copied from iterate's monorepo says so at the top, with the commit it came from. +- New React code: no `useEffect`, and `useState` only as a last resort; server state is TanStack + Query. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d645695 --- /dev/null +++ b/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README.md b/README.md new file mode 100644 index 0000000..366f83f --- /dev/null +++ b/README.md @@ -0,0 +1,53 @@ +# iterate Docs + +Markdown docs in an [iterate](https://iterate.com) project's repos, co-edited live in the browser. + +| Path | What | +| ------------------- | --------------------------------------------------------------------------------------------------------------------- | +| `packages/docs` | `@iterate-com/docs`: the processors a project installs, which hold an open doc as Yjs, autosave it, keep its comments | +| `packages/docs-app` | The Docs app: a TanStack Start Worker that projects serve on their `docs` routing slug | + +```sh +pnpm install +pnpm typecheck +pnpm lint +pnpm format +pnpm test +``` + +## Releases and deploys + +- **pkg.pr.new**: every main commit and pull request head publishes `@iterate-com/docs` + (`pkg.pr.new/iterate/docs/@iterate-com/docs@`, `.github/workflows/pkg-pr-new.yml`). +- **npm**: every main commit whose packed package changed publishes + `-main.-` under the `main` and `latest` dist-tags + (`.github/workflows/npm-publish.yml`, `scripts/ci/npm-publish.ts`). +- **The app**: main deploys `preview` (docs.iterate-dev-preview.workers.dev), then `prd` + (docs.iterate.workers.dev), both in `envs.ts` (`.depot/workflows/deploy.yml`). A red run pages + #error-pulse. + +CI runs on [Depot](https://depot.dev) (`.depot/workflows/`); its one secret is the org's +`DOPPLER_TOKEN`. pkg.pr.new and npm run on GitHub Actions, which their GitHub App and trusted +publishing need. + +### A new package on npm + +A package joins main's npm publish by joining pkg-pr-new.yml's `pkg-pr-new publish` line. The pull +request that adds it fails **npm / check** until npm has three things for it, which the job's +summary prints the commands for: + +1. **The package**: `npm login`, then + `cd "$(mktemp -d)" && echo '{"name":"","version":"0.0.0"}' > package.json && npm publish --access public`. +2. **A trusted publisher** for `iterate/docs` and `npm-publish.yml` with "Allow npm publish": + `npx npm@latest trust github --repo iterate/docs --file npm-publish.yml --allow-publish`. +3. **"Allow npm dist-tag"** on that trusted publisher: `https://www.npmjs.com/package//access`, + **Edit**, tick it, save. The CLI can't set it. + +## History + +Docs moved here from iterate's monorepo (iterate/private) on 2026-11-05. Main starts with Docs' own +commits: the iterate/iterate archive's (`apps/docs` written as `packages/docs-app`), each ending in +`Original-Commit: iterate/iterate@`, then iterate/packages' public copies of iterate/private's, +each ending in `Original-Commit: iterate/packages@`. + +Apache-2.0. diff --git a/doppler.yaml b/doppler.yaml new file mode 100644 index 0000000..269ba16 --- /dev/null +++ b/doppler.yaml @@ -0,0 +1,5 @@ +# `doppler run` in the Docs app reads project `_shared`, which holds the Cloudflare token every +# iterate app deploys with. The deploy script names its own config (envs.ts `dopplerConfig`). +setup: + - project: _shared + path: ./ diff --git a/envs.ts b/envs.ts new file mode 100644 index 0000000..f442968 --- /dev/null +++ b/envs.ts @@ -0,0 +1,89 @@ +/** Docs' deployments. packages/docs-app/vite.config.ts turns one into the Worker's config (there is + * no wrangler file), and `pnpm --dir packages/docs-app run deploy --env ` ships it + * (packages/docs-app/scripts/app.ts). Docs has no secrets of its own: the deploy reads the + * Cloudflare token every iterate app deploys with, in Doppler project `_shared`, one config per + * deployment. + * + * Docs is served only under a project's hosts: a members-only fetch route on the project fetches + * through to this Worker, whose own URL no one signs in on. The prd iterate project routes its + * `docs` slug and docs.iterate.com to `prd`, so the Worker keeps a workers.dev origin. */ + +export interface DocsEnv { + cloudflareAccountId: string; + /** Doppler config (project `_shared`) supplying the deploy's Cloudflare token. */ + dopplerConfig: string; + workerName: string; + baseUrl: string; + /** The iterate platform whose projects serve this Docs, and its first-party apps, as iterate's + * envs.ts names them: the Worker's `ITERATE_APP` `urls` (src/app/start-app-config.ts), Docs' own + * `baseUrl` beside them. Docs reads `dash`, where a doc's "Stream ↗" opens its context. */ + urls: { os: string; dash: string; agents: string; notes: string; admin: string; voice: string }; + /** PostHog's project key ("iterate (prd)" in PostHog EU; public, it ships in every page). Unset ⇒ + * no PostHog. */ + posthogProjectKey?: string; +} + +/** The two Cloudflare accounts: production (iterate.com's zones), and dev/preview. */ +const PRD_ACCOUNT_ID = "04b3b57291ef2626c6a8daa9d47065a7"; +const PREVIEW_AND_DEV_ACCOUNT_ID = "376ef7ed81b0573f93524de763666c15"; + +export const docsEnvs = { + // DOCS AT MAIN on the dev/preview account, for the projects of iterate's main on dev + preview: { + cloudflareAccountId: PREVIEW_AND_DEV_ACCOUNT_ID, + dopplerConfig: "preview", + workerName: "docs", + baseUrl: "https://docs.iterate-dev-preview.workers.dev", + urls: { + os: "https://os.iterate-dev-preview.workers.dev", + dash: "https://dash.iterate-dev-preview.workers.dev", + agents: "https://agents.iterate-dev-preview.workers.dev", + notes: "https://notes.iterate-dev-preview.workers.dev", + admin: "https://admin.iterate-dev-preview.workers.dev", + voice: "https://voice.iterate-dev-preview.workers.dev", + }, + }, + prd: { + cloudflareAccountId: PRD_ACCOUNT_ID, + dopplerConfig: "prd", + workerName: "docs", + baseUrl: "https://docs.iterate.workers.dev", + urls: { + os: "https://os.iterate.com", + dash: "https://dash.iterate.com", + agents: "https://agents.iterate.com", + notes: "https://notes.iterate.com", + admin: "https://admin.iterate.com", + voice: "https://voice.iterate.com", + }, + posthogProjectKey: "phc_2MGb9SEJABGj4sCx4grFIbzMR7NjbcUgP5YmhSXfcr7", + }, +} satisfies Record; + +export function getEnv(name: string): DocsEnv { + const env = (docsEnvs as Record)[name]; + if (!env) + throw new Error( + `unknown env ${JSON.stringify(name)}; known envs: ${Object.keys(docsEnvs).join(", ")}`, + ); + return env; +} + +/** ITERATE'S OWN ZONES (`denyZones`): a project host or a custom apex is userspace and could serve a + * look-alike issuer. Docs has no sign-in gate of its own (a project host signs people in), but the + * config carries them as every iterate app's does. A copy of iterate's `ownZones()` (its + * scripts/lib/start-app.ts, from its envs.ts) at iterate/private@a541c9d, when Docs moved here. */ +export const DENY_ZONES = [ + "admin.iterate-dev-preview.workers.dev", + "agents.iterate-dev-preview.workers.dev", + "dash.iterate-dev-preview.workers.dev", + "docs.iterate-dev-preview.workers.dev", + "docs.iterate.workers.dev", + ...Array.from({ length: 19 }, (_, i) => `iterate-preview-${i + 1}.app`), + "iterate.app", + "iterate.com", + "kit.iterate-dev-preview.workers.dev", + "notes.iterate-dev-preview.workers.dev", + "os.iterate-dev-preview.workers.dev", + "voice.iterate-dev-preview.workers.dev", +]; diff --git a/package.json b/package.json new file mode 100644 index 0000000..78b2ba7 --- /dev/null +++ b/package.json @@ -0,0 +1,28 @@ +{ + "name": "iterate-docs", + "private": true, + "license": "Apache-2.0", + "type": "module", + "scripts": { + "typecheck": "pnpm -r --parallel typecheck && tsc -p scripts", + "lint": "oxlint . --deny-warnings --report-unused-disable-directives-severity error", + "format": "oxfmt", + "format:check": "oxfmt --check", + "test": "pnpm -r --parallel test && vitest run --root scripts" + }, + "devDependencies": { + "@types/node": "catalog:", + "iterate": "catalog:", + "oxfmt": "^0.70.0", + "oxlint": "^1.85.0", + "pkg-pr-new": "0.0.79", + "trpc-cli": "catalog:", + "typescript": "catalog:", + "vitest": "catalog:", + "zod": "catalog:" + }, + "engines": { + "node": ">=24.11.1" + }, + "packageManager": "pnpm@10.24.0" +} diff --git a/packages/docs-app/README.md b/packages/docs-app/README.md index cddf123..9705dcf 100644 --- a/packages/docs-app/README.md +++ b/packages/docs-app/README.md @@ -1,16 +1,20 @@ # Docs -A project's docs: the markdown files in its repos (`/repos/config` first), written in the browser. Served -like [Notes](../notes/README.md): a project routes its `docs` routing slug to this Worker +A project's docs: the markdown files in its repos (`/repos/config` first), written in the browser. +Served like iterate's Notes app: a project routes its `docs` routing slug to this Worker (`docsEnvs` in the root `envs.ts`) with a members-only fetch route, so it's `docs--.iterate.app` under subdomains and `/projects//docs/` under -paths (a PR preview's `pr` gets that route from its seed, scripts/os/preview-config.ts -`proxiedAppRoute`). The project installs Docs in its config repo, as it does agents: a `docs.ts` -re-exporting `@iterate-com/docs`'s processors and a pin in its root `package.json` -([packages/docs](../../packages/docs/README.md)); the page says so when a project hasn't. The page signs in on its host's `/.auth/*` and talks to its -host's `/api`, both the platform's; Docs has no OAuth client, no secrets and no state of its own. -The base path handling is Notes', shared in -[packages/ui/src/apps/base-path.ts](../../packages/ui/src/apps/base-path.ts). +paths. The project installs Docs in its config repo, as it does agents: a `docs.ts` re-exporting +`@iterate-com/docs`'s processors and a pin in its root `package.json` +([packages/docs](../../packages/docs/README.md)); the page says so when a project hasn't. The page +signs in on its host's `/.auth/*` and talks to its host's `/api`, both the platform's; Docs has no +OAuth client, no secrets and no state of its own. The base path handling is Notes', copied from +iterate's packages/ui to [src/app/base-path.ts](src/app/base-path.ts). + +`src/components/ui/` is shadcn's and the rest of `src/components/` that isn't Docs' own (the app +shell, PostHog, the stream link) is iterate's, from the shadcn registry iterate/packages serves: +`pnpm exec shadcn add iterate/packages/` re-adds one. `src/app/` holds the few files of +iterate's `packages/ui/src/apps` and `packages/shared` it uses, each naming where it came from. - The sidebar picks one of the project's repos and shows its files as a tree (@pierre/trees, [src/components/doc-tree.ts](src/components/doc-tree.ts)), following the repo's commits @@ -47,11 +51,24 @@ The prd iterate project serves it at `docs--iterate.iterate.app` and `docs.itera members-only fetch routes on the project, one by routing slug and one by hostname, to the prd Worker. -What's next: `tasks/complete/2026-09-30-docs-app.md`, -"Later". +What's next: "Later" in the iterate/iterate archive's +[tasks/complete/2026-09-30-docs-app.md](https://github.com/iterate/iterate/blob/main/tasks/complete/2026-09-30-docs-app.md). + +Local dev: `pnpm dev` (Vite, with the Cloudflare plugin's local workerd), reached through a project +like the deployed Worker: behind a tunnel, hot module reloading included. Under paths ingress a +tunnel's URL is `/projects///`, so the dev server starts under that base +path, then the tunnel lends it to the project +([iterate's CLI](https://github.com/iterate/core/blob/main/core/lib/src/cli/README.md#tunnel)): + +```sh +pnpm dev --port 5173 --base /projects/my-project/docs-dev/ +iterate tunnel 5173 --project my-project --name docs-dev +``` -Local dev is Notes': `pnpm dev`, reached through a project behind `iterate tunnel` -([Notes' README](../notes/README.md)). The browser proof is `test/playwright/docs`. +Under subdomains the tunnel's host is an origin of its own, and plain `pnpm dev` serves it. Local +dev signs in against prd's platform; a gitignored `.dev.vars` names another +(`ITERATE_APP__URLS__OS=http://localhost:8788`). -Deploy: `pnpm --dir packages/docs-app run deploy --env prd`. Docs has no secrets, so it deploys from -`_shared`'s `prd` Doppler config (`dopplerProject` in [scripts/app.ts](scripts/app.ts)). +Deploy: main deploys `preview`, then `prd` (`.depot/workflows/deploy.yml`). By hand, +`pnpm --dir packages/docs-app run deploy --env prd`. Docs has no secrets, so it deploys with the +Cloudflare token in Doppler's `_shared` project ([scripts/app.ts](scripts/app.ts)). diff --git a/packages/docs-app/components.json b/packages/docs-app/components.json new file mode 100644 index 0000000..ca92d51 --- /dev/null +++ b/packages/docs-app/components.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "base-nova", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "", + "css": "src/styles/globals.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "iconLibrary": "lucide", + "aliases": { + "components": "#/components", + "utils": "cn", + "hooks": "#/hooks", + "lib": "#/lib", + "ui": "#/components/ui" + } +} diff --git a/packages/docs-app/package.json b/packages/docs-app/package.json index 16f60c7..45a1884 100644 --- a/packages/docs-app/package.json +++ b/packages/docs-app/package.json @@ -1,17 +1,21 @@ { "name": "@iterate-com/docs-app", + "private": true, "type": "module", + "imports": { + "#/*": "./src/*" + }, "scripts": { "dev": "vite dev", "build": "vite build", "typecheck": "pnpm routes:check && tsc --noEmit", "deploy": "node scripts/app.ts deploy", - "ensure-resources": "node scripts/app.ts ensure-resources", "routes:generate": "node scripts/app.ts generate-route-tree", "routes:check": "node scripts/app.ts generate-route-tree --check" }, "dependencies": { "@atomic-editor/editor": "0.6.2", + "@base-ui/react": "^1.8.0", "@codemirror/autocomplete": "catalog:", "@codemirror/commands": "^6.10.2", "@codemirror/lang-cpp": "^6.0.3", @@ -33,9 +37,8 @@ "@codemirror/search": "catalog:", "@codemirror/state": "catalog:", "@codemirror/view": "catalog:", + "@fontsource/ubuntu": "5.3.0", "@iterate-com/docs": "workspace:*", - "@iterate-com/shared": "workspace:*", - "@iterate-com/ui": "workspace:*", "@lezer/common": "^1.5.2", "@lezer/highlight": "^1.2.4", "@lezer/markdown": "^1.7.2", @@ -43,12 +46,18 @@ "@tanstack/react-query": "^5.103.2", "@tanstack/react-router": "catalog:", "@tanstack/react-start": "catalog:", + "class-variance-authority": "^0.7.1", + "cmdk": "^1.1.1", "cn": "catalog:", - "iterate": "workspace:*", + "iterate": "catalog:", "lucide-react": "catalog:", + "next-themes": "^0.4.6", + "posthog-js": "1.434.12", "react": "catalog:", "react-dom": "catalog:", + "sonner": "^2.0.8", "tailwindcss": "catalog:", + "tw-animate-css": "^1.4.0", "y-codemirror.next": "^0.3.5", "y-protocols": "^1.0.6", "yjs": "catalog:", @@ -58,9 +67,13 @@ "@cloudflare/vite-plugin": "catalog:", "@cloudflare/workers-types": "catalog:", "@tailwindcss/vite": "catalog:", + "@tanstack/router-generator": "1.167.38", + "@types/node": "catalog:", "@types/react": "catalog:", "@types/react-dom": "catalog:", "@vitejs/plugin-react": "catalog:", + "shadcn": "4.21.0", + "trpc-cli": "catalog:", "typescript": "catalog:", "vite": "catalog:", "wrangler": "catalog:" diff --git a/packages/docs-app/scripts/app.ts b/packages/docs-app/scripts/app.ts index 325e70e..8919b90 100644 --- a/packages/docs-app/scripts/app.ts +++ b/packages/docs-app/scripts/app.ts @@ -1,13 +1,181 @@ -import { docsEnvs } from "../../../envs.ts"; -import { startAppCli } from "../../../scripts/lib/start-app.ts"; - -/** packages/docs-app as scripts/lib/start-app.ts sees it: the package scripts and vite.config.ts run off this. */ -export const docs = { - name: "docs", - // Docs has no secrets of its own: its deploy reads the Cloudflare and CI tokens every app - // inherits, straight from _shared - dopplerProject: "_shared", - root: new URL("..", import.meta.url), - envs: docsEnvs, -}; -if (import.meta.main) void startAppCli(docs).run(); +/** + * The Docs app's command line, behind its package scripts: + * + * deploy --env vite build for that env → wrangler deploy → /healthz smoke + * generate-route-tree regenerate src/routeTree.gen.ts outside `vite dev`/`vite build`; + * `--check` fails (and restores the file) when the checked-in tree is stale + * + * What iterate's monorepo runs for each of its apps (its scripts/lib/start-app.ts and deploy-app.ts + * at iterate/private@a541c9d), cut down to Docs, which has one Worker, no secrets and no resources. + * iterate/kit's scripts/app.ts made the same cut, and this began as a copy of it. + */ +import { execFileSync, spawnSync } from "node:child_process"; +import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { Generator, getConfig } from "@tanstack/router-generator"; +import { createCli, t } from "trpc-cli"; +import { z } from "zod"; +import { getEnv } from "../../../envs.ts"; + +const root = fileURLToPath(new URL("..", import.meta.url)); + +/** How the route generator writes src/routeTree.gen.ts: vite.config.ts and generate-route-tree alike. */ +export const routeTreeStyle = { + addExtensions: true, + semicolons: true, + quoteStyle: "double", +} as const; + +/** + * Builds and deploys one env: `vite build` with CLOUDFLARE_ENV set, so the Cloudflare plugin + * snapshots that env's Worker config (vite.config.ts `workerConfig`) into dist/server/wrangler.json, + * then `wrangler deploy` of that file with the env's Cloudflare token from Doppler, then /healthz + * until it answers. Doppler's CLI authenticates with DOPPLER_TOKEN in CI and the developer's login + * elsewhere. + */ +async function deploy(options: { env: string }) { + const env = getEnv(options.env); + console.log( + `Deploying Docs to ${options.env} (worker ${env.workerName}, account ${env.cloudflareAccountId})`, + ); + const token = execFileSync("doppler", ["secrets", "get", "CLOUDFLARE_API_TOKEN", "--plain"], { + encoding: "utf8", + env: { ...process.env, DOPPLER_PROJECT: "_shared", DOPPLER_CONFIG: env.dopplerConfig }, + }).trim(); + rmSync(path.join(root, "dist"), { recursive: true, force: true }); + run("pnpm", ["exec", "vite", "build"], { CLOUDFLARE_ENV: options.env }); + const credentials = { + CLOUDFLARE_API_TOKEN: token, + CLOUDFLARE_ACCOUNT_ID: env.cloudflareAccountId, + }; + // Cloudflare's API answers a burst of deploys with 429 (code 971); wait and try again + for (let attempt = 1; ; attempt++) { + const result = spawnSync( + "pnpm", + ["exec", "wrangler", "deploy", "--config", "dist/server/wrangler.json"], + { + cwd: root, + env: { ...process.env, ...credentials }, + encoding: "utf8", + stdio: ["inherit", "pipe", "pipe"], + }, + ); + process.stdout.write(result.stdout); + process.stderr.write(result.stderr); + if (result.status === 0) break; + if (attempt === 4 || !/429|code: 971|Too Many Requests/.test(result.stdout + result.stderr)) + throw new Error(`wrangler deploy failed (exit ${result.status})`); + console.warn(`wrangler deploy was rate limited; retrying in ${attempt * 15}s`); + await new Promise((resolve) => setTimeout(resolve, attempt * 15_000)); + } + await smoke(`${env.baseUrl}/healthz`); + console.log(`✅ ${options.env} deployed and serving at ${env.baseUrl}`); +} + +/** Probes `url` until it answers 200: a new version takes a few seconds to reach every colo. */ +async function smoke(url: string) { + for (let attempt = 1; attempt <= 18; attempt++) { + try { + const response = await fetch(url, { + redirect: "manual", + signal: AbortSignal.timeout(15_000), + }); + if (response.status === 200) { + console.log(`smoke ok: ${url} → 200`); + return; + } + console.warn(`smoke attempt ${attempt}: ${url} → ${response.status}`); + } catch (error) { + console.warn(`smoke attempt ${attempt}: ${url} → ${error}`); + } + await new Promise((resolve) => setTimeout(resolve, 5000)); + } + throw new Error(`Smoke failed: ${url} never answered 200, so the deploy is NOT verified.`); +} + +function run(command: string, args: string[], env: Record) { + const result = spawnSync(command, args, { + cwd: root, + env: { ...process.env, ...env }, + stdio: "inherit", + }); + if (result.status !== 0) + throw new Error(`${command} ${args.join(" ")} failed (exit ${result.status})`); +} + +/** + * Regenerates src/routeTree.gen.ts with the same generator + config that @tanstack/react-start's vite + * plugin uses. `check` fails (and restores the original file) when the checked-in tree is stale, so + * route files added or renamed without regenerating are caught. + */ +async function generateRouteTree(options: { check?: boolean }) { + const routeTreePath = path.resolve(root, "src/routeTree.gen.ts"); + const config = getConfig( + { + routesDirectory: path.resolve(root, "src/routes"), + generatedRouteTree: routeTreePath, + target: "react", + ...routeTreeStyle, + // @tanstack/start-plugin-core appends this Register block when the vite plugin runs the + // generator; mirror it so this script produces the same output as the build. Source of the + // footer (pin bumps may change it): + // https://github.com/TanStack/router/blob/main/packages/start-plugin-core/src/start-compiler-plugin/route-tree-footer.ts + routeTreeFileFooter: [ + [ + 'import type { getRouter } from "./router.tsx";', + 'import type { startInstance } from "./start.ts";', + 'declare module "@tanstack/react-start" {', + " interface Register {", + " ssr: true;", + " router: Awaited>;", + " config: Awaited>;", + " }", + "}", + ].join("\n"), + ], + }, + root, + ); + + // Generator.run() writes the file in place. In check mode we always restore the original afterwards + // (even if run() throws) so a failed/interrupted check never leaves a mutated working tree. + const before = existsSync(routeTreePath) ? readFileSync(routeTreePath, "utf8") : ""; + let after = before; + try { + await new Generator({ config, root }).run(); + after = readFileSync(routeTreePath, "utf8"); + } finally { + if (options.check) writeFileSync(routeTreePath, before); + } + + if (before === after) { + console.log("routeTree.gen.ts is up to date"); + } else if (options.check) { + console.error( + "routeTree.gen.ts is stale. Run `pnpm routes:generate` (or `pnpm dev`) and commit the result.", + ); + process.exit(1); + } else { + console.log("routeTree.gen.ts regenerated"); + } +} + +if (import.meta.main) + void createCli({ + router: t.router({ + deploy: t.procedure + .input(z.object({ env: z.string().describe("Target environment name from envs.ts") })) + .handler(({ input }) => deploy(input)), + generateRouteTree: t.procedure + .input( + z.object({ + check: z + .boolean() + .optional() + .describe("Fail when the checked-in tree is stale instead of rewriting it"), + }), + ) + .handler(({ input }) => generateRouteTree(input)), + }), + }).run(); diff --git a/packages/docs-app/src/app/base-path.ts b/packages/docs-app/src/app/base-path.ts new file mode 100644 index 0000000..cc58292 --- /dev/null +++ b/packages/docs-app/src/app/base-path.ts @@ -0,0 +1,78 @@ +// Copied from iterate/packages' packages/ui/src/apps/base-path.ts at +// iterate/private@a541c9d, when Docs moved to iterate/docs. Unchanged. + +// base-path.ts — THE PATH A PROXIED APP IS SERVED UNDER in the browser (packages/notes, packages/docs-app). A +// project's config worker (the app's config-worker.ts) proxies it: on a host of its own under subdomains ingress +// (`notes--.`) the base path is "", and under paths ingress it is +// `/projects//`. There the platform's edge strips that prefix from the URL the app sees and +// says it in `x-iterate-base-path` (iterate/project-ingress), while the browser's URLs keep it. So +// every path the page names carries it — its links, its assets (the app's server.ts `transformAssets`), its +// server functions (the app's start.ts) — and the router drops it on the way in (`basePathRewrite`). The +// browser adapter's `/.auth/*` and `/api` stay root paths: the host's, which the platform answers +// (core/os/src/worker.ts); under paths they are its own, on the origin the page shares with it. +// +// A dev server behind `iterate tunnel` under paths starts under the tunnel's base path (README): +// Vite's `base`, which its module URLs and its HMR socket carry. The page's base path takes the +// build's place in every URL the build names (`underBasePath`), and the URL the tunnel hands over, +// base path on, is served as the edge's is (`withoutBuildBasePath`). A deployed build's base is `/`. +import type { LocationRewrite } from "@tanstack/react-router"; +import { ITERATE_BASE_PATH_HEADER } from "iterate/project-ingress"; + +/** The build's base path: Vite's `base` without its trailing slash, "" but on a dev server + * started under a tunnel's base path. */ +export const buildBasePath = import.meta.env.BASE_URL.replace(/\/$/, ""); + +/** The base path a request says, else the build's — plain path segments only: the header reaches + * the app's Worker from anyone who fetches it, and what it says is written into the page's links. */ +export function basePathOf(headers: Headers) { + const value = headers.get(ITERATE_BASE_PATH_HEADER) || ""; + return /^(?:\/[a-z0-9-]+)+$/.test(value) ? value : buildBasePath; +} + +/** The base path in the browser: the server render writes it on `` + * (the app's routes/__root.tsx), before any script runs. */ +export function documentBasePath() { + return document.documentElement.dataset.basePath || ""; +} + +/** A URL the build names — a manifest's script or stylesheet, a `?url` import — under the page's + * base path in place of the build's. */ +export function underBasePath(basePath: string, url: string) { + return url.startsWith(`${buildBasePath}/`) + ? `${basePath}${url.slice(buildBasePath.length)}` + : url; +} + +/** The request as the edge hands a proxied app over, its base path stripped: a dev server under + * a tunnel's base path is handed the URL the browser addressed (the tunnel puts it back). */ +export function withoutBuildBasePath(request: Request) { + if (!buildBasePath) return request; + const url = new URL(request.url); + url.pathname = withoutBasePath(url.pathname, buildBasePath); + return new Request(url, request); +} + +/** The router's side of the base path: the browser's URL without it on the way in, with it on the + * way out. A rewrite, not TanStack's `basepath`, which Start sets from its `router.basepath` + * (the app's vite.config.ts) on every request (start-server-core `createStartHandler`, start-client-core + * `hydrateStart`). */ +export function basePathRewrite(basePath: string): LocationRewrite | undefined { + if (!basePath) return undefined; + return { + input: ({ url }) => { + url.pathname = withoutBasePath(url.pathname, basePath); + return url; + }, + output: ({ url }) => { + url.pathname = `${basePath}${url.pathname}`; + return url; + }, + }; +} + +/** `pathname` without `basePath` in front when it is under it, else as it is. */ +function withoutBasePath(pathname: string, basePath: string) { + return pathname === basePath || pathname.startsWith(`${basePath}/`) + ? pathname.slice(basePath.length) || "/" + : pathname; +} diff --git a/packages/docs-app/src/app/document.tsx b/packages/docs-app/src/app/document.tsx new file mode 100644 index 0000000..1cfbf2d --- /dev/null +++ b/packages/docs-app/src/app/document.tsx @@ -0,0 +1,34 @@ +// Copied from iterate/packages' packages/ui/src/apps/document.tsx at +// iterate/private@a541c9d, when Docs moved to iterate/docs. Unchanged. + +import { Outlet, Scripts, useHydrated } from "@tanstack/react-router"; +import { AppProviders } from "./providers.tsx"; +import { EnvironmentHeadContent } from "#/components/environment-head-content.tsx"; + +/** Every app's root route component: the document, with the app's providers around its pages. + * `icon` is the app's icon file in production. `basePath` is the path the page is served under + * when a project proxies it (packages/ui/src/apps/base-path.ts): the icon goes under it, and the browser's + * router reads it from `` before it starts. */ +export function AppDocument(props: { + icon: string; + posthogProjectKey: string | null; + basePath?: string; +}) { + // false in the server's HTML, true once React owns the page: the specs' hydration-waiter + // (test/playwright/AGENTS.md) holds actions until then + const hydrated = useHydrated(); + const basePath = props.basePath || ""; + return ( + + + + + + + + + + + + ); +} diff --git a/packages/docs-app/src/app/head.ts b/packages/docs-app/src/app/head.ts new file mode 100644 index 0000000..f18024f --- /dev/null +++ b/packages/docs-app/src/app/head.ts @@ -0,0 +1,16 @@ +// Copied from iterate/packages' packages/ui/src/apps/head.ts at +// iterate/private@a541c9d, when Docs moved to iterate/docs. Unchanged. + +/** Every app's root route `head`: its title, its stylesheet (a `?url` import) and, when it has + * one, its description. */ +export function appHead(page: { title: string; stylesheet: string; description?: string }) { + return { + meta: [ + { charSet: "utf-8" }, + { name: "viewport", content: "width=device-width, initial-scale=1" }, + ...(page.description ? [{ name: "description", content: page.description }] : []), + { title: page.title }, + ], + links: [{ rel: "stylesheet", href: page.stylesheet }], + }; +} diff --git a/packages/docs-app/src/app/posthog-proxy.ts b/packages/docs-app/src/app/posthog-proxy.ts new file mode 100644 index 0000000..d5492c4 --- /dev/null +++ b/packages/docs-app/src/app/posthog-proxy.ts @@ -0,0 +1,38 @@ +// Copied from iterate/packages' packages/shared/src/posthog/posthog.ts at +// iterate/private@a541c9d, when Docs moved to iterate/docs. Unchanged. + +/** Forward a browser SDK request under `proxyPrefix` to PostHog's EU region, as PostHog's Cloudflare + * Worker proxy does (https://posthog.com/docs/advanced/proxy/cloudflare): the SDK's assets to the + * asset host, everything else to ingest with the visitor's IP and without this app's cookies or + * credentials. The body is read whole first, the guide's fix for POSTs that never reach PostHog. */ +export async function proxyPosthogRequest(options: { + request: Request; + proxyPrefix: string; +}): Promise { + const apiHost = "eu.i.posthog.com"; + const assetHost = "eu-assets.i.posthog.com"; + const url = new URL(options.request.url); + const posthogPath = url.pathname.slice(options.proxyPrefix.length); + const isAsset = posthogPath.startsWith("/static/") || posthogPath.startsWith("/array/"); + const targetHost = isAsset ? assetHost : apiHost; + const posthogUrl = `https://${targetHost}${posthogPath}${url.search}`; + const headers = isAsset ? undefined : new Headers(options.request.headers); + headers?.delete("cookie"); + headers?.delete("authorization"); + headers?.delete("connection"); + // fetch sets PostHog's own from the URL. + headers?.delete("host"); + const clientIp = options.request.headers.get("cf-connecting-ip"); + if (headers && clientIp) headers.set("X-Forwarded-For", clientIp); + + const body = + options.request.method === "GET" || options.request.method === "HEAD" + ? undefined + : await options.request.arrayBuffer(); + return fetch(posthogUrl, { + method: options.request.method, + headers, + body, + redirect: options.request.redirect, + }); +} diff --git a/packages/docs-app/src/app/providers.tsx b/packages/docs-app/src/app/providers.tsx new file mode 100644 index 0000000..ec58ccd --- /dev/null +++ b/packages/docs-app/src/app/providers.tsx @@ -0,0 +1,24 @@ +// Copied from iterate/packages' packages/ui/src/apps/providers.tsx at +// iterate/private@a541c9d, when Docs moved to iterate/docs. Unchanged. + +import type { ReactNode } from "react"; +import { initPosthog } from "#/components/posthog.tsx"; +import { Toaster } from "#/components/ui/sonner.tsx"; +import { TooltipProvider } from "#/components/ui/tooltip.tsx"; + +/** Every client app's root providers. PostHog starts when the app has a key (envs.ts hands one to + * prd only). */ +export function AppProviders(props: { children: ReactNode; posthogApiKey?: string }) { + initPosthog(props.posthogApiKey); + + return ( + // Base UI tooltips are intended to share a provider; setting delay=0 + // here makes hover tooltips feel immediate across the app. + // First-party docs: + // https://github.com/mui/base-ui/blob/master/docs/src/app/(docs)/react/components/tooltip/page.mdx + + {props.children} + + + ); +} diff --git a/packages/docs-app/src/app/router.tsx b/packages/docs-app/src/app/router.tsx new file mode 100644 index 0000000..6b70e33 --- /dev/null +++ b/packages/docs-app/src/app/router.tsx @@ -0,0 +1,40 @@ +// Copied from iterate/packages' packages/ui/src/apps/router.tsx at +// iterate/private@a541c9d, when Docs moved to iterate/docs. Unchanged. + +import { + createRouter, + type AnyRoute, + type RouterConstructorOptions, + type RouterHistory, +} from "@tanstack/react-router"; +import { + DefaultErrorComponent, + DefaultNotFoundComponent, + DefaultPendingComponent, +} from "#/components/route-defaults.tsx"; + +/** Every app's router (its src/router.tsx `getRouter`): TanStack's `createRouter` with the defaults + * every app shares; an app's own options win. */ +export function createAppRouter( + options: RouterConstructorOptions>, +) { + return createRouter({ + defaultPreload: "intent", + scrollRestoration: true, + // Components passed as options are wrapped in lambdas so checking them doesn't traverse the + // registered router types (TS7023). + defaultErrorComponent: (props) => , + defaultNotFoundComponent: () => , + // Without a default pending component, an `ssr: false` route — the whole signed-in `_auth` + // layout — renders a BLANK outlet in the SSR shell and again while `beforeLoad`/`loader` run on + // the client. Blank is bad UX and breaks the "the app always reports progress" contract the + // specs enforce (their spinner-waiter only extends waits while a spinner is visible). + defaultPendingComponent: () => , + // Show that feedback quickly on client-side loads too: the library defaults (1000ms before + // pending shows, 500ms minimum once shown) leave a full second of blank panel before any + // signal appears. + defaultPendingMs: 300, + defaultPendingMinMs: 200, + ...options, + }); +} diff --git a/packages/docs-app/src/app/server.ts b/packages/docs-app/src/app/server.ts new file mode 100644 index 0000000..d10b273 --- /dev/null +++ b/packages/docs-app/src/app/server.ts @@ -0,0 +1,94 @@ +// Copied from iterate/packages' packages/ui/src/apps/server.ts at +// iterate/private@a541c9d, when Docs moved to iterate/docs. Only its imports changed: +// @iterate-com/shared's files are this folder's copies. + +// server.ts — EVERY APP'S WORKER (dash, agents, notes, voice, admin): its src/server.ts, Start's +// server entry, is `appServerEntry(handler, { … })` with only what the app does differently. +import { env } from "cloudflare:workers"; +import { proxyPosthogRequest } from "./posthog-proxy.ts"; +import { startAppConfigOf, type StartAppConfig } from "./start-app-config.ts"; +import { appAuth, appSession } from "iterate/app-server"; +import type { BrowserSession } from "iterate/app-session"; + +declare global { + namespace Cloudflare { + /** The bindings every app's Worker config gives it (startAppWorkerConfig, scripts/lib/start-app.ts). */ + interface Env { + ASSETS: Fetcher; + BROWSER_SESSION: DurableObjectNamespace; + /** THE APP'S CONFIGURATION, JSON (@iterate-com/shared/start-app-config): its platform, the + * other apps' origins, our own zones and its PostHog key — from envs.ts (startAppWorkerConfig) */ + ITERATE_APP: string; + } + } +} + +type AppServer = { + /** The app's own routes ahead of the sign-in gate; null passes the request on. */ + before?: (request: Request, config: StartAppConfig) => Promise; +} & ( + | { + proxied?: false; + /** The name the platform's consent page shows (`/.auth/client.json`, beside + * `/client-logo.svg`). Without one the gate names the app by its host. */ + clientName?: string; + /** What a login that names no `scope` asks for (`appAuth` `scopes`): the app's + * `createIterateClient({ scopes })`. */ + scopes?: readonly string[]; + /** Where a signed-in browser landing on `/` goes; the landing page is for signing in. */ + home?: string; + } + | { + /** Served only under a project's hosts, which a config worker fetches it through to (Notes): + * the platform answers those hosts' `/.auth/*` and `/api` itself (core/os/src/worker.ts), so + * the app has no sign-in gate and no OAuth client of its own. */ + proxied: true; + } +); + +/** The app's own origin signs a person in through the platform's OAuth (`appAuth`) and proxies the + * authenticated /api; it works through project ingress too. A `proxied` app skips that gate. + * Everything else is TanStack Start's: the built assets, then `pages`. */ +export function appServerEntry( + pages: { fetch(request: Request): Promise | Response }, + app: AppServer, +) { + return { + async fetch(request: Request) { + // parsed on the first request, /healthz's included: a malformed config fails the deploy's smoke + const config = startAppConfigOf(env); + const url = new URL(request.url); + if (url.pathname === "/healthz") return new Response("ok"); + // posthog-js's `api_host` (packages/ui posthog.tsx): PostHog EU through our own origin + if (url.pathname.startsWith("/e/")) + return proxyPosthogRequest({ request, proxyPrefix: "/e" }); + const own = await app.before?.(request, config); + if (own) return own; + if (!app.proxied) { + const auth = await appAuth(request, { + client: app.clientName + ? { name: app.clientName, logoUri: "/client-logo.svg" } + : undefined, + scopes: app.scopes, + sessions: env.BROWSER_SESSION, + issuer: config.urls.os, + resource: `${config.urls.os}/api`, + denyZones: config.denyZones, + api: (request) => fetch(request), + }); + if (auth) return auth; + if (app.home && url.pathname === "/" && request.method === "GET") { + const bearer = await appSession(env.BROWSER_SESSION, request)?.bearer(); + if (bearer) + return new Response(null, { + status: 302, + headers: { Location: app.home, "Cache-Control": "no-store" }, + }); + } + } + const asset = await env.ASSETS.fetch(request); + if (asset.status !== 404) return asset; + return pages.fetch(request); + }, + }; +} diff --git a/packages/docs-app/src/app/start-app-config.ts b/packages/docs-app/src/app/start-app-config.ts new file mode 100644 index 0000000..ec83bea --- /dev/null +++ b/packages/docs-app/src/app/start-app-config.ts @@ -0,0 +1,78 @@ +// Copied from iterate/packages' packages/shared/src/start-app-config.ts at +// iterate/private@a541c9d, when Docs moved to iterate/docs. Unchanged. + +// start-app-config.ts — THE CONFIGURATION OF AN APP ON TOP of the platform: dash, agents, notes, +// docs, admin and voice, TanStack Start apps that are each an OAuth client of core/os and nothing +// else — but notes and docs, which a project's config worker serves under its hosts, on the host's sign-in. The platform's mechanism (iterate/app-config): one `ITERATE_APP` object, and any key set alone +// as an `ITERATE_APP__*` var merged on top. +// +// { urls: { os, dash, agents, notes, docs, admin, voice }, denyZones, posthogProjectKey, pkgPrNewRef } +// +// scripts/lib/start-app.ts writes it from envs.ts as the Worker's one `ITERATE_APP` var +// (`startAppWorkerConfig`), and a per-PR preview swaps in the same PR's origins +// (`startAppPreviewConfig`). The apps hold no secrets, so nothing comes from Doppler. Local dev +// starts from prd's and names a local platform, or a local app, in the app's gitignored +// `.dev.vars`: +// +// ITERATE_APP__URLS__OS=http://localhost:8788 +// ITERATE_APP__URLS__VOICE=http://localhost:5174 + +import { z } from "zod"; +import { dnsName, httpOrigin, optionalOrigin, parseAppConfigVars } from "iterate/app-config"; + +/** THE SCHEMA. `urls` names the platform and every first-party app (scripts/lib/start-app.ts + * `FIRST_PARTY_APPS` is typed against it, so an app added there is added here). */ +export const StartAppConfig = z.object({ + urls: z + .object({ + /** THE PLATFORM this app signs in against (core/os): the default issuer, and `/api` on it the + * resource. */ + os: httpOrigin, + // THE FIRST-PARTY APPS, what a link from one app to another follows: the dash's directory of + // apps (packages/dash/src/apps.ts), the admin app's "View dash as". The same environment's as + // `os`. Blank ⇒ no link: a per-PR preview names only the apps its run deploys, because + // production does not know the preview's projects. + dash: optionalOrigin, + agents: optionalOrigin, + /** read by no app: nothing links to Notes, which is served under each project's hosts. It + * is here because `FIRST_PARTY_APPS` is typed against `urls`, which gives Notes' origin its + * place in `ownZones` and a per-PR preview its Notes Worker. */ + notes: optionalOrigin, + /** read by no app, like `notes`: Docs is served under each project's hosts too. */ + docs: optionalOrigin, + admin: optionalOrigin, + voice: optionalOrigin, + }) + // the prefault must satisfy the input type; `os: ""` then fails naming urls.os + .prefault({ os: "" }), + /** OUR OWN ZONES (scripts/lib/start-app.ts `ownZones`, from envs.ts): project hosts and custom + * apexes are userspace and could serve a look-alike issuer, so the browser-auth gate (`appAuth` + * `denyZones`, the SDK's `issuerOriginOf`) refuses to connect to an issuer under one. The + * default issuer, `urls.os`, is exempt. */ + denyZones: z.array(dnsName), + /** PostHog's project key (envs.ts `posthogProjectKey`, prd only): the app's pages start + * posthog-js with it. A public key, not a secret. Blank ⇒ no PostHog. */ + posthogProjectKey: z.string().trim().default(""), + /** WHICH BUILD OF THIS REPOSITORY'S PACKAGES goes with this deployment, as pkg.pr.new names it + * (iterate/package-builds): `main`, or the commit a per-commit deployment's packages are + * published at (scripts/os/published-package-commit.ts). What an app installs in a project, + * pinned as it writes (Docs' "Install Docs"). A config written without it (a test's) goes with + * main's. */ + pkgPrNewRef: z.string().trim().default("main"), +}); + +export type StartAppConfig = z.output; + +const startAppConfigByEnv = new WeakMap(); + +/** The configuration of the isolate `env` belongs to (`import { env } from "cloudflare:workers"`): + * parsed on first use, then the same object every time. A malformed field throws HERE, on the + * first request, naming the field. */ +export function startAppConfigOf(env: object): StartAppConfig { + let config = startAppConfigByEnv.get(env); + if (!config) { + config = parseAppConfigVars(env, StartAppConfig, { prefix: "ITERATE_APP" }); + startAppConfigByEnv.set(env, config); + } + return config; +} diff --git a/packages/docs-app/src/components/app-shell-palette-entries.ts b/packages/docs-app/src/components/app-shell-palette-entries.ts new file mode 100644 index 0000000..4472278 --- /dev/null +++ b/packages/docs-app/src/components/app-shell-palette-entries.ts @@ -0,0 +1,129 @@ +// What ⌘K in the AppShell offers, and how typing narrows it: the projects the shell's switcher +// lists, and the sidebar's own navigation — read off the rendered sidebar when the palette opens, so +// an app declares its pages once (its `nav`) and the palette offers exactly what the sidebar shows. +// No React here: the filter is a pure function, the reader takes a DOM element. + +/** One row of the palette. */ +export type PaletteEntry = { + /** unique across the palette — the row's cmdk value */ + id: string; + label: string; + /** the heading the row is listed under */ + group: string; + /** what the label belongs to (a project's organization, a sub-page's parent) — shown after the + * label, and searched with it */ + detail: string | undefined; + /** the page on screen, or the project it belongs to */ + active: boolean; +}; + +/** The sidebar's navigation as the palette lists it: every enabled menu button and sub-button in + * the sidebar's content, the element itself kept so choosing the row clicks it — the app's own + * handler (a client-router `Link`, "New agent") runs exactly as a click in the sidebar would. */ +export type SidebarNavItem = Omit & { + element: HTMLElement; + /** the absolute URL, for a link */ + href: string | undefined; +}; + +/** A row an app hands the palette itself (`AppShell`'s `paletteEntries`): what its sidebar shows + * in a way the palette can't read, such as a file tree drawn in its own shadow DOM. Choosing it + * runs `onSelect`, which navigates the app's own way. */ +export type AppPaletteEntry = Omit & { onSelect: () => void }; + +/** Rows under a heading the sidebar gives none (an app's top group of pages). */ +export const UNLABELLED_NAV_GROUP = "Pages"; + +/** The rows that match `query`, grouped under their headings in order of first appearance. A row + * matches when every whitespace-separated term occurs, case-insensitively, in its label or + * detail; a row whose label starts with the query comes first within its heading, the rest keep + * their order. */ +export function filterPaletteEntries( + entries: readonly Entry[], + query: string, +): { group: string; entries: Entry[] }[] { + const needle = query.trim().toLowerCase(); + const terms = needle.split(/\s+/).filter(Boolean); + const matching = entries.filter((entry) => { + const haystack = `${entry.label} ${entry.detail || ""}`.toLowerCase(); + return terms.every((term) => haystack.includes(term)); + }); + const groups = new Map(); + for (const entry of matching) + groups.set(entry.group, [...(groups.get(entry.group) || []), entry]); + return [...groups].map(([group, rows]) => ({ + group, + entries: needle + ? [ + ...rows.filter((row) => row.label.toLowerCase().startsWith(needle)), + ...rows.filter((row) => !row.label.toLowerCase().startsWith(needle)), + ] + : rows, + })); +} + +/** Reads the sidebar's navigation off `content` (the sidebar's `SidebarContent`): each enabled menu + * button and sub-button, labelled by its first text (or its aria-label when it shows none), under + * its group's label; its detail is a sub-button's parent item, or the rest of its text. Nothing on a phone, where the + * sidebar is a sheet (`data-mobile`): closed, `content` is not mounted (null); open, the palette's + * first press is outside it, the sheet closes and unmounts, and a row's element would be detached + * by the time it is chosen — a click on it does nothing. */ +export function readSidebarNav(content: Element | null): SidebarNavItem[] { + if (!content || content.closest("[data-mobile]")) return []; + return [ + ...content.querySelectorAll( + '[data-slot="sidebar-menu-button"], [data-slot="sidebar-menu-sub-button"]', + ), + ].flatMap((element) => { + if (element.matches(':disabled, [aria-disabled="true"]')) return []; + const [text, ...more] = textBlocks(element); + const label = text || element.getAttribute("aria-label")?.trim(); + if (!label) return []; + const group = element.closest('[data-slot="sidebar-group"]'); + const parent = + element.dataset.slot === "sidebar-menu-sub-button" + ? element + .closest('[data-slot="sidebar-menu-sub"]') + ?.closest('[data-slot="sidebar-menu-item"]') + ?.querySelector('[data-slot="sidebar-menu-button"]') + : null; + return [ + { + element, + label, + group: + textBlocks(group?.querySelector('[data-slot="sidebar-group-label"]'))[0] || + UNLABELLED_NAV_GROUP, + detail: textBlocks(parent)[0] || more.join(" · ") || undefined, + active: element.hasAttribute("data-active"), + href: element instanceof HTMLAnchorElement ? element.href : undefined, + }, + ]; + }); +} + +/** The element's text, one entry per element that holds some: a row that stacks a title over a + * path (the agents app) reads as `["title", "/agents/…"]`, a group label with a count badge + * (`Agents` · `2 active or waiting`) as its name first — the first entry is the label. */ +function textBlocks(element: Element | null | undefined) { + if (!element) return []; + const blocks = new Map(); + const walker = element.ownerDocument.createTreeWalker(element, NodeFilter.SHOW_TEXT); + for (let node = walker.nextNode(); node; node = walker.nextNode()) { + const holder = node.parentNode || element; + blocks.set(holder, `${blocks.get(holder) || ""}${node.nodeValue || ""}`); + } + return [...blocks.values()].map((text) => text.replace(/\s+/g, " ").trim()).filter(Boolean); +} + +/** The sidebar's items less those that are only a link to a project — the dash's organization tree + * lists each project by its slug, and the project's own row already offers it. A page at a + * project's URL under its own label (the dash's "Overview" is `/projects/`) stays. + * `projects` carry absolute hrefs, as the reader's do. */ +export function withoutProjectLinks( + nav: SidebarNavItem[], + projects: { label: string; href: string }[], +): SidebarNavItem[] { + const links = new Set(projects.map((project) => `${project.label} ${project.href}`)); + return nav.filter((item) => !links.has(`${item.label} ${item.href}`)); +} diff --git a/packages/docs-app/src/components/app-shell-palette.tsx b/packages/docs-app/src/components/app-shell-palette.tsx new file mode 100644 index 0000000..6f3c54c --- /dev/null +++ b/packages/docs-app/src/components/app-shell-palette.tsx @@ -0,0 +1,297 @@ +// ⌘K in the AppShell: one dialog to switch project or jump to any page the sidebar offers, by +// typing. The rows and the filter are app-shell-palette-entries.ts; this is the dialog, the +// shortcut and the buttons that open it (the sidebar's "Search" row, and the header's on a phone, +// where the sidebar is a sheet). +// +// command.tsx is vendored shadcn (packages/ui/AGENTS.md), so the palette's own look (iterate/iterate#2991) lives +// here, in the classNames it passes: flush rows edge to edge instead of upstream's inset, rounded +// ones, and a borderless search row. Two parts are composed here instead: the search row is cmdk's +// input under the palette's own markup, because upstream's CommandInput wraps it in an InputGroup +// that takes no className; and the dialog is Dialog's parts, because upstream's CommandDialog puts +// its sr-only title outside the popup, where a screen reader finds a "Search" heading on every page +// even while the palette is closed. +import { useEffect, useRef, useState, useSyncExternalStore, type MouseEvent } from "react"; +import { Command as CommandPrimitive } from "cmdk"; +import { CheckIcon, SearchIcon } from "lucide-react"; +import { + filterPaletteEntries, + withoutProjectLinks, + type AppPaletteEntry, + type PaletteEntry, + type SidebarNavItem, +} from "./app-shell-palette-entries.ts"; +import type { AppShellProject } from "./app-shell.tsx"; +import { plainLeftClick } from "#/lib/plain-left-click.ts"; +import { Button } from "#/components/ui/button.tsx"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandItem, + CommandList, + CommandShortcut, +} from "#/components/ui/command.tsx"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "#/components/ui/dialog.tsx"; +import { SidebarMenu, SidebarMenuButton, SidebarMenuItem } from "#/components/ui/sidebar.tsx"; + +type PaletteRow = + | (PaletteEntry & { kind: "project"; href: string }) + | (PaletteEntry & { kind: "nav"; element: HTMLElement }) + | (PaletteEntry & { kind: "app"; onSelect: () => void }); + +/** Nothing to subscribe to: the platform does not change under a page. */ +const subscribeToNothing = () => () => {}; + +/** The shortcut as this platform spells it. */ +function useShortcutLabel() { + return useSyncExternalStore( + subscribeToNothing, + () => (/Mac|iPhone|iPad/.test(navigator.platform) ? "⌘K" : "Ctrl K"), + () => "⌘K", + ); +} + +/** ⌘K (Ctrl+K off a Mac) anywhere on the page toggles the palette — in a text field too, as the + * sidebar's ⌘B does. */ +export function usePaletteShortcut(toggle: () => void) { + useEffect(() => { + function onKeyDown(event: KeyboardEvent) { + if (event.defaultPrevented || event.altKey || event.shiftKey) return; + if (!(event.metaKey || event.ctrlKey) || event.key.toLowerCase() !== "k") return; + event.preventDefault(); + toggle(); + } + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }, [toggle]); +} + +/** The sidebar's "Search" row, above the app's navigation: an icon with a tooltip when the sidebar + * is collapsed to its rail. */ +export function PaletteSidebarButton({ onOpen }: { onOpen: () => void }) { + const shortcut = useShortcutLabel(); + return ( + + + + + Search + {shortcut} + + + + ); +} + +/** The header's search button, on a phone: there the sidebar is a sheet, closed until asked for. */ +export function PaletteHeaderButton({ onOpen }: { onOpen: () => void }) { + return ( + + ); +} + +/** The dialog. `nav` is the sidebar's navigation as it read when the palette opened, null while + * closed. A project row is a real link to `projectHref(project)` — a plain click (or Enter) goes + * through `onNavigate` when the app has a client router, a modified or middle click opens a tab — + * a nav row clicks the sidebar's own element, and an entry the app handed over runs its + * `onSelect`. */ +export function AppShellPalette({ + nav, + entries, + onClose, + projects, + activeProjectId, + projectHref, + onNavigate, +}: { + nav: SidebarNavItem[] | null; + entries: AppPaletteEntry[]; + onClose: () => void; + projects: AppShellProject[]; + activeProjectId: string | null; + projectHref: (project: AppShellProject) => string; + onNavigate: ((href: string, event: MouseEvent) => void) | undefined; +}) { + return ( + { + if (!open) onClose(); + }} + > + + + Search + Switch project or go to a page + + {nav ? ( + + ) : null} + + + ); +} + +/** Mounted per opening, so the query starts empty each time. */ +function PaletteBody({ + nav, + entries, + onClose, + projects, + activeProjectId, + projectHref, + onNavigate, +}: { + nav: SidebarNavItem[]; + entries: AppPaletteEntry[]; + onClose: () => void; + projects: AppShellProject[]; + activeProjectId: string | null; + projectHref: (project: AppShellProject) => string; + onNavigate: ((href: string, event: MouseEvent) => void) | undefined; +}) { + const [query, setQuery] = useState(""); + const links = useRef(new Map()); + const projectRows = projects.map((project): PaletteRow => ({ + kind: "project", + id: `project:${project.id}`, + label: project.slug, + group: "Projects", + detail: project.org?.name, + active: project.id === activeProjectId, + href: projectHref(project), + })); + const navRows = withoutProjectLinks( + nav, + projects.map((project) => ({ + label: project.slug, + href: new URL(projectHref(project), location.href).href, + })), + ).map(({ element, label, group, detail, active }, index): PaletteRow => ({ + kind: "nav", + id: `nav:${index}`, + label, + group, + detail, + active, + element, + })); + const appRows = entries.map((entry, index): PaletteRow => ({ + kind: "app", + id: `app:${index}`, + ...entry, + })); + const groups = filterPaletteEntries([...navRows, ...appRows, ...projectRows], query); + return ( + +
+ + +
+ + Nothing matches. + {groups.map(({ group, entries }) => ( + + {entries.map((row) => ( + { + if (row.kind === "project") { + links.current.get(row.id)?.click(); + return; + } + if (row.kind === "app") { + onClose(); + row.onSelect(); + return; + } + onClose(); + row.element.click(); + }} + > + {row.kind === "project" ? ( + { + if (link) links.current.set(row.id, link); + else links.current.delete(row.id); + }} + href={row.href} + tabIndex={-1} + className="flex min-w-0 flex-1 items-center gap-2" + onClick={(event) => { + // the row's own onSelect would click this again + event.stopPropagation(); + if (!plainLeftClick(event)) return; + onClose(); + onNavigate?.(row.href, event); + }} + > + + + ) : ( + + )} + + ))} + + ))} + +
+ ); +} + +function PaletteRowLabel({ row }: { row: PaletteRow }) { + return ( + <> + {row.label} + {row.detail ? ( + {row.detail} + ) : null} + {row.active ? ( + + + + ) : null} + + ); +} diff --git a/packages/docs-app/src/components/app-shell-platform.ts b/packages/docs-app/src/components/app-shell-platform.ts new file mode 100644 index 0000000..c3c7cb7 --- /dev/null +++ b/packages/docs-app/src/components/app-shell-platform.ts @@ -0,0 +1,34 @@ +// app-shell-platform.ts — WHICH PLATFORM the app talks to, for the line at the bottom of the +// shell's sidebar. The app's sign-in gate knows (iterate/app-server `/.auth/session.json`): the +// issuer this browser's session is bound to, and the app's own default issuer. +import { z } from "zod"; + +const GateSession = z.object({ issuer: z.url().nullable(), defaultIssuer: z.url() }); + +/** The platform by host, and whether it is the app's own default. Not the default: a person + * connected the app to another platform on purpose (a self-hosted one, through `/.auth/connect`). */ +export type ConnectedPlatform = { host: string; defaultHost: string; isDefault: boolean }; + +/** The gate's answer as the line shows it; null for anything that is not one. A session bound to + * no issuer sends its requests to the default (`appAuth`'s `/api`), so it is connected there. */ +export function connectedPlatformOf(answer: unknown): ConnectedPlatform | null { + const parsed = GateSession.safeParse(answer); + if (!parsed.success) return null; + const connected = new URL(parsed.data.issuer || parsed.data.defaultIssuer); + const own = new URL(parsed.data.defaultIssuer); + return { + host: connected.host, + defaultHost: own.host, + isDefault: connected.origin === own.origin, + }; +} + +/** Ask the gate. `/.auth/*` is at the root of the page's origin in every app, a proxied one's + * included (packages/ui/src/apps/base-path.ts). Null when the gate does not answer as one. */ +export async function readConnectedPlatform(signal: AbortSignal) { + const response = await fetch("/.auth/session.json", { + headers: { accept: "application/json" }, + signal, + }); + return response.ok ? connectedPlatformOf(await response.json()) : null; +} diff --git a/packages/docs-app/src/components/app-shell.tsx b/packages/docs-app/src/components/app-shell.tsx new file mode 100644 index 0000000..c66c715 --- /dev/null +++ b/packages/docs-app/src/components/app-shell.tsx @@ -0,0 +1,663 @@ +// The one shell for the client apps (dash, agents, notes, docs, voice, admin): the sidebar (the +// project switcher in its header, the app's own navigation in its body, the collapse button, the +// account menu and the platform the app is connected to in its footer, the rail) and the page +// beside it under a header row that carries the phone's sidebar trigger, a page's own row +// (`AppShellPageHeader`) and its menu (`AppShellPageMenu`) — and ⌘K, a palette over the projects +// and the sidebar's pages (app-shell-palette.tsx). Router-agnostic on purpose: the app +// hands over hrefs and its current location, nothing from TanStack comes in here. +import { + createContext, + useCallback, + useContext, + useEffect, + useRef, + useState, + useSyncExternalStore, + type MouseEvent, + type ReactNode, + type RefObject, +} from "react"; +import { createPortal } from "react-dom"; +import { + CheckIcon, + ChevronsLeftIcon, + ChevronsUpDownIcon, + EllipsisIcon, + EyeIcon, + LogOutIcon, + SearchIcon, + TriangleAlertIcon, + UsersIcon, +} from "lucide-react"; +import { cn } from "cn"; +import type { Principal } from "iterate/principal"; +import { + AppShellPalette, + PaletteHeaderButton, + PaletteSidebarButton, + usePaletteShortcut, +} from "./app-shell-palette.tsx"; +import { + readSidebarNav, + type AppPaletteEntry, + type SidebarNavItem, +} from "./app-shell-palette-entries.ts"; +import { readConnectedPlatform, type ConnectedPlatform } from "./app-shell-platform.ts"; +import { plainLeftClick } from "#/lib/plain-left-click.ts"; +import { Avatar, AvatarFallback } from "#/components/ui/avatar.tsx"; +import { Button } from "#/components/ui/button.tsx"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "#/components/ui/dropdown-menu.tsx"; +import { IterateLogo } from "#/components/iterate-logo.tsx"; +import { + Sidebar, + SidebarContent, + SidebarFooter, + SidebarHeader, + SidebarInset, + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, + SidebarProvider, + SidebarRail, + SidebarTrigger, + useSidebar, +} from "#/components/ui/sidebar.tsx"; +import { Skeleton } from "#/components/ui/skeleton.tsx"; +import { resetPosthog } from "#/components/posthog.tsx"; + +/** A project as the switcher lists it; `org` is its organization, when the app knows it — grouped + * by the id (two organizations may share a name), labelled by the name. */ +export type AppShellProject = { id: string; slug: string; org?: { id: string; name: string } }; + +/** Nothing to subscribe to: the cookie has no change event, and `SidebarProvider` reads + * `defaultOpen` once, when it mounts. */ +const subscribeToNothing = () => () => {}; + +/** Frames a signed-in page. It reads the `sidebar_state` cookie shadcn's provider writes, so the + * sidebar reopens the way it was left — through `useSyncExternalStore`, which keeps the read out + * of render: a server render (the dash's frame before sign-in, routes/_auth.tsx) gets shadcn's + * default, open, until the page hydrates. */ +export function AppShell({ + app, + projects, + activeProjectId, + projectHref, + onNavigate, + switcherActions, + nav, + paletteEntries, + searchRow = true, + header, + account, + accountActions, + locationKey, + children, +}: { + /** the app's name, on top of the switcher: "Agents", "Notes", "Voice" */ + app: string; + /** the projects this session lists; grouped by `org` when the app names one */ + projects: AppShellProject[]; + activeProjectId: string | null; + /** where the app shows a project (`/projects/` by convention) — a same-origin href; + * switching is a full navigation unless + * `onNavigate` takes it (an app with a client router prevents the default and navigates itself). + * Only a plain left click is handed over: a modified or middle click keeps the anchor's own + * behaviour (a new tab). */ + projectHref: (project: AppShellProject) => string; + onNavigate?: (href: string, event: MouseEvent) => void; + /** the app's own items at the end of the switcher menu — `DropdownMenuItem`s, after a separator */ + switcherActions?: ReactNode; + /** the app's own navigation, its `SidebarGroup`s */ + nav?: ReactNode; + /** rows for ⌘K beside what it reads off `nav`: what the sidebar shows in a way it can't read + * (a file tree in its own shadow DOM) */ + paletteEntries?: AppPaletteEntry[]; + /** the sidebar's "Search" row above `nav`; without it, ⌘K and the phone's header button still + * open the palette */ + searchRow?: boolean; + /** what sits beside the phone's sidebar trigger in the header row; without it, a desktop shows + * the row only while a page puts its own in it (`AppShellPageHeader`) */ + header?: ReactNode; + /** who the app is signed in as (`info.principal`); "Sign out" posts to the SDK's `/.auth/logout`, + * "Switch account…" signs in again through the issuer's consent. A platform admin signed in as + * someone (`impersonatedBy`) sees whom and who they are, above the account menu, with Stop + * impersonating, which signs in again the same way (the issuer still knows them). None while the + * app is still signing in: a placeholder of the menu's height holds its place. */ + account?: Principal; + /** the app's own items in the account menu, before Sign out — `DropdownMenuItem`s */ + accountActions?: ReactNode; + /** the router's current href — a change closes the phone's sidebar sheet */ + locationKey: string; + children: ReactNode; +}) { + const platform = useConnectedPlatform(); + const defaultOpen = useSyncExternalStore( + subscribeToNothing, + () => !document.cookie.split("; ").includes("sidebar_state=false"), + () => true, + ); + // the palette: the sidebar's navigation as it read when ⌘K opened it, null while closed + const navRef = useRef(null); + const [palette, setPalette] = useState(null); + const openPalette = () => setPalette(readSidebarNav(navRef.current)); + // the header row's middle and end, where a page's own row (`AppShellPageHeader`) and its menu + // (`AppShellPageMenu`) go + const [pageHeaderSlot, setPageHeaderSlot] = useState(null); + const [pageMenuSlot, setPageMenuSlot] = useState(null); + usePaletteShortcut( + useCallback(() => setPalette((open) => (open ? null : readSidebarNav(navRef.current))), []), + ); + return ( + + {/* outside : on a phone its children live in a Sheet that remounts when opened */} + + + + + {searchRow ? : null} + + {/* ⌘K lists what the sidebar shows here — on a desktop; a phone's lists the projects alone */} + {nav} + + + {account?.impersonatedBy ? ( + + ) : null} + {account ? ( + + ) : ( + + + + + + )} + + + + +
+ + {/* in a page's menu instead on a phone, while it has one */} +
+ + {header} +
+
+
+
+ +
{children}
+
+
+ setPalette(null)} + projects={projects} + activeProjectId={activeProjectId} + projectHref={projectHref} + onNavigate={onNavigate} + /> +
+ ); +} + +const PageSlotsContext = createContext<{ + header: HTMLElement | null; + menu: HTMLElement | null; + openPalette: () => void; + /** the app's `header`, which a phone shows in the page's menu */ + appHeader: ReactNode; +} | null>(null); + +/** A page's own row of the header: what it shows above itself (a doc's path and its controls), in + * the row the shell has anyway, between the phone's sidebar trigger and the page's ⋯. The row gets + * a rule under it while a page has one, and a desktop shows it then even when the app gave the + * shell no `header`. */ +export function AppShellPageHeader({ children }: { children: ReactNode }) { + const shell = useContext(PageSlotsContext); + if (!shell) throw new Error("AppShellPageHeader is drawn inside an AppShell"); + if (!shell.header) return null; + return createPortal( + // one line on a phone, where the page's controls are in its ⋯: what's too long truncates +
+ {children} +
, + shell.header, + ); +} + +/** A page's own controls on a phone, behind a ⋯ at the end of the header row: the row's `header` + * and Search move in above them, so the row is the sidebar trigger and the ⋯ alone. `children` + * are `DropdownMenuItem`s (in `DropdownMenuGroup`s). Hidden from `md` up, where the page shows + * its controls itself: the page hides them below `md`, so one breakpoint decides both, in CSS. */ +export function AppShellPageMenu({ children }: { children: ReactNode }) { + const shell = useContext(PageSlotsContext); + if (!shell) throw new Error("AppShellPageMenu is drawn inside an AppShell"); + if (!shell.menu) return null; + return createPortal( + + + } + > + + + + {shell.appHeader ?
{shell.appHeader}
: null} + + + + Search + + + + {children} +
+
, + shell.menu, + ); +} + +/** A sidebar row while what it names loads: `SidebarMenuSkeleton`'s shape at a fixed width, so a + * server render hydrates as it was drawn (the vendored one picks its width at random). */ +export function SidebarRowSkeleton({ className }: { className?: string }) { + return ( +
+ + +
+ ); +} + +/** Dismiss the mobile sidebar (a Sheet) when the page navigates — shadcn's Sidebar exposes + * `setOpenMobile` and does not do this itself. The whole href counts, not the pathname: the apps + * navigate by search string (`/agents?project=…&agent=…`). Only on an actual change, never on + * mount: the Sheet remounts its children when opened. */ +function CloseMobileSidebarOnNavigate({ locationKey }: { locationKey: string }) { + const { setOpenMobile } = useSidebar(); + const previousLocationKeyRef = useRef(locationKey); + useEffect(() => { + if (previousLocationKeyRef.current === locationKey) return; + previousLocationKeyRef.current = locationKey; + setOpenMobile(false); + }, [locationKey, setOpenMobile]); + return null; +} + +/** The app's navigation. On a phone, a same-tab link click in it closes the sidebar sheet at once, + * as the navigation starts; shadcn leaves it open (https://github.com/shadcn-ui/ui/issues/5561), + * and `CloseMobileSidebarOnNavigate` only fires once the location has changed. A modified or + * middle click, a download, or a link to another tab leaves the sheet open, as does a button (the + * collapse button, a dropdown trigger). This used to live in the vendored sidebar.tsx's + * SidebarMenuButton (iterate/iterate#1984); a vendored file stays byte-identical to upstream + * (packages/ui/AGENTS.md). */ +function SidebarNav({ + navRef, + children, +}: { + navRef: RefObject; + children: ReactNode; +}) { + const { isMobile, setOpenMobile } = useSidebar(); + return ( + { + if (!isMobile) return; + if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) + return; + const link = event.target instanceof Element ? event.target.closest("a[href]") : null; + if (!link || !event.currentTarget.contains(link) || link.hasAttribute("download")) return; + const target = link.getAttribute("target"); + if (target && target !== "_self") return; + setOpenMobile(false); + }} + > + {children} + + ); +} + +function ProjectSwitcher({ + app, + projects, + activeProjectId, + projectHref, + onNavigate, + actions, +}: { + app: string; + projects: AppShellProject[]; + activeProjectId: string | null; + projectHref: (project: AppShellProject) => string; + onNavigate?: (href: string, event: MouseEvent) => void; + actions?: ReactNode; +}) { + const { isMobile } = useSidebar(); + // one group per organization (by id), in order of first appearance; a bare list when the app + // names none + const orgs = [...new Map(projects.map((project) => [project.org?.id, project.org])).values()]; + return ( + + + + + + + + + {app} + + {projects.find((project) => project.id === activeProjectId)?.slug || + "(select project)"} + + + + + } + /> + + {projects.length === 0 ? ( + + No projects yet + + ) : null} + {orgs.map((org) => ( + + {/* Base UI: a menu label lives inside a group, never bare in the menu */} + {org ? ( + + {org.name} + + ) : null} + {projects + .filter((project) => project.org?.id === org?.id) + .map((project) => ( + { + if (!plainLeftClick(event)) return; + onNavigate(projectHref(project), event); + } + : undefined + } + /> + } + > + + {project.slug.slice(0, 1)} + + {project.slug} + {project.id === activeProjectId ? : null} + + ))} + + ))} + {actions ? ( + <> + + {actions} + + ) : null} + + + + + ); +} + +function CollapseButton() { + const { state, toggleSidebar } = useSidebar(); + const collapsed = state === "collapsed"; + return ( + + + + + Collapse sidebar + + + + ); +} + +/** The page's one read of the gate, and its answer once it has one. */ +let platformRead: Promise | undefined; +let platformAnswer: ConnectedPlatform | null = null; + +/** Connecting to another platform is a full page load (`/.auth/connect`), so the shell reads + * once, including while the account loads. A remounted shell can use the cached answer. */ +function useConnectedPlatform() { + const [platform, setPlatform] = useState(platformAnswer); + useEffect(() => { + let mounted = true; + platformRead ||= readConnectedPlatform(AbortSignal.timeout(10_000)).then( + (answer) => { + platformAnswer = answer; + }, + () => {}, + ); + void platformRead.then(() => mounted && setPlatform(platformAnswer)); + return () => { + mounted = false; + }; + }, []); + return platform; +} + +/** SIGN IN AGAIN, through the issuer: the app's own logout (ends this sign-in's grant), then its + * login with the same permissions (app-server.ts), which finds the person still signed in at the + * issuer and shows its consent — where a platform admin may "Sign in as someone else…" — and lands + * on the app's root (not the page shown: it is often another person's project). */ +const SIGN_IN_AGAIN = `/.auth/logout?${new URLSearchParams({ next: "/.auth/login?next=/" })}`; + +/** SIGNED IN AS SOMEONE ELSE, unmissable: whom, who you are, and Stop — signing in again as + * yourself (`SIGN_IN_AGAIN`). A collapsed sidebar keeps the eye. */ +function ImpersonationMarker({ email, admin }: { email: string; admin: string }) { + return ( +
+
+ + + Signed in as {email} + +
+ + You are {admin} + + +
+ ); +} + +/** The deployment beneath the account in the sidebar: quiet when it is the app's default. Any other + * is orange with a warning mark, as it is again in the account menu (`DeploymentNote`). */ +function DeploymentLine({ platform }: { platform: ConnectedPlatform }) { + if (platform.isDefault) + return {platform.host}; + return ( + + {/* important: the menu button sets every svg in it to size-4 */} + + {platform.host} + + ); +} + +/** The deployment in the account menu, its host in full. A custom one repeats the sidebar line's + * mark and orange. */ +function DeploymentNote({ platform }: { platform: ConnectedPlatform }) { + if (platform.isDefault) + return ( +
+ Deployment + {platform.host} +
+ ); + return ( +
+ Custom deployment + + + {platform.host} + +
+ ); +} + +/** The signed-in person and the deployment the app is connected to; Switch account… + * (`SIGN_IN_AGAIN`); and sign out: a POST to the app's own logout, which ends this browser's grant + * at the issuer. */ +function AccountMenu({ + email, + actions, + platform, +}: { + email: string; + actions?: ReactNode; + platform: ConnectedPlatform | null; +}) { + const { isMobile } = useSidebar(); + const logout = useRef(null); + const switchAccount = useRef(null); + const initials = email.slice(0, 2).toUpperCase(); + return ( + + + + + + {initials} + + + {email} + {platform ? : null} + + + + } + /> + + {/* Base UI: a menu label lives inside a group, never bare in the menu */} + + {email} + {platform ? : null} + {actions} + + + + switchAccount.current?.requestSubmit()}> + + Switch account… + + { + resetPosthog(); + logout.current?.requestSubmit(); + }} + > + + Sign out + + + + + + {/* the POSTs, outside the menu items so nothing but an item's click submits one */} +