diff --git a/README.md b/README.md index 23135e7..f72cb5b 100644 --- a/README.md +++ b/README.md @@ -130,10 +130,10 @@ Quickly return to the 14-in-1 hub anytime by clicking the brand mascot logo 🦭 - **Styling & UI**: [Tailwind CSS](https://tailwindcss.com/) & [Lucide Icons](https://lucide.dev/) - **PDF Engine**: [pdf-lib](https://pdf-lib.js.org/) & [pdfjs-dist](https://mozilla.github.io/pdf.js/) - **Security & Cryptography**: Native Web Crypto API (SubtleCrypto AES-GCM-256, SHA-256) -- **Internationalization**: Custom lightweight reactive i18n architecture with zero runtime bloat (Full native support for English, Deutsch, Español, Français, 日本語, 简体中文). Clean language-based design without geopolitical flag bindings. +- **Internationalization**: Custom lightweight reactive i18n architecture with zero runtime bloat (Full native support for English, Deutsch, Español, Français, 日本語, 简体中文 with URL parameter query syncing and global hreflang search indexing). Clean language-based design using standard native endonyms. - **Local Storage**: IndexedDB with structured transactional stores (Privacy Vault) - **Performance & Scheduling**: Active Event Loop Yielding to prevent UI freezing during intensive WASM/canvas computations -- **Testing Suite**: [Vitest](https://vitest.dev/) & [Puppeteer](https://pptr.dev/) (**36 unit test suites, 228 unit tests 100% passing**, plus headless browser E2E regression suites) +- **Testing Suite**: [Vitest](https://vitest.dev/) & [Puppeteer](https://pptr.dev/) (**36 unit test suites, 230 unit tests 100% passing**, plus headless browser E2E regression suites) --- diff --git a/README.zh-CN.md b/README.zh-CN.md index 8c5f4b3..0c09ca9 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -130,10 +130,10 @@ - **样式与 UI**:[Tailwind CSS](https://tailwindcss.com/) & [Lucide Icons](https://lucide.dev/) - **PDF 引擎**:[pdf-lib](https://pdf-lib.js.org/) & [pdfjs-dist](https://mozilla.github.io/pdf.js/) - **安全与加密**:原生 Web Crypto API(SubtleCrypto AES-GCM-256、SHA-256) -- **国际化**:轻量级响应式 i18n 架构,零运行时负担(完整原生支持 English、Deutsch、Español、Français、日本語、简体中文)。采用纯粹的语言母语标识,无地缘政治国旗绑定。 +- **国际化**:轻量级响应式 i18n 架构,零运行时负担(完整原生支持 English、Deutsch、Español、Français、日本語、简体中文,具备 URL 语言参数直达与全局 hreflang 搜索引擎多语言收录架构)。采用标准的母语原生标识(Native Endonyms)排版。 - **本地存储**:IndexedDB 结构化事务存储(海豹收纳箱) - **调度性能**:Event Loop 宏任务平滑分片(Active Event Loop Yielding,防长耗时计算卡顿) -- **测试体系**:[Vitest](https://vitest.dev/) + [Puppeteer](https://pptr.dev/)(**36 个算法与单元测试套件,228 项用例 100% 通过**,配合无头浏览器端到端 E2E 回归套件) +- **测试体系**:[Vitest](https://vitest.dev/) + [Puppeteer](https://pptr.dev/)(**36 个算法与单元测试套件,230 项用例 100% 通过**,配合无头浏览器端到端 E2E 回归套件) --- diff --git a/package-lock.json b/package-lock.json index 3b968d3..aa1edc0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "pdfseal", - "version": "1.2.1", + "version": "1.2.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pdfseal", - "version": "1.2.1", + "version": "1.2.2", "license": "AGPL-3.0-only", "dependencies": { "@pdfsmaller/pdf-encrypt": "^1.2.0", diff --git a/package.json b/package.json index 6b55776..b2190ba 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "pdfseal", - "version": "1.2.1", + "version": "1.2.2", "private": true, "type": "module", "license": "AGPL-3.0-only", diff --git a/scripts/run_all_tests.js b/scripts/run_all_tests.js index dfec110..ab6402e 100644 --- a/scripts/run_all_tests.js +++ b/scripts/run_all_tests.js @@ -4,7 +4,7 @@ import path from 'path'; const isWindows = process.platform === 'win32'; const testSuites = [ - { id: 'unit', name: 'Vitest Unit Test Suite (36 files, 228 tests)', cmd: isWindows ? 'npx.cmd' : 'npx', args: ['vitest', 'run'] }, + { id: 'unit', name: 'Vitest Unit Test Suite (36 files, 230 tests)', cmd: isWindows ? 'npx.cmd' : 'npx', args: ['vitest', 'run'] }, { id: 'i18n', name: 'i18n Multi-Language Verification (EN/DE/ES/FR/JA/ZH)', cmd: 'node', args: ['scripts/e2e_i18n_test.js'] }, { id: 'merge', name: 'E2E Business: PDF Merge (合并多个 PDF)', cmd: 'node', args: ['scripts/e2e_merge_test.js'] }, { id: 'compress', name: 'E2E Business: PDF Compress (智能模式与压缩率)', cmd: 'node', args: ['scripts/e2e_compress_test.js'] }, diff --git a/src/config/siteConfig.js b/src/config/siteConfig.js index 46c2ac8..220730d 100644 --- a/src/config/siteConfig.js +++ b/src/config/siteConfig.js @@ -59,7 +59,7 @@ export const isOfficialMode = ref(checkInitialOfficialMode()); export const siteConfig = reactive({ // Version Identity - version: import.meta.env.VITE_APP_VERSION || '1.2.1', + version: import.meta.env.VITE_APP_VERSION || '1.2.2', // Brand Identity brandName: import.meta.env.VITE_BRAND_NAME || 'PDFSeal', diff --git a/src/i18n.js b/src/i18n.js index cae9df6..982e261 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -8,9 +8,29 @@ import ja from './locales/ja.json'; const dictionaries = { en, zh, de, es, fr, ja }; +export const SUPPORTED_LANGUAGES = ['en', 'de', 'es', 'fr', 'ja', 'zh']; + // Force Vite HMR reload for JSON locales // Determine initial language safely for both browser and test/SSR environments -function getInitialLang() { +export function getInitialLang() { + // 1. Priority: URL query parameter (?lang=ja or ?hl=ja) for direct language sharing & crawler discovery + if (typeof window !== 'undefined' && window.location && window.location.search) { + try { + const searchParams = new URLSearchParams(window.location.search); + const paramLang = (searchParams.get('lang') || searchParams.get('hl') || '').toLowerCase().trim(); + if (paramLang) { + if (dictionaries[paramLang]) return paramLang; + if (paramLang.startsWith('zh')) return 'zh'; + if (paramLang.startsWith('ja')) return 'ja'; + if (paramLang.startsWith('de')) return 'de'; + if (paramLang.startsWith('es')) return 'es'; + if (paramLang.startsWith('fr')) return 'fr'; + if (paramLang.startsWith('en')) return 'en'; + } + } catch (e) {} + } + + // 2. Local storage if (typeof window !== 'undefined' && typeof localStorage !== 'undefined') { try { const saved = localStorage.getItem('pdfseal_lang'); @@ -18,6 +38,7 @@ function getInitialLang() { } catch (e) {} } + // 3. Browser system language const nav = (typeof navigator !== 'undefined' ? (navigator.language || navigator.userLanguage || 'en') : 'en').toLowerCase(); if (nav.startsWith('zh')) return 'zh'; if (nav.startsWith('ja')) return 'ja'; @@ -34,13 +55,28 @@ export function onLanguageChange(cb) { onLanguageChangeCallback = cb; } -export function setLanguage(lang) { +export function setLanguage(lang, syncUrl = true) { if (dictionaries[lang]) { currentLang.value = lang; - if (typeof window !== 'undefined' && typeof localStorage !== 'undefined') { - try { - localStorage.setItem('pdfseal_lang', lang); - } catch (e) {} + if (typeof window !== 'undefined') { + if (typeof localStorage !== 'undefined') { + try { + localStorage.setItem('pdfseal_lang', lang); + } catch (e) {} + } + if (syncUrl && window.history && window.location) { + try { + const url = new URL(window.location.href); + if (lang === 'en') { + url.searchParams.delete('lang'); + url.searchParams.delete('hl'); + } else { + url.searchParams.set('lang', lang); + } + const newUrl = url.pathname + (url.search ? url.search : '') + (url.hash || ''); + window.history.replaceState(window.history.state, '', newUrl); + } catch (e) {} + } } updateTitle(); if (onLanguageChangeCallback) { diff --git a/src/utils/seo.js b/src/utils/seo.js index 4b2ab2a..c40f018 100644 --- a/src/utils/seo.js +++ b/src/utils/seo.js @@ -33,6 +33,33 @@ function setCanonical(href) { link.setAttribute('href', href); } +export const HREFLANG_LOCALES = [ + { hreflang: 'en', lang: 'en' }, + { hreflang: 'de', lang: 'de' }, + { hreflang: 'es', lang: 'es' }, + { hreflang: 'fr', lang: 'fr' }, + { hreflang: 'ja', lang: 'ja' }, + { hreflang: 'zh', lang: 'zh' }, + { hreflang: 'zh-Hans', lang: 'zh' }, + { hreflang: 'x-default', lang: 'en' } +]; + +function setHreflangTags(origin, path) { + if (typeof document === 'undefined') return; + HREFLANG_LOCALES.forEach(({ hreflang, lang }) => { + const href = lang === 'en' ? `${origin}${path}` : `${origin}${path}?lang=${lang}`; + const selector = `link[rel="alternate"][hreflang="${hreflang}"]`; + let link = document.querySelector(selector); + if (!link) { + link = document.createElement('link'); + link.setAttribute('rel', 'alternate'); + link.setAttribute('hreflang', hreflang); + document.head.appendChild(link); + } + link.setAttribute('href', href); + }); +} + /** * Updates SEO document title, meta descriptions, canonical link, and Open Graph tags. * @param {object|string} routeOrToolId Vue router route object or toolId string @@ -73,9 +100,12 @@ export function updateSeoMeta(routeOrToolId) { setMetaTag('meta[property="og:title"]', 'property', 'og:title', title); setMetaTag('meta[property="og:description"]', 'property', 'og:description', description); if (typeof window !== 'undefined') { - const fullUrl = `${window.location.origin}${path}`; - setMetaTag('meta[property="og:url"]', 'property', 'og:url', fullUrl); - setCanonical(fullUrl); + const origin = window.location.origin; + const isDefaultLang = currentLang.value === 'en'; + const canonicalUrl = isDefaultLang ? `${origin}${path}` : `${origin}${path}?lang=${currentLang.value}`; + setMetaTag('meta[property="og:url"]', 'property', 'og:url', canonicalUrl); + setCanonical(canonicalUrl); + setHreflangTags(origin, path); } // Update Twitter Card diff --git a/tests/pdfToImage.test.js b/tests/pdfToImage.test.js index 1599524..609d373 100644 --- a/tests/pdfToImage.test.js +++ b/tests/pdfToImage.test.js @@ -61,7 +61,7 @@ describe('PDF to Image Engine & Pipeline Node', () => { globalThis.window = { location: { - origin: 'https://pdfseal.com', + origin: 'https://pdf.sealkit.org', pathname: '/pdf-to-image' } }; diff --git a/tests/routerSeo.test.js b/tests/routerSeo.test.js index e1a0bbd..3eda901 100644 --- a/tests/routerSeo.test.js +++ b/tests/routerSeo.test.js @@ -17,6 +17,10 @@ describe('Vue Router & Dynamic SEO Metadata Engine', () => { const key = el.name ? `name="${el.name}"` : `property="${el.property}"`; mockMetaTags.set(key, el); } else if (el._tag === 'link') { + if (el.hreflang) { + mockLinks.set(`link[rel="${el.rel}"][hreflang="${el.hreflang}"]`, el); + } + mockLinks.set(`link[rel="${el.rel}"]`, el); mockLinks.set(el.rel, el); } } @@ -48,7 +52,10 @@ describe('Vue Router & Dynamic SEO Metadata Engine', () => { const key = selector.replace('meta[', '').replace(']', ''); return mockMetaTags.get(key) || null; } - if (selector.startsWith('link[rel="')) { + if (mockLinks.has(selector)) { + return mockLinks.get(selector); + } + if (selector.startsWith('link[rel="') && !selector.includes('hreflang=')) { const rel = selector.match(/rel="([^"]+)"/)?.[1]; return mockLinks.get(rel) || null; } @@ -58,8 +65,13 @@ describe('Vue Router & Dynamic SEO Metadata Engine', () => { globalThis.window = { location: { - origin: 'https://pdfseal.com', - pathname: '/merge-pdf' + origin: 'https://pdf.sealkit.org', + pathname: '/merge-pdf', + search: '' + }, + history: { + state: null, + replaceState: (state, title, url) => {} } }; }); @@ -133,7 +145,7 @@ describe('Vue Router & Dynamic SEO Metadata Engine', () => { updateSeoMeta({ meta: { toolId: 'split' }, path: '/split-pdf' }); const canonical = document.querySelector('link[rel="canonical"]'); expect(canonical).not.toBeNull(); - expect(canonical.getAttribute('href')).toBe('https://pdfseal.com/split-pdf'); + expect(canonical.getAttribute('href')).toBe('https://pdf.sealkit.org/split-pdf'); }); it('should support dedicated 14-tool matrix homepage route and SEO metadata', () => { @@ -152,7 +164,7 @@ describe('Vue Router & Dynamic SEO Metadata Engine', () => { // Canonical link for home const canonical = document.querySelector('link[rel="canonical"]'); expect(canonical).not.toBeNull(); - expect(canonical.getAttribute('href')).toBe('https://pdfseal.com/'); + expect(canonical.getAttribute('href')).toBe('https://pdf.sealkit.org/'); // Chinese Home SEO setLanguage('zh'); @@ -163,4 +175,39 @@ describe('Vue Router & Dynamic SEO Metadata Engine', () => { expect(zhMetaDesc).not.toBeNull(); expect(zhMetaDesc.getAttribute('content')).toContain('14 合 1'); }); + + it('should generate language-aware canonical and full hreflang alternate links', () => { + setLanguage('ja'); + updateSeoMeta('merge'); + const canonical = document.querySelector('link[rel="canonical"]'); + expect(canonical.getAttribute('href')).toBe('https://pdf.sealkit.org/merge-pdf?lang=ja'); + + const jaAlternate = document.querySelector('link[rel="alternate"][hreflang="ja"]'); + expect(jaAlternate).not.toBeNull(); + expect(jaAlternate.getAttribute('href')).toBe('https://pdf.sealkit.org/merge-pdf?lang=ja'); + + const enAlternate = document.querySelector('link[rel="alternate"][hreflang="en"]'); + expect(enAlternate).not.toBeNull(); + expect(enAlternate.getAttribute('href')).toBe('https://pdf.sealkit.org/merge-pdf'); + + const xDefault = document.querySelector('link[rel="alternate"][hreflang="x-default"]'); + expect(xDefault).not.toBeNull(); + expect(xDefault.getAttribute('href')).toBe('https://pdf.sealkit.org/merge-pdf'); + + setLanguage('en'); + }); + + it('should detect language from URL query parameter (?lang=ja and ?hl=de)', async () => { + const { getInitialLang } = await import('../src/i18n.js'); + globalThis.window.location.search = '?lang=ja'; + expect(getInitialLang()).toBe('ja'); + + globalThis.window.location.search = '?hl=de'; + expect(getInitialLang()).toBe('de'); + + globalThis.window.location.search = '?lang=zh-CN'; + expect(getInitialLang()).toBe('zh'); + + globalThis.window.location.search = ''; + }); }); diff --git a/vite.config.js b/vite.config.js index 0be7652..c99e5e0 100644 --- a/vite.config.js +++ b/vite.config.js @@ -25,12 +25,45 @@ function seoStaticFilesPlugin(siteUrl) { } const today = new Date().toISOString().slice(0, 10); const robotsTxt = `User-agent: *\nAllow: /\n\nSitemap: ${siteUrl}/sitemap.xml\n`; + const SITEMAP_LANGS = [ + { code: 'en', hreflang: 'en', isDefault: true }, + { code: 'de', hreflang: 'de' }, + { code: 'es', hreflang: 'es' }, + { code: 'fr', hreflang: 'fr' }, + { code: 'ja', hreflang: 'ja' }, + { code: 'zh', hreflang: 'zh' } + ]; + + const uniquePaths = Array.from(new Set(['/', ...Object.values(TOOL_ROUTES)])); + + const urlEntries = uniquePaths.flatMap((p) => { + const alternateLinks = [ + ...SITEMAP_LANGS.map(l => + ` ` + ), + ` `, + ` ` + ].join('\n'); + + return SITEMAP_LANGS.map((l) => { + const loc = `${siteUrl}${p}${l.isDefault ? '' : `?lang=${l.code}`}`; + const priority = p === '/' ? (l.isDefault ? '1.0' : '0.9') : (l.isDefault ? '0.8' : '0.7'); + return ( + ` \n` + + ` ${loc}\n` + + `${alternateLinks}\n` + + ` ${today}\n` + + ` weekly\n` + + ` ${priority}\n` + + ` ` + ); + }); + }); + const sitemapXml = `\n` + - `\n` + - Array.from(new Set(['/', ...Object.values(TOOL_ROUTES)])) - .map((p) => ` \n ${siteUrl}${p}\n ${today}\n `) - .join('\n') + + `\n` + + urlEntries.join('\n') + `\n\n`; return {