Skip to content

Commit

Permalink
feat: add localization support
Browse files Browse the repository at this point in the history
fixes #60
  • Loading branch information
b-kelly committed Apr 14, 2022
1 parent 5a4c9b0 commit e9ae75e
Show file tree
Hide file tree
Showing 10 changed files with 156 additions and 19 deletions.
11 changes: 10 additions & 1 deletion site/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,16 @@ domReady(() => {
}

// asynchronously load the required bundles
void import("../src/index").then(function ({ StacksEditor }) {
void import("../src/index").then(function ({
StacksEditor,
registerLocalizationStrings,
}) {
registerLocalizationStrings({
menubar: {
mode_toggle_title: "Localization test: Toggle editor mode",
},
});

const options: StacksEditorOptions = {
defaultView: getDefaultEditor(),
editorHelpLink: "#TODO",
Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,5 @@ export {
StacksEditorOptions,
EditorType,
} from "./stacks-editor/editor";
export { registerLocalizationStrings } from "./shared/localization";
export type { ExternalEditorPlugin } from "./shared/external-editor-plugin";
6 changes: 4 additions & 2 deletions src/rich-text/node-views/code-block.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Node as ProsemirrorNode } from "prosemirror-model";
import { NodeView } from "prosemirror-view";
import { getBlockLanguage } from "../../shared/highlighting/highlight-plugin";
import { _t } from "../../shared/localization";
import { escapeHTML } from "../../shared/utils";

/**
Expand Down Expand Up @@ -49,8 +50,9 @@ export class CodeBlockView implements NodeView {
.detectedHighlightLanguage as string;

if (autodetectedLanguage) {
// TODO localization
autodetectedLanguage += " (auto)";
autodetectedLanguage = _t("nodes.codeblock_lang_auto", {
lang: autodetectedLanguage,
});
}

return autodetectedLanguage || getBlockLanguage(node);
Expand Down
14 changes: 10 additions & 4 deletions src/rich-text/plugins/link-tooltip.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { toggleMark } from "prosemirror-commands";
import { Mark, Schema } from "prosemirror-model";
import { EditorState, TextSelection, Transaction } from "prosemirror-state";
import { Decoration, DecorationSet, EditorView } from "prosemirror-view";
import { _t } from "../../shared/localization";
import {
StatefulPlugin,
StatefulPluginKey,
Expand Down Expand Up @@ -53,7 +54,6 @@ class LinkTooltip {
this.content.setAttribute("data-controller", "s-popover");
this.content.setAttribute("data-s-popover-placement", "bottom");

// TODO localization (everywhere we have harcoded template strings)
this.content.innerHTML = escapeHTML`<div class="s-popover is-visible p4 w-auto wmx-initial wmn-initial js-link-tooltip"
id="link-tooltip-popover"
role="menu">
Expand All @@ -72,13 +72,19 @@ class LinkTooltip {
</div>
<button type="button"
class="flex--item s-btn mr4 js-link-tooltip-edit"
title="${"Edit link"}"><span class="svg-icon icon-bg iconPencilSm"></span></button>
title="${_t(
"link_tooltip.edit_button_title"
)}"><span class="svg-icon icon-bg iconPencilSm"></span></button>
<button type="button"
class="flex--item s-btn d-none js-link-tooltip-apply"
title="${"Apply new link"}">${"Apply"}</button>
title="${_t("link_tooltip.apply_button_title")}">${_t(
"link_tooltip.apply_button_text"
)}</button>
<button type="button"
class="flex--item s-btn js-link-tooltip-remove"
title="${"Remove link"}"><span class="svg-icon icon-bg iconTrashSm"></span></button>
title="${_t(
"link_tooltip.remove_button_title"
)}"><span class="svg-icon icon-bg iconTrashSm"></span></button>
</div>
</div>`;

Expand Down
78 changes: 78 additions & 0 deletions src/shared/localization.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import type { PartialDeep } from "./utils";

type Strings = {
[key: string]:
| string
| ((params: Record<string, unknown>) => string)
| Strings;
};

/** The default set of localizable strings */
export const defaultStrings = {
link_tooltip: {
apply_button_text: "Apply" as string,
apply_button_title: "Apply new link" as string,
edit_button_title: "Edit link" as string,
remove_button_title: "Remove link" as string,
},
menubar: {
mode_toggle_label: "Markdown" as string,
mode_toggle_title: "Toggle Markdown mode" as string,
},
nodes: {
codeblock_lang_auto: ({ lang }: { lang: string }) => `${lang} (auto)`,
spoiler_reveal_text: "Reveal spoiler" as string,
},
image_upload: {
upload_error_file_too_big:
"Your image is too large to upload (over 2 MiB)" as string,
upload_error_generic:
"Image upload failed. Please try again." as string,
upload_error_unsupported_format:
"Please select an image (jpeg, png, gif) to upload" as string,
uploaded_image_preview_alt: "uploaded image preview" as string,
},
} as const;

/** The set of strings that were overridden by registerLocalizationStrings */
let strings: PartialDeep<typeof defaultStrings> = defaultStrings;

/** Registers new localization strings; any strings that are left unregistered will fall back to the default value */
export function registerLocalizationStrings(
newStrings: PartialDeep<typeof defaultStrings>
) {
strings = newStrings;
}

/** Resolves a dot-separeated key against an object */
function resolve(obj: Strings, key: string) {
return key.split(".").reduce((p, n) => p?.[n], obj);
}

/** Caches key lookups to their values so we're not continuously splitting */
const cache: Strings = {};

/**
* Checks the localized strings for a given key and returns the value
* @param key A dot-separated key to the localized string e.g. "menubar.mode_toggle_label"
* @param params An object of parameters to pass to the localization function if it exists
*/
export function _t(key: string, params: Record<string, unknown> = {}): string {
if (!(key in cache)) {
cache[key] = resolve(strings, key) || resolve(defaultStrings, key);
}

const string = cache[key];

if (!string) {
throw `Missing translation for key: ${key}`;
}

if (typeof string === "string") {
return string;
} else if (typeof string === "function") {
return string(params);
}

throw `Missing translation for key: ${key}`;
}
10 changes: 5 additions & 5 deletions src/shared/prosemirror-plugins/image-upload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { richTextSchema } from "../schema";
import { PluginView } from "../view";
import { StatefulPlugin, StatefulPluginKey } from "./plugin-extensions";
import { dispatchEditorEvent, escapeHTML } from "../utils";
import { _t } from "../localization";

/**
* Async image upload callback that is passed the uploaded file and retuns a resolvable path to the image
Expand Down Expand Up @@ -319,13 +320,13 @@ export class ImageUploader implements PluginView {
switch (validationResult) {
case ValidationResult.FileTooLarge:
this.showValidationError(
"Your image is too large to upload (over 2 MiB)"
_t("image_upload.upload_error_file_too_big")
);
reject("file too large");
return;
case ValidationResult.InvalidFileType:
this.showValidationError(
"Please select an image (jpeg, png, gif) to upload"
_t("image_upload.upload_error_unsupported_format")
);
reject("invalid filetype");
return;
Expand All @@ -341,8 +342,7 @@ export class ImageUploader implements PluginView {
image.className = "hmx1 w-auto";
image.title = file.name;
image.src = reader.result as string;
// TODO localization
image.alt = "uploaded image preview";
image.alt = _t("image_upload.uploaded_image_preview_alt");
previewElement.appendChild(image);
previewElement.classList.remove("d-none");
this.image = file;
Expand Down Expand Up @@ -446,7 +446,7 @@ export class ImageUploader implements PluginView {
// reshow the image uploader along with an error message
showImageUploader(view);
this.showValidationError(
"Image upload failed. Please try again.",
_t("image_upload.upload_error_generic"),
"error"
);
}
Expand Down
4 changes: 2 additions & 2 deletions src/shared/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
ParseRule,
Schema,
} from "prosemirror-model";
import { _t } from "./localization";
import { escapeHTML } from "./utils";

//TODO this relies on Stacks classes, should we abstract?
Expand Down Expand Up @@ -136,8 +137,7 @@ const spoilerNodeSpec: NodeSpec = {
"blockquote",
{
"class": "spoiler" + (node.attrs.revealed ? " is-visible" : ""),
// TODO localization
"data-spoiler": "Reveal spoiler",
"data-spoiler": _t("nodes.spoiler_reveal_text"),
},
0,
];
Expand Down
3 changes: 3 additions & 0 deletions src/shared/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -239,3 +239,6 @@ export function dispatchEditorEvent(
});
return target.dispatchEvent(event);
}

/** Helper type that recursively makes an object and all its children Partials */
export type PartialDeep<T> = { [key in keyof T]?: PartialDeep<T[key]> };
16 changes: 11 additions & 5 deletions src/stacks-editor/editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { View, CommonViewOptions, BaseView } from "../shared/view";
import type { Node as ProseMirrorNode } from "prosemirror-model";
import { EditorView } from "prosemirror-view";
import { toggleReadonly } from "../shared/prosemirror-plugins/readonly";
import { _t } from "../shared/localization";

//NOTE relies on Stacks classes. Should we separate out so the editor is more agnostic?

Expand Down Expand Up @@ -314,17 +315,22 @@ export class StacksEditor implements View {
const container = document.createElement("div");
container.className = "flex--item d-flex ai-center ml24 fc-medium";

// TODO localization
container.innerHTML = escapeHTML`<label class="flex--item fs-caption mr4 sm:d-none" for="js-editor-toggle-${this.internalId}">Markdown</label>
<label class="flex--item mr4 d-none sm:d-block" for="js-editor-toggle-${this.internalId}">
container.innerHTML = escapeHTML`<label class="flex--item fs-caption mr4 sm:d-none" for="js-editor-toggle-${
this.internalId
}">${_t("menubar.mode_toggle_label")}</label>
<label class="flex--item mr4 d-none sm:d-block" for="js-editor-toggle-${
this.internalId
}">
<span class="icon-bg iconMarkdown"></span>
</label>
<div class="flex--item s-toggle-switch js-editor-mode-switcher">
<input class="js-editor-toggle-state" id="js-editor-toggle-${this.internalId}" type="checkbox" ${checkedProp}/>
<input class="js-editor-toggle-state" id="js-editor-toggle-${
this.internalId
}" type="checkbox" ${checkedProp}/>
<div class="s-toggle-switch--indicator"></div>
</div>`;

container.title = "Toggle Markdown editing";
container.title = _t("menubar.mode_toggle_title");

container
.querySelector("#js-editor-toggle-" + this.internalId)
Expand Down
32 changes: 32 additions & 0 deletions test/shared/localization.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { registerLocalizationStrings, _t } from "../../src/shared/localization";

describe("localization", () => {
it("should find nested entries", () => {
expect(_t("link_tooltip.apply_button_text")).toBe("Apply");
});

it("should execute function values with params passed", () => {
expect(_t("nodes.codeblock_lang_auto", { lang: "test" })).toBe(
"test (auto)"
);
});

it("should throw when an entry is not found", () => {
expect(() => _t("fake.faker.fakest")).toThrow(
/^Missing translation for key:/
);
});

it("should allow overriding a partial set of strings", () => {
registerLocalizationStrings({
nodes: {
spoiler_reveal_text: "Раскрыть спойлер",
},
});

// overridden
expect(_t("nodes.spoiler_reveal_text")).toBe("Раскрыть спойлер");
// not overridden - falls back to default
expect(_t("link_tooltip.apply_button_text")).toBe("Apply");
});
});

0 comments on commit e9ae75e

Please sign in to comment.