diff --git a/knip.config.js b/knip.config.js index 4a8765e4fd..3e1f8984d8 100644 --- a/knip.config.js +++ b/knip.config.js @@ -24,6 +24,7 @@ export default { '@nvidia-elements/forms', '@nvidia-elements/lint', '@nvidia-elements/markdown', + '@nvidia-elements/media', '@nvidia-elements/styles', '@semantic-release/commit-analyzer', '@semantic-release/github', diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ee1808b554..efcaf83656 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1220,6 +1220,9 @@ importers: '@nvidia-elements/markdown': specifier: workspace:* version: link:../markdown + '@nvidia-elements/media': + specifier: workspace:* + version: link:../media '@nvidia-elements/monaco': specifier: workspace:* version: link:../monaco diff --git a/projects/internals/eslint/README.md b/projects/internals/eslint/README.md index 2f25b969ed..e862d2e214 100644 --- a/projects/internals/eslint/README.md +++ b/projects/internals/eslint/README.md @@ -74,6 +74,7 @@ Applied to `src/**/*.ts`, `src/**/*.tsx`, test files, and `*.examples.ts`. **Source hygiene** - **`no-dead-code`**. Flags commented-out imports, exports, declarations, control-flow, and test blocks. The project currently sets this to `warn` during cleanup. +- **`no-deep-class-inheritance`**. Limits class inheritance chains to two superclass hops by default, stopping at configured `allowedRoots` such as `HTMLElement` and `LitElement`. - **`require-spdx-header`**. Every source file must start with the two-line SPDX header (`SPDX-FileCopyrightText` copyright + `SPDX-License-Identifier: Apache-2.0`). The rule accepts any 4-digit year; auto-fix preserves an existing year and falls back to the current year only when inserting a header from scratch. ### Example rules (plugin `local-typescript`, files `**/*.examples.ts`) diff --git a/projects/internals/eslint/src/configs/lit.js b/projects/internals/eslint/src/configs/lit.js index 31eed92046..1a7f7a56e9 100644 --- a/projects/internals/eslint/src/configs/lit.js +++ b/projects/internals/eslint/src/configs/lit.js @@ -16,6 +16,8 @@ import requireElementDefinitions from '../local/require-element-definitions.js'; import requireTestCompleteness from '../local/require-test-completeness.js'; import requireComposedEvents from '../local/require-composed-events.js'; import noMissingBundleRegistration from '../local/no-missing-bundle-registration.js'; +import noHostManagedAriaAttributes from '../local/no-host-managed-aria-attributes.js'; +import noSingleConsumerInternalBase from '../local/no-single-consumer-internal-base.js'; const source = ['src/**/*.ts', 'src/**/*.tsx', 'src/**/*.d.ts']; const tests = [ @@ -64,7 +66,9 @@ export const litConfig = [ 'require-element-definitions': requireElementDefinitions, 'require-test-completeness': requireTestCompleteness, 'require-composed-events': requireComposedEvents, - 'no-missing-bundle-registration': noMissingBundleRegistration + 'no-missing-bundle-registration': noMissingBundleRegistration, + 'no-host-managed-aria-attributes': noHostManagedAriaAttributes, + 'no-single-consumer-internal-base': noSingleConsumerInternalBase } } }, @@ -134,6 +138,8 @@ export const litConfig = [ 'local/require-internal-host': ['error'], 'local/require-element-definitions': ['error'], 'local/require-composed-events': ['error'], + 'local/no-host-managed-aria-attributes': ['error'], + 'local/no-single-consumer-internal-base': ['error'], 'local/no-missing-bundle-registration': [ 'error', { diff --git a/projects/internals/eslint/src/configs/typescript.js b/projects/internals/eslint/src/configs/typescript.js index 8322ad10dc..e195b3e899 100644 --- a/projects/internals/eslint/src/configs/typescript.js +++ b/projects/internals/eslint/src/configs/typescript.js @@ -3,6 +3,7 @@ import tseslint from 'typescript-eslint'; import importPlugin from 'eslint-plugin-import'; import jsdoc from 'eslint-plugin-jsdoc'; import deadCode from '../local/dead-code.js'; +import noDeepClassInheritance from '../local/no-deep-class-inheritance.js'; import exampleMetadata from '../local/example-metadata.js'; import exampleNaming from '../local/example-naming.js'; import exampleTemplateSize from '../local/example-template-size.js'; @@ -48,6 +49,7 @@ const config = { 'local-typescript': { rules: { 'no-dead-code': deadCode, + 'no-deep-class-inheritance': noDeepClassInheritance, 'example-metadata': exampleMetadata, 'example-naming': exampleNaming, 'example-template-size': exampleTemplateSize, @@ -106,6 +108,10 @@ const config = { 'jsdoc/check-tag-names': 'error', 'jsdoc/informative-docs': 'error', 'local-typescript/no-dead-code': ['warn'], // todo, this should be migrated to the internal playground template config + 'local-typescript/no-deep-class-inheritance': [ + 'error', + { maxDepth: 2, allowedRoots: ['HTMLElement', 'LitElement', 'FormControlMixin', 'BaseButton'] } + ], 'local-typescript/require-listener-cleanup': 'error', 'local-typescript/require-observer-cleanup': 'error', 'local-typescript/require-timer-cleanup': 'error', diff --git a/projects/internals/eslint/src/local/no-deep-class-inheritance.js b/projects/internals/eslint/src/local/no-deep-class-inheritance.js new file mode 100644 index 0000000000..3a0d23a581 --- /dev/null +++ b/projects/internals/eslint/src/local/no-deep-class-inheritance.js @@ -0,0 +1,114 @@ +const DEFAULT_MAX_DEPTH = 2; +const DEFAULT_ALLOWED_ROOTS = ['HTMLElement', 'LitElement']; + +/** + * ESLint rule that limits class inheritance depth. The rule counts superclass + * hops until it reaches a configured allowed root class so local wrappers around + * platform/framework bases stay possible without allowing hierarchy creep. + * + * @type {import('eslint').Rule.RuleModule} + */ +export default { + meta: { + type: 'problem', + name: 'no-deep-class-inheritance', + docs: { + description: 'Disallows class inheritance chains deeper than the configured maximum.', + category: 'Best Practice', + recommended: true + }, + schema: [ + { + type: 'object', + additionalProperties: false, + properties: { + maxDepth: { + type: 'integer', + minimum: 1 + }, + allowedRoots: { + type: 'array', + items: { type: 'string' }, + uniqueItems: true + } + } + } + ], + messages: { + 'too-deep': + '`{{className}}` has inheritance depth {{depth}} (`{{chain}}`). Maximum allowed depth is {{maxDepth}}.' + } + }, + create(context) { + const options = context.options[0] ?? {}; + const maxDepth = options.maxDepth ?? DEFAULT_MAX_DEPTH; + const allowedRoots = new Set(options.allowedRoots ?? DEFAULT_ALLOWED_ROOTS); + + return { + ClassDeclaration(node) { + if (!node.superClass) { + return; + } + + const services = context.sourceCode.parserServices; + if (!services?.program || !services.esTreeNodeToTSNodeMap) { + return; + } + + const superClass = services.esTreeNodeToTSNodeMap.get(node.superClass); + const chain = getInheritanceChain(superClass, services.program.getTypeChecker(), allowedRoots); + + if (chain.length <= maxDepth) { + return; + } + + context.report({ + node, + messageId: 'too-deep', + data: { + className: node.id?.name ?? '', + depth: String(chain.length), + maxDepth: String(maxDepth), + chain: `${node.id?.name ?? ''} -> ${chain.join(' -> ')}` + } + }); + } + }; + } +}; + +function getInheritanceChain(superClass, checker, allowedRoots) { + const chain = []; + const visited = new Set(); + let expression = superClass; + + while (expression) { + const symbol = checker.getTypeAtLocation(expression).symbol; + const className = symbol?.getName() ?? expression.getText(); + + chain.push(className); + + if (allowedRoots.has(className)) { + break; + } + + const declaration = getClassDeclaration(symbol); + if (!declaration || visited.has(declaration)) { + break; + } + + visited.add(declaration); + expression = getExtendsExpression(declaration); + } + + return chain; +} + +function getClassDeclaration(symbol) { + return symbol?.declarations?.find(declaration => Array.isArray(declaration.members)) ?? null; +} + +function getExtendsExpression(classDeclaration) { + const heritageClause = classDeclaration.heritageClauses?.find(clause => clause.getText().startsWith('extends ')); + return heritageClause?.types?.[0]?.expression ?? null; +} diff --git a/projects/internals/eslint/src/local/no-deep-class-inheritance.test.js b/projects/internals/eslint/src/local/no-deep-class-inheritance.test.js new file mode 100644 index 0000000000..a38b03233f --- /dev/null +++ b/projects/internals/eslint/src/local/no-deep-class-inheritance.test.js @@ -0,0 +1,137 @@ +import { beforeEach, test } from 'node:test'; +import assert from 'node:assert/strict'; +import { RuleTester } from 'eslint'; +import tseslint from 'typescript-eslint'; +import noDeepClassInheritance from './no-deep-class-inheritance.js'; + +let tester; + +beforeEach(() => { + tester = new RuleTester({ + languageOptions: { + parser: tseslint.parser, + parserOptions: { + ecmaVersion: 'latest', + sourceType: 'module', + projectService: { allowDefaultProject: ['*.ts'] }, + tsconfigRootDir: import.meta.dirname + } + } + }); +}); + +test('defines rule metadata', () => { + assert.equal(noDeepClassInheritance.meta.type, 'problem'); + assert.equal(noDeepClassInheritance.meta.name, 'no-deep-class-inheritance'); + assert.ok(noDeepClassInheritance.meta.messages['too-deep']); +}); + +test('valid: allows direct and depth-2 class inheritance', () => { + tester.run('no-deep-class-inheritance', noDeepClassInheritance, { + valid: [ + { + filename: 'direct-lit.ts', + code: ` + declare class LitElement {} + + class Badge extends LitElement {} + ` + }, + { + filename: 'depth-two.ts', + code: ` + declare class LitElement {} + + class BaseButton extends LitElement {} + class SortButton extends BaseButton {} + ` + }, + { + filename: 'event-target-root.ts', + code: ` + class VideoGroup extends EventTarget {} + class ManagedVideoGroup extends VideoGroup {} + ` + } + ], + invalid: [] + }); +}); + +test('valid: supports custom maxDepth and allowedRoots options', () => { + tester.run('no-deep-class-inheritance', noDeepClassInheritance, { + valid: [ + { + filename: 'custom-max-depth.ts', + options: [{ maxDepth: 3 }], + code: ` + declare class LitElement {} + + class BaseButton extends LitElement {} + class SortButton extends BaseButton {} + class ToolbarSortButton extends SortButton {} + ` + }, + { + filename: 'custom-root.ts', + options: [{ allowedRoots: ['BaseElement'] }], + code: ` + class BaseElement {} + class Control extends BaseElement {} + class Color extends Control {} + ` + } + ], + invalid: [] + }); +}); + +test('invalid: reports classes deeper than maxDepth', () => { + tester.run('no-deep-class-inheritance', noDeepClassInheritance, { + valid: [], + invalid: [ + { + filename: 'too-deep.ts', + code: ` + declare class LitElement {} + + class BaseButton extends LitElement {} + class SortButton extends BaseButton {} + class ToolbarSortButton extends SortButton {} + `, + errors: [ + { + messageId: 'too-deep', + data: { + className: 'ToolbarSortButton', + depth: '3', + maxDepth: '2', + chain: 'ToolbarSortButton -> SortButton -> BaseButton -> LitElement' + } + } + ] + }, + { + filename: 'custom-max-depth-invalid.ts', + options: [{ maxDepth: 1, allowedRoots: ['BaseElement'] }], + code: ` + class BaseElement {} + + class Control extends BaseElement {} + class Radio extends Control {} + `, + errors: [ + { + messageId: 'too-deep', + data: { + className: 'Radio', + depth: '2', + maxDepth: '1', + chain: 'Radio -> Control -> BaseElement' + } + } + ] + } + ] + }); +}); diff --git a/projects/internals/eslint/src/local/no-host-managed-aria-attributes.js b/projects/internals/eslint/src/local/no-host-managed-aria-attributes.js new file mode 100644 index 0000000000..dbfed6e236 --- /dev/null +++ b/projects/internals/eslint/src/local/no-host-managed-aria-attributes.js @@ -0,0 +1,86 @@ +const MANAGED_ATTRIBUTES = { + 'aria-current': 'stateCurrent', + 'aria-disabled': 'stateDisabled', + 'aria-expanded': 'stateExpanded', + 'aria-label': 'ElementInternals.ariaLabel', + 'aria-pressed': 'statePressed', + 'aria-selected': 'stateSelected', + role: 'ElementInternals.role' +}; + +/** + * Prevent setting host ARIA state attributes that should be managed through + * ElementInternals and the shared state/type controllers. + * + * @type {import('eslint').Rule.RuleModule} + */ +export default { + meta: { + type: 'problem', + name: 'no-host-managed-aria-attributes', + docs: { + description: 'Prevent host ARIA state attributes that should be managed with ElementInternals.', + category: 'Best Practice', + recommended: true + }, + schema: [], + messages: { + 'managed-attribute': + 'Use {{controller}} instead of setting "{{attribute}}" on the custom element host with {{method}}().' + } + }, + create(context) { + return { + CallExpression(node) { + if (!isHostAttributeMutation(node)) { + return; + } + + const attribute = getStaticString(node.arguments[0])?.toLowerCase(); + const controller = MANAGED_ATTRIBUTES[attribute]; + + if (!controller) { + return; + } + + context.report({ + node, + messageId: 'managed-attribute', + data: { + attribute, + controller, + method: node.callee.property.name + } + }); + } + }; + } +}; + +function isHostAttributeMutation(node) { + if (node.callee.type !== 'MemberExpression' || node.callee.computed) { + return false; + } + + if (node.callee.object.type !== 'ThisExpression') { + return false; + } + + return ['setAttribute', 'removeAttribute', 'toggleAttribute'].includes(node.callee.property.name); +} + +function getStaticString(node) { + if (!node) { + return null; + } + + if (node.type === 'Literal' && typeof node.value === 'string') { + return node.value; + } + + if (node.type === 'TemplateLiteral' && node.expressions.length === 0) { + return node.quasis[0]?.value.cooked ?? null; + } + + return null; +} diff --git a/projects/internals/eslint/src/local/no-host-managed-aria-attributes.test.js b/projects/internals/eslint/src/local/no-host-managed-aria-attributes.test.js new file mode 100644 index 0000000000..6eba02b980 --- /dev/null +++ b/projects/internals/eslint/src/local/no-host-managed-aria-attributes.test.js @@ -0,0 +1,220 @@ +import { beforeEach, test } from 'node:test'; +import assert from 'node:assert/strict'; +import { RuleTester } from 'eslint'; +import tseslint from 'typescript-eslint'; +import noHostManagedAriaAttributes from './no-host-managed-aria-attributes.js'; + +let tester; + +beforeEach(() => { + tester = new RuleTester({ + languageOptions: { + parser: tseslint.parser, + parserOptions: { + ecmaVersion: 'latest', + sourceType: 'module' + } + } + }); +}); + +test('defines rule metadata', () => { + assert.equal(noHostManagedAriaAttributes.meta.type, 'problem'); + assert.equal(noHostManagedAriaAttributes.meta.name, 'no-host-managed-aria-attributes'); + assert.ok(noHostManagedAriaAttributes.meta.messages['managed-attribute']); +}); + +test('valid: keeps host relationships and non-host attribute mutations valid', () => { + tester.run('no-host-managed-aria-attributes', noHostManagedAriaAttributes, { + valid: [ + { + code: ` + class Foo { + connectedCallback() { + this.setAttribute('aria-labelledby', this.labelId); + } + } + ` + }, + { + code: ` + class Foo { + connectedCallback() { + this._internals.role = 'status'; + this._internals.ariaPressed = 'true'; + } + } + ` + }, + { + code: ` + class Foo { + connectedCallback() { + this.host.setAttribute('aria-pressed', 'true'); + panel.setAttribute('role', 'tabpanel'); + } + } + ` + }, + { + code: ` + class Foo { + connectedCallback() { + this.setAttribute(attribute, value); + } + } + ` + } + ], + invalid: [] + }); +}); + +test('invalid: flags host-managed ARIA attributes', () => { + tester.run('no-host-managed-aria-attributes', noHostManagedAriaAttributes, { + valid: [], + invalid: [ + { + code: ` + class Foo { + connectedCallback() { + this.setAttribute('aria-disabled', 'true'); + } + } + `, + errors: [ + { + messageId: 'managed-attribute', + data: { + attribute: 'aria-disabled', + controller: 'stateDisabled', + method: 'setAttribute' + } + } + ] + }, + { + code: ` + class Foo { + connectedCallback() { + this.setAttribute('aria-label', 'open'); + } + } + `, + errors: [ + { + messageId: 'managed-attribute', + data: { + attribute: 'aria-label', + controller: 'ElementInternals.ariaLabel', + method: 'setAttribute' + } + } + ] + }, + { + code: ` + class Foo { + updated() { + this.removeAttribute('aria-pressed'); + } + } + `, + errors: [ + { + messageId: 'managed-attribute', + data: { + attribute: 'aria-pressed', + controller: 'statePressed', + method: 'removeAttribute' + } + } + ] + }, + { + code: ` + class Foo { + updated() { + this.toggleAttribute('aria-expanded', this.open); + } + } + `, + errors: [ + { + messageId: 'managed-attribute', + data: { + attribute: 'aria-expanded', + controller: 'stateExpanded', + method: 'toggleAttribute' + } + } + ] + } + ] + }); +}); + +test('invalid: flags host role mutations with literal or static template names', () => { + tester.run('no-host-managed-aria-attributes', noHostManagedAriaAttributes, { + valid: [], + invalid: [ + { + code: ` + class Foo { + connectedCallback() { + this.setAttribute('role', 'button'); + } + } + `, + errors: [ + { + messageId: 'managed-attribute', + data: { + attribute: 'role', + controller: 'ElementInternals.role', + method: 'setAttribute' + } + } + ] + }, + { + code: ` + class Foo { + updated() { + this.setAttribute(\`aria-current\`, 'page'); + } + } + `, + errors: [ + { + messageId: 'managed-attribute', + data: { + attribute: 'aria-current', + controller: 'stateCurrent', + method: 'setAttribute' + } + } + ] + }, + { + code: ` + class Foo { + updated() { + this.setAttribute('aria-selected', 'true'); + } + } + `, + errors: [ + { + messageId: 'managed-attribute', + data: { + attribute: 'aria-selected', + controller: 'stateSelected', + method: 'setAttribute' + } + } + ] + } + ] + }); +}); diff --git a/projects/internals/eslint/src/local/no-single-consumer-internal-base.js b/projects/internals/eslint/src/local/no-single-consumer-internal-base.js new file mode 100644 index 0000000000..ec25ad59a0 --- /dev/null +++ b/projects/internals/eslint/src/local/no-single-consumer-internal-base.js @@ -0,0 +1,331 @@ +import { existsSync, readdirSync, readFileSync } from 'node:fs'; +import { dirname, join, relative, sep } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { walk } from './utils.js'; + +const DEFAULT_MINIMUM_CONSUMERS = 2; +const PACKAGE_CACHE = new Map(); + +/** + * Prevent internal base classes before the abstraction has enough consumers. + * + * @type {import('eslint').Rule.RuleModule} + */ +export default { + meta: { + type: 'problem', + name: 'no-single-consumer-internal-base', + docs: { + description: 'Prevent internal base classes that have fewer than two implementation consumers.', + category: 'Best Practice', + recommended: true + }, + schema: [ + { + type: 'object', + additionalProperties: false, + properties: { + minimumConsumers: { type: 'number', minimum: 2 }, + rootDir: { type: 'string' } + } + } + ], + messages: { + 'single-consumer': + 'Internal base "{{name}}" has {{count}} implementation consumer(s). Inline it into the consumer until at least {{minimum}} consumers need the abstraction.' + } + }, + create(context) { + const filename = normalizePath(context.physicalFilename ?? context.filename); + const options = context.options[0] ?? {}; + const rootDir = options.rootDir ? normalizePath(options.rootDir) : getPackageRoot(filename); + const minimumConsumers = options.minimumConsumers ?? DEFAULT_MINIMUM_CONSUMERS; + + if (!rootDir || !isInternalSourceFile(filename)) { + return {}; + } + + return { + ClassDeclaration(node) { + if (!isExportedBaseCandidate(node)) { + return; + } + + const consumerCount = countConsumers({ + className: node.id.name, + defaultExported: isDefaultExported(node), + filename, + rootDir, + sourceCode: context.sourceCode + }); + if (consumerCount >= minimumConsumers) { + return; + } + + context.report({ + node, + messageId: 'single-consumer', + data: { + name: node.id.name, + count: `${consumerCount}`, + minimum: `${minimumConsumers}` + } + }); + } + }; + } +}; + +function isExportedBaseCandidate(node) { + return Boolean( + node.id?.name && + isExported(node) && + (node.abstract === true || node.id.name.startsWith('Base') || node.id.name.endsWith('Base')) + ); +} + +function isExported(node) { + return node.parent?.type === 'ExportNamedDeclaration' || node.parent?.type === 'ExportDefaultDeclaration'; +} + +function isDefaultExported(node) { + return node.parent?.type === 'ExportDefaultDeclaration'; +} + +function countConsumers({ className, defaultExported, filename, rootDir, sourceCode }) { + let consumers = countLocalSubclasses(sourceCode.ast, className); + const packageName = getPackageName(rootDir); + const isBarrelExported = isExportedFromInternalBarrel(rootDir, filename, className); + + for (const file of getPackageFiles(rootDir)) { + if (file === filename) { + continue; + } + + const text = readFileSync(file, 'utf8'); + const specifier = getRelativeJsImport(file, filename); + const localNames = [ + ...getImportedClassNames(text, className, specifier, { defaultExported }), + ...(packageName && isBarrelExported + ? getImportedClassNames(text, className, `${packageName}/internal`, { defaultExported: false }) + : []) + ]; + + consumers += countSubclasses(text, localNames); + } + + return consumers; +} + +function countLocalSubclasses(ast, className) { + let count = 0; + walk(ast, node => { + if (node.type !== 'ClassDeclaration' || !node.superClass) { + return; + } + + if (isSuperClass(node.superClass, className)) { + count += 1; + } + }); + return count; +} + +function isSuperClass(superClass, className) { + if (superClass.type === 'Identifier') { + return superClass.name === className; + } + + if (superClass.type === 'TSInstantiationExpression') { + return isSuperClass(superClass.expression, className); + } + + return false; +} + +function getImportedClassNames(text, className, specifier, { defaultExported }) { + const importMap = getImportMap(text, specifier); + const classNames = new Set(importMap.get(className) ?? []); + + for (const namespace of importMap.get('*') ?? []) { + classNames.add(`${namespace}.${className}`); + } + + if (defaultExported) { + for (const localName of importMap.get('default') ?? []) { + classNames.add(localName); + } + } + + return [...classNames]; +} + +function getImportMap(text, specifier) { + const imports = new Map(); + const importPattern = new RegExp( + String.raw`import\s+(?:type\s+)?([^;]*?)\s+from\s+['"]${escapeRegExp(specifier)}['"]`, + 'g' + ); + + for (const match of text.matchAll(importPattern)) { + const importClause = match[1]?.trim() ?? ''; + + addDefaultImport(imports, importClause); + addNamespaceImport(imports, importClause); + addNamedImports(imports, importClause); + } + + return imports; +} + +function addDefaultImport(imports, importClause) { + if (!importClause || importClause.startsWith('{') || importClause.startsWith('*')) { + return; + } + + const localName = importClause.split(/[,{]/)[0]?.trim(); + if (isIdentifier(localName)) { + addImport(imports, 'default', localName); + } +} + +function addNamespaceImport(imports, importClause) { + const namespace = importClause.match(/\*\s+as\s+([A-Za-z_$][\w$]*)/)?.[1]; + if (namespace) { + addImport(imports, '*', namespace); + } +} + +function addNamedImports(imports, importClause) { + const namedImports = importClause.match(/{(?[\s\S]*?)}/)?.groups?.imports; + if (!namedImports) { + return; + } + + for (const specifier of namedImports.split(',')) { + const normalized = specifier.trim().replace(/^type\s+/, ''); + const match = normalized.match(/^([A-Za-z_$][\w$]*)(?:\s+as\s+([A-Za-z_$][\w$]*))?$/); + if (match) { + addImport(imports, match[1], match[2] ?? match[1]); + } + } +} + +function addImport(imports, exportedName, localName) { + if (!imports.has(exportedName)) { + imports.set(exportedName, new Set()); + } + imports.get(exportedName).add(localName); +} + +function countSubclasses(text, classNames) { + if (classNames.length === 0) { + return 0; + } + + const superClassNames = classNames.map(escapeRegExp).join('|'); + const subclassPattern = new RegExp( + String.raw`\bclass\s+[A-Za-z_$][\w$]*(?:\s*<[^>{}]*>)?\s+extends\s+(?:${superClassNames})\b`, + 'g' + ); + return [...text.matchAll(subclassPattern)].length; +} + +function isIdentifier(value) { + return typeof value === 'string' && /^[A-Za-z_$][\w$]*$/.test(value); +} + +function getRelativeJsImport(fromFile, toFile) { + const fromDirectory = fromFile.slice(0, fromFile.lastIndexOf('/')); + const withoutExtension = toFile.replace(/\.tsx?$/, '.js'); + let specifier = normalizePath(relative(fromDirectory, withoutExtension)); + + if (!specifier.startsWith('.')) { + specifier = `./${specifier}`; + } + + return specifier; +} + +function getPackageFiles(rootDir) { + const cacheKey = normalizePath(rootDir); + if (PACKAGE_CACHE.has(cacheKey)) { + return PACKAGE_CACHE.get(cacheKey); + } + + const srcDir = join(rootDir, 'src'); + const files = existsSync(srcDir) ? collectFiles(srcDir).filter(isImplementationTsFile).map(normalizePath) : []; + PACKAGE_CACHE.set(cacheKey, files); + return files; +} + +function getPackageName(rootDir) { + const packageFile = join(rootDir, 'package.json'); + if (!existsSync(packageFile)) { + return null; + } + + const pkg = JSON.parse(readFileSync(packageFile, 'utf8')); + return typeof pkg.name === 'string' ? pkg.name : null; +} + +function isExportedFromInternalBarrel(rootDir, filename, className) { + const indexFile = join(rootDir, 'src/internal/index.ts'); + if (!existsSync(indexFile)) { + return false; + } + + const specifier = getRelativeJsImport(indexFile, filename); + const text = readFileSync(indexFile, 'utf8'); + return exportsModule(text, className, specifier); +} + +function exportsModule(text, className, specifier) { + const starExportPattern = new RegExp(String.raw`export\s+\*\s+from\s+['"]${escapeRegExp(specifier)}['"]`, 'm'); + const namedExportPattern = new RegExp( + String.raw`export\s+{[^}]*\b${escapeRegExp(className)}\b[^}]*}\s+from\s+['"]${escapeRegExp(specifier)}['"]`, + 'm' + ); + return starExportPattern.test(text) || namedExportPattern.test(text); +} + +function collectFiles(dir) { + return readdirSync(dir, { withFileTypes: true }).flatMap(entry => { + const filepath = join(dir, entry.name); + return entry.isDirectory() ? collectFiles(filepath) : [filepath]; + }); +} + +function isImplementationTsFile(filename) { + return ( + (filename.endsWith('.ts') || filename.endsWith('.tsx')) && + !filename.endsWith('.d.ts') && + !filename.includes('.test.') && + !filename.includes('.examples.') + ); +} + +function isInternalSourceFile(filename) { + return isImplementationTsFile(filename) && filename.includes('/src/internal/'); +} + +function getPackageRoot(filename) { + let currentDirectory = dirname(filename); + while (currentDirectory && currentDirectory !== dirname(currentDirectory)) { + if (existsSync(join(currentDirectory, 'package.json')) && existsSync(join(currentDirectory, 'src'))) { + return normalizePath(currentDirectory); + } + currentDirectory = dirname(currentDirectory); + } + + return null; +} + +function normalizePath(filepath) { + const normalized = filepath.startsWith('file://') ? fileURLToPath(filepath) : filepath; + return normalized.replaceAll(sep, '/'); +} + +function escapeRegExp(value) { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} diff --git a/projects/internals/eslint/src/local/no-single-consumer-internal-base.test.js b/projects/internals/eslint/src/local/no-single-consumer-internal-base.test.js new file mode 100644 index 0000000000..ae60d9cae0 --- /dev/null +++ b/projects/internals/eslint/src/local/no-single-consumer-internal-base.test.js @@ -0,0 +1,280 @@ +import { afterEach, beforeEach, test } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, rmSync, mkdirSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { RuleTester } from 'eslint'; +import tseslint from 'typescript-eslint'; +import noSingleConsumerInternalBase from './no-single-consumer-internal-base.js'; + +let tester; +let rootDir; + +beforeEach(() => { + rootDir = mkdtempSync(join(tmpdir(), 'no-single-consumer-internal-base-')); + tester = new RuleTester({ + languageOptions: { + parser: tseslint.parser, + parserOptions: { + ecmaVersion: 'latest', + sourceType: 'module' + } + } + }); +}); + +afterEach(() => { + rmSync(rootDir, { recursive: true, force: true }); +}); + +test('defines rule metadata', () => { + assert.equal(noSingleConsumerInternalBase.meta.type, 'problem'); + assert.equal(noSingleConsumerInternalBase.meta.name, 'no-single-consumer-internal-base'); + assert.ok(noSingleConsumerInternalBase.meta.messages['single-consumer']); +}); + +test('valid: internal abstract base with two implementation consumers', () => { + const filename = createFile('src/internal/media-toggle-button.ts', 'export abstract class MediaToggleButton {}'); + createFile( + 'src/media-play-button/media-play-button.ts', + "import { MediaToggleButton } from '../internal/media-toggle-button.js';\nexport class MediaPlayButton extends MediaToggleButton {}" + ); + createFile( + 'src/media-mute-button/media-mute-button.ts', + "import { MediaToggleButton } from '../internal/media-toggle-button.js';\nexport class MediaMuteButton extends MediaToggleButton {}" + ); + + tester.run('no-single-consumer-internal-base', noSingleConsumerInternalBase, { + valid: [ + { + filename, + code: 'export abstract class MediaToggleButton {}', + options: [{ rootDir }] + } + ], + invalid: [] + }); +}); + +test('valid: counts named alias and namespace import subclasses', () => { + const filename = createFile('src/internal/media-button.ts', 'export abstract class MediaButton {}'); + createFile( + 'src/media-play-button/media-play-button.ts', + "import { MediaButton as InternalMediaButton } from '../internal/media-button.js';\nexport class MediaPlayButton extends InternalMediaButton {}" + ); + createFile( + 'src/media-mute-button/media-mute-button.ts', + "import * as media from '../internal/media-button.js';\nexport class MediaMuteButton extends media.MediaButton {}" + ); + + tester.run('no-single-consumer-internal-base', noSingleConsumerInternalBase, { + valid: [ + { + filename, + code: 'export abstract class MediaButton {}', + options: [{ rootDir }] + } + ], + invalid: [] + }); +}); + +test('valid: counts default import subclasses for default-exported bases', () => { + const filename = createFile('src/internal/media-button.ts', 'export default abstract class MediaButton {}'); + createFile( + 'src/media-play-button/media-play-button.ts', + "import InternalMediaButton from '../internal/media-button.js';\nexport class MediaPlayButton extends InternalMediaButton {}" + ); + createFile( + 'src/media-mute-button/media-mute-button.ts', + "import MediaBase from '../internal/media-button.js';\nexport class MediaMuteButton extends MediaBase {}" + ); + + tester.run('no-single-consumer-internal-base', noSingleConsumerInternalBase, { + valid: [ + { + filename, + code: 'export default abstract class MediaButton {}', + options: [{ rootDir }] + } + ], + invalid: [] + }); +}); + +test('valid: internal base consumed through package internal barrel', () => { + const filename = createFile('src/internal/base/button.ts', 'export class BaseButton {}'); + createFile('package.json', JSON.stringify({ name: '@nvidia-elements/core' })); + createFile('src/internal/index.ts', "export * from './base/button.js';"); + createFile( + 'src/button/button.ts', + "import { BaseButton } from '@nvidia-elements/core/internal';\nexport class Button extends BaseButton {}" + ); + createFile( + 'src/icon-button/icon-button.ts', + "import { BaseButton } from '@nvidia-elements/core/internal';\nexport class IconButton extends BaseButton {}" + ); + + tester.run('no-single-consumer-internal-base', noSingleConsumerInternalBase, { + valid: [ + { + filename, + code: 'export class BaseButton {}', + options: [{ rootDir }] + } + ], + invalid: [] + }); +}); + +test('valid: infers package root from filename', () => { + const filename = createFile('src/internal/base/button.ts', 'export class BaseButton {}'); + createFile('package.json', JSON.stringify({ name: '@nvidia-elements/core' })); + createFile('src/internal/index.ts', "export * from './base/button.js';"); + createFile( + 'src/button/button.ts', + "import { BaseButton } from '@nvidia-elements/core/internal';\nexport class Button extends BaseButton {}" + ); + createFile( + 'src/tag/tag.ts', + "import { BaseButton } from '@nvidia-elements/core/internal';\nexport class Tag extends BaseButton {}" + ); + + tester.run('no-single-consumer-internal-base', noSingleConsumerInternalBase, { + valid: [ + { + filename, + code: 'export class BaseButton {}' + } + ], + invalid: [] + }); +}); + +test('valid: ignores non-internal and non-base internal classes', () => { + const componentFilename = createFile('src/media-button/media-button.ts', 'export abstract class MediaButton {}'); + const helperFilename = createFile('src/internal/media-target-observer.ts', 'export class MediaTargetObserver {}'); + + tester.run('no-single-consumer-internal-base', noSingleConsumerInternalBase, { + valid: [ + { + filename: componentFilename, + code: 'export abstract class MediaButton {}', + options: [{ rootDir }] + }, + { + filename: helperFilename, + code: 'export class MediaTargetObserver {}', + options: [{ rootDir }] + } + ], + invalid: [] + }); +}); + +test('invalid: internal abstract base with one importing consumer', () => { + const filename = createFile('src/internal/media-button.ts', 'export abstract class MediaButton {}'); + createFile( + 'src/media-play-button/media-play-button.ts', + "import { MediaButton } from '../internal/media-button.js';\nexport class MediaPlayButton extends MediaButton {}" + ); + + tester.run('no-single-consumer-internal-base', noSingleConsumerInternalBase, { + valid: [], + invalid: [ + { + filename, + code: 'export abstract class MediaButton {}', + options: [{ rootDir }], + errors: [ + { + messageId: 'single-consumer', + data: { name: 'MediaButton', count: '1', minimum: '2' } + } + ] + } + ] + }); +}); + +test('invalid: ignores imports that do not subclass the base', () => { + const filename = createFile('src/internal/media-button.ts', 'export abstract class MediaButton {}'); + createFile( + 'src/internal/media-button.types.ts', + "import type { MediaButton } from './media-button.js';\nexport type MediaButtonLike = MediaButton;" + ); + createFile( + 'src/media-play-button/media-play-button.ts', + "import { MediaButton } from '../internal/media-button.js';\nexport class MediaPlayButton extends MediaButton {}" + ); + + tester.run('no-single-consumer-internal-base', noSingleConsumerInternalBase, { + valid: [], + invalid: [ + { + filename, + code: 'export abstract class MediaButton {}', + options: [{ rootDir }], + errors: [ + { + messageId: 'single-consumer', + data: { name: 'MediaButton', count: '1', minimum: '2' } + } + ] + } + ] + }); +}); + +test('invalid: internal abstract base with only a same-file subclass', () => { + const code = ` + export abstract class MediaButton {} + export class MediaPlayButton extends MediaButton {} + `; + const filename = createFile('src/internal/media-button.ts', code); + + tester.run('no-single-consumer-internal-base', noSingleConsumerInternalBase, { + valid: [], + invalid: [ + { + filename, + code, + options: [{ rootDir }], + errors: [ + { + messageId: 'single-consumer', + data: { name: 'MediaButton', count: '1', minimum: '2' } + } + ] + } + ] + }); +}); + +test('invalid: internal base name with no consumers', () => { + const filename = createFile('src/internal/base-overlay.ts', 'export class BaseOverlay {}'); + + tester.run('no-single-consumer-internal-base', noSingleConsumerInternalBase, { + valid: [], + invalid: [ + { + filename, + code: 'export class BaseOverlay {}', + options: [{ rootDir }], + errors: [ + { + messageId: 'single-consumer', + data: { name: 'BaseOverlay', count: '0', minimum: '2' } + } + ] + } + ] + }); +}); + +function createFile(relativePath, content) { + const filename = join(rootDir, relativePath); + mkdirSync(filename.slice(0, filename.lastIndexOf('/')), { recursive: true }); + writeFileSync(filename, content); + return filename; +} diff --git a/projects/site/eleventy.config.js b/projects/site/eleventy.config.js index d0f2282cfb..ad65bb7062 100644 --- a/projects/site/eleventy.config.js +++ b/projects/site/eleventy.config.js @@ -222,7 +222,7 @@ export default function (eleventyConfig) { * - Access the collection data in templates and layouts * - Sort and filter content based on frontmatter or other criteria * - * This collection includes all markdown files in src/docs/elements/, making component docs easily accessible throughout the site build process. + * This collection includes public component docs, making component metadata accessible throughout the site build process. * * Used by `../src/docs/elements/_tabs/api.11ty.js` to generate the API documentation page for each component. */ @@ -233,6 +233,7 @@ export default function (eleventyConfig) { 'src/docs/elements/data-grid/index.md', 'src/docs/code/*.md', 'src/docs/monaco/*.md', + 'src/docs/media/*.md', 'src/docs/markdown/index.md' ]); }); diff --git a/projects/site/package.json b/projects/site/package.json index 2a2b325ceb..d6a980b99d 100644 --- a/projects/site/package.json +++ b/projects/site/package.json @@ -88,6 +88,8 @@ "../forms/dist/**/*.examples.json", "../markdown/dist/**/*.js", "../markdown/dist/**/*.examples.json", + "../media/dist/**/*.js", + "../media/dist/**/*.examples.json", "../monaco/dist/**/*.js", "../monaco/dist/**/*.examples.json", "../internals/metadata/static/**", @@ -121,6 +123,10 @@ "script": "../markdown:build", "cascade": false }, + { + "script": "../media:build", + "cascade": false + }, { "script": "../monaco:build", "cascade": false @@ -244,6 +250,7 @@ "@nvidia-elements/forms": "workspace:*", "@nvidia-elements/lint": "workspace:*", "@nvidia-elements/markdown": "workspace:*", + "@nvidia-elements/media": "workspace:*", "@nvidia-elements/core": "workspace:*", "@nvidia-elements/monaco": "workspace:*", "@nvidia-elements/styles": "workspace:*", diff --git a/projects/site/src/_11ty/layouts/docs.11ty.js b/projects/site/src/_11ty/layouts/docs.11ty.js index fe2d38a00e..2af2646f9b 100644 --- a/projects/site/src/_11ty/layouts/docs.11ty.js +++ b/projects/site/src/_11ty/layouts/docs.11ty.js @@ -36,6 +36,7 @@ function getSection(url) { if (section === 'integrations') return 'integrations'; if (section === 'foundations') return 'foundations'; if (section === 'patterns') return 'patterns'; + if (section === 'media') return 'elements'; if (section === 'code' || section === 'monaco' || section === 'markdown') return 'code'; if (section === 'labs') return 'labs'; if (section === 'internal' || section === 'api-design') return 'internal'; diff --git a/projects/site/src/_11ty/templates/api.js b/projects/site/src/_11ty/templates/api.js index f506f4f30f..13b95f93f9 100644 --- a/projects/site/src/_11ty/templates/api.js +++ b/projects/site/src/_11ty/templates/api.js @@ -58,8 +58,7 @@ export function elementSummary(tag) { const element = elements.find(d => d.name === tag); const testReports = Object.values(tests.projects); const unitTestResults = testReports.flatMap(report => report.coverage.testResults); - const coverageTotal = - unitTestResults.find(result => result.file?.includes(tag.replace('nve-', '')))?.branches.pct ?? 0; + const coverageTotal = getElementCoverageTotal(tag, unitTestResults); const lighthouseResults = testReports .flatMap(report => report.lighthouse) .flatMap(result => result.testResults) @@ -93,6 +92,18 @@ export function elementSummary(tag) { `; } +function getElementCoverageTotal(tag, unitTestResults) { + const elementName = tag.replace('nve-', ''); + const implementationPath = `${elementName}/${elementName}.ts`; + const exactResult = unitTestResults.find(result => result.file === implementationPath); + const fileNameResult = unitTestResults.find(result => result.file?.endsWith(`/${elementName}.ts`)); + const broadResult = unitTestResults.find( + result => result.file?.includes(elementName) && !result.file.endsWith('/define.ts') + ); + + return (exactResult ?? fileNameResult ?? broadResult)?.branches.pct; +} + /** * Generates the component support buttons section for a given custom element tag. * @param {string} tag - The component tag name @@ -192,15 +203,16 @@ export function badgeStatus(status, container = '', content = '') { */ export function badgeCoverage(value, container = '', content = '') { let status = 'unknown'; - let formattedValue = value + const hasCoverage = Number.isFinite(value); + const formattedValue = hasCoverage ? new Intl.NumberFormat('default', { style: 'percent', minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(value / 100) - : null; + : 'unknown'; - if (value !== undefined) { + if (hasCoverage) { if (value >= 90) { status = 'success'; } else if (value >= 70) { diff --git a/projects/site/src/docs/elements/_tabs/examples.11ty.js b/projects/site/src/docs/elements/_tabs/examples.11ty.js index be1feee827..46aa74bf50 100644 --- a/projects/site/src/docs/elements/_tabs/examples.11ty.js +++ b/projects/site/src/docs/elements/_tabs/examples.11ty.js @@ -26,12 +26,13 @@ export const data = { eleventyComputed: { noindex: data => data.component.data.hideExamplesTab || !data.component.data.tag }, - // Generate URLs in the format /docs/elements/{component-name}/examples/ or /docs/code/{component-name}/examples/ or /docs/monaco/{component-name}/examples/ + // Generate URLs in the format /docs/elements/{component-name}/examples/ or package-specific component docs paths. permalink: data => { const filePath = data.component.filePathStem; let dir = 'elements'; if (filePath.includes('/code/')) dir = 'code'; else if (filePath.includes('/monaco/')) dir = 'monaco'; + else if (filePath.includes('/media/')) dir = 'media'; else if (filePath.includes('/markdown/')) dir = ''; return `/docs/${dir}/${data.component.fileSlug}/examples/`; } diff --git a/projects/starters/eleventy-ssr/src/index.11ty.js b/projects/starters/eleventy-ssr/src/index.11ty.js index fa4c7de8f0..b04a565921 100644 --- a/projects/starters/eleventy-ssr/src/index.11ty.js +++ b/projects/starters/eleventy-ssr/src/index.11ty.js @@ -2,6 +2,9 @@ import { ApiService, ExamplesService } from '@internals/metadata'; +const ssrPackageNames = ['@nvidia-elements/code', '@nvidia-elements/core', '@nvidia-elements/media']; +const hasSsrEntrypoint = entrypoint => ssrPackageNames.some(packageName => entrypoint?.startsWith(`${packageName}/`)); + const elements = (await ApiService.getData()).data.elements; const examples = (await ExamplesService.getData()) .filter( @@ -14,10 +17,11 @@ const examples = (await ExamplesService.getData()) ) .map(example => { const element = elements.find(e => e.name === example.element && !e.manifest?.deprecated); - return element + const entrypoint = element?.manifest?.metadata?.entrypoint; + return element && hasSsrEntrypoint(entrypoint) ? { name: example.element, - entrypoint: element?.manifest?.metadata?.entrypoint, + entrypoint, template: example.template .replaceAll('