diff --git a/.changeset/brown-pugs-add.md b/.changeset/brown-pugs-add.md new file mode 100644 index 00000000..17bf9cc5 --- /dev/null +++ b/.changeset/brown-pugs-add.md @@ -0,0 +1,6 @@ +--- +"@calycode/core": patch +"@calycode/cli": patch +--- + +fix: minor fixes for the internal documentation generation diff --git a/.changeset/salty-parrots-matter.md b/.changeset/salty-parrots-matter.md new file mode 100644 index 00000000..f265a1b0 --- /dev/null +++ b/.changeset/salty-parrots-matter.md @@ -0,0 +1,5 @@ +--- +"@calycode/cli": minor +--- + +feat: processing js and json testconfig files as well to allow advanced asserts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a9892555..3e25c323 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -46,21 +46,8 @@ jobs: uses: changesets/action@v1 with: commit: "changeset-release" - title: "Release Version" + title: "Release Version [@coderabbitai ignore]" publish: pnpm -r publish env: GITHUB_TOKEN: ${{ secrets.CHANGESETS_GH_TOKEN }} NPM_TOKEN: ${{ secrets.NPM_TOKEN }} - - - name: Log Changesets outputs - run: | - echo "published: ${{ steps.changesets.outputs.published }}" - echo "publishedPackages: ${{ steps.changesets.outputs.publishedPackages }}" - echo "hasChangesets: ${{ steps.changesets.outputs.hasChangesets }}" - - # Upload docs to GCP ONLY after publishing - - name: Upload docs - if: steps.changesets.outputs.published == 'true' - uses: "./.github/actions/upload-docs" - with: - gcp_credentials: "${{ secrets.GCP_CREDENTIALS }}" diff --git a/packages/cli/src/commands/test/implementation/test.ts b/packages/cli/src/commands/test/implementation/test.ts index 96aafd96..66fac86b 100644 --- a/packages/cli/src/commands/test/implementation/test.ts +++ b/packages/cli/src/commands/test/implementation/test.ts @@ -1,4 +1,5 @@ import { readFile, writeFile, mkdir } from 'node:fs/promises'; +import path from 'node:path'; import { intro, log, spinner } from '@clack/prompts'; import { normalizeApiGroupName, replacePlaceholders } from '@repo/utils'; import { @@ -8,6 +9,17 @@ import { resolveConfigs, } from '../../../utils/index'; +/** + * Prints a formatted summary table of test outcomes to the log. + * + * Logs a header, one row per result showing status, HTTP method, path, and duration, and a final summary line with totals and aggregate duration. + * + * @param results - Array of test result objects. Each object should include: + * - `success` (boolean): whether the test passed, + * - `method` (string): HTTP method used, + * - `path` (string): endpoint path, + * - `duration` (number, optional): duration of the test in milliseconds + */ function printTestSummary(results) { const total = results.length; const succeeded = results.filter((r) => r.success).length; @@ -40,6 +52,40 @@ function printTestSummary(results) { ); } +/** + * Load a test configuration from a file path supporting `.json`, `.js`, and `.ts` files. + * + * For `.json` files the content is read and parsed as JSON. For `.js` and `.ts` files the module is required and the `default` export is returned if present, otherwise the module itself is returned. + * + * @param testConfigPath - Filesystem path to the test configuration file + * @returns The loaded test configuration object + * @throws Error if the file extension is not `.json`, `.js`, or `.ts` + */ +async function loadTestConfig(testConfigPath) { + const ext = path.extname(testConfigPath).toLowerCase(); + if (ext === '.json') { + const content = await readFile(testConfigPath, 'utf8'); + return JSON.parse(content); + } else if (ext === '.js') { + const config = require(path.resolve(testConfigPath)); + return config.default || config; + } else { + throw new Error('Unsupported test config file type.'); + } +} + +/** + * Runs API tests for selected API groups using a provided test configuration and writes per-group results to disk. + * + * @param instance - Name or alias of the target instance + * @param workspace - Workspace name within the instance + * @param branch - Branch label within the workspace + * @param group - Specific API group name to run; when omitted and `isAll` is false the user may be prompted + * @param testConfigPath - Filesystem path to the test configuration file (supported: .json, .js, .ts) + * @param isAll - If true, run tests for all API groups without prompting + * @param printOutput - If true, display the output directory path after writing results + * @param core - Runtime provider exposing `loadToken` and `runTests` used to execute tests and load credentials + */ async function runTest({ instance, workspace, @@ -80,8 +126,7 @@ async function runTest({ // Take the core implementation for test running: // for now testconfig has to exist on the machine prior to running the tests. - const testConfigFileContent = await readFile(testConfigPath, { encoding: 'utf-8' }); - const testConfig = JSON.parse(testConfigFileContent); + const testConfig = await loadTestConfig(testConfigPath); const s = spinner(); s.start('Running tests based on the provided spec'); const testResults = await core.runTests({ @@ -123,4 +168,4 @@ async function runTest({ } } -export { runTest }; +export { runTest }; \ No newline at end of file diff --git a/packages/core/src/features/internal-docs/index.ts b/packages/core/src/features/internal-docs/index.ts index 9ef7fcbc..ff015d64 100644 --- a/packages/core/src/features/internal-docs/index.ts +++ b/packages/core/src/features/internal-docs/index.ts @@ -21,6 +21,7 @@ const INTERNAL_DOCS_ASSETS = { :root { --theme-color: #1b62f8; --content-max-width: 104ch; + --content-margin-inline: 16px; --sidebar-toggle-alignment: start; /* start center end */ --sidebar-toggle-bg: var(--color-mono-2); @@ -210,12 +211,36 @@ Docs powered by [Docsify](https:docsifyjs.org) `, }; +/** + * Normalize a path by replacing an initial "./", "src/", or "/src/" segment with a single leading "/". + * + * @param url - The URL or filesystem path to normalize; may start with "./", "src/", or "/src/". + * @returns The input path with a leading "./", "src/", or "/src/" replaced by "/" (otherwise returns the original string). + */ +function removeLeadingSrc(url: string): string { + return url.replace(/^(\.\/)?(\/?src\/)/, '/'); +} + +/** + * Capitalizes the first character of the given string. + * + * @param str - The input string; if empty, the empty string is returned. + * @returns The input string with its first character converted to uppercase. + */ function capitalize(str: string) { return str.charAt(0).toUpperCase() + str.slice(1); } type DocFile = { path: string; content: string }; +/** + * Generate README.md files for folders that do not already contain a README, based on the provided file paths. + * + * Each generated README lists the folder's direct children (subfolders or files) as a simple contents section. + * + * @param paths - Array of file paths used to infer folder structure + * @returns An array of DocFile objects, each with `path` set to the new README path and `content` containing the generated Markdown for that folder + */ function generateAllFolderReadmes(paths: string[]): DocFile[] { const fileSet = new Set(paths.map((p) => p.toLowerCase())); const folderSet = new Set(); @@ -231,7 +256,7 @@ function generateAllFolderReadmes(paths: string[]): DocFile[] { const results: DocFile[] = []; for (const folder of allFolders) { - const readmePath = `${folder}/README.md`; + const readmePath = `${removeLeadingSrc(folder)}/README.md`; if (fileSet.has(readmePath.toLowerCase())) continue; // Already has a README // Find all direct children (folders or files, but not README.md itself) @@ -249,9 +274,9 @@ function generateAllFolderReadmes(paths: string[]): DocFile[] { let md = `# ${folder.split('/').pop()}\n\n`; md += `˙\n\n > [!INFO|label:Description]\n> This is just a brief table of contents. See what's inside below:`; - md += `## Contents:\n\n`; + md += `\n\n## Contents:\n\n`; for (const child of children.sort()) { - md += `- [${child}](/${folder}/${child}/)\n`; + md += `- [${child}](/${removeLeadingSrc(folder)}/${child}/)\n`; } results.push({ path: readmePath, content: md }); } @@ -298,4 +323,4 @@ function generateWelcomeReadme(paths: string[], welcomeReadmeTemplate: string): return welcomeReadmeTemplate.replace('{{ doc_items }}', docItems.trim()); } -export { INTERNAL_DOCS_ASSETS, generateAllFolderReadmes, generateSidebar, generateWelcomeReadme }; +export { INTERNAL_DOCS_ASSETS, generateAllFolderReadmes, generateSidebar, generateWelcomeReadme }; \ No newline at end of file diff --git a/packages/core/src/implementations/generate-internal-docs.ts b/packages/core/src/implementations/generate-internal-docs.ts index 71c7c27c..7065cd0c 100644 --- a/packages/core/src/implementations/generate-internal-docs.ts +++ b/packages/core/src/implementations/generate-internal-docs.ts @@ -17,35 +17,73 @@ function normalizeDynamicSegments(str: string): string { } /** - * Remove leading "/src/" or "src/" from a URL or path, if present. + * Remove a leading "src/" or "/src/" prefix from a path, replacing it with a single leading slash. + * + * @param url - The path or URL to normalize + * @returns The path with a leading `src/` or `/src/` replaced by `/`, or the original `url` if no such prefix exists */ function removeLeadingSrc(url: string): string { return url.replace(/^\/?src\//, '/'); } /** - * Fix links in markdown content: - * - Normalizes dynamic segments in the URL. - * - Removes leading "src/" or "/src/" from the URL. - * - Adjusts links to README.md and directories for Docsify. + * Compute the parent directory of a forward-slash-delimited path. + * + * @param path - The input path (file or directory) using `/` as segment separator; may include a trailing slash + * @returns The parent path with no trailing slash; returns an empty string if the input has no parent (single-segment or root) */ -function fixMarkdownLinks(content: string, allPaths: string[]): string { +function getParentDir(path: string): string { + // Remove trailing slash if present + path = path.replace(/\/$/, ''); + // Remove last segment (file or directory) + const parts = path.split('/'); + parts.pop(); + return parts.join('/'); +} + +/** + * Rewrite Markdown links so they resolve correctly for the generated docs. + * + * Rewrites link targets to remove leading `src/`, normalize dynamic segments like `{slug}`, + * convert `README.md` targets into directory-style URLs, and resolve leading `./` links + * relative to the parent directory of `current_path`. + * + * @param content - The markdown text containing links to fix + * @param current_path - Path of the current markdown file used to resolve `./`-relative links + * @param allPaths - List of normalized markdown file paths used to detect existing `README.md` targets + * @returns The markdown text with updated link targets + */ +function fixMarkdownLinks(content: string, current_path: string, allPaths: string[]): string { + const parentDir = getParentDir(current_path); + return content.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (match, text, url) => { let cleanUrl = url.replace(/\\/g, '/'); + + // Magic: Replace leading './' with parentDir if present + if (cleanUrl.startsWith('./')) { + cleanUrl = (parentDir ? parentDir + '/' : '') + cleanUrl.slice(2); + // Remove possible double slashes + cleanUrl = cleanUrl.replace(/\/{2,}/g, '/'); + } + cleanUrl = removeLeadingSrc(cleanUrl); cleanUrl = normalizeDynamicSegments(cleanUrl); // Handle links to README.md if (cleanUrl.endsWith('/README.md')) { cleanUrl = cleanUrl.replace(/README\.md$/, ''); - if (!cleanUrl.endsWith('/')) cleanUrl += '/'; + if (cleanUrl === '') { + cleanUrl = '/'; + } else if (!cleanUrl.endsWith('/')) { + cleanUrl += '/'; + } } else if ( allPaths.includes(joinPath(cleanUrl, 'README.md')) || allPaths.includes(cleanUrl + '/README.md') ) { - // If it's a folder, ensure trailing slash if (!cleanUrl.endsWith('/')) cleanUrl += '/'; } + // Remove double slashes except for protocol (e.g., http://) cleanUrl = cleanUrl.replace(/([^:]\/)\/+/g, '$1'); return `[${text}](${cleanUrl})`; @@ -59,6 +97,19 @@ function fixDynamicLinksInMarkdown(content: string): string { return normalizeDynamicSegments(content); } +/** + * Builds a set of internal documentation files from repository JSON and storage inputs. + * + * Processes repository items into normalized markdown files with fixed links, generates folder-level README files, and adds core documentation files (index.html, README.md, _sidebar.md). + * + * @param jsonData - Repository metadata or manifest used to produce repository items + * @param storage - Storage adapter or client used to read repository file contents + * @param core - Caly core instance used by the repository generation step + * @param instance - Optional instance identifier to scope the repository generation + * @param workspace - Optional workspace identifier to scope the repository generation + * @param branch - Optional branch name to scope the repository generation + * @returns An array of objects each containing `path` and `content` for generated documentation files (processed markdown, folder readmes, and core files) + */ async function generateInternalDocsImplementation({ jsonData, storage, @@ -95,7 +146,7 @@ async function generateInternalDocsImplementation({ // Now process content with all link fixes const processedMarkdownItems = markdownItems.map((item) => ({ ...item, - content: fixDynamicLinksInMarkdown(fixMarkdownLinks(item.content, allPaths)), + content: fixDynamicLinksInMarkdown(fixMarkdownLinks(item.content, item.path, allPaths)), })); const generatedReadmes = generateAllFolderReadmes(allPaths); @@ -118,4 +169,4 @@ async function generateInternalDocsImplementation({ return [...processedMarkdownItems, ...generatedReadmes, ...coreFiles]; } -export { generateInternalDocsImplementation }; +export { generateInternalDocsImplementation }; \ No newline at end of file