Skip to content

Commit 106fdc9

Browse files
authored
docs: add prettier for jsdoc examples (#4807)
1 parent bc90929 commit 106fdc9

51 files changed

Lines changed: 617 additions & 176 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@
3535
"release:prepare": "node --experimental-strip-types resources/release-prepare.ts",
3636
"release:metadata": "node --experimental-strip-types resources/release-metadata.ts",
3737
"benchmark": "node --experimental-strip-types resources/benchmark.ts",
38-
"test": "npm run lint && npm run check && npm run testonly:cover && npm run prettier:check && npm run check:spelling && npm run check:integrations",
38+
"test": "npm run lint && npm run check && npm run testonly:cover && npm run prettier:check && npm run prettier:examples:check && npm run check:spelling && npm run check:integrations",
3939
"lint": "eslint --cache --max-warnings 0 .",
4040
"check": "npm run check:ts && npm run check:deno",
4141
"check:ts": "tsc --pretty",
@@ -45,6 +45,8 @@
4545
"testonly:watch": "npm run node:test -- --watch \"src/**/__tests__/**/*-test.ts\"",
4646
"prettier": "prettier --cache --cache-strategy metadata --write --list-different .",
4747
"prettier:check": "prettier --cache --cache-strategy metadata --check .",
48+
"prettier:examples": "node --experimental-strip-types resources/prettier-examples.ts --write",
49+
"prettier:examples:check": "node --experimental-strip-types resources/prettier-examples.ts --check",
4850
"precommit": "lint-staged",
4951
"check:spelling": "cspell --cache --no-progress \"**/*\"",
5052
"check:integrations": "npm run node:test -- \"resources/integration-test.ts\"",

resources/prettier-examples.ts

Lines changed: 297 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,297 @@
1+
import fs from 'node:fs';
2+
import path from 'node:path';
3+
4+
import { localRepoPath, prettify } from './utils.ts';
5+
6+
type Mode = 'check' | 'write';
7+
8+
interface JsdocLine {
9+
content: string;
10+
prefix: string;
11+
}
12+
13+
interface LineUpdate {
14+
end: number;
15+
lines: Array<string>;
16+
start: number;
17+
}
18+
19+
const languageExtensions = new Map([
20+
['graphql', 'graphql'],
21+
['gql', 'graphql'],
22+
['javascript', 'js'],
23+
['js', 'js'],
24+
['jsx', 'jsx'],
25+
['ts', 'ts'],
26+
['tsx', 'tsx'],
27+
['typescript', 'ts'],
28+
]);
29+
30+
const mode = parseMode(process.argv.slice(2));
31+
const sourceDir = localRepoPath('src');
32+
const allIssues = [];
33+
let changedFiles = 0;
34+
35+
const results = await Promise.all(
36+
Array.from(sourceFiles(sourceDir), async (filePath) => {
37+
const source = fs.readFileSync(filePath, 'utf-8');
38+
const result = await prettifyFile(filePath, source);
39+
return { filePath, result, source };
40+
}),
41+
);
42+
43+
for (const { filePath, result, source } of results) {
44+
allIssues.push(...result.issues);
45+
46+
if (mode === 'write' && result.source !== source) {
47+
fs.writeFileSync(filePath, result.source);
48+
changedFiles++;
49+
}
50+
}
51+
52+
if (allIssues.length > 0) {
53+
for (const message of allIssues) {
54+
console.error(message);
55+
}
56+
process.exitCode = 1;
57+
} else if (mode === 'write') {
58+
console.log(`Prettified JSDoc examples in ${changedFiles} file(s).`);
59+
}
60+
61+
function parseMode(args: ReadonlyArray<string>): Mode {
62+
if (args.length === 1 && args[0] === '--check') {
63+
return 'check';
64+
}
65+
if (args.length === 1 && args[0] === '--write') {
66+
return 'write';
67+
}
68+
69+
console.error('Usage: prettier-examples.ts --check|--write');
70+
process.exit(1);
71+
}
72+
73+
async function prettifyFile(
74+
filePath: string,
75+
source: string,
76+
): Promise<{ issues: Array<string>; source: string }> {
77+
const lines = source.split('\n');
78+
const blockResults = [];
79+
const updates: Array<LineUpdate> = [];
80+
81+
for (let lineIndex = 0; lineIndex < lines.length; lineIndex++) {
82+
if (!lines[lineIndex].includes('/**')) {
83+
continue;
84+
}
85+
86+
const blockStart = lineIndex;
87+
while (lineIndex < lines.length && !lines[lineIndex].includes('*/')) {
88+
lineIndex++;
89+
}
90+
91+
if (lineIndex === lines.length) {
92+
break;
93+
}
94+
95+
const blockEnd = lineIndex;
96+
if (hasExampleTag(lines, blockStart, blockEnd)) {
97+
blockResults.push(prettifyBlock(filePath, lines, blockStart, blockEnd));
98+
}
99+
}
100+
101+
const fileIssues = [];
102+
for (const result of await Promise.all(blockResults)) {
103+
fileIssues.push(...result.issues);
104+
updates.push(...result.updates);
105+
}
106+
107+
for (const update of updates.reverse()) {
108+
lines.splice(update.start, update.end - update.start + 1, ...update.lines);
109+
}
110+
111+
return { issues: fileIssues, source: lines.join('\n') };
112+
}
113+
114+
async function prettifyBlock(
115+
filePath: string,
116+
lines: ReadonlyArray<string>,
117+
blockStart: number,
118+
blockEnd: number,
119+
): Promise<{ issues: Array<string>; updates: Array<LineUpdate> }> {
120+
const fenceResults: Array<
121+
Promise<{ issues: Array<string>; update?: LineUpdate }>
122+
> = [];
123+
let currentTag;
124+
125+
for (let lineIndex = blockStart + 1; lineIndex < blockEnd; lineIndex++) {
126+
const line = jsdocLine(lines[lineIndex]);
127+
if (line == null) {
128+
continue;
129+
}
130+
131+
const tag = /^@([^\s]+)/.exec(line.content.trimStart())?.[1];
132+
if (tag != null) {
133+
currentTag = tag;
134+
}
135+
136+
const fence = /^\s*```([^\s`]*)\s*(.*)$/.exec(line.content);
137+
if (fence == null) {
138+
continue;
139+
}
140+
141+
const fenceEnd = closingFenceLine(lines, lineIndex + 1, blockEnd);
142+
if (fenceEnd == null) {
143+
fenceResults.push(
144+
Promise.resolve({
145+
issues: [
146+
formatIssue(filePath, lineIndex, 'Unclosed JSDoc example fence.'),
147+
],
148+
}),
149+
);
150+
break;
151+
}
152+
153+
const language = fence[1].toLowerCase();
154+
const extension = languageExtensions.get(language);
155+
const metadata = fence[2];
156+
if (
157+
currentTag === 'example' &&
158+
extension != null &&
159+
!metadata.includes('prettier-ignore')
160+
) {
161+
fenceResults.push(
162+
prettifyFence(filePath, lines, lineIndex, fenceEnd, extension),
163+
);
164+
}
165+
166+
lineIndex = fenceEnd;
167+
}
168+
169+
const blockIssues = [];
170+
const updates: Array<LineUpdate> = [];
171+
for (const result of await Promise.all(fenceResults)) {
172+
blockIssues.push(...result.issues);
173+
if (result.update != null) {
174+
updates.push(result.update);
175+
}
176+
}
177+
178+
return { issues: blockIssues, updates };
179+
}
180+
181+
async function prettifyFence(
182+
filePath: string,
183+
lines: ReadonlyArray<string>,
184+
fenceStart: number,
185+
fenceEnd: number,
186+
extension: string,
187+
): Promise<{ issues: Array<string>; update?: LineUpdate }> {
188+
const codeLines = [];
189+
const contentPrefix = jsdocLine(lines[fenceStart])?.prefix ?? ' * ';
190+
191+
for (let lineIndex = fenceStart + 1; lineIndex < fenceEnd; lineIndex++) {
192+
const line = jsdocLine(lines[lineIndex]);
193+
codeLines.push(line?.content ?? lines[lineIndex]);
194+
}
195+
196+
const code = codeLines.join('\n');
197+
let formatted;
198+
try {
199+
formatted = (await prettify(`example.${extension}`, code)).trimEnd();
200+
} catch (error) {
201+
return {
202+
issues: [
203+
formatIssue(
204+
filePath,
205+
fenceStart,
206+
`Could not prettify ${extension} example: ${errorMessage(error)}`,
207+
),
208+
],
209+
};
210+
}
211+
212+
if (formatted === code) {
213+
return { issues: [] };
214+
}
215+
216+
if (mode === 'check') {
217+
return {
218+
issues: [
219+
formatIssue(filePath, fenceStart, 'JSDoc example is not formatted.'),
220+
],
221+
};
222+
}
223+
224+
return {
225+
issues: [],
226+
update: {
227+
end: fenceEnd - 1,
228+
lines: formatted
229+
.split('\n')
230+
.map((line) => commentLine(contentPrefix, line)),
231+
start: fenceStart + 1,
232+
},
233+
};
234+
}
235+
236+
function hasExampleTag(
237+
lines: ReadonlyArray<string>,
238+
blockStart: number,
239+
blockEnd: number,
240+
): boolean {
241+
for (let lineIndex = blockStart + 1; lineIndex < blockEnd; lineIndex++) {
242+
const line = jsdocLine(lines[lineIndex]);
243+
if (line?.content.trimStart().startsWith('@example')) {
244+
return true;
245+
}
246+
}
247+
return false;
248+
}
249+
250+
function closingFenceLine(
251+
lines: ReadonlyArray<string>,
252+
start: number,
253+
blockEnd: number,
254+
): number | undefined {
255+
for (let lineIndex = start; lineIndex < blockEnd; lineIndex++) {
256+
if (jsdocLine(lines[lineIndex])?.content.trim() === '```') {
257+
return lineIndex;
258+
}
259+
}
260+
}
261+
262+
function jsdocLine(line: string): JsdocLine | undefined {
263+
const match = /^(\s*\*\s?)(.*)$/.exec(line);
264+
if (match == null) {
265+
return;
266+
}
267+
return { content: match[2], prefix: match[1] };
268+
}
269+
270+
function commentLine(prefix: string, content: string): string {
271+
return content === '' ? prefix.trimEnd() : prefix + content;
272+
}
273+
274+
function formatIssue(
275+
filePath: string,
276+
lineIndex: number,
277+
message: string,
278+
): string {
279+
return `${path.relative(localRepoPath(), filePath)}:${lineIndex + 1}: ${message}`;
280+
}
281+
282+
function errorMessage(error: unknown): string {
283+
return error instanceof Error ? error.message.split('\n')[0] : String(error);
284+
}
285+
286+
function* sourceFiles(dirPath: string): Generator<string> {
287+
const entries = fs.readdirSync(dirPath, { withFileTypes: true });
288+
289+
for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
290+
const entryPath = path.join(dirPath, entry.name);
291+
if (entry.isDirectory()) {
292+
yield* sourceFiles(entryPath);
293+
} else if (entry.name.endsWith('.ts')) {
294+
yield entryPath;
295+
}
296+
}
297+
}

src/error/locatedError.ts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,7 @@ import { GraphQLError } from './GraphQLError.ts';
2222
*
2323
* const document = parse('{ viewer { name } }');
2424
* const fieldNode = document.definitions[0].selectionSet.selections[0];
25-
* const error = locatedError(new Error('Resolver failed'), fieldNode, [
26-
* 'viewer',
27-
* ]);
25+
* const error = locatedError(new Error('Resolver failed'), fieldNode, ['viewer']);
2826
*
2927
* error.message; // => 'Resolver failed'
3028
* error.locations; // => [{ line: 1, column: 3 }]

src/execution/execute.ts

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -276,7 +276,10 @@ function executeIgnoringIncrementalImpl(
276276
* import assert from 'node:assert';
277277
* import { parse } from 'graphql/language';
278278
* import { buildSchema } from 'graphql/utilities';
279-
* import { executeRootSelectionSet, validateExecutionArgs } from 'graphql/execution';
279+
* import {
280+
* executeRootSelectionSet,
281+
* validateExecutionArgs,
282+
* } from 'graphql/execution';
280283
*
281284
* const schema = buildSchema('type Query { greeting: String }');
282285
* const validatedArgs = validateExecutionArgs({
@@ -388,7 +391,10 @@ export function executeSync(args: ExecutionArgs): ExecutionResult {
388391
* import assert from 'node:assert';
389392
* import { parse } from 'graphql/language';
390393
* import { buildSchema } from 'graphql/utilities';
391-
* import { executeSubscriptionEvent, validateSubscriptionArgs } from 'graphql/execution';
394+
* import {
395+
* executeSubscriptionEvent,
396+
* validateSubscriptionArgs,
397+
* } from 'graphql/execution';
392398
*
393399
* const schema = buildSchema(`
394400
* type Query {
@@ -612,7 +618,10 @@ function subscribeImpl(
612618
* import assert from 'node:assert';
613619
* import { parse } from 'graphql/language';
614620
* import { buildSchema } from 'graphql/utilities';
615-
* import { createSourceEventStream, validateSubscriptionArgs } from 'graphql/execution';
621+
* import {
622+
* createSourceEventStream,
623+
* validateSubscriptionArgs,
624+
* } from 'graphql/execution';
616625
*
617626
* async function* greetings() {
618627
* yield { greeting: 'Hello' };
@@ -1007,7 +1016,10 @@ export const defaultFieldResolver: GraphQLFieldResolver<unknown, unknown> =
10071016
* import assert from 'node:assert';
10081017
* import { parse } from 'graphql/language';
10091018
* import { buildSchema } from 'graphql/utilities';
1010-
* import { mapSourceToResponseEvent, validateSubscriptionArgs } from 'graphql/execution';
1019+
* import {
1020+
* mapSourceToResponseEvent,
1021+
* validateSubscriptionArgs,
1022+
* } from 'graphql/execution';
10111023
*
10121024
* async function* events() {
10131025
* yield { greeting: 'Hello' };

0 commit comments

Comments
 (0)