From bab8b5f39ae3944d31897b432f0b014c6a687d67 Mon Sep 17 00:00:00 2001 From: Cory Rylan Date: Mon, 26 Oct 2026 20:28:14 -0500 Subject: [PATCH 1/2] feat(lint): new rules - no-excessive-popover-instances - no-invalid-date-format-options - no-invalid-date-value - no-invalid-number-format-options - no-invalid-numeric-value - no-invalid-parent-elements - no-multiple-pages Signed-off-by: Cory Rylan --- .../internals/patterns/src/agent.examples.ts | 2 +- projects/lint/README.md | 9 +- projects/lint/src/declarations.d.ts | 5 + projects/lint/src/eslint/configs/html.ts | 21 ++ .../lint/src/eslint/internals/format-input.ts | 63 +++++ .../src/eslint/internals/format-options.ts | 34 +++ .../src/eslint/internals/instance-context.ts | 189 +++++++++++++++ .../src/eslint/internals/parent-elements.ts | 24 ++ .../src/eslint/internals/slotted-elements.ts | 4 +- .../src/eslint/rule-documentation.test.ts | 2 +- .../no-excessive-popover-instances.test.ts | 193 +++++++++++++++ .../rules/no-excessive-popover-instances.ts | 70 ++++++ .../no-invalid-date-format-options.test.ts | 190 +++++++++++++++ .../rules/no-invalid-date-format-options.ts | 55 +++++ .../rules/no-invalid-date-value.test.ts | 153 ++++++++++++ .../src/eslint/rules/no-invalid-date-value.ts | 50 ++++ .../no-invalid-number-format-options.test.ts | 223 ++++++++++++++++++ .../rules/no-invalid-number-format-options.ts | 99 ++++++++ .../rules/no-invalid-numeric-value.test.ts | 128 ++++++++++ .../eslint/rules/no-invalid-numeric-value.ts | 53 +++++ .../rules/no-invalid-parent-elements.test.ts | 118 +++++++++ .../rules/no-invalid-parent-elements.ts | 66 ++++++ .../rules/no-invalid-slotted-elements.test.ts | 50 +++- .../rules/no-multiple-page-elements.test.ts | 185 +++++++++++++++ .../eslint/rules/no-multiple-page-elements.ts | 50 ++++ .../no-unexpected-attribute-value.test.ts | 29 +++ projects/site/eleventy.config.js | 3 +- projects/site/src/docs/api-design/logs.md | 48 +++- .../site/src/docs/foundations/layout/index.md | 12 +- projects/site/src/docs/lint/rules.test.ts | 4 +- 30 files changed, 2107 insertions(+), 25 deletions(-) create mode 100644 projects/lint/src/eslint/internals/format-input.ts create mode 100644 projects/lint/src/eslint/internals/format-options.ts create mode 100644 projects/lint/src/eslint/internals/instance-context.ts create mode 100644 projects/lint/src/eslint/internals/parent-elements.ts create mode 100644 projects/lint/src/eslint/rules/no-excessive-popover-instances.test.ts create mode 100644 projects/lint/src/eslint/rules/no-excessive-popover-instances.ts create mode 100644 projects/lint/src/eslint/rules/no-invalid-date-format-options.test.ts create mode 100644 projects/lint/src/eslint/rules/no-invalid-date-format-options.ts create mode 100644 projects/lint/src/eslint/rules/no-invalid-date-value.test.ts create mode 100644 projects/lint/src/eslint/rules/no-invalid-date-value.ts create mode 100644 projects/lint/src/eslint/rules/no-invalid-number-format-options.test.ts create mode 100644 projects/lint/src/eslint/rules/no-invalid-number-format-options.ts create mode 100644 projects/lint/src/eslint/rules/no-invalid-numeric-value.test.ts create mode 100644 projects/lint/src/eslint/rules/no-invalid-numeric-value.ts create mode 100644 projects/lint/src/eslint/rules/no-invalid-parent-elements.test.ts create mode 100644 projects/lint/src/eslint/rules/no-invalid-parent-elements.ts create mode 100644 projects/lint/src/eslint/rules/no-multiple-page-elements.test.ts create mode 100644 projects/lint/src/eslint/rules/no-multiple-page-elements.ts diff --git a/projects/internals/patterns/src/agent.examples.ts b/projects/internals/patterns/src/agent.examples.ts index c624a0c7d4..a7e553367b 100644 --- a/projects/internals/patterns/src/agent.examples.ts +++ b/projects/internals/patterns/src/agent.examples.ts @@ -460,7 +460,7 @@ export const Context = {
Reasoning
4000
Cache Hit Rate
-
95%
+
0.95

40000 / 128000 tokens used

diff --git a/projects/lint/README.md b/projects/lint/README.md index f9dce67803..7c7b8001ea 100644 --- a/projects/lint/README.md +++ b/projects/lint/README.md @@ -45,7 +45,7 @@ eslint -c ./eslint.config.js --color ## Severity -You can individually adjust rules for lint severity. By default rules use `error` severity. +You can individually adjust rules for lint severity. Most rules use `error` severity; performance guidance can use `warn`. ```javascript import { elementsHtmlConfig, elementsCssConfig } from '@nvidia-elements/lint/eslint'; @@ -80,9 +80,15 @@ export default [ | `@nvidia-elements/lint/no-deprecated-tags` | Disallow use of deprecated elements in HTML. | HTML | `error` | | `@nvidia-elements/lint/no-display-override` | Disallow display overrides on Elements components. | CSS | `error` | | `@nvidia-elements/lint/no-excessive-primary-actions` | Limit primary actions to two per page. | HTML | `error` | +| `@nvidia-elements/lint/no-excessive-popover-instances` | Warn about excessive or repeated popover instances. | HTML | `warn` | +| `@nvidia-elements/lint/no-invalid-date-format-options` | Require valid date and relative time formatting options. | HTML | `error` | +| `@nvidia-elements/lint/no-invalid-date-value` | Require valid date input for date formatting elements. | HTML | `error` | | `@nvidia-elements/lint/no-invalid-event-listeners` | Disallow inline event handler attributes in HTML. | HTML | `error` | | `@nvidia-elements/lint/no-invalid-invoker-triggers` | Disallow use of invoker trigger attributes on non-button nve-* elements. | HTML | `error` | +| `@nvidia-elements/lint/no-invalid-number-format-options` | Require valid number and byte formatting options. | HTML | `error` | +| `@nvidia-elements/lint/no-invalid-numeric-value` | Require finite numeric input for number and byte formatting elements. | HTML | `error` | | `@nvidia-elements/lint/no-invalid-page-header-slot` | Require direct nve-page-header children of nve-page to use the header slot. | HTML | `error` | +| `@nvidia-elements/lint/no-invalid-parent-elements` | Require supported direct parents for Elements components. | HTML | `error` | | `@nvidia-elements/lint/no-invalid-slotted-elements` | Disallow invalid direct slotted elements in Elements components. | HTML | `error` | | `@nvidia-elements/lint/no-missing-control-label` | Require form controls to have an accessible label. | HTML | `error` | | `@nvidia-elements/lint/no-missing-gap-space` | Require gap spacing on row, column, and grid layouts. | HTML | `error` | @@ -90,6 +96,7 @@ export default [ | `@nvidia-elements/lint/no-missing-popover-trigger` | Require popover elements to have a corresponding trigger element. | HTML | `error` | | `@nvidia-elements/lint/no-missing-slotted-elements` | Disallow use of missing slotted elements. | HTML | `error` | | `@nvidia-elements/lint/no-misprefixed-tags` | Disallow misprefixed Elements tags that resolve to a known nve-* element. | HTML | `error` | +| `@nvidia-elements/lint/no-multiple-page-elements` | Require one page shell per template. | HTML | `error` | | `@nvidia-elements/lint/no-nested-container-types` | Require nested container components to use flat container mode. | HTML | `error` | | `@nvidia-elements/lint/no-restricted-attributes` | Disallow use of invalid API attributes or utility attributes on custom HTML element tags. | HTML | `error` | | `@nvidia-elements/lint/no-restricted-container-full` | Restrict container="full" to direct children of nve-page. | HTML | `error` | diff --git a/projects/lint/src/declarations.d.ts b/projects/lint/src/declarations.d.ts index 6a613738b5..1bd5d7a6ed 100644 --- a/projects/lint/src/declarations.d.ts +++ b/projects/lint/src/declarations.d.ts @@ -5,6 +5,11 @@ declare module '@html-eslint/eslint-plugin/lib/rules/utils/node.js'; declare module '@html-eslint/eslint-plugin/lib/rules/utils/visitors.js'; +declare module '@internals/eslint' { + import type { Linter } from 'eslint'; + export const nodeTypescriptConfig: Linter.Config[]; +} + declare module '*.json?inline' { const content: { [key: string]: unknown }; export = content; diff --git a/projects/lint/src/eslint/configs/html.ts b/projects/lint/src/eslint/configs/html.ts index 90959fca4d..3d5bd848f8 100644 --- a/projects/lint/src/eslint/configs/html.ts +++ b/projects/lint/src/eslint/configs/html.ts @@ -17,6 +17,8 @@ import noRestrictedAttributes from '../rules/no-restricted-attributes.js'; import noSlottedPopovers from '../rules/no-slotted-popovers.js'; import noDeprecatedSlots from '../rules/no-deprecated-slots.js'; import noExcessivePrimaryActions from '../rules/no-excessive-primary-actions.js'; +import noExcessivePopoverInstances from '../rules/no-excessive-popover-instances.js'; +import noMultiplePageElements from '../rules/no-multiple-page-elements.js'; import noMissingSlottedElements from '../rules/no-missing-slotted-elements.js'; import noMissingControlLabel from '../rules/no-missing-control-label.js'; import noMissingIconName from '../rules/no-missing-icon-name.js'; @@ -27,8 +29,13 @@ import noUnknownTags from '../rules/no-unknown-tags.js'; import noUnexpectedAttributeValue from '../rules/no-unexpected-attribute-value.js'; import noUnexpectedInputType from '../rules/no-unexpected-input-type.js'; import noInvalidEventListeners from '../rules/no-invalid-event-listeners.js'; +import noInvalidDateValue from '../rules/no-invalid-date-value.js'; +import noInvalidDateFormatOptions from '../rules/no-invalid-date-format-options.js'; import noInvalidInvokerTriggers from '../rules/no-invalid-invoker-triggers.js'; +import noInvalidNumericValue from '../rules/no-invalid-numeric-value.js'; +import noInvalidNumberFormatOptions from '../rules/no-invalid-number-format-options.js'; import noInvalidPageHeaderSlot from '../rules/no-invalid-page-header-slot.js'; +import noInvalidParentElements from '../rules/no-invalid-parent-elements.js'; import noInvalidSlottedElements from '../rules/no-invalid-slotted-elements.js'; import noNestedInteractiveElements from '../rules/no-nested-interactive-elements.js'; import noMissingGapSpace from '../rules/no-missing-gap-space.js'; @@ -67,6 +74,8 @@ export const elementsHtmlConfig: Linter.Config = { 'no-deprecated-global-attributes': noDeprecatedGlobalAttributes, 'no-deprecated-slots': noDeprecatedSlots, 'no-excessive-primary-actions': noExcessivePrimaryActions, + 'no-excessive-popover-instances': noExcessivePopoverInstances, + 'no-multiple-page-elements': noMultiplePageElements, 'no-missing-slotted-elements': noMissingSlottedElements, 'no-missing-control-label': noMissingControlLabel, 'no-missing-icon-name': noMissingIconName, @@ -83,8 +92,13 @@ export const elementsHtmlConfig: Linter.Config = { 'no-unexpected-attribute-value': noUnexpectedAttributeValue, 'no-unexpected-input-type': noUnexpectedInputType, 'no-invalid-event-listeners': noInvalidEventListeners, + 'no-invalid-date-value': noInvalidDateValue, + 'no-invalid-date-format-options': noInvalidDateFormatOptions, 'no-invalid-invoker-triggers': noInvalidInvokerTriggers, + 'no-invalid-numeric-value': noInvalidNumericValue, + 'no-invalid-number-format-options': noInvalidNumberFormatOptions, 'no-invalid-page-header-slot': noInvalidPageHeaderSlot, + 'no-invalid-parent-elements': noInvalidParentElements, 'no-invalid-slotted-elements': noInvalidSlottedElements, 'no-nested-interactive-elements': noNestedInteractiveElements, 'no-missing-gap-space': noMissingGapSpace, @@ -107,6 +121,8 @@ export const elementsHtmlConfig: Linter.Config = { '@nvidia-elements/lint/no-deprecated-global-attributes': ['error'], '@nvidia-elements/lint/no-deprecated-slots': ['error'], '@nvidia-elements/lint/no-excessive-primary-actions': ['error'], + '@nvidia-elements/lint/no-excessive-popover-instances': ['warn'], + '@nvidia-elements/lint/no-multiple-page-elements': ['error'], '@nvidia-elements/lint/no-missing-slotted-elements': ['error'], '@nvidia-elements/lint/no-missing-control-label': ['error'], '@nvidia-elements/lint/no-missing-icon-name': ['error'], @@ -122,8 +138,13 @@ export const elementsHtmlConfig: Linter.Config = { '@nvidia-elements/lint/no-unexpected-attribute-value': ['error'], '@nvidia-elements/lint/no-unexpected-input-type': ['error'], '@nvidia-elements/lint/no-invalid-event-listeners': ['error'], + '@nvidia-elements/lint/no-invalid-date-value': ['error'], + '@nvidia-elements/lint/no-invalid-date-format-options': ['error'], '@nvidia-elements/lint/no-invalid-invoker-triggers': ['error'], + '@nvidia-elements/lint/no-invalid-numeric-value': ['error'], + '@nvidia-elements/lint/no-invalid-number-format-options': ['error'], '@nvidia-elements/lint/no-invalid-page-header-slot': ['error'], + '@nvidia-elements/lint/no-invalid-parent-elements': ['error'], '@nvidia-elements/lint/no-invalid-slotted-elements': ['error'], '@nvidia-elements/lint/no-nested-interactive-elements': ['error'], '@nvidia-elements/lint/no-unknown-css-variable': ['error'], diff --git a/projects/lint/src/eslint/internals/format-input.ts b/projects/lint/src/eslint/internals/format-input.ts new file mode 100644 index 0000000000..501b54f455 --- /dev/null +++ b/projects/lint/src/eslint/internals/format-input.ts @@ -0,0 +1,63 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { findAttr } from '@html-eslint/eslint-plugin/lib/rules/utils/node.js'; +import { VALUE_BINDINGS } from './attributes.js'; +import type { HtmlTagNode } from '../rule-types.js'; + +function hasDynamicInput(node: HtmlTagNode, attribute: string): boolean { + const property = attribute.replaceAll('-', ''); + const bindings = [attribute, property].flatMap(name => [ + `.${name}`, + `?${name}`, + `:${name}`, + `[${name}]`, + `[attr.${name}]`, + `v-bind:${name}` + ]); + return (node.attributes ?? []).some(attr => { + const name = attr.key?.value?.toLowerCase() ?? ''; + return ( + name === 'v-bind' || + name.startsWith('v-bind:[') || + name.startsWith(':[') || + (property !== attribute && name === property) || + bindings.includes(name) || + VALUE_BINDINGS.some(binding => name.includes(binding)) + ); + }); +} + +function getTextInput(node: HtmlTagNode): string | undefined { + const contentBindings = ['v-text', 'v-html', '.textcontent', '.innerhtml', '[textcontent]', '[innerhtml]']; + if (node.attributes?.some(attr => contentBindings.includes(attr.key?.value?.toLowerCase() ?? ''))) return; + if (node.children?.some(child => child.type !== 'Text' && child.type !== 'Comment')) return; + + return (node.children ?? []) + .filter(child => child.type === 'Text') + .map(child => child.value ?? '') + .join('') + .trim(); +} + +/** Returns a known attribute, including an absent value; skips dynamic and encoded attributes. */ +export function getLiteralFormatAttribute(node: HtmlTagNode, attributeName: string) { + if (hasDynamicInput(node, attributeName)) return; + + const attribute = findAttr(node, attributeName); + const value = attribute ? (attribute.value?.value ?? '') : undefined; + if (value !== undefined && (value.includes('&') || VALUE_BINDINGS.some(binding => value.includes(binding)))) return; + + return { node: attribute ?? node, value }; +} + +export function getLiteralFormatInput(node: HtmlTagNode, attributeName: string) { + const attribute = getLiteralFormatAttribute(node, attributeName); + if (!attribute) return; + const value = attribute.value ?? getTextInput(node); + + // The parser preserves character references instead of decoding their text. + if (value === undefined || value.includes('&') || VALUE_BINDINGS.some(binding => value.includes(binding))) return; + + return { node: attribute.node, value }; +} diff --git a/projects/lint/src/eslint/internals/format-options.ts b/projects/lint/src/eslint/internals/format-options.ts new file mode 100644 index 0000000000..96d4ed9b9a --- /dev/null +++ b/projects/lint/src/eslint/internals/format-options.ts @@ -0,0 +1,34 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { Rule } from 'eslint'; +import type { HtmlTagNode } from '../rule-types.js'; +import { getLiteralFormatAttribute } from './format-input.js'; + +export function reportFormatOption( + context: Rule.RuleContext, + node: HtmlTagNode, + problem: { attribute: string; reason: string } +) { + const { attribute, reason } = problem; + const option = getLiteralFormatAttribute(node, attribute); + if (!option) return; + context.report({ + node: option.node, + messageId: 'invalid-option', + data: { tagName: node.name, attribute, value: option.value, reason } + }); +} + +export function checkFormatLocale(context: Rule.RuleContext, node: HtmlTagNode) { + const locale = getLiteralFormatAttribute(node, 'locale'); + if (locale?.value === undefined) return; + try { + Intl.getCanonicalLocales(locale.value); + } catch (error) { + reportFormatOption(context, node, { + attribute: 'locale', + reason: error instanceof Error ? error.message : String(error) + }); + } +} diff --git a/projects/lint/src/eslint/internals/instance-context.ts b/projects/lint/src/eslint/internals/instance-context.ts new file mode 100644 index 0000000000..722ff97250 --- /dev/null +++ b/projects/lint/src/eslint/internals/instance-context.ts @@ -0,0 +1,189 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { Linter, type Rule, type SourceCode } from 'eslint'; +import type { HtmlTagNode } from '../rule-types.js'; + +const scriptSources = new WeakMap(); +const LOOP_ATTRIBUTES = new Set(['v-for', '*ngfor', 'ngfor', '[ngforof]']); +// Independent conditions may render together; only alternative branches split scopes. +const BRANCH_ATTRIBUTES = new Set(['v-else', 'v-else-if']); +const CONDITIONAL_ATTRIBUTES = new Set(['v-if', '*ngif', '[ngif]']); +const LOOP_STATEMENTS = new Set([ + 'ForStatement', + 'ForOfStatement', + 'ForInStatement', + 'WhileStatement', + 'DoWhileStatement' +]); +const JSX_SCOPES = new Set(['JSXElement', 'JSXFragment']); +const JSX_SCOPE_WRAPPERS = new Set(['JSXExpressionContainer', 'LogicalExpression']); +const HTML_DOCUMENTS = new Set(['Document']); + +interface InstanceContext { + scope: object | string | null; + repeated: boolean; + containingScope?: object | string | null; + branches?: ReadonlyMap; +} + +function getScriptSource(context: Rule.RuleContext): SourceCode | null { + const source = context.sourceCode; + if (!source.ast.body.some(node => HTML_DOCUMENTS.has(node.type))) return source; + if (scriptSources.has(source)) return scriptSources.get(source) ?? null; + + // Reuse ESLint's JavaScript parser when the HTML config receives raw script text. + // Typed syntax requires the consumer's TypeScript parser; opaque code stays unchecked. + const linter = new Linter(); + const messages = linter.verify(source.text, [ + { + languageOptions: { ecmaVersion: 'latest', sourceType: 'module', parserOptions: { ecmaFeatures: { jsx: true } } } + } + ]); + const parsed = messages.some(message => message.fatal) ? null : linter.getSourceCode(); + scriptSources.set(source, parsed); + return parsed; +} + +function isRepeatedScript(source: SourceCode, node: Parameters[0]): boolean { + return [...source.getAncestors(node), node].some(ancestor => { + if (LOOP_STATEMENTS.has(ancestor.type)) return true; + if (ancestor.type !== 'CallExpression') return false; + const callee = ancestor.callee; + if (callee.type === 'Identifier') return callee.name === 'repeat'; + return ( + callee.type === 'MemberExpression' && + callee.property.type === 'Identifier' && + ['map', 'flatMap', 'forEach'].includes(callee.property.name) + ); + }); +} + +function getJSXScope(source: SourceCode, node: Parameters[0]) { + let scope = node; + const ancestors = source.getAncestors(node).reverse(); + for (const ancestor of ancestors) { + // Logical inclusions share their containing JSX scope; ternary branches stay separate. + if (JSX_SCOPE_WRAPPERS.has(ancestor.type)) continue; + if (!JSX_SCOPES.has(ancestor.type)) break; + scope = ancestor; + } + return scope; +} + +function getScriptContext(source: SourceCode, node: HtmlTagNode): InstanceContext { + const original = source.getNodeByRangeIndex(node.openStart.range[0]); + if (!original) return { scope: null, repeated: false }; + const ancestors = [...source.getAncestors(original), original]; + const closest = ancestors + .reverse() + .find(ancestor => ancestor.type === 'TemplateLiteral' || JSX_SCOPES.has(ancestor.type)); + if (!closest) return { scope: null, repeated: false }; + const scope = JSX_SCOPES.has(closest.type) ? getJSXScope(source, closest) : closest; + return { scope, repeated: isRepeatedScript(source, scope) }; +} + +function isTemplateBoundary(node: HtmlTagNode): boolean { + if (node.type !== 'Tag') return false; + const attributes = (node.attributes ?? []).map(attribute => attribute.key?.value?.toLowerCase() ?? ''); + return ( + ['template', 'ng-template'].includes(node.name.toLowerCase()) && + !attributes.some(attribute => CONDITIONAL_ATTRIBUTES.has(attribute) || BRANCH_ATTRIBUTES.has(attribute)) + ); +} + +function getVueCondition(node: HtmlTagNode) { + return node.attributes + ?.map(attribute => attribute.key?.value?.toLowerCase() ?? '') + .find(attribute => attribute === 'v-if' || BRANCH_ATTRIBUTES.has(attribute)); +} + +function getPreviousBranch(node: HtmlTagNode) { + const siblings = node.parent?.children ?? []; + return siblings + .slice(0, Math.max(0, siblings.indexOf(node))) + .reverse() + .find(sibling => sibling.type !== 'Comment' && (sibling.type !== 'Text' || Boolean(sibling.value?.trim()))); +} + +function getBranchGroup(node: HtmlTagNode) { + let branch = node; + let condition = getVueCondition(branch); + if (!condition) return; + while (condition !== 'v-if') { + const previous = getPreviousBranch(branch); + if (!previous) return; + condition = getVueCondition(previous); + if (condition !== 'v-if' && condition !== 'v-else-if') return; + branch = previous; + } + return branch; +} + +function getBranchContext(node: HtmlTagNode) { + const branches = new Map(); + let containingScope = node; + for (let ancestor: HtmlTagNode | undefined = node; ancestor; ancestor = ancestor.parent) { + containingScope = ancestor; + const group = getBranchGroup(ancestor); + if (group) branches.set(group, ancestor); + if (isTemplateBoundary(ancestor)) break; + } + return { containingScope, branches }; +} + +function getMarkupContext(node: HtmlTagNode): InstanceContext { + let scope: object | string = node; + let boundary: HtmlTagNode | undefined; + let repeated = false; + for (let ancestor: HtmlTagNode | undefined = node; ancestor; ancestor = ancestor.parent) { + repeated ||= + ancestor.attributes?.some(attribute => LOOP_ATTRIBUTES.has(attribute.key?.value?.toLowerCase() ?? '')) ?? false; + if (!boundary && (isTemplateBoundary(ancestor) || BRANCH_ATTRIBUTES.has(getVueCondition(ancestor) ?? ''))) + boundary = ancestor; + scope = ancestor; + } + return { scope: boundary ?? scope, repeated }; +} + +function getMarkdownScope(context: Rule.RuleContext, node: HtmlTagNode, scope: InstanceContext['scope']) { + const position = node.openStart.range[0]; + if (context.sourceCode.text[position - 1] === '`') return null; + if (scope && typeof scope === 'object' && 'type' in scope && scope.type === 'Tag') return scope; + const fences = context.sourceCode.text.matchAll(/(?:^|\n)(`{3,}|~{3,})[^\n]*\n[\s\S]*?\n\1[ \t]*\r?(?=\n|$)/g); + for (const fence of fences) { + if (position >= fence.index && position < fence.index + fence[0].length) return `fence:${fence.index}`; + } + return scope; +} + +export function getInstanceContext(context: Rule.RuleContext, node: HtmlTagNode): InstanceContext { + const markup = getMarkupContext(node); + if ( + /\.[cm]?[jt]sx?$/i.test(context.filename) || + !context.sourceCode.ast.body.some(entry => HTML_DOCUMENTS.has(entry.type)) + ) { + const source = getScriptSource(context); + const script = source ? getScriptContext(source, node) : { scope: null, repeated: false }; + return { scope: script.scope, repeated: markup.repeated || script.repeated }; + } + const branches = getBranchContext(node); + if (context.filename.endsWith('.md')) + return { + ...markup, + ...branches, + scope: getMarkdownScope(context, node, markup.scope), + containingScope: getMarkdownScope(context, node, branches.containingScope) + }; + return { ...markup, ...branches }; +} + +export function canRenderTogether(first: InstanceContext, second: InstanceContext): boolean { + const scope = first.containingScope ?? first.scope; + if (!scope || scope !== (second.containingScope ?? second.scope)) return false; + for (const [group, branch] of first.branches ?? []) { + const other = second.branches?.get(group); + if (other && other !== branch) return false; + } + return true; +} diff --git a/projects/lint/src/eslint/internals/parent-elements.ts b/projects/lint/src/eslint/internals/parent-elements.ts new file mode 100644 index 0000000000..540e62b69f --- /dev/null +++ b/projects/lint/src/eslint/internals/parent-elements.ts @@ -0,0 +1,24 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +/** Direct parent contracts declared by component metadata, including layout-only contracts. */ +export const PARENT_ELEMENT_CONTRACTS: Readonly> = { + 'nve-card-content': ['nve-card'], + 'nve-card-footer': ['nve-card'], + 'nve-card-header': ['nve-card'], + 'nve-dialog-footer': ['nve-dialog'], + 'nve-dialog-header': ['nve-dialog'], + 'nve-drawer-content': ['nve-drawer'], + 'nve-drawer-footer': ['nve-drawer'], + 'nve-drawer-header': ['nve-drawer'], + 'nve-dropdown-footer': ['nve-dropdown'], + 'nve-dropdown-header': ['nve-dropdown'], + 'nve-menu-item': ['nve-menu'], + 'nve-page-panel': ['nve-page'], + 'nve-steps-item': ['nve-steps'], + 'nve-tabs-item': ['nve-tabs', 'nve-tabs-item-group'], + 'nve-tabs-item-group': ['nve-tabs'], + 'nve-toggletip-footer': ['nve-toggletip'], + 'nve-toggletip-header': ['nve-toggletip'], + 'nve-tree-node': ['nve-tree', 'nve-tree-node'] +}; diff --git a/projects/lint/src/eslint/internals/slotted-elements.ts b/projects/lint/src/eslint/internals/slotted-elements.ts index 37cbb3e08b..2b83347f09 100644 --- a/projects/lint/src/eslint/internals/slotted-elements.ts +++ b/projects/lint/src/eslint/internals/slotted-elements.ts @@ -2,8 +2,8 @@ // SPDX-License-Identifier: Apache-2.0 /** - * Allowed direct element children declared by production component - * `metadata.children` contracts. + * Allowed direct default-slot children for static validation, + * including supported media controls in button groups. */ export const SLOTTED_ELEMENT_CONTRACTS = { 'nve-accordion': ['nve-accordion-header', 'nve-accordion-content', 'nve-icon-button'], diff --git a/projects/lint/src/eslint/rule-documentation.test.ts b/projects/lint/src/eslint/rule-documentation.test.ts index c4e9f5f8f7..7fde5823ba 100644 --- a/projects/lint/src/eslint/rule-documentation.test.ts +++ b/projects/lint/src/eslint/rule-documentation.test.ts @@ -66,7 +66,7 @@ describe('rule documentation', () => { it('should document every registered rule', () => { const rules = getDocumentedRules(); - expect(rules).toHaveLength(43); + expect(rules).toHaveLength(50); for (const { name, rule } of rules) { expect(rule.meta.docs).toMatchObject({ name, diff --git a/projects/lint/src/eslint/rules/no-excessive-popover-instances.test.ts b/projects/lint/src/eslint/rules/no-excessive-popover-instances.test.ts new file mode 100644 index 0000000000..8060b8d78b --- /dev/null +++ b/projects/lint/src/eslint/rules/no-excessive-popover-instances.test.ts @@ -0,0 +1,193 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { assert, beforeEach, describe, expect, it } from 'vitest'; +import { Linter, RuleTester, type JSRuleDefinition } from 'eslint'; +import htmlParser from '@html-eslint/parser'; +import { nodeTypescriptConfig } from '@internals/eslint'; +import { elementsHtmlConfig } from '../configs/html.js'; +import noExcessivePopoverInstances from './no-excessive-popover-instances.js'; + +const rule = noExcessivePopoverInstances as unknown as JSRuleDefinition; +const popovers = ['nve-tooltip', 'nve-toggletip', 'nve-dropdown', 'nve-dialog', 'nve-toast']; +const copies = (tagName: string, count: number) => `<${tagName}>`.repeat(count); + +describe('noExcessivePopoverInstances', () => { + let tester: RuleTester; + + beforeEach(() => { + tester = new RuleTester({ languageOptions: { parser: htmlParser } }); + }); + + it('should register as a performance warning', () => { + expect(elementsHtmlConfig.plugins?.['@nvidia-elements/lint']?.rules?.['no-excessive-popover-instances']).toBe( + noExcessivePopoverInstances + ); + expect(elementsHtmlConfig.rules?.['@nvidia-elements/lint/no-excessive-popover-instances']).toEqual(['warn']); + }); + + it.each(popovers)('should warn once when a template exceeds 50 %s instances', tagName => { + tester.run('literal instance budget', rule, { + valid: [copies(tagName, 50)], + invalid: [ + { + code: copies(tagName, 100), + errors: [{ messageId: 'excessive-popovers', data: { element: tagName, count: '100', max: '50' } }] + } + ] + }); + }); + + it('should count each popover type separately and allow small static grids', () => { + tester.run('separate budgets', rule, { + valid: [ + popovers.map(tag => copies(tag, 50)).join(''), + 'Help', + '' + ], + invalid: [] + }); + }); + + it('should isolate independent templates and documentation examples', () => { + tester.run('render scopes', rule, { + valid: [ + ``, + `
${copies('nve-tooltip', 30)}
${copies('nve-tooltip', 30)}
`, + { + filename: 'src/example.js', + code: `const a = html\`${copies('nve-tooltip', 30)}\`; const b = html\`${copies('nve-tooltip', 30)}\`;` + }, + { + filename: 'src/example.tsx', + code: `const a =
${copies('nve-tooltip', 30)}
; const b =
${copies('nve-tooltip', 30)}
;` + }, + { + filename: 'src/example.md', + code: `\`\`\`html\n${copies('nve-tooltip', 30)}\n\`\`\`\n\n\`\`\`html\n${copies('nve-tooltip', 30)}\n\`\`\`` + } + ], + invalid: [ + { + filename: 'src/example.ts', + code: `const view = html\`${copies('nve-tooltip', 51)}\`;`, + errors: [{ messageId: 'excessive-popovers' }] + } + ] + }); + }); + + it('should aggregate coexisting conditional instances', () => { + const half = copies('nve-tooltip', 30); + tester.run('coexisting conditional budgets', rule, { + valid: [ + { + filename: 'src/example.tsx', + code: `const view =
{condition ?
${half}
:
${half}
}
;` + } + ], + invalid: [ + { code: `
${half}
${half}
` }, + { code: `${half}${half}` }, + { code: `
${half}
${half}
` }, + { + filename: 'src/example.tsx', + code: `const view =
${half}{condition &&
${half}
}
;` + } + ].map(test => ({ + ...test, + errors: [{ messageId: 'excessive-popovers', data: { element: 'nve-tooltip', count: '60', max: '50' } }] + })) + }); + }); + + it.each(['v-for="row in rows"', '*ngFor="let row of rows"', 'ngFor [ngForOf]="rows"'])( + 'should detect repeated markup with %s', + attribute => { + tester.run('framework repetition', rule, { + valid: [``], + invalid: [ + { + code: `
Help
`, + errors: [{ messageId: 'repeated-popover' }] + } + ] + }); + } + ); + + it.each([ + { + filename: 'src/example.ts', + code: 'const render = rows => rows.map(row => html`${row.title}`);' + }, + { + filename: 'src/example.js', + code: 'const render = rows => rows.flatMap(row => html`${row.title}`);' + }, + { + filename: 'src/example.js', + code: 'const render = rows => repeat(rows, row => row.id, row => html`${row.title}`);' + }, + { + filename: 'src/example.js', + code: 'for (const row of rows) { views.push(html`${row.title}`); }' + }, + { + filename: 'src/example.tsx', + code: 'const render = rows => rows.map(row =>
{row.title}
);' + } + ])('should detect repeated script templates in $filename', ({ filename, code }) => { + tester.run('script repetition', rule, { + valid: [], + invalid: [{ filename, code, errors: [{ messageId: 'repeated-popover' }] }] + }); + }); + + it('should use a consumer TypeScript parser for typed callback templates', () => { + const parser = nodeTypescriptConfig.find(config => config.languageOptions?.parser)?.languageOptions?.parser; + assert.exists(parser); + const typedTester = new RuleTester({ languageOptions: { parser } }); + typedTester.run('typed repetition', rule, { + valid: ['const view = html`${rows.map((row: Row) => row.title)}`;'], + invalid: [ + { + code: 'const views = rows.map((row: Row) => html`${row.title}`);', + errors: [{ messageId: 'repeated-popover' }] + } + ] + }); + }); + + it('should leave opaque syntax and externally declared renderers to runtime counting', () => { + tester.run('unknown cardinality', rule, { + valid: [ + { + filename: 'src/example.ts', + code: 'type Row = { title: string }; const views = rows.map((row: Row) => html`${row.title}`);' + }, + { + filename: 'src/example.js', + code: 'const renderRow = row => html`${row.title}`; const views = rows.map(renderRow);' + }, + { filename: 'src/example.md', code: 'Use `` for details.' } + ], + invalid: [] + }); + }); + + it('should expose the companion diagnostic at warning severity in the recommended config', () => { + const messages = new Linter().verify( + 'const views = rows.map(row => html`Details`);', + [elementsHtmlConfig], + { filename: 'src/example.ts' } + ); + expect(messages).toEqual([ + expect.objectContaining({ + ruleId: '@nvidia-elements/lint/no-excessive-popover-instances', + messageId: 'repeated-popover', + severity: 1 + }) + ]); + }); +}); diff --git a/projects/lint/src/eslint/rules/no-excessive-popover-instances.ts b/projects/lint/src/eslint/rules/no-excessive-popover-instances.ts new file mode 100644 index 0000000000..6572f7220f --- /dev/null +++ b/projects/lint/src/eslint/rules/no-excessive-popover-instances.ts @@ -0,0 +1,70 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { Rule } from 'eslint'; +import { createVisitors } from '@html-eslint/eslint-plugin/lib/rules/utils/visitors.js'; +import { getInstanceContext } from '../internals/instance-context.js'; +import { defineRuleDocumentation } from '../rule-documentation.js'; +import type { HtmlTagNode } from '../rule-types.js'; + +const MAX_INSTANCES = 50; +const POPOVERS = new Set(['nve-tooltip', 'nve-toggletip', 'nve-dropdown', 'nve-dialog', 'nve-toast']); + +const rule = { + meta: { + type: 'suggestion' as const, + docs: defineRuleDocumentation({ + name: 'no-excessive-popover-instances', + description: 'Warn about excessive or repeated popover instances.', + category: 'Best Practice', + recommended: true, + examples: { + language: 'html', + valid: 'Details', + invalid: '
Details
' + } + }), + schema: [], + messages: { + ['repeated-popover']: + '<{{element}}> is created in repeated content. Consider sharing one popover across triggers to reduce instance overhead.', + ['excessive-popovers']: + 'This template declares {{count}} <{{element}}> instances (advisory limit {{max}}). Consider sharing or creating popovers on demand.' + } + }, + create(context: Rule.RuleContext) { + const scopes = new Map>(); + return createVisitors(context, { + Tag(node: HtmlTagNode) { + const tagName = node.name.toLowerCase(); + if (!POPOVERS.has(tagName)) return; + const { scope, repeated } = getInstanceContext(context, node); + if (repeated) { + context.report({ node, messageId: 'repeated-popover', data: { element: tagName } }); + return; + } + if (!scope) return; + const counts = scopes.get(scope) ?? new Map(); + const nodes = counts.get(tagName) ?? []; + nodes.push(node); + counts.set(tagName, nodes); + scopes.set(scope, counts); + }, + 'Program:exit'() { + for (const counts of scopes.values()) { + for (const [tagName, nodes] of counts) { + const node = nodes[MAX_INSTANCES]; + if (!node) continue; + context.report({ + node, + messageId: 'excessive-popovers', + data: { element: tagName, count: String(nodes.length), max: String(MAX_INSTANCES) } + }); + } + } + } + }); + } +} as const; + +export default rule; diff --git a/projects/lint/src/eslint/rules/no-invalid-date-format-options.test.ts b/projects/lint/src/eslint/rules/no-invalid-date-format-options.test.ts new file mode 100644 index 0000000000..eaec894b67 --- /dev/null +++ b/projects/lint/src/eslint/rules/no-invalid-date-format-options.test.ts @@ -0,0 +1,190 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { beforeEach, describe, expect, it } from 'vitest'; +import { Linter, RuleTester, type JSRuleDefinition } from 'eslint'; +import htmlParser from '@html-eslint/parser'; +import { elementsHtmlConfig } from '../configs/html.js'; +import noInvalidDateFormatOptions from './no-invalid-date-format-options.js'; + +const rule = noInvalidDateFormatOptions as unknown as JSRuleDefinition; +const formatters = ['nve-format-datetime', 'nve-format-relative-time']; + +describe('noInvalidDateFormatOptions', () => { + let tester: RuleTester; + + beforeEach(() => { + tester = new RuleTester({ languageOptions: { parser: htmlParser } }); + }); + + it('should register as a recommended error', () => { + expect(elementsHtmlConfig.plugins?.['@nvidia-elements/lint']?.rules?.['no-invalid-date-format-options']).toBe( + noInvalidDateFormatOptions + ); + expect(elementsHtmlConfig.rules?.['@nvidia-elements/lint/no-invalid-date-format-options']).toEqual(['error']); + }); + + it.each(formatters)('should allow valid and unsupported well formed locales on %s', tag => { + tester.run('valid locales', rule, { + valid: [undefined, 'en-US', 'de-DE', 'en-u-ca-gregory', 'zz-ZZ'].map( + locale => `<${tag}${locale === undefined ? '' : ` locale="${locale}"`}>2026-01-01` + ), + invalid: [] + }); + }); + + it.each(formatters)('should reject malformed locales on %s', tag => { + tester.run('malformed locales', rule, { + valid: [], + invalid: ['', 'en_US', 'not_a_locale', 'en-US,de-DE'].map(value => ({ + code: `<${tag} locale="${value}">2026-01-01`, + errors: [{ messageId: 'invalid-option' }] + })) + }); + }); + + it.each(formatters)('should skip unknown locale values on %s', tag => { + tester.run('unknown locales', rule, { + valid: [ + 'locale="${locale}"', + 'locale="{{ locale }}"', + 'locale="{locale}"', + 'locale="{% locale %}"', + 'locale="en-US"', + 'locale="bad_locale" .locale=${locale}', + 'locale="bad_locale" :locale="locale"', + 'locale="bad_locale" [locale]="locale"', + 'locale="bad_locale" [attr.locale]="locale"', + 'locale="bad_locale" v-bind:locale="locale"', + 'locale="bad_locale" v-bind="options"', + 'locale="bad_locale" ${attributes}', + 'locale="bad_locale" {...props}' + ].map(attributes => `<${tag} ${attributes}>2026-01-01`), + invalid: [] + }); + }); + + it('should allow named zones, aliases, offsets, and omitted or empty zones', () => { + tester.run('valid time zones', rule, { + valid: [undefined, '', 'UTC', 'utc', 'America/New_York', 'US/Eastern', '+01:00', '+0100', '+01', '-23:59'].map( + zone => + `2026-01-01` + ), + invalid: [] + }); + }); + + it('should reject invalid literal zones and offsets', () => { + tester.run('invalid time zones', rule, { + valid: [], + invalid: ['Invalid/Zone', 'Mars/Olympus', '+24:00', '-24', '+01:60', '+01:00:30', ' UTC '].map(value => ({ + code: `2026-01-01`, + errors: [{ messageId: 'invalid-option' }] + })) + }); + }); + + it('should skip dynamic and encoded time zones', () => { + tester.run('unknown time zones', rule, { + valid: [ + 'time-zone="${zone}"', + 'time-zone="{{ zone }}"', + 'time-zone="UTC"', + 'time-zone="Invalid/Zone" .timeZone=${zone}', + 'time-zone="Invalid/Zone" timeZone={zone}', + 'time-zone="Invalid/Zone" [timeZone]="zone"', + 'time-zone="Invalid/Zone" [attr.time-zone]="zone"', + 'time-zone="Invalid/Zone" :time-zone="zone"', + 'time-zone="Invalid/Zone" v-bind:time-zone="zone"', + 'time-zone="Invalid/Zone" v-bind="options"', + 'time-zone="Invalid/Zone" {...props}' + ].map(attributes => `2026-01-01`), + invalid: [] + }); + }); + + it('should respect preset precedence over granular date options', () => { + tester.run('preset precedence', rule, { + valid: [ + '2026-01-01', + '2026-01-01', + '2026-01-01' + ], + invalid: [ + { + code: '2026-01-01', + errors: [{ messageId: 'invalid-option' }] + } + ] + }); + }); + + it('should validate locale and time zone independently', () => { + tester.run('independent options', rule, { + valid: [], + invalid: [ + { + code: '2026-01-01', + errors: [{ messageId: 'invalid-option' }, { messageId: 'invalid-option' }] + }, + { + code: '2026-01-01', + errors: [{ messageId: 'invalid-option' }] + }, + { + code: '2026-01-01', + errors: [{ messageId: 'invalid-option' }] + } + ] + }); + }); + + it('should leave enum options and other components to their existing rules', () => { + tester.run('separate diagnostics', rule, { + valid: [ + '2026-01-01', + '2026-01-01', + '2026-01-01', + '12', + '
' + ], + invalid: [] + }); + }); + + it('should skip JSX property overrides through the recommended config', () => { + const code = + 'const view = 2026-01-01;'; + const messages = new Linter().verify(code, [elementsHtmlConfig], { filename: 'src/example.tsx' }); + expect( + messages.filter(message => message.ruleId === '@nvidia-elements/lint/no-invalid-date-format-options') + ).toEqual([]); + }); + + it.each([ + { + filename: 'src/example.html', + code: '2026-01-01' + }, + { + filename: 'src/example.ts', + code: 'const view = html`2026-01-01`;' + }, + { + filename: 'src/example.tsx', + code: 'const view = 2026-01-01;' + }, + { + filename: 'src/example.md', + code: '```html\n2026-01-01\n```' + } + ])('should report through the recommended config in $filename', ({ filename, code }) => { + expect(new Linter().verify(code, [elementsHtmlConfig], { filename })).toEqual([ + expect.objectContaining({ + ruleId: '@nvidia-elements/lint/no-invalid-date-format-options', + messageId: 'invalid-option', + severity: 2 + }) + ]); + }); +}); diff --git a/projects/lint/src/eslint/rules/no-invalid-date-format-options.ts b/projects/lint/src/eslint/rules/no-invalid-date-format-options.ts new file mode 100644 index 0000000000..a880895981 --- /dev/null +++ b/projects/lint/src/eslint/rules/no-invalid-date-format-options.ts @@ -0,0 +1,55 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { Rule } from 'eslint'; +import { createVisitors } from '@html-eslint/eslint-plugin/lib/rules/utils/visitors.js'; +import { getLiteralFormatAttribute } from '../internals/format-input.js'; +import { checkFormatLocale, reportFormatOption } from '../internals/format-options.js'; +import { defineRuleDocumentation } from '../rule-documentation.js'; +import type { HtmlTagNode } from '../rule-types.js'; + +function checkTimeZone(context: Rule.RuleContext, node: HtmlTagNode) { + const timeZone = getLiteralFormatAttribute(node, 'time-zone'); + // The component omits empty time zones from both preset and granular options. + if (!timeZone?.value) return; + try { + new Intl.DateTimeFormat('en', { timeZone: timeZone.value }); + } catch (error) { + reportFormatOption(context, node, { + attribute: 'time-zone', + reason: error instanceof Error ? error.message : String(error) + }); + } +} + +const rule = { + meta: { + type: 'problem' as const, + docs: defineRuleDocumentation({ + name: 'no-invalid-date-format-options', + description: 'Require valid date and relative time formatting options.', + category: 'Best Practice', + recommended: true, + examples: { + language: 'html', + valid: '2026-01-01', + invalid: '2026-01-01' + } + }), + schema: [], + messages: { + ['invalid-option']: 'Invalid {{attribute}} value "{{value}}" on <{{tagName}}>: {{reason}}' + } + }, + create(context: Rule.RuleContext) { + return createVisitors(context, { + Tag(node: HtmlTagNode) { + if (node.name !== 'nve-format-datetime' && node.name !== 'nve-format-relative-time') return; + checkFormatLocale(context, node); + if (node.name === 'nve-format-datetime') checkTimeZone(context, node); + } + }); + } +} as const; + +export default rule; diff --git a/projects/lint/src/eslint/rules/no-invalid-date-value.test.ts b/projects/lint/src/eslint/rules/no-invalid-date-value.test.ts new file mode 100644 index 0000000000..85ccc69133 --- /dev/null +++ b/projects/lint/src/eslint/rules/no-invalid-date-value.test.ts @@ -0,0 +1,153 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { beforeEach, describe, expect, it } from 'vitest'; +import { Linter, RuleTester, type JSRuleDefinition } from 'eslint'; +import htmlParser from '@html-eslint/parser'; +import { elementsHtmlConfig } from '../configs/html.js'; +import noInvalidDateValue from './no-invalid-date-value.js'; + +const rule = noInvalidDateValue as unknown as JSRuleDefinition; +const formatters = ['nve-format-datetime', 'nve-format-relative-time']; + +describe('noInvalidDateValue', () => { + let tester: RuleTester; + + beforeEach(() => { + tester = new RuleTester({ languageOptions: { parser: htmlParser } }); + }); + + it('should register the rule as a recommended error', () => { + const plugin = elementsHtmlConfig.plugins?.['@nvidia-elements/lint']; + expect(plugin?.rules?.['no-invalid-date-value']).toBe(noInvalidDateValue); + expect(elementsHtmlConfig.rules?.['@nvidia-elements/lint/no-invalid-date-value']).toEqual(['error']); + }); + + it.each(formatters)('should allow dates accepted by JavaScript and empty input for %s', tagName => { + const values = [ + '2026-10-26', + '2026-10-26T12:30:00Z', + '2026-10-26T12:30:00.123+02:00', + '2026-10-26T12:30:00', + '2026-10', + '2026', + 'October 26, 2026', + 'Mon, 26 Oct 2026 12:30:00 GMT', + '2026-02-30', + '' + ]; + tester.run('accepted date literals', rule, { + valid: [ + ...values.flatMap(value => [ + `<${tagName} date="${value}">`, + `<${tagName}> ${value} ` + ]), + `<${tagName} date>not-a-date`, + `<${tagName}>2026-10-26` + ], + invalid: [] + }); + }); + + it.each(formatters)('should report invalid date input for %s', tagName => { + const values = [ + 'not-a-date', + 'Invalid Date', + 'NaN', + 'Infinity', + '2026-13-01', + '2026-01-32', + '2026-10-26T25:00:00Z', + '+999999-01-01' + ]; + tester.run('invalid date literals', rule, { + valid: [], + invalid: values.flatMap(value => { + const errors = [{ messageId: 'invalid-date-value', data: { tagName, value } }]; + return [ + { code: `<${tagName} date="${value}">2026-10-26`, errors }, + { code: `<${tagName}> ${value} `, errors }, + { code: `<${tagName}>${value}`, errors } + ]; + }) + }); + }); + + it.each(formatters)('should give the date attribute precedence for %s', tagName => { + tester.run('attribute precedence', rule, { + valid: [ + `<${tagName} date="2026-10-26">not-a-date`, + `<${tagName} date="">not-a-date`, + `<${tagName} date="2026-10-26">not-a-date` + ], + invalid: [] + }); + }); + + it.each(formatters)('should distinguish empty text from invalid whitespace attributes for %s', tagName => { + tester.run('empty input', rule, { + valid: [`<${tagName}> `], + invalid: [ + { + code: `<${tagName} date=" ">2026-10-26`, + errors: [{ messageId: 'invalid-date-value', data: { tagName, value: ' ' } }] + } + ] + }); + }); + + it.each(formatters)('should skip dynamic and encoded values for %s', tagName => { + const bindings = ['${date}', '{{ date }}', '{date}', '{% date %}']; + const boundAttributes = ['.date', '?date', ':date', '[date]', '[attr.date]', 'v-bind:date']; + const contentBindings = ['v-text', 'v-html', '.textContent', '.innerHTML', '[textContent]', '[innerHTML]']; + tester.run('unknown date values', rule, { + valid: [ + ...bindings.flatMap(value => [ + `<${tagName} date="${value}">not-a-date`, + `<${tagName}>${value}` + ]), + ...boundAttributes.map(name => `<${tagName} ${name}="date">not-a-date`), + ...contentBindings.map(name => `<${tagName} ${name}="date">not-a-date`), + `<${tagName} v-bind="props">not-a-date`, + `<${tagName} \${attributes}>not-a-date`, + `<${tagName} {...props}>not-a-date`, + `<${tagName} date="2026-10-26">not-a-date`, + `<${tagName}>2026-10-26`, + `<${tagName}>not-a-date` + ], + invalid: [] + }); + }); + + it('should ignore unrelated elements', () => { + tester.run('other elements', rule, { + valid: [ + '', + 'not-a-number' + ], + invalid: [] + }); + }); + + it.each([ + { filename: 'src/example.html', code: 'not-a-date' }, + { + filename: 'src/example.ts', + code: 'const template = html``;' + }, + { filename: 'src/example.tsx', code: 'const template = ;' }, + { + filename: 'src/example.md', + code: '```html\nnot-a-date\n```' + } + ])('should report date errors through the HTML config in $filename', ({ filename, code }) => { + const messages = new Linter().verify(code, [elementsHtmlConfig], { filename }); + expect(messages).toEqual([ + expect.objectContaining({ + ruleId: '@nvidia-elements/lint/no-invalid-date-value', + messageId: 'invalid-date-value', + severity: 2 + }) + ]); + }); +}); diff --git a/projects/lint/src/eslint/rules/no-invalid-date-value.ts b/projects/lint/src/eslint/rules/no-invalid-date-value.ts new file mode 100644 index 0000000000..ddaefd7e87 --- /dev/null +++ b/projects/lint/src/eslint/rules/no-invalid-date-value.ts @@ -0,0 +1,50 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { Rule } from 'eslint'; +import { createVisitors } from '@html-eslint/eslint-plugin/lib/rules/utils/visitors.js'; +import { getLiteralFormatInput } from '../internals/format-input.js'; +import { defineRuleDocumentation } from '../rule-documentation.js'; +import type { HtmlTagNode } from '../rule-types.js'; + +const DATE_ELEMENTS = new Set(['nve-format-datetime', 'nve-format-relative-time']); + +const rule = { + meta: { + type: 'problem' as const, + docs: defineRuleDocumentation({ + name: 'no-invalid-date-value', + description: 'Require valid date input for date formatting elements.', + category: 'Best Practice', + recommended: true, + examples: { + language: 'html', + valid: '2026-10-26T12:30:00Z', + invalid: 'not-a-date' + } + }), + schema: [], + messages: { + ['invalid-date-value']: 'Invalid date value "{{value}}" for <{{tagName}}>. Use a valid date string.' + } + }, + create(context: Rule.RuleContext) { + return createVisitors(context, { + Tag(node: HtmlTagNode) { + const tagName = node.name.toLowerCase(); + if (!DATE_ELEMENTS.has(tagName)) return; + + const input = getLiteralFormatInput(node, 'date'); + if (!input || input.value === '' || !Number.isNaN(Date.parse(input.value))) return; + + context.report({ + node: input.node, + messageId: 'invalid-date-value', + data: { tagName, value: input.value } + }); + } + }); + } +} as const; + +export default rule; diff --git a/projects/lint/src/eslint/rules/no-invalid-number-format-options.test.ts b/projects/lint/src/eslint/rules/no-invalid-number-format-options.test.ts new file mode 100644 index 0000000000..9bf38f0045 --- /dev/null +++ b/projects/lint/src/eslint/rules/no-invalid-number-format-options.test.ts @@ -0,0 +1,223 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { beforeEach, describe, expect, it } from 'vitest'; +import { Linter, RuleTester, type JSRuleDefinition } from 'eslint'; +import htmlParser from '@html-eslint/parser'; +import { elementsHtmlConfig } from '../configs/html.js'; +import noInvalidNumberFormatOptions from './no-invalid-number-format-options.js'; + +const rule = noInvalidNumberFormatOptions as unknown as JSRuleDefinition; +const formatters = ['nve-format-number', 'nve-format-bytes']; + +describe('noInvalidNumberFormatOptions', () => { + let tester: RuleTester; + + beforeEach(() => { + tester = new RuleTester({ languageOptions: { parser: htmlParser } }); + }); + + it('should register as a recommended error', () => { + expect(elementsHtmlConfig.plugins?.['@nvidia-elements/lint']?.rules?.['no-invalid-number-format-options']).toBe( + noInvalidNumberFormatOptions + ); + expect(elementsHtmlConfig.rules?.['@nvidia-elements/lint/no-invalid-number-format-options']).toEqual(['error']); + }); + + it.each(formatters)('should allow valid and unsupported well formed locales for %s', tag => { + tester.run('valid locales', rule, { + valid: [undefined, 'en-US', 'de-DE', 'en-u-nu-latn', 'zz-ZZ'].map( + locale => `<${tag}${locale === undefined ? '' : ` locale="${locale}"`}>1234` + ), + invalid: [] + }); + }); + + it.each(formatters)('should reject malformed locales for %s', tag => { + tester.run('invalid locales', rule, { + valid: [], + invalid: ['', 'en_US', 'not_a_locale', 'en-US,de-DE'].map(value => ({ + code: `<${tag} locale="${value}">1234`, + errors: [{ messageId: 'invalid-option' }] + })) + }); + }); + + it.each(formatters)('should follow Intl digit coercion and defaults for %s', tag => { + tester.run('valid digit options', rule, { + valid: [ + 'minimum-fraction-digits="0" maximum-fraction-digits="100"', + 'minimum-fraction-digits="100"', + 'maximum-fraction-digits="0"', + 'minimum-fraction-digits="3"', + 'minimum-fraction-digits="2.9" maximum-fraction-digits="2.1"', + 'minimum-fraction-digits="0x2" maximum-fraction-digits="2e0"', + 'minimum-fraction-digits=" " maximum-fraction-digits=""', + 'minimum-fraction-digits maximum-fraction-digits' + ].map(attributes => `<${tag} ${attributes}>1234`), + invalid: [] + }); + }); + + it.each(formatters)('should reject invalid digit values for %s', tag => { + tester.run('invalid digit values', rule, { + valid: [], + invalid: ['minimum-fraction-digits', 'maximum-fraction-digits'].flatMap(attribute => + ['-1', '-0.1', '101', '100.1', 'NaN', 'Infinity', '-Infinity', '1e309', 'two'].map(value => ({ + code: `<${tag} ${attribute}="${value}">1234`, + errors: [{ messageId: 'invalid-option' }] + })) + ) + }); + }); + + it.each(formatters)('should reject reversed fraction digit bounds for %s', tag => { + tester.run('digit ordering', rule, { + valid: [], + invalid: [ + { + code: `<${tag} minimum-fraction-digits="3" maximum-fraction-digits="2">1234`, + errors: [{ messageId: 'invalid-digit-order', data: { tagName: tag } }] + } + ] + }); + }); + + it('should enforce integer digit bounds only for the number formatter', () => { + tester.run('integer digit bounds', rule, { + valid: ['1', '21', '2.9'].map( + value => `12` + ), + invalid: ['0', '22', '21.9', '', 'NaN'].map(value => ({ + code: `12`, + errors: [{ messageId: 'invalid-option' }] + })) + }); + }); + + it.each(['currency', 'unit'])('should require a %s only for a known matching format style', style => { + tester.run('required style options', rule, { + valid: [ + '12', + '12', + `12`, + `12`, + `12`, + '12', + '12' + ], + invalid: [ + { + code: `12`, + errors: [{ messageId: 'missing-option', data: { tagName: 'nve-format-number', option: style } }] + } + ] + }); + }); + + it('should accept currency syntax without requiring a known currency', () => { + tester.run('valid currency identifiers', rule, { + valid: ['USD', 'usd', 'EUR', 'ZZZ'].map( + currency => `12` + ), + invalid: ['', 'US', 'USDD', '123', ' USD '].map(currency => ({ + code: `12`, + errors: [{ messageId: 'invalid-option' }] + })) + }); + }); + + it('should validate simple and compound Intl unit identifiers', () => { + tester.run('unit identifiers', rule, { + valid: ['kilometer', 'byte', 'liter', 'meter-per-second'].map( + unit => `12` + ), + invalid: ['', 'kilometers', 'mb', 'METER', 'meter-per-second-per-second'].map(unit => ({ + code: `12`, + errors: [{ messageId: 'invalid-option' }] + })) + }); + }); + + it('should validate supplied identifiers even when their style is inactive', () => { + tester.run('inactive identifiers', rule, { + valid: ['12'], + invalid: ['currency="bad!"', 'unit="bad"'].map(attribute => ({ + code: `12`, + errors: [{ messageId: 'invalid-option' }] + })) + }); + }); + + it.each(formatters)('should skip unknown options without hiding other literal errors for %s', tag => { + tester.run('unknown options', rule, { + valid: [ + 'locale="${locale}"', + 'locale="{{ locale }}"', + 'locale="{locale}"', + 'locale="{% locale %}"', + 'locale="en-US"', + 'minimum-fraction-digits="2"', + 'minimum-fraction-digits="101" .minimumFractionDigits=${minimum}', + 'minimum-fraction-digits="101" minimumFractionDigits={minimum}', + 'minimum-fraction-digits="101" [minimumFractionDigits]="minimum"', + 'minimum-fraction-digits="101" [attr.minimum-fraction-digits]="minimum"', + 'minimum-fraction-digits="101" v-bind:minimum-fraction-digits="minimum"', + 'locale="bad_locale" :locale="locale"', + 'locale="bad_locale" v-bind="options"', + 'locale="bad_locale" v-bind:[key]="value"', + 'locale="bad_locale" :[key]="value"', + 'locale="bad_locale" ${attributes}', + 'locale="bad_locale" {...props}', + 'minimum-fraction-digits="3" maximum-fraction-digits="${maximum}"' + ].map(attributes => `<${tag} ${attributes}>12`), + invalid: [ + { + code: `<${tag} locale="bad_locale" .maximumFractionDigits=\${maximum}>12`, + errors: [{ messageId: 'invalid-option' }] + }, + { + code: `<${tag} minimum-fraction-digits="101" maximum-fraction-digits="2">12`, + errors: [{ messageId: 'invalid-option' }] + } + ] + }); + }); + + it('should leave byte enums and unrelated formatters to their existing rules', () => { + tester.run('separate diagnostics', rule, { + valid: [ + '12', + '12', + '12', + '2026-01-01', + '
12
' + ], + invalid: [] + }); + }); + + it('should skip JSX property overrides through the recommended config', () => { + const code = + 'const view = 12;'; + const messages = new Linter().verify(code, [elementsHtmlConfig], { filename: 'src/example.tsx' }); + expect( + messages.filter(message => message.ruleId === '@nvidia-elements/lint/no-invalid-number-format-options') + ).toEqual([]); + }); + + it.each([ + { filename: 'src/example.html', code: '12' }, + { filename: 'src/example.ts', code: 'const view = html`12`;' }, + { filename: 'src/example.tsx', code: 'const view = 12;' }, + { + filename: 'src/example.md', + code: '```html\n12\n```' + } + ])('should report options through the recommended config in $filename', ({ filename, code }) => { + const messages = new Linter().verify(code, [elementsHtmlConfig], { filename }); + expect(messages).toEqual([ + expect.objectContaining({ ruleId: '@nvidia-elements/lint/no-invalid-number-format-options', severity: 2 }) + ]); + }); +}); diff --git a/projects/lint/src/eslint/rules/no-invalid-number-format-options.ts b/projects/lint/src/eslint/rules/no-invalid-number-format-options.ts new file mode 100644 index 0000000000..ce83493570 --- /dev/null +++ b/projects/lint/src/eslint/rules/no-invalid-number-format-options.ts @@ -0,0 +1,99 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { Rule } from 'eslint'; +import { createVisitors } from '@html-eslint/eslint-plugin/lib/rules/utils/visitors.js'; +import { getLiteralFormatAttribute } from '../internals/format-input.js'; +import { checkFormatLocale, reportFormatOption } from '../internals/format-options.js'; +import { defineRuleDocumentation } from '../rule-documentation.js'; +import type { HtmlTagNode } from '../rule-types.js'; + +const DIGIT_OPTIONS = [ + ['minimum-fraction-digits', 'minimumFractionDigits'], + ['maximum-fraction-digits', 'maximumFractionDigits'], + ['minimum-integer-digits', 'minimumIntegerDigits'] +] as const; + +function getFormatError(options: Intl.NumberFormatOptions): string | undefined { + try { + new Intl.NumberFormat('en', options); + } catch (error) { + return error instanceof Error ? error.message : String(error); + } + return undefined; +} + +function checkDigits(context: Rule.RuleContext, node: HtmlTagNode) { + const options: Intl.NumberFormatOptions = {}; + for (const [attribute, property] of DIGIT_OPTIONS) { + if (property === 'minimumIntegerDigits' && node.name === 'nve-format-bytes') continue; + const option = getLiteralFormatAttribute(node, attribute); + if (option?.value === undefined) continue; + const value = Number(option.value); + const error = getFormatError({ [property]: value }); + if (error) reportFormatOption(context, node, { attribute, reason: error }); + else options[property] = value; + } + + checkDigitOrder(context, node, options); +} + +function checkDigitOrder(context: Rule.RuleContext, node: HtmlTagNode, options: Intl.NumberFormatOptions) { + // Intl applies numeric coercion and truncation before comparing the digit options. + if (options.minimumFractionDigits === undefined || options.maximumFractionDigits === undefined) return; + if (!getFormatError(options)) return; + const maximum = getLiteralFormatAttribute(node, 'maximum-fraction-digits'); + if (maximum) context.report({ node: maximum.node, messageId: 'invalid-digit-order', data: { tagName: node.name } }); +} + +function checkStyleOption(context: Rule.RuleContext, node: HtmlTagNode, style: 'currency' | 'unit') { + const option = getLiteralFormatAttribute(node, style); + if (!option) return; + if (option.value === undefined) { + if (getLiteralFormatAttribute(node, 'format-style')?.value === style) { + context.report({ node, messageId: 'missing-option', data: { option: style, tagName: node.name } }); + } + return; + } + // Intl validates supplied currency and unit identifiers even with another style. + const error = getFormatError({ style, [style]: option.value }); + if (error) reportFormatOption(context, node, { attribute: style, reason: error }); +} + +const rule = { + meta: { + type: 'problem' as const, + docs: defineRuleDocumentation({ + name: 'no-invalid-number-format-options', + description: 'Require valid number and byte formatting options.', + category: 'Best Practice', + recommended: true, + examples: { + language: 'html', + valid: '1234.56', + invalid: '1234.56' + } + }), + schema: [], + messages: { + ['invalid-option']: 'Invalid {{attribute}} value "{{value}}" on <{{tagName}}>: {{reason}}', + ['missing-option']: '<{{tagName}}> requires {{option}} when format-style="{{option}}".', + ['invalid-digit-order']: '<{{tagName}}> requires minimum-fraction-digits to be at most maximum-fraction-digits.' + } + }, + create(context: Rule.RuleContext) { + return createVisitors(context, { + Tag(node: HtmlTagNode) { + if (node.name !== 'nve-format-number' && node.name !== 'nve-format-bytes') return; + checkFormatLocale(context, node); + checkDigits(context, node); + if (node.name === 'nve-format-number') { + checkStyleOption(context, node, 'currency'); + checkStyleOption(context, node, 'unit'); + } + } + }); + } +} as const; + +export default rule; diff --git a/projects/lint/src/eslint/rules/no-invalid-numeric-value.test.ts b/projects/lint/src/eslint/rules/no-invalid-numeric-value.test.ts new file mode 100644 index 0000000000..91b07a943e --- /dev/null +++ b/projects/lint/src/eslint/rules/no-invalid-numeric-value.test.ts @@ -0,0 +1,128 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { beforeEach, describe, expect, it } from 'vitest'; +import { Linter, RuleTester, type JSRuleDefinition } from 'eslint'; +import htmlParser from '@html-eslint/parser'; +import { elementsHtmlConfig } from '../configs/html.js'; +import noInvalidNumericValue from './no-invalid-numeric-value.js'; + +const rule = noInvalidNumericValue as unknown as JSRuleDefinition; +const formatters = [ + { tagName: 'nve-format-number', attribute: 'number' }, + { tagName: 'nve-format-bytes', attribute: 'value' } +]; + +describe('noInvalidNumericValue', () => { + let tester: RuleTester; + + beforeEach(() => { + tester = new RuleTester({ languageOptions: { parser: htmlParser } }); + }); + + it('should register the rule as a recommended error', () => { + const plugin = elementsHtmlConfig.plugins?.['@nvidia-elements/lint']; + expect(plugin?.rules?.['no-invalid-numeric-value']).toBe(noInvalidNumericValue); + expect(elementsHtmlConfig.rules?.['@nvidia-elements/lint/no-invalid-numeric-value']).toEqual(['error']); + }); + + it.each(formatters)('should allow finite and empty input for $tagName', ({ tagName, attribute }) => { + const values = ['0', '-42', '+4', '1234.56', '1e3', '1e-999', '0xFF', '0b101', '0o77', ' 12 ', '', ' ']; + tester.run('finite numeric literals', rule, { + valid: [ + ...values.flatMap(value => [ + `<${tagName} ${attribute}="${value}">`, + `<${tagName}>${value}` + ]), + `<${tagName} ${attribute}>not-a-number`, + `<${tagName}>12` + ], + invalid: [] + }); + }); + + it.each(formatters)('should report invalid numeric input for $tagName', ({ tagName, attribute }) => { + const values = ['not-a-number', 'NaN', 'Infinity', '-Infinity', '1e309', '1,234', '12px', '95%']; + tester.run('invalid numeric literals', rule, { + valid: [], + invalid: values.flatMap(value => { + const errors = [{ messageId: 'invalid-numeric-value', data: { tagName, value } }]; + return [ + { code: `<${tagName} ${attribute}="${value}">1234`, errors }, + { code: `<${tagName}> ${value} `, errors }, + { code: `<${tagName}>${value}`, errors } + ]; + }) + }); + }); + + it.each(formatters)('should give the numeric attribute precedence for $tagName', ({ tagName, attribute }) => { + tester.run('attribute precedence', rule, { + valid: [ + `<${tagName} ${attribute}="0">not-a-number`, + `<${tagName} ${attribute}="">not-a-number`, + `<${tagName} ${attribute}="1234">not-a-number` + ], + invalid: [] + }); + }); + + it.each(formatters)('should skip dynamic and encoded values for $tagName', ({ tagName, attribute }) => { + const bindings = ['${amount}', '{{ amount }}', '{amount}', '{% amount %}']; + const boundAttributes = [ + `.${attribute}`, + `?${attribute}`, + `:${attribute}`, + `[${attribute}]`, + `[attr.${attribute}]`, + `v-bind:${attribute}` + ]; + const contentBindings = ['v-text', 'v-html', '.textContent', '.innerHTML', '[textContent]', '[innerHTML]']; + tester.run('unknown numeric values', rule, { + valid: [ + ...bindings.flatMap(value => [ + `<${tagName} ${attribute}="${value}">not-a-number`, + `<${tagName}>${value}` + ]), + ...boundAttributes.map(name => `<${tagName} ${name}="amount">not-a-number`), + ...contentBindings.map(name => `<${tagName} ${name}="amount">not-a-number`), + `<${tagName} v-bind="props">not-a-number`, + `<${tagName} \${attributes}>not-a-number`, + `<${tagName} {...props}>not-a-number`, + `<${tagName} ${attribute}="1">not-a-number`, + `<${tagName}>1 `, + `<${tagName}>not-a-number` + ], + invalid: [] + }); + }); + + it('should ignore unrelated elements', () => { + tester.run('other elements', rule, { + valid: [ + '
not-a-number
', + 'not-a-date' + ], + invalid: [] + }); + }); + + it.each([ + { filename: 'src/example.html', code: 'NaN' }, + { + filename: 'src/example.ts', + code: 'const template = html``;' + }, + { filename: 'src/example.tsx', code: 'const template = ;' }, + { filename: 'src/example.md', code: '```html\ninvalid\n```' } + ])('should report numeric errors through the HTML config in $filename', ({ filename, code }) => { + const messages = new Linter().verify(code, [elementsHtmlConfig], { filename }); + expect(messages).toEqual([ + expect.objectContaining({ + ruleId: '@nvidia-elements/lint/no-invalid-numeric-value', + messageId: 'invalid-numeric-value', + severity: 2 + }) + ]); + }); +}); diff --git a/projects/lint/src/eslint/rules/no-invalid-numeric-value.ts b/projects/lint/src/eslint/rules/no-invalid-numeric-value.ts new file mode 100644 index 0000000000..8862436f50 --- /dev/null +++ b/projects/lint/src/eslint/rules/no-invalid-numeric-value.ts @@ -0,0 +1,53 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { Rule } from 'eslint'; +import { createVisitors } from '@html-eslint/eslint-plugin/lib/rules/utils/visitors.js'; +import { getLiteralFormatInput } from '../internals/format-input.js'; +import { defineRuleDocumentation } from '../rule-documentation.js'; +import type { HtmlTagNode } from '../rule-types.js'; + +const NUMERIC_ATTRIBUTES = new Map([ + ['nve-format-number', 'number'], + ['nve-format-bytes', 'value'] +]); + +const rule = { + meta: { + type: 'problem' as const, + docs: defineRuleDocumentation({ + name: 'no-invalid-numeric-value', + description: 'Require finite numeric input for number and byte formatting elements.', + category: 'Best Practice', + recommended: true, + examples: { + language: 'html', + valid: '1234.56', + invalid: 'not-a-number' + } + }), + schema: [], + messages: { + ['invalid-numeric-value']: 'Invalid numeric value "{{value}}" for <{{tagName}}>. Use a finite number.' + } + }, + create(context: Rule.RuleContext) { + return createVisitors(context, { + Tag(node: HtmlTagNode) { + const tagName = node.name.toLowerCase(); + const attributeName = NUMERIC_ATTRIBUTES.get(tagName); + if (!attributeName) return; + const input = getLiteralFormatInput(node, attributeName); + if (!input || Number.isFinite(Number(input.value))) return; + + context.report({ + node: input.node, + messageId: 'invalid-numeric-value', + data: { tagName, value: input.value } + }); + } + }); + } +} as const; + +export default rule; diff --git a/projects/lint/src/eslint/rules/no-invalid-parent-elements.test.ts b/projects/lint/src/eslint/rules/no-invalid-parent-elements.test.ts new file mode 100644 index 0000000000..6cc3513239 --- /dev/null +++ b/projects/lint/src/eslint/rules/no-invalid-parent-elements.test.ts @@ -0,0 +1,118 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { beforeEach, describe, expect, it } from 'vitest'; +import { Linter, RuleTester, type JSRuleDefinition } from 'eslint'; +import htmlParser from '@html-eslint/parser'; +import { elementsHtmlConfig } from '../configs/html.js'; +import { PARENT_ELEMENT_CONTRACTS } from '../internals/parent-elements.js'; +import noInvalidParentElements from './no-invalid-parent-elements.js'; + +const rule = noInvalidParentElements as unknown as JSRuleDefinition; + +describe('noInvalidParentElements', () => { + let tester: RuleTester; + beforeEach(() => { + tester = new RuleTester({ languageOptions: { parser: htmlParser } }); + }); + + it('should register as a recommended error', () => { + expect(elementsHtmlConfig.plugins?.['@nvidia-elements/lint']?.rules?.['no-invalid-parent-elements']).toBe( + noInvalidParentElements + ); + expect(elementsHtmlConfig.rules?.['@nvidia-elements/lint/no-invalid-parent-elements']).toEqual(['error']); + }); + + it.each(Object.entries(PARENT_ELEMENT_CONTRACTS))('should enforce the parent contract for %s', (tagName, parents) => { + tester.run('direct parent contracts', rule, { + valid: parents.map(parent => `<${parent}><${tagName}>`), + invalid: [ + { + code: `
<${tagName}>
`, + errors: [ + { + messageId: 'invalid-parent-element', + data: { element: tagName, parent: 'div', parents: parents.map(parent => `<${parent}>`).join(' or ') } + } + ] + } + ] + }); + }); + + it('should require the direct parent even when an allowed ancestor or slot exists', () => { + tester.run('direct ownership', rule, { + valid: [''], + invalid: [ + { + code: '
Item
', + errors: [{ messageId: 'invalid-parent-element' }] + }, + { + code: '
Title
', + errors: [{ messageId: 'invalid-parent-element' }] + }, + { + code: '
Title
', + errors: [{ messageId: 'invalid-parent-element' }] + }, + { code: 'Item', errors: [{ messageId: 'invalid-parent-element' }] } + ] + }); + }); + + it('should traverse Angular containers that do not render an element', () => { + tester.run('transparent containers', rule, { + valid: [ + 'Item' + ], + invalid: [ + { + code: '
Item
', + errors: [{ messageId: 'invalid-parent-element' }] + } + ] + }); + }); + + it('should leave external placement and opaque wrappers unchecked', () => { + tester.run('unknown parents', rule, { + valid: [ + 'Item', + '', + 'Item', + 'Item', + 'Item', + 'Item', + 'Item', + { + filename: 'src/example.ts', + code: 'const renderItem = item => html`${item.label}`;' + }, + { + filename: 'src/example.tsx', + code: 'const view = Item;' + }, + { filename: 'src/example.md', code: 'Use `` inside a menu.' }, + '
Content
' + ], + invalid: [] + }); + }); + + it.each([ + { filename: 'src/example.html', code: '
Item
' }, + { filename: 'src/example.ts', code: 'const view = html`
Item
`;' }, + { filename: 'src/example.tsx', code: 'const view =
Item
;' }, + { filename: 'src/example.md', code: '```html\n
Item
\n```' } + ])('should report parent errors through the recommended config in $filename', ({ filename, code }) => { + const messages = new Linter().verify(code, [elementsHtmlConfig], { filename }); + expect(messages).toEqual([ + expect.objectContaining({ + ruleId: '@nvidia-elements/lint/no-invalid-parent-elements', + messageId: 'invalid-parent-element', + severity: 2 + }) + ]); + }); +}); diff --git a/projects/lint/src/eslint/rules/no-invalid-parent-elements.ts b/projects/lint/src/eslint/rules/no-invalid-parent-elements.ts new file mode 100644 index 0000000000..c33207f0a6 --- /dev/null +++ b/projects/lint/src/eslint/rules/no-invalid-parent-elements.ts @@ -0,0 +1,66 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { Rule } from 'eslint'; +import { createVisitors } from '@html-eslint/eslint-plugin/lib/rules/utils/visitors.js'; +import { PARENT_ELEMENT_CONTRACTS } from '../internals/parent-elements.js'; +import { defineRuleDocumentation } from '../rule-documentation.js'; +import type { HtmlTagNode } from '../rule-types.js'; + +const STATIC_TAG_NAME = /^[a-z][a-z0-9.-]*$/; +const OPAQUE_PARENTS = new Set(['template', 'ng-template', 'slot', 'component']); + +function getRenderedParent(node: HtmlTagNode, source: string): HtmlTagNode | undefined { + let parent = node.parent; + while (parent?.name === 'ng-container') parent = parent.parent; + if (!parent || parent.type !== 'Tag') return; + const originalName = source.slice(parent.openStart.range[0] + 1, parent.openStart.range[1]); + if (!STATIC_TAG_NAME.test(originalName)) return; + const tagName = parent.name.toLowerCase(); + if (OPAQUE_PARENTS.has(tagName) || (tagName.includes('-') && !tagName.startsWith('nve-'))) return; + return parent; +} + +const rule = { + meta: { + type: 'problem' as const, + docs: defineRuleDocumentation({ + name: 'no-invalid-parent-elements', + description: 'Require supported direct parents for Elements components.', + category: 'Best Practice', + recommended: true, + examples: { + language: 'html', + valid: 'Item', + invalid: '
Item
' + } + }), + schema: [], + messages: { + ['invalid-parent-element']: '<{{element}}> requires a direct parent of {{parents}}. Found <{{parent}}>.' + } + }, + create(context: Rule.RuleContext) { + return createVisitors(context, { + Tag(node: HtmlTagNode) { + const tagName = node.name.toLowerCase(); + const parents = PARENT_ELEMENT_CONTRACTS[tagName]; + if (!parents) return; + if (context.filename.endsWith('.md') && context.sourceCode.text[node.openStart.range[0] - 1] === '`') return; + const parent = getRenderedParent(node, context.sourceCode.text); + if (!parent || parents.includes(parent.name.toLowerCase())) return; + context.report({ + node, + messageId: 'invalid-parent-element', + data: { + element: tagName, + parent: parent.name.toLowerCase(), + parents: parents.map(name => `<${name}>`).join(' or ') + } + }); + } + }); + } +} as const; + +export default rule; diff --git a/projects/lint/src/eslint/rules/no-invalid-slotted-elements.test.ts b/projects/lint/src/eslint/rules/no-invalid-slotted-elements.test.ts index 2efec7cb31..25ac873fe1 100644 --- a/projects/lint/src/eslint/rules/no-invalid-slotted-elements.test.ts +++ b/projects/lint/src/eslint/rules/no-invalid-slotted-elements.test.ts @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import { beforeEach, describe, expect, it } from 'vitest'; -import { RuleTester } from 'eslint'; +import { Linter, RuleTester } from 'eslint'; import type { JSRuleDefinition } from 'eslint'; import htmlParser from '@html-eslint/parser'; import { elementsHtmlConfig } from '../configs/html.js'; @@ -66,9 +66,7 @@ describe('noInvalidSlottedElements', () => { '
', '
', '', - '', '', - '', '
', 'Tab', 'dynamic rows', @@ -181,4 +179,50 @@ describe('noInvalidSlottedElements', () => { ] }); }); + + it('should scope grid scroller and media control allowances to their supported parents', () => { + tester.run('supported compositions', rule, { + valid: [ + '', + '
', + '' + ], + invalid: [ + { + code: '
', + errors: [ + { + messageId: 'invalid-slotted-element', + data: { parent: 'nve-menu', child: 'div', allowed: 'nve-menu-item, nve-divider' } + } + ] + }, + { + code: '', + errors: [ + { + messageId: 'invalid-slotted-element', + data: { parent: 'nve-menu', child: 'nve-media-seek-button', allowed: 'nve-menu-item, nve-divider' } + } + ] + } + ] + }); + }); + + it.each([ + { filename: 'src/example.html', code: '
' }, + { filename: 'src/example.ts', code: 'const template = html`
`;' }, + { filename: 'src/example.tsx', code: 'const template = ;' }, + { filename: 'src/example.md', code: '```html\n
\n```' } + ])('should report invalid children through the HTML config in $filename', ({ filename, code }) => { + const messages = new Linter().verify(code, [elementsHtmlConfig], { filename }); + expect(messages).toEqual([ + expect.objectContaining({ + ruleId: '@nvidia-elements/lint/no-invalid-slotted-elements', + messageId: 'invalid-slotted-element', + severity: 2 + }) + ]); + }); }); diff --git a/projects/lint/src/eslint/rules/no-multiple-page-elements.test.ts b/projects/lint/src/eslint/rules/no-multiple-page-elements.test.ts new file mode 100644 index 0000000000..ac00f77737 --- /dev/null +++ b/projects/lint/src/eslint/rules/no-multiple-page-elements.test.ts @@ -0,0 +1,185 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { beforeEach, describe, expect, it } from 'vitest'; +import { Linter, RuleTester, type JSRuleDefinition } from 'eslint'; +import htmlParser from '@html-eslint/parser'; +import { elementsHtmlConfig } from '../configs/html.js'; +import noMultiplePageElements from './no-multiple-page-elements.js'; + +const rule = noMultiplePageElements as unknown as JSRuleDefinition; + +describe('noMultiplePageElements', () => { + let tester: RuleTester; + beforeEach(() => { + tester = new RuleTester({ languageOptions: { parser: htmlParser } }); + }); + + it('should register as a structural error', () => { + expect(elementsHtmlConfig.plugins?.['@nvidia-elements/lint']?.rules?.['no-multiple-page-elements']).toBe( + noMultiplePageElements + ); + expect(elementsHtmlConfig.rules?.['@nvidia-elements/lint/no-multiple-page-elements']).toEqual(['error']); + }); + + it('should allow one shell in each independent template', () => { + tester.run('independent shells', rule, { + valid: [ + '', + '', + '
', + { + filename: 'src/example.ts', + code: 'const a = html``; const b = html``;' + }, + { filename: 'src/example.tsx', code: 'const a = ; const b = ;' }, + { filename: 'src/example.tsx', code: 'const view =
{condition ? : }
;' }, + { + filename: 'src/example.md', + code: '```html\n\n```\n\n```html\n\n```' + }, + { filename: 'src/example.md', code: 'Use `` as the shell.' } + ], + invalid: [] + }); + }); + + it('should count coexisting JSX inclusions in their containing scope', () => { + tester.run('coexisting JSX inclusions', rule, { + valid: [ + { filename: 'src/example.tsx', code: 'const view =
{condition ? : }
;' }, + { + filename: 'src/example.tsx', + code: 'const view =
{first ? : second ? : }
;' + }, + { filename: 'src/example.tsx', code: 'const a = first && ; const b = second && ;' } + ], + invalid: [ + 'const view =
{condition && }
;', + 'const view =
{first && }{second && }
;', + 'const view = <>{first && second &&
};', + 'const view =
{condition || }
;', + 'const view =
{value ?? }
;', + 'const view =
{}
;', + 'const view =
{condition ? <> : }
;' + ].map(code => ({ filename: 'src/example.tsx', code, errors: [{ messageId: 'multiple-pages' }] })) + }); + }); + + it('should count independent conditions in their containing scope', () => { + tester.run('independent markup conditions', rule, { + valid: [ + '
', + '', + '
' + ], + invalid: [ + '
', + '
', + '', + '
', + '
', + '
', + '', + '
' + ].map(code => ({ code, errors: [{ messageId: 'multiple-pages' }] })) + }); + }); + + it.each([ + { name: 'LF', newline: '\n' }, + { name: 'CRLF', newline: '\r\n' } + ])('should isolate fenced examples with $name line endings', ({ newline }) => { + const examples = ['```', '~~~'].flatMap(fence => + ['', newline].flatMap(ending => + ['', ' ', '\t', ' \t '].map(trailing => ({ + valid: + `${fence}html\n\n${fence}${trailing}\n\n${fence}html\n\n${fence}${trailing}`.replaceAll( + '\n', + newline + ) + ending, + invalid: + `${fence}html\n\n${fence}${trailing}\n\n${fence}html\n\n${fence}${trailing}`.replaceAll( + '\n', + newline + ) + ending + })) + ) + ); + tester.run('fenced render scopes', rule, { + valid: examples.map(example => ({ filename: 'src/example.md', code: example.valid })), + invalid: examples.map(example => ({ + filename: 'src/example.md', + code: example.invalid, + errors: [{ messageId: 'multiple-pages' }] + })) + }); + }); + + it('should count unconditional siblings with compatible alternative branches', () => { + tester.run('alternative branch compatibility', rule, { + valid: [ + '
', + '\n\n', + '
', + '', + { + filename: 'src/example.md', + code: '```html\n
\n```\n\n```html\n\n```' + } + ], + invalid: [ + '
', + '
', + '
', + '', + '
', + '
', + { + filename: 'src/example.md', + code: '```html\n
\n```' + } + ].map(test => ({ + ...(typeof test === 'string' ? { code: test } : test), + errors: [{ messageId: 'multiple-pages' }] + })) + }); + }); + + it.each([ + { code: '' }, + { code: '' }, + { filename: 'src/example.ts', code: 'const view = html``;' }, + { filename: 'src/example.tsx', code: 'const view =
;' }, + { filename: 'src/example.md', code: '```html\n\n```' } + ])('should reject multiple shells in one render scope', test => { + tester.run('multiple shells', rule, { + valid: [], + invalid: [{ ...test, errors: [{ messageId: 'multiple-pages' }] }] + }); + }); + + it.each([ + { code: '' }, + { code: '
' }, + { filename: 'src/example.js', code: 'const views = pages.map(page => html`${page.title}`);' } + ])('should reject page shells in repeated content', test => { + tester.run('repeated shells', rule, { + valid: [], + invalid: [{ ...test, errors: [{ messageId: 'repeated-page' }] }] + }); + }); + + it('should report a structural error through the recommended HTML config', () => { + const messages = new Linter().verify('', [elementsHtmlConfig], { + filename: 'src/example.html' + }); + expect(messages).toEqual([ + expect.objectContaining({ + ruleId: '@nvidia-elements/lint/no-multiple-page-elements', + messageId: 'multiple-pages', + severity: 2 + }) + ]); + }); +}); diff --git a/projects/lint/src/eslint/rules/no-multiple-page-elements.ts b/projects/lint/src/eslint/rules/no-multiple-page-elements.ts new file mode 100644 index 0000000000..3740551757 --- /dev/null +++ b/projects/lint/src/eslint/rules/no-multiple-page-elements.ts @@ -0,0 +1,50 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { Rule } from 'eslint'; +import { createVisitors } from '@html-eslint/eslint-plugin/lib/rules/utils/visitors.js'; +import { canRenderTogether, getInstanceContext } from '../internals/instance-context.js'; +import { defineRuleDocumentation } from '../rule-documentation.js'; +import type { HtmlTagNode } from '../rule-types.js'; + +const rule = { + meta: { + type: 'problem' as const, + docs: defineRuleDocumentation({ + name: 'no-multiple-page-elements', + description: 'Require one page shell per template.', + category: 'Best Practice', + recommended: true, + examples: { + language: 'html', + valid: '', + invalid: '' + } + }), + schema: [], + messages: { + ['multiple-pages']: 'This template declares multiple elements. Use one page shell.', + ['repeated-page']: ' is created in repeated content. Use one page shell outside the repetition.' + } + }, + create(context: Rule.RuleContext) { + const instances: ReturnType[] = []; + return createVisitors(context, { + Tag(node: HtmlTagNode) { + if (node.name.toLowerCase() !== 'nve-page') return; + const instance = getInstanceContext(context, node); + const { scope, repeated } = instance; + if (repeated) { + context.report({ node, messageId: 'repeated-page' }); + return; + } + if (!scope) return; + if (instances.some(previous => canRenderTogether(previous, instance))) + context.report({ node, messageId: 'multiple-pages' }); + instances.push(instance); + } + }); + } +} as const; + +export default rule; diff --git a/projects/lint/src/eslint/rules/no-unexpected-attribute-value.test.ts b/projects/lint/src/eslint/rules/no-unexpected-attribute-value.test.ts index 6119df24d0..e2a1399247 100644 --- a/projects/lint/src/eslint/rules/no-unexpected-attribute-value.test.ts +++ b/projects/lint/src/eslint/rules/no-unexpected-attribute-value.test.ts @@ -72,6 +72,35 @@ describe('noUnexpectedAttributeValue', () => { }); }); + it.each([ + { attribute: 'display', values: ['decimal', 'binary'] }, + { attribute: 'unit', values: ['b', 'kb', 'mb', 'gb', 'tb', 'pb'] }, + { attribute: 'unit-display', values: ['short', 'long'] } + ])('should validate byte formatting $attribute values', ({ attribute, values }) => { + tester.run(`byte formatting ${attribute}`, rule, { + valid: values.map(value => `1024`), + invalid: [ + { + code: `1024`, + errors: [ + { + messageId: 'no-unexpected-attribute-value', + data: { + tagName: 'nve-format-bytes', + attribute, + value: 'invalid', + validValues: [...values] + .sort((a, b) => a.localeCompare(b)) + .map(value => `"${value}"`) + .join(', ') + } + } + ] + } + ] + }); + }); + it('should allow non-enum attribute values', () => { tester.run('should allow non-enum attribute values', rule, { valid: [ diff --git a/projects/site/eleventy.config.js b/projects/site/eleventy.config.js index 2c4008ab10..57750b3a53 100644 --- a/projects/site/eleventy.config.js +++ b/projects/site/eleventy.config.js @@ -94,7 +94,8 @@ const entrypoints = [ */ export default function (eleventyConfig) { // Vite must finish before the other output writers run. - eleventyConfig.setEventEmitterMode('sequential'); + // Knip's Eleventy config mock omits this API. + eleventyConfig.setEventEmitterMode?.('sequential'); eleventyConfig.pathPrefix = BASE_URL; // Add core 11ty plugins diff --git a/projects/site/src/docs/api-design/logs.md b/projects/site/src/docs/api-design/logs.md index fa7c867cb8..4fc13b6077 100644 --- a/projects/site/src/docs/api-design/logs.md +++ b/projects/site/src/docs/api-design/logs.md @@ -8,7 +8,7 @@ # {{ title }} -This document describes the runtime logs and warnings from the Element libraries. These warnings help identify potential issues with component usage and implementation. +This document describes runtime logs and static usage checks from the Element libraries and ESLint rules. These diagnostics help identify potential issues with component usage and implementation. ## Duplicate Package Version @@ -18,7 +18,11 @@ This warning appears when the application bundles or imports many versions of El ## Excessive Instance Limit -This warning appears when the DOM contains too many instances of a component. This can impact application performance. Common components that can have an excessive amount of instances include `nve-grid-row/cell` and `nve-tree-item`. +In development, this warning appears when more than 50 instances of the same `nve-tooltip`, `nve-toggletip`, `nve-dropdown`, `nve-dialog`, or `nve-toast` connect to the DOM. Each instance adds setup and lifecycle work. For example, a tooltip inside a repeated grid cell can create hundreds of popovers. + +The count includes dynamically generated instances. The warning fires once when the count exceeds the limit and can fire again after the count returns within the limit. The default limit is an advisory budget. + +The [no-excessive-popover-instances ESLint rule](/docs/lint/rules/no-excessive-popover-instances/) complements the runtime warning by detecting excessive literal counts and popovers inside recognizable repeated templates. To resolve this warning: @@ -27,9 +31,33 @@ To resolve this warning: - [Data Grid Performance Documentation](/docs/elements/data-grid/performance/) - [Dynamic Tree Documentation](/docs/elements/tree/#dynamic-tree) +## Page Shell Count + +Use one `nve-page` as the application shell. In development, a separate warning identifies when more than one page shell connects, including pages generated by JavaScript. The [page shell ESLint rule](/docs/lint/rules/no-multiple-page-elements/) checks templates with more than one shell and shells in detectable repetition. Independent templates and documentation examples have separate static counts. + +## Number and Byte Formatting + +The [number formatting options ESLint rule](/docs/lint/rules/no-invalid-number-format-options/) checks literal locales and fraction-digit options on `nve-format-number` and `nve-format-bytes`. It also checks integer digits, currency codes, unit identifiers, and required currency/unit options on `nve-format-number`. The existing attribute-value rule checks enum options. + +The rule uses the lint environment's `Intl.NumberFormat` to validate numeric coercion and digit ordering. Dynamic bindings, encoded values, document language defaults, and JavaScript assignments remain outside static coverage. + +Both components preserve the original input when formatting fails. They keep exception handling and fallback behavior without logging a runtime warning. + +## Date and Relative Time Formatting + +The [date formatting options ESLint rule](/docs/lint/rules/no-invalid-date-format-options/) checks literal locales on `nve-format-datetime` and `nve-format-relative-time`. It also checks literal `time-zone` values on `nve-format-datetime` using the lint environment's `Intl.DateTimeFormat` data. The rule accepts supported zone aliases and offsets. Empty time zones use the component's default, and preset date/time styles keep precedence over granular options. The existing attribute-value rule checks enum options. + +Locale parsing follows the same shared check as number and byte formatting. Dynamic bindings, encoded values, document language defaults, and JavaScript assignments remain outside static coverage. + +Both date components preserve the original input when formatting fails, with no runtime warning. Date validation, exception handling, and relative time updates continue to run. + ## Invalid Parent -This warning occurs when a consumer places a component as a child of an unsupported parent element. Example, a `nve-card-header` element can only appear as a direct child of `nve-card`. +The [parent element ESLint rule](/docs/lint/rules/no-invalid-parent-elements/) checks supported direct parents for components. For example, `nve-card-header` requires `nve-card` as its direct parent. + +Runtime development warnings remain for `nve-menu-item`, `nve-tabs-item`, `nve-tabs-item-group`, `nve-steps-item`, and `nve-tree-node`. These controls depend on their owners for selection, keyboard navigation, or accessibility semantics. The runtime check also covers controls created by JavaScript when static placement is unknown. + +Card sections, popover headers and footers, drawer content, and page panels use static parent checks only. Their parent contracts describe layout and slot composition. Valid @@ -41,20 +69,24 @@ This warning occurs when a consumer places a component as a child of an unsuppor Invalid + + ```html
``` -To resolve this warning: + + +To resolve this diagnostic: - Place the component directly under the correct parent element - Review the component's API for parent element requirements ## Invalid Slotted Children -This warning appears when invalid elements are slotted into a component. For example, `nve-tree` and `nve-grid-row` require specific direct children. +The [no-invalid-slotted-elements ESLint rule](/docs/lint/rules/no-invalid-slotted-elements/) reports invalid direct children in a component's default slot. For example, `nve-tree` and `nve-grid-row` require specific direct children. Run ESLint to check these relationships during development. Valid @@ -70,7 +102,7 @@ This warning appears when invalid elements are slotted into a component. For exa Invalid - + ```html @@ -86,9 +118,9 @@ This warning appears when invalid elements are slotted into a component. For exa ``` - + -To resolve this warning: +To resolve this lint error: - Check the component's documentation for allowed slotted elements - Place only supported elements in slots diff --git a/projects/site/src/docs/foundations/layout/index.md b/projects/site/src/docs/foundations/layout/index.md index 8dea813618..33f41a5e5f 100644 --- a/projects/site/src/docs/foundations/layout/index.md +++ b/projects/site/src/docs/foundations/layout/index.md @@ -315,14 +315,14 @@ You can compose layout attributes to create sophisticated designs:
diff --git a/projects/site/src/docs/lint/rules.test.ts b/projects/site/src/docs/lint/rules.test.ts index aded836476..e26e96ea97 100644 --- a/projects/site/src/docs/lint/rules.test.ts +++ b/projects/site/src/docs/lint/rules.test.ts @@ -5,9 +5,9 @@ import { data, renderRulePage } from './rules/index.11ty.js'; describe('lint rule documentation', () => { it('should create one sorted record for every registered rule', () => { - expect(createLintRules()).toHaveLength(43); + expect(createLintRules()).toHaveLength(50); expect(lintRules.map(rule => rule.name)).toEqual([...lintRules.map(rule => rule.name)].sort()); - expect(new Set(lintRules.map(rule => rule.name)).size).toBe(43); + expect(new Set(lintRules.map(rule => rule.name)).size).toBe(50); }); it('should merge language registrations for shared rules', () => { From 8dd7440742911651b1bdbf22f3dd189d93e002b4 Mon Sep 17 00:00:00 2001 From: Cory Rylan Date: Mon, 26 Oct 2026 20:30:21 -0500 Subject: [PATCH 2/2] fix(core): migrate runtime to static lint checks Signed-off-by: Cory Rylan --- projects/core/eslint-suppressions.json | 4 +- .../core/src/button-group/button-group.ts | 4 +- projects/core/src/card/card.ts | 3 - projects/core/src/dialog/dialog-footer.ts | 3 +- projects/core/src/dialog/dialog-header.ts | 3 +- projects/core/src/drawer/drawer-content.ts | 3 +- projects/core/src/drawer/drawer-footer.ts | 3 +- projects/core/src/drawer/drawer-header.ts | 3 +- projects/core/src/dropdown/dropdown-footer.ts | 3 +- projects/core/src/dropdown/dropdown-header.ts | 3 +- .../src/format-bytes/format-bytes.test.ts | 60 +++-- .../core/src/format-bytes/format-bytes.ts | 25 +-- .../format-datetime/format-datetime.test.ts | 87 +++++++- .../src/format-datetime/format-datetime.ts | 6 +- .../src/format-number/format-number.test.ts | 71 +++++- .../core/src/format-number/format-number.ts | 10 +- .../format-relative-time.test.ts | 82 ++++++- .../format-relative-time.ts | 6 +- projects/core/src/icon/icon.test.visual.ts | 16 +- .../controllers/audit.controller.test.ts | 211 +++++++++++++++++- .../internal/controllers/audit.controller.ts | 85 ++++--- .../src/internal/utils/audit-logs.test.ts | 10 +- .../core/src/internal/utils/audit-logs.ts | 4 +- .../core/src/internal/utils/audit.test.ts | 98 +------- projects/core/src/internal/utils/audit.ts | 10 - .../page/page-panel/page-panel.test.visual.ts | 2 + .../core/src/page/page-panel/page-panel.ts | 2 - projects/core/src/page/page.ts | 2 +- .../core/src/toggletip/toggletip-footer.ts | 3 +- .../core/src/toggletip/toggletip-header.ts | 3 +- projects/core/src/tree/tree.examples.ts | 4 +- 31 files changed, 559 insertions(+), 270 deletions(-) diff --git a/projects/core/eslint-suppressions.json b/projects/core/eslint-suppressions.json index fa9bb4a206..bf013b95c3 100644 --- a/projects/core/eslint-suppressions.json +++ b/projects/core/eslint-suppressions.json @@ -11,7 +11,7 @@ }, "src/format-datetime/format-datetime.test.ts": { "@typescript-eslint/no-unnecessary-type-assertion": { - "count": 27 + "count": 26 } }, "src/format-number/format-number.test.ts": { @@ -21,7 +21,7 @@ }, "src/format-relative-time/format-relative-time.test.ts": { "@typescript-eslint/no-unnecessary-type-assertion": { - "count": 40 + "count": 39 } }, "src/internal/controllers/audit.controller.ts": { diff --git a/projects/core/src/button-group/button-group.ts b/projects/core/src/button-group/button-group.ts index 1d874beb97..b521f0bb8d 100644 --- a/projects/core/src/button-group/button-group.ts +++ b/projects/core/src/button-group/button-group.ts @@ -25,7 +25,7 @@ import globalStyles from './button-group.global.css?inline'; * @documentation https://nvidia.github.io/elements/docs/elements/button-group/ * @since 0.16.0 * @entrypoint \@nvidia-elements/core/button-group - * @slot - default slot for `nve-button` or `nve-icon-button` + * @slot - default slot for `nve-button`, `nve-icon-button`, `nve-divider`, `nve-media-seek-button`, or `nve-media-pause-button` * @cssprop --background * @cssprop --border-radius * @cssprop --padding @@ -66,7 +66,7 @@ export class ButtonGroup extends LitElement { static readonly metadata = { tag: 'nve-button-group', version: '0.0.0', - children: ['nve-button', 'nve-icon-button', 'nve-divider'] + children: ['nve-button', 'nve-icon-button', 'nve-divider', 'nve-media-seek-button', 'nve-media-pause-button'] }; /** @private */ diff --git a/projects/core/src/card/card.ts b/projects/core/src/card/card.ts index 39310ba3a6..ac3b119e5f 100644 --- a/projects/core/src/card/card.ts +++ b/projects/core/src/card/card.ts @@ -62,7 +62,6 @@ export class Card extends LitElement implements ContainerElement { * @cssprop --gap * @aria https://github.com/w3c/aria-practices/issues */ -@audit() export class CardHeader extends LitElement { static styles = useStyles([cardHeaderStyleSheet]); @@ -96,7 +95,6 @@ export class CardHeader extends LitElement { * @cssprop --padding * @aria https://github.com/w3c/aria-practices/issues */ -@audit() export class CardContent extends LitElement { static styles = useStyles([cardContentStyleSheet]); @@ -124,7 +122,6 @@ export class CardContent extends LitElement { * @cssprop --border-top * @aria https://github.com/w3c/aria-practices/issues */ -@audit() export class CardFooter extends LitElement { static styles = useStyles([cardFooterStyleSheet]); diff --git a/projects/core/src/dialog/dialog-footer.ts b/projects/core/src/dialog/dialog-footer.ts index bd2197fc49..041df46510 100644 --- a/projects/core/src/dialog/dialog-footer.ts +++ b/projects/core/src/dialog/dialog-footer.ts @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import { html, LitElement } from 'lit'; -import { audit, useStyles } from '@nvidia-elements/core/internal'; +import { useStyles } from '@nvidia-elements/core/internal'; import styles from './dialog-footer.css?inline'; /** @@ -17,7 +17,6 @@ import styles from './dialog-footer.css?inline'; * @cssprop --gap * @aria https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/ */ -@audit() export class DialogFooter extends LitElement { static styles = useStyles([styles]); diff --git a/projects/core/src/dialog/dialog-header.ts b/projects/core/src/dialog/dialog-header.ts index 655e4a21e4..d2e503451c 100644 --- a/projects/core/src/dialog/dialog-header.ts +++ b/projects/core/src/dialog/dialog-header.ts @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import { html, LitElement } from 'lit'; -import { audit, useStyles } from '@nvidia-elements/core/internal'; +import { useStyles } from '@nvidia-elements/core/internal'; import styles from './dialog-header.css?inline'; /** @@ -16,7 +16,6 @@ import styles from './dialog-header.css?inline'; * @cssprop --padding * @aria https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/ */ -@audit() export class DialogHeader extends LitElement { static styles = useStyles([styles]); diff --git a/projects/core/src/drawer/drawer-content.ts b/projects/core/src/drawer/drawer-content.ts index 5ef196130e..6786f11e21 100644 --- a/projects/core/src/drawer/drawer-content.ts +++ b/projects/core/src/drawer/drawer-content.ts @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import { html, LitElement } from 'lit'; -import { audit, useStyles } from '@nvidia-elements/core/internal'; +import { useStyles } from '@nvidia-elements/core/internal'; import styles from './drawer-content.css?inline'; /** @@ -16,7 +16,6 @@ import styles from './drawer-content.css?inline'; * @cssprop --width * @aria https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/ */ -@audit() export class DrawerContent extends LitElement { static styles = useStyles([styles]); diff --git a/projects/core/src/drawer/drawer-footer.ts b/projects/core/src/drawer/drawer-footer.ts index 4050d11198..73a49006c4 100644 --- a/projects/core/src/drawer/drawer-footer.ts +++ b/projects/core/src/drawer/drawer-footer.ts @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import { html, LitElement } from 'lit'; -import { audit, useStyles } from '@nvidia-elements/core/internal'; +import { useStyles } from '@nvidia-elements/core/internal'; import styles from './drawer-footer.css?inline'; /** @@ -18,7 +18,6 @@ import styles from './drawer-footer.css?inline'; * @cssprop --min-height * @aria https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/ */ -@audit() export class DrawerFooter extends LitElement { static styles = useStyles([styles]); diff --git a/projects/core/src/drawer/drawer-header.ts b/projects/core/src/drawer/drawer-header.ts index f3e259b66d..9fedf619b9 100644 --- a/projects/core/src/drawer/drawer-header.ts +++ b/projects/core/src/drawer/drawer-header.ts @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import { html, LitElement } from 'lit'; -import { audit, useStyles } from '@nvidia-elements/core/internal'; +import { useStyles } from '@nvidia-elements/core/internal'; import styles from './drawer-header.css?inline'; /** @@ -17,7 +17,6 @@ import styles from './drawer-header.css?inline'; * @cssprop --padding * @aria https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/ */ -@audit() export class DrawerHeader extends LitElement { static styles = useStyles([styles]); diff --git a/projects/core/src/dropdown/dropdown-footer.ts b/projects/core/src/dropdown/dropdown-footer.ts index 90fcda8794..58c9afee09 100644 --- a/projects/core/src/dropdown/dropdown-footer.ts +++ b/projects/core/src/dropdown/dropdown-footer.ts @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import { html, LitElement } from 'lit'; -import { audit, useStyles } from '@nvidia-elements/core/internal'; +import { useStyles } from '@nvidia-elements/core/internal'; import styles from './dropdown-footer.css?inline'; /** @@ -17,7 +17,6 @@ import styles from './dropdown-footer.css?inline'; * @cssprop --gap * @aria https://www.w3.org/WAI/ARIA/apg/patterns/menubar/ */ -@audit() export class DropdownFooter extends LitElement { static styles = useStyles([styles]); diff --git a/projects/core/src/dropdown/dropdown-header.ts b/projects/core/src/dropdown/dropdown-header.ts index c267282e13..56286546be 100644 --- a/projects/core/src/dropdown/dropdown-header.ts +++ b/projects/core/src/dropdown/dropdown-header.ts @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import { html, LitElement } from 'lit'; -import { audit, useStyles } from '@nvidia-elements/core/internal'; +import { useStyles } from '@nvidia-elements/core/internal'; import styles from './dropdown-header.css?inline'; /** @@ -16,7 +16,6 @@ import styles from './dropdown-header.css?inline'; * @cssprop --padding * @aria https://www.w3.org/WAI/ARIA/apg/patterns/menubar/ */ -@audit() export class DropdownHeader extends LitElement { static styles = useStyles([styles]); diff --git a/projects/core/src/format-bytes/format-bytes.test.ts b/projects/core/src/format-bytes/format-bytes.test.ts index c3d20814a2..a51a4fc6f3 100644 --- a/projects/core/src/format-bytes/format-bytes.test.ts +++ b/projects/core/src/format-bytes/format-bytes.test.ts @@ -228,51 +228,81 @@ describe(FormatBytes.metadata.tag, () => { ['display', 'invalid'], ['unit', 'invalid'], ['unit-display', 'invalid'] - ])('should preserve input for an invalid %s option', async (attribute, value) => { + ])('should preserve input without warning for an invalid %s option', async (attribute, value) => { const warn = vi.spyOn(LogService, 'warn').mockImplementation(() => undefined); element.setAttribute(attribute, value); await elementIsStable(element); expect(renderedText(element)).toBe('1048576'); - expect(warn).toHaveBeenCalledOnce(); + expect(warn).not.toHaveBeenCalled(); }); - it('should preserve and warn for invalid numeric input', async () => { - const warn = vi.spyOn(LogService, 'warn').mockImplementation(() => undefined); - element.textContent = 'not-a-number'; - await elementIsStable(element); + it.each(['not-a-number', 'NaN', 'Infinity', '-Infinity', '1e309'])( + 'should preserve invalid numeric input %s without warning', + async value => { + const warn = vi.spyOn(LogService, 'warn').mockImplementation(() => undefined); + element.textContent = value; + await elementIsStable(element); - expect(renderedText(element)).toBe('not-a-number'); - expect(warn).toHaveBeenCalledWith('format-bytes: invalid numeric value "not-a-number"'); - }); + expect(renderedText(element)).toBe(value); + expect(warn).not.toHaveBeenCalled(); + } + ); - it('should preserve and warn for an invalid value attribute', async () => { + it('should preserve an invalid value attribute without warning', async () => { const warn = vi.spyOn(LogService, 'warn').mockImplementation(() => undefined); element.setAttribute('value', 'not-a-number'); await elementIsStable(element); expect(renderedData(element)?.getAttribute('value')).toBe('not-a-number'); expect(renderedText(element)).toBe('not-a-number'); - expect(warn).toHaveBeenCalledWith('format-bytes: invalid numeric value "not-a-number"'); + expect(warn).not.toHaveBeenCalled(); }); - it('should preserve and warn for a NaN value property', async () => { + it('should preserve a NaN value property without warning', async () => { const warn = vi.spyOn(LogService, 'warn').mockImplementation(() => undefined); element.value = Number.NaN; await elementIsStable(element); expect(renderedData(element)?.getAttribute('value')).toBe('NaN'); expect(renderedText(element)).toBe('NaN'); - expect(warn).toHaveBeenCalledWith('format-bytes: invalid numeric value "NaN"'); + expect(warn).not.toHaveBeenCalled(); }); - it('should preserve input for invalid fraction digit options', async () => { + it('should preserve input silently for invalid fraction digit options', async () => { const warn = vi.spyOn(LogService, 'warn').mockImplementation(() => undefined); element.minimumFractionDigits = 3; element.maximumFractionDigits = 2; await elementIsStable(element); expect(renderedText(element)).toBe('1048576'); - expect(warn).toHaveBeenCalledOnce(); + expect(warn).not.toHaveBeenCalled(); + }); + + it.each([ + ['locale', 'en_US'], + ['minimum-fraction-digits', '-1'], + ['maximum-fraction-digits', '101'], + ['maximum-fraction-digits', 'NaN'], + ['maximum-fraction-digits', 'Infinity'] + ])('should preserve input without warning for invalid %s', async (attribute, value) => { + const warn = vi.spyOn(LogService, 'warn').mockImplementation(() => undefined); + element.setAttribute(attribute, value); + await elementIsStable(element); + + expect(renderedText(element)).toBe('1048576'); + expect(warn).not.toHaveBeenCalled(); + }); + + it('should resume formatting after correcting an invalid locale', async () => { + const warn = vi.spyOn(LogService, 'warn').mockImplementation(() => undefined); + element.locale = 'en_US'; + await elementIsStable(element); + expect(renderedText(element)).toBe('1048576'); + + element.locale = 'en-US'; + await elementIsStable(element); + expect(renderedText(element)).toBe('1.05 MB'); + expect(warn).not.toHaveBeenCalled(); }); }); diff --git a/projects/core/src/format-bytes/format-bytes.ts b/projects/core/src/format-bytes/format-bytes.ts index 344cf8e8a8..85feaa291a 100644 --- a/projects/core/src/format-bytes/format-bytes.ts +++ b/projects/core/src/format-bytes/format-bytes.ts @@ -3,7 +3,7 @@ import { html, LitElement } from 'lit'; import { property } from 'lit/decorators/property.js'; -import { LogService, typeSSR, useStyles } from '@nvidia-elements/core/internal'; +import { typeSSR, useStyles } from '@nvidia-elements/core/internal'; import styles from './format-bytes.css?inline'; export type FormatBytesDisplay = 'decimal' | 'binary'; @@ -122,7 +122,6 @@ export class FormatBytes extends LitElement { const numericValue = Number(rawValue); if (Number.isFinite(numericValue)) return numericValue; - LogService.warn(`format-bytes: invalid numeric value "${rawValue}"`); return null; } @@ -144,24 +143,8 @@ export class FormatBytes extends LitElement { return Math.abs(convertedValue) === 1 ? labels.singular : labels.plural; } - #warnInvalidOption(name: string, value: unknown): void { - LogService.warn(`format-bytes: invalid ${name} value "${String(value)}"`); - } - #hasValidConfiguration(): boolean { - if (!isDisplay(this.display)) { - this.#warnInvalidOption('display', this.display); - return false; - } - if (!isUnitDisplay(this.unitDisplay)) { - this.#warnInvalidOption('unit-display', this.unitDisplay); - return false; - } - if (this.unit !== undefined && !isUnit(this.unit)) { - this.#warnInvalidOption('unit', this.unit); - return false; - } - return true; + return isDisplay(this.display) && isUnitDisplay(this.unitDisplay) && (this.unit === undefined || isUnit(this.unit)); } get #numberFormatOptions(): Intl.NumberFormatOptions { @@ -201,9 +184,7 @@ export class FormatBytes extends LitElement { try { const roundedValue = this.#roundNumber(convertedValue); return `${this.#formatNumber(roundedValue)} ${this.#formatLabel(resolvedUnit, roundedValue)}`; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - LogService.warn(`format-bytes: ${message}`); + } catch { return rawValue; } } diff --git a/projects/core/src/format-datetime/format-datetime.test.ts b/projects/core/src/format-datetime/format-datetime.test.ts index 21ef10e4a5..bb66afeff8 100644 --- a/projects/core/src/format-datetime/format-datetime.test.ts +++ b/projects/core/src/format-datetime/format-datetime.test.ts @@ -2,9 +2,10 @@ // SPDX-License-Identifier: Apache-2.0 import { html } from 'lit'; -import { describe, expect, it, beforeEach, afterEach } from 'vitest'; +import { describe, expect, it, beforeEach, afterEach, vi } from 'vitest'; import { createFixture, elementIsStable, removeFixture } from '@internals/testing'; import { FormatDatetime } from '@nvidia-elements/core/format-datetime'; +import { LogService } from '@nvidia-elements/core/internal'; import '@nvidia-elements/core/format-datetime/define.js'; describe(FormatDatetime.metadata.tag, () => { @@ -24,6 +25,7 @@ describe(FormatDatetime.metadata.tag, () => { afterEach(() => { document.documentElement.lang = originalDocumentLang; removeFixture(fixture); + vi.restoreAllMocks(); }); it('should define element', () => { @@ -315,18 +317,93 @@ describe(FormatDatetime.metadata.tag, () => { expect(time!.textContent!.trim()).toBe('7/28/23'); }); - it('should show raw string for invalid date', async () => { - element.date = 'not-a-date'; + it.each(['not-a-date', '2026-13-01', '2026-10-26T25:00:00Z', '+999999-01-01'])( + 'should preserve invalid date %s without warning', + async value => { + const warn = vi.spyOn(LogService, 'warn').mockImplementation(() => undefined); + element.date = value; + await elementIsStable(element); + + const time = element.shadowRoot!.querySelector('time'); + expect(time?.getAttribute('datetime')).toBe(value); + expect(time?.textContent.trim()).toBe(value); + expect(warn).not.toHaveBeenCalled(); + } + ); + + it('should preserve invalid date attributes without warning', async () => { + const warn = vi.spyOn(LogService, 'warn').mockImplementation(() => undefined); + element.setAttribute('date', 'not-a-date'); await elementIsStable(element); + const time = element.shadowRoot!.querySelector('time'); - expect(time!.textContent!.trim()).toBe('not-a-date'); + expect(time?.getAttribute('datetime')).toBe('not-a-date'); + expect(time?.textContent.trim()).toBe('not-a-date'); + expect(warn).not.toHaveBeenCalled(); }); - it('should fall back to raw string for invalid Intl options', async () => { + it('should preserve invalid text content without warning', async () => { + const warn = vi.spyOn(LogService, 'warn').mockImplementation(() => undefined); + element.textContent = 'not-a-date'; + await elementIsStable(element); + + const time = element.shadowRoot!.querySelector('time'); + expect(time?.getAttribute('datetime')).toBe('not-a-date'); + expect(time?.textContent.trim()).toBe('not-a-date'); + expect(warn).not.toHaveBeenCalled(); + }); + + it('should fall back silently to raw string for invalid Intl options', async () => { + const warn = vi.spyOn(LogService, 'warn').mockImplementation(() => undefined); element.setAttribute('date-style', 'banana'); await elementIsStable(element); const time = element.shadowRoot!.querySelector('time'); expect(time!.textContent!.trim()).toBe('2023-07-28T04:20:17.434Z'); + expect(warn).not.toHaveBeenCalled(); + }); + + it.each([ + ['locale', 'en_US'], + ['locale', ''], + ['time-zone', 'Invalid/Zone'], + ['time-zone', '+24:00'], + ['time-zone', ' '] + ])('should preserve input without warning for invalid %s=%s', async (attribute, value) => { + const warn = vi.spyOn(LogService, 'warn').mockImplementation(() => undefined); + element.setAttribute(attribute, value); + await elementIsStable(element); + + const time = element.shadowRoot?.querySelector('time'); + expect(time?.textContent.trim()).toBe('2023-07-28T04:20:17.434Z'); + expect(time?.getAttribute('datetime')).toBe('2023-07-28T04:20:17.434Z'); + expect(warn).not.toHaveBeenCalled(); + }); + + it.each([ + ['locale', 'en_US', 'en-US'], + ['time-zone', 'Invalid/Zone', 'UTC'] + ])('should resume formatting after correcting %s', async (attribute, invalid, valid) => { + const warn = vi.spyOn(LogService, 'warn').mockImplementation(() => undefined); + element.setAttribute(attribute, invalid); + await elementIsStable(element); + expect(element.shadowRoot?.querySelector('time')?.textContent.trim()).toBe('2023-07-28T04:20:17.434Z'); + + element.setAttribute(attribute, valid); + await elementIsStable(element); + expect(element.shadowRoot?.querySelector('time')?.textContent.trim()).toBe('July 28, 2023'); + expect(warn).not.toHaveBeenCalled(); + }); + + it('should use the default time zone for an empty time zone option', async () => { + const warn = vi.spyOn(LogService, 'warn').mockImplementation(() => undefined); + element.timeZone = ''; + await elementIsStable(element); + const expected = new Intl.DateTimeFormat('en-US', { dateStyle: 'long' }).format( + new Date('2023-07-28T04:20:17.434Z') + ); + + expect(element.shadowRoot?.querySelector('time')?.textContent.trim()).toBe(expected); + expect(warn).not.toHaveBeenCalled(); }); it('should render empty for no content', async () => { diff --git a/projects/core/src/format-datetime/format-datetime.ts b/projects/core/src/format-datetime/format-datetime.ts index 4795ad826f..a89412464c 100644 --- a/projects/core/src/format-datetime/format-datetime.ts +++ b/projects/core/src/format-datetime/format-datetime.ts @@ -3,7 +3,7 @@ import { html, LitElement } from 'lit'; import { property } from 'lit/decorators/property.js'; -import { useStyles, typeSSR, LogService } from '@nvidia-elements/core/internal'; +import { useStyles, typeSSR } from '@nvidia-elements/core/internal'; import styles from './format-datetime.css?inline'; /** @@ -111,8 +111,7 @@ export class FormatDatetime extends LitElement { try { return new Intl.DateTimeFormat(this.#resolvedLocale, this.#formatOptions).format(date); - } catch (e) { - LogService.warn(`format-datetime: ${(e as Error).message}`); + } catch { return iso; } } @@ -155,7 +154,6 @@ export class FormatDatetime extends LitElement { const date = new Date(iso); if (!isNaN(date.getTime())) return date; - LogService.warn(`format-datetime: invalid date value "${iso}"`); return null; } diff --git a/projects/core/src/format-number/format-number.test.ts b/projects/core/src/format-number/format-number.test.ts index 7ea24d4d22..1e3b42f93c 100644 --- a/projects/core/src/format-number/format-number.test.ts +++ b/projects/core/src/format-number/format-number.test.ts @@ -290,26 +290,38 @@ describe(FormatNumber.metadata.tag, () => { warnSpy.mockRestore(); }); - it('should show raw string for invalid number input', async () => { - element.number = 'not-a-number'; + it.each(['not-a-number', 'NaN', 'Infinity', '-Infinity', '1e309'])( + 'should preserve invalid number %s without warning', + async value => { + element.number = value; + await elementIsStable(element); + + const data = element.shadowRoot!.querySelector('data'); + expect(data!.textContent!.trim()).toBe(value); + expect(warnSpy).not.toHaveBeenCalled(); + } + ); + + it('should preserve invalid text content without warning', async () => { + element.textContent = 'not-a-number'; await elementIsStable(element); const data = element.shadowRoot!.querySelector('data'); - expect(data!.textContent!.trim()).toBe('not-a-number'); - expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('invalid numeric value')); + expect(data!.textContent.trim()).toBe('not-a-number'); + expect(warnSpy).not.toHaveBeenCalled(); }); - it('should fall back for invalid Intl options', async () => { + it('should fall back silently for invalid Intl options', async () => { element.number = '1234.56'; element.setAttribute('format-style', 'banana'); await elementIsStable(element); const data = element.shadowRoot!.querySelector('data'); expect(data!.textContent!.trim()).toBe('1234.56'); - expect(warnSpy).toHaveBeenCalled(); + expect(warnSpy).not.toHaveBeenCalled(); }); - it('should fall back when currency is missing', async () => { + it('should fall back silently when currency is missing', async () => { element.number = '1234.56'; element.formatStyle = 'currency'; element.currency = undefined; @@ -317,10 +329,10 @@ describe(FormatNumber.metadata.tag, () => { const data = element.shadowRoot!.querySelector('data'); expect(data!.textContent!.trim()).toBe('1234.56'); - expect(warnSpy).toHaveBeenCalled(); + expect(warnSpy).not.toHaveBeenCalled(); }); - it('should fall back when unit is missing', async () => { + it('should fall back silently when unit is missing', async () => { element.number = '12'; element.formatStyle = 'unit'; element.unit = undefined; @@ -328,7 +340,46 @@ describe(FormatNumber.metadata.tag, () => { const data = element.shadowRoot!.querySelector('data'); expect(data!.textContent!.trim()).toBe('12'); - expect(warnSpy).toHaveBeenCalled(); + expect(warnSpy).not.toHaveBeenCalled(); + }); + + it.each([ + ['locale', 'en_US'], + ['currency', 'US'], + ['unit', 'invalid'], + ['minimum-fraction-digits', '-1'], + ['maximum-fraction-digits', '101'], + ['maximum-fraction-digits', 'NaN'], + ['minimum-integer-digits', '0'] + ])('should preserve input without warning for invalid %s', async (attribute, value) => { + element.number = '1234.56'; + element.setAttribute(attribute, value); + await elementIsStable(element); + + expect(element.shadowRoot!.querySelector('data')!.textContent.trim()).toBe('1234.56'); + expect(warnSpy).not.toHaveBeenCalled(); + }); + + it('should preserve input silently for reversed fraction digit bounds', async () => { + element.number = '1234.56'; + element.minimumFractionDigits = 3; + element.maximumFractionDigits = 2; + await elementIsStable(element); + + expect(element.shadowRoot!.querySelector('data')!.textContent.trim()).toBe('1234.56'); + expect(warnSpy).not.toHaveBeenCalled(); + }); + + it('should resume formatting after correcting an invalid locale', async () => { + element.number = '1234.56'; + element.locale = 'en_US'; + await elementIsStable(element); + expect(element.shadowRoot!.querySelector('data')!.textContent.trim()).toBe('1234.56'); + + element.locale = 'en-US'; + await elementIsStable(element); + expect(element.shadowRoot!.querySelector('data')!.textContent.trim()).toBe('1,234.56'); + expect(warnSpy).not.toHaveBeenCalled(); }); it('should render empty for no content', async () => { diff --git a/projects/core/src/format-number/format-number.ts b/projects/core/src/format-number/format-number.ts index c28efa7a8d..77d4c00578 100644 --- a/projects/core/src/format-number/format-number.ts +++ b/projects/core/src/format-number/format-number.ts @@ -3,7 +3,7 @@ import { html, LitElement } from 'lit'; import { property } from 'lit/decorators/property.js'; -import { useStyles, typeSSR, LogService } from '@nvidia-elements/core/internal'; +import { useStyles, typeSSR } from '@nvidia-elements/core/internal'; import styles from './format-number.css?inline'; export type FormatNumberStyle = 'decimal' | 'currency' | 'percent' | 'unit'; @@ -114,12 +114,12 @@ export class FormatNumber extends LitElement { useGrouping?: 'auto' | 'always' | 'min2' | 'true' | 'false' | boolean; /** - * Pad fraction output to at least this many digits (0-20). + * Pad fraction output to at least this many digits (0-100). */ @property({ type: Number, attribute: 'minimum-fraction-digits' }) minimumFractionDigits?: number; /** - * Round fraction output to at most this many digits (0-20). + * Round fraction output to at most this many digits (0-100). */ @property({ type: Number, attribute: 'maximum-fraction-digits' }) maximumFractionDigits?: number; @@ -143,7 +143,6 @@ export class FormatNumber extends LitElement { const numericValue = Number(raw); if (Number.isFinite(numericValue)) return numericValue; - LogService.warn(`format-number: invalid numeric value "${raw}"`); return null; } @@ -177,8 +176,7 @@ export class FormatNumber extends LitElement { try { return new Intl.NumberFormat(this.#resolvedLocale, this.#formatOptions).format(numericValue); - } catch (e) { - LogService.warn(`format-number: ${(e as Error).message}`); + } catch { return raw; } } diff --git a/projects/core/src/format-relative-time/format-relative-time.test.ts b/projects/core/src/format-relative-time/format-relative-time.test.ts index 970a616be1..47aa799f8f 100644 --- a/projects/core/src/format-relative-time/format-relative-time.test.ts +++ b/projects/core/src/format-relative-time/format-relative-time.test.ts @@ -5,6 +5,7 @@ import { html } from 'lit'; import { describe, expect, it, beforeEach, afterEach, vi } from 'vitest'; import { createFixture, elementIsStable, removeFixture } from '@internals/testing'; import { FormatRelativeTime } from '@nvidia-elements/core/format-relative-time'; +import { LogService } from '@nvidia-elements/core/internal'; import '@nvidia-elements/core/format-relative-time/define.js'; const NOW = new Date('2023-07-28T12:00:00.000Z'); @@ -30,6 +31,7 @@ describe(FormatRelativeTime.metadata.tag, () => { vi.useRealTimers(); document.documentElement.lang = originalDocumentLang; removeFixture(fixture); + vi.restoreAllMocks(); }); it('should define element', () => { @@ -384,11 +386,41 @@ describe(FormatRelativeTime.metadata.tag, () => { }); describe('fallback', () => { - it('should show raw string for invalid date', async () => { - element.date = 'not-a-date'; + it.each(['not-a-date', '2026-13-01', '2026-10-26T25:00:00Z', '+999999-01-01'])( + 'should preserve invalid date %s without warning', + async value => { + const warn = vi.spyOn(LogService, 'warn').mockImplementation(() => undefined); + element.date = value; + await elementIsStable(element); + + const time = element.shadowRoot!.querySelector('time'); + expect(time?.getAttribute('datetime')).toBe(value); + expect(time?.textContent.trim()).toBe(value); + expect(warn).not.toHaveBeenCalled(); + } + ); + + it('should preserve invalid date attributes without warning', async () => { + const warn = vi.spyOn(LogService, 'warn').mockImplementation(() => undefined); + element.setAttribute('date', 'not-a-date'); + await elementIsStable(element); + + const time = element.shadowRoot!.querySelector('time'); + expect(time?.getAttribute('datetime')).toBe('not-a-date'); + expect(time?.textContent.trim()).toBe('not-a-date'); + expect(warn).not.toHaveBeenCalled(); + }); + + it('should preserve invalid text content without warning', async () => { + const warn = vi.spyOn(LogService, 'warn').mockImplementation(() => undefined); + element.date = undefined; + element.textContent = 'not-a-date'; await elementIsStable(element); + const time = element.shadowRoot!.querySelector('time'); - expect(time!.textContent!.trim()).toBe('not-a-date'); + expect(time?.getAttribute('datetime')).toBe('not-a-date'); + expect(time?.textContent.trim()).toBe('not-a-date'); + expect(warn).not.toHaveBeenCalled(); }); it('should render empty for no content', async () => { @@ -402,13 +434,55 @@ describe(FormatRelativeTime.metadata.tag, () => { expect(time!.textContent!.trim()).toBe(''); }); - it('should fall back to raw string for invalid options', async () => { + it('should fall back silently to raw string for invalid options', async () => { + const warn = vi.spyOn(LogService, 'warn').mockImplementation(() => undefined); element.setAttribute('format-style', 'banana'); await elementIsStable(element); const time = element.shadowRoot!.querySelector('time'); expect(time!.textContent!.trim()).toBe('2023-07-27T12:00:00.000Z'); + expect(warn).not.toHaveBeenCalled(); + }); + + it.each([ + ['locale', 'en_US'], + ['locale', ''], + ['numeric', 'invalid'], + ['unit', 'invalid'] + ])('should preserve input without warning for invalid %s=%s', async (attribute, value) => { + const warn = vi.spyOn(LogService, 'warn').mockImplementation(() => undefined); + element.setAttribute(attribute, value); + await elementIsStable(element); + + const time = element.shadowRoot?.querySelector('time'); + expect(time?.textContent.trim()).toBe('2023-07-27T12:00:00.000Z'); + expect(time?.getAttribute('datetime')).toBe('2023-07-27T12:00:00.000Z'); + expect(warn).not.toHaveBeenCalled(); + }); + + it('should resume relative formatting after correcting an invalid locale', async () => { + const warn = vi.spyOn(LogService, 'warn').mockImplementation(() => undefined); + element.locale = 'en_US'; + await elementIsStable(element); + expect(element.shadowRoot?.querySelector('time')?.textContent.trim()).toBe('2023-07-27T12:00:00.000Z'); + + element.locale = 'en-US'; + await elementIsStable(element); + expect(element.shadowRoot?.querySelector('time')?.textContent.trim()).toBe('1 day ago'); + expect(warn).not.toHaveBeenCalled(); }); + it.each(['-271821-04-20T00:00:00.000Z', '+275760-09-13T00:00:00.000Z'])( + 'should format the valid date boundary %s without warning', + async value => { + const warn = vi.spyOn(LogService, 'warn').mockImplementation(() => undefined); + element.date = value; + await elementIsStable(element); + + expect(element.shadowRoot?.querySelector('time')?.textContent).toContain('years'); + expect(warn).not.toHaveBeenCalled(); + } + ); + it('should fall back to raw string for relative values outside Intl range', async () => { vi.useRealTimers(); const now = vi.spyOn(Date, 'now').mockReturnValue(Number.NEGATIVE_INFINITY); diff --git a/projects/core/src/format-relative-time/format-relative-time.ts b/projects/core/src/format-relative-time/format-relative-time.ts index 03d98b9836..c3433ecf40 100644 --- a/projects/core/src/format-relative-time/format-relative-time.ts +++ b/projects/core/src/format-relative-time/format-relative-time.ts @@ -3,7 +3,7 @@ import { html, LitElement } from 'lit'; import { property } from 'lit/decorators/property.js'; -import { useStyles, typeSSR, LogService } from '@nvidia-elements/core/internal'; +import { useStyles, typeSSR } from '@nvidia-elements/core/internal'; import styles from './format-relative-time.css?inline'; type TimeUnitOption = 'second' | 'minute' | 'hour' | 'day' | 'week' | 'month' | 'year'; @@ -117,7 +117,6 @@ export class FormatRelativeTime extends LitElement { const target = new Date(iso); if (isNaN(target.getTime())) { - LogService.warn(`format-relative-time: invalid date value "${iso}"`); return iso; } @@ -128,8 +127,7 @@ export class FormatRelativeTime extends LitElement { numeric: this.numeric, style: this.formatStyle }).format(value, resolvedUnit); - } catch (e) { - LogService.warn(`format-relative-time: ${(e as Error).message}`); + } catch { return iso; } } diff --git a/projects/core/src/icon/icon.test.visual.ts b/projects/core/src/icon/icon.test.visual.ts index 64f53af30d..1a914b411c 100644 --- a/projects/core/src/icon/icon.test.visual.ts +++ b/projects/core/src/icon/icon.test.visual.ts @@ -7,12 +7,12 @@ import { ICON_NAMES, ICON_NAMES_SOLID } from '@nvidia-elements/core/icon'; describe('icon visual', () => { test('icon should match visual baseline', async () => { - const report = await visualRunner.render('icon', template(), { network: true }); + const report = await visualRunner.render('icon', template(), { waitFor: waitForIcons }); expect(report.maxDiffPercentage).toBeLessThan(0.1); }); test('icon should match visual baseline dark theme', async () => { - const report = await visualRunner.render('icon.dark', template('dark'), { network: true }); + const report = await visualRunner.render('icon.dark', template('dark'), { waitFor: waitForIcons }); expect(report.maxDiffPercentage).toBeLessThan(0.1); }); @@ -57,7 +57,7 @@ describe('icon visual', () => { .join('')}
`, - { network: true } + { waitFor: waitForIcons } ); expect(report.maxDiffPercentage).toBeLessThan(0.1); }); @@ -84,7 +84,7 @@ describe('icon visual', () => { ${ICONS_WITH_OPTICAL_SPACING.map(i => ``).join('')} `, - { network: true } + { waitFor: waitForIcons } ); expect(report.maxDiffPercentage).toBeLessThan(0.1); }); @@ -111,12 +111,18 @@ describe('icon visual', () => { ${ICON_NAMES_SOLID.map(i => ``).join('')} `, - { network: true } + { waitFor: waitForIcons } ); expect(report.maxDiffPercentage).toBeLessThan(0.1); }); }); +async function waitForIcons(waitForFunction: (...args: unknown[]) => Promise) { + await waitForFunction(() => + Array.from(document.querySelectorAll('nve-icon')).every(icon => icon.shadowRoot?.querySelector('svg')) + ); +} + function template(theme: '' | 'dark' = '') { return /* html */ `