diff --git a/.github/workflows/preflight.yml b/.github/workflows/preflight.yml index 49d7bf5..c704090 100644 --- a/.github/workflows/preflight.yml +++ b/.github/workflows/preflight.yml @@ -40,6 +40,10 @@ jobs: npm -w packages/plugins/markdown pack npm -w packages/plugins/examples pack rm -f opensyntaxhq-*.tgz + - name: Verify CLI package includes bundled UI + run: npm run verify:cli:pack + - name: Smoke test packed CLI artifact + run: npm run test:smoke:cli-tarball - name: Publish dry-run (requires NPM_TOKEN) if: ${{ env.NPM_TOKEN != '' }} env: diff --git a/.gitignore b/.gitignore index d34a6dd..b1faa63 100644 --- a/.gitignore +++ b/.gitignore @@ -55,3 +55,4 @@ Thumbs.db # Autodocs docs-dist .autodocs-cache +packages/cli/ui-dist diff --git a/e2e/guides.spec.ts b/e2e/guides.spec.ts new file mode 100644 index 0000000..4a26c74 --- /dev/null +++ b/e2e/guides.spec.ts @@ -0,0 +1,14 @@ +import { test, expect } from './coverage'; + +test('guide navigation uses stable short IDs', async ({ page }) => { + await page.goto('/'); + + const openNav = page.getByRole('button', { name: /open navigation/i }); + if (await openNav.isVisible()) { + await openNav.click(); + } + + await page.getByRole('link', { name: 'README' }).first().click(); + + await expect(page).toHaveURL(/\/guide\/[0-9a-f]{8}\/readme$/i); +}); diff --git a/e2e/performance.spec.ts b/e2e/performance.spec.ts index edd1b9e..745e52b 100644 --- a/e2e/performance.spec.ts +++ b/e2e/performance.spec.ts @@ -1,9 +1,10 @@ import { test, expect } from './coverage'; test('homepage loads within acceptable time', async ({ page }) => { + const maxDurationMs = Number(process.env.E2E_HOME_LOAD_BUDGET_MS || '5000'); const start = Date.now(); await page.goto('/', { waitUntil: 'load' }); const duration = Date.now() - start; - expect(duration).toBeLessThan(15000); + expect(duration).toBeLessThan(maxDurationMs); }); diff --git a/eslint.config.mjs b/eslint.config.mjs index 4c77bea..949e94c 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -2,10 +2,13 @@ import js from '@eslint/js'; import tseslint from 'typescript-eslint'; import globals from 'globals'; +const disableTypeCheckedForJs = tseslint.configs.disableTypeChecked; + export default tseslint.config( { ignores: [ '**/dist/**', + '**/ui-dist/**', '**/node_modules/**', '**/.turbo/**', '**/coverage/**', @@ -43,7 +46,18 @@ export default tseslint.config( }, { files: ['**/*.{js,mjs,cjs}'], - ...tseslint.configs.disableTypeChecked, + ...disableTypeCheckedForJs, + languageOptions: { + ...(disableTypeCheckedForJs.languageOptions ?? {}), + globals: { + ...globals.node, + ...globals.es2021, + }, + }, + rules: { + ...(disableTypeCheckedForJs.rules ?? {}), + '@typescript-eslint/no-require-imports': 'off', + }, }, { files: ['**/tests/**/*.ts', '**/*.test.ts', '**/*.spec.ts'], diff --git a/package.json b/package.json index 66b3df2..e33de89 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "autodocs-monorepo", - "version": "2.0.0", + "version": "2.1.0", "private": true, "license": "Apache-2.0", "packageManager": "npm@11.9.0", @@ -19,9 +19,11 @@ "test:e2e": "playwright test", "test:e2e:ui": "playwright test --ui", "test:e2e:debug": "playwright test --debug", + "verify:cli:pack": "node scripts/verify-cli-pack.js", + "test:smoke:cli-tarball": "node scripts/smoke-cli-tarball.js", "serve:test": "node scripts/serve-test-docs.js", "type-check": "turbo run type-check", - "docs:build": "npm run build && node packages/cli/dist/index.js build", + "docs:build": "npm run build && npm -w packages/cli run prepare:ui-dist && node packages/cli/dist/index.js build", "version": "node scripts/version.js", "clean": "turbo run clean && rm -rf node_modules .turbo", "prepare": "husky" diff --git a/packages/cli/README.md b/packages/cli/README.md index 4f012cd..ab489d5 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -8,6 +8,20 @@ Engineer-first documentation generator for TypeScript. npm install -D @opensyntaxhq/autodocs ``` +Run via local binary: + +```bash +npx autodocs build +# or +npm exec autodocs build +``` + +If you want `autodocs` available globally in your shell: + +```bash +npm install -g @opensyntaxhq/autodocs +``` + ## Quick start ```bash @@ -45,3 +59,5 @@ export default defineConfig({ ## Notes Set `SITE_URL` (env or config) to generate `sitemap.xml` and `robots.txt`. + +From `2.0.1+`, the React UI assets are bundled with the CLI package, so `autodocs build` static output does not require installing a separate UI package. diff --git a/packages/cli/jest.config.js b/packages/cli/jest.config.js index 1c32e05..f3cecbf 100644 --- a/packages/cli/jest.config.js +++ b/packages/cli/jest.config.js @@ -1,4 +1,3 @@ -/* global module, process */ /** @type {import('ts-jest').JestConfigWithTsJest} */ module.exports = { preset: 'ts-jest', diff --git a/packages/cli/package.json b/packages/cli/package.json index ba0116e..d9d3f0f 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,20 +1,23 @@ { "name": "@opensyntaxhq/autodocs", - "version": "2.0.0", + "version": "2.1.0", "description": "CLI for Autodocs documentation generator", "bin": { "autodocs": "dist/index.js" }, "files": [ - "dist" + "dist", + "ui-dist" ], "scripts": { "build": "tsup src/index.ts src/index-exports.ts --format cjs --dts --no-splitting", + "prepare:ui-dist": "node scripts/prepare-ui-dist.js", + "prepack": "npm run prepare:ui-dist", "dev": "tsup src/index.ts --format cjs --watch", "lint": "eslint .", "test": "jest", "type-check": "tsc --noEmit", - "clean": "rm -rf dist" + "clean": "rm -rf dist ui-dist" }, "exports": { ".": { @@ -27,7 +30,7 @@ } }, "dependencies": { - "@opensyntaxhq/autodocs-core": "^2.0.0", + "@opensyntaxhq/autodocs-core": "^2.1.0", "chalk": "^5.6.2", "chokidar": "^5.0.0", "commander": "^14.0.3", @@ -73,7 +76,7 @@ "publishConfig": { "access": "public" }, - "author": "", + "author": "OpenSyntaxHQ", "license": "Apache-2.0", "type": "commonjs" } diff --git a/packages/cli/scripts/prepare-ui-dist.js b/packages/cli/scripts/prepare-ui-dist.js new file mode 100644 index 0000000..8ef98ea --- /dev/null +++ b/packages/cli/scripts/prepare-ui-dist.js @@ -0,0 +1,40 @@ +#!/usr/bin/env node + +const fs = require('fs/promises'); +const path = require('path'); + +async function pathExists(target) { + try { + await fs.access(target); + return true; + } catch { + return false; + } +} + +async function main() { + const cliDir = path.resolve(__dirname, '..'); + const defaultUiDistDir = path.resolve(cliDir, '../ui/dist'); + const sourceUiDistDir = process.env.AUTODOCS_UI_DIST_SOURCE + ? path.resolve(process.env.AUTODOCS_UI_DIST_SOURCE) + : defaultUiDistDir; + const targetUiDistDir = path.resolve(cliDir, 'ui-dist'); + + if (!(await pathExists(sourceUiDistDir))) { + console.error( + `[prepare-ui-dist] Missing UI build at ${sourceUiDistDir}. Run "npm -w packages/ui run build" first.` + ); + process.exit(1); + } + + await fs.rm(targetUiDistDir, { recursive: true, force: true }); + await fs.cp(sourceUiDistDir, targetUiDistDir, { recursive: true }); + + console.log(`[prepare-ui-dist] Copied ${sourceUiDistDir} -> ${targetUiDistDir}`); +} + +main().catch((error) => { + const message = error instanceof Error ? error.message : String(error); + console.error(`[prepare-ui-dist] Failed: ${message}`); + process.exit(1); +}); diff --git a/packages/cli/src/commands/build.ts b/packages/cli/src/commands/build.ts index 167d29b..7936b9a 100644 --- a/packages/cli/src/commands/build.ts +++ b/packages/cli/src/commands/build.ts @@ -4,9 +4,8 @@ import chalk from 'chalk'; import ora from 'ora'; import { Command } from 'commander'; import { glob } from 'glob'; -import { exec } from 'child_process'; -import { promisify } from 'util'; import fsPromises from 'fs/promises'; +import type { CompilerOptions, Diagnostic } from 'typescript'; import { loadConfig, resolveConfigPaths } from '../config'; import { computeConfigHash } from '../utils/configHash'; import { @@ -21,7 +20,23 @@ import { VERSION, } from '@opensyntaxhq/autodocs-core'; -const execAsync = promisify(exec); +function toCompilerOptions(options?: Record): CompilerOptions | undefined { + return options ? (options as unknown as CompilerOptions) : undefined; +} + +interface UiSourceCandidate { + label: string; + distDir: string; +} + +async function pathExists(target: string): Promise { + try { + await fsPromises.access(target); + return true; + } catch { + return false; + } +} interface BuildOptions { config?: string; @@ -253,50 +268,49 @@ export async function buildReactUI( siteName?: string; } ): Promise { - // Find the UI package using require.resolve - works in monorepo - let uiDir: string; - let uiDistDir: string; + const candidates: UiSourceCandidate[] = []; + const candidateDistDirs = new Set(); + const addCandidate = (candidate: UiSourceCandidate): void => { + if (candidateDistDirs.has(candidate.distDir)) { + return; + } + candidateDistDirs.add(candidate.distDir); + candidates.push(candidate); + }; if (options.uiDir) { - uiDir = options.uiDir; - uiDistDir = path.join(uiDir, 'dist'); + addCandidate({ + label: `custom uiDir (${options.uiDir})`, + distDir: path.join(options.uiDir, 'dist'), + }); } else { - try { - // Try to resolve the UI package from the monorepo - const uiPackageJson = require.resolve('@opensyntaxhq/autodocs-ui/package.json'); - uiDir = path.dirname(uiPackageJson); - uiDistDir = path.join(uiDir, 'dist'); - } catch { - // Fallback: resolve relative to CLI package in monorepo - uiDir = path.resolve(__dirname, '../../ui'); - uiDistDir = path.join(uiDir, 'dist'); - } + // Bundled UI assets are the primary source for published CLI packages. + addCandidate({ + label: 'bundled CLI assets', + distDir: path.resolve(__dirname, '../ui-dist'), + }); + addCandidate({ + label: 'bundled CLI assets', + distDir: path.resolve(__dirname, '../../ui-dist'), + }); } - // Check if UI package exists - try { - await fsPromises.access(uiDir); - } catch { - // UI package not found, fall back to basic HTML - spinner.text = 'React UI not found, using basic HTML generator...'; - const { generateHtml } = await import('@opensyntaxhq/autodocs-core'); - await generateHtml(docs, outputDir); - return; + let selectedSource: UiSourceCandidate | null = null; + for (const candidate of candidates) { + if (await pathExists(candidate.distDir)) { + selectedSource = candidate; + break; + } } - // Step 1: Build the React UI - spinner.text = 'Building React UI...'; - - try { - await execAsync('npm run build', { cwd: uiDir }); - } catch { - spinner.fail(chalk.red('Failed to build React UI, falling back to basic HTML')); + if (!selectedSource) { + spinner.text = 'React UI not found, using basic HTML generator...'; const { generateHtml } = await import('@opensyntaxhq/autodocs-core'); await generateHtml(docs, outputDir); return; } - spinner.succeed(chalk.green('React UI built')); + spinner.succeed(chalk.green(`React UI ready (${selectedSource.label})`)); // Step 2: Clean and create output directory spinner.start('Preparing output directory...'); @@ -305,9 +319,9 @@ export async function buildReactUI( await fsPromises.mkdir(outputDir, { recursive: true }); // Step 3: Copy React UI assets - spinner.text = 'Copying UI assets...'; + spinner.text = `Copying UI assets (${selectedSource.label})...`; - await copyDirectory(uiDistDir, outputDir); + await copyDirectory(selectedSource.distDir, outputDir); spinner.succeed(chalk.green('UI assets copied')); @@ -403,7 +417,7 @@ export function registerBuild(program: Command): void { let docs: DocEntry[] = []; let rootDir = process.cwd(); - let diagnostics: Array = []; + let diagnostics: Diagnostic[] = []; spinner.start('Parsing TypeScript...'); @@ -417,8 +431,7 @@ export function registerBuild(program: Command): void { files, cache, tsconfig: config.tsconfig, - // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment - compilerOptions: config.compilerOptions as any, + compilerOptions: toCompilerOptions(config.compilerOptions), configHash, onProgram: async (program, parsedSourceFiles) => { await manager.runHook('afterParse', program); @@ -438,8 +451,7 @@ export function registerBuild(program: Command): void { } else { const parseResult = createProgram(files, { configFile: config.tsconfig, - // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment - compilerOptions: config.compilerOptions as any, + compilerOptions: toCompilerOptions(config.compilerOptions), skipLibCheck: true, }); diff --git a/packages/cli/src/commands/serve.ts b/packages/cli/src/commands/serve.ts index a1da6a7..bf50d6c 100644 --- a/packages/cli/src/commands/serve.ts +++ b/packages/cli/src/commands/serve.ts @@ -53,8 +53,9 @@ export function registerServe(program: Command): void { console.log(chalk.gray('\nPress Ctrl+C to stop')); if (open) { - // eslint-disable-next-line @typescript-eslint/no-floating-promises - open.default(url); + void Promise.resolve(open.default(url)).catch((openError: unknown) => { + console.warn(chalk.yellow('Could not open browser:'), openError); + }); } }); } catch (error) { diff --git a/packages/cli/src/commands/watch.ts b/packages/cli/src/commands/watch.ts index eb9472b..858cfca 100644 --- a/packages/cli/src/commands/watch.ts +++ b/packages/cli/src/commands/watch.ts @@ -3,6 +3,7 @@ import chalk from 'chalk'; import ora from 'ora'; import { Command } from 'commander'; import { glob } from 'glob'; +import type { CompilerOptions } from 'typescript'; import { loadConfig, resolveConfigPaths } from '../config'; import { FileWatcher } from '../utils/watcher'; import { computeConfigHash } from '../utils/configHash'; @@ -22,6 +23,10 @@ interface WatchBuildOptions { mode: 'full' | 'docs-only'; } +function toCompilerOptions(options?: Record): CompilerOptions | undefined { + return options ? (options as unknown as CompilerOptions) : undefined; +} + export async function runBuild({ config, configDir, mode }: WatchBuildOptions): Promise { const spinner = ora('Loading plugins...').start(); let pluginManager: PluginManager | null = null; @@ -64,8 +69,7 @@ export async function runBuild({ config, configDir, mode }: WatchBuildOptions): files, cache, tsconfig: config.tsconfig, - // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment - compilerOptions: config.compilerOptions as any, + compilerOptions: toCompilerOptions(config.compilerOptions), configHash, onProgram: async (program, sourceFiles) => { await manager.runHook('afterParse', program); @@ -84,8 +88,7 @@ export async function runBuild({ config, configDir, mode }: WatchBuildOptions): } else { const parseResult = createProgram(files, { configFile: config.tsconfig, - // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment - compilerOptions: config.compilerOptions as any, + compilerOptions: toCompilerOptions(config.compilerOptions), skipLibCheck: true, }); diff --git a/packages/cli/tests/build-helpers.test.ts b/packages/cli/tests/build-helpers.test.ts index 4c56fe3..3ac52f2 100644 --- a/packages/cli/tests/build-helpers.test.ts +++ b/packages/cli/tests/build-helpers.test.ts @@ -27,10 +27,6 @@ jest.mock('../src/config', () => ({ resolveConfigPaths: jest.fn(), })); -jest.mock('child_process', () => ({ - exec: jest.fn(), -})); - const pluginManagerInstances: Array<{ cleanup: jest.Mock; runHook: jest.Mock }> = []; jest.mock('@opensyntaxhq/autodocs-core', () => ({ @@ -53,7 +49,6 @@ jest.mock('@opensyntaxhq/autodocs-core', () => ({ })); import { glob } from 'glob'; -import { ChildProcess, exec } from 'child_process'; import { loadConfig, resolveConfigPaths } from '../src/config'; import { createProgram, @@ -66,8 +61,6 @@ import { 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(() => { @@ -253,17 +246,10 @@ describe('build helpers', () => { expect(generateHtml).toHaveBeenCalled(); }); - it('falls back to HTML generator when UI build fails', async () => { + it('falls back to HTML generator when custom uiDir has no dist folder', 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( [ diff --git a/packages/cli/tests/build.test.ts b/packages/cli/tests/build.test.ts index 699ee1a..5b9ef6a 100644 --- a/packages/cli/tests/build.test.ts +++ b/packages/cli/tests/build.test.ts @@ -3,18 +3,6 @@ import path from 'path'; import type { DocEntry } from '@opensyntaxhq/autodocs-core'; import { createTempDir } from './helpers/temp'; -jest.mock('child_process', () => ({ - exec: jest.fn( - ( - _command: string, - _options: unknown, - callback: (error: Error | null, stdout: string, stderr: string) => void - ) => { - callback(null, '', ''); - } - ), -})); - import { buildReactUI } from '../src/commands/build'; import type { Ora } from 'ora'; diff --git a/packages/cli/tests/commands-serve.test.ts b/packages/cli/tests/commands-serve.test.ts index 1222ed3..d2407e5 100644 --- a/packages/cli/tests/commands-serve.test.ts +++ b/packages/cli/tests/commands-serve.test.ts @@ -128,6 +128,39 @@ describe('serve command', () => { expect(openMock).toHaveBeenCalledWith('http://127.0.0.1:4567'); }); + it('warns when opening browser fails', 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); + + openMock.mockRejectedValueOnce(new Error('cannot open browser')); + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => undefined); + + const program = new Command(); + registerServe(program); + + await program.parseAsync([ + 'node', + 'cli', + 'serve', + '--docs', + docsDir, + '--port', + '4567', + '--host', + '127.0.0.1', + '--open', + ]); + await Promise.resolve(); + + expect(warnSpy).toHaveBeenCalled(); + expect(String(warnSpy.mock.calls[0]?.[0] ?? '')).toContain('Could not open browser:'); + + warnSpy.mockRestore(); + }); + it('returns 404 when index.html is missing', async () => { const tempDir = await createTempDir('autodocs-serve-'); const docsDir = path.join(tempDir, 'docs-dist'); diff --git a/packages/cli/tests/package-artifact.test.ts b/packages/cli/tests/package-artifact.test.ts new file mode 100644 index 0000000..b4fa79d --- /dev/null +++ b/packages/cli/tests/package-artifact.test.ts @@ -0,0 +1,76 @@ +import fs from 'fs/promises'; +import path from 'path'; +import { execFile } from 'child_process'; +import { promisify } from 'util'; +import { createTempDir } from './helpers/temp'; + +const execFileAsync = promisify(execFile); + +async function pathExists(target: string): Promise { + try { + await fs.access(target); + return true; + } catch { + return false; + } +} + +function parsePackJson(output: string): Array<{ files?: Array<{ path: string }> }> { + const match = output.match(/\[\s*\{[\s\S]*\}\s*\]\s*$/); + if (!match) { + throw new Error('npm pack did not produce JSON output'); + } + + const jsonPayload = match[0]; + return JSON.parse(jsonPayload) as Array<{ files?: Array<{ path: string }> }>; +} + +describe('CLI package artifact', () => { + it('includes bundled ui-dist assets in npm pack output', async () => { + const cliDir = path.resolve(__dirname, '..'); + const sourceUiDist = await createTempDir('autodocs-ui-dist-'); + const npmCacheRoot = await createTempDir('autodocs-npm-cache-'); + const packagedUiDist = path.join(cliDir, 'ui-dist'); + const packagedUiDistBackup = path.join(cliDir, 'ui-dist.__bak__'); + const hadPackagedUiDist = await pathExists(packagedUiDist); + + await fs.mkdir(path.join(sourceUiDist, 'assets'), { recursive: true }); + await fs.writeFile( + path.join(sourceUiDist, 'index.html'), + '
', + 'utf-8' + ); + await fs.writeFile(path.join(sourceUiDist, 'assets', 'app.js'), 'console.log("ui");', 'utf-8'); + + if (hadPackagedUiDist) { + await fs.rm(packagedUiDistBackup, { recursive: true, force: true }); + await fs.rename(packagedUiDist, packagedUiDistBackup); + } + + try { + const { stdout } = await execFileAsync('npm', ['pack', '--dry-run', '--json'], { + cwd: cliDir, + env: { + ...process.env, + AUTODOCS_UI_DIST_SOURCE: sourceUiDist, + npm_config_cache: path.join(npmCacheRoot, 'cache'), + }, + }); + + const packed = parsePackJson(stdout); + const packedPaths = new Set((packed[0]?.files || []).map((entry) => entry.path)); + + expect(packedPaths.has('ui-dist/index.html')).toBe(true); + expect(Array.from(packedPaths).some((entry) => entry.startsWith('ui-dist/assets/'))).toBe( + true + ); + } finally { + await fs.rm(packagedUiDist, { recursive: true, force: true }); + if (hadPackagedUiDist && (await pathExists(packagedUiDistBackup))) { + await fs.rename(packagedUiDistBackup, packagedUiDist); + } else { + await fs.rm(packagedUiDistBackup, { recursive: true, force: true }); + } + } + }); +}); diff --git a/packages/core/jest.config.js b/packages/core/jest.config.js index 2d9e636..efa068e 100644 --- a/packages/core/jest.config.js +++ b/packages/core/jest.config.js @@ -1,4 +1,3 @@ -/* global module, process */ /** @type {import('ts-jest').JestConfigWithTsJest} */ module.exports = { preset: 'ts-jest', diff --git a/packages/core/package.json b/packages/core/package.json index 5415c23..418d20d 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@opensyntaxhq/autodocs-core", - "version": "2.0.0", + "version": "2.1.0", "license": "Apache-2.0", "description": "Core parsing and extraction engine for Autodocs", "repository": { diff --git a/packages/core/src/parser/index.ts b/packages/core/src/parser/index.ts index ced2cb0..e08fc27 100644 --- a/packages/core/src/parser/index.ts +++ b/packages/core/src/parser/index.ts @@ -86,8 +86,7 @@ export function createProgram(entryFiles: string[], options: ParserOptions = {}) function findConfigFile(startPath: string): string | undefined { let currentDir = path.dirname(path.resolve(startPath)); - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - while (true) { + for (;;) { const configPath = path.join(currentDir, 'tsconfig.json'); if (fs.existsSync(configPath)) { diff --git a/packages/core/src/version.ts b/packages/core/src/version.ts index e74e7b3..3a7017a 100644 --- a/packages/core/src/version.ts +++ b/packages/core/src/version.ts @@ -1 +1 @@ -export const VERSION = '2.0.0'; +export const VERSION = '2.1.0'; diff --git a/packages/core/tests/examples-plugin.test.ts b/packages/core/tests/examples-plugin.test.ts index beefb0e..f9439fa 100644 --- a/packages/core/tests/examples-plugin.test.ts +++ b/packages/core/tests/examples-plugin.test.ts @@ -93,4 +93,59 @@ describe('Examples plugin', () => { const plugin = examplesPlugin({ validate: true }); await expect(plugin.afterExtract?.(docs)).resolves.toBeTruthy(); }); + + it('does not mutate entry identity fields or add entries', async () => { + const docs: DocEntry[] = [ + { + id: 'abc12345', + name: 'SomeType', + kind: 'type', + fileName: 'src/types.ts', + module: 'src/types', + position: { line: 10, column: 0 }, + signature: 'type SomeType = { value: string }', + documentation: { + summary: 'A sample type', + tags: [], + examples: [{ code: 'const x: SomeType = { value: "ok" };', language: 'ts' }], + }, + }, + ]; + + const plugin = examplesPlugin({ validate: false }); + const before = { id: docs[0].id, kind: docs[0].kind, name: docs[0].name }; + const result = (await plugin.afterExtract?.(docs)) || docs; + + expect(result).toHaveLength(1); + expect(result[0].id).toBe(before.id); + expect(result[0].kind).toBe(before.kind); + expect(result[0].name).toBe(before.name); + }); + + it('skips output generation when entries contain no examples', async () => { + const tempDir = await createTempDir('autodocs-ex-no-output-'); + const outputDir = path.join(tempDir, 'docs'); + + const docs: DocEntry[] = [ + { + id: 'NoExamples', + name: 'NoExamples', + kind: 'interface', + fileName: 'src/no-examples.ts', + module: 'src/no-examples', + position: { line: 1, column: 0 }, + signature: 'interface NoExamples { value: string }', + documentation: { + summary: 'No examples here', + tags: [], + }, + }, + ]; + + const plugin = examplesPlugin({ outputDir: 'examples' }); + await plugin.afterExtract?.(docs); + await plugin.afterGenerate?.(outputDir); + + await expect(fs.access(path.join(outputDir, 'examples', 'examples.json'))).rejects.toThrow(); + }); }); diff --git a/packages/core/tests/markdown-plugin.test.ts b/packages/core/tests/markdown-plugin.test.ts index 8311b3f..3251bd8 100644 --- a/packages/core/tests/markdown-plugin.test.ts +++ b/packages/core/tests/markdown-plugin.test.ts @@ -45,7 +45,12 @@ Some content.`, const guide = docs[0]; expect(guide.kind).toBe('guide'); + expect(guide.id).toMatch(/^[0-9a-f]{8}$/); + expect(guide.id).not.toContain(':'); + expect(guide.id).not.toContain('/'); expect(guide.name).toBe('Getting Started'); + expect(guide.fileName).toBe('guide.md'); + expect(guide.source?.file).toBe('guide.md'); expect(guide.documentation?.summary).toBe('Intro guide'); const metadata = guide.metadata as { markdown?: string; html?: string }; expect(metadata.markdown).toContain('Some content'); diff --git a/packages/plugins/examples/package.json b/packages/plugins/examples/package.json index bcc5306..45a6755 100644 --- a/packages/plugins/examples/package.json +++ b/packages/plugins/examples/package.json @@ -1,6 +1,6 @@ { "name": "@opensyntaxhq/autodocs-plugin-examples", - "version": "2.0.0", + "version": "2.1.0", "license": "Apache-2.0", "description": "Code example validation and extraction plugin for Autodocs", "repository": { @@ -45,10 +45,10 @@ "typescript": "^5.9.3" }, "peerDependencies": { - "@opensyntaxhq/autodocs-core": "^2.0.0" + "@opensyntaxhq/autodocs-core": "^2.1.0" }, "devDependencies": { - "@opensyntaxhq/autodocs-core": "^2.0.0", + "@opensyntaxhq/autodocs-core": "^2.1.0", "@types/node": "^25.2.2", "tsup": "^8.5.1" } diff --git a/packages/plugins/examples/src/index.ts b/packages/plugins/examples/src/index.ts index bb62cec..685beb3 100644 --- a/packages/plugins/examples/src/index.ts +++ b/packages/plugins/examples/src/index.ts @@ -119,8 +119,7 @@ async function validateExample(example: CodeExample, entry: DocEntry): Promise; markdown: string; html: string; } +function normalizePath(input: string): string { + return input.replace(/\\/g, '/'); +} + +function generateGuideId(relativePath: string): string { + const content = `guide|${normalizePath(relativePath)}`; + return crypto.createHash('md5').update(content).digest('hex').slice(0, 8); +} + export default function markdownPlugin(options: MarkdownPluginOptions): Plugin { const files: MarkdownFile[] = []; @@ -37,6 +48,7 @@ export default function markdownPlugin(options: MarkdownPluginOptions): Plugin { for (const file of foundFiles) { const content = await fs.readFile(file, 'utf-8'); + const relativePath = normalizePath(path.relative(options.sourceDir, file)); let frontMatter: Record = {}; let markdown = content; @@ -51,6 +63,7 @@ export default function markdownPlugin(options: MarkdownPluginOptions): Plugin { files.push({ path: file, + relativePath, frontMatter, markdown, html, @@ -64,17 +77,19 @@ export default function markdownPlugin(options: MarkdownPluginOptions): Plugin { afterExtract(docs: DocEntry[]) { const guideDocs: DocEntry[] = files.map((file) => { const fileName = path.basename(file.path, path.extname(file.path)); + const modulePath = file.relativePath.replace(/\.[^/.]+$/, ''); const title = typeof file.frontMatter.title === 'string' ? file.frontMatter.title : fileName; const description = typeof file.frontMatter.description === 'string' ? file.frontMatter.description : ''; return { - id: `guide:${file.path}`, + id: generateGuideId(file.relativePath), name: title, kind: 'guide', - fileName: file.path, - source: { file: file.path, line: 1, column: 0 }, + fileName: file.relativePath, + module: modulePath, + source: { file: file.relativePath, line: 1, column: 0 }, position: { line: 1, column: 0 }, signature: '', documentation: { diff --git a/packages/ui/package.json b/packages/ui/package.json index d55db85..0dd479f 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@opensyntaxhq/autodocs-ui", - "version": "2.0.0", + "version": "2.1.0", "private": true, "type": "module", "scripts": { diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx index 601370a..1ad5351 100644 --- a/packages/ui/src/App.tsx +++ b/packages/ui/src/App.tsx @@ -5,16 +5,17 @@ import { GuidePage } from './pages/GuidePage'; import { MarkdownPage } from './pages/MarkdownPage'; import { SectionPage } from './pages/SectionPage'; import { TypePage } from './pages/TypePage'; +import { ErrorBoundary } from './components/ErrorBoundary'; import { useStore } from './store'; -import { useEffect, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { applyTheme, loadConfig, loadDocs } from './lib/loaders'; export function App() { - const docs = useStore((state) => state.docs); const setDocs = useStore((state) => state.setDocs); const theme = useStore((state) => state.theme); const setTheme = useStore((state) => state.setTheme); const setConfig = useStore((state) => state.setConfig); + const didInitializeDocs = useRef(false); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -47,8 +48,11 @@ export function App() { } } - if (docs.length === 0) { - setDocs(docsData.entries); + if (!didInitializeDocs.current) { + if (useStore.getState().docs.length === 0) { + setDocs(docsData.entries); + } + didInitializeDocs.current = true; } } catch (err) { if (active) { @@ -66,7 +70,7 @@ export function App() { return () => { active = false; }; - }, [docs.length, setDocs, setConfig, setTheme]); + }, [setDocs, setConfig, setTheme]); if (loading) { return ( @@ -88,16 +92,18 @@ export function App() { } return ( - - - }> - } /> - } /> - } /> - } /> - } /> - - - + + + + }> + } /> + } /> + } /> + } /> + } /> + + + + ); } diff --git a/packages/ui/src/components/ErrorBoundary.tsx b/packages/ui/src/components/ErrorBoundary.tsx new file mode 100644 index 0000000..3a3b510 --- /dev/null +++ b/packages/ui/src/components/ErrorBoundary.tsx @@ -0,0 +1,37 @@ +import React from 'react'; + +interface ErrorBoundaryProps { + children: React.ReactNode; + fallback?: React.ReactNode; +} + +interface ErrorBoundaryState { + hasError: boolean; +} + +export class ErrorBoundary extends React.Component { + state: ErrorBoundaryState = { hasError: false }; + + static getDerivedStateFromError(): ErrorBoundaryState { + return { hasError: true }; + } + + render() { + if (this.state.hasError) { + return ( + this.props.fallback ?? ( +
+
+

Something went wrong

+

+ Refresh the page or rebuild docs if this persists. +

+
+
+ ) + ); + } + + return this.props.children; + } +} diff --git a/packages/ui/src/lib/loaders.ts b/packages/ui/src/lib/loaders.ts index 600521d..932979b 100644 --- a/packages/ui/src/lib/loaders.ts +++ b/packages/ui/src/lib/loaders.ts @@ -1,5 +1,65 @@ import { DocEntry, UiConfig } from '../store'; +const HEX_COLOR_PATTERN = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/; +const FUNCTION_COLOR_PATTERN = /^(?:rgb|hsl)a?\([^()]+\)$/i; +const CSS_VAR_PATTERN = /^var\(--[a-zA-Z0-9-_]+\)$/; +const SAFE_FONT_PATTERN = /^[a-zA-Z0-9\s'",-]+$/; +const SAFE_DATA_IMAGE_PATTERN = /^data:image\/[a-zA-Z0-9.+-]+;base64,[a-zA-Z0-9+/=]+$/; + +function sanitizeColor(value?: string): string | undefined { + if (!value) { + return undefined; + } + const trimmed = value.trim(); + if (!trimmed) { + return undefined; + } + if (HEX_COLOR_PATTERN.test(trimmed) || FUNCTION_COLOR_PATTERN.test(trimmed)) { + return trimmed; + } + if (CSS_VAR_PATTERN.test(trimmed)) { + return trimmed; + } + return undefined; +} + +function sanitizeFont(value?: string): string | undefined { + if (!value) { + return undefined; + } + const trimmed = value.trim(); + if (!trimmed) { + return undefined; + } + return SAFE_FONT_PATTERN.test(trimmed) ? trimmed : undefined; +} + +function sanitizeFavicon(value?: string): string | undefined { + if (!value) { + return undefined; + } + + const trimmed = value.trim(); + if (!trimmed) { + return undefined; + } + + if (SAFE_DATA_IMAGE_PATTERN.test(trimmed)) { + return trimmed; + } + + try { + const resolved = new URL(trimmed, window.location.origin); + if (resolved.protocol === 'http:' || resolved.protocol === 'https:') { + return trimmed; + } + } catch { + return undefined; + } + + return undefined; +} + export interface DocsPayload { meta?: { version?: string; @@ -64,38 +124,44 @@ export function applyTheme(config: UiConfig | null): void { const root = document.documentElement; const theme = config.theme; - - if (theme.primaryColor) { - root.style.setProperty('--primary', theme.primaryColor); - root.style.setProperty('--ring', theme.primaryColor); - root.style.setProperty('--sidebar-primary', theme.primaryColor); - root.style.setProperty('--color-primary', theme.primaryColor); + const primaryColor = sanitizeColor(theme.primaryColor); + const secondaryColor = sanitizeColor(theme.secondaryColor); + const sansFont = sanitizeFont(theme.fonts?.sans); + const monoFont = sanitizeFont(theme.fonts?.mono); + const displayFont = sanitizeFont(theme.fonts?.display); + const favicon = sanitizeFavicon(theme.favicon); + + if (primaryColor) { + root.style.setProperty('--primary', primaryColor); + root.style.setProperty('--ring', primaryColor); + root.style.setProperty('--sidebar-primary', primaryColor); + root.style.setProperty('--color-primary', primaryColor); } - if (theme.secondaryColor) { - root.style.setProperty('--secondary', theme.secondaryColor); - root.style.setProperty('--color-secondary', theme.secondaryColor); + if (secondaryColor) { + root.style.setProperty('--secondary', secondaryColor); + root.style.setProperty('--color-secondary', secondaryColor); } - if (theme.fonts?.sans) { - root.style.setProperty('--font-sans', theme.fonts.sans); + if (sansFont) { + root.style.setProperty('--font-sans', sansFont); } - if (theme.fonts?.mono) { - root.style.setProperty('--font-mono', theme.fonts.mono); + if (monoFont) { + root.style.setProperty('--font-mono', monoFont); } - if (theme.fonts?.display) { - root.style.setProperty('--font-display', theme.fonts.display); + if (displayFont) { + root.style.setProperty('--font-display', displayFont); } - if (theme.favicon) { + if (favicon) { let link = document.querySelector('link[rel="icon"]'); if (!link) { link = document.createElement('link'); link.rel = 'icon'; document.head.appendChild(link); } - link.href = theme.favicon; + link.href = favicon; } } diff --git a/packages/ui/src/lib/routes.ts b/packages/ui/src/lib/routes.ts index 556ae2c..57ddef9 100644 --- a/packages/ui/src/lib/routes.ts +++ b/packages/ui/src/lib/routes.ts @@ -3,5 +3,6 @@ import type { DocEntry } from '../store'; export function docPath(entry: Pick): string { const slug = slugify(entry.name) || 'entry'; - return `/${entry.kind}/${entry.id}/${slug}`; + const encodedId = encodeURIComponent(entry.id); + return `/${entry.kind}/${encodedId}/${slug}`; } diff --git a/packages/ui/src/lib/search.ts b/packages/ui/src/lib/search.ts index efd708b..767a95b 100644 --- a/packages/ui/src/lib/search.ts +++ b/packages/ui/src/lib/search.ts @@ -17,21 +17,21 @@ interface SearchDoc { summary: string; } +function createSearchDocumentIndex(): FlexSearchDocument { + return new FlexSearchDocument({ + document: { + id: 'id', + index: ['name', 'summary', 'kind'], + }, + tokenize: 'forward', + resolution: 9, + }); +} + export class SearchIndex { - private index: FlexSearchDocument; + private index: FlexSearchDocument = createSearchDocumentIndex(); private store: Map = new Map(); - constructor() { - this.index = new FlexSearchDocument({ - document: { - id: 'id', - index: ['name', 'summary', 'kind'], - }, - tokenize: 'forward', - resolution: 9, - }); - } - addDocuments(docs: DocEntry[]): void { for (const doc of docs) { const searchDoc: SearchDoc = { @@ -76,14 +76,7 @@ export class SearchIndex { } clear(): void { - this.index = new FlexSearchDocument({ - document: { - id: 'id', - index: ['name', 'summary', 'kind'], - }, - tokenize: 'forward', - resolution: 9, - }); + this.index = createSearchDocumentIndex(); this.store.clear(); } } diff --git a/packages/ui/src/pages/GuidePage.tsx b/packages/ui/src/pages/GuidePage.tsx index 9fda4b1..3af943a 100644 --- a/packages/ui/src/pages/GuidePage.tsx +++ b/packages/ui/src/pages/GuidePage.tsx @@ -11,12 +11,13 @@ interface GuideMetadata { } export function GuidePage() { - const { name } = useParams<{ name: string }>(); + const { id } = useParams<{ id: string; slug?: string }>(); const docs = useStore((state) => state.docs); + const decodedId = id ? decodeURIComponent(id) : id; const entry = useMemo( - () => docs.find((doc) => doc.kind === 'guide' && doc.name === name), - [docs, name] + () => docs.find((doc) => doc.kind === 'guide' && doc.id === decodedId), + [docs, decodedId] ); if (!entry) { diff --git a/packages/ui/src/pages/TypePage.tsx b/packages/ui/src/pages/TypePage.tsx index 3739b43..49e0342 100644 --- a/packages/ui/src/pages/TypePage.tsx +++ b/packages/ui/src/pages/TypePage.tsx @@ -5,15 +5,17 @@ import { TypeView } from '../components/Documentation/TypeView'; export function TypePage() { const { kind, id } = useParams<{ kind: string; id: string; slug?: string }>(); const docs = useStore((state) => state.docs); + const decodedId = id ? decodeURIComponent(id) : id; - const entry = kind && id ? docs.find((d) => d.id === id && d.kind === kind) : undefined; + const entry = + kind && decodedId ? docs.find((d) => d.id === decodedId && d.kind === kind) : undefined; if (!entry) { return (

Not Found

- Could not find documentation for {kind ?? 'unknown'}/{id ?? 'unknown'} + Could not find documentation for {kind ?? 'unknown'}/{decodedId ?? 'unknown'}

); diff --git a/packages/ui/src/store/index.ts b/packages/ui/src/store/index.ts index c0f08a3..af0502f 100644 --- a/packages/ui/src/store/index.ts +++ b/packages/ui/src/store/index.ts @@ -1,74 +1,16 @@ import { create } from 'zustand'; import { persist } from 'zustand/middleware'; - -export type DocKind = 'interface' | 'type' | 'class' | 'function' | 'enum' | 'variable' | 'guide'; - -export interface DocParam { - name: string; - type?: string; - text: string; -} - -export interface DocComment { - summary: string; - description?: string; - tags: Array<{ name: string; text: string; type?: string; paramName?: string }>; - examples?: Array<{ code: string; language: string }>; - params?: DocParam[]; - returns?: string; - deprecated?: string; -} - -export interface DocEntry { - id: string; - name: string; - kind: DocKind; - fileName: string; - module?: string; - source?: { - file: string; - line: number; - column: number; - }; - position: { - line: number; - column: number; - }; - signature: string; - documentation?: DocComment; - metadata?: Record; - typeParameters?: Array<{ - name: string; - constraint?: string; - default?: string; - }>; - members?: Array<{ - name: string; - type: string; - optional: boolean; - readonly: boolean; - documentation?: string; - kind?: 'property' | 'method' | 'enum'; - value?: string; - }>; - parameters?: Array<{ - name: string; - type: string; - optional: boolean; - defaultValue?: string; - rest: boolean; - documentation?: string; - }>; - returnType?: { - text: string; - kind: string; - }; - heritage?: Array<{ - id: string; - name: string; - kind: 'extends' | 'implements'; - }>; -} +import type { + DocEntry as CoreDocEntry, + DocKind as CoreDocKind, + DocComment as CoreDocComment, + DocParam as CoreDocParam, +} from '@autodocs-core/extractor/types'; + +export type DocKind = CoreDocKind; +export type DocParam = CoreDocParam; +export type DocComment = CoreDocComment; +export type DocEntry = CoreDocEntry; export interface UiConfig { version?: string; diff --git a/packages/ui/tests/components/ErrorBoundary.test.tsx b/packages/ui/tests/components/ErrorBoundary.test.tsx new file mode 100644 index 0000000..19c784f --- /dev/null +++ b/packages/ui/tests/components/ErrorBoundary.test.tsx @@ -0,0 +1,37 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import type { ReactElement } from 'react'; +import { ErrorBoundary } from '@/components/ErrorBoundary'; + +function ThrowOnRender(): ReactElement { + throw new Error('boom'); +} + +describe('ErrorBoundary', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('renders children when no error occurs', () => { + render( + +
healthy child
+
+ ); + + expect(screen.getByText('healthy child')).toBeInTheDocument(); + }); + + it('renders fallback UI when a child throws', () => { + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined); + + render( + + + + ); + + expect(screen.getByText('Something went wrong')).toBeInTheDocument(); + expect(consoleErrorSpy).toHaveBeenCalled(); + }); +}); diff --git a/packages/ui/tests/components/Layout/Sidebar.test.tsx b/packages/ui/tests/components/Layout/Sidebar.test.tsx index 0181fa1..ca4c29a 100644 --- a/packages/ui/tests/components/Layout/Sidebar.test.tsx +++ b/packages/ui/tests/components/Layout/Sidebar.test.tsx @@ -20,6 +20,15 @@ const docs: DocEntry[] = [ position: { line: 1, column: 0 }, signature: 'class Beta {}', }, + { + id: 'a1b2c3d4', + name: 'README', + kind: 'guide', + fileName: 'docs/README.md', + module: 'docs/README', + position: { line: 1, column: 0 }, + signature: 'markdown README', + }, ]; describe('Sidebar', () => { @@ -59,4 +68,14 @@ describe('Sidebar', () => { expect(queryByText(/functions/i)).not.toBeInTheDocument(); expect(queryByText(/classes/i)).not.toBeInTheDocument(); }); + + it('uses clean guide IDs in links', () => { + const { getByRole } = renderWithStore(, { + initialState: { docs }, + route: '/', + }); + + const link = getByRole('link', { name: 'README' }); + expect(link.getAttribute('href')).toBe('/guide/a1b2c3d4/readme'); + }); }); diff --git a/packages/ui/tests/lib/loaders.test.ts b/packages/ui/tests/lib/loaders.test.ts index 86103d9..915fd9e 100644 --- a/packages/ui/tests/lib/loaders.test.ts +++ b/packages/ui/tests/lib/loaders.test.ts @@ -97,6 +97,34 @@ describe('applyTheme', () => { expect(document.documentElement.style.cssText).toBe(''); expect(document.querySelector('link[rel="icon"]')).toBeNull(); }); + + it('ignores unsafe theme values', () => { + applyTheme({ + theme: { + primaryColor: 'url(javascript:alert(1))', + secondaryColor: 'expression(alert(1))', + fonts: { sans: 'Inter; color: red' }, + favicon: 'javascript:alert(1)', + }, + }); + + expect(document.documentElement.style.getPropertyValue('--primary')).toBe(''); + expect(document.documentElement.style.getPropertyValue('--secondary')).toBe(''); + expect(document.documentElement.style.getPropertyValue('--font-sans')).toBe(''); + expect(document.querySelector('link[rel="icon"]')).toBeNull(); + }); + + it('accepts safe favicon data URLs', () => { + applyTheme({ + theme: { + favicon: 'data:image/svg+xml;base64,PHN2Zz48L3N2Zz4=', + }, + }); + + const link = document.querySelector('link[rel="icon"]'); + expect(link).not.toBeNull(); + expect(link?.href).toBe('data:image/svg+xml;base64,PHN2Zz48L3N2Zz4='); + }); }); describe('loadConfig', () => { diff --git a/packages/ui/tests/lib/routes.test.ts b/packages/ui/tests/lib/routes.test.ts new file mode 100644 index 0000000..53aae02 --- /dev/null +++ b/packages/ui/tests/lib/routes.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from 'vitest'; +import { docPath } from '@/lib/routes'; + +describe('docPath', () => { + it('builds clean guide URLs with stable short IDs', () => { + const path = docPath({ + kind: 'guide', + id: 'a1b2c3d4', + name: 'Intro', + }); + + expect(path).toBe('/guide/a1b2c3d4/intro'); + }); +}); diff --git a/packages/ui/tests/pages/GuidePage.test.tsx b/packages/ui/tests/pages/GuidePage.test.tsx index 6df089a..785e7a4 100644 --- a/packages/ui/tests/pages/GuidePage.test.tsx +++ b/packages/ui/tests/pages/GuidePage.test.tsx @@ -11,11 +11,12 @@ describe('GuidePage', () => { }); }); it('renders markdown guides from metadata', async () => { + const guideId = 'a1b2c3d4'; act(() => { useStore.setState({ docs: [ { - id: 'guide:1', + id: guideId, name: 'Getting Started', kind: 'guide', fileName: 'guide.md', @@ -28,13 +29,60 @@ describe('GuidePage', () => { }); render( - + - } /> + } /> ); expect(await screen.findByText('Hello guide')).toBeInTheDocument(); }); + + it('renders HTML guides from metadata when html is present', async () => { + const guideId = 'd4c3b2a1'; + act(() => { + useStore.setState({ + docs: [ + { + id: guideId, + name: 'HTML Guide', + kind: 'guide', + fileName: 'html-guide.md', + position: { line: 1, column: 0 }, + signature: '', + metadata: { html: '

Rendered HTML guide

Body

' }, + }, + ], + }); + }); + + render( + + + } /> + + + ); + + expect(await screen.findByText('Rendered HTML guide')).toBeInTheDocument(); + expect(screen.getByText('Body')).toBeInTheDocument(); + }); + + it('renders a not found state for unknown guides', () => { + act(() => { + useStore.setState({ docs: [] }); + }); + + render( + + + } /> + + + ); + + expect(screen.getByText('Guide not found')).toBeInTheDocument(); + expect(screen.getByText(/does not exist/i)).toBeInTheDocument(); + }); }); diff --git a/packages/ui/tests/pages/SectionPage.test.tsx b/packages/ui/tests/pages/SectionPage.test.tsx index 2da7a33..acad3c9 100644 --- a/packages/ui/tests/pages/SectionPage.test.tsx +++ b/packages/ui/tests/pages/SectionPage.test.tsx @@ -76,4 +76,135 @@ describe('SectionPage', () => { const entryLink = screen.getByRole('link', { name: 'doWork' }); expect(entryLink).toHaveAttribute('href', '/function/abcd1234/dowork'); }); + + it('renders not found state when sidebar section is missing or not autogenerate', () => { + act(() => { + useStore.setState({ + docs: [], + config: { + sidebar: [{ title: 'Manual Section', path: '/docs/manual.md' }], + }, + }); + }); + + render( + + + } /> + + + ); + + expect(screen.getByText('Section not found')).toBeInTheDocument(); + expect(screen.getByText(/missing or not configured/i)).toBeInTheDocument(); + }); + + it('normalizes backslashes, shows class pluralization, and truncates long lists', () => { + act(() => { + useStore.setState({ + docs: [ + { + id: 'c1', + name: 'AlphaClass', + kind: 'class', + fileName: 'src\\api\\classes\\alpha.ts', + module: 'src\\api\\classes\\alpha', + position: { line: 1, column: 0 }, + signature: 'class AlphaClass {}', + }, + { + id: 'c2', + name: 'BetaClass', + kind: 'class', + fileName: 'src\\api\\classes\\beta.ts', + module: 'src\\api\\classes\\beta', + position: { line: 1, column: 0 }, + signature: 'class BetaClass {}', + }, + { + id: 'f1', + name: 'fn1', + kind: 'function', + fileName: 'src\\api\\fn1.ts', + module: 'src\\api\\fn1', + position: { line: 1, column: 0 }, + signature: 'function fn1(): void', + }, + { + id: 'f2', + name: 'fn2', + kind: 'function', + fileName: 'src\\api\\fn2.ts', + module: 'src\\api\\fn2', + position: { line: 1, column: 0 }, + signature: 'function fn2(): void', + }, + { + id: 'f3', + name: 'fn3', + kind: 'function', + fileName: 'src\\api\\fn3.ts', + module: 'src\\api\\fn3', + position: { line: 1, column: 0 }, + signature: 'function fn3(): void', + }, + { + id: 'f4', + name: 'fn4', + kind: 'function', + fileName: 'src\\api\\fn4.ts', + module: 'src\\api\\fn4', + position: { line: 1, column: 0 }, + signature: 'function fn4(): void', + }, + { + id: 'f5', + name: 'fn5', + kind: 'function', + fileName: 'src\\api\\fn5.ts', + module: 'src\\api\\fn5', + position: { line: 1, column: 0 }, + signature: 'function fn5(): void', + }, + { + id: 'f6', + name: 'fn6', + kind: 'function', + fileName: 'src\\api\\fn6.ts', + module: 'src\\api\\fn6', + position: { line: 1, column: 0 }, + signature: 'function fn6(): void', + }, + { + id: 'f7', + name: 'fn7', + kind: 'function', + fileName: 'src\\api\\fn7.ts', + module: 'src\\api\\fn7', + position: { line: 1, column: 0 }, + signature: 'function fn7(): void', + }, + ], + config: { + sidebar: [{ title: 'API Reference', autogenerate: 'src/api/' }], + }, + }); + }); + + render( + + + } /> + + + ); + + expect(screen.getByText(/Autogenerated from "src\/api\/"/i)).toBeInTheDocument(); + expect(screen.getByRole('heading', { name: 'classes' })).toBeInTheDocument(); + expect(screen.getByText('+1 more')).toBeInTheDocument(); + expect(screen.getByRole('link', { name: 'AlphaClass' })).toHaveAttribute( + 'href', + '/class/c1/alphaclass' + ); + }); }); diff --git a/packages/ui/tests/setup.ts b/packages/ui/tests/setup.ts index 379eeb3..75fd9d2 100644 --- a/packages/ui/tests/setup.ts +++ b/packages/ui/tests/setup.ts @@ -10,14 +10,16 @@ if (!('ResizeObserver' in globalThis)) { globalThis.ResizeObserver = ResizeObserver; } -// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -if (!window.HTMLElement.prototype.scrollIntoView) { - window.HTMLElement.prototype.scrollIntoView = () => undefined; +const htmlElementPrototype = window.HTMLElement.prototype as Partial; +if (typeof htmlElementPrototype.scrollIntoView !== 'function') { + htmlElementPrototype.scrollIntoView = () => undefined; } -// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -if (!window.matchMedia) { - window.matchMedia = () => ({ +const windowWithOptionalMatchMedia = window as Window & { + matchMedia?: Window['matchMedia']; +}; +if (typeof windowWithOptionalMatchMedia.matchMedia !== 'function') { + windowWithOptionalMatchMedia.matchMedia = () => ({ matches: false, media: '', onchange: null, diff --git a/packages/ui/tsconfig.json b/packages/ui/tsconfig.json index 23b609b..8badbac 100644 --- a/packages/ui/tsconfig.json +++ b/packages/ui/tsconfig.json @@ -10,7 +10,8 @@ "noEmit": true, "types": ["vitest/globals", "@testing-library/jest-dom"], "paths": { - "@/*": ["./src/*"] + "@/*": ["./src/*"], + "@autodocs-core/*": ["../core/src/*"] } }, "include": ["src", "tests"], diff --git a/scripts/smoke-cli-tarball.js b/scripts/smoke-cli-tarball.js new file mode 100644 index 0000000..9703503 --- /dev/null +++ b/scripts/smoke-cli-tarball.js @@ -0,0 +1,131 @@ +#!/usr/bin/env node + +const fs = require('fs/promises'); +const path = require('path'); +const os = require('os'); +const { execFile } = require('child_process'); +const { promisify } = require('util'); + +const execFileAsync = promisify(execFile); + +async function runCommand(command, args, options) { + return execFileAsync(command, args, options); +} + +function parsePackJson(output) { + const match = output.match(/\[\s*\{[\s\S]*\}\s*\]\s*$/); + if (!match) { + throw new Error('npm pack did not produce JSON output'); + } + + const jsonPayload = match[0]; + return JSON.parse(jsonPayload); +} + +async function run() { + const repoRoot = path.resolve(__dirname, '..'); + const cliDir = path.join(repoRoot, 'packages', 'cli'); + const uiDistDir = process.env.AUTODOCS_UI_DIST_SOURCE + ? path.resolve(process.env.AUTODOCS_UI_DIST_SOURCE) + : path.join(repoRoot, 'packages', 'ui', 'dist'); + const tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'autodocs-cli-smoke-')); + const extractDir = path.join(tmpRoot, 'extract'); + const fixtureDir = path.join(tmpRoot, 'fixture'); + const npmCache = path.join(tmpRoot, 'npm-cache'); + + let tarballPath; + + try { + const { stdout: packStdout } = await runCommand('npm', ['pack', '--json'], { + cwd: cliDir, + env: { + ...process.env, + AUTODOCS_UI_DIST_SOURCE: uiDistDir, + npm_config_cache: npmCache, + }, + }); + + const packed = parsePackJson(packStdout); + const tarballFile = packed[0]?.filename; + if (!tarballFile) { + throw new Error('npm pack did not return a tarball filename'); + } + + tarballPath = path.join(cliDir, tarballFile); + + await fs.mkdir(extractDir, { recursive: true }); + await runCommand('tar', ['-xzf', tarballPath, '-C', extractDir], { cwd: repoRoot }); + + const extractedPackageDir = path.join(extractDir, 'package'); + const extractedNodeModulesDir = path.join(extractedPackageDir, 'node_modules'); + const extractedCoreScopeDir = path.join(extractedNodeModulesDir, '@opensyntaxhq'); + const extractedCoreLink = path.join(extractedCoreScopeDir, 'autodocs-core'); + const localCorePackageDir = path.join(repoRoot, 'packages', 'core'); + + await fs.mkdir(extractedCoreScopeDir, { recursive: true }); + await fs.rm(extractedCoreLink, { recursive: true, force: true }); + await fs.symlink(localCorePackageDir, extractedCoreLink, 'dir'); + + const cliEntry = path.join(extractedPackageDir, 'dist', 'index.js'); + + await fs.mkdir(path.join(fixtureDir, 'src'), { recursive: true }); + await fs.writeFile(path.join(fixtureDir, 'src', 'index.ts'), 'export const value = 1;\n', 'utf-8'); + await fs.writeFile( + path.join(fixtureDir, 'autodocs.config.json'), + JSON.stringify( + { + include: ['src/**/*.ts'], + output: { dir: './docs-dist', format: 'static', clean: true }, + }, + null, + 2 + ), + 'utf-8' + ); + + const nodePathParts = [path.join(repoRoot, 'node_modules')]; + if (process.env.NODE_PATH) { + nodePathParts.push(process.env.NODE_PATH); + } + + await runCommand( + process.execPath, + [cliEntry, 'build', '--config', path.join(fixtureDir, 'autodocs.config.json')], + { + cwd: fixtureDir, + env: { + ...process.env, + NODE_PATH: nodePathParts.join(path.delimiter), + npm_config_cache: npmCache, + }, + } + ); + + const outputDir = path.join(fixtureDir, 'docs-dist'); + const indexHtmlPath = path.join(outputDir, 'index.html'); + const docsJsonPath = path.join(outputDir, 'docs.json'); + const configJsonPath = path.join(outputDir, 'config.json'); + + await fs.access(indexHtmlPath); + await fs.access(docsJsonPath); + await fs.access(configJsonPath); + + const indexHtml = await fs.readFile(indexHtmlPath, 'utf-8'); + if (!indexHtml.includes('
')) { + throw new Error('Generated index.html does not appear to be React UI output'); + } + + console.log('[smoke-cli-tarball] Packed CLI generated React UI output successfully'); + } finally { + if (tarballPath) { + await fs.rm(tarballPath, { force: true }).catch(() => undefined); + } + await fs.rm(tmpRoot, { recursive: true, force: true }).catch(() => undefined); + } +} + +run().catch((error) => { + const message = error instanceof Error ? error.message : String(error); + console.error(`[smoke-cli-tarball] ${message}`); + process.exit(1); +}); diff --git a/scripts/verify-cli-pack.js b/scripts/verify-cli-pack.js new file mode 100644 index 0000000..b77f06b --- /dev/null +++ b/scripts/verify-cli-pack.js @@ -0,0 +1,59 @@ +#!/usr/bin/env node + +const { execFile } = require('child_process'); +const path = require('path'); +const { promisify } = require('util'); + +const execFileAsync = promisify(execFile); + +function parsePackJson(output) { + const match = output.match(/\[\s*\{[\s\S]*\}\s*\]\s*$/); + if (!match) { + throw new Error('npm pack did not produce JSON output'); + } + + const jsonPayload = match[0]; + return JSON.parse(jsonPayload); +} + +async function run() { + const repoRoot = path.resolve(__dirname, '..'); + const cliDir = path.join(repoRoot, 'packages', 'cli'); + const uiDistDir = path.join(repoRoot, 'packages', 'ui', 'dist'); + + const { stdout } = await execFileAsync('npm', ['pack', '--dry-run', '--json'], { + cwd: cliDir, + env: { + ...process.env, + AUTODOCS_UI_DIST_SOURCE: uiDistDir, + npm_config_cache: '/tmp/npm-cache', + }, + }); + + let packed; + try { + packed = parsePackJson(stdout); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error(`Failed to parse npm pack output: ${message}`); + } + + const files = new Set((packed[0]?.files || []).map((entry) => entry.path)); + + if (!files.has('ui-dist/index.html')) { + throw new Error('CLI pack output is missing ui-dist/index.html'); + } + + const hasUiAssets = Array.from(files).some((entry) => entry.startsWith('ui-dist/assets/')); + if (!hasUiAssets) { + throw new Error('CLI pack output is missing ui-dist/assets/*'); + } + + console.log('[verify-cli-pack] CLI package contains bundled UI assets'); +} + +run().catch((error) => { + const message = error instanceof Error ? error.message : String(error); + console.error(`[verify-cli-pack] ${message}`); + process.exit(1); +}); diff --git a/scripts/version.js b/scripts/version.js index f5d11cc..7e8b4a3 100644 --- a/scripts/version.js +++ b/scripts/version.js @@ -8,6 +8,12 @@ if (!version) { process.exit(1); } +const semverPattern = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/; +if (!semverPattern.test(version)) { + console.error(`Invalid version "${version}". Expected semver format (e.g. 2.1.0)`); + process.exit(1); +} + const packageFiles = [ 'package.json', 'packages/core/package.json', @@ -17,17 +23,69 @@ const packageFiles = [ 'packages/plugins/examples/package.json', ]; -for (const pkgPath of packageFiles) { - const absolutePath = path.resolve(__dirname, '..', pkgPath); - const pkg = JSON.parse(fs.readFileSync(absolutePath, 'utf-8')); - pkg.version = version; - fs.writeFileSync(absolutePath, JSON.stringify(pkg, null, 2) + '\n'); - console.log(`Updated ${pkgPath} to ${version}`); +const dependencyFields = [ + 'dependencies', + 'devDependencies', + 'peerDependencies', + 'optionalDependencies', +]; + +function readPackageJson(filePath) { + if (!fs.existsSync(filePath)) { + throw new Error(`Package file not found: ${filePath}`); + } + const raw = fs.readFileSync(filePath, 'utf-8'); + try { + return JSON.parse(raw); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error(`Invalid JSON in ${filePath}: ${message}`); + } } -const coreVersionPath = path.resolve(__dirname, '..', 'packages/core/src/version.ts'); -const versionSource = `export const VERSION = '${version}';\n`; -fs.writeFileSync(coreVersionPath, versionSource); -console.log(`Updated packages/core/src/version.ts to ${version}`); +try { + const packageEntries = packageFiles.map((pkgPath) => { + const absolutePath = path.resolve(__dirname, '..', pkgPath); + const pkg = readPackageJson(absolutePath); + return { pkgPath, absolutePath, pkg }; + }); + + const internalPackageNames = new Set( + packageEntries + .map((entry) => entry.pkg.name) + .filter((name) => typeof name === 'string' && name.startsWith('@opensyntaxhq/')) + ); + + for (const entry of packageEntries) { + const { pkgPath, absolutePath, pkg } = entry; + pkg.version = version; + + for (const field of dependencyFields) { + const deps = pkg[field]; + if (!deps || typeof deps !== 'object') { + continue; + } + + for (const depName of Object.keys(deps)) { + if (!internalPackageNames.has(depName)) { + continue; + } + deps[depName] = `^${version}`; + } + } + + fs.writeFileSync(absolutePath, JSON.stringify(pkg, null, 2) + '\n'); + console.log(`Updated ${pkgPath} to ${version}`); + } + + const coreVersionPath = path.resolve(__dirname, '..', 'packages/core/src/version.ts'); + const versionSource = `export const VERSION = '${version}';\n`; + fs.writeFileSync(coreVersionPath, versionSource); + console.log(`Updated packages/core/src/version.ts to ${version}`); +} catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.error(`Failed to update version: ${message}`); + process.exit(1); +} console.log('✓ Version update complete');