From c0e086ce3beeeb21f0761d3c8fdd2b28e8e375c8 Mon Sep 17 00:00:00 2001 From: Konstantinos Paparas Date: Mon, 24 Aug 2026 16:27:19 +0200 Subject: [PATCH 1/2] docs: document the 1.44 features that were never written up Twelve features shipped in 1.44 with no mention anywhere in the docs. All verified against the v1.44.0 tag. The notification area had never been documented at all, so silencing popups had nowhere to live. It gets a section in utilities covering the area itself, the tabs, the silence toggle and its persistence, the back-off schedule for a condition that keeps recurring, and clearing. Settings entries: auto detect tokens on login and its 1-168 hour cooldown; suppressing the "No indexers available" notification per chain and where to undo it; the recommended-settings dialog shown after an upgrade changes a chain's default indexer; and the integrity checks now run in both directions of a premium cloud sync. Feature mentions: Hyperliquid Core tokens as first class assets keyed by token ID; Gnosis Pay Safe migration detection and which address to add; jumping from a rule to the events it governs and back; and asset search by contract address with exact matches first, on both the dashboard and blockchain balances. The calendar's automatic-entry list named only ENS. It was missing five other categories besides the Yearn vesting escrow entries new in 1.44, so it now lists all of them, with ENS widened to the name services it actually covers (ENS, Basenames and .gwei). The filter bar rolled out to nine more tables in 1.44 but only history events documented it. Rather than repeat that page nine times, address book, oracle prices and accounting rules each get a short section naming the pills that page offers, pointing at the canonical reference for the shared syntax. Also correct where the directory entries live: Logs Directory and Data Directory are in the desktop app's own menu bar, not the in-app ? menu, so they are documented on the data directory page with a pointer from help. --- usage-guides/advanced/data-directory.md | 14 +++++++ usage-guides/data-management/address-book.md | 12 ++++++ usage-guides/data-management/assets.md | 9 +++++ usage-guides/data-management/prices.md | 7 ++++ usage-guides/integrations/calendar.md | 7 +++- .../integrations/external-services.md | 12 ++++++ usage-guides/portfolio/accounts.md | 4 ++ usage-guides/portfolio/dashboard.md | 2 +- usage-guides/settings/account.md | 10 +++++ usage-guides/settings/blockchain.md | 23 +++++++++++ usage-guides/settings/general.md | 10 +++++ .../tax-accounting/accounting-rules.md | 18 +++++++++ usage-guides/utilities/help.md | 8 ++++ usage-guides/utilities/index.md | 38 +++++++++++++++++++ 14 files changed, 172 insertions(+), 2 deletions(-) diff --git a/usage-guides/advanced/data-directory.md b/usage-guides/advanced/data-directory.md index 4b1b782b..3be8e3b5 100644 --- a/usage-guides/advanced/data-directory.md +++ b/usage-guides/advanced/data-directory.md @@ -12,6 +12,20 @@ rotki saves user data by default in a different directory per OS. For each OS, d Before v1.6.0, rotki was saving data in `$USER/.rotkehlchen`. From v1.6.0, that directory got migrated to the OS equivalent standard directory, and it should be safe for users to delete the old directory as long as the new directory contains the migrated DB. +## Opening it from the app + +In the desktop app you do not have to go looking for either directory. The **Help** menu in the +application menu bar has two entries that open them in your file manager: + +- **Logs Directory** — where rotki writes its log files. +- **Data Directory** — the directory the running backend actually resolved, which is not necessarily + the default above if you configured a different one. + +> [!NOTE] +> **Data Directory** stays disabled until the backend is running, because until then rotki does not +> yet know which directory is in use. Both entries are in the desktop application's own menu bar, not +> the in-app `?` menu, so they are not available when running rotki in a browser through Docker. + A very good idea for the rotki data directory would be to make frequent backups of it as it contains all of the data of all of your rotki accounts and cache data for historical price queries. > [!WARNING] diff --git a/usage-guides/data-management/address-book.md b/usage-guides/data-management/address-book.md index 03cce2fe..00bafb40 100644 --- a/usage-guides/data-management/address-book.md +++ b/usage-guides/data-management/address-book.md @@ -24,6 +24,18 @@ rotki provides an address book for blockchains. This replaces addresses with nam > 5. Hardcoded Mappings > 6. ENS names. +## Filtering the address book + +The address book uses the same filter bar as the rest of rotki, with a pill for `Address`, `Name` and +`Chain`. Chains are shown with their logo and name and picked from a list. There is also a +`Strict chain` pill: with it added, only entries recorded for that exact chain are shown, rather than +also including entries that apply to every chain. + +Both the chain selector and the strict-chain checkbox used to sit beside the bar; they are pills +inside it now, so there is a single place to filter from. See +[Filtering history events](/usage-guides/history/filtering) for the shared syntax and keyboard +handling. + ## Importing address book names (CSV) You can add multiple address book entries at once with CSV import. You can find the menu in the three dots `⋮` menu here. diff --git a/usage-guides/data-management/assets.md b/usage-guides/data-management/assets.md index 825f3aaa..6c8d728d 100644 --- a/usage-guides/data-management/assets.md +++ b/usage-guides/data-management/assets.md @@ -55,6 +55,15 @@ There are also some other fields that are completely optional and expand if you > [!NOTE] > Underlying tokens only apply to asset type of `EVM Token`. +### Hyperliquid Core tokens + +Native tokens on Hyperliquid Core are first class assets, with the asset type `Hyperliquid Token`. +You can add or edit their metadata here the same way as any other token, search for them by name, +symbol or token ID, and use them in asset mappings. + +Their identifier is a **token ID** rather than a contract address, so the address field takes that ID +and, like other token types, cannot be changed after the asset is created. + ## Adding/editing a custom asset There is a lot of assets that rotki can't automatically track and they don't fit into traditional crypto assets. For example an ETF, real estate, ancient coins, valuable art etc. To represent those you can create custom assets, and then add a manual balance of those assets. diff --git a/usage-guides/data-management/prices.md b/usage-guides/data-management/prices.md index 87302cb8..5537c8ef 100644 --- a/usage-guides/data-management/prices.md +++ b/usage-guides/data-management/prices.md @@ -27,6 +27,13 @@ The `Oracle Prices` page has two tabs: ![Oracle prices listing](/images/usage-guides/data-management/prices/oracle_list.webp) +The **Prices** tab uses the same filter bar as the rest of rotki, with a pill for the `From asset`, +the `To asset`, the `Source` oracle and the `Period`. Assets are shown with their icon and symbol, +and each oracle by the name the table itself uses rather than its raw identifier. The two separate +start and end date filters are now one period pill with a date picker for each end. See +[Filtering history events](/usage-guides/history/filtering) for the shared syntax and keyboard +handling. + Each row in the **Prices** tab can be edited or deleted. Use this when you know the cached price is wrong (for example, an oracle returned a stale or incorrect value) and want to override it without adding a separate manual entry. Deleting an entry removes the cached value entirely; the next lookup will hit the oracle again. Click the edit icon on a row to open the edit dialog: diff --git a/usage-guides/integrations/calendar.md b/usage-guides/integrations/calendar.md index 5ef0d43c..05011dbf 100644 --- a/usage-guides/integrations/calendar.md +++ b/usage-guides/integrations/calendar.md @@ -21,7 +21,12 @@ Here the non-obvious fields are: rotki can also create automatic events based on your on-chain activity. The events that rotki currently can check include: -- ENS Expiration & Renewal +- **Name service expiry and renewal** — for ENS, Basenames and Gwei Name Service (`.gwei`) names you hold. +- **Airdrop claim deadlines** — so a claimable airdrop does not expire unnoticed. +- **Yearn vesting escrows** — one entry at the cliff date and one when the escrow is fully vested. +- **CRV vote escrow** — when your locked CRV lock period ends. +- **VELO and AERO vote escrow** — when a veNFT lock period ends. +- **L2 bridging** — when funds bridged to or from an L2 become available. You can customize how rotki handles automatic events by clicking the `setting` icon at the top. diff --git a/usage-guides/integrations/external-services.md b/usage-guides/integrations/external-services.md index caf934c2..e4a23882 100644 --- a/usage-guides/integrations/external-services.md +++ b/usage-guides/integrations/external-services.md @@ -128,6 +128,18 @@ To connect your Gnosis Pay account: > **Note**: The access token only provides read-only access to your Gnosis Pay data. The "Sign in with Ethereum" authentication process described above is mandated by Gnosis Pay's API requirements, not a design choice by rotki. rotki simply integrates with their authentication system as required. +### Safe migration + +Gnosis Pay replaced user Safes during its security migration, so an account can have both a previous +and a current Safe. rotki notices when you track only one of the two and tells you which address is +missing: + +- If you track only the **previous** Safe, add the new active one to keep tracking future activity. +- If you track only the **new** Safe, add the previous one to keep its historical activity. + +Add the named address as a normal blockchain account on Gnosis. Tracking both means your history +covers the whole account rather than stopping or starting at the migration. + ## The Graph rotki uses The Graph to obtain Balancer balances and particular ENS data. You can create one [here](https://thegraph.com/studio/apikeys). diff --git a/usage-guides/portfolio/accounts.md b/usage-guides/portfolio/accounts.md index ac186054..b9c2c4a2 100644 --- a/usage-guides/portfolio/accounts.md +++ b/usage-guides/portfolio/accounts.md @@ -168,3 +168,7 @@ You can see the list of aggregated assets from Blockchain Accounts from menu `Ba You can also see the breakdown of the assets, which locations they belong to, whether they are in the wallet, or being put into some protocol. ![Aggregated list of assets from blockchain accounts](/images/usage-guides/portfolio/accounts/blockchain_balances.webp) + +The search box above the table finds an asset by name, symbol or **contract address**, and shows an +exact symbol or name match first. Pasting a contract address is the quickest way to identify a token +whose name you do not know, or to tell apart several tokens sharing a symbol. diff --git a/usage-guides/portfolio/dashboard.md b/usage-guides/portfolio/dashboard.md index e47a3e4d..3dccdb2f 100644 --- a/usage-guides/portfolio/dashboard.md +++ b/usage-guides/portfolio/dashboard.md @@ -104,7 +104,7 @@ The assets table shows all your holdings aggregated across all sources. Each row ### Table Features - **Sorting** — Click any column header to sort. Default sort is by value (descending). -- **Search** — Use the search box to filter by asset name or symbol. +- **Search** — Use the search box to filter by asset name, symbol, or **contract address**. Pasting a contract address is the quickest way to pin down a token whose name you do not know, or one that shares a symbol with several others. An asset whose symbol or name is exactly what you typed is shown first, so searching for `eth` finds ETH rather than burying it under every token containing those letters. Matches appear as you type. - **Pagination** — Configure how many rows to display per page using the "Rows per page" dropdown. - **Column Visibility** — Click the column configuration button to toggle optional columns ("% of Net Value" and "% of Group"). - **Row Expansion** — Click the expand arrow on a row to see a detailed breakdown of where that asset is held (per chain, per exchange, per protocol). diff --git a/usage-guides/settings/account.md b/usage-guides/settings/account.md index 6699e1b7..b24e31ee 100644 --- a/usage-guides/settings/account.md +++ b/usage-guides/settings/account.md @@ -26,6 +26,16 @@ View information about your user and global database, such as directory, size, a Create new database backups, delete backups, and download backups locally. +### Cloud sync integrity checks + +For premium users syncing the database to rotki's cloud, rotki verifies the database before it moves +in either direction. It runs an integrity check before uploading a backup, and again on a downloaded +copy before that copy replaces your local database. + +If a check fails, the sync is abandoned and rotki reports the problem rather than continuing. That +stops a corrupt local database from overwriting a good remote copy, and a corrupt download from +overwriting a good local one. + ### Purging Data rotki keeps a lot of data cached locally. Clean this data periodically from the "Manage Data" section in the settings. Remove specific exchanges by first removing any active API keys. diff --git a/usage-guides/settings/blockchain.md b/usage-guides/settings/blockchain.md index 81007cef..52ebab47 100644 --- a/usage-guides/settings/blockchain.md +++ b/usage-guides/settings/blockchain.md @@ -62,6 +62,29 @@ Which means, in practice: - **Scroll** needs a **Blockscout key**, since Etherscan no longer serves Scroll and Routescan does not cover it. - **Binance SC** needs a **paid Etherscan plan**, since it is the only indexer that serves the chain. +### Suppressing indexer notifications + +When a chain has no usable indexer, rotki raises a **"No indexers available"** notification for it. +If it names a chain you have no intention of configuring, the notification offers to suppress it: +confirm, and rotki stops raising it for that chain. + +Suppressed chains are listed under **Suppressed "No indexers available" notifications** on this page. +Remove a chain from that list to start showing its notification again. + +Suppression is per chain, so silencing one never silences another. Even without suppressing anything, +a notification you keep ignoring backs off on its own — see +[how often a notification repeats](/usage-guides/utilities/#how-often-a-notification-repeats). + +### Recommended settings after upgrading + +Some releases change which indexer a chain should use by default. When that happens and you had +already chosen your own order for the affected chain, rotki shows a recommended-settings dialog after +the upgrade rather than silently overriding your choice. + +Upgrading to 1.44 shows this for **Gnosis** if you have Gnosis events and had picked your own order. +It explains that Etherscan no longer serves Gnosis for free, shows which of the two keys you already +have, and preselects the order matching them. You can accept the recommendation or keep what you had. + ## Price Oracle Settings ![Change the order of price sources](/images/usage-guides/settings/blockchain/price_oracle_order.webp) diff --git a/usage-guides/settings/general.md b/usage-guides/settings/general.md index 14666c80..77832b1a 100644 --- a/usage-guides/settings/general.md +++ b/usage-guides/settings/general.md @@ -28,6 +28,16 @@ Specify whether the application can submit anonymous usage analytics. This helps Specify whether to automatically detect tokens and refresh balances by periodically checking historical events. +#### Auto detect tokens on login + +Also run token detection after the first balance scan following login, so newly acquired tokens show up without waiting for the next periodic run. This is opt-in and **disabled by default**. + +#### Auto detect tokens cooldown + +The minimum time between two on-login detection runs, from 1 to 168 hours (7 days). Logging in again inside the cooldown skips the scan, so several logins in a day do not each trigger one. Triggering detection yourself from the accounts page ignores the cooldown. + +This setting only appears when **Auto detect tokens on login** is enabled. + #### Display Date in Localtime Specify whether dates in CSV exports should be displayed in local time rather than UTC. Enabled by default. diff --git a/usage-guides/tax-accounting/accounting-rules.md b/usage-guides/tax-accounting/accounting-rules.md index 393f0c00..50ada993 100644 --- a/usage-guides/tax-accounting/accounting-rules.md +++ b/usage-guides/tax-accounting/accounting-rules.md @@ -174,6 +174,24 @@ You can create custom rules that override the defaults for specific combinations See [Add/Edit accounting rules](/usage-guides/settings/accounting#add-edit-accounting-rules) for how to create them in the UI. You can also [import/export accounting rules](/usage-guides/settings/accounting#import-export-accounting-rules) as JSON to back them up or share them. +### Seeing which events a rule governs + +A rule and the events it applies to are one click apart in either direction: + +- From a rule, use **View events matching this rule**. It opens the history events page with the + filter already set to that rule's event type, subtype and counterparty, so you see exactly what it + affects before changing it. +- From an event, the [edit accounting rule](/usage-guides/history/events#edit-accounting-rule) action + takes you to the rule that governs it. + +### Filtering the rules + +The rules table uses the same filter bar as the rest of rotki, with a pill for `Event type`, +`Event subtype` and `Counterparty`. Types and subtypes are picked from a list and read as words +rather than as their raw values, and counterparties are shown with their protocol icon and name. See +[Filtering history events](/usage-guides/history/filtering) for the shared syntax and keyboard +handling. + ### Limitations - **Counterparty must be a known protocol**: You can only create rules for counterparties that rotki recognizes (e.g., "uniswap-v3", "aave", "makerdao_dsr"). Custom smart contract addresses cannot currently be used as counterparties for accounting rules. Support for custom counterparties is tracked in [rotki/rotki#9803](https://github.com/rotki/rotki/issues/9803). diff --git a/usage-guides/utilities/help.md b/usage-guides/utilities/help.md index c266e3e8..fa4f3b4a 100644 --- a/usage-guides/utilities/help.md +++ b/usage-guides/utilities/help.md @@ -16,6 +16,14 @@ The Help & Support menu provides quick access to: - **Discord** - Join the community and get support - **Github** - Review the code and open issues - **Twitter / X** - Follow for updates +- **About** - Version information, including the data directory currently in use +- **Browser Log** - Download the browser log file + +> [!NOTE] +> The desktop app also has its own **Help** menu in the application menu bar, which is a different +> menu with different entries. That is where you will find **Logs Directory** and **Data Directory**, +> which open those folders in your file manager — see +> [opening the data directory](/usage-guides/advanced/data-directory#opening-it-from-the-app). ## Report Issue diff --git a/usage-guides/utilities/index.md b/usage-guides/utilities/index.md index 91536bb6..98a4d4d8 100644 --- a/usage-guides/utilities/index.md +++ b/usage-guides/utilities/index.md @@ -31,6 +31,44 @@ You can now take notes in various sections of the application. Note taking is ca You can also pin notes; the pinned notes will appear at the top. +## Notifications + +rotki reports what it is doing, and anything that needs your attention, through the **notification +area** in the toolbar. Opening it shows every notification received this session, grouped into four +tabs: `View All`, `Error`, `Needs Action` and `Reminder`. Many notifications carry an action, such as +adding a missing API key or opening the dialog that resolves what they are about. + +Notifications also appear briefly as a **popup** in the corner of the window as they arrive. + +### Silencing popups + +If the popups interrupt you, click **Silence notification popups** in the notification area. The +button then reads `Popups silenced. Click to allow them again`, and you can turn them back on the +same way. + +Nothing is lost while popups are silenced. Every notification still arrives in the notification area +with its actions intact; only the transient popup is suppressed. The setting is stored with your +account, so it stays as you left it the next time you log in. + +### How often a notification repeats + +Notifications about a condition that persists — a missing API key, or a chain with no indexer +available — would otherwise greet you at every login. Instead each one backs off: you see it +immediately, then again a day later, then two days after that, then a week after that, and after +that it stops interrupting you. + +It still appears in the notification area with its action, so you can deal with it whenever you +like, and the schedule starts over if you change that chain's indexer order or that service's key. +Chains and services are tracked separately, so silencing one never affects another. + +To stop the "No indexers available" notification for a chain you have no intention of configuring, +see [Suppressing indexer notifications](/usage-guides/settings/blockchain#suppressing-indexer-notifications). + +### Clearing notifications + +Use the clear button to dismiss all active notifications at once; rotki asks for confirmation first. +The area holds a maximum of 200 notifications, after which the oldest are dropped. + ## Background Tasks A list of processing tasks is available on the notifications tray. From 17aaad044a0aa370033e80c455f3424bdd0b47af Mon Sep 17 00:00:00 2001 From: Konstantinos Paparas Date: Mon, 24 Aug 2026 16:28:53 +0200 Subject: [PATCH 2/2] ci: gate internal anchors VitePress checks that a linked page exists but not that the #anchor on the end of it does, so renaming a heading silently breaks every link pointing at it. That has now happened twice here. Two links in premium/index.md still pointed at #creating-api-keys and #using-api-keys-in-the-app after those headings became "API Credentials", and the app's importAddressBook deep link pointed at a heading slug that had never existed. Both were found by hand; nothing would have caught the next one. Add scripts/check-anchors.mjs, wired in as pnpm run check:anchors and a CI step. It slugs headings the way VitePress does (lowercase, every run of non-alphanumerics collapsed to one hyphen) and fails on any internal link whose anchor matches no heading on the target page. Verified against a deliberately broken link before trusting the green result. Then fix the two links it found. --- .github/workflows/ci.yml | 3 ++ package.json | 1 + premium/index.md | 4 +- scripts/check-anchors.mjs | 99 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 105 insertions(+), 2 deletions(-) create mode 100644 scripts/check-anchors.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 199f42df..4b0b0381 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -61,5 +61,8 @@ jobs: - name: Check images 🖼 run: pnpm run check:images + - name: Check anchors 🔗 + run: pnpm run check:anchors + - name: Build run: pnpm run build diff --git a/package.json b/package.json index c2cdb00a..8462d670 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "preview": "vitepress preview", "lint": "eslint .", "check:images": "node scripts/check-images.mjs", + "check:anchors": "node scripts/check-anchors.mjs", "lint-staged": "lint-staged", "prepare": "husky" }, diff --git a/premium/index.md b/premium/index.md index 0e216757..dc751bd6 100644 --- a/premium/index.md +++ b/premium/index.md @@ -48,7 +48,7 @@ See [Payment Process](/premium/payment) for step-by-step instructions. After successful payment, generate your **API Key** and **API Secret** by clicking the **Create** button on your [subscription page](https://rotki.com/home/subscription). -For detailed instructions, see [Creating API Keys](/premium/api-keys#creating-api-keys). +For detailed instructions, see [Creating API Credentials](/premium/api-keys#creating-api-credentials). ### 4. Add Keys to the App @@ -56,7 +56,7 @@ Navigate to **API Keys** → **rotki Premium** in the rotki application and ente **API Key** and **API Secret** generated in the last step to activate the premium features included in your subscription. -For detailed instructions, see [Using API Keys in the App](/premium/api-keys#using-api-keys-in-the-app). +For detailed instructions, see [Using API Credentials in the App](/premium/api-keys#using-api-credentials-in-the-app). ## Managing Your Premium Subscription diff --git a/scripts/check-anchors.mjs b/scripts/check-anchors.mjs new file mode 100644 index 00000000..1b8c68ec --- /dev/null +++ b/scripts/check-anchors.mjs @@ -0,0 +1,99 @@ +/** + * Internal anchor gate. + * + * VitePress validates that a linked *page* exists, but not that the `#anchor` on the end of it + * does. So renaming a heading silently breaks every link pointing at it, and nothing reports it. + * That has already happened twice in this repo: "API Keys" became "API Credentials" while two + * links in premium/index.md kept pointing at the old slug, and the app's own importAddressBook + * deep link pointed at a heading that had never rendered that way. + * + * Run with `pnpm run check:anchors`. + */ +import { readdir, readFile } from 'node:fs/promises'; +import { join, relative, resolve } from 'node:path'; +import process from 'node:process'; + +const ROOT = resolve(import.meta.dirname, '..'); +const SKIP_DIRS = new Set(['node_modules', '.git', '.vitepress', 'dist', 'cache', 'scripts', 'public']); + +/** A markdown link to an internal page with an anchor: `](/some/page#the-anchor)`. */ +const ANCHORED_LINK = /]\(\/([\w/-]+)#([\w-]+)\)/g; + +/** ATX headings. Setext headings are not used in this corpus. */ +const HEADING = /^#{1,6} +(.+?)\s*$/gm; + +/** + * VitePress slugs a heading by lowercasing it and collapsing every run of non-alphanumeric + * characters into a single hyphen. So "Add / edit events" is `add-edit-events` and + * "Set the backend's arguments" is `set-the-backend-s-arguments`. + */ +function slug(heading) { + return heading + .replace(/`/g, '') + .toLowerCase() + .replace(/[^\da-z]+/g, '-') + .replace(/^-|-$/g, ''); +} + +async function walk(dir) { + const found = []; + for (const entry of await readdir(dir, { withFileTypes: true })) { + if (entry.isDirectory()) { + if (SKIP_DIRS.has(entry.name)) + continue; + found.push(...await walk(join(dir, entry.name))); + } + else if (entry.name.endsWith('.md')) { + found.push(join(dir, entry.name)); + } + } + return found; +} + +/** A link target resolves to `.md` or `/index.md`. */ +function pageFor(path, pages) { + return pages.get(path) ?? pages.get(`${path.replace(/\/$/, '')}/index`); +} + +async function main() { + const files = await walk(ROOT); + const pages = new Map(files.map(file => [ + relative(ROOT, file).split('\\').join('/').replace(/\.md$/, ''), + file, + ])); + + const anchorsByPage = new Map(); + for (const [path, file] of pages) { + const content = await readFile(file, 'utf8'); + anchorsByPage.set(path, new Set([...content.matchAll(HEADING)].map(match => slug(match[1])))); + } + + const errors = []; + for (const file of files) { + const content = await readFile(file, 'utf8'); + const source = relative(ROOT, file); + + for (const [, path, anchor] of content.matchAll(ANCHORED_LINK)) { + const page = pageFor(path, pages); + if (!page) { + errors.push(`${source}\n links to /${path}#${anchor}, but no such page exists`); + continue; + } + + const resolved = relative(ROOT, page).split('\\').join('/').replace(/\.md$/, ''); + if (!anchorsByPage.get(resolved)?.has(anchor)) + errors.push(`${source}\n links to /${path}#${anchor}, but that page has no such heading`); + } + } + + if (errors.length > 0) { + console.error(`\n${errors.length} broken anchor${errors.length === 1 ? '' : 's'} found:\n`); + for (const error of errors) + console.error(` ✖ ${error}\n`); + process.exit(1); + } + + console.log(`✔ every internal #anchor across ${files.length} pages resolves to a heading`); +} + +await main();