diff --git a/packages/website-eslint/src/linter/linter.js b/packages/website-eslint/src/linter/linter.js index 4b798674eeb6..1643dcf2562f 100644 --- a/packages/website-eslint/src/linter/linter.js +++ b/packages/website-eslint/src/linter/linter.js @@ -8,11 +8,13 @@ const PARSER_NAME = '@typescript-eslint/parser'; export function loadLinter() { const linter = new Linter(); let storedAST; + let storedTsAST; linter.defineParser(PARSER_NAME, { parseForESLint(code, options) { const toParse = parseForESLint(code, options); storedAST = toParse.ast; + storedTsAST = toParse.tsAst; return toParse; }, // parse(code: string, options: ParserOptions): ParseForESLintResult['ast'] { // const toParse = parseForESLint(code, options); @@ -39,6 +41,10 @@ export function loadLinter() { return storedAST; }, + getTsAst() { + return storedTsAST; + }, + lint(code, parserOptions, rules) { return linter.verify(code, { parser: PARSER_NAME, diff --git a/packages/website-eslint/src/linter/parser.js b/packages/website-eslint/src/linter/parser.js index 282f67c0da7a..41c0b24baf10 100644 --- a/packages/website-eslint/src/linter/parser.js +++ b/packages/website-eslint/src/linter/parser.js @@ -14,6 +14,7 @@ function parseAndGenerateServices(code, options) { return { ast: estree, + tsAst: ast, services: { hasFullTypeInformation: true, program, @@ -24,7 +25,7 @@ function parseAndGenerateServices(code, options) { } export function parseForESLint(code, parserOptions) { - const { ast, services } = parseAndGenerateServices(code, { + const { ast, tsAst, services } = parseAndGenerateServices(code, { ...parserOptions, jsx: parserOptions.ecmaFeatures?.jsx ?? false, useJSXTextNode: true, @@ -40,6 +41,7 @@ export function parseForESLint(code, parserOptions) { return { ast, + tsAst, services, scopeManager, visitorKeys, diff --git a/packages/website-eslint/types/index.d.ts b/packages/website-eslint/types/index.d.ts index eaa86160cba7..86e0c02eed6c 100644 --- a/packages/website-eslint/types/index.d.ts +++ b/packages/website-eslint/types/index.d.ts @@ -12,6 +12,7 @@ export interface WebLinter { ruleNames: { name: string; description?: string }[]; getAst(): ESLintAST; + getTsAst(): Record; lint( code: string, diff --git a/packages/website/src/components/ASTViewerESTree.tsx b/packages/website/src/components/ASTViewerESTree.tsx new file mode 100644 index 000000000000..899daeedba9a --- /dev/null +++ b/packages/website/src/components/ASTViewerESTree.tsx @@ -0,0 +1,52 @@ +import React, { useCallback } from 'react'; + +import ASTViewer from './ast/ASTViewer'; +import { isRecord } from './ast/utils'; +import type { ASTViewerBaseProps, SelectedRange } from './ast/types'; +import { TSESTree } from '@typescript-eslint/website-eslint'; + +function isESTreeNode( + value: unknown, +): value is Record & TSESTree.BaseNode { + return isRecord(value) && 'type' in value && 'loc' in value; +} + +export const propsToFilter = ['parent', 'comments', 'tokens']; + +export default function ASTViewerESTree( + props: ASTViewerBaseProps, +): JSX.Element { + const filterProps = useCallback( + (item: [string, unknown]): boolean => + !propsToFilter.includes(item[0]) && + !item[0].startsWith('_') && + item[1] !== undefined, + [], + ); + + const getRange = useCallback( + (value: unknown): SelectedRange | undefined => + isESTreeNode(value) + ? { + start: value.loc.start, + end: value.loc.end, + } + : undefined, + [], + ); + + const getNodeName = useCallback( + (value: unknown): string | undefined => + isESTreeNode(value) ? String(value.type) : undefined, + [], + ); + + return ( + + ); +} diff --git a/packages/website/src/components/ASTViewerTS.tsx b/packages/website/src/components/ASTViewerTS.tsx new file mode 100644 index 000000000000..f30265ba574e --- /dev/null +++ b/packages/website/src/components/ASTViewerTS.tsx @@ -0,0 +1,149 @@ +import React, { useCallback, useEffect, useState } from 'react'; + +import ASTViewer from './ast/ASTViewer'; +import { isRecord } from './ast/utils'; +import type { + ASTViewerBaseProps, + SelectedRange, + SelectedPosition, +} from './ast/types'; +import type { Node, SourceFile } from 'typescript'; + +export interface ASTTsViewerProps extends ASTViewerBaseProps { + readonly version: string; +} + +function extractEnum( + obj: Record, +): Record { + const result: Record = {}; + const keys = Object.entries(obj); + for (const [name, value] of keys) { + if (typeof value === 'number') { + if (!(value in result)) { + result[value] = name; + } + } + } + return result; +} + +function isTsNode(value: unknown): value is Node { + return isRecord(value) && typeof value.kind === 'number'; +} + +function getFlagNamesFromEnum( + allFlags: Record, + flags: number, + prefix: string, +): string[] { + return Object.entries(allFlags) + .filter(([f, _]) => (Number(f) & flags) !== 0) + .map(([_, name]) => `${prefix}.${name}`); +} + +export function getLineAndCharacterFor( + pos: number, + ast: SourceFile, +): SelectedPosition { + const loc = ast.getLineAndCharacterOfPosition(pos); + return { + line: loc.line + 1, + column: loc.character, + }; +} + +export function getLocFor( + start: number, + end: number, + ast: SourceFile, +): SelectedRange { + return { + start: getLineAndCharacterFor(start, ast), + end: getLineAndCharacterFor(end, ast), + }; +} + +export const propsToFilter = [ + 'parent', + 'jsDoc', + 'lineMap', + 'externalModuleIndicator', + 'bindDiagnostics', + 'transformFlags', + 'resolvedModules', + 'imports', +]; + +export default function ASTViewerTS(props: ASTTsViewerProps): JSX.Element { + const [syntaxKind, setSyntaxKind] = useState>({}); + const [nodeFlags, setNodeFlags] = useState>({}); + const [tokenFlags, setTokenFlags] = useState>({}); + const [modifierFlags, setModifierFlags] = useState>( + {}, + ); + + useEffect(() => { + setSyntaxKind(extractEnum(window.ts.SyntaxKind)); + setNodeFlags(extractEnum(window.ts.NodeFlags)); + setTokenFlags(extractEnum(window.ts.TokenFlags)); + setModifierFlags(extractEnum(window.ts.ModifierFlags)); + }, [props.version]); + + const getTooltip = useCallback( + (key: string, value: unknown): string | undefined => { + if (key === 'flags' && typeof value === 'number') { + return getFlagNamesFromEnum(nodeFlags, value, 'NodeFlags').join('\n'); + } else if (key === 'numericLiteralFlags' && typeof value === 'number') { + return getFlagNamesFromEnum(tokenFlags, value, 'TokenFlags').join('\n'); + } else if (key === 'modifierFlagsCache' && typeof value === 'number') { + return getFlagNamesFromEnum(modifierFlags, value, 'ModifierFlags').join( + '\n', + ); + } else if (key === 'kind' && typeof value === 'number') { + return `SyntaxKind.${syntaxKind[value]}`; + } + return undefined; + }, + [nodeFlags, tokenFlags, syntaxKind], + ); + + const getNodeName = useCallback( + (value: unknown): string | undefined => + isTsNode(value) ? syntaxKind[value.kind] : undefined, + [syntaxKind], + ); + + const filterProps = useCallback( + (item: [string, unknown]): boolean => + !propsToFilter.includes(item[0]) && + !item[0].startsWith('_') && + item[1] !== undefined, + [], + ); + + const getRange = useCallback( + (value: unknown): SelectedRange | undefined => { + if (props.value && isTsNode(value)) { + return getLocFor( + value.pos, + value.end, + // @ts-expect-error: unsafe cast + props.value as SourceFile, + ); + } + return undefined; + }, + [props.value], + ); + + return ( + + ); +} diff --git a/packages/website/src/components/OptionsSelector.tsx b/packages/website/src/components/OptionsSelector.tsx index 023efce6315d..ee1f14648d9d 100644 --- a/packages/website/src/components/OptionsSelector.tsx +++ b/packages/website/src/components/OptionsSelector.tsx @@ -10,18 +10,15 @@ import Tooltip from './inputs/Tooltip'; import EditIcon from './icons/EditIcon'; import CopyIcon from './icons/CopyIcon'; +import useDebouncedToggle from './hooks/useDebouncedToggle'; + import { createMarkdown } from './lib/markdown'; import type { RuleDetails } from './types'; import styles from './OptionsSelector.module.css'; -import type { - CompilerFlags, - ConfigModel, - SourceType, - RulesRecord, -} from './types'; +import type { CompilerFlags, ConfigModel, RulesRecord } from './types'; export interface OptionsSelectorParams { readonly ruleOptions: RuleDetails[]; @@ -31,6 +28,12 @@ export interface OptionsSelectorParams { readonly isLoading: boolean; } +const ASTOptions = [ + { value: false, label: 'Disabled' }, + { value: 'es', label: 'ESTree' }, + { value: 'ts', label: 'TypeScript' }, +] as const; + function OptionsSelector({ ruleOptions, state, @@ -40,8 +43,8 @@ function OptionsSelector({ }: OptionsSelectorParams): JSX.Element { const [eslintModal, setEslintModal] = useState(false); const [typeScriptModal, setTypeScriptModal] = useState(false); - const [copyLink, setCopyLink] = useState(false); - const [copyMarkdown, setCopyMarkdown] = useState(false); + const [copyLink, setCopyLink] = useDebouncedToggle(false); + const [copyMarkdown, setCopyMarkdown] = useDebouncedToggle(false); const updateTS = useCallback( (version: string) => { @@ -145,12 +148,12 @@ function OptionsSelector({ /> @@ -180,7 +183,7 @@ function OptionsSelector({ @@ -189,7 +192,7 @@ function OptionsSelector({ onClick={copyMarkdownToClipboard} > Copy Markdown - + diff --git a/packages/website/src/components/Playground.module.css b/packages/website/src/components/Playground.module.css index 6c5a9fdad71e..f1d78f13c6c4 100644 --- a/packages/website/src/components/Playground.module.css +++ b/packages/website/src/components/Playground.module.css @@ -25,8 +25,8 @@ height: 100%; width: 50%; border: 1px solid var(--ifm-color-emphasis-100); + padding: 0; overflow: auto; - background: var(--ifm-background-surface-color); word-wrap: initial; white-space: nowrap; background: var(--code-editor-bg); diff --git a/packages/website/src/components/Playground.tsx b/packages/website/src/components/Playground.tsx index f0c7903e5f27..844b955dd8b8 100644 --- a/packages/website/src/components/Playground.tsx +++ b/packages/website/src/components/Playground.tsx @@ -8,10 +8,14 @@ import Loader from './layout/Loader'; import useHashState from './hooks/useHashState'; import OptionsSelector from './OptionsSelector'; -import ASTViewer from './ast/ASTViewer'; import { LoadingEditor } from './editor/LoadingEditor'; import { EditorEmbed } from './editor/EditorEmbed'; -import type { RuleDetails } from './types'; +import { shallowEqual } from './lib/shallowEqual'; + +import ASTViewerESTree from './ASTViewerESTree'; +import ASTViewerTS from './ASTViewerTS'; + +import type { RuleDetails, SelectedRange } from './types'; import type { TSESTree } from '@typescript-eslint/website-eslint'; @@ -26,25 +30,28 @@ function Playground(): JSX.Element { tsConfig: {}, }); const { isDarkTheme } = useThemeContext(); - const [ast, setAST] = useState(); + const [esAst, setEsAst] = useState(); + const [tsAst, setTsAST] = useState | string | null>(); const [ruleNames, setRuleNames] = useState([]); const [isLoading, setIsLoading] = useState(true); const [tsVersions, setTSVersion] = useState([]); - const [selectedNode, setSelectedNode] = useState(null); + const [selectedRange, setSelectedRange] = useState( + null, + ); const [position, setPosition] = useState(null); const updateSelectedNode = useCallback( - (node: TSESTree.Node | null) => { + (value: SelectedRange | null) => { if ( - !node || - !selectedNode || - selectedNode.range[0] !== node.range[0] || - selectedNode.range[1] !== node.range[1] + !value || + !selectedRange || + !shallowEqual(selectedRange.start, value.start) || + !shallowEqual(selectedRange.end, value.end) ) { - setSelectedNode(node); + setSelectedRange(value); } }, - [selectedNode], + [selectedRange], ); return ( @@ -76,8 +83,9 @@ function Playground(): JSX.Element { sourceType={state.sourceType} rules={state.rules} showAST={state.showAST} - onASTChange={setAST} - decoration={selectedNode} + onEsASTChange={setEsAst} + onTsASTChange={setTsAST} + decoration={selectedRange} onChange={(code): void => setState({ code: code })} onLoaded={(ruleNames, tsVersions): void => { setRuleNames(ruleNames); @@ -89,13 +97,21 @@ function Playground(): JSX.Element { {state.showAST && (
- {ast && ( - - )} + )) || + (esAst && ( + + ))}
)} diff --git a/packages/website/src/components/ast/ASTViewer.module.css b/packages/website/src/components/ast/ASTViewer.module.css index 1739f872ecb6..84a40b866c26 100644 --- a/packages/website/src/components/ast/ASTViewer.module.css +++ b/packages/website/src/components/ast/ASTViewer.module.css @@ -1,16 +1,24 @@ +.list { + font-family: var(--ifm-font-family-monospace); + background: transparent; + border: none; + padding: 0; + font-size: 13px; + line-height: 18px; + letter-spacing: 0; + font-feature-settings: 'liga' 0, 'calt' 0; +} + .list, .subList { - cursor: default; box-sizing: border-box; + white-space: break-spaces; margin: 0; - list-style: none; padding-left: 1.5rem; - font-family: Consolas, 'Courier New', monospace; - font-weight: normal; - font-size: 13px; - font-feature-settings: 'liga' 0, 'calt' 0; - line-height: 18px; - letter-spacing: 0px; +} + +.selected { + background: var(--code-line-decoration); } .nonExpand, @@ -22,10 +30,6 @@ content: '+'; } -.selected { - background: var(--code-line-decoration); -} - .expand::before { content: '-'; margin-left: -1rem; @@ -58,6 +62,10 @@ color: #b58900; } +.propClass { + color: #b58900; +} + .propBoolean { color: #b58900; } @@ -68,11 +76,10 @@ .hidden { color: var(--ifm-color-emphasis-400); -} - -.clickable { - cursor: pointer; -} -.clickable:hover { - text-decoration: underline; + max-width: 40%; + overflow: hidden; + display: inline-block; + text-overflow: ellipsis; + white-space: nowrap; + vertical-align: bottom; } diff --git a/packages/website/src/components/ast/ASTViewer.tsx b/packages/website/src/components/ast/ASTViewer.tsx index 7c234f91a238..aa1e35687ec0 100644 --- a/packages/website/src/components/ast/ASTViewer.tsx +++ b/packages/website/src/components/ast/ASTViewer.tsx @@ -1,25 +1,13 @@ import React, { useEffect, useState } from 'react'; import styles from './ASTViewer.module.css'; -import type { TSESTree } from '@typescript-eslint/website-eslint'; -import type { Position } from './types'; +import type { SelectedPosition, ASTViewerProps } from './types'; -import { ElementObject } from './Elements'; -import type Monaco from 'monaco-editor'; +import { ComplexItem } from './Elements'; +import { isRecord } from './utils'; -function ASTViewer(props: { - ast: TSESTree.Node | string; - position?: Monaco.Position | null; - onSelectNode: (node: TSESTree.Node | null) => void; -}): JSX.Element { - const [selection, setSelection] = useState(() => - props.position - ? { - line: props.position.lineNumber, - column: props.position.column - 1, - } - : null, - ); +function ASTViewer(props: ASTViewerProps): JSX.Element { + const [selection, setSelection] = useState(null); useEffect(() => { setSelection( @@ -32,17 +20,21 @@ function ASTViewer(props: { ); }, [props.position]); - return typeof props.ast === 'string' ? ( -
{props.ast}
- ) : ( + return isRecord(props.value) ? (
-
+ ) : ( +
{props.value}
); } diff --git a/packages/website/src/components/ast/Elements.tsx b/packages/website/src/components/ast/Elements.tsx index 9dba9eff491a..be573770d94d 100644 --- a/packages/website/src/components/ast/Elements.tsx +++ b/packages/website/src/components/ast/Elements.tsx @@ -1,218 +1,140 @@ -import React, { - SyntheticEvent, - useCallback, - useEffect, - useRef, - useState, -} from 'react'; -import clsx from 'clsx'; +import React, { useCallback, useEffect, useState } from 'react'; -import type { TSESTree } from '@typescript-eslint/website-eslint'; import type { GenericParams } from './types'; -import { scrollIntoViewIfNeeded } from '@site/src/components/lib/scroll-into'; -import { - filterRecord, - hasChildInRange, - isArrayInRange, - isEsNode, - isInRange, - isRecord, -} from './selection'; +import { hasChildInRange, isArrayInRange, isInRange, isRecord } from './utils'; -import PropertyNameComp from '@site/src/components/ast/PropertyName'; -import PropertyValueComp from '@site/src/components/ast/PropertyValue'; import styles from '@site/src/components/ast/ASTViewer.module.css'; -export const PropertyName = React.memo(PropertyNameComp); -export const PropertyValue = React.memo(PropertyValueComp); +import PropertyValue from '@site/src/components/ast/PropertyValue'; +import ItemGroup from '@site/src/components/ast/ItemGroup'; +import HiddenItem from '@site/src/components/ast/HiddenItem'; +import Tooltip from '@site/src/components/inputs/Tooltip'; -export function ElementArray(props: GenericParams): JSX.Element { - const [isComplex, setIsComplex] = useState(() => - isRecord(props.value), - ); +export function ComplexItem( + props: GenericParams | unknown[]>, +): JSX.Element { const [isExpanded, setIsExpanded] = useState( - () => - isComplex || props.value.some(item => isInRange(props.selection, item)), + () => props.level === 'ast', ); + const [isSelected, setIsSelected] = useState(false); + const [model, setModel] = useState<[string, unknown][]>([]); useEffect(() => { - setIsComplex( - props.value.some(item => typeof item === 'object' && item !== null), + setModel( + Object.entries(props.value).filter(item => props.filterProps(item)), ); - }, [props.value]); - - useEffect(() => { - if (isComplex && !isExpanded) { - setIsExpanded(isArrayInRange(props.selection, props.value)); - } - }, [props.value, props.selection]); - - return ( -
- setIsExpanded(!isExpanded)} - /> - [ - {isExpanded ? ( -
- {props.value.map((item, index) => { - return ( - - ); - })} -
- ) : !isComplex ? ( - - {props.value.map((item, index) => ( - - {index > 0 && ', '} - - - ))} - - ) : ( - - {props.value.length} {props.value.length > 1 ? 'elements' : 'element'} - - )} - ] -
- ); -} - -export function ElementObject( - props: GenericParams>, -): JSX.Element { - const [isExpanded, setIsExpanded] = useState(() => { - return isInRange(props.selection, props.value); - }); - const [isSelected, setIsSelected] = useState( - () => - isInRange(props.selection, props.value) && props.value.type !== 'Program', - ); - const listItem = useRef(null); - - const onMouseEnter = useCallback( - (e: SyntheticEvent) => { - if (isEsNode(props.value)) { - props.onSelectNode(props.value as TSESTree.Node); - e.stopPropagation(); - e.preventDefault(); - } - }, - [props.value], - ); - - const onMouseLeave = useCallback( - (_e: SyntheticEvent) => { - if (isEsNode(props.value)) { - props.onSelectNode(null); + }, [props.value, props.filterProps]); + + const onHover = useCallback( + (state: boolean) => { + if (props.onSelectNode) { + const range = props.getRange(props.value); + if (range) { + props.onSelectNode(state ? range : null); + } } }, [props.value], ); useEffect(() => { - const selected = isInRange(props.selection, props.value); + const selected = props.selection + ? props.isArray + ? isArrayInRange(props.selection, props.value, props.getRange) + : isInRange(props.selection, props.value, props.getRange) + : false; setIsSelected( - selected && - props.value.type !== 'Program' && - !hasChildInRange(props.selection, props.value), + props.level !== 'ast' && + selected && + !hasChildInRange(props.selection, model, props.getRange), ); if (selected && !isExpanded) { - setIsExpanded(isInRange(props.selection, props.value)); + setIsExpanded(selected); } - }, [props.selection, props.value]); - - useEffect(() => { - if (listItem.current && isSelected) { - scrollIntoViewIfNeeded(listItem.current); - } - }, [isSelected, listItem]); + }, [model, props.selection, props.value, props.isArray, props.getRange]); return ( -
setIsExpanded(!isExpanded)} > - setIsExpanded(!isExpanded)} - /> - {'{'} + {props.isArray ? '[' : '{'} {isExpanded ? (
- {filterRecord(props.value).map((item, index) => ( + {model.map((item, index) => ( ))}
) : ( - - {filterRecord(props.value) - .map(item => item[0]) - .join(', ')} - + + )} + {props.isArray ? ']' : '}'} + + ); +} + +export function SimpleItem(props: GenericParams): JSX.Element { + const [tooltip, setTooltip] = useState(); + + useEffect(() => { + setTooltip(props.getTooltip?.(props.propName ?? '', props.value)); + }, [props.getTooltip, props.propName, props.value]); + + return ( + + {tooltip ? ( + + + + ) : ( + )} - {'}'} -
+ ); } export function ElementItem(props: GenericParams): JSX.Element { - if (Array.isArray(props.value)) { + const isArray = Array.isArray(props.value); + if (isArray || isRecord(props.value)) { return ( - - ); - } else if ( - typeof props.value === 'object' && - props.value && - props.value.constructor === Object - ) { - return ( - } selection={props.selection} onSelectNode={props.onSelectNode} /> ); + } else { + return ; } - return ( -
- {props.name && {props.name}} - {props.name && : } - -
- ); } diff --git a/packages/website/src/components/ast/HiddenItem.tsx b/packages/website/src/components/ast/HiddenItem.tsx new file mode 100644 index 000000000000..26717b349567 --- /dev/null +++ b/packages/website/src/components/ast/HiddenItem.tsx @@ -0,0 +1,48 @@ +import React, { useEffect, useState } from 'react'; +import styles from './ASTViewer.module.css'; +import PropertyValue from './PropertyValue'; + +export interface HiddenItemProps { + readonly value: [string, unknown][]; + readonly level: string; + readonly isArray?: boolean; +} + +export default function HiddenItem(props: HiddenItemProps): JSX.Element { + const [isComplex, setIsComplex] = useState(true); + const [length, setLength] = useState(0); + + useEffect(() => { + if (props.isArray) { + const filtered = props.value.filter(item => !isNaN(Number(item[0]))); + setIsComplex( + !filtered.some(item => typeof item[1] !== 'object' || item[1] === null), + ); + setLength(filtered.length); + } + }, [props.value, props.isArray]); + + return ( + + {props.isArray && !isComplex ? ( + props.value.map((item, index) => ( + + {index > 0 && ', '} + + + )) + ) : props.isArray ? ( + <> + {length} {length === 1 ? 'element' : 'elements'} + + ) : ( + props.value.map((item, index) => ( + + {index > 0 && ', '} + {String(item[0])} + + )) + )} + + ); +} diff --git a/packages/website/src/components/ast/ItemGroup.tsx b/packages/website/src/components/ast/ItemGroup.tsx new file mode 100644 index 000000000000..a117c9c05f89 --- /dev/null +++ b/packages/website/src/components/ast/ItemGroup.tsx @@ -0,0 +1,52 @@ +import React, { MouseEvent, useEffect, useRef } from 'react'; +import { scrollIntoViewIfNeeded } from '@site/src/components/lib/scroll-into'; +import clsx from 'clsx'; + +import styles from './ASTViewer.module.css'; + +import PropertyNameComp from './PropertyName'; +import type { GetNodeNameFn } from './types'; + +const PropertyName = React.memo(PropertyNameComp); + +export interface ItemGroupProps { + readonly propName?: string; + readonly value: unknown; + readonly getNodeName: GetNodeNameFn; + readonly isSelected?: boolean; + readonly isExpanded?: boolean; + readonly canExpand?: boolean; + readonly onClick?: (e: MouseEvent) => void; + readonly onHover?: (e: boolean) => void; + readonly children: JSX.Element | false | (JSX.Element | false)[]; +} + +export default function ItemGroup(props: ItemGroupProps): JSX.Element { + const listItem = useRef(null); + + useEffect(() => { + if (listItem.current && props.isSelected) { + scrollIntoViewIfNeeded(listItem.current); + } + }, [props.isSelected, listItem]); + + return ( +
+ props.onHover?.(true)} + onMouseLeave={(): void => props.onHover?.(false)} + onClick={(props.canExpand && props.onClick) || undefined} + /> + {React.Children.map(props.children, child => child)} +
+ ); +} diff --git a/packages/website/src/components/ast/PropertyName.tsx b/packages/website/src/components/ast/PropertyName.tsx index f8768a6c4a2a..36c3a6341534 100644 --- a/packages/website/src/components/ast/PropertyName.tsx +++ b/packages/website/src/components/ast/PropertyName.tsx @@ -1,27 +1,54 @@ -import React, { SyntheticEvent } from 'react'; -import clsx from 'clsx'; +import React, { MouseEvent } from 'react'; import styles from './ASTViewer.module.css'; -export default function PropertyName(props: { - name?: string; - propName?: string; - onClick?: (e: SyntheticEvent) => void; - onMouseEnter?: (e: SyntheticEvent) => void; -}): JSX.Element { - return ( - // eslint-disable-next-line jsx-a11y/click-events-have-key-events,jsx-a11y/no-static-element-interactions - +export interface PropertyNameProps { + readonly typeName?: string; + readonly propName?: string; + readonly onClick?: (e: MouseEvent) => void; + readonly onMouseEnter?: (e: MouseEvent) => void; + readonly onMouseLeave?: (e: MouseEvent) => void; +} + +export default function PropertyName(props: PropertyNameProps): JSX.Element { + return props.onClick ? ( + <> {props.propName && ( - + // eslint-disable-next-line jsx-a11y/anchor-is-valid + {props.propName} - + + )} + {props.propName && : } + {props.typeName && ( + // eslint-disable-next-line jsx-a11y/anchor-is-valid + + {props.typeName} + + )} + {props.typeName && } + + ) : ( + <> + {props.propName && ( + {props.propName} )} {props.propName && : } - {props.name && ( - - {props.name} - + {props.typeName && ( + {props.typeName} )} - + {props.typeName && } + ); } diff --git a/packages/website/src/components/ast/PropertyValue.tsx b/packages/website/src/components/ast/PropertyValue.tsx index 48f3ba119a68..4d777b3a4bf4 100644 --- a/packages/website/src/components/ast/PropertyValue.tsx +++ b/packages/website/src/components/ast/PropertyValue.tsx @@ -1,7 +1,12 @@ import React from 'react'; import styles from './ASTViewer.module.css'; +import { objType } from './utils'; -export default function PropertyValue(props: { value: unknown }): JSX.Element { +export interface PropertyValueProps { + readonly value: unknown; +} + +function PropertyValue(props: PropertyValueProps): JSX.Element { if (typeof props.value === 'string') { return ( {JSON.stringify(props.value)} @@ -21,5 +26,7 @@ export default function PropertyValue(props: { value: unknown }): JSX.Element { ); } - return {String(props.value)}; + return {objType(props.value)}; } + +export default PropertyValue; diff --git a/packages/website/src/components/ast/selection.ts b/packages/website/src/components/ast/selection.ts deleted file mode 100644 index 9a882d2bf9af..000000000000 --- a/packages/website/src/components/ast/selection.ts +++ /dev/null @@ -1,79 +0,0 @@ -import type { TSESTree } from '@typescript-eslint/website-eslint'; -import type { Position } from './types'; - -export const propsToFilter = ['parent', 'comments', 'tokens', 'loc']; - -export function filterRecord( - values: TSESTree.Node | Record, -): [string, unknown][] { - return Object.entries(values).filter( - item => !propsToFilter.includes(item[0]), - ); -} - -export function isNode(node: unknown): node is TSESTree.Node { - return Boolean( - typeof node === 'object' && node && 'type' in node && 'loc' in node, - ); -} - -export function isWithinNode( - loc: Position, - start: Position, - end: Position, -): boolean { - const canStart = - start.line < loc.line || - (start.line === loc.line && start.column <= loc.column); - const canEnd = - end.line > loc.line || (end.line === loc.line && end.column >= loc.column); - return canStart && canEnd; -} - -export function isRecord(value: unknown): value is Record { - return Boolean( - typeof value === 'object' && value && value.constructor === Object, - ); -} - -export function isEsNode( - value: unknown, -): value is Record & TSESTree.BaseNode { - return isRecord(value) && 'type' in value && 'loc' in value; -} - -export function isInRange( - position: Position | null | undefined, - value: unknown, -): boolean { - return Boolean( - position && - isEsNode(value) && - isWithinNode(position, value.loc.start, value.loc.end), - ); -} - -export function isArrayInRange( - position: Position | null | undefined, - value: unknown, -): boolean { - return Boolean( - position && - Array.isArray(value) && - value.some(item => isInRange(position, item)), - ); -} - -export function hasChildInRange( - position: Position | null | undefined, - value: unknown, -): boolean { - return Boolean( - position && - isEsNode(value) && - filterRecord(value).some( - ([, item]) => - isInRange(position, item) || isArrayInRange(position, item), - ), - ); -} diff --git a/packages/website/src/components/ast/types.ts b/packages/website/src/components/ast/types.ts index 53a565599ab9..8b27865e3428 100644 --- a/packages/website/src/components/ast/types.ts +++ b/packages/website/src/components/ast/types.ts @@ -1,15 +1,37 @@ -import type { TSESTree } from '@typescript-eslint/website-eslint'; +import type { SelectedPosition, SelectedRange } from '../types'; +import { TSESTree } from '@typescript-eslint/website-eslint'; +import Monaco from 'monaco-editor'; -export interface Position { - line: number; - column: number; -} +export type GetNodeNameFn = (data: unknown) => string | undefined; +export type GetTooltipFn = (key: string, data: unknown) => string | undefined; +export type GetRangeFn = (data: unknown) => SelectedRange | undefined; +export type OnSelectNodeFn = (node: SelectedRange | null) => void; +export type FilterPropsFn = (item: [string, unknown]) => boolean; export interface GenericParams { readonly propName?: string; - readonly name?: string; readonly value: V; readonly level: string; - readonly selection?: Position | null; - readonly onSelectNode: (node: TSESTree.Node | null) => void; + readonly selection?: SelectedPosition | null; + readonly onSelectNode?: OnSelectNodeFn; + readonly getNodeName: GetNodeNameFn; + readonly getTooltip?: GetTooltipFn; + readonly filterProps: FilterPropsFn; + readonly getRange: GetRangeFn; + readonly isArray?: boolean; +} + +export interface ASTViewerBaseProps { + readonly value: Record | TSESTree.Node | string; + readonly position?: Monaco.Position | null; + readonly onSelectNode?: OnSelectNodeFn; } + +export interface ASTViewerProps extends ASTViewerBaseProps { + readonly getNodeName: GetNodeNameFn; + readonly getTooltip?: GetTooltipFn; + readonly getRange: GetRangeFn; + readonly filterProps: FilterPropsFn; +} + +export type { SelectedPosition, SelectedRange }; diff --git a/packages/website/src/components/ast/utils.ts b/packages/website/src/components/ast/utils.ts new file mode 100644 index 000000000000..a06436e9c356 --- /dev/null +++ b/packages/website/src/components/ast/utils.ts @@ -0,0 +1,71 @@ +import type { SelectedPosition, SelectedRange } from './types'; +import { GetRangeFn } from './types'; + +export function isWithinRange( + loc: SelectedPosition, + range: SelectedRange, +): boolean { + const canStart = + range.start.line < loc.line || + (range.start.line === loc.line && range.start.column < loc.column); + const canEnd = + range.end.line > loc.line || + (range.end.line === loc.line && range.end.column >= loc.column); + return canStart && canEnd; +} + +export function objType(obj: unknown): string { + const type = Object.prototype.toString.call(obj).slice(8, -1); + // @ts-expect-error: this is correct check + if (type === 'Object' && obj && typeof obj[Symbol.iterator] === 'function') { + return 'Iterable'; + } + + return type; +} + +export function isRecord(value: unknown): value is Record { + return objType(value) === 'Object'; +} + +export function isInRange( + position: SelectedPosition | null | undefined, + value: unknown, + getRange: GetRangeFn, +): boolean { + if (!position) { + return false; + } + const range = getRange(value); + if (!range) { + return false; + } + return isWithinRange(position, range); +} + +export function isArrayInRange( + position: SelectedPosition | null | undefined, + value: unknown, + getRange: GetRangeFn, +): boolean { + return Boolean( + position && + Array.isArray(value) && + value.some(item => isInRange(position, item, getRange)), + ); +} + +export function hasChildInRange( + position: SelectedPosition | null | undefined, + value: [string, unknown][], + getRange: GetRangeFn, +): boolean { + return Boolean( + position && + value.some( + ([, item]) => + isInRange(position, item, getRange) || + isArrayInRange(position, item, getRange), + ), + ); +} diff --git a/packages/website/src/components/editor/LoadedEditor.tsx b/packages/website/src/components/editor/LoadedEditor.tsx index 309da95ad6fb..5a2180ba0fdc 100644 --- a/packages/website/src/components/editor/LoadedEditor.tsx +++ b/packages/website/src/components/editor/LoadedEditor.tsx @@ -11,7 +11,6 @@ import { createProvideCodeActions } from './createProvideCodeActions'; export interface LoadedEditorProps extends CommonEditorProps { readonly main: typeof Monaco; - readonly onSelect: (position: Monaco.Position | null) => void; readonly sandboxInstance: SandboxInstance; readonly webLinter: WebLinter; } @@ -22,7 +21,8 @@ export const LoadedEditor: React.FC = ({ decoration, jsx, main, - onASTChange, + onEsASTChange, + onTsASTChange, onChange, onSelect, rules, @@ -63,7 +63,8 @@ export const LoadedEditor: React.FC = ({ ); } - onASTChange(fatalMessage ?? webLinter.getAst()); + onEsASTChange(fatalMessage ?? webLinter.getAst()); + onTsASTChange(fatalMessage ?? webLinter.getTsAst()); onSelect(sandboxInstance.editor.getPosition()); }, 500), [code, jsx, sandboxInstance, rules, sourceType, webLinter], @@ -149,10 +150,10 @@ export const LoadedEditor: React.FC = ({ ? [ { range: new sandboxInstance.monaco.Range( - decoration.loc.start.line, - decoration.loc.start.column + 1, - decoration.loc.end.line, - decoration.loc.end.column + 1, + decoration.start.line, + decoration.start.column + 1, + decoration.end.line, + decoration.end.column + 1, ), options: { inlineClassName: 'myLineDecoration', diff --git a/packages/website/src/components/editor/types.ts b/packages/website/src/components/editor/types.ts index 6760c57f13d8..56048321473f 100644 --- a/packages/website/src/components/editor/types.ts +++ b/packages/website/src/components/editor/types.ts @@ -1,11 +1,12 @@ import type Monaco from 'monaco-editor'; -import type { ConfigModel } from '../types'; +import type { ConfigModel, SelectedRange } from '../types'; import type { TSESTree } from '@typescript-eslint/website-eslint'; export interface CommonEditorProps extends ConfigModel { readonly darkTheme: boolean; - readonly decoration: TSESTree.Node | null; + readonly decoration: SelectedRange | null; readonly onChange: (value: string) => void; - readonly onASTChange: (value: string | TSESTree.Program) => void; + readonly onTsASTChange: (value: string | Record) => void; + readonly onEsASTChange: (value: string | TSESTree.Program) => void; readonly onSelect: (position: Monaco.Position | null) => void; } diff --git a/packages/website/src/components/hooks/useDebouncedToggle.ts b/packages/website/src/components/hooks/useDebouncedToggle.ts new file mode 100644 index 000000000000..73b655d28e79 --- /dev/null +++ b/packages/website/src/components/hooks/useDebouncedToggle.ts @@ -0,0 +1,26 @@ +import { useRef, useCallback, useState } from 'react'; + +export default function useDebouncedToggle( + value: T, + timeout = 1000, +): [T, (data: T) => void] { + const [state, setState] = useState(value); + const timeoutIdRef = useRef(); + + const update = useCallback( + (data: T) => { + setState(data); + const timeoutId = timeoutIdRef.current; + if (timeoutId) { + timeoutIdRef.current = undefined; + clearTimeout(timeoutId); + } + timeoutIdRef.current = setTimeout(() => { + setState(value); + }, timeout); + }, + [timeoutIdRef], + ); + + return [state, update]; +} diff --git a/packages/website/src/components/hooks/useHashState.ts b/packages/website/src/components/hooks/useHashState.ts index b1263ce04ccb..9c495facc591 100644 --- a/packages/website/src/components/hooks/useHashState.ts +++ b/packages/website/src/components/hooks/useHashState.ts @@ -15,6 +15,16 @@ function readQueryParam(value: string | null, fallback: string): string { : fallback; } +function readShowAST(value: string | null): 'ts' | 'es' | boolean { + switch (value) { + case 'es': + return 'es'; + case 'ts': + return 'ts'; + } + return Boolean(value); +} + const parseStateFromUrl = (hash: string): ConfigModel | undefined => { if (!hash) { return; @@ -25,7 +35,8 @@ const parseStateFromUrl = (hash: string): ConfigModel | undefined => { return { ts: (searchParams.get('ts') ?? process.env.TS_VERSION).trim(), jsx: searchParams.has('jsx'), - showAST: searchParams.has('showAST'), + showAST: + searchParams.has('showAST') && readShowAST(searchParams.get('showAST')), sourceType: searchParams.has('sourceType') && searchParams.get('sourceType') === 'script' @@ -81,14 +92,14 @@ function useHashState( initialState: ConfigModel, ): [ConfigModel, (cfg: Partial) => void] { const [hash, setHash] = useState(window.location.hash.slice(1)); - const [state, setState] = useState({ + const [state, setState] = useState(() => ({ ...initialState, ...parseStateFromUrl(window.location.hash.slice(1)), - }); - const [tmpState, setTmpState] = useState>({ + })); + const [tmpState, setTmpState] = useState>(() => ({ ...initialState, ...parseStateFromUrl(window.location.hash.slice(1)), - }); + })); useEffect(() => { const newHash = window.location.hash.slice(1); diff --git a/packages/website/src/components/inputs/Dropdown.tsx b/packages/website/src/components/inputs/Dropdown.tsx index 3a298eb5910f..929f4bc6bbbc 100644 --- a/packages/website/src/components/inputs/Dropdown.tsx +++ b/packages/website/src/components/inputs/Dropdown.tsx @@ -2,27 +2,45 @@ import React from 'react'; import styles from '../OptionsSelector.module.css'; import clsx from 'clsx'; -export interface DropdownProps { - readonly onChange: (value: string) => void; - readonly options: string[]; - readonly value: string | undefined; +export interface DropdownOption { + readonly value: T; + readonly label: string; +} + +export interface DropdownProps { + readonly onChange: (value: T) => void; + readonly options: readonly (DropdownOption | T)[]; + readonly value: T | undefined; readonly name: string; readonly className?: string; } -function Dropdown(props: DropdownProps): JSX.Element { +function Dropdown( + props: DropdownProps, +): JSX.Element { + const options: DropdownOption[] = props.options.map(option => + typeof option !== 'object' + ? { label: String(option), value: option } + : option, + ); + return ( diff --git a/packages/website/src/components/inputs/Tooltip.module.css b/packages/website/src/components/inputs/Tooltip.module.css index 95895ffc6092..2d5303e01105 100644 --- a/packages/website/src/components/inputs/Tooltip.module.css +++ b/packages/website/src/components/inputs/Tooltip.module.css @@ -1,34 +1,89 @@ +:root { + --tooltip-bg-color: var(--ifm-color-emphasis-200); + --tooltip-text-color: var(--ifm-color-emphasis-900); + --tooltip-arrow-size: 0.3125rem; +} + .tooltip { position: relative; display: inline-block; } -.tooltipText { - visibility: hidden; - background-color: var(--ifm-color-emphasis-200); - color: var(--ifm-color-emphasis-900); - text-align: center; +.tooltip.hover { + text-decoration: underline; + cursor: pointer; +} + +.tooltip:after { + background-color: var(--tooltip-bg-color); border-radius: 6px; + color: var(--tooltip-text-color); + content: attr(aria-label); padding: 0.2rem 1rem; - margin-right: 0.3rem; - position: absolute; - z-index: 1; - top: -5px; - right: 110%; + text-indent: 0; + text-shadow: none; + white-space: normal; + word-wrap: break-word; + z-index: 10; + min-width: 6.25rem; + max-width: 25rem; + visibility: hidden; } -.tooltipText::after { +.tooltip:before { content: ''; + z-index: 11; + border: var(--tooltip-arrow-size) solid transparent; + height: 0; + width: 0; + visibility: hidden; +} + +.tooltip:after, +.tooltip:before { + box-sizing: border-box; + opacity: 0; + pointer-events: none; position: absolute; - top: 50%; - left: 100%; - margin-top: -5px; - border-width: 5px; - border-style: solid; - border-color: transparent transparent transparent - var(--ifm-color-emphasis-200); + transition: opacity 120ms ease-out 120ms; } -.tooltip.tooltipActive .tooltipText { +.tooltip.hover:hover:before, +.tooltip.hover:hover:after, +.tooltip.visible:before, +.tooltip.visible:after { visibility: visible; + opacity: 100%; +} + +.tooltipLeft:after { + margin-right: calc(var(--tooltip-arrow-size) * 2); +} + +.tooltipLeft:before { + border-left-color: var(--tooltip-bg-color); +} + +.tooltipLeft:after, +.tooltipLeft:before { + right: calc(100% - var(--tooltip-arrow-size) / 2); + top: 50%; + transform-origin: left; + transform: translateY(-50%); +} + +.tooltipRight:after { + margin-left: calc(var(--tooltip-arrow-size) * 2); +} + +.tooltipRight:before { + border-right-color: var(--tooltip-bg-color); +} + +.tooltipRight:after, +.tooltipRight:before { + left: calc(100% - var(--tooltip-arrow-size) / 2); + top: 50%; + transform-origin: right; + transform: translateY(-50%); } diff --git a/packages/website/src/components/inputs/Tooltip.tsx b/packages/website/src/components/inputs/Tooltip.tsx index 5a0542ad6048..5756321e0719 100644 --- a/packages/website/src/components/inputs/Tooltip.tsx +++ b/packages/website/src/components/inputs/Tooltip.tsx @@ -1,28 +1,28 @@ -import React, { useEffect } from 'react'; +import React from 'react'; import styles from './Tooltip.module.css'; import clsx from 'clsx'; export interface TooltipProps { readonly children: JSX.Element | (JSX.Element | false)[]; readonly text: string; + readonly position?: 'left' | 'right'; readonly open?: boolean; - readonly close: (status: boolean) => void; + readonly hover?: boolean; } function Tooltip(props: TooltipProps): JSX.Element { - useEffect(() => { - if (props.open) { - setTimeout(() => { - props.close(false); - }, 1000); - } - }, [props.open]); - return ( -
+ {React.Children.map(props.children, child => child)} - {props.text} -
+ ); } diff --git a/packages/website/src/components/lib/shallowEqual.ts b/packages/website/src/components/lib/shallowEqual.ts index 2f34444c5874..f1e26ede82d5 100644 --- a/packages/website/src/components/lib/shallowEqual.ts +++ b/packages/website/src/components/lib/shallowEqual.ts @@ -1,8 +1,6 @@ -import type { ConfigModel } from '@site/src/components/types'; - export function shallowEqual( - object1: Record | ConfigModel | undefined, - object2: Record | ConfigModel | undefined, + object1: object | undefined | null, + object2: object | undefined | null, ): boolean { if (object1 === object2) { return true; diff --git a/packages/website/src/components/types.ts b/packages/website/src/components/types.ts index 4d5d032db0fc..7830baa224c5 100644 --- a/packages/website/src/components/types.ts +++ b/packages/website/src/components/types.ts @@ -42,5 +42,15 @@ export interface ConfigModel { tsConfig?: CompilerFlags; code: string; ts: string; - showAST?: boolean; + showAST?: boolean | 'ts' | 'es'; +} + +export interface SelectedPosition { + line: number; + column: number; +} + +export interface SelectedRange { + start: SelectedPosition; + end: SelectedPosition; }