From 28a37d59b81451cd64790cf8dd222d297bd421ac Mon Sep 17 00:00:00 2001 From: aecomet <16721102+aecomet@users.noreply.github.com> Date: Mon, 10 Aug 2026 17:21:47 +0900 Subject: [PATCH 1/4] feat(i18n): add Japanese/English language switching --- __tests__/components.test.ts | 33 +++++++++- __tests__/pages.test.ts | 55 ++++++++-------- package.json | 1 + pnpm-lock.yaml | 56 ++++++++++++++++ src/app.ts | 2 + src/assets/404.html | 44 ++++++++++++- src/components/AppHeader.vue | 51 +++++++++++++-- src/locales/en.json | 120 +++++++++++++++++++++++++++++++++++ src/locales/ja.json | 120 +++++++++++++++++++++++++++++++++++ src/pages/HomePage.vue | 10 +-- src/pages/NotFoundPage.vue | 11 ++-- src/pages/ProjectsPage.vue | 17 +++-- src/pages/UserCareer.vue | 89 ++++++++------------------ src/pages/UserContact.vue | 11 +++- src/pages/UserProfile.vue | 44 ++++++++----- src/plugins/i18n.ts | 53 ++++++++++++++++ tsconfig.json | 1 + vitest.config.ts | 5 ++ 18 files changed, 592 insertions(+), 131 deletions(-) create mode 100644 src/locales/en.json create mode 100644 src/locales/ja.json create mode 100644 src/plugins/i18n.ts diff --git a/__tests__/components.test.ts b/__tests__/components.test.ts index b385c873..d29c979a 100644 --- a/__tests__/components.test.ts +++ b/__tests__/components.test.ts @@ -1,7 +1,8 @@ -import { describe, test, expect } from 'vitest'; +import { describe, test, expect, afterEach } from 'vitest'; import { mount } from '@vue/test-utils'; import { createRouter, createMemoryHistory } from 'vue-router'; import AppHeader from '../src/components/AppHeader.vue'; +import { i18n, setLocale } from '../src/plugins/i18n'; function createTestRouter() { return createRouter({ @@ -13,6 +14,14 @@ function createTestRouter() { }); } +const stubs = { + 'v-btn': { template: '' } +}; + +afterEach(() => { + setLocale('ja'); +}); + describe('AppHeader', () => { test('renders navigation links', async () => { const router = createTestRouter(); @@ -20,11 +29,31 @@ describe('AppHeader', () => { await router.isReady(); const wrapper = mount(AppHeader, { - global: { plugins: [router] } + global: { plugins: [router, i18n], stubs } }); expect(wrapper.findAll('li').length).toBeGreaterThan(0); + expect(wrapper.text()).toContain('ホーム'); + expect(wrapper.text()).toContain('プロフィール'); + }); + + test('toggles language between ja and en', async () => { + const router = createTestRouter(); + router.push('/'); + await router.isReady(); + + const wrapper = mount(AppHeader, { + global: { plugins: [router, i18n], stubs } + }); + + expect(wrapper.text()).toContain('ホーム'); + expect(wrapper.text()).not.toContain('Home'); + + await wrapper.find('.lang-toggle').trigger('click'); + expect(wrapper.text()).toContain('Home'); expect(wrapper.text()).toContain('Profile'); + expect(wrapper.text()).toContain('日本語'); + expect(wrapper.text()).not.toContain('ホーム'); }); }); diff --git a/__tests__/pages.test.ts b/__tests__/pages.test.ts index 59b94abe..0620f145 100644 --- a/__tests__/pages.test.ts +++ b/__tests__/pages.test.ts @@ -7,6 +7,7 @@ import ProjectsPage from '../src/pages/ProjectsPage.vue'; import UserCareer from '../src/pages/UserCareer.vue'; import UserContact from '../src/pages/UserContact.vue'; import UserProfile from '../src/pages/UserProfile.vue'; +import { i18n } from '../src/plugins/i18n'; function createTestRouter() { return createRouter({ @@ -48,13 +49,13 @@ describe('HomePage', () => { await router.isReady(); const wrapper = mount(HomePage, { - global: { plugins: [router] } + global: { plugins: [router, i18n] } }); expect(wrapper.find('.hero').exists()).toBe(true); - expect(wrapper.find('.hero-badge').text()).toBe('Portfolio'); + expect(wrapper.find('.hero-badge').text()).toBe('ポートフォリオ'); expect(wrapper.find('.accent').text()).toBe('こめっと'); - expect(wrapper.find('.hero-sub').text()).toBe('Software Engineer'); + expect(wrapper.find('.hero-sub').text()).toBe('ソフトウェアエンジニア'); }); test('renders CTA buttons', async () => { @@ -63,7 +64,7 @@ describe('HomePage', () => { await router.isReady(); const wrapper = mount(HomePage, { - global: { plugins: [router] } + global: { plugins: [router, i18n] } }); const buttons = wrapper.findAll('button'); @@ -78,7 +79,7 @@ describe('HomePage', () => { await router.isReady(); const wrapper = mount(HomePage, { - global: { plugins: [router] } + global: { plugins: [router, i18n] } }); const pushSpy = vi.spyOn(router, 'push'); @@ -92,7 +93,7 @@ describe('HomePage', () => { await router.isReady(); const wrapper = mount(HomePage, { - global: { plugins: [router] } + global: { plugins: [router, i18n] } }); const pushSpy = vi.spyOn(router, 'push'); @@ -108,7 +109,7 @@ describe('NotFoundPage', () => { await router.isReady(); const wrapper = mount(NotFoundPage, { - global: { plugins: [router] } + global: { plugins: [router, i18n] } }); expect(wrapper.find('#not-found').exists()).toBe(true); @@ -122,7 +123,7 @@ describe('NotFoundPage', () => { await router.isReady(); const wrapper = mount(NotFoundPage, { - global: { plugins: [router] } + global: { plugins: [router, i18n] } }); const button = wrapper.find('.btn-primary'); @@ -138,11 +139,11 @@ describe('UserCareer', () => { await router.isReady(); const wrapper = mount(UserCareer, { - global: { plugins: [router], stubs: vuetifyStubs } + global: { plugins: [router, i18n], stubs: vuetifyStubs } }); expect(wrapper.find('#contact').exists()).toBe(true); - expect(wrapper.text()).toContain('Career'); + expect(wrapper.text()).toContain('経歴'); expect(wrapper.text()).toContain('職務経歴について'); }); @@ -152,7 +153,7 @@ describe('UserCareer', () => { await router.isReady(); const wrapper = mount(UserCareer, { - global: { plugins: [router], stubs: vuetifyStubs } + global: { plugins: [router, i18n], stubs: vuetifyStubs } }); const items = wrapper.findAll('.v-timeline-item'); @@ -170,7 +171,7 @@ describe('UserCareer', () => { await router.isReady(); const wrapper = mount(UserCareer, { - global: { plugins: [router], stubs: vuetifyStubs } + global: { plugins: [router, i18n], stubs: vuetifyStubs } }); expect(wrapper.text()).toContain('情報工学科'); @@ -185,11 +186,11 @@ describe('UserContact', () => { await router.isReady(); const wrapper = mount(UserContact, { - global: { plugins: [router], stubs: vuetifyStubs } + global: { plugins: [router, i18n], stubs: vuetifyStubs } }); expect(wrapper.find('#contact').exists()).toBe(true); - expect(wrapper.text()).toContain('Contact'); + expect(wrapper.text()).toContain('お問い合わせ'); }); test('renders email information', async () => { @@ -198,7 +199,7 @@ describe('UserContact', () => { await router.isReady(); const wrapper = mount(UserContact, { - global: { plugins: [router], stubs: vuetifyStubs } + global: { plugins: [router, i18n], stubs: vuetifyStubs } }); expect(wrapper.text()).toContain('Email'); @@ -214,13 +215,13 @@ describe('UserProfile', () => { await router.isReady(); const wrapper = mount(UserProfile, { - global: { plugins: [router], stubs: vuetifyStubs } + global: { plugins: [router, i18n], stubs: vuetifyStubs } }); expect(wrapper.find('#profile').exists()).toBe(true); - expect(wrapper.text()).toContain('Profile'); + expect(wrapper.text()).toContain('プロフィール'); expect(wrapper.text()).toContain('Comet / こめっと'); - expect(wrapper.text()).toContain('Software Engineer'); + expect(wrapper.text()).toContain('ソフトウェアエンジニア'); }); test('renders external links', async () => { @@ -229,7 +230,7 @@ describe('UserProfile', () => { await router.isReady(); const wrapper = mount(UserProfile, { - global: { plugins: [router], stubs: vuetifyStubs } + global: { plugins: [router, i18n], stubs: vuetifyStubs } }); const links = wrapper.findAll('a'); @@ -245,7 +246,7 @@ describe('UserProfile', () => { await router.isReady(); const wrapper = mount(UserProfile, { - global: { plugins: [router], stubs: vuetifyStubs } + global: { plugins: [router, i18n], stubs: vuetifyStubs } }); expect(wrapper.text()).toContain('技術選定'); @@ -261,7 +262,7 @@ describe('UserProfile', () => { await router.isReady(); const wrapper = mount(UserProfile, { - global: { plugins: [router], stubs: vuetifyStubs } + global: { plugins: [router, i18n], stubs: vuetifyStubs } }); expect(wrapper.text()).toContain('基本情報技術者試験'); @@ -278,7 +279,7 @@ describe('UserProfile', () => { await router.isReady(); const wrapper = mount(UserProfile, { - global: { plugins: [router], stubs: vuetifyStubs } + global: { plugins: [router, i18n], stubs: vuetifyStubs } }); const links = wrapper.findAll('a'); @@ -296,11 +297,11 @@ describe('ProjectsPage', () => { await router.isReady(); const wrapper = mount(ProjectsPage, { - global: { plugins: [router], stubs: vuetifyStubs } + global: { plugins: [router, i18n], stubs: vuetifyStubs } }); expect(wrapper.find('#projects').exists()).toBe(true); - expect(wrapper.text()).toContain('Projects'); + expect(wrapper.text()).toContain('プロジェクト'); }); test('renders backoff-util project card', async () => { @@ -309,7 +310,7 @@ describe('ProjectsPage', () => { await router.isReady(); const wrapper = mount(ProjectsPage, { - global: { plugins: [router], stubs: vuetifyStubs } + global: { plugins: [router, i18n], stubs: vuetifyStubs } }); expect(wrapper.text()).toContain('backoff-util'); @@ -322,7 +323,7 @@ describe('ProjectsPage', () => { await router.isReady(); const wrapper = mount(ProjectsPage, { - global: { plugins: [router], stubs: vuetifyStubs } + global: { plugins: [router, i18n], stubs: vuetifyStubs } }); const hrefs = wrapper.findAll('a').map((l) => l.attributes('href')); @@ -336,7 +337,7 @@ describe('ProjectsPage', () => { await router.isReady(); const wrapper = mount(ProjectsPage, { - global: { plugins: [router], stubs: vuetifyStubs } + global: { plugins: [router, i18n], stubs: vuetifyStubs } }); const links = wrapper.findAll('a'); diff --git a/package.json b/package.json index 67b26142..e59a4165 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,7 @@ "dependencies": { "@mdi/js": "^7.4.47", "vue": "^3.5.41", + "vue-i18n": "^11.4.8", "vue-router": "^5.2.0", "vuetify": "^4.1.8" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 54172a4f..44f5fdb1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,6 +14,9 @@ importers: vue: specifier: ^3.5.41 version: 3.5.41(typescript@7.0.2) + vue-i18n: + specifier: ^11.4.8 + version: 11.4.8(vue@3.5.41(typescript@7.0.2)) vue-router: specifier: ^5.2.0 version: 5.2.0(@vue/compiler-sfc@3.5.41)(rolldown@1.2.3)(vite@8.2.1(@types/node@26.2.0)(sass@1.102.0)(terser@5.49.2)(yaml@2.9.0))(vue@3.5.41(typescript@7.0.2)) @@ -187,6 +190,22 @@ packages: '@noble/hashes': optional: true + '@intlify/core-base@11.4.8': + resolution: {integrity: sha512-A+Q7SKm5oEcy1E/cghqd7n/St4XjTqLhiiyDuieNcMrJcrHlkY5n0jp7Q9dD3txvVHzvsmBVV5M9wD5/s1zfzw==} + engines: {node: '>= 22'} + + '@intlify/devtools-types@11.4.8': + resolution: {integrity: sha512-MGpID+rlfzGUbNcnC20bm5NMSBHPrvx0atLTfv9dftn3kjXw1hGKDcIcwrO99tSrZEc2i+hczRL7ks8qXsHPkQ==} + engines: {node: '>= 22'} + + '@intlify/message-compiler@11.4.8': + resolution: {integrity: sha512-vbzk17dYwduYiv52EK61+FDCyhfVg1uPUtPmiD/d45W99uJIcXywrweOBcHv7n9/iEqmXiMGT52bgJbZDQqK3w==} + engines: {node: '>= 22'} + + '@intlify/shared@11.4.8': + resolution: {integrity: sha512-XbRgrv+XEuvDr7UCY55oibVrh+o4u+A0VB6nSL0F5Z8LcZxE/8j573LYG6bCrOigIcHdGpSNI7Rh5UpC5/B/eg==} + engines: {node: '>= 22'} + '@isaacs/cliui@8.0.2': resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} engines: {node: '>=12'} @@ -868,6 +887,9 @@ packages: '@vue/compiler-ssr@3.5.41': resolution: {integrity: sha512-U3v5OejKEGqOI0Wy0+Sz7hGuIFZHA4LSXzrNM3IMIeDyJEBBfTpX26n3SDgToRpP2bLc9FfI2j/kSgcJ8Emq5A==} + '@vue/devtools-api@6.6.4': + resolution: {integrity: sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==} + '@vue/devtools-api@8.2.1': resolution: {integrity: sha512-6u4vXBlIBAC1wMplIZgpyPn7uh/s4Bf6F5bMzvLv+EdJ0aHs/+4B7Ygv864EStQSjRbsRzTko/kUG1A1IejQ3A==} @@ -1993,6 +2015,12 @@ packages: vue-component-type-helpers@3.3.9: resolution: {integrity: sha512-3c/UfMe0SqyEfcGTyH7mfshHagJ9QTCbppCb0/uGpHZpFug7+If3GeGZN7I0YheKEExemx3xldQPoO7PQSOLQg==} + vue-i18n@11.4.8: + resolution: {integrity: sha512-0ULeHP6Z9CGvAm67S77ZEp41cfGXIREGL8qfhos2BMgcQQewtQcDKuojt6jjasAD/S8GwfTp2ySPmDSpwvrCMQ==} + engines: {node: '>= 22'} + peerDependencies: + vue: ^3.0.0 + vue-router@5.2.0: resolution: {integrity: sha512-QAC5i0LEb1GLG0LXDQmHu8L7FX12j0KwU/JTKmLQUJMrn04gQdKP6Du+p0QwpHb3iy71vBlqnHQ8WAfOSAWhqw==} peerDependencies: @@ -2191,6 +2219,24 @@ snapshots: '@exodus/bytes@1.15.1': {} + '@intlify/core-base@11.4.8': + dependencies: + '@intlify/devtools-types': 11.4.8 + '@intlify/message-compiler': 11.4.8 + '@intlify/shared': 11.4.8 + + '@intlify/devtools-types@11.4.8': + dependencies: + '@intlify/core-base': 11.4.8 + '@intlify/shared': 11.4.8 + + '@intlify/message-compiler@11.4.8': + dependencies: + '@intlify/shared': 11.4.8 + source-map-js: 1.2.1 + + '@intlify/shared@11.4.8': {} + '@isaacs/cliui@8.0.2': dependencies: string-width: 5.1.2 @@ -2643,6 +2689,8 @@ snapshots: '@vue/compiler-dom': 3.5.41 '@vue/shared': 3.5.41 + '@vue/devtools-api@6.6.4': {} + '@vue/devtools-api@8.2.1': dependencies: '@vue/devtools-kit': 8.2.1 @@ -3743,6 +3791,14 @@ snapshots: vue-component-type-helpers@3.3.9: {} + vue-i18n@11.4.8(vue@3.5.41(typescript@7.0.2)): + dependencies: + '@intlify/core-base': 11.4.8 + '@intlify/devtools-types': 11.4.8 + '@intlify/shared': 11.4.8 + '@vue/devtools-api': 6.6.4 + vue: 3.5.41(typescript@7.0.2) + vue-router@5.2.0(@vue/compiler-sfc@3.5.41)(rolldown@1.2.3)(vite@8.2.1(@types/node@26.2.0)(sass@1.102.0)(terser@5.49.2)(yaml@2.9.0))(vue@3.5.41(typescript@7.0.2)): dependencies: '@babel/generator': 8.0.0 diff --git a/src/app.ts b/src/app.ts index 23a9385b..14a76274 100755 --- a/src/app.ts +++ b/src/app.ts @@ -2,6 +2,7 @@ // Libraries import App from '@src/App.vue'; import '@src/assets/style.scss'; +import { i18n } from '@src/plugins/i18n'; import { router } from '@src/plugins/router'; import MyVuetify from '@src/plugins/vuetify'; import { ViteSSG } from 'vite-ssg/single-page'; @@ -9,4 +10,5 @@ import { ViteSSG } from 'vite-ssg/single-page'; export const createApp = ViteSSG(App, ({ app }) => { app.use(router); app.use(MyVuetify); + app.use(i18n); }); diff --git a/src/assets/404.html b/src/assets/404.html index 33694bb7..ffd9b595 100644 --- a/src/assets/404.html +++ b/src/assets/404.html @@ -34,6 +34,10 @@ box-sizing: border-box; } + [hidden] { + display: none !important; + } + html, body { margin: 0; @@ -278,14 +282,50 @@ -

+

お探しのページは存在しないか、移動された可能性があります。 トップページへ戻って再度アクセスしてください。

+
- トップページへ移動 + トップページへ移動 +
+ diff --git a/src/components/AppHeader.vue b/src/components/AppHeader.vue index 52aaa764..f73fa7a3 100644 --- a/src/components/AppHeader.vue +++ b/src/components/AppHeader.vue @@ -2,28 +2,69 @@ + + diff --git a/src/locales/en.json b/src/locales/en.json new file mode 100644 index 00000000..e06891d3 --- /dev/null +++ b/src/locales/en.json @@ -0,0 +1,120 @@ +{ + "nav": { + "home": "Home", + "profile": "Profile", + "career": "Career", + "projects": "Projects", + "contact": "Contact", + "languageTitle": "Switch language", + "toEnglish": "EN", + "toJapanese": "日本語" + }, + "home": { + "badge": "Portfolio", + "subtitle": "Software Engineer", + "ctaProfile": "Profile", + "ctaCareer": "View Career" + }, + "profile": { + "label": "— Profile", + "role": "Software Engineer", + "links": "External Links", + "experience": "Areas of Experience", + "certifications": "Certifications", + "experiences": { + "techSelection": "Technology Selection", + "systemArchitect": "System Architecture", + "teamLead": "Team Leadership", + "incidentResponse": "Incident Response", + "development": "Implementation & Maintenance" + }, + "certifications": { + "fe": "Fundamental Information Technology Engineer Examination", + "ap": "Applied Information Technology Engineer Examination", + "security": "Information Security Specialist Examination", + "database": "Database Specialist Examination", + "architect": "Systems Architect Examination", + "eiken": "EIKEN Grade 2" + } + }, + "career": { + "label": "— Career", + "title": "Career History", + "description": "For a detailed career history, feel free to email me and I'll send you my résumé.", + "items": { + "highSchool": { + "date": "March 2013", + "header": "Graduated from High School", + "note": "I wasn't directly exposed to programming in high school, but editing videos for the school festival sparked my interest in computers." + }, + "university": { + "date": "April 2013", + "header": "Entered University", + "note": "Majored in information engineering. During my studies I worked part-time as a programmer at a startup, where I first got hands-on with PHP, Ruby, and jQuery. In class I also learned the basics of Java, C, and Perl." + }, + "gradUniversity": { + "date": "March 2017", + "header": "Graduated from University", + "note": "" + }, + "gradSchool": { + "date": "April 2017", + "header": "Entered Graduate School", + "note": "Pursued a master's degree in information engineering while continuing my part-time programming work and joining company internships in summer and winter. My research involved building applications with Django (Python) and Vuex." + }, + "gradGradSchool": { + "date": "March 2019", + "header": "Completed Graduate School", + "note": "" + }, + "company": { + "date": "April 2019", + "header": "Joined a Company as an Engineer", + "note": "Joined the development team of a shared infrastructure platform supporting social game development as an application engineer. For the first few years I built systems with Ruby, Java, Python, and Vue, and later took charge of detailed system design with UML as the lead on client projects. Since then I've served as the team lead driving feature development for microservices that manage authentication and game users, and I continue to lead a team of five." + }, + "securityExam": { + "date": "June 2023", + "header": "Passed the Information Security Specialist Examination", + "note": "" + }, + "databaseExam": { + "date": "December 2023", + "header": "Passed the Database Specialist Examination", + "note": "" + }, + "architectExam": { + "date": "July 2024", + "header": "Passed the Systems Architect Examination", + "note": "" + }, + "eiken": { + "date": "August 2024", + "header": "Passed EIKEN Grade 2", + "note": "" + }, + "newRole": { + "date": "March 2026", + "header": "Changed Jobs and Started a New Challenge", + "note": "To be continued..." + } + } + }, + "projects": { + "label": "— Projects", + "title": "Projects", + "desc": "A showcase of the libraries and sites I've published as personal projects.", + "backoffDesc": "A TypeScript utility library that adds exponential backoff retry logic to any async function. Zero dependencies, with support for both ESM and CJS.", + "moreHint": "※ More projects are coming soon" + }, + "contact": { + "label": "— Contact", + "email": "Email", + "emailNote": "※ Replace *** with gmail.com" + }, + "notFound": { + "label": "— Not Found", + "title": "Page Not Found", + "description": "The page you're looking for doesn't exist or may have been moved.", + "back": "Back to Home" + } +} diff --git a/src/locales/ja.json b/src/locales/ja.json new file mode 100644 index 00000000..ed70a684 --- /dev/null +++ b/src/locales/ja.json @@ -0,0 +1,120 @@ +{ + "nav": { + "home": "ホーム", + "profile": "プロフィール", + "career": "経歴", + "projects": "プロジェクト", + "contact": "お問い合わせ", + "languageTitle": "言語を切り替える", + "toEnglish": "EN", + "toJapanese": "日本語" + }, + "home": { + "badge": "ポートフォリオ", + "subtitle": "ソフトウェアエンジニア", + "ctaProfile": "プロフィール", + "ctaCareer": "経歴を見る" + }, + "profile": { + "label": "— プロフィール", + "role": "ソフトウェアエンジニア", + "links": "外部リンク", + "experience": "経験領域", + "certifications": "試験合格実績", + "experiences": { + "techSelection": "技術選定", + "systemArchitect": "システムアーキテクト", + "teamLead": "チームリーダー", + "incidentResponse": "障害対応", + "development": "実装・保守" + }, + "certifications": { + "fe": "基本情報技術者試験", + "ap": "応用情報技術者試験", + "security": "情報処理安全確保支援士試験", + "database": "データベーススペシャリスト試験", + "architect": "システムアーキテクト試験", + "eiken": "英検2級" + } + }, + "career": { + "label": "— 経歴", + "title": "職務経歴について", + "description": "詳しい職務経歴についてはメールしていただければ職務経歴書を送付します", + "items": { + "highSchool": { + "date": "2013年3月", + "header": "高等学校卒業", + "note": "高校ではプログラミングに直接関わる機会はなかったが文化祭で動画編集を担当したことがきっかけでコンピュータ関連に興味を持つ。" + }, + "university": { + "date": "2013年4月", + "header": "大学入学", + "note": "情報工学科。在学中にベンチャー企業にてプログラミングのアルバイトを行う。このとき初めてPHP, Ruby, jQueryを触る。授業では簡単なJava, C, Perlを学ぶ" + }, + "gradUniversity": { + "date": "2017年3月", + "header": "大学卒業", + "note": "" + }, + "gradSchool": { + "date": "2017年4月", + "header": "大学院入学", + "note": "情報工学専攻。プログラミングのアルバイトは継続。その他夏冬に企業インターンシップに参加。研究でPythonのDjango, Vuexを用いたアプリケーション開発を行う。" + }, + "gradGradSchool": { + "date": "2019年3月", + "header": "大学院卒業", + "note": "" + }, + "company": { + "date": "2019年4月", + "header": "企業に入社", + "note": "ソーシャルゲーム開発を支援する共通基盤システムの開発チームにアプリケーションエンジニアとして入社。入社後数年はRuby、Java、 Python、Vueを用いたシステム開発に従事。その後は案件の主担当としてUMLを用いたシステムの詳細設計も担当。その後、認証やゲームユーザーを管理するマイクロサービスの機能開発を主導するチームリーダーに就任し、5人のメンバーをリードしながらシステム開発を続けている。" + }, + "securityExam": { + "date": "2023年6月", + "header": "情報処理安全確保支援士試験 合格", + "note": "" + }, + "databaseExam": { + "date": "2023年12月", + "header": "データベーススペシャリスト試験 合格", + "note": "" + }, + "architectExam": { + "date": "2024年7月", + "header": "システムアーキテクト試験 合格", + "note": "" + }, + "eiken": { + "date": "2024年8月", + "header": "英検2級 合格", + "note": "" + }, + "newRole": { + "date": "2026年3月", + "header": "転職して新しい環境での挑戦を開始", + "note": "To be continued..." + } + } + }, + "projects": { + "label": "— プロジェクト", + "title": "プロジェクト", + "desc": "個人開発で公開しているライブラリ・サイトの紹介です。", + "backoffDesc": "任意の async 関数に指数バックオフ付きのリトライ処理を組み込める TypeScript ユーティリティライブラリ。ゼロ依存で ESM / CJS 両対応。", + "moreHint": "※ 他のプロジェクトも随時追加予定" + }, + "contact": { + "label": "— お問い合わせ", + "email": "Email", + "emailNote": "※ ***にはgmail.comを入力してください" + }, + "notFound": { + "label": "— 見つかりません", + "title": "ページが見つかりません", + "description": "指定されたURLは存在しないか、移動された可能性があります。", + "back": "トップページへ戻る" + } +} diff --git a/src/pages/HomePage.vue b/src/pages/HomePage.vue index f52d5e23..9f872d9a 100644 --- a/src/pages/HomePage.vue +++ b/src/pages/HomePage.vue @@ -1,22 +1,24 @@ diff --git a/src/pages/NotFoundPage.vue b/src/pages/NotFoundPage.vue index 694fe72f..60fe514c 100644 --- a/src/pages/NotFoundPage.vue +++ b/src/pages/NotFoundPage.vue @@ -1,16 +1,19 @@ diff --git a/src/pages/ProjectsPage.vue b/src/pages/ProjectsPage.vue index 99b066e5..17b40333 100644 --- a/src/pages/ProjectsPage.vue +++ b/src/pages/ProjectsPage.vue @@ -1,10 +1,10 @@ - + diff --git a/src/locales/en.json b/src/locales/en.json index e06891d3..6b71b349 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -1,4 +1,7 @@ { + "brand": { + "name": "Comet" + }, "nav": { "home": "Home", "profile": "Profile", diff --git a/src/locales/ja.json b/src/locales/ja.json index ed70a684..1c33d6d8 100644 --- a/src/locales/ja.json +++ b/src/locales/ja.json @@ -1,4 +1,7 @@ { + "brand": { + "name": "こめっと" + }, "nav": { "home": "ホーム", "profile": "プロフィール", diff --git a/src/pages/HomePage.vue b/src/pages/HomePage.vue index 9f872d9a..91883ad0 100644 --- a/src/pages/HomePage.vue +++ b/src/pages/HomePage.vue @@ -3,7 +3,7 @@
{{ t('home.badge') }}

- こめっと + {{ t('brand.name') }}

{{ t('home.subtitle') }}

diff --git a/src/pages/UserProfile.vue b/src/pages/UserProfile.vue index 65a35e76..29844031 100644 --- a/src/pages/UserProfile.vue +++ b/src/pages/UserProfile.vue @@ -15,7 +15,7 @@
-
Comet / こめっと
+
{{ t('brand.name') }}
{{ t('profile.role') }}
diff --git a/src/plugins/i18n.ts b/src/plugins/i18n.ts index eb86bfa1..485c9153 100644 --- a/src/plugins/i18n.ts +++ b/src/plugins/i18n.ts @@ -29,6 +29,25 @@ export const i18n = createI18n({ messages: { ja, en } }); +const SITE_TITLE_SUFFIX = ' | Portfolio'; + +const setMetaContent = (selector: string, content: string): void => { + document.querySelector(selector)?.setAttribute('content', content); +}; + +// Reflect the brand name on and meta tags (client-side only). +const applyDocumentMeta = (): void => { + const brand = i18n.global.t('brand.name'); + const title = `${brand}${SITE_TITLE_SUFFIX}`; + document.title = title; + setMetaContent('meta[name="title"]', title); + setMetaContent('meta[name="author"]', brand); + setMetaContent('meta[name="description"]', title); + setMetaContent('meta[property="og:site_name"]', title); + setMetaContent('meta[property="og:title"]', title); + setMetaContent('meta[property="og:description"]', title); +}; + export const setLocale = (locale: Locale): void => { i18n.global.locale.value = locale; if (typeof window !== 'undefined') { @@ -38,6 +57,7 @@ export const setLocale = (locale: Locale): void => { // ignore storage access errors (e.g. privacy mode) } document.documentElement.lang = locale; + applyDocumentMeta(); } }; @@ -50,4 +70,5 @@ export const toggleLocale = (): Locale => { // Reflect the restored locale on the <html> element (client-side only). if (typeof window !== 'undefined') { document.documentElement.lang = i18n.global.locale.value; + applyDocumentMeta(); } From 44e4fb69baeb9f4f29b60eb18398c6d6f6806ebd Mon Sep 17 00:00:00 2001 From: aecomet <16721102+aecomet@users.noreply.github.com> Date: Tue, 11 Aug 2026 09:03:05 +0900 Subject: [PATCH 4/4] fix(ci): update e2e tests for i18n --- e2e/smoke.spec.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/e2e/smoke.spec.ts b/e2e/smoke.spec.ts index ddb0d1df..2da0b567 100644 --- a/e2e/smoke.spec.ts +++ b/e2e/smoke.spec.ts @@ -1,5 +1,13 @@ import { test, expect } from '@playwright/test'; +// i18n 導入後、デフォルト言語は日本語。テストは英語 UI を前提としているため、 +// ページ読み込み前に localStorage へ locale を設定して英語モードで開始する。 +test.beforeEach(async ({ page }) => { + await page.addInitScript(() => { + localStorage.setItem('locale', 'en'); + }); +}); + for (const { path, name } of [ { path: '/', name: 'Home' }, { path: '/profile', name: 'Profile' }, @@ -31,5 +39,5 @@ test('navigates via header links', async ({ page }) => { test('renders 404 for unknown route', async ({ page }) => { await page.goto('/#/unknown-page'); - await expect(page.getByText('ページが見つかりません')).toBeVisible(); + await expect(page.getByText('Page Not Found')).toBeVisible(); });