Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

infra(docs): add script to regenerate jsdoc examples #3352

Draft
wants to merge 5 commits into
base: next
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ versions.json
!/docs/api/index.md
/docs/api/api-search-index.json
/docs/public/api-diff-index.json
/script/temp/

# Faker
TAGS
Expand Down
8 changes: 7 additions & 1 deletion docs/.vitepress/components/api-docs/format.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,20 @@
const nbsp = '\u00A0';

export function formatResult(result: unknown): string {
return result === undefined
? 'undefined'
: typeof result === 'bigint'
? `${result}n`
: JSON.stringify(result, undefined, 2)
.replaceAll('\\r', '')
.replaceAll('<', '&lt;')
.replaceAll(nbsp, ' ')
.replaceAll(
/(^ *|: )"([^'\n]*?)"(?=,?$|: )/gm,
(_, p1, p2) => `${p1}'${p2.replace(/\\"/g, '"')}'`
)
.replaceAll(/\n */g, ' ');
}

export function formatResultForHtml(result: unknown): string {
return formatResult(result).replaceAll('<', '&lt;');
}
4 changes: 2 additions & 2 deletions docs/.vitepress/components/api-docs/method.vue
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
import { computed, ref, useTemplateRef } from 'vue';
import { sourceBaseUrl } from '../../../api/source-base-url';
import { slugify } from '../../shared/utils/slugify';
import { formatResult } from './format';
import { formatResultForHtml } from './format';
import type { ApiDocsMethod } from './method';
import MethodParameters from './method-parameters.vue';
import RefreshButton from './refresh-button.vue';
Expand Down Expand Up @@ -102,7 +102,7 @@ async function onRefresh(): Promise<void> {
for (let i = 0; i < results.length; i++) {
const result = results[i];
const domLine = codeLines.value[i];
const prettyResult = formatResult(result);
const prettyResult = formatResultForHtml(result);
const resultLines = prettyResult.split('\\n');

if (resultLines.length === 1) {
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
"build": "run-s build:clean build:code",
"generate": "run-s generate:locales generate:api-docs",
"generate:api-docs": "tsx ./scripts/apidocs.ts",
"generate:examples": "tsx ./scripts/examples.ts && pnpm run format",
"generate:locales": "tsx ./scripts/generate-locales.ts",
"docs:build": "run-s generate:api-docs docs:build:run",
"docs:build:run": "vitepress build docs",
Expand Down
159 changes: 159 additions & 0 deletions scripts/apidocs/examples.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
import { mkdir, rm, writeFile } from 'node:fs/promises';
import { resolve } from 'node:path';
import type { ClassDeclaration, MethodDeclaration } from 'ts-morph';
import { formatResult } from '../../docs/.vitepress/components/api-docs/format';
import * as globalFaker from '../../src';
import { groupBy } from '../../src/internal/group-by';
import { prepareExampleCapturing } from './output/page';
import { findModuleClasses, findProjectClasses } from './processing/class';
import { getExamples, getJsDocs, getTagsFromJSDoc } from './processing/jsdocs';
import { isApiMethod } from './processing/method';
import type { SignatureLikeDeclaration } from './processing/signature';
import { getProject } from './project';
import { FILE_PATH_PROJECT } from './utils/paths';

const tempDir = resolve(FILE_PATH_PROJECT, 'script/temp');
let i = 0;

Object.assign(globalThis, globalFaker);

export async function refreshExamples(): Promise<void> {
console.log('Reading project');
const project = getProject();
const classes = [
...findProjectClasses(project),
...findModuleClasses(project),
];
const classesByFile = groupBy(classes, (clazz) =>
clazz.getSourceFile().getFilePath()
);

await mkdir(tempDir, { recursive: true });

console.log('Processing...');
for (const classes of Object.values(classesByFile)) {
for (const clazz of classes) {
console.log(`- ${clazz.getName()}`);
await processClass(clazz);
}

await classes[0].getSourceFile().save();
}

await rm(tempDir, { recursive: true });

console.log('Completed');
console.log('Remember to check the changes before committing');
}

async function processClass(clazz: ClassDeclaration) {
const methods = clazz.getMethods().filter(isApiMethod);
for (const method of methods) {
await processMethod(method);
}

const baseClass = clazz.getBaseClass();
if (baseClass != null) {
await processClass(baseClass);
}
}

async function processMethod(method: MethodDeclaration) {
console.log(` - ${method.getName()}`);
const overloads = method.getOverloads();
const signatures: SignatureLikeDeclaration[] =
overloads.length > 0 ? overloads : [method];
for (const [index, signature] of Object.entries(signatures)) {
await processSignature(index, signature);
}
}

async function processSignature(
index: string,
signature: SignatureLikeDeclaration
) {
const jsdocs = getJsDocs(signature);
const exampleTags = getTagsFromJSDoc(jsdocs, 'example');
const exampleTag = exampleTags[0];
if (exampleTags.length === 0) {
console.log('No examples found');
return;
} else if (exampleTags.length > 1) {
console.error('Multiple examples found');
return;
}

const exampleCode = getExamples(jsdocs).join('\n');
const exampleLines = exampleCode.split('\n');

const captureCode = prepareExampleCapturing({
main: exampleCode,
async: false,
init: [
'faker.seed(0);',
'faker.setDefaultRefDate(new Date(2025, 0, 1));',
...(exampleCode.match(/^(faker[A-Z]\w+)\./gm) ?? []).map(
(match) => `${match}seed(0);`
),
],
});

const tempFilePath = resolve(tempDir, `example-${i++}.ts`);
await writeFile(tempFilePath, `export const fn = ${captureCode}`);

const { fn } = (await import(`file://${tempFilePath}`)) as {
fn: () => unknown[];
};
const result = fn();
let lineIndex = 0;
let resultIndex = 0;
while (lineIndex < exampleLines.length && resultIndex < result.length) {
// Skip empty and preparatory lines (no '^faker.' invocation)
if (!/^\w*faker\w*\./i.test(exampleLines[lineIndex])) {
lineIndex++;
continue;
}

// Skip to end of the invocation (if multiline)
while (
lineIndex < exampleLines.length &&
!/^([^ ].*)?\)(\.\w+)?;? ?(\/\/|$)/.test(exampleLines[lineIndex])
) {
lineIndex++;
}

if (lineIndex >= exampleLines.length) {
break;
}

// Purge old results
if (exampleLines[lineIndex].includes('//')) {
// Inline comments
exampleLines[lineIndex] = exampleLines[lineIndex].replace(/ ?\/\/.*/, '');
} else {
// Multiline comments
while (exampleLines[lineIndex + 1]?.trimStart().startsWith('//')) {
exampleLines.splice(lineIndex + 1, 1);
}
}

// Insert new results
const prettyResult = formatResult(result[resultIndex++]);
const resultLines = prettyResult.split('\\n');
if (resultLines.length === 1) {
exampleLines[lineIndex] = `${exampleLines[lineIndex]} // ${prettyResult}`;
} else {
exampleLines.splice(
lineIndex + 1,
0,
...resultLines.map((line) => `// ${line}`)
);
}

lineIndex += resultLines.length;
}

// Update jsdoc
exampleLines.unshift('@example');
exampleTag.replaceWithText(exampleLines.join('\n * '));
}
59 changes: 39 additions & 20 deletions scripts/apidocs/output/page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -208,30 +208,20 @@ export async function toRefreshFunction(
const { examples } = signatureData;

const exampleCode = examples.join('\n');
if (!/^\w*faker\w*\./im.test(exampleCode)) {
if (!hasFakerCalls(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<unknown[]> => {
await enableFaker();
faker.seed();
faker.setDefaultRefDate();
const result: unknown[] = [];

${exampleLines}

return result;
}`;
const fullMethod = prepareExampleCapturing({
main: exampleCode,
async: true,
init: [
'await enableFaker();',
'faker.seed();',
'faker.setDefaultRefDate();',
],
});
try {
const formattedMethod = await formatTypescript(fullMethod);
return formattedMethod.replace(/;\s+$/, ''); // Remove trailing semicolon
Expand All @@ -245,3 +235,32 @@ return result;
return 'undefined';
}
}

function hasFakerCalls(exampleCode: string) {
return /^\w*faker\w*\./im.test(exampleCode);
}

export function prepareExampleCapturing(options: {
main: string;
async: boolean;
init?: string[];
}): string {
const { main, async, init = [] } = options;
const captureCode = main
.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`
);

return `${async ? 'async (): Promise<unknown[]>' : '(): unknown[]'} => {
${init.join('\n')}
const result: unknown[] = [];

${captureCode}

return result;
}`;
}
28 changes: 16 additions & 12 deletions scripts/apidocs/processing/class.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,9 +66,11 @@ function getAllClasses(
}

export function processProjectClasses(project: Project): RawApiDocsPage[] {
return processClasses(
valuesForKeys(getAllClasses(project), ['Faker', 'SimpleFaker'])
);
return processClasses(findProjectClasses(project));
}

export function findProjectClasses(project: Project): ClassDeclaration[] {
return valuesForKeys(getAllClasses(project), ['Faker', 'SimpleFaker']);
}

function processClasses(classes: ClassDeclaration[]): RawApiDocsPage[] {
Expand All @@ -95,15 +97,17 @@ export function processClass(clazz: ClassDeclaration): RawApiDocsPage {
// Modules

export function processModuleClasses(project: Project): RawApiDocsPage[] {
return processModules(
Object.values(
getAllClasses(
project,
(module: string): boolean =>
module.endsWith('Module') && !module.startsWith('Simple')
)
).sort((a, b) => a.getNameOrThrow().localeCompare(b.getNameOrThrow()))
);
return processModules(findModuleClasses(project));
}

export function findModuleClasses(project: Project): ClassDeclaration[] {
return Object.values(
getAllClasses(
project,
(module: string): boolean =>
module.endsWith('Module') && !module.startsWith('Simple')
)
).sort((a, b) => a.getNameOrThrow().localeCompare(b.getNameOrThrow()));
}

function processModules(modules: ClassDeclaration[]): RawApiDocsPage[] {
Expand Down
25 changes: 13 additions & 12 deletions scripts/apidocs/processing/jsdocs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,40 +58,41 @@ export function getDefault(jsdocs: JSDoc): string | undefined {
}

export function getThrows(jsdocs: JSDoc): string[] {
return getTagsFromJSDoc(jsdocs, 'throws');
return getTagsContentsFromJSDoc(jsdocs, 'throws');
}

export function getExamples(jsdocs: JSDoc): string[] {
return getTagsFromJSDoc(jsdocs, 'example');
return getTagsContentsFromJSDoc(jsdocs, 'example');
}

export function getSeeAlsos(jsdocs: JSDoc): string[] {
return getTagsFromJSDoc(jsdocs, 'see', true);
return getTagsContentsFromJSDoc(jsdocs, 'see', true);
}

function getOptionalTagFromJSDoc(
jsdocs: JSDoc,
type: string
): string | undefined {
return optionalOne(getTagsFromJSDoc(jsdocs, type), `@${type}`);
return optionalOne(getTagsContentsFromJSDoc(jsdocs, type), `@${type}`);
}

function getExactlyOneTagFromJSDoc(jsdocs: JSDoc, type: string): string {
return exactlyOne(getTagsFromJSDoc(jsdocs, type), `@${type}`);
return exactlyOne(getTagsContentsFromJSDoc(jsdocs, type), `@${type}`);
}

function getTagsFromJSDoc(
function getTagsContentsFromJSDoc(
jsdocs: JSDoc,
type: string,
full: boolean = false
): string[] {
return allRequired(
jsdocs
.getTags()
.filter((tag) => tag.getTagName() === type)
.map((tag) =>
full ? tag.getStructure().text?.toString() : tag.getCommentText()
),
getTagsFromJSDoc(jsdocs, type).map((tag) =>
full ? tag.getStructure().text?.toString() : tag.getCommentText()
),
`@${type}`
);
}

export function getTagsFromJSDoc(jsdocs: JSDoc, type: string): JSDocTag[] {
return jsdocs.getTags().filter((tag) => tag.getTagName() === type);
}
Loading
Loading