From 8ea2545885e921b5cb96220c4eecb2945a4dc22b Mon Sep 17 00:00:00 2001 From: Duncan Crawbuck Date: Tue, 17 Mar 2026 14:01:30 -0700 Subject: [PATCH] Systematize docs links after Start migration --- AGENTS.md | 111 ++++++---- bun.lock | 4 +- .../android/sdk-reference/SuperwallEvent.mdx | 2 +- content/docs/ios/guides/local-resources.mdx | 81 +++++++- content/docs/variable-reference.mdx | 109 +++++----- package.json | 5 +- plugins/remark-link-paths.test.ts | 23 ++- plugins/remark-link-paths.ts | 23 ++- scripts/check-doc-links.ts | 189 ++++++++++++++++++ scripts/check-source-links.ts | 12 ++ scripts/lib/source-link-validation.ts | 109 ++++++++++ src/lib/docs-link-aliases.ts | 49 +++++ src/lib/docs-link-canonical.ts | 85 ++++++++ src/lib/docs-url.test.ts | 48 ++++- src/lib/docs-url.ts | 125 ++++++++++-- src/routes/__root.tsx | 45 ++++- test/source-link-validation.test.ts | 16 ++ vite.config.ts | 1 + 18 files changed, 896 insertions(+), 141 deletions(-) create mode 100644 scripts/check-doc-links.ts create mode 100644 scripts/check-source-links.ts create mode 100644 scripts/lib/source-link-validation.ts create mode 100644 src/lib/docs-link-aliases.ts create mode 100644 src/lib/docs-link-canonical.ts create mode 100644 test/source-link-validation.test.ts diff --git a/AGENTS.md b/AGENTS.md index 7e579b57..3517ddf8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,13 +5,15 @@ Instructions for AI coding agents working in this repository (Superwall docs sit For documentation-writing guidelines (tone, content conventions, etc.), see `content/README/AGENTS.md`. ## Project overview -- **Stack**: Next.js + Fumadocs (MDX) + TailwindCSS -- **Hosting**: Cloudflare (via `@opennextjs/cloudflare`) + +- **Stack**: Vite + TanStack Start + Fumadocs (MDX) + TailwindCSS +- **Hosting**: Cloudflare Workers (via `@cloudflare/vite-plugin` + Wrangler) - **Docs**: multi-platform SDK docs (iOS, Android, Flutter, Expo, React Native) + dashboard/integration guides - **Search/AI**: built-in search + AI search mode; support center integration - **Theming**: custom themes; dark/light mode is disabled by default ## Non-negotiable rules + - **Never deploy without explicit user approval.** - **Content vs. engineering changes**: - **Content changes**: update files in `content/docs/**` (and `content/shared/**` if used). @@ -19,28 +21,36 @@ For documentation-writing guidelines (tone, content conventions, etc.), see `con - **Never edit `public/**`** (it is generated and will be overwritten). - **SDK references first**: if a task needs SDK APIs/changelogs, run `bun run download:references` before writing. - **SDK reference tables**: use the Fumadocs-style `TypeTable` for parameters/types; do not use ``. -- **Test**: run `bun run build:cf` and `bun test` after making changes to ensure you didn't break anything +- **Test**: run `bun run build` and `bun test` after making changes to ensure you didn't break anything ## Common commands (run from repo root) + ```bash -# Dev (http://localhost:8293) +# Dev (http://localhost:3000/docs) bun run dev -# Build (runs all generators, then MDX + Next build) +# Build (generates changelogs, copies images, then Vite build + static cache) bun run build +# Type-check +bun run types:check + +# Lint / format +bun run lint +bun run fmt + # Clear caches (when build/dev gets weird) -rm -rf .next -bun run clear:cache +rm -rf .source node_modules/.vite # Content generation / assets -bun run generate:llm -bun run generate:md -bun run copy:docs-images -bun run watch:images +bun run generate:changelog +bun run scripts/copy-docs-images.cjs + +# Sync docs to AI search +bun run sync:mixedbread # Cloudflare builds / preview -bun run build:cf +bun run build:cf # same as bun run build bun run preview # Deploy (DO NOT RUN WITHOUT USER APPROVAL) @@ -49,64 +59,82 @@ bun run deploy ``` ## Content + navigation system + - **Navigation** is defined by `meta.json` files alongside content directories. - If a `meta.json` does **not** include `"..."`, every page must be explicitly listed. - Use **relative paths without extensions** (e.g. `"guides/my-guide"`, not `"guides/my-guide.mdx"`). - You can nest objects to group related pages. - **Adding a new page**: - Create the `.mdx` file under the correct `content/docs/**` folder. - - Update the folder’s `meta.json` to include it in navigation. + - Update the folder's `meta.json` to include it in navigation. - Run `bun run build` to verify generation + routing. -- **Images**: assets in docs content are copied into `public/` during build (use `bun run copy:docs-images`; in dev use `bun run watch:images`). +- **Images**: assets in docs content are copied into `public/` during build (runs automatically via `prebuild` script). ## Routing, redirects, and base path -- The site is served under **`/docs`** (`basePath` + `assetPrefix` in `next.config.ts`). + +- The site is served under **`/docs`** (configured as `router.basepath` in `vite.config.ts`). - `/docs/sdk/*` is a selector route that redirects to the chosen platform page. -- Redirects are generated from `redirects-map.ts` (`folderRedirectsMap`, `fileRedirectsMap`, `externalRedirectsMap`) inside `next.config.ts`. - **Changes require a rebuild** to take effect. +- Redirects are defined in `redirects-map.ts` (`folderRedirectsMap`, `fileRedirectsMap`) and processed at runtime via TanStack middleware in `src/start.ts`. ## MDX / remark pipeline (order matters) + Custom remark plugins are wired in `source.config.ts`. The current order is: + 1. `remark-image-paths` (must run first) -2. `remark-follow-export` -3. Fumadocs “existing” plugins -4. `remark-include` -5. `remark-directive` -6. `remark-tabs-syntax` -7. `remark-code-language` -8. `remark-codegroup-to-tabs` -9. `remark-sdk-filter` (must run last; removes non-matching SDK blocks) +2. `remark-link-paths` +3. `remark-follow-export` +4. Fumadocs "existing" plugins +5. `remark-include` +6. `remark-directive` +7. `remark-tabs-syntax` +8. `remark-code-language` +9. `remark-codegroup-to-tabs` +10. `remark-sdk-filter` (must run last; removes non-matching SDK blocks) ## Key files (where to look first) -- **Config**: `source.config.ts`, `next.config.ts`, `open-next.config.ts`, `tsconfig.json` + +- **Config**: `vite.config.ts`, `source.config.ts`, `wrangler.jsonc`, `tsconfig.json` - **Redirects**: `redirects-map.ts` -- **Layout/routing**: `src/app/layout.config.tsx`, `src/app/(docs)/[[...slug]]/page.tsx` +- **Middleware**: `src/start.ts` (legacy redirects, LLM rewriting, static cache) +- **Router/routing**: `src/router.tsx`, `src/routes/__root.tsx`, `src/routes/$.tsx` - **Source plumbing**: `src/lib/source.ts`, `src/mdx-components.tsx` - **Plugins**: `plugins/*` -- **Scripts**: `scripts/*` (title map, generators, image copy/watch, cache clear, etc.) -- **New UI/components**: put React components in `src/components/` and wire site-level layout/nav in `src/app/layout.config.tsx` / `src/lib/source.ts`. +- **Scripts**: `scripts/*` (changelog generation, image copy, static cache, etc.) +- **New UI/components**: put React components in `src/components/`. ## What requires a rebuild (not just hot reload) + - `meta.json` navigation edits - `redirects-map.ts` changes - remark plugin changes (`plugins/*` or `source.config.ts`) -- new/updated images (make sure copy/watch runs) +- `wrangler.jsonc` changes +- new/updated images (ensure `copy-docs-images` runs) ## Cloudflare / deployment notes -- Cloudflare builds use `bun run build:cf` (OpenNext adapter). -- Cloudflare Workers disallow `eval()`: `next.config.ts` aliases the eval-based `fumadocs-ui` `hide-if-empty` component to `src/components/HideIfEmptyStub.tsx`. - + +- Cloudflare builds use `bun run build:cf` (Vite + `@cloudflare/vite-plugin`). +- Deployment config is in `wrangler.jsonc` (routes, compatibility flags, staging env). +- Deploy commands run `wrangler deploy` after build. + ## Performance notes -- Builds use Turbo (`next dev --turbo`) and Cloudflare’s CDN for delivery. + +- Shiki language filtering: custom Vite plugin in `vite.config.ts` reduces bundle from ~200 language chunks to ~20 actually used. +- Prerendering: enabled via TanStack Start with concurrency of 3. +- Static cache middleware in `src/start.ts` for server-side caching. +- Cloudflare smart placement enabled. ## SDK references (required for SDK-accuracy work) + Run: + ```bash bun run download:references ``` + This clones/pulls SDK repos into `reference/` (gitignored). Use the source to confirm API signatures and behavior before updating docs. ## SDK changelogs (strict rules) + - **Docs location**: `content/docs//changelog.mdx` for `ios`, `android`, `flutter`, `expo`, `react-native` - **Source of truth**: `reference//CHANGELOG.md` - **Process**: run `bun run download:references`, then copy upstream changelog **verbatim** @@ -115,20 +143,25 @@ This clones/pulls SDK repos into `reference/` (gitignored). Use the source to co - **Navigation**: ensure each SDK `meta.json` lists `changelog` right after `index`. ## AI SDK / AI SDK Elements + - For any AI SDK or AI SDK Elements work, fetch the latest docs via Context7 MCP before making changes. ## Environment variables -- **`SEARCH_MODE`**: `'fumadocs'` (default) or `'rag'` (external AI search) -- **`NEXTJS_ENV`**: development/production + +- **`CLOUDFLARE_ENV`**: `staging` or `production` (for Cloudflare builds) +- **`MIXEDBREAD_API_KEY`** / **`MIXEDBREAD_STORE_ID`**: for AI search sync (`bun run sync:mixedbread`) - Optional integration keys may exist (Slack, Mesh, Unify, RB2B, Pylon). See project environment documentation if present. ## TypeScript path aliases + - `@/*` → `src/*` -- `@/.source` → `.source/index.ts` (generated by Fumadocs) +- `fumadocs-mdx:collections/*` → `.source/*` (generated by Fumadocs) ## Troubleshooting + - **Build failures**: check MDX syntax; validate `meta.json`; ensure listed pages exist; run `bun run build` for full errors. -- **Dev server issues**: `rm -rf .next`, `bun run clear:cache`, verify port 8293 is free, reinstall dependencies if needed. +- **Dev server issues**: `rm -rf .source node_modules/.vite`, verify port 3000 is free, reinstall dependencies if needed. ## Skill mirrors -- Keep `.agents/skills/` and `.claude/skills/` in sync for shared skills. \ No newline at end of file + +- Keep `.agents/skills/` and `.claude/skills/` in sync for shared skills. diff --git a/bun.lock b/bun.lock index 5d46cd05..39ad79db 100644 --- a/bun.lock +++ b/bun.lock @@ -1,6 +1,5 @@ { "lockfileVersion": 1, - "configVersion": 0, "workspaces": { "": { "name": "superwall-docs", @@ -45,6 +44,7 @@ "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^5.1.4", + "next-validate-link": "^1.6.4", "oxfmt": "^0.36.0", "oxlint": "^1.51.0", "srvx": "^0.11.8", @@ -1256,6 +1256,8 @@ "next-themes": ["next-themes@0.4.6", "", { "peerDependencies": { "react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc", "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc" } }, "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA=="], + "next-validate-link": ["next-validate-link@1.6.4", "", { "dependencies": { "gray-matter": "^4.0.3", "picocolors": "^1.1.1", "remark": "^15.0.1", "remark-gfm": "^4.0.1", "remark-mdx": "^3.1.1", "tinyglobby": "^0.2.15", "unist-util-visit": "^5.0.0" } }, "sha512-wR/VKyJlaTbUT5k1uujEnk6O616YtWGt52s9FJa3tRCQ2qL2TGFTAklXJ0QdX1NTAEeP6rGFOTtHEDwveFrc2g=="], + "node-releases": ["node-releases@2.0.27", "", {}, "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA=="], "normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="], diff --git a/content/docs/android/sdk-reference/SuperwallEvent.mdx b/content/docs/android/sdk-reference/SuperwallEvent.mdx index 9a691718..71243fdb 100644 --- a/content/docs/android/sdk-reference/SuperwallEvent.mdx +++ b/content/docs/android/sdk-reference/SuperwallEvent.mdx @@ -156,7 +156,7 @@ These events are received via [`SuperwallDelegate.handleSuperwallEvent(eventInfo ## New events in 2.6.6+ -- `CustomerInfoDidChange` fires whenever the SDK merges device, web, and external purchase controller data into a new [`CustomerInfo`](/android/quickstart/tracking-subscription-state#reading-detailed-purchase-history-2-6-6) snapshot. The event includes the previous and next objects so you can diff entitlements or transactions. +- `CustomerInfoDidChange` fires whenever the SDK merges device, web, and external purchase controller data into a new [`CustomerInfo`](/android/quickstart/tracking-subscription-state#reading-detailed-purchase-history-266) snapshot. The event includes the previous and next objects so you can diff entitlements or transactions. - `PermissionRequested`, `PermissionGranted`, and `PermissionDenied` correspond to the new **Request permission** action in the paywall editor. Each event carries the `permissionName` and `paywallIdentifier`. - `PaywallPreloadStart` and `PaywallPreloadComplete` track when preloading kicks off and how many paywalls finished warming the cache. diff --git a/content/docs/ios/guides/local-resources.mdx b/content/docs/ios/guides/local-resources.mdx index 8afad3b3..7bb3072e 100644 --- a/content/docs/ios/guides/local-resources.mdx +++ b/content/docs/ios/guides/local-resources.mdx @@ -3,4 +3,83 @@ title: "Local Resources" description: "Bundle images, videos, and other media in your app for use in paywalls, enabling faster load times and offline support." --- -../../../shared/local-resources.mdx +Local resources allow you to register media files bundled in your app (images, videos, audio) so that paywalls can reference them by ID instead of loading them from a remote URL. The SDK serves these files directly from disk, which means instant loading and no network dependency. + + + Local resources require **iOS SDK v4.13.0+**. Make sure you're on a compatible version before + using this feature. + + +## Registering local resources + +Set the `localResources` property on `SuperwallOptions` **before** calling `configure()`. Each entry maps a **resource ID** (a string you choose) to a local file URL: + +```swift Swift +let options = SuperwallOptions() +options.localResources = [ + "hero-image": Bundle.main.url(forResource: "hero", withExtension: "png")!, + "onboarding-video": Bundle.main.url(forResource: "onboarding", withExtension: "mp4")! +] + +Superwall.configure(apiKey: "pk_your_api_key", options: options) +``` + +The resource IDs you choose here are the same IDs you'll select in the [paywall editor](/dashboard/dashboard-creating-paywalls/paywall-editor-local-resources) when configuring an image or video component. + + + Local resources must be set **before** calling `configure()`. Resources added after configuration + will not be available for paywalls that have already loaded. + + +## Supported file types + +The SDK supports a wide range of media formats: + +| Category | Formats | +| -------- | ------------------------------------------------------ | +| Images | PNG, JPEG, GIF, WebP, SVG, HEIC, HEIF, AVIF, BMP, TIFF | +| Videos | MP4, MOV, WebM, AVI, HEVC/H.265 | + +## Choosing resource IDs + +Resource IDs are simple strings that act as the key between your app and the paywall editor. A few tips: + +- **Use descriptive names** like `"hero-image"` or `"onboarding-video"` rather than `"img1"`. +- **Keep them stable.** If you change a resource ID, you'll need to update any paywalls that reference it in the editor. +- **They're case-sensitive.** `"Hero-Image"` and `"hero-image"` are different IDs. + +## Fallback behavior + +In the paywall editor, you can set both a local resource and a remote URL on the same image or video component. If the local file can't be loaded (for example, the resource ID isn't registered or the file is missing from the bundle), the paywall automatically falls back to the remote URL. This ensures paywalls still work on older SDK versions or if a resource is accidentally removed from the app bundle. + +## Debugging + +The SDK includes a built-in debug view for verifying your local resources are set up correctly. It shows each registered resource ID, its file path, and a preview of the content. + + + If a resource isn't showing up in the paywall editor dropdown, make sure your test device has + opened a paywall (or otherwise triggered a device attributes event) after configuring + `localResources`. The editor only shows resource IDs reported in the last 7 days. + + +## Example: Onboarding paywall with a bundled video + +A common use case is bundling an onboarding video so it loads instantly the first time a user sees your paywall: + +```swift Swift +// In your app's initialization +let options = SuperwallOptions() +options.localResources = [ + "onboarding-video": Bundle.main.url(forResource: "welcome", withExtension: "mp4")!, + "app-logo": Bundle.main.url(forResource: "logo", withExtension: "png")! +] + +Superwall.configure(apiKey: "pk_your_api_key", options: options) +``` + +Then in the paywall editor, select "onboarding-video" as the local resource for your video component and "app-logo" for the logo image. Set remote URLs as fallbacks for both. + +## Related + +- [`SuperwallOptions`](/ios/sdk-reference/SuperwallOptions): Full configuration reference. +- [Paywall Editor: Local Resources](/dashboard/dashboard-creating-paywalls/paywall-editor-local-resources): How to use local resources in the paywall editor. diff --git a/content/docs/variable-reference.mdx b/content/docs/variable-reference.mdx index 3b9ff25a..dec59996 100644 --- a/content/docs/variable-reference.mdx +++ b/content/docs/variable-reference.mdx @@ -6,16 +6,10 @@ title: Variable Reference These are all the properties that Superwall automatically exposes on the user object. It can be referenced as `{{ user. }}` in your templates and `user.` in rules. -Property | Type | Examples | Description | Available Since --------- | ------ | --------- | ------------------------------------------------------------------------------------------------------ | --------------- -seed | number | 0, 35, 99 | A random number between 0 and 99 (inclusive) seeded by the appUserId. Useful for routing in campaigns. | 3.2.0 - -\ - - - - -| any | foo, bar | A custom property defined in the Superwall dashboard | 3.2.0 +| Property | Type | Examples | Description | Available Since | +| ---------- | ------ | --------- | ------------------------------------------------------------------------------------------------------ | --------------- | +| seed | number | 0, 35, 99 | A random number between 0 and 99 (inclusive) seeded by the appUserId. Useful for routing in campaigns. | 3.2.0 | +| `` | any | foo, bar | A custom property defined in the Superwall dashboard | 3.2.0 | Any properties you assign with setUserAttributes will be available on the user @@ -26,60 +20,53 @@ seed | number | 0, 35, 99 | A random number between 0 and 99 (inclusive) see These are all the properties that Superwall automoatically exposes on the device object. It can be referenced as `{{ device. }}` in your templates and `device.` in rules. -Property | Type | Examples | Description | Available Since ---------------------------- | --------- | -------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------- -publicApiKey | string | pk_79e750d225197c7d774602f5698b7510dad0dfe15d91464a | The Public API key for Superwall | 3.0.0 -platform | string | macOS, iOS | The platform the user is on | 3.0.0 -appUserId | string | 2c79db670ec39dd8e747ae2ff4, $SuperwallAlias:9b8663b5-eb67-41fd-a5a1-972ea2149e23 | The unique identifier for the user, provided by the app. This can also be a `$SuperwallAlias` if you have not called `identify` | 3.0.0 -aliases | string | 9b8663b5-eb67-41fd-a5a1-972ea2149e23 | A list of aliases for the user. If you've called `identify` this may contain the alias Superwall originally created. | 3.0.0 -vendorId | string | 9b8663b5-eb67-41fd-a5a1-972ea2149e23 | A unique identifier for the device. This persists between `identify` and `reset` calls | 3.0.0 -appVersion | string | 1.0.0, 4.3.1 | The version of the app, as defined in the app's `Info.plist` | 3.0.0 -osVersion | string | 16.3.1, 14.4.1 | The version of the operating system | 3.0.0 -deviceModel | string | iPhone12, iPhone8, iPad5 | The model of the device | 3.0.0 -deviceLocale | string | en_US, en_GB, fr_FR, fr_OM | The locale of the device | 3.0.0 -deviceLanguageCode | string | en, fr | The language code of the device, just the first part of the locale | 3.0.0 -deviceCurrencyCode | string | USD, EUR | The currency code of the device | 3.0.0 -deviceCurrencySymbol | string | $, € | The currency symbol of the device | 3.0.0 -timezoneOffset | number | -18000, 3600 | The timezone offset of the device in seconds. Ex: -14,400 for EST | 3.0.0 -radioType | string | Cellular, Wifi, No Internet | The radio type of the device | 3.0.0 -interfaceType | string | ipad, iphone, mac, carplay, tv, unspecified | The type of interface of the device, preferred over `deviceModel`. Note that iPhone screen size emulated in iPad will be iphone. Built for iPad on Mac will be ipad. | 3.2.0 -interfaceStyle | string | Unspecified, Unknown, Light, Dark | The interface style of the device | 3.0.0 -isLowPowerModeEnabled | string | true, false | Whether or not low power mode is enabled | 3.0.0 -bundleId | string | com.superwall.test | The bundle ID of the app | 3.0.0 -appInstallDate | string | 2021-07-01T00:00:00.000Z | The date the app was installed, ISO-8601 format | 3.0.0 -isMac | boolean | true, false | Whether or not the device is a Mac | 3.0.0 -daysSinceInstall | number | 0, 1, 2 | The number of days since the app was installed | 3.0.0 -minutesSinceInstall | number | 0, 1, 2 | The number of minutes since the app was installed | 3.0.0 -daysSinceLastPaywallView | undefined | , 0, 1, 2 | The number of days since the last paywall view | 3.0.0 -minutesSinceLastPaywallView | undefined | , 0, 1, 2 | The number of minutes since the last paywall view | 3.0.0 -totalPaywallViews | number | 0, 1, 2 | The total number of paywall views | 3.0.0 -utcDate | string | 2021-07-01 | The UTC date | 3.0.0 -localDate | string | 2021-07-01 | The local date | 3.0.0 -utcTime | string | 15:54:28 | The UTC time | 3.0.0 -localTime | string | 15:54:28 | The local time | 3.0.0 -localDateTime | string | 2021-07-01T15:54:28 | The local date time | 3.0.0 -utcDateTime | string | 2021-07-01T15:54:28 | The UTC date time | 3.0.0 -isSandbox | string | true, false | Whether or not the device is a sandbox | 3.0.0 -subscriptionStatus | string | ACTIVE, INACTIVE, UNKNOWN | The subscription status of the user | 3.0.0 -isFirstAppOpen | boolean | true, false | Whether or not this is the first app open | 3.0.0 -sdkVersion | string | 3.0.0 | The version of the Superwall SDK | 3.4.0 -sdkVersionPadded | string | 003.000.010 | The version of the Superwall SDK, padded to 3 digits | 3.4.0 -appBuildString | string | 1234 | The build string of the app, usually a number. From the app's `Info.plist` | 3.4.0 -appBuildStringNumber | undefined | , 1 | The build string of the app, converted to a number | 3.4.0 +| Property | Type | Examples | Description | Available Since | +| --------------------------- | --------- | -------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------- | +| publicApiKey | string | pk_79e750d225197c7d774602f5698b7510dad0dfe15d91464a | The Public API key for Superwall | 3.0.0 | +| platform | string | macOS, iOS | The platform the user is on | 3.0.0 | +| appUserId | string | 2c79db670ec39dd8e747ae2ff4, $SuperwallAlias:9b8663b5-eb67-41fd-a5a1-972ea2149e23 | The unique identifier for the user, provided by the app. This can also be a `$SuperwallAlias` if you have not called `identify` | 3.0.0 | +| aliases | string | 9b8663b5-eb67-41fd-a5a1-972ea2149e23 | A list of aliases for the user. If you've called `identify` this may contain the alias Superwall originally created. | 3.0.0 | +| vendorId | string | 9b8663b5-eb67-41fd-a5a1-972ea2149e23 | A unique identifier for the device. This persists between `identify` and `reset` calls | 3.0.0 | +| appVersion | string | 1.0.0, 4.3.1 | The version of the app, as defined in the app's `Info.plist` | 3.0.0 | +| osVersion | string | 16.3.1, 14.4.1 | The version of the operating system | 3.0.0 | +| deviceModel | string | iPhone12, iPhone8, iPad5 | The model of the device | 3.0.0 | +| deviceLocale | string | en_US, en_GB, fr_FR, fr_OM | The locale of the device | 3.0.0 | +| deviceLanguageCode | string | en, fr | The language code of the device, just the first part of the locale | 3.0.0 | +| deviceCurrencyCode | string | USD, EUR | The currency code of the device | 3.0.0 | +| deviceCurrencySymbol | string | $, € | The currency symbol of the device | 3.0.0 | +| timezoneOffset | number | -18000, 3600 | The timezone offset of the device in seconds. Ex: -14,400 for EST | 3.0.0 | +| radioType | string | Cellular, Wifi, No Internet | The radio type of the device | 3.0.0 | +| interfaceType | string | ipad, iphone, mac, carplay, tv, unspecified | The type of interface of the device, preferred over `deviceModel`. Note that iPhone screen size emulated in iPad will be iphone. Built for iPad on Mac will be ipad. | 3.2.0 | +| interfaceStyle | string | Unspecified, Unknown, Light, Dark | The interface style of the device | 3.0.0 | +| isLowPowerModeEnabled | string | true, false | Whether or not low power mode is enabled | 3.0.0 | +| bundleId | string | com.superwall.test | The bundle ID of the app | 3.0.0 | +| appInstallDate | string | 2021-07-01T00:00:00.000Z | The date the app was installed, ISO-8601 format | 3.0.0 | +| isMac | boolean | true, false | Whether or not the device is a Mac | 3.0.0 | +| daysSinceInstall | number | 0, 1, 2 | The number of days since the app was installed | 3.0.0 | +| minutesSinceInstall | number | 0, 1, 2 | The number of minutes since the app was installed | 3.0.0 | +| daysSinceLastPaywallView | undefined | , 0, 1, 2 | The number of days since the last paywall view | 3.0.0 | +| minutesSinceLastPaywallView | undefined | , 0, 1, 2 | The number of minutes since the last paywall view | 3.0.0 | +| totalPaywallViews | number | 0, 1, 2 | The total number of paywall views | 3.0.0 | +| utcDate | string | 2021-07-01 | The UTC date | 3.0.0 | +| localDate | string | 2021-07-01 | The local date | 3.0.0 | +| utcTime | string | 15:54:28 | The UTC time | 3.0.0 | +| localTime | string | 15:54:28 | The local time | 3.0.0 | +| localDateTime | string | 2021-07-01T15:54:28 | The local date time | 3.0.0 | +| utcDateTime | string | 2021-07-01T15:54:28 | The UTC date time | 3.0.0 | +| isSandbox | string | true, false | Whether or not the device is a sandbox | 3.0.0 | +| subscriptionStatus | string | ACTIVE, INACTIVE, UNKNOWN | The subscription status of the user | 3.0.0 | +| isFirstAppOpen | boolean | true, false | Whether or not this is the first app open | 3.0.0 | +| sdkVersion | string | 3.0.0 | The version of the Superwall SDK | 3.4.0 | +| sdkVersionPadded | string | 003.000.010 | The version of the Superwall SDK, padded to 3 digits | 3.4.0 | +| appBuildString | string | 1234 | The build string of the app, usually a number. From the app's `Info.plist` | 3.4.0 | +| appBuildStringNumber | undefined | , 1 | The build string of the app, converted to a number | 3.4.0 | # Params -These are all the properties that Superwall automatically exposes on the params object. It can be referenced as `{{ params. }}` in your templates and `params.` in rules. These are all defined by what you pass in to a register call. See the [iOS reference](https://sdk.superwall.me/documentation/superwallkit/superwall/register(placement:params:handler:feature:)>>) for more details. - -Property | Type | Examples | Description | Available Since --------- | ---- | -------- | ----------- | --------------- - | - -\ - - - +These are all the properties that Superwall automatically exposes on the params object. It can be referenced as `{{ params. }}` in your templates and `params.` in rules. These are all defined by what you pass in to a register call. See the [iOS reference]() for more details. -| any | foo, bar | A custom parameter defined when you trigger an event | 3.0.0 +| Property | Type | Examples | Description | Available Since | +| ---------- | ---- | -------- | ---------------------------------------------------- | --------------- | +| `` | any | foo, bar | A custom parameter defined when you trigger an event | 3.0.0 | [//]: < "END_PARAMS_PROPERTIES - DO NOT MODIFY BETWEEN THESE LINES" diff --git a/package.json b/package.json index 6b269d35..65472775 100644 --- a/package.json +++ b/package.json @@ -6,12 +6,14 @@ "scripts": { "generate:changelog": "bun run scripts/generate-changelog.ts", "download:references": "bun run scripts/download-references.ts", + "check:links:source": "bun run scripts/check-source-links.ts", + "check:links": "bun run scripts/check-doc-links.ts", "test": "bun test", "predev": "bun run scripts/copy-docs-images.cjs", "dev": "vite dev", "dev:port": "vite dev --port", "prebuild": "bun run generate:changelog && bun run scripts/copy-docs-images.cjs", - "build": "NODE_OPTIONS=--max-old-space-size=8192 vite build && bun run scripts/generate-static-cache.ts", + "build": "rm -rf dist && NODE_OPTIONS=--max-old-space-size=8192 vite build && bun run scripts/generate-static-cache.ts && bun run check:links", "build:cf": "bun run build", "build:cf:staging": "NODE_OPTIONS=--max-old-space-size=8192 CLOUDFLARE_ENV=staging vite build", "sync:mixedbread": "mxbai vs sync $MIXEDBREAD_STORE_ID './content/docs' --ci", @@ -68,6 +70,7 @@ "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^5.1.4", + "next-validate-link": "^1.6.4", "oxfmt": "^0.36.0", "oxlint": "^1.51.0", "srvx": "^0.11.8", diff --git a/plugins/remark-link-paths.test.ts b/plugins/remark-link-paths.test.ts index 8feae0f1..523df10f 100644 --- a/plugins/remark-link-paths.test.ts +++ b/plugins/remark-link-paths.test.ts @@ -24,12 +24,29 @@ describe("remarkLinkPaths", () => { remarkLinkPaths()(tree); - assert.equal(tree.children[0].url, "/docs/paywall-editor-overview"); - assert.equal(tree.children[1].url, "/docs/home"); - assert.equal(tree.children[2].attributes[0].value, "/docs/campaigns"); + assert.equal(tree.children[0].url, "/docs/dashboard/dashboard-creating-paywalls/paywall-editor-overview"); + assert.equal(tree.children[1].url, "/docs/dashboard"); + assert.equal(tree.children[2].attributes[0].value, "/docs/dashboard/dashboard-campaigns/campaigns"); assert.equal(tree.children[3].attributes[0].value, "/docs/ios"); }); + test("rewrites known legacy hashes and relative links to canonical docs targets", () => { + const tree: any = { + type: "root", + children: [ + { type: "link", url: "/campaigns-audience#matching-to-entitlements" }, + { type: "link", url: "../game-controller-support" }, + ], + }; + + remarkLinkPaths()(tree, { + path: "/repo/content/docs/legacy/legacy_using-superwalloptions.mdx", + }); + + assert.equal(tree.children[0].url, "/docs/dashboard/dashboard-campaigns/campaigns-audience#matching-to-entitlements-or-subscription-status"); + assert.equal(tree.children[1].url, "/docs/sdk/guides/advanced/game-controller-support"); + }); + test("keeps external and special URLs unchanged", () => { const tree: any = { type: "root", diff --git a/plugins/remark-link-paths.ts b/plugins/remark-link-paths.ts index a011b85f..875a981a 100644 --- a/plugins/remark-link-paths.ts +++ b/plugins/remark-link-paths.ts @@ -6,41 +6,46 @@ type MdxJsxAttribute = { value?: unknown; }; -function rewriteMdxAttributes(attributes: MdxJsxAttribute[] | undefined) { +function rewriteMdxAttributes(attributes: MdxJsxAttribute[] | undefined, currentFilePath?: string) { if (!attributes) return; for (const attribute of attributes) { - if ((attribute.name === "href" || attribute.name === "to") && typeof attribute.value === "string") { - attribute.value = normalizeDocsInternalHref(attribute.value); + if ( + (attribute.name === "href" || attribute.name === "to") && + typeof attribute.value === "string" + ) { + attribute.value = normalizeDocsInternalHref(attribute.value, { currentFilePath }); } } } export default function remarkLinkPaths() { - return (tree: any) => { + return (tree: any, file?: { path?: string }) => { + const currentFilePath = typeof file?.path === "string" ? file.path : undefined; + visit(tree, "link", (node: any) => { if (typeof node.url === "string") { - node.url = normalizeDocsInternalHref(node.url); + node.url = normalizeDocsInternalHref(node.url, { currentFilePath }); } }); visit(tree, "definition", (node: any) => { if (typeof node.url === "string") { - node.url = normalizeDocsInternalHref(node.url); + node.url = normalizeDocsInternalHref(node.url, { currentFilePath }); } }); visit(tree, "mdxJsxFlowElement", (node: any) => { - rewriteMdxAttributes(node.attributes); + rewriteMdxAttributes(node.attributes, currentFilePath); }); visit(tree, "mdxJsxTextElement", (node: any) => { - rewriteMdxAttributes(node.attributes); + rewriteMdxAttributes(node.attributes, currentFilePath); }); visit(tree, "mdxJsxAttribute", (node: any) => { if ((node.name === "href" || node.name === "to") && typeof node.value === "string") { - node.value = normalizeDocsInternalHref(node.value); + node.value = normalizeDocsInternalHref(node.value, { currentFilePath }); } }); }; diff --git a/scripts/check-doc-links.ts b/scripts/check-doc-links.ts new file mode 100644 index 00000000..3b21e44a --- /dev/null +++ b/scripts/check-doc-links.ts @@ -0,0 +1,189 @@ +import { existsSync, readFileSync, readdirSync, statSync } from "node:fs"; +import { join, relative, resolve } from "node:path"; + +type Failure = + | { + type: "missing-path"; + source: string; + target: string; + } + | { + type: "missing-hash"; + source: string; + target: string; + targetFile: string; + }; + +const distRoot = resolve(process.cwd(), "dist/client"); +const docsStaticRoot = join(distRoot, "docs"); + +function walkHtmlFiles(directory: string, files: string[] = []): string[] { + for (const entry of readdirSync(directory)) { + const fullPath = join(directory, entry); + const stats = statSync(fullPath); + if (stats.isDirectory()) { + walkHtmlFiles(fullPath, files); + continue; + } + if (entry.endsWith(".html")) { + files.push(fullPath); + } + } + return files; +} + +function toSitePath(filePath: string): string { + const relPath = relative(distRoot, filePath).replace(/\\/g, "/"); + if (relPath === "index.html") return "/docs"; + if (relPath.endsWith("/index.html")) { + return `/docs/${relPath.slice(0, -"/index.html".length)}`; + } + return `/docs/${relPath}`; +} + +function resolveBuiltFile( + baseDirectory: string, + relPath: string, + allowIndexLookup: boolean, +): string | null { + const decodedRelPath = decodeURIComponent(relPath); + const directFile = join(baseDirectory, decodedRelPath); + if (existsSync(directFile) && statSync(directFile).isFile()) { + return directFile; + } + + if (!allowIndexLookup) return null; + + const indexFile = join(baseDirectory, decodedRelPath, "index.html"); + if (existsSync(indexFile)) { + return indexFile; + } + + if (!decodedRelPath.includes(".")) { + const htmlFile = join(baseDirectory, `${decodedRelPath}.html`); + if (existsSync(htmlFile)) { + return htmlFile; + } + } + + return null; +} + +function findTargetFile(pathname: string): string | null { + if (!pathname.startsWith("/docs")) return null; + + const relPath = pathname.slice("/docs".length).replace(/^\/+/, ""); + if (/^(assets|images|resources|__sfn_cache)(\/|$)/.test(relPath)) { + return resolveBuiltFile(docsStaticRoot, relPath, false); + } + + return resolveBuiltFile(distRoot, relPath, true); +} + +function getHtmlIds(filePath: string, cache: Map>): Set { + const cached = cache.get(filePath); + if (cached) return cached; + + const html = readFileSync(filePath, "utf8"); + const ids = new Set(); + for (const match of html.matchAll(/\sid="([^"]+)"/g)) { + ids.add(match[1]); + } + + cache.set(filePath, ids); + return ids; +} + +function collectInternalReferences(html: string): string[] { + const refs: string[] = []; + + for (const match of html.matchAll(/\s(?:href|src)="([^"]+)"/g)) { + refs.push(match[1]); + } + + for (const match of html.matchAll(/\ssrcset="([^"]+)"/g)) { + for (const candidate of match[1].split(",")) { + const [ref = ""] = candidate.trim().split(/\s+/, 1); + if (ref) refs.push(ref); + } + } + + return refs; +} + +function isSkippableRef(ref: string): boolean { + if (!ref) return true; + if (ref.startsWith("#") || ref.startsWith("//")) return true; + if (ref.startsWith("mailto:") || ref.startsWith("tel:")) return true; + return /^[a-zA-Z][a-zA-Z\d+\-.]*:/.test(ref); +} + +function run(): void { + const htmlFiles = walkHtmlFiles(distRoot); + const idsCache = new Map>(); + const failures: Failure[] = []; + + for (const htmlFile of htmlFiles) { + const html = readFileSync(htmlFile, "utf8"); + const sourcePath = toSitePath(htmlFile); + + for (const ref of collectInternalReferences(html)) { + if (isSkippableRef(ref)) continue; + + const url = new URL(ref, "https://superwall.com"); + if (!url.pathname.startsWith("/docs")) continue; + + const targetFile = findTargetFile(url.pathname); + if (!targetFile) { + failures.push({ + type: "missing-path", + source: sourcePath, + target: url.pathname, + }); + continue; + } + + if (!url.hash || !targetFile.endsWith(".html")) continue; + + const targetId = decodeURIComponent(url.hash.slice(1)); + const ids = getHtmlIds(targetFile, idsCache); + if (ids.has(targetId)) continue; + + failures.push({ + type: "missing-hash", + source: sourcePath, + target: `${url.pathname}${url.hash}`, + targetFile: toSitePath(targetFile), + }); + } + } + + if (failures.length === 0) { + console.log( + `[check-doc-links] Checked ${htmlFiles.length} HTML files. No broken internal docs links found.`, + ); + return; + } + + console.error(`[check-doc-links] Found ${failures.length} broken internal docs references.`); + + const seen = new Set(); + for (const failure of failures) { + const key = `${failure.type}:${failure.source}:${failure.target}`; + if (seen.has(key)) continue; + seen.add(key); + + if (failure.type === "missing-path") { + console.error(`- missing path: ${failure.source} -> ${failure.target}`); + continue; + } + + console.error( + `- missing hash: ${failure.source} -> ${failure.target} (target page: ${failure.targetFile})`, + ); + } + + process.exit(1); +} + +run(); diff --git a/scripts/check-source-links.ts b/scripts/check-source-links.ts new file mode 100644 index 00000000..c8e3aa2b --- /dev/null +++ b/scripts/check-source-links.ts @@ -0,0 +1,12 @@ +import { printErrors } from "next-validate-link"; +import { validateSourceLinks } from "./lib/source-link-validation"; + +const run = await validateSourceLinks(); + +if (run.results.length === 0) { + console.log( + `[check-source-links] Checked ${run.fileCount} docs source files against ${run.pageCount} docs URLs. No source link issues found.`, + ); +} else { + printErrors(run.results, true); +} diff --git a/scripts/lib/source-link-validation.ts b/scripts/lib/source-link-validation.ts new file mode 100644 index 00000000..d05e959d --- /dev/null +++ b/scripts/lib/source-link-validation.ts @@ -0,0 +1,109 @@ +import { relative, resolve } from "node:path"; +import { + readFiles, + scanURLs, + validateFiles, + type ValidateResult, +} from "next-validate-link"; +import remarkLinkPaths from "../../plugins/remark-link-paths"; +import { fileRedirectsMap, folderRedirectsMap } from "../../redirects-map"; + +const DOCS_CONTENT_GLOB = "content/docs/**/*.{md,mdx}"; +const DOCS_CONTENT_ROOT = resolve(process.cwd(), "content/docs"); + +export type SourceLinkValidationRun = { + fileCount: number; + pageCount: number; + results: ValidateResult[]; +}; + +function contentFilePathToDocsUrl(filePath: string): string | undefined { + const normalized = relative(DOCS_CONTENT_ROOT, resolve(filePath)).replace(/\\/g, "/"); + if (!normalized || normalized.startsWith("..")) return undefined; + if (!/\.(md|mdx)$/i.test(normalized)) return undefined; + + const withoutExtension = normalized.replace(/\.(md|mdx)$/i, ""); + if (withoutExtension === "index") return "/docs"; + + const trimmedIndex = withoutExtension.replace(/\/index$/i, ""); + return trimmedIndex ? `/docs/${trimmedIndex}` : "/docs"; +} + +function toScanPage(pathname: string): string { + const trimmed = pathname.replace(/^\/+/, "").replace(/\/+$/, ""); + return trimmed === "" ? "docs" : trimmed.startsWith("docs/") || trimmed === "docs" ? trimmed : `docs/${trimmed}`; +} + +function getRedirectDestinationPages(): string[] { + const pages = new Set(); + + for (const destination of Object.values(fileRedirectsMap)) { + pages.add(toScanPage(destination)); + } + + for (const [folder, slugs] of Object.entries(folderRedirectsMap as Record)) { + for (const slug of slugs) { + pages.add(toScanPage(`${folder}/${slug}`)); + } + } + + return [...pages]; +} + +function getSdkSelectorPages(urls: string[]): string[] { + const pages = new Set(); + + for (const url of urls) { + const match = url.match( + /^\/docs\/(?:ios|android|flutter|expo|react-native)\/(quickstart|guides|sdk-reference)(\/.*)?$/, + ); + if (!match) continue; + + pages.add(toScanPage(`/docs/sdk/${match[1]}${match[2] ?? ""}`)); + } + + return [...pages]; +} + +export async function validateSourceLinks(): Promise { + const files = await readFiles(DOCS_CONTENT_GLOB, { + pathToUrl: contentFilePathToDocsUrl, + }); + + const fileUrls = files.flatMap((file) => (file.url ? [file.url] : [])); + const pages = [ + ...new Set([ + ...fileUrls.map((url) => url.slice(1)), + ...getRedirectDestinationPages(), + ...getSdkSelectorPages(fileUrls), + ]), + ]; + const scanned = await scanURLs({ + preset: "tanstack-start", + cwd: process.cwd(), + pages, + }); + + const results = await validateFiles(files, { + scanned, + ignoreFragment: true, + ignoreQuery: true, + markdown: { + remarkPlugins: [remarkLinkPaths], + components: { + Card: { + attributes: ["href"], + }, + Link: { + attributes: ["href", "to"], + }, + }, + }, + }); + + return { + fileCount: files.length, + pageCount: pages.length, + results, + }; +} diff --git a/src/lib/docs-link-aliases.ts b/src/lib/docs-link-aliases.ts new file mode 100644 index 00000000..c58e6eca --- /dev/null +++ b/src/lib/docs-link-aliases.ts @@ -0,0 +1,49 @@ +const DOCS_LINK_ALIASES = { + "/docs/3rd-party-analytics#using-events-to-see-purchased-products": + "/docs/viewing-purchased-products", + "/docs/android/quickstart/tracking-subscription-state#reading-detailed-purchase-history-2-6-6": + "/docs/android/quickstart/tracking-subscription-state#reading-detailed-purchase-history-266", + "/docs/android/sdk-reference/SuperwallDelegate#customerinfodidchangefrom-customerinfo-to-customerinfo": + "/docs/android/sdk-reference/SuperwallDelegate#usage", + "/docs/campaigns-audience#matching-to-entitlements": + "/docs/campaigns-audience#matching-to-entitlements-or-subscription-status", + "/docs/campaigns-placements#implicit-placements": "/docs/campaigns-standard-placements", + "/docs/campaigns-standard-placements#standard-placements": "/docs/campaigns-standard-placements", + "/docs/campaigns-standard-placements#using-the-deeplink-open-event": + "/docs/campaigns-standard-placements#deeplink_open", + "/docs/campaigns-standard-placements#using-the-paywall-decline-event": + "/docs/campaigns-standard-placements#paywall_decline", + "/docs/campaigns-standard-placements#using-the-survey-response-event": + "/docs/campaigns-standard-placements#survey_response", + "/docs/configuring-the-sdk#conforming-to-the-delegate": "/docs/using-superwall-delegate", + "/docs/custom-paywall-events#custom-paywall-actions": "/docs/custom-paywall-events", + "/docs/feature-gating#handling-network-issues": "/docs/feature-gating", + "/docs/flutter/sdk-reference/PaywallOptions#overrideproductsbyname": + "/docs/flutter/sdk-reference/PaywallOptions#parameters", + "/docs/game-controller-support#game-controller-support": "/docs/game-controller-support", + "/docs/ios/sdk-reference/PaywallOptions#properties": + "/docs/ios/sdk-reference/PaywallOptions#parameters", + "/docs/ios/sdk-reference/Superwall#customerinfostream": "/docs/ios/sdk-reference/Superwall#usage", + "/docs/ios/sdk-reference/Superwall#integrationattributes": + "/docs/ios/sdk-reference/Superwall#usage", + "/docs/ios/sdk-reference/SuperwallDelegate#customerinfodidchange": + "/docs/ios/sdk-reference/SuperwallDelegate#usage", + "/docs/legacy/legacy_custom-paywall-events#custom-paywall-actions": + "/docs/legacy/legacy_custom-paywall-events", + "/docs/troubleshooting#my-products-arent-loading": + "/docs/support/troubleshooting/products-not-loading", + "/docs/web-checkout-adding-a-stripe-product#creating-sandbox-products-to-test-with": + "/docs/web-checkout-adding-a-stripe-product#sandbox-products", + "/docs/web-checkout-direct-stripe-checkout#prefill-email": + "/docs/web-checkout-direct-stripe-checkout#prefill-customer-information", +} as const; + +function normalizeHash(hash: string): string { + if (!hash) return ""; + return hash.startsWith("#") ? hash : `#${hash}`; +} + +export function resolveDocsLinkAlias(pathname: string, hash = ""): string | undefined { + const key = `${pathname}${normalizeHash(hash)}`; + return DOCS_LINK_ALIASES[key as keyof typeof DOCS_LINK_ALIASES]; +} diff --git a/src/lib/docs-link-canonical.ts b/src/lib/docs-link-canonical.ts new file mode 100644 index 00000000..8081761e --- /dev/null +++ b/src/lib/docs-link-canonical.ts @@ -0,0 +1,85 @@ +import { externalRedirectsMap, fileRedirectsMap, folderRedirectsMap } from "../../redirects-map"; + +const DOCS_BASE = "/docs"; +const DOCS_COMPATIBILITY_REDIRECTS = { + [`${DOCS_BASE}/ai`]: `${DOCS_BASE}/support`, + [`${DOCS_BASE}/installation-via-rn-expo`]: `${DOCS_BASE}/installation-via-expo`, +} as const; + +function normalizeSourcePath(path: string): string { + const trimmed = path.trim(); + if (!trimmed) return "/"; + return `/${trimmed.replace(/^\/+/, "").replace(/\/+$/, "")}`; +} + +function normalizeDocsScopedPath(path: string): string { + const normalized = normalizeSourcePath(path); + if (normalized === DOCS_BASE || normalized.startsWith(`${DOCS_BASE}/`)) return normalized; + if (normalized === "/") return DOCS_BASE; + return `${DOCS_BASE}${normalized}`; +} + +function normalizeInternalDestination(path: string): string { + const normalized = normalizeSourcePath(path); + if (normalized === DOCS_BASE || normalized.startsWith(`${DOCS_BASE}/`)) return normalized; + if (normalized === "/") return DOCS_BASE; + return `${DOCS_BASE}${normalized}`; +} + +const internalRedirects = new Map(); +const externalRedirects = new Map(); + +for (const [source, destination] of Object.entries(DOCS_COMPATIBILITY_REDIRECTS)) { + internalRedirects.set(source, destination); +} + +for (const [folder, slugs] of Object.entries(folderRedirectsMap as Record)) { + for (const slug of slugs) { + internalRedirects.set( + normalizeDocsScopedPath(slug), + normalizeInternalDestination(`${folder}/${slug}`), + ); + } +} + +for (const [source, destination] of Object.entries(fileRedirectsMap)) { + internalRedirects.set(normalizeDocsScopedPath(source), normalizeInternalDestination(destination)); +} + +for (const [source, destination] of Object.entries(externalRedirectsMap)) { + externalRedirects.set(normalizeDocsScopedPath(source), destination); +} + +export function canonicalizeDocsPathname(pathname: string): string { + let current = normalizeDocsScopedPath(pathname); + const seen = new Set(); + + while (!seen.has(current)) { + seen.add(current); + + if (current === `${DOCS_BASE}/docs`) { + current = DOCS_BASE; + continue; + } + + if (current.startsWith(`${DOCS_BASE}/docs/`)) { + current = `${DOCS_BASE}/${current.slice(`${DOCS_BASE}/docs/`.length)}`; + continue; + } + + const internalDestination = internalRedirects.get(current); + if (internalDestination && internalDestination !== current) { + current = internalDestination; + continue; + } + + const externalDestination = externalRedirects.get(current); + if (externalDestination) { + return externalDestination; + } + + return current; + } + + return current; +} diff --git a/src/lib/docs-url.test.ts b/src/lib/docs-url.test.ts index 80c0e90c..c9b47d8a 100644 --- a/src/lib/docs-url.test.ts +++ b/src/lib/docs-url.test.ts @@ -4,7 +4,7 @@ import { normalizeDocsInternalHref } from "./docs-url"; describe("normalizeDocsInternalHref", () => { test("prefixes root docs-style paths with /docs", () => { - assert.equal(normalizeDocsInternalHref("/paywall-editor-overview"), "/docs/paywall-editor-overview"); + assert.equal(normalizeDocsInternalHref("/paywall-editor-overview"), "/docs/dashboard/dashboard-creating-paywalls/paywall-editor-overview"); assert.equal(normalizeDocsInternalHref("/"), "/docs"); }); @@ -13,17 +13,55 @@ describe("normalizeDocsInternalHref", () => { }); test("preserves query and hash", () => { + assert.equal(normalizeDocsInternalHref("/paywall-editor-overview?foo=1#section"), "/docs/dashboard/dashboard-creating-paywalls/paywall-editor-overview?foo=1#section"); + }); + + test("rewrites known legacy hash targets to canonical destinations", () => { + assert.equal(normalizeDocsInternalHref("/campaigns-audience#matching-to-entitlements"), "/docs/dashboard/dashboard-campaigns/campaigns-audience#matching-to-entitlements-or-subscription-status"); + assert.equal(normalizeDocsInternalHref("/3rd-party-analytics#using-events-to-see-purchased-products"), "/docs/sdk/guides/advanced/viewing-purchased-products"); + assert.equal( + normalizeDocsInternalHref( + "/android/quickstart/tracking-subscription-state#reading-detailed-purchase-history-2-6-6", + ), + "/docs/android/quickstart/tracking-subscription-state#reading-detailed-purchase-history-266", + ); + }); + + test("canonicalizes legacy redirect aliases to real docs pages or external URLs", () => { + assert.equal(normalizeDocsInternalHref("/troubleshooting"), "https://support.superwall.com/collections/6437438776-troubleshooting"); + assert.equal(normalizeDocsInternalHref("/campaigns"), "/docs/dashboard/dashboard-campaigns/campaigns"); + assert.equal( + normalizeDocsInternalHref("/ios/troubleshooting"), + "https://support.superwall.com/articles/1219792086-products-not-loading", + ); + }); + + test("resolves relative page links when file context is provided", () => { + assert.equal( + normalizeDocsInternalHref("../game-controller-support", { + currentFilePath: "/repo/content/docs/legacy/legacy_using-superwalloptions.mdx", + }), + "/docs/sdk/guides/advanced/game-controller-support", + ); assert.equal( - normalizeDocsInternalHref("/paywall-editor-overview?foo=1#section"), - "/docs/paywall-editor-overview?foo=1#section", + normalizeDocsInternalHref("./cohorting-in-3rd-party-tools", { + currentFilePath: "/repo/content/docs/ios/guides/3rd-party-analytics/tracking-analytics.mdx", + }), + "/docs/ios/guides/3rd-party-analytics/cohorting-in-3rd-party-tools", ); }); test("keeps external and special URLs unchanged", () => { assert.equal(normalizeDocsInternalHref("https://example.com/x"), "https://example.com/x"); - assert.equal(normalizeDocsInternalHref("mailto:docs@superwall.com"), "mailto:docs@superwall.com"); + assert.equal( + normalizeDocsInternalHref("mailto:docs@superwall.com"), + "mailto:docs@superwall.com", + ); assert.equal(normalizeDocsInternalHref("#local"), "#local"); - assert.equal(normalizeDocsInternalHref("//cdn.example.com/file.css"), "//cdn.example.com/file.css"); + assert.equal( + normalizeDocsInternalHref("//cdn.example.com/file.css"), + "//cdn.example.com/file.css", + ); }); test("keeps root passthrough paths unchanged", () => { diff --git a/src/lib/docs-url.ts b/src/lib/docs-url.ts index 06164fa4..5515042c 100644 --- a/src/lib/docs-url.ts +++ b/src/lib/docs-url.ts @@ -1,8 +1,15 @@ +import { resolveDocsLinkAlias } from "./docs-link-aliases"; +import { canonicalizeDocsPathname } from "./docs-link-canonical"; + const DOCS_PREFIX = "/docs"; const SPECIAL_URL_PATTERN = /^[a-zA-Z][a-zA-Z\d+\-.]*:/; const ROOT_PASSTHROUGH_PREFIXES = ["/_next", "/api", "/icons"]; const ROOT_PASSTHROUGH_EXACT = new Set(["/favicon.ico", "/manifest.json"]); +type NormalizeDocsInternalHrefOptions = { + currentFilePath?: string; +}; + function isRootPassthroughPath(pathname: string): boolean { if (ROOT_PASSTHROUGH_EXACT.has(pathname)) return true; return ROOT_PASSTHROUGH_PREFIXES.some( @@ -10,46 +17,126 @@ function isRootPassthroughPath(pathname: string): boolean { ); } -export function normalizeDocsInternalHref(input: string): string { - const trimmed = input.trim(); - if (!trimmed) return trimmed; +function splitUrlParts(input: string): { pathPart: string; query: string; hash: string } { + const [, pathPart = "", query = "", hash = ""] = input.match(/^([^?#]*)(\?[^#]*)?(#.*)?$/) ?? []; + return { pathPart, query, hash }; +} - if (trimmed.startsWith("#") || trimmed.startsWith("//") || SPECIAL_URL_PATTERN.test(trimmed)) { - return trimmed; - } +function normalizePathname(pathname: string): string { + const segments = pathname.replace(/^\/+/, "").split("/").filter(Boolean); - const [, pathPart = "", suffix = ""] = trimmed.match(/^([^?#]*)([?#].*)?$/) ?? []; - if (!pathPart.startsWith("/")) { - return trimmed; + const normalizedSegments: string[] = []; + for (const segment of segments) { + if (!segment || segment === ".") continue; + if (segment === "..") { + normalizedSegments.pop(); + continue; + } + normalizedSegments.push(segment); } - const segments = pathPart + return `/${normalizedSegments.join("/")}`; +} + +function buildDocsPathFromSourceFile(filePath: string): string | null { + const normalizedFilePath = filePath.replace(/\\/g, "/"); + const marker = "/content/docs/"; + const markerIndex = normalizedFilePath.lastIndexOf(marker); + if (markerIndex === -1) return null; + + const relativeFilePath = normalizedFilePath + .slice(markerIndex + marker.length) + .replace(/\.(md|mdx)$/i, "") + .replace(/\/index$/i, "") .replace(/^\/+/, "") - .split("/") - .filter(Boolean); + .replace(/\/+$/, ""); + + return relativeFilePath ? `${DOCS_PREFIX}/${relativeFilePath}` : DOCS_PREFIX; +} + +function resolveRelativeDocsPath(pathname: string, currentFilePath: string): string | null { + const currentDocPath = buildDocsPathFromSourceFile(currentFilePath); + if (!currentDocPath) return null; + + const baseSegments = currentDocPath.replace(/^\/+/, "").split("/").filter(Boolean); + if (baseSegments.length > 1) { + baseSegments.pop(); + } + const relativeSegments = pathname.split("/").filter(Boolean); const normalizedSegments: string[] = []; - for (const segment of segments) { + for (const segment of baseSegments) { + normalizedSegments.push(segment); + } + for (const segment of relativeSegments) { if (!segment || segment === ".") continue; if (segment === "..") { - normalizedSegments.pop(); + if (normalizedSegments.length > 1) { + normalizedSegments.pop(); + } continue; } normalizedSegments.push(segment); } - const normalizedPath = `/${normalizedSegments.join("/")}`; + return `/${normalizedSegments.join("/")}`; +} + +function applyDocsLinkAlias(pathname: string, hash: string): { pathname: string; hash: string } { + const aliased = resolveDocsLinkAlias(pathname, hash); + if (!aliased) return { pathname, hash }; + + const { pathPart, hash: aliasedHash } = splitUrlParts(aliased); + return { + pathname: pathPart || pathname, + hash: aliasedHash, + }; +} + +function mergeResolvedUrl(input: string, query: string, hash: string): string { + const { pathPart, query: resolvedQuery, hash: resolvedHash } = splitUrlParts(input); + return `${pathPart}${resolvedQuery || query}${resolvedHash || hash}`; +} + +export function normalizeDocsInternalHref( + input: string, + options: NormalizeDocsInternalHrefOptions = {}, +): string { + const trimmed = input.trim(); + if (!trimmed) return trimmed; + + if (trimmed.startsWith("#") || trimmed.startsWith("//") || SPECIAL_URL_PATTERN.test(trimmed)) { + return trimmed; + } + + const { currentFilePath } = options; + const { pathPart, query, hash } = splitUrlParts(trimmed); + + let normalizedPath: string | null = null; + if (pathPart.startsWith("/")) { + normalizedPath = normalizePathname(pathPart); + } else if (currentFilePath && (pathPart.startsWith("./") || pathPart.startsWith("../"))) { + normalizedPath = resolveRelativeDocsPath(pathPart, currentFilePath); + } + + if (!normalizedPath) { + return trimmed; + } + if (normalizedPath === DOCS_PREFIX || normalizedPath.startsWith(`${DOCS_PREFIX}/`)) { - return `${normalizedPath}${suffix}`; + const aliased = applyDocsLinkAlias(normalizedPath, hash); + return mergeResolvedUrl(canonicalizeDocsPathname(aliased.pathname), query, aliased.hash); } if (isRootPassthroughPath(normalizedPath)) { - return `${normalizedPath}${suffix}`; + return `${normalizedPath}${query}${hash}`; } if (normalizedPath === "/") { - return `${DOCS_PREFIX}${suffix}`; + const aliased = applyDocsLinkAlias(DOCS_PREFIX, hash); + return mergeResolvedUrl(canonicalizeDocsPathname(aliased.pathname), query, aliased.hash); } - return `${DOCS_PREFIX}${normalizedPath}${suffix}`; + const aliased = applyDocsLinkAlias(`${DOCS_PREFIX}${normalizedPath}`, hash); + return mergeResolvedUrl(canonicalizeDocsPathname(aliased.pathname), query, aliased.hash); } diff --git a/src/routes/__root.tsx b/src/routes/__root.tsx index cbdc798c..1646f8c2 100644 --- a/src/routes/__root.tsx +++ b/src/routes/__root.tsx @@ -9,7 +9,7 @@ import { useRouter as useTanstackRouter, } from "@tanstack/react-router"; import * as React from "react"; -import { useRef, useMemo } from "react"; +import { useEffect, useRef, useMemo } from "react"; import appCss from "@/styles/app.css?url"; import { RootProvider as BaseRootProvider } from "fumadocs-ui/provider/base"; import { FrameworkProvider } from "fumadocs-core/framework"; @@ -26,6 +26,7 @@ import { TWITTER_HANDLE, } from "@/lib/metadata"; import { DOCS_BASE, toRouterPath } from "@/lib/url-base"; +import { resolveDocsLinkAlias } from "@/lib/docs-link-aliases"; type RootProviderLinkProps = React.ComponentProps<"a"> & { prefetch?: boolean; @@ -93,6 +94,47 @@ function DocsLink({ href, prefetch = true, ...props }: RootProviderLinkProps) { ); } +function LegacyDocsLinkRedirects() { + const { pathname, hash } = useRouterState({ + select: (state) => ({ + pathname: state.location.pathname, + hash: state.location.hash, + }), + }); + + useEffect(() => { + if (!hash || typeof window === "undefined") return; + + const fullPath = pathname === "/" ? DOCS_BASE : `${DOCS_BASE}${pathname}`; + const destination = resolveDocsLinkAlias(fullPath, hash); + if (!destination) return; + + const currentUrl = new URL(window.location.href); + const destinationUrl = new URL(destination, currentUrl.origin); + const nextHref = `${destinationUrl.pathname}${destinationUrl.search}${destinationUrl.hash}`; + const currentHref = `${fullPath}${currentUrl.search}${hash}`; + + if (nextHref === currentHref) return; + + if (destinationUrl.pathname !== fullPath || destinationUrl.search !== currentUrl.search) { + window.location.replace(nextHref); + return; + } + + window.history.replaceState(window.history.state, "", nextHref); + + if (destinationUrl.hash) { + const targetId = decodeURIComponent(destinationUrl.hash.slice(1)); + document.getElementById(targetId)?.scrollIntoView(); + return; + } + + window.scrollTo({ top: 0 }); + }, [pathname, hash]); + + return null; +} + export const Route = createRootRoute({ head: () => ({ meta: [ @@ -187,6 +229,7 @@ function RootDocument({ children }: { children: React.ReactNode }) { }} /> + { + test("validates docs source links against TanStack Start routes", async () => { + const run = await validateSourceLinks(); + + if (run.results.length > 0) { + printErrors(run.results); + } + + assert.equal(run.results.length, 0); + }); +}); diff --git a/vite.config.ts b/vite.config.ts index a10df5d3..d5c4b28c 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -56,6 +56,7 @@ export default defineConfig({ }), cloudflare({ viteEnvironment: { name: "ssr" }, + inspectorPort: false, }), tanstackStart({ router: {