import fs from 'node:fs';
import path from 'node:path';
import { localRepoPath, prettify } from './utils.ts';
type Mode = 'check' | 'write';
interface Options {
filePaths: ReadonlyArray<string>;
mode: Mode;
}
interface JsdocLine {
content: string;
prefix: string;
}
interface LineUpdate {
end: number;
lines: Array<string>;
start: number;
}
const languageExtensions = new Map([
['graphql', 'graphql'],
['gql', 'graphql'],
['javascript', 'js'],
['js', 'js'],
['jsx', 'jsx'],
['ts', 'ts'],
['tsx', 'tsx'],
['typescript', 'ts'],
]);
const sourceDir = localRepoPath('src');
const options = parseOptions(process.argv.slice(2));
const allIssues = [];
let changedFiles = 0;
const results = await Promise.all(
options.filePaths.map(async (filePath) => {
const source = fs.readFileSync(filePath, 'utf-8');
const result = await prettifyFile(filePath, source);
return { filePath, result, source };
}),
);
for (const { filePath, result, source } of results) {
allIssues.push(...result.issues);
if (options.mode === 'write' && result.source !== source) {
fs.writeFileSync(filePath, result.source);
changedFiles++;
}
}
if (allIssues.length > 0) {
for (const message of allIssues) {
console.error(message);
}
process.exitCode = 1;
} else if (options.mode === 'write') {
console.log(`Prettified JSDoc examples in ${changedFiles} file(s).`);
}
function parseOptions(args: ReadonlyArray<string>): Options {
const [modeArg, ...fileArgs] = args;
let mode: Mode;
if (modeArg === '--check') {
mode = 'check';
} else if (modeArg === '--write') {
mode = 'write';
} else {
usage();
}
return { filePaths: sourceFilePaths(fileArgs), mode };
}
function sourceFilePaths(
fileArgs: ReadonlyArray<string>,
): ReadonlyArray<string> {
if (fileArgs.length === 0) {
return Array.from(sourceFiles(sourceDir));
}
const filePaths = new Set<string>();
for (const fileArg of fileArgs) {
const filePath = path.resolve(fileArg);
const relativePath = path.relative(sourceDir, filePath);
if (
!relativePath.startsWith('..') &&
!path.isAbsolute(relativePath) &&
filePath.endsWith('.ts') &&
fs.existsSync(filePath) &&
fs.statSync(filePath).isFile()
) {
filePaths.add(filePath);
}
}
return Array.from(filePaths).sort((a, b) => a.localeCompare(b));
}
function usage(): never {
console.error('Usage: prettier-examples.ts --check|--write');
process.exit(1);
}
async function prettifyFile(
filePath: string,
source: string,
): Promise<{ issues: Array<string>; source: string }> {
const lines = source.split('\n');
const blockResults = [];
const updates: Array<LineUpdate> = [];
for (let lineIndex = 0; lineIndex < lines.length; lineIndex++) {
if (!lines[lineIndex].includes('/**')) {
continue;
}
const blockStart = lineIndex;
while (lineIndex < lines.length && !lines[lineIndex].includes('*/')) {
lineIndex++;
}
if (lineIndex === lines.length) {
break;
}
const blockEnd = lineIndex;
if (hasExampleTag(lines, blockStart, blockEnd)) {
blockResults.push(prettifyBlock(filePath, lines, blockStart, blockEnd));
}
}
const fileIssues = [];
for (const result of await Promise.all(blockResults)) {
fileIssues.push(...result.issues);
updates.push(...result.updates);
}
for (const update of updates.reverse()) {
lines.splice(update.start, update.end - update.start + 1, ...update.lines);
}
return { issues: fileIssues, source: lines.join('\n') };
}
async function prettifyBlock(
filePath: string,
lines: ReadonlyArray<string>,
blockStart: number,
blockEnd: number,
): Promise<{ issues: Array<string>; updates: Array<LineUpdate> }> {
const fenceResults: Array<
Promise<{ issues: Array<string>; update?: LineUpdate }>
> = [];
let currentTag;
for (let lineIndex = blockStart + 1; lineIndex < blockEnd; lineIndex++) {
const line = jsdocLine(lines[lineIndex]);
if (line == null) {
continue;
}
const tag = /^@([^\s]+)/.exec(line.content.trimStart())?.[1];
if (tag != null) {
currentTag = tag;
}
const fence = /^\s*```([^\s`]*)\s*(.*)$/.exec(line.content);
if (fence == null) {
continue;
}
const fenceEnd = closingFenceLine(lines, lineIndex + 1, blockEnd);
if (fenceEnd == null) {
fenceResults.push(
Promise.resolve({
issues: [
formatIssue(filePath, lineIndex, 'Unclosed JSDoc example fence.'),
],
}),
);
break;
}
const language = fence[1].toLowerCase();
const extension = languageExtensions.get(language);
const metadata = fence[2];
if (
currentTag === 'example' &&
extension != null &&
!metadata.includes('prettier-ignore')
) {
fenceResults.push(
prettifyFence(filePath, lines, lineIndex, fenceEnd, extension),
);
}
lineIndex = fenceEnd;
}
const blockIssues = [];
const updates: Array<LineUpdate> = [];
for (const result of await Promise.all(fenceResults)) {
blockIssues.push(...result.issues);
if (result.update != null) {
updates.push(result.update);
}
}
return { issues: blockIssues, updates };
}
async function prettifyFence(
filePath: string,
lines: ReadonlyArray<string>,
fenceStart: number,
fenceEnd: number,
extension: string,
): Promise<{ issues: Array<string>; update?: LineUpdate }> {
const codeLines = [];
const contentPrefix = jsdocLine(lines[fenceStart])?.prefix ?? ' * ';
for (let lineIndex = fenceStart + 1; lineIndex < fenceEnd; lineIndex++) {
const line = jsdocLine(lines[lineIndex]);
codeLines.push(line?.content ?? lines[lineIndex]);
}
const code = codeLines.join('\n');
let formatted;
try {
formatted = (await prettify(`example.${extension}`, code)).trimEnd();
} catch (error) {
return {
issues: [
formatIssue(
filePath,
fenceStart,
`Could not prettify ${extension} example: ${errorMessage(error)}`,
),
],
};
}
if (formatted === code) {
return { issues: [] };
}
if (options.mode === 'check') {
return {
issues: [
formatIssue(filePath, fenceStart, 'JSDoc example is not formatted.'),
],
};
}
return {
issues: [],
update: {
end: fenceEnd - 1,
lines: formatted
.split('\n')
.map((line) => commentLine(contentPrefix, line)),
start: fenceStart + 1,
},
};
}
function hasExampleTag(
lines: ReadonlyArray<string>,
blockStart: number,
blockEnd: number,
): boolean {
for (let lineIndex = blockStart + 1; lineIndex < blockEnd; lineIndex++) {
const line = jsdocLine(lines[lineIndex]);
if (line?.content.trimStart().startsWith('@example')) {
return true;
}
}
return false;
}
function closingFenceLine(
lines: ReadonlyArray<string>,
start: number,
blockEnd: number,
): number | undefined {
for (let lineIndex = start; lineIndex < blockEnd; lineIndex++) {
if (jsdocLine(lines[lineIndex])?.content.trim() === '```') {
return lineIndex;
}
}
}
function jsdocLine(line: string): JsdocLine | undefined {
const match = /^(\s*\*\s?)(.*)$/.exec(line);
if (match == null) {
return;
}
return { content: match[2], prefix: match[1] };
}
function commentLine(prefix: string, content: string): string {
return content === '' ? prefix.trimEnd() : prefix + content;
}
function formatIssue(
filePath: string,
lineIndex: number,
message: string,
): string {
return `${path.relative(localRepoPath(), filePath)}:${lineIndex + 1}: ${message}`;
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message.split('\n')[0] : String(error);
}
function* sourceFiles(dirPath: string): Generator<string> {
const entries = fs.readdirSync(dirPath, { withFileTypes: true });
for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
const entryPath = path.join(dirPath, entry.name);
if (entry.isDirectory()) {
yield* sourceFiles(entryPath);
} else if (entry.name.endsWith('.ts')) {
yield entryPath;
}
}
}