Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
6 changes: 4 additions & 2 deletions components/global.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
// ! AVOID IMPORTS HERE
import { phrases } from "./i18n";
// phrasesData resolves the Firebase runtime phrases (top-level await) so the
// full translation table is available before rc.init below runs.
import { phrasesData } from "../preprocess/phrases-loader";

// Map data-structure with a default value
// https://stackoverflow.com/questions/51319147/map-default-value
Expand Down Expand Up @@ -99,7 +101,7 @@ if (typeof RemoteCalibrator === "undefined") {
export const rc = RemoteCalibrator; // Currently imported from HTML script tag
await rc.init(
{
languagePhrasesJSON: phrases,
languagePhrasesJSON: phrasesData.phrases,
},
undefined,
{
Expand Down
37,605 changes: 0 additions & 37,605 deletions components/i18n.js

This file was deleted.

27 changes: 27 additions & 0 deletions components/loadingScreenText.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
type Phrases = Record<string, Record<string, string>>;

export interface LoadingScreenText {
loadingText: string;
timeoutMessage: string;
reloadButton: string;
}

/**
* Resolve the loading-screen strings for the experiment's language, looked up by
* its English language name (as set in index.html). Returns null when the name
* matches no known language.
*/
export const localizeLoadingScreen = (
phrases: Phrases,
experimentLanguageName: string,
): LoadingScreenText | null => {
const lang = Object.keys(phrases.EE_languageNameEnglish).find(
(key) => phrases.EE_languageNameEnglish[key] === experimentLanguageName,
);
if (!lang) return null;
return {
loadingText: phrases.RC_LoadingStudy[lang],
timeoutMessage: phrases.RC_LoadingStudyTakingLonger[lang],
reloadButton: phrases.RC_ReloadStudyButton[lang],
};
};
9 changes: 9 additions & 0 deletions components/readPhrases.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
export declare const useWordDigitBool: { current: boolean };

export declare function readi18nPhrases(
phraseName: string,
language: string,
): string;
export declare function readi18nPhrases(
phraseName: string,
): Record<string, string>;
4 changes: 3 additions & 1 deletion components/readPhrases.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { phrases } from "./i18n";
import { getPhrases } from "../parameters/phrasesRegistry";

export const useWordDigitBool = { current: false };

export const readi18nPhrases = (phraseName, language = undefined) => {
const phrases = getPhrases();

if (phraseName.toLowerCase().includes("letter") && useWordDigitBool.current) {
phraseName = phraseName.replace("letter", "digit");
phraseName = phraseName.replace("Letter", "Digit");
Expand Down
6 changes: 3 additions & 3 deletions components/useSoundCalibration.js
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@ import {
initializeMicrophoneDropdownForCalibration,
startMicrophonePolling,
} from "./soundTest";
import { phrases } from "./i18n";
import { getPhrases } from "../parameters/phrasesRegistry";
import {
CompatibilityPeer,
ConnectionManager,
Expand Down Expand Up @@ -2461,7 +2461,7 @@ const startCalibration = async (
: select
? select.options[select.selectedIndex].textContent
: "",
phrases: phrases,
phrases: getPhrases(),
calibrateSoundSimulateMicrophone: simulationEnabled
? calibrateSoundSimulateMicrophone.type === "impulseResponse"
? calibrateSoundSimulateMicrophone.amplitudes
Expand Down Expand Up @@ -2787,7 +2787,7 @@ export const calibrateAgain = async (
: select
? select.options[select.selectedIndex].textContent
: "",
phrases: phrases,
phrases: getPhrases(),
calibrateSoundSimulateMicrophone: simulationEnabled
? calibrateSoundSimulateMicrophone.type === "impulseResponse"
? calibrateSoundSimulateMicrophone.amplitudes
Expand Down
46 changes: 34 additions & 12 deletions first.js
Original file line number Diff line number Diff line change
@@ -1,11 +1,36 @@
// Import only what's needed for initial page rendering
import { initProgress } from "./components/timeoutUtils.js";
import * as sentry from "./components/sentry";
import { localizeLoadingScreen } from "./components/loadingScreenText";

// Resolve the EasyEyes base URL for the loading-screen phrases fetch. Kept
// self-contained (no shared imports) so first.js stays a standalone bundle —
// see loadI18n below. Mirrors components/easyeyesBaseUrl.ts for the deployed
// cases (preview-deploy param, production); a local dev server is assumed at
// :8888 without probing, since the spinner falls back gracefully on failure.
const getBaseUrl = () => {
const previewDeployBase = new URLSearchParams(window.location.search).get(
"preview-deploy",
);
if (previewDeployBase) return previewDeployBase;
if (window.location.hostname !== "localhost") return "https://easyeyes.app";
return "http://localhost:8888";
};

// Load i18n asynchronously (don't block spinner display)
// Load phrases for the loading screen from the same versioned read-path the
// experiment uses. Inlined here (not imported from preprocess/phrases-loader,
// which has a top-level await) so first.js bundles standalone — sharing a module
// across that async boundary would split a chunk that Pavlovia does not deploy.
// A single attempt is enough: setupInitialUI's .catch falls back to plain text.
const loadI18n = async () => {
const i18nModule = await import("./components/i18n.js");
return i18nModule.phrases;
const [username, experimentName] = window.location.pathname
.split("/")
.filter(Boolean);
const url = `${getBaseUrl()}/.netlify/functions/phrases?pinned=${username}/${experimentName}`;
const res = await fetch(url);
if (!res.ok) throw new Error(`phrases fetch failed: ${res.status}`);
const data = await res.json();
return data.phrases;
};

// Initial UI setup function - show spinner immediately
Expand Down Expand Up @@ -33,24 +58,21 @@ const setupInitialUI = () => {
// Lazy-load i18n and update text elements once available
loadI18n()
.then((phrases) => {
const el = experimentLanguage; // It is loaded in the index.html
const lang = Object.keys(phrases.EE_languageNameEnglish).find(
(key) => phrases.EE_languageNameEnglish[key] === el,
);
if (lang) {
// experimentLanguage is loaded in the index.html
const text = localizeLoadingScreen(phrases, experimentLanguage);
if (text) {
// Update text elements (DOM already has them, just populate)
const loadingText = loadingElement.querySelector(".loading-text");
if (loadingText) {
loadingText.textContent = phrases.RC_LoadingStudy[lang];
loadingText.textContent = text.loadingText;
}
const timeoutMessage = document.getElementById("timeoutMessage");
if (timeoutMessage) {
timeoutMessage.textContent =
phrases.RC_LoadingStudyTakingLonger[lang];
timeoutMessage.textContent = text.timeoutMessage;
}
const reloadButton = document.getElementById("reloadButton");
if (reloadButton) {
reloadButton.textContent = phrases.RC_ReloadStudyButton[lang];
reloadButton.textContent = text.reloadButton;
}
}
})
Expand Down
1 change: 1 addition & 0 deletions jest.config.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -22,5 +22,6 @@ module.exports = {
"<rootDir>/node_modules",
"<rootDir>/tests/setup.ts",
"<rootDir>/tests/glossary-loader.test.ts",
"<rootDir>/tests/phrases-loader.test.ts",
],
};
5 changes: 4 additions & 1 deletion jest.esm.config.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,8 @@ module.exports = {
...base,
testPathIgnorePatterns: ["<rootDir>/node_modules"],
setupFilesAfterEnv: ["<rootDir>/tests/setup.ts"],
testMatch: ["<rootDir>/tests/glossary-loader.test.ts"],
testMatch: [
"<rootDir>/tests/glossary-loader.test.ts",
"<rootDir>/tests/phrases-loader.test.ts",
],
};
9 changes: 3 additions & 6 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@
"sideEffects": [
"*.css",
"components/sentry.js",
"components/global.js"
"components/global.js",
"preprocess/phrases-loader.ts"
],
"description": "Threshold Participant.",
"main": "threshold.js",
Expand All @@ -14,8 +15,6 @@
"test:loader": "NODE_OPTIONS=--experimental-vm-modules jest --config jest.esm.config.cjs --no-coverage",
"setup:psychojs": "cd psychojs && git checkout threshold-prod && npm ci && cd ..",
"format": "prettier --write \"**/*.{js,css,html,md,ts}\"",
"phrases": "node server/fetch-languages-sheets.mjs && prettier --write components/i18n.js && git add components/i18n.js",
"fetch": "npm run phrases",
"prepare": "husky install && npm run setup:psychojs",
"check:rust": "sh server/check-rust.sh",
"check:ts": "tsc --noEmit -p tsconfig.json",
Expand All @@ -30,9 +29,7 @@
"clean": "rm -r node_modules",
"netlify:install": "npm ci",
"netlify": "npm run netlify:install && npm run update:submodules && npm run setup:psychojs && npm run build && npm run examples",
"netlify:website": "npm run netlify:install && npm run update:submodules && npm run setup:psychojs && npm run build",
"analyze:phrases-undefined": "node server/i18n-phrases-static-analysis.mjs undefined",
"analyze:phrases-unused": "node server/i18n-phrases-static-analysis.mjs unused"
"netlify:website": "npm run netlify:install && npm run update:submodules && npm run setup:psychojs && npm run build"
},
"repository": {
"type": "git",
Expand Down
18 changes: 18 additions & 0 deletions parameters/phrasesRegistry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import type { PhrasesData } from "../../source/components/types";

let registry: PhrasesData | null = null;

export function initPhrases(data: PhrasesData): void {
registry = data;
}

export function getPhrases(): Record<string, Record<string, string>> {
if (registry === null) {
throw new Error("getPhrases() called before initPhrases()");
}
return registry.phrases;
}

export function getPhrasesVersion(): string | null {
return registry?.version ?? null;
}
3 changes: 0 additions & 3 deletions preprocess/files.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,6 @@ export const _loadFiles: string[] = [
"js/easyeyes_wasm.min.js.map",
"js/first.min.js",
"js/first.min.js.map",
"js/i18n.js",
"js/preload-helper.js",
"js/preload-helper.min.js.map",
"js/threshold.css",
"js/threshold.min.js",
"js/threshold.min.js.map",
Expand Down
45 changes: 45 additions & 0 deletions preprocess/phrases-loader.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { wait, getRetryDelayMs } from "./retry";
import { initPhrases } from "../parameters/phrasesRegistry";
import type { PhrasesData } from "../../source/components/types";
import { getEasyEyesBaseUrl } from "../components/easyeyesBaseUrl";

export async function loadPhrases(pathname: string): Promise<PhrasesData> {
const [username, experimentName] = pathname.split("/").filter(Boolean);
const base = await getEasyEyesBaseUrl();
const url = `${base}/.netlify/functions/phrases?pinned=${username}/${experimentName}`;

let attempt = 0;
while (true) {
let res: Response;
try {
res = await fetch(url);
} catch {
await wait(getRetryDelayMs(attempt++));
continue;
}

if (res.status === 404) {
const body = (await res.json()) as { error?: string };
if (body.error === "No pinned version") {
throw new Error(
`No phrasesVersion pinned for ${username}/${experimentName}. Recompile the experiment.`,
);
}
await wait(getRetryDelayMs(attempt++));
continue;
}

if (!res.ok) {
await wait(getRetryDelayMs(attempt++));
continue;
}

const data = (await res.json()) as PhrasesData;
initPhrases(data);
return data;
}
}

export const phrasesData: PhrasesData = await loadPhrases(
window.location.pathname,
);
Loading