Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/brown-pugs-add.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@calycode/core": patch
"@calycode/cli": patch
---

fix: minor fixes for the internal documentation generation
5 changes: 5 additions & 0 deletions .changeset/salty-parrots-matter.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@calycode/cli": minor
---

feat: processing js and json testconfig files as well to allow advanced asserts
15 changes: 1 addition & 14 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}"
51 changes: 48 additions & 3 deletions packages/cli/src/commands/test/implementation/test.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -123,4 +168,4 @@ async function runTest({
}
}

export { runTest };
export { runTest };
33 changes: 29 additions & 4 deletions packages/core/src/features/internal-docs/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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<string>();
Expand All @@ -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)
Expand All @@ -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 });
}
Expand Down Expand Up @@ -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 };
71 changes: 61 additions & 10 deletions packages/core/src/implementations/generate-internal-docs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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})`;
Expand All @@ -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,
Expand Down Expand Up @@ -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);
Expand All @@ -118,4 +169,4 @@ async function generateInternalDocsImplementation({
return [...processedMarkdownItems, ...generatedReadmes, ...coreFiles];
}

export { generateInternalDocsImplementation };
export { generateInternalDocsImplementation };