diff --git a/.agents/agents/rapidoc-core.md b/.agents/agents/rapidoc-core.md index f7ac135dd..899bb075c 100644 --- a/.agents/agents/rapidoc-core.md +++ b/.agents/agents/rapidoc-core.md @@ -7,13 +7,13 @@ You are a specialist agent dedicated to `packages/rapidoc` — the core Lit-base ## Workspace Scope & Key Paths - **Package Directory**: `packages/rapidoc` -- **Component Entry**: `packages/rapidoc/src/index.js` -- **Main Custom Element**: `packages/rapidoc/src/rapidoc.js` +- **Component Entry**: `packages/rapidoc/src/index.ts` +- **Main Custom Element**: `packages/rapidoc/src/rapidoc.ts` - **Parser & Schema Utilities**: - - `packages/rapidoc/src/utils/spec-parser.js` - - `packages/rapidoc/src/utils/schema-ast.js` - - `packages/rapidoc/src/utils/schema-utils.js` - - `packages/rapidoc/src/utils/mock-interceptor.js` + - `packages/rapidoc/src/utils/spec-parser.ts` + - `packages/rapidoc/src/utils/schema-ast.ts` + - `packages/rapidoc/src/utils/schema-utils.ts` + - `packages/rapidoc/src/utils/mock-interceptor.ts` - **Sub-templates**: `packages/rapidoc/src/templates/` - **Styles**: `packages/rapidoc/src/styles/` - **Tests**: `tests/unit/*.test.js`, `tests/perf/benchmark.js` diff --git a/README.md b/README.md index b92a807a8..60b741b0c 100644 --- a/README.md +++ b/README.md @@ -121,7 +121,7 @@ npm run dev npm run stop ``` - Open `http://localhost:4321` in your browser. -- **Live Unminified ES Modules:** In development mode (`npm run dev`), the Astro dev server bypasses pre-compiled production bundles and serves live ES modules directly from `packages/rapidoc/src/index.js`. +- **Live Unminified ES Modules:** In development mode (`npm run dev`), the Astro dev server bypasses pre-compiled production bundles and serves live ES modules directly from `packages/rapidoc/src/index.ts`. - **Full DevTools & Console Visibility:** All `console.log`, `console.warn`, `console.error`, and `debugger` breakpoints remain completely intact with original file names and exact line numbers (e.g., `rapidoc.js:516`). - **Instant Browser Reloading:** Component changes in `packages/rapidoc/src/` trigger immediate hot reloads without waiting for a full Rollup/Vite compilation cycle. @@ -159,7 +159,7 @@ Astro documentation and example pages include the component via ` + + + + +`); + } + res.writeHead(404); + res.end('Not Found'); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + baseUrl = `http://127.0.0.1:${server.address().port}`; + browser = await chromium.launch({ + executablePath: findChromeExecutable(), + headless: true, + args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-gpu'], + }); + }); + + after(async () => { + if (browser) await browser.close(); + if (server) server.close(); + }); + + for (const variant of ['default', 'headings']) { + it(`keeps links of the info description on the current page (${variant} renderer)`, async () => { + const page = await browser.newPage(); + await page.goto(`${baseUrl}/${variant}.html`, { waitUntil: 'networkidle' }); + const link = page.locator('rapi-doc a.anchor-link', { hasText: 'status codes' }).first(); + await link.waitFor({ state: 'attached', timeout: 5000 }); + assert.equal(await link.getAttribute('href'), `${baseUrl}/${variant}.html#api-status-codes`); + await page.close(); + }); + } + + it('also fixes links in response descriptions', async () => { + const page = await browser.newPage(); + await page.goto(`${baseUrl}/default.html`, { waitUntil: 'networkidle' }); + const link = page.locator('rapi-doc a.anchor-link', { hasText: 'the overview' }).first(); + await link.waitFor({ state: 'attached', timeout: 5000 }); + assert.equal(await link.getAttribute('href'), `${baseUrl}/default.html#overview`); + await page.close(); + }); +}); diff --git a/tests/perf/benchmark.js b/tests/perf/benchmark.js index cdb29f4e1..824f55cef 100644 --- a/tests/perf/benchmark.js +++ b/tests/perf/benchmark.js @@ -16,7 +16,7 @@ import path from 'path'; import { fileURLToPath } from 'url'; import { performance } from 'perf_hooks'; import { load, dereference } from '@scalar/openapi-parser'; -import ProcessSpec from '../../packages/rapidoc/src/utils/spec-parser.js'; +import ProcessSpec from '../../packages/rapidoc/src/utils/spec-parser.ts'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); diff --git a/tests/unit/api-request-response.test.js b/tests/unit/api-request-response.test.js index b22d6d10b..fe801f666 100644 --- a/tests/unit/api-request-response.test.js +++ b/tests/unit/api-request-response.test.js @@ -1,6 +1,6 @@ import { describe, it } from 'node:test'; import assert from 'node:assert/strict'; -import { processFetchResponse } from '../../packages/rapidoc/src/utils/response-utils.js'; +import { processFetchResponse } from '../../packages/rapidoc/src/utils/response-utils.ts'; describe('processFetchResponse', () => { it('should parse JSON response and format with 2-space indentation', async () => { diff --git a/tests/unit/markdown-utils.test.js b/tests/unit/markdown-utils.test.js new file mode 100644 index 000000000..b46c697cb --- /dev/null +++ b/tests/unit/markdown-utils.test.js @@ -0,0 +1,53 @@ +import { describe, it, beforeEach, afterEach } from 'node:test'; +import assert from 'node:assert/strict'; +import { marked } from 'marked'; +import { fixRenderedAnchorLinks } from '../../packages/rapidoc/src/utils/markdown-utils.ts'; + +describe('markdown in-page anchor links', () => { + let originalLocation; + beforeEach(() => { + originalLocation = globalThis.location; + globalThis.location = { href: 'https://example.com/docs/page?x=1#old-hash' }; + }); + afterEach(() => { + if (originalLocation === undefined) { + delete globalThis.location; + } else { + globalThis.location = originalLocation; + } + }); + + it('renders #anchors as absolute URLs of the current page with the anchor-link class', () => { + const html = marked.parse('[go](#section-1)'); + assert.match(html, /go<\/a>/); + }); + + it('keeps the link title only when present', () => { + assert.match(marked.parse('[go](#a "Title")'), /title="Title"/); + assert.doesNotMatch(marked.parse('[go](#a)'), /title=/); + }); + + it('renders inline markdown inside the link text', () => { + assert.match(marked.parse('[**bold**](#a)'), /]*>bold<\/strong><\/a>/); + }); + + it('leaves external and relative links to the default renderer', () => { + const html = marked.parse('[ext](https://other.org/a) [rel](./b)'); + assert.match(html, /ext<\/a>/); + assert.match(html, /rel<\/a>/); + assert.doesNotMatch(html, /anchor-link/); + }); + + it('also fixes links when a custom renderer replaces the global one (e.g. headings in the nav bar)', () => { + const renderer = new marked.Renderer(); + fixRenderedAnchorLinks(renderer); + const html = marked.parse('[go](#a) and [ext](https://other.org/a)', { renderer }); + assert.match(html, /go<\/a>/); + assert.match(html, /ext<\/a>/); + }); + + it('does not fix links of a custom renderer that was not opted in', () => { + const html = marked.parse('[go](#a)', { renderer: new marked.Renderer() }); + assert.doesNotMatch(html, /anchor-link/); + }); +}); diff --git a/tests/unit/mock-interceptor.test.js b/tests/unit/mock-interceptor.test.js index 7ca819ced..413468b4d 100644 --- a/tests/unit/mock-interceptor.test.js +++ b/tests/unit/mock-interceptor.test.js @@ -6,7 +6,7 @@ import { disableMockServer, updateMockConfig, isMockServerActive, -} from '../../packages/rapidoc/src/utils/mock-interceptor.js'; +} from '../../packages/rapidoc/src/utils/mock-interceptor.ts'; describe('mock-interceptor', () => { const originalFetch = async (url) => { diff --git a/tests/unit/schema-ast.test.js b/tests/unit/schema-ast.test.js index f94ea25aa..a7b952e76 100644 --- a/tests/unit/schema-ast.test.js +++ b/tests/unit/schema-ast.test.js @@ -1,6 +1,6 @@ import { describe, it } from 'node:test'; import assert from 'node:assert'; -import { schemaToAST } from '../../packages/rapidoc/src/utils/schema-utils.js'; +import { schemaToAST } from '../../packages/rapidoc/src/utils/schema-utils.ts'; describe('schemaToAST', () => { describe('primitive data types', () => { diff --git a/tests/unit/schema-in-object-notation.test.js b/tests/unit/schema-in-object-notation.test.js index 675f04b55..56661babb 100644 --- a/tests/unit/schema-in-object-notation.test.js +++ b/tests/unit/schema-in-object-notation.test.js @@ -1,6 +1,6 @@ import { describe, it } from 'node:test'; import assert from 'node:assert'; -import { schemaInObjectNotation } from '../../packages/rapidoc/src/utils/schema-utils.js'; +import { schemaInObjectNotation } from '../../packages/rapidoc/src/utils/schema-utils.ts'; describe('schemaInObjectNotation', () => { describe('primitive data types', () => { diff --git a/tests/unit/schema-utils.test.js b/tests/unit/schema-utils.test.js index 9482c3b43..e3f3f8809 100644 --- a/tests/unit/schema-utils.test.js +++ b/tests/unit/schema-utils.test.js @@ -1,6 +1,6 @@ import { describe, it } from 'node:test'; import assert from 'node:assert'; -import { generateExample, getTypeInfo, isBinaryFileField } from '../../packages/rapidoc/src/utils/schema-utils.js'; +import { generateExample, getTypeInfo, isBinaryFileField, standardizeExample } from '../../packages/rapidoc/src/utils/schema-utils.ts'; describe('generateExample', () => { it('should use single "example" when "examples" is undefined', () => { @@ -456,3 +456,21 @@ describe('generateExample with contentEncoding and contentMediaType', () => { assert.strictEqual(parsed.name, 'Alice'); }); }); + +describe('standardizeExample', () => { + it('should return undefined for a null example instead of throwing', () => { + assert.equal(standardizeExample(null), undefined); + }); + + it('should wrap a single object with a value property', () => { + assert.deepEqual(standardizeExample({ value: 1, summary: 's' }), { Example: { value: 1, summary: 's' } }); + }); + + it('should ignore null entries of an examples map instead of throwing', () => { + assert.deepEqual(standardizeExample({ a: null, b: { value: 2 } }), { b: { value: 2 } }); + }); + + it('should return undefined for undefined', () => { + assert.equal(standardizeExample(undefined), undefined); + }); +});