Skip to content
Draft
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
9 changes: 9 additions & 0 deletions .github/skills/bloom-automation/switchWorkspaceTab.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,15 @@ const getBloomPage = (browser) => {
};

const clickWorkspaceTab = async (page, tab) => {
// Prefer the stable attribute (see TopBar.tsx): the fallbacks below match on the visible
// English label, which fails in any other UI language -- including the Pseudo-English
// i18n-testing locale (BL-16748).
const taggedTab = page.getByTestId(`workspace-tab-${tab}`);
if ((await taggedTab.count()) > 0) {
await taggedTab.first().click();
return "top-level-data-testid";
}

const tabLabel = getTabLabel(tab);
const topLevelTab = page.getByRole("tab", { name: tabLabel });
if ((await topLevelTab.count()) > 0) {
Expand Down
51 changes: 51 additions & 0 deletions DistFiles/localization/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,57 @@ Once the translation process is started on Crowdin for a given language, transla
made outside of Crowdin are discouraged because it complicates merging changes made on Crowdin
and it negates most of the value of using Crowdin to begin with.

## The "Pseudo-English" UI language (qps-ploc) has no files here, and never should

On the developer and alpha channels, the UI Language menu offers **Pseudo-English (i18n test)**,
whose language tag is the standard pseudo-locale `qps-ploc`. It is *not* a translation. It is
produced by L10NSharp at lookup time by transforming the live English text: every vowel is
doubled with an accent on the first of the pair, and the whole string is wrapped in brackets, so
`Title Missing` becomes `[Tîitlée Mîissîing]`. Format placeholders (`{0}`, `%0`, `{name}`) and
markup pass through untouched.

It exists so we can see internationalization problems that are invisible in English:

- plain English in the UI = a hard-coded string that was never internationalized;
- a visible `{0}` / `%0` / `{name}` = a broken placeholder;
- a missing `]` = the string is being truncated;
- brackets in the middle of a sentence = the sentence is being concatenated at runtime;
- clipped or overflowing layout = the layout can't cope with the ~30-40% growth that real
translations routinely bring.

Because the pseudo text is derived from the English at the moment of lookup, **no `qps-*`
xliff files exist, are loaded, or are ever written**, and none should ever be added here or to
Crowdin. The pseudo-locale is always exactly as complete as the English source strings are.
(The unrelated `qaa` folder here is a leftover from Crowdin and has nothing to do with this.)

### What stays plain English on purpose

A few surfaces are localized by *whole file* rather than string by string: the file for the
current UI language is chosen at runtime, or the English one is used if there isn't one. There
is no `qps-ploc` file for any of them and there should not be, so under the pseudo-locale they
correctly show English:

- the built-in template readmes (`ReadMe-en.htm`, baked from
`DistFiles/localization/<Template>/ReadMe-<lang>.xlf` at build time by
`src/BloomBrowserUI/scripts/l10n-build.js`);
- readmes of downloaded or user-made templates (`ReadMe-en.md`) -- author content, never
localized by us at all;
- help and documentation pages reached through
`BloomFileLocator.GetBestLocalizableFileDistributedWithApplication`;
- xmatter descriptions (`<desc>-<lang>.txt`, see `XMatterInfo`).

This does not weaken the pseudo-locale: a whole document is either the translated file or the
English one, so there is no mixed population to read a signal from, and "the readme is in
English" is already obvious without any transform. So the tester's rule is: **plain English
means the string was never internationalized, unless it is one of the whole-file surfaces
listed above.**

(Note too that a development build generates only `ReadMe-en.htm`, so template readmes show
English there for *every* UI language, not just the pseudo-locale.)

See BL-16748, and `LocalizationManager.PseudoLocalizationLanguageId` /
`OfferPseudoLocalization` / `PseudoLocalize` in L10NSharp.

## Effects of English xliff file changes

- Changing the *original* attribute of the *file* element in the xliff file causes all *target*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
import { isLegacyThemeName } from "./appearanceThemeUtils";
import { FieldVisibilityGroup } from "./FieldVisibilityGroup";
import { StyleAndFontTable } from "./StyleAndFontTable";
import { findLinkTextBrackets } from "../../utils/textUtils";

// Should stay in sync with AppearanceSettings.PageNumberPosition
enum PageNumberPosition {
Expand Down Expand Up @@ -592,13 +593,14 @@ export const ThemeDisablesOptionsNoticeWithLink: React.FunctionComponent<{
"BookSettings.ThemeDisablesOptionsNoticeWithLink",
);

const linkStart = message.indexOf("[");
const linkEnd = message.indexOf("]", linkStart >= 0 ? linkStart + 1 : 0);
const brackets = findLinkTextBrackets(message);

if (linkStart < 0 || linkEnd <= linkStart) {
if (!brackets) {
return <span>{message}</span>;
}

const { open: linkStart, close: linkEnd } = brackets;

return (
<span>
{message.substring(0, linkStart)}
Expand Down
26 changes: 11 additions & 15 deletions src/BloomBrowserUI/bookEdit/toolbox/canvas/customPageLayoutMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { useL10n } from "../../../react_components/l10nHooks";
import { LocalizableSelectableMenuItem } from "../../../react_components/localizableMenuItem";
import { useGetFeatureStatus } from "../../../react_components/featureStatus";
import { getWorkspaceBundleExports } from "../../js/workspaceFrames";
import { findLinkTextBrackets } from "../../../utils/textUtils";

export const CustomPageLayoutMenu: React.FunctionComponent<{
isCustom: boolean;
Expand Down Expand Up @@ -168,22 +169,17 @@ const LegacyThemeCustomLayoutTooltip: React.FunctionComponent<{
"EditTab.CustomCover.Custom.DisabledForLegacyTheme.Message",
);

const linkStart = tooltipMessage.indexOf("[");
const linkEnd = tooltipMessage.indexOf(
"]",
linkStart >= 0 ? linkStart + 1 : 0,
);
const brackets = findLinkTextBrackets(tooltipMessage);

const beforeLink =
linkStart >= 0 ? tooltipMessage.substring(0, linkStart) : "";
const linkText =
linkStart >= 0 && linkEnd > linkStart
? tooltipMessage.substring(linkStart + 1, linkEnd)
: tooltipMessage;
const afterLink =
linkStart >= 0 && linkEnd > linkStart
? tooltipMessage.substring(linkEnd + 1)
: "";
const beforeLink = brackets
? tooltipMessage.substring(0, brackets.open)
: "";
const linkText = brackets
? tooltipMessage.substring(brackets.open + 1, brackets.close)
: tooltipMessage;
const afterLink = brackets
? tooltipMessage.substring(brackets.close + 1)
: "";

return (
<div
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -515,7 +515,11 @@ export class LocalizationManager {
let newstr = text.replace(reStrong, "$1<strong>$2</strong>$3");
const reEm = /(^|[^*])\*([^*]+)\*([^*]|$)/g;
newstr = newstr.replace(reEm, "$1<em>$2</em>$3");
const reA = /\[([^\]]*)\]\(([^)]*)\)/g;
// The link text may not itself contain "[". That keeps the Pseudo-English UI
// language working (BL-16748): pseudo-localization wraps the whole string in
// square brackets, and if the link text were allowed to span a "[" this would
// start matching at that wrapper and swallow the whole sentence into the link.
const reA = /\[([^[\]]*)\]\(([^)]*)\)/g;
newstr = newstr.replace(reA, '<a href="$2">$1</a>');
return newstr;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,18 @@ describe("localizationManager", () => {
expect(result6).toBe("This is a [**] test (*).");
});

it("processSimpleMarkdown leaves the pseudo-localization wrapper alone (BL-16748)", () => {
// Pseudo-English wraps the whole string in square brackets. The link markup is
// still the inner pair, so only "here" may become the link; the wrapper's own
// brackets stay as visible text.
const result = theOneLocalizationManager.processSimpleMarkdown(
"[Séeée höow îit wöorks [héerée](https://sil.org).]",
);
expect(result).toBe(
'[Séeée höow îit wöorks <a href="https://sil.org">héerée</a>.]',
);
});

it("simpleFormat replaces %0 and %1 with l10nParams", () => {
const result = theOneLocalizationManager.simpleFormat(
"%1 likes %0, but %0 does not like %1",
Expand Down
8 changes: 5 additions & 3 deletions src/BloomBrowserUI/publish/Apps/PrepareAppStepper.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
import * as React from "react";
import { kBloomBlue } from "../../bloomMaterialUITheme";
import { BloomStepper } from "../../react_components/BloomStepper";
import { findLinkTextBrackets } from "../../utils/textUtils";
import {
AppBuilderPrepareStepId,
IAppBuilderPrepareStepStatus,
Expand Down Expand Up @@ -45,10 +46,9 @@ export const PrepareStepTooltipContent: React.FunctionComponent<{
return <>{props.tooltip.text}</>;
}

const idxOpen = props.tooltip.text.indexOf("[");
const idxClose = props.tooltip.text.indexOf("]", idxOpen + 1);
const brackets = findLinkTextBrackets(props.tooltip.text);

if (idxOpen < 0 || idxClose <= idxOpen) {
if (!brackets) {
return (
<Link
underline="hover"
Expand All @@ -61,6 +61,8 @@ export const PrepareStepTooltipContent: React.FunctionComponent<{
);
}

const { open: idxOpen, close: idxClose } = brackets;

return (
<span>
{props.tooltip.text.substring(0, idxOpen)}
Expand Down
3 changes: 2 additions & 1 deletion src/BloomBrowserUI/react_components/TopBar/TopBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,8 @@ const Tab: React.FunctionComponent<{
<a
role="tab"
// Automation clicks tabs by this id. The visible label is localized, so matching
// on it would confine every test to an English UI.
// on it would confine every test to an English UI -- including the
// Pseudo-English i18n-testing locale (BL-16748).
data-testid={`workspace-tab-${props.tab.id}`}
Comment thread
andrew-polk marked this conversation as resolved.
aria-selected={props.selected ? "true" : "false"}
aria-disabled={props.disabled ? "true" : "false"}
Expand Down
7 changes: 4 additions & 3 deletions src/BloomBrowserUI/react_components/link.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
LocalizableElement,
} from "./l10nComponents";
import { kBloomDisabledText } from "../utils/colorUtils";
import { findLinkTextBrackets } from "../utils/textUtils";

interface ILinkProps extends ILocalizationProps {
id?: string;
Expand Down Expand Up @@ -78,9 +79,9 @@ export class TextWithEmbeddedLink extends LocalizableElement<
public render() {
// Text within [] is for the link.
const parts = this.getLocalizedContentAndClass();
const idxOpen = parts.text.indexOf("[");
const idxClose = parts.text.indexOf("]", idxOpen + 1);
if (idxOpen >= 0 && idxClose > idxOpen) {
const brackets = findLinkTextBrackets(parts.text);
if (brackets) {
const { open: idxOpen, close: idxClose } = brackets;
// We found the link text, piece together the desired output
return (
<span className={parts.l10nClass}>
Expand Down
7 changes: 4 additions & 3 deletions src/BloomBrowserUI/react_components/pWithLink.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
LocalizableElement,
} from "./l10nComponents";
import { Link as MuiLink } from "@mui/material";
import { findLinkTextBrackets } from "../utils/textUtils";

export interface ILocalizationPropsWithLink extends ILocalizationProps {
href: string;
Expand All @@ -18,9 +19,9 @@ export class PWithLink extends LocalizableElement<

// Text within [] is for the link.
const parts = this.getLocalizedContentAndClass();
const idxOpen = parts.text.indexOf("[");
const idxClose = parts.text.indexOf("]", idxOpen + 1);
if (idxOpen >= 0 && idxClose > idxOpen) {
const brackets = findLinkTextBrackets(parts.text);
if (brackets) {
const { open: idxOpen, close: idxClose } = brackets;
// We found the link text, piece together the desired output
return (
<p className={this.getClassName()}>
Expand Down
50 changes: 49 additions & 1 deletion src/BloomBrowserUI/utils/textUtils.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, it, expect } from "vitest";
import { splitIntoGraphemes } from "./textUtils";
import { findLinkTextBrackets, splitIntoGraphemes } from "./textUtils";

describe("split into grapheme tests", () => {
it("handles diacritics followed by space", () => {
Expand All @@ -15,3 +15,51 @@ describe("split into grapheme tests", () => {
expect(letters[6]).toBe("s");
});
});

describe("findLinkTextBrackets", () => {
// The marked-up words are the ones a caller turns into a hyperlink.
const expectLinkText = (text: string, expected: string) => {
const brackets = findLinkTextBrackets(text);
if (!brackets) {
throw new Error(`Found no link brackets at all in "${text}"`);
}
expect(text.substring(brackets.open + 1, brackets.close)).toBe(
expected,
);
};

it("finds the marked words in an ordinary string", () => {
expectLinkText(
"Most ePUB readers are very low quality ([see our research and recommendations]).",
"see our research and recommendations",
);
});

it("finds them when the string opens with the marker", () => {
expectLinkText(
"[Some Bloom features] are not supported by most or all ePUB readers.",
"Some Bloom features",
);
});

it("ignores the wrapper that pseudo-localization adds (BL-16748)", () => {
// Pseudo-English brackets the whole string, so the first "[" is no longer the
// link's. Taking the first "[" here would make the link swallow the sentence.
expectLinkText(
"[Möost éePÛUB réeåadéers ([séeée öoûur réeséeåarch]).]",
"séeée öoûur réeséeåarch",
);
expectLinkText(
"[[Söomée Blöoöom féeåatûurées] åarée nöot sûuppöortéed.]",
"Söomée Blöoöom féeåatûurées",
);
});

it("reports no pair when the string is not marked up", () => {
expect(findLinkTextBrackets("Nothing to link here.")).toBeUndefined();
expect(findLinkTextBrackets("Half a pair [ only.")).toBeUndefined();
expect(
findLinkTextBrackets("Closing ] with no opener."),
).toBeUndefined();
});
});
25 changes: 25 additions & 0 deletions src/BloomBrowserUI/utils/textUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,28 @@ export function splitIntoGraphemes(text: string): string[] {
const graphemeRegex = /(\p{L}| )\p{M}*/gu;
return text.match(graphemeRegex) || [];
}

/**
* Several of our UI strings mark the words that should become a hyperlink by wrapping
* them in square brackets, e.g. "Most ePUB readers are very low quality ([see our
* research and recommendations])."
*
* Finding that pair by taking the first "[" and the next "]" breaks in the
* Pseudo-English UI language (BL-16748), because pseudo-localization wraps the whole
* string in brackets of its own: the first "[" is then the wrapper's, and everything up
* to the real link's "]" gets swallowed into the link. So we anchor on the first "]"
* instead and take the nearest "[" before it, which picks the innermost pair -- the real
* link markup in both the plain and the pseudo-localized string.
*
* Returns undefined when there is no usable pair, in which case the caller decides what
* to do with the unmarked string.
*/
export function findLinkTextBrackets(
text: string,
): { open: number; close: number } | undefined {
const close = text.indexOf("]");
if (close < 0) return undefined;
const open = text.lastIndexOf("[", close);
if (open < 0) return undefined;
return { open, close };
}
2 changes: 1 addition & 1 deletion src/BloomExe/BloomExe.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -247,7 +247,7 @@
<PackageReference Include="Fleck" Version="1.1.0" />
<PackageReference Include="Glob" Version="1.1.8" />
<PackageReference Include="HtmlAgilityPack" Version="1.12.1" />
<PackageReference Include="L10NSharp" Version="10.0.0-beta0005" />
<PackageReference Include="L10NSharp" Version="10.1.0-beta0001" />
<PackageReference Include="LargeAddressAware" Version="1.0.5" />
<PackageReference Include="Markdig.Signed" Version="0.37.0" />
<PackageReference Include="Microsoft.CSharp" Version="4.7.0" />
Expand Down
5 changes: 5 additions & 0 deletions src/BloomExe/Book/TranslationGroupManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,11 @@ SafeXmlElement editableDiv in elementOrDom.SafeSelectNodes(

foreach (var uiLanguage in LocalizationManager.GetAvailableLocalizedLanguages())
{
// The pseudo-locale is a UI-testing device, not a translation; this loop
// writes into the book's own content, which must never be pseudolocalized.
// See BL-16748.
if (uiLanguage == LocalizationManager.PseudoLocalizationLanguageId)
continue;
var translation = LocalizationManager.GetDynamicStringOrEnglish(
"Bloom",
l10nId,
Expand Down
Loading