Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
8b3d7d9
test(reliability): expose unbounded accessibility metadata
seonghobae Aug 11, 2026
3b44239
fix(reliability): bound accessibility metadata normalization
seonghobae Aug 11, 2026
75d0008
test(accessibility): reject malformed editor language tags
seonghobae Aug 11, 2026
d762601
fix(accessibility): validate editor language tags
seonghobae Aug 11, 2026
64e46d7
test(accessibility): preserve RFC 5646 language-tag forms
seonghobae Aug 11, 2026
6ba8555
fix(accessibility): validate full RFC 5646 tag syntax
seonghobae Aug 11, 2026
7af1227
test(a11y): reject duplicate RFC 5646 subtags
seonghobae Aug 11, 2026
e4a7c04
fix(a11y): enforce RFC 5646 subtag uniqueness
seonghobae Aug 11, 2026
210ca6a
test(a11y): cover RFC 5646 private-use suffix
seonghobae Aug 11, 2026
c9eb527
test(a11y): consolidate runtime accessibility contracts
seonghobae Aug 11, 2026
04595d1
fix(a11y): consolidate accessibility runtime validation
seonghobae Aug 11, 2026
0551ae3
test(accessibility): reject invalid extlang chains
seonghobae Aug 11, 2026
f21e923
fix(accessibility): reject invalid extlang chains
seonghobae Aug 11, 2026
fb70eb2
merge(main): synchronize accessibility metadata lane
seonghobae Aug 16, 2026
61842ec
merge: synchronize accessibility metadata hardening with protected main
seonghobae Aug 18, 2026
cae7477
test(a11y): reject non-boolean editor accessibility state
seonghobae Aug 20, 2026
f5e51c3
fix(a11y): validate editor accessibility editability
seonghobae Aug 20, 2026
9fb8889
chore(a11y): restore canonical metadata test scope
seonghobae Aug 20, 2026
2cc4b5f
chore(a11y): restore canonical accessibility ownership
seonghobae Aug 20, 2026
f6aa305
test(accessibility): cover required fallback label boundary
seonghobae Aug 20, 2026
a9fc72c
fix(accessibility): bound required fallback label metadata
seonghobae Aug 20, 2026
d950d61
test(accessibility): expose finite-state getter TOCTOU
seonghobae Aug 20, 2026
59d82f8
fix(accessibility): snapshot finite runtime metadata
seonghobae Aug 20, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
169 changes: 169 additions & 0 deletions src/components/editorAccessibility.resourceBoundary.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> {
return buildEditorAccessibilityAttributes({
defaultLabel: 'Editor',
editable: true,
ariaLabel: value as EditorAccessibilityOptions['ariaLabel'],
});
}

function attributesWithDefaultLabel(value: unknown): Record<string, string> {
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));
},
);
});
177 changes: 165 additions & 12 deletions src/components/editorAccessibility.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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<string>();
const extensionSingletons = new Set<string>();
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))
);
}
Comment thread
seonghobae marked this conversation as resolved.

/** 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.
*
Expand All @@ -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<string, string> {
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);
Comment thread
seonghobae marked this conversation as resolved.
const labelledBy = normalizedAccessibilityValue(options.ariaLabelledBy);
const describedBy = normalizedAccessibilityValue(options.ariaDescribedBy);
const errorMessage = normalizedAccessibilityValue(options.ariaErrorMessage);
Expand All @@ -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;
Expand Down
Loading
Loading