Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
c815814
chore: install lz-string
som-sm Oct 14, 2025
305b733
feat: add lint rule to add playground URL
som-sm Oct 14, 2025
d2c6591
test: add valid cases
som-sm Oct 20, 2025
b2b9ac9
fix: preserve leading tabs
som-sm Oct 21, 2025
124e024
refactor: add test helpers
som-sm Oct 21, 2025
2388267
refactor: improve test helpers
som-sm Oct 21, 2025
8226603
test: add invalid cases
som-sm Oct 21, 2025
14cc6cc
test: fix indentation in helper function
som-sm Oct 21, 2025
18b637a
fix: preserve leading tabs
som-sm Oct 21, 2025
9841ad5
test: add more invalid cases
som-sm Oct 21, 2025
261fb2b
test: improve cases
som-sm Oct 22, 2025
c74fe22
test: combine type and options
som-sm Oct 22, 2025
a13fb8c
test: add more cases
som-sm Oct 23, 2025
7198669
test: improve some cases
som-sm Oct 23, 2025
07c3933
improve: lint rule link matching
som-sm Oct 23, 2025
668f7f6
test: add some more cases
som-sm Oct 25, 2025
299fc05
chore: merge 'main' branch
som-sm Nov 12, 2025
f708de1
refactor: use updated utils
som-sm Nov 12, 2025
0e0bebf
refactor: significantly improve `'missingPlaygroundLink'` tests
som-sm Nov 12, 2025
0629abf
style: improve comment text
som-sm Nov 12, 2025
7736208
refactor: significantly improve `'incorrectPlaygroundLink'` tests
som-sm Nov 12, 2025
473f295
chore: update error message
som-sm Nov 12, 2025
56fab56
refactor: improve error reporting location
som-sm Nov 12, 2025
f467334
test: add one more indentation case
som-sm Nov 12, 2025
ab66b08
fix: add playground links to JSDoc codeblocks
som-sm Nov 12, 2025
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
118 changes: 118 additions & 0 deletions lint-rules/require-playground-link.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import lzString from 'lz-string';
import outdent from 'outdent';

const CODEBLOCK_REGEX = /(?<openingFence>```(?:ts|typescript)?)(?<code>[\s\S]*?)```/g;
const PLAYGROUND_BASE_URL = 'https://www.typescriptlang.org/play/?exactOptionalPropertyTypes=true#code/';

const generatePlaygroundLink = code => {
const zippedCode = lzString.compressToEncodedURIComponent(code);
return `${PLAYGROUND_BASE_URL}${zippedCode}`;
};

export const generateLinkText = code => `[Playground Link](${generatePlaygroundLink(code)})`;

const getCodeIndent = code => {
const line = code.split('\n').filter(Boolean); // eslint-disable-line unicorn/prefer-array-find
const firstLine = line[0];
const leadingSpaces = firstLine.slice(0, firstLine.length - firstLine.trimStart().length);
return leadingSpaces;
};

export const requirePlaygroundLinkRule = /** @type {const} */ ({
meta: {
type: 'suggestion',
docs: {
description: 'Ensures JSDoc example codeblocks for publicly available types have playground links.',
},
fixable: 'code',
messages: {
missingPlaygroundLink: 'Example codeblocks must have an associated playground link. Add the following after the example codeblock:\n\n[Playground Link]({{playgroundLink}})\n\n',
incorrectPlaygroundLink: 'Incorrect playground link. Update the link to the following:\n{{playgroundLink}}',
},
schema: [],
},
defaultOptions: [],
create(context) {
return {
TSTypeAliasDeclaration(node) {
const {parent} = node;

// Skip if type is not exported or starts with an underscore (private/internal)
if (parent.type !== 'ExportNamedDeclaration' || node.id.name.startsWith('_')) {
return;
}

const previousNodes = [context.sourceCode.getTokenBefore(parent, {includeComments: true})];

// Handle JSDoc blocks for options
if (node.id.name.endsWith('Options') && node.typeAnnotation.type === 'TSTypeLiteral') {
for (const member of node.typeAnnotation.members) {
previousNodes.push(context.sourceCode.getTokenBefore(member, {includeComments: true}));
}
}

for (const previousNode of previousNodes) {
// Skip if previous node is not a JSDoc comment
if (!previousNode || previousNode.type !== 'Block' || !previousNode.value.startsWith('*')) {
continue;
}

const comment = previousNode.value;

for (const match of comment.matchAll(CODEBLOCK_REGEX)) {
const {code, openingFence} = match.groups ?? {};

// Skip empty code blocks
if (!code || !openingFence) {
continue;
}

const nextLineIndex = match.index + match[0].length + 1; // +1 to move past the newline
const nextLine = comment.slice(nextLineIndex).split('\n')[0];

const playgroundLink = generatePlaygroundLink(outdent.string(code));
const indentation = getCodeIndent(code);
const insertText = `${indentation}${generateLinkText(outdent.string(code))}`;

if (nextLine === insertText) {
continue;
}

const codeblockStart = previousNode.range[0] + match.index + 2;

const fixerRangeStart = previousNode.range[0] + nextLineIndex + 2;
const fixerRangeEnd = fixerRangeStart + nextLine.length;

const doesPlaygroundLinkExist = nextLine.includes('[Playground Link]');

// For missing link, highlight the opening fence, and for incorrect link, highlight the link line
const errorStart = doesPlaygroundLinkExist ? fixerRangeStart : codeblockStart;
const errorEnd = doesPlaygroundLinkExist ? fixerRangeEnd : codeblockStart + openingFence.length;

context.report({
loc: {
start: context.sourceCode.getLocFromIndex(errorStart),
end: context.sourceCode.getLocFromIndex(errorEnd),
},
messageId: doesPlaygroundLinkExist
? 'incorrectPlaygroundLink'
: 'missingPlaygroundLink',
data: {
playgroundLink,
},
fix(fixer) {
return fixer.replaceTextRange(
[
fixerRangeStart, // Start is inclusive.
doesPlaygroundLinkExist ? fixerRangeEnd : fixerRangeStart, // End is exclusive. If start and end are the same, it inserts at that position.
],
insertText + (doesPlaygroundLinkExist ? '' : '\n'),
);
},
});
}
}
},
};
},
});
217 changes: 217 additions & 0 deletions lint-rules/require-playground-link.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@
import outdent from 'outdent';
import {createRuleTester, dedenter, exportType, exportTypeAndOption, fence, jsdoc} from './test-utils.js';
import {generateLinkText, requirePlaygroundLinkRule} from './require-playground-link.js';

const ruleTester = createRuleTester();

const fenceWithLink = (code, lang = '', linkCode = code) =>
outdent`
${fence(code, lang)}
${generateLinkText(linkCode)}
`;

const missingPlaygroundLinkError = getPrefixes => {
// Track the number of times `fence` is called to determine the no. of errors
let callCount = 0;
const trackedFence = (...args) => {
callCount++;
return fence(...args);
};

return {
// Codeblocks in input code don't have playground links
code: exportTypeAndOption(...getPrefixes(trackedFence)),
// Create two error objects for each `fence` call (one for type and one for option)
errors: Array.from({length: callCount * 2}, () => ({messageId: 'missingPlaygroundLink'})),
// Codeblocks in output code have playground links
output: exportTypeAndOption(...getPrefixes(fenceWithLink)),
};
};

const incorrectPlaygroundLinkError = getPrefixes => {
// Track the number of times `fence` is called to determine the no. of errors
let callCount = 0;
const trackedFenceWithIncorrectLink = (code, lang) => {
callCount++;
return fenceWithLink(code, lang, code + '\n// incorrect');
};

return {
// Codeblocks in input code have incorrect playground links
code: exportTypeAndOption(...getPrefixes(trackedFenceWithIncorrectLink)),
// Create two error objects for each `fence` call (one for type and one for option)
errors: Array.from({length: callCount * 2}, () => ({messageId: 'incorrectPlaygroundLink'})),
// Codeblocks in output code have correct playground links
output: exportTypeAndOption(...getPrefixes(fenceWithLink)),
};
};

// Code samples
const code1 = outdent`
type A = string;
//=> string

type B = number;
//=> number
`;

const code2 = outdent`
type T1 = {
foo: string;
bar: number;
};

type T2 = T1[keyof T1];
//=> string | number
`;

ruleTester.run('require-playground-link', requirePlaygroundLinkRule, {
valid: [
// Not exported
dedenter`
${jsdoc(fence(code1))}
type NotExported = string;
`,
dedenter`
type NotExportedOptions = {
${jsdoc(fence(code1))}
p1: string;
}
`,

// Internal (leading underscore)
dedenter`
${jsdoc(fence(code1))}
export type _Internal = string;
`,
dedenter`
export type _InternalOptions = {
${jsdoc(fence(code1))}
p1: string;
}
`,

// Without `Options` suffix
dedenter`
export type NoSuffix = {
${jsdoc(fence(code1))}
p1: string;
}
`,

// No JSDoc
exportTypeAndOption(''),
exportType('type Some = number;'),
exportTypeAndOption('// Not block comment'),
exportTypeAndOption('/* Block comment, but not JSDoc */'),

// No codeblock in JSDoc
exportType(jsdoc('No codeblock here')),

// Valid link
exportTypeAndOption(jsdoc(fenceWithLink(code1))),

// With text before and after
exportTypeAndOption(jsdoc('Some description.', fenceWithLink(code1), '@category Test')),

// With line breaks before and after
exportTypeAndOption(
jsdoc('Some description.\n', 'Note: Some note.\n', fenceWithLink(code1, 'ts'), '\n@category Test'),
),

// With `@example` tag
exportTypeAndOption(jsdoc('@example', fenceWithLink(code1))),

// With language specifiers
exportTypeAndOption(jsdoc(fenceWithLink(code1, 'ts'))),
exportTypeAndOption(jsdoc(fenceWithLink(code1, 'typescript'))),

// Multiple code blocks
exportTypeAndOption(
jsdoc('@example', fenceWithLink(code1, 'ts'), '\nSome text in between.\n', '@example', fenceWithLink(code2)),
),

// Multiple exports and multiple properties
exportTypeAndOption(jsdoc(fenceWithLink(code1)), jsdoc(fenceWithLink(code2))),
],
invalid: [
// Missing link
missingPlaygroundLinkError(fence => [jsdoc(fence(code1))]),

// With text before and after
missingPlaygroundLinkError(fence => [jsdoc('Some description.', fence(code1), '@category Test')]),

// With line breaks before and after
missingPlaygroundLinkError(fence => [
jsdoc('Some description.\n', 'Note: Some note.\n', fence(code1, 'ts'), '\n@category Test'),
]),

// With `@example` tag
missingPlaygroundLinkError(fence => [jsdoc('@example', fence(code1))]),

// With language specifiers
missingPlaygroundLinkError(fence => [jsdoc(fence(code1, 'ts'))]),
missingPlaygroundLinkError(fence => [jsdoc(fence(code1, 'typescript'))]),

// Multiple code blocks
missingPlaygroundLinkError(fence => [
jsdoc('@example', fence(code1, 'ts'), '\nSome text in between.\n', '@example', fence(code2)),
]),

// Multiple exports and multiple properties
missingPlaygroundLinkError(fence => [jsdoc(fence(code1)), jsdoc(fence(code2))]),

// Incorrect existing link
incorrectPlaygroundLinkError(fence => [jsdoc(fence(code1))]),

// Fix indentation
{
code: exportTypeAndOption(jsdoc(fence(code1), '\t' + generateLinkText(code1))),
output: exportTypeAndOption(jsdoc(fenceWithLink(code1))),
errors: [{messageId: 'incorrectPlaygroundLink'}, {messageId: 'incorrectPlaygroundLink'}],
},
{
code: exportTypeAndOption(jsdoc(dedenter`
1. First point
${fence(code1)}
${generateLinkText(code1)}
`)),
output: exportTypeAndOption(jsdoc(dedenter`
1. First point
${fence(code1)}
${generateLinkText(code1)}
`)),
errors: [{messageId: 'incorrectPlaygroundLink'}, {messageId: 'incorrectPlaygroundLink'}],
},

// Empty link
{
code: exportTypeAndOption(jsdoc(fence(code1), '[Playground Link]()')),
output: exportTypeAndOption(jsdoc(fenceWithLink(code1))),
errors: [{messageId: 'incorrectPlaygroundLink'}, {messageId: 'incorrectPlaygroundLink'}],
},

// With text before and after
incorrectPlaygroundLinkError(fence => [jsdoc('Some description.', fence(code1), '@category Test')]),

// With line breaks before and after
incorrectPlaygroundLinkError(fence => [
jsdoc('Some description.\n', 'Note: Some note.\n', fence(code1, 'ts'), '\n@category Test'),
]),

// With `@example` tag
incorrectPlaygroundLinkError(fence => [jsdoc('@example', fence(code1))]),

// With language specifiers
incorrectPlaygroundLinkError(fence => [jsdoc(fence(code1, 'ts'))]),
incorrectPlaygroundLinkError(fence => [jsdoc(fence(code1, 'typescript'))]),

// Multiple code blocks
incorrectPlaygroundLinkError(fence => [
jsdoc('@example', fence(code1, 'ts'), '\nSome text in between.\n', '@example', fence(code2)),
]),

// Multiple exports and multiple properties
incorrectPlaygroundLinkError(fence => [jsdoc(fence(code1)), jsdoc(fence(code2))]),
],
});
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,9 @@
"@typescript/vfs": "^1.6.1",
"dedent": "^1.7.0",
"expect-type": "^1.2.2",
"lz-string": "^1.5.0",
"npm-run-all2": "^8.0.4",
"outdent": "^0.8.0",
"tsd": "^0.33.0",
"typescript": "^5.9.2",
"xo": "^1.2.2"
Expand Down
3 changes: 3 additions & 0 deletions source/all-extend.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ export type AllExtendOptions = {
type H = AllExtend<[never, 1], never, {strictNever: false}>;
//=> false
```
[Playground Link](https://www.typescriptlang.org/play/?exactOptionalPropertyTypes=true#code/JYWwDg9gTgLgBDAnmApnA3gQQDbYKIAeMKAdgCYC+cAZlBCHAORKoC01KAzjIwNwBQ-FmkxwAvHBz4ipMgB4A2gEYANHABMakigBuKKAF0tAVxAAjfWvTcowAMYwAcrv0AuBFGMoKAPgEB6fzEfGgBDbE4UQWE4ACFxSVxCYnJFVQ0tF0MTc0sMG3snLPdqcMjfAKCQmE8ooWQ0AGEEqWTZRW09KEyuozhOvOsawucu9xqvCv5A4I8vaIa4ABEWpJlUhQHu-qy+rasCh1G3MIjvP2mqubqYvFXpFPkFRlDGNUYzN53etVCSRAOwyOxWuUxm1VqC1QcAAYvc2hsXl8Pl8tn0-gD8kCimNTuULuDrlC0ABxeHrJ77OBKPZZQG2YG4ibnSqzUpnYlwAAS5MeHTp1NpXXpIxB7PxrJC4pQQA)
*/
strictNever?: boolean;
};
Expand Down Expand Up @@ -74,6 +75,7 @@ type C = AllExtend<[number, number | string], number>;
type D = AllExtend<[true, boolean, true], true>;
//=> boolean
```
[Playground Link](https://www.typescriptlang.org/play/?exactOptionalPropertyTypes=true#code/JYWwDg9gTgLgBDAnmApnA3gQQDbYKIAeMKAdgCYC+cAZlBCHAORKoC01KAzjIwNwBQ-FmkxwAvHBz4ipMgB4A2gEYANHABMagMwBdNSQCuIAEYooAPgEB6K2PMIoBlIOFwAQuMm5CxcotUaaoxajHpwhiZmlvw2djQAhticzkLIaADCnlI+sooRplD6RgVwAD5w3FDAJADmYflR1rb2xhAQ2CjxJC5pcAAiWd4yfgowjihqre2dJGpjTmHzKNGxLW0dXUA)

Note: Behaviour of optional elements depend on the `exactOptionalPropertyTypes` compiler option. When the option is disabled, the target type must include `undefined` for a successful match.

Expand All @@ -92,6 +94,7 @@ type B = AllExtend<[1?, 2?, 3?], number>;
type C = AllExtend<[1?, 2?, 3?], number | undefined>;
//=> true
```
[Playground Link](https://www.typescriptlang.org/play/?exactOptionalPropertyTypes=true#code/JYWwDg9gTgLgBDAnmApnA3gQQDbYKIAeMKAdgCYC+cAZlBCHAORKoC01KAzjIwNwBQ-APRC4AAxQEAhgGMYAeTAxgEElOwAFOqliIAKsi5i4pKQCNsKMvxZpMcALxwc+IqTIAeANoBGAPwANHAATIFwAMx+ALpBJACuIGYoUAB8AiIOKQhQcSiCIuKSsgpKKmqa2slIBqicxmTAnOaW1rZwAEKOzriExOTe-kGhQZExcPGJyWnCQpk06px5M4XScorKqupaEDrVhnVwDU0WVjaGcADCXS697gNhwxHRsQlJUHAAPnBx5CjUwCQrNMMlkYDkUEA)

@see {@link AllExtendOptions}

Expand Down
1 change: 1 addition & 0 deletions source/all-union-fields.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ function displayPetInfoWithAllUnionFields(petInfo: AllUnionFields<Cat | Dog>) {
console.log('animal type: ', petInfo.catType ?? petInfo.dogType);
}
```
[Playground Link](https://www.typescriptlang.org/play/?exactOptionalPropertyTypes=true#code/JYWwDg9gTgLgBDAnmApnA3gQQDbYKoB2wEBAYsCtgCYDOAvnAGZQQhwDkSqAtIyjTHYBuAFAiuaAMIBDeAF4MIgJAFpIFAC44AqMAIBzUUolb2AY1nDlFmABVkm7TF0HRdUeIdwAIhH1wFdGVVdS0dPUNlEw4qPyslWP17VDDnCLcPRgBXAjMYYgI4KmAaMGxpRAAFFBgASQJGCAAKVDqGiC0ZeAAfHz8ASkUlAHphhAcIRjhW+saAgD5lUaHllTVHcNclseMHUxt41ZtkjbStkbGGXqCLuDXQpxdI292UmLijVcST1KfPy7ESjMJBoEGwKAAdNg-E12CFHOwADTTGqzCAQ+H9IzAgig8FQmGcPYcZEzdoQiRYwHLE4AZTMujA8GB4HKehocGkACMIFl4AADY4OfmcghUOD877CuAECDwFAADxK+QMcBI41QEq6cF6vn0-Ih2zgAAEYDRuIrUHkLVAWFBrCCwZDofpYdIiCBpNgNQjSajyULNQB+IMotqNCFS1BUuhibK5fLq4qlcpVf2NADqwBgAAscPgiCRyJRaC10x04PnCAVi9QaAAebW6vzzQY3ZYSSZhtELI3tnbw37pI2vBEHHXvfSHHaBlBBofnL5+E7zx7D26xh24p0E11w9amP3h9GY7GO-Eu2HRJHd8mUjy3AByEDgdIZwCZcBQtughqB5+dQl3VAL0fUPW8I1nOAQwg9EoxQGMgA)

@see {@link SharedUnionFields}

Expand Down
Loading