diff --git a/src/components/editorAccessibility.resourceBoundary.test.ts b/src/components/editorAccessibility.resourceBoundary.test.ts new file mode 100644 index 00000000..ca76e62e --- /dev/null +++ b/src/components/editorAccessibility.resourceBoundary.test.ts @@ -0,0 +1,169 @@ +import { describe, expect, it } from 'vitest'; +import { + buildEditorAccessibilityAttributes, + type EditorAccessibilityOptions, +} from './editorAccessibility.js'; + +const ACCESSIBILITY_METADATA_MAX_CODE_UNITS = 65_536; +const INVALID_ACCESSIBILITY_METADATA_MESSAGE = + 'Accessibility metadata must be a string within the supported length.'; +const INVALID_LANGUAGE_TAG_MESSAGE = + 'Accessibility language tag must be valid BCP 47 metadata.'; + +function attributesWithAriaLabel(value: unknown): Record { + return buildEditorAccessibilityAttributes({ + defaultLabel: 'Editor', + editable: true, + ariaLabel: value as EditorAccessibilityOptions['ariaLabel'], + }); +} + +function attributesWithDefaultLabel(value: unknown): Record { + return buildEditorAccessibilityAttributes({ + defaultLabel: value as EditorAccessibilityOptions['defaultLabel'], + editable: true, + }); +} + +describe('editor accessibility metadata resource boundary', () => { + it('rejects non-string runtime metadata through one stable error contract', () => { + expect(() => attributesWithAriaLabel(42)).toThrowError( + new RangeError(INVALID_ACCESSIBILITY_METADATA_MESSAGE), + ); + }); + + it('rejects oversized metadata without reflecting its payload', () => { + const privateMarker = 'private-accessibility-marker'; + const value = `${privateMarker}${'x'.repeat(ACCESSIBILITY_METADATA_MAX_CODE_UNITS)}`; + let failure: unknown; + + try { + attributesWithAriaLabel(value); + } catch (error) { + failure = error; + } + + expect(failure).toEqual( + new RangeError(INVALID_ACCESSIBILITY_METADATA_MESSAGE), + ); + expect(String(failure)).not.toContain(privateMarker); + }); + + it('accepts metadata exactly at the local ceiling', () => { + const value = 'x'.repeat(ACCESSIBILITY_METADATA_MAX_CODE_UNITS); + + expect(attributesWithAriaLabel(value)['aria-label']).toBe(value); + }); + + it('rejects non-string required fallback labels through the stable metadata error contract', () => { + expect(() => attributesWithDefaultLabel(42)).toThrowError( + new RangeError(INVALID_ACCESSIBILITY_METADATA_MESSAGE), + ); + }); + + it('rejects oversized required fallback labels without reflecting their payload', () => { + const privateMarker = 'private-default-label-marker'; + const value = `${privateMarker}${'x'.repeat(ACCESSIBILITY_METADATA_MAX_CODE_UNITS)}`; + let failure: unknown; + + try { + attributesWithDefaultLabel(value); + } catch (error) { + failure = error; + } + + expect(failure).toEqual( + new RangeError(INVALID_ACCESSIBILITY_METADATA_MESSAGE), + ); + expect(String(failure)).not.toContain(privateMarker); + }); + + it('keeps blank optional metadata omitted after bounded normalization', () => { + expect( + buildEditorAccessibilityAttributes({ + defaultLabel: 'Editor', + editable: true, + placeholder: ' ', + languageTag: ' ', + ariaLabelledBy: ' ', + ariaDescribedBy: ' ', + ariaErrorMessage: ' ', + }), + ).toEqual({ + class: 'cwl-editor__content', + role: 'textbox', + 'aria-multiline': 'true', + 'aria-readonly': 'false', + 'aria-label': 'Editor', + }); + }); + + it('rejects malformed editor language tags without reflecting the payload', () => { + const privateMarker = 'private-invalid-language-marker'; + let failure: unknown; + + try { + buildEditorAccessibilityAttributes({ + defaultLabel: 'Editor', + editable: true, + languageTag: `${privateMarker} not-a-tag`, + }); + } catch (error) { + failure = error; + } + + expect(failure).toEqual(new RangeError(INVALID_LANGUAGE_TAG_MESSAGE)); + expect(String(failure)).not.toContain(privateMarker); + }); + + it('validates but does not canonicalize accepted language tag spelling', () => { + expect( + buildEditorAccessibilityAttributes({ + defaultLabel: 'Editor', + editable: true, + languageTag: ' EN-us ', + }).lang, + ).toBe('EN-us'); + }); + + it.each([ + 'x-private', + 'i-klingon', + 'zh-cmn-Hans-CN', + 'en-US-x-private', + ])('preserves well-formed RFC 5646 language tag %s', (languageTag) => { + expect( + buildEditorAccessibilityAttributes({ + defaultLabel: 'Editor', + editable: true, + languageTag: ` ${languageTag} `, + }).lang, + ).toBe(languageTag); + }); + + it.each(['zh-cmn-hak', 'zh-cmn-hak-yue'])( + 'rejects RFC 5646 tag with a permanently invalid extra extlang: %s', + (languageTag) => { + expect(() => + buildEditorAccessibilityAttributes({ + defaultLabel: 'Editor', + editable: true, + languageTag, + }), + ).toThrowError(new RangeError(INVALID_LANGUAGE_TAG_MESSAGE)); + }, + ); + + it.each(['de-DE-1901-1901', 'en-a-bbb-a-ccc'])( + 'rejects RFC 5646 tag with repeated variant or extension singleton: %s', + (languageTag) => { + expect(() => + buildEditorAccessibilityAttributes({ + defaultLabel: 'Editor', + editable: true, + languageTag, + }), + ).toThrowError(new RangeError(INVALID_LANGUAGE_TAG_MESSAGE)); + }, + ); +}); diff --git a/src/components/editorAccessibility.ts b/src/components/editorAccessibility.ts index f2cc1779..9599f299 100644 --- a/src/components/editorAccessibility.ts +++ b/src/components/editorAccessibility.ts @@ -1,5 +1,44 @@ import type { EditorTextDirection } from '../types.js'; +const ACCESSIBILITY_METADATA_MAX_CODE_UNITS = 65_536; +const INVALID_ACCESSIBILITY_METADATA_MESSAGE = + 'Accessibility metadata must be a string within the supported length.'; +const INVALID_LANGUAGE_TAG_MESSAGE = + 'Accessibility language tag must be valid BCP 47 metadata.'; + +const RFC_5646_GRANDFATHERED_TAGS = new Set([ + 'art-lojban', + 'cel-gaulish', + 'en-gb-oed', + 'i-ami', + 'i-bnn', + 'i-default', + 'i-enochian', + 'i-hak', + 'i-klingon', + 'i-lux', + 'i-mingo', + 'i-navajo', + 'i-pwn', + 'i-tao', + 'i-tay', + 'i-tsu', + 'no-bok', + 'no-nyn', + 'sgn-be-fr', + 'sgn-be-nl', + 'sgn-ch-de', + 'zh-guoyu', + 'zh-hakka', + 'zh-min', + 'zh-min-nan', + 'zh-xiang', +]); +const RFC_5646_PRIVATE_USE_TAG = /^[xX](?:-[A-Za-z0-9]{1,8})+$/; +const RFC_5646_LANGTAG = /^(?:[A-Za-z]{2,3}(?:-[A-Za-z]{3})?|[A-Za-z]{4}|[A-Za-z]{5,8})(?:-[A-Za-z]{4})?(?:-(?:[A-Za-z]{2}|[0-9]{3}))?(?:-(?:[A-Za-z0-9]{5,8}|[0-9][A-Za-z0-9]{3}))*(?:-[0-9A-WY-Za-wy-z](?:-[A-Za-z0-9]{2,8})+)*(?:-[xX](?:-[A-Za-z0-9]{1,8})+)?$/; +const RFC_5646_VARIANT = /^(?:[A-Za-z0-9]{5,8}|[0-9][A-Za-z0-9]{3})$/; +const RFC_5646_EXTENSION_SINGLETON = /^[0-9A-WY-Za-wy-z]$/; + /** Values accepted by the WAI-ARIA `aria-invalid` state on a textbox. */ export type EditorAriaInvalid = boolean | 'grammar' | 'spelling'; @@ -29,14 +68,112 @@ export interface EditorAccessibilityOptions { editable: boolean; } -/** Normalize an optional host-supplied accessibility string. */ +/** Enforce Inkspan's local type and resource boundary for accessibility text. */ +function validatedAccessibilityValue(value: string): string { + if ( + typeof value !== 'string' || + value.length > ACCESSIBILITY_METADATA_MAX_CODE_UNITS + ) { + throw new RangeError(INVALID_ACCESSIBILITY_METADATA_MESSAGE); + } + return value; +} + +/** Normalize optional host metadata after enforcing Inkspan's local size boundary. */ function normalizedAccessibilityValue( value: string | undefined, ): string | undefined { - const normalized = value?.trim(); + if (value === undefined) return undefined; + + const normalized = validatedAccessibilityValue(value).trim(); return normalized ? normalized : undefined; } +/** Enforce RFC 5646's locally decidable uniqueness rules beyond its ABNF shape. */ +function hasUniqueLanguageTagSubtags(value: string): boolean { + const variants = new Set(); + const extensionSingletons = new Set(); + let readingExtensions = false; + + for (const subtag of value.split('-').slice(1)) { + const normalized = subtag.toLowerCase(); + if (normalized === 'x') break; + + if (RFC_5646_EXTENSION_SINGLETON.test(subtag)) { + if (extensionSingletons.has(normalized)) return false; + extensionSingletons.add(normalized); + readingExtensions = true; + continue; + } + + if (!readingExtensions && RFC_5646_VARIANT.test(subtag)) { + if (variants.has(normalized)) return false; + variants.add(normalized); + } + } + + return true; +} + +/** Check locally decidable RFC 5646 validity without IANA registry lookup. */ +function isWellFormedLanguageTag(value: string): boolean { + return ( + RFC_5646_GRANDFATHERED_TAGS.has(value.toLowerCase()) || + RFC_5646_PRIVATE_USE_TAG.test(value) || + (RFC_5646_LANGTAG.test(value) && hasUniqueLanguageTagSubtags(value)) + ); +} + +/** Validate one non-blank editor language tag without changing caller spelling. */ +function normalizedEditorLanguageTag( + value: string | undefined, +): string | undefined { + const normalized = normalizedAccessibilityValue(value); + if (normalized === undefined) return undefined; + if (!isWellFormedLanguageTag(normalized)) { + throw new RangeError(INVALID_LANGUAGE_TAG_MESSAGE); + } + return normalized; +} + +/** Reject runtime direction values outside Inkspan's public finite contract. */ +function validateEditorTextDirection( + value: EditorTextDirection | undefined, +): void { + if ( + value !== undefined && + value !== 'ltr' && + value !== 'rtl' && + value !== 'auto' + ) { + throw new RangeError('Editor text direction must be ltr, rtl, or auto.'); + } +} + +/** Reject runtime `aria-invalid` values outside Inkspan's finite contract. */ +function validateEditorAriaInvalid( + value: EditorAriaInvalid | undefined, +): void { + if ( + value !== undefined && + value !== false && + value !== true && + value !== 'grammar' && + value !== 'spelling' + ) { + throw new RangeError( + 'Editor aria-invalid must be false, true, grammar, or spelling.', + ); + } +} + +/** Reject runtime `aria-required` values outside Inkspan's boolean contract. */ +function validateEditorAriaRequired(value: boolean | undefined): void { + if (value !== undefined && value !== false && value !== true) { + throw new RangeError('Editor aria-required must be false or true.'); + } +} + /** * Normalize the shared visual and semantic empty-editor guidance. * @@ -54,15 +191,31 @@ export function normalizeEditorPlaceholder( * collaborative editor surfaces. * * A non-blank `aria-labelledby` reference takes precedence over the fallback - * string label. Optional placeholder, language, and ID-reference values are - * omitted when blank. Placeholder guidance remains supplemental and never - * replaces the accessible name. + * string label. Required and optional accessibility strings are bounded before + * attribute emission; optional placeholder, language, and ID-reference values + * are omitted when blank. Non-blank language metadata must satisfy RFC 5646 rules + * that Inkspan can decide locally, including private-use, grandfathered, + * extlang-position, variant-uniqueness, and extension-uniqueness constraints; + * IANA registry-content validity remains a host policy concern. Runtime direction + * and ARIA state values are each captured once and checked against Inkspan's finite + * public contracts before the same captured value is emitted. The trimmed caller + * spelling of accepted language tags is preserved. Placeholder guidance remains + * supplemental and never replaces the accessible name. */ export function buildEditorAccessibilityAttributes( options: EditorAccessibilityOptions, ): Record { + const textDirection = options.textDirection; + const ariaInvalid = options.ariaInvalid; + const ariaRequired = options.ariaRequired; + + validateEditorTextDirection(textDirection); + validateEditorAriaInvalid(ariaInvalid); + validateEditorAriaRequired(ariaRequired); + + const defaultLabel = validatedAccessibilityValue(options.defaultLabel); const placeholder = normalizeEditorPlaceholder(options.placeholder); - const languageTag = normalizedAccessibilityValue(options.languageTag); + const languageTag = normalizedEditorLanguageTag(options.languageTag); const labelledBy = normalizedAccessibilityValue(options.ariaLabelledBy); const describedBy = normalizedAccessibilityValue(options.ariaDescribedBy); const errorMessage = normalizedAccessibilityValue(options.ariaErrorMessage); @@ -76,19 +229,19 @@ export function buildEditorAccessibilityAttributes( if (placeholder) attributes['aria-placeholder'] = placeholder; if (languageTag) attributes.lang = languageTag; - if (options.textDirection) attributes.dir = options.textDirection; + if (textDirection) attributes.dir = textDirection; if (labelledBy) { attributes['aria-labelledby'] = labelledBy; } else { - attributes['aria-label'] = explicitLabel ?? options.defaultLabel; + attributes['aria-label'] = explicitLabel ?? defaultLabel; } if (describedBy) attributes['aria-describedby'] = describedBy; if (errorMessage) attributes['aria-errormessage'] = errorMessage; - if (options.ariaInvalid !== undefined) { - attributes['aria-invalid'] = String(options.ariaInvalid); + if (ariaInvalid !== undefined) { + attributes['aria-invalid'] = String(ariaInvalid); } - if (options.ariaRequired !== undefined) { - attributes['aria-required'] = String(options.ariaRequired); + if (ariaRequired !== undefined) { + attributes['aria-required'] = String(ariaRequired); } return attributes; diff --git a/src/components/editorAccessibilityRuntime.test.ts b/src/components/editorAccessibilityRuntime.test.ts new file mode 100644 index 00000000..80ca2173 --- /dev/null +++ b/src/components/editorAccessibilityRuntime.test.ts @@ -0,0 +1,142 @@ +import { describe, expect, it } from 'vitest'; +import { + buildEditorAccessibilityAttributes, + type EditorAccessibilityOptions, +} from './editorAccessibility.js'; + +describe('editor accessibility runtime contracts', () => { + it.each(['ltr', 'rtl', 'auto'] as const)( + 'preserves the valid %s text direction', + (textDirection) => { + expect( + buildEditorAccessibilityAttributes({ + defaultLabel: 'Editor', + editable: true, + textDirection, + }).dir, + ).toBe(textDirection); + }, + ); + + it('rejects a runtime text direction outside the public enumerated states', () => { + expect(() => + buildEditorAccessibilityAttributes({ + defaultLabel: 'Editor', + editable: true, + textDirection: 'sideways' as never, + }), + ).toThrowError( + new RangeError('Editor text direction must be ltr, rtl, or auto.'), + ); + }); + + it('snapshots text direction once before validation and emission', () => { + const options: EditorAccessibilityOptions = { + defaultLabel: 'Editor', + editable: true, + }; + let reads = 0; + Object.defineProperty(options, 'textDirection', { + enumerable: true, + get() { + reads += 1; + return reads === 1 ? 'ltr' : 'sideways'; + }, + }); + + expect(buildEditorAccessibilityAttributes(options).dir).toBe('ltr'); + expect(reads).toBe(1); + }); + + it.each([false, true, 'grammar', 'spelling'] as const)( + 'preserves the valid %s aria-invalid state', + (ariaInvalid) => { + expect( + buildEditorAccessibilityAttributes({ + defaultLabel: 'Editor', + editable: true, + ariaInvalid, + })['aria-invalid'], + ).toBe(String(ariaInvalid)); + }, + ); + + it('rejects a runtime aria-invalid value outside the public states', () => { + expect(() => + buildEditorAccessibilityAttributes({ + defaultLabel: 'Editor', + editable: true, + ariaInvalid: 'unknown' as never, + }), + ).toThrowError( + new RangeError( + 'Editor aria-invalid must be false, true, grammar, or spelling.', + ), + ); + }); + + it('snapshots aria-invalid once before validation and emission', () => { + const options: EditorAccessibilityOptions = { + defaultLabel: 'Editor', + editable: true, + }; + let reads = 0; + Object.defineProperty(options, 'ariaInvalid', { + enumerable: true, + get() { + reads += 1; + return reads === 1 ? false : 'unknown'; + }, + }); + + expect(buildEditorAccessibilityAttributes(options)['aria-invalid']).toBe( + 'false', + ); + expect(reads).toBe(1); + }); + + it.each([false, true] as const)( + 'preserves the valid %s aria-required state', + (ariaRequired) => { + expect( + buildEditorAccessibilityAttributes({ + defaultLabel: 'Editor', + editable: true, + ariaRequired, + })['aria-required'], + ).toBe(String(ariaRequired)); + }, + ); + + it('rejects a runtime aria-required value outside the public states', () => { + expect(() => + buildEditorAccessibilityAttributes({ + defaultLabel: 'Editor', + editable: true, + ariaRequired: 'maybe' as never, + }), + ).toThrowError( + new RangeError('Editor aria-required must be false or true.'), + ); + }); + + it('snapshots aria-required once before validation and emission', () => { + const options: EditorAccessibilityOptions = { + defaultLabel: 'Editor', + editable: true, + }; + let reads = 0; + Object.defineProperty(options, 'ariaRequired', { + enumerable: true, + get() { + reads += 1; + return reads === 1 ? false : 'maybe'; + }, + }); + + expect(buildEditorAccessibilityAttributes(options)['aria-required']).toBe( + 'false', + ); + expect(reads).toBe(1); + }); +});