diff --git a/scripts/apidoc.ts b/scripts/apidoc.ts index ecb1f8fe02c..31171b9d7af 100644 --- a/scripts/apidoc.ts +++ b/scripts/apidoc.ts @@ -1,5 +1,5 @@ import { generate } from './apidoc/generate'; -import { initMarkdownRenderer } from './apidoc/signature'; +import { initMarkdownRenderer } from './apidoc/markdown'; async function build(): Promise { await initMarkdownRenderer(); diff --git a/scripts/apidoc/fakerClass.ts b/scripts/apidoc/fakerClass.ts index 9d11815f0a3..c3a5287d620 100644 --- a/scripts/apidoc/fakerClass.ts +++ b/scripts/apidoc/fakerClass.ts @@ -3,8 +3,8 @@ import { ReflectionKind } from 'typedoc'; import type { Method } from '../../docs/.vitepress/components/api-docs/method'; import { writeApiDocsModule } from './apiDocsWriter'; import { processModuleMethods } from './moduleMethods'; -import { analyzeSignature, toBlock } from './signature'; -import { selectApiSignature } from './typedoc'; +import { analyzeSignature } from './signature'; +import { extractDescription, selectApiSignature } from './typedoc'; import type { ModuleSummary } from './utils'; export function processFakerClass(project: ProjectReflection): ModuleSummary { @@ -21,7 +21,7 @@ export function processFakerClass(project: ProjectReflection): ModuleSummary { function processClass(fakerClass: DeclarationReflection): ModuleSummary { console.log(`Processing Faker class`); - const comment = toBlock(fakerClass.comment); + const comment = extractDescription(fakerClass); const methods: Method[] = []; console.debug(`- constructor`); diff --git a/scripts/apidoc/markdown.ts b/scripts/apidoc/markdown.ts new file mode 100644 index 00000000000..8505b51d715 --- /dev/null +++ b/scripts/apidoc/markdown.ts @@ -0,0 +1,68 @@ +import sanitizeHtml from 'sanitize-html'; +import type { MarkdownRenderer } from 'vitepress'; +import { createMarkdownRenderer } from 'vitepress'; +import vitepressConfig from '../../docs/.vitepress/config'; +import { pathOutputDir } from './utils'; + +let markdown: MarkdownRenderer; + +export async function initMarkdownRenderer(): Promise { + markdown = await createMarkdownRenderer( + pathOutputDir, + vitepressConfig.markdown, + '/' + ); +} + +const htmlSanitizeOptions: sanitizeHtml.IOptions = { + allowedTags: [ + 'a', + 'button', + 'code', + 'div', + 'li', + 'p', + 'pre', + 'span', + 'strong', + 'ul', + ], + allowedAttributes: { + a: ['href', 'target', 'rel'], + button: ['class', 'title'], + div: ['class'], + pre: ['class', 'tabindex', 'v-pre'], + span: ['class', 'style'], + }, + selfClosing: [], +}; + +function comparableSanitizedHtml(html: string): string { + return html + .replace(/>/g, '>') + .replace(/ /g, '') + .replace(/"/g, '"') + .replace(/'/g, "'"); +} + +/** + * Converts Markdown to an HTML string and sanitizes it. + * @param md The markdown to convert. + * @param inline Whether to render the markdown as inline, without a wrapping `

` tag. Defaults to `false`. + * @returns The converted HTML string. + */ +export function mdToHtml(md: string, inline: boolean = false): string { + const rawHtml = inline ? markdown.renderInline(md) : markdown.render(md); + + const safeHtml: string = sanitizeHtml(rawHtml, htmlSanitizeOptions); + // Revert some escaped characters for comparison. + if (comparableSanitizedHtml(rawHtml) === comparableSanitizedHtml(safeHtml)) { + return safeHtml.replace(/https:\/\/(next.)?fakerjs.dev\//g, '/'); + } + + console.debug('Rejected unsafe md:', md); + console.error('Rejected unsafe html:', rawHtml); + console.error('Rejected unsafe html:', comparableSanitizedHtml(rawHtml)); + console.error('Expected safe html:', comparableSanitizedHtml(safeHtml)); + throw new Error('Found unsafe html'); +} diff --git a/scripts/apidoc/moduleMethods.ts b/scripts/apidoc/moduleMethods.ts index 9c902dd7038..55b1869f798 100644 --- a/scripts/apidoc/moduleMethods.ts +++ b/scripts/apidoc/moduleMethods.ts @@ -5,9 +5,10 @@ import type { } from 'typedoc'; import type { Method } from '../../docs/.vitepress/components/api-docs/method'; import { writeApiDocsModule } from './apiDocsWriter'; -import { analyzeSignature, stripAbsoluteFakerUrls, toBlock } from './signature'; +import { analyzeSignature } from './signature'; import { extractDeprecated, + extractDescription, extractModuleFieldName, extractModuleName, selectApiMethodSignatures, @@ -35,7 +36,7 @@ function processModule(module: DeclarationReflection): ModuleSummary { const moduleName = extractModuleName(module); const moduleFieldName = extractModuleFieldName(module); console.log(`Processing Module ${moduleName}`); - const comment = stripAbsoluteFakerUrls(toBlock(module.comment)); + const comment = extractDescription(module); const deprecated = extractDeprecated(module); const methods = processModuleMethods(module, `faker.${moduleFieldName}.`); diff --git a/scripts/apidoc/signature.ts b/scripts/apidoc/signature.ts index 8b5083ae7d6..671015747d9 100644 --- a/scripts/apidoc/signature.ts +++ b/scripts/apidoc/signature.ts @@ -1,4 +1,3 @@ -import sanitizeHtml from 'sanitize-html'; import type { Comment, DeclarationReflection, @@ -10,16 +9,15 @@ import type { Type, } from 'typedoc'; import { ReflectionFlag, ReflectionKind } from 'typedoc'; -import type { MarkdownRenderer } from 'vitepress'; -import { createMarkdownRenderer } from 'vitepress'; import type { Method, MethodParameter, } from '../../docs/.vitepress/components/api-docs/method'; -import vitepressConfig from '../../docs/.vitepress/config'; import { formatTypescript } from './format'; +import { mdToHtml } from './markdown'; import { extractDeprecated, + extractDescription, extractRawDefault, extractRawExamples, extractSeeAlsos, @@ -27,84 +25,11 @@ import { extractSourcePath, extractThrows, joinTagParts, + toBlock, } from './typedoc'; -import { pathOutputDir } from './utils'; const code = '```'; -export const MISSING_DESCRIPTION = 'Missing'; - -export function toBlock(comment?: Comment): string { - return joinTagParts(comment?.summary) || MISSING_DESCRIPTION; -} - -export function stripAbsoluteFakerUrls(markdown: string): string { - return markdown.replace(/https:\/\/(next.)?fakerjs.dev\//g, '/'); -} - -let markdown: MarkdownRenderer; - -export async function initMarkdownRenderer(): Promise { - markdown = await createMarkdownRenderer( - pathOutputDir, - vitepressConfig.markdown, - '/' - ); -} - -const htmlSanitizeOptions: sanitizeHtml.IOptions = { - allowedTags: [ - 'a', - 'button', - 'code', - 'div', - 'li', - 'p', - 'pre', - 'span', - 'strong', - 'ul', - ], - allowedAttributes: { - a: ['href', 'target', 'rel'], - button: ['class', 'title'], - div: ['class'], - pre: ['class', 'tabindex', 'v-pre'], - span: ['class', 'style'], - }, - selfClosing: [], -}; - -function comparableSanitizedHtml(html: string): string { - return html - .replace(/>/g, '>') - .replace(/ /g, '') - .replace(/"/g, '"') - .replace(/'/g, "'"); -} - -/** - * Converts Markdown to an HTML string and sanitizes it. - * @param md The markdown to convert. - * @param inline Whether to render the markdown as inline, without a wrapping `

` tag. Defaults to `false`. - * @returns The converted HTML string. - */ -function mdToHtml(md: string, inline: boolean = false): string { - const rawHtml = inline ? markdown.renderInline(md) : markdown.render(md); - - const safeHtml: string = sanitizeHtml(rawHtml, htmlSanitizeOptions); - // Revert some escaped characters for comparison. - if (comparableSanitizedHtml(rawHtml) === comparableSanitizedHtml(safeHtml)) { - return safeHtml; - } - - console.debug('Rejected unsafe md:', md); - console.error('Rejected unsafe html:', rawHtml); - console.error('Rejected unsafe html:', comparableSanitizedHtml(rawHtml)); - console.error('Expected safe html:', comparableSanitizedHtml(safeHtml)); - throw new Error('Found unsafe html'); -} - export function analyzeSignature( signature: SignatureReflection, accessor: string, @@ -120,7 +45,7 @@ export function analyzeSignature( parameters.push({ name: `<${parameter.name}>`, type: parameter.type ? typeToText(parameter.type) : undefined, - description: mdToHtml(toBlock(parameter.comment)), + description: mdToHtml(extractDescription(parameter)), }); } @@ -166,7 +91,7 @@ export function analyzeSignature( return { name: methodName, - description: mdToHtml(toBlock(signature.comment)), + description: mdToHtml(extractDescription(signature)), parameters: parameters, since: extractSince(signature), sourcePath: extractSourcePath(signature), @@ -200,7 +125,7 @@ function analyzeParameter(parameter: ParameterReflection): { name: declarationName, type: typeToText(type, true), default: defaultValue, - description: mdToHtml(toBlock(parameter.comment)), + description: mdToHtml(extractDescription(parameter)), }, ]; parameters.push(...analyzeParameterOptions(name, type)); diff --git a/scripts/apidoc/typedoc.ts b/scripts/apidoc/typedoc.ts index 858171ac22d..1a782aa9b0d 100644 --- a/scripts/apidoc/typedoc.ts +++ b/scripts/apidoc/typedoc.ts @@ -1,4 +1,5 @@ import type { + Comment, CommentDisplayPart, CommentTag, DeclarationReflection, @@ -149,6 +150,16 @@ export function extractModuleFieldName(module: DeclarationReflection): string { return moduleName.substring(0, 1).toLowerCase() + moduleName.substring(1); } +export const MISSING_DESCRIPTION = 'Missing'; + +export function toBlock(comment?: Comment): string { + return joinTagParts(comment?.summary) || MISSING_DESCRIPTION; +} + +export function extractDescription(reflection: Reflection): string { + return toBlock(reflection.comment); +} + /** * Extracts the source url from the jsdocs. * diff --git a/src/modules/image/index.ts b/src/modules/image/index.ts index e7d268141e2..2e7806ff3b0 100644 --- a/src/modules/image/index.ts +++ b/src/modules/image/index.ts @@ -10,7 +10,7 @@ import { Unsplash } from './providers/unsplash'; * * ### Overview * - * For a random image, use [`url()`](https://next.fakerjs.dev/api/image.html#url). This will not return the image directly but a URL pointing to an image from one of two demo image providers "Picsum" and "LoremFlickr". You can request an image specifically from one of two providers using [`urlLoremFlickr()`](https://next.fakerjs.dev/api/image.html#urlloremflickr) or [`urlPicsum()`](https://next.fakerjs.dev/api/image.html#urlpicsum). + * For a random image, use [`url()`](https://next.fakerjs.dev/api/image.html#url). This will not return the image directly but a URL pointing to an image from one of two demo image providers "Picsum" and "LoremFlickr". You can request an image specifically from one of two providers using [`urlLoremFlickr()`](https://next.fakerjs.dev/api/image.html#urlloremflickr) or [`urlPicsumPhotos()`](https://next.fakerjs.dev/api/image.html#urlpicsumphotos). * * For a random placeholder image containing only solid color and text, use [`urlPlaceholder()`](https://next.fakerjs.dev/api/image.html#urlplaceholder) (uses a third-party service) or [`dataUri()`](https://next.fakerjs.dev/api/image.html#datauri) (returns a SVG string). * diff --git a/test/scripts/apidoc/signature.debug.ts b/test/scripts/apidoc/signature.debug.ts index 0076dfa74e3..347b8235b0f 100644 --- a/test/scripts/apidoc/signature.debug.ts +++ b/test/scripts/apidoc/signature.debug.ts @@ -2,10 +2,8 @@ * This file exists, because vitest doesn't allow me to debug code outside of src and test. * And it's easier to test these features independently from the main project. */ -import { - analyzeSignature, - initMarkdownRenderer, -} from '../../../scripts/apidoc/signature'; +import { initMarkdownRenderer } from '../../../scripts/apidoc/markdown'; +import { analyzeSignature } from '../../../scripts/apidoc/signature'; import { loadExampleMethods } from './utils'; /* Run with `pnpm tsx test/scripts/apidoc/signature.debug.ts` */ diff --git a/test/scripts/apidoc/signature.spec.ts b/test/scripts/apidoc/signature.spec.ts index 3a48c29b7dd..e80d78e6e6f 100644 --- a/test/scripts/apidoc/signature.spec.ts +++ b/test/scripts/apidoc/signature.spec.ts @@ -1,8 +1,6 @@ import { beforeAll, describe, expect, it } from 'vitest'; -import { - analyzeSignature, - initMarkdownRenderer, -} from '../../../scripts/apidoc/signature'; +import { initMarkdownRenderer } from '../../../scripts/apidoc/markdown'; +import { analyzeSignature } from '../../../scripts/apidoc/signature'; import { SignatureTest } from './signature.example'; import { loadExampleMethods } from './utils'; @@ -10,9 +8,7 @@ describe('signature', () => { describe('analyzeSignature()', () => { const methods = loadExampleMethods(); - beforeAll(async () => { - await initMarkdownRenderer(); - }); + beforeAll(initMarkdownRenderer); it('dummy dependency to rerun the test if the example changes', () => { expect(new SignatureTest()).toBeTruthy(); diff --git a/test/scripts/apidoc/utils.ts b/test/scripts/apidoc/utils.ts index 809240c8ad7..281fb2ef6e9 100644 --- a/test/scripts/apidoc/utils.ts +++ b/test/scripts/apidoc/utils.ts @@ -1,4 +1,8 @@ -import type { SignatureReflection, TypeDocOptions } from 'typedoc'; +import type { + DeclarationReflection, + SignatureReflection, + TypeDocOptions, +} from 'typedoc'; import { loadProject, selectApiMethodSignatures, @@ -12,12 +16,15 @@ import { mapByName } from '../../../scripts/apidoc/utils'; export function loadProjectModules( options?: Partial, includeTestModules = false -): Record> { +): Record< + string, + [DeclarationReflection, Record] +> { const [, project] = loadProject(options); const modules = selectApiModules(project, includeTestModules); - return mapByName(modules, selectApiMethodSignatures); + return mapByName(modules, (m) => [m, selectApiMethodSignatures(m)]); } /** @@ -30,5 +37,5 @@ export function loadExampleMethods(): Record { tsconfig: 'test/scripts/apidoc/tsconfig.json', }, true - )['SignatureTest']; + )['SignatureTest'][1]; } diff --git a/test/scripts/apidoc/verify-jsdoc-tags.spec.ts b/test/scripts/apidoc/verify-jsdoc-tags.spec.ts index 68692fabc7a..1e8fc55e2b0 100644 --- a/test/scripts/apidoc/verify-jsdoc-tags.spec.ts +++ b/test/scripts/apidoc/verify-jsdoc-tags.spec.ts @@ -1,18 +1,18 @@ -import { mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; import { resolve } from 'node:path'; import validator from 'validator'; import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; -import { - analyzeSignature, - initMarkdownRenderer, - MISSING_DESCRIPTION, -} from '../../../scripts/apidoc/signature'; +import { initMarkdownRenderer } from '../../../scripts/apidoc/markdown'; +import { analyzeSignature } from '../../../scripts/apidoc/signature'; import { extractDeprecated, + extractDescription, + extractModuleFieldName, extractRawExamples, extractSeeAlsos, extractSince, extractTagContent, + MISSING_DESCRIPTION, } from '../../../scripts/apidoc/typedoc'; import { loadProjectModules } from './utils'; @@ -26,7 +26,9 @@ const tempDir = resolve(__dirname, 'temp'); afterAll(() => { // Remove temp folder - rmSync(tempDir, { recursive: true }); + if (existsSync(tempDir)) { + rmSync(tempDir, { recursive: true }); + } }); describe('verify JSDoc tags', () => { @@ -44,107 +46,173 @@ describe('verify JSDoc tags', () => { return resolve(dir, `${methodName}.ts`); } - describe.each(Object.entries(modules))('%s', (moduleName, methodsByName) => { - describe.each(Object.entries(methodsByName))( - '%s', - (methodName, signature) => { - beforeAll(() => { - // Write temp files to disk - - // Extract examples and make them runnable - const examples = extractRawExamples(signature).join('').trim(); - - // Save examples to a file to run them later in the specific tests - const dir = resolveDirToModule(moduleName); - mkdirSync(dir, { recursive: true }); - - const path = resolvePathToMethodFile(moduleName, methodName); - const imports = [...new Set(examples.match(/faker[^\.]*(?=\.)/g))]; - writeFileSync( - path, - `import { ${imports.join( - ', ' - )} } from '../../../../../src';\n\n${examples}` - ); + const allowedReferences = new Set( + Object.values(modules).reduce((acc, [module, methods]) => { + const moduleFieldName = extractModuleFieldName(module); + return [ + ...acc, + ...Object.keys(methods).map( + (methodName) => `faker.${moduleFieldName}.${methodName}` + ), + ]; + }, [] as string[]) + ); + const allowedLinks = new Set( + Object.values(modules).reduce((acc, [module, methods]) => { + const moduleFieldName = extractModuleFieldName(module); + return [ + ...acc, + `/api/${moduleFieldName}.html`, + ...Object.keys(methods).map( + (methodName) => + `/api/${moduleFieldName}.html#${methodName.toLowerCase()}` + ), + ]; + }, [] as string[]) + ); + + function assertDescription(description: string, isHtml: boolean): void { + const linkRegexp = isHtml + ? /(href)="([^"]+)"/g + : /\[([^\]]+)\]\(([^)]+)\)/g; + const links = [...description.matchAll(linkRegexp)].map((m) => m[2]); + + for (const link of links) { + if (!isHtml) { + expect(link).toMatch(/^https?:\/\//); + expect(link).toSatisfy(validator.isURL); + } + + if ( + isHtml ? link.startsWith('/api/') : link.includes('fakerjs.dev/api/') + ) { + expect(allowedLinks, `${link} to point to a valid target`).toContain( + link.replace(/.*fakerjs.dev\//, '/') + ); + } + } + } + + describe.each(Object.entries(modules))( + '%s', + (moduleName, [module, methodsByName]) => { + describe('verify module', () => { + it('verify description', () => { + const description = extractDescription(module); + assertDescription(description, false); }); + }); + + describe.each(Object.entries(methodsByName))( + '%s', + (methodName, signature) => { + beforeAll(() => { + // Write temp files to disk + + // Extract examples and make them runnable + const examples = extractRawExamples(signature).join('').trim(); + + // Save examples to a file to run them later in the specific tests + const dir = resolveDirToModule(moduleName); + mkdirSync(dir, { recursive: true }); + + const path = resolvePathToMethodFile(moduleName, methodName); + const imports = [...new Set(examples.match(/faker[^\.]*(?=\.)/g))]; + writeFileSync( + path, + `import { ${imports.join( + ', ' + )} } from '../../../../../src';\n\n${examples}` + ); + }); - it('verify @example tag', async () => { - // Extract the examples - const examples = extractRawExamples(signature).join('').trim(); + it('verify description', () => { + const description = extractDescription(signature); + assertDescription(description, false); + }); - expect( - examples, - `${moduleName}.${methodName} to have examples` - ).not.toBe(''); + it('verify @example tag', async () => { + // Extract the examples + const examples = extractRawExamples(signature).join('').trim(); - // Grab path to example file - const path = resolvePathToMethodFile(moduleName, methodName); + expect( + examples, + `${moduleName}.${methodName} to have examples` + ).not.toBe(''); - // Executing the examples should not throw - await expect(import(`${path}?scope=example`)).resolves.toBeDefined(); - }); + // Grab path to example file + const path = resolvePathToMethodFile(moduleName, methodName); - // This only checks whether the whole method is deprecated or not - // It does not check whether the method is deprecated for a specific set of arguments - it('verify @deprecated tag', async () => { - // Grab path to example file - const path = resolvePathToMethodFile(moduleName, methodName); + // Executing the examples should not throw + await expect( + import(`${path}?scope=example`) + ).resolves.toBeDefined(); + }); - const consoleWarnSpy = vi.spyOn(console, 'warn'); + // This only checks whether the whole method is deprecated or not + // It does not check whether the method is deprecated for a specific set of arguments + it('verify @deprecated tag', async () => { + // Grab path to example file + const path = resolvePathToMethodFile(moduleName, methodName); - // Run the examples - await import(`${path}?scope=deprecated`); + const consoleWarnSpy = vi.spyOn(console, 'warn'); - // Verify that deprecated methods log a warning - const deprecatedFlag = extractDeprecated(signature) !== undefined; - if (deprecatedFlag) { - expect(consoleWarnSpy).toHaveBeenCalled(); - expect( - extractTagContent('@deprecated', signature).join(''), - '@deprecated tag without message' - ).not.toBe(''); - } else { - expect(consoleWarnSpy).not.toHaveBeenCalled(); - } - }); + // Run the examples + await import(`${path}?scope=deprecated`); - it('verify @param tags', () => { - analyzeSignature(signature, '', methodName).parameters.forEach( - (param) => { - const { name, description } = param; - const plainDescription = description - .replace(/<[^>]+>/g, '') - .trim(); + // Verify that deprecated methods log a warning + const deprecatedFlag = extractDeprecated(signature) !== undefined; + if (deprecatedFlag) { + expect(consoleWarnSpy).toHaveBeenCalled(); expect( - plainDescription, - `Expect param ${name} to have a description` - ).not.toBe(MISSING_DESCRIPTION); + extractTagContent('@deprecated', signature).join(''), + '@deprecated tag without message' + ).not.toBe(''); + } else { + expect(consoleWarnSpy).not.toHaveBeenCalled(); } - ); - }); + }); - it('verify @see tags', () => { - extractSeeAlsos(signature).forEach((link) => { - if (link.startsWith('faker.')) { - // Expected @see faker.xxx.yyy() - expect(link, 'Expect method reference to contain ()').toContain( - '(' - ); - expect(link, 'Expect method reference to contain ()').toContain( - ')' - ); - } + it('verify @param tags', () => { + analyzeSignature(signature, '', methodName).parameters.forEach( + (param) => { + const { name, description } = param; + const plainDescription = description + .replace(/<[^>]+>/g, '') + .trim(); + expect( + plainDescription, + `Expect param ${name} to have a description` + ).not.toBe(MISSING_DESCRIPTION); + assertDescription(description, true); + } + ); }); - }); - it('verify @since tag', () => { - const since = extractSince(signature); - expect(since, '@since to be present').toBeTruthy(); - expect(since, '@since to be a valid semver').toSatisfy( - validator.isSemVer - ); - }); - } - ); - }); + it('verify @see tags', () => { + extractSeeAlsos(signature).forEach((link) => { + if (link.startsWith('faker.')) { + // Expected @see faker.xxx.yyy() + expect(link, 'Expect method reference to contain ()').toContain( + '(' + ); + expect(link, 'Expect method reference to contain ()').toContain( + ')' + ); + expect(allowedReferences).toContain(link.replace(/\(.*/, '')); + } + }); + }); + + it('verify @since tag', () => { + const since = extractSince(signature); + expect(since, '@since to be present').toBeTruthy(); + expect(since, '@since to be a valid semver').toSatisfy( + validator.isSemVer + ); + }); + } + ); + } + ); });