Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Introduce i18n #933

Merged
merged 17 commits into from Jun 13, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
3 changes: 2 additions & 1 deletion package.json
Expand Up @@ -29,7 +29,8 @@
"option-t": "^32.2.1",
"prismjs": "^1.28.0",
"react": "^18.1.0",
"react-dom": "^18.1.0"
"react-dom": "^18.1.0",
"rosetta": "^1.1.0"
},
"devDependencies": {
"@mapbox/rehype-prism": "^0.8.0",
Expand Down
3 changes: 2 additions & 1 deletion src/components/EntryList.tsx
Expand Up @@ -7,6 +7,7 @@ import { formatYYMMDDString, formatISOString } from '../entry/date';
import { EntryValue } from '../entry/entry_value';

import styles from './EntryList.module.css';
import { retrieveTranslation } from '../locales/i18n';

interface Props {
title: string;
Expand Down Expand Up @@ -40,5 +41,5 @@ export const EntryList = ({ title, entries }: Props): JSX.Element =>
</ol>
</>
) : (
<p>記事はありません。</p>
<p>{retrieveTranslation('components.entryList.notFound')}</p>
);
8 changes: 5 additions & 3 deletions src/components/SiteMetadata.tsx
Expand Up @@ -3,19 +3,21 @@ import Head from 'next/head';
import { GTM_ID } from '../tracking/gtm_id';
import { createGAOptout } from '../tracking/ga_optout';
import { insertGtmScript } from '../tracking/gtm';
import { FAVICON_URL, OG_IMAGE_URL, SITE_TITLE, TWITTER_ACCOUNT_ID } from '../constants/site_data';
import { FAVICON_URL, OG_IMAGE_URL, TWITTER_ACCOUNT_ID } from '../constants/site_data';
import { PathList } from '../constants/path_list';
import { retrieveTranslation } from '../locales/i18n';

const BLUE_600 = '#003760';
const MAIN_COLOR = BLUE_600;

export const SiteMetadata = (): JSX.Element => {
const gaOptout = createGAOptout(GTM_ID);
const webSiteTitle = retrieveTranslation('website.title');

return (
<Head>
<meta name="theme-color" content={MAIN_COLOR} />
<meta property="og:site_name" content={SITE_TITLE} />
<meta property="og:site_name" content={webSiteTitle} />
<meta property="og:image" content={OG_IMAGE_URL} />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
Expand All @@ -25,7 +27,7 @@ export const SiteMetadata = (): JSX.Element => {
<meta name="Hatena::Bookmark" content="nocomment" />
<link rel="apple-touch-icon" href={FAVICON_URL} />
<link rel="icon" type="image/png" href={FAVICON_URL} />
<link rel="alternate" type="application/atom+xml" href={PathList.Feed} title={SITE_TITLE} />
<link rel="alternate" type="application/atom+xml" href={PathList.Feed} title={webSiteTitle} />
{!gaOptout.enabled() && insertGtmScript(GTM_ID)}
</Head>
);
Expand Down
3 changes: 2 additions & 1 deletion src/components/icon/facebook.tsx
@@ -1,9 +1,10 @@
import React from 'react';
import { retrieveTranslation } from '../../locales/i18n';

// https://github.com/FortAwesome/Font-Awesome/blob/master/js-packages/@fortawesome/fontawesome-free/svgs/brands/facebook-f.svg
// License: https://fontawesome.com/license/free
export const FacebookSvg = (): JSX.Element => (
<svg viewBox="0 0 320 512" aria-label="Facebook でシェア" role="img">
<svg viewBox="0 0 320 512" aria-label={retrieveTranslation('components.icon.facebook')} role="img">
<path d="M279.14 288l14.22-92.66h-88.91v-60.13c0-25.35 12.42-50.06 52.24-50.06h40.42V6.26S260.43 0 225.36 0c-73.22 0-121.08 44.38-121.08 124.72v70.62H22.89V288h81.39v224h100.17V288z" />
</svg>
);
3 changes: 2 additions & 1 deletion src/components/icon/twitter.tsx
@@ -1,8 +1,9 @@
import React from 'react';
import { retrieveTranslation } from '../../locales/i18n';
// https://github.com/FortAwesome/Font-Awesome/blob/master/js-packages/@fortawesome/fontawesome-free/svgs/brands/twitter.svg
// License: https://fontawesome.com/license/free
export const TwitterSvg = (): JSX.Element => (
<svg viewBox="0 0 512 512" aria-label="Twitter でシェア" role="img">
<svg viewBox="0 0 512 512" aria-label={retrieveTranslation('components.icon.twitter')} role="img">
<path d="M459.37 151.716c.325 4.548.325 9.097.325 13.645 0 138.72-105.583 298.558-298.558 298.558-59.452 0-114.68-17.219-161.137-47.106 8.447.974 16.568 1.299 25.34 1.299 49.055 0 94.213-16.568 130.274-44.832-46.132-.975-84.792-31.188-98.112-72.772 6.498.974 12.995 1.624 19.818 1.624 9.421 0 18.843-1.3 27.614-3.573-48.081-9.747-84.143-51.98-84.143-102.985v-1.299c13.969 7.797 30.214 12.67 47.431 13.319-28.264-18.843-46.781-51.005-46.781-87.391 0-19.492 5.197-37.36 14.294-52.954 51.655 63.675 129.3 105.258 216.365 109.807-1.624-7.797-2.599-15.918-2.599-24.04 0-57.828 46.782-104.934 104.934-104.934 30.213 0 57.502 12.67 76.67 33.137 23.715-4.548 46.456-13.32 66.599-25.34-7.798 24.366-24.366 44.833-46.132 57.827 21.117-2.273 41.584-8.122 60.426-16.243-14.292 20.791-32.161 39.308-52.628 54.253z" />
</svg>
);
11 changes: 11 additions & 0 deletions src/locales/__tests__/i18n.test.ts
@@ -0,0 +1,11 @@
import { expect, test } from 'vitest';
import { activateI18n, retrieveTranslation, setLocale } from '../i18n';

test('i18n', () => {
activateI18n();
setLocale('ja');

const actual = retrieveTranslation('website.title');

expect(actual).toBe('学ぶ、考える、書き出す。');
});
47 changes: 47 additions & 0 deletions src/locales/i18n.ts
@@ -0,0 +1,47 @@
import rosetta, { Rosetta } from 'rosetta';

import { localeJa } from './ja';

export type SupportedLanguage = 'ja';

let i18n: Rosetta<unknown> | null = null;
let currentLanguage: SupportedLanguage | null = null;

export function activateI18n(): void {
if (i18n !== null) {
return;
}

i18n = rosetta({ ja: { ...localeJa } });
}

export function setLocale(lang: SupportedLanguage): void {
if (i18n === null) {
throw new Error('i18n is not activated');
}

if (currentLanguage === lang) {
return;
}

currentLanguage = lang;
i18n.locale(currentLanguage);
}

export function retrieveTranslation(
key: string | (string | number)[],
params?: unknown[] | Record<string, unknown>,
lang?: string,
): string {
if (i18n === null) {
throw new Error('i18n is not activated');
}

const r = i18n.t(key, params, lang);

if (r === '') {
throw new Error(`Translate text is not retrieved: ${key}`);
}

return r;
}
89 changes: 89 additions & 0 deletions src/locales/ja.ts
@@ -0,0 +1,89 @@
export const localeJa = {
website: {
title: '学ぶ、考える、書き出す。',
description: '学習し、自分なりに噛み砕いて、書き出すブログ。',
author: 'kubosho',
},
navigation: {
feed: 'フィード',
policy: 'ポリシー',
},
optout: {
actions: {
enabled: 'オプトアウトの有効化',
disabled: 'オプトアウトの無効化',
},
status: {
enabled: 'オプトアウトが有効になっています。Googleアナリティクスによるアクセス解析はおこなわれません。',
disabled: 'オプトアウトが無効になっています。Googleアナリティクスによるアクセス解析がおこなわれます。',
},
},
components: {
entryList: {
notFound: '記事はありません。',
},
icon: {
facebook: 'Facebookでシェア',
twitter: 'Twitterでシェア',
},
},
top: {
headings: {
entryList: '記事一覧',
},
},
draft: {
notAvailable: 'プレビューは利用できません。',
},
entry: {
share: '記事を共有する',
headings: {
related: '関連記事',
},
},
categories: {
description: '{{webSiteTitle}}の「{{category}}」に関連した記事の一覧です。',
headings: {
entryList: 'カテゴリー「{{category}}」の記事一覧',
},
},
tags: {
description: '{{webSiteTitle}}の「{{tag}}」に関連した記事の一覧です。',
headings: {
entryList: 'タグ「{{tag}}」の記事一覧',
},
},
policy: {
title: 'ポリシー',
headings: {
affiliate: 'アフィリエイト',
disclaimer: '免責事項',
optout: 'Googleアナリティクスによる解析のオプトアウト',
privacy: 'プライバシー',
},
intro: {
text: 'このページでは当ブログ『{{webSiteTitle}}』内で適用されるポリシーについて書きます。',
},
text: {
privacy: {
1: '当ブログでは内容の改善を目的として、Googleアナリティクスによるアクセス分析をおこなっています。',
2: 'Googleアナリティクスは、Cookie(クッキー)により、匿名のトラフィックデータを収集しています。',
3: 'Cookieに含まれるデータは利用者の個人情報を特定しません。利用者はCookieを無効にした状態で当ブログにアクセスできます。',
4: '詳しくはGoogleが公開している',
5: 'Google のサービスを使用するサイトやアプリから収集した情報の Google による使用',
6: 'のページを参照してください。',
},
optout: {
1: '以下のボタンからGoogleアナリティクスによる解析のオプトアウトの有効化・無効化がおこなえます。',
},
affiliate: {
1: '『{{webSiteTitle}}』は、Amazon.co.jpを宣伝しリンクすることによってサイトが紹介料を獲得できる手段を提供することを目的に設定されたアフィリエイトプログラムである、Amazonアソシエイト・プログラムの参加者です。',
},
disclaimer: {
1: '当ブログではコンテンツ・情報について、できる限り正確な情報を提供するように努めています。',
2: 'しかし、完全な正確性や安全性は保障いたしません。情報が古くなったり間違っていたりすることがあります。',
3: 'また、当ブログに掲載した内容によって生じた損害などの一切の責任は負いません。ご了承ください。',
},
},
},
};
17 changes: 10 additions & 7 deletions src/pages/_app.tsx
Expand Up @@ -5,7 +5,6 @@ import Link from 'next/link';
import { useRouter } from 'next/router';
import { isUndefined } from 'option-t/lib/Undefinable/Undefinable';

import { AUTHOR, SITE_DESCRIPTION, SITE_TITLE } from '../constants/site_data';
import { BUGSNAG_API_KEY } from '../constants/environment';
import { activateErrorBoundaryComponent } from '../components/ErrorBoundary';
import { PathList } from '../constants/path_list';
Expand All @@ -16,47 +15,51 @@ import '../common_styles/foundation.css';
import '../common_styles/site_specific.css';
import './variables.css';
import './app.page.css';
import { activateI18n, retrieveTranslation, setLocale } from '../locales/i18n';

function MyApp({ Component, pageProps }: AppProps): JSX.Element {
const router = useRouter();
const isDisplayedDescription = ![PathList.Entry, PathList.Feed, PathList.Policy].includes(
router.pathname as PathList,
);

activateI18n();
setLocale('ja');

const element = (
<>
<SiteMetadata />
<header className="site-header">
<h1 className="site-title">
<Link href={PathList.Root} passHref>
<a>{SITE_TITLE}</a>
<a>{retrieveTranslation('website.title')}</a>
</Link>
</h1>
{isDisplayedDescription && <p className="site-description">{SITE_DESCRIPTION}</p>}
{isDisplayedDescription && <p className="site-description">{retrieveTranslation('website.description')}</p>}
</header>
<Component {...pageProps} />
<footer className="site-footer">
<div className="site-links">
<p>
<Link href={PathList.Root} passHref>
<a>{SITE_TITLE}</a>
<a>{retrieveTranslation('website.title')}</a>
</Link>
</p>
<ul className="site-navigation">
<li>
<Link href={PathList.Feed}>
<a>フィード</a>
<a>{retrieveTranslation('navigation.feed')}</a>
</Link>
</li>
<li>
<Link href={PathList.Policy}>
<a>ポリシー</a>
<a>{retrieveTranslation('navigation.policy')}</a>
</Link>
</li>
</ul>
</div>
<p className="site-copyright">
<small>© {AUTHOR}</small>
<small>© {retrieveTranslation('website.author')}</small>
</p>
</footer>
</>
Expand Down
8 changes: 5 additions & 3 deletions src/pages/categories/[category].tsx
Expand Up @@ -6,8 +6,9 @@ import { EntryValue } from '../../entry/entry_value';
import { EntryList } from '../../components/EntryList';
import { SiteContents } from '../../components/SiteContents';
import { addSiteTitleToSuffix } from '../../site_title_inserter';
import { SITE_TITLE, SITE_URL } from '../../constants/site_data';
import { SITE_URL } from '../../constants/site_data';
import { getCategoryIdList, getEntryListByCategory } from '../../entry/entry_gateway';
import { retrieveTranslation } from '../../locales/i18n';

interface Props {
filteredEntries: Array<EntryValue>;
Expand All @@ -16,9 +17,10 @@ interface Props {

export const CategoryPage = (props: Props): JSX.Element => {
const { category, filteredEntries } = props;
const title = `${category}の記事一覧`;
const title = retrieveTranslation('categories.headings.entryList', { category });
const webSiteTitle = retrieveTranslation('website.title');
const titleInHead = addSiteTitleToSuffix(title);
const description = `${SITE_TITLE}の「${category}」に関連した記事の一覧です。`;
const description = retrieveTranslation('categories.description', { category, webSiteTitle });
const pageUrl = `${SITE_URL}/categories/${category}`;

const e = (
Expand Down
5 changes: 3 additions & 2 deletions src/pages/draft/[id].tsx
Expand Up @@ -17,6 +17,7 @@ import { PublishedDate } from '../../components/PublishedDate';
import { formatISOString, formatYYMMDDString } from '../../entry/date';
import { mapEntryValue } from '../../entry/entry_converter';
import { getEntryIdList } from '../../entry/entry_gateway';
import { retrieveTranslation } from '../../locales/i18n';

import styles from '../entry/entry.module.css';
import entryContentsChildrenStyles from '../entry/entryContentsChildren.module.css';
Expand All @@ -43,7 +44,7 @@ const Draft = (props: Props): JSX.Element => {
return (
<SiteContents>
<div className={entryContentsChildrenStyles['entry-contents']}>
<p>プレビューは利用できません。</p>
<p>{retrieveTranslation('draft.notAvailable')}</p>
</div>
</SiteContents>
);
Expand Down Expand Up @@ -94,7 +95,7 @@ const Draft = (props: Props): JSX.Element => {
</header>
<div className={entryContentsChildrenStyles['entry-contents']} dangerouslySetInnerHTML={{ __html: body }} />
<div className={styles['entry-share']}>
<p className={styles['entry-share-text']}>記事を共有する</p>
<p className={styles['entry-share-text']}>{retrieveTranslation('entry.share')}</p>
<SnsShare shareText={pageTitle} />
</div>
</article>
Expand Down
5 changes: 3 additions & 2 deletions src/pages/entry/[id].tsx
Expand Up @@ -14,6 +14,7 @@ import { addSiteTitleToSuffix } from '../../site_title_inserter';
import { getEntry, getEntrySlugList, getEntryListByCategory, getEntryListByTag } from '../../entry/entry_gateway';
import { createBlogPostingStructuredData } from '../../structured_data/blog_posting_structured_data';
import { getRelatedEntryList } from '../../entry/related_entry_list';
import { retrieveTranslation } from '../../locales/i18n';

import styles from './entry.module.css';
import entryContentsChildrenStyles from './entryContentsChildren.module.css';
Expand Down Expand Up @@ -88,13 +89,13 @@ const Entry = (props: Props): JSX.Element => {
</header>
<div className={entryContentsChildrenStyles['entry-contents']} dangerouslySetInnerHTML={{ __html: body }} />
<div className={styles['entry-share']}>
<p className={styles['entry-share-text']}>記事を共有する</p>
<p className={styles['entry-share-text']}>{retrieveTranslation('entry.share')}</p>
<SnsShare shareText={pageTitle} />
</div>
</article>
{relatedEntryList.length > 0 && (
<section className={styles['related-entry-list']}>
<h2>関連記事</h2>
<h2>{retrieveTranslation('entry.headings.related')}</h2>
<ul>
{relatedEntryList.map(({ id, title }) => (
<li key={id}>
Expand Down