From 2cf1c085bf9865c8e0b5ecaf9454039e055bedcb Mon Sep 17 00:00:00 2001 From: Misha Kaletsky <15040698+mmkal@users.noreply.github.com> Date: Thu, 5 Nov 2026 11:05:29 +0000 Subject: [PATCH 1/4] Docs builds, tests, publishes and deploys from iterate/docs The tree main holds, Docs' two packages as iterate/private had them, made to stand alone: - A pnpm workspace of packages/docs and packages/docs-app, with iterate/private's catalog versions, pnpm settings and zod patch. `iterate` comes from npm (a main build). - The app's UI from the shadcn registry: shadcn's components, and iterate's (the app shell, PostHog, the stream link) as iterate's packages/ui has them. The few packages/ui/src/apps and packages/shared files it used are copied to src/app, each naming where it came from. - envs.ts and the app's vite.config.ts and scripts/app.ts: the same Worker config iterate deploys (preview on the dev account, prd at docs.iterate.workers.dev), cut down from iterate's start-app.ts the way iterate/kit's was. - CI on Depot (typecheck, lint, format, tests, the app's build) and main's deploy (preview, then prd, paging #error-pulse when red); pkg.pr.new and npm publishing on GitHub Actions, as iterate/private does. - @iterate-com/docs points agents at its guide on iterate/docs, and the Docs app installs this repo's pkg.pr.new builds for a per-commit deployment. Co-Authored-By: Claude Opus 5.5 --- .depot/actions/setup/action.yml | 16 + .depot/workflows/ci.yml | 45 + .depot/workflows/deploy.yml | 80 + .github/workflows/npm-publish.yml | 69 + .github/workflows/pkg-pr-new.yml | 33 + .gitignore | 21 + .nvmrc | 1 + .oxfmtrc.json | 26 + .oxlintrc.json | 131 + AGENTS.md | 18 + LICENSE | 202 + README.md | 53 + doppler.yaml | 5 + envs.ts | 89 + package.json | 28 + packages/docs-app/README.md | 47 +- packages/docs-app/components.json | 21 + packages/docs-app/package.json | 21 +- packages/docs-app/scripts/app.ts | 194 +- packages/docs-app/src/app/base-path.ts | 78 + packages/docs-app/src/app/document.tsx | 34 + packages/docs-app/src/app/head.ts | 16 + packages/docs-app/src/app/posthog-proxy.ts | 38 + packages/docs-app/src/app/providers.tsx | 24 + packages/docs-app/src/app/router.tsx | 40 + packages/docs-app/src/app/server.ts | 94 + packages/docs-app/src/app/start-app-config.ts | 78 + .../components/app-shell-palette-entries.ts | 129 + .../src/components/app-shell-palette.tsx | 297 + .../src/components/app-shell-platform.ts | 34 + .../docs-app/src/components/app-shell.tsx | 663 ++ .../src/components/comments-panel.tsx | 4 +- .../docs-app/src/components/doc-editor.tsx | 12 +- packages/docs-app/src/components/doc-tree.ts | 2 +- packages/docs-app/src/components/docs-nav.tsx | 6 +- .../components/environment-head-content.tsx | 43 + .../src/components/first-project-redirect.tsx | 21 + .../docs-app/src/components/iterate-logo.svg | 7 + .../docs-app/src/components/iterate-logo.tsx | 20 + packages/docs-app/src/components/posthog.tsx | 139 + .../src/components/project-app-shell.tsx | 76 + .../src/components/route-defaults.tsx | 59 + .../docs-app/src/components/stream-link.tsx | 40 + .../docs-app/src/components/ui/avatar.tsx | 106 + .../docs-app/src/components/ui/button.tsx | 57 + .../docs-app/src/components/ui/command.tsx | 194 + .../docs-app/src/components/ui/dialog.tsx | 160 + .../src/components/ui/dropdown-menu.tsx | 265 + packages/docs-app/src/components/ui/empty.tsx | 103 + .../src/components/ui/input-group.tsx | 156 + packages/docs-app/src/components/ui/input.tsx | 19 + .../src/components/ui/native-select.tsx | 60 + .../docs-app/src/components/ui/separator.tsx | 24 + packages/docs-app/src/components/ui/sheet.tsx | 136 + .../docs-app/src/components/ui/sidebar.tsx | 723 ++ .../docs-app/src/components/ui/skeleton.tsx | 13 + .../docs-app/src/components/ui/sonner.tsx | 47 + .../docs-app/src/components/ui/spinner.tsx | 10 + .../docs-app/src/components/ui/textarea.tsx | 17 + .../docs-app/src/components/ui/tooltip.tsx | 65 + packages/docs-app/src/editor/extensions.ts | 2 +- packages/docs-app/src/hooks/use-mobile.ts | 19 + packages/docs-app/src/lib/plain-left-click.ts | 17 + packages/docs-app/src/router.tsx | 6 +- packages/docs-app/src/routes/__root.tsx | 10 +- packages/docs-app/src/routes/_auth.tsx | 4 +- .../routes/_auth/projects.$slug.$repo.$.tsx | 21 +- .../_auth/projects.$slug.$repo.index.tsx | 4 +- .../src/routes/_auth/projects.$slug.tsx | 2 +- .../src/routes/_auth/projects.index.tsx | 2 +- packages/docs-app/src/server.ts | 15 +- packages/docs-app/src/start.ts | 4 +- packages/docs-app/src/styles.css | 6 +- packages/docs-app/src/styles/globals.css | 128 + packages/docs-app/src/styles/iterate.css | 106 + packages/docs-app/src/styles/tokens.css | 38 + packages/docs-app/vite.config.ts | 85 +- packages/docs/README.md | 3 +- packages/docs/package.json | 4 +- packages/docs/src/install.test.ts | 14 +- packages/docs/src/install.ts | 7 +- packages/docs/src/processor.test.ts | 12 +- packages/docs/src/processor.ts | 2 +- packages/docs/vitest.config.ts | 2 - patches/zod@4.6.5.patch | 50 + pnpm-lock.yaml | 7699 +++++++++++++++++ pnpm-workspace.yaml | 81 + scripts/ci/npm-publish.test.ts | 261 + scripts/ci/npm-publish.ts | 503 ++ scripts/ci/page.ts | 45 + scripts/ci/toolchain.sh | 105 + scripts/ci/toolchain.test.ts | 167 + scripts/tsconfig.json | 8 + scripts/vitest.config.ts | 13 + tsconfig.app.json | 13 + tsconfig.base.json | 20 + 96 files changed, 14574 insertions(+), 113 deletions(-) create mode 100644 .depot/actions/setup/action.yml create mode 100644 .depot/workflows/ci.yml create mode 100644 .depot/workflows/deploy.yml create mode 100644 .github/workflows/npm-publish.yml create mode 100644 .github/workflows/pkg-pr-new.yml create mode 100644 .gitignore create mode 100644 .nvmrc create mode 100644 .oxfmtrc.json create mode 100644 .oxlintrc.json create mode 100644 AGENTS.md create mode 100644 LICENSE create mode 100644 README.md create mode 100644 doppler.yaml create mode 100644 envs.ts create mode 100644 package.json create mode 100644 packages/docs-app/components.json create mode 100644 packages/docs-app/src/app/base-path.ts create mode 100644 packages/docs-app/src/app/document.tsx create mode 100644 packages/docs-app/src/app/head.ts create mode 100644 packages/docs-app/src/app/posthog-proxy.ts create mode 100644 packages/docs-app/src/app/providers.tsx create mode 100644 packages/docs-app/src/app/router.tsx create mode 100644 packages/docs-app/src/app/server.ts create mode 100644 packages/docs-app/src/app/start-app-config.ts create mode 100644 packages/docs-app/src/components/app-shell-palette-entries.ts create mode 100644 packages/docs-app/src/components/app-shell-palette.tsx create mode 100644 packages/docs-app/src/components/app-shell-platform.ts create mode 100644 packages/docs-app/src/components/app-shell.tsx create mode 100644 packages/docs-app/src/components/environment-head-content.tsx create mode 100644 packages/docs-app/src/components/first-project-redirect.tsx create mode 100644 packages/docs-app/src/components/iterate-logo.svg create mode 100644 packages/docs-app/src/components/iterate-logo.tsx create mode 100644 packages/docs-app/src/components/posthog.tsx create mode 100644 packages/docs-app/src/components/project-app-shell.tsx create mode 100644 packages/docs-app/src/components/route-defaults.tsx create mode 100644 packages/docs-app/src/components/stream-link.tsx create mode 100644 packages/docs-app/src/components/ui/avatar.tsx create mode 100644 packages/docs-app/src/components/ui/button.tsx create mode 100644 packages/docs-app/src/components/ui/command.tsx create mode 100644 packages/docs-app/src/components/ui/dialog.tsx create mode 100644 packages/docs-app/src/components/ui/dropdown-menu.tsx create mode 100644 packages/docs-app/src/components/ui/empty.tsx create mode 100644 packages/docs-app/src/components/ui/input-group.tsx create mode 100644 packages/docs-app/src/components/ui/input.tsx create mode 100644 packages/docs-app/src/components/ui/native-select.tsx create mode 100644 packages/docs-app/src/components/ui/separator.tsx create mode 100644 packages/docs-app/src/components/ui/sheet.tsx create mode 100644 packages/docs-app/src/components/ui/sidebar.tsx create mode 100644 packages/docs-app/src/components/ui/skeleton.tsx create mode 100644 packages/docs-app/src/components/ui/sonner.tsx create mode 100644 packages/docs-app/src/components/ui/spinner.tsx create mode 100644 packages/docs-app/src/components/ui/textarea.tsx create mode 100644 packages/docs-app/src/components/ui/tooltip.tsx create mode 100644 packages/docs-app/src/hooks/use-mobile.ts create mode 100644 packages/docs-app/src/lib/plain-left-click.ts create mode 100644 packages/docs-app/src/styles/globals.css create mode 100644 packages/docs-app/src/styles/iterate.css create mode 100644 packages/docs-app/src/styles/tokens.css create mode 100644 patches/zod@4.6.5.patch create mode 100644 pnpm-lock.yaml create mode 100644 pnpm-workspace.yaml create mode 100644 scripts/ci/npm-publish.test.ts create mode 100644 scripts/ci/npm-publish.ts create mode 100644 scripts/ci/page.ts create mode 100644 scripts/ci/toolchain.sh create mode 100644 scripts/ci/toolchain.test.ts create mode 100644 scripts/tsconfig.json create mode 100644 scripts/vitest.config.ts create mode 100644 tsconfig.app.json create mode 100644 tsconfig.base.json 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 */} +