diff --git a/frontend/package.json b/frontend/package.json index f59d7ec..97eb10a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -5,10 +5,12 @@ "type": "module", "scripts": { "dev": "vite", + "prebuild": "node scripts/generate-seo-assets.mjs", "build": "tsc --noEmit && vite build", "preview": "vite preview", "lint": "eslint .", - "test": "vitest run" + "test": "vitest run", + "seo:generate": "node scripts/generate-seo-assets.mjs" }, "dependencies": { "axios": "^1.7.0", diff --git a/frontend/public/robots.txt b/frontend/public/robots.txt index dd33691..344117a 100644 --- a/frontend/public/robots.txt +++ b/frontend/public/robots.txt @@ -2,11 +2,12 @@ User-agent: * Allow: / Disallow: /api/ +Disallow: /internal/ Disallow: /account Disallow: /forgot-password Disallow: /reset-password +Disallow: /internal/admin -# Sitemaps Sitemap: https://dociva.io/sitemap.xml # AI/LLM discoverability diff --git a/frontend/public/sitemap.xml b/frontend/public/sitemap.xml index c03f37d..4cb365a 100644 --- a/frontend/public/sitemap.xml +++ b/frontend/public/sitemap.xml @@ -2,342 +2,392 @@ https://dociva.io/ - 2026-03-17 + 2026-03-20 daily 1.0 https://dociva.io/about - 2026-03-17 + 2026-03-20 monthly 0.4 https://dociva.io/contact - 2026-03-17 + 2026-03-20 monthly 0.4 https://dociva.io/privacy - 2026-03-17 + 2026-03-20 yearly 0.3 https://dociva.io/terms - 2026-03-17 + 2026-03-20 yearly 0.3 https://dociva.io/pricing - 2026-03-17 + 2026-03-20 monthly 0.7 https://dociva.io/blog - 2026-03-17 + 2026-03-20 weekly 0.6 - - + + https://dociva.io/developers + 2026-03-20 + monthly + 0.5 + https://dociva.io/blog/how-to-compress-pdf-online - 2026-03-17 + 2026-03-20 monthly 0.6 https://dociva.io/blog/convert-images-without-losing-quality - 2026-03-17 + 2026-03-20 monthly 0.6 https://dociva.io/blog/ocr-extract-text-from-images - 2026-03-17 + 2026-03-20 monthly 0.6 https://dociva.io/blog/merge-split-pdf-files - 2026-03-17 + 2026-03-20 monthly 0.6 https://dociva.io/blog/ai-chat-with-pdf-documents - 2026-03-17 + 2026-03-20 monthly 0.6 - - https://dociva.io/tools/pdf-to-word - 2026-03-17 + 2026-03-20 weekly 0.9 https://dociva.io/tools/word-to-pdf - 2026-03-17 + 2026-03-20 weekly 0.9 https://dociva.io/tools/compress-pdf - 2026-03-17 + 2026-03-20 weekly 0.9 https://dociva.io/tools/merge-pdf - 2026-03-17 + 2026-03-20 weekly 0.9 https://dociva.io/tools/split-pdf - 2026-03-17 + 2026-03-20 weekly 0.8 https://dociva.io/tools/rotate-pdf - 2026-03-17 + 2026-03-20 weekly 0.7 https://dociva.io/tools/pdf-to-images - 2026-03-17 + 2026-03-20 weekly 0.8 https://dociva.io/tools/images-to-pdf - 2026-03-17 + 2026-03-20 weekly 0.8 https://dociva.io/tools/watermark-pdf - 2026-03-17 + 2026-03-20 weekly 0.7 https://dociva.io/tools/remove-watermark-pdf - 2026-03-17 + 2026-03-20 weekly 0.7 https://dociva.io/tools/protect-pdf - 2026-03-17 + 2026-03-20 weekly 0.8 https://dociva.io/tools/unlock-pdf - 2026-03-17 + 2026-03-20 weekly 0.8 https://dociva.io/tools/page-numbers - 2026-03-17 + 2026-03-20 weekly 0.7 https://dociva.io/tools/reorder-pdf - 2026-03-17 + 2026-03-20 weekly 0.7 https://dociva.io/tools/extract-pages - 2026-03-17 + 2026-03-20 weekly 0.7 https://dociva.io/tools/pdf-editor - 2026-03-17 + 2026-03-20 weekly 0.8 https://dociva.io/tools/pdf-flowchart - 2026-03-17 + 2026-03-20 weekly 0.7 https://dociva.io/tools/pdf-to-excel - 2026-03-17 + 2026-03-20 weekly 0.8 https://dociva.io/tools/sign-pdf - 2026-03-17 + 2026-03-20 weekly 0.8 https://dociva.io/tools/crop-pdf - 2026-03-17 + 2026-03-20 weekly 0.7 https://dociva.io/tools/flatten-pdf - 2026-03-17 + 2026-03-20 weekly 0.7 https://dociva.io/tools/repair-pdf - 2026-03-17 + 2026-03-20 weekly 0.7 https://dociva.io/tools/pdf-metadata - 2026-03-17 + 2026-03-20 weekly 0.6 - - https://dociva.io/tools/image-converter - 2026-03-17 + 2026-03-20 weekly 0.8 https://dociva.io/tools/image-resize - 2026-03-17 + 2026-03-20 weekly 0.8 https://dociva.io/tools/compress-image - 2026-03-17 + 2026-03-20 weekly 0.8 https://dociva.io/tools/remove-background - 2026-03-17 + 2026-03-20 weekly 0.8 https://dociva.io/tools/image-crop - 2026-03-17 + 2026-03-20 weekly 0.7 https://dociva.io/tools/image-rotate-flip - 2026-03-17 + 2026-03-20 weekly 0.7 - - https://dociva.io/tools/ocr - 2026-03-17 + 2026-03-20 weekly 0.8 https://dociva.io/tools/chat-pdf - 2026-03-17 + 2026-03-20 weekly 0.8 https://dociva.io/tools/summarize-pdf - 2026-03-17 + 2026-03-20 weekly 0.8 https://dociva.io/tools/translate-pdf - 2026-03-17 + 2026-03-20 weekly 0.8 https://dociva.io/tools/extract-tables - 2026-03-17 + 2026-03-20 weekly 0.8 - - https://dociva.io/tools/html-to-pdf - 2026-03-17 + 2026-03-20 weekly 0.7 https://dociva.io/tools/qr-code - 2026-03-17 + 2026-03-20 weekly 0.7 https://dociva.io/tools/video-to-gif - 2026-03-17 + 2026-03-20 weekly 0.7 https://dociva.io/tools/word-counter - 2026-03-17 + 2026-03-20 weekly 0.6 https://dociva.io/tools/text-cleaner - 2026-03-17 + 2026-03-20 weekly 0.6 https://dociva.io/tools/pdf-to-pptx - 2026-03-17 + 2026-03-20 weekly 0.8 https://dociva.io/tools/excel-to-pdf - 2026-03-17 + 2026-03-20 weekly 0.8 https://dociva.io/tools/pptx-to-pdf - 2026-03-17 + 2026-03-20 weekly 0.8 https://dociva.io/tools/barcode-generator - 2026-03-17 + 2026-03-20 weekly 0.7 - \ No newline at end of file + + https://dociva.io/pdf-to-word + 2026-03-20 + weekly + 0.88 + + + https://dociva.io/word-to-pdf + 2026-03-20 + weekly + 0.88 + + + https://dociva.io/compress-pdf-online + 2026-03-20 + weekly + 0.88 + + + https://dociva.io/convert-jpg-to-pdf + 2026-03-20 + weekly + 0.88 + + + https://dociva.io/merge-pdf-files + 2026-03-20 + weekly + 0.88 + + + https://dociva.io/remove-pdf-password + 2026-03-20 + weekly + 0.88 + + + https://dociva.io/best-pdf-tools + 2026-03-20 + weekly + 0.82 + + + https://dociva.io/free-pdf-tools-online + 2026-03-20 + weekly + 0.82 + + + https://dociva.io/convert-files-online + 2026-03-20 + weekly + 0.82 + + diff --git a/frontend/scripts/generate-seo-assets.mjs b/frontend/scripts/generate-seo-assets.mjs new file mode 100644 index 0000000..e636170 --- /dev/null +++ b/frontend/scripts/generate-seo-assets.mjs @@ -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 ` \n ${loc}\n ${today}\n ${changefreq}\n ${priority}\n `; +} + +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 = `\n\n${sitemapEntries.join('\n')}\n\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}`); \ No newline at end of file diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 37511e6..630f3ba 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -25,6 +25,8 @@ const BlogPage = lazy(() => import('@/pages/BlogPage')); const BlogPostPage = lazy(() => import('@/pages/BlogPostPage')); const DevelopersPage = lazy(() => import('@/pages/DevelopersPage')); const InternalAdminPage = lazy(() => import('@/pages/InternalAdminPage')); +const SeoProgrammaticPage = lazy(() => import('@/pages/SeoProgrammaticPage')); +const SeoCollectionPage = lazy(() => import('@/pages/SeoCollectionPage')); // Tool Pages const PdfToWord = lazy(() => import('@/components/tools/PdfToWord')); @@ -117,6 +119,15 @@ export default function App() { } /> } /> } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> {/* PDF Tools */} } /> diff --git a/frontend/src/components/layout/Footer.tsx b/frontend/src/components/layout/Footer.tsx index 72d9948..4ec5e7c 100644 --- a/frontend/src/components/layout/Footer.tsx +++ b/frontend/src/components/layout/Footer.tsx @@ -31,6 +31,11 @@ const FOOTER_TOOLS = { { slug: 'video-to-gif', label: 'Video to GIF' }, { 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() { @@ -50,7 +55,7 @@ export default function Footer() { {tools.map((tool) => (
  • {tool.label} diff --git a/frontend/src/components/seo/SEOHead.tsx b/frontend/src/components/seo/SEOHead.tsx index 489a62c..7964161 100644 --- a/frontend/src/components/seo/SEOHead.tsx +++ b/frontend/src/components/seo/SEOHead.tsx @@ -9,6 +9,8 @@ interface SEOHeadProps { title: string; /** Meta description */ description: string; + /** Optional keywords meta tag */ + keywords?: string; /** Canonical URL path (e.g. "/about") — origin is auto-prefixed */ path: string; /** OG type — defaults to "website" */ @@ -20,11 +22,12 @@ interface SEOHeadProps { /** * Reusable SEO head component that injects: * - title, description, canonical URL + * - optional keywords meta tag * - OpenGraph meta tags (title, description, url, type, site_name, locale) * - Twitter card meta tags * - 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 origin = getSiteOrigin(typeof window !== 'undefined' ? window.location.origin : ''); const canonicalUrl = `${origin}${path}`; @@ -39,6 +42,7 @@ export default function SEOHead({ title, description, path, type = 'website', js {fullTitle} + {keywords ? : null} {languageAlternates.map((alternate) => ( 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 ( <> @@ -105,6 +114,9 @@ export default function ToolLandingPage({ slug, children }: ToolLandingPageProps {faqSchema && ( )} + {howToSchema && ( + + )} {/* Tool Interface */} diff --git a/frontend/src/config/routes.ts b/frontend/src/config/routes.ts index 093a665..ca9b3c9 100644 --- a/frontend/src/config/routes.ts +++ b/frontend/src/config/routes.ts @@ -21,6 +21,15 @@ export const PAGE_ROUTES = [ '/blog/:slug', '/developers', '/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; // ─── Tool routes ───────────────────────────────────────────────── diff --git a/frontend/src/config/seo-tools.json b/frontend/src/config/seo-tools.json new file mode 100644 index 0000000..ebf4ebf --- /dev/null +++ b/frontend/src/config/seo-tools.json @@ -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"] + } + ] +} \ No newline at end of file diff --git a/frontend/src/config/seoPages.ts b/frontend/src/config/seoPages.ts new file mode 100644 index 0000000..7154e01 --- /dev/null +++ b/frontend/src/config/seoPages.ts @@ -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 { + 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()]; +} \ No newline at end of file diff --git a/frontend/src/pages/SeoCollectionPage.tsx b/frontend/src/pages/SeoCollectionPage.tsx new file mode 100644 index 0000000..bcc80ca --- /dev/null +++ b/frontend/src/pages/SeoCollectionPage.tsx @@ -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 ; + } + + 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 => 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 ( + <> + + +
    +
    +
    +
    + +
    +
    +

    + {focusKeyword} +

    +

    + {title} +

    +
    +
    + +

    + {description} +

    +

    + {intro} +

    +
    + +
    +

    + {copy.toolsHeading} +

    +
    + {page.targetToolSlugs.map((toolSlug) => { + const tool = getToolSEO(toolSlug); + if (!tool) { + return null; + } + + return ( + +

    + {tool.category} +

    +

    + {t(`tools.${tool.i18nKey}.title`)} +

    +

    + {t(`tools.${tool.i18nKey}.shortDesc`)} +

    + + {copy.openTool} + + + + ); + })} +
    +
    + +
    +

    + {copy.selectionHeading} +

    +
      + {copy.chooseBullets.map((item) => ( +
    • + {item} +
    • + ))} +
    +
    + +
    +

    + {copy.relatedHeading} +

    +
    + {relatedCollections.map((collection) => { + const collectionTitle = interpolateTemplate(getLocalizedText(collection.titleTemplate, locale), { + brand: 'Dociva', + focusKeyword: getLocalizedText(collection.focusKeyword, locale), + }); + + return ( + +
    + + /{collection.slug} +
    +

    {collectionTitle}

    + + ); + })} +
    +
    + + +
    + + ); +} \ No newline at end of file diff --git a/frontend/src/pages/SeoProgrammaticPage.tsx b/frontend/src/pages/SeoProgrammaticPage.tsx new file mode 100644 index 0000000..308e9f8 --- /dev/null +++ b/frontend/src/pages/SeoProgrammaticPage.tsx @@ -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 ; + } + + const tool = getToolSEO(page.toolSlug); + if (!tool) { + return ; + } + + 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 => 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 ( + <> + + +
    +
    +
    +
    +

    + {focusKeyword} +

    +

    + {title} +

    +

    + {description} +

    + +
    + + {copy.cta} + + + + {toolTitle} + +
    +
    + +
    +
    +
    + +
    +
    +

    {copy.popularTools}

    +

    {toolTitle}

    +
    +
    +

    + {toolDescription} +

    +
      + {resolvedBenefits.slice(0, 4).map((item) => ( +
    • + + {item} +
    • + ))} +
    +
    +
    +
    + +
    +
    +

    + {copy.introHeading} +

    +

    + {introBody} +

    +
    + +
    +

    + {copy.workflowHeading} +

    +

    + {workflowBody} +

    +
      + {(Array.isArray(steps) ? steps : []).map((step) => ( +
    1. {step}
    2. + ))} +
    +
    +
    + +
    +

    + {copy.useCasesHeading} +

    +
    + {resolvedUseCases.slice(0, 6).map((item) => ( +
    + {item} +
    + ))} +
    +
    + +
    +

    + {copy.relatedHeading} +

    +
    + {relatedCollections.map((collection) => { + const collectionTitle = interpolateTemplate(getLocalizedText(collection.titleTemplate, locale), { + brand: 'Dociva', + focusKeyword: getLocalizedText(collection.focusKeyword, locale), + }); + + return ( + +
    + + /{collection.slug} +
    +

    {collectionTitle}

    +

    + {interpolateTemplate(getLocalizedText(collection.descriptionTemplate, locale), { + brand: 'Dociva', + focusKeyword: getLocalizedText(collection.focusKeyword, locale), + })} +

    + + ); + })} +
    +
    + +
    +

    + {copy.supportHeading} +

    +

    {copy.supportBody}

    +
    + + + +
    + + ); +} \ No newline at end of file diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 35ba4d1..160674c 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -2,6 +2,7 @@ import axios, { type InternalAxiosRequestConfig } from 'axios'; const CSRF_COOKIE_NAME = 'csrf_token'; const CSRF_HEADER_NAME = 'X-CSRF-Token'; +let csrfRefreshPromise: Promise | null = null; function getCookieValue(name: string): string { @@ -47,6 +48,57 @@ function setRequestHeader(config: InternalAxiosRequestConfig, key: string, value } +async function ensureCsrfToken(forceRefresh = false): Promise { + 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 { + const streamHeaders: Record = { + '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({ baseURL: '/api', timeout: 15000, @@ -72,11 +124,7 @@ api.interceptors.request.use( return config; } - let csrfToken = getCookieValue(CSRF_COOKIE_NAME); - if (!csrfToken) { - await csrfBootstrapClient.get('/auth/csrf'); - csrfToken = getCookieValue(CSRF_COOKIE_NAME); - } + const csrfToken = await ensureCsrfToken(); if (csrfToken) { setRequestHeader(config, CSRF_HEADER_NAME, csrfToken); @@ -346,6 +394,7 @@ export async function startTask(endpoint: string): Promise { */ export async function registerUser(email: string, password: string): Promise { const response = await api.post('/auth/register', { email, password }); + await ensureCsrfToken(true); return response.data.user; } @@ -354,6 +403,7 @@ export async function registerUser(email: string, password: string): Promise { const response = await api.post('/auth/login', { email, password }); + await ensureCsrfToken(true); return response.data.user; } @@ -362,6 +412,7 @@ export async function loginUser(email: string, password: string): Promise { await api.post('/auth/logout'); + await ensureCsrfToken(true); } /** @@ -412,31 +463,20 @@ export async function streamAssistantChat( payload: AssistantChatRequest, handlers: AssistantStreamHandlers = {} ): Promise { - // Ensure a CSRF token cookie exists before streaming - let csrfToken = getCookieValue(CSRF_COOKIE_NAME); - if (!csrfToken) { - await csrfBootstrapClient.get('/auth/csrf'); - csrfToken = getCookieValue(CSRF_COOKIE_NAME); - } - - const streamHeaders: Record = { - '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), - }); + let response = await postAssistantStream(payload, await ensureCsrfToken()); if (!response.ok) { - const bodyText = await response.text(); - throw normalizeStreamError(response.status, bodyText); + 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); + } + } else { + throw normalizeStreamError(response.status, bodyText); + } } if (!response.body) { diff --git a/frontend/src/utils/seo.ts b/frontend/src/utils/seo.ts index f0c82e3..72e8a29 100644 --- a/frontend/src/utils/seo.ts +++ b/frontend/src/utils/seo.ts @@ -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. */ diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json index b27dd5a..9e057c2 100644 --- a/frontend/tsconfig.json +++ b/frontend/tsconfig.json @@ -4,6 +4,7 @@ "useDefineForClassFields": true, "lib": ["ES2020", "DOM", "DOM.Iterable"], "module": "ESNext", + "resolveJsonModule": true, "skipLibCheck": true, "moduleResolution": "bundler", "allowImportingTsExtensions": true, diff --git a/scripts/generate_sitemap.py b/scripts/generate_sitemap.py index bbcbdde..81953cb 100644 --- a/scripts/generate_sitemap.py +++ b/scripts/generate_sitemap.py @@ -10,6 +10,7 @@ Usage: """ import argparse +import json import os import re 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]: repo_root = Path(__file__).resolve().parents[1] 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') urls = [] blog_slugs = get_blog_slugs() + seo_tool_pages, seo_collection_pages = get_seo_landing_paths() # Static pages for page in PAGES: @@ -143,6 +158,26 @@ def generate_sitemap(domain: str) -> str: {route["priority"]} ''') + if seo_tool_pages: + urls.append('\n ') + for slug in seo_tool_pages: + urls.append(f''' + {domain}/{slug} + {today} + weekly + 0.88 + ''') + + if seo_collection_pages: + urls.append('\n ') + for slug in seo_collection_pages: + urls.append(f''' + {domain}/{slug} + {today} + weekly + 0.82 + ''') + sitemap = f''' {chr(10).join(urls)} @@ -167,7 +202,14 @@ def main(): with open(args.output, 'w', encoding='utf-8') as f: 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"Total URLs: {total}")