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
5 changes: 5 additions & 0 deletions .changeset/early-impalas-pump.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@usebruno/api-docs": patch
---

fix(docs): matches Bruno app's markdown rendering (BRU-4228)
17 changes: 17 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

160 changes: 160 additions & 0 deletions packages/bruno-api-docs/e2e/tests/markdown/markdown.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
import { test, expect } from '../../playwright';

test.describe('Rendered markdown documentation', () => {
test.beforeEach(async ({ folderPage }) => {
await folderPage.open(['billing']);
await expect(folderPage.folderMarkdownDocs).toBeVisible();
await folderPage.folderMarkdownDocsToggle.click();
await expect(folderPage.folderMarkdownDocsToggle).toHaveText('View less');
});

test('renders each heading level at a distinct, decreasing size', async ({ folderPage }) => {
const docs = folderPage.folderMarkdownDocs;
const sizeOf = async (tag: string): Promise<number> => {
const px = await docs.locator(tag).first().evaluate((el) => getComputedStyle(el).fontSize);
return parseFloat(px);
};

const [h1, h2, h3, h4] = [await sizeOf('h1'), await sizeOf('h2'), await sizeOf('h3'), await sizeOf('h4')];

expect(h1).toBeGreaterThan(h2);
expect(h2).toBeGreaterThan(h3);
expect(h3).toBeGreaterThan(h4);
});

test('renders a literal <br/> as a line break rather than as text', async ({ folderPage }) => {
const docs = folderPage.folderMarkdownDocs;

await expect(docs).not.toContainText('<br');
await expect(docs).toContainText('Line one');
await expect(docs).toContainText('line two');
await expect(docs.locator('br')).toHaveCount(1);
});

test('renders task list items as checkboxes, not as [ ] / [x] text', async ({ folderPage }) => {
const docs = folderPage.folderMarkdownDocs;
const checkboxes = docs.locator('input[type="checkbox"]');

await expect(checkboxes).toHaveCount(6);
await expect(docs).not.toContainText('[x]');
await expect(docs).not.toContainText('[ ]');

await test.step('the checked item shows a tick and the unchecked one does not', async () => {
await expect(checkboxes.nth(0)).toBeChecked();
await expect(checkboxes.nth(1)).not.toBeChecked();
});

await test.step('the boxes are read-only in published docs', async () => {
await expect(checkboxes.nth(0)).toBeDisabled();
});

await test.step('a completed item struck through in Bruno renders struck through here', async () => {
const struck = docs.locator('li.task-list-item s').first();
await expect(struck).toHaveText('Invoice export shipped');
await expect(struck).toHaveCSS('text-decoration-line', 'line-through');
});

await test.step('a plain list item in the same list keeps its bullet', async () => {
const plain = docs.locator('li', { hasText: 'Not a task item' }).first();
await expect(plain).toHaveCSS('list-style-type', 'disc');
});

await test.step('inline markup inside an item keeps normal word spacing', async () => {
const item = docs.locator('li.task-list-item', { hasText: 'Review the' }).first();
await expect(item.locator('> .task-list-item-content')).toHaveCount(1);

const gap = await item.evaluate((el) => {
const strong = el.querySelector('strong');
if (!strong?.previousSibling) return -1;
const range = document.createRange();
range.selectNodeContents(strong.previousSibling);
return Math.round(strong.getBoundingClientRect().left - range.getBoundingClientRect().right);
});

expect(gap).toBe(0);
});

await test.step('a second block inside an item stacks below it, not beside it', async () => {
const item = docs.locator('li.task-list-item', { hasText: 'Multi-block item' }).first();
const paras = item.locator('> p');
await expect(paras).toHaveCount(2);

const [first, second] = [await paras.nth(0).boundingBox(), await paras.nth(1).boundingBox()];
if (!first || !second) throw new Error('expected both blocks to be laid out');
expect(second.y).toBeGreaterThanOrEqual(first.y + first.height);
});

await test.step('a nested task list sits below its parent, not beside it', async () => {
const parent = docs.locator('li.task-list-item', { hasText: 'Parent task' }).first();
const nestedItem = parent.locator('ul li').first();

const [parentBox, nestedBox] = [await parent.boundingBox(), await nestedItem.boundingBox()];
if (!parentBox || !nestedBox) throw new Error('expected the nested task list to be laid out');

expect(nestedBox.y).toBeGreaterThan(parentBox.y);
expect(nestedBox.x).toBeGreaterThan(parentBox.x);
});
});

test('renders code blocks on the docs code surface with syntax highlighting', async ({ folderPage }) => {
const pre = folderPage.folderMarkdownDocs.locator('pre').first();

await expect(pre).toBeVisible();
await expect(pre).toContainText('bru.get(\'/invoices/42\')');

await test.step('the block uses the app code-surface token (the suite runs the light theme)', async () => {
const [background, token, borderWidth] = await pre.evaluate((el) => {
const probe = document.createElement('div');
probe.style.backgroundColor = 'var(--oc-sidebar-bg)';
document.body.appendChild(probe);
const expected = getComputedStyle(probe).backgroundColor;
probe.remove();

const style = getComputedStyle(el);
return [style.backgroundColor, expected, style.borderTopWidth];
});

expect(background).toBe(token);
expect(borderWidth).toBe('1px');
});

await test.step('the fence is syntax highlighted', async () => {
await expect(pre.locator('.hljs-keyword').first()).toBeVisible();
});
});

test('every code block exposes a working Copy control', async ({ folderPage, page, context }) => {
await context.grantPermissions(['clipboard-read', 'clipboard-write']);

const block = folderPage.folderMarkdownDocs.locator('.md-code-block').first();
const pre = block.locator('pre');
const copy = block.getByTestId('markdown-code-copy');

await expect(copy).toBeVisible();
await copy.click();

await test.step('the button acknowledges the copy', async () => {
await expect(block.getByTestId('markdown-code-copy-tick')).toBeVisible();
});

await test.step('the control stays pinned when a wide block is scrolled sideways', async () => {
const before = await copy.boundingBox();

const scrollable = await pre.evaluate((el) => {
el.scrollLeft = el.scrollWidth;
return el.scrollWidth > el.clientWidth;
});
expect(scrollable).toBe(true);

const after = await copy.boundingBox();
if (!before || !after) throw new Error('expected the copy control to stay visible');
expect(after.x).toBe(before.x);
});

await test.step('the clipboard holds the code, without the button label', async () => {
const clipboard = await page.evaluate(() => navigator.clipboard.readText());
expect(clipboard).toContain('const invoice = await bru.get(\'/invoices/42\');');
expect(clipboard).not.toContain('Copy');
});
});
});
2 changes: 2 additions & 0 deletions packages/bruno-api-docs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -82,13 +82,15 @@
"crypto-js": "^4.2.0",
"fast-json-format": "~0.4.0",
"fuse.js": "^7.5.0",
"highlight.js": "~11.8.0",
"js-md5": "^0.9.2",
"js-yaml": "^4.1.0",
"jshint": "^2.13.6",
"jsonc-parser": "^3.3.1",
"jsonpath-plus": "^10.3.0",
"lodash-es": "~4.17.21",
"markdown-it": "^14.1.0",
"markdown-it-task-lists": "^2.1.1",
"moment": "^2.30.1",
"monaco-editor": "^0.53.0",
"nanoid": "~3.3.11",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import React from 'react';
import { describe, it, expect } from 'vitest';
import { useRenderToDom } from '@/hooks/useRenderToDom';
import { getByTestId, query } from '@/test-utils/dom';
import { MarkdownContent } from './MarkdownContent';

describe('MarkdownContent', () => {
it('injects the rendered markdown', () => {
const root = useRenderToDom(<MarkdownContent html="<h1>Title</h1><p>Body</p>" testId="docs" />);
const container = getByTestId(root, 'docs');
expect(query(container, 'h1').textContent).toBe('Title');
expect(query(container, 'p').textContent).toBe('Body');
});

it('forwards the class name', () => {
const root = useRenderToDom(
<MarkdownContent html="<p>x</p>" className="markdown-documentation extra" testId="docs" />
);
expect(getByTestId(root, 'docs').getAttribute('class')).toBe('markdown-documentation extra');
});

it('marks the block for heading collection when asked', () => {
const root = useRenderToDom(<MarkdownContent html="<h2>A</h2>" testId="docs" navHeadings navLevel={2} />);
const container = getByTestId(root, 'docs');
expect(container.getAttribute('data-nav-headings')).toBe('');
expect(container.getAttribute('data-nav-level')).toBe('2');
});

it('omits the nav marker by default', () => {
const root = useRenderToDom(<MarkdownContent html="<h2>A</h2>" testId="docs" />);
expect(getByTestId(root, 'docs').getAttribute('data-nav-headings')).toBeUndefined();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import React, { useEffect, useRef, useState, type ElementType } from 'react';
import { createPortal } from 'react-dom';
import { CopyButton } from '@/ui/CopyButton/CopyButton';

interface CopyTarget {
host: HTMLElement;
text: string;
}

interface MarkdownContentProps {
html: string;
className?: string;
testId?: string;
navHeadings?: boolean;
navLevel?: number;
component?: ElementType;
}

const COPY_HOST_CLASS = 'md-code-copy';
const CODE_BLOCK_CLASS = 'md-code-block';

export const MarkdownContent: React.FC<MarkdownContentProps> = ({
html,
className,
testId,
navHeadings,
navLevel,
component: Component = 'div'
}) => {
const rootRef = useRef<HTMLElement>(null);
const [copyTargets, setCopyTargets] = useState<CopyTarget[]>([]);

useEffect(() => {
const root = rootRef.current;
if (!root) {
setCopyTargets([]);
return;
}

const targets = Array.from(root.querySelectorAll('pre')).map((pre) => {
const text = (pre.querySelector('code') ?? pre).textContent || '';

let block = pre.parentElement;
if (!block?.classList.contains(CODE_BLOCK_CLASS)) {
block = document.createElement('div');
block.className = CODE_BLOCK_CLASS;
pre.replaceWith(block);
block.appendChild(pre);
}

let host = block.querySelector<HTMLElement>(`:scope > .${COPY_HOST_CLASS}`);
if (!host) {
host = document.createElement('div');
host.className = COPY_HOST_CLASS;
block.appendChild(host);
}

return { host, text };
});

setCopyTargets((previous) => (previous.length === 0 && targets.length === 0 ? previous : targets));
}, [html]);

return (
<>
<Component
ref={rootRef}
className={className}
data-testid={testId}
data-nav-headings={navHeadings ? '' : undefined}
data-nav-level={navLevel}
dangerouslySetInnerHTML={{ __html: html }}
/>
{copyTargets.map(({ host, text }, index) => createPortal(
<CopyButton text={text} label="Copy code" testId="markdown-code-copy" />,
host,
`md-code-copy-${index}`
))}
</>
);
};

export default MarkdownContent;
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { StyledWrapper } from './StyledWrapper';
import { EmptyState } from '@/ui/EmptyState/EmptyState';
import { BookIcon } from '@/assets/icons';
import NoContentText from '@/ui/NoContentText/NoContentText';
import { MarkdownContent } from '@/components/MarkdownContent/MarkdownContent';

interface OverviewTabProps {
docs?: string;
Expand Down Expand Up @@ -35,10 +36,11 @@ const OverviewTab: React.FC<OverviewTabProps> = ({
}

return (
<StyledWrapper
<MarkdownContent
component={StyledWrapper}
className="markdown-documentation"
data-testid="overview-markdown-documentation"
dangerouslySetInnerHTML={{ __html: docsHtml }}
testId="overview-markdown-documentation"
html={docsHtml}
/>
);
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { Tags } from '@/components/Tags/Tags';
import type { HttpRequestBody, HttpRequestBodyVariant } from '@opencollection/types/requests/http';
import type { RequestPageData } from '@/hooks/useRequestPageData';
import { StyledWrapper } from './StyledWrapper';
import { MarkdownContent } from '@/components/MarkdownContent/MarkdownContent';

export const NAV_GROUP = { configuration: 'Configuration' } as const;
export const NAV_LEVEL = { section: 1, configItem: 2 } as const;
Expand Down Expand Up @@ -106,11 +107,11 @@ export const RequestPageLayout: React.FC<RequestPageLayoutProps> = ({

{descHtml && (
<ViewMore collapsedHeight="4.5rem" style={{ marginTop: '1.5rem' }} testId="request-description">
<div
<MarkdownContent
className="markdown-documentation"
data-nav-headings
data-nav-level={NAV_LEVEL.section}
dangerouslySetInnerHTML={{ __html: descHtml }}
navHeadings
navLevel={NAV_LEVEL.section}
html={descHtml}
/>
</ViewMore>
)}
Expand Down
Loading
Loading