diff --git a/.changeset/early-impalas-pump.md b/.changeset/early-impalas-pump.md new file mode 100644 index 00000000..9bc6471c --- /dev/null +++ b/.changeset/early-impalas-pump.md @@ -0,0 +1,5 @@ +--- +"@usebruno/api-docs": patch +--- + +fix(docs): matches Bruno app's markdown rendering (BRU-4228) diff --git a/package-lock.json b/package-lock.json index da5aa9d3..6e4beaac 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6378,6 +6378,15 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/highlight.js": { + "version": "11.8.0", + "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.8.0.tgz", + "integrity": "sha512-MedQhoqVdr0U6SSnWPzfiadUcDHfN/Wzq25AkXiQv9oiOO/sG0S7XkvpFIqWBl9Yq1UYyYOOVORs5UW2XlPyzg==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/hoist-non-react-statics": { "version": "3.3.2", "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", @@ -7711,6 +7720,12 @@ "markdown-it": "bin/markdown-it.mjs" } }, + "node_modules/markdown-it-task-lists": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/markdown-it-task-lists/-/markdown-it-task-lists-2.1.1.tgz", + "integrity": "sha512-TxFAc76Jnhb2OUu+n3yz9RMu4CwGfaT788br6HhEDlvWfdeJcLUsxk1Hgw2yJio0OXsxv7pyIPmvECY7bMbluA==", + "license": "ISC" + }, "node_modules/markdown-it/node_modules/entities": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", @@ -13275,6 +13290,7 @@ "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", @@ -13282,6 +13298,7 @@ "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", diff --git a/packages/bruno-api-docs/e2e/tests/markdown/markdown.spec.ts b/packages/bruno-api-docs/e2e/tests/markdown/markdown.spec.ts new file mode 100644 index 00000000..cfe76a19 --- /dev/null +++ b/packages/bruno-api-docs/e2e/tests/markdown/markdown.spec.ts @@ -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 => { + 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
as a line break rather than as text', async ({ folderPage }) => { + const docs = folderPage.folderMarkdownDocs; + + await expect(docs).not.toContainText(' { + 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'); + }); + }); +}); diff --git a/packages/bruno-api-docs/package.json b/packages/bruno-api-docs/package.json index 9ec83b58..ff638ae1 100644 --- a/packages/bruno-api-docs/package.json +++ b/packages/bruno-api-docs/package.json @@ -82,6 +82,7 @@ "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", @@ -89,6 +90,7 @@ "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", diff --git a/packages/bruno-api-docs/src/components/MarkdownContent/MarkdownContent.spec.tsx b/packages/bruno-api-docs/src/components/MarkdownContent/MarkdownContent.spec.tsx new file mode 100644 index 00000000..98076e11 --- /dev/null +++ b/packages/bruno-api-docs/src/components/MarkdownContent/MarkdownContent.spec.tsx @@ -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(); + 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( + + ); + expect(getByTestId(root, 'docs').getAttribute('class')).toBe('markdown-documentation extra'); + }); + + it('marks the block for heading collection when asked', () => { + const root = useRenderToDom(); + 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(); + expect(getByTestId(root, 'docs').getAttribute('data-nav-headings')).toBeUndefined(); + }); +}); diff --git a/packages/bruno-api-docs/src/components/MarkdownContent/MarkdownContent.tsx b/packages/bruno-api-docs/src/components/MarkdownContent/MarkdownContent.tsx new file mode 100644 index 00000000..259f9681 --- /dev/null +++ b/packages/bruno-api-docs/src/components/MarkdownContent/MarkdownContent.tsx @@ -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 = ({ + html, + className, + testId, + navHeadings, + navLevel, + component: Component = 'div' +}) => { + const rootRef = useRef(null); + const [copyTargets, setCopyTargets] = useState([]); + + 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(`: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 ( + <> + + {copyTargets.map(({ host, text }, index) => createPortal( + , + host, + `md-code-copy-${index}` + ))} + + ); +}; + +export default MarkdownContent; diff --git a/packages/bruno-api-docs/src/components/Playground/Content/Views/Common/OverviewTab/OverviewTab.tsx b/packages/bruno-api-docs/src/components/Playground/Content/Views/Common/OverviewTab/OverviewTab.tsx index 9e65aa32..7ca226e4 100644 --- a/packages/bruno-api-docs/src/components/Playground/Content/Views/Common/OverviewTab/OverviewTab.tsx +++ b/packages/bruno-api-docs/src/components/Playground/Content/Views/Common/OverviewTab/OverviewTab.tsx @@ -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; @@ -35,10 +36,11 @@ const OverviewTab: React.FC = ({ } return ( - ); }; diff --git a/packages/bruno-api-docs/src/components/RequestPageLayout/RequestPageLayout.tsx b/packages/bruno-api-docs/src/components/RequestPageLayout/RequestPageLayout.tsx index c8bd49ce..f3dc9d79 100644 --- a/packages/bruno-api-docs/src/components/RequestPageLayout/RequestPageLayout.tsx +++ b/packages/bruno-api-docs/src/components/RequestPageLayout/RequestPageLayout.tsx @@ -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; @@ -106,11 +107,11 @@ export const RequestPageLayout: React.FC = ({ {descHtml && ( -
)} diff --git a/packages/bruno-api-docs/src/components/UnsupportedRequest/UnsupportedRequest.tsx b/packages/bruno-api-docs/src/components/UnsupportedRequest/UnsupportedRequest.tsx index 9ccd5391..714e20bd 100644 --- a/packages/bruno-api-docs/src/components/UnsupportedRequest/UnsupportedRequest.tsx +++ b/packages/bruno-api-docs/src/components/UnsupportedRequest/UnsupportedRequest.tsx @@ -12,6 +12,7 @@ import { REQUEST_TYPE_LABELS } from '@/constants'; import ViewMore from '../ViewMore/ViewMore'; import { TitleLabel } from '../TitleLabel/TitleLabel'; import { useMarkdownRenderer } from '@/hooks'; +import { MarkdownContent } from '@/components/MarkdownContent/MarkdownContent'; function getRequestTypeLabel(label: string | undefined) { const fallback = { @@ -91,12 +92,12 @@ export const UnsupportedRequest: React.FC = ({ {showRequestDocs && ( -
)} diff --git a/packages/bruno-api-docs/src/hooks/useMarkdownRenderer.spec.ts b/packages/bruno-api-docs/src/hooks/useMarkdownRenderer.spec.ts new file mode 100644 index 00000000..d89f8308 --- /dev/null +++ b/packages/bruno-api-docs/src/hooks/useMarkdownRenderer.spec.ts @@ -0,0 +1,230 @@ +import { describe, it, expect } from 'vitest'; +import { createMarkdownRenderer } from './useMarkdownRenderer'; + +const md = createMarkdownRenderer(); +const render = (src: string) => md.render(src); +const renderText = (src: string) => md.render(src).replace(/<[^>]*>/g, ''); + +describe('createMarkdownRenderer', () => { + describe('headings', () => { + it('tags each level with its heading class so the stylesheet can size it', () => { + expect(render('# One')).toContain('heading-1'); + expect(render('## Two')).toContain('heading-2'); + expect(render('### Three')).toContain('heading-3'); + expect(render('#### Four')).toContain('heading-4'); + expect(render('##### Five')).toContain('heading-5'); + expect(render('###### Six')).toContain('heading-6'); + }); + }); + + describe('line breaks', () => { + it('renders a literal
as a line break, not as escaped text', () => { + const html = render('first line
second line'); + expect(html).toContain('
'); + expect(html).not.toContain('<br'); + }); + + it('accepts every spelling of the tag', () => { + for (const tag of ['
', '
', '
', '
']) { + expect(render(`a${tag}b`)).not.toContain('<br'); + } + }); + + it('renders a hard break inside a table cell', () => { + const html = render('| h |\n| --- |\n| x
y |'); + expect(html).not.toContain('<br'); + }); + + it('still renders the CommonMark backslash hard break', () => { + expect(render('line one\\\nline two')).toContain('
'); + }); + + it('joins a soft newline into one line, the way the app does', () => { + const html = render('line one\nline two'); + expect(html).not.toContain('
'); + expect(html).toContain('line one\nline two'); + }); + }); + + describe('task lists', () => { + it('renders an unchecked item as an empty checkbox, not as [ ] text', () => { + const html = render('- [ ] todo'); + expect(html).toContain('type="checkbox"'); + expect(html).not.toContain('[ ]'); + }); + + it('renders a checked item as a checked checkbox, not as [x] text', () => { + const html = render('- [x] done'); + expect(html).toContain('checked'); + expect(html).not.toContain('[x]'); + }); + + it('normalises [X] and [] the way the Bruno editor does', () => { + expect(render('- [X] done')).toContain('checked'); + expect(render('- [] todo')).toContain('type="checkbox"'); + }); + + it('marks up task items so the stylesheet can drop the bullet', () => { + const html = render('- [ ] todo'); + expect(html).toContain('contains-task-list'); + expect(html).toContain('task-list-item'); + }); + + it('renders checkboxes read-only, since published docs cannot persist a change', () => { + expect(render('- [ ] todo')).toContain('disabled'); + }); + + it('leaves task-list syntax inside a fenced code block untouched', () => { + const text = renderText(['```md', '- [X] example', '- [xx] aligned', '```'].join('\n')); + expect(text).toContain('- [X] example'); + expect(text).toContain('- [xx] aligned'); + expect(render(['```md', '- [X] example', '```'].join('\n'))).not.toContain('type="checkbox"'); + }); + + it('leaves task-list syntax inside an indented code block untouched', () => { + const text = renderText('para\n\n - [X] code sample\n - [] empty'); + expect(text).toContain('- [X] code sample'); + expect(text).toContain('- [] empty'); + expect(render('para\n\n - [X] code sample')).not.toContain('type="checkbox"'); + }); + + it('is not confused by an inline triple-backtick span earlier in the document', () => { + const html = render('a ```inline``` b\n\n- [ ] after'); + expect(html).toContain('type="checkbox"'); + expect(html).not.toContain('[ ] after'); + }); + + it('accepts the same loose markers the app does', () => { + for (const src of ['- [xx] thing', '- [ ] thing', '- [ x] thing']) { + expect(render(src)).toContain('type="checkbox"'); + } + + expect(render('- [ x] thing')).toContain('checked'); + expect(render('- [xx] thing')).not.toContain('checked'); + expect(render('- [ ] thing')).not.toContain('checked'); + }); + + it('accepts a marker with no space after it, as the app does', () => { + expect(render('- [x]done')).toContain('checked'); + expect(render('- [ ]todo')).toContain('type="checkbox"'); + expect(render('- [ ]todo')).not.toContain('checked'); + }); + + it('needs content after the marker, as the app does', () => { + expect(render('- [x]')).not.toContain('type="checkbox"'); + expect(render('- [ ]')).not.toContain('type="checkbox"'); + }); + + it('leaves a tilde-fenced block untouched too', () => { + expect(renderText(['~~~', '- [X] example', '~~~'].join('\n'))).toContain('- [X] example'); + }); + + it('preserves spacing after the marker', () => { + expect(renderText('- [x] spaced out')).toContain(' spaced out'); + }); + + it('does not turn a link labelled x into a checkbox', () => { + const html = render('- [x](https://example.com)'); + expect(html).not.toContain('type="checkbox"'); + expect(html).toContain('x'); + }); + + it('wraps item content in an element that is valid inside a loose list paragraph', () => { + const html = render('- [ ] one\n\n- [x] two'); + expect(html).toContain('

'); + expect(html).not.toContain(' { + const html = render('- [ x] done\n\n[x]: https://example.com'); + expect(html).not.toContain('type="checkbox"'); + expect(html).toContain(''); + }); + + it('leaves a plain list item alone', () => { + const html = render('- plain'); + expect(html).not.toContain('type="checkbox"'); + expect(html).toContain('

  • plain
  • '); + }); + }); + + describe('code blocks', () => { + it('highlights a fence with a known language and keeps the language class', () => { + const html = render('```js\nconst a = 1;\n```'); + expect(html).toContain('hljs-keyword'); + expect(html).toContain('language-js'); + }); + + it('leaves a text fence uncoloured, since text means do not highlight', () => { + for (const lang of ['text', 'plaintext', 'txt']) { + expect(render(`\`\`\`${lang}\nThis is prose. It costs 25.\n\`\`\``)).not.toContain('hljs-'); + } + }); + + it('highlights the languages an API docs renderer needs', () => { + expect(render('```http\nGET /v1/orders HTTP/1.1\n```')).toContain('hljs-'); + expect(render('```graphql\nquery { user(id: 1) { name } }\n```')).toContain('hljs-'); + }); + + it('renders an unlabelled fence without throwing', () => { + expect(() => render('```\nplain text\n```')).not.toThrow(); + expect(render('```\nplain text\n```')).toContain('
    ');
    +    });
    +
    +    it('falls back to auto-detection for an unrecognised language, still escaping the content', () => {
    +      const html = render('```notalanguage\na < b\n```');
    +      expect(html).toContain('<');
    +      expect(html).toContain('language-notalanguage');
    +    });
    +  });
    +
    +  describe('untouched markdown', () => {
    +    it('renders tables inside the scroll wrapper', () => {
    +      const html = render('| a | b |\n| --- | --- |\n| 1 | 2 |');
    +      expect(html).toContain('md-table-scroll');
    +      expect(html).toContain('');
    +    });
    +
    +    it('renders lists, emphasis and blockquotes', () => {
    +      expect(render('- one\n- two')).toContain('