Skip to content
Merged
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -109,5 +109,6 @@ yarn.lock

cypress/screenshots
cypress/videos
.playwright-mcp/

_local/
11 changes: 8 additions & 3 deletions scripts/apidocs/output/page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,12 @@ import { writeFileSync } from 'node:fs';
import { resolve } from 'node:path';
import type { ApiDocsMethod } from '../../../docs/.vitepress/components/api-docs/method';
import { formatMarkdown, formatTypescript } from '../../shared/format';
import { adjustUrls, codeToHtml, mdToHtml } from '../../shared/markdown';
import {
adjustUrls,
codeGroupToHtml,
codeToHtml,
mdToHtml,
} from '../../shared/markdown';
import { FILE_PATH_API_DOCS } from '../../shared/paths';
import { toRefreshableCode } from '../../shared/refreshable-code';
import type { RawApiDocsPage } from '../processing/class';
Expand Down Expand Up @@ -74,7 +79,7 @@ async function writePageMarkdown(page: RawApiDocsPage): Promise<void> {

${adjustUrls(description)}

${examples.length === 0 ? '' : `<div class="examples">${await codeToHtml(examples.join('\n'))}</div>`}
${examples.length === 0 ? '' : `<div class="examples">${await codeGroupToHtml(examples)}</div>`}

:::

Expand Down Expand Up @@ -199,7 +204,7 @@ async function toMethodData(method: RawApiDocsMethod): Promise<ApiDocsMethod> {
throws.length === 0 ? undefined : await mdToHtml(throws.join('\n'), true),
returns: returns.text,
signature: await codeToHtml(formattedSignature),
examples: await codeToHtml(examples.join('\n')),
examples: await codeGroupToHtml(examples),
Comment thread
ST-DDT marked this conversation as resolved.
refresh,
deprecated: await mdToHtml(deprecated),
seeAlsos: await Promise.all(
Expand Down
58 changes: 57 additions & 1 deletion scripts/shared/markdown.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ const htmlSanitizeOptions: sanitizeHtml.IOptions = {
'button',
'code',
'div',
'input',
'label',
'li',
'p',
'pre',
Expand All @@ -37,13 +39,15 @@ const htmlSanitizeOptions: sanitizeHtml.IOptions = {
a: ['href', 'target', 'rel'],
button: ['class', 'title'],
div: ['class'],
input: ['type', 'name', 'id', 'checked'],
label: ['for', 'data-title'],
pre: ['class', 'dir', 'style', 'v-pre', 'tabindex'],
span: ['class', 'style'],
table: ['tabindex'],
th: ['style'],
td: ['style'],
},
selfClosing: [],
selfClosing: ['input'],
};

function comparableSanitizedHtml(html: string): string {
Expand All @@ -55,6 +59,7 @@ function comparableSanitizedHtml(html: string): string {
.replaceAll('&lt;', '<')
.replaceAll('&amp;', '&')
.replaceAll('=""', '')
.replaceAll('/>', '>')
.replaceAll(' ', '');
}

Expand All @@ -81,6 +86,57 @@ export async function codeToHtml(code: string): Promise<string> {
return mdToHtml(wrapCode(code));
}

/**
* Converts a list of Typescript code blocks to a tabbed VitePress code group
* and returns the rendered, sanitized HTML.
*
* If only a single code block is provided, a plain code block is rendered
* instead so we don't emit an unnecessary tab strip.
*
* When multiple code blocks are provided, each block's first line must be a
* single-line `//` comment that is used as the tab title. This keeps the tab
* titles visible in JSDoc-driven IDE tooltips without introducing any
* docs-site-only metadata in the source.
*
* @param codes The code blocks to convert.
*
* @returns The converted HTML string.
*/
export async function codeGroupToHtml(codes: string[]): Promise<string> {
if (codes.length <= 1) {
return codeToHtml(codes.join('\n'));
}

const delimiter = '```';
const blocks = codes
.map((code, index) => {
const { title, body } = extractCodeGroupTitle(code, index);
return `${delimiter}ts [${title}]\n${body}\n${delimiter}`;
})
.join('\n\n');
return mdToHtml(`::: code-group\n\n${blocks}\n\n:::`);
}

const codeGroupTitleCommentRegex = /^\s*\/\/\s*(.+?)\s*$/;

function extractCodeGroupTitle(
code: string,
index: number
): { title: string; body: string } {
const newlineIndex = code.indexOf('\n');
const firstLine = newlineIndex === -1 ? code : code.slice(0, newlineIndex);
Comment thread
ST-DDT marked this conversation as resolved.
Outdated
const match = codeGroupTitleCommentRegex.exec(firstLine);
if (match == null) {
Comment thread
ST-DDT marked this conversation as resolved.
Outdated
throw new Error(
`Example ${index + 1} in a multi-example block must start with a \`// Title\` line comment to label the code-group tab, but got: ${JSON.stringify(firstLine)}`
Comment thread
ST-DDT marked this conversation as resolved.
Outdated
);
}

const body = newlineIndex === -1 ? '' : code.slice(newlineIndex + 1);
Comment thread
ST-DDT marked this conversation as resolved.
Outdated

return { title: match[1], body };
Comment thread
ST-DDT marked this conversation as resolved.
Outdated
}

/**
* Converts Markdown to an HTML string and sanitizes it.
*
Expand Down
2 changes: 2 additions & 0 deletions src/faker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import { SimpleFaker } from './simple-faker';
* Please have a look at the individual modules and methods for more information and examples.
*
* @example
* // Default Faker instance
* import { faker } from '@faker-js/faker';
* // const { faker } = require('@faker-js/faker');
*
Expand All @@ -43,6 +44,7 @@ import { SimpleFaker } from './simple-faker';
* faker.person.firstName(); // 'John'
* faker.person.lastName(); // 'Doe'
* @example
* // Custom locale without en fallback
* import { Faker, es } from '@faker-js/faker';
* // const { Faker, es } = require('@faker-js/faker');
*
Expand Down
19 changes: 19 additions & 0 deletions test/scripts/apidocs/__snapshots__/class.spec.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ exports[`class > expected and actual modules are equal 1`] = `
"ModuleDeprecationTest",
"ModuleExampleTest",
"ModuleFakerJsLinkTest",
"ModuleMultipleExamplesTest",
"ModuleNextFakerJsLinkTest",
"ModuleSimpleTest",
]
Expand Down Expand Up @@ -49,6 +50,24 @@ and [api docs](https://fakerjs.dev/api/).",
}
`;

exports[`class > processClass(ModuleMultipleExamplesTest) 1`] = `
{
"camelTitle": "moduleMultipleExamplesTest",
"category": undefined,
"deprecated": undefined,
"description": "This is a description for a module with multiple code examples.",
"examples": [
"// Basic instantiation
new ModuleMultipleExamplesTest()",
"// Stateful usage
const instance = new ModuleMultipleExamplesTest();
instance.doSomething();",
],
"methods": [],
"title": "ModuleMultipleExamplesTest",
}
`;

exports[`class > processClass(ModuleNextFakerJsLinkTest) 1`] = `
{
"camelTitle": "moduleNextFakerJsLinkTest",
Expand Down
35 changes: 35 additions & 0 deletions test/scripts/apidocs/__snapshots__/method.spec.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ exports[`method > expected and actual methods are equal 1`] = `
"methodWithDeprecated",
"methodWithDeprecatedOption",
"methodWithExample",
"methodWithMultipleExamples",
"methodWithMultipleRemarks",
"methodWithMultipleSeeMarkers",
"methodWithMultipleSeeMarkersAndBackticks",
Expand Down Expand Up @@ -604,6 +605,40 @@ exports[`method > processMethodLike(methodWithExample) 1`] = `
}
`;

exports[`method > processMethodLike(methodWithMultipleExamples) 1`] = `
{
"name": "methodWithMultipleExamples",
"signatures": [
{
"deprecated": undefined,
"description": "Test with multiple example markers.",
"examples": [
"// Inline usage
test.apidocs.methodWithMultipleExamples() // 0",
"// Stored in a variable
const value = test.apidocs.methodWithMultipleExamples();
console.log(value); // 0",
],
"parameters": [],
"remarks": [],
"returns": {
"text": "number",
"type": "simple",
},
"seeAlsos": [],
"signature": "function methodWithMultipleExamples(): number;",
"since": "1.0.0",
"throws": [],
},
],
"source": {
"column": -1,
"filePath": "test/scripts/apidocs/method.example.ts",
"line": -1,
},
}
`;

exports[`method > processMethodLike(methodWithMultipleRemarks) 1`] = `
{
"name": "methodWithMultipleRemarks",
Expand Down
13 changes: 13 additions & 0 deletions test/scripts/apidocs/class.example.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,16 @@ export class ModuleDeprecationTest {}
* new ModuleExampleTest()
*/
export class ModuleExampleTest {}

/**
* This is a description for a module with multiple code examples.
*
* @example
* // Basic instantiation
* new ModuleMultipleExamplesTest()
* @example
* // Stateful usage
* const instance = new ModuleMultipleExamplesTest();
* instance.doSomething();
*/
export class ModuleMultipleExamplesTest {}
17 changes: 17 additions & 0 deletions test/scripts/apidocs/method.example.ts
Original file line number Diff line number Diff line change
Expand Up @@ -347,6 +347,23 @@ export class SignatureTest {
return 0;
}

/**
* Test with multiple example markers.
*
* @example
* // Inline usage
* test.apidocs.methodWithMultipleExamples() // 0
* @example
* // Stored in a variable
* const value = test.apidocs.methodWithMultipleExamples();
* console.log(value); // 0
*
* @since 1.0.0
*/
methodWithMultipleExamples(): number {
return 0;
}

/**
* Test with deprecated and see marker.
*
Expand Down
14 changes: 14 additions & 0 deletions test/scripts/shared/__snapshots__/markdown.spec.ts.snap
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html

exports[`markdown > codeGroupToHtml() > renders a plain code block for a single example 1`] = `
"<div class="language-ts"><button title="Copy Code" class="copy"></button><span class="lang">ts</span><pre class="shiki shiki-themes github-light github-dark" style="--shiki-light:#24292e;--shiki-dark:#e1e4e8;--shiki-light-bg:#fff;--shiki-dark-bg:#24292e" tabindex="0" dir="ltr" v-pre><code><span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">const</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF"> a</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583"> =</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF"> 1</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">;</span></span></code></pre>
</div>"
`;

exports[`markdown > codeGroupToHtml() > renders a tabbed code group with titles from leading comments 1`] = `
"<div class="vp-code-group"><div class="tabs"><input type="radio" name="group-0" id="tab-1" checked /><label data-title="First title" for="tab-1">First title</label><input type="radio" name="group-0" id="tab-2" /><label data-title="Second title" for="tab-2">Second title</label></div><div class="blocks">
<div class="language-ts active"><button title="Copy Code" class="copy"></button><span class="lang">ts</span><pre class="shiki shiki-themes github-light github-dark" style="--shiki-light:#24292e;--shiki-dark:#e1e4e8;--shiki-light-bg:#fff;--shiki-dark-bg:#24292e" tabindex="0" dir="ltr" v-pre><code><span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">const</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF"> a</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583"> =</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF"> 1</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">;</span></span></code></pre>
</div><div class="language-ts"><button title="Copy Code" class="copy"></button><span class="lang">ts</span><pre class="shiki shiki-themes github-light github-dark" style="--shiki-light:#24292e;--shiki-dark:#e1e4e8;--shiki-light-bg:#fff;--shiki-dark-bg:#24292e" tabindex="0" dir="ltr" v-pre><code><span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">const</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF"> b</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583"> =</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF"> 2</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">;</span></span></code></pre>
</div></div></div>
"
`;
36 changes: 36 additions & 0 deletions test/scripts/shared/markdown.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { beforeAll, describe, expect, it } from 'vitest';
import {
codeGroupToHtml,
initMarkdownRenderer,
} from '../../../scripts/shared/markdown';

describe('markdown', () => {
beforeAll(async () => {
await initMarkdownRenderer();
});

describe('codeGroupToHtml()', () => {
it('renders a plain code block for a single example', async () => {
const html = await codeGroupToHtml(['const a = 1;']);

expect(html).toMatchSnapshot();
});

it('renders a tabbed code group with titles from leading comments', async () => {
const html = await codeGroupToHtml([
'// First title\nconst a = 1;',
'// Second title\nconst b = 2;',
]);

expect(html).toMatchSnapshot();
});

it('throws when a multi-example block is missing a title comment', async () => {
await expect(
codeGroupToHtml(['// First title\nconst a = 1;', 'const b = 2;'])
).rejects.toThrow(
/Example 2 in a multi-example block must start with a `\/\/ Title` line comment/
);
});
});
});
Loading