feat: add SEO configuration and pages for programmatic tools and collections

- Introduced seoPages.ts to manage SEO-related configurations and types for programmatic tools and collection pages.
- Created SeoCollectionPage and SeoProgrammaticPage components to render SEO content dynamically based on the new configuration.
- Enhanced API service to ensure CSRF token handling for secure requests.
- Added generateHowTo utility function for structured data generation.
- Updated sitemap generation script to include SEO tool and collection pages.
- Configured TypeScript to resolve JSON modules for easier integration of SEO data.   ستراتيجية التنفيذ

لم أغير أي core logic في أدوات التحويل أو الضغط أو التحرير
استخدمت architecture إضافية فوق النظام الحالي بدل استبداله
جعلت الـ SEO pages تعتمد على source of truth واحد حتى يسهل التوسع
ربطت التوليد مع build حتى لا تبقى sitemap وrobots ثابتة أو منسية
دعمت العربية والإنجليزية داخل نفس config الجديد
عززت internal linking من:
صفحات SEO إلى tool pages
صفحات SEO إلى collection pages
footer إلى collection pages
Suggested tools داخل صفحات الأدوات
التحقق
This commit is contained in:
Your Name
2026-03-21 01:19:32 +02:00
parent 0174f935c3
commit f347022924
17 changed files with 1398 additions and 100 deletions

View File

@@ -5,10 +5,12 @@
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"prebuild": "node scripts/generate-seo-assets.mjs",
"build": "tsc --noEmit && vite build", "build": "tsc --noEmit && vite build",
"preview": "vite preview", "preview": "vite preview",
"lint": "eslint .", "lint": "eslint .",
"test": "vitest run" "test": "vitest run",
"seo:generate": "node scripts/generate-seo-assets.mjs"
}, },
"dependencies": { "dependencies": {
"axios": "^1.7.0", "axios": "^1.7.0",

View File

@@ -2,11 +2,12 @@
User-agent: * User-agent: *
Allow: / Allow: /
Disallow: /api/ Disallow: /api/
Disallow: /internal/
Disallow: /account Disallow: /account
Disallow: /forgot-password Disallow: /forgot-password
Disallow: /reset-password Disallow: /reset-password
Disallow: /internal/admin
# Sitemaps
Sitemap: https://dociva.io/sitemap.xml Sitemap: https://dociva.io/sitemap.xml
# AI/LLM discoverability # AI/LLM discoverability

View File

@@ -2,342 +2,392 @@
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"> <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url> <url>
<loc>https://dociva.io/</loc> <loc>https://dociva.io/</loc>
<lastmod>2026-03-17</lastmod> <lastmod>2026-03-20</lastmod>
<changefreq>daily</changefreq> <changefreq>daily</changefreq>
<priority>1.0</priority> <priority>1.0</priority>
</url> </url>
<url> <url>
<loc>https://dociva.io/about</loc> <loc>https://dociva.io/about</loc>
<lastmod>2026-03-17</lastmod> <lastmod>2026-03-20</lastmod>
<changefreq>monthly</changefreq> <changefreq>monthly</changefreq>
<priority>0.4</priority> <priority>0.4</priority>
</url> </url>
<url> <url>
<loc>https://dociva.io/contact</loc> <loc>https://dociva.io/contact</loc>
<lastmod>2026-03-17</lastmod> <lastmod>2026-03-20</lastmod>
<changefreq>monthly</changefreq> <changefreq>monthly</changefreq>
<priority>0.4</priority> <priority>0.4</priority>
</url> </url>
<url> <url>
<loc>https://dociva.io/privacy</loc> <loc>https://dociva.io/privacy</loc>
<lastmod>2026-03-17</lastmod> <lastmod>2026-03-20</lastmod>
<changefreq>yearly</changefreq> <changefreq>yearly</changefreq>
<priority>0.3</priority> <priority>0.3</priority>
</url> </url>
<url> <url>
<loc>https://dociva.io/terms</loc> <loc>https://dociva.io/terms</loc>
<lastmod>2026-03-17</lastmod> <lastmod>2026-03-20</lastmod>
<changefreq>yearly</changefreq> <changefreq>yearly</changefreq>
<priority>0.3</priority> <priority>0.3</priority>
</url> </url>
<url> <url>
<loc>https://dociva.io/pricing</loc> <loc>https://dociva.io/pricing</loc>
<lastmod>2026-03-17</lastmod> <lastmod>2026-03-20</lastmod>
<changefreq>monthly</changefreq> <changefreq>monthly</changefreq>
<priority>0.7</priority> <priority>0.7</priority>
</url> </url>
<url> <url>
<loc>https://dociva.io/blog</loc> <loc>https://dociva.io/blog</loc>
<lastmod>2026-03-17</lastmod> <lastmod>2026-03-20</lastmod>
<changefreq>weekly</changefreq> <changefreq>weekly</changefreq>
<priority>0.6</priority> <priority>0.6</priority>
</url> </url>
<url>
<!-- Blog Posts --> <loc>https://dociva.io/developers</loc>
<lastmod>2026-03-20</lastmod>
<changefreq>monthly</changefreq>
<priority>0.5</priority>
</url>
<url> <url>
<loc>https://dociva.io/blog/how-to-compress-pdf-online</loc> <loc>https://dociva.io/blog/how-to-compress-pdf-online</loc>
<lastmod>2026-03-17</lastmod> <lastmod>2026-03-20</lastmod>
<changefreq>monthly</changefreq> <changefreq>monthly</changefreq>
<priority>0.6</priority> <priority>0.6</priority>
</url> </url>
<url> <url>
<loc>https://dociva.io/blog/convert-images-without-losing-quality</loc> <loc>https://dociva.io/blog/convert-images-without-losing-quality</loc>
<lastmod>2026-03-17</lastmod> <lastmod>2026-03-20</lastmod>
<changefreq>monthly</changefreq> <changefreq>monthly</changefreq>
<priority>0.6</priority> <priority>0.6</priority>
</url> </url>
<url> <url>
<loc>https://dociva.io/blog/ocr-extract-text-from-images</loc> <loc>https://dociva.io/blog/ocr-extract-text-from-images</loc>
<lastmod>2026-03-17</lastmod> <lastmod>2026-03-20</lastmod>
<changefreq>monthly</changefreq> <changefreq>monthly</changefreq>
<priority>0.6</priority> <priority>0.6</priority>
</url> </url>
<url> <url>
<loc>https://dociva.io/blog/merge-split-pdf-files</loc> <loc>https://dociva.io/blog/merge-split-pdf-files</loc>
<lastmod>2026-03-17</lastmod> <lastmod>2026-03-20</lastmod>
<changefreq>monthly</changefreq> <changefreq>monthly</changefreq>
<priority>0.6</priority> <priority>0.6</priority>
</url> </url>
<url> <url>
<loc>https://dociva.io/blog/ai-chat-with-pdf-documents</loc> <loc>https://dociva.io/blog/ai-chat-with-pdf-documents</loc>
<lastmod>2026-03-17</lastmod> <lastmod>2026-03-20</lastmod>
<changefreq>monthly</changefreq> <changefreq>monthly</changefreq>
<priority>0.6</priority> <priority>0.6</priority>
</url> </url>
<!-- PDF Tools -->
<url> <url>
<loc>https://dociva.io/tools/pdf-to-word</loc> <loc>https://dociva.io/tools/pdf-to-word</loc>
<lastmod>2026-03-17</lastmod> <lastmod>2026-03-20</lastmod>
<changefreq>weekly</changefreq> <changefreq>weekly</changefreq>
<priority>0.9</priority> <priority>0.9</priority>
</url> </url>
<url> <url>
<loc>https://dociva.io/tools/word-to-pdf</loc> <loc>https://dociva.io/tools/word-to-pdf</loc>
<lastmod>2026-03-17</lastmod> <lastmod>2026-03-20</lastmod>
<changefreq>weekly</changefreq> <changefreq>weekly</changefreq>
<priority>0.9</priority> <priority>0.9</priority>
</url> </url>
<url> <url>
<loc>https://dociva.io/tools/compress-pdf</loc> <loc>https://dociva.io/tools/compress-pdf</loc>
<lastmod>2026-03-17</lastmod> <lastmod>2026-03-20</lastmod>
<changefreq>weekly</changefreq> <changefreq>weekly</changefreq>
<priority>0.9</priority> <priority>0.9</priority>
</url> </url>
<url> <url>
<loc>https://dociva.io/tools/merge-pdf</loc> <loc>https://dociva.io/tools/merge-pdf</loc>
<lastmod>2026-03-17</lastmod> <lastmod>2026-03-20</lastmod>
<changefreq>weekly</changefreq> <changefreq>weekly</changefreq>
<priority>0.9</priority> <priority>0.9</priority>
</url> </url>
<url> <url>
<loc>https://dociva.io/tools/split-pdf</loc> <loc>https://dociva.io/tools/split-pdf</loc>
<lastmod>2026-03-17</lastmod> <lastmod>2026-03-20</lastmod>
<changefreq>weekly</changefreq> <changefreq>weekly</changefreq>
<priority>0.8</priority> <priority>0.8</priority>
</url> </url>
<url> <url>
<loc>https://dociva.io/tools/rotate-pdf</loc> <loc>https://dociva.io/tools/rotate-pdf</loc>
<lastmod>2026-03-17</lastmod> <lastmod>2026-03-20</lastmod>
<changefreq>weekly</changefreq> <changefreq>weekly</changefreq>
<priority>0.7</priority> <priority>0.7</priority>
</url> </url>
<url> <url>
<loc>https://dociva.io/tools/pdf-to-images</loc> <loc>https://dociva.io/tools/pdf-to-images</loc>
<lastmod>2026-03-17</lastmod> <lastmod>2026-03-20</lastmod>
<changefreq>weekly</changefreq> <changefreq>weekly</changefreq>
<priority>0.8</priority> <priority>0.8</priority>
</url> </url>
<url> <url>
<loc>https://dociva.io/tools/images-to-pdf</loc> <loc>https://dociva.io/tools/images-to-pdf</loc>
<lastmod>2026-03-17</lastmod> <lastmod>2026-03-20</lastmod>
<changefreq>weekly</changefreq> <changefreq>weekly</changefreq>
<priority>0.8</priority> <priority>0.8</priority>
</url> </url>
<url> <url>
<loc>https://dociva.io/tools/watermark-pdf</loc> <loc>https://dociva.io/tools/watermark-pdf</loc>
<lastmod>2026-03-17</lastmod> <lastmod>2026-03-20</lastmod>
<changefreq>weekly</changefreq> <changefreq>weekly</changefreq>
<priority>0.7</priority> <priority>0.7</priority>
</url> </url>
<url> <url>
<loc>https://dociva.io/tools/remove-watermark-pdf</loc> <loc>https://dociva.io/tools/remove-watermark-pdf</loc>
<lastmod>2026-03-17</lastmod> <lastmod>2026-03-20</lastmod>
<changefreq>weekly</changefreq> <changefreq>weekly</changefreq>
<priority>0.7</priority> <priority>0.7</priority>
</url> </url>
<url> <url>
<loc>https://dociva.io/tools/protect-pdf</loc> <loc>https://dociva.io/tools/protect-pdf</loc>
<lastmod>2026-03-17</lastmod> <lastmod>2026-03-20</lastmod>
<changefreq>weekly</changefreq> <changefreq>weekly</changefreq>
<priority>0.8</priority> <priority>0.8</priority>
</url> </url>
<url> <url>
<loc>https://dociva.io/tools/unlock-pdf</loc> <loc>https://dociva.io/tools/unlock-pdf</loc>
<lastmod>2026-03-17</lastmod> <lastmod>2026-03-20</lastmod>
<changefreq>weekly</changefreq> <changefreq>weekly</changefreq>
<priority>0.8</priority> <priority>0.8</priority>
</url> </url>
<url> <url>
<loc>https://dociva.io/tools/page-numbers</loc> <loc>https://dociva.io/tools/page-numbers</loc>
<lastmod>2026-03-17</lastmod> <lastmod>2026-03-20</lastmod>
<changefreq>weekly</changefreq> <changefreq>weekly</changefreq>
<priority>0.7</priority> <priority>0.7</priority>
</url> </url>
<url> <url>
<loc>https://dociva.io/tools/reorder-pdf</loc> <loc>https://dociva.io/tools/reorder-pdf</loc>
<lastmod>2026-03-17</lastmod> <lastmod>2026-03-20</lastmod>
<changefreq>weekly</changefreq> <changefreq>weekly</changefreq>
<priority>0.7</priority> <priority>0.7</priority>
</url> </url>
<url> <url>
<loc>https://dociva.io/tools/extract-pages</loc> <loc>https://dociva.io/tools/extract-pages</loc>
<lastmod>2026-03-17</lastmod> <lastmod>2026-03-20</lastmod>
<changefreq>weekly</changefreq> <changefreq>weekly</changefreq>
<priority>0.7</priority> <priority>0.7</priority>
</url> </url>
<url> <url>
<loc>https://dociva.io/tools/pdf-editor</loc> <loc>https://dociva.io/tools/pdf-editor</loc>
<lastmod>2026-03-17</lastmod> <lastmod>2026-03-20</lastmod>
<changefreq>weekly</changefreq> <changefreq>weekly</changefreq>
<priority>0.8</priority> <priority>0.8</priority>
</url> </url>
<url> <url>
<loc>https://dociva.io/tools/pdf-flowchart</loc> <loc>https://dociva.io/tools/pdf-flowchart</loc>
<lastmod>2026-03-17</lastmod> <lastmod>2026-03-20</lastmod>
<changefreq>weekly</changefreq> <changefreq>weekly</changefreq>
<priority>0.7</priority> <priority>0.7</priority>
</url> </url>
<url> <url>
<loc>https://dociva.io/tools/pdf-to-excel</loc> <loc>https://dociva.io/tools/pdf-to-excel</loc>
<lastmod>2026-03-17</lastmod> <lastmod>2026-03-20</lastmod>
<changefreq>weekly</changefreq> <changefreq>weekly</changefreq>
<priority>0.8</priority> <priority>0.8</priority>
</url> </url>
<url> <url>
<loc>https://dociva.io/tools/sign-pdf</loc> <loc>https://dociva.io/tools/sign-pdf</loc>
<lastmod>2026-03-17</lastmod> <lastmod>2026-03-20</lastmod>
<changefreq>weekly</changefreq> <changefreq>weekly</changefreq>
<priority>0.8</priority> <priority>0.8</priority>
</url> </url>
<url> <url>
<loc>https://dociva.io/tools/crop-pdf</loc> <loc>https://dociva.io/tools/crop-pdf</loc>
<lastmod>2026-03-17</lastmod> <lastmod>2026-03-20</lastmod>
<changefreq>weekly</changefreq> <changefreq>weekly</changefreq>
<priority>0.7</priority> <priority>0.7</priority>
</url> </url>
<url> <url>
<loc>https://dociva.io/tools/flatten-pdf</loc> <loc>https://dociva.io/tools/flatten-pdf</loc>
<lastmod>2026-03-17</lastmod> <lastmod>2026-03-20</lastmod>
<changefreq>weekly</changefreq> <changefreq>weekly</changefreq>
<priority>0.7</priority> <priority>0.7</priority>
</url> </url>
<url> <url>
<loc>https://dociva.io/tools/repair-pdf</loc> <loc>https://dociva.io/tools/repair-pdf</loc>
<lastmod>2026-03-17</lastmod> <lastmod>2026-03-20</lastmod>
<changefreq>weekly</changefreq> <changefreq>weekly</changefreq>
<priority>0.7</priority> <priority>0.7</priority>
</url> </url>
<url> <url>
<loc>https://dociva.io/tools/pdf-metadata</loc> <loc>https://dociva.io/tools/pdf-metadata</loc>
<lastmod>2026-03-17</lastmod> <lastmod>2026-03-20</lastmod>
<changefreq>weekly</changefreq> <changefreq>weekly</changefreq>
<priority>0.6</priority> <priority>0.6</priority>
</url> </url>
<!-- Image Tools -->
<url> <url>
<loc>https://dociva.io/tools/image-converter</loc> <loc>https://dociva.io/tools/image-converter</loc>
<lastmod>2026-03-17</lastmod> <lastmod>2026-03-20</lastmod>
<changefreq>weekly</changefreq> <changefreq>weekly</changefreq>
<priority>0.8</priority> <priority>0.8</priority>
</url> </url>
<url> <url>
<loc>https://dociva.io/tools/image-resize</loc> <loc>https://dociva.io/tools/image-resize</loc>
<lastmod>2026-03-17</lastmod> <lastmod>2026-03-20</lastmod>
<changefreq>weekly</changefreq> <changefreq>weekly</changefreq>
<priority>0.8</priority> <priority>0.8</priority>
</url> </url>
<url> <url>
<loc>https://dociva.io/tools/compress-image</loc> <loc>https://dociva.io/tools/compress-image</loc>
<lastmod>2026-03-17</lastmod> <lastmod>2026-03-20</lastmod>
<changefreq>weekly</changefreq> <changefreq>weekly</changefreq>
<priority>0.8</priority> <priority>0.8</priority>
</url> </url>
<url> <url>
<loc>https://dociva.io/tools/remove-background</loc> <loc>https://dociva.io/tools/remove-background</loc>
<lastmod>2026-03-17</lastmod> <lastmod>2026-03-20</lastmod>
<changefreq>weekly</changefreq> <changefreq>weekly</changefreq>
<priority>0.8</priority> <priority>0.8</priority>
</url> </url>
<url> <url>
<loc>https://dociva.io/tools/image-crop</loc> <loc>https://dociva.io/tools/image-crop</loc>
<lastmod>2026-03-17</lastmod> <lastmod>2026-03-20</lastmod>
<changefreq>weekly</changefreq> <changefreq>weekly</changefreq>
<priority>0.7</priority> <priority>0.7</priority>
</url> </url>
<url> <url>
<loc>https://dociva.io/tools/image-rotate-flip</loc> <loc>https://dociva.io/tools/image-rotate-flip</loc>
<lastmod>2026-03-17</lastmod> <lastmod>2026-03-20</lastmod>
<changefreq>weekly</changefreq> <changefreq>weekly</changefreq>
<priority>0.7</priority> <priority>0.7</priority>
</url> </url>
<!-- AI Tools -->
<url> <url>
<loc>https://dociva.io/tools/ocr</loc> <loc>https://dociva.io/tools/ocr</loc>
<lastmod>2026-03-17</lastmod> <lastmod>2026-03-20</lastmod>
<changefreq>weekly</changefreq> <changefreq>weekly</changefreq>
<priority>0.8</priority> <priority>0.8</priority>
</url> </url>
<url> <url>
<loc>https://dociva.io/tools/chat-pdf</loc> <loc>https://dociva.io/tools/chat-pdf</loc>
<lastmod>2026-03-17</lastmod> <lastmod>2026-03-20</lastmod>
<changefreq>weekly</changefreq> <changefreq>weekly</changefreq>
<priority>0.8</priority> <priority>0.8</priority>
</url> </url>
<url> <url>
<loc>https://dociva.io/tools/summarize-pdf</loc> <loc>https://dociva.io/tools/summarize-pdf</loc>
<lastmod>2026-03-17</lastmod> <lastmod>2026-03-20</lastmod>
<changefreq>weekly</changefreq> <changefreq>weekly</changefreq>
<priority>0.8</priority> <priority>0.8</priority>
</url> </url>
<url> <url>
<loc>https://dociva.io/tools/translate-pdf</loc> <loc>https://dociva.io/tools/translate-pdf</loc>
<lastmod>2026-03-17</lastmod> <lastmod>2026-03-20</lastmod>
<changefreq>weekly</changefreq> <changefreq>weekly</changefreq>
<priority>0.8</priority> <priority>0.8</priority>
</url> </url>
<url> <url>
<loc>https://dociva.io/tools/extract-tables</loc> <loc>https://dociva.io/tools/extract-tables</loc>
<lastmod>2026-03-17</lastmod> <lastmod>2026-03-20</lastmod>
<changefreq>weekly</changefreq> <changefreq>weekly</changefreq>
<priority>0.8</priority> <priority>0.8</priority>
</url> </url>
<!-- Utility Tools -->
<url> <url>
<loc>https://dociva.io/tools/html-to-pdf</loc> <loc>https://dociva.io/tools/html-to-pdf</loc>
<lastmod>2026-03-17</lastmod> <lastmod>2026-03-20</lastmod>
<changefreq>weekly</changefreq> <changefreq>weekly</changefreq>
<priority>0.7</priority> <priority>0.7</priority>
</url> </url>
<url> <url>
<loc>https://dociva.io/tools/qr-code</loc> <loc>https://dociva.io/tools/qr-code</loc>
<lastmod>2026-03-17</lastmod> <lastmod>2026-03-20</lastmod>
<changefreq>weekly</changefreq> <changefreq>weekly</changefreq>
<priority>0.7</priority> <priority>0.7</priority>
</url> </url>
<url> <url>
<loc>https://dociva.io/tools/video-to-gif</loc> <loc>https://dociva.io/tools/video-to-gif</loc>
<lastmod>2026-03-17</lastmod> <lastmod>2026-03-20</lastmod>
<changefreq>weekly</changefreq> <changefreq>weekly</changefreq>
<priority>0.7</priority> <priority>0.7</priority>
</url> </url>
<url> <url>
<loc>https://dociva.io/tools/word-counter</loc> <loc>https://dociva.io/tools/word-counter</loc>
<lastmod>2026-03-17</lastmod> <lastmod>2026-03-20</lastmod>
<changefreq>weekly</changefreq> <changefreq>weekly</changefreq>
<priority>0.6</priority> <priority>0.6</priority>
</url> </url>
<url> <url>
<loc>https://dociva.io/tools/text-cleaner</loc> <loc>https://dociva.io/tools/text-cleaner</loc>
<lastmod>2026-03-17</lastmod> <lastmod>2026-03-20</lastmod>
<changefreq>weekly</changefreq> <changefreq>weekly</changefreq>
<priority>0.6</priority> <priority>0.6</priority>
</url> </url>
<url> <url>
<loc>https://dociva.io/tools/pdf-to-pptx</loc> <loc>https://dociva.io/tools/pdf-to-pptx</loc>
<lastmod>2026-03-17</lastmod> <lastmod>2026-03-20</lastmod>
<changefreq>weekly</changefreq> <changefreq>weekly</changefreq>
<priority>0.8</priority> <priority>0.8</priority>
</url> </url>
<url> <url>
<loc>https://dociva.io/tools/excel-to-pdf</loc> <loc>https://dociva.io/tools/excel-to-pdf</loc>
<lastmod>2026-03-17</lastmod> <lastmod>2026-03-20</lastmod>
<changefreq>weekly</changefreq> <changefreq>weekly</changefreq>
<priority>0.8</priority> <priority>0.8</priority>
</url> </url>
<url> <url>
<loc>https://dociva.io/tools/pptx-to-pdf</loc> <loc>https://dociva.io/tools/pptx-to-pdf</loc>
<lastmod>2026-03-17</lastmod> <lastmod>2026-03-20</lastmod>
<changefreq>weekly</changefreq> <changefreq>weekly</changefreq>
<priority>0.8</priority> <priority>0.8</priority>
</url> </url>
<url> <url>
<loc>https://dociva.io/tools/barcode-generator</loc> <loc>https://dociva.io/tools/barcode-generator</loc>
<lastmod>2026-03-17</lastmod> <lastmod>2026-03-20</lastmod>
<changefreq>weekly</changefreq> <changefreq>weekly</changefreq>
<priority>0.7</priority> <priority>0.7</priority>
</url> </url>
<url>
<loc>https://dociva.io/pdf-to-word</loc>
<lastmod>2026-03-20</lastmod>
<changefreq>weekly</changefreq>
<priority>0.88</priority>
</url>
<url>
<loc>https://dociva.io/word-to-pdf</loc>
<lastmod>2026-03-20</lastmod>
<changefreq>weekly</changefreq>
<priority>0.88</priority>
</url>
<url>
<loc>https://dociva.io/compress-pdf-online</loc>
<lastmod>2026-03-20</lastmod>
<changefreq>weekly</changefreq>
<priority>0.88</priority>
</url>
<url>
<loc>https://dociva.io/convert-jpg-to-pdf</loc>
<lastmod>2026-03-20</lastmod>
<changefreq>weekly</changefreq>
<priority>0.88</priority>
</url>
<url>
<loc>https://dociva.io/merge-pdf-files</loc>
<lastmod>2026-03-20</lastmod>
<changefreq>weekly</changefreq>
<priority>0.88</priority>
</url>
<url>
<loc>https://dociva.io/remove-pdf-password</loc>
<lastmod>2026-03-20</lastmod>
<changefreq>weekly</changefreq>
<priority>0.88</priority>
</url>
<url>
<loc>https://dociva.io/best-pdf-tools</loc>
<lastmod>2026-03-20</lastmod>
<changefreq>weekly</changefreq>
<priority>0.82</priority>
</url>
<url>
<loc>https://dociva.io/free-pdf-tools-online</loc>
<lastmod>2026-03-20</lastmod>
<changefreq>weekly</changefreq>
<priority>0.82</priority>
</url>
<url>
<loc>https://dociva.io/convert-files-online</loc>
<lastmod>2026-03-20</lastmod>
<changefreq>weekly</changefreq>
<priority>0.82</priority>
</url>
</urlset> </urlset>

View File

@@ -0,0 +1,125 @@
import { readFile, writeFile } from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const frontendRoot = path.resolve(__dirname, '..');
const publicDir = path.join(frontendRoot, 'public');
const siteOrigin = String(process.env.VITE_SITE_DOMAIN || 'https://dociva.io').trim().replace(/\/$/, '');
const today = new Date().toISOString().slice(0, 10);
const seoConfig = JSON.parse(
await readFile(path.join(frontendRoot, 'src', 'config', 'seo-tools.json'), 'utf8')
);
const staticPages = [
{ path: '/', changefreq: 'daily', priority: '1.0' },
{ path: '/about', changefreq: 'monthly', priority: '0.4' },
{ path: '/contact', changefreq: 'monthly', priority: '0.4' },
{ path: '/privacy', changefreq: 'yearly', priority: '0.3' },
{ path: '/terms', changefreq: 'yearly', priority: '0.3' },
{ path: '/pricing', changefreq: 'monthly', priority: '0.7' },
{ path: '/blog', changefreq: 'weekly', priority: '0.6' },
{ path: '/developers', changefreq: 'monthly', priority: '0.5' },
];
const toolRoutePriorities = new Map([
['pdf-to-word', '0.9'],
['word-to-pdf', '0.9'],
['compress-pdf', '0.9'],
['merge-pdf', '0.9'],
['split-pdf', '0.8'],
['rotate-pdf', '0.7'],
['pdf-to-images', '0.8'],
['images-to-pdf', '0.8'],
['watermark-pdf', '0.7'],
['remove-watermark-pdf', '0.7'],
['protect-pdf', '0.8'],
['unlock-pdf', '0.8'],
['page-numbers', '0.7'],
['reorder-pdf', '0.7'],
['extract-pages', '0.7'],
['pdf-editor', '0.8'],
['pdf-flowchart', '0.7'],
['pdf-to-excel', '0.8'],
['sign-pdf', '0.8'],
['crop-pdf', '0.7'],
['flatten-pdf', '0.7'],
['repair-pdf', '0.7'],
['pdf-metadata', '0.6'],
['image-converter', '0.8'],
['image-resize', '0.8'],
['compress-image', '0.8'],
['remove-background', '0.8'],
['image-crop', '0.7'],
['image-rotate-flip', '0.7'],
['ocr', '0.8'],
['chat-pdf', '0.8'],
['summarize-pdf', '0.8'],
['translate-pdf', '0.8'],
['extract-tables', '0.8'],
['html-to-pdf', '0.7'],
['qr-code', '0.7'],
['video-to-gif', '0.7'],
['word-counter', '0.6'],
['text-cleaner', '0.6'],
['pdf-to-pptx', '0.8'],
['excel-to-pdf', '0.8'],
['pptx-to-pdf', '0.8'],
['barcode-generator', '0.7'],
]);
function extractBlogSlugs(source) {
return [...source.matchAll(/slug:\s*'([^']+)'/g)].map((match) => match[1]);
}
function makeUrlTag({ loc, changefreq, priority }) {
return ` <url>\n <loc>${loc}</loc>\n <lastmod>${today}</lastmod>\n <changefreq>${changefreq}</changefreq>\n <priority>${priority}</priority>\n </url>`;
}
const blogSource = await readFile(path.join(frontendRoot, 'src', 'content', 'blogArticles.ts'), 'utf8');
const blogSlugs = extractBlogSlugs(blogSource);
const sitemapEntries = [
...staticPages.map((page) =>
makeUrlTag({ loc: `${siteOrigin}${page.path}`, changefreq: page.changefreq, priority: page.priority })
),
...blogSlugs.map((slug) =>
makeUrlTag({ loc: `${siteOrigin}/blog/${slug}`, changefreq: 'monthly', priority: '0.6' })
),
...[...toolRoutePriorities.entries()].map(([slug, priority]) =>
makeUrlTag({ loc: `${siteOrigin}/tools/${slug}`, changefreq: 'weekly', priority })
),
...seoConfig.toolPages.map((page) =>
makeUrlTag({ loc: `${siteOrigin}/${page.slug}`, changefreq: 'weekly', priority: '0.88' })
),
...seoConfig.collectionPages.map((page) =>
makeUrlTag({ loc: `${siteOrigin}/${page.slug}`, changefreq: 'weekly', priority: '0.82' })
),
];
const sitemap = `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${sitemapEntries.join('\n')}\n</urlset>\n`;
const robots = [
'# robots.txt — Dociva',
'User-agent: *',
'Allow: /',
'Disallow: /api/',
'Disallow: /internal/',
'Disallow: /account',
'Disallow: /forgot-password',
'Disallow: /reset-password',
'Disallow: /internal/admin',
'',
`Sitemap: ${siteOrigin}/sitemap.xml`,
'',
'# AI/LLM discoverability',
'# See also: /llms.txt',
'',
].join('\n');
await writeFile(path.join(publicDir, 'sitemap.xml'), sitemap, 'utf8');
await writeFile(path.join(publicDir, 'robots.txt'), robots, 'utf8');
console.log(`Generated SEO assets for ${siteOrigin}`);

View File

@@ -25,6 +25,8 @@ const BlogPage = lazy(() => import('@/pages/BlogPage'));
const BlogPostPage = lazy(() => import('@/pages/BlogPostPage')); const BlogPostPage = lazy(() => import('@/pages/BlogPostPage'));
const DevelopersPage = lazy(() => import('@/pages/DevelopersPage')); const DevelopersPage = lazy(() => import('@/pages/DevelopersPage'));
const InternalAdminPage = lazy(() => import('@/pages/InternalAdminPage')); const InternalAdminPage = lazy(() => import('@/pages/InternalAdminPage'));
const SeoProgrammaticPage = lazy(() => import('@/pages/SeoProgrammaticPage'));
const SeoCollectionPage = lazy(() => import('@/pages/SeoCollectionPage'));
// Tool Pages // Tool Pages
const PdfToWord = lazy(() => import('@/components/tools/PdfToWord')); const PdfToWord = lazy(() => import('@/components/tools/PdfToWord'));
@@ -117,6 +119,15 @@ export default function App() {
<Route path="/blog/:slug" element={<BlogPostPage />} /> <Route path="/blog/:slug" element={<BlogPostPage />} />
<Route path="/developers" element={<DevelopersPage />} /> <Route path="/developers" element={<DevelopersPage />} />
<Route path="/internal/admin" element={<InternalAdminPage />} /> <Route path="/internal/admin" element={<InternalAdminPage />} />
<Route path="/pdf-to-word" element={<SeoProgrammaticPage slug="pdf-to-word" />} />
<Route path="/word-to-pdf" element={<SeoProgrammaticPage slug="word-to-pdf" />} />
<Route path="/compress-pdf-online" element={<SeoProgrammaticPage slug="compress-pdf-online" />} />
<Route path="/convert-jpg-to-pdf" element={<SeoProgrammaticPage slug="convert-jpg-to-pdf" />} />
<Route path="/merge-pdf-files" element={<SeoProgrammaticPage slug="merge-pdf-files" />} />
<Route path="/remove-pdf-password" element={<SeoProgrammaticPage slug="remove-pdf-password" />} />
<Route path="/best-pdf-tools" element={<SeoCollectionPage slug="best-pdf-tools" />} />
<Route path="/free-pdf-tools-online" element={<SeoCollectionPage slug="free-pdf-tools-online" />} />
<Route path="/convert-files-online" element={<SeoCollectionPage slug="convert-files-online" />} />
{/* PDF Tools */} {/* PDF Tools */}
<Route path="/tools/pdf-to-word" element={<ToolLandingPage slug="pdf-to-word"><PdfToWord /></ToolLandingPage>} /> <Route path="/tools/pdf-to-word" element={<ToolLandingPage slug="pdf-to-word"><PdfToWord /></ToolLandingPage>} />

View File

@@ -31,6 +31,11 @@ const FOOTER_TOOLS = {
{ slug: 'video-to-gif', label: 'Video to GIF' }, { slug: 'video-to-gif', label: 'Video to GIF' },
{ slug: 'word-counter', label: 'Word Counter' }, { slug: 'word-counter', label: 'Word Counter' },
], ],
Guides: [
{ slug: 'best-pdf-tools', label: 'Best PDF Tools', isLanding: true },
{ slug: 'free-pdf-tools-online', label: 'Free PDF Tools Online', isLanding: true },
{ slug: 'convert-files-online', label: 'Convert Files Online', isLanding: true },
],
}; };
export default function Footer() { export default function Footer() {
@@ -50,7 +55,7 @@ export default function Footer() {
{tools.map((tool) => ( {tools.map((tool) => (
<li key={tool.slug}> <li key={tool.slug}>
<Link <Link
to={`/tools/${tool.slug}`} to={(tool as { slug: string; isLanding?: boolean }).isLanding ? `/${tool.slug}` : `/tools/${tool.slug}`}
className="text-sm text-slate-500 transition-colors hover:text-primary-600 dark:text-slate-400 dark:hover:text-primary-400" className="text-sm text-slate-500 transition-colors hover:text-primary-600 dark:text-slate-400 dark:hover:text-primary-400"
> >
{tool.label} {tool.label}

View File

@@ -9,6 +9,8 @@ interface SEOHeadProps {
title: string; title: string;
/** Meta description */ /** Meta description */
description: string; description: string;
/** Optional keywords meta tag */
keywords?: string;
/** Canonical URL path (e.g. "/about") — origin is auto-prefixed */ /** Canonical URL path (e.g. "/about") — origin is auto-prefixed */
path: string; path: string;
/** OG type — defaults to "website" */ /** OG type — defaults to "website" */
@@ -20,11 +22,12 @@ interface SEOHeadProps {
/** /**
* Reusable SEO head component that injects: * Reusable SEO head component that injects:
* - title, description, canonical URL * - title, description, canonical URL
* - optional keywords meta tag
* - OpenGraph meta tags (title, description, url, type, site_name, locale) * - OpenGraph meta tags (title, description, url, type, site_name, locale)
* - Twitter card meta tags * - Twitter card meta tags
* - Optional JSON-LD structured data * - Optional JSON-LD structured data
*/ */
export default function SEOHead({ title, description, path, type = 'website', jsonLd }: SEOHeadProps) { export default function SEOHead({ title, description, keywords, path, type = 'website', jsonLd }: SEOHeadProps) {
const { i18n } = useTranslation(); const { i18n } = useTranslation();
const origin = getSiteOrigin(typeof window !== 'undefined' ? window.location.origin : ''); const origin = getSiteOrigin(typeof window !== 'undefined' ? window.location.origin : '');
const canonicalUrl = `${origin}${path}`; const canonicalUrl = `${origin}${path}`;
@@ -39,6 +42,7 @@ export default function SEOHead({ title, description, path, type = 'website', js
<Helmet> <Helmet>
<title>{fullTitle}</title> <title>{fullTitle}</title>
<meta name="description" content={description} /> <meta name="description" content={description} />
{keywords ? <meta name="keywords" content={keywords} /> : null}
<link rel="canonical" href={canonicalUrl} /> <link rel="canonical" href={canonicalUrl} />
{languageAlternates.map((alternate) => ( {languageAlternates.map((alternate) => (
<link <link

View File

@@ -2,7 +2,7 @@ import { Helmet } from 'react-helmet-async';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { CheckCircle } from 'lucide-react'; import { CheckCircle } from 'lucide-react';
import { getToolSEO } from '@/config/seoData'; import { getToolSEO } from '@/config/seoData';
import { buildLanguageAlternates, buildSocialImageUrl, generateToolSchema, generateBreadcrumbs, generateFAQ, getOgLocale, getSiteOrigin } from '@/utils/seo'; import { buildLanguageAlternates, buildSocialImageUrl, generateToolSchema, generateBreadcrumbs, generateFAQ, generateHowTo, getOgLocale, getSiteOrigin } from '@/utils/seo';
import FAQSection from './FAQSection'; import FAQSection from './FAQSection';
import RelatedTools from './RelatedTools'; import RelatedTools from './RelatedTools';
import ToolRating from '@/components/shared/ToolRating'; import ToolRating from '@/components/shared/ToolRating';
@@ -60,6 +60,15 @@ export default function ToolLandingPage({ slug, children }: ToolLandingPageProps
]); ]);
const faqSchema = seo.faqs.length > 0 ? generateFAQ(seo.faqs) : null; const faqSchema = seo.faqs.length > 0 ? generateFAQ(seo.faqs) : null;
const howToSteps = t(`seo.${seo.i18nKey}.howToUse`, { returnObjects: true }) as string[];
const howToSchema = Array.isArray(howToSteps) && howToSteps.length > 0
? generateHowTo({
name: toolTitle,
description: seo.metaDescription,
steps: howToSteps,
url: canonicalUrl,
})
: null;
return ( return (
<> <>
@@ -105,6 +114,9 @@ export default function ToolLandingPage({ slug, children }: ToolLandingPageProps
{faqSchema && ( {faqSchema && (
<script type="application/ld+json">{JSON.stringify(faqSchema)}</script> <script type="application/ld+json">{JSON.stringify(faqSchema)}</script>
)} )}
{howToSchema && (
<script type="application/ld+json">{JSON.stringify(howToSchema)}</script>
)}
</Helmet> </Helmet>
{/* Tool Interface */} {/* Tool Interface */}

View File

@@ -21,6 +21,15 @@ export const PAGE_ROUTES = [
'/blog/:slug', '/blog/:slug',
'/developers', '/developers',
'/internal/admin', '/internal/admin',
'/pdf-to-word',
'/word-to-pdf',
'/compress-pdf-online',
'/convert-jpg-to-pdf',
'/merge-pdf-files',
'/remove-pdf-password',
'/best-pdf-tools',
'/free-pdf-tools-online',
'/convert-files-online',
] as const; ] as const;
// ─── Tool routes ───────────────────────────────────────────────── // ─── Tool routes ─────────────────────────────────────────────────

View File

@@ -0,0 +1,411 @@
{
"toolPages": [
{
"slug": "pdf-to-word",
"toolSlug": "pdf-to-word",
"category": "PDF",
"focusKeyword": {
"en": "pdf to word",
"ar": "تحويل PDF إلى Word"
},
"supportingKeywords": {
"en": ["pdf to docx", "convert pdf to word online", "editable word from pdf"],
"ar": ["تحويل pdf الى word", "تحويل pdf إلى docx", "تحويل ملف pdf الى وورد"]
},
"titleTemplate": {
"en": "{{focusKeyword}} converter online free | {{brand}}",
"ar": "{{focusKeyword}} أونلاين مجاناً | {{brand}}"
},
"descriptionTemplate": {
"en": "Use {{brand}} to convert PDF files to editable Word documents online with secure processing, no signup, and fast downloads.",
"ar": "استخدم {{brand}} لتحويل ملفات PDF إلى مستندات Word قابلة للتعديل أونلاين مع معالجة آمنة وبدون تسجيل وتنزيل سريع."
},
"faqTemplates": [
{
"question": {
"en": "Can I keep the original layout when converting PDF to Word?",
"ar": "هل يمكن الحفاظ على التخطيط الأصلي عند تحويل PDF إلى Word؟"
},
"answer": {
"en": "Yes. Dociva is designed to preserve text flow, page structure, headings, tables, and images as closely as possible during conversion.",
"ar": "نعم. تم تصميم Dociva للحفاظ على تدفق النص وبنية الصفحة والعناوين والجداول والصور بأكبر قدر ممكن أثناء التحويل."
}
},
{
"question": {
"en": "Is this page suitable for scanned PDFs?",
"ar": "هل هذه الصفحة مناسبة لملفات PDF الممسوحة ضوئياً؟"
},
"answer": {
"en": "For scanned files, start with OCR when text is image-based, then continue with the PDF to Word workflow for editable output.",
"ar": "بالنسبة للملفات الممسوحة ضوئياً، ابدأ بأداة OCR عندما يكون النص داخل صورة، ثم أكمل سير عمل PDF إلى Word للحصول على ناتج قابل للتحرير."
}
}
],
"relatedCollectionSlugs": ["best-pdf-tools", "convert-files-online"]
},
{
"slug": "word-to-pdf",
"toolSlug": "word-to-pdf",
"category": "Convert",
"focusKeyword": {
"en": "word to pdf",
"ar": "تحويل Word إلى PDF"
},
"supportingKeywords": {
"en": ["docx to pdf", "convert word to pdf online", "save word as pdf"],
"ar": ["تحويل وورد إلى pdf", "docx إلى pdf", "تحويل ملف word إلى pdf"]
},
"titleTemplate": {
"en": "{{focusKeyword}} converter online free | {{brand}}",
"ar": "{{focusKeyword}} أونلاين مجاناً | {{brand}}"
},
"descriptionTemplate": {
"en": "Turn DOC and DOCX files into clean PDF documents online with preserved formatting, secure uploads, and no registration.",
"ar": "حوّل ملفات DOC وDOCX إلى مستندات PDF نظيفة أونلاين مع الحفاظ على التنسيق ورفع آمن وبدون تسجيل."
},
"faqTemplates": [
{
"question": {
"en": "Will tables and images stay intact in the final PDF?",
"ar": "هل ستبقى الجداول والصور سليمة في ملف PDF النهائي؟"
},
"answer": {
"en": "Yes. This workflow is intended for print-ready output, so layout elements such as tables, images, and headers are preserved.",
"ar": "نعم. هذا المسار مخصص لمخرجات جاهزة للطباعة، لذلك يتم الحفاظ على عناصر التخطيط مثل الجداول والصور والرؤوس."
}
},
{
"question": {
"en": "When should I use Word to PDF instead of PDF to Word?",
"ar": "متى أستخدم Word إلى PDF بدلاً من PDF إلى Word؟"
},
"answer": {
"en": "Use Word to PDF when you want a fixed, shareable version of a document. Use PDF to Word when you need to edit an existing PDF.",
"ar": "استخدم Word إلى PDF عندما تريد نسخة ثابتة وقابلة للمشاركة من المستند. واستخدم PDF إلى Word عندما تحتاج إلى تعديل ملف PDF موجود."
}
}
],
"relatedCollectionSlugs": ["best-pdf-tools", "convert-files-online"]
},
{
"slug": "compress-pdf-online",
"toolSlug": "compress-pdf",
"category": "PDF",
"focusKeyword": {
"en": "compress pdf online",
"ar": "ضغط PDF أونلاين"
},
"supportingKeywords": {
"en": ["reduce pdf size", "make pdf smaller", "shrink pdf for email"],
"ar": ["تقليل حجم pdf", "تصغير ملف pdf", "ضغط pdf للايميل"]
},
"titleTemplate": {
"en": "{{focusKeyword}} free without signup | {{brand}}",
"ar": "{{focusKeyword}} مجاناً بدون تسجيل | {{brand}}"
},
"descriptionTemplate": {
"en": "Reduce PDF file size for email, uploads, and mobile sharing using fast online compression with balanced quality controls.",
"ar": "قلّل حجم ملف PDF للبريد الإلكتروني والرفع والمشاركة عبر الجوال باستخدام ضغط سريع أونلاين مع توازن مناسب بين الجودة والحجم."
},
"faqTemplates": [
{
"question": {
"en": "Is online PDF compression safe for work files?",
"ar": "هل ضغط PDF أونلاين آمن لملفات العمل؟"
},
"answer": {
"en": "Yes. Files are processed securely and removed automatically after a short retention window, which keeps the workflow practical for business use.",
"ar": "نعم. تتم معالجة الملفات بشكل آمن ويتم حذفها تلقائياً بعد فترة احتفاظ قصيرة، مما يجعل هذا المسار عملياً لملفات العمل."
}
},
{
"question": {
"en": "What if my PDF still feels too large after compression?",
"ar": "ماذا لو بقي ملف PDF كبيراً بعد الضغط؟"
},
"answer": {
"en": "Try a stronger compression level, remove unnecessary metadata, or split large documents into focused sections before sharing.",
"ar": "جرّب مستوى ضغط أقوى أو احذف البيانات الوصفية غير الضرورية أو قسّم المستندات الكبيرة إلى أقسام مركزة قبل مشاركتها."
}
}
],
"relatedCollectionSlugs": ["best-pdf-tools", "free-pdf-tools-online"]
},
{
"slug": "convert-jpg-to-pdf",
"toolSlug": "images-to-pdf",
"category": "PDF",
"focusKeyword": {
"en": "convert jpg to pdf",
"ar": "تحويل JPG إلى PDF"
},
"supportingKeywords": {
"en": ["jpg to pdf online", "image to pdf", "combine photos into pdf"],
"ar": ["تحويل jpg الى pdf", "صورة إلى pdf", "دمج صور في pdf"]
},
"titleTemplate": {
"en": "{{focusKeyword}} online free | {{brand}}",
"ar": "{{focusKeyword}} أونلاين مجاناً | {{brand}}"
},
"descriptionTemplate": {
"en": "Turn JPG images into shareable PDF files online, arrange page order, and download one clean document without installing software.",
"ar": "حوّل صور JPG إلى ملفات PDF قابلة للمشاركة أونلاين، ورتّب الصفحات، وحمّل مستنداً نظيفاً واحداً بدون تثبيت أي برنامج."
},
"faqTemplates": [
{
"question": {
"en": "Can I combine multiple JPG files into one PDF?",
"ar": "هل يمكنني دمج عدة ملفات JPG في PDF واحد؟"
},
"answer": {
"en": "Yes. Upload multiple images, set the order you want, and the output will be a single PDF document.",
"ar": "نعم. ارفع عدة صور وحدد الترتيب الذي تريده وسيكون الناتج مستند PDF واحداً."
}
},
{
"question": {
"en": "Is this useful for receipts, scans, and application documents?",
"ar": "هل هذا مفيد للإيصالات والمسوح الضوئية ومستندات التقديم؟"
},
"answer": {
"en": "Yes. This is a common workflow for receipts, IDs, signed pages, and image-based submissions that need one portable PDF file.",
"ar": "نعم. هذا مسار شائع للإيصالات وبطاقات الهوية والصفحات الموقعة والملفات المعتمدة على الصور التي تحتاج إلى ملف PDF واحد قابل للنقل."
}
}
],
"relatedCollectionSlugs": ["free-pdf-tools-online", "convert-files-online"]
},
{
"slug": "merge-pdf-files",
"toolSlug": "merge-pdf",
"category": "PDF",
"focusKeyword": {
"en": "merge pdf files",
"ar": "دمج ملفات PDF"
},
"supportingKeywords": {
"en": ["combine pdf files", "join pdf online", "merge pdf documents"],
"ar": ["دمج pdf", "جمع ملفات pdf", "دمج مستندات pdf"]
},
"titleTemplate": {
"en": "{{focusKeyword}} online free | {{brand}}",
"ar": "{{focusKeyword}} أونلاين مجاناً | {{brand}}"
},
"descriptionTemplate": {
"en": "Combine multiple PDF documents into one file online, reorder uploads before merging, and keep the original page quality intact.",
"ar": "ادمج عدة مستندات PDF في ملف واحد أونلاين، وأعد ترتيب الملفات قبل الدمج، مع الحفاظ على جودة الصفحات الأصلية."
},
"faqTemplates": [
{
"question": {
"en": "Can I reorder PDFs before I merge them?",
"ar": "هل يمكنني إعادة ترتيب ملفات PDF قبل الدمج؟"
},
"answer": {
"en": "Yes. Reordering uploads before merging is part of the intended workflow so the final file matches the sequence you need.",
"ar": "نعم. إعادة ترتيب الملفات قبل الدمج جزء من سير العمل المقصود حتى يطابق الملف النهائي التسلسل الذي تحتاجه."
}
},
{
"question": {
"en": "Should I compress files before merging?",
"ar": "هل يجب أن أضغط الملفات قبل دمجها؟"
},
"answer": {
"en": "If the final document may become large, compressing individual PDFs first often keeps the merged file easier to share and upload.",
"ar": "إذا كان من المحتمل أن يصبح المستند النهائي كبيراً، فإن ضغط ملفات PDF الفردية أولاً يجعل الملف المدمج أسهل في المشاركة والرفع غالباً."
}
}
],
"relatedCollectionSlugs": ["best-pdf-tools", "free-pdf-tools-online"]
},
{
"slug": "remove-pdf-password",
"toolSlug": "unlock-pdf",
"category": "PDF",
"focusKeyword": {
"en": "remove pdf password",
"ar": "إزالة كلمة مرور PDF"
},
"supportingKeywords": {
"en": ["unlock pdf", "remove pdf protection", "open locked pdf"],
"ar": ["فتح قفل pdf", "إزالة حماية pdf", "إلغاء كلمة سر pdf"]
},
"titleTemplate": {
"en": "{{focusKeyword}} online with known password | {{brand}}",
"ar": "{{focusKeyword}} أونلاين بكلمة المرور المعروفة | {{brand}}"
},
"descriptionTemplate": {
"en": "Unlock protected PDF files online when you know the current password, then continue editing, printing, merging, or sharing more easily.",
"ar": "افتح ملفات PDF المحمية أونلاين عندما تعرف كلمة المرور الحالية، ثم أكمل التعديل أو الطباعة أو الدمج أو المشاركة بسهولة أكبر."
},
"faqTemplates": [
{
"question": {
"en": "Do I need the current password to remove PDF protection?",
"ar": "هل أحتاج إلى كلمة المرور الحالية لإزالة حماية PDF؟"
},
"answer": {
"en": "Yes. This workflow is meant for documents you are authorized to open already, so the current password is required.",
"ar": "نعم. هذا المسار مخصص للمستندات التي لديك صلاحية فتحها بالفعل، لذلك تكون كلمة المرور الحالية مطلوبة."
}
},
{
"question": {
"en": "What should I do after unlocking a PDF?",
"ar": "ماذا أفعل بعد فتح قفل ملف PDF؟"
},
"answer": {
"en": "After unlocking, you can move into editing, compression, merging, or format conversion depending on the task you need next.",
"ar": "بعد فتح القفل يمكنك الانتقال إلى التعديل أو الضغط أو الدمج أو التحويل حسب المهمة التي تحتاجها بعد ذلك."
}
}
],
"relatedCollectionSlugs": ["best-pdf-tools", "free-pdf-tools-online"]
}
],
"collectionPages": [
{
"slug": "best-pdf-tools",
"focusKeyword": {
"en": "best pdf tools",
"ar": "أفضل أدوات PDF"
},
"supportingKeywords": {
"en": ["online pdf toolkit", "top pdf tools", "pdf workflow tools"],
"ar": ["أفضل أدوات pdf أونلاين", "مجموعة أدوات pdf", "أدوات التعامل مع pdf"]
},
"titleTemplate": {
"en": "{{focusKeyword}} for everyday document work | {{brand}}",
"ar": "{{focusKeyword}} لأعمال المستندات اليومية | {{brand}}"
},
"descriptionTemplate": {
"en": "Discover the most useful PDF workflows in one place, including conversion, compression, merging, page extraction, and security tasks.",
"ar": "اكتشف أكثر مسارات PDF فائدة في مكان واحد، بما يشمل التحويل والضغط والدمج واستخراج الصفحات والمهام الأمنية."
},
"introTemplate": {
"en": "This page groups high-utility PDF workflows for teams, students, operations, and support staff who need fast results without switching between multiple products.",
"ar": "تجمع هذه الصفحة مسارات PDF عالية الفائدة للفرق والطلاب وفرق العمليات والدعم الذين يحتاجون إلى نتائج سريعة بدون التنقل بين عدة منتجات."
},
"targetToolSlugs": ["pdf-to-word", "word-to-pdf", "compress-pdf", "merge-pdf", "split-pdf", "unlock-pdf"],
"faqTemplates": [
{
"question": {
"en": "What are the most commonly used PDF tools?",
"ar": "ما أكثر أدوات PDF استخداماً؟"
},
"answer": {
"en": "Conversion, compression, merging, splitting, and unlocking are the most frequent everyday PDF tasks for business and personal workflows.",
"ar": "التحويل والضغط والدمج والتقسيم وفتح القفل هي أكثر مهام PDF اليومية شيوعاً في سير العمل الشخصي والعملي."
}
},
{
"question": {
"en": "How should I choose the right PDF tool for a task?",
"ar": "كيف أختار أداة PDF المناسبة للمهمة؟"
},
"answer": {
"en": "Start with the outcome you need: edit content, reduce file size, combine files, extract pages, or remove restrictions. Then open the matching workflow.",
"ar": "ابدأ بالنتيجة التي تحتاجها: تعديل المحتوى أو تقليل حجم الملف أو دمج الملفات أو استخراج الصفحات أو إزالة القيود. ثم افتح المسار المطابق."
}
}
],
"relatedCollectionSlugs": ["free-pdf-tools-online", "convert-files-online"]
},
{
"slug": "free-pdf-tools-online",
"focusKeyword": {
"en": "free pdf tools online",
"ar": "أدوات PDF مجانية أونلاين"
},
"supportingKeywords": {
"en": ["free online pdf editor", "free pdf converter", "browser pdf tools"],
"ar": ["أدوات pdf مجانية", "محرر pdf مجاني", "محول pdf مجاني"]
},
"titleTemplate": {
"en": "{{focusKeyword}} with no signup required | {{brand}}",
"ar": "{{focusKeyword}} بدون تسجيل | {{brand}}"
},
"descriptionTemplate": {
"en": "Browse free browser-based PDF tools for compression, conversion, page management, and security workflows without installing desktop software.",
"ar": "تصفح أدوات PDF مجانية تعمل من المتصفح للضغط والتحويل وإدارة الصفحات والمهام الأمنية بدون تثبيت برامج سطح مكتب."
},
"introTemplate": {
"en": "If you need practical document work inside the browser, this collection covers the common tasks people search for before uploading, emailing, printing, or archiving PDFs.",
"ar": "إذا كنت تحتاج إلى إنجاز أعمال المستندات داخل المتصفح، فهذه المجموعة تغطي المهام الشائعة التي يبحث عنها المستخدمون قبل رفع ملفات PDF أو إرسالها أو طباعتها أو أرشفتها."
},
"targetToolSlugs": ["compress-pdf", "merge-pdf", "split-pdf", "unlock-pdf", "protect-pdf", "pdf-editor"],
"faqTemplates": [
{
"question": {
"en": "Are free PDF tools enough for professional work?",
"ar": "هل أدوات PDF المجانية كافية للعمل الاحترافي؟"
},
"answer": {
"en": "For many daily workflows, yes. Teams often need fast conversion, smaller file sizes, and simple page operations before they need heavier document systems.",
"ar": "نعم في كثير من المهام اليومية. تحتاج الفرق غالباً إلى تحويل سريع وأحجام ملفات أصغر وعمليات صفحات بسيطة قبل أن تحتاج إلى أنظمة مستندات أثقل."
}
},
{
"question": {
"en": "Which PDF tool should I open first?",
"ar": "ما أول أداة PDF يجب أن أفتحها؟"
},
"answer": {
"en": "Open the workflow that matches the outcome: compress for size, merge for package assembly, split for extraction, unlock for permissions, and editor for cleanup.",
"ar": "افتح المسار الذي يطابق النتيجة: الضغط للحجم، والدمج لتجميع الملفات، والتقسيم للاستخراج، وفتح القفل للصلاحيات، والمحرر للتنظيف."
}
}
],
"relatedCollectionSlugs": ["best-pdf-tools", "convert-files-online"]
},
{
"slug": "convert-files-online",
"focusKeyword": {
"en": "convert files online",
"ar": "تحويل الملفات أونلاين"
},
"supportingKeywords": {
"en": ["online file converter", "document converter", "image and pdf converter"],
"ar": ["محول ملفات أونلاين", "تحويل مستندات أونلاين", "تحويل الصور وpdf"]
},
"titleTemplate": {
"en": "{{focusKeyword}} across PDF, image, and office formats | {{brand}}",
"ar": "{{focusKeyword}} عبر PDF والصور وملفات المكتب | {{brand}}"
},
"descriptionTemplate": {
"en": "Explore file conversion workflows for PDF, Word, Excel, HTML, images, and video in one searchable landing page.",
"ar": "استكشف مسارات تحويل الملفات لملفات PDF وWord وExcel وHTML والصور والفيديو في صفحة واحدة قابلة للبحث."
},
"introTemplate": {
"en": "Conversion traffic is broad, so this page groups the workflows people use most when they need one format changed into another quickly from the browser.",
"ar": "ترافيك التحويل واسع، لذلك تجمع هذه الصفحة المسارات الأكثر استخداماً عندما يحتاج المستخدم إلى تغيير صيغة ملف إلى أخرى بسرعة من المتصفح."
},
"targetToolSlugs": ["pdf-to-word", "word-to-pdf", "images-to-pdf", "image-converter", "html-to-pdf", "video-to-gif"],
"faqTemplates": [
{
"question": {
"en": "What kinds of files can I convert online here?",
"ar": "ما أنواع الملفات التي يمكنني تحويلها أونلاين هنا؟"
},
"answer": {
"en": "This collection covers PDF, Office files, images, HTML, and video-to-GIF workflows, with each tool focused on a specific conversion path.",
"ar": "تغطي هذه المجموعة ملفات PDF وملفات أوفيس والصور وHTML ومسارات تحويل الفيديو إلى GIF، مع تركيز كل أداة على مسار تحويل محدد."
}
},
{
"question": {
"en": "Should I use a direct converter or a multi-step workflow?",
"ar": "هل أستخدم محولاً مباشراً أم مساراً متعدد الخطوات؟"
},
"answer": {
"en": "Use a direct converter first. If the source is scanned or image-heavy, pair OCR, compression, or formatting tools to improve the final output.",
"ar": "استخدم محولاً مباشراً أولاً. وإذا كان المصدر ممسوحاً ضوئياً أو غنياً بالصور، فاجمع بين OCR أو الضغط أو أدوات التنسيق لتحسين النتيجة النهائية."
}
}
],
"relatedCollectionSlugs": ["best-pdf-tools", "free-pdf-tools-online"]
}
]
}

View File

@@ -0,0 +1,88 @@
import seoToolsConfig from '@/config/seo-tools.json';
export type SeoLocale = 'en' | 'ar';
export interface LocalizedText {
en: string;
ar: string;
}
export interface LocalizedTextList {
en: string[];
ar: string[];
}
export interface SeoFaqTemplate {
question: LocalizedText;
answer: LocalizedText;
}
export interface ProgrammaticToolPage {
slug: string;
toolSlug: string;
category: 'PDF' | 'Image' | 'AI' | 'Convert' | 'Utility';
focusKeyword: LocalizedText;
supportingKeywords: LocalizedTextList;
titleTemplate: LocalizedText;
descriptionTemplate: LocalizedText;
faqTemplates: SeoFaqTemplate[];
relatedCollectionSlugs: string[];
}
export interface SeoCollectionPage {
slug: string;
focusKeyword: LocalizedText;
supportingKeywords: LocalizedTextList;
titleTemplate: LocalizedText;
descriptionTemplate: LocalizedText;
introTemplate: LocalizedText;
targetToolSlugs: string[];
faqTemplates: SeoFaqTemplate[];
relatedCollectionSlugs: string[];
}
interface SeoToolsConfig {
toolPages: ProgrammaticToolPage[];
collectionPages: SeoCollectionPage[];
}
const config = seoToolsConfig as SeoToolsConfig;
export const PROGRAMMATIC_TOOL_PAGES = config.toolPages;
export const SEO_COLLECTION_PAGES = config.collectionPages;
export function normalizeSeoLocale(language: string): SeoLocale {
return language.toLowerCase().startsWith('ar') ? 'ar' : 'en';
}
export function getLocalizedText(value: LocalizedText, locale: SeoLocale): string {
return value[locale] || value.en;
}
export function getLocalizedTextList(value: LocalizedTextList, locale: SeoLocale): string[] {
return value[locale] || value.en;
}
export function interpolateTemplate(template: string, tokens: Record<string, string>): string {
return template.replace(/{{\s*([a-zA-Z0-9_]+)\s*}}/g, (_, key: string) => tokens[key] ?? '');
}
export function getProgrammaticToolPage(slug: string): ProgrammaticToolPage | undefined {
return PROGRAMMATIC_TOOL_PAGES.find((page) => page.slug === slug);
}
export function getSeoCollectionPage(slug: string): SeoCollectionPage | undefined {
return SEO_COLLECTION_PAGES.find((page) => page.slug === slug);
}
export function getAllProgrammaticSeoPaths(): string[] {
return PROGRAMMATIC_TOOL_PAGES.map((page) => `/${page.slug}`);
}
export function getAllCollectionSeoPaths(): string[] {
return SEO_COLLECTION_PAGES.map((page) => `/${page.slug}`);
}
export function getAllSeoLandingPaths(): string[] {
return [...getAllProgrammaticSeoPaths(), ...getAllCollectionSeoPaths()];
}

View File

@@ -0,0 +1,201 @@
import { Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { ArrowRight, FolderKanban, Link2 } from 'lucide-react';
import SEOHead from '@/components/seo/SEOHead';
import FAQSection from '@/components/seo/FAQSection';
import {
getLocalizedText,
getLocalizedTextList,
getSeoCollectionPage,
interpolateTemplate,
normalizeSeoLocale,
} from '@/config/seoPages';
import { getToolSEO } from '@/config/seoData';
import { generateBreadcrumbs, generateFAQ, generateWebPage, getSiteOrigin } from '@/utils/seo';
import NotFoundPage from '@/pages/NotFoundPage';
interface SeoCollectionPageProps {
slug: string;
}
const COPY = {
en: {
toolsHeading: 'Popular tools in this collection',
selectionHeading: 'How to choose the right workflow',
relatedHeading: 'Related landing pages',
openTool: 'Open tool',
chooseBullets: [
'Pick a conversion workflow when the format itself needs to change.',
'Pick a PDF workflow when you need to compress, merge, split, or secure a file.',
'Use the shortest path first, then add OCR or cleanup only if the source file needs it.',
],
breadcrumbLabel: 'Collections',
},
ar: {
toolsHeading: 'أدوات شائعة داخل هذه المجموعة',
selectionHeading: 'كيف تختار سير العمل المناسب',
relatedHeading: 'صفحات هبوط ذات صلة',
openTool: 'افتح الأداة',
chooseBullets: [
'اختر مسار تحويل عندما تحتاج إلى تغيير الصيغة نفسها.',
'اختر مسار PDF عندما تحتاج إلى الضغط أو الدمج أو التقسيم أو الحماية.',
'ابدأ بأقصر مسار مباشر، ثم أضف OCR أو التنظيف فقط إذا احتاج الملف المصدر إلى ذلك.',
],
breadcrumbLabel: 'المجموعات',
},
} as const;
export default function SeoCollectionPage({ slug }: SeoCollectionPageProps) {
const { t, i18n } = useTranslation();
const locale = normalizeSeoLocale(i18n.language);
const copy = COPY[locale];
const page = getSeoCollectionPage(slug);
if (!page) {
return <NotFoundPage />;
}
const focusKeyword = getLocalizedText(page.focusKeyword, locale);
const tokens = {
brand: 'Dociva',
focusKeyword,
};
const title = interpolateTemplate(getLocalizedText(page.titleTemplate, locale), tokens);
const description = interpolateTemplate(getLocalizedText(page.descriptionTemplate, locale), tokens);
const intro = interpolateTemplate(getLocalizedText(page.introTemplate, locale), tokens);
const keywords = [focusKeyword, ...getLocalizedTextList(page.supportingKeywords, locale)].join(', ');
const path = `/${page.slug}`;
const siteOrigin = getSiteOrigin(typeof window !== 'undefined' ? window.location.origin : '');
const url = `${siteOrigin}${path}`;
const faqItems = page.faqTemplates.map((item) => ({
question: getLocalizedText(item.question, locale),
answer: getLocalizedText(item.answer, locale),
}));
const relatedCollections = page.relatedCollectionSlugs
.map((collectionSlug) => getSeoCollectionPage(collectionSlug))
.filter((entry): entry is NonNullable<typeof entry> => Boolean(entry));
const jsonLd = [
generateWebPage({
name: title,
description,
url,
}),
generateBreadcrumbs([
{ name: t('common.home'), url: siteOrigin },
{ name: copy.breadcrumbLabel, url: siteOrigin },
{ name: title, url },
]),
generateFAQ(faqItems),
];
return (
<>
<SEOHead title={title} description={description} path={path} keywords={keywords} jsonLd={jsonLd} />
<div className="mx-auto max-w-6xl space-y-10">
<section className="rounded-[2rem] border border-slate-200 bg-white p-8 shadow-sm dark:border-slate-700 dark:bg-slate-900/70 sm:p-10">
<div className="flex items-center gap-4">
<div className="flex h-14 w-14 items-center justify-center rounded-2xl bg-primary-100 text-primary-700 dark:bg-primary-900/30 dark:text-primary-300">
<FolderKanban className="h-7 w-7" />
</div>
<div>
<p className="text-sm font-semibold uppercase tracking-[0.2em] text-primary-600 dark:text-primary-400">
{focusKeyword}
</p>
<h1 className="text-3xl font-bold tracking-tight text-slate-900 dark:text-white sm:text-4xl">
{title}
</h1>
</div>
</div>
<p className="mt-6 max-w-4xl text-lg leading-8 text-slate-600 dark:text-slate-400">
{description}
</p>
<p className="mt-4 max-w-4xl leading-8 text-slate-700 dark:text-slate-300">
{intro}
</p>
</section>
<section className="rounded-2xl border border-slate-200 bg-white p-7 shadow-sm dark:border-slate-700 dark:bg-slate-900/70">
<h2 className="text-2xl font-semibold text-slate-900 dark:text-white">
{copy.toolsHeading}
</h2>
<div className="mt-5 grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{page.targetToolSlugs.map((toolSlug) => {
const tool = getToolSEO(toolSlug);
if (!tool) {
return null;
}
return (
<Link
key={toolSlug}
to={`/tools/${toolSlug}`}
className="rounded-2xl border border-slate-200 p-5 transition-colors hover:border-primary-300 hover:bg-slate-50 dark:border-slate-700 dark:hover:border-primary-600 dark:hover:bg-slate-800"
>
<p className="text-sm font-medium uppercase tracking-wide text-primary-600 dark:text-primary-400">
{tool.category}
</p>
<h3 className="mt-2 text-lg font-semibold text-slate-900 dark:text-white">
{t(`tools.${tool.i18nKey}.title`)}
</h3>
<p className="mt-2 text-sm leading-6 text-slate-600 dark:text-slate-400">
{t(`tools.${tool.i18nKey}.shortDesc`)}
</p>
<span className="mt-4 inline-flex items-center gap-2 text-sm font-medium text-primary-600 dark:text-primary-400">
{copy.openTool}
<ArrowRight className="h-4 w-4" />
</span>
</Link>
);
})}
</div>
</section>
<section className="rounded-2xl border border-slate-200 bg-white p-7 shadow-sm dark:border-slate-700 dark:bg-slate-900/70">
<h2 className="text-2xl font-semibold text-slate-900 dark:text-white">
{copy.selectionHeading}
</h2>
<ul className="mt-5 space-y-3">
{copy.chooseBullets.map((item) => (
<li key={item} className="rounded-xl bg-slate-50 p-4 text-sm leading-6 text-slate-700 dark:bg-slate-800 dark:text-slate-300">
{item}
</li>
))}
</ul>
</section>
<section className="rounded-2xl border border-slate-200 bg-white p-7 shadow-sm dark:border-slate-700 dark:bg-slate-900/70">
<h2 className="text-2xl font-semibold text-slate-900 dark:text-white">
{copy.relatedHeading}
</h2>
<div className="mt-5 grid gap-4 md:grid-cols-2">
{relatedCollections.map((collection) => {
const collectionTitle = interpolateTemplate(getLocalizedText(collection.titleTemplate, locale), {
brand: 'Dociva',
focusKeyword: getLocalizedText(collection.focusKeyword, locale),
});
return (
<Link
key={collection.slug}
to={`/${collection.slug}`}
className="rounded-2xl border border-slate-200 p-5 transition-colors hover:border-primary-300 hover:bg-slate-50 dark:border-slate-700 dark:hover:border-primary-600 dark:hover:bg-slate-800"
>
<div className="flex items-center gap-2 text-primary-600 dark:text-primary-400">
<Link2 className="h-4 w-4" />
<span className="text-sm font-medium">/{collection.slug}</span>
</div>
<p className="mt-3 font-semibold text-slate-900 dark:text-white">{collectionTitle}</p>
</Link>
);
})}
</div>
</section>
<FAQSection faqs={faqItems} />
</div>
</>
);
}

View File

@@ -0,0 +1,275 @@
import { Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { ArrowRight, CheckCircle, FileText, Link2 } from 'lucide-react';
import SEOHead from '@/components/seo/SEOHead';
import FAQSection from '@/components/seo/FAQSection';
import SuggestedTools from '@/components/seo/SuggestedTools';
import {
getLocalizedText,
getLocalizedTextList,
getProgrammaticToolPage,
getSeoCollectionPage,
interpolateTemplate,
normalizeSeoLocale,
} from '@/config/seoPages';
import { getToolSEO } from '@/config/seoData';
import {
generateBreadcrumbs,
generateFAQ,
generateHowTo,
generateToolSchema,
generateWebPage,
getSiteOrigin,
} from '@/utils/seo';
import NotFoundPage from '@/pages/NotFoundPage';
interface SeoProgrammaticPageProps {
slug: string;
}
const COPY = {
en: {
cta: 'Open the tool',
introHeading: 'What this page helps you do',
workflowHeading: 'Recommended workflow',
useCasesHeading: 'When this workflow fits best',
relatedHeading: 'Related guides',
supportHeading: 'Built for fast bilingual workflows',
supportBody:
'Dociva supports English and Arabic user flows, which makes these landing pages usable for both local and international search traffic.',
stepsName: 'How to use this workflow',
breadcrumbLabel: 'Guides',
popularTools: 'Popular tools',
},
ar: {
cta: 'افتح الأداة',
introHeading: 'ما الذي تساعدك عليه هذه الصفحة',
workflowHeading: 'سير العمل المقترح',
useCasesHeading: 'متى يكون هذا المسار مناسباً',
relatedHeading: 'صفحات ذات صلة',
supportHeading: 'مصممة لسير عمل ثنائي اللغة بسرعة',
supportBody:
'يدعم Dociva سير العمل بالإنجليزية والعربية، مما يجعل صفحات الهبوط هذه قابلة للاستخدام مع الترافيك المحلي والدولي معاً.',
stepsName: 'كيفية استخدام هذا المسار',
breadcrumbLabel: 'الأدلة',
popularTools: 'أدوات شائعة',
},
} as const;
export default function SeoProgrammaticPage({ slug }: SeoProgrammaticPageProps) {
const { t, i18n } = useTranslation();
const locale = normalizeSeoLocale(i18n.language);
const copy = COPY[locale];
const page = getProgrammaticToolPage(slug);
if (!page) {
return <NotFoundPage />;
}
const tool = getToolSEO(page.toolSlug);
if (!tool) {
return <NotFoundPage />;
}
const toolTitle = t(`tools.${tool.i18nKey}.title`);
const toolDescription = t(`tools.${tool.i18nKey}.description`);
const steps = t(`seo.${tool.i18nKey}.howToUse`, { returnObjects: true }) as string[];
const benefits = t(`seo.${tool.i18nKey}.benefits`, { returnObjects: true }) as string[];
const useCases = t(`seo.${tool.i18nKey}.useCases`, { returnObjects: true }) as string[];
const focusKeyword = getLocalizedText(page.focusKeyword, locale);
const keywords = [focusKeyword, ...getLocalizedTextList(page.supportingKeywords, locale)].join(', ');
const tokens = {
brand: 'Dociva',
focusKeyword,
};
const title = interpolateTemplate(getLocalizedText(page.titleTemplate, locale), tokens);
const description = interpolateTemplate(getLocalizedText(page.descriptionTemplate, locale), tokens);
const path = `/${page.slug}`;
const siteOrigin = getSiteOrigin(typeof window !== 'undefined' ? window.location.origin : '');
const url = `${siteOrigin}${path}`;
const faqItems = page.faqTemplates.map((item) => ({
question: getLocalizedText(item.question, locale),
answer: getLocalizedText(item.answer, locale),
}));
const relatedCollections = page.relatedCollectionSlugs
.map((collectionSlug) => getSeoCollectionPage(collectionSlug))
.filter((entry): entry is NonNullable<typeof entry> => Boolean(entry));
const introBody = `${toolDescription} ${description}`;
const workflowBody = `${t(`seo.${tool.i18nKey}.whatItDoes`)} ${t(`tools.${tool.i18nKey}.shortDesc`)}`;
const fallbackBenefits = tool.features;
const resolvedBenefits = Array.isArray(benefits) && benefits.length > 0 ? benefits : fallbackBenefits;
const resolvedUseCases = Array.isArray(useCases) && useCases.length > 0 ? useCases : tool.relatedSlugs.map((relatedSlug) => {
const relatedTool = getToolSEO(relatedSlug);
return relatedTool ? t(`tools.${relatedTool.i18nKey}.title`) : relatedSlug;
});
const jsonLd = [
generateWebPage({
name: title,
description,
url,
}),
generateToolSchema({
name: toolTitle,
description,
url,
category: tool.category === 'PDF' ? 'UtilitiesApplication' : 'WebApplication',
}),
generateBreadcrumbs([
{ name: t('common.home'), url: siteOrigin },
{ name: copy.breadcrumbLabel, url: siteOrigin },
{ name: title, url },
]),
generateHowTo({
name: copy.stepsName,
description,
steps: Array.isArray(steps) ? steps : [],
url,
}),
generateFAQ(faqItems),
];
return (
<>
<SEOHead title={title} description={description} path={path} keywords={keywords} jsonLd={jsonLd} />
<div className="mx-auto max-w-6xl space-y-12">
<section className="rounded-[2rem] border border-slate-200 bg-white p-8 shadow-sm dark:border-slate-700 dark:bg-slate-900/70 sm:p-10">
<div className="grid gap-8 lg:grid-cols-[minmax(0,1fr)_320px] lg:items-start">
<div>
<p className="mb-3 text-sm font-semibold uppercase tracking-[0.2em] text-primary-600 dark:text-primary-400">
{focusKeyword}
</p>
<h1 className="text-3xl font-bold tracking-tight text-slate-900 dark:text-white sm:text-4xl">
{title}
</h1>
<p className="mt-4 max-w-3xl text-lg leading-8 text-slate-600 dark:text-slate-400">
{description}
</p>
<div className="mt-6 flex flex-wrap gap-3">
<Link
to={`/tools/${page.toolSlug}`}
className="inline-flex items-center gap-2 rounded-xl bg-primary-600 px-5 py-3 text-sm font-semibold text-white transition-colors hover:bg-primary-700"
>
{copy.cta}
<ArrowRight className="h-4 w-4" />
</Link>
<span className="inline-flex items-center rounded-xl border border-slate-200 px-4 py-3 text-sm text-slate-600 dark:border-slate-700 dark:text-slate-300">
{toolTitle}
</span>
</div>
</div>
<div className="rounded-2xl border border-slate-200 bg-slate-50 p-6 dark:border-slate-700 dark:bg-slate-800/70">
<div className="flex items-center gap-3">
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-primary-100 text-primary-700 dark:bg-primary-900/30 dark:text-primary-300">
<FileText className="h-6 w-6" />
</div>
<div>
<p className="text-sm font-medium text-slate-500 dark:text-slate-400">{copy.popularTools}</p>
<p className="text-base font-semibold text-slate-900 dark:text-white">{toolTitle}</p>
</div>
</div>
<p className="mt-4 text-sm leading-6 text-slate-600 dark:text-slate-400">
{toolDescription}
</p>
<ul className="mt-4 space-y-2 text-sm text-slate-700 dark:text-slate-300">
{resolvedBenefits.slice(0, 4).map((item) => (
<li key={item} className="flex items-start gap-2">
<CheckCircle className="mt-0.5 h-4 w-4 shrink-0 text-green-500" />
<span>{item}</span>
</li>
))}
</ul>
</div>
</div>
</section>
<section className="grid gap-8 lg:grid-cols-2">
<div className="rounded-2xl border border-slate-200 bg-white p-7 shadow-sm dark:border-slate-700 dark:bg-slate-900/70">
<h2 className="text-2xl font-semibold text-slate-900 dark:text-white">
{copy.introHeading}
</h2>
<p className="mt-4 leading-8 text-slate-700 dark:text-slate-300">
{introBody}
</p>
</div>
<div className="rounded-2xl border border-slate-200 bg-white p-7 shadow-sm dark:border-slate-700 dark:bg-slate-900/70">
<h2 className="text-2xl font-semibold text-slate-900 dark:text-white">
{copy.workflowHeading}
</h2>
<p className="mt-4 leading-8 text-slate-700 dark:text-slate-300">
{workflowBody}
</p>
<ol className="mt-5 list-decimal space-y-2 pl-5 text-slate-700 dark:text-slate-300">
{(Array.isArray(steps) ? steps : []).map((step) => (
<li key={step}>{step}</li>
))}
</ol>
</div>
</section>
<section className="rounded-2xl border border-slate-200 bg-white p-7 shadow-sm dark:border-slate-700 dark:bg-slate-900/70">
<h2 className="text-2xl font-semibold text-slate-900 dark:text-white">
{copy.useCasesHeading}
</h2>
<div className="mt-5 grid gap-3 sm:grid-cols-2">
{resolvedUseCases.slice(0, 6).map((item) => (
<div key={item} className="rounded-xl bg-slate-50 p-4 text-sm leading-6 text-slate-700 dark:bg-slate-800 dark:text-slate-300">
{item}
</div>
))}
</div>
</section>
<section className="rounded-2xl border border-slate-200 bg-white p-7 shadow-sm dark:border-slate-700 dark:bg-slate-900/70">
<h2 className="text-2xl font-semibold text-slate-900 dark:text-white">
{copy.relatedHeading}
</h2>
<div className="mt-5 grid gap-4 md:grid-cols-2">
{relatedCollections.map((collection) => {
const collectionTitle = interpolateTemplate(getLocalizedText(collection.titleTemplate, locale), {
brand: 'Dociva',
focusKeyword: getLocalizedText(collection.focusKeyword, locale),
});
return (
<Link
key={collection.slug}
to={`/${collection.slug}`}
className="rounded-2xl border border-slate-200 p-5 transition-colors hover:border-primary-300 hover:bg-slate-50 dark:border-slate-700 dark:hover:border-primary-600 dark:hover:bg-slate-800"
>
<div className="flex items-center gap-2 text-primary-600 dark:text-primary-400">
<Link2 className="h-4 w-4" />
<span className="text-sm font-medium">/{collection.slug}</span>
</div>
<p className="mt-3 font-semibold text-slate-900 dark:text-white">{collectionTitle}</p>
<p className="mt-2 text-sm leading-6 text-slate-600 dark:text-slate-400">
{interpolateTemplate(getLocalizedText(collection.descriptionTemplate, locale), {
brand: 'Dociva',
focusKeyword: getLocalizedText(collection.focusKeyword, locale),
})}
</p>
</Link>
);
})}
</div>
</section>
<section className="rounded-2xl border border-slate-200 bg-white p-7 shadow-sm dark:border-slate-700 dark:bg-slate-900/70">
<h2 className="text-2xl font-semibold text-slate-900 dark:text-white">
{copy.supportHeading}
</h2>
<p className="mt-4 leading-8 text-slate-700 dark:text-slate-300">{copy.supportBody}</p>
</section>
<FAQSection faqs={faqItems} />
<SuggestedTools currentSlug={page.toolSlug} limit={4} />
</div>
</>
);
}

View File

@@ -2,6 +2,7 @@ import axios, { type InternalAxiosRequestConfig } from 'axios';
const CSRF_COOKIE_NAME = 'csrf_token'; const CSRF_COOKIE_NAME = 'csrf_token';
const CSRF_HEADER_NAME = 'X-CSRF-Token'; const CSRF_HEADER_NAME = 'X-CSRF-Token';
let csrfRefreshPromise: Promise<string> | null = null;
function getCookieValue(name: string): string { function getCookieValue(name: string): string {
@@ -47,6 +48,57 @@ function setRequestHeader(config: InternalAxiosRequestConfig, key: string, value
} }
async function ensureCsrfToken(forceRefresh = false): Promise<string> {
const existingToken = getCookieValue(CSRF_COOKIE_NAME);
if (existingToken && !forceRefresh) {
return existingToken;
}
if (!csrfRefreshPromise) {
csrfRefreshPromise = csrfBootstrapClient
.get('/auth/csrf')
.then(() => getCookieValue(CSRF_COOKIE_NAME))
.finally(() => {
csrfRefreshPromise = null;
});
}
return csrfRefreshPromise;
}
function isCsrfFailure(status: number, bodyText: string): boolean {
if (status !== 403) {
return false;
}
const normalizedBody = bodyText.toLowerCase();
return normalizedBody.includes('csrf');
}
async function postAssistantStream(
payload: AssistantChatRequest,
csrfToken: string
): Promise<Response> {
const streamHeaders: Record<string, string> = {
'Content-Type': 'application/json',
Accept: 'text/event-stream',
};
if (csrfToken) {
streamHeaders[CSRF_HEADER_NAME] = csrfToken;
}
return fetch('/api/assistant/chat/stream', {
method: 'POST',
credentials: 'include',
headers: streamHeaders,
body: JSON.stringify(payload),
});
}
const csrfBootstrapClient = axios.create({ const csrfBootstrapClient = axios.create({
baseURL: '/api', baseURL: '/api',
timeout: 15000, timeout: 15000,
@@ -72,11 +124,7 @@ api.interceptors.request.use(
return config; return config;
} }
let csrfToken = getCookieValue(CSRF_COOKIE_NAME); const csrfToken = await ensureCsrfToken();
if (!csrfToken) {
await csrfBootstrapClient.get('/auth/csrf');
csrfToken = getCookieValue(CSRF_COOKIE_NAME);
}
if (csrfToken) { if (csrfToken) {
setRequestHeader(config, CSRF_HEADER_NAME, csrfToken); setRequestHeader(config, CSRF_HEADER_NAME, csrfToken);
@@ -346,6 +394,7 @@ export async function startTask(endpoint: string): Promise<TaskResponse> {
*/ */
export async function registerUser(email: string, password: string): Promise<AuthUser> { export async function registerUser(email: string, password: string): Promise<AuthUser> {
const response = await api.post<AuthResponse>('/auth/register', { email, password }); const response = await api.post<AuthResponse>('/auth/register', { email, password });
await ensureCsrfToken(true);
return response.data.user; return response.data.user;
} }
@@ -354,6 +403,7 @@ export async function registerUser(email: string, password: string): Promise<Aut
*/ */
export async function loginUser(email: string, password: string): Promise<AuthUser> { export async function loginUser(email: string, password: string): Promise<AuthUser> {
const response = await api.post<AuthResponse>('/auth/login', { email, password }); const response = await api.post<AuthResponse>('/auth/login', { email, password });
await ensureCsrfToken(true);
return response.data.user; return response.data.user;
} }
@@ -362,6 +412,7 @@ export async function loginUser(email: string, password: string): Promise<AuthUs
*/ */
export async function logoutUser(): Promise<void> { export async function logoutUser(): Promise<void> {
await api.post('/auth/logout'); await api.post('/auth/logout');
await ensureCsrfToken(true);
} }
/** /**
@@ -412,32 +463,21 @@ export async function streamAssistantChat(
payload: AssistantChatRequest, payload: AssistantChatRequest,
handlers: AssistantStreamHandlers = {} handlers: AssistantStreamHandlers = {}
): Promise<AssistantChatResponse> { ): Promise<AssistantChatResponse> {
// Ensure a CSRF token cookie exists before streaming let response = await postAssistantStream(payload, await ensureCsrfToken());
let csrfToken = getCookieValue(CSRF_COOKIE_NAME);
if (!csrfToken) {
await csrfBootstrapClient.get('/auth/csrf');
csrfToken = getCookieValue(CSRF_COOKIE_NAME);
}
const streamHeaders: Record<string, string> = {
'Content-Type': 'application/json',
Accept: 'text/event-stream',
};
if (csrfToken) {
streamHeaders[CSRF_HEADER_NAME] = csrfToken;
}
const response = await fetch('/api/assistant/chat/stream', {
method: 'POST',
credentials: 'include',
headers: streamHeaders,
body: JSON.stringify(payload),
});
if (!response.ok) { if (!response.ok) {
const bodyText = await response.text(); let bodyText = await response.text();
if (isCsrfFailure(response.status, bodyText)) {
response = await postAssistantStream(payload, await ensureCsrfToken(true));
if (!response.ok) {
bodyText = await response.text();
throw normalizeStreamError(response.status, bodyText); throw normalizeStreamError(response.status, bodyText);
} }
} else {
throw normalizeStreamError(response.status, bodyText);
}
}
if (!response.body) { if (!response.body) {
throw new Error('Streaming is not supported by this browser.'); throw new Error('Streaming is not supported by this browser.');

View File

@@ -133,6 +133,27 @@ export function generateFAQ(
}; };
} }
export function generateHowTo(data: {
name: string;
description: string;
steps: string[];
url: string;
}): object {
return {
'@context': 'https://schema.org',
'@type': 'HowTo',
name: data.name,
description: data.description,
url: data.url,
step: data.steps.map((text, index) => ({
'@type': 'HowToStep',
position: index + 1,
name: text,
text,
})),
};
}
/** /**
* Generate Organization JSON-LD for the site. * Generate Organization JSON-LD for the site.
*/ */

View File

@@ -4,6 +4,7 @@
"useDefineForClassFields": true, "useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"], "lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext", "module": "ESNext",
"resolveJsonModule": true,
"skipLibCheck": true, "skipLibCheck": true,
"moduleResolution": "bundler", "moduleResolution": "bundler",
"allowImportingTsExtensions": true, "allowImportingTsExtensions": true,

View File

@@ -10,6 +10,7 @@ Usage:
""" """
import argparse import argparse
import json
import os import os
import re import re
from datetime import datetime from datetime import datetime
@@ -97,6 +98,19 @@ TOOL_GROUPS = [
] ]
def get_seo_landing_paths() -> tuple[list[str], list[str]]:
repo_root = Path(__file__).resolve().parents[1]
seo_config_path = repo_root / 'frontend' / 'src' / 'config' / 'seo-tools.json'
if not seo_config_path.exists():
return [], []
raw = json.loads(seo_config_path.read_text(encoding='utf-8'))
tool_pages = [entry.get('slug', '').strip() for entry in raw.get('toolPages', []) if entry.get('slug')]
collection_pages = [entry.get('slug', '').strip() for entry in raw.get('collectionPages', []) if entry.get('slug')]
return tool_pages, collection_pages
def get_blog_slugs() -> list[str]: def get_blog_slugs() -> list[str]:
repo_root = Path(__file__).resolve().parents[1] repo_root = Path(__file__).resolve().parents[1]
blog_articles_path = repo_root / 'frontend' / 'src' / 'content' / 'blogArticles.ts' blog_articles_path = repo_root / 'frontend' / 'src' / 'content' / 'blogArticles.ts'
@@ -112,6 +126,7 @@ def generate_sitemap(domain: str) -> str:
today = datetime.now().strftime('%Y-%m-%d') today = datetime.now().strftime('%Y-%m-%d')
urls = [] urls = []
blog_slugs = get_blog_slugs() blog_slugs = get_blog_slugs()
seo_tool_pages, seo_collection_pages = get_seo_landing_paths()
# Static pages # Static pages
for page in PAGES: for page in PAGES:
@@ -143,6 +158,26 @@ def generate_sitemap(domain: str) -> str:
<priority>{route["priority"]}</priority> <priority>{route["priority"]}</priority>
</url>''') </url>''')
if seo_tool_pages:
urls.append('\n <!-- Programmatic SEO Tool Pages -->')
for slug in seo_tool_pages:
urls.append(f''' <url>
<loc>{domain}/{slug}</loc>
<lastmod>{today}</lastmod>
<changefreq>weekly</changefreq>
<priority>0.88</priority>
</url>''')
if seo_collection_pages:
urls.append('\n <!-- SEO Collection Pages -->')
for slug in seo_collection_pages:
urls.append(f''' <url>
<loc>{domain}/{slug}</loc>
<lastmod>{today}</lastmod>
<changefreq>weekly</changefreq>
<priority>0.82</priority>
</url>''')
sitemap = f'''<?xml version="1.0" encoding="UTF-8"?> sitemap = f'''<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"> <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
{chr(10).join(urls)} {chr(10).join(urls)}
@@ -167,7 +202,14 @@ def main():
with open(args.output, 'w', encoding='utf-8') as f: with open(args.output, 'w', encoding='utf-8') as f:
f.write(sitemap) f.write(sitemap)
total = len(PAGES) + len(get_blog_slugs()) + sum(len(routes) for _, routes in TOOL_GROUPS) seo_tool_pages, seo_collection_pages = get_seo_landing_paths()
total = (
len(PAGES)
+ len(get_blog_slugs())
+ sum(len(routes) for _, routes in TOOL_GROUPS)
+ len(seo_tool_pages)
+ len(seo_collection_pages)
)
print(f"Sitemap generated: {args.output}") print(f"Sitemap generated: {args.output}")
print(f"Total URLs: {total}") print(f"Total URLs: {total}")