Implement translations

See also: https://github.com/kytta/share2fedi/pull/43
Co-authored-by: Sunny Ripert <sunny@sunfox.org>
This commit is contained in:
Nikita Karamov 2023-08-27 23:43:01 +02:00
parent 31d95aa834
commit 89bb338065
No known key found for this signature in database
GPG key ID: 41D6F71EE78E77CD
10 changed files with 231 additions and 29 deletions

View file

@ -13,14 +13,16 @@ const { prefilledText, prefilledInstance } = Astro.props;
action="/api/share" action="/api/share"
method="POST" method="POST"
> >
<label> <label data-translate="postText">
Post text Post text
<textarea <textarea
name="text" name="text"
id="text" id="text"
rows="7" rows="7"
placeholder="What's on your mind?" placeholder="Whats on your mind?"
required required
data-translate="postTextPlaceholder"
data-translate-attribute="placeholder"
>{prefilledText}</textarea >{prefilledText}</textarea
> >
</label> </label>
@ -30,5 +32,7 @@ const { prefilledText, prefilledInstance } = Astro.props;
<input <input
type="submit" type="submit"
value="Publish" value="Publish"
data-translate="publish"
data-translate-attribute="value"
/> />
</form> </form>

View file

@ -8,7 +8,7 @@ const { prefilledInstance } = Astro.props;
--- ---
<datalist id="popular-instances"></datalist> <datalist id="popular-instances"></datalist>
<label> <label data-translate="instance">
Fediverse instance Fediverse instance
<div class="instance-input"> <div class="instance-input">
<span id="https-label">https://</span> <span id="https-label">https://</span>
@ -16,7 +16,6 @@ const { prefilledInstance } = Astro.props;
type="text" type="text"
name="instance" name="instance"
id="instance" id="instance"
placeholder="mastodon.social"
list="popular-instances" list="popular-instances"
required required
aria-describedby="https-label" aria-describedby="https-label"
@ -25,15 +24,24 @@ const { prefilledInstance } = Astro.props;
</div> </div>
</label> </label>
<div id="saved-instances"></div> <div
id="saved-instances"
data-translate="previouslyUsed"
>
Previously used:
<div></div>
</div>
<label for="remember"> <label
for="remember"
data-translate="rememberInstance"
>
<input <input
type="checkbox" type="checkbox"
id="remember" id="remember"
name="remember" name="remember"
/> />
Remember my instance on this device Remember instance on this device
</label> </label>
<style lang="scss"> <style lang="scss">
@ -61,6 +69,10 @@ const { prefilledInstance } = Astro.props;
#saved-instances { #saved-instances {
margin-bottom: 1rem; margin-bottom: 1rem;
> div {
display: inline-block;
}
:global(span) { :global(span) {
color: var(--s2f-accent-color-contrast); color: var(--s2f-accent-color-contrast);
cursor: pointer; cursor: pointer;
@ -87,8 +99,7 @@ const { prefilledInstance } = Astro.props;
instanceElement.value = getUrlDomain(savedInstances[0] as string); instanceElement.value = getUrlDomain(savedInstances[0] as string);
} }
document.querySelector("#saved-instances")!.replaceChildren( document.querySelector("#saved-instances>div")!.replaceChildren(
"Previously used: ",
...savedInstances ...savedInstances
.flatMap((instance: string) => { .flatMap((instance: string) => {
if (!instance) { if (!instance) {

View file

@ -0,0 +1,55 @@
---
/*!
* © 2023 Nikita Karamov
* Licensed under AGPL v3 or later
*/
import { languages } from "@i18n/translations";
const initialLanguage = "en";
---
<div>
<span data-translate="language">Language:</span>
<select
name="language"
id="language"
>
{
Object.entries(languages).map(([k, v]) => {
return (
<option
selected={k === initialLanguage}
value={k}
>
{v}
</option>
);
})
}
</select>
</div>
<script>
import { findBestLanguage } from "@i18n/engine";
import { applyTranslations } from "@i18n/engine";
import { $locale } from "@stores/i18n";
const select: HTMLSelectElement = document.querySelector("#language")!;
document.addEventListener("DOMContentLoaded", () => {
$locale.subscribe((newLocale) => {
if (newLocale === undefined) {
newLocale = findBestLanguage();
}
applyTranslations(newLocale);
if (select.value !== newLocale) {
select.value = newLocale;
}
});
select.addEventListener("change", (event) => {
$locale.set((event.target as typeof select).value);
});
});
</script>

54
src/i18n/engine.ts Normal file
View file

@ -0,0 +1,54 @@
import { strings, defaultLanguage, languages } from "./translations";
export function useTranslations(language: string) {
if (!(language in strings)) {
language = defaultLanguage;
}
return function t(
key: keyof (typeof strings)[typeof defaultLanguage],
): string {
return strings[language]![key] || strings[defaultLanguage]![key] || "";
};
}
export function findBestLanguage(): string {
let browserLanguages = navigator.languages;
if (!navigator.languages) browserLanguages = [navigator.language];
for (const language of browserLanguages) {
const locale = new Intl.Locale(language);
const minimized = locale.minimize();
for (const candidate of [locale.baseName, minimized.baseName]) {
if (candidate in languages) {
return candidate;
}
}
}
return defaultLanguage;
}
export function applyTranslations(language: string) {
const t = useTranslations(language);
for (const node of document.querySelectorAll("[data-translate]")) {
const dataset = (node as HTMLElement).dataset;
if (dataset.translateAttribute) {
node.setAttribute(dataset.translateAttribute, t(dataset.translate!));
continue;
}
const splitTranslated = t(dataset.translate!).split("{}");
if (splitTranslated.length === 1) {
node.innerHTML = t(dataset.translate!);
continue;
}
for (const child of node.childNodes) {
if (child.nodeType === Node.TEXT_NODE) {
child.textContent = splitTranslated.shift() || "";
}
}
}
}

55
src/i18n/translations.ts Normal file
View file

@ -0,0 +1,55 @@
export const languages = {
en: "English",
de: "Deutsch",
};
export const strings: Record<keyof typeof languages, Record<string, string>> = {
en: {
title: "Share₂Fedi — an instance-agnostic share page for the Fediverse",
metaDescription:
"Share₂Fedi is a share page for Mastodon, Pleroma, Misskey, and others. Type in your post text and the instance URL and click Publish!",
language: "Language:",
description:
"Share₂Fedi is an instance-agnostic share page for {}. With it, you can post to various federated platforms from a single page.",
fediverse: "the Fediverse",
supportedProjects: "Supported projects:",
incl: "incl.",
credits:
"Share₂Fedi is developed and maintained by {}. Source code is {}. Hosted with {}. {}.",
onGitHub: "on GitHub",
statusPage: "Status page",
licence: "Licence",
privacyNotice: "Privacy Notice",
postText: "Post text{}",
postTextPlaceholder: "Whats on your mind?",
instance: "Fediverse instance{}",
previouslyUsed: "Previously used: {}",
rememberInstance: "{} Remember instance on this device",
publish: "Publish",
},
de: {
title: "Share₂Fedi — eine instanzunabhängige Share-Seite für das Fediverse",
metaDescription:
"Share₂Fedi ist eine Share-Seite für Mastodon, Pleroma, Misskey und andere. Geben Sie Ihren Beitragstext und die Instanz-URL ein und klicken Sie auf „Veröffentlichen“!",
language: "Sprache:",
description:
"Share₂Fedi ist eine instanzunabhängige Share-Seite für {}. Mit ihr können Sie von einer einzigen Seite aus auf verschiedenen föderierten Plattformen posten.",
fediverse: "das Fediverse",
supportedProjects: "Unterstützte Projekte:",
incl: "inkl.",
credits:
"Share₂Fedi wird von {} entwickelt und gepflegt. Der Quellcode ist {}. Gehostet mit {}. {}.",
onGitHub: "auf GitHub",
statusPage: "Statusseite",
licence: "Lizenz",
privacyNotice: "Datenschutzhinweis",
postText: "Beitragstext{}",
postTextPlaceholder: "Was gibts Neues?",
instance: "Fediverse-Instanz{}",
previouslyUsed: "Bisher verwendet: {}",
rememberInstance: "{} Instanz auf diesem Gerät merken",
publish: "Veröffentlichen",
},
} as const;
export const defaultLanguage: keyof typeof strings = "en";

View file

@ -16,10 +16,12 @@ const { title } = Astro.props;
name="viewport" name="viewport"
content="width=device-width, initial-scale=1.0" content="width=device-width, initial-scale=1.0"
/> />
<title>{title}</title> <title data-translate="title">{title}</title>
<meta <meta
name="description" name="description"
content="Share₂Fedi is a share page for Mastodon, Pleroma, Misskey, and others. Type in your post text and the instance URL and click Publish!" content="Share₂Fedi is a share page for Mastodon, Pleroma, Misskey, and others. Type in your post text and the instance URL and click Publish!"
data-translate="metaDescription"
data-translate-attribute="content"
/> />
<link <link
rel="canonical" rel="canonical"

View file

@ -8,6 +8,8 @@ import Form from "@components/form.astro";
import { Content as PrivacyNotice } from "@pages/_privacy.md"; import { Content as PrivacyNotice } from "@pages/_privacy.md";
import { Content as Licence } from "@pages/_licence.md"; import { Content as Licence } from "@pages/_licence.md";
import LanguageSelect from "@components/language-select.astro";
const searchParameters = new URL(Astro.request.url).searchParams; const searchParameters = new URL(Astro.request.url).searchParams;
const prefilledText = searchParameters.get("text"); const prefilledText = searchParameters.get("text");
const prefilledInstance = searchParameters.get("instance"); const prefilledInstance = searchParameters.get("instance");
@ -21,6 +23,8 @@ const prefilledInstance = searchParameters.get("instance");
width="195" width="195"
height="60" height="60"
/> />
<LanguageSelect />
</header> </header>
<main> <main>
<Form <Form
@ -29,43 +33,53 @@ const prefilledInstance = searchParameters.get("instance");
/> />
</main> </main>
<aside> <aside>
<p> <p data-translate="description">
Share₂Fedi is an instance-agnostic share page for Share₂Fedi is an instance-agnostic share page for
<a href="https://en.wikipedia.org/wiki/Fediverse">the Fediverse</a>. With <a
it, you can post to various federated platforms from a single page. href="https://en.wikipedia.org/wiki/Fediverse"
data-translate="fediverse"
>the Fediverse</a
>. With it, you can post to various federated platforms from a single
page.
</p> </p>
<p><b>Supported projects:</b></p> <p><b data-translate="supportedProjects">Supported projects:</b></p>
<ul> <ul>
<li>Mastodon (incl. Hometown, Fedibird, GlitchCafé)</li>
<li> <li>
Pleroma (incl. Akkoma) Mastodon (<span data-translate="incl">incl.</span> Hometown, Fedibird,
GlitchCafé)
</li> </li>
<li> <li>
Misskey (incl. Firefish/Calckey, FoundKey, Meisskey) Pleroma (<span data-translate="incl">incl.</span> Akkoma)
</li> </li>
<li> <li>
Friendica Misskey (<span data-translate="incl">incl.</span> Firefish/Calckey,
</li> FoundKey, Meisskey)
<li>
Hubzilla
</li>
<li>
GNU Social
</li> </li>
<li>Friendica</li>
<li>Hubzilla</li>
<li>GNU Social</li>
</ul> </ul>
<p> <p data-translate="credits">
Share₂Fedi is developed and maintained by Share₂Fedi is developed and maintained by
<a href="https://www.kytta.dev/">Nikita Karamov</a>. Source code is <a href="https://www.kytta.dev/">Nikita Karamov</a>. Source code is
<a href="https://github.com/kytta/share2fedi">on GitHub</a>. Hosted with <a
href="https://github.com/kytta/share2fedi"
data-translate="onGitHub"
>on GitHub</a
>. Hosted with
<a href="https://vercel.com">Vercel</a>. <a href="https://vercel.com">Vercel</a>.
<a href="https://stats.uptimerobot.com/QOXj3uXPDX">Status page</a>. <a
href="https://stats.uptimerobot.com/QOXj3uXPDX"
data-translate="statusPage"
>Status page</a
>.
</p> </p>
<details> <details>
<summary>Licence</summary> <summary data-translate="licence">Licence</summary>
<Licence /> <Licence />
</details> </details>
<details> <details>
<summary>Privacy Notice</summary> <summary data-translate="privacyNotice">Privacy Notice</summary>
<PrivacyNotice /> <PrivacyNotice />
</details> </details>
</aside> </aside>

3
src/stores/i18n.ts Normal file
View file

@ -0,0 +1,3 @@
import { persistentAtom } from "@nanostores/persistent";
export const $locale = persistentAtom<string | undefined>("locale");

View file

@ -47,6 +47,9 @@ body {
header { header {
padding: 1rem; padding: 1rem;
display: flex;
align-items: center;
justify-content: space-between;
} }
main, main,

View file

@ -5,6 +5,7 @@
"baseUrl": ".", "baseUrl": ".",
"paths": { "paths": {
"@components/*": ["src/components/*"], "@components/*": ["src/components/*"],
"@i18n/*": ["src/i18n/*"],
"@layouts/*": ["src/layouts/*"], "@layouts/*": ["src/layouts/*"],
"@pages/*": ["src/pages/*"], "@pages/*": ["src/pages/*"],
"@scripts/*": ["src/scripts/*"], "@scripts/*": ["src/scripts/*"],