Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
8 changes: 4 additions & 4 deletions packages/cli/assets/public-package-versions.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"cli": "0.0.50",
"docs-config": "0.0.50",
"docs-theme": "0.0.50",
"docs-transforms": "0.0.50"
"cli": "0.0.51",
"docs-config": "0.0.51",
"docs-theme": "0.0.51",
"docs-transforms": "0.0.51"
}
2 changes: 1 addition & 1 deletion packages/cli/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@open-agent-toolkit/cli",
"version": "0.0.50",
"version": "0.0.51",
"private": false,
"description": "Open Agent Toolkit CLI",
"homepage": "https://github.com/voxmedia/open-agent-toolkit/tree/main/packages/cli",
Expand Down
2 changes: 1 addition & 1 deletion packages/control-plane/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@open-agent-toolkit/control-plane",
"version": "0.0.50",
"version": "0.0.51",
"private": false,
"description": "Read-only OAT control-plane library for structured project state",
"homepage": "https://github.com/voxmedia/open-agent-toolkit/tree/main/packages/control-plane",
Expand Down
2 changes: 1 addition & 1 deletion packages/docs-config/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@open-agent-toolkit/docs-config",
"version": "0.0.50",
"version": "0.0.51",
"private": false,
"description": "Configuration factories for OAT documentation sites",
"homepage": "https://github.com/voxmedia/open-agent-toolkit/tree/main/packages/docs-config",
Expand Down
2 changes: 1 addition & 1 deletion packages/docs-theme/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@open-agent-toolkit/docs-theme",
"version": "0.0.50",
"version": "0.0.51",
"private": false,
"description": "Theme components for OAT documentation sites",
"homepage": "https://github.com/voxmedia/open-agent-toolkit/tree/main/packages/docs-theme",
Expand Down
2 changes: 1 addition & 1 deletion packages/docs-transforms/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@open-agent-toolkit/docs-transforms",
"version": "0.0.50",
"version": "0.0.51",
"private": false,
"description": "Remark/unified transforms for OAT documentation",
"homepage": "https://github.com/voxmedia/open-agent-toolkit/tree/main/packages/docs-transforms",
Expand Down
77 changes: 55 additions & 22 deletions packages/docs-transforms/src/remark-links.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,30 +37,35 @@ function urls(links: LinkResult[]): string[] {
}

describe('remarkLinks — URL rewriting', () => {
it('strips .md extension from relative links', async () => {
it('strips .md extension and appends trailing slash on relative links', async () => {
const links = await transformLinks('[Quickstart](quickstart.md)');
expect(urls(links)).toEqual(['./quickstart']);
expect(urls(links)).toEqual(['./quickstart/']);
});

it('converts dir/index.md to dir path', async () => {
it('converts dir/index.md to dir path with trailing slash', async () => {
const links = await transformLinks('[CLI](cli/index.md)');
expect(urls(links)).toEqual(['./cli']);
expect(urls(links)).toEqual(['./cli/']);
});

it('handles parent-relative index links with trailing-slash adjustment', async () => {
it('handles parent-relative index links with trailing slash', async () => {
const links = await transformLinks(
'[Back](../reference/index.md)',
'/repo/docs/guide/concepts.md',
);
expect(urls(links)).toEqual(['../../reference']);
expect(urls(links)).toEqual(['../../reference/']);
});

it('preserves anchor fragments', async () => {
it('preserves anchor fragments and inserts slash before them', async () => {
const links = await transformLinks('[Section](quickstart.md#setup)');
expect(urls(links)).toEqual(['./quickstart#setup']);
expect(urls(links)).toEqual(['./quickstart/#setup']);
});

it('preserves pure anchor links', async () => {
it('preserves query strings and inserts slash before them', async () => {
const links = await transformLinks('[Section](quickstart.md?v=1)');
expect(urls(links)).toEqual(['./quickstart/?v=1']);
});

it('preserves pure anchor links unchanged', async () => {
const links = await transformLinks('[Top](#top)');
expect(urls(links)).toEqual(['#top']);
});
Expand All @@ -70,55 +75,83 @@ describe('remarkLinks — URL rewriting', () => {
expect(urls(links)).toEqual(['https://example.com/page.md']);
});

it('handles bare index.md', async () => {
it('ignores protocol-relative mailto links', async () => {
const links = await transformLinks('[Mail](mailto:dev@example.com)');
expect(urls(links)).toEqual(['mailto:dev@example.com']);
});

it('rewrites bare index.md to ./', async () => {
const links = await transformLinks('[Home](index.md)');
expect(urls(links)).toEqual(['.']);
expect(urls(links)).toEqual(['./']);
});

it('appends trailing slash to bare self-link `.`', async () => {
const links = await transformLinks('[Here](.)');
expect(urls(links)).toEqual(['./']);
});

it('handles nested paths without index', async () => {
const links = await transformLinks('[Design](cli/design-principles.md)');
expect(urls(links)).toEqual(['./cli/design-principles']);
expect(urls(links)).toEqual(['./cli/design-principles/']);
});

it('adds extra ../ for deep parent-relative links (trailing-slash compensation)', async () => {
it('adds extra ../ for deep parent-relative links with trailing slash', async () => {
const links = await transformLinks(
'[Contract](../../reference/docs-index-contract.md)',
'/repo/docs/guide/documentation/index.md',
);
expect(urls(links)).toEqual(['../../reference/docs-index-contract']);
expect(urls(links)).toEqual(['../../reference/docs-index-contract/']);
});

it('rewrites source-relative links for non-index pages', async () => {
it('rewrites source-relative links for non-index pages with trailing slash', async () => {
const links = await transformLinks(
'[Docs Commands](documentation/commands.md)',
'/repo/docs/guide/cli-reference.md',
);
expect(urls(links)).toEqual(['../documentation/commands']);
expect(urls(links)).toEqual(['../documentation/commands/']);
});

it('leaves non-.md links unchanged', async () => {
it('leaves asset links with extensions unchanged', async () => {
const links = await transformLinks('[Image](logo.png)');
expect(urls(links)).toEqual(['logo.png']);
});

it('leaves nested parent-relative asset links unchanged', async () => {
const links = await transformLinks(
'[Diagram](../images/diagram.png)',
'/repo/docs/guide/concepts.md',
);
expect(urls(links)).toEqual(['../images/diagram.png']);
});

it('leaves same-folder svg asset unchanged', async () => {
const links = await transformLinks('[Asset](./asset.svg)');
expect(urls(links)).toEqual(['./asset.svg']);
});

it('does not double-up trailing slash on URLs already ending in /', async () => {
const links = await transformLinks('[Home](./)');
expect(urls(links)).toEqual(['./']);
});

it('handles multiple links in one document', async () => {
const md = `- [A](a.md)
- [B](b/index.md)
- [C](https://example.com)`;
const links = await transformLinks(md);
expect(urls(links)).toEqual(['./a', './b', 'https://example.com']);
expect(urls(links)).toEqual(['./a/', './b/', 'https://example.com']);
});
});

describe('remarkLinks — display text cleanup', () => {
it('strips .md from inline code display text', async () => {
const links = await transformLinks('[`quickstart.md`](quickstart.md)');
expect(links).toEqual([{ url: './quickstart', text: 'quickstart' }]);
expect(links).toEqual([{ url: './quickstart/', text: 'quickstart' }]);
});

it('strips .md and /index from inline code display text', async () => {
const links = await transformLinks('[`cli/index.md`](cli/index.md)');
expect(links).toEqual([{ url: './cli', text: 'cli' }]);
expect(links).toEqual([{ url: './cli/', text: 'cli' }]);
});

it('preserves human-readable display text unchanged', async () => {
Expand All @@ -128,7 +161,7 @@ describe('remarkLinks — display text cleanup', () => {

it('strips .mdx from inline code display text', async () => {
const links = await transformLinks('[`quickstart.mdx`](quickstart.mdx)');
expect(links).toEqual([{ url: './quickstart', text: 'quickstart' }]);
expect(links).toEqual([{ url: './quickstart/', text: 'quickstart' }]);
});

it('does not touch inline code that is not a .md path', async () => {
Expand All @@ -141,7 +174,7 @@ describe('remarkLinks — display text cleanup', () => {
'[`cli/provider-interop/index.md`](cli/provider-interop/index.md)',
);
expect(links).toEqual([
{ url: './cli/provider-interop', text: 'cli/provider-interop' },
{ url: './cli/provider-interop/', text: 'cli/provider-interop' },
]);
});
});
84 changes: 56 additions & 28 deletions packages/docs-transforms/src/remark-links.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,31 @@ function routePathFromDocsFile(filePath: string): string | null {
return path.normalize(`/${cleaned}`);
}

/**
* Append a trailing slash to the path portion of a rewritten URL so it works
* on static hosts (e.g., S3) where pages live at `<route>/index.html` and
* `/foo` returns 403 — only `/foo/` resolves the index file. Skips URLs
* whose last segment looks like a file with an extension (e.g., `.png`),
* URLs that already end in `/`, and empty paths. Preserves any `#fragment`
* or `?query` suffix.
*
* Assumes markdown slugs are dot-free (kebab-case). A page named
* `release-1.0.md` would rewrite to `./release-1.0` and be treated as an
* asset by the extension check, leaving it without a trailing slash.
*/
function appendTrailingSlash(url: string): string {
const suffixIndex = url.search(/[#?]/);
const pathPortion = suffixIndex >= 0 ? url.slice(0, suffixIndex) : url;
const suffix = suffixIndex >= 0 ? url.slice(suffixIndex) : '';

if (!pathPortion || pathPortion.endsWith('/')) return url;

const lastSegment = pathPortion.slice(pathPortion.lastIndexOf('/') + 1);
if (/\.[^.]+$/.test(lastSegment)) return url;

return `${pathPortion}/${suffix}`;
}

function resolveRelativeDocsLink(
sourceFilePath: string,
targetPath: string,
Expand All @@ -74,10 +99,11 @@ function resolveRelativeDocsLink(
* slashes.
*
* URL rewriting:
* - `quickstart.md` from `docs/index.md` → `./quickstart`
* - `quickstart.md` from `docs/index.md` → `./quickstart/`
* - `documentation/commands.md` from `docs/guide/cli-reference.md`
* → `../documentation/commands`
* - `../reference/index.md` from `docs/guide/concepts.md` → `../../reference`
* → `../documentation/commands/`
* - `../reference/index.md` from `docs/guide/concepts.md` → `../../reference/`
* - `quickstart.md#setup` → `./quickstart/#setup`
* - Absolute URLs and anchors are left unchanged.
*
* Display text cleanup:
Expand All @@ -96,35 +122,37 @@ export const remarkLinks: Plugin<[], Root> = function remarkLinks() {
return;
}

// Split off any anchor fragment
const hashIndex = url.indexOf('#');
const path = hashIndex >= 0 ? url.slice(0, hashIndex) : url;
const fragment = hashIndex >= 0 ? url.slice(hashIndex) : '';
// Split off any anchor fragment or query string
const suffixIndex = url.search(/[#?]/);
const path = suffixIndex >= 0 ? url.slice(0, suffixIndex) : url;
const fragment = suffixIndex >= 0 ? url.slice(suffixIndex) : '';

const cleaned = cleanMdPath(path);
if (cleaned === null) return;

const rewrittenPath =
typeof file?.path === 'string'
? (resolveRelativeDocsLink(file.path, path) ?? cleaned)
: cleaned;

// Rewrite URL
const prefix =
rewrittenPath.startsWith('.') || rewrittenPath.startsWith('/')
? ''
: './';
node.url = prefix + rewrittenPath + fragment;

// Clean display text when it's a single inlineCode child with a .md path
const firstChild = node.children[0];
if (node.children.length === 1 && firstChild?.type === 'inlineCode') {
const code = firstChild as InlineCode;
const cleanedText = cleanMdPath(code.value);
if (cleanedText !== null) {
code.value = cleanedText;

if (cleaned !== null) {
const rewrittenPath =
typeof file?.path === 'string'
? (resolveRelativeDocsLink(file.path, path) ?? cleaned)
: cleaned;

const prefix =
rewrittenPath.startsWith('.') || rewrittenPath.startsWith('/')
? ''
: './';
node.url = prefix + rewrittenPath + fragment;

// Clean display text when it's a single inlineCode child with a .md path
const firstChild = node.children[0];
if (node.children.length === 1 && firstChild?.type === 'inlineCode') {
const code = firstChild as InlineCode;
const cleanedText = cleanMdPath(code.value);
if (cleanedText !== null) {
code.value = cleanedText;
}
}
}

node.url = appendTrailingSlash(node.url);
});
};
};