diff --git a/README.md b/README.md
index 12a14c357..4341616c6 100644
--- a/README.md
+++ b/README.md
@@ -14,6 +14,8 @@
The official Algorand Developer Portal - a comprehensive documentation site for Algorand blockchain developers.
+**Live site: [dev.algorand.co](https://dev.algorand.co)**
+
## Table of Contents
- [Prerequisites](#prerequisites)
@@ -78,7 +80,6 @@ Before you begin, ensure you have the following installed:
│ └── transforms/ # Content transformation utilities
├── public/ # Static assets (favicons, etc.)
├── scripts/ # Build and utility scripts
-│ ├── clean-docs-import.ts # Clear imported documentation
│ ├── generate-opcode-list.js # Generate Algorand opcodes list
│ ├── manage-sidebar-meta.ts # Sidebar metadata generator
│ └── prose-check.ts # AI-powered prose quality checker
@@ -168,7 +169,6 @@ Documentation is imported from external GitHub repositories using `@larkiny/astr
| `pnpm run import:docs` | Import all content from GitHub, regenerate sidebar, and fix linting |
| `pnpm run import:force` | Force re-import all content, ignoring cache |
| `pnpm run import:dry-run` | Preview GitHub content imports without making changes |
-| `pnpm run import:clear` | Remove all imported documentation content |
### Auto-Sidebar Management
diff --git a/docs/plans/2026-02-28-macos-case-rename-fix-design.md b/docs/plans/2026-02-28-macos-case-rename-fix-design.md
new file mode 100644
index 000000000..e356c47f9
--- /dev/null
+++ b/docs/plans/2026-02-28-macos-case-rename-fix-design.md
@@ -0,0 +1,72 @@
+# macOS Case-Insensitive Filename Fix
+
+## Problem
+
+On macOS (case-insensitive APFS), two scripts fail to produce git-visible case
+changes when renaming PascalCase filenames to lowercase:
+
+1. **`packages/devportal-docs/src/commands/normalize-links.ts`** —
+ `lowercaseContentPaths()` uses `renameSync()` which changes the on-disk name
+ (APFS is case-preserving) but git's `core.ignorecase=true` prevents the index
+ from updating. Files stay PascalCase in git.
+
+2. **`scripts/import-release-docs.ts`** — after `rmSync` + `cp -R` with
+ correctly-lowercased tarball content, git's index still tracks the old
+ PascalCase entries. `git add` doesn't detect the case-only change.
+
+On Linux (CI), both scripts work correctly because the filesystem is
+case-sensitive.
+
+## Approach
+
+Git-aware renames in each script. Each fix is self-contained — no shared imports
+between the two scripts.
+
+## Design
+
+### Fix 1: `normalize-links.ts`
+
+Add a local `caseRename` helper in
+`packages/devportal-docs/src/commands/normalize-links.ts`.
+
+Replace the current `renameSync(old, new)` call in `lowercaseContentPaths` with
+`caseRename(dir, name, target)`.
+
+**`caseRename(dir, oldName, newName)` behavior:**
+
+1. Two-step filesystem rename: `oldName` -> `oldName.__tmp__` -> `newName`
+ (safe on case-insensitive filesystems where a direct rename is a no-op)
+2. Best-effort `execFileSync('git', ['mv', '-f', old, new])` to update the git
+ index (uses execFileSync, not execSync, to avoid shell injection)
+3. If git is unavailable or the file is untracked, fall back silently
+ (the filesystem rename already succeeded)
+
+### Fix 2: `import-release-docs.ts`
+
+Add a local `fixGitCaseMismatches(dir)` function in
+`scripts/import-release-docs.ts`. Called after content extraction (after the
+`cp -R` step, before link normalization).
+
+**`fixGitCaseMismatches(dir)` behavior:**
+
+1. Run `git ls-files
` (via execFileSync) to get tracked filenames with
+ their index case
+2. Walk the filesystem to get actual filenames
+3. For case-only mismatches, run `git mv -f `
+4. Best-effort — if git unavailable, skip silently (Linux CI doesn't need this)
+
+### Tests
+
+- Add a test for the two-step rename in `normalize-links.test.ts` verifying the
+ temp-name approach
+- Existing `lowercaseContentPaths` tests use `/tmp` dirs and continue passing
+- No test changes for `import-release-docs.ts` (no existing test suite)
+
+## Files Modified
+
+- `packages/devportal-docs/src/commands/normalize-links.ts` — add `caseRename`
+ helper, update `lowercaseContentPaths` to use it
+- `scripts/import-release-docs.ts` — add `fixGitCaseMismatches`, call it after
+ extraction
+- `packages/devportal-docs/tests/commands/normalize-links.test.ts` — add test
+ for two-step rename
diff --git a/docs/plans/2026-02-28-macos-case-rename-fix.md b/docs/plans/2026-02-28-macos-case-rename-fix.md
new file mode 100644
index 000000000..ee5f15f6c
--- /dev/null
+++ b/docs/plans/2026-02-28-macos-case-rename-fix.md
@@ -0,0 +1,234 @@
+# macOS Case-Insensitive Filename Fix — Implementation Plan
+
+> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
+
+**Goal:** Fix PascalCase-to-lowercase renames so git tracks them correctly on macOS (case-insensitive APFS with `core.ignorecase=true`).
+
+**Architecture:** Two self-contained fixes — one in `normalize-links.ts` (two-step rename + best-effort `git mv`), one in `import-release-docs.ts` (post-extract git index reconciliation). No shared imports between them. All new git interactions use `execFileSync` (not shell-based exec) to avoid injection risks.
+
+**Tech Stack:** Node.js `fs`, `child_process.execFileSync`, git CLI, vitest
+
+---
+
+### Task 1: Add `caseRename` helper and test to `normalize-links.ts`
+
+**Files:**
+- Modify: `packages/devportal-docs/src/commands/normalize-links.ts:1,35-66`
+- Modify: `packages/devportal-docs/tests/commands/normalize-links.test.ts:30-60`
+
+**Step 1: Write the failing test**
+
+In `packages/devportal-docs/tests/commands/normalize-links.test.ts`, add a new
+test inside the existing `describe('lowercaseContentPaths', ...)` block, after
+the last test (line 59):
+
+```typescript
+ it('uses two-step rename for case-only changes', () => {
+ const dir = makeTmpDir();
+ writeFileSync(join(dir, 'MyFile.md'), '# Content');
+ lowercaseContentPaths(dir);
+ // On any filesystem (case-sensitive or not), the file should be lowercase
+ const files = readdirSync(dir);
+ expect(files).toContain('myfile.md');
+ expect(files).not.toContain('MyFile.md');
+ // Content is preserved
+ expect(readFileSync(join(dir, 'myfile.md'), 'utf-8')).toBe('# Content');
+ });
+```
+
+**Step 2: Run test to verify it passes (existing behavior already handles this in /tmp)**
+
+Run: `cd packages/devportal-docs && pnpm test -- --reporter=verbose 2>&1 | head -60`
+Expected: PASS (the existing `renameSync` works in /tmp on macOS because it's
+case-preserving — the test validates the contract, not the git fix specifically)
+
+**Step 3: Add `caseRename` helper and update `lowercaseContentPaths`**
+
+In `packages/devportal-docs/src/commands/normalize-links.ts`:
+
+a) Add `execFileSync` to the imports (line 1 area):
+
+```typescript
+import { execFileSync } from 'node:child_process';
+```
+
+b) Add `caseRename` function after the `targetName` function (after line 33),
+before `lowercaseContentPaths`:
+
+```typescript
+function caseRename(dir: string, oldName: string, newName: string): void {
+ const oldPath = join(dir, oldName);
+ const newPath = join(dir, newName);
+
+ // Two-step rename via temp name — safe on case-insensitive filesystems
+ // where renameSync('FooBar.md', 'foobar.md') may not update git's index.
+ const tmpPath = join(dir, `${oldName}.__tmp__`);
+ renameSync(oldPath, tmpPath);
+ renameSync(tmpPath, newPath);
+
+ // Best-effort: update git index so the case change is tracked.
+ // Silently ignored if git is unavailable or the file is untracked.
+ try {
+ execFileSync('git', ['mv', '-f', oldPath, newPath], { stdio: 'pipe' });
+ } catch {
+ // Not in a git repo, or file not tracked — filesystem rename is enough.
+ }
+}
+```
+
+c) In `lowercaseContentPaths`, replace the `renameSync` call (line 60):
+
+Change:
+```typescript
+ renameSync(join(dir, name), join(dir, target));
+```
+
+To:
+```typescript
+ caseRename(dir, name, target);
+```
+
+**Step 4: Run tests to verify everything passes**
+
+Run: `cd packages/devportal-docs && pnpm test -- --reporter=verbose 2>&1 | head -60`
+Expected: All tests PASS (including the new one)
+
+**Step 5: Commit**
+
+```bash
+git add packages/devportal-docs/src/commands/normalize-links.ts packages/devportal-docs/tests/commands/normalize-links.test.ts
+git commit -m "fix: use two-step rename + git mv for case-insensitive FS in normalize-links
+
+On macOS (core.ignorecase=true), renameSync('FooBar.md', 'foobar.md')
+doesn't update git's index. Use a two-step rename via temp name and
+best-effort git mv -f to ensure git tracks the case change."
+```
+
+---
+
+### Task 2: Add `fixGitCaseMismatches` to `import-release-docs.ts`
+
+**Files:**
+- Modify: `scripts/import-release-docs.ts:21,104-116,325-330`
+
+**Step 1: Add `execFileSync` import**
+
+In `scripts/import-release-docs.ts`, update the `child_process` import (line 21):
+
+Change:
+```typescript
+import { execSync } from 'child_process';
+```
+
+To:
+```typescript
+import { execFileSync, execSync } from 'child_process';
+```
+
+Note: The existing `execSync` calls for `tar` and `cp` use hardcoded commands
+with quoted paths — they are retained as-is. New git interactions use
+`execFileSync` with array arguments to avoid shell injection.
+
+**Step 2: Add `fixGitCaseMismatches` function**
+
+Add after the `walkFiles` function (after line 116), before
+`applyPostImportTransforms`:
+
+```typescript
+/**
+ * Fix case-only mismatches between git's index and the filesystem.
+ *
+ * On macOS (core.ignorecase=true), after rmSync + cp -R with correctly-cased
+ * content from a tarball, git's index may still track the old PascalCase
+ * names. This function detects mismatches and uses git mv -f to reconcile.
+ */
+function fixGitCaseMismatches(dir: string): void {
+ let trackedFiles: string[];
+ try {
+ const output = execFileSync('git', ['ls-files', dir], {
+ encoding: 'utf-8',
+ stdio: ['pipe', 'pipe', 'pipe'],
+ });
+ trackedFiles = output.trim().split('\n').filter(Boolean);
+ } catch {
+ // Not in a git repo — nothing to fix.
+ return;
+ }
+
+ if (trackedFiles.length === 0) return;
+
+ // Build a map from lowercase path to actual filesystem path
+ const fsFiles = walkFiles(dir);
+ const fsMap = new Map();
+ for (const f of fsFiles) {
+ fsMap.set(f.toLowerCase(), f);
+ }
+
+ let fixCount = 0;
+ for (const tracked of trackedFiles) {
+ const fsPath = fsMap.get(tracked.toLowerCase());
+ if (!fsPath || fsPath === tracked) continue;
+
+ // Case mismatch: git tracks 'FooBar.md' but filesystem has 'foobar.md'
+ try {
+ execFileSync('git', ['mv', '-f', tracked, fsPath], { stdio: 'pipe' });
+ fixCount++;
+ } catch {
+ // File may have been deleted or is otherwise not fixable — skip.
+ }
+ }
+
+ if (fixCount > 0) {
+ console.log(` Fixed ${fixCount} case mismatch(es) in git index`);
+ }
+}
+```
+
+**Step 3: Call `fixGitCaseMismatches` after content extraction**
+
+In `downloadAndUnpack`, after step 6 (the `cp -R` block, around line 330) and
+before step 7 (sidebar.json copy), add:
+
+```typescript
+ // 6b. Fix case mismatches between git index and extracted content.
+ // On macOS, git may still track PascalCase names from a prior import.
+ fixGitCaseMismatches(destDir);
+```
+
+**Step 4: Verify the script still works**
+
+Run: `pnpm run import:release:dry-run 2>&1 | head -20`
+Expected: Dry run output listing configured variants (no errors)
+
+**Step 5: Commit**
+
+```bash
+git add scripts/import-release-docs.ts
+git commit -m "fix: reconcile git index case mismatches after tarball extraction
+
+On macOS (core.ignorecase=true), after rmSync + cp -R with lowercase
+content from a tarball, git's index may still track old PascalCase names.
+After extraction, compare git ls-files against the filesystem and use
+git mv -f to fix any case-only mismatches."
+```
+
+---
+
+### Task 3: Run full test suite and verify
+
+**Files:** None (verification only)
+
+**Step 1: Run devportal-docs tests**
+
+Run: `cd packages/devportal-docs && pnpm test -- --reporter=verbose`
+Expected: All tests PASS
+
+**Step 2: Run lint**
+
+Run: `pnpm run lint 2>&1 | tail -20`
+Expected: No new errors
+
+**Step 3: Run dry-run import to verify no regressions**
+
+Run: `pnpm run import:release:dry-run 2>&1 | head -20`
+Expected: Lists configured variants without errors
diff --git a/eslint.config.js b/eslint.config.js
index 39e7cfad4..815e5f287 100644
--- a/eslint.config.js
+++ b/eslint.config.js
@@ -12,6 +12,7 @@ export default [
'src/global.d.ts',
'imports/*',
'scripts/*',
+ 'packages/*',
],
},
eslintConfigPrettier,
diff --git a/package.json b/package.json
index c0fcd5b68..f1914dc26 100644
--- a/package.json
+++ b/package.json
@@ -22,7 +22,6 @@
"import:docs": "pnpm run import:release && IMPORT_GITHUB=true astro sync && pnpm run sidebar:generate && pnpm run lint:fix",
"import:force": "FORCE_IMPORT=true pnpm run import:docs",
"import:dry-run": "IMPORT_GITHUB=true IMPORT_DRY_RUN=true astro sync",
- "import:clear": "tsx scripts/clean-docs-import.ts",
"import:release": "npx tsx scripts/import-release-docs.ts",
"import:release:dry-run": "npx tsx scripts/import-release-docs.ts --dry-run",
"_section:sidebar": "====================== Sidebar Scripts ======================",
diff --git a/packages/devportal-docs/.gitignore b/packages/devportal-docs/.gitignore
new file mode 100644
index 000000000..e72899590
--- /dev/null
+++ b/packages/devportal-docs/.gitignore
@@ -0,0 +1,3 @@
+dist/
+node_modules/
+*.tgz
diff --git a/packages/devportal-docs/README.md b/packages/devportal-docs/README.md
new file mode 100644
index 000000000..803a5448c
--- /dev/null
+++ b/packages/devportal-docs/README.md
@@ -0,0 +1,104 @@
+# @algorandfoundation/devportal-docs
+
+CLI tools, types, and theme for publishing library docs to the Algorand Developer Portal.
+
+This package replaces the manual template scripts that library repos previously copied. Install it as a dev dependency and the devportal's CI composite action will call `docs:devportal` to prepare your Starlight site for integration.
+
+## Quick Start
+
+```bash
+# 1. Install
+npm install -D @algorandfoundation/devportal-docs
+
+# 2. Scaffold & validate (run from your docs/ directory)
+npx devportal-docs init
+
+# 3. Verify the build
+npm run docs:devportal
+```
+
+`init` will:
+- Add a `docs:devportal` script to your `package.json` (`astro build && devportal-docs build`)
+- Check that your GitHub workflow references the devportal composite action
+- Verify that Tailwind CSS v4+ is installed
+- Add theme CSS imports to `astro.config.mjs`
+
+## CLI Commands
+
+| Command | Description |
+| --- | --- |
+| `devportal-docs init` | Scaffold and validate devportal integration |
+| `devportal-docs build` | Run all build steps in sequence |
+| `devportal-docs normalize-links` | Normalize relative markdown links to absolute paths |
+| `devportal-docs build-sidebar` | Serialize `sidebar.config.ts` to `sidebar.json` |
+| `devportal-docs build-manifest` | Write `manifest.json` with site base metadata |
+
+### Options
+
+- `--base ` — Override the auto-detected site base (read from `astro.config.mjs`)
+- `--dry-run` — (init only) Show what would change without modifying files
+
+## Theme Setup
+
+The package ships brand CSS and fonts. Add them to your Starlight `customCss` in `astro.config.mjs`:
+
+```js
+import { css, fonts } from '@algorandfoundation/devportal-docs/theme';
+
+export default defineConfig({
+ integrations: [
+ starlight({
+ customCss: [css, fonts],
+ }),
+ ],
+});
+```
+
+This gives your library site the same color palette, font stack, and Starlight overrides used by the main developer portal.
+
+## Type Exports
+
+For `sidebar.config.ts`, import the sidebar types:
+
+```ts
+import type { DevportalSidebarConfig } from '@algorandfoundation/devportal-docs/types';
+
+export default {
+ sidebar: [
+ { label: 'Guides', autogenerate: { directory: 'guides' } },
+ { label: 'API Reference', autogenerate: { directory: 'api' } },
+ ],
+} satisfies DevportalSidebarConfig;
+```
+
+The `DevportalSidebarConfig` interface expects:
+- `sidebar` — Array of Starlight-compatible sidebar entries
+- `devportalFallbacks` — (optional) Serializable replacements for non-serializable entries (e.g. TypeDoc sidebar groups)
+
+## How the Build Pipeline Works
+
+1. Your library's CI calls the devportal composite action
+2. The action installs dependencies and runs `npm run docs:devportal`
+3. `docs:devportal` runs `astro build && devportal-docs build`, which:
+ - **normalize-links** — Rewrites relative markdown links to use the site's base path, lowercases content paths, and strips dead links
+ - **build-sidebar** — Dynamically imports `sidebar.config.ts`, filters non-serializable entries, and writes `dist-devportal/sidebar.json`
+ - **build-manifest** — Writes `dist-devportal/manifest.json` with the site's base path and a timestamp
+4. The composite action packages the `dist-devportal/` contents and attaches the artifact to the tagged release (special `docs-latest` tag gets updated nightly)
+
+## Directory Conventions
+
+Library docs are expected to follow this structure:
+
+```
+docs/
+├── astro.config.mjs
+├── sidebar.config.ts
+├── package.json
+└── src/content/docs/
+ ├── guides/ # Authored documentation
+ └── api/ # Generated API reference
+```
+
+## License
+
+MIT
diff --git a/packages/devportal-docs/package.json b/packages/devportal-docs/package.json
new file mode 100644
index 000000000..fde627e76
--- /dev/null
+++ b/packages/devportal-docs/package.json
@@ -0,0 +1,44 @@
+{
+ "name": "@algorandfoundation/devportal-docs",
+ "version": "0.1.0",
+ "description": "CLI tools, types, and theme for publishing library docs to the Algorand Developer Portal",
+ "type": "module",
+ "exports": {
+ ".": "./dist/cli.js",
+ "./types": "./dist/types.js",
+ "./theme": "./theme/index.js"
+ },
+ "bin": {
+ "devportal-docs": "./dist/cli.js"
+ },
+ "files": [
+ "dist",
+ "theme"
+ ],
+ "scripts": {
+ "build": "tsc",
+ "test": "vitest run",
+ "test:watch": "vitest",
+ "prepublishOnly": "tsc"
+ },
+ "devDependencies": {
+ "@types/node": "^22.0.0",
+ "typescript": "^5.9.0",
+ "vitest": "^4.0.0"
+ },
+ "dependencies": {
+ "tsx": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "license": "MIT",
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/algorandfoundation/devportal.git",
+ "directory": "packages/devportal-docs"
+ },
+ "publishConfig": {
+ "access": "public"
+ }
+}
diff --git a/packages/devportal-docs/src/cli.ts b/packages/devportal-docs/src/cli.ts
new file mode 100644
index 000000000..e4ce29f31
--- /dev/null
+++ b/packages/devportal-docs/src/cli.ts
@@ -0,0 +1,82 @@
+#!/usr/bin/env node
+
+import { resolve } from 'node:path';
+
+const COMMANDS = ['init', 'build', 'normalize-links', 'build-sidebar', 'build-manifest'] as const;
+type Command = (typeof COMMANDS)[number] | 'help';
+
+export interface ParsedCommand {
+ command: Command;
+ args: string[];
+}
+
+export function parseCommand(argv: string[]): ParsedCommand {
+ const [cmd, ...args] = argv;
+
+ if (!cmd || !COMMANDS.includes(cmd as (typeof COMMANDS)[number])) {
+ return { command: 'help', args: [] };
+ }
+
+ return { command: cmd as Command, args };
+}
+
+function printHelp(): void {
+ console.log(`
+devportal-docs — CLI for publishing library docs to the Algorand Developer Portal
+
+Commands:
+ init Scaffold and validate devportal integration
+ build Run all build steps (normalize-links → build-sidebar → build-manifest)
+ normalize-links Normalize relative markdown links to absolute paths
+ build-sidebar Serialize sidebar.config.ts → sidebar.json
+ build-manifest Write manifest.json with site base metadata
+
+Options:
+ --base Override auto-detected site base
+ --dry-run (init only) Show what would change without modifying files
+`.trim());
+}
+
+async function main(): Promise {
+ const { command, args } = parseCommand(process.argv.slice(2));
+
+ if (command === 'help') {
+ printHelp();
+ return;
+ }
+
+ const docsDir = resolve('.');
+
+ switch (command) {
+ case 'init': {
+ const { run } = await import('./commands/init.js');
+ run(args, docsDir);
+ break;
+ }
+ case 'build': {
+ const { run } = await import('./commands/build.js');
+ await run(args, docsDir);
+ break;
+ }
+ case 'normalize-links': {
+ const { run } = await import('./commands/normalize-links.js');
+ run(args, docsDir);
+ break;
+ }
+ case 'build-sidebar': {
+ const { run } = await import('./commands/build-sidebar.js');
+ await run(args, docsDir);
+ break;
+ }
+ case 'build-manifest': {
+ const { run } = await import('./commands/build-manifest.js');
+ run(args, docsDir);
+ break;
+ }
+ }
+}
+
+main().catch((err) => {
+ console.error(err);
+ process.exit(1);
+});
diff --git a/packages/devportal-docs/src/commands/build-manifest.ts b/packages/devportal-docs/src/commands/build-manifest.ts
new file mode 100644
index 000000000..f610610c3
--- /dev/null
+++ b/packages/devportal-docs/src/commands/build-manifest.ts
@@ -0,0 +1,17 @@
+import { mkdirSync, writeFileSync } from 'node:fs';
+import { dirname, join } from 'node:path';
+import { resolveBase } from '../utils/resolve-base.js';
+
+export function run(args: string[], docsDir: string): void {
+ const base = resolveBase(args, docsDir);
+ const outPath = join(docsDir, 'dist-devportal', 'manifest.json');
+
+ const manifest = {
+ base,
+ timestamp: new Date().toISOString(),
+ };
+
+ mkdirSync(dirname(outPath), { recursive: true });
+ writeFileSync(outPath, JSON.stringify(manifest, null, 2) + '\n');
+ console.log(`Wrote manifest.json (base: ${base})`);
+}
diff --git a/packages/devportal-docs/src/commands/build-sidebar.ts b/packages/devportal-docs/src/commands/build-sidebar.ts
new file mode 100644
index 000000000..a052e526c
--- /dev/null
+++ b/packages/devportal-docs/src/commands/build-sidebar.ts
@@ -0,0 +1,74 @@
+import { writeFileSync, mkdirSync } from 'node:fs';
+import { join } from 'node:path';
+
+// ---------------------------------------------------------------------------
+// Serialization helpers
+// ---------------------------------------------------------------------------
+
+export function isSerializable(entry: unknown): boolean {
+ if (typeof entry !== 'object' || entry === null) return false;
+ return (
+ 'slug' in entry ||
+ ('link' in entry && 'label' in entry) ||
+ ('items' in entry && 'label' in entry) ||
+ ('autogenerate' in entry && 'label' in entry)
+ );
+}
+
+export function filterSerializable(entries: unknown[]): unknown[] {
+ return entries
+ .filter(isSerializable)
+ .map((entry) => {
+ if (typeof entry === 'object' && entry !== null && 'items' in entry) {
+ const e = entry as Record;
+ return { ...e, items: filterSerializable(e.items as unknown[]) };
+ }
+ return entry;
+ });
+}
+
+// ---------------------------------------------------------------------------
+// Command entry point
+// ---------------------------------------------------------------------------
+
+export async function run(_args: string[], docsDir: string): Promise {
+ // Dynamic import of the library's sidebar config.
+ // Uses tsx's tsImport for .ts file support.
+ const configPath = join(docsDir, 'sidebar.config.ts');
+
+ let sidebar: unknown[];
+ let devportalFallbacks: unknown[] | undefined;
+
+ try {
+ const { tsImport } = await import('tsx/esm/api');
+ const mod = await tsImport(configPath, import.meta.url);
+ sidebar = mod.sidebar;
+ devportalFallbacks = mod.devportalFallbacks;
+ } catch (primaryErr) {
+ // Fallback: try direct import (works if file is .mjs or tsx is registered)
+ try {
+ const mod = await import(configPath);
+ sidebar = mod.sidebar;
+ devportalFallbacks = mod.devportalFallbacks;
+ } catch {
+ // Surface the original tsx error — it's more useful than the fallback error
+ throw primaryErr;
+ }
+ }
+
+ if (!Array.isArray(sidebar)) {
+ throw new Error("sidebar.config.ts must export a named 'sidebar' array");
+ }
+
+ const filtered = filterSerializable(sidebar);
+ const fallbacks = Array.isArray(devportalFallbacks) ? devportalFallbacks : [];
+ const result = [...filtered, ...fallbacks];
+
+ const outputDir = join(docsDir, 'dist-devportal');
+ mkdirSync(outputDir, { recursive: true });
+
+ const outputPath = join(outputDir, 'sidebar.json');
+ writeFileSync(outputPath, JSON.stringify(result, null, 2));
+
+ console.log(`Wrote ${result.length} sidebar entries to ${outputPath}`);
+}
diff --git a/packages/devportal-docs/src/commands/build.ts b/packages/devportal-docs/src/commands/build.ts
new file mode 100644
index 000000000..3da2ff6f6
--- /dev/null
+++ b/packages/devportal-docs/src/commands/build.ts
@@ -0,0 +1,18 @@
+import { run as normalizeLinks } from './normalize-links.js';
+import { run as buildSidebar } from './build-sidebar.js';
+import { run as buildManifest } from './build-manifest.js';
+
+export async function run(args: string[], docsDir: string): Promise {
+ console.log('=== devportal-docs build ===\n');
+
+ console.log('[1/3] Normalizing links...');
+ normalizeLinks(args, docsDir);
+
+ console.log('\n[2/3] Building sidebar...');
+ await buildSidebar(args, docsDir);
+
+ console.log('\n[3/3] Building manifest...');
+ buildManifest(args, docsDir);
+
+ console.log('\n=== Build complete ===');
+}
diff --git a/packages/devportal-docs/src/commands/init.ts b/packages/devportal-docs/src/commands/init.ts
new file mode 100644
index 000000000..c9080d7a0
--- /dev/null
+++ b/packages/devportal-docs/src/commands/init.ts
@@ -0,0 +1,266 @@
+import { existsSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
+import { dirname, join } from 'node:path';
+
+const COMPOSITE_ACTION = 'algorandfoundation/devportal/.github/actions/publish-devportal-docs';
+const DEFAULT_SCRIPT = 'astro build && devportal-docs build';
+const THEME_IMPORT = '@algorandfoundation/devportal-docs/theme';
+
+// ---------------------------------------------------------------------------
+// Helpers
+// ---------------------------------------------------------------------------
+
+export function findRepoRoot(startDir: string): string | null {
+ let dir = startDir;
+ while (true) {
+ if (existsSync(join(dir, '.git'))) return dir;
+ const parent = dirname(dir);
+ if (parent === dir) return null;
+ dir = parent;
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Check: docs:devportal script
+// ---------------------------------------------------------------------------
+
+export interface ScriptCheckResult {
+ status: 'ok' | 'added' | 'warn' | 'error';
+ message: string;
+}
+
+export function checkDocScript(docsDir: string, dryRun: boolean): ScriptCheckResult {
+ const pkgPath = join(docsDir, 'package.json');
+ if (!existsSync(pkgPath)) {
+ return { status: 'error', message: 'No package.json found in docs directory' };
+ }
+
+ let pkg: Record;
+ try {
+ pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
+ } catch (err) {
+ return { status: 'error', message: `Failed to read package.json: ${(err as Error).message}` };
+ }
+ const scripts = (pkg.scripts ?? {}) as Record;
+ const existing = scripts['docs:devportal'];
+
+ if (existing === DEFAULT_SCRIPT) {
+ return { status: 'ok', message: 'docs:devportal script is correct' };
+ }
+
+ if (existing && !existing.endsWith('devportal-docs build')) {
+ return {
+ status: 'warn',
+ message: `docs:devportal script exists but does not end with "devportal-docs build": "${existing}"`,
+ };
+ }
+
+ if (existing && existing.endsWith('devportal-docs build')) {
+ return { status: 'ok', message: 'docs:devportal script is correct (custom prefix)' };
+ }
+
+ // Script missing — add it
+ if (!dryRun) {
+ scripts['docs:devportal'] = DEFAULT_SCRIPT;
+ pkg.scripts = scripts;
+ writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n');
+ }
+
+ return {
+ status: 'added',
+ message: dryRun
+ ? `Would add docs:devportal script: "${DEFAULT_SCRIPT}"`
+ : `Added docs:devportal script: "${DEFAULT_SCRIPT}"`,
+ };
+}
+
+// ---------------------------------------------------------------------------
+// Check: workflow references composite action
+// ---------------------------------------------------------------------------
+
+export interface WorkflowCheckResult {
+ actionFound: boolean;
+ permissionsFound: boolean;
+ workflowFile: string | null;
+}
+
+export function checkWorkflow(repoRoot: string): WorkflowCheckResult {
+ const workflowDir = join(repoRoot, '.github', 'workflows');
+ if (!existsSync(workflowDir)) {
+ return { actionFound: false, permissionsFound: false, workflowFile: null };
+ }
+
+ const files = readdirSync(workflowDir).filter(
+ (f) => f.endsWith('.yml') || f.endsWith('.yaml'),
+ );
+
+ for (const file of files) {
+ const content = readFileSync(join(workflowDir, file), 'utf-8');
+ if (content.includes(COMPOSITE_ACTION)) {
+ return {
+ actionFound: true,
+ permissionsFound: content.includes('contents: write'),
+ workflowFile: file,
+ };
+ }
+ }
+
+ return { actionFound: false, permissionsFound: false, workflowFile: null };
+}
+
+// ---------------------------------------------------------------------------
+// Check: Tailwind CSS v4
+// ---------------------------------------------------------------------------
+
+export interface TailwindCheckResult {
+ status: 'ok' | 'warn' | 'error';
+ message: string;
+}
+
+export function checkTailwind(docsDir: string): TailwindCheckResult {
+ const pkgPath = join(docsDir, 'package.json');
+ if (!existsSync(pkgPath)) {
+ return { status: 'error', message: 'No package.json found — cannot check Tailwind' };
+ }
+
+ let pkg: Record;
+ try {
+ pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
+ } catch {
+ return { status: 'error', message: 'Failed to parse package.json — cannot check Tailwind' };
+ }
+ const deps = (pkg.dependencies ?? {}) as Record;
+ const devDeps = (pkg.devDependencies ?? {}) as Record;
+ const allDeps = { ...deps, ...devDeps };
+ const twRange = allDeps['tailwindcss'];
+
+ if (!twRange) {
+ return { status: 'error', message: 'tailwindcss not found in dependencies — install tailwindcss v4+' };
+ }
+
+ // Extract the first numeric version from the range (e.g. "^4.2.1" → 4, "~3.4.0" → 3)
+ const majorMatch = twRange.match(/(\d+)/);
+ if (!majorMatch) {
+ return { status: 'warn', message: `tailwindcss found but could not parse version range: "${twRange}"` };
+ }
+
+ const major = parseInt(majorMatch[1], 10);
+ if (major < 4) {
+ return { status: 'error', message: `tailwindcss v${major}.x found — theme requires v4+` };
+ }
+
+ return { status: 'ok', message: `tailwindcss v4 found (${twRange})` };
+}
+
+// ---------------------------------------------------------------------------
+// Scaffold: theme CSS in astro.config.mjs
+// ---------------------------------------------------------------------------
+
+export interface ThemeCheckResult {
+ status: 'ok' | 'added' | 'error';
+ message: string;
+}
+
+const THEME_IMPORT_LINE = `import { css, fonts } from '${THEME_IMPORT}';`;
+
+export function ensureThemeInConfig(docsDir: string, dryRun: boolean): ThemeCheckResult {
+ const configPath = join(docsDir, 'astro.config.mjs');
+ if (!existsSync(configPath)) {
+ return { status: 'error', message: 'No astro.config.mjs found' };
+ }
+
+ let content = readFileSync(configPath, 'utf-8');
+
+ // Already present
+ if (content.includes(THEME_IMPORT)) {
+ return { status: 'ok', message: 'Theme CSS already referenced in astro.config.mjs' };
+ }
+
+ if (dryRun) {
+ return { status: 'added', message: 'Would add theme CSS import and customCss entries to astro.config.mjs' };
+ }
+
+ // 1. Add import statement after the last existing import
+ const lines = content.split('\n');
+ let lastImportIndex = -1;
+ for (let i = 0; i < lines.length; i++) {
+ if (/^import\s/.test(lines[i])) lastImportIndex = i;
+ }
+ if (lastImportIndex >= 0) {
+ lines.splice(lastImportIndex + 1, 0, THEME_IMPORT_LINE);
+ } else {
+ lines.unshift(THEME_IMPORT_LINE);
+ }
+ content = lines.join('\n');
+
+ // 2. Add css, fonts to customCss array
+ const hadCustomCss = /customCss:\s*\[/.test(content);
+ content = content.replace(
+ /customCss:\s*\[/,
+ 'customCss: [\n css,\n fonts,',
+ );
+
+ writeFileSync(configPath, content);
+
+ if (!hadCustomCss) {
+ return { status: 'added', message: 'Added theme import but customCss array not found — add css, fonts to customCss manually' };
+ }
+ return { status: 'added', message: 'Added theme CSS import and customCss entries to astro.config.mjs' };
+}
+
+// ---------------------------------------------------------------------------
+// Command entry point
+// ---------------------------------------------------------------------------
+
+export function run(args: string[], docsDir: string): void {
+ const dryRun = args.includes('--dry-run');
+
+ // Verify we're in a Starlight docs directory
+ if (!existsSync(join(docsDir, 'astro.config.mjs'))) {
+ console.error('Error: No astro.config.mjs found. Run this from your library\'s docs/ directory.');
+ process.exit(1);
+ }
+
+ const repoRoot = findRepoRoot(docsDir);
+ if (!repoRoot) {
+ console.error('Error: Could not find .git directory. Are you in a git repository?');
+ process.exit(1);
+ }
+
+ console.log(dryRun ? '=== devportal-docs init (dry run) ===\n' : '=== devportal-docs init ===\n');
+
+ // 1. Check docs:devportal script
+ const scriptResult = checkDocScript(docsDir, dryRun);
+ const scriptIcon =
+ scriptResult.status === 'ok' || scriptResult.status === 'added' ? '\u2713' : '\u26A0';
+ console.log(`${scriptIcon} ${scriptResult.message}`);
+
+ // 2. Check workflow
+ const workflowResult = checkWorkflow(repoRoot);
+ if (workflowResult.actionFound) {
+ console.log(`\u2713 Composite action found in .github/workflows/${workflowResult.workflowFile}`);
+ if (workflowResult.permissionsFound) {
+ console.log('\u2713 permissions.contents: write present');
+ } else {
+ console.log('\u26A0 permissions.contents: write not found in workflow \u2014 add it for release publishing');
+ }
+ } else {
+ console.log('\u2717 No workflow references the devportal composite action');
+ console.log(' Add: uses: algorandfoundation/devportal/.github/actions/publish-devportal-docs@main');
+ }
+
+ // 3. Check Tailwind v4
+ const twResult = checkTailwind(docsDir);
+ const twIcon = twResult.status === 'ok' ? '\u2713' : twResult.status === 'warn' ? '\u26A0' : '\u2717';
+ console.log(`${twIcon} ${twResult.message}`);
+
+ // 4. Ensure theme CSS
+ const themeResult = ensureThemeInConfig(docsDir, dryRun);
+ const themeIcon = themeResult.status === 'ok' || themeResult.status === 'added' ? '\u2713' : '\u26A0';
+ console.log(`${themeIcon} ${themeResult.message}`);
+
+ // Exit code
+ const failed = !workflowResult.actionFound || scriptResult.status === 'error' || twResult.status === 'error' || themeResult.status === 'error';
+ if (failed) {
+ process.exit(1);
+ }
+}
diff --git a/scripts/library-templates/normalize-links.ts b/packages/devportal-docs/src/commands/normalize-links.ts
similarity index 50%
rename from scripts/library-templates/normalize-links.ts
rename to packages/devportal-docs/src/commands/normalize-links.ts
index 15eb868da..10d23df25 100644
--- a/scripts/library-templates/normalize-links.ts
+++ b/packages/devportal-docs/src/commands/normalize-links.ts
@@ -1,98 +1,66 @@
-#!/usr/bin/env npx tsx
-/**
- * Normalize relative markdown links to absolute paths in a Starlight docs
- * site. Handles both hand-written guides (with filesystem fallback for
- * broken links) and generated API docs (Sphinx, TypeDoc, etc.).
- *
- * Usage:
- * npx tsx normalize-links.ts
- * npx tsx normalize-links.ts --base /algokit-utils-ts
- */
-
+import { execFileSync } from 'node:child_process';
import { existsSync, readFileSync, readdirSync, renameSync, writeFileSync } from 'node:fs';
-import { dirname, join, relative, resolve } from 'node:path';
-import { fileURLToPath } from 'node:url';
+import { dirname, join, relative } from 'node:path';
+import {
+ resolveRelativePath,
+ slugExists,
+ buildFileIndex,
+ findBestMatch,
+ walkMdDir,
+ type FileIndex,
+} from '../utils/fs.js';
+import { resolveBase } from '../utils/resolve-base.js';
// ---------------------------------------------------------------------------
-// Config
+// Constants
// ---------------------------------------------------------------------------
const CONTENT_ROOT = 'src/content/docs';
-/** Directories to process, relative to CONTENT_ROOT. */
const TARGETS = [
{ dir: 'guides', useFallback: true },
{ dir: 'api', useFallback: false },
];
-/** Link prefixes to skip (not relative content links). */
const SKIP_PATTERN = /^(?:https?:\/\/|mailto:|tel:|#|\/|(?:\.\.?\/)+images\/)/;
-// ---------------------------------------------------------------------------
-// Path resolution
-// ---------------------------------------------------------------------------
-
-/** Collapse `.` and `..` components in a forward-slash path. */
-function resolveRelativePath(base: string, rel: string): string {
- const combined = base === '.' ? rel : `${base}/${rel}`;
- const parts = combined.split('/');
- const resolved: string[] = [];
- for (const part of parts) {
- if (part === '..') {
- resolved.pop();
- } else if (part !== '.' && part !== '') {
- resolved.push(part);
- }
- }
- return resolved.join('/');
-}
-
-// ---------------------------------------------------------------------------
-// Filesystem validation
-// ---------------------------------------------------------------------------
-
-/** Check if a slug maps to a real content file. */
-function slugExists(contentRoot: string, slug: string): boolean {
- if (!slug) return false;
- const base = join(contentRoot, slug);
- return (
- existsSync(`${base}.md`) ||
- existsSync(`${base}.mdx`) ||
- existsSync(join(base, 'index.md')) ||
- existsSync(join(base, 'index.mdx'))
- );
-}
-
// ---------------------------------------------------------------------------
// Path lowercasing
// ---------------------------------------------------------------------------
-/**
- * Compute the target filename: lowercase, and rename `readme` → `index`
- * so that TypeDoc module overviews become proper index pages.
- */
-function targetName(name: string): string {
+export function targetName(name: string): string {
const lower = name.toLowerCase();
return lower.replace(/^readme(\.(md|mdx))$/, 'index$1');
}
-/**
- * Recursively lowercase all file and directory names under `dir`,
- * and rename `readme.md` → `index.md`. Processes depth-first so
- * children are renamed before their parent directories.
- * Returns the number of entries renamed.
- */
-function lowercaseContentPaths(dir: string): number {
+function caseRename(dir: string, oldName: string, newName: string): void {
+ const oldPath = join(dir, oldName);
+ const newPath = join(dir, newName);
+
+ // Two-step rename via temp name — safe on case-insensitive filesystems
+ // where renameSync('FooBar.md', 'foobar.md') may not update git's index.
+ const tmpPath = join(dir, `${oldName}.__tmp__`);
+ renameSync(oldPath, tmpPath);
+ renameSync(tmpPath, newPath);
+
+ // Best-effort: update git index so the case change is tracked.
+ // Silently ignored if git is unavailable or the file is untracked.
+ try {
+ execFileSync('git', ['mv', '-f', oldPath, newPath], { stdio: 'pipe' });
+ } catch {
+ // Not in a git repo, or file not tracked — filesystem rename is enough.
+ }
+}
+
+export function lowercaseContentPaths(dir: string): number {
let count = 0;
- // Recurse into subdirectories first
for (const entry of readdirSync(dir, { withFileTypes: true })) {
if (entry.isDirectory()) {
count += lowercaseContentPaths(join(dir, entry.name));
}
}
- // Group entries by target name to detect conflicts
const entries = readdirSync(dir, { withFileTypes: true });
const groups = new Map();
for (const entry of entries) {
@@ -109,7 +77,7 @@ function lowercaseContentPaths(dir: string): number {
}
const name = names[0];
if (name !== target) {
- renameSync(join(dir, name), join(dir, target));
+ caseRename(dir, name, target);
count++;
}
}
@@ -117,76 +85,11 @@ function lowercaseContentPaths(dir: string): number {
return count;
}
-// ---------------------------------------------------------------------------
-// File index for fallback resolution
-// ---------------------------------------------------------------------------
-
-type FileIndex = Map;
-
-/** Build an index of all content files for fallback lookups. */
-function buildFileIndex(contentRoot: string): FileIndex {
- const index: FileIndex = new Map();
-
- function walk(dir: string): void {
- for (const entry of readdirSync(dir, { withFileTypes: true })) {
- const full = join(dir, entry.name);
- if (entry.isDirectory()) {
- walk(full);
- } else if (/\.mdx?$/.test(entry.name)) {
- let slug = relative(contentRoot, full).split('\\').join('/');
- slug = slug.replace(/\.mdx?$/i, '');
- slug = slug.replace(/\/index$/, '');
- const key = slug.includes('/') ? slug.split('/').pop()! : slug;
- const existing = index.get(key) ?? [];
- existing.push(slug);
- index.set(key, existing);
- }
- }
- }
-
- walk(contentRoot);
- return index;
-}
-
-/**
- * Fallback: find the best-matching slug when naive resolution fails.
- * Scores candidates by trailing path component overlap.
- */
-function findBestMatch(index: FileIndex, resolved: string): string | null {
- const target = resolved.includes('/') ? resolved.split('/').pop()! : resolved;
- if (!target) return null;
- const candidates = index.get(target);
- if (!candidates?.length) return null;
- if (candidates.length === 1) return candidates[0];
-
- const resolvedParts = resolved.split('/');
- let bestScore = -1;
- let best: string | null = null;
-
- for (const candidate of candidates) {
- const candParts = candidate.split('/');
- let score = 0;
- let ri = resolvedParts.length - 1;
- let ci = candParts.length - 1;
- while (ri >= 0 && ci >= 0 && resolvedParts[ri] === candParts[ci]) {
- score++;
- ri--;
- ci--;
- }
- if (score > bestScore) {
- bestScore = score;
- best = candidate;
- }
- }
-
- return best;
-}
-
// ---------------------------------------------------------------------------
// Link normalization
// ---------------------------------------------------------------------------
-interface NormalizeLinkOptions {
+export interface NormalizeLinkOptions {
fileDir: string;
contentRoot: string;
siteBase: string;
@@ -195,22 +98,21 @@ interface NormalizeLinkOptions {
filePath: string;
}
-interface NormalizeResult {
+export interface NormalizeResult {
content: string;
changed: boolean;
warnings: string[];
}
-function normalizeLinksInContent(
+export function normalizeLinksInContent(
content: string,
opts: NormalizeLinkOptions,
): NormalizeResult {
const warnings: string[] = [];
let changed = false;
- // Temporarily replace code blocks and inline code so the link regex
- // doesn't match patterns inside them (e.g. TemplateVar[bool]("X")).
const codeSlots: string[] = [];
+ // eslint-disable-next-line no-control-regex
const placeholder = (i: number) => `\x00CODE${i}\x00`;
let safeContent = content.replace(/```[\s\S]*?```|`[^`\n]+`/g, (m) => {
codeSlots.push(m);
@@ -254,21 +156,16 @@ function normalizeLinksInContent(
},
);
- // Restore code blocks
+ // eslint-disable-next-line no-control-regex
const result = safeContent.replace(/\x00CODE(\d+)\x00/g, (_, i) => codeSlots[Number(i)]);
-
return { content: result, changed, warnings };
}
// ---------------------------------------------------------------------------
-// readme → index link rewriting
+// readme -> index link rewriting
// ---------------------------------------------------------------------------
-/**
- * Rewrite `/readme/` link targets to `/` since readme.md files are renamed
- * to index.md. Handles both absolute and resolved links.
- */
-function rewriteReadmeLinks(content: string): string {
+export function rewriteReadmeLinks(content: string): string {
return content.replace(
/\[([^\]]*)\]\(([^)]+)\)/g,
(match, text: string, url: string) => {
@@ -284,12 +181,7 @@ function rewriteReadmeLinks(content: string): string {
// Dead-link stripping
// ---------------------------------------------------------------------------
-/**
- * Strip links whose absolute targets don't exist as content files.
- * Converts `[text](url)` → `text` for dead links so downstream
- * validators don't flag them.
- */
-function stripDeadLinks(
+export function stripDeadLinks(
content: string,
contentRoot: string,
siteBase: string,
@@ -297,10 +189,8 @@ function stripDeadLinks(
return content.replace(
/\[([^\]]*)\]\(([^)]+)\)/g,
(match, text: string, url: string) => {
- // Only check internal absolute links under the site base
if (!url.startsWith(`${siteBase}/`)) return match;
- // Extract slug: strip site base prefix and trailing slash
let slug = url.slice(siteBase.length + 1);
slug = slug.replace(/#.*$/, '');
slug = slug.replace(/\/$/, '');
@@ -325,8 +215,16 @@ function processFile(
useFallback: boolean,
fileIndex: FileIndex,
): string[] {
- const content = readFileSync(filePath, 'utf-8');
const relPath = relative(contentRoot, filePath).split('\\').join('/');
+
+ let content: string;
+ try {
+ content = readFileSync(filePath, 'utf-8');
+ } catch (err) {
+ console.error(` Error reading ${relPath}: ${(err as Error).message}`);
+ return [`Failed to read ${relPath}`];
+ }
+
const fileDir = dirname(relPath);
const result = normalizeLinksInContent(content, {
@@ -338,17 +236,17 @@ function processFile(
filePath: relPath,
});
- // Rewrite /readme/ links to match readme.md → index.md rename
let final = rewriteReadmeLinks(result.content);
-
- // Strip links to content pages that don't exist on disk
final = stripDeadLinks(final, contentRoot, siteBase);
- const changed = final !== content;
-
- if (changed) {
- writeFileSync(filePath, final, 'utf-8');
- console.log(`Updated: ${relPath}`);
+ if (final !== content) {
+ try {
+ writeFileSync(filePath, final, 'utf-8');
+ console.log(`Updated: ${relPath}`);
+ } catch (err) {
+ console.error(` Error writing ${relPath}: ${(err as Error).message}`);
+ return [...result.warnings, `Failed to write ${relPath}`];
+ }
}
return result.warnings;
@@ -368,61 +266,23 @@ function processDirectory(
}
const warnings: string[] = [];
-
- function walk(d: string): void {
- for (const entry of readdirSync(d, { withFileTypes: true })) {
- const full = join(d, entry.name);
- if (entry.isDirectory()) {
- walk(full);
- } else if (/\.mdx?$/.test(entry.name)) {
- warnings.push(...processFile(full, contentRoot, siteBase, useFallback, fileIndex));
- }
- }
- }
-
- walk(fullDir);
+ walkMdDir(fullDir, (filePath) => {
+ warnings.push(...processFile(filePath, contentRoot, siteBase, useFallback, fileIndex));
+ });
return warnings;
}
// ---------------------------------------------------------------------------
-// CLI
+// Command entry point
// ---------------------------------------------------------------------------
-function readBaseFromConfig(docsDir: string): string | null {
- const configPath = join(docsDir, 'astro.config.mjs');
- if (!existsSync(configPath)) return null;
- const content = readFileSync(configPath, 'utf-8');
- const match = content.match(/base:\s*["']([^"']+)["']/);
- if (!match) return null;
- const base = match[1].replace(/\/$/, '');
- if (!base.startsWith('/')) return null;
- return base;
-}
-
-function main(): void {
- const args = process.argv.slice(2);
- const docsDir = resolve(dirname(fileURLToPath(import.meta.url)));
+export function run(args: string[], docsDir: string): void {
+ const siteBase = resolveBase(args, docsDir);
const contentRoot = join(docsDir, CONTENT_ROOT);
- let siteBase: string | null = null;
- const baseIdx = args.indexOf('--base');
- if (baseIdx >= 0 && args[baseIdx + 1]) {
- siteBase = args[baseIdx + 1].replace(/\/$/, '');
- }
-
- if (!siteBase) {
- siteBase = readBaseFromConfig(docsDir);
- }
-
- if (!siteBase) {
- console.error('Error: Could not determine site base. Pass --base or ensure astro.config.mjs has a base field.');
- process.exit(1);
- }
-
console.log(`Site base: ${siteBase}`);
console.log(`Content root: ${contentRoot}`);
- // Lowercase file/directory names so slugs match TypeDoc's slugified links
console.log('\n==> Lowercasing content paths...');
let totalRenamed = 0;
for (const { dir } of TARGETS) {
@@ -431,9 +291,11 @@ function main(): void {
totalRenamed += lowercaseContentPaths(fullDir);
}
}
- console.log(totalRenamed > 0
- ? `Renamed ${totalRenamed} path(s) to lowercase.`
- : 'All paths already lowercase.');
+ console.log(
+ totalRenamed > 0
+ ? `Renamed ${totalRenamed} path(s) to lowercase.`
+ : 'All paths already lowercase.',
+ );
const fileIndex = buildFileIndex(contentRoot);
@@ -453,5 +315,3 @@ function main(): void {
console.log('\nAll links normalized successfully.');
}
-
-main();
diff --git a/packages/devportal-docs/src/types.ts b/packages/devportal-docs/src/types.ts
new file mode 100644
index 000000000..763168184
--- /dev/null
+++ b/packages/devportal-docs/src/types.ts
@@ -0,0 +1,27 @@
+/**
+ * Sidebar types compatible with Starlight's sidebar configuration.
+ * Defined inline to avoid build-time dependency on @astrojs/starlight
+ * (which requires Astro virtual modules not available outside a build).
+ */
+
+/** A single Starlight sidebar entry. */
+export type SidebarItem =
+ | { slug: string; label?: string; badge?: SidebarBadge; attrs?: Record }
+ | { label: string; link: string; badge?: SidebarBadge; attrs?: Record }
+ | { label: string; autogenerate: { directory: string; collapsed?: boolean }; badge?: SidebarBadge; collapsed?: boolean }
+ | { label: string; items: SidebarItem[]; badge?: SidebarBadge; collapsed?: boolean };
+
+type SidebarBadge =
+ | string
+ | { text: string; variant?: 'note' | 'danger' | 'success' | 'caution' | 'tip' | 'default'; class?: string };
+
+/** Shape of a library's sidebar.config.ts exports. */
+export interface DevportalSidebarConfig {
+ /** Primary sidebar entries. */
+ sidebar: SidebarItem[];
+ /**
+ * Serializable replacements for non-serializable entries
+ * (e.g. typeDocSidebarGroup → autogenerate fallback).
+ */
+ devportalFallbacks?: SidebarItem[];
+}
diff --git a/packages/devportal-docs/src/utils/fs.ts b/packages/devportal-docs/src/utils/fs.ts
new file mode 100644
index 000000000..cfbf37f5c
--- /dev/null
+++ b/packages/devportal-docs/src/utils/fs.ts
@@ -0,0 +1,108 @@
+import { existsSync, readdirSync } from 'node:fs';
+import { join, relative } from 'node:path';
+
+/** Collapse `.` and `..` components in a forward-slash path. */
+export function resolveRelativePath(base: string, rel: string): string {
+ const combined = base === '.' ? rel : `${base}/${rel}`;
+ const parts = combined.split('/');
+ const resolved: string[] = [];
+ for (const part of parts) {
+ if (part === '..') {
+ resolved.pop();
+ } else if (part !== '.' && part !== '') {
+ resolved.push(part);
+ }
+ }
+ return resolved.join('/');
+}
+
+/** Check if a slug maps to a real content file (.md, .mdx, or index). */
+export function slugExists(contentRoot: string, slug: string): boolean {
+ if (!slug) return false;
+ const base = join(contentRoot, slug);
+ return (
+ existsSync(`${base}.md`) ||
+ existsSync(`${base}.mdx`) ||
+ existsSync(join(base, 'index.md')) ||
+ existsSync(join(base, 'index.mdx'))
+ );
+}
+
+/** Recursively walk a directory, calling `visitor` for each .md/.mdx file. */
+export function walkMdDir(dir: string, visitor: (filePath: string) => void): void {
+ if (!existsSync(dir)) return;
+
+ function walk(d: string): void {
+ for (const entry of readdirSync(d, { withFileTypes: true })) {
+ const full = join(d, entry.name);
+ if (entry.isDirectory()) {
+ walk(full);
+ } else if (/\.mdx?$/.test(entry.name)) {
+ visitor(full);
+ }
+ }
+ }
+
+ walk(dir);
+}
+
+/** Recursively walk a directory and return all .md/.mdx file paths. */
+export function walkMdFiles(dir: string): string[] {
+ const results: string[] = [];
+ walkMdDir(dir, (filePath) => results.push(filePath));
+ return results;
+}
+
+/** File index: maps basename (last slug component) → full slug paths. */
+export type FileIndex = Map;
+
+/** Build an index of all content files for fallback lookups. */
+export function buildFileIndex(contentRoot: string): FileIndex {
+ const index: FileIndex = new Map();
+
+ walkMdDir(contentRoot, (full) => {
+ let slug = relative(contentRoot, full).split('\\').join('/');
+ slug = slug.replace(/\.mdx?$/i, '');
+ slug = slug.replace(/\/index$/, '');
+ const key = slug.includes('/') ? slug.split('/').pop()! : slug;
+ const existing = index.get(key) ?? [];
+ existing.push(slug);
+ index.set(key, existing);
+ });
+
+ return index;
+}
+
+/**
+ * Fallback: find the best-matching slug when naive resolution fails.
+ * Scores candidates by trailing path component overlap.
+ */
+export function findBestMatch(index: FileIndex, resolved: string): string | null {
+ const target = resolved.includes('/') ? resolved.split('/').pop()! : resolved;
+ if (!target) return null;
+ const candidates = index.get(target);
+ if (!candidates?.length) return null;
+ if (candidates.length === 1) return candidates[0];
+
+ const resolvedParts = resolved.split('/');
+ let bestScore = -1;
+ let best: string | null = null;
+
+ for (const candidate of candidates) {
+ const candParts = candidate.split('/');
+ let score = 0;
+ let ri = resolvedParts.length - 1;
+ let ci = candParts.length - 1;
+ while (ri >= 0 && ci >= 0 && resolvedParts[ri] === candParts[ci]) {
+ score++;
+ ri--;
+ ci--;
+ }
+ if (score > bestScore) {
+ bestScore = score;
+ best = candidate;
+ }
+ }
+
+ return best;
+}
diff --git a/packages/devportal-docs/src/utils/resolve-base.ts b/packages/devportal-docs/src/utils/resolve-base.ts
new file mode 100644
index 000000000..d7840078a
--- /dev/null
+++ b/packages/devportal-docs/src/utils/resolve-base.ts
@@ -0,0 +1,38 @@
+import { existsSync, readFileSync } from 'node:fs';
+import { join } from 'node:path';
+
+/**
+ * Read the Starlight `base` field from astro.config.mjs via regex.
+ * Returns null if the file doesn't exist, has no base, or base is relative.
+ */
+export function readBaseFromConfig(docsDir: string): string | null {
+ const configPath = join(docsDir, 'astro.config.mjs');
+ if (!existsSync(configPath)) return null;
+ const content = readFileSync(configPath, 'utf-8');
+ const match = content.match(/base:\s*["']([^"']+)["']/);
+ if (!match) return null;
+ const base = match[1].replace(/\/$/, '');
+ if (!base.startsWith('/')) return null;
+ return base;
+}
+
+/**
+ * Resolve the site base from CLI args, astro.config.mjs, or fail.
+ */
+export function resolveBase(args: string[], docsDir: string): string {
+ const baseIdx = args.indexOf('--base');
+ if (baseIdx >= 0) {
+ const value = args[baseIdx + 1];
+ if (!value || value.startsWith('-')) {
+ throw new Error('--base requires a value (e.g. --base /algokit-utils-ts)');
+ }
+ return value.replace(/\/$/, '');
+ }
+
+ const fromConfig = readBaseFromConfig(docsDir);
+ if (fromConfig) return fromConfig;
+
+ throw new Error(
+ 'Could not determine site base. Pass --base or ensure astro.config.mjs has a base field.',
+ );
+}
diff --git a/packages/devportal-docs/tests/cli.test.ts b/packages/devportal-docs/tests/cli.test.ts
new file mode 100644
index 000000000..b8accf2fb
--- /dev/null
+++ b/packages/devportal-docs/tests/cli.test.ts
@@ -0,0 +1,33 @@
+import { describe, it, expect } from 'vitest';
+import { parseCommand } from '../src/cli.js';
+
+describe('parseCommand', () => {
+ it.each(['init', 'build', 'normalize-links', 'build-sidebar', 'build-manifest'])(
+ 'parses "%s" command',
+ (cmd) => {
+ const result = parseCommand([cmd]);
+ expect(result.command).toBe(cmd);
+ },
+ );
+
+ it('passes remaining args through', () => {
+ const result = parseCommand(['build', '--base', '/test', '--verbose']);
+ expect(result.command).toBe('build');
+ expect(result.args).toEqual(['--base', '/test', '--verbose']);
+ });
+
+ it('returns help for no args', () => {
+ const result = parseCommand([]);
+ expect(result.command).toBe('help');
+ });
+
+ it('returns help for unknown command', () => {
+ const result = parseCommand(['unknown']);
+ expect(result.command).toBe('help');
+ });
+
+ it('returns help for --help flag', () => {
+ const result = parseCommand(['--help']);
+ expect(result.command).toBe('help');
+ });
+});
diff --git a/packages/devportal-docs/tests/commands/build-manifest.test.ts b/packages/devportal-docs/tests/commands/build-manifest.test.ts
new file mode 100644
index 000000000..2e93a4870
--- /dev/null
+++ b/packages/devportal-docs/tests/commands/build-manifest.test.ts
@@ -0,0 +1,55 @@
+import { describe, it, expect } from 'vitest';
+import { mkdtempSync, writeFileSync, readFileSync, existsSync } from 'node:fs';
+import { join } from 'node:path';
+import { tmpdir } from 'node:os';
+import { run } from '../../src/commands/build-manifest.js';
+
+function makeTmpDir(): string {
+ return mkdtempSync(join(tmpdir(), 'devportal-docs-test-'));
+}
+
+describe('build-manifest', () => {
+ it('writes manifest.json with base from astro.config.mjs', () => {
+ const dir = makeTmpDir();
+ writeFileSync(
+ join(dir, 'astro.config.mjs'),
+ `export default { base: '/algokit-utils-ts' };`,
+ );
+ run([], dir);
+
+ const manifest = JSON.parse(
+ readFileSync(join(dir, 'dist-devportal', 'manifest.json'), 'utf-8'),
+ );
+ expect(manifest.base).toBe('/algokit-utils-ts');
+ expect(manifest.timestamp).toBeDefined();
+ });
+
+ it('uses --base flag over config file', () => {
+ const dir = makeTmpDir();
+ writeFileSync(
+ join(dir, 'astro.config.mjs'),
+ `export default { base: '/wrong' };`,
+ );
+ run(['--base', '/correct'], dir);
+
+ const manifest = JSON.parse(
+ readFileSync(join(dir, 'dist-devportal', 'manifest.json'), 'utf-8'),
+ );
+ expect(manifest.base).toBe('/correct');
+ });
+
+ it('creates dist-devportal directory if missing', () => {
+ const dir = makeTmpDir();
+ writeFileSync(
+ join(dir, 'astro.config.mjs'),
+ `export default { base: '/test' };`,
+ );
+ run([], dir);
+ expect(existsSync(join(dir, 'dist-devportal', 'manifest.json'))).toBe(true);
+ });
+
+ it('throws when no base can be determined', () => {
+ const dir = makeTmpDir();
+ expect(() => run([], dir)).toThrow('Could not determine site base');
+ });
+});
diff --git a/packages/devportal-docs/tests/commands/build-sidebar.test.ts b/packages/devportal-docs/tests/commands/build-sidebar.test.ts
new file mode 100644
index 000000000..c3d8b86f4
--- /dev/null
+++ b/packages/devportal-docs/tests/commands/build-sidebar.test.ts
@@ -0,0 +1,68 @@
+import { describe, it, expect } from 'vitest';
+import { isSerializable, filterSerializable } from '../../src/commands/build-sidebar.js';
+
+describe('isSerializable', () => {
+ it('accepts slug entries', () => {
+ expect(isSerializable({ slug: 'guides/intro' })).toBe(true);
+ });
+
+ it('accepts link+label entries', () => {
+ expect(isSerializable({ link: '/', label: 'Home' })).toBe(true);
+ });
+
+ it('accepts autogenerate entries', () => {
+ expect(
+ isSerializable({ label: 'API', autogenerate: { directory: 'api' } }),
+ ).toBe(true);
+ });
+
+ it('accepts group entries with items', () => {
+ expect(
+ isSerializable({ label: 'Group', items: [{ slug: 'a' }] }),
+ ).toBe(true);
+ });
+
+ it('rejects non-objects', () => {
+ expect(isSerializable('string')).toBe(false);
+ });
+
+ it('rejects objects without recognized keys', () => {
+ expect(isSerializable({ unknown: true })).toBe(false);
+ });
+});
+
+describe('filterSerializable', () => {
+ it('filters out non-serializable entries', () => {
+ const input = [
+ { slug: 'a' },
+ { unknown: true },
+ { label: 'B', link: '/b' },
+ ];
+ const result = filterSerializable(input);
+ expect(result).toHaveLength(2);
+ });
+
+ it('recursively filters nested items', () => {
+ const input = [
+ {
+ label: 'Group',
+ items: [{ slug: 'a' }, { unknown: true }],
+ },
+ ];
+ const result = filterSerializable(input);
+ expect(result).toHaveLength(1);
+ expect((result[0] as { items: unknown[] }).items).toHaveLength(1);
+ });
+
+ it('preserves all fields of serializable entries', () => {
+ const input = [
+ { label: 'API', autogenerate: { directory: 'api' }, collapsed: true },
+ ];
+ const result = filterSerializable(input);
+ expect(result[0]).toEqual({
+ label: 'API',
+ autogenerate: { directory: 'api' },
+ collapsed: true,
+ });
+ });
+});
diff --git a/packages/devportal-docs/tests/commands/build.test.ts b/packages/devportal-docs/tests/commands/build.test.ts
new file mode 100644
index 000000000..cc25f2177
--- /dev/null
+++ b/packages/devportal-docs/tests/commands/build.test.ts
@@ -0,0 +1,49 @@
+import { describe, it, expect, vi } from 'vitest';
+
+// Mock the sub-commands so we don't need real filesystem structure
+vi.mock('../../src/commands/normalize-links.js', () => ({
+ run: vi.fn(),
+}));
+vi.mock('../../src/commands/build-sidebar.js', () => ({
+ run: vi.fn(),
+}));
+vi.mock('../../src/commands/build-manifest.js', () => ({
+ run: vi.fn(),
+}));
+
+import { run } from '../../src/commands/build.js';
+import { run as normalizeLinks } from '../../src/commands/normalize-links.js';
+import { run as buildSidebar } from '../../src/commands/build-sidebar.js';
+import { run as buildManifest } from '../../src/commands/build-manifest.js';
+
+describe('build', () => {
+ it('calls all three sub-commands in order', async () => {
+ const callOrder: string[] = [];
+ vi.mocked(normalizeLinks).mockImplementation(() => {
+ callOrder.push('normalize-links');
+ });
+ vi.mocked(buildSidebar).mockImplementation(async () => {
+ callOrder.push('build-sidebar');
+ });
+ vi.mocked(buildManifest).mockImplementation(() => {
+ callOrder.push('build-manifest');
+ });
+
+ await run([], '/tmp/docs');
+
+ expect(callOrder).toEqual(['normalize-links', 'build-sidebar', 'build-manifest']);
+ });
+
+ it('passes args and docsDir to sub-commands', async () => {
+ vi.mocked(normalizeLinks).mockImplementation(() => {});
+ vi.mocked(buildSidebar).mockImplementation(async () => {});
+ vi.mocked(buildManifest).mockImplementation(() => {});
+
+ const args = ['--base', '/test'];
+ await run(args, '/my/docs');
+
+ expect(normalizeLinks).toHaveBeenCalledWith(args, '/my/docs');
+ expect(buildSidebar).toHaveBeenCalledWith(args, '/my/docs');
+ expect(buildManifest).toHaveBeenCalledWith(args, '/my/docs');
+ });
+});
diff --git a/packages/devportal-docs/tests/commands/init.test.ts b/packages/devportal-docs/tests/commands/init.test.ts
new file mode 100644
index 000000000..b3663b699
--- /dev/null
+++ b/packages/devportal-docs/tests/commands/init.test.ts
@@ -0,0 +1,476 @@
+import { describe, it, expect, vi, afterEach } from 'vitest';
+import {
+ findRepoRoot,
+ checkDocScript,
+ checkWorkflow,
+ checkTailwind,
+ ensureThemeInConfig,
+ run,
+} from '../../src/commands/init.js';
+import { mkdtempSync, writeFileSync, mkdirSync, readFileSync } from 'node:fs';
+import { join } from 'node:path';
+import { tmpdir } from 'node:os';
+
+function makeTmpDir(): string {
+ return mkdtempSync(join(tmpdir(), 'devportal-docs-test-'));
+}
+
+function makeRepoWithDocs(): { repoRoot: string; docsDir: string } {
+ const repoRoot = makeTmpDir();
+ mkdirSync(join(repoRoot, '.git'));
+ const docsDir = join(repoRoot, 'docs');
+ mkdirSync(docsDir);
+ writeFileSync(join(docsDir, 'astro.config.mjs'), `export default {};`);
+ return { repoRoot, docsDir };
+}
+
+describe('findRepoRoot', () => {
+ it('finds .git directory walking up', () => {
+ const { repoRoot, docsDir } = makeRepoWithDocs();
+ expect(findRepoRoot(docsDir)).toBe(repoRoot);
+ });
+
+ it('returns null when no .git found', () => {
+ const dir = makeTmpDir();
+ // Create a nested dir so we don't walk all the way to /
+ const nested = join(dir, 'a', 'b');
+ mkdirSync(nested, { recursive: true });
+ // Note: this test may walk up to a real .git, so we test the concept
+ // For a reliable test, we'd need to mock fs, but this is sufficient
+ // to verify the function signature and basic logic
+ const result = findRepoRoot(nested);
+ // Result may find a real .git ancestor; just verify it returns string or null
+ expect(result === null || typeof result === 'string').toBe(true);
+ });
+});
+
+describe('checkDocScript', () => {
+ it('adds docs:devportal script when missing', () => {
+ const { docsDir } = makeRepoWithDocs();
+ writeFileSync(
+ join(docsDir, 'package.json'),
+ JSON.stringify({ scripts: {} }),
+ );
+
+ const result = checkDocScript(docsDir, false);
+ expect(result.status).toBe('added');
+
+ const pkg = JSON.parse(readFileSync(join(docsDir, 'package.json'), 'utf-8'));
+ expect(pkg.scripts['docs:devportal']).toBe('astro build && devportal-docs build');
+ });
+
+ it('passes when script already correct', () => {
+ const { docsDir } = makeRepoWithDocs();
+ writeFileSync(
+ join(docsDir, 'package.json'),
+ JSON.stringify({
+ scripts: { 'docs:devportal': 'astro build && devportal-docs build' },
+ }),
+ );
+
+ const result = checkDocScript(docsDir, false);
+ expect(result.status).toBe('ok');
+ });
+
+ it('accepts custom prefix ending with devportal-docs build', () => {
+ const { docsDir } = makeRepoWithDocs();
+ writeFileSync(
+ join(docsDir, 'package.json'),
+ JSON.stringify({
+ scripts: { 'docs:devportal': 'pnpm run api-build && astro build && devportal-docs build' },
+ }),
+ );
+
+ const result = checkDocScript(docsDir, false);
+ expect(result.status).toBe('ok');
+ });
+
+ it('warns when script exists but does not end with devportal-docs build', () => {
+ const { docsDir } = makeRepoWithDocs();
+ writeFileSync(
+ join(docsDir, 'package.json'),
+ JSON.stringify({
+ scripts: { 'docs:devportal': 'custom-build-only' },
+ }),
+ );
+
+ const result = checkDocScript(docsDir, false);
+ expect(result.status).toBe('warn');
+ });
+
+ it('dry-run does not modify package.json', () => {
+ const { docsDir } = makeRepoWithDocs();
+ writeFileSync(
+ join(docsDir, 'package.json'),
+ JSON.stringify({ scripts: {} }),
+ );
+
+ checkDocScript(docsDir, true);
+
+ const pkg = JSON.parse(readFileSync(join(docsDir, 'package.json'), 'utf-8'));
+ expect(pkg.scripts['docs:devportal']).toBeUndefined();
+ });
+
+ it('returns error when no package.json exists', () => {
+ const { docsDir } = makeRepoWithDocs();
+ // Don't create package.json
+ const result = checkDocScript(docsDir, false);
+ expect(result.status).toBe('error');
+ });
+
+ it('handles package.json with no scripts field', () => {
+ const { docsDir } = makeRepoWithDocs();
+ writeFileSync(join(docsDir, 'package.json'), JSON.stringify({}));
+
+ const result = checkDocScript(docsDir, false);
+ expect(result.status).toBe('added');
+
+ const pkg = JSON.parse(readFileSync(join(docsDir, 'package.json'), 'utf-8'));
+ expect(pkg.scripts['docs:devportal']).toBe('astro build && devportal-docs build');
+ });
+});
+
+describe('checkWorkflow', () => {
+ it('passes when workflow references composite action with write perms', () => {
+ const { repoRoot } = makeRepoWithDocs();
+ mkdirSync(join(repoRoot, '.github', 'workflows'), { recursive: true });
+ writeFileSync(
+ join(repoRoot, '.github', 'workflows', 'publish-docs.yml'),
+ [
+ 'permissions:',
+ ' contents: write',
+ 'steps:',
+ ' - uses: algorandfoundation/devportal/.github/actions/publish-devportal-docs@main',
+ ].join('\n'),
+ );
+
+ const result = checkWorkflow(repoRoot);
+ expect(result.actionFound).toBe(true);
+ expect(result.permissionsFound).toBe(true);
+ expect(result.workflowFile).toBe('publish-docs.yml');
+ });
+
+ it('fails when no workflow references the action', () => {
+ const { repoRoot } = makeRepoWithDocs();
+ mkdirSync(join(repoRoot, '.github', 'workflows'), { recursive: true });
+ writeFileSync(
+ join(repoRoot, '.github', 'workflows', 'ci.yml'),
+ 'name: CI\non: push',
+ );
+
+ const result = checkWorkflow(repoRoot);
+ expect(result.actionFound).toBe(false);
+ });
+
+ it('warns when action found but no write permission', () => {
+ const { repoRoot } = makeRepoWithDocs();
+ mkdirSync(join(repoRoot, '.github', 'workflows'), { recursive: true });
+ writeFileSync(
+ join(repoRoot, '.github', 'workflows', 'publish-docs.yml'),
+ 'steps:\n - uses: algorandfoundation/devportal/.github/actions/publish-devportal-docs@main',
+ );
+
+ const result = checkWorkflow(repoRoot);
+ expect(result.actionFound).toBe(true);
+ expect(result.permissionsFound).toBe(false);
+ });
+
+ it('handles missing .github/workflows directory', () => {
+ const repoRoot = makeTmpDir();
+ mkdirSync(join(repoRoot, '.git'));
+
+ const result = checkWorkflow(repoRoot);
+ expect(result.actionFound).toBe(false);
+ });
+
+ it('scans .yaml extension too', () => {
+ const { repoRoot } = makeRepoWithDocs();
+ mkdirSync(join(repoRoot, '.github', 'workflows'), { recursive: true });
+ writeFileSync(
+ join(repoRoot, '.github', 'workflows', 'publish-docs.yaml'),
+ [
+ 'permissions:',
+ ' contents: write',
+ 'steps:',
+ ' - uses: algorandfoundation/devportal/.github/actions/publish-devportal-docs@main',
+ ].join('\n'),
+ );
+
+ const result = checkWorkflow(repoRoot);
+ expect(result.actionFound).toBe(true);
+ });
+});
+
+describe('checkTailwind', () => {
+ it('passes when tailwindcss v4+ is in dependencies', () => {
+ const { docsDir } = makeRepoWithDocs();
+ writeFileSync(
+ join(docsDir, 'package.json'),
+ JSON.stringify({ dependencies: { tailwindcss: '^4.2.1' } }),
+ );
+
+ const result = checkTailwind(docsDir);
+ expect(result.status).toBe('ok');
+ expect(result.message).toContain('v4');
+ });
+
+ it('passes when tailwindcss v4+ is in devDependencies', () => {
+ const { docsDir } = makeRepoWithDocs();
+ writeFileSync(
+ join(docsDir, 'package.json'),
+ JSON.stringify({ devDependencies: { tailwindcss: '~4.0.0' } }),
+ );
+
+ const result = checkTailwind(docsDir);
+ expect(result.status).toBe('ok');
+ });
+
+ it('errors when tailwindcss is v3', () => {
+ const { docsDir } = makeRepoWithDocs();
+ writeFileSync(
+ join(docsDir, 'package.json'),
+ JSON.stringify({ dependencies: { tailwindcss: '^3.4.0' } }),
+ );
+
+ const result = checkTailwind(docsDir);
+ expect(result.status).toBe('error');
+ expect(result.message).toContain('v3');
+ expect(result.message).toContain('v4+');
+ });
+
+ it('errors when tailwindcss is not installed', () => {
+ const { docsDir } = makeRepoWithDocs();
+ writeFileSync(
+ join(docsDir, 'package.json'),
+ JSON.stringify({ dependencies: { astro: '^5.0.0' } }),
+ );
+
+ const result = checkTailwind(docsDir);
+ expect(result.status).toBe('error');
+ expect(result.message).toContain('not found');
+ });
+
+ it('warns when version range is unparseable', () => {
+ const { docsDir } = makeRepoWithDocs();
+ writeFileSync(
+ join(docsDir, 'package.json'),
+ JSON.stringify({ dependencies: { tailwindcss: 'latest' } }),
+ );
+
+ const result = checkTailwind(docsDir);
+ expect(result.status).toBe('warn');
+ });
+});
+
+describe('ensureThemeInConfig', () => {
+ it('reports ok when theme already referenced', () => {
+ const { docsDir } = makeRepoWithDocs();
+ writeFileSync(
+ join(docsDir, 'astro.config.mjs'),
+ `import { css, fonts } from '@algorandfoundation/devportal-docs/theme';\nexport default {};`,
+ );
+
+ const result = ensureThemeInConfig(docsDir, false);
+ expect(result.status).toBe('ok');
+ });
+
+ it('adds import after last existing import', () => {
+ const { docsDir } = makeRepoWithDocs();
+ writeFileSync(
+ join(docsDir, 'astro.config.mjs'),
+ [
+ "import starlight from '@astrojs/starlight';",
+ "import { defineConfig } from 'astro/config';",
+ '',
+ 'export default defineConfig({',
+ ' integrations: [starlight({ customCss: [] })],',
+ '});',
+ ].join('\n'),
+ );
+
+ const result = ensureThemeInConfig(docsDir, false);
+ expect(result.status).toBe('added');
+
+ const content = readFileSync(join(docsDir, 'astro.config.mjs'), 'utf-8');
+ expect(content).toContain("import { css, fonts } from '@algorandfoundation/devportal-docs/theme';");
+
+ // Import should appear after the defineConfig import
+ const lines = content.split('\n');
+ const starlightLine = lines.findIndex((l) => l.includes('@astrojs/starlight'));
+ const defineConfigLine = lines.findIndex((l) => l.includes('astro/config'));
+ const themeLine = lines.findIndex((l) => l.includes('devportal-docs/theme'));
+ expect(themeLine).toBeGreaterThan(defineConfigLine);
+ expect(themeLine).toBeGreaterThan(starlightLine);
+ });
+
+ it('adds css and fonts to customCss array', () => {
+ const { docsDir } = makeRepoWithDocs();
+ writeFileSync(
+ join(docsDir, 'astro.config.mjs'),
+ [
+ "import starlight from '@astrojs/starlight';",
+ "import { defineConfig } from 'astro/config';",
+ '',
+ 'export default defineConfig({',
+ ' integrations: [starlight({',
+ " customCss: ['./src/styles/global.css'],",
+ ' })],',
+ '});',
+ ].join('\n'),
+ );
+
+ ensureThemeInConfig(docsDir, false);
+ const content = readFileSync(join(docsDir, 'astro.config.mjs'), 'utf-8');
+ expect(content).toContain('css,');
+ expect(content).toContain('fonts,');
+ // css should appear before the existing entry
+ const cssPos = content.indexOf('css,');
+ const globalPos = content.indexOf('./src/styles/global.css');
+ expect(cssPos).toBeLessThan(globalPos);
+ });
+
+ it('dry-run does not modify astro.config.mjs', () => {
+ const { docsDir } = makeRepoWithDocs();
+ const original = "import { defineConfig } from 'astro/config';\nexport default defineConfig({ integrations: [starlight({ customCss: [] })] });";
+ writeFileSync(join(docsDir, 'astro.config.mjs'), original);
+
+ const result = ensureThemeInConfig(docsDir, true);
+ expect(result.status).toBe('added');
+
+ const content = readFileSync(join(docsDir, 'astro.config.mjs'), 'utf-8');
+ expect(content).toBe(original);
+ });
+
+ it('returns error when no astro.config.mjs', () => {
+ const dir = makeTmpDir();
+ const result = ensureThemeInConfig(dir, false);
+ expect(result.status).toBe('error');
+ });
+
+ it('adds import at top when no existing imports', () => {
+ const { docsDir } = makeRepoWithDocs();
+ writeFileSync(
+ join(docsDir, 'astro.config.mjs'),
+ 'export default { integrations: [starlight({ customCss: [] })] };',
+ );
+
+ ensureThemeInConfig(docsDir, false);
+ const content = readFileSync(join(docsDir, 'astro.config.mjs'), 'utf-8');
+ expect(content.startsWith("import { css, fonts }")).toBe(true);
+ });
+});
+
+describe('run (integration)', () => {
+ const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
+ const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
+
+ afterEach(() => {
+ logSpy.mockClear();
+ warnSpy.mockClear();
+ errorSpy.mockClear();
+ });
+
+ // Vitest intercepts process.exit and throws — we catch the throw
+ // and verify the error path was reached via console output.
+ function callRun(args: string[], docsDir: string): boolean {
+ try {
+ run(args, docsDir);
+ return true; // completed without exit
+ } catch {
+ return false; // process.exit was called (vitest threw)
+ }
+ }
+
+ function makeFullSetup(): string {
+ const { repoRoot, docsDir } = makeRepoWithDocs();
+ writeFileSync(
+ join(docsDir, 'package.json'),
+ JSON.stringify({
+ scripts: { 'docs:devportal': 'astro build && devportal-docs build' },
+ devDependencies: { tailwindcss: '^4.2.1' },
+ }),
+ );
+ writeFileSync(
+ join(docsDir, 'astro.config.mjs'),
+ [
+ "import { css, fonts } from '@algorandfoundation/devportal-docs/theme';",
+ "import { defineConfig } from 'astro/config';",
+ 'export default defineConfig({ integrations: [starlight({ customCss: [css, fonts] })] });',
+ ].join('\n'),
+ );
+ mkdirSync(join(repoRoot, '.github', 'workflows'), { recursive: true });
+ writeFileSync(
+ join(repoRoot, '.github', 'workflows', 'publish-docs.yml'),
+ 'permissions:\n contents: write\nsteps:\n - uses: algorandfoundation/devportal/.github/actions/publish-devportal-docs@main',
+ );
+ return docsDir;
+ }
+
+ it('succeeds when all checks pass', () => {
+ const docsDir = makeFullSetup();
+ expect(callRun([], docsDir)).toBe(true);
+ });
+
+ it('exits when no astro.config.mjs exists', () => {
+ const dir = makeTmpDir();
+ expect(callRun([], dir)).toBe(false);
+ });
+
+ it('exits when no workflow references the composite action', () => {
+ const { docsDir } = makeRepoWithDocs();
+ writeFileSync(
+ join(docsDir, 'package.json'),
+ JSON.stringify({
+ scripts: { 'docs:devportal': 'astro build && devportal-docs build' },
+ devDependencies: { tailwindcss: '^4.2.1' },
+ }),
+ );
+ writeFileSync(
+ join(docsDir, 'astro.config.mjs'),
+ "import { css, fonts } from '@algorandfoundation/devportal-docs/theme';\nexport default {};",
+ );
+
+ expect(callRun([], docsDir)).toBe(false);
+ });
+
+ it('exits when tailwind is missing', () => {
+ const { repoRoot, docsDir } = makeRepoWithDocs();
+ writeFileSync(
+ join(docsDir, 'package.json'),
+ JSON.stringify({
+ scripts: { 'docs:devportal': 'astro build && devportal-docs build' },
+ dependencies: {},
+ }),
+ );
+ writeFileSync(
+ join(docsDir, 'astro.config.mjs'),
+ "import { css, fonts } from '@algorandfoundation/devportal-docs/theme';\nexport default {};",
+ );
+ mkdirSync(join(repoRoot, '.github', 'workflows'), { recursive: true });
+ writeFileSync(
+ join(repoRoot, '.github', 'workflows', 'publish.yml'),
+ 'permissions:\n contents: write\nsteps:\n - uses: algorandfoundation/devportal/.github/actions/publish-devportal-docs@main',
+ );
+
+ expect(callRun([], docsDir)).toBe(false);
+ });
+
+ it('dry-run flag prevents file modifications', () => {
+ const { repoRoot, docsDir } = makeRepoWithDocs();
+ writeFileSync(
+ join(docsDir, 'package.json'),
+ JSON.stringify({ scripts: {}, devDependencies: { tailwindcss: '^4.2.1' } }),
+ );
+ mkdirSync(join(repoRoot, '.github', 'workflows'), { recursive: true });
+ writeFileSync(
+ join(repoRoot, '.github', 'workflows', 'publish.yml'),
+ 'permissions:\n contents: write\nsteps:\n - uses: algorandfoundation/devportal/.github/actions/publish-devportal-docs@main',
+ );
+
+ callRun(['--dry-run'], docsDir);
+
+ const pkg = JSON.parse(readFileSync(join(docsDir, 'package.json'), 'utf-8'));
+ expect(pkg.scripts['docs:devportal']).toBeUndefined();
+ });
+});
diff --git a/packages/devportal-docs/tests/commands/normalize-links.test.ts b/packages/devportal-docs/tests/commands/normalize-links.test.ts
new file mode 100644
index 000000000..d4eaa6b6c
--- /dev/null
+++ b/packages/devportal-docs/tests/commands/normalize-links.test.ts
@@ -0,0 +1,286 @@
+import { describe, it, expect, vi, afterEach } from 'vitest';
+import {
+ normalizeLinksInContent,
+ lowercaseContentPaths,
+ rewriteReadmeLinks,
+ stripDeadLinks,
+ targetName,
+ run,
+} from '../../src/commands/normalize-links.js';
+import { buildFileIndex } from '../../src/utils/fs.js';
+import { mkdtempSync, writeFileSync, mkdirSync, readdirSync, readFileSync } from 'node:fs';
+import { join } from 'node:path';
+import { tmpdir } from 'node:os';
+
+function makeTmpDir(): string {
+ return mkdtempSync(join(tmpdir(), 'devportal-docs-test-'));
+}
+
+describe('targetName', () => {
+ it('lowercases filenames', () => {
+ expect(targetName('AlgorandClient.md')).toBe('algorandclient.md');
+ });
+
+ it('renames readme.md to index.md', () => {
+ expect(targetName('README.md')).toBe('index.md');
+ });
+
+});
+
+describe('lowercaseContentPaths', () => {
+ it('renames PascalCase files to lowercase', () => {
+ const dir = makeTmpDir();
+ writeFileSync(join(dir, 'AlgorandClient.md'), '# Test');
+ const count = lowercaseContentPaths(dir);
+ expect(count).toBe(1);
+ expect(readdirSync(dir)).toContain('algorandclient.md');
+ });
+
+ it('renames README.md to index.md', () => {
+ const dir = makeTmpDir();
+ writeFileSync(join(dir, 'README.md'), '# Readme');
+ lowercaseContentPaths(dir);
+ expect(readdirSync(dir)).toContain('index.md');
+ });
+
+ it('handles nested directories depth-first', () => {
+ const dir = makeTmpDir();
+ mkdirSync(join(dir, 'SubDir'));
+ writeFileSync(join(dir, 'SubDir', 'File.md'), '');
+ lowercaseContentPaths(dir);
+ expect(readdirSync(dir)).toContain('subdir');
+ expect(readdirSync(join(dir, 'subdir'))).toContain('file.md');
+ });
+
+ it('returns 0 when all paths already lowercase', () => {
+ const dir = makeTmpDir();
+ writeFileSync(join(dir, 'already-lower.md'), '');
+ expect(lowercaseContentPaths(dir)).toBe(0);
+ });
+
+ it('uses two-step rename for case-only changes', () => {
+ const dir = makeTmpDir();
+ writeFileSync(join(dir, 'MyFile.md'), '# Content');
+ lowercaseContentPaths(dir);
+ // On any filesystem (case-sensitive or not), the file should be lowercase
+ const files = readdirSync(dir);
+ expect(files).toContain('myfile.md');
+ expect(files).not.toContain('MyFile.md');
+ // Content is preserved
+ expect(readFileSync(join(dir, 'myfile.md'), 'utf-8')).toBe('# Content');
+ });
+});
+
+describe('normalizeLinksInContent', () => {
+ it('converts relative link to absolute', () => {
+ const dir = makeTmpDir();
+ mkdirSync(join(dir, 'guides'), { recursive: true });
+ writeFileSync(join(dir, 'guides', 'intro.md'), '');
+ const fileIndex = buildFileIndex(dir);
+
+ const result = normalizeLinksInContent('[link](intro.md)', {
+ fileDir: 'guides',
+ contentRoot: dir,
+ siteBase: '/algokit-utils-ts',
+ useFallback: false,
+ fileIndex,
+ filePath: 'guides/page.md',
+ });
+
+ expect(result.content).toBe('[link](/algokit-utils-ts/guides/intro/)');
+ expect(result.changed).toBe(true);
+ });
+
+ it('skips absolute URLs', () => {
+ const result = normalizeLinksInContent('[link](https://example.com)', {
+ fileDir: 'guides',
+ contentRoot: '/tmp',
+ siteBase: '/base',
+ useFallback: false,
+ fileIndex: new Map(),
+ filePath: 'guides/page.md',
+ });
+ expect(result.content).toBe('[link](https://example.com)');
+ expect(result.changed).toBe(false);
+ });
+
+ it('skips links inside code blocks', () => {
+ const content = '```\n[link](relative.md)\n```';
+ const result = normalizeLinksInContent(content, {
+ fileDir: '.',
+ contentRoot: '/tmp',
+ siteBase: '/base',
+ useFallback: false,
+ fileIndex: new Map(),
+ filePath: 'page.md',
+ });
+ expect(result.content).toBe(content);
+ });
+
+ it('preserves anchor fragments', () => {
+ const dir = makeTmpDir();
+ mkdirSync(join(dir, 'guides'), { recursive: true });
+ writeFileSync(join(dir, 'guides', 'intro.md'), '');
+ const fileIndex = buildFileIndex(dir);
+
+ const result = normalizeLinksInContent('[link](intro.md#section)', {
+ fileDir: 'guides',
+ contentRoot: dir,
+ siteBase: '/base',
+ useFallback: false,
+ fileIndex,
+ filePath: 'guides/page.md',
+ });
+ expect(result.content).toBe('[link](/base/guides/intro/#section)');
+ });
+
+ it('skips already-absolute links (starting with /)', () => {
+ const result = normalizeLinksInContent('[link](/absolute/path)', {
+ fileDir: 'guides',
+ contentRoot: '/tmp',
+ siteBase: '/base',
+ useFallback: false,
+ fileIndex: new Map(),
+ filePath: 'guides/page.md',
+ });
+ expect(result.content).toBe('[link](/absolute/path)');
+ expect(result.changed).toBe(false);
+ });
+
+ it('uses fallback resolution when enabled', () => {
+ const dir = makeTmpDir();
+ mkdirSync(join(dir, 'guides', 'deep'), { recursive: true });
+ writeFileSync(join(dir, 'guides', 'deep', 'target.md'), '');
+ const fileIndex = buildFileIndex(dir);
+
+ const result = normalizeLinksInContent('[link](target.md)', {
+ fileDir: 'other',
+ contentRoot: dir,
+ siteBase: '/base',
+ useFallback: true,
+ fileIndex,
+ filePath: 'other/page.md',
+ });
+ expect(result.content).toBe('[link](/base/guides/deep/target/)');
+ });
+});
+
+describe('rewriteReadmeLinks', () => {
+ it('rewrites /readme/ to /', () => {
+ expect(rewriteReadmeLinks('[link](/base/readme/)')).toBe('[link](/base/)');
+ });
+
+ it('rewrites mid-path /readme/', () => {
+ expect(rewriteReadmeLinks('[link](/base/module/readme/)')).toBe(
+ '[link](/base/module/)',
+ );
+ });
+
+ it('leaves non-readme links alone', () => {
+ expect(rewriteReadmeLinks('[link](/base/page/)')).toBe('[link](/base/page/)');
+ });
+});
+
+describe('stripDeadLinks', () => {
+ it('strips links to nonexistent content', () => {
+ const dir = makeTmpDir();
+ const result = stripDeadLinks(
+ '[text](/base/missing/)',
+ dir,
+ '/base',
+ );
+ expect(result).toBe('text');
+ });
+
+ it('preserves links to existing content', () => {
+ const dir = makeTmpDir();
+ writeFileSync(join(dir, 'page.md'), '');
+ const result = stripDeadLinks('[text](/base/page/)', dir, '/base');
+ expect(result).toBe('[text](/base/page/)');
+ });
+
+ it('ignores external links', () => {
+ const dir = makeTmpDir();
+ const result = stripDeadLinks(
+ '[text](https://example.com)',
+ dir,
+ '/base',
+ );
+ expect(result).toBe('[text](https://example.com)');
+ });
+
+ it('ignores links outside the site base', () => {
+ const dir = makeTmpDir();
+ const result = stripDeadLinks(
+ '[text](/other-base/page/)',
+ dir,
+ '/base',
+ );
+ expect(result).toBe('[text](/other-base/page/)');
+ });
+});
+
+describe('run (integration)', () => {
+ const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
+ const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
+
+ afterEach(() => {
+ logSpy.mockClear();
+ warnSpy.mockClear();
+ errorSpy.mockClear();
+ });
+
+ function callRun(args: string[], docsDir: string): boolean {
+ try {
+ run(args, docsDir);
+ return true;
+ } catch {
+ return false;
+ }
+ }
+
+ function makeContentDir(): string {
+ const docsDir = makeTmpDir();
+ writeFileSync(
+ join(docsDir, 'astro.config.mjs'),
+ `export default { base: '/test-lib' };`,
+ );
+ const contentRoot = join(docsDir, 'src', 'content', 'docs');
+ mkdirSync(join(contentRoot, 'guides'), { recursive: true });
+ return docsDir;
+ }
+
+ it('normalizes links and lowercases paths in content files', () => {
+ const docsDir = makeContentDir();
+ const contentRoot = join(docsDir, 'src', 'content', 'docs');
+
+ writeFileSync(join(contentRoot, 'guides', 'AlgorandClient.md'), '# Client');
+ writeFileSync(join(contentRoot, 'guides', 'intro.md'), '# Intro');
+ writeFileSync(
+ join(contentRoot, 'guides', 'getting-started.md'),
+ 'See [intro](intro.md) for details.',
+ );
+
+ expect(callRun([], docsDir)).toBe(true);
+
+ // Lowercasing worked
+ const files = readdirSync(join(contentRoot, 'guides'));
+ expect(files).toContain('algorandclient.md');
+ // Link normalization worked
+ const updated = readFileSync(join(contentRoot, 'guides', 'getting-started.md'), 'utf-8');
+ expect(updated).toContain('/test-lib/guides/intro/');
+ });
+
+ it('exits on unresolvable links', () => {
+ const docsDir = makeContentDir();
+ const contentRoot = join(docsDir, 'src', 'content', 'docs');
+
+ writeFileSync(
+ join(contentRoot, 'guides', 'page.md'),
+ 'See [missing](totally-nonexistent-page.md) for info.',
+ );
+
+ expect(callRun([], docsDir)).toBe(false);
+ });
+});
diff --git a/packages/devportal-docs/tests/theme.test.ts b/packages/devportal-docs/tests/theme.test.ts
new file mode 100644
index 000000000..9931e1f76
--- /dev/null
+++ b/packages/devportal-docs/tests/theme.test.ts
@@ -0,0 +1,17 @@
+import { describe, it, expect } from 'vitest';
+import { existsSync } from 'node:fs';
+import { css, fonts } from '../theme/index.js';
+
+describe('theme exports', () => {
+ it('exports css path that exists', () => {
+ expect(typeof css).toBe('string');
+ expect(css.endsWith('theme.css')).toBe(true);
+ expect(existsSync(css)).toBe(true);
+ });
+
+ it('exports fonts path that exists', () => {
+ expect(typeof fonts).toBe('string');
+ expect(fonts.endsWith('fonts.css')).toBe(true);
+ expect(existsSync(fonts)).toBe(true);
+ });
+});
diff --git a/packages/devportal-docs/tests/types.test.ts b/packages/devportal-docs/tests/types.test.ts
new file mode 100644
index 000000000..70b6c3015
--- /dev/null
+++ b/packages/devportal-docs/tests/types.test.ts
@@ -0,0 +1,30 @@
+import { describe, it, expectTypeOf } from 'vitest';
+import type { DevportalSidebarConfig, SidebarItem } from '../src/types.js';
+
+describe('DevportalSidebarConfig', () => {
+ it('accepts a sidebar array with autogenerate entries', () => {
+ const config: DevportalSidebarConfig = {
+ sidebar: [{ label: 'Guides', autogenerate: { directory: 'guides' } }],
+ };
+ expectTypeOf(config.sidebar).toBeArray();
+ });
+
+ it('accepts optional devportalFallbacks', () => {
+ const config: DevportalSidebarConfig = {
+ sidebar: [],
+ devportalFallbacks: [
+ { label: 'API', autogenerate: { directory: 'api' } },
+ ],
+ };
+ expectTypeOf(config.devportalFallbacks).toEqualTypeOf<
+ SidebarItem[] | undefined
+ >();
+ });
+
+ it('accepts link entries', () => {
+ const config: DevportalSidebarConfig = {
+ sidebar: [{ label: 'Home', link: '/' }],
+ };
+ expectTypeOf(config.sidebar[0]).toMatchTypeOf();
+ });
+});
diff --git a/packages/devportal-docs/tests/utils/fs.test.ts b/packages/devportal-docs/tests/utils/fs.test.ts
new file mode 100644
index 000000000..0e4995623
--- /dev/null
+++ b/packages/devportal-docs/tests/utils/fs.test.ts
@@ -0,0 +1,122 @@
+import { describe, it, expect } from 'vitest';
+import { slugExists, walkMdFiles, resolveRelativePath, buildFileIndex, findBestMatch } from '../../src/utils/fs.js';
+import { mkdtempSync, writeFileSync, mkdirSync } from 'node:fs';
+import { join } from 'node:path';
+import { tmpdir } from 'node:os';
+
+function makeTmpDir(): string {
+ return mkdtempSync(join(tmpdir(), 'devportal-docs-test-'));
+}
+
+describe('resolveRelativePath', () => {
+ it('resolves simple relative path', () => {
+ expect(resolveRelativePath('guides', 'intro')).toBe('guides/intro');
+ });
+
+ it('resolves .. components', () => {
+ expect(resolveRelativePath('guides/advanced', '../intro')).toBe('guides/intro');
+ });
+
+ it('resolves from current directory (.)', () => {
+ expect(resolveRelativePath('.', 'guides/intro')).toBe('guides/intro');
+ });
+
+ it('collapses multiple .. components', () => {
+ expect(resolveRelativePath('a/b/c', '../../d')).toBe('a/d');
+ });
+});
+
+describe('slugExists', () => {
+ it('finds .md file', () => {
+ const dir = makeTmpDir();
+ writeFileSync(join(dir, 'intro.md'), '# Intro');
+ expect(slugExists(dir, 'intro')).toBe(true);
+ });
+
+ it('finds .mdx file', () => {
+ const dir = makeTmpDir();
+ writeFileSync(join(dir, 'intro.mdx'), '# Intro');
+ expect(slugExists(dir, 'intro')).toBe(true);
+ });
+
+ it('finds index.md in directory', () => {
+ const dir = makeTmpDir();
+ mkdirSync(join(dir, 'guides'));
+ writeFileSync(join(dir, 'guides', 'index.md'), '# Guides');
+ expect(slugExists(dir, 'guides')).toBe(true);
+ });
+
+ it('returns false for nonexistent slug', () => {
+ const dir = makeTmpDir();
+ expect(slugExists(dir, 'nope')).toBe(false);
+ });
+
+ it('returns false for empty slug', () => {
+ const dir = makeTmpDir();
+ expect(slugExists(dir, '')).toBe(false);
+ });
+});
+
+describe('walkMdFiles', () => {
+ it('finds .md and .mdx files recursively', () => {
+ const dir = makeTmpDir();
+ mkdirSync(join(dir, 'sub'), { recursive: true });
+ writeFileSync(join(dir, 'a.md'), '');
+ writeFileSync(join(dir, 'b.mdx'), '');
+ writeFileSync(join(dir, 'sub', 'c.md'), '');
+ writeFileSync(join(dir, 'skip.txt'), '');
+ const files = walkMdFiles(dir);
+ expect(files).toHaveLength(3);
+ expect(files.every((f) => /\.mdx?$/.test(f))).toBe(true);
+ });
+
+ it('returns empty array for nonexistent directory', () => {
+ expect(walkMdFiles('/nonexistent')).toEqual([]);
+ });
+});
+
+describe('buildFileIndex', () => {
+ it('indexes files by basename', () => {
+ const dir = makeTmpDir();
+ mkdirSync(join(dir, 'guides'), { recursive: true });
+ writeFileSync(join(dir, 'guides', 'intro.md'), '');
+ writeFileSync(join(dir, 'guides', 'setup.mdx'), '');
+ const index = buildFileIndex(dir);
+ expect(index.get('intro')).toEqual(['guides/intro']);
+ expect(index.get('setup')).toEqual(['guides/setup']);
+ });
+
+ it('groups duplicates under same key', () => {
+ const dir = makeTmpDir();
+ mkdirSync(join(dir, 'a'), { recursive: true });
+ mkdirSync(join(dir, 'b'), { recursive: true });
+ writeFileSync(join(dir, 'a', 'page.md'), '');
+ writeFileSync(join(dir, 'b', 'page.md'), '');
+ const index = buildFileIndex(dir);
+ expect(index.get('page')).toHaveLength(2);
+ });
+});
+
+describe('findBestMatch', () => {
+ it('returns single candidate directly', () => {
+ const index = new Map([['intro', ['guides/intro']]]);
+ expect(findBestMatch(index, 'some/intro')).toBe('guides/intro');
+ });
+
+ it('scores by trailing path overlap', () => {
+ const index = new Map([
+ ['page', ['a/b/page', 'x/y/page']],
+ ]);
+ expect(findBestMatch(index, 'x/y/page')).toBe('x/y/page');
+ });
+
+ it('returns null for no matches', () => {
+ const index = new Map();
+ expect(findBestMatch(index, 'missing')).toBeNull();
+ });
+
+ it('returns null for empty target', () => {
+ const index = new Map();
+ expect(findBestMatch(index, '')).toBeNull();
+ });
+});
diff --git a/packages/devportal-docs/tests/utils/resolve-base.test.ts b/packages/devportal-docs/tests/utils/resolve-base.test.ts
new file mode 100644
index 000000000..f3415bd4e
--- /dev/null
+++ b/packages/devportal-docs/tests/utils/resolve-base.test.ts
@@ -0,0 +1,77 @@
+import { describe, it, expect } from 'vitest';
+import { readBaseFromConfig, resolveBase } from '../../src/utils/resolve-base.js';
+import { mkdtempSync, writeFileSync } from 'node:fs';
+import { join } from 'node:path';
+import { tmpdir } from 'node:os';
+
+describe('readBaseFromConfig', () => {
+ function makeTmpDir(): string {
+ return mkdtempSync(join(tmpdir(), 'devportal-docs-test-'));
+ }
+
+ it('reads base from astro.config.mjs', () => {
+ const dir = makeTmpDir();
+ writeFileSync(
+ join(dir, 'astro.config.mjs'),
+ `export default { base: '/algokit-utils-ts' };`,
+ );
+ expect(readBaseFromConfig(dir)).toBe('/algokit-utils-ts');
+ });
+
+ it('strips trailing slash', () => {
+ const dir = makeTmpDir();
+ writeFileSync(
+ join(dir, 'astro.config.mjs'),
+ `export default { base: '/algokit-utils-ts/' };`,
+ );
+ expect(readBaseFromConfig(dir)).toBe('/algokit-utils-ts');
+ });
+
+ it('returns null when no config file', () => {
+ const dir = makeTmpDir();
+ expect(readBaseFromConfig(dir)).toBeNull();
+ });
+
+ it('returns null when no base field', () => {
+ const dir = makeTmpDir();
+ writeFileSync(join(dir, 'astro.config.mjs'), `export default {};`);
+ expect(readBaseFromConfig(dir)).toBeNull();
+ });
+
+ it('returns null for relative base (no leading /)', () => {
+ const dir = makeTmpDir();
+ writeFileSync(
+ join(dir, 'astro.config.mjs'),
+ `export default { base: 'no-slash' };`,
+ );
+ expect(readBaseFromConfig(dir)).toBeNull();
+ });
+});
+
+describe('resolveBase', () => {
+ function makeTmpDir(): string {
+ return mkdtempSync(join(tmpdir(), 'devportal-docs-test-'));
+ }
+
+ it('uses --base flag when provided', () => {
+ expect(resolveBase(['--base', '/test'], '/tmp')).toBe('/test');
+ });
+
+ it('strips trailing slash from --base', () => {
+ expect(resolveBase(['--base', '/test/'], '/tmp')).toBe('/test');
+ });
+
+ it('falls back to astro.config.mjs', () => {
+ const dir = makeTmpDir();
+ writeFileSync(
+ join(dir, 'astro.config.mjs'),
+ `export default { base: '/from-config' };`,
+ );
+ expect(resolveBase([], dir)).toBe('/from-config');
+ });
+
+ it('throws when no base can be determined', () => {
+ const dir = makeTmpDir();
+ expect(() => resolveBase([], dir)).toThrow('Could not determine site base');
+ });
+});
diff --git a/packages/devportal-docs/theme/fonts.css b/packages/devportal-docs/theme/fonts.css
new file mode 100644
index 000000000..38f25853d
--- /dev/null
+++ b/packages/devportal-docs/theme/fonts.css
@@ -0,0 +1,62 @@
+@font-face {
+ font-family: 'Aeonik';
+ src:
+ url('./fonts/Aeonik-Regular.woff2') format('woff2'),
+ url('./fonts/Aeonik-Regular.woff') format('woff');
+ font-weight: normal;
+ font-style: normal;
+}
+
+@font-face {
+ font-family: 'Aeonik';
+ src:
+ url('./fonts/Aeonik-Bold.woff2') format('woff2'),
+ url('./fonts/Aeonik-Bold.woff') format('woff');
+ font-weight: bold;
+ font-style: normal;
+}
+
+@font-face {
+ font-family: 'Inter';
+ src:
+ url('./fonts/Inter-Regular.woff2') format('woff2'),
+ url('./fonts/Inter-Regular.woff') format('woff');
+ font-weight: normal;
+ font-style: normal;
+}
+
+@font-face {
+ font-family: 'Inter';
+ src:
+ url('./fonts/Inter-Bold.woff2') format('woff2'),
+ url('./fonts/Inter-Bold.woff') format('woff');
+ font-weight: bold;
+ font-style: normal;
+}
+
+@font-face {
+ font-family: 'Source Code Pro';
+ src:
+ url('./fonts/SourceCodePro-Regular.woff2') format('woff2'),
+ url('./fonts/SourceCodePro-Regular.woff') format('woff');
+ font-weight: normal;
+ font-style: normal;
+}
+
+@font-face {
+ font-family: 'Source Code Pro';
+ src:
+ url('./fonts/SourceCodePro-Bold.woff2') format('woff2'),
+ url('./fonts/SourceCodePro-Bold.woff') format('woff');
+ font-weight: bold;
+ font-style: normal;
+}
+
+@font-face {
+ font-family: 'Source Code Pro';
+ src:
+ url('./fonts/SourceCodePro-Italic.woff2') format('woff2'),
+ url('./fonts/SourceCodePro-Italic.woff') format('woff');
+ font-weight: normal;
+ font-style: italic;
+}
diff --git a/packages/devportal-docs/theme/fonts/Aeonik-Bold.woff b/packages/devportal-docs/theme/fonts/Aeonik-Bold.woff
new file mode 100644
index 000000000..bb2013b68
Binary files /dev/null and b/packages/devportal-docs/theme/fonts/Aeonik-Bold.woff differ
diff --git a/packages/devportal-docs/theme/fonts/Aeonik-Bold.woff2 b/packages/devportal-docs/theme/fonts/Aeonik-Bold.woff2
new file mode 100644
index 000000000..650c0d1a2
Binary files /dev/null and b/packages/devportal-docs/theme/fonts/Aeonik-Bold.woff2 differ
diff --git a/packages/devportal-docs/theme/fonts/Aeonik-Regular.woff b/packages/devportal-docs/theme/fonts/Aeonik-Regular.woff
new file mode 100644
index 000000000..b5445f26d
Binary files /dev/null and b/packages/devportal-docs/theme/fonts/Aeonik-Regular.woff differ
diff --git a/packages/devportal-docs/theme/fonts/Aeonik-Regular.woff2 b/packages/devportal-docs/theme/fonts/Aeonik-Regular.woff2
new file mode 100644
index 000000000..9dfd8b6eb
Binary files /dev/null and b/packages/devportal-docs/theme/fonts/Aeonik-Regular.woff2 differ
diff --git a/packages/devportal-docs/theme/fonts/Inter-Bold.woff b/packages/devportal-docs/theme/fonts/Inter-Bold.woff
new file mode 100644
index 000000000..70eff9094
Binary files /dev/null and b/packages/devportal-docs/theme/fonts/Inter-Bold.woff differ
diff --git a/packages/devportal-docs/theme/fonts/Inter-Bold.woff2 b/packages/devportal-docs/theme/fonts/Inter-Bold.woff2
new file mode 100644
index 000000000..a00407988
Binary files /dev/null and b/packages/devportal-docs/theme/fonts/Inter-Bold.woff2 differ
diff --git a/packages/devportal-docs/theme/fonts/Inter-Regular.woff b/packages/devportal-docs/theme/fonts/Inter-Regular.woff
new file mode 100644
index 000000000..5032825be
Binary files /dev/null and b/packages/devportal-docs/theme/fonts/Inter-Regular.woff differ
diff --git a/packages/devportal-docs/theme/fonts/Inter-Regular.woff2 b/packages/devportal-docs/theme/fonts/Inter-Regular.woff2
new file mode 100644
index 000000000..817403fde
Binary files /dev/null and b/packages/devportal-docs/theme/fonts/Inter-Regular.woff2 differ
diff --git a/packages/devportal-docs/theme/fonts/SourceCodePro-Bold.woff b/packages/devportal-docs/theme/fonts/SourceCodePro-Bold.woff
new file mode 100644
index 000000000..1b5caf290
Binary files /dev/null and b/packages/devportal-docs/theme/fonts/SourceCodePro-Bold.woff differ
diff --git a/packages/devportal-docs/theme/fonts/SourceCodePro-Bold.woff2 b/packages/devportal-docs/theme/fonts/SourceCodePro-Bold.woff2
new file mode 100644
index 000000000..3d1c14276
Binary files /dev/null and b/packages/devportal-docs/theme/fonts/SourceCodePro-Bold.woff2 differ
diff --git a/packages/devportal-docs/theme/fonts/SourceCodePro-Italic.woff b/packages/devportal-docs/theme/fonts/SourceCodePro-Italic.woff
new file mode 100644
index 000000000..13963bf5d
Binary files /dev/null and b/packages/devportal-docs/theme/fonts/SourceCodePro-Italic.woff differ
diff --git a/packages/devportal-docs/theme/fonts/SourceCodePro-Italic.woff2 b/packages/devportal-docs/theme/fonts/SourceCodePro-Italic.woff2
new file mode 100644
index 000000000..ee881feb9
Binary files /dev/null and b/packages/devportal-docs/theme/fonts/SourceCodePro-Italic.woff2 differ
diff --git a/packages/devportal-docs/theme/fonts/SourceCodePro-Regular.woff b/packages/devportal-docs/theme/fonts/SourceCodePro-Regular.woff
new file mode 100644
index 000000000..b7812fd77
Binary files /dev/null and b/packages/devportal-docs/theme/fonts/SourceCodePro-Regular.woff differ
diff --git a/packages/devportal-docs/theme/fonts/SourceCodePro-Regular.woff2 b/packages/devportal-docs/theme/fonts/SourceCodePro-Regular.woff2
new file mode 100644
index 000000000..d62d6a50a
Binary files /dev/null and b/packages/devportal-docs/theme/fonts/SourceCodePro-Regular.woff2 differ
diff --git a/packages/devportal-docs/theme/index.js b/packages/devportal-docs/theme/index.js
new file mode 100644
index 000000000..0a0dd36f7
--- /dev/null
+++ b/packages/devportal-docs/theme/index.js
@@ -0,0 +1,10 @@
+import { dirname, join } from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const __dirname = dirname(fileURLToPath(import.meta.url));
+
+/** Path to the brand tokens + Starlight color overrides CSS file. */
+export const css = join(__dirname, 'theme.css');
+
+/** Path to the font-face declarations CSS file. */
+export const fonts = join(__dirname, 'fonts.css');
diff --git a/packages/devportal-docs/theme/theme.css b/packages/devportal-docs/theme/theme.css
new file mode 100644
index 000000000..dfb53cdcb
--- /dev/null
+++ b/packages/devportal-docs/theme/theme.css
@@ -0,0 +1,119 @@
+/* ==========================================================================
+ Algorand Developer Portal — Library Theme
+ Brand colors, Starlight overrides, and font sizing.
+ ========================================================================== */
+
+/* ---- Font families ---- */
+:root {
+ --__sl-font: 'Inter', sans-serif;
+ --font-sans: Inter, sans-serif;
+ --font-mono: Source Code Pro, monospace;
+}
+
+/* ---- Brand palette: Algorand Teal ---- */
+:root {
+ --color-algo-teal-100: #e7faf9;
+ --color-algo-teal-200: #d1f4f4;
+ --color-algo-teal-300: #b9efee;
+ --color-algo-teal-400: #74dfdd;
+ --color-algo-teal-500: #45d5d1;
+ --color-algo-teal-600: #17cac6;
+ --color-algo-teal-700: #5cdad7;
+}
+
+/* ---- Brand palette: Algorand Lavender ---- */
+:root {
+ --color-algo-lavender-100: #e9e9fd;
+ --color-algo-lavender-200: #8080f3;
+ --color-algo-lavender-300: #4444ed;
+ --color-algo-lavender-400: #2d2df1;
+}
+
+/* ---- Brand palette: Algorand Navy ---- */
+:root {
+ --color-algo-navy: #001324;
+}
+
+/* ==========================================================================
+ Starlight Overrides — Dark Mode (default)
+ ========================================================================== */
+:root {
+ --sl-color-accent-low: #131e4f;
+ --sl-color-accent: #a2eae8;
+ --sl-color-accent-high: var(--color-algo-teal-600);
+ --sl-color-white: #dfe1e5;
+ --sl-color-gray-1: #e5e7e9;
+ --sl-color-gray-2: #ccd0d3;
+ --sl-color-gray-3: #66717c;
+ --sl-color-gray-4: #334250;
+ --sl-color-gray-5: #192a39;
+ --sl-color-gray-6: var(--color-algo-navy);
+ --sl-color-black: var(--color-algo-navy);
+ --sl-color-text-accent: var(--color-algo-teal-600);
+ --sl-color-accent-medium: var(--color-algo-teal-700);
+}
+
+/* ==========================================================================
+ Starlight Overrides — Light Mode
+ ========================================================================== */
+:root[data-theme='light'] {
+ --sl-color-accent-low: #c7d6ff;
+ --sl-color-accent: var(--color-algo-lavender-400);
+ --sl-color-accent-high: var(--color-algo-lavender-200);
+ --sl-color-white: #282930;
+ --sl-color-gray-1: #24272f;
+ --sl-color-gray-2: #353841;
+ --sl-color-gray-3: #60646e;
+ --sl-color-gray-4: #888b96;
+ --sl-color-gray-5: #c0c2c7;
+ --sl-color-gray-6: #ccd0d3;
+ --sl-color-gray-7: #f6f6f6;
+ --sl-color-black: #f6f6f6;
+ --sl-color-text-accent: var(--color-algo-lavender-400);
+ --sl-color-accent-medium: #c7d6ff;
+}
+
+/* ==========================================================================
+ Starlight Font Overrides
+ ========================================================================== */
+:root {
+ --sl-text-3xs: 0.6rem;
+ --sl-text-2xs: 0.75rem;
+ --sl-text-xs: 0.825rem;
+ --sl-text-sm: 0.85rem;
+ --sl-text-base: 0.875rem;
+ --sl-text-lg: 1.125rem;
+ --sl-text-xl: 1.25rem;
+ --sl-text-2xl: 1.5rem;
+ --sl-text-3xl: 1.8125rem;
+ --sl-text-4xl: 2.1875rem;
+ --sl-text-5xl: 2.625rem;
+ --sl-text-6xl: 4rem;
+ --sl-text-body: var(--sl-text-base);
+ --sl-text-body-sm: var(--sl-text-xs);
+ --sl-text-code: var(--sl-text-sm);
+ --sl-text-code-sm: var(--sl-text-xs);
+}
+
+h1,
+h2,
+h3,
+h4,
+h5,
+h6 {
+ font-family: 'Aeonik', sans-serif;
+}
+
+/* ---- Panel borders (hidden) ---- */
+.sidebar-pane {
+ border: none;
+}
+.right-sidebar {
+ border: none;
+}
+.content-panel + .content-panel {
+ border: none;
+}
+header.header {
+ border: none;
+}
diff --git a/packages/devportal-docs/tsconfig.json b/packages/devportal-docs/tsconfig.json
new file mode 100644
index 000000000..5a4fe929d
--- /dev/null
+++ b/packages/devportal-docs/tsconfig.json
@@ -0,0 +1,18 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "module": "ESNext",
+ "moduleResolution": "Bundler",
+ "outDir": "dist",
+ "rootDir": "src",
+ "declaration": true,
+ "strict": true,
+ "esModuleInterop": true,
+ "skipLibCheck": true,
+ "forceConsistentCasingInFileNames": true,
+ "resolveJsonModule": true,
+ "types": ["node"]
+ },
+ "include": ["src"],
+ "exclude": ["dist", "theme", "tests"]
+}
diff --git a/packages/devportal-docs/vitest.config.ts b/packages/devportal-docs/vitest.config.ts
new file mode 100644
index 000000000..27a13fced
--- /dev/null
+++ b/packages/devportal-docs/vitest.config.ts
@@ -0,0 +1,11 @@
+import { defineConfig } from 'vitest/config';
+
+export default defineConfig({
+ test: {
+ include: ['tests/**/*.test.ts'],
+ passWithNoTests: true,
+ clearMocks: true,
+ mockReset: true,
+ restoreMocks: true,
+ },
+});
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index a746cd935..a02a02c79 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -212,6 +212,22 @@ importers:
specifier: ^4.0.18
version: 4.0.18(@types/node@25.2.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.7.1)
+ packages/devportal-docs:
+ dependencies:
+ tsx:
+ specifier: ^4.0.0
+ version: 4.21.0
+ devDependencies:
+ '@types/node':
+ specifier: ^22.0.0
+ version: 22.19.11
+ typescript:
+ specifier: ^5.9.0
+ version: 5.9.3
+ vitest:
+ specifier: ^4.0.0
+ version: 4.0.18(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.7.1)
+
packages:
'@antfu/install-pkg@1.1.0':
@@ -1546,6 +1562,9 @@ packages:
'@types/node@17.0.45':
resolution: {integrity: sha512-w+tIMs3rq2afQdsPJlODhoUEKzFP1ayaoyl1CcnwtIlsVe7K7bA1NGm4s3PraqTLlXnbIN84zuBlxBWo1u9BLw==}
+ '@types/node@22.19.11':
+ resolution: {integrity: sha512-BH7YwL6rA93ReqeQS1c4bsPpcfOmJasG+Fkr6Y59q83f9M1WcBRHR2vM+P9eOisYRcN3ujQoiZY8uk5W+1WL8w==}
+
'@types/node@25.2.2':
resolution: {integrity: sha512-BkmoP5/FhRYek5izySdkOneRyXYN35I860MFAGupTdebyE66uZaR+bXLHq8k4DirE5DwQi3NuhvRU1jqTVwUrQ==}
@@ -3822,6 +3841,9 @@ packages:
uncrypto@0.1.3:
resolution: {integrity: sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==}
+ undici-types@6.21.0:
+ resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
+
undici-types@7.16.0:
resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==}
@@ -5607,6 +5629,10 @@ snapshots:
'@types/node@17.0.45': {}
+ '@types/node@22.19.11':
+ dependencies:
+ undici-types: 6.21.0
+
'@types/node@25.2.2':
dependencies:
undici-types: 7.16.0
@@ -8622,6 +8648,8 @@ snapshots:
uncrypto@0.1.3: {}
+ undici-types@6.21.0: {}
+
undici-types@7.16.0: {}
undici@7.21.0: {}
@@ -8754,6 +8782,22 @@ snapshots:
'@types/unist': 3.0.3
vfile-message: 4.0.3
+ vite@6.4.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.7.1):
+ dependencies:
+ esbuild: 0.25.12
+ fdir: 6.5.0(picomatch@4.0.3)
+ picomatch: 4.0.3
+ postcss: 8.5.6
+ rollup: 4.57.1
+ tinyglobby: 0.2.15
+ optionalDependencies:
+ '@types/node': 22.19.11
+ fsevents: 2.3.3
+ jiti: 2.6.1
+ lightningcss: 1.30.2
+ tsx: 4.21.0
+ yaml: 2.7.1
+
vite@6.4.1(@types/node@25.2.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.7.1):
dependencies:
esbuild: 0.25.12
@@ -8774,6 +8818,43 @@ snapshots:
optionalDependencies:
vite: 6.4.1(@types/node@25.2.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.7.1)
+ vitest@4.0.18(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.7.1):
+ dependencies:
+ '@vitest/expect': 4.0.18
+ '@vitest/mocker': 4.0.18(vite@6.4.1(@types/node@25.2.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.7.1))
+ '@vitest/pretty-format': 4.0.18
+ '@vitest/runner': 4.0.18
+ '@vitest/snapshot': 4.0.18
+ '@vitest/spy': 4.0.18
+ '@vitest/utils': 4.0.18
+ es-module-lexer: 1.7.0
+ expect-type: 1.3.0
+ magic-string: 0.30.21
+ obug: 2.1.1
+ pathe: 2.0.3
+ picomatch: 4.0.3
+ std-env: 3.10.0
+ tinybench: 2.9.0
+ tinyexec: 1.0.2
+ tinyglobby: 0.2.15
+ tinyrainbow: 3.0.3
+ vite: 6.4.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.7.1)
+ why-is-node-running: 2.3.0
+ optionalDependencies:
+ '@types/node': 22.19.11
+ transitivePeerDependencies:
+ - jiti
+ - less
+ - lightningcss
+ - msw
+ - sass
+ - sass-embedded
+ - stylus
+ - sugarss
+ - terser
+ - tsx
+ - yaml
+
vitest@4.0.18(@types/node@25.2.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.7.1):
dependencies:
'@vitest/expect': 4.0.18
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
new file mode 100644
index 000000000..18ec407ef
--- /dev/null
+++ b/pnpm-workspace.yaml
@@ -0,0 +1,2 @@
+packages:
+ - 'packages/*'
diff --git a/scripts/clean-docs-import.ts b/scripts/clean-docs-import.ts
deleted file mode 100644
index 3bb3bcc76..000000000
--- a/scripts/clean-docs-import.ts
+++ /dev/null
@@ -1,96 +0,0 @@
-#!/usr/bin/env tsx
-
-import { fileURLToPath } from 'url';
-import { dirname, resolve } from 'path';
-import { promises as fs } from 'fs';
-import type { ImportOptions } from '@larkiny/astro-github-loader';
-import { REMOTE_CONTENT } from '../imports/configs/index.js';
-
-const __filename = fileURLToPath(import.meta.url);
-const __dirname = dirname(__filename);
-const PROJECT_ROOT = resolve(__dirname, '..');
-
-type RepoConfig = ImportOptions & { repo: string };
-
-const configs: RepoConfig[] = REMOTE_CONTENT.filter(
- (c): c is RepoConfig => 'repo' in c && typeof c.repo === 'string',
-);
-
-function parseRepoArg(): string {
- const [, , ...args] = process.argv;
- const repoFlagIndex = args.findIndex(arg => arg === '--repo');
-
- if (repoFlagIndex !== -1) {
- const value = args[repoFlagIndex + 1];
- if (!value) {
- throw new Error('Missing value for --repo option.');
- }
- return value;
- }
-
- const repoFromFlag = args.find(arg => arg.startsWith('--repo='));
- if (repoFromFlag) {
- return repoFromFlag.split('=')[1];
- }
-
- if (args[0]) {
- return args[0];
- }
-
- throw new Error('Please provide a repository via `clean-docs-import.ts --repo `.');
-}
-
-function resolvePath(relativePath: string): string {
- return resolve(PROJECT_ROOT, relativePath);
-}
-
-async function clearDirectory(relativePath: string) {
- const targetPath = resolvePath(relativePath);
- await fs.rm(targetPath, { recursive: true, force: true });
- await fs.mkdir(targetPath, { recursive: true });
- console.log(`🧹 Cleared ${relativePath}`);
-}
-
-async function run() {
- try {
- const repo = parseRepoArg();
- const targetConfig = configs.find(
- config => `${config.owner}/${config.repo}` === repo,
- );
-
- if (!targetConfig) {
- console.error(`❌ No import configuration found for repo "${repo}".`);
- process.exit(1);
- }
-
- const pathsToClear = new Set();
-
- if (targetConfig.assetsPath) {
- pathsToClear.add(targetConfig.assetsPath);
- }
-
- if (Array.isArray(targetConfig.includes)) {
- for (const include of targetConfig.includes) {
- if (include?.basePath) {
- pathsToClear.add(include.basePath);
- }
- }
- }
-
- if (pathsToClear.size === 0) {
- console.warn(`⚠️ Configuration for "${repo}" does not define any paths to clean.`);
- return;
- }
-
- for (const path of pathsToClear) {
- await clearDirectory(path);
- }
-
- console.log(`✅ Completed cleanup for ${repo}`);
- } catch (error) {
- console.error(`❌ ${error instanceof Error ? error.message : String(error)}`);
- process.exit(1);
- }
-}
-
-void run();
diff --git a/scripts/import-release-docs.ts b/scripts/import-release-docs.ts
index 146e01273..9bac6d0b0 100644
--- a/scripts/import-release-docs.ts
+++ b/scripts/import-release-docs.ts
@@ -18,7 +18,7 @@ import {
rmSync,
writeFileSync,
} from 'fs';
-import { execSync } from 'child_process';
+import { execFileSync, execSync } from 'child_process';
import { join, posix, relative } from 'path';
import { tmpdir } from 'os';
@@ -115,6 +115,54 @@ function walkFiles(dir: string): string[] {
return results;
}
+/**
+ * Fix case-only mismatches between git's index and the filesystem.
+ *
+ * On macOS (core.ignorecase=true), after rmSync + cp -R with correctly-cased
+ * content from a tarball, git's index may still track the old PascalCase
+ * names. This function detects mismatches and uses git mv -f to reconcile.
+ */
+function fixGitCaseMismatches(dir: string): void {
+ let trackedFiles: string[];
+ try {
+ const output = execFileSync('git', ['ls-files', dir], {
+ encoding: 'utf-8',
+ stdio: 'pipe',
+ });
+ trackedFiles = output.trim().split('\n').filter(Boolean);
+ } catch {
+ // Not in a git repo — nothing to fix.
+ return;
+ }
+
+ if (trackedFiles.length === 0) return;
+
+ // Build a map from lowercase path to actual filesystem path
+ const fsFiles = walkFiles(dir);
+ const fsMap = new Map();
+ for (const f of fsFiles) {
+ fsMap.set(f.toLowerCase(), f);
+ }
+
+ let fixCount = 0;
+ for (const tracked of trackedFiles) {
+ const fsPath = fsMap.get(tracked.toLowerCase());
+ if (!fsPath || fsPath === tracked) continue;
+
+ // Case mismatch: git tracks 'FooBar.md' but filesystem has 'foobar.md'
+ try {
+ execFileSync('git', ['mv', '-f', tracked, fsPath], { stdio: 'pipe' });
+ fixCount++;
+ } catch {
+ // File may have been deleted or is otherwise not fixable — skip.
+ }
+ }
+
+ if (fixCount > 0) {
+ console.log(` Fixed ${fixCount} case mismatch(es) in git index`);
+ }
+}
+
/** Apply post-import transforms to files matching their glob patterns. */
function applyPostImportTransforms(
destDir: string,
@@ -329,6 +377,10 @@ async function downloadAndUnpack(task: DownloadTask): Promise {
mkdirSync(destDir, { recursive: true });
execSync(`cp -R "${contentSrc}/"* "${destDir}/"`, { stdio: 'pipe' });
+ // 6b. Fix case mismatches between git index and extracted content.
+ // On macOS, git may still track PascalCase names from a prior import.
+ fixGitCaseMismatches(destDir);
+
// 7. Copy sidebar.json if present in artifact (written as-is;
// rebasing happens in buildSidebarEntries() at Astro config time)
const sidebarSrc = join(extractDir, 'sidebar.json');
diff --git a/scripts/library-templates/README.md b/scripts/library-templates/README.md
deleted file mode 100644
index 6042c7197..000000000
--- a/scripts/library-templates/README.md
+++ /dev/null
@@ -1,190 +0,0 @@
-# DevPortal Documentation Templates
-
-Templates for publishing library documentation to the Algorand Developer Portal.
-
-## Overview
-
-These templates enable your library to publish pre-built documentation
-artifacts that the devportal imports automatically. This replaces the
-raw-file import strategy with a faster, more reliable pipeline.
-
-## Template files
-
-| File | Copy to | Purpose |
-|------|---------|---------|
-| `build-sidebar-json.ts` | `docs/build-sidebar-json.ts` | Serializes `sidebar.config.ts` → `sidebar.json` |
-| `build-manifest.ts` | `docs/build-manifest.ts` | Writes `manifest.json` with site base and metadata |
-| `normalize-links.ts` | `docs/normalize-links.ts` | Converts relative markdown links to absolute paths |
-| `publish-devportal-docs.yml` | `.github/workflows/publish-devportal-docs.yml` | CI workflow for publishing doc artifacts |
-
-## Setup
-
-### 1. Copy template files
-
-Copy all four template files into your library repo at the paths
-shown in the table above.
-
-### 2. Add tsx dev dependency
-
-Each library's `docs/package.json` must have `tsx` as a dev dependency:
-
-```bash
-cd docs && pnpm add -D tsx
-```
-
-### 3. Add npm scripts to docs/package.json
-
-Your `docs/package.json` must define these scripts:
-
-```json
-{
- "scripts": {
- "build-devportal": "pnpm build && pnpm run normalize-links && pnpm run build-sidebar && pnpm run build-manifest",
- "normalize-links": "npx tsx normalize-links.ts",
- "build-sidebar": "npx tsx build-sidebar-json.ts",
- "build-manifest": "npx tsx build-manifest.ts"
- }
-}
-```
-
-The `build-devportal` script is the single entry point called by the CI
-workflow. Customize it if your library needs additional steps (e.g.,
-Sphinx API doc generation before the Astro build).
-
-### 4. Verify sidebar.config.ts
-
-Your `docs/sidebar.config.ts` must export a `sidebar` array:
-
-```ts
-import type { StarlightUserConfig } from '@astrojs/starlight/types'
-
-export const sidebar: NonNullable = [
- // Your sidebar entries here
-]
-```
-
-Non-serializable entries (like `typeDocSidebarGroup`) are automatically
-filtered out by `build-sidebar-json.ts`. To provide serializable
-replacements (e.g., an autogenerate fallback for filtered entries),
-export a `devportalFallbacks` array alongside `sidebar`.
-
-### 5. Add language-specific CI steps (if needed)
-
-The workflow template has a placeholder section for language-specific
-setup. Add your steps there:
-
-- **Python/Sphinx repos:** Add `astral-sh/setup-uv` + `uv sync --group dev`
-- **TypeScript/TypeDoc repos:** Add `npm ci` at root for TypeDoc dependencies
-
-### 6. Verify content paths
-
-The workflow copies content from these directories (adjust in the
-workflow YAML if your paths differ):
-
-- `docs/src/content/docs/guides/` - Guide documentation
-- `docs/src/content/docs/api/` - API reference
-- Root `.md`/`.mdx` files in `docs/src/content/docs/`
-
-### 7. Test locally
-
-```bash
-cd docs
-pnpm run build-devportal
-ls dist-devportal/
-# Should contain: content/, sidebar.json, manifest.json
-```
-
-## How it works
-
-1. **Build**: Library CI runs `pnpm run build-devportal` which:
- - Builds the Starlight site (`pnpm build`)
- - Normalizes relative links to absolute paths (`normalize-links.ts`)
- - Serializes the sidebar config to JSON (`build-sidebar-json.ts`)
- - Writes manifest with site base metadata (`build-manifest.ts`)
-2. **Package**: Copies `guides/`, `api/`, and root content files +
- sidebar.json + manifest.json into a tarball (`devportal-docs.tar.gz`)
-3. **Publish**: Attaches tarball to a GitHub Release
- - Nightly → rolling `docs-latest` pre-release
- - Tag push (`v*`) → attached to the version's release
-4. **Import**: Devportal's `import-release-docs.ts` downloads the tarball,
- unpacks content, normalizes links to devportal paths, and applies any
- configured post-import transforms
-
-## normalize-links.ts
-
-Converts all relative markdown links in `guides/` and `api/` to absolute
-paths rooted at the library's Starlight site base (read from
-`astro.config.mjs` or passed via `--base`).
-
-Features:
-- Skips links inside fenced code blocks and inline code
-- Filesystem fallback resolution for hand-written guides with broken relative links
-- Strips `.md`/`.mdx` extensions and normalizes `index` paths
-- Exits with error if any links can't be resolved
-
-The devportal's import script applies a second normalization pass that
-rewrites these site-base-rooted links to devportal content paths.
-
-## Using the composite action (recommended)
-
-Instead of copying `publish-devportal-docs.yml`, you can reference the
-reusable composite action from the devportal repo. This keeps the
-shared packaging/publishing logic in one place.
-
-Your library workflow only needs checkout, Node/pnpm setup, and
-language-specific steps:
-
-```yaml
-name: Publish DevPortal Docs
-
-on:
- schedule:
- - cron: '0 2 * * *'
- push:
- tags: ['v*']
- workflow_dispatch:
- inputs:
- ref:
- description: 'Git ref to build from'
- required: false
- default: 'main'
-
-permissions:
- contents: write
-
-jobs:
- publish-docs:
- name: Build and Publish Docs
- runs-on: ubuntu-latest
- timeout-minutes: 15
- steps:
- - uses: actions/checkout@v4
- with:
- ref: ${{ github.event.inputs.ref || github.ref }}
-
- - uses: actions/setup-node@v4
- with:
- node-version: '22'
-
- - uses: pnpm/action-setup@v4
- with:
- version: 10
-
- # Add language-specific steps here:
- # - run: npm ci # TypeScript/TypeDoc
- # - uses: astral-sh/setup-uv@v5 # Python/Sphinx
- # - run: uv sync --group dev
-
- - name: Publish DevPortal Docs
- uses: algorandfoundation/devportal/.github/actions/publish-devportal-docs@main
-```
-
-The composite action handles: dependency install, `build-devportal`,
-content packaging, CI provenance enrichment of the manifest, tarball
-creation, and GitHub Release publishing.
-
-## No secrets required
-
-The CI workflow publishes to the library's own GitHub Releases (using
-the built-in `GITHUB_TOKEN`). The devportal's nightly import picks up
-new releases automatically — no `repository_dispatch` or PAT needed.
diff --git a/scripts/library-templates/build-manifest.ts b/scripts/library-templates/build-manifest.ts
deleted file mode 100644
index ce911082d..000000000
--- a/scripts/library-templates/build-manifest.ts
+++ /dev/null
@@ -1,61 +0,0 @@
-#!/usr/bin/env npx tsx
-/**
- * Generate a manifest.json for the devportal doc artifact.
- *
- * Reads the Starlight `base` from astro.config.mjs and writes a JSON
- * manifest that the devportal import script uses for link normalization
- * and provenance tracking.
- *
- * Usage:
- * npx tsx build-manifest.ts
- * npx tsx build-manifest.ts --base /algokit-utils-ts
- */
-
-import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
-import { dirname, join, resolve } from 'node:path';
-import { fileURLToPath } from 'node:url';
-
-// ---------------------------------------------------------------------------
-// Config resolution
-// ---------------------------------------------------------------------------
-
-function readBaseFromConfig(docsDir: string): string | null {
- const configPath = join(docsDir, 'astro.config.mjs');
- if (!existsSync(configPath)) return null;
- const content = readFileSync(configPath, 'utf-8');
- const match = content.match(/base:\s*["']([^"']+)["']/);
- if (!match) return null;
- const base = match[1].replace(/\/$/, '');
- if (!base.startsWith('/')) return null;
- return base;
-}
-
-// ---------------------------------------------------------------------------
-// Main
-// ---------------------------------------------------------------------------
-
-function main(): void {
- const args = process.argv.slice(2);
- const docsDir = resolve(dirname(fileURLToPath(import.meta.url)));
- const outPath = join(docsDir, 'dist-devportal', 'manifest.json');
-
- // Resolve base: CLI flag > astro.config.mjs > default '/'
- let base: string = '/';
- const baseIdx = args.indexOf('--base');
- if (baseIdx >= 0 && args[baseIdx + 1]) {
- base = args[baseIdx + 1].replace(/\/$/, '');
- } else {
- base = readBaseFromConfig(docsDir) ?? '/';
- }
-
- const manifest = {
- base,
- timestamp: new Date().toISOString(),
- };
-
- mkdirSync(dirname(outPath), { recursive: true });
- writeFileSync(outPath, JSON.stringify(manifest, null, 2) + '\n');
- console.log(`Wrote manifest.json (base: ${base})`);
-}
-
-main();
diff --git a/scripts/library-templates/build-sidebar-json.ts b/scripts/library-templates/build-sidebar-json.ts
deleted file mode 100644
index f42988a3a..000000000
--- a/scripts/library-templates/build-sidebar-json.ts
+++ /dev/null
@@ -1,69 +0,0 @@
-#!/usr/bin/env npx tsx
-/**
- * build-sidebar-json.ts
- *
- * Imports the library's sidebar.config.ts, filters out non-serializable
- * entries (runtime plugins like typeDocSidebarGroup), and writes sidebar.json.
- *
- * Usage (from library's docs/ directory):
- * npx tsx build-sidebar-json.ts
- *
- * Output: dist-devportal/sidebar.json
- *
- * Prerequisite: tsx must be a dev dependency in the library's docs/package.json.
- * Copy this file into your library's docs/ directory.
- */
-
-import { writeFileSync, mkdirSync } from 'node:fs';
-import { join, dirname } from 'node:path';
-import { fileURLToPath } from 'node:url';
-
-const __dirname = dirname(fileURLToPath(import.meta.url));
-
-// Import the library's sidebar config.
-// Adjust this path if your sidebar.config.ts is in a different location.
-// devportalFallbacks: optional serializable replacements for non-serializable
-// entries (e.g. typeDocSidebarGroup → autogenerate fallback).
-const { sidebar, devportalFallbacks } = await import('./sidebar.config.ts');
-
-/**
- * Check if a sidebar entry is serializable (plain data object).
- * Runtime plugin entries (like typeDocSidebarGroup) are class instances
- * or objects with methods and should be excluded.
- */
-function isSerializable(entry: unknown): boolean {
- if (typeof entry !== 'object' || entry === null) return false;
- return (
- 'slug' in entry ||
- ('link' in entry && 'label' in entry) ||
- ('items' in entry && 'label' in entry) ||
- ('autogenerate' in entry && 'label' in entry)
- );
-}
-
-/**
- * Recursively filter sidebar entries, keeping only serializable ones.
- */
-function filterSerializable(entries: unknown[]): unknown[] {
- return entries
- .filter(isSerializable)
- .map((entry) => {
- if (typeof entry === 'object' && entry !== null && 'items' in entry) {
- const e = entry as Record;
- return { ...e, items: filterSerializable(e.items as unknown[]) };
- }
- return entry;
- });
-}
-
-const filtered = filterSerializable(sidebar);
-const fallbacks = Array.isArray(devportalFallbacks) ? devportalFallbacks : [];
-const result = [...filtered, ...fallbacks];
-
-const outputDir = join(__dirname, 'dist-devportal');
-mkdirSync(outputDir, { recursive: true });
-
-const outputPath = join(outputDir, 'sidebar.json');
-writeFileSync(outputPath, JSON.stringify(result, null, 2));
-
-console.log(`Wrote ${result.length} sidebar entries to ${outputPath}`);
diff --git a/scripts/library-templates/publish-devportal-docs.yml b/scripts/library-templates/publish-devportal-docs.yml
deleted file mode 100644
index 700826f8a..000000000
--- a/scripts/library-templates/publish-devportal-docs.yml
+++ /dev/null
@@ -1,156 +0,0 @@
-# publish-devportal-docs.yml
-#
-# Copy this file to .github/workflows/publish-devportal-docs.yml
-# in your library repo.
-#
-# Triggers:
-# - Nightly cron: builds from main, upserts 'docs-latest' pre-release
-# - Tag push (v*): builds from tag, attaches to existing release
-# - Manual dispatch: builds from selected ref
-#
-# Prerequisites:
-# - docs/package.json must define a "build-devportal" script that runs
-# the full build pipeline (astro build + normalize-links + build-sidebar)
-# - If your library has language-specific setup (Python/uv, root npm ci
-# for TypeDoc, etc.), add those steps before the "Build devportal
-# artifact" step
-#
-# No secrets required — devportal nightly import picks up new releases
-# automatically.
-#
-# Output: devportal-docs.tar.gz attached to a GitHub Release
-# Contents:
-# content/ - doc pages (guides + API reference)
-# sidebar.json - serialized sidebar config
-# manifest.json - metadata (repo, ref, timestamp)
-
-name: Publish DevPortal Docs
-
-on:
- schedule:
- # Nightly at 2:00 AM UTC
- - cron: '0 2 * * *'
-
- push:
- tags:
- - 'v*'
-
- workflow_dispatch:
- inputs:
- ref:
- description: 'Git ref to build from'
- required: false
- default: 'main'
-
-permissions:
- contents: write
-
-jobs:
- publish-docs:
- name: Build and Publish Docs
- runs-on: ubuntu-latest
- timeout-minutes: 15
-
- steps:
- - name: Checkout
- uses: actions/checkout@v4
- with:
- ref: ${{ github.event.inputs.ref || github.ref }}
-
- - name: Setup Node.js
- uses: actions/setup-node@v4
- with:
- node-version: '22'
-
- - name: Setup pnpm
- uses: pnpm/action-setup@v4
- with:
- version: 10
-
- # ---- Language-specific setup (add your steps here) ----
- # Examples:
- # Python/Sphinx: setup-uv + uv sync --group dev
- # TypeScript/TypeDoc: npm ci (root deps for TypeDoc)
-
- - name: Install docs dependencies
- run: pnpm install --frozen-lockfile --dir docs
-
- - name: Build devportal artifact
- working-directory: docs
- run: pnpm run build-devportal
-
- # ---- Package for DevPortal ----
-
- - name: Prepare dist-devportal
- working-directory: docs
- run: |
- mkdir -p dist-devportal/content
-
- # Copy root content files (index.mdx etc)
- find src/content/docs -maxdepth 1 -type f \( -name '*.md' -o -name '*.mdx' \) -exec cp {} dist-devportal/content/ \;
-
- # Copy guides and API content
- cp -R src/content/docs/guides dist-devportal/content/ 2>/dev/null || true
- cp -R src/content/docs/api dist-devportal/content/ 2>/dev/null || true
-
- # Merge CI provenance into manifest.json (base + timestamp
- # written by build-devportal; repo/ref/tag added here)
- if [ -f dist-devportal/manifest.json ]; then
- node -e "
- const m = JSON.parse(require('fs').readFileSync('dist-devportal/manifest.json','utf-8'));
- Object.assign(m, {
- repo: process.env.GITHUB_REPOSITORY,
- ref: process.env.GITHUB_SHA,
- tag: process.env.GITHUB_REF_NAME
- });
- require('fs').writeFileSync('dist-devportal/manifest.json', JSON.stringify(m,null,2)+'\n');
- "
- else
- echo '::warning::manifest.json not found — build-devportal should run build-manifest'
- cat > dist-devportal/manifest.json <> "$GITHUB_OUTPUT"
- echo "prerelease=false" >> "$GITHUB_OUTPUT"
- else
- echo "tag=docs-latest" >> "$GITHUB_OUTPUT"
- echo "prerelease=true" >> "$GITHUB_OUTPUT"
- fi
-
- - name: Upsert docs-latest release (nightly/manual)
- if: steps.release-tag.outputs.prerelease == 'true'
- uses: softprops/action-gh-release@v2
- with:
- tag_name: docs-latest
- name: 'Documentation (Latest)'
- body: 'Rolling pre-release with latest documentation build.'
- prerelease: true
- files: docs/devportal-docs.tar.gz
- fail_on_unmatched_files: true
-
- - name: Attach to version release (tag push)
- if: steps.release-tag.outputs.prerelease == 'false'
- uses: softprops/action-gh-release@v2
- with:
- tag_name: ${{ github.ref_name }}
- files: docs/devportal-docs.tar.gz
- fail_on_unmatched_files: true
diff --git a/search-desktop.png b/search-desktop.png
deleted file mode 100644
index 891ad5824..000000000
Binary files a/search-desktop.png and /dev/null differ
diff --git a/search-mobile.png b/search-mobile.png
deleted file mode 100644
index f67beeb20..000000000
Binary files a/search-mobile.png and /dev/null differ
diff --git a/tsconfig.json b/tsconfig.json
index 12ab5e789..93b04ca79 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -1,4 +1,4 @@
{
"extends": "astro/tsconfigs/strict",
- "exclude": ["./examples", "./dist", "./scripts/library-templates", "./tests"]
+ "exclude": ["./examples", "./dist", "./packages", "./tests"]
}