From 6f2c9360acab0821556250071473a4887fb12fcd Mon Sep 17 00:00:00 2001 From: night-slayer18 Date: Mon, 9 Feb 2026 19:35:31 +0530 Subject: [PATCH 1/9] fix(ui): harden theme application and runtime safety sanitize theme values before applying them to the DOM add ErrorBoundary fallback to protect route rendering refactor search index reset path and update related tests Signed-off-by: night-slayer18 --- packages/ui/src/App.tsx | 38 ++++--- packages/ui/src/components/ErrorBoundary.tsx | 37 +++++++ packages/ui/src/lib/loaders.ts | 100 +++++++++++++++--- packages/ui/src/lib/search.ts | 33 +++--- .../tests/components/ErrorBoundary.test.tsx | 37 +++++++ packages/ui/tests/lib/loaders.test.ts | 28 +++++ packages/ui/tests/setup.ts | 14 +-- 7 files changed, 228 insertions(+), 59 deletions(-) create mode 100644 packages/ui/src/components/ErrorBoundary.tsx create mode 100644 packages/ui/tests/components/ErrorBoundary.test.tsx diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx index 601370a..56d7651 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/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/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/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/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, From bac3b9a1c71f49b707f71403eaf042c9097c23b0 Mon Sep 17 00:00:00 2001 From: night-slayer18 Date: Mon, 9 Feb 2026 19:35:33 +0530 Subject: [PATCH 2/9] refactor(ui): align store doc types with core replace duplicated UI doc model types with core type imports add tsconfig path mapping for core type resolution in UI Signed-off-by: night-slayer18 --- packages/ui/src/store/index.ts | 80 +++++----------------------------- packages/ui/tsconfig.json | 3 +- 2 files changed, 13 insertions(+), 70 deletions(-) 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/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"], From 83da8c3894510743df59f9832ea582f125ea3e84 Mon Sep 17 00:00:00 2001 From: night-slayer18 Date: Mon, 9 Feb 2026 19:35:35 +0530 Subject: [PATCH 3/9] fix(cli,core,plugins): improve safety and typing paths handle browser open failures in serve command with warning fallback replace broad compilerOptions any-casts with typed conversion helpers use fs.rm recursive cleanup and remove lint-prone infinite loop pattern Signed-off-by: night-slayer18 --- packages/cli/src/commands/build.ts | 13 +++++---- packages/cli/src/commands/serve.ts | 5 ++-- packages/cli/src/commands/watch.ts | 11 +++++--- packages/cli/tests/commands-serve.test.ts | 33 +++++++++++++++++++++++ packages/core/src/parser/index.ts | 3 +-- packages/plugins/examples/src/index.ts | 3 +-- 6 files changed, 53 insertions(+), 15 deletions(-) diff --git a/packages/cli/src/commands/build.ts b/packages/cli/src/commands/build.ts index 167d29b..1fc2833 100644 --- a/packages/cli/src/commands/build.ts +++ b/packages/cli/src/commands/build.ts @@ -7,6 +7,7 @@ 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 { @@ -23,6 +24,10 @@ import { const execAsync = promisify(exec); +function toCompilerOptions(options?: Record): CompilerOptions | undefined { + return options ? (options as unknown as CompilerOptions) : undefined; +} + interface BuildOptions { config?: string; output?: string; @@ -403,7 +408,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 +422,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 +442,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/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/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/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 Date: Mon, 9 Feb 2026 19:35:37 +0530 Subject: [PATCH 4/9] chore(repo): harden version tooling and test configuration add semver and JSON/file validation to version script set CLI author metadata make e2e performance budget configurable via environment variable Signed-off-by: night-slayer18 --- e2e/performance.spec.ts | 3 ++- packages/cli/package.json | 2 +- scripts/version.js | 45 ++++++++++++++++++++++++++++++--------- 3 files changed, 38 insertions(+), 12 deletions(-) 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/packages/cli/package.json b/packages/cli/package.json index ba0116e..045467e 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -73,7 +73,7 @@ "publishConfig": { "access": "public" }, - "author": "", + "author": "OpenSyntaxHQ", "license": "Apache-2.0", "type": "commonjs" } diff --git a/scripts/version.js b/scripts/version.js index f5d11cc..c043a12 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,36 @@ 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}`); +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 { + for (const pkgPath of packageFiles) { + const absolutePath = path.resolve(__dirname, '..', pkgPath); + const pkg = readPackageJson(absolutePath); + pkg.version = 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'); From 752822eb47ed55ee90c5c64388d258959df79069 Mon Sep 17 00:00:00 2001 From: night-slayer18 Date: Mon, 9 Feb 2026 20:40:19 +0530 Subject: [PATCH 5/9] fix(markdown,ui): route guides by stable IDs generate deterministic guide IDs from relative markdown paths encode/decode route IDs safely in UI links and page params switch guide route to /guide/:id/:slug and keep readable slugs add unit/e2e coverage for guide navigation and section edge branches Signed-off-by: night-slayer18 --- e2e/guides.spec.ts | 14 ++ packages/core/tests/markdown-plugin.test.ts | 5 + packages/plugins/markdown/src/index.ts | 21 ++- packages/ui/src/App.tsx | 2 +- packages/ui/src/lib/routes.ts | 3 +- packages/ui/src/pages/GuidePage.tsx | 7 +- packages/ui/src/pages/TypePage.tsx | 6 +- .../tests/components/Layout/Sidebar.test.tsx | 19 +++ packages/ui/tests/lib/routes.test.ts | 14 ++ packages/ui/tests/pages/GuidePage.test.tsx | 54 +++++++- packages/ui/tests/pages/SectionPage.test.tsx | 131 ++++++++++++++++++ 11 files changed, 263 insertions(+), 13 deletions(-) create mode 100644 e2e/guides.spec.ts create mode 100644 packages/ui/tests/lib/routes.test.ts 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/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/markdown/src/index.ts b/packages/plugins/markdown/src/index.ts index 7dbdd29..b27619e 100644 --- a/packages/plugins/markdown/src/index.ts +++ b/packages/plugins/markdown/src/index.ts @@ -1,5 +1,6 @@ import fs from 'fs/promises'; import path from 'path'; +import crypto from 'crypto'; import matter from 'gray-matter'; import { marked } from 'marked'; import { glob } from 'glob'; @@ -13,11 +14,21 @@ export interface MarkdownPluginOptions { interface MarkdownFile { path: string; + relativePath: string; frontMatter: Record; 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/src/App.tsx b/packages/ui/src/App.tsx index 56d7651..1ad5351 100644 --- a/packages/ui/src/App.tsx +++ b/packages/ui/src/App.tsx @@ -98,7 +98,7 @@ export function App() { }> } /> } /> - } /> + } /> } /> } /> 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/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/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/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' + ); + }); }); From ad9ea449533522849062e8041e0fe793a765103d Mon Sep 17 00:00:00 2001 From: night-slayer18 Date: Mon, 9 Feb 2026 20:40:28 +0530 Subject: [PATCH 6/9] fix(cli): bundle ui-dist and prefer it at runtime prepare ui-dist during prepack and include it in published files remove runtime UI build dependency from static generation path prefer bundled assets in buildReactUI and keep HTML fallback safety update CLI tests for bundled-asset resolution behavior Signed-off-by: night-slayer18 --- .gitignore | 1 + packages/cli/package.json | 7 +- packages/cli/scripts/prepare-ui-dist.js | 40 +++++++++++ packages/cli/src/commands/build.ts | 85 +++++++++++++----------- packages/cli/tests/build-helpers.test.ts | 16 +---- packages/cli/tests/build.test.ts | 12 ---- 6 files changed, 94 insertions(+), 67 deletions(-) create mode 100644 packages/cli/scripts/prepare-ui-dist.js 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/packages/cli/package.json b/packages/cli/package.json index 045467e..9035b0d 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -6,15 +6,18 @@ "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": { ".": { 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 1fc2833..7936b9a 100644 --- a/packages/cli/src/commands/build.ts +++ b/packages/cli/src/commands/build.ts @@ -4,8 +4,6 @@ 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'; @@ -22,12 +20,24 @@ 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; output?: string; @@ -258,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...'); @@ -310,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')); 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'; From 8440f7334fd9e11a5f488c1ef96b1ca4536f93d9 Mon Sep 17 00:00:00 2001 From: night-slayer18 Date: Mon, 9 Feb 2026 20:40:35 +0530 Subject: [PATCH 7/9] ci(cli): gate publish with pack and tarball smoke checks verify npm pack contains ui-dist assets required for React output add packed-tarball smoke test that runs autodocs build in temp fixture wire checks into preflight workflow and root npm scripts document local invocation and bundled-UI behavior in CLI README Signed-off-by: night-slayer18 --- .github/workflows/preflight.yml | 4 + package.json | 4 +- packages/cli/README.md | 16 +++ packages/cli/tests/package-artifact.test.ts | 76 ++++++++++++ scripts/smoke-cli-tarball.js | 131 ++++++++++++++++++++ scripts/verify-cli-pack.js | 59 +++++++++ 6 files changed, 289 insertions(+), 1 deletion(-) create mode 100644 packages/cli/tests/package-artifact.test.ts create mode 100644 scripts/smoke-cli-tarball.js create mode 100644 scripts/verify-cli-pack.js 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/package.json b/package.json index 66b3df2..e49a5fd 100644 --- a/package.json +++ b/package.json @@ -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/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/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); +}); From 85ecaff1fc193d8ae72b089740415b7159f3e354 Mon Sep 17 00:00:00 2001 From: night-slayer18 Date: Mon, 9 Feb 2026 20:40:46 +0530 Subject: [PATCH 8/9] test(core): harden plugin invariants and lint config add examples-plugin guards for identity stability and no-output path fix eslint/js config handling for js files and generated ui-dist ignore remove unnecessary jest global comments causing lint noise Signed-off-by: night-slayer18 --- eslint.config.mjs | 16 +++++- packages/cli/jest.config.js | 1 - packages/core/jest.config.js | 1 - packages/core/tests/examples-plugin.test.ts | 55 +++++++++++++++++++++ 4 files changed, 70 insertions(+), 3 deletions(-) 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/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/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/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(); + }); }); From 5538620f0ee727faf29d80eaabfbd97abbd4648e Mon Sep 17 00:00:00 2001 From: night-slayer18 Date: Mon, 9 Feb 2026 20:51:09 +0530 Subject: [PATCH 9/9] chore(release): bump monorepo to v2.1.0 update root and workspace package versions to 2.1.0 sync internal dependency and peerDependency ranges to ^2.1.0 update core VERSION constant and harden scripts/version.js to keep internal ranges aligned Signed-off-by: night-slayer18 --- package.json | 2 +- packages/cli/package.json | 4 +-- packages/core/package.json | 2 +- packages/core/src/version.ts | 2 +- packages/plugins/examples/package.json | 6 ++--- packages/plugins/markdown/package.json | 6 ++--- packages/ui/package.json | 2 +- scripts/version.js | 35 +++++++++++++++++++++++++- 8 files changed, 46 insertions(+), 13 deletions(-) diff --git a/package.json b/package.json index e49a5fd..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", diff --git a/packages/cli/package.json b/packages/cli/package.json index 9035b0d..d9d3f0f 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@opensyntaxhq/autodocs", - "version": "2.0.0", + "version": "2.1.0", "description": "CLI for Autodocs documentation generator", "bin": { "autodocs": "dist/index.js" @@ -30,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", 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/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/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/markdown/package.json b/packages/plugins/markdown/package.json index c0d8454..4cc0737 100644 --- a/packages/plugins/markdown/package.json +++ b/packages/plugins/markdown/package.json @@ -1,6 +1,6 @@ { "name": "@opensyntaxhq/autodocs-plugin-markdown", - "version": "2.0.0", + "version": "2.1.0", "license": "Apache-2.0", "description": "Markdown guide plugin for Autodocs", "repository": { @@ -47,10 +47,10 @@ "marked": "^17.0.1" }, "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", "typescript": "^5.9.3" 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/scripts/version.js b/scripts/version.js index c043a12..7e8b4a3 100644 --- a/scripts/version.js +++ b/scripts/version.js @@ -23,6 +23,13 @@ const packageFiles = [ 'packages/plugins/examples/package.json', ]; +const dependencyFields = [ + 'dependencies', + 'devDependencies', + 'peerDependencies', + 'optionalDependencies', +]; + function readPackageJson(filePath) { if (!fs.existsSync(filePath)) { throw new Error(`Package file not found: ${filePath}`); @@ -37,10 +44,36 @@ function readPackageJson(filePath) { } try { - for (const pkgPath of packageFiles) { + 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}`); }