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..4cf43050 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('Comet / こめっと');
- expect(wrapper.text()).toContain('Software Engineer');
+ expect(wrapper.text()).toContain('プロフィール');
+ expect(wrapper.text()).toContain('こめっと');
+ 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/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();
});
diff --git a/package.json b/package.json
index 67b26142..877cca02 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "aecomet.github.io",
- "version": "8.13.0",
+ "version": "9.0.0",
"description": "My portfolio.",
"scripts": {
"dev": "vite",
@@ -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..6099d7e9 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,55 @@
+
お探しのページは存在しないか、移動された可能性があります。 トップページへ戻って再度アクセスしてください。
++ The page you're looking for doesn't exist or may have been moved. Please return to the top page and try + again. +
+