Skip to content
Draft
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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ node_modules
dist
.DS_Store
dist-standalone
dist-docs
dist-playground
test.js

# Claude Code
Expand Down
24 changes: 24 additions & 0 deletions examples/exported/docs.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Docs only - Exported Fixture</title>
<style>
body { margin: 0; padding: 0; }
#opencollection-container { width: 100vw; height: 100vh; }
</style>
<link rel="stylesheet" href="/api-docs/docs.css">
<script src="/api-docs/docs.js"></script>
</head>
<body>
<div id="opencollection-container"></div>
<script>
const collectionData = {"info":{"name":"Exported Fixture","version":"1.0.0"},"config":{"environments":[{"name":"Local","variables":[{"name":"postId","value":"1"}]}]},"items":[{"name":"Get post","type":"http","method":"GET","url":"https://jsonplaceholder.typicode.com/posts/{{postId}}","headers":[{"name":"Accept","value":"application/json"}],"docs":"Rendered by the Docs-only bundle."}]};
new window.OpenCollectionDocs({
target: document.getElementById('opencollection-container'),
opencollection: collectionData
});
</script>
</body>
</html>
25 changes: 25 additions & 0 deletions examples/exported/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Exported Fixture - API Documentation</title>
<style>
body { margin: 0; padding: 0; }
#opencollection-container { width: 100vw; height: 100vh; }
</style>
<link rel="stylesheet" href="https://cdn.usebruno.com/api-docs/api-docs.css">
<script src="https://cdn.usebruno.com/api-docs/api-docs.js"></script>
</head>
<body>
<div id="opencollection-container"></div>
<script>
const collectionData = {"info":{"name":"Exported Fixture","version":"1.0.0"},"config":{"environments":[{"name":"Local","variables":[{"name":"postId","value":"1"}]}]},"items":[{"name":"Get post","type":"http","method":"GET","url":"https://jsonplaceholder.typicode.com/posts/{{postId}}","headers":[{"name":"Accept","value":"application/json"}],"docs":"Shaped exactly like a `bru docs generate` export: CDN renderer URL, embedded collection, theme option."}]};
new window.OpenCollection({
target: document.getElementById('opencollection-container'),
opencollection: collectionData,
theme: 'light'
});
</script>
</body>
</html>
24 changes: 24 additions & 0 deletions examples/exported/playground.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Playground only - Exported Fixture</title>
<style>
body { margin: 0; padding: 0; }
#opencollection-container { width: 100vw; height: 100vh; }
</style>
<link rel="stylesheet" href="/api-docs/playground.css">
<script src="/api-docs/playground.js"></script>
</head>
<body>
<div id="opencollection-container"></div>
<script>
const collectionData = {"info":{"name":"Exported Fixture","version":"1.0.0"},"config":{"environments":[{"name":"Local","variables":[{"name":"postId","value":"1"}]}]},"items":[{"name":"Get post","type":"http","method":"GET","url":"https://jsonplaceholder.typicode.com/posts/{{postId}}","headers":[{"name":"Accept","value":"application/json"}],"docs":"Rendered by the Playground-only bundle."}]};
new window.OpenCollectionPlayground({
target: document.getElementById('opencollection-container'),
opencollection: collectionData
});
</script>
</body>
</html>
46 changes: 46 additions & 0 deletions examples/standalone-html/docs-only.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>OpenCollection - Docs only</title>
<style>
html, body { margin: 0; height: 100%; }
#opencollection-container { height: 100vh; }
</style>
<link rel="stylesheet" href="../../packages/bruno-api-docs/dist-docs/docs.css">
<script src="../../packages/bruno-api-docs/dist-docs/docs.js"></script>
</head>
<body>
<div id="opencollection-container"></div>

<script>
// Read-only reference. No Try button, no playground code in the bundle.
const sampleCollection = `
info:
name: Split Bundle Example
version: "1.0.0"
config:
environments:
- name: Local
variables:
- name: postId
value: "1"
items:
- name: Get post
type: http
method: GET
url: https://jsonplaceholder.typicode.com/posts/{{postId}}
headers:
- name: Accept
value: application/json
docs: Fetches one post. Loaded from the Docs only bundle.
`;

new window.OpenCollectionDocs({
target: document.getElementById('opencollection-container'),
opencollection: sampleCollection
});
</script>
</body>
</html>
46 changes: 46 additions & 0 deletions examples/standalone-html/playground-only.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>OpenCollection - Playground only</title>
<style>
html, body { margin: 0; height: 100%; }
#opencollection-container { height: 100vh; }
</style>
<link rel="stylesheet" href="../../packages/bruno-api-docs/dist-playground/playground.css">
<script src="../../packages/bruno-api-docs/dist-playground/playground.js"></script>
</head>
<body>
<div id="opencollection-container"></div>

<script>
// The try-it app on its own. No docs shell in the bundle. logo is not used here.
const sampleCollection = `
info:
name: Split Bundle Example
version: "1.0.0"
config:
environments:
- name: Local
variables:
- name: postId
value: "1"
items:
- name: Get post
type: http
method: GET
url: https://jsonplaceholder.typicode.com/posts/{{postId}}
headers:
- name: Accept
value: application/json
docs: Fetches one post. Loaded from the Playground only bundle.
`;

new window.OpenCollectionPlayground({
target: document.getElementById('opencollection-container'),
opencollection: sampleCollection
});
</script>
</body>
</html>
6 changes: 5 additions & 1 deletion packages/bruno-api-docs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,11 @@
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui",
"clean": "rimraf dist",
"clean:standalone": "rimraf dist-standalone"
"clean:standalone": "rimraf dist-standalone",
"build:docs": "vite build --config vite.config.docs.ts",
"build:playground": "vite build --config vite.config.playground.ts",
"build:surfaces": "npm run build:docs && npm run build:playground",
"serve:local": "node scripts/serve-local.mjs"
},
"dependencies": {
"@emotion/css": "^11.13.5",
Expand Down
60 changes: 60 additions & 0 deletions packages/bruno-api-docs/scripts/serve-local.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
// Serve exported API docs against the LOCAL renderer build instead of the CDN.
//
// node scripts/serve-local.mjs [--dir <exported-html-dir>] [--port 4600]
//
// Every exported document hard-codes `https://cdn.usebruno.com/api-docs/api-docs.js`.
// This server answers `/api-docs/*` from the local dist folders and rewrites the CDN
// origin to itself inside any HTML it serves, so an export renders against whatever
// you just built, with the file on disk untouched.
import { createServer } from 'node:http';
import { readFile, stat } from 'node:fs/promises';
import { extname, join, resolve, normalize } from 'node:path';
import { fileURLToPath } from 'node:url';

const args = process.argv.slice(2);
const arg = (name, fallback) => { const i = args.indexOf(name); return i === -1 ? fallback : args[i + 1]; };
const port = Number(arg('--port', 4600));
const pkg = resolve(fileURLToPath(new URL('..', import.meta.url)));
const docsDir = resolve(arg('--dir', join(pkg, '../../examples/exported')));
const CDN = 'https://cdn.usebruno.com';

// The literal paths the documents and the two split shells load.
const bundles = {
'/api-docs/api-docs.js': join(pkg, 'dist-standalone/api-docs.js'),
'/api-docs/api-docs.css': join(pkg, 'dist-standalone/api-docs.css'),
'/api-docs/docs.js': join(pkg, 'dist-docs/docs.js'),
'/api-docs/docs.css': join(pkg, 'dist-docs/docs.css'),
'/api-docs/playground.js': join(pkg, 'dist-playground/playground.js'),
'/api-docs/playground.css': join(pkg, 'dist-playground/playground.css')
};
const types = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript', '.css': 'text/css', '.json': 'application/json', '.yml': 'text/yaml' };

const server = createServer(async (req, res) => {
const path = decodeURIComponent(new URL(req.url, 'http://x').pathname);
const origin = `http://localhost:${port}`;
let file = bundles[path];
if (!file) {
const safe = normalize(path).replace(/^(\.\.[/\\])+/, '');
file = join(docsDir, safe === '/' ? 'index.html' : safe);
}
try {
if ((await stat(file)).isDirectory()) file = join(file, 'index.html');
let body = await readFile(file);
const ext = extname(file);
if (ext === '.html') body = Buffer.from(body.toString('utf8').replaceAll(CDN, origin));
res.writeHead(200, { 'content-type': types[ext] ?? 'application/octet-stream', 'cache-control': 'no-store' });
res.end(body);
} catch {
res.writeHead(404, { 'content-type': 'text/plain' });
res.end(`not found: ${path}`);
}
});

server.listen(port, async () => {
console.log(`serving exported docs from ${docsDir}`);
console.log(`rewriting ${CDN} -> http://localhost:${port}`);
for (const [route, file] of Object.entries(bundles)) {
try { const s = await stat(file); console.log(` ${route.padEnd(28)} ${(s.size / 1024).toFixed(0).padStart(6)} kB built ${s.mtime.toISOString()}`); }
catch { console.log(` ${route.padEnd(28)} MISSING (run the matching build)`); }
}
});
48 changes: 48 additions & 0 deletions packages/bruno-api-docs/src/components/AppShell/AppShell.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import React from 'react';
import { describe, it, expect } from 'vitest';
import { Provider } from 'react-redux';
import { MemoryRouter } from 'react-router-dom';
import AppShell from './AppShell';
import { createOpenCollectionStore } from '@/store/store';
import { collectionLoaded } from '@/store/slices/collection';
import { useRenderToDom } from '@/hooks/useRenderToDom';
import { queryByTestId } from '@/test-utils/dom';

const collection = {
info: { name: 'C' },
items: [{ type: 'http', name: 'Login', method: 'POST', url: 'https://example.test/login' }]
} as any;

Check warning on line 14 in packages/bruno-api-docs/src/components/AppShell/AppShell.spec.tsx

View workflow job for this annotation

GitHub Actions / lint_unit_tests_and_builds

Unexpected any. Specify a different type

const render = (renderPlayground?: (openNonce: number) => React.ReactNode, path = '/login') => {
const store = createOpenCollectionStore();
store.dispatch(collectionLoaded(collection));
return useRenderToDom(

Check failure on line 19 in packages/bruno-api-docs/src/components/AppShell/AppShell.spec.tsx

View workflow job for this annotation

GitHub Actions / lint_unit_tests_and_builds

React Hook "useRenderToDom" is called in function "render" that is neither a React function component nor a custom React Hook function. React component names must start with an uppercase letter. React Hook names must start with the word "use"
<Provider store={store}>
<MemoryRouter initialEntries={[path]}>
<AppShell renderPlayground={renderPlayground} />
</MemoryRouter>
</Provider>
);
};

describe('AppShell playground slot', () => {
it('offers Try when a playground is supplied', () => {
const root = render(() => null);
expect(queryByTestId(root, 'app-shell')).not.toBeNull();
expect(queryByTestId(root, 'request-try-button')).not.toBeNull();
});

it('drops Try when no playground is supplied', () => {
const root = render();
expect(queryByTestId(root, 'app-shell')).not.toBeNull();
expect(queryByTestId(root, 'request-try-button')).toBeNull();
});

// `?pg=1` is the playground's own open flag: a docs-only build must ignore it
// rather than reach for a playground it does not have.
it('ignores the playground open flag in the URL when no playground is supplied', () => {
const root = render(undefined, '/login?pg=1&pgReq=login');
expect(queryByTestId(root, 'app-shell')).not.toBeNull();
expect(queryByTestId(root, 'request-try-button')).toBeNull();
});
});
18 changes: 11 additions & 7 deletions packages/bruno-api-docs/src/components/AppShell/AppShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,11 @@ import SidebarDrawer from '../SidebarDrawer/SidebarDrawer';
import IconButton from '@/ui/IconButton/IconButton';
import { ChevronLeftIcon, ChevronRightIcon } from '@/assets/icons';
import PageRouter from '../PageRouter/PageRouter';
import Playground from '../Playground/Playground';
import SearchBar from '../Search/SearchBar/SearchBar';
import { useSearchHotkey, usePlaygroundUrlState, useElementWidth, useResizableSidebar } from '@/hooks';
import { useAppSelector } from '@/store/hooks';
import { selectDocsCollection } from '@/store/slices/docs';
import { selectGitCollectionUrl } from '@/store/slices/app';
import { selectCollection } from '@/store/slices/collection';
import { selectGitCollectionUrl } from '@/store/slices/collection';
import { useActiveResolution } from '@/routing/hooks';
import { layoutModeForWidth } from '@/hooks/useTopbarLayout';
import { buildFetchInBrunoUrl } from '@/utils/buildFetchInBrunoUrl';
Expand All @@ -23,10 +22,15 @@ import { StyledWrapper } from './StyledWrapper';
interface AppShellProps {
logo?: React.ReactNode;
testId?: string;
/**
* Renders the playground when it is open. Omitted means this build has no
* playground at all - the Try affordance goes with it.
*/
renderPlayground?: (openNonce: number) => React.ReactNode;
}

const AppShell: React.FC<AppShellProps> = ({ logo, testId = 'app-shell' }) => {
const collection = useAppSelector(selectDocsCollection);
const AppShell: React.FC<AppShellProps> = ({ logo, testId = 'app-shell', renderPlayground }) => {
const collection = useAppSelector(selectCollection);
const gitCollectionUrl = useAppSelector(selectGitCollectionUrl);
const resolution = useActiveResolution();

Expand Down Expand Up @@ -168,13 +172,13 @@ const AppShell: React.FC<AppShellProps> = ({ logo, testId = 'app-shell' }) => {
</IconButton>
)}
<main className="appshell-content" ref={contentRef}>
<PageRouter onOpenPlayground={handleOpenPlayground} />
<PageRouter onOpenPlayground={renderPlayground ? handleOpenPlayground : undefined} />
</main>
</div>
</div>
</div>

{playgroundOpen && <Playground openNonce={playgroundOpenNonce} />}
{renderPlayground && playgroundOpen && renderPlayground(playgroundOpenNonce)}

{!isDesktop && (
<SidebarDrawer open={drawerOpen} onClose={closeDrawer}>
Expand Down
Loading
Loading