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"] }