diff --git a/.github/workflows/cd-production.yml b/.github/workflows/cd-production.yml index c658ff7..7a80d2c 100644 --- a/.github/workflows/cd-production.yml +++ b/.github/workflows/cd-production.yml @@ -43,7 +43,6 @@ jobs: with: publish-dir: './docs-dist' production-deploy: true - github-token: ${{ secrets.GITHUB_TOKEN }} deploy-message: "Production deployment" env: NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }} diff --git a/.github/workflows/cd-staging.yml b/.github/workflows/cd-staging.yml index 67d088f..b51cf2d 100644 --- a/.github/workflows/cd-staging.yml +++ b/.github/workflows/cd-staging.yml @@ -41,7 +41,6 @@ jobs: with: publish-dir: './docs-dist' production-deploy: false - github-token: ${{ secrets.GITHUB_TOKEN }} deploy-message: "Staging deployment from develop" alias: staging env: diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 6180882..3a32e62 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -12,6 +12,8 @@ jobs: name: E2E (chromium) runs-on: ubuntu-latest timeout-minutes: 30 + env: + E2E_COVERAGE: '1' steps: - uses: actions/checkout@v6 - name: Setup Node.js @@ -27,6 +29,15 @@ jobs: run: npm run build - name: Run E2E tests run: npx playwright test --project=chromium + - name: Merge E2E coverage + run: node scripts/merge-e2e-coverage.js + - name: Upload E2E coverage to Codecov + uses: codecov/codecov-action@v5 + with: + token: ${{ secrets.CODECOV_TOKEN }} + files: ./coverage-e2e/lcov.info + flags: e2e + fail_ci_if_error: false - name: Upload report if: always() uses: actions/upload-artifact@v6 diff --git a/.gitignore b/.gitignore index c67f170..d34a6dd 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,7 @@ out # Testing coverage +coverage-e2e .nyc_output playwright-report test-results diff --git a/README.md b/README.md index e5bacd5..4875d15 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ [![CI](https://github.com/OpenSyntaxHQ/autodocs/workflows/CI/badge.svg)](https://github.com/OpenSyntaxHQ/autodocs/actions) [![codecov](https://codecov.io/gh/OpenSyntaxHQ/autodocs/branch/main/graph/badge.svg)](https://codecov.io/gh/OpenSyntaxHQ/autodocs) -[![npm version](https://badge.fury.io/js/@opensyntaxhq%2Fautodocs.svg)](https://www.npmjs.com/package/@opensyntaxhq/autodocs) +[![npm version](https://img.shields.io/npm/v/%40opensyntaxhq%2Fautodocs?logo=npm&color=cb3837)](https://www.npmjs.com/package/@opensyntaxhq/autodocs) [![License](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](https://opensource.org/licenses/Apache-2.0) ## Features diff --git a/e2e/accessibility.spec.ts b/e2e/accessibility.spec.ts index fe7c2f1..517be9d 100644 --- a/e2e/accessibility.spec.ts +++ b/e2e/accessibility.spec.ts @@ -1,4 +1,4 @@ -import { test, expect } from '@playwright/test'; +import { test, expect } from './coverage'; import AxeBuilder from '@axe-core/playwright'; test('homepage has no critical accessibility violations', async ({ page }) => { diff --git a/e2e/coverage.ts b/e2e/coverage.ts new file mode 100644 index 0000000..82dd151 --- /dev/null +++ b/e2e/coverage.ts @@ -0,0 +1,76 @@ +import { test as base, expect, type Page, type TestInfo } from '@playwright/test'; +import { createHash } from 'crypto'; +import fs from 'fs'; +import path from 'path'; +import v8toIstanbul from 'v8-to-istanbul'; + +const COVERAGE_ENV = 'E2E_COVERAGE'; +const ROOT_DIR = process.cwd(); +const DOCS_DIST = path.join(ROOT_DIR, 'docs-dist'); +const RAW_DIR = path.join(ROOT_DIR, 'coverage-e2e', 'raw'); + +function shouldCollect(testInfo: TestInfo): boolean { + return process.env[COVERAGE_ENV] === '1' && testInfo.project.name === 'chromium'; +} + +function getOutputPath(testInfo: TestInfo): string { + const hash = createHash('sha256').update(testInfo.testId).digest('hex').slice(0, 12); + return path.join( + RAW_DIR, + `${testInfo.project.name}-${String(testInfo.workerIndex)}-${hash}.json` + ); +} + +async function convertCoverage(page: Page, testInfo: TestInfo): Promise { + const entries = await page.coverage.stopJSCoverage(); + const coverageMap: Record = {}; + + for (const entry of entries) { + if (!entry.url || !entry.url.startsWith('http')) { + continue; + } + + const url = new URL(entry.url); + if (!url.pathname.endsWith('.js')) { + continue; + } + + const filePath = path.join(DOCS_DIST, decodeURIComponent(url.pathname)); + if (!fs.existsSync(filePath)) { + continue; + } + + const converter = v8toIstanbul(filePath, 0, { source: entry.source }); + await converter.load(); + converter.applyCoverage(entry.functions); + Object.assign(coverageMap, converter.toIstanbul()); + } + + if (Object.keys(coverageMap).length === 0) { + return; + } + + fs.mkdirSync(RAW_DIR, { recursive: true }); + fs.writeFileSync(getOutputPath(testInfo), JSON.stringify(coverageMap)); +} + +export const test = base.extend<{ page: Page }>({ + page: async ({ page }, use, testInfo) => { + const collect = shouldCollect(testInfo); + + if (collect) { + await page.coverage.startJSCoverage({ + resetOnNavigation: false, + reportAnonymousScripts: true, + }); + } + + await use(page); + + if (collect) { + await convertCoverage(page, testInfo); + } + }, +}); + +export { expect }; diff --git a/e2e/homepage.spec.ts b/e2e/homepage.spec.ts index 5daeee8..cbc4ffa 100644 --- a/e2e/homepage.spec.ts +++ b/e2e/homepage.spec.ts @@ -1,4 +1,4 @@ -import { test, expect } from '@playwright/test'; +import { test, expect } from './coverage'; test('homepage renders stats and navigation', async ({ page }) => { await page.goto('/'); diff --git a/e2e/markdown-pages.spec.ts b/e2e/markdown-pages.spec.ts index 4cd3f3c..24cca55 100644 --- a/e2e/markdown-pages.spec.ts +++ b/e2e/markdown-pages.spec.ts @@ -1,4 +1,4 @@ -import { test, expect } from '@playwright/test'; +import { test, expect } from './coverage'; test('markdown pages render content', async ({ page }) => { await page.goto('/docs/intro.md'); diff --git a/e2e/performance.spec.ts b/e2e/performance.spec.ts index 5865dc6..edd1b9e 100644 --- a/e2e/performance.spec.ts +++ b/e2e/performance.spec.ts @@ -1,4 +1,4 @@ -import { test, expect } from '@playwright/test'; +import { test, expect } from './coverage'; test('homepage loads within acceptable time', async ({ page }) => { const start = Date.now(); diff --git a/e2e/responsive.spec.ts b/e2e/responsive.spec.ts index bd47ea6..b9f90cd 100644 --- a/e2e/responsive.spec.ts +++ b/e2e/responsive.spec.ts @@ -1,4 +1,4 @@ -import { test, expect } from '@playwright/test'; +import { test, expect } from './coverage'; test('mobile navigation shows menu button', async ({ page }) => { await page.setViewportSize({ width: 375, height: 800 }); diff --git a/e2e/search.spec.ts b/e2e/search.spec.ts index 8ea8109..e52a5ad 100644 --- a/e2e/search.spec.ts +++ b/e2e/search.spec.ts @@ -1,4 +1,4 @@ -import { test, expect } from '@playwright/test'; +import { test, expect } from './coverage'; test('search navigates to result', async ({ page }) => { await page.goto('/'); @@ -14,5 +14,5 @@ test('search navigates to result', async ({ page }) => { await expect(dialog.getByText('DocKind').first()).toBeVisible(); await dialog.getByText('DocKind').first().click(); - await expect(page).toHaveURL(/\/type\/DocKind/); + await expect(page).toHaveURL(/\/type\/[0-9a-f]{8}\/dockind$/); }); diff --git a/e2e/sidebar.spec.ts b/e2e/sidebar.spec.ts index 8416c07..fce0715 100644 --- a/e2e/sidebar.spec.ts +++ b/e2e/sidebar.spec.ts @@ -1,4 +1,4 @@ -import { test, expect } from '@playwright/test'; +import { test, expect } from './coverage'; test('sidebar shows overview and groups', async ({ page }) => { await page.goto('/'); diff --git a/e2e/theme.spec.ts b/e2e/theme.spec.ts index 234e5f8..310b1e1 100644 --- a/e2e/theme.spec.ts +++ b/e2e/theme.spec.ts @@ -1,4 +1,4 @@ -import { test, expect } from '@playwright/test'; +import { test, expect } from './coverage'; test('theme toggle updates document class', async ({ page }) => { await page.goto('/'); diff --git a/e2e/type-documentation.spec.ts b/e2e/type-documentation.spec.ts index ab7df5e..502447f 100644 --- a/e2e/type-documentation.spec.ts +++ b/e2e/type-documentation.spec.ts @@ -1,7 +1,23 @@ -import { test, expect } from '@playwright/test'; +import { test, expect } from './coverage'; test('type documentation page renders signature', async ({ page }) => { - await page.goto('/type/DocKind'); + const docsResponse = await page.request.get('/docs.json'); + const docsJson = (await docsResponse.json()) as { + entries: Array<{ id: string; name: string; kind: string }>; + }; + + const entry = docsJson.entries.find((doc) => doc.kind === 'type' && doc.name === 'DocKind'); + if (!entry) { + throw new Error('Expected DocKind type entry to exist in docs.json'); + } + + const slug = entry.name + .toLowerCase() + .trim() + .replace(/[^a-z0-9]+/g, '-') + .replace(/(^-|-$)+/g, ''); + + await page.goto(`/${entry.kind}/${entry.id}/${slug}`); await expect(page.getByRole('heading', { name: 'DocKind' })).toBeVisible(); await expect(page.getByText('Signature')).toBeVisible(); diff --git a/package-lock.json b/package-lock.json index fc50adf..ec1ca2a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,33 +1,37 @@ { "name": "autodocs-monorepo", - "version": "1.0.0", + "version": "2.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "autodocs-monorepo", - "version": "1.0.0", + "version": "2.0.0", "license": "Apache-2.0", "workspaces": [ "packages/*", "packages/plugins/*" ], "devDependencies": { - "@axe-core/playwright": "^4.10.1", + "@axe-core/playwright": "^4.11.1", "@eslint/js": "^9.39.2", "@playwright/test": "^1.58.2", - "@types/node": "^25.2.1", + "@types/node": "^25.2.2", "@types/tmp": "^0.2.6", - "eslint": "^9.0.0", - "execa": "^9.6.0", + "eslint": "^9.39.2", + "execa": "^9.6.1", "globals": "^17.3.0", - "husky": "^9.0.0", + "husky": "^9.1.7", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.2.0", "lint-staged": "^16.2.7", - "prettier": "^3.2.0", - "tmp": "^0.2.3", + "prettier": "^3.8.1", + "tmp": "^0.2.5", "turbo": "^2.8.3", - "typescript": "^5.9.0", + "typescript": "^5.9.3", "typescript-eslint": "^8.54.0", + "v8-to-istanbul": "^9.3.0", "vitest": "^4.0.18" } }, @@ -6139,9 +6143,9 @@ "license": "MIT" }, "node_modules/@types/inquirer": { - "version": "8.2.12", - "resolved": "https://registry.npmjs.org/@types/inquirer/-/inquirer-8.2.12.tgz", - "integrity": "sha512-YxURZF2ZsSjU5TAe06tW0M3sL4UI9AMPA6dd8I72uOtppzNafcY38xkYgCZ/vsVOAyNdzHmvtTpLWilOrbP0dQ==", + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/@types/inquirer/-/inquirer-9.0.9.tgz", + "integrity": "sha512-/mWx5136gts2Z2e5izdoRCo46lPp5TMs9R15GTSsgg/XnZyxDWVqoVU3R9lWnccKpqwsJLvRoxbCjoJtZB7DSw==", "dev": true, "license": "MIT", "dependencies": { @@ -6210,9 +6214,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "25.2.1", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.2.1.tgz", - "integrity": "sha512-CPrnr8voK8vC6eEtyRzvMpgp3VyVRhgclonE7qYi6P9sXwYb59ucfrnmFBTaP0yUi8Gk4yZg/LlTJULGxvTNsg==", + "version": "25.2.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.2.2.tgz", + "integrity": "sha512-BkmoP5/FhRYek5izySdkOneRyXYN35I860MFAGupTdebyE66uZaR+bXLHq8k4DirE5DwQi3NuhvRU1jqTVwUrQ==", "devOptional": true, "license": "MIT", "dependencies": { @@ -15997,13 +16001,6 @@ "node": ">=20.18.1" } }, - "node_modules/undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, - "license": "MIT" - }, "node_modules/unicorn-magic": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.3.0.tgz", @@ -16929,10 +16926,10 @@ }, "packages/cli": { "name": "@opensyntaxhq/autodocs", - "version": "1.0.0", + "version": "2.0.0", "license": "Apache-2.0", "dependencies": { - "@opensyntaxhq/autodocs-core": "^1.0.0", + "@opensyntaxhq/autodocs-core": "^2.0.0", "chalk": "^5.6.2", "chokidar": "^5.0.0", "commander": "^14.0.3", @@ -16949,59 +16946,49 @@ }, "devDependencies": { "@types/express": "^5.0.6", - "@types/inquirer": "^8.2.10", + "@types/inquirer": "^9.0.9", "@types/jest": "^30.0.0", - "@types/node": "^25.2.1", + "@types/node": "^25.2.2", "jest": "^30.2.0", "ts-jest": "^29.4.6", - "tsup": "^8.0.0", - "typescript": "^5.9.0" + "tsup": "^8.5.1", + "typescript": "^5.9.3" } }, "packages/core": { "name": "@opensyntaxhq/autodocs-core", - "version": "1.0.0", + "version": "2.0.0", "license": "Apache-2.0", "dependencies": { - "typescript": "^5.9.0" + "typescript": "^5.9.3" }, "devDependencies": { "@types/jest": "^30.0.0", - "@types/node": "^25.2.1", + "@types/node": "^25.2.2", "jest": "^30.2.0", "ts-jest": "^29.4.6", - "tsup": "^8.0.0" + "tsup": "^8.5.1" } }, "packages/plugins/examples": { "name": "@opensyntaxhq/autodocs-plugin-examples", - "version": "1.0.0", + "version": "2.0.0", "license": "Apache-2.0", "dependencies": { - "typescript": "^5.9.0" + "typescript": "^5.9.3" }, "devDependencies": { - "@opensyntaxhq/autodocs-core": "^1.0.0", - "@types/node": "^22.0.0", - "tsup": "^8.3.0" + "@opensyntaxhq/autodocs-core": "^2.0.0", + "@types/node": "^25.2.2", + "tsup": "^8.5.1" }, "peerDependencies": { - "@opensyntaxhq/autodocs-core": "^1.0.0" - } - }, - "packages/plugins/examples/node_modules/@types/node": { - "version": "22.19.8", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.8.tgz", - "integrity": "sha512-ebO/Yl+EAvVe8DnMfi+iaAyIqYdK0q/q0y0rw82INWEKJOBe6b/P3YWE8NW7oOlF/nXFNrHwhARrN/hdgDkraA==", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~6.21.0" + "@opensyntaxhq/autodocs-core": "^2.0.0" } }, "packages/plugins/markdown": { "name": "@opensyntaxhq/autodocs-plugin-markdown", - "version": "1.0.0", + "version": "2.0.0", "license": "Apache-2.0", "dependencies": { "glob": "^13.0.1", @@ -17009,23 +16996,13 @@ "marked": "^17.0.1" }, "devDependencies": { - "@opensyntaxhq/autodocs-core": "^1.0.0", - "@types/node": "^22.0.0", - "tsup": "^8.3.0", - "typescript": "^5.9.0" + "@opensyntaxhq/autodocs-core": "^2.0.0", + "@types/node": "^25.2.2", + "tsup": "^8.5.1", + "typescript": "^5.9.3" }, "peerDependencies": { - "@opensyntaxhq/autodocs-core": "^1.0.0" - } - }, - "packages/plugins/markdown/node_modules/@types/node": { - "version": "22.19.8", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.8.tgz", - "integrity": "sha512-ebO/Yl+EAvVe8DnMfi+iaAyIqYdK0q/q0y0rw82INWEKJOBe6b/P3YWE8NW7oOlF/nXFNrHwhARrN/hdgDkraA==", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~6.21.0" + "@opensyntaxhq/autodocs-core": "^2.0.0" } }, "packages/plugins/openapi": { @@ -17035,7 +17012,7 @@ }, "packages/ui": { "name": "@opensyntaxhq/autodocs-ui", - "version": "1.0.0", + "version": "2.0.0", "dependencies": { "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -17046,29 +17023,29 @@ "react": "^19.2.4", "react-dom": "^19.2.4", "react-markdown": "^10.1.0", - "react-router-dom": "^7.2.0", + "react-router-dom": "^7.13.0", "remark-gfm": "^4.0.1", "tailwind-merge": "^3.4.0", - "zustand": "^5.0.3" + "zustand": "^5.0.11" }, "devDependencies": { "@tailwindcss/postcss": "^4.1.18", "@tailwindcss/vite": "^4.1.18", - "@testing-library/jest-dom": "^6.8.0", - "@testing-library/react": "^16.3.0", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", "@types/react": "^19.2.13", - "@types/react-dom": "^19.0.3", + "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^5.1.3", "@vitest/coverage-v8": "^4.0.18", - "autoprefixer": "^10.4.20", - "eslint": "^9.19.0", + "autoprefixer": "^10.4.24", + "eslint": "^9.39.2", "jsdom": "^28.0.0", - "postcss": "^8.5.3", + "postcss": "^8.5.6", "rollup-plugin-visualizer": "^6.0.5", "tailwindcss": "^4.1.18", - "terser": "^5.39.0", - "typescript": "^5.7.3", + "terser": "^5.46.0", + "typescript": "^5.9.3", "vite": "^7.3.1", "vitest": "^4.0.18" } diff --git a/package.json b/package.json index 3888276..66b3df2 100644 --- a/package.json +++ b/package.json @@ -1,9 +1,9 @@ { "name": "autodocs-monorepo", - "version": "1.0.0", + "version": "2.0.0", "private": true, "license": "Apache-2.0", - "packageManager": "npm@11.7.0", + "packageManager": "npm@11.9.0", "workspaces": [ "packages/*", "packages/plugins/*" @@ -15,6 +15,7 @@ "format": "prettier --write \"**/*.{ts,tsx,md,json}\"", "format:check": "prettier --check \"**/*.{ts,tsx,md,json}\"", "test": "turbo run test", + "test:coverage": "turbo run test -- --coverage --maxWorkers=2", "test:e2e": "playwright test", "test:e2e:ui": "playwright test --ui", "test:e2e:debug": "playwright test --debug", @@ -27,20 +28,24 @@ }, "devDependencies": { "@eslint/js": "^9.39.2", - "@axe-core/playwright": "^4.10.1", + "@axe-core/playwright": "^4.11.1", "@playwright/test": "^1.58.2", - "@types/node": "^25.2.1", + "@types/node": "^25.2.2", "@types/tmp": "^0.2.6", - "eslint": "^9.0.0", - "execa": "^9.6.0", + "eslint": "^9.39.2", + "execa": "^9.6.1", "globals": "^17.3.0", - "husky": "^9.0.0", + "husky": "^9.1.7", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.2.0", "lint-staged": "^16.2.7", - "prettier": "^3.2.0", - "tmp": "^0.2.3", + "prettier": "^3.8.1", + "tmp": "^0.2.5", "turbo": "^2.8.3", - "typescript": "^5.9.0", + "typescript": "^5.9.3", "typescript-eslint": "^8.54.0", + "v8-to-istanbul": "^9.3.0", "vitest": "^4.0.18" } } diff --git a/packages/cli/README.md b/packages/cli/README.md index a0ef357..4f012cd 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -1,15 +1,47 @@ # @opensyntaxhq/autodocs -CLI for Autodocs documentation generator. +Engineer-first documentation generator for TypeScript. -## Installation +## Install ```bash -npm install -g @opensyntaxhq/autodocs +npm install -D @opensyntaxhq/autodocs ``` -## Usage +## Quick start ```bash -autodocs --help +npx autodocs init +npx autodocs build +npx autodocs serve ``` + +## Commands + +- `autodocs init` - create `autodocs.config.*` +- `autodocs build` - generate docs and UI in `docs-dist` +- `autodocs watch` - incremental rebuilds with cache +- `autodocs serve` - serve the generated site +- `autodocs check` - validate configuration and inputs + +## Config + +```ts +import { defineConfig } from '@opensyntaxhq/autodocs'; + +export default defineConfig({ + input: 'src', + output: 'docs-dist', + plugins: [ + { name: '@opensyntaxhq/autodocs-plugin-markdown', options: { sourceDir: 'docs' } }, + { + name: '@opensyntaxhq/autodocs-plugin-examples', + options: { validate: true, outputDir: 'examples' }, + }, + ], +}); +``` + +## Notes + +Set `SITE_URL` (env or config) to generate `sitemap.xml` and `robots.txt`. diff --git a/packages/cli/jest.config.js b/packages/cli/jest.config.js index 6306e06..1c32e05 100644 --- a/packages/cli/jest.config.js +++ b/packages/cli/jest.config.js @@ -10,9 +10,8 @@ module.exports = { '^.+\\.[tj]sx?$': [ 'ts-jest', { - tsconfig: { - allowJs: true, - }, + tsconfig: '/tsconfig.test.json', + allowJs: true, }, ], }, diff --git a/packages/cli/package.json b/packages/cli/package.json index 635c228..ba0116e 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@opensyntaxhq/autodocs", - "version": "1.0.0", + "version": "2.0.0", "description": "CLI for Autodocs documentation generator", "bin": { "autodocs": "dist/index.js" @@ -27,7 +27,7 @@ } }, "dependencies": { - "@opensyntaxhq/autodocs-core": "^1.0.0", + "@opensyntaxhq/autodocs-core": "^2.0.0", "chalk": "^5.6.2", "chokidar": "^5.0.0", "commander": "^14.0.3", @@ -41,13 +41,13 @@ }, "devDependencies": { "@types/express": "^5.0.6", - "@types/inquirer": "^8.2.10", + "@types/inquirer": "^9.0.9", "@types/jest": "^30.0.0", - "@types/node": "^25.2.1", + "@types/node": "^25.2.2", "jest": "^30.2.0", "ts-jest": "^29.4.6", - "tsup": "^8.0.0", - "typescript": "^5.9.0" + "tsup": "^8.5.1", + "typescript": "^5.9.3" }, "main": "./dist/index-exports.js", "types": "./dist/index-exports.d.ts", diff --git a/packages/cli/tests/build-helpers.test.ts b/packages/cli/tests/build-helpers.test.ts new file mode 100644 index 0000000..4c56fe3 --- /dev/null +++ b/packages/cli/tests/build-helpers.test.ts @@ -0,0 +1,537 @@ +import fs from 'fs/promises'; +import path from 'path'; +import { pathToFileURL } from 'url'; +import { Command } from 'commander'; +import type { Ora } from 'ora'; +import type { AutodocsConfig } from '../src/config'; +import { createTempDir } from './helpers/temp'; +import type { PluginManager } from '@opensyntaxhq/autodocs-core'; + +const spinner = { + text: '', + start: jest.fn().mockReturnThis(), + succeed: jest.fn().mockReturnThis(), + fail: jest.fn().mockReturnThis(), + warn: jest.fn().mockReturnThis(), + info: jest.fn().mockReturnThis(), +} as unknown as Ora; + +jest.mock('ora', () => () => spinner); + +jest.mock('glob', () => ({ + glob: jest.fn(), +})); + +jest.mock('../src/config', () => ({ + loadConfig: jest.fn(), + resolveConfigPaths: jest.fn(), +})); + +jest.mock('child_process', () => ({ + exec: jest.fn(), +})); + +const pluginManagerInstances: Array<{ cleanup: jest.Mock; runHook: jest.Mock }> = []; + +jest.mock('@opensyntaxhq/autodocs-core', () => ({ + VERSION: '0.0.0-test', + generateJson: jest.fn(), + generateMarkdown: jest.fn(), + generateStaticSite: jest.fn(), + generateHtml: jest.fn(), + createProgram: jest.fn(), + extractDocs: jest.fn(), + incrementalBuild: jest.fn(), + FileCache: jest.fn(), + PluginManager: class { + runHook = jest.fn((_hook: string, value: unknown) => value); + cleanup = jest.fn(); + constructor() { + pluginManagerInstances.push(this); + } + }, +})); + +import { glob } from 'glob'; +import { ChildProcess, exec } from 'child_process'; +import { loadConfig, resolveConfigPaths } from '../src/config'; +import { + createProgram, + extractDocs, + generateHtml, + generateJson, + generateMarkdown, + incrementalBuild, +} from '@opensyntaxhq/autodocs-core'; +import { loadPlugins, writeStaticDocs, buildReactUI, registerBuild } from '../src/commands/build'; + +const globMock = glob as unknown as jest.MockedFunction; +const execMock = exec as unknown as jest.MockedFunction; +const createChildProcess = (): ChildProcess => ({ pid: 0 }) as ChildProcess; + +describe('build helpers', () => { + beforeEach(() => { + jest.clearAllMocks(); + pluginManagerInstances.length = 0; + }); + + it('loads plugins from relative path and module name', async () => { + const tempDir = await createTempDir('autodocs-plugin-'); + const pluginPath = path.join(tempDir, 'plugin.cjs'); + await fs.writeFile( + pluginPath, + "module.exports = () => ({ name: 'plugin-relative' });\n", + 'utf-8' + ); + + const loadPlugin = jest.fn(); + const manager = { + loadPlugin, + runHook: jest.fn(), + cleanup: jest.fn(), + plugins: [], + context: {}, + } as unknown as PluginManager; + + await loadPlugins(manager, ['./plugin.cjs', 'autodocs-plugin-remote'], tempDir); + + expect(loadPlugin).toHaveBeenCalledWith(expect.objectContaining({ name: 'plugin-relative' })); + expect(loadPlugin).toHaveBeenCalledWith('autodocs-plugin-remote'); + }); + + it('loads plugins from file URLs and config objects', async () => { + const tempDir = await createTempDir('autodocs-plugin-'); + const filePluginPath = path.join(tempDir, 'plugin-file.cjs'); + const factoryPluginPath = path.join(tempDir, 'plugin-factory.cjs'); + + await fs.writeFile(filePluginPath, "module.exports = { name: 'plugin-file' };\n", 'utf-8'); + await fs.writeFile( + factoryPluginPath, + "module.exports = (options = {}) => ({ name: 'plugin-factory', options });\n", + 'utf-8' + ); + + const loadPlugin = jest.fn(); + const manager = { + loadPlugin, + runHook: jest.fn(), + cleanup: jest.fn(), + plugins: [], + context: {}, + } as unknown as PluginManager; + + await loadPlugins( + manager, + [ + pathToFileURL(filePluginPath).href, + { name: './plugin-factory.cjs', options: { foo: 'bar' } }, + ], + tempDir + ); + + expect(loadPlugin).toHaveBeenCalledWith(expect.objectContaining({ name: 'plugin-file' })); + expect(loadPlugin).toHaveBeenCalledWith( + expect.objectContaining({ name: 'plugin-factory', options: { foo: 'bar' } }) + ); + }); + + it('writes config payload and copies sidebar markdown', async () => { + const tempDir = await createTempDir('autodocs-write-'); + const configDir = path.join(tempDir, 'config'); + const docsDir = path.join(configDir, 'docs'); + await fs.mkdir(docsDir, { recursive: true }); + await fs.writeFile(path.join(docsDir, 'intro.md'), '# Intro', 'utf-8'); + + const logoPath = path.join(tempDir, 'logo.svg'); + await fs.writeFile(logoPath, '', 'utf-8'); + + const outputDir = path.join(tempDir, 'out'); + + await writeStaticDocs( + [ + { + id: 'Example', + name: 'Example', + kind: 'function', + fileName: 'src/example.ts', + source: { file: 'src/example.ts', line: 1, column: 0 }, + position: { line: 1, column: 0 }, + signature: 'function Example(): void', + }, + ], + outputDir, + { + configDir, + uiConfig: { + theme: { + name: 'default', + primaryColor: '#000000', + logo: logoPath, + favicon: './favicon.svg', + }, + sidebar: [ + { title: 'Intro', path: '/docs/intro.md' }, + { title: 'Remote', path: 'https://example.com/remote.md' }, + ], + }, + } + ); + + const configJson = JSON.parse( + await fs.readFile(path.join(outputDir, 'config.json'), 'utf-8') + ) as { theme?: { logo?: string; favicon?: string } }; + + expect(configJson.theme?.logo).toMatch(/^data:image\/svg\+xml;base64,/); + expect(configJson.theme?.favicon).toBe('./favicon.svg'); + + const copied = await fs.readFile(path.join(outputDir, 'docs', 'intro.md'), 'utf-8'); + expect(copied).toContain('# Intro'); + }); + + it('handles missing asset files and keeps data URLs intact', async () => { + const tempDir = await createTempDir('autodocs-assets-'); + const outputDir = path.join(tempDir, 'out'); + const missingLogo = path.join(tempDir, 'missing.svg'); + + await writeStaticDocs( + [ + { + id: 'Example', + name: 'Example', + kind: 'function', + fileName: 'src/example.ts', + source: { file: 'src/example.ts', line: 1, column: 0 }, + position: { line: 1, column: 0 }, + signature: 'function Example(): void', + }, + ], + outputDir, + { + configDir: tempDir, + uiConfig: { + theme: { + name: 'default', + primaryColor: '#000000', + logo: missingLogo, + favicon: 'data:image/svg+xml;base64,PHN2Zz48L3N2Zz4=', + }, + }, + } + ); + + const configJson = JSON.parse( + await fs.readFile(path.join(outputDir, 'config.json'), 'utf-8') + ) as { theme?: { logo?: string; favicon?: string } }; + + expect(configJson.theme?.logo).toBeUndefined(); + expect(configJson.theme?.favicon).toBe('data:image/svg+xml;base64,PHN2Zz48L3N2Zz4='); + }); + + it('falls back to HTML generator when UI package is missing', async () => { + const outputDir = path.join(await createTempDir('autodocs-build-'), 'out'); + + await buildReactUI( + [ + { + id: 'Example', + name: 'Example', + kind: 'function', + fileName: 'src/example.ts', + source: { file: 'src/example.ts', line: 1, column: 0 }, + position: { line: 1, column: 0 }, + signature: 'function Example(): void', + }, + ], + outputDir, + spinner, + { + uiDir: path.join(outputDir, 'missing-ui'), + uiConfig: { theme: { name: 'default' } }, + } + ); + + expect(generateHtml).toHaveBeenCalled(); + }); + + it('falls back to HTML generator when UI build fails', async () => { + const tempDir = await createTempDir('autodocs-build-'); + const uiDir = path.join(tempDir, 'ui'); + await fs.mkdir(uiDir, { recursive: true }); + execMock.mockImplementation((...args: Parameters) => { + const cb = typeof args[1] === 'function' ? args[1] : args[2]; + if (cb) { + cb(new Error('build failed'), '', ''); + } + return createChildProcess(); + }); + + await buildReactUI( + [ + { + id: 'Example', + name: 'Example', + kind: 'function', + fileName: 'src/example.ts', + source: { file: 'src/example.ts', line: 1, column: 0 }, + position: { line: 1, column: 0 }, + signature: 'function Example(): void', + }, + ], + path.join(tempDir, 'out'), + spinner, + { + uiDir, + uiConfig: { theme: { name: 'default' } }, + } + ); + + expect(generateHtml).toHaveBeenCalled(); + }); +}); + +describe('registerBuild', () => { + const baseConfig: AutodocsConfig = { + include: ['src/**/*.ts'], + output: { dir: '/tmp/out', format: 'json', clean: true }, + theme: { name: 'default' }, + cache: false, + } as AutodocsConfig; + + beforeEach(() => { + jest.clearAllMocks(); + pluginManagerInstances.length = 0; + }); + + it('exits when no config is found', async () => { + (loadConfig as jest.Mock).mockResolvedValueOnce(null); + + const program = new Command(); + registerBuild(program); + + const exitSpy = jest.spyOn(process, 'exit').mockImplementation(((code?: number) => { + throw new Error(`exit:${String(code ?? '')}`); + }) as never); + + await expect(program.parseAsync(['node', 'cli', 'build'])).rejects.toThrow('exit:1'); + + exitSpy.mockRestore(); + }); + + it('exits when no files are found', async () => { + (loadConfig as jest.Mock).mockResolvedValueOnce(baseConfig); + (resolveConfigPaths as jest.Mock).mockReturnValueOnce(baseConfig); + globMock.mockResolvedValueOnce([]); + + const program = new Command(); + registerBuild(program); + + const exitSpy = jest.spyOn(process, 'exit').mockImplementation(((code?: number) => { + throw new Error(`exit:${String(code ?? '')}`); + }) as never); + + await expect(program.parseAsync(['node', 'cli', 'build'])).rejects.toThrow('exit:1'); + + exitSpy.mockRestore(); + }); + + it('signals exit 0 when no docs are extracted', async () => { + (loadConfig as jest.Mock).mockResolvedValueOnce(baseConfig); + (resolveConfigPaths as jest.Mock).mockReturnValueOnce(baseConfig); + globMock.mockResolvedValueOnce(['/tmp/example.ts']); + + (createProgram as jest.Mock).mockReturnValueOnce({ + program: {}, + sourceFiles: [], + diagnostics: [], + rootDir: '/tmp', + }); + (extractDocs as jest.Mock).mockReturnValueOnce([]); + + const program = new Command(); + registerBuild(program); + + const exitSpy = jest.spyOn(process, 'exit').mockImplementation(((code?: number) => { + throw new Error(`exit:${String(code ?? '')}`); + }) as never); + + await expect(program.parseAsync(['node', 'cli', 'build'])).rejects.toThrow('exit:1'); + + expect(exitSpy).toHaveBeenCalledWith(0); + + exitSpy.mockRestore(); + }); + + it('generates JSON output when format is json', async () => { + const config = { ...baseConfig, output: { ...baseConfig.output, format: 'json' } }; + + (loadConfig as jest.Mock).mockResolvedValueOnce(config); + (resolveConfigPaths as jest.Mock).mockReturnValueOnce(config); + globMock.mockResolvedValueOnce(['/tmp/example.ts']); + (createProgram as jest.Mock).mockReturnValueOnce({ + program: {}, + sourceFiles: [], + diagnostics: [], + rootDir: '/tmp', + }); + (extractDocs as jest.Mock).mockReturnValueOnce([ + { + id: 'Example', + name: 'Example', + kind: 'function', + fileName: 'src/example.ts', + source: { file: 'src/example.ts', line: 1, column: 0 }, + position: { line: 1, column: 0 }, + signature: 'function Example(): void', + }, + ]); + + const program = new Command(); + registerBuild(program); + + await program.parseAsync(['node', 'cli', 'build']); + + expect(generateJson).toHaveBeenCalled(); + }); + + it('generates Markdown output when format is markdown', async () => { + const config = { ...baseConfig, output: { ...baseConfig.output, format: 'markdown' } }; + + (loadConfig as jest.Mock).mockResolvedValueOnce(config); + (resolveConfigPaths as jest.Mock).mockReturnValueOnce(config); + globMock.mockResolvedValueOnce(['/tmp/example.ts']); + (createProgram as jest.Mock).mockReturnValueOnce({ + program: {}, + sourceFiles: [], + diagnostics: [], + rootDir: '/tmp', + }); + (extractDocs as jest.Mock).mockReturnValueOnce([ + { + id: 'Example', + name: 'Example', + kind: 'function', + fileName: 'src/example.ts', + source: { file: 'src/example.ts', line: 1, column: 0 }, + position: { line: 1, column: 0 }, + signature: 'function Example(): void', + }, + ]); + + const program = new Command(); + registerBuild(program); + + await program.parseAsync(['node', 'cli', 'build']); + + expect(generateMarkdown).toHaveBeenCalled(); + }); + + it('uses incremental build when cache is enabled', async () => { + const config = { ...baseConfig, cache: true }; + + (loadConfig as jest.Mock).mockResolvedValueOnce(config); + (resolveConfigPaths as jest.Mock).mockReturnValueOnce(config); + globMock.mockResolvedValueOnce(['/tmp/example.ts']); + (incrementalBuild as jest.Mock).mockResolvedValueOnce({ + docs: [ + { + id: 'Example', + name: 'Example', + kind: 'function', + fileName: 'src/example.ts', + source: { file: 'src/example.ts', line: 1, column: 0 }, + position: { line: 1, column: 0 }, + signature: 'function Example(): void', + }, + ], + rootDir: '/tmp', + diagnostics: [], + changedFiles: ['src/example.ts'], + fromCache: 0, + }); + + const program = new Command(); + registerBuild(program); + + await program.parseAsync(['node', 'cli', 'build']); + + expect(incrementalBuild).toHaveBeenCalled(); + }); + + it('prints diagnostics when verbose mode is enabled', async () => { + const config = { ...baseConfig, verbose: true }; + + (loadConfig as jest.Mock).mockResolvedValueOnce(config); + (resolveConfigPaths as jest.Mock).mockReturnValueOnce(config); + globMock.mockResolvedValueOnce(['/tmp/example.ts']); + (createProgram as jest.Mock).mockReturnValueOnce({ + program: {}, + sourceFiles: [], + diagnostics: [{ messageText: 'Something went wrong' }], + rootDir: '/tmp', + }); + (extractDocs as jest.Mock).mockReturnValueOnce([ + { + id: 'Example', + name: 'Example', + kind: 'function', + fileName: 'src/example.ts', + source: { file: 'src/example.ts', line: 1, column: 0 }, + position: { line: 1, column: 0 }, + signature: 'function Example(): void', + }, + ]); + + const logSpy = jest.spyOn(console, 'log').mockImplementation(() => undefined); + + const program = new Command(); + registerBuild(program); + + await program.parseAsync(['node', 'cli', 'build']); + + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('Something went wrong')); + + logSpy.mockRestore(); + }); + + it('cleans up plugins on failure', async () => { + const config = { ...baseConfig, output: { ...baseConfig.output, format: 'json' } }; + + (loadConfig as jest.Mock).mockResolvedValueOnce(config); + (resolveConfigPaths as jest.Mock).mockReturnValueOnce(config); + globMock.mockResolvedValueOnce(['/tmp/example.ts']); + (createProgram as jest.Mock).mockReturnValueOnce({ + program: {}, + sourceFiles: [], + diagnostics: [], + rootDir: '/tmp', + }); + (extractDocs as jest.Mock).mockReturnValueOnce([ + { + id: 'Example', + name: 'Example', + kind: 'function', + fileName: 'src/example.ts', + source: { file: 'src/example.ts', line: 1, column: 0 }, + position: { line: 1, column: 0 }, + signature: 'function Example(): void', + }, + ]); + + (generateJson as jest.Mock).mockImplementationOnce(() => { + throw new Error('boom'); + }); + + const program = new Command(); + registerBuild(program); + + const exitSpy = jest.spyOn(process, 'exit').mockImplementation(((code?: number) => { + throw new Error(`exit:${String(code ?? '')}`); + }) as never); + + await expect(program.parseAsync(['node', 'cli', 'build'])).rejects.toThrow('exit:1'); + + expect(pluginManagerInstances[0]?.cleanup).toHaveBeenCalled(); + + exitSpy.mockRestore(); + }); +}); diff --git a/packages/cli/tests/cli-entry.test.ts b/packages/cli/tests/cli-entry.test.ts new file mode 100644 index 0000000..09c6658 --- /dev/null +++ b/packages/cli/tests/cli-entry.test.ts @@ -0,0 +1,69 @@ +const programMock = { + name: jest.fn().mockReturnThis(), + description: jest.fn().mockReturnThis(), + version: jest.fn().mockReturnThis(), + parse: jest.fn(), + outputHelp: jest.fn(), +}; + +const commandMock = jest.fn(() => programMock); + +const registerInit = jest.fn(); +const registerBuild = jest.fn(); +const registerCheck = jest.fn(); +const registerServe = jest.fn(); +const registerWatch = jest.fn(); + +jest.mock('commander', () => ({ + Command: commandMock, +})); + +jest.mock('../src/commands/init', () => ({ registerInit })); +jest.mock('../src/commands/build', () => ({ registerBuild })); +jest.mock('../src/commands/check', () => ({ registerCheck })); +jest.mock('../src/commands/serve', () => ({ registerServe })); +jest.mock('../src/commands/watch', () => ({ registerWatch })); + +jest.mock('@opensyntaxhq/autodocs-core', () => ({ + VERSION: '0.0.0-test', +})); + +describe('cli entrypoint', () => { + const originalArgv = process.argv; + + beforeEach(() => { + jest.clearAllMocks(); + process.argv = [...originalArgv]; + }); + + afterEach(() => { + process.argv = originalArgv; + }); + + it('registers commands and shows help with no args', async () => { + process.argv = ['node', 'autodocs']; + + await jest.isolateModulesAsync(async () => { + await import('../src/index'); + }); + + expect(commandMock).toHaveBeenCalled(); + expect(registerInit).toHaveBeenCalledWith(programMock); + expect(registerBuild).toHaveBeenCalledWith(programMock); + expect(registerCheck).toHaveBeenCalledWith(programMock); + expect(registerServe).toHaveBeenCalledWith(programMock); + expect(registerWatch).toHaveBeenCalledWith(programMock); + expect(programMock.parse).toHaveBeenCalledWith(process.argv); + expect(programMock.outputHelp).toHaveBeenCalled(); + }); + + it('does not show help when args are provided', async () => { + process.argv = ['node', 'autodocs', 'build']; + + await jest.isolateModulesAsync(async () => { + await import('../src/index'); + }); + + expect(programMock.outputHelp).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/cli/tests/commands-check.test.ts b/packages/cli/tests/commands-check.test.ts new file mode 100644 index 0000000..0dcc137 --- /dev/null +++ b/packages/cli/tests/commands-check.test.ts @@ -0,0 +1,134 @@ +import { Command } from 'commander'; +import { registerCheck } from '../src/commands/check'; +import type { AutodocsConfig } from '../src/config'; + +jest.mock('ora', () => () => ({ + text: '', + start: jest.fn().mockReturnThis(), + succeed: jest.fn().mockReturnThis(), + fail: jest.fn().mockReturnThis(), + warn: jest.fn().mockReturnThis(), + info: jest.fn().mockReturnThis(), +})); + +jest.mock('../src/config', () => ({ + loadConfig: jest.fn(), + resolveConfigPaths: jest.fn(), +})); + +jest.mock('glob', () => ({ + glob: jest.fn(), +})); + +jest.mock('fs/promises', () => ({ + access: jest.fn(), + stat: jest.fn(), +})); + +import { loadConfig, resolveConfigPaths } from '../src/config'; +import { glob } from 'glob'; +import fs from 'fs/promises'; + +const globMock = glob as unknown as jest.MockedFunction; + +describe('check command', () => { + const baseConfig: AutodocsConfig = { + include: ['src/**/*.ts'], + output: { dir: './docs-dist', format: 'json', clean: true }, + } as AutodocsConfig; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('exits when no config is found', async () => { + (loadConfig as jest.Mock).mockResolvedValueOnce(null); + + const program = new Command(); + registerCheck(program); + + const exitSpy = jest.spyOn(process, 'exit').mockImplementation(((code?: number) => { + throw new Error(`exit:${String(code ?? '')}`); + }) as never); + + await expect(program.parseAsync(['node', 'cli', 'check'])).rejects.toThrow('exit:1'); + + exitSpy.mockRestore(); + }); + + it('exits when no files match include patterns', async () => { + (loadConfig as jest.Mock).mockResolvedValueOnce(baseConfig); + (resolveConfigPaths as jest.Mock).mockReturnValueOnce(baseConfig); + globMock.mockResolvedValueOnce([]); + (fs.stat as jest.Mock).mockRejectedValueOnce(new Error('missing')); + + const program = new Command(); + registerCheck(program); + + const exitSpy = jest.spyOn(process, 'exit').mockImplementation(((code?: number) => { + throw new Error(`exit:${String(code ?? '')}`); + }) as never); + + await expect(program.parseAsync(['node', 'cli', 'check'])).rejects.toThrow('exit:1'); + + exitSpy.mockRestore(); + }); + + it('warns when tsconfig is missing but does not exit', async () => { + const config: AutodocsConfig = { + ...baseConfig, + tsconfig: './tsconfig.json', + } as AutodocsConfig; + + (loadConfig as jest.Mock).mockResolvedValueOnce(config); + (resolveConfigPaths as jest.Mock).mockReturnValueOnce(config); + globMock.mockResolvedValueOnce(['/tmp/example.ts']); + (fs.access as jest.Mock).mockRejectedValueOnce(new Error('missing')); + (fs.stat as jest.Mock).mockRejectedValueOnce(new Error('missing')); + + const program = new Command(); + registerCheck(program); + + const exitSpy = jest.spyOn(process, 'exit').mockImplementation(jest.fn() as never); + + await program.parseAsync(['node', 'cli', 'check']); + + expect(exitSpy).not.toHaveBeenCalled(); + exitSpy.mockRestore(); + }); + + it('exits when output path exists but is not a directory', async () => { + (loadConfig as jest.Mock).mockResolvedValueOnce(baseConfig); + (resolveConfigPaths as jest.Mock).mockReturnValueOnce(baseConfig); + globMock.mockResolvedValueOnce(['/tmp/example.ts']); + (fs.stat as jest.Mock).mockResolvedValueOnce({ isDirectory: () => false }); + + const program = new Command(); + registerCheck(program); + + const exitSpy = jest.spyOn(process, 'exit').mockImplementation(((code?: number) => { + throw new Error(`exit:${String(code ?? '')}`); + }) as never); + + await expect(program.parseAsync(['node', 'cli', 'check'])).rejects.toThrow('exit:1'); + + exitSpy.mockRestore(); + }); + + it('completes successfully when no issues are found', async () => { + (loadConfig as jest.Mock).mockResolvedValueOnce(baseConfig); + (resolveConfigPaths as jest.Mock).mockReturnValueOnce(baseConfig); + globMock.mockResolvedValueOnce(['/tmp/example.ts']); + (fs.stat as jest.Mock).mockResolvedValueOnce({ isDirectory: () => true }); + + const program = new Command(); + registerCheck(program); + + const exitSpy = jest.spyOn(process, 'exit').mockImplementation(jest.fn() as never); + + await program.parseAsync(['node', 'cli', 'check']); + + expect(exitSpy).not.toHaveBeenCalled(); + exitSpy.mockRestore(); + }); +}); diff --git a/packages/cli/tests/commands-init.test.ts b/packages/cli/tests/commands-init.test.ts index 9d7e62f..c6689af 100644 --- a/packages/cli/tests/commands-init.test.ts +++ b/packages/cli/tests/commands-init.test.ts @@ -10,20 +10,25 @@ jest.mock('inquirer', () => ({ import inquirer from 'inquirer'; -const mockPrompt = inquirer.prompt as jest.Mock; +const mockPrompt = jest.mocked(inquirer.prompt); + +const makePromptReturn = (answers: Record) => + Object.assign(Promise.resolve(answers), { ui: {} }) as ReturnType; describe('init command', () => { const originalCwd = process.cwd(); beforeEach(() => { - mockPrompt.mockResolvedValue({ - include: 'src/**/*.ts', - outputDir: './docs-dist', - format: 'json', - primaryColor: '#6366f1', - darkMode: true, - search: true, - }); + mockPrompt.mockImplementation(() => + makePromptReturn({ + include: 'src/**/*.ts', + outputDir: './docs-dist', + format: 'json', + primaryColor: '#6366f1', + darkMode: true, + search: true, + }) + ); }); afterEach(() => { @@ -65,4 +70,88 @@ describe('init command', () => { const configContent = await fs.readFile(configPath, 'utf-8'); expect(configContent).toBe('existing'); }); + + it('writes JavaScript config with require syntax', async () => { + const tempDir = await createTempDir('autodocs-init-'); + process.chdir(tempDir); + + const program = new Command(); + registerInit(program); + + await program.parseAsync(['node', 'cli', 'init', '--javascript']); + + const configPath = path.join(tempDir, 'autodocs.config.js'); + const configContent = await fs.readFile(configPath, 'utf-8'); + expect(configContent).toContain("const { defineConfig } = require('@opensyntaxhq/autodocs');"); + expect(configContent).toContain('module.exports = defineConfig('); + }); + + it('validates the primary color prompt', async () => { + const tempDir = await createTempDir('autodocs-init-'); + process.chdir(tempDir); + + let questions: Array<{ name?: string; validate?: (input: string) => boolean | string }> = []; + mockPrompt.mockImplementation((qs) => { + questions = qs as unknown as typeof questions; + return makePromptReturn({ + include: 'src/**/*.ts', + outputDir: './docs-dist', + format: 'json', + primaryColor: '#6366f1', + darkMode: true, + search: true, + }); + }); + + const program = new Command(); + registerInit(program); + + await program.parseAsync(['node', 'cli', 'init', '--json']); + + const colorQuestion = questions.find((q) => q.name === 'primaryColor'); + expect(colorQuestion?.validate?.('#ZZZZZZ')).toBe('Invalid hex color'); + expect(colorQuestion?.validate?.('#abcdef')).toBe(true); + }); + + it('warns when gitignore update fails', async () => { + const tempDir = await createTempDir('autodocs-init-'); + process.chdir(tempDir); + + const realWriteFile = fs.writeFile; + const writeFileSpy = jest.spyOn(fs, 'writeFile'); + writeFileSpy.mockImplementationOnce((...args) => realWriteFile(...args)); + writeFileSpy.mockImplementationOnce(() => Promise.reject(new Error('fail'))); + + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => undefined); + const exitSpy = jest.spyOn(process, 'exit').mockImplementation(jest.fn() as never); + + const program = new Command(); + registerInit(program); + + await program.parseAsync(['node', 'cli', 'init', '--json']); + + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Could not update .gitignore')); + expect(exitSpy).not.toHaveBeenCalled(); + + warnSpy.mockRestore(); + exitSpy.mockRestore(); + }); + + it('exits when config write fails', async () => { + const tempDir = await createTempDir('autodocs-init-'); + process.chdir(tempDir); + + jest.spyOn(fs, 'writeFile').mockRejectedValueOnce(new Error('fail')); + + const exitSpy = jest.spyOn(process, 'exit').mockImplementation(((code?: number) => { + throw new Error(`exit:${String(code ?? '')}`); + }) as never); + + const program = new Command(); + registerInit(program); + + await expect(program.parseAsync(['node', 'cli', 'init', '--json'])).rejects.toThrow('exit:1'); + + exitSpy.mockRestore(); + }); }); diff --git a/packages/cli/tests/commands-serve.test.ts b/packages/cli/tests/commands-serve.test.ts index d166262..1222ed3 100644 --- a/packages/cli/tests/commands-serve.test.ts +++ b/packages/cli/tests/commands-serve.test.ts @@ -7,8 +7,22 @@ import { createTempDir } from './helpers/temp'; const listenMock = jest.fn((_port: number, _host: string, cb: () => void) => { cb(); }); +type ExpressHandler = ( + _req: unknown, + res: { + sendFile: (file: string, cb: (err?: unknown) => void) => void; + status: (code: number) => { send: (body: string) => void }; + send: (body: string) => void; + } +) => void; +type ExpressResponse = { + sendFile: (file: string, cb: (err?: unknown) => void) => void; + status: (code: number) => { send: (body: string) => void }; + send: (body: string) => void; +}; + const useMock = jest.fn(); -const getMock = jest.fn(); +const getMock = jest.fn(); const expressMock = Object.assign( () => ({ @@ -26,14 +40,23 @@ jest.mock('express', () => ({ default: expressMock, })); +jest.mock('open', () => ({ + __esModule: true, + default: jest.fn(), +})); + +import open from 'open'; + describe('serve command', () => { const originalCwd = process.cwd(); + const openMock = open as jest.Mock; afterEach(() => { process.chdir(originalCwd); listenMock.mockClear(); useMock.mockClear(); getMock.mockClear(); + openMock.mockClear(); }); it('exits when docs directory is missing', async () => { @@ -78,4 +101,61 @@ describe('serve command', () => { expect(listenMock).toHaveBeenCalledWith(4567, '127.0.0.1', expect.any(Function)); }); + + it('opens browser when --open is provided', async () => { + const tempDir = await createTempDir('autodocs-serve-'); + const docsDir = path.join(tempDir, 'docs-dist'); + await fs.mkdir(docsDir, { recursive: true }); + await fs.writeFile(path.join(docsDir, 'index.html'), '', 'utf-8'); + process.chdir(tempDir); + + const program = new Command(); + registerServe(program); + + await program.parseAsync([ + 'node', + 'cli', + 'serve', + '--docs', + docsDir, + '--port', + '4567', + '--host', + '127.0.0.1', + '--open', + ]); + + expect(openMock).toHaveBeenCalledWith('http://127.0.0.1:4567'); + }); + + it('returns 404 when index.html is missing', async () => { + const tempDir = await createTempDir('autodocs-serve-'); + const docsDir = path.join(tempDir, 'docs-dist'); + await fs.mkdir(docsDir, { recursive: true }); + process.chdir(tempDir); + + const program = new Command(); + registerServe(program); + + await program.parseAsync(['node', 'cli', 'serve', '--docs', docsDir]); + + expect(getMock).toHaveBeenCalled(); + const handler = getMock.mock.calls[0]?.[1]; + if (!handler) return; + + const res: ExpressResponse = { + sendFile: jest.fn((_file: string, cb: (err?: unknown) => void) => { + cb(new Error('missing')); + }), + status: jest.fn(function status(this: ExpressResponse) { + return this; + }), + send: jest.fn(), + }; + + handler({}, res); + + expect(res.status).toHaveBeenCalledWith(404); + expect(res.send).toHaveBeenCalledWith('Documentation not found. Run: autodocs build'); + }); }); diff --git a/packages/cli/tests/commands-watch.test.ts b/packages/cli/tests/commands-watch.test.ts index d463b96..e8b05d1 100644 --- a/packages/cli/tests/commands-watch.test.ts +++ b/packages/cli/tests/commands-watch.test.ts @@ -1,9 +1,16 @@ import path from 'path'; +import { EventEmitter } from 'events'; +import { Command } from 'commander'; jest.mock('glob', () => ({ glob: jest.fn(() => Promise.resolve([path.resolve('/tmp/example.ts')])), })); +jest.mock('../src/config', () => ({ + loadConfig: jest.fn(), + resolveConfigPaths: jest.fn(), +})); + jest.mock('../src/commands/build', () => ({ buildReactUI: jest.fn(), loadPlugins: jest.fn(), @@ -11,7 +18,10 @@ jest.mock('../src/commands/build', () => ({ })); jest.mock('@opensyntaxhq/autodocs-core', () => { + const pluginInstances: Array<{ cleanup: jest.Mock; runHook: jest.Mock }> = []; return { + generateJson: jest.fn(), + generateMarkdown: jest.fn(), createProgram: jest.fn(() => ({ program: {}, sourceFiles: [], @@ -22,14 +32,41 @@ jest.mock('@opensyntaxhq/autodocs-core', () => { PluginManager: class { runHook = jest.fn((_hook: string, value: unknown) => Promise.resolve(value)); cleanup = jest.fn(); + constructor() { + pluginInstances.push(this); + } }, FileCache: jest.fn(), incrementalBuild: jest.fn(), + __pluginInstances: pluginInstances, }; }); -import { runBuild } from '../src/commands/watch'; +const watcherInstances: Array = []; + +class FileWatcherMock extends EventEmitter { + start = jest.fn(); + stop = jest.fn(() => Promise.resolve()); + options: unknown; + + constructor(options: unknown) { + super(); + this.options = options; + watcherInstances.push(this); + } +} + +jest.mock('../src/utils/watcher', () => ({ + FileWatcher: FileWatcherMock, +})); + +import { glob } from 'glob'; +import { runBuild, registerWatch } from '../src/commands/watch'; import { buildReactUI, writeStaticDocs } from '../src/commands/build'; +import { loadConfig, resolveConfigPaths } from '../src/config'; +import { generateJson, generateMarkdown, incrementalBuild } from '@opensyntaxhq/autodocs-core'; + +const globMock = glob as unknown as jest.MockedFunction; describe('watch command build modes', () => { const baseConfig = { @@ -39,6 +76,18 @@ describe('watch command build modes', () => { cache: false, }; + beforeEach(() => { + watcherInstances.length = 0; + jest.clearAllMocks(); + process.removeAllListeners('SIGINT'); + const coreModule: { __pluginInstances?: Array } = jest.requireMock( + '@opensyntaxhq/autodocs-core' + ); + if (coreModule.__pluginInstances) { + coreModule.__pluginInstances.length = 0; + } + }); + it('runs full build when mode is full', async () => { await runBuild({ config: baseConfig as import('../src/config').AutodocsConfig, @@ -58,4 +107,188 @@ describe('watch command build modes', () => { expect(writeStaticDocs).toHaveBeenCalled(); }); + + it('returns early when no files are found', async () => { + globMock.mockResolvedValueOnce([]); + + await runBuild({ + config: baseConfig as import('../src/config').AutodocsConfig, + configDir: '/tmp', + mode: 'full', + }); + + expect(buildReactUI).not.toHaveBeenCalled(); + expect(writeStaticDocs).not.toHaveBeenCalled(); + }); + + it('uses incremental build cache and writes JSON output', async () => { + const config = { + ...baseConfig, + cache: true, + output: { ...baseConfig.output, format: 'json' }, + } as import('../src/config').AutodocsConfig; + + (incrementalBuild as jest.Mock).mockResolvedValueOnce({ + docs: [ + { + id: 'Example', + name: 'Example', + kind: 'function', + fileName: 'src/example.ts', + source: { file: 'src/example.ts', line: 1, column: 0 }, + position: { line: 1, column: 0 }, + signature: 'function Example(): void', + }, + ], + rootDir: '/tmp', + diagnostics: [], + changedFiles: ['src/example.ts'], + fromCache: 0, + }); + + await runBuild({ + config, + configDir: '/tmp', + mode: 'full', + }); + + expect(incrementalBuild).toHaveBeenCalled(); + expect(generateJson).toHaveBeenCalled(); + }); + + it('generates markdown output when format is markdown', async () => { + const config = { + ...baseConfig, + cache: false, + output: { ...baseConfig.output, format: 'markdown' }, + } as import('../src/config').AutodocsConfig; + + await runBuild({ + config, + configDir: '/tmp', + mode: 'full', + }); + + expect(generateMarkdown).toHaveBeenCalled(); + }); + + it('cleans up plugin manager on build failure', async () => { + const config = { + ...baseConfig, + output: { ...baseConfig.output, format: 'static' }, + } as import('../src/config').AutodocsConfig; + + (buildReactUI as jest.Mock).mockRejectedValueOnce(new Error('boom')); + + const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined); + + await runBuild({ + config, + configDir: '/tmp', + mode: 'full', + }); + + const coreModule = await import('@opensyntaxhq/autodocs-core'); + const instances = ( + coreModule as unknown as { __pluginInstances?: Array<{ cleanup: jest.Mock }> } + ).__pluginInstances; + + expect(instances?.[0]?.cleanup).toHaveBeenCalled(); + + consoleSpy.mockRestore(); + }); + + it('skips watcher setup when config is missing', async () => { + (loadConfig as jest.Mock).mockResolvedValueOnce(null); + + const program = new Command(); + registerWatch(program); + + await program.parseAsync(['node', 'cli', 'watch']); + + expect(watcherInstances).toHaveLength(0); + }); + + it('logs when watcher is ready', async () => { + (loadConfig as jest.Mock).mockResolvedValueOnce(baseConfig); + (resolveConfigPaths as jest.Mock).mockReturnValueOnce(baseConfig); + + const logSpy = jest.spyOn(console, 'log').mockImplementation(() => undefined); + + const program = new Command(); + registerWatch(program); + + await program.parseAsync(['node', 'cli', 'watch']); + + const watcher = watcherInstances[0]; + if (!watcher) { + throw new Error('Watcher was not initialized'); + } + watcher.emit('ready'); + + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('Watching for changes')); + + logSpy.mockRestore(); + }); + + it('triggers docs-only rebuild on change', async () => { + (loadConfig as jest.Mock).mockResolvedValueOnce(baseConfig); + (resolveConfigPaths as jest.Mock).mockReturnValueOnce(baseConfig); + + const program = new Command(); + registerWatch(program); + + await program.parseAsync(['node', 'cli', 'watch']); + + const watcher = watcherInstances[0]; + if (!watcher) { + throw new Error('Watcher was not initialized'); + } + watcher.emit('change', '/tmp/example.ts'); + + await new Promise((resolve) => setImmediate(resolve)); + + expect(writeStaticDocs).toHaveBeenCalled(); + }); + + it('handles SIGINT by stopping watcher and exiting', async () => { + (loadConfig as jest.Mock).mockResolvedValueOnce(baseConfig); + (resolveConfigPaths as jest.Mock).mockReturnValueOnce(baseConfig); + + const program = new Command(); + registerWatch(program); + + await program.parseAsync(['node', 'cli', 'watch']); + + const exitSpy = jest.spyOn(process, 'exit').mockImplementation(jest.fn() as never); + + process.emit('SIGINT'); + + await new Promise((resolve) => setImmediate(resolve)); + + expect(watcherInstances).toHaveLength(1); + const watcher = watcherInstances[0]; + if (!watcher) { + throw new Error('Watcher was not initialized'); + } + expect(watcher.stop).toHaveBeenCalled(); + expect(exitSpy).toHaveBeenCalledWith(0); + + exitSpy.mockRestore(); + }); + + it('exits when watch configuration load fails', async () => { + (loadConfig as jest.Mock).mockRejectedValueOnce(new Error('boom')); + + const program = new Command(); + registerWatch(program); + + const exitSpy = jest.spyOn(process, 'exit').mockImplementation(((code?: number) => { + throw new Error(`exit:${String(code ?? '')}`); + }) as never); + + await expect(program.parseAsync(['node', 'cli', 'watch'])).rejects.toThrow('exit:1'); + + exitSpy.mockRestore(); + }); }); diff --git a/packages/cli/tests/config-loader.test.ts b/packages/cli/tests/config-loader.test.ts index 72be4dd..b31be18 100644 --- a/packages/cli/tests/config-loader.test.ts +++ b/packages/cli/tests/config-loader.test.ts @@ -36,6 +36,7 @@ describe('config loader', () => { const config = resolveConfigPaths( { include: ['src/**/*.ts'], + exclude: ['dist'], output: { dir: './docs-dist', format: 'json' }, theme: { name: 'default', logo: './assets/logo.svg' }, } as import('../src/config').AutodocsConfig, @@ -44,6 +45,26 @@ describe('config loader', () => { expect(config.output.dir).toBe(path.join(tempDir, 'docs-dist')); expect(config.include[0]).toBe(path.join(tempDir, 'src/**/*.ts')); + expect(config.exclude?.[0]).toBe(path.join(tempDir, 'dist')); expect(config.theme?.logo).toBe(path.join(tempDir, 'assets/logo.svg')); }); + + it('keeps absolute and remote asset paths', async () => { + const tempDir = await createTempDir(); + const config = resolveConfigPaths( + { + include: ['src/**/*.ts'], + output: { dir: './docs-dist', format: 'json' }, + theme: { + name: 'default', + logo: 'https://example.com/logo.svg', + favicon: '/var/tmp/icon.ico', + }, + } as import('../src/config').AutodocsConfig, + tempDir + ); + + expect(config.theme?.logo).toBe('https://example.com/logo.svg'); + expect(config.theme?.favicon).toBe('/var/tmp/icon.ico'); + }); }); diff --git a/packages/cli/tests/index-exports.test.ts b/packages/cli/tests/index-exports.test.ts new file mode 100644 index 0000000..48e250b --- /dev/null +++ b/packages/cli/tests/index-exports.test.ts @@ -0,0 +1,13 @@ +import { defineConfig } from '../src/index-exports'; +import type { AutodocsConfig } from '../src/config'; + +describe('index-exports', () => { + it('returns the config unchanged', () => { + const config: AutodocsConfig = { + include: ['src/**/*.ts'], + output: { dir: './docs-dist', format: 'json', clean: true }, + } as AutodocsConfig; + + expect(defineConfig(config)).toBe(config); + }); +}); diff --git a/packages/cli/tests/setup.ts b/packages/cli/tests/setup.ts index 9215114..3fa54f7 100644 --- a/packages/cli/tests/setup.ts +++ b/packages/cli/tests/setup.ts @@ -1,6 +1,9 @@ -import { afterAll, afterEach, jest } from '@jest/globals'; +import { afterAll, afterEach, beforeEach, jest } from '@jest/globals'; import { cleanupTempDirs } from './helpers/temp'; +const originalConsoleError = console.error; +const originalConsoleWarn = console.warn; + jest.mock('ora', () => ({ __esModule: true, default: () => { @@ -27,8 +30,15 @@ jest.mock('chokidar', () => ({ afterEach(async () => { await cleanupTempDirs(); + console.error = originalConsoleError; + console.warn = originalConsoleWarn; }); afterAll(async () => { await cleanupTempDirs(); }); + +beforeEach(() => { + console.error = jest.fn(); + console.warn = jest.fn(); +}); diff --git a/packages/cli/tests/watcher.test.ts b/packages/cli/tests/watcher.test.ts index a7e9cdc..53dfb52 100644 --- a/packages/cli/tests/watcher.test.ts +++ b/packages/cli/tests/watcher.test.ts @@ -36,9 +36,28 @@ describe('FileWatcher', () => { jest.useRealTimers(); }); + it('debounces add/unlink events and clears timers', () => { + jest.useFakeTimers(); + const watcher = new FileWatcher({ paths: ['src'], debounce: 30 }); + const handler = jest.fn(); + watcher.on('change', handler); + + watcher.start(); + emitter.emit('add', 'src/added.ts'); + emitter.emit('unlink', 'src/removed.ts'); + + jest.advanceTimersByTime(35); + + expect(handler).toHaveBeenCalledTimes(1); + expect(handler).toHaveBeenCalledWith('src/removed.ts'); + + jest.useRealTimers(); + }); + it('stops the underlying watcher', async () => { const watcher = new FileWatcher({ paths: ['src'] }); watcher.start(); + emitter.emit('change', 'src/index.ts'); await watcher.stop(); expect(closeMock).toHaveBeenCalled(); }); diff --git a/packages/cli/tsconfig.test.json b/packages/cli/tsconfig.test.json index fac2784..6219dc7 100644 --- a/packages/cli/tsconfig.test.json +++ b/packages/cli/tsconfig.test.json @@ -1,9 +1,8 @@ { "extends": "./tsconfig.json", - "include": ["src/**/*", "tests/**/*"], - "exclude": ["node_modules", "dist"], "compilerOptions": { - "noEmit": true, - "types": ["node", "jest"] - } + "types": ["jest", "node"], + "noEmit": true + }, + "include": ["src/**/*", "tests/**/*"] } diff --git a/packages/core/README.md b/packages/core/README.md index 0bc1ae5..1bc87a5 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -1,8 +1,8 @@ # @opensyntaxhq/autodocs-core -Core parsing and extraction engine for Autodocs. +Core parsing, extraction, and generation engine for Autodocs. -## Installation +## Install ```bash npm install @opensyntaxhq/autodocs-core @@ -10,8 +10,23 @@ npm install @opensyntaxhq/autodocs-core ## Usage -```typescript -import { VERSION } from '@opensyntaxhq/autodocs-core'; +```ts +import path from 'path'; +import { createProgram, extractDocs, generateJson } from '@opensyntaxhq/autodocs-core'; -console.log(VERSION); +const entryFile = path.join(process.cwd(), 'src/index.ts'); +const { program, rootDir } = createProgram([entryFile]); +const docs = extractDocs(program, { rootDir }); + +await generateJson(docs, path.join(process.cwd(), 'docs-dist'), { + pretty: true, + rootDir, +}); ``` + +## API Surface + +- `createProgram` / `extractDocs` +- `generateJson`, `generateMarkdown`, `generateHtml` +- `PluginManager` +- `FileCache` and `incrementalBuild` diff --git a/packages/core/package.json b/packages/core/package.json index 91a6596..5415c23 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@opensyntaxhq/autodocs-core", - "version": "1.0.0", + "version": "2.0.0", "license": "Apache-2.0", "description": "Core parsing and extraction engine for Autodocs", "repository": { @@ -45,13 +45,13 @@ "clean": "rm -rf dist" }, "dependencies": { - "typescript": "^5.9.0" + "typescript": "^5.9.3" }, "devDependencies": { "@types/jest": "^30.0.0", - "@types/node": "^25.2.1", + "@types/node": "^25.2.2", "jest": "^30.2.0", "ts-jest": "^29.4.6", - "tsup": "^8.0.0" + "tsup": "^8.5.1" } } diff --git a/packages/core/src/extractor/serializers.ts b/packages/core/src/extractor/serializers.ts index 600671f..158ee51 100644 --- a/packages/core/src/extractor/serializers.ts +++ b/packages/core/src/extractor/serializers.ts @@ -40,9 +40,18 @@ export function serializeInterface( const heritageType = checker.getTypeAtLocation(typeNode); const heritageSymbol = heritageType.getSymbol(); + const heritageName = heritageSymbol?.getName() || 'unknown'; + const heritageKind = getSymbolKind(heritageSymbol); + const heritageSource = getSymbolSourceInfo(heritageSymbol, rootDir); + heritage.push({ - id: generateId(heritageSymbol?.getName() || 'unknown'), - name: heritageSymbol?.getName() || 'unknown', + id: generateEntryId({ + kind: heritageKind, + name: heritageName, + module: heritageSource.module, + file: heritageSource.file, + }), + name: heritageName, kind: 'extends', }); } @@ -73,7 +82,12 @@ export function serializeInterface( } return { - id: generateId(symbol.getName()), + id: generateEntryId({ + kind: 'interface', + name: symbol.getName(), + module: sourceInfo.module, + file: sourceInfo.file, + }), name: symbol.getName(), kind: 'interface', fileName: sourceInfo.file, @@ -120,7 +134,12 @@ export function serializeTypeAlias( } return { - id: generateId(symbol.getName()), + id: generateEntryId({ + kind: 'type', + name: symbol.getName(), + module: sourceInfo.module, + file: sourceInfo.file, + }), name: symbol.getName(), kind: 'type', fileName: sourceInfo.file, @@ -173,7 +192,12 @@ export function serializeFunction( const returnType = signature.getReturnType(); return { - id: generateId(symbol.getName()), + id: generateEntryId({ + kind: 'function', + name: symbol.getName(), + module: sourceInfo.module, + file: sourceInfo.file, + }), name: symbol.getName(), kind: 'function', fileName: sourceInfo.file, @@ -229,9 +253,18 @@ export function serializeClass( const heritageSymbol = heritageType.getSymbol(); const kind = clause.token === ts.SyntaxKind.ExtendsKeyword ? 'extends' : 'implements'; + const heritageName = heritageSymbol?.getName() || 'unknown'; + const heritageKind = getSymbolKind(heritageSymbol); + const heritageSource = getSymbolSourceInfo(heritageSymbol, rootDir); + heritage.push({ - id: generateId(heritageSymbol?.getName() || 'unknown'), - name: heritageSymbol?.getName() || 'unknown', + id: generateEntryId({ + kind: heritageKind, + name: heritageName, + module: heritageSource.module, + file: heritageSource.file, + }), + name: heritageName, kind, }); } @@ -266,7 +299,12 @@ export function serializeClass( } return { - id: generateId(symbol.getName()), + id: generateEntryId({ + kind: 'class', + name: symbol.getName(), + module: sourceInfo.module, + file: sourceInfo.file, + }), name: symbol.getName(), kind: 'class', fileName: sourceInfo.file, @@ -315,7 +353,12 @@ export function serializeEnum( }); return { - id: generateId(symbol.getName()), + id: generateEntryId({ + kind: 'enum', + name: symbol.getName(), + module: sourceInfo.module, + file: sourceInfo.file, + }), name: symbol.getName(), kind: 'enum', fileName: sourceInfo.file, @@ -345,7 +388,12 @@ export function serializeVariable( const sourceInfo = getSourceInfo(declaration, sourceFile, rootDir); return { - id: generateId(symbol.getName()), + id: generateEntryId({ + kind: 'variable', + name: symbol.getName(), + module: sourceInfo.module, + file: sourceInfo.file, + }), name: symbol.getName(), kind: 'variable', fileName: sourceInfo.file, @@ -361,8 +409,41 @@ export function serializeVariable( }; } -function generateId(name: string): string { - return crypto.createHash('md5').update(name).digest('hex').slice(0, 8); +function generateEntryId({ + kind, + name, + module, + file, +}: { + kind: string; + name: string; + module?: string; + file?: string; +}): string { + const scope = module || file || 'unknown'; + return crypto.createHash('md5').update(`${kind}|${scope}|${name}`).digest('hex').slice(0, 8); +} + +function getSymbolKind(symbol?: ts.Symbol): string { + const declaration = symbol?.valueDeclaration ?? symbol?.declarations?.[0]; + if (!declaration) return 'unknown'; + if (ts.isClassDeclaration(declaration)) return 'class'; + if (ts.isInterfaceDeclaration(declaration)) return 'interface'; + if (ts.isTypeAliasDeclaration(declaration)) return 'type'; + if (ts.isEnumDeclaration(declaration)) return 'enum'; + if (ts.isFunctionDeclaration(declaration)) return 'function'; + if (ts.isVariableDeclaration(declaration)) return 'variable'; + return 'unknown'; +} + +function getSymbolSourceInfo( + symbol?: ts.Symbol, + rootDir?: string +): { module?: string; file?: string } { + const declaration = symbol?.valueDeclaration ?? symbol?.declarations?.[0]; + if (!declaration) return {}; + const info = getSourceInfo(declaration, declaration.getSourceFile(), rootDir); + return { module: info.module, file: info.file }; } function generateInterfaceSignature( diff --git a/packages/core/src/version.ts b/packages/core/src/version.ts index 059488a..e74e7b3 100644 --- a/packages/core/src/version.ts +++ b/packages/core/src/version.ts @@ -1 +1 @@ -export const VERSION = '1.0.0'; +export const VERSION = '2.0.0'; diff --git a/packages/core/tests/extractor-edge-cases.test.ts b/packages/core/tests/extractor-edge-cases.test.ts index 6c443f2..80584ca 100644 --- a/packages/core/tests/extractor-edge-cases.test.ts +++ b/packages/core/tests/extractor-edge-cases.test.ts @@ -71,4 +71,71 @@ describe('Extractor Edge Cases', () => { expect(entry?.kind).toBe('interface'); expect(entry?.members?.length).toBeGreaterThan(0); }); + + it('builds stable heritage ids for multiple symbol shapes', async () => { + const tempDir = await createTempDir('autodocs-extractor-'); + const entryPath = await writeTempFile( + tempDir, + 'src/heritage.ts', + ` + export interface InterfaceBase { + value: string; + } + export type TypeBase = { + count: number; + }; + export class ClassBase {} + export const VariableBase = class {}; + export function FunctionBase(this: unknown) {} + export enum EnumBase { + Alpha = 'alpha' + } + + export interface InterfaceChild extends InterfaceBase {} + export interface TypeChild extends TypeBase {} + export interface UnknownChild extends MissingBase {} + + export class ClassChild extends ClassBase {} + export class VariableChild extends VariableBase {} + export class FunctionChild extends (FunctionBase as unknown as { new (): unknown }) {} + export class EnumChild extends (EnumBase as unknown as { new (): unknown }) {} + ` + ); + + const result = createProgram([entryPath]); + const docs = extractDocs(result.program, { rootDir: tempDir }); + + const interfaceChild = docs.find((d) => d.name === 'InterfaceChild'); + const typeChild = docs.find((d) => d.name === 'TypeChild'); + const unknownChild = docs.find((d) => d.name === 'UnknownChild'); + const classChild = docs.find((d) => d.name === 'ClassChild'); + const variableChild = docs.find((d) => d.name === 'VariableChild'); + const functionChild = docs.find((d) => d.name === 'FunctionChild'); + const enumChild = docs.find((d) => d.name === 'EnumChild'); + + expect(interfaceChild?.heritage?.[0]).toMatchObject({ name: 'InterfaceBase', kind: 'extends' }); + expect(typeChild?.heritage?.[0]).toMatchObject({ kind: 'extends' }); + expect(typeChild?.heritage?.[0]?.name).toBeTruthy(); + expect(unknownChild?.heritage?.[0]).toMatchObject({ name: 'unknown', kind: 'extends' }); + expect(classChild?.heritage?.[0]).toMatchObject({ kind: 'extends' }); + expect(variableChild?.heritage?.[0]).toMatchObject({ kind: 'extends' }); + expect(functionChild?.heritage?.[0]).toMatchObject({ kind: 'extends' }); + expect(enumChild?.heritage?.[0]).toMatchObject({ kind: 'extends' }); + expect(classChild?.heritage?.[0]?.name).toBeTruthy(); + expect(variableChild?.heritage?.[0]?.name).toBeTruthy(); + expect(functionChild?.heritage?.[0]?.name).toBeTruthy(); + expect(enumChild?.heritage?.[0]?.name).toBeTruthy(); + + for (const entry of [ + interfaceChild, + typeChild, + unknownChild, + classChild, + variableChild, + functionChild, + enumChild, + ]) { + expect(entry?.heritage?.[0]?.id).toMatch(/^[0-9a-f]{8}$/); + } + }); }); diff --git a/packages/core/tests/generators-html.test.ts b/packages/core/tests/generators-html.test.ts index a5e65ba..23dab14 100644 --- a/packages/core/tests/generators-html.test.ts +++ b/packages/core/tests/generators-html.test.ts @@ -61,4 +61,76 @@ describe('HTML Generator', () => { expect(enumHtml).toContain('Ready'); expect(enumHtml).toContain('ready'); }); + + it('renders parameters, returns, examples, and non-enum members', async () => { + const tempDir = await createTempDir('autodocs-html-branches-'); + + const docs: DocEntry[] = [ + { + id: 'Transform', + name: 'Transform', + kind: 'function', + fileName: 'src/transform.ts', + position: { line: 1, column: 0 }, + signature: 'function Transform(input: string): number', + parameters: [ + { + name: 'input', + type: 'string', + optional: false, + rest: false, + }, + ], + returnType: { text: 'number', kind: 'number' }, + documentation: { + summary: 'Transform docs', + params: [{ name: 'input', text: 'Input docs' }], + returns: 'Return docs', + examples: [ + { + language: 'ts', + code: '```ts\nTransform("ok")\n```', + }, + ], + tags: [], + }, + }, + { + id: 'Widget', + name: 'Widget', + kind: 'interface', + fileName: 'src/widget.ts', + position: { line: 1, column: 0 }, + signature: 'interface Widget', + members: [ + { + name: 'name', + type: 'string', + optional: true, + readonly: true, + documentation: 'Widget name', + }, + ], + }, + ]; + + await generateHtml(docs, tempDir); + + const transformHtml = await fs.readFile( + path.join(tempDir, 'api', 'function', 'Transform.html'), + 'utf-8' + ); + expect(transformHtml).toContain('Parameters'); + expect(transformHtml).toContain('Input docs'); + expect(transformHtml).toContain('Returns'); + expect(transformHtml).toContain('Return docs'); + expect(transformHtml).toContain('Examples'); + + const widgetHtml = await fs.readFile( + path.join(tempDir, 'api', 'interface', 'Widget.html'), + 'utf-8' + ); + expect(widgetHtml).toContain('Properties'); + expect(widgetHtml).toContain('Widget name'); + }); }); diff --git a/packages/core/tests/generators-markdown.test.ts b/packages/core/tests/generators-markdown.test.ts index b070180..7ba203b 100644 --- a/packages/core/tests/generators-markdown.test.ts +++ b/packages/core/tests/generators-markdown.test.ts @@ -45,4 +45,101 @@ describe('Markdown Generator', () => { expect(entry).toContain('## Examples'); expect(entry).toContain('```typescript'); }); + + it('renders enums, members, type params, and parameter docs', async () => { + const tempDir = await createTempDir('autodocs-md-branches-'); + + const docs: DocEntry[] = [ + { + id: 'Complex', + name: 'Complex', + kind: 'function', + fileName: 'src/complex.ts', + position: { line: 5, column: 0 }, + signature: 'function Complex(input: T): T', + typeParameters: [ + { + name: 'T', + constraint: 'string', + default: 'string', + }, + ], + parameters: [ + { + name: 'input', + type: 'T', + optional: false, + rest: false, + }, + ], + returnType: { text: 'T', kind: 'type' }, + documentation: { + summary: 'Summary line 1\nSummary line 2', + deprecated: 'Use ComplexV2', + params: [{ name: 'input', text: 'Input docs' }], + returns: 'Return docs', + examples: [ + { + language: 'ts', + code: '```ts\nComplex("ok")\n```', + }, + ], + tags: [], + }, + }, + { + id: 'Widget', + name: 'Widget', + kind: 'interface', + fileName: 'src/widget.ts', + position: { line: 1, column: 0 }, + signature: 'interface Widget', + members: [ + { + name: 'name', + type: 'string', + optional: true, + readonly: true, + documentation: 'Widget name', + }, + ], + }, + { + id: 'Status', + name: 'Status', + kind: 'enum', + fileName: 'src/status.ts', + position: { line: 1, column: 0 }, + signature: 'enum Status', + members: [ + { + name: 'Ready', + type: 'enum', + optional: false, + readonly: true, + value: 'ready', + documentation: 'Ready state', + }, + ], + }, + ]; + + await generateMarkdown(docs, tempDir); + + const complex = await fs.readFile(path.join(tempDir, 'api', 'function', 'Complex.md'), 'utf-8'); + expect(complex).toContain('## Type Parameters'); + expect(complex).toContain('Input docs'); + expect(complex).toContain('Return docs'); + expect(complex).toContain('## Examples'); + + const widget = await fs.readFile(path.join(tempDir, 'api', 'interface', 'Widget.md'), 'utf-8'); + expect(widget).toContain('## Properties'); + expect(widget).toContain('Widget name'); + + const status = await fs.readFile(path.join(tempDir, 'api', 'enum', 'Status.md'), 'utf-8'); + expect(status).toContain('## Members'); + + const index = await fs.readFile(path.join(tempDir, 'API_INDEX.md'), 'utf-8'); + expect(index).toContain('Summary line 1'); + }); }); diff --git a/packages/core/tests/plugins.test.ts b/packages/core/tests/plugins.test.ts index 065e535..25ba0c9 100644 --- a/packages/core/tests/plugins.test.ts +++ b/packages/core/tests/plugins.test.ts @@ -1,5 +1,32 @@ import { PluginManager } from '../src/plugins'; -import type { Plugin, PluginContext, Logger } from '../src/plugins'; +import type { Plugin, Logger } from '../src/plugins'; + +jest.mock( + '@opensyntaxhq/autodocs-plugin-default', + () => ({ + name: 'default-plugin', + version: '1.0.0', + }), + { virtual: true } +); + +jest.mock( + '@opensyntaxhq/autodocs-plugin-factory', + () => () => ({ + name: 'factory-plugin', + version: '1.0.0', + }), + { virtual: true } +); + +jest.mock( + '@custom/plugin', + () => ({ + name: 'custom-plugin', + version: '1.0.0', + }), + { virtual: true } +); describe('PluginManager', () => { it('loads plugins, runs hooks in order, and cleans up', async () => { @@ -14,23 +41,31 @@ describe('PluginManager', () => { const pluginA: Plugin = { name: 'plugin-a', version: '1.0.0', - initialize: () => calls.push('init-a'), + initialize: () => { + calls.push('init-a'); + }, beforeParse: (files) => { calls.push('before-a'); return [...files, 'a.ts']; }, - cleanup: () => calls.push('cleanup-a'), + cleanup: () => { + calls.push('cleanup-a'); + }, }; const pluginB: Plugin = { name: 'plugin-b', version: '1.0.0', - initialize: () => calls.push('init-b'), + initialize: () => { + calls.push('init-b'); + }, beforeParse: (files) => { calls.push('before-b'); return [...files, 'b.ts']; }, - cleanup: () => calls.push('cleanup-b'), + cleanup: () => { + calls.push('cleanup-b'); + }, }; const manager = new PluginManager({}, logger); @@ -74,7 +109,7 @@ describe('PluginManager', () => { }); it('exposes context cache and events', async () => { - let capturedContext: PluginContext | null = null; + const payloads: string[] = []; const logger: Logger = { info: () => undefined, warn: () => undefined, @@ -87,24 +122,94 @@ describe('PluginManager', () => { name: 'context-plugin', version: '1.0.0', initialize: (context) => { - capturedContext = context; context.cache.set('answer', 42); + expect(context.cache.get('answer')).toBe(42); + context.addHook('test', (data) => { + payloads.push(String(data)); + }); + context.emitEvent('test', 'ping'); }, }); - const context = capturedContext as { - cache: Map; - addHook: (name: string, handler: (data: unknown) => void) => void; - emitEvent: (name: string, data: unknown) => void; + expect(payloads).toEqual(['ping']); + }); + + it('resolves string plugins from default exports and factories', async () => { + const info = jest.fn(); + const logger: Logger = { + info, + warn: () => undefined, + error: () => undefined, + debug: () => undefined, + }; + + const manager = new PluginManager({}, logger); + await manager.loadPlugin('default'); + await manager.loadPlugin('factory'); + + expect(info).toHaveBeenCalledWith('Loaded plugin: default-plugin'); + expect(info).toHaveBeenCalledWith('Loaded plugin: factory-plugin'); + }); + + it('accepts scoped plugin names without rewriting', async () => { + const info = jest.fn(); + const logger: Logger = { + info, + warn: () => undefined, + error: () => undefined, + debug: () => undefined, }; - expect(context.cache.get('answer')).toBe(42); + const manager = new PluginManager({}, logger); + await manager.loadPlugin('@custom/plugin'); - const payloads: string[] = []; - context.addHook('test', (data) => { - payloads.push(String(data)); + expect(info).toHaveBeenCalledWith('Loaded plugin: custom-plugin'); + }); + + it('keeps prior hook result when a hook returns undefined', async () => { + const logger: Logger = { + info: () => undefined, + warn: () => undefined, + error: () => undefined, + debug: () => undefined, + }; + + const manager = new PluginManager({}, logger); + const noopHook: Plugin = { + name: 'noop-hook', + version: '1.0.0', + beforeParse: ((_: string[]) => undefined) as unknown as Plugin['beforeParse'], + }; + await manager.loadPlugin(noopHook); + await manager.loadPlugin({ + name: 'append-hook', + version: '1.0.0', + beforeParse: (files) => [...files, 'extra.ts'], }); - context.emitEvent('test', 'ping'); - expect(payloads).toEqual(['ping']); + + const files = await manager.runHook('beforeParse', ['entry.ts']); + expect(files).toEqual(['entry.ts', 'extra.ts']); + }); + + it('logs cleanup errors without throwing', async () => { + const error = jest.fn(); + const logger: Logger = { + info: () => undefined, + warn: () => undefined, + error, + debug: () => undefined, + }; + + const manager = new PluginManager({}, logger); + await manager.loadPlugin({ + name: 'cleanup-bomb', + version: '1.0.0', + cleanup: () => { + throw new Error('boom'); + }, + }); + + await manager.cleanup(); + expect(error).toHaveBeenCalledWith(expect.stringContaining('cleanup-bomb')); }); }); diff --git a/packages/plugins/examples/README.md b/packages/plugins/examples/README.md new file mode 100644 index 0000000..2eff45c --- /dev/null +++ b/packages/plugins/examples/README.md @@ -0,0 +1,36 @@ +# @opensyntaxhq/autodocs-plugin-examples + +Code example validation and extraction plugin for Autodocs. + +## Install + +```bash +npm install -D @opensyntaxhq/autodocs-plugin-examples +``` + +## Usage + +```ts +import { defineConfig } from '@opensyntaxhq/autodocs'; + +export default defineConfig({ + plugins: [ + { + name: '@opensyntaxhq/autodocs-plugin-examples', + options: { + validate: true, + outputDir: 'examples', + }, + }, + ], +}); +``` + +## Options + +- `validate` (boolean, optional): type-check examples (default `false`). +- `outputDir` (string, optional): directory (relative to output) for extracted examples. + +## Output + +When `outputDir` is set, the plugin writes `examples.json` plus individual example files. diff --git a/packages/plugins/examples/package.json b/packages/plugins/examples/package.json index c524fb2..bcc5306 100644 --- a/packages/plugins/examples/package.json +++ b/packages/plugins/examples/package.json @@ -1,6 +1,6 @@ { "name": "@opensyntaxhq/autodocs-plugin-examples", - "version": "1.0.0", + "version": "2.0.0", "license": "Apache-2.0", "description": "Code example validation and extraction plugin for Autodocs", "repository": { @@ -42,14 +42,14 @@ "clean": "rm -rf dist" }, "dependencies": { - "typescript": "^5.9.0" + "typescript": "^5.9.3" }, "peerDependencies": { - "@opensyntaxhq/autodocs-core": "^1.0.0" + "@opensyntaxhq/autodocs-core": "^2.0.0" }, "devDependencies": { - "@opensyntaxhq/autodocs-core": "^1.0.0", - "@types/node": "^22.0.0", - "tsup": "^8.3.0" + "@opensyntaxhq/autodocs-core": "^2.0.0", + "@types/node": "^25.2.2", + "tsup": "^8.5.1" } } diff --git a/packages/plugins/markdown/README.md b/packages/plugins/markdown/README.md new file mode 100644 index 0000000..20e7e06 --- /dev/null +++ b/packages/plugins/markdown/README.md @@ -0,0 +1,38 @@ +# @opensyntaxhq/autodocs-plugin-markdown + +Markdown guide plugin for Autodocs. It ingests `.md` files and exposes them as `guide` entries in `docs.json`. + +## Install + +```bash +npm install -D @opensyntaxhq/autodocs-plugin-markdown +``` + +## Usage + +```ts +import { defineConfig } from '@opensyntaxhq/autodocs'; + +export default defineConfig({ + plugins: [ + { + name: '@opensyntaxhq/autodocs-plugin-markdown', + options: { + sourceDir: 'docs', + patterns: ['**/*.md'], + frontMatter: true, + }, + }, + ], +}); +``` + +## Options + +- `sourceDir` (string, required): directory to scan for markdown files. +- `patterns` (string[], optional): glob patterns (default `['**/*.md']`). +- `frontMatter` (boolean, optional): parse front matter (default `true`). + +## Output + +Each markdown file becomes a `guide` entry with rendered HTML and raw markdown stored in `metadata`. diff --git a/packages/plugins/markdown/package.json b/packages/plugins/markdown/package.json index c1fadac..c0d8454 100644 --- a/packages/plugins/markdown/package.json +++ b/packages/plugins/markdown/package.json @@ -1,6 +1,6 @@ { "name": "@opensyntaxhq/autodocs-plugin-markdown", - "version": "1.0.0", + "version": "2.0.0", "license": "Apache-2.0", "description": "Markdown guide plugin for Autodocs", "repository": { @@ -47,12 +47,12 @@ "marked": "^17.0.1" }, "peerDependencies": { - "@opensyntaxhq/autodocs-core": "^1.0.0" + "@opensyntaxhq/autodocs-core": "^2.0.0" }, "devDependencies": { - "@opensyntaxhq/autodocs-core": "^1.0.0", - "@types/node": "^22.0.0", - "tsup": "^8.3.0", - "typescript": "^5.9.0" + "@opensyntaxhq/autodocs-core": "^2.0.0", + "@types/node": "^25.2.2", + "tsup": "^8.5.1", + "typescript": "^5.9.3" } } diff --git a/packages/ui/README.md b/packages/ui/README.md new file mode 100644 index 0000000..995e7a6 --- /dev/null +++ b/packages/ui/README.md @@ -0,0 +1,15 @@ +# @opensyntaxhq/autodocs-ui + +React UI for Autodocs. This package is built and bundled by the CLI and is not intended for direct use. + +## Development + +```bash +npm run dev +npm run build +npm run test +``` + +## Notes + +The UI assets are copied into the generated `docs-dist` folder during `autodocs build`. diff --git a/packages/ui/package.json b/packages/ui/package.json index 44baafc..d55db85 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@opensyntaxhq/autodocs-ui", - "version": "1.0.0", + "version": "2.0.0", "private": true, "type": "module", "scripts": { @@ -21,29 +21,29 @@ "react": "^19.2.4", "react-dom": "^19.2.4", "react-markdown": "^10.1.0", - "react-router-dom": "^7.2.0", + "react-router-dom": "^7.13.0", "remark-gfm": "^4.0.1", "tailwind-merge": "^3.4.0", - "zustand": "^5.0.3" + "zustand": "^5.0.11" }, "devDependencies": { "@tailwindcss/postcss": "^4.1.18", "@tailwindcss/vite": "^4.1.18", - "@testing-library/jest-dom": "^6.8.0", - "@testing-library/react": "^16.3.0", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", "@types/react": "^19.2.13", - "@types/react-dom": "^19.0.3", + "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^5.1.3", "@vitest/coverage-v8": "^4.0.18", - "autoprefixer": "^10.4.20", - "eslint": "^9.19.0", + "autoprefixer": "^10.4.24", + "eslint": "^9.39.2", "jsdom": "^28.0.0", - "postcss": "^8.5.3", + "postcss": "^8.5.6", "rollup-plugin-visualizer": "^6.0.5", "tailwindcss": "^4.1.18", - "terser": "^5.39.0", - "typescript": "^5.7.3", + "terser": "^5.46.0", + "typescript": "^5.9.3", "vite": "^7.3.1", "vitest": "^4.0.18" }, diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx index bbd8917..601370a 100644 --- a/packages/ui/src/App.tsx +++ b/packages/ui/src/App.tsx @@ -95,7 +95,7 @@ export function App() { } /> } /> } /> - } /> + } /> diff --git a/packages/ui/src/components/Layout/Sidebar.tsx b/packages/ui/src/components/Layout/Sidebar.tsx index 2b48e36..a457988 100644 --- a/packages/ui/src/components/Layout/Sidebar.tsx +++ b/packages/ui/src/components/Layout/Sidebar.tsx @@ -4,6 +4,7 @@ import { ScrollArea } from '@/components/ui/scroll-area'; import { cn, slugify } from '@/lib/utils'; import { buttonVariants } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; +import { docPath } from '@/lib/routes'; interface SidebarProps { className?: string; @@ -115,22 +116,25 @@ export function Sidebar({ className }: SidebarProps) {
    - {sortedItems.map((item) => ( -
  • - - {item.name} - -
  • - ))} + {sortedItems.map((item) => { + const itemPath = docPath(item); + return ( +
  • + + {item.name} + +
  • + ); + })}
); diff --git a/packages/ui/src/components/Search/CommandMenu.test.tsx b/packages/ui/src/components/Search/CommandMenu.test.tsx deleted file mode 100644 index 5d3cac5..0000000 --- a/packages/ui/src/components/Search/CommandMenu.test.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { render, waitFor } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { MemoryRouter } from 'react-router-dom'; -import { CommandMenu } from './CommandMenu'; -import { useStore, DocEntry } from '../../store'; - -const docs: DocEntry[] = [ - { - id: 'alpha', - name: 'Alpha', - kind: 'function', - fileName: 'src/alpha.ts', - position: { line: 1, column: 0 }, - signature: 'function Alpha(): void', - documentation: { summary: 'Alpha summary', tags: [] }, - }, -]; - -describe('CommandMenu', () => { - it('shows suggestions and search results', async () => { - useStore.setState({ - docs, - searchOpen: true, - }); - - const user = userEvent.setup(); - - const { getByPlaceholderText, getByText } = render( - - - - ); - - expect(getByText('Alpha')).toBeInTheDocument(); - - const input = getByPlaceholderText('Type a command or search...'); - await user.type(input, 'Alpha'); - - await waitFor(() => { - expect(getByText('Alpha')).toBeInTheDocument(); - }); - }); -}); diff --git a/packages/ui/src/components/Search/CommandMenu.tsx b/packages/ui/src/components/Search/CommandMenu.tsx index 368fb96..5afd7ea 100644 --- a/packages/ui/src/components/Search/CommandMenu.tsx +++ b/packages/ui/src/components/Search/CommandMenu.tsx @@ -2,6 +2,7 @@ import * as React from 'react'; import { useNavigate } from 'react-router-dom'; import { useStore } from '../../store'; import { searchIndex, SearchResult } from '../../lib/search'; +import { docPath } from '@/lib/routes'; import { CommandDialog, CommandEmpty, @@ -90,7 +91,9 @@ export function CommandMenu() { key={item.id} value={item.name} onSelect={() => { - runCommand(() => navigate(`/${item.kind}/${item.name}`)); + runCommand(() => + navigate(docPath({ kind: item.kind, id: item.id, name: item.name })) + ); }} > {getIcon(item.kind)} @@ -110,7 +113,7 @@ export function CommandMenu() { key={doc.id} value={doc.name} onSelect={() => { - runCommand(() => navigate(`/${doc.kind}/${doc.name}`)); + runCommand(() => navigate(docPath(doc))); }} > {getIcon(doc.kind)} diff --git a/packages/ui/src/lib/routes.ts b/packages/ui/src/lib/routes.ts new file mode 100644 index 0000000..556ae2c --- /dev/null +++ b/packages/ui/src/lib/routes.ts @@ -0,0 +1,7 @@ +import { slugify } from './utils'; +import type { DocEntry } from '../store'; + +export function docPath(entry: Pick): string { + const slug = slugify(entry.name) || 'entry'; + return `/${entry.kind}/${entry.id}/${slug}`; +} diff --git a/packages/ui/src/lib/search.ts b/packages/ui/src/lib/search.ts index 29e70f5..efd708b 100644 --- a/packages/ui/src/lib/search.ts +++ b/packages/ui/src/lib/search.ts @@ -4,7 +4,7 @@ import { DocEntry } from '../store'; export interface SearchResult { id: string; name: string; - kind: string; + kind: DocEntry['kind']; summary: string; score: number; } @@ -13,7 +13,7 @@ interface SearchDoc { [key: string]: string; id: string; name: string; - kind: string; + kind: DocEntry['kind']; summary: string; } diff --git a/packages/ui/src/pages/HomePage.tsx b/packages/ui/src/pages/HomePage.tsx index 0a29192..1125d35 100644 --- a/packages/ui/src/pages/HomePage.tsx +++ b/packages/ui/src/pages/HomePage.tsx @@ -4,6 +4,7 @@ import { Card } from '@/components/ui/card'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import defaultLogo from '@/assets/logo.svg'; +import { docPath } from '@/lib/routes'; function pluralize(kind: string, count: number): string { const singular = kind.toLowerCase(); @@ -97,9 +98,7 @@ export function HomePage() {
{searchEnabled && (