Skip to content
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.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

---

Expand Down
4 changes: 2 additions & 2 deletions README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 回归套件)

---

Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "pdfseal",
"version": "1.2.1",
"version": "1.2.2",
"private": true,
"type": "module",
"license": "AGPL-3.0-only",
Expand Down
2 changes: 1 addition & 1 deletion scripts/run_all_tests.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'] },
Expand Down
2 changes: 1 addition & 1 deletion src/config/siteConfig.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
48 changes: 42 additions & 6 deletions src/i18n.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,37 @@ 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');
if (saved && dictionaries[saved]) return saved;
} 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';
Expand All @@ -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) {
Expand Down
36 changes: 33 additions & 3 deletions src/utils/seo.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion tests/pdfToImage.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'
}
};
Expand Down
57 changes: 52 additions & 5 deletions tests/routerSeo.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
Expand Down Expand Up @@ -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;
}
Expand All @@ -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) => {}
}
};
});
Expand Down Expand Up @@ -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', () => {
Expand All @@ -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');
Expand All @@ -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 = '';
});
});
41 changes: 37 additions & 4 deletions vite.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 =>
` <xhtml:link rel="alternate" hreflang="${l.hreflang}" href="${siteUrl}${p}${l.isDefault ? '' : `?lang=${l.code}`}" />`
),
` <xhtml:link rel="alternate" hreflang="zh-Hans" href="${siteUrl}${p}?lang=zh" />`,
` <xhtml:link rel="alternate" hreflang="x-default" href="${siteUrl}${p}" />`
].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 (
` <url>\n` +
` <loc>${loc}</loc>\n` +
`${alternateLinks}\n` +
` <lastmod>${today}</lastmod>\n` +
` <changefreq>weekly</changefreq>\n` +
` <priority>${priority}</priority>\n` +
` </url>`
);
});
});

const sitemapXml =
`<?xml version="1.0" encoding="UTF-8"?>\n` +
`<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n` +
Array.from(new Set(['/', ...Object.values(TOOL_ROUTES)]))
.map((p) => ` <url>\n <loc>${siteUrl}${p}</loc>\n <lastmod>${today}</lastmod>\n </url>`)
.join('\n') +
`<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml">\n` +
urlEntries.join('\n') +
`\n</urlset>\n`;

return {
Expand Down
Loading