Skip to content

feat(core): add i18n foundation with English locale and UI wiring#1589

Merged
luwes merged 9 commits into
mainfrom
feat/i18n-core
Jun 18, 2026
Merged

feat(core): add i18n foundation with English locale and UI wiring#1589
luwes merged 9 commits into
mainfrom
feat/i18n-core

Conversation

@sampotts

@sampotts sampotts commented May 25, 2026

Copy link
Copy Markdown
Collaborator

Refs #222
Closes #1363

Summary

Foundation PR for Video.js 10 i18n: registry, translator, English copy, browser Translation API fallback, locale-aware formatting utilities, and opaque translation keys wired through core UI components.

Changes

  • @videojs/core/i18n — registry, createTranslator, BCP 47 lookup chain, en.ts base layer
  • Browser Translation API fallback with placeholder masking and cache
  • @videojs/utils — DOM locale helpers (subscribeAmbientLang, mergeLocaleOverlays) and formatDuration via Intl.DurationFormat
  • Core UI controls use translation keys for aria labels, tooltips, error dialog copy, and input-feedback indicators
  • Design doc updated to implemented status
Stack info

This is PR 1 of 5 in the i18n stack. Merge order:

  1. Core (this PR) → 2. Locale packs → 3. HTML → 4. React → 5. Sandbox

Testing

pnpm -F @videojs/core build
pnpm -F @videojs/core test src/core/i18n src/core/ui
pnpm -F @videojs/utils test src/dom src/time
pnpm typecheck

Made with Cursor


Note

Medium Risk
Touches accessibility strings and media error messaging across many UI cores; behavior depends on downstream HTML/React translators, but English defaults and tests limit user-visible regressions in this PR alone.

Overview
Adds @videojs/core/i18n: a global registry with BCP 47 lookup, typed createTranslator, English defaults in en.ts, and optional Browser Translation API fallback (placeholder masking, shouldAttemptBrowserTranslation, pre-installed models only).

Core UI now exposes opaque translation keys (and param hooks like getLabelParams / getValueTextParams) instead of hardcoded English for play/mute/seek/sliders/time, live/cast/captions, playback rate, error dialog copy, and input-feedback indicators. resolveControlAttrs / resolveOptionalControlLabel bridge keys to resolved strings for platform layers.

@videojs/utils gains locale DOM helpers (effectiveLocale, mergeLocaleOverlays, ambient lang subscription) and formatDuration / formatVolumePercent via Intl. Native HLS errors for standard codes are normalized to canonical messages so dialog copy can map to registry keys.

internal/design/i18n.md is marked implemented and documents keys, merge order, and tooltip/media-text behavior. @videojs/core exports ./i18n and ./i18n/locales/*; registry is a declared side effect. Non-English locale packs and HTML/React providers are explicitly deferred to follow-up stack PRs.

Reviewed by Cursor Bugbot for commit b58714d. Bugbot is set up for automated code reviews on this repo. Configure here.

@netlify

netlify Bot commented May 25, 2026

Copy link
Copy Markdown

Deploy Preview for vjs10-site ready!

Name Link
🔨 Latest commit d4c9551
🔍 Latest deploy log https://app.netlify.com/projects/vjs10-site/deploys/6a343de2c8b6d200088ca541
😎 Deploy Preview https://deploy-preview-1589--vjs10-site.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.
🤖 Make changes Run an agent on this branch

To edit notification comments on pull requests, go to your Netlify project configuration.

@vercel

vercel Bot commented May 25, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
v10-sandbox Ready Ready Preview, Comment Jun 18, 2026 6:50pm

Request Review

@github-actions

github-actions Bot commented May 25, 2026

Copy link
Copy Markdown
Contributor

📦 Bundle Size Report

🎨 @videojs/html

Path Base PR Diff %
/video/minimal-skin 44.01 kB 44.57 kB +572 B +1.3% 🔺
/video/minimal-skin.tailwind 44.60 kB 45.16 kB +578 B +1.3% 🔺
/video/skin 44.28 kB 44.88 kB +612 B +1.3% 🔺
/video/skin.tailwind 44.89 kB 45.49 kB +616 B +1.3% 🔺
/audio/minimal-skin 36.59 kB 37.17 kB +598 B +1.6% 🔺
/audio/minimal-skin.tailwind 36.98 kB 37.56 kB +596 B +1.6% 🔺
/audio/skin 37.77 kB 38.34 kB +578 B +1.5% 🔺
/audio/skin.tailwind 38.14 kB 38.72 kB +591 B +1.5% 🔺
/live-video/minimal-skin 43.06 kB 43.68 kB +634 B +1.4% 🔺
/live-video/minimal-skin.tailwind 43.49 kB 44.09 kB +619 B +1.4% 🔺
/live-video/skin 43.08 kB 43.64 kB +575 B +1.3% 🔺
/live-video/skin.tailwind 43.56 kB 44.12 kB +573 B +1.3% 🔺
/live-audio/minimal-skin 29.68 kB 30.17 kB +502 B +1.7% 🔺
/live-audio/minimal-skin.tailwind 29.12 kB 29.65 kB +539 B +1.8% 🔺
/live-audio/skin 31.02 kB 31.48 kB +470 B +1.5% 🔺
/live-audio/skin.tailwind 30.62 kB 31.06 kB +449 B +1.4% 🔺
/ui/compounds 8.72 kB 8.75 kB +24 B +0.3% 🔺
/ui/time-slider 4.10 kB 4.04 kB -65 B -1.5% 🔽
/video (default) 44.32 kB 44.89 kB +579 B +1.3% 🔺
/video (default + hls) 183.74 kB 184.36 kB +638 B +0.3% 🔺
/video (minimal) 43.99 kB 44.55 kB +580 B +1.3% 🔺
/video (minimal + hls) 183.58 kB 184.06 kB +493 B +0.3% 🔺
/audio (default) 37.82 kB 38.40 kB +590 B +1.5% 🔺
/audio (minimal) 36.56 kB 37.17 kB +625 B +1.7% 🔺
Presets (7)
Entry Size
/video (default) 44.89 kB
/video (default + hls) 184.36 kB
/video (minimal) 44.55 kB
/video (minimal + hls) 184.06 kB
/audio (default) 38.40 kB
/audio (minimal) 37.17 kB
/background 4.22 kB
Media (10)
Entry Size
/media/background-video 1.07 kB
/media/container 1.72 kB
/media/dash-video 242.74 kB
/media/hls-video 141.10 kB
/media/mux-audio 163.66 kB
/media/mux-video 163.68 kB
/media/native-hls-video 9.02 kB
/media/simple-hls-audio-only 16.94 kB
/media/simple-hls-video 18.77 kB
/media/vimeo-video 12.29 kB
Players (5)
Entry Size
/video/player 8.06 kB
/audio/player 5.38 kB
/background/player 3.93 kB
/live-video/player 7.63 kB
/live-audio/player 5.39 kB
Skins (30)
Entry Type Size
/video/minimal-skin.css css 5.45 kB
/video/skin.css css 5.43 kB
/video/minimal-skin js 44.57 kB
/video/minimal-skin.tailwind js 45.16 kB
/video/skin js 44.88 kB
/video/skin.tailwind js 45.49 kB
/audio/minimal-skin.css css 3.60 kB
/audio/skin.css css 3.53 kB
/audio/minimal-skin js 37.17 kB
/audio/minimal-skin.tailwind js 37.56 kB
/audio/skin js 38.34 kB
/audio/skin.tailwind js 38.72 kB
/background/skin.css css 133 B
/background/skin js 1.16 kB
/live-video/minimal-skin.css css 5.45 kB
/live-video/skin.css css 5.43 kB
/live-video/minimal-skin js 43.68 kB
/live-video/minimal-skin.tailwind js 44.09 kB
/live-video/skin js 43.64 kB
/live-video/skin.tailwind js 44.12 kB
/live-audio/minimal-skin.css css 3.60 kB
/live-audio/skin.css css 3.53 kB
/live-audio/minimal-skin js 30.17 kB
/live-audio/minimal-skin.tailwind js 29.65 kB
/live-audio/skin js 31.48 kB
/live-audio/skin.tailwind js 31.06 kB
/global.css css 176 B
/shared.css css 88 B
/tailwind.css css 228 B
/skin-element js 1.44 kB
UI Components (38)
Entry Size
/ui/airplay-button 3.66 kB
/ui/alert-dialog 1.32 kB
/ui/alert-dialog-close 524 B
/ui/alert-dialog-description 483 B
/ui/alert-dialog-title 405 B
/ui/buffering-indicator 2.33 kB
/ui/captions-button 3.76 kB
/ui/captions-radio-group 3.36 kB
/ui/cast-button 3.73 kB
/ui/compounds 8.75 kB
/ui/controls 2.38 kB
/ui/error-dialog 3.47 kB
/ui/fullscreen-button 3.65 kB
/ui/hotkey 2.19 kB
/ui/menu 5.05 kB
/ui/mute-button 3.69 kB
/ui/pip-button 3.69 kB
/ui/play-button 3.74 kB
/ui/playback-rate-button 3.72 kB
/ui/playback-rate-radio-group 3.15 kB
/ui/popover 2.00 kB
/ui/poster 2.35 kB
/ui/quality-radio-group 2.31 kB
/ui/seek-button 3.71 kB
/ui/seek-indicator 3.88 kB
/ui/seek-indicator-value 235 B
/ui/slider 1.56 kB
/ui/status-announcer 3.62 kB
/ui/status-indicator 3.74 kB
/ui/status-indicator-value 237 B
/ui/thumbnail 3.30 kB
/ui/time 3.17 kB
/ui/time-slider 4.04 kB
/ui/tooltip 2.14 kB
/ui/volume-indicator 3.94 kB
/ui/volume-indicator-fill 176 B
/ui/volume-indicator-value 166 B
/ui/volume-slider 2.96 kB

Sizes are marginal over the root entry point.

⚛️ @videojs/react

Path Base PR Diff %
/video/minimal-skin 36.81 kB 37.42 kB +618 B +1.6% 🔺
/video/minimal-skin.tailwind 42.53 kB 43.10 kB +582 B +1.3% 🔺
/video/skin 36.73 kB 37.39 kB +669 B +1.8% 🔺
/video/skin.tailwind 42.46 kB 43.10 kB +658 B +1.5% 🔺
/audio/minimal-skin 29.96 kB 30.56 kB +623 B +2.0% 🔺
/audio/minimal-skin.tailwind 31.76 kB 32.36 kB +614 B +1.9% 🔺
/audio/skin 29.93 kB 30.54 kB +625 B +2.0% 🔺
/audio/skin.tailwind 33.77 kB 34.36 kB +612 B +1.8% 🔺
/live-video/minimal-skin 32.57 kB 32.92 kB +359 B +1.1% 🔺
/live-video/minimal-skin.tailwind 38.21 kB 38.51 kB +301 B +0.8% 🔺
/live-video/skin 32.61 kB 32.94 kB +339 B +1.0% 🔺
/live-video/skin.tailwind 38.27 kB 38.58 kB +318 B +0.8% 🔺
/ui/time-slider 4.19 kB 4.28 kB +94 B +2.2% 🔺
/video (default) 36.83 kB 37.38 kB +561 B +1.5% 🔺
/video (default + hls) 175.18 kB 175.59 kB +419 B +0.2% 🔺
/video (minimal) 36.91 kB 37.50 kB +611 B +1.6% 🔺
/video (minimal + hls) 175.13 kB 175.83 kB +718 B +0.4% 🔺
/audio (default) 29.96 kB 30.57 kB +625 B +2.0% 🔺
/audio (minimal) 30.04 kB 30.62 kB +596 B +1.9% 🔺
Presets (7)
Entry Size
/video (default) 37.38 kB
/video (default + hls) 175.59 kB
/video (minimal) 37.50 kB
/video (minimal + hls) 175.83 kB
/audio (default) 30.57 kB
/audio (minimal) 30.62 kB
/background 754 B
Media (9)
Entry Size
/media/background-video 575 B
/media/dash-video 241.13 kB
/media/hls-video 139.78 kB
/media/mux-audio 162.29 kB
/media/mux-video 162.29 kB
/media/native-hls-video 7.41 kB
/media/simple-hls-audio-only 15.37 kB
/media/simple-hls-video 17.18 kB
/media/vimeo-video 10.58 kB
Skins (27)
Entry Type Size
/tailwind.css css 228 B
/video/minimal-skin.css css 5.37 kB
/video/skin.css css 5.34 kB
/video/minimal-skin js 37.42 kB
/video/minimal-skin.tailwind js 43.10 kB
/video/skin js 37.39 kB
/video/skin.tailwind js 43.10 kB
/audio/minimal-skin.css css 3.47 kB
/audio/skin.css css 3.39 kB
/audio/minimal-skin js 30.56 kB
/audio/minimal-skin.tailwind js 32.36 kB
/audio/skin js 30.54 kB
/audio/skin.tailwind js 34.36 kB
/background/skin.css css 90 B
/background/skin js 272 B
/live-video/minimal-skin.css css 5.37 kB
/live-video/skin.css css 5.34 kB
/live-video/minimal-skin js 32.92 kB
/live-video/minimal-skin.tailwind js 38.51 kB
/live-video/skin js 32.94 kB
/live-video/skin.tailwind js 38.58 kB
/live-audio/minimal-skin.css css 3.47 kB
/live-audio/skin.css css 3.39 kB
/live-audio/minimal-skin js 21.98 kB
/live-audio/minimal-skin.tailwind js 24.86 kB
/live-audio/skin js 22.04 kB
/live-audio/skin.tailwind js 25.02 kB
UI Components (32)
Entry Size
/ui/airplay-button 3.08 kB
/ui/alert-dialog 1.25 kB
/ui/buffering-indicator 2.55 kB
/ui/captions-button 3.11 kB
/ui/captions-radio-group 2.83 kB
/ui/cast-button 2.76 kB
/ui/controls 2.52 kB
/ui/error-dialog 2.72 kB
/ui/fullscreen-button 2.75 kB
/ui/gesture 2.20 kB
/ui/hotkey 2.39 kB
/ui/live-button 3.33 kB
/ui/menu 6.79 kB
/ui/mute-button 2.75 kB
/ui/pip-button 3.10 kB
/ui/play-button 2.79 kB
/ui/playback-rate 2.85 kB
/ui/playback-rate-button 2.73 kB
/ui/popover 2.48 kB
/ui/poster 2.40 kB
/ui/quality 3.03 kB
/ui/seek-button 2.72 kB
/ui/seek-indicator 2.31 kB
/ui/slider 3.62 kB
/ui/status-announcer 2.49 kB
/ui/status-indicator 2.18 kB
/ui/thumbnail 2.75 kB
/ui/time 3.27 kB
/ui/time-slider 4.28 kB
/ui/tooltip 2.43 kB
/ui/volume-indicator 2.19 kB
/ui/volume-slider 3.63 kB

Sizes are marginal over the root entry point.

🧩 @videojs/core

Path Base PR Diff %
. 8.65 kB 10.05 kB +1.40 kB +16.2% 🔴
/i18n 1.76 kB 🆕
/i18n/locales/en 645 B 🆕
Entries (15)
Entry Size
. 10.05 kB
/dom 17.11 kB
/dom/media/custom-media-element 2.04 kB
/dom/media/dash 236.79 kB
/dom/media/google-cast 4.03 kB
/dom/media/hls 135.73 kB
/dom/media/media-host 1.25 kB
/dom/media/media-played-ranges 576 B
/dom/media/mux 151.26 kB
/dom/media/native-hls 3.07 kB
/dom/media/simple-hls 16.55 kB
/dom/media/simple-hls-audio-only 14.79 kB
/dom/media/vimeo 9.86 kB
/i18n 1.76 kB
/i18n/locales/en 645 B
🏷️ @videojs/element — no changes
Entries (2)
Entry Size
. 996 B
/context 943 B
📦 @videojs/store — no changes
Entries (3)
Entry Size
. 1.39 kB
/html 696 B
/react 360 B

🔧 @videojs/utils

Path Base PR Diff %
/dom 2.22 kB 2.63 kB +424 B +18.7% 🔴
/time 478 B 930 B +452 B +94.6% 🔴
Entries (10)
Entry Size
/array 104 B
/dom 2.63 kB
/events 319 B
/function 327 B
/object 275 B
/predicate 265 B
/string 232 B
/style 190 B
/time 930 B
/number 158 B
📦 @videojs/spf — no changes
Entries (4)
Entry Size
. 4.45 kB
/dom 6.33 kB
/hls 15.44 kB
/background-looping-video 12.97 kB

ℹ️ How to interpret

All sizes are standalone totals (minified + brotli).

Icon Meaning
No change
🔺 Increased ≤ 10%
🔴 Increased > 10%
🔽 Decreased
🆕 New (no baseline)

Run pnpm size locally to check current sizes.

Comment thread packages/core/src/core/ui/error-dialog/error-dialog-i18n.ts
Comment thread packages/core/src/core/i18n/browser-translation.ts Outdated

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Happy to remove this if we don't think it's worthwhile. It's a bit of an edge case solution anyway. For it to work currently:

  • the user has to be using Chrome; and
  • they have to already have the language pack downloaded (we won't trigger it)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure, context matters sometimes with translations. That browser API might give some strange results?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, that's valid. What I've done in the past is have a "Description" field alongside the key to set the context as translators often ask how it's being used (e.g. verb vs noun). Maybe we could add that somehow.

import packageJson from './package.json' with { type: 'json' };

const localeEntries = {
'i18n/locales/en': './src/core/i18n/locales/en.ts',

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

More are added in later PRs 😅

it('initializes with default property values', () => {
const slider = createElement(TimeSliderElement);
expect(slider.label).toBe('Seek');
expect(slider.label).toBe('');

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Empty string means it'll be translated with the default key.

Comment thread packages/core/src/core/i18n/browser-translation.ts
Comment thread packages/utils/src/time/format.ts Outdated
Comment thread packages/core/src/core/ui/play-button/play-button-core.ts
/**
* Most-specific-first BCP 47 lookup tags (normalized). Always ends with `en` when missing from the truncated chain.
*
* @example `es-419-u-nu-latn` → `['es-419', 'es', 'en']`

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

unmute: 'Unmute',
seekForward: 'Seek forward {seconds} seconds',
seekBackward: 'Seek backward {seconds} seconds',
enterFullscreen: 'Enter fullscreen',

@luwes luwes Jun 8, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(blocking) I didn't expect this type of keys. Why not use both Enter fullscreen for key and value?
this will be a lot easier for translations because the key is the english value to translate.
in code this will read well too, maybe long in some cases.
(feedback is about all keys in this file)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@github-actions github-actions Bot mentioned this pull request Jun 17, 2026
Introduce the core i18n registry, translator, browser translation fallback,
English copy, locale-aware formatting utilities, and opaque translation keys
across core UI components.

Co-authored-by: Cursor <cursoragent@cursor.com>
sampotts and others added 7 commits June 18, 2026 14:24
Use relative imports in core tests, exclude formatOptions from HTML element
property maps, and align html/react tests with opaque translation keys until
platform i18n wiring lands in later stack PRs.

Co-authored-by: Cursor <cursoragent@cursor.com>
Only map known browser-specific strings to registry keys so app-supplied
copy on standard error codes is shown instead of the generic translation.

Co-authored-by: Cursor <cursoragent@cursor.com>
Skip Browser Translation API for en-US and en-GB so regional English UI
locales keep the built-in en registry layer.

Co-authored-by: Cursor <cursoragent@cursor.com>
Use a parametric timeRemainingPhrase key so locales control full remaining
time word order; formatDuration accepts formatRemaining instead of translate.

Co-authored-by: Cursor <cursoragent@cursor.com>
nearestLang now falls back to element.lang when the attribute is empty so
document.documentElement.lang updates resolve consistently. Adds tests for
lang property changes in subscribeAmbientLang and nearestLang.

Co-authored-by: Cursor <cursoragent@cursor.com>
shouldAttemptBrowserTranslation now tracks which BCP 47 tags returned
lazy packs instead of treating any merged overlay (including en) as a
built-in locale. mergeLocaleOverlays returns loadedTags for callers.

Co-authored-by: Cursor <cursoragent@cursor.com>
Avoid mixed-language remaining-time phrases when callers omit
formatRemaining but pass a non-English locale to formatDuration.

Co-authored-by: Cursor <cursoragent@cursor.com>

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit bf72f98. Configure here.

@luwes luwes left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM thanks @sampotts !

@luwes luwes merged commit 768bf09 into main Jun 18, 2026
26 checks passed
@luwes luwes deleted the feat/i18n-core branch June 18, 2026 18:55
@luwes luwes mentioned this pull request Jun 18, 2026
@sampotts sampotts restored the feat/i18n-core branch June 18, 2026 21:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

Feature: Core i18n Types, Translator, and Registry

2 participants