@mui/internal-docs-infra
Version:
MUI Infra - internal documentation creation tools.
398 lines (383 loc) • 16.3 kB
JavaScript
const WRAPPER_TAGS = ['div', 'Box', 'Stack'];
function getTagName(openingElement) {
if (openingElement.name.type === 'JSXIdentifier') {
return openingElement.name.name;
}
if (openingElement.name.type === 'JSXMemberExpression' && openingElement.name.object.type === 'JSXIdentifier' && openingElement.name.object.name === 'React' && openingElement.name.property.name === 'Fragment') {
return 'React.Fragment';
}
return null;
}
function isBlankJSXText(child) {
return child.type === 'JSXText' && !/\S/.test(child.value);
}
function trimBlankJSXText(children) {
return children.filter((child, index, arr) => {
const isSurrounding = index === 0 || index === arr.length - 1;
return !(isSurrounding && isBlankJSXText(child));
});
}
function isWrapperTag(node) {
const tagName = getTagName(node.openingElement);
return tagName !== null && WRAPPER_TAGS.includes(tagName);
}
function isFragmentTag(node) {
return getTagName(node.openingElement) === 'React.Fragment';
}
function isJSXElement(node) {
return node.type === 'JSXElement';
}
function isJSXFragment(node) {
return node.type === 'JSXFragment';
}
/**
* Resolves the function body statements from a function-like declaration.
* Handles FunctionDeclaration, ArrowFunctionExpression, and FunctionExpression.
*/
function getFunctionBody(declaration) {
if (declaration.type === 'FunctionDeclaration' && declaration.body) {
return declaration.body.body;
}
if (declaration.type === 'ArrowFunctionExpression' || declaration.type === 'FunctionExpression') {
if (declaration.body.type === 'BlockStatement') {
return declaration.body.body;
}
return null;
}
return null;
}
/**
* Gets the implicit return expression from an arrow function with expression body.
*/
function getImplicitReturn(declaration) {
if (declaration.type === 'ArrowFunctionExpression' && declaration.body.type !== 'BlockStatement') {
return declaration.body;
}
return null;
}
/**
* Gets the name of a function-like node.
* For FunctionDeclaration, returns the id name.
*/
function getFunctionName(node) {
if (node.type === 'FunctionDeclaration' && node.id) {
return node.id.name;
}
return null;
}
/**
* Unwraps call expressions (e.g. React.forwardRef, React.memo) to find the
* inner function argument. Returns the node itself if it is already a function.
*/
function unwrapCallExpression(node) {
if (node.type === 'ArrowFunctionExpression' || node.type === 'FunctionExpression' || node.type === 'FunctionDeclaration') {
return node;
}
if (node.type === 'CallExpression') {
for (const arg of node.arguments) {
if (arg.type !== 'SpreadElement') {
const inner = unwrapCallExpression(arg);
if (inner) {
return inner;
}
}
}
}
return null;
}
/**
* Determines the preview nodes from a returned JSX value.
* Wrappers (div, Box, Stack, fragments) are unwrapped to their trimmed children.
* Non-wrapper elements are returned as-is.
*/
function getPreviewNodes(returnedJSX) {
if (isJSXElement(returnedJSX)) {
if ((isWrapperTag(returnedJSX) || isFragmentTag(returnedJSX)) && returnedJSX.children.length > 0) {
const trimmed = trimBlankJSXText(returnedJSX.children);
if (trimmed.length > 0) {
return {
nodes: trimmed,
insideWrapper: true
};
}
}
return {
nodes: [returnedJSX],
insideWrapper: false
};
}
if (isJSXFragment(returnedJSX) && returnedJSX.children.length > 0) {
const trimmed = trimBlankJSXText(returnedJSX.children);
if (trimmed.length > 0) {
return {
nodes: trimmed,
insideWrapper: true
};
}
}
return null;
}
/**
* ESLint rule requiring demo files to have focus comments around the preview section.
*/
export const lintJavascriptDemoFocus = {
meta: {
type: 'suggestion',
docs: {
description: 'Require demo files to have @focus-start / @focus-end comments around the preview section.'
},
fixable: 'code',
messages: {
missingDemoFocusJsx: 'Demo file is missing {/* @focus-start */} and {/* @focus-end */} comments around the preview section. Run with --fix to add them automatically.',
missingDemoFocusJsxSingle: 'Demo file is missing {/* @focus */} comment on the preview line. Run with --fix to add it automatically.',
missingDemoFocusJs: 'Demo file is missing // @focus-start and // @focus-end comments around the preview section. Run with --fix to add them automatically.',
missingDemoFocusJsSingle: 'Demo file is missing // @focus comment on the preview line. Run with --fix to add it automatically.',
missingDemoFocusBody: 'Demo file is missing // @focus-start @padding 1 and // @focus-end comments around the function body. Run with --fix to add them automatically.'
},
schema: [{
type: 'object',
properties: {
wrapReturn: {
type: 'boolean',
description: 'When true, bare return statements without parentheses are wrapped in return (...) and the highlight comment is placed inside the parentheses.'
}
},
additionalProperties: false
}]
},
create(context) {
const sourceCode = context.sourceCode;
const options = context.options[0] ?? {};
// Skip files that already have @focus or @highlight directives in comments —
// those files already declare a focus region (a @highlight implicitly defines
// one), so the auto-fixer should not add additional markers.
// @highlight-text is excluded because it only marks inline text within a line
// and does not on its own define a focus region.
// We check actual parsed comments (not raw source text) to avoid false
// negatives from tokens appearing in string literals or identifiers.
// The regex matches @focus, @focus-start, @focus-end, @highlight,
// @highlight-start, and @highlight-end as standalone tokens (not as
// substrings of other words or prose).
const focusDirectivePattern = /(?:^|\s)@(?:focus(?:-(?:start|end))?|highlight(?:-(?:start|end))?)(?:\s|$)/;
const hasFocusComment = sourceCode.getAllComments().some(comment => {
return focusDirectivePattern.test(comment.value);
});
if (hasFocusComment) {
return {};
}
function processFunctionNode(declaration) {
const implicitReturn = getImplicitReturn(declaration);
if (implicitReturn) {
const result = getPreviewNodes(implicitReturn);
if (result) {
reportPreview(context, sourceCode, result, null, options);
}
return;
}
const body = getFunctionBody(declaration);
if (!body || body.length === 0) {
return;
}
// If the body has more than just a return (e.g. hooks, variables),
// highlight the entire function body
const hasSetupStatements = body.length > 1 || body[0].type !== 'ReturnStatement';
if (hasSetupStatements) {
const firstStatement = body[0];
const lastStatement = body[body.length - 1];
reportFunctionBody(context, sourceCode, firstStatement, lastStatement);
return;
}
const lastReturn = body[0];
if (!lastReturn.argument) {
return;
}
const result = getPreviewNodes(lastReturn.argument);
if (result) {
reportPreview(context, sourceCode, result, lastReturn, options);
}
}
let handled = false;
const namedExportFunctions = [];
return {
ExportDefaultDeclaration(node) {
handled = true;
const inner = unwrapCallExpression(node.declaration);
processFunctionNode(inner ?? node.declaration);
},
ExportNamedDeclaration(node) {
const {
declaration
} = node;
if (!declaration) {
return;
}
if (declaration.type === 'FunctionDeclaration') {
namedExportFunctions.push({
name: getFunctionName(declaration),
node: declaration
});
return;
}
// Handle `export const Demo = () => ...`, `export const Demo = function() ...`,
// and call-wrapped patterns like `export const Demo = React.forwardRef(function() ...)`
if (declaration.type === 'VariableDeclaration') {
for (const declarator of declaration.declarations) {
if (declarator.init) {
const inner = unwrapCallExpression(declarator.init);
if (inner) {
const name = declarator.id.type === 'Identifier' ? declarator.id.name : null;
namedExportFunctions.push({
name,
node: inner
});
}
}
}
}
},
'Program:exit'() {
if (handled || namedExportFunctions.length === 0) {
return;
}
const base = context.filename.split(/[/\\]/).pop() ?? context.filename;
const filename = base.includes('.') ? base.slice(0, base.lastIndexOf('.')) : base;
// Strategy 1: find an exported function whose name matches the filename
const filenameMatch = namedExportFunctions.find(fn => fn.name === filename);
if (filenameMatch) {
processFunctionNode(filenameMatch.node);
return;
}
// Strategy 2: if there's exactly one exported function, use it
if (namedExportFunctions.length === 1) {
processFunctionNode(namedExportFunctions[0].node);
}
}
};
}
};
function reportPreview(context, sourceCode, {
nodes: previewNodes,
insideWrapper
}, returnStatement, options) {
const firstNode = previewNodes[0];
const lastNode = previewNodes[previewNodes.length - 1];
const isSingleLine = firstNode.loc.start.line === lastNode.loc.end.line;
const firstLine = sourceCode.lines[firstNode.loc.start.line - 1];
const indentation = firstLine.match(/^\s*/)?.[0] ?? '';
let messageId;
if (insideWrapper) {
messageId = isSingleLine ? 'missingDemoFocusJsxSingle' : 'missingDemoFocusJsx';
} else {
messageId = isSingleLine ? 'missingDemoFocusJsSingle' : 'missingDemoFocusJs';
}
context.report({
loc: {
start: firstNode.loc.start,
end: lastNode.loc.end
},
messageId,
fix(fixer) {
if (insideWrapper && isSingleLine) {
return fixer.insertTextBeforeRange(firstNode.range, `{/* @focus */}\n${indentation}`);
}
if (insideWrapper) {
return [fixer.insertTextBeforeRange(firstNode.range, `{/* @focus-start */}\n${indentation}`), fixer.insertTextAfterRange(lastNode.range, `\n${indentation}{/* @focus-end */}`)];
}
// Non-wrapper: wrapReturn with explicit return wraps `return <X>` into `return (\n // comment\n <X>\n)`
if (options.wrapReturn && returnStatement) {
const hasParens = hasReturnParens(sourceCode, returnStatement);
if (hasParens) {
// Already `return (...)` — just insert the comment inside. The focus
// sits one level deeper than the function body, so `@padding 2` keeps
// the `return (` line and the function signature/closing brace visible
// as context when collapsed.
const lineStartOffset = sourceCode.getIndexFromLoc({
line: firstNode.loc.start.line,
column: 0
});
if (isSingleLine) {
return fixer.insertTextBeforeRange([lineStartOffset, lineStartOffset], `${indentation}// @focus @padding 2\n`);
}
const lastLineStartOffset = sourceCode.getIndexFromLoc({
line: lastNode.loc.end.line,
column: 0
});
const lastLine = sourceCode.lines[lastNode.loc.end.line - 1];
const lastIndentation = lastLine.match(/^\s*/)?.[0] ?? '';
return [fixer.insertTextBeforeRange([lineStartOffset, lineStartOffset], `${indentation}// @focus-start @padding 2\n`), fixer.insertTextAfterRange([lastLineStartOffset, lastLineStartOffset + lastLine.length], `\n${lastIndentation}// @focus-end`)];
}
// No parens — wrap `return <X>` into `return (\n // comment\n <X>\n)`
const returnKeywordEnd = returnStatement.range[0] + 'return'.length;
const returnIndentation = sourceCode.lines[returnStatement.loc.start.line - 1].match(/^\s*/)?.[0] ?? '';
const innerIndentation = `${returnIndentation} `;
if (isSingleLine) {
return [fixer.replaceTextRange([returnKeywordEnd, firstNode.range[0]], ` (\n${innerIndentation}// @focus @padding 2\n${innerIndentation}`), fixer.insertTextAfterRange(lastNode.range, `\n${returnIndentation})`)];
}
return [fixer.replaceTextRange([returnKeywordEnd, firstNode.range[0]], ` (\n${innerIndentation}// @focus-start @padding 2\n${innerIndentation}`), fixer.insertTextAfterRange(lastNode.range, `\n${innerIndentation}// @focus-end\n${returnIndentation})`)];
}
// Non-wrapper: wrapReturn with implicit-return arrow — wrap expression in parens with comment.
// `returnStatement` is null for implicit-return arrow functions (`() => <X />`).
if (options.wrapReturn && !returnStatement) {
const arrowToken = sourceCode.getTokenBefore(firstNode);
if (arrowToken?.value === '=>') {
const arrowIndentation = sourceCode.lines[arrowToken.loc.start.line - 1].match(/^\s*/)?.[0] ?? '';
const innerIndentation = `${arrowIndentation} `;
if (isSingleLine) {
return [fixer.replaceTextRange([arrowToken.range[1], firstNode.range[0]], ` (\n${innerIndentation}// @focus @padding 2\n${innerIndentation}`), fixer.insertTextAfterRange(lastNode.range, `\n${arrowIndentation})`)];
}
return [fixer.replaceTextRange([arrowToken.range[1], firstNode.range[0]], ` (\n${innerIndentation}// @focus-start @padding 2\n${innerIndentation}`), fixer.insertTextAfterRange(lastNode.range, `\n${innerIndentation}// @focus-end\n${arrowIndentation})`)];
}
}
// Default non-wrapper: insert JS comment at the start of the line
const lineStartOffset = sourceCode.getIndexFromLoc({
line: firstNode.loc.start.line,
column: 0
});
if (isSingleLine) {
return fixer.insertTextBeforeRange([lineStartOffset, lineStartOffset], `${indentation}// @focus\n`);
}
const lastLineStartOffset = sourceCode.getIndexFromLoc({
line: lastNode.loc.end.line,
column: 0
});
const lastLine = sourceCode.lines[lastNode.loc.end.line - 1];
const lastIndentation = lastLine.match(/^\s*/)?.[0] ?? '';
return [fixer.insertTextBeforeRange([lineStartOffset, lineStartOffset], `${indentation}// @focus-start\n`), fixer.insertTextAfterRange([lastLineStartOffset, lastLineStartOffset + lastLine.length], `\n${lastIndentation}// @focus-end`)];
}
});
}
/**
* Checks whether a return statement's argument is wrapped in parentheses.
*/
function hasReturnParens(sourceCode, returnStatement) {
const text = sourceCode.getText(returnStatement);
const afterReturn = text.slice('return'.length).trimStart();
return afterReturn.startsWith('(');
}
/**
* Reports when the entire function body should be highlighted (e.g. when there are hooks/setup).
*/
function reportFunctionBody(context, sourceCode, firstStatement, lastStatement) {
const firstLine = sourceCode.lines[firstStatement.loc.start.line - 1];
const indentation = firstLine.match(/^\s*/)?.[0] ?? '';
const lastLine = sourceCode.lines[lastStatement.loc.end.line - 1];
const lastIndentation = lastLine.match(/^\s*/)?.[0] ?? '';
const lineStartOffset = sourceCode.getIndexFromLoc({
line: firstStatement.loc.start.line,
column: 0
});
const lastLineStartOffset = sourceCode.getIndexFromLoc({
line: lastStatement.loc.end.line,
column: 0
});
context.report({
loc: {
start: firstStatement.loc.start,
end: lastStatement.loc.end
},
messageId: 'missingDemoFocusBody',
fix(fixer) {
return [fixer.insertTextBeforeRange([lineStartOffset, lineStartOffset], `${indentation}// @focus-start @padding 1\n`), fixer.insertTextAfterRange([lastLineStartOffset, lastLineStartOffset + lastLine.length], `\n${lastIndentation}// @focus-end`)];
}
});
}