feat: enhance SEO capabilities and add All Tools page
- Updated generate-seo-assets script to create separate sitemap files for static, blog, tools, and SEO pages. - Introduced render-seo-shells script to generate HTML shells for SEO pages with dynamic metadata. - Added All Tools page with categorized tool listings and SEO metadata. - Updated routing to include /tools path and linked it in the footer. - Enhanced SEOHead component to remove unused keywords and improve OpenGraph metadata. - Updated translations for tools hub in English, Arabic, and French. - Refactored SEO-related utility functions to support new structured data formats.
This commit is contained in:
99
frontend/src/pages/AllToolsPage.tsx
Normal file
99
frontend/src/pages/AllToolsPage.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import SEOHead from '@/components/seo/SEOHead';
|
||||
import BreadcrumbNav from '@/components/seo/BreadcrumbNav';
|
||||
import { TOOLS_SEO } from '@/config/seoData';
|
||||
import { generateBreadcrumbs, generateCollectionPage, generateItemList, getSiteOrigin } from '@/utils/seo';
|
||||
|
||||
const CATEGORY_ORDER = ['PDF', 'Convert', 'Image', 'AI', 'Utility'] as const;
|
||||
|
||||
export default function AllToolsPage() {
|
||||
const { t } = useTranslation();
|
||||
const origin = getSiteOrigin(typeof window !== 'undefined' ? window.location.origin : '');
|
||||
const path = '/tools';
|
||||
const url = `${origin}${path}`;
|
||||
|
||||
const groupedTools = CATEGORY_ORDER.map((category) => ({
|
||||
category,
|
||||
items: TOOLS_SEO.filter((tool) => tool.category === category),
|
||||
})).filter((group) => group.items.length > 0);
|
||||
|
||||
const jsonLd = [
|
||||
generateCollectionPage({
|
||||
name: t('pages.toolsHub.metaTitle'),
|
||||
description: t('pages.toolsHub.metaDescription'),
|
||||
url,
|
||||
}),
|
||||
generateBreadcrumbs([
|
||||
{ name: t('common.home'), url: origin },
|
||||
{ name: t('common.allTools'), url },
|
||||
]),
|
||||
generateItemList(
|
||||
TOOLS_SEO.map((tool) => ({
|
||||
name: t(`tools.${tool.i18nKey}.title`),
|
||||
url: `${origin}/tools/${tool.slug}`,
|
||||
})),
|
||||
),
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<SEOHead
|
||||
title={t('pages.toolsHub.metaTitle')}
|
||||
description={t('pages.toolsHub.metaDescription')}
|
||||
path={path}
|
||||
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">
|
||||
<BreadcrumbNav
|
||||
className="mb-6"
|
||||
items={[
|
||||
{ label: t('common.home'), to: '/' },
|
||||
{ label: t('common.allTools') },
|
||||
]}
|
||||
/>
|
||||
|
||||
<h1 className="text-3xl font-bold tracking-tight text-slate-900 dark:text-white sm:text-4xl">
|
||||
{t('pages.toolsHub.title')}
|
||||
</h1>
|
||||
<p className="mt-4 max-w-3xl text-lg leading-8 text-slate-600 dark:text-slate-400">
|
||||
{t('pages.toolsHub.description')}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{groupedTools.map((group) => (
|
||||
<section
|
||||
key={group.category}
|
||||
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">
|
||||
{t(`pages.toolsHub.categories.${group.category}`)}
|
||||
</h2>
|
||||
|
||||
<div className="mt-5 grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
{group.items.map((tool) => (
|
||||
<Link
|
||||
key={tool.slug}
|
||||
to={`/tools/${tool.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"
|
||||
>
|
||||
<p className="text-sm font-medium uppercase tracking-wide text-primary-600 dark:text-primary-400">
|
||||
{group.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>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import SEOHead from '@/components/seo/SEOHead';
|
||||
import { generateWebPage, getSiteOrigin } from '@/utils/seo';
|
||||
import { generateCollectionPage, generateItemList, getSiteOrigin } from '@/utils/seo';
|
||||
import { BookOpen, Calendar, ArrowRight, Search, X } from 'lucide-react';
|
||||
import {
|
||||
BLOG_ARTICLES,
|
||||
@@ -44,11 +44,17 @@ export default function BlogPage() {
|
||||
title={t('pages.blog.metaTitle')}
|
||||
description={t('pages.blog.metaDescription')}
|
||||
path="/blog"
|
||||
jsonLd={generateWebPage({
|
||||
name: t('pages.blog.metaTitle'),
|
||||
description: t('pages.blog.metaDescription'),
|
||||
url: `${siteOrigin}/blog`,
|
||||
})}
|
||||
jsonLd={[
|
||||
generateCollectionPage({
|
||||
name: t('pages.blog.metaTitle'),
|
||||
description: t('pages.blog.metaDescription'),
|
||||
url: `${siteOrigin}/blog`,
|
||||
}),
|
||||
generateItemList(posts.map((post) => ({
|
||||
name: post.title,
|
||||
url: `${siteOrigin}/blog/${post.slug}`,
|
||||
}))),
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className="mx-auto max-w-4xl">
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useDeferredValue } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import SEOHead from '@/components/seo/SEOHead';
|
||||
import { generateOrganization, getSiteOrigin } from '@/utils/seo';
|
||||
import { generateOrganization, generateWebSite, getSiteOrigin } from '@/utils/seo';
|
||||
import {
|
||||
FileText,
|
||||
FileOutput,
|
||||
@@ -121,18 +121,10 @@ export default function HomePage() {
|
||||
description={t('home.heroSub')}
|
||||
path="/"
|
||||
jsonLd={[
|
||||
{
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'WebSite',
|
||||
name: t('common.appName'),
|
||||
url: siteOrigin,
|
||||
generateWebSite({
|
||||
origin: siteOrigin,
|
||||
description: t('home.heroSub'),
|
||||
potentialAction: {
|
||||
'@type': 'SearchAction',
|
||||
target: `${siteOrigin}/?q={search_term_string}`,
|
||||
'query-input': 'required name=search_term_string',
|
||||
},
|
||||
},
|
||||
}),
|
||||
generateOrganization(siteOrigin),
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -6,13 +6,19 @@ 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 {
|
||||
generateBreadcrumbs,
|
||||
generateCollectionPage,
|
||||
generateFAQ,
|
||||
generateItemList,
|
||||
generateWebPage,
|
||||
getSiteOrigin,
|
||||
} from '@/utils/seo';
|
||||
import NotFoundPage from '@/pages/NotFoundPage';
|
||||
|
||||
interface SeoCollectionPageProps {
|
||||
@@ -64,7 +70,6 @@ export default function SeoCollectionPage({ slug }: SeoCollectionPageProps) {
|
||||
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 siteOrigin = getSiteOrigin(typeof window !== 'undefined' ? window.location.origin : '');
|
||||
const faqItems = page.faqTemplates.map((item) => ({
|
||||
question: getLocalizedText(item.question, locale),
|
||||
@@ -82,6 +87,11 @@ export default function SeoCollectionPage({ slug }: SeoCollectionPageProps) {
|
||||
];
|
||||
|
||||
const jsonLd = [
|
||||
generateCollectionPage({
|
||||
name: title,
|
||||
description,
|
||||
url,
|
||||
}),
|
||||
generateWebPage({
|
||||
name: title,
|
||||
description,
|
||||
@@ -93,11 +103,18 @@ export default function SeoCollectionPage({ slug }: SeoCollectionPageProps) {
|
||||
{ name: title, url },
|
||||
]),
|
||||
generateFAQ(faqItems),
|
||||
generateItemList(page.targetToolSlugs.map((toolSlug) => {
|
||||
const tool = getToolSEO(toolSlug);
|
||||
return {
|
||||
name: tool ? t(`tools.${tool.i18nKey}.title`) : toolSlug,
|
||||
url: `${siteOrigin}/tools/${toolSlug}`,
|
||||
};
|
||||
})),
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<SEOHead title={title} description={description} path={path} keywords={keywords} jsonLd={jsonLd} alternates={alternates} />
|
||||
<SEOHead title={title} description={description} path={path} jsonLd={jsonLd} alternates={alternates} />
|
||||
|
||||
<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">
|
||||
@@ -227,4 +244,4 @@ export default function SeoCollectionPage({ slug }: SeoCollectionPageProps) {
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ import RelatedTools from '@/components/seo/RelatedTools';
|
||||
import SuggestedTools from '@/components/seo/SuggestedTools';
|
||||
import {
|
||||
getLocalizedText,
|
||||
getLocalizedTextList,
|
||||
getProgrammaticToolPage,
|
||||
getSeoCollectionPage,
|
||||
interpolateTemplate,
|
||||
@@ -19,6 +18,7 @@ import {
|
||||
generateBreadcrumbs,
|
||||
generateFAQ,
|
||||
generateHowTo,
|
||||
generateItemList,
|
||||
generateToolSchema,
|
||||
generateWebPage,
|
||||
getSiteOrigin,
|
||||
@@ -82,7 +82,6 @@ export default function SeoPage({ slug }: SeoPageProps) {
|
||||
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,
|
||||
@@ -139,11 +138,25 @@ export default function SeoPage({ slug }: SeoPageProps) {
|
||||
url,
|
||||
}),
|
||||
generateFAQ(faqItems),
|
||||
generateItemList(page.relatedCollectionSlugs.map((collectionSlug) => {
|
||||
const collection = getSeoCollectionPage(collectionSlug);
|
||||
const collectionTitle = collection
|
||||
? interpolateTemplate(getLocalizedText(collection.titleTemplate, locale), {
|
||||
brand: 'Dociva',
|
||||
focusKeyword: getLocalizedText(collection.focusKeyword, locale),
|
||||
})
|
||||
: collectionSlug;
|
||||
|
||||
return {
|
||||
name: collectionTitle,
|
||||
url: `${siteOrigin}${localizedCollectionPath(collectionSlug)}`,
|
||||
};
|
||||
})),
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<SEOHead title={title} description={description} path={path} keywords={keywords} jsonLd={jsonLd} alternates={alternates} />
|
||||
<SEOHead title={title} description={description} path={path} jsonLd={jsonLd} alternates={alternates} />
|
||||
|
||||
<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">
|
||||
@@ -312,4 +325,4 @@ export default function SeoPage({ slug }: SeoPageProps) {
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user