Skip to content

Commit 0c40de5

Browse files
committed
feat(i18n,parts,locales): English chrome on a translated page, part names nobody published, and research notes shipping inside the npm tarball
Three streams landed together because the tree could not be split without publishing half of each. i18n. The two declared widgets, roxy-endpoint-form and roxy-location-search, extended LitElement directly, so they had no t() and no LocaleController: a Spanish page rendered a Spanish result card over an English form, and neither widget re-rendered when a deferred locale payload arrived. The mechanism moves into one RoxyLocalizedElement that all three bases inherit, kept in its own module because utils/base-element.ts imports it and because every CDN component is a self-contained IIFE that would otherwise compile the data base into the city search. Chrome strings for the form path added across all seven locales plus the nine spec form-group names, with the wrappers regaining load-ui. parts. No component declared which ::part names it answers to, so one rule reached one component and silently missed another rendering the same concept. components-catalog.json now carries a parts array per component, scanned from source by scripts/component-parts.ts and never hand-listed, guarded by packages/ui/tests/catalog.test.ts and proven in the browser by packages/ui/tests/e2e/parts.e2e.ts. The scan found a live defect on the way: vedic-planets-table rendered its interpretations regardless of hide-readings. locales. The seven locale headers had grown to 497 lines of translation sourcing evidence, and packages/ui/package.json lists src in its files array, so all of it shipped inside the npm tarball and, through the declaration files, into a consumer editor tooltip. It named around 23 third-party sites as the source our wording was lifted from, called a named competitor machine translated, retold a customer support contact with identifying detail, and cited paths inside the closed main repo. The research is not lost: it moved verbatim to the internal wiki, and the split is now standing. The file states the choice and the grammar, collision or width reason behind it. Who else prints the word stays internal. String counts are unchanged in all seven files. Verified: lint, css-backticks, brand-grep, typecheck, two byte-identical builds, 726 unit tests, size budget, preview coverage, 45 of 45 e2e, and the UI audit. The i18n path was additionally checked in a real browser across es, de, tr and hi, including the nested city search inside a synastry form, which renders the group-specific placeholder in each language.
1 parent a5e873a commit 0c40de5

55 files changed

Lines changed: 3430 additions & 366 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

AGENTS.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -614,7 +614,9 @@ roxy-natal-chart::part(card) {
614614
| `section` | Any structural block, paired with a specific name (`section patterns`), so `::part(patterns)` targets that block alone |
615615
| `readings` | The interpretation accordion |
616616
| `reading` | One disclosure card inside it |
617-
| `form`, `loading`, `error`, `attribution` | The built-in states |
617+
| `form`, `loading`, `error`, `edit-bar`, `attribution` | The built-in states, on every component |
618+
619+
The table is the shared vocabulary, not the whole list. **`components-catalog.json` carries a `parts` array for every component, so you can read the exact names a component answers to instead of guessing or inspecting the DOM.** A name means the same block wherever it appears, which is what makes one rule enough: `::part(aspects)` reaches the aspect grid on a natal chart, the aspect list on an aspects table and the transit aspects on a transits table alike.
618620

619621
Two notes. Parts reach exactly one shadow root deep, and a component that draws another one re-exports its parts, so `roxy-relocation-wheel::part(readings)` reaches the wheel it nests. And to remove a reading rather than hide it, use `hide-readings` (above): a `display: none` rule still ships the words in the page.
620622

packages/ui-react/src/index.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -238,7 +238,12 @@ export {
238238
RoxyYogaList,
239239
type RoxyYogaListProps,
240240
} from './components/yoga-list.js';
241-
export { ensureScriptLoaded, ROXY_UI_VERSION } from './load-ui.js';
241+
export {
242+
ensureLocaleLoaded,
243+
ensureScriptLoaded,
244+
ROXY_UI_LOCALES,
245+
ROXY_UI_VERSION,
246+
} from './load-ui.js';
242247
/**
243248
* Every response type these components accept, re-exported so you can type your own
244249
* fetch helper without installing a second package. For example:

packages/ui-react/src/load-ui.ts

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,11 @@
33
* many components on the same page. Skips on the server (no document) so
44
* React server components and Next.js SSR work without a flash.
55
*
6+
* {@link ensureLocaleLoaded} is the other half and is OPT-IN: it loads the label
7+
* catalogue for one language from the same release, so a non-English app calls
8+
* both. There is one catalogue, shipped by `@roxyapi/ui` and read by every
9+
* wrapper, so nothing is restated here.
10+
*
611
* Defaults to the EXACT @roxyapi/ui release this wrapper was built against, so
712
* `@roxyapi/ui-vue@x.y.z` always runs `@roxyapi/ui@x.y.z` and a lockfile actually
813
* pins the runtime. It used to default to '@latest', which meant a pinned wrapper
@@ -73,6 +78,91 @@ export function ensureScriptLoaded(
7378
return loaded;
7479
}
7580

81+
/** Every language a label catalogue ships for. English is absent on purpose: the catalogue key IS the English string, so an English page downloads nothing. */
82+
export const ROXY_UI_LOCALES = [
83+
'de',
84+
'es',
85+
'fr',
86+
'hi',
87+
'pt',
88+
'ru',
89+
'tr',
90+
] as const;
91+
92+
const localesLoaded = new Map<string, Promise<void>>();
93+
94+
/**
95+
* Load the label catalogue for one language, so the components write their own
96+
* headings, buttons, empty states and form labels in it.
97+
*
98+
* Call it once at app entry, beside `ensureScriptLoaded`. The components read the
99+
* language from the page (their own `lang` attribute, the nearest ancestor
100+
* carrying one, or `<html lang>`), so this call supplies the WORDS and the page
101+
* supplies the CHOICE. Loading a catalogue a page never asks for is harmless.
102+
*
103+
* `es-AR`, `es-MX` and `es-419` all resolve to the `es` catalogue, so pass the
104+
* page tag as it stands. English resolves to nothing at all and is not an error:
105+
* the catalogue is keyed by the English text, so an English page needs no payload.
106+
* A language outside `ROXY_UI_LOCALES` resolves the same way rather than appending
107+
* a script tag that 404s.
108+
*
109+
* `version` and `baseUrl` behave exactly as they do for {@link ensureScriptLoaded},
110+
* and passing the same `baseUrl` to both is what an air-gapped or strict-CSP host
111+
* needs: copy `node_modules/@roxyapi/ui/dist/cdn/` onto your own origin, which
112+
* carries `locales/` beside `roxy-ui.js`.
113+
*
114+
* @example
115+
* ```ts
116+
* import { ensureScriptLoaded, ensureLocaleLoaded } from '@roxyapi/ui-react';
117+
*
118+
* ensureScriptLoaded();
119+
* ensureLocaleLoaded(document.documentElement.lang);
120+
* ```
121+
*/
122+
export function ensureLocaleLoaded(
123+
lang: string,
124+
version: string = ROXY_UI_VERSION,
125+
baseUrl?: string,
126+
): Promise<void> {
127+
if (typeof document === 'undefined') return Promise.resolve();
128+
const base = (lang || '').toLowerCase().split('-')[0] ?? '';
129+
if (!(ROXY_UI_LOCALES as readonly string[]).includes(base)) {
130+
return Promise.resolve();
131+
}
132+
const existing = localesLoaded.get(base);
133+
if (existing) return existing;
134+
135+
const pending = new Promise<void>((resolve, reject) => {
136+
const id = `${SCRIPT_ID}-locale-${base}`;
137+
let el = document.getElementById(id) as HTMLScriptElement | null;
138+
if (el) {
139+
if (el.dataset.loaded === 'true') resolve();
140+
else {
141+
el.addEventListener('load', () => resolve());
142+
el.addEventListener('error', () =>
143+
reject(new Error(`roxy-ui locale ${base} load failed`)),
144+
);
145+
}
146+
return;
147+
}
148+
el = document.createElement('script');
149+
el.id = id;
150+
el.src = `${baseUrl ?? buildBase(version)}/locales/${base}.js`;
151+
el.async = true;
152+
el.crossOrigin = 'anonymous';
153+
el.addEventListener('load', () => {
154+
el!.dataset.loaded = 'true';
155+
resolve();
156+
});
157+
el.addEventListener('error', () =>
158+
reject(new Error(`roxy-ui locale ${base} load failed`)),
159+
);
160+
document.head.appendChild(el);
161+
});
162+
localesLoaded.set(base, pending);
163+
return pending;
164+
}
165+
76166
// Default export retained for convenience; matches the named export.
77167
export default ensureScriptLoaded;
78168
// Surfaces the embedded @roxyapi/ui version this build of @roxyapi/ui-react

packages/ui-vue/src/index.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -238,7 +238,12 @@ export {
238238
RoxyYogaList,
239239
type RoxyYogaListProps,
240240
} from './components/yoga-list.js';
241-
export { ensureScriptLoaded, ROXY_UI_VERSION } from './load-ui.js';
241+
export {
242+
ensureLocaleLoaded,
243+
ensureScriptLoaded,
244+
ROXY_UI_LOCALES,
245+
ROXY_UI_VERSION,
246+
} from './load-ui.js';
242247
/**
243248
* Every response type these components accept, re-exported so you can type your own
244249
* fetch helper without installing a second package. For example:

packages/ui-vue/src/load-ui.ts

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,11 @@
33
* many components on the same page. Skips on the server (no document) so
44
* Nuxt and Vue server rendering work without a flash.
55
*
6+
* {@link ensureLocaleLoaded} is the other half and is OPT-IN: it loads the label
7+
* catalogue for one language from the same release, so a non-English app calls
8+
* both. There is one catalogue, shipped by `@roxyapi/ui` and read by every
9+
* wrapper, so nothing is restated here.
10+
*
611
* Defaults to the EXACT @roxyapi/ui release this wrapper was built against, so
712
* `@roxyapi/ui-vue@x.y.z` always runs `@roxyapi/ui@x.y.z` and a lockfile actually
813
* pins the runtime. It used to default to '@latest', which meant a pinned wrapper
@@ -73,6 +78,91 @@ export function ensureScriptLoaded(
7378
return loaded;
7479
}
7580

81+
/** Every language a label catalogue ships for. English is absent on purpose: the catalogue key IS the English string, so an English page downloads nothing. */
82+
export const ROXY_UI_LOCALES = [
83+
'de',
84+
'es',
85+
'fr',
86+
'hi',
87+
'pt',
88+
'ru',
89+
'tr',
90+
] as const;
91+
92+
const localesLoaded = new Map<string, Promise<void>>();
93+
94+
/**
95+
* Load the label catalogue for one language, so the components write their own
96+
* headings, buttons, empty states and form labels in it.
97+
*
98+
* Call it once at app entry, beside `ensureScriptLoaded`. The components read the
99+
* language from the page (their own `lang` attribute, the nearest ancestor
100+
* carrying one, or `<html lang>`), so this call supplies the WORDS and the page
101+
* supplies the CHOICE. Loading a catalogue a page never asks for is harmless.
102+
*
103+
* `es-AR`, `es-MX` and `es-419` all resolve to the `es` catalogue, so pass the
104+
* page tag as it stands. English resolves to nothing at all and is not an error:
105+
* the catalogue is keyed by the English text, so an English page needs no payload.
106+
* A language outside `ROXY_UI_LOCALES` resolves the same way rather than appending
107+
* a script tag that 404s.
108+
*
109+
* `version` and `baseUrl` behave exactly as they do for {@link ensureScriptLoaded},
110+
* and passing the same `baseUrl` to both is what an air-gapped or strict-CSP host
111+
* needs: copy `node_modules/@roxyapi/ui/dist/cdn/` onto your own origin, which
112+
* carries `locales/` beside `roxy-ui.js`.
113+
*
114+
* @example
115+
* ```ts
116+
* import { ensureScriptLoaded, ensureLocaleLoaded } from '@roxyapi/ui-vue';
117+
*
118+
* ensureScriptLoaded();
119+
* ensureLocaleLoaded(document.documentElement.lang);
120+
* ```
121+
*/
122+
export function ensureLocaleLoaded(
123+
lang: string,
124+
version: string = ROXY_UI_VERSION,
125+
baseUrl?: string,
126+
): Promise<void> {
127+
if (typeof document === 'undefined') return Promise.resolve();
128+
const base = (lang || '').toLowerCase().split('-')[0] ?? '';
129+
if (!(ROXY_UI_LOCALES as readonly string[]).includes(base)) {
130+
return Promise.resolve();
131+
}
132+
const existing = localesLoaded.get(base);
133+
if (existing) return existing;
134+
135+
const pending = new Promise<void>((resolve, reject) => {
136+
const id = `${SCRIPT_ID}-locale-${base}`;
137+
let el = document.getElementById(id) as HTMLScriptElement | null;
138+
if (el) {
139+
if (el.dataset.loaded === 'true') resolve();
140+
else {
141+
el.addEventListener('load', () => resolve());
142+
el.addEventListener('error', () =>
143+
reject(new Error(`roxy-ui locale ${base} load failed`)),
144+
);
145+
}
146+
return;
147+
}
148+
el = document.createElement('script');
149+
el.id = id;
150+
el.src = `${baseUrl ?? buildBase(version)}/locales/${base}.js`;
151+
el.async = true;
152+
el.crossOrigin = 'anonymous';
153+
el.addEventListener('load', () => {
154+
el!.dataset.loaded = 'true';
155+
resolve();
156+
});
157+
el.addEventListener('error', () =>
158+
reject(new Error(`roxy-ui locale ${base} load failed`)),
159+
);
160+
document.head.appendChild(el);
161+
});
162+
localesLoaded.set(base, pending);
163+
return pending;
164+
}
165+
76166
// Default export retained for convenience; matches the named export.
77167
export default ensureScriptLoaded;
78168
// Surfaces the embedded @roxyapi/ui version this build of @roxyapi/ui-vue

0 commit comments

Comments
 (0)