diff --git a/.gitignore b/.gitignore index a26870e4325..df1219d9d09 100644 --- a/.gitignore +++ b/.gitignore @@ -80,6 +80,11 @@ versions.json /dist /docs/.vitepress/cache /docs/.vitepress/dist +/docs/api/*.ts +!/docs/api/api-types.ts +/docs/api/*.md +!/docs/api/index.md +/docs/api/api-search-index.json /docs/public/api-diff-index.json # Faker diff --git a/cypress/e2e/example-refresh.cy.ts b/cypress/e2e/example-refresh.cy.ts new file mode 100644 index 00000000000..df792eba5b2 --- /dev/null +++ b/cypress/e2e/example-refresh.cy.ts @@ -0,0 +1,32 @@ +describe('example-refresh', () => { + it('should refresh the example', () => { + // given + cy.visit('/api/faker.html#constructor'); + cy.get('.refresh').first().as('refresh'); + cy.get('@refresh').next().find('code').as('codeBlock'); + cy.get('@codeBlock').then(($el) => { + const originalCodeText = $el.text(); + + cy.get('@refresh') + .click() + .should('not.be.disabled') // stays disabled on error + .then(() => { + cy.get('@codeBlock').then(($el) => { + const newCodeText = $el.text(); + expect(newCodeText).not.to.equal(originalCodeText); + + cy.get('@refresh') + .click() + .should('not.be.disabled') // stays disabled on error + .then(() => { + cy.get('@codeBlock').then(($el) => { + const newCodeText2 = $el.text(); + expect(newCodeText2).not.to.equal(originalCodeText); + expect(newCodeText2).not.to.equal(newCodeText); + }); + }); + }); + }); + }); + }); +}); diff --git a/docs/.vitepress/components/api-docs/format.ts b/docs/.vitepress/components/api-docs/format.ts new file mode 100644 index 00000000000..34de1e0c494 --- /dev/null +++ b/docs/.vitepress/components/api-docs/format.ts @@ -0,0 +1,14 @@ +export function formatResult(result: unknown): string { + return result === undefined + ? 'undefined' + : typeof result === 'bigint' + ? `${result}n` + : JSON.stringify(result, undefined, 2) + .replaceAll('\\r', '') + .replaceAll('<', '<') + .replaceAll( + /(^ *|: )"([^'\n]*?)"(?=,?$|: )/gm, + (_, p1, p2) => `${p1}'${p2.replace(/\\"/g, '"')}'` + ) + .replaceAll(/\n */g, ' '); +} diff --git a/docs/.vitepress/components/api-docs/method.ts b/docs/.vitepress/components/api-docs/method.ts index 4da480b806e..91f99d4ee8f 100644 --- a/docs/.vitepress/components/api-docs/method.ts +++ b/docs/.vitepress/components/api-docs/method.ts @@ -8,6 +8,7 @@ export interface ApiDocsMethod { readonly throws: string | undefined; // HTML readonly signature: string; // HTML readonly examples: string; // HTML + readonly refresh: (() => Promise) | undefined; readonly seeAlsos: string[]; readonly sourcePath: string; // URL-Suffix } diff --git a/docs/.vitepress/components/api-docs/method.vue b/docs/.vitepress/components/api-docs/method.vue index 83a4100cfde..37b447482c7 100644 --- a/docs/.vitepress/components/api-docs/method.vue +++ b/docs/.vitepress/components/api-docs/method.vue @@ -1,8 +1,11 @@ + + + + diff --git a/docs/.vitepress/components/api-docs/refresh.svg b/docs/.vitepress/components/api-docs/refresh.svg new file mode 100644 index 00000000000..8320a2b2ba6 --- /dev/null +++ b/docs/.vitepress/components/api-docs/refresh.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 746803ad594..4fcc469eb92 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -133,6 +133,7 @@ async function enableFaker() { e.g. 'faker.food.description()' or 'fakerZH_CN.person.firstName()' For other languages please refer to https://fakerjs.dev/guide/localization.html#available-locales For a full list of all methods please refer to https://fakerjs.dev/api/\`, logStyle); + enableFaker = () => imported; // Init only once return imported; } `, diff --git a/docs/api/.gitignore b/docs/api/.gitignore deleted file mode 100644 index 47b11a83ed7..00000000000 --- a/docs/api/.gitignore +++ /dev/null @@ -1,10 +0,0 @@ -# Markdown -*.md -!index.md - -# TypeScript -*.ts -!api-types.ts - -# JSON -*.json diff --git a/eslint.config.ts b/eslint.config.ts index 0ae63ccc3d8..b23bdd45c93 100644 --- a/eslint.config.ts +++ b/eslint.config.ts @@ -24,6 +24,7 @@ const config: ReturnType = tseslint.config( '.github/workflows/commentCodeGeneration.ts', '.prettierrc.js', 'docs/.vitepress/components/shims.d.ts', + 'docs/.vitepress/components/api-docs/format.ts', 'docs/.vitepress/shared/utils/slugify.ts', 'docs/.vitepress/theme/index.ts', 'eslint.config.js', diff --git a/scripts/apidocs/output/page.ts b/scripts/apidocs/output/page.ts index c00ba4d23ad..a95703099c4 100644 --- a/scripts/apidocs/output/page.ts +++ b/scripts/apidocs/output/page.ts @@ -33,7 +33,7 @@ export async function writePages(pages: RawApiDocsPage[]): Promise { async function writePage(page: RawApiDocsPage): Promise { try { await writePageMarkdown(page); - await writePageJsonData(page); + await writePageData(page); } catch (error) { throw new Error(`Error writing page ${page.title}`, { cause: error }); } @@ -51,7 +51,7 @@ async function writePageMarkdown(page: RawApiDocsPage): Promise { let content = ` @@ -98,16 +98,33 @@ async function writePageMarkdown(page: RawApiDocsPage): Promise { * * @param page The page to write. */ -async function writePageJsonData(page: RawApiDocsPage): Promise { +async function writePageData(page: RawApiDocsPage): Promise { const { camelTitle, methods } = page; const pageData: Record = Object.fromEntries( await Promise.all( methods.map(async (method) => [method.name, await toMethodData(method)]) ) ); - const content = JSON.stringify(pageData, null, 2); - writeFileSync(resolve(FILE_PATH_API_DOCS, `${camelTitle}.json`), content); + const refreshFunctions: Record = Object.fromEntries( + await Promise.all( + methods.map(async (method) => [ + method.name, + await toRefreshFunction(method), + ]) + ) + ); + + const content = + `export default ${JSON.stringify(pageData, undefined, 2)}`.replaceAll( + /"refresh-([^"-]+)-placeholder"/g, + (_, name) => refreshFunctions[name] + ); + + writeFileSync( + resolve(FILE_PATH_API_DOCS, `${camelTitle}.ts`), + await formatTypescript(content) + ); } const defaultCommentRegex = /\s+Defaults to `([^`]+)`\..*/; @@ -130,6 +147,12 @@ async function toMethodData(method: RawApiDocsMethod): Promise { let formattedSignature = await formatTypescript(signature); formattedSignature = formattedSignature.trim(); + // eslint-disable-next-line @typescript-eslint/require-await + const refresh = async () => ['refresh', name, 'placeholder']; + // This is a placeholder to be replaced by the actual refresh function code + // If we put the actual code here, it would be a string and not executable + refresh.toJSON = () => `refresh-${name}-placeholder`; + /* Target order, omitted to improve diff to old files return { name, @@ -167,6 +190,7 @@ async function toMethodData(method: RawApiDocsMethod): Promise { returns: returns.text, signature: codeToHtml(formattedSignature), examples: codeToHtml(examples.join('\n')), + refresh, deprecated: mdToHtml(deprecated), seeAlsos: seeAlsos.map((seeAlso) => mdToHtml(seeAlso, true)), }; @@ -175,3 +199,49 @@ async function toMethodData(method: RawApiDocsMethod): Promise { export function extractSummaryDefault(description: string): string | undefined { return defaultCommentRegex.exec(description)?.[1]; } + +export async function toRefreshFunction( + method: RawApiDocsMethod +): Promise { + const { name, signatures } = method; + const signatureData = required(signatures.at(-1), 'method signature'); + const { examples } = signatureData; + + const exampleCode = examples.join('\n'); + if (!/^\w*faker\w*\./im.test(exampleCode)) { + // No recordable faker calls in examples + return 'undefined'; + } + + const exampleLines = exampleCode + .replaceAll(/ ?\/\/.*$/gm, '') // Remove comments + .replaceAll(/^import .*$/gm, '') // Remove imports + .replaceAll( + // record results of faker calls + /^(\w*faker\w*\..+(?:(?:.|\n..)*\n[^ ])?\)(?:\.\w+)?);?$/gim, + `try { result.push($1); } catch (error: unknown) { result.push(error instanceof Error ? error.name : 'Error'); }\n` + ); + + const fullMethod = `async (): Promise => { +await enableFaker(); +faker.seed(); +faker.setDefaultRefDate(); +const result: unknown[] = []; + +${exampleLines} + +return result; +}`; + try { + const formattedMethod = await formatTypescript(fullMethod); + return formattedMethod.replace(/;\s+$/, ''); // Remove trailing semicolon + } catch (error: unknown) { + console.error( + 'Failed to format refresh function for', + name, + fullMethod, + error + ); + return 'undefined'; + } +} diff --git a/test/docs/__snapshots__/format.spec.ts.snap b/test/docs/__snapshots__/format.spec.ts.snap new file mode 100644 index 00000000000..36679523c33 --- /dev/null +++ b/test/docs/__snapshots__/format.spec.ts.snap @@ -0,0 +1,19 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`formatResult > should format Date 1`] = `"'2025-01-01T00:00:00.000Z'"`; + +exports[`formatResult > should format array 1`] = `"[ 1, '2' ]"`; + +exports[`formatResult > should format bigint 1`] = `"135464154865415n"`; + +exports[`formatResult > should format number 1`] = `"123"`; + +exports[`formatResult > should format object 1`] = `"{ 'a': 1, 'b': '2' }"`; + +exports[`formatResult > should format string 1`] = `"'a simple string'"`; + +exports[`formatResult > should format string with new lines 1`] = `"'string\\nwith\\nnew\\nlines'"`; + +exports[`formatResult > should format string with special characters 1`] = `"'string with "special" characters'"`; + +exports[`formatResult > should format undefined 1`] = `"undefined"`; diff --git a/test/docs/format.spec.ts b/test/docs/format.spec.ts new file mode 100644 index 00000000000..bc4a0d6638e --- /dev/null +++ b/test/docs/format.spec.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from 'vitest'; +import { formatResult } from '../../docs/.vitepress/components/api-docs/format'; + +describe('formatResult', () => { + it('should format undefined', () => { + const value = undefined; + const actual = formatResult(value); + + expect(actual).toBeTypeOf('string'); + expect(actual).toBe('undefined'); + expect(actual).toMatchSnapshot(); + }); + + it('should format bigint', () => { + const actual = formatResult(135464154865415n); + + expect(actual).toBeTypeOf('string'); + expect(actual).toMatchSnapshot(); + }); + + it('should format object', () => { + const actual = formatResult({ a: 1, b: '2' }); + + expect(actual).toBeTypeOf('string'); + expect(actual).toMatchSnapshot(); + }); + + it('should format array', () => { + const actual = formatResult([1, '2']); + + expect(actual).toBeTypeOf('string'); + expect(actual).toMatchSnapshot(); + }); + + it('should format string', () => { + const actual = formatResult('a simple string'); + + expect(actual).toBeTypeOf('string'); + expect(actual).toMatchSnapshot(); + }); + + it('should format string with special characters', () => { + const actual = formatResult('string with "special" characters'); + + expect(actual).toBeTypeOf('string'); + expect(actual).toMatchSnapshot(); + }); + + it('should format string with new lines', () => { + const actual = formatResult('string\nwith\nnew\nlines'); + + expect(actual).toBeTypeOf('string'); + expect(actual).toMatchSnapshot(); + }); + + it('should format number', () => { + const actual = formatResult(123); + + expect(actual).toBeTypeOf('string'); + expect(actual).toMatchSnapshot(); + }); + + it('should format Date', () => { + const actual = formatResult(new Date(Date.UTC(2025, 0, 1))); + + expect(actual).toBeTypeOf('string'); + expect(actual).toMatchSnapshot(); + }); +}); diff --git a/test/scripts/apidocs/__snapshots__/page.spec.ts.snap b/test/scripts/apidocs/__snapshots__/page.spec.ts.snap new file mode 100644 index 00000000000..4824c101ab5 --- /dev/null +++ b/test/scripts/apidocs/__snapshots__/page.spec.ts.snap @@ -0,0 +1,97 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`toRefreshFunction > should handle multiline calls 1`] = ` +"async (): Promise => { + await enableFaker(); + faker.seed(); + faker.setDefaultRefDate(); + const result: unknown[] = []; + + try { + result.push( + faker.number.int({ + min: 1, + max: 10, + }) + ); + } catch (error: unknown) { + result.push(error instanceof Error ? error.name : 'Error'); + } + + return result; +}" +`; + +exports[`toRefreshFunction > should handle multiple calls 1`] = ` +"async (): Promise => { + await enableFaker(); + faker.seed(); + faker.setDefaultRefDate(); + const result: unknown[] = []; + + try { + result.push(faker.number.int()); + } catch (error: unknown) { + result.push(error instanceof Error ? error.name : 'Error'); + } + + try { + result.push(faker.number.int()); + } catch (error: unknown) { + result.push(error instanceof Error ? error.name : 'Error'); + } + + return result; +}" +`; + +exports[`toRefreshFunction > should handle properties after calls 1`] = ` +"async (): Promise => { + await enableFaker(); + faker.seed(); + faker.setDefaultRefDate(); + const result: unknown[] = []; + + try { + result.push(faker.airline.airport().name); + } catch (error: unknown) { + result.push(error instanceof Error ? error.name : 'Error'); + } + + return result; +}" +`; + +exports[`toRefreshFunction > should handle single line calls with semicolon 1`] = ` +"async (): Promise => { + await enableFaker(); + faker.seed(); + faker.setDefaultRefDate(); + const result: unknown[] = []; + + try { + result.push(faker.number.int()); + } catch (error: unknown) { + result.push(error instanceof Error ? error.name : 'Error'); + } + + return result; +}" +`; + +exports[`toRefreshFunction > should handle single line calls without semicolon 1`] = ` +"async (): Promise => { + await enableFaker(); + faker.seed(); + faker.setDefaultRefDate(); + const result: unknown[] = []; + + try { + result.push(faker.number.int()); + } catch (error: unknown) { + result.push(error instanceof Error ? error.name : 'Error'); + } + + return result; +}" +`; diff --git a/test/scripts/apidocs/page.spec.ts b/test/scripts/apidocs/page.spec.ts new file mode 100644 index 00000000000..00fd4da2277 --- /dev/null +++ b/test/scripts/apidocs/page.spec.ts @@ -0,0 +1,114 @@ +import { describe, expect, it } from 'vitest'; +import { toRefreshFunction } from '../../../scripts/apidocs/output/page'; +import type { RawApiDocsMethod } from '../../../scripts/apidocs/processing/method'; +import type { RawApiDocsSignature } from '../../../scripts/apidocs/processing/signature'; + +function newTestMethod( + signature: Partial +): RawApiDocsMethod { + return { + name: 'test', + signatures: [ + { + deprecated: 'deprecated', + description: 'description', + since: 'since', + parameters: [], + returns: { + type: 'simple', + text: 'returns', + }, + throws: [], + signature: 'signature', + examples: [], + seeAlsos: [], + ...signature, + }, + ], + source: { + filePath: 'test/page.spec.ts', + line: 1, + column: 1, + }, + }; +} + +describe('toRefreshFunction', () => { + it("should return 'undefined' when there are no faker calls", async () => { + // given + const method = newTestMethod({ + examples: ['const a = 1;'], + }); + + // when + const result = await toRefreshFunction(method); + + // then + expect(result).toBe('undefined'); + }); + + it('should handle single line calls with semicolon', async () => { + // given + const method = newTestMethod({ + examples: ['faker.number.int(); // 834135'], + }); + + // when + const result = await toRefreshFunction(method); + + // then + expect(result).toMatchSnapshot(); + }); + + it('should handle single line calls without semicolon', async () => { + // given + const method = newTestMethod({ + examples: ['faker.number.int() // 834135'], + }); + + // when + const result = await toRefreshFunction(method); + + // then + expect(result).toMatchSnapshot(); + }); + + it('should handle multiple calls', async () => { + // given + const method = newTestMethod({ + examples: ['faker.number.int()', 'faker.number.int()'], + }); + + // when + const result = await toRefreshFunction(method); + + // then + expect(result).toMatchSnapshot(); + }); + + it('should handle multiline calls', async () => { + // given + const method = newTestMethod({ + examples: 'faker.number.int({\n min: 1,\n max: 10\n})'.split('\n'), + }); + + // when + const result = await toRefreshFunction(method); + + // then + expect(result).toMatchSnapshot(); + }); + + it('should handle properties after calls', async () => { + // given + const method = newTestMethod({ + examples: ['faker.airline.airport().name'], + }); + + // when + const result = await toRefreshFunction(method); + + // then + expect(result).toMatchSnapshot(); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index c8ffb862324..388a8f79c31 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -14,6 +14,8 @@ "exclude": [ "node_modules", "dist", + // Ignore the generated API documentation + "docs/api", // required for the signature related tests on macOS #2280 "test/scripts/apidocs/temp" ]