From a46f6fbe61aaa2983c79252c68b0eb0abfca3cf2 Mon Sep 17 00:00:00 2001 From: OlolaJaco Date: Sun, 30 Aug 2026 22:06:53 +0100 Subject: [PATCH] feat: add changelog verification script and CI workflow - Added script to verify release tags and changelog state. - Implemented GitHub Actions workflow for automated release verification and publishing. - Introduced new changelog entries for recent documentation improvements. - Enhanced documentation with generated and . - Added tests for new features including TOC component and sitemap generation. - Updated for GitHub edit links and date formatting. --- .../537-dx-025-verify-release-tags.md | 7 + .changelog/unreleased/538-docs-workspace.md | 10 +- .../unreleased/544-dx-032-toc-scroll-spy.md | 7 + .../557-dx-045-edit-page-and-freshness.md | 7 + .../558-dx-046-sitemap-and-robots.md | 7 + .github/workflows/release.yml | 74 ++++++ CONTRIBUTING.md | 42 ++++ .../content/concepts/funding-and-fees.mdx | 2 +- apps/docs/content/concepts/liquidation.mdx | 2 +- apps/docs/content/concepts/risk.mdx | 2 +- apps/docs/content/developers/local-setup.mdx | 2 +- apps/docs/content/guides/troubleshooting.mdx | 10 +- apps/docs/content/index.mdx | 2 +- apps/docs/content/reference/glossary.mdx | 2 +- apps/docs/content/reference/order-vault.mdx | 6 +- .../content/reference/synthetics-reader.mdx | 2 +- apps/docs/content/resources/changelog.mdx | 2 +- apps/docs/content/resources/faq.mdx | 2 +- apps/docs/content/resources/roadmap.mdx | 2 +- apps/docs/content/resources/security.mdx | 4 +- apps/docs/package.json | 14 +- apps/docs/public/robots.txt | 3 + apps/docs/public/sitemap.xml | 111 +++++++++ apps/docs/scripts/build.ts | 78 ++++++- apps/docs/scripts/check-content.ts | 39 +++- apps/docs/scripts/lint-prose.ts | 26 ++- apps/docs/scripts/seo.test.ts | 2 +- apps/docs/scripts/sitemap.test.ts | 35 +++ apps/docs/scripts/sitemap.ts | 92 ++++++++ apps/docs/src/components/DocsHome.test.tsx | 1 + apps/docs/src/components/Sidebar.tsx | 2 +- apps/docs/src/components/Toc.test.tsx | 89 ++++++++ apps/docs/src/components/Toc.tsx | 116 +++++++++- apps/docs/src/lib/docs-helpers.test.ts | 61 +++++ apps/docs/src/lib/docs-helpers.ts | 70 ++++++ apps/docs/src/styles/globals.css | 2 +- apps/docs/src/styles/print.css | 2 +- apps/docs/src/vite-env.d.ts | 6 + apps/docs/tsconfig.json | 10 +- .../src/features/changelog/changelog.test.tsx | 2 +- .../changelog/components/ChangelogPage.tsx | 99 +++++++-- .../changelog/components/ReleaseSection.tsx | 23 +- apps/web/src/features/changelog/types.ts | 50 ++++- apps/web/src/features/changelog/utils.ts | 29 +-- apps/web/src/routes/a11y.test.tsx | 5 +- apps/web/src/routes/changelog.tsx | 12 - apps/web/src/ui/Navbar.tsx | 5 +- apps/web/test/msw/handlers.ts | 2 - bun.lock | 12 +- package.json | 3 +- packages/ui/package.json | 2 +- scripts/changelog/verify-tag.test.ts | 179 +++++++++++++++ scripts/changelog/verify-tag.ts | 210 ++++++++++++++++++ 53 files changed, 1438 insertions(+), 148 deletions(-) create mode 100644 .changelog/unreleased/537-dx-025-verify-release-tags.md create mode 100644 .changelog/unreleased/544-dx-032-toc-scroll-spy.md create mode 100644 .changelog/unreleased/557-dx-045-edit-page-and-freshness.md create mode 100644 .changelog/unreleased/558-dx-046-sitemap-and-robots.md create mode 100644 .github/workflows/release.yml create mode 100644 apps/docs/public/robots.txt create mode 100644 apps/docs/public/sitemap.xml create mode 100644 apps/docs/scripts/sitemap.test.ts create mode 100644 apps/docs/scripts/sitemap.ts create mode 100644 apps/docs/src/components/Toc.test.tsx create mode 100644 apps/docs/src/lib/docs-helpers.test.ts create mode 100644 apps/docs/src/lib/docs-helpers.ts create mode 100644 apps/docs/src/vite-env.d.ts create mode 100644 scripts/changelog/verify-tag.test.ts create mode 100644 scripts/changelog/verify-tag.ts diff --git a/.changelog/unreleased/537-dx-025-verify-release-tags.md b/.changelog/unreleased/537-dx-025-verify-release-tags.md new file mode 100644 index 00000000..d85e401b --- /dev/null +++ b/.changelog/unreleased/537-dx-025-verify-release-tags.md @@ -0,0 +1,7 @@ +--- +type: added +area: ci +pr: 537 +breaking: false +--- +Added CI release workflow that verifies changelog state and publishes GitHub Releases on release tags. diff --git a/.changelog/unreleased/538-docs-workspace.md b/.changelog/unreleased/538-docs-workspace.md index 107be59f..f0181b5c 100644 --- a/.changelog/unreleased/538-docs-workspace.md +++ b/.changelog/unreleased/538-docs-workspace.md @@ -1,7 +1,7 @@ --- -title: Scaffold docs workspace -category: docs +type: added +area: docs +pr: 538 +breaking: false --- - -Adds the initial `apps/docs` workspace, content validation command, keyboard -shortcut shell, and accessibility coverage for representative docs states. +Added the initial docs workspace, content validation command, keyboard shortcut shell, and accessibility coverage for representative docs states. diff --git a/.changelog/unreleased/544-dx-032-toc-scroll-spy.md b/.changelog/unreleased/544-dx-032-toc-scroll-spy.md new file mode 100644 index 00000000..8bb8adb4 --- /dev/null +++ b/.changelog/unreleased/544-dx-032-toc-scroll-spy.md @@ -0,0 +1,7 @@ +--- +type: added +area: docs +pr: 544 +breaking: false +--- +Added on-page table of contents with scroll-spy and heading focus navigation to the documentation site. diff --git a/.changelog/unreleased/557-dx-045-edit-page-and-freshness.md b/.changelog/unreleased/557-dx-045-edit-page-and-freshness.md new file mode 100644 index 00000000..235fd592 --- /dev/null +++ b/.changelog/unreleased/557-dx-045-edit-page-and-freshness.md @@ -0,0 +1,7 @@ +--- +type: added +area: docs +pr: 557 +breaking: false +--- +Added GitHub edit links, relative updated dates, and commit-date freshness validation to documentation pages. diff --git a/.changelog/unreleased/558-dx-046-sitemap-and-robots.md b/.changelog/unreleased/558-dx-046-sitemap-and-robots.md new file mode 100644 index 00000000..f2492107 --- /dev/null +++ b/.changelog/unreleased/558-dx-046-sitemap-and-robots.md @@ -0,0 +1,7 @@ +--- +type: added +area: docs +pr: 558 +breaking: false +--- +Generated sitemap.xml and robots.txt with canonical link tags during the documentation build. diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..bdfc14d4 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,74 @@ +name: Release + +on: + push: + tags: + - "v*" + +jobs: + release: + name: Verify & Publish Release + runs-on: ubuntu-latest + permissions: + contents: write + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Cache Bun dependencies + uses: actions/cache@v4 + with: + path: ~/.bun/install/cache + key: ${{ runner.os }}-bun-${{ hashFiles('bun.lock') }} + restore-keys: | + ${{ runner.os }}-bun- + + - name: Install dependencies + run: bun install --frozen-lockfile + + # Quality Gate from AGENTS.md §1 + - name: Lint + run: bun lint + + - name: Typecheck + run: bun typecheck + + - name: Design token check + run: bun run check:tokens + + - name: Documentation content and prose checks + run: bun run check:content && bun run --cwd apps/docs lint:prose + + - name: Documentation link and FAQ drift checks + run: bun run check:links && bun run --cwd apps/docs check:faq + + - name: Documentation generated-reference drift checks + run: bun run --cwd apps/docs check:tokens:generated && bun run --cwd apps/docs check:errors:generated + + - name: Test + run: bun run test + + - name: Coverage + run: bun run test:coverage + + - name: Build + run: bun run build + + # Release verification assertions & note extraction + - name: Verify release tag and changelog + run: bun run scripts/changelog/verify-tag.ts --tag "${{ github.ref_name }}" --output-notes /tmp/release-notes.md + + # Publish GitHub Release (never pushes a commit back) + - name: Publish GitHub Release + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh release create "${{ github.ref_name }}" \ + --title "${{ github.ref_name }}" \ + --notes-file /tmp/release-notes.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8f95d7ea..4c287e13 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -16,6 +16,8 @@ quality bar every change must clear, and how to get a pull request merged. - [The quality gate](#the-quality-gate) - [Development workflow](#development-workflow) - [Fixing issues properly](#fixing-issues-properly) +- [Changelog entries](#changelog-entries) +- [Release procedure](#release-procedure) - [Commit conventions](#commit-conventions) - [Pull requests](#pull-requests) - [Project-specific gotchas](#project-specific-gotchas) @@ -247,6 +249,46 @@ Run `bun run changelog:validate` before committing to catch formatting errors. --- +## Release procedure + +SO4 Market uses tag-driven release automation with CI verification (DX-025). + +CI **verifies** release readiness; it **never writes** back to `main`. The release process is kept entirely in reviewed PRs: + +1. **Cut the release in a PR**: + Run the release command with the target SemVer version: + ```bash + bun run changelog:release 0.2.0 + ``` + This command: + - Validates all pending entry files in `.changelog/unreleased/`. + - Aggregates them into a new `## [0.2.0] - YYYY-MM-DD` section in `CHANGELOG.md`. + - Clears consumed entry files from `.changelog/unreleased/`. + - Updates compare links at the bottom of `CHANGELOG.md`. + +2. **Open, review, and merge the PR**: + Submit the release PR targeting `main`. Run the full quality gate. Once approved and CI is green, merge to `main`. + +3. **Tag the release on `main`**: + After merging, pull the latest `main` and push an annotated git tag matching the release version: + ```bash + git checkout main + git pull upstream main + git tag -a v0.2.0 -m "Release v0.2.0" + git push upstream v0.2.0 + ``` + +4. **CI Release Workflow (`.github/workflows/release.yml`)**: + On push of any `v*` tag, CI: + - Runs the full quality gate (`AGENTS.md` §1). + - Asserts `.changelog/unreleased/` is empty. + - Asserts `CHANGELOG.md` contains a release section matching the version in the tag. + - Asserts the release date in `CHANGELOG.md` is within 1 day of the tag date. + - Extracts the release section notes and creates a GitHub Release for the tag. + - Never pushes commits to any branch. + +--- + ## Commit conventions We follow [Conventional Commits](https://www.conventionalcommits.org/): diff --git a/apps/docs/content/concepts/funding-and-fees.mdx b/apps/docs/content/concepts/funding-and-fees.mdx index 9ff0deec..9988ad54 100644 --- a/apps/docs/content/concepts/funding-and-fees.mdx +++ b/apps/docs/content/concepts/funding-and-fees.mdx @@ -1,7 +1,7 @@ --- title: Funding and fees description: How funding, transaction charges, borrowing costs, and price impact affect a perpetual position. -updated: 2026-08-24 +updated: 2026-08-25 status: stable --- diff --git a/apps/docs/content/concepts/liquidation.mdx b/apps/docs/content/concepts/liquidation.mdx index c8f2fb8f..8c6d06fc 100644 --- a/apps/docs/content/concepts/liquidation.mdx +++ b/apps/docs/content/concepts/liquidation.mdx @@ -1,7 +1,7 @@ --- title: Liquidation description: How maintenance margin and changing position value determine whether a leveraged position may be liquidated. -updated: 2026-08-24 +updated: 2026-08-25 status: stable --- diff --git a/apps/docs/content/concepts/risk.mdx b/apps/docs/content/concepts/risk.mdx index 58290ffb..71fe91a1 100644 --- a/apps/docs/content/concepts/risk.mdx +++ b/apps/docs/content/concepts/risk.mdx @@ -1,7 +1,7 @@ --- title: Risk description: A concise overview of market, liquidation, oracle, contract, network, interface, and custody risks in SO4. -updated: 2026-08-24 +updated: 2026-08-25 status: stable --- diff --git a/apps/docs/content/developers/local-setup.mdx b/apps/docs/content/developers/local-setup.mdx index af24e112..e11d05b6 100644 --- a/apps/docs/content/developers/local-setup.mdx +++ b/apps/docs/content/developers/local-setup.mdx @@ -104,7 +104,7 @@ bun run --cwd apps/web typecheck bun run --cwd apps/web build ``` -**Key rule: run the whole gate, not a subset.** Even if you only changed a comment, run all commands. Turbo caches aggressively, so the full run is usually just a few seconds. +**Key rule: run the whole gate, not a subset.** Even if you only changed a comment, run all commands. Turbo caches aggressively, so the full run usually takes a few seconds. The gate exists to catch: diff --git a/apps/docs/content/guides/troubleshooting.mdx b/apps/docs/content/guides/troubleshooting.mdx index 0a1ac9f7..f166bf18 100644 --- a/apps/docs/content/guides/troubleshooting.mdx +++ b/apps/docs/content/guides/troubleshooting.mdx @@ -18,11 +18,11 @@ Likely causes, in order: 1. **The signature dialog is still open or was missed.** A Soroban transaction only leaves "pending" once your wallet returns a signed envelope. Check the wallet extension for a pending approval popup — it can open behind the browser window. 2. **The transaction expired before inclusion** (`tx_too_late` in [/reference/errors#tx-too-late](/reference/errors#tx-too-late)). Slow signing or a busy network can push the transaction past its time bounds. 3. **The submitted fee was too low for current network conditions** (`tx_insufficient_fee`). More likely during periods of high testnet/mainnet load. -4. **The RPC node is degraded.** If every submission from your session hangs, not just one, the endpoint itself may be slow — see "Network is busy" in [/reference/errors#timeout-or-try-again](/reference/errors#timeout-or-try-again). +4. **The RPC node is degraded.** If every submission from your session hangs, rather than a single one, the endpoint itself may be slow — see "Network is busy" in [/reference/errors#timeout-or-try-again](/reference/errors#timeout-or-try-again). **Checks:** - Look for a wallet popup, including behind other windows. -- Reload the page and check whether the transaction hash (if you have one) appears on [Stellar Expert](https://stellar.expert) — if it does, it succeeded and the UI simply didn't confirm; if it doesn't after a few minutes, it did not land and is safe to resubmit. +- Reload the page and check whether the transaction hash (if you have one) appears on [Stellar Expert](https://stellar.expert) — if it does, it succeeded and the UI did not confirm; if it doesn't after a few minutes, it did not land and is safe to resubmit. - Retry once. Soroban transactions are not resubmitted automatically by design — see [Write SO4 transactions](/developers/writing-transactions). ## Wallet will not connect @@ -72,11 +72,11 @@ Likely causes, in order: Likely causes, in order: 1. **Indexer lag**, same as "Balance not updating" above — positions listed through the indexer reflect its current sync height, not the chain tip. -2. **The order hasn't executed yet.** Opening a position through a limit or trigger order creates an *order* first; it only becomes a *position* once that order executes. Check the orders list, not just positions. +2. **The order hasn't executed yet.** Opening a position through a limit or trigger order creates an *order* first; it only becomes a *position* once that order executes. Check the orders list, not only positions. 3. **The transaction that would have created it failed.** Check `TxStatus` for a failure state and the message against [/reference/errors](/reference/errors) — a position that never opened has nothing to display. **Checks:** -- Confirm the transaction hash on Stellar Expert actually invoked `exchange-router` successfully. +- Confirm the transaction hash on Stellar Expert actually invoked `ExchangeRouter` successfully. - Check the orders list for a pending order with the same parameters. - Confirm you're on the same network and market you traded on. @@ -88,7 +88,7 @@ Likely causes, in order: 1. **An oracle read failed or is stale.** SO4 sources prices from more than one oracle; a temporarily stale or unavailable feed can leave the chart without fresh data rather than showing wrong data. 2. **A market data subscription dropped.** Realtime chart updates depend on a live connection; a network blip can leave the chart showing the last received point without visibly erroring. -3. **The selected market has no recent activity.** A thinly-traded market can look "stuck" simply because there's nothing new to plot. +3. **The selected market has no recent activity.** A thinly-traded market can look "stuck" because there's nothing new to plot. **Checks:** - Switch to a different, more active market and confirm its chart updates — this isolates whether the problem is connection-wide or market-specific. diff --git a/apps/docs/content/index.mdx b/apps/docs/content/index.mdx index 5e77b954..344b3b40 100644 --- a/apps/docs/content/index.mdx +++ b/apps/docs/content/index.mdx @@ -2,7 +2,7 @@ title: SO4 Docs description: Documentation for SO4 Markets, including concepts, developer references, and operational guides. status: stable -updated: 2026-08-27 +updated: 2026-08-28 --- # SO4 Docs diff --git a/apps/docs/content/reference/glossary.mdx b/apps/docs/content/reference/glossary.mdx index 54fe47f5..d7052802 100644 --- a/apps/docs/content/reference/glossary.mdx +++ b/apps/docs/content/reference/glossary.mdx @@ -1,7 +1,7 @@ --- title: Glossary description: Alphabetical definitions linking perpetual-markets and Stellar or Soroban terms to their detailed documentation. -updated: 2026-08-24 +updated: 2026-08-25 status: stable --- diff --git a/apps/docs/content/reference/order-vault.mdx b/apps/docs/content/reference/order-vault.mdx index 77ceb5f5..5543b1f4 100644 --- a/apps/docs/content/reference/order-vault.mdx +++ b/apps/docs/content/reference/order-vault.mdx @@ -62,7 +62,7 @@ In practice, this means the refund path is governed by the order lifecycle rathe - a failed or expired order should leave the vault in a recoverable state such that the original user can receive the unused collateral back; - a cancellation path releases the escrowed amount through the vault transfer-out primitive. -The key security rule is simple: the order-vault escrow is only as safe as the order handler's settlement logic. If the order never reaches a valid executed state, the refund path must release the deposit to the correct account and not leave a stranded balance in custody. +The key security rule is simple: the OrderVault escrow is only as safe as the order handler's settlement logic. If the order never reaches a valid executed state, the refund path must release the deposit to the correct account and not leave a stranded balance in custody. ## Worked example: cancelled order {#worked-example} @@ -98,9 +98,9 @@ For an order that fails at execution or expires before it can be filled, the sam ## Implementation notes {#implementation-notes} -The generated client is intentionally minimal. It records only the two transfer primitives the order-vault contract needs to support escrow movement: +The generated client is intentionally minimal. It records only the two transfer primitives the OrderVault contract needs to support escrow movement: - `recordTransferIn` to accept collateral into custody - `transferOut` to release collateral when the order settles or is cancelled -This is why the vault is best thought of as the escrow ledger for live orders rather than as a full order-management API. The actual lifecycle rules live in the order and execution handlers, while the vault simply enforces the asset movement that those lifecycle decisions require. +This is why the vault is best thought of as the escrow ledger for live orders rather than as a full order-management API. The actual lifecycle rules live in the order and execution handlers, while the vault enforces the asset movement that those lifecycle decisions require. diff --git a/apps/docs/content/reference/synthetics-reader.mdx b/apps/docs/content/reference/synthetics-reader.mdx index 633adfa9..781f373c 100644 --- a/apps/docs/content/reference/synthetics-reader.mdx +++ b/apps/docs/content/reference/synthetics-reader.mdx @@ -140,4 +140,4 @@ The generated reader relies on several infrastructure contracts at read time: - `oracle` for pool pricing and value calculations - `orderHandler` for account-order lookups -This is why the raw contract API has explicit address arguments even for a read-only query. The reader is intentionally not a generic `DataStore` client: it is a protocol-specific view layer that translates data-store state into the market, pool, funding, and position objects the interface actually needs. +This is why the raw contract API has explicit address arguments even for a read-only query. The reader is intentionally not a generic `DataStore` client: it is a protocol-specific view layer that translates DataStore state into the market, pool, funding, and position objects the interface actually needs. diff --git a/apps/docs/content/resources/changelog.mdx b/apps/docs/content/resources/changelog.mdx index 319da84c..cd10ffdc 100644 --- a/apps/docs/content/resources/changelog.mdx +++ b/apps/docs/content/resources/changelog.mdx @@ -1,7 +1,7 @@ --- title: Changelog description: How documentation updates relate to the product release history published on the SO4 interface. -updated: 2026-08-24 +updated: 2026-08-25 status: stable --- diff --git a/apps/docs/content/resources/faq.mdx b/apps/docs/content/resources/faq.mdx index 118496d2..ed7bb23f 100644 --- a/apps/docs/content/resources/faq.mdx +++ b/apps/docs/content/resources/faq.mdx @@ -1,7 +1,7 @@ --- title: Frequently asked questions description: Short answers to common SO4 trading, custody, fees, liquidation, wallet, and availability questions. -updated: 2026-08-24 +updated: 2026-08-25 status: stable landing: [what-is-so4, self-custody, liquidation, fees, availability] --- diff --git a/apps/docs/content/resources/roadmap.mdx b/apps/docs/content/resources/roadmap.mdx index 69f0a03d..0090f4da 100644 --- a/apps/docs/content/resources/roadmap.mdx +++ b/apps/docs/content/resources/roadmap.mdx @@ -1,7 +1,7 @@ --- title: Roadmap description: The current status of shipped and planned SO4 interface, protocol, and documentation work. -updated: 2026-08-24 +updated: 2026-08-25 status: stable --- diff --git a/apps/docs/content/resources/security.mdx b/apps/docs/content/resources/security.mdx index 6af8cf0d..986aeef5 100644 --- a/apps/docs/content/resources/security.mdx +++ b/apps/docs/content/resources/security.mdx @@ -25,7 +25,7 @@ Current deployments are testnet-only — see [Contract addresses](/reference/con Report privately instead, by messaging the project maintainer directly: [t.me/ibrahimijai](https://t.me/ibrahimijai). Include: -- What you found and why it's a vulnerability, not just unexpected behavior. +- What you found and why it's a vulnerability, rather than unexpected behavior. - Steps to reproduce, or a proof of concept if you have one. - The affected contract, file, or endpoint. - Your assessment of severity and impact, if you have one — helpful, not required. @@ -55,7 +55,7 @@ There is no bug bounty program. Reports are still welcome and will be credited ( - **No independent audit** (above) — the largest single caveat on this page. - **Oracle dependence.** Pricing relies on external oracle feeds; a stale, manipulated, or unavailable feed can affect liquidations and execution prices. See [Risk](/concepts/risk). - **Testnet-only deployment.** Current contract addresses are testnet; testnet tokens and state carry no real value and can be reset. -- **Interface trust.** The interface prepares transactions for your wallet to sign; a compromised build of the interface (not just the contracts) could construct a malicious transaction. Always review what you're signing in your wallet, not just in the browser UI. +- **Interface trust.** The interface prepares transactions for your wallet to sign; a compromised build of the interface (and not only the contracts) could construct a malicious transaction. Always review what you're signing in your wallet, rather than relying solely on the browser UI. - **Rapidly changing code.** The protocol and interface are under active development; behavior documented today can change before an equivalent audit or review catches up. The full, longer list of what can go wrong — market, liquidation, oracle, contract, network, interface, and custody risk — lives at [/concepts/risk](/concepts/risk); this page covers the security-process side specifically (audits, disclosure, scope), not the trading-risk side. diff --git a/apps/docs/package.json b/apps/docs/package.json index ba01d343..adbe9b0e 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -10,8 +10,8 @@ "lint:prose": "bun run scripts/lint-prose.ts", "format": "prettier --write \"content/**/*.mdx\" \"scripts/**/*.ts\" \"src/**/*.{ts,tsx}\"", "typecheck": "tsc --noEmit", - "test": "bun test", - "test:coverage": "bun test --coverage", + "test": "vitest run && bun test scripts/content-loader.test.ts scripts/content.test.ts scripts/frontmatter.test.ts scripts/generate-graphql.test.ts scripts/image-pipeline.test.ts scripts/lint-prose.test.ts scripts/nav-builder.test.ts scripts/seo.test.ts scripts/shiki.test.ts scripts/sitemap.test.ts", + "test:coverage": "vitest run --coverage", "check:content": "bun run scripts/check-content.ts", "check:links": "bun run scripts/check-links.ts", "generate:faq": "bun run scripts/generate-faq.ts", @@ -38,14 +38,21 @@ "@repo/vitest-config": "workspace:*", "@shikijs/rehype": "^4.4.3", "@shikijs/transformers": "^4.4.3", + "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", "@types/bun": "^1.3.0", + "@vitest/coverage-v8": "^3.2.0", + "@vitest/expect": "^3.2.7", + "vitest": "3", + "vitest-axe": "^0.1.0", "@types/mdx": "^2.0.14", "@types/react": "^19.2.18", "@types/react-dom": "^19.2.5", "h3": "^2.0.1-rc.29", "happy-dom": "^20.11.6", "jsdom": "^30.0.1", + "msw": "^2.12.12", "nitro": "^3.0.260610-beta", "pagefind": "^1.5.2", "prettier": "^3.8.1", @@ -55,6 +62,7 @@ "shiki": "^4.4.3", "tailwindcss": "^4.1.18", "typescript": "^5.9.3", - "vite": "^7.3.2" + "vite": "^7.3.2", + "vite-tsconfig-paths": "^5.1.4" } } diff --git a/apps/docs/public/robots.txt b/apps/docs/public/robots.txt new file mode 100644 index 00000000..b660537e --- /dev/null +++ b/apps/docs/public/robots.txt @@ -0,0 +1,3 @@ +User-agent: * +Allow: / +Sitemap: https://docs.so4.market/sitemap.xml diff --git a/apps/docs/public/sitemap.xml b/apps/docs/public/sitemap.xml new file mode 100644 index 00000000..a3203805 --- /dev/null +++ b/apps/docs/public/sitemap.xml @@ -0,0 +1,111 @@ + + + + https://docs.so4.market/ + 2026-08-28 + + + https://docs.so4.market/concepts/funding-and-fees + 2026-08-25 + + + https://docs.so4.market/concepts/liquidation + 2026-08-25 + + + https://docs.so4.market/concepts/risk + 2026-08-25 + + + https://docs.so4.market/developers/architecture + 2026-08-25 + + + https://docs.so4.market/developers/contract-clients + 2026-08-25 + + + https://docs.so4.market/developers/design-system + 2026-08-25 + + + https://docs.so4.market/developers/indexer + 2026-08-25 + + + https://docs.so4.market/developers/json-feed + 2026-08-25 + + + https://docs.so4.market/developers/local-setup + 2026-08-25 + + + https://docs.so4.market/developers/reading-data + 2026-08-25 + + + https://docs.so4.market/developers/writing-transactions + 2026-08-25 + + + https://docs.so4.market/get-started/introduction + 2026-08-30 + + + https://docs.so4.market/get-started/quickstart + 2026-08-30 + + + https://docs.so4.market/guides/troubleshooting + 2026-08-25 + + + https://docs.so4.market/reference/contracts.generated + 2026-08-25 + + + https://docs.so4.market/reference/data-store + 2026-08-25 + + + https://docs.so4.market/reference/errors + 2026-08-25 + + + https://docs.so4.market/reference/exchange-router + 2026-08-25 + + + https://docs.so4.market/reference/glossary + 2026-08-25 + + + https://docs.so4.market/reference/order-vault + 2026-08-25 + + + https://docs.so4.market/reference/synthetics-reader + 2026-08-25 + + + https://docs.so4.market/reference/tokens.generated + 2026-08-25 + + + https://docs.so4.market/resources/changelog + 2026-08-25 + + + https://docs.so4.market/resources/faq + 2026-08-25 + + + https://docs.so4.market/resources/roadmap + 2026-08-25 + + + https://docs.so4.market/resources/security + 2026-08-25 + + diff --git a/apps/docs/scripts/build.ts b/apps/docs/scripts/build.ts index 3d28cc15..7a1cdae4 100644 --- a/apps/docs/scripts/build.ts +++ b/apps/docs/scripts/build.ts @@ -2,8 +2,14 @@ import { mkdir, readdir, rm } from "node:fs/promises" import { join } from "node:path" import { $ } from "bun" -import { appRoot, loadPages, slugifyHeading } from "./content" -import { DEFAULT_SITE_URL } from "../src/lib/seo" +import { appRoot, loadPages, slugifyHeading } from "./content.ts" +import { DEFAULT_SITE_URL, generateSeoTags } from "../src/lib/seo.ts" +import { + formatRelativeTime, + getGitHubEditUrl, + isPageStale, +} from "../src/lib/docs-helpers.ts" +import { buildSitemapAndRobots } from "./sitemap.ts" await $`bun run ${join(appRoot, "scripts/check-content.ts")}` await $`bun run ${join(appRoot, "scripts/check-links.ts")}` @@ -34,6 +40,41 @@ function renderInline(value: string) { .replace(/`([^`]+)`/g, "$1") } +export function extractHeadings(body: string): Array<{ + title: string + id: string + level: number +}> { + const blocks = body.split(/\n\n+/) + const headings: Array<{ title: string; id: string; level: number }> = [] + for (const block of blocks) { + const match = block.match(/^(#{2,3}) (.+?)(?: \{#([a-z0-9-]+)\})?$/) + if (match) { + const level = match[1].length + const title = match[2] + const id = match[3] ?? slugifyHeading(title) + headings.push({ title, id, level }) + } + } + return headings +} + +function renderTocHtml( + headings: Array<{ title: string; id: string; level: number }> +): string { + if (headings.length < 2) return "" + + const items = headings + .map((h) => { + const isH3 = h.level === 3 + const indentClass = isH3 ? "ps-3 text-xs" : "" + return `
  • ${escape(h.title)}
  • ` + }) + .join("\n") + + return `` +} + function render(body: string) { const blocks = body.split(/\n\n+/) return blocks @@ -81,8 +122,37 @@ if (!stylesheet) throw new Error("Vite did not emit the docs stylesheet") for (const page of pages) { const directory = join(outputRoot, page.route.slice(1)) await mkdir(directory, { recursive: true }) - const html = `${escape(page.frontmatter.title)} · SO4 docs
    SO4 docsOpen interface

    ${escape(page.frontmatter.title)}

    ${render(page.body)}
    Last updated
    ` + + const canonicalRoute = page.route === "/index" ? "/" : page.route + const sectionName = page.route.split("/")[1] || "Documentation" + + const { headTags, structuredDataHtml } = generateSeoTags({ + title: page.frontmatter.title, + description: page.frontmatter.description, + route: canonicalRoute, + updated: page.frontmatter.updated, + section: sectionName, + }) + + const headings = extractHeadings(page.body) + const tocHtml = renderTocHtml(headings) + const editUrl = getGitHubEditUrl(page.route) + const relativeDate = formatRelativeTime(page.frontmatter.updated) + const isStale = isPageStale(page.frontmatter.updated) + + const staleBanner = isStale + ? `` + : "" + + const html = `${headTags}${structuredDataHtml}
    SO4 docs

    ${escape(page.frontmatter.title)}

    ${staleBanner}${render(page.body)}
    Last updated Edit this page on GitHub ↗
    Last updated
    ${tocHtml}
    ` + await Bun.write(join(directory, "index.html"), html) } -console.log(`Built ${pages.length} static documentation routes.`) +// DX-046: Generate sitemap.xml and robots.txt +await buildSitemapAndRobots(DEFAULT_SITE_URL, [ + join(appRoot, "public"), + outputRoot, +]) + +console.log(`Built ${pages.length} static documentation routes with TOC, SEO metadata, sitemap.xml, and robots.txt.`) diff --git a/apps/docs/scripts/check-content.ts b/apps/docs/scripts/check-content.ts index ad43bd66..810c669d 100644 --- a/apps/docs/scripts/check-content.ts +++ b/apps/docs/scripts/check-content.ts @@ -1,9 +1,10 @@ -import { readFile } from "node:fs/promises" +import { readFile, writeFile } from "node:fs/promises" import { join } from "node:path" -import { contentRoot, headingEntries, loadPages } from "./content" -import { validateFrontmatter } from "../src/lib/frontmatter" +import { contentRoot, headingEntries, loadPages } from "./content.ts" +import { validateFrontmatter } from "../src/lib/frontmatter.ts" +const isFix = process.argv.includes("--fix") const pages = await loadPages() const errors: Array = [] const routes = new Set(pages.map((page) => page.route)) @@ -20,6 +21,37 @@ for (const page of pages) { if (!["stable", "beta", "draft"].includes(status)) errors.push(`${page.route}: invalid status`) + // DX-045: Validate updated date against git commit history if tracked + if (!page.file.endsWith(".generated.mdx") && status !== "draft") { + try { + const proc = Bun.spawnSync([ + "git", + "log", + "-1", + "--format=%cs", + "--", + page.file, + ]) + const gitDate = proc.stdout.toString().trim() + if (gitDate && /^\d{4}-\d{2}-\d{2}$/.test(gitDate)) { + if (updated !== gitDate) { + if (isFix) { + const raw = await readFile(page.file, "utf-8") + const fixed = raw.replace(/^updated:\s*.*$/m, `updated: ${gitDate}`) + await writeFile(page.file, fixed, "utf-8") + page.frontmatter.updated = gitDate + } else { + errors.push( + `${page.route}: frontmatter updated date (${updated}) does not match last git commit date (${gitDate})` + ) + } + } + } + } catch { + // Ignore if git is not available + } + } + // DX-055: Enforce image alt text and dimension requirements const imgMatches = page.body.matchAll(/!\[([^\]]*)\]\(([^)]+)\)/g) for (const match of imgMatches) { @@ -31,7 +63,6 @@ for (const page of pages) { } } - const meta = JSON.parse( await readFile(join(contentRoot, "meta.json"), "utf8"), ) as { diff --git a/apps/docs/scripts/lint-prose.ts b/apps/docs/scripts/lint-prose.ts index fc37a746..330225d0 100644 --- a/apps/docs/scripts/lint-prose.ts +++ b/apps/docs/scripts/lint-prose.ts @@ -29,10 +29,10 @@ const REQUIRED_CAPITALIZATIONS = [ { wrong: /\bstellar\b/g, correct: "Stellar" }, { wrong: /\bfreighter\b/g, correct: "Freighter" }, { wrong: /\bturborepo\b/g, correct: "Turborepo" }, - { wrong: /\border-vault\b/i, correct: "OrderVault" }, - { wrong: /\bexchange-router\b/i, correct: "ExchangeRouter" }, - { wrong: /\bsynthetics-reader\b/i, correct: "SyntheticsReader" }, - { wrong: /\bdata-store\b/i, correct: "DataStore" }, + { wrong: /\border-vault\b/gi, correct: "OrderVault" }, + { wrong: /\bexchange-router\b/gi, correct: "ExchangeRouter" }, + { wrong: /\bsynthetics-reader\b/gi, correct: "SyntheticsReader" }, + { wrong: /\bdata-store\b/gi, correct: "DataStore" }, ] const PASSIVE_VOICE_PATTERNS = [ @@ -67,9 +67,15 @@ export function lintMarkdownContent(file: string, source: string): LintResult { } if (inCodeBlock) continue + // Mask inline code spans, link targets, and HTML tags/elements with spaces to preserve column indices + const proseLine = line + .replace(/`[^`]+`/g, (m) => " ".repeat(m.length)) + .replace(/\]\([^)]+\)/g, (m) => "]" + " ".repeat(m.length - 1)) + .replace(/<[^>]+>/g, (m) => " ".repeat(m.length)) + // 1. Exclamation marks check (Error) - const exclamIdx = line.indexOf("!") - if (exclamIdx !== -1 && !line.match(/!\[.*?\]\(.*?\)/) && !line.match(/!=\s*/)) { + const exclamIdx = proseLine.indexOf("!") + if (exclamIdx !== -1 && !proseLine.match(/!=\s*/)) { errors.push({ file, line: lineNum, @@ -84,7 +90,7 @@ export function lintMarkdownContent(file: string, source: string): LintResult { for (const { word, reason } of BANNED_WORDS) { const regex = new RegExp(`\\b${word}\\b`, "gi") let match: RegExpExecArray | null - while ((match = regex.exec(line)) !== null) { + while ((match = regex.exec(proseLine)) !== null) { errors.push({ file, line: lineNum, @@ -100,7 +106,7 @@ export function lintMarkdownContent(file: string, source: string): LintResult { for (const { wrong, correct } of REQUIRED_CAPITALIZATIONS) { let match: RegExpExecArray | null wrong.lastIndex = 0 - while ((match = wrong.exec(line)) !== null) { + while ((match = wrong.exec(proseLine)) !== null) { // Skip if matched inside link or code snippet if case matches correct if (match[0] !== correct) { errors.push({ @@ -117,7 +123,7 @@ export function lintMarkdownContent(file: string, source: string): LintResult { // 4. Passive voice check (Warning) for (const pattern of PASSIVE_VOICE_PATTERNS) { - const match = pattern.exec(line) + const match = pattern.exec(proseLine) if (match) { warnings.push({ file, @@ -131,7 +137,7 @@ export function lintMarkdownContent(file: string, source: string): LintResult { } // 5. Sentence length threshold (> 30 words) (Warning) - const sentences = line.split(/(?<=[.!?])\s+/) + const sentences = proseLine.split(/(?<=[.!?])\s+/) for (const sentence of sentences) { const words = sentence.trim().split(/\s+/).filter(Boolean) if (words.length > 30) { diff --git a/apps/docs/scripts/seo.test.ts b/apps/docs/scripts/seo.test.ts index 7c07cf4f..0edfc4cd 100644 --- a/apps/docs/scripts/seo.test.ts +++ b/apps/docs/scripts/seo.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test" import { generateSeoTags } from "../src/lib/seo" -import { generateOgSvg } from "../../scripts/lib/og-generator" +import { generateOgSvg } from "../../../scripts/lib/og-generator.ts" describe("SEO metadata and social preview generator (DX-047)", () => { test("generates unique title suffix, description, OG/Twitter tags, and TechArticle schema", () => { diff --git a/apps/docs/scripts/sitemap.test.ts b/apps/docs/scripts/sitemap.test.ts new file mode 100644 index 00000000..9e3c81d2 --- /dev/null +++ b/apps/docs/scripts/sitemap.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, test } from "bun:test" +import { generateSitemapXml, generateRobotsTxt, buildSitemapAndRobots } from "./sitemap.ts" + +describe("DX-046: sitemap.xml and robots.txt generation", () => { + test("generateSitemapXml builds standard schema compliant xml", () => { + const entries = [ + { url: "https://docs.so4.market/", lastmod: "2026-08-30" }, + { url: "https://docs.so4.market/get-started/quickstart", lastmod: "2026-08-28" }, + ] + + const xml = generateSitemapXml(entries) + expect(xml).toContain('') + expect(xml).toContain('') + expect(xml).toContain("https://docs.so4.market/") + expect(xml).toContain("2026-08-30") + expect(xml).toContain("https://docs.so4.market/get-started/quickstart") + expect(xml).toContain("2026-08-28") + }) + + test("generateRobotsTxt allows root and references sitemap URL", () => { + const robots = generateRobotsTxt("https://docs.so4.market") + expect(robots).toContain("User-agent: *") + expect(robots).toContain("Allow: /") + expect(robots).toContain("Sitemap: https://docs.so4.market/sitemap.xml") + }) + + test("buildSitemapAndRobots excludes draft pages and populates validated lastmod", async () => { + const { sitemapXml, entryCount } = await buildSitemapAndRobots("https://docs.so4.market") + expect(entryCount).toBeGreaterThan(0) + // Draft page should be excluded + expect(sitemapXml).not.toContain("/resources/terms") + expect(sitemapXml).toContain("/get-started/introduction") + expect(sitemapXml).toContain("") + }) +}) diff --git a/apps/docs/scripts/sitemap.ts b/apps/docs/scripts/sitemap.ts new file mode 100644 index 00000000..39d42bda --- /dev/null +++ b/apps/docs/scripts/sitemap.ts @@ -0,0 +1,92 @@ +/** + * DX-046: Generate sitemap.xml and robots.txt for the documentation site. + * + * Scans content pages, excludes drafts and redirect sources, attaches validated + * lastmod dates from frontmatter, and writes standard XML sitemap and robots.txt. + * + * Usage: + * bun run scripts/sitemap.ts + */ + +import { mkdir, writeFile } from "node:fs/promises" +import { dirname, join } from "node:path" +import { appRoot, loadPages } from "./content.ts" +import { DEFAULT_SITE_URL } from "../src/lib/seo.ts" + +export interface SitemapUrlEntry { + url: string + lastmod?: string +} + +export function generateSitemapXml( + entries: Array +): string { + const urlNodes = entries + .map((e) => { + const lastmodNode = e.lastmod ? `\n ${e.lastmod}` : "" + return ` \n ${e.url}${lastmodNode}\n ` + }) + .join("\n") + + return `\n\n${urlNodes}\n\n` +} + +export function generateRobotsTxt(siteUrl = DEFAULT_SITE_URL): string { + return [ + "User-agent: *", + "Allow: /", + `Sitemap: ${siteUrl}/sitemap.xml`, + "", + ].join("\n") +} + +export async function buildSitemapAndRobots( + siteUrl = DEFAULT_SITE_URL, + targetDirs = [ + join(appRoot, "public"), + join(appRoot, ".nitro-static"), + ] +): Promise<{ sitemapXml: string; robotsTxt: string; entryCount: number }> { + const pages = await loadPages() + // Exclude drafts + const publicPages = pages.filter((p) => p.frontmatter.status !== "draft") + + const entries: Array = publicPages.map((page) => { + const route = page.route === "/index" ? "/" : page.route + return { + url: `${siteUrl}${route}`, + lastmod: page.frontmatter.updated, + } + }) + + // Sort URLs deterministically + entries.sort((a, b) => a.url.localeCompare(b.url)) + + const sitemapXml = generateSitemapXml(entries) + const robotsTxt = generateRobotsTxt(siteUrl) + + for (const dir of targetDirs) { + try { + await mkdir(dir, { recursive: true }) + await writeFile(join(dir, "sitemap.xml"), sitemapXml, "utf-8") + await writeFile(join(dir, "robots.txt"), robotsTxt, "utf-8") + } catch { + // Ignore if dir cannot be written yet + } + } + + return { sitemapXml, robotsTxt, entryCount: entries.length } +} + +async function main() { + const { entryCount } = await buildSitemapAndRobots() + console.log(`✓ Generated sitemap.xml with ${entryCount} URL(s) and robots.txt`) +} + +const invokedDirectly = process.argv[1]?.endsWith("sitemap.ts") +if (invokedDirectly) { + main().catch((err) => { + console.error(err) + process.exit(1) + }) +} diff --git a/apps/docs/src/components/DocsHome.test.tsx b/apps/docs/src/components/DocsHome.test.tsx index a0462981..1ecae181 100644 --- a/apps/docs/src/components/DocsHome.test.tsx +++ b/apps/docs/src/components/DocsHome.test.tsx @@ -1,5 +1,6 @@ import { render, screen } from "@testing-library/react" import userEvent from "@testing-library/user-event" +import "@testing-library/jest-dom/vitest" import { axe } from "vitest-axe" import { describe, expect, it } from "vitest" import { KeyboardShortcut } from "@workspace/ui/components/keyboard-shortcut" diff --git a/apps/docs/src/components/Sidebar.tsx b/apps/docs/src/components/Sidebar.tsx index 0f969039..0e866f69 100644 --- a/apps/docs/src/components/Sidebar.tsx +++ b/apps/docs/src/components/Sidebar.tsx @@ -47,7 +47,7 @@ export function Sidebar({ sections, currentRoute, className }: SidebarProps) { > {page.sidebarLabel ?? page.title} {page.status === "beta" && ( - + beta )} diff --git a/apps/docs/src/components/Toc.test.tsx b/apps/docs/src/components/Toc.test.tsx new file mode 100644 index 00000000..5cfecf8b --- /dev/null +++ b/apps/docs/src/components/Toc.test.tsx @@ -0,0 +1,89 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest" +import { render, screen, fireEvent } from "@testing-library/react" +import { Toc } from "./Toc" + +describe("DX-032: Toc component", () => { + let mockObserve: ReturnType + let mockDisconnect: ReturnType + + beforeEach(() => { + mockObserve = vi.fn() + mockDisconnect = vi.fn() + + class MockIntersectionObserver { + observe = mockObserve + disconnect = mockDisconnect + unobserve = vi.fn() + } + + vi.stubGlobal("IntersectionObserver", MockIntersectionObserver) + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + it("renders nothing when there are fewer than 2 headings", () => { + const { container } = render( + + ) + expect(container.firstChild).toBeNull() + }) + + it("renders table of contents when there are 2 or more headings", () => { + render( + + ) + + expect( + screen.getByRole("navigation", { name: "Table of contents" }) + ).toBeInTheDocument() + expect( + screen.getByRole("link", { name: "First Section" }) + ).toBeInTheDocument() + expect( + screen.getByRole("link", { name: "Second Section" }) + ).toBeInTheDocument() + }) + + it("nests h3 headings under h2 headings", () => { + const { container } = render( + + ) + + const subtopicItem = container.querySelector("li.ps-3") + expect(subtopicItem).not.toBeNull() + expect(subtopicItem?.textContent).toContain("Subtopic Detail") + }) + + it("sets active state on click and invokes onSelect callback", () => { + const onSelect = vi.fn() + render( + + ) + + const detailsLink = screen.getByRole("link", { name: "Details" }) + fireEvent.click(detailsLink) + + expect(onSelect).toHaveBeenCalledWith("details") + expect(detailsLink).toHaveAttribute("aria-current", "location") + }) +}) diff --git a/apps/docs/src/components/Toc.tsx b/apps/docs/src/components/Toc.tsx index d919832c..ca160a18 100644 --- a/apps/docs/src/components/Toc.tsx +++ b/apps/docs/src/components/Toc.tsx @@ -1,4 +1,6 @@ -import React from "react" +"use client" + +import { useEffect, useState, useRef } from "react" import { cn } from "@workspace/ui/lib/utils" export interface TocEntry { @@ -11,35 +13,131 @@ export interface TocProps { entries: TocEntry[] activeId?: string className?: string + onSelect?: (id: string) => void } -export function Toc({ entries, activeId, className }: TocProps) { - if (entries.length === 0) return null +export function Toc({ + entries, + activeId: initialActiveId, + className, + onSelect, +}: TocProps) { + // Only h2 and h3 headings are included in the table of contents + const validEntries = entries.filter( + (e) => !e.level || e.level === 2 || e.level === 3 + ) + + const [activeId, setActiveId] = useState( + initialActiveId || validEntries[0]?.id || "" + ) + const isClickRef = useRef(false) + + useEffect(() => { + if ( + validEntries.length < 2 || + typeof window === "undefined" || + typeof IntersectionObserver === "undefined" + ) + return + + // Hold last heading when scrolled to bottom of document + const handleScroll = () => { + if (isClickRef.current) return + const isBottom = + window.innerHeight + window.scrollY >= + document.documentElement.scrollHeight - 50 + + if (isBottom && validEntries.length > 0) { + setActiveId(validEntries[validEntries.length - 1].id) + } + } + + window.addEventListener("scroll", handleScroll, { passive: true }) + + const observer = new IntersectionObserver( + (intersectingEntries) => { + if (isClickRef.current) return + + const visible = intersectingEntries + .filter((e) => e.isIntersecting) + .sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top) + + if (visible.length > 0) { + setActiveId(visible[0].target.id) + } + }, + { + rootMargin: "0px 0px -70% 0px", + threshold: [0, 1.0], + } + ) + + validEntries.forEach((entry) => { + const element = document.getElementById(entry.id) + if (element) { + observer.observe(element) + } + }) + + return () => { + window.removeEventListener("scroll", handleScroll) + observer.disconnect() + } + }, [validEntries]) + + // DX-032: Pages with < 2 headings render no rail and re-center content + if (validEntries.length < 2) return null + + const handleLinkClick = ( + e: React.MouseEvent, + id: string + ) => { + isClickRef.current = true + setActiveId(id) + onSelect?.(id) + + const target = document.getElementById(id) + if (target) { + if (!target.hasAttribute("tabindex")) { + target.setAttribute("tabindex", "-1") + } + target.focus({ preventScroll: true }) + } + + setTimeout(() => { + isClickRef.current = false + }, 800) + } return (