feat: Enhance task access control and session management

- Implemented API and web task access assertions in the task status polling endpoint.
- Added functions to remember and check task access in user sessions.
- Updated task status tests to validate access control based on session data.
- Enhanced download route tests to ensure proper access checks.
- Improved SEO metadata handling with dynamic social preview images.
- Updated sitemap generation to include blog posts and new tools.
- Added a social preview SVG for better sharing on social media platforms.
This commit is contained in:
Your Name
2026-03-17 21:19:23 +02:00
parent ff5bd19335
commit 3f24a7ea3e
17 changed files with 384 additions and 75 deletions

View File

@@ -16,7 +16,7 @@ CELERY_BROKER_URL=redis://redis:6379/0
CELERY_RESULT_BACKEND=redis://redis:6379/1 CELERY_RESULT_BACKEND=redis://redis:6379/1
# OpenRouter AI # OpenRouter AI
OPENROUTER_API_KEY=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 OPENROUTER_API_KEY=sk-or-v1-your-openrouter-api-key
OPENROUTER_MODEL=nvidia/nemotron-3-super-120b-a12b:free OPENROUTER_MODEL=nvidia/nemotron-3-super-120b-a12b:free
OPENROUTER_BASE_URL=https://openrouter.ai/api/v1/chat/completions OPENROUTER_BASE_URL=https://openrouter.ai/api/v1/chat/completions

3
.gitignore vendored
View File

@@ -54,6 +54,3 @@ htmlcov/
.coverage .coverage
coverage/ coverage/
# Celery
celerybeat-schedule
backend/celerybeat-schedule

View File

@@ -3,6 +3,14 @@ import os
from flask import Blueprint, send_file, abort, request, current_app from flask import Blueprint, send_file, abort, request, current_app
from app.services.policy_service import (
PolicyError,
assert_api_task_access,
assert_web_task_access,
resolve_api_actor,
resolve_web_actor,
)
download_bp = Blueprint("download", __name__) download_bp = Blueprint("download", __name__)
@@ -20,6 +28,16 @@ def download_file(task_id: str, filename: str):
if ".." in filename or "/" in filename or "\\" in filename: if ".." in filename or "/" in filename or "\\" in filename:
abort(400, "Invalid filename.") abort(400, "Invalid filename.")
try:
if request.headers.get("X-API-Key", "").strip():
actor = resolve_api_actor()
assert_api_task_access(actor, task_id)
else:
actor = resolve_web_actor()
assert_web_task_access(actor, task_id)
except PolicyError as exc:
abort(exc.status_code, exc.message)
output_dir = current_app.config["OUTPUT_FOLDER"] output_dir = current_app.config["OUTPUT_FOLDER"]
file_path = os.path.join(output_dir, task_id, filename) file_path = os.path.join(output_dir, task_id, filename)

View File

@@ -1,9 +1,16 @@
"""Task status polling endpoint.""" """Task status polling endpoint."""
from flask import Blueprint, jsonify from flask import Blueprint, jsonify, request
from celery.result import AsyncResult from celery.result import AsyncResult
from app.extensions import celery from app.extensions import celery
from app.middleware.rate_limiter import limiter from app.middleware.rate_limiter import limiter
from app.services.policy_service import (
PolicyError,
assert_api_task_access,
assert_web_task_access,
resolve_api_actor,
resolve_web_actor,
)
tasks_bp = Blueprint("tasks", __name__) tasks_bp = Blueprint("tasks", __name__)
@@ -17,6 +24,16 @@ def get_task_status(task_id: str):
Returns: Returns:
JSON with task state and result (if completed) JSON with task state and result (if completed)
""" """
try:
if request.headers.get("X-API-Key", "").strip():
actor = resolve_api_actor()
assert_api_task_access(actor, task_id)
else:
actor = resolve_web_actor()
assert_web_task_access(actor, task_id)
except PolicyError as exc:
return jsonify({"error": exc.message}), exc.status_code
result = AsyncResult(task_id, app=celery) result = AsyncResult(task_id, app=celery)
response = { response = {

View File

@@ -13,6 +13,7 @@ from app.services.account_service import (
record_usage_event, record_usage_event,
) )
from app.utils.auth import get_current_user_id, logout_user_session from app.utils.auth import get_current_user_id, logout_user_session
from app.utils.auth import has_session_task_access, remember_task_access
from app.utils.file_validator import validate_file from app.utils.file_validator import validate_file
FREE_PLAN = "free" FREE_PLAN = "free"
@@ -202,6 +203,9 @@ def assert_quota_available(actor: ActorContext):
def record_accepted_usage(actor: ActorContext, tool: str, celery_task_id: str): def record_accepted_usage(actor: ActorContext, tool: str, celery_task_id: str):
"""Record one accepted usage event after task dispatch succeeds.""" """Record one accepted usage event after task dispatch succeeds."""
if actor.source == "web":
remember_task_access(celery_task_id)
record_usage_event( record_usage_event(
user_id=actor.user_id, user_id=actor.user_id,
source=actor.source, source=actor.source,
@@ -225,3 +229,14 @@ def assert_api_task_access(actor: ActorContext, task_id: str):
"""Ensure one API actor can poll one task id.""" """Ensure one API actor can poll one task id."""
if actor.user_id is None or not has_task_access(actor.user_id, "api", task_id): if actor.user_id is None or not has_task_access(actor.user_id, "api", task_id):
raise PolicyError("Task not found.", 404) raise PolicyError("Task not found.", 404)
def assert_web_task_access(actor: ActorContext, task_id: str):
"""Ensure one web browser session can access one task id."""
if actor.user_id is not None and has_task_access(actor.user_id, "web", task_id):
return
if has_session_task_access(task_id):
return
raise PolicyError("Task not found.", 404)

View File

@@ -1,6 +1,9 @@
"""Session helpers for authenticated routes.""" """Session helpers for authenticated routes."""
from flask import session from flask import session
TASK_ACCESS_SESSION_KEY = "task_access_ids"
MAX_TRACKED_TASK_IDS = 200
def get_current_user_id() -> int | None: def get_current_user_id() -> int | None:
"""Return the authenticated user id from session storage.""" """Return the authenticated user id from session storage."""
@@ -8,11 +11,31 @@ def get_current_user_id() -> int | None:
return user_id if isinstance(user_id, int) else None return user_id if isinstance(user_id, int) else None
def remember_task_access(task_id: str):
"""Persist one web task id in the active browser session."""
tracked = session.get(TASK_ACCESS_SESSION_KEY, [])
if not isinstance(tracked, list):
tracked = []
normalized = [value for value in tracked if isinstance(value, str) and value != task_id]
normalized.append(task_id)
session[TASK_ACCESS_SESSION_KEY] = normalized[-MAX_TRACKED_TASK_IDS:]
def has_session_task_access(task_id: str) -> bool:
"""Return whether the active browser session owns one web task id."""
tracked = session.get(TASK_ACCESS_SESSION_KEY, [])
return isinstance(tracked, list) and task_id in tracked
def login_user_session(user_id: int): def login_user_session(user_id: int):
"""Persist the authenticated user in the Flask session.""" """Persist the authenticated user in the Flask session."""
tracked_task_ids = session.get(TASK_ACCESS_SESSION_KEY, [])
session.clear() session.clear()
session.permanent = True session.permanent = True
session["user_id"] = user_id session["user_id"] = user_id
if isinstance(tracked_task_ids, list) and tracked_task_ids:
session[TASK_ACCESS_SESSION_KEY] = tracked_task_ids[-MAX_TRACKED_TASK_IDS:]
def logout_user_session(): def logout_user_session():

View File

@@ -1,6 +1,8 @@
"""Tests for file download route.""" """Tests for file download route."""
import os import os
from app.utils.auth import TASK_ACCESS_SESSION_KEY
class TestDownload: class TestDownload:
def test_download_nonexistent_file(self, client): def test_download_nonexistent_file(self, client):
@@ -31,6 +33,9 @@ class TestDownload:
with open(file_path, 'wb') as f: with open(file_path, 'wb') as f:
f.write(b'%PDF-1.4 test content') f.write(b'%PDF-1.4 test content')
with client.session_transaction() as session:
session[TASK_ACCESS_SESSION_KEY] = [task_id]
response = client.get(f'/api/download/{task_id}/{filename}') response = client.get(f'/api/download/{task_id}/{filename}')
assert response.status_code == 200 assert response.status_code == 200
assert response.data == b'%PDF-1.4 test content' assert response.data == b'%PDF-1.4 test content'
@@ -45,5 +50,21 @@ class TestDownload:
with open(os.path.join(output_dir, filename), 'wb') as f: with open(os.path.join(output_dir, filename), 'wb') as f:
f.write(b'%PDF-1.4') f.write(b'%PDF-1.4')
with client.session_transaction() as session:
session[TASK_ACCESS_SESSION_KEY] = [task_id]
response = client.get(f'/api/download/{task_id}/{filename}?name=my-document.pdf') response = client.get(f'/api/download/{task_id}/{filename}?name=my-document.pdf')
assert response.status_code == 200 assert response.status_code == 200
def test_download_requires_task_access(self, client, app):
"""Should not serve an existing file without session or API ownership."""
task_id = 'protected-download-id'
filename = 'output.pdf'
output_dir = os.path.join(app.config['OUTPUT_FOLDER'], task_id)
os.makedirs(output_dir, exist_ok=True)
with open(os.path.join(output_dir, filename), 'wb') as f:
f.write(b'%PDF-1.4 protected')
response = client.get(f'/api/download/{task_id}/{filename}')
assert response.status_code == 404

View File

@@ -1,6 +1,8 @@
"""Tests for task status polling route.""" """Tests for task status polling route."""
from unittest.mock import patch, MagicMock from unittest.mock import patch, MagicMock
from app.utils.auth import TASK_ACCESS_SESSION_KEY
class TestTaskStatus: class TestTaskStatus:
def test_pending_task(self, client, monkeypatch): def test_pending_task(self, client, monkeypatch):
@@ -9,6 +11,9 @@ class TestTaskStatus:
mock_result.state = 'PENDING' mock_result.state = 'PENDING'
mock_result.info = None mock_result.info = None
with client.session_transaction() as session:
session[TASK_ACCESS_SESSION_KEY] = ['test-task-id']
with patch('app.routes.tasks.AsyncResult', return_value=mock_result): with patch('app.routes.tasks.AsyncResult', return_value=mock_result):
response = client.get('/api/tasks/test-task-id/status') response = client.get('/api/tasks/test-task-id/status')
@@ -24,6 +29,9 @@ class TestTaskStatus:
mock_result.state = 'PROCESSING' mock_result.state = 'PROCESSING'
mock_result.info = {'step': 'Converting page 3 of 10...'} mock_result.info = {'step': 'Converting page 3 of 10...'}
with client.session_transaction() as session:
session[TASK_ACCESS_SESSION_KEY] = ['processing-id']
with patch('app.routes.tasks.AsyncResult', return_value=mock_result): with patch('app.routes.tasks.AsyncResult', return_value=mock_result):
response = client.get('/api/tasks/processing-id/status') response = client.get('/api/tasks/processing-id/status')
@@ -42,6 +50,9 @@ class TestTaskStatus:
'filename': 'output.pdf', 'filename': 'output.pdf',
} }
with client.session_transaction() as session:
session[TASK_ACCESS_SESSION_KEY] = ['success-id']
with patch('app.routes.tasks.AsyncResult', return_value=mock_result): with patch('app.routes.tasks.AsyncResult', return_value=mock_result):
response = client.get('/api/tasks/success-id/status') response = client.get('/api/tasks/success-id/status')
@@ -57,10 +68,19 @@ class TestTaskStatus:
mock_result.state = 'FAILURE' mock_result.state = 'FAILURE'
mock_result.info = Exception('Conversion failed due to corrupt PDF.') mock_result.info = Exception('Conversion failed due to corrupt PDF.')
with client.session_transaction() as session:
session[TASK_ACCESS_SESSION_KEY] = ['failed-id']
with patch('app.routes.tasks.AsyncResult', return_value=mock_result): with patch('app.routes.tasks.AsyncResult', return_value=mock_result):
response = client.get('/api/tasks/failed-id/status') response = client.get('/api/tasks/failed-id/status')
assert response.status_code == 200 assert response.status_code == 200
data = response.get_json() data = response.get_json()
assert data['state'] == 'FAILURE' assert data['state'] == 'FAILURE'
assert 'error' in data assert 'error' in data
def test_unknown_task_without_access_returns_404(self, client):
"""Should not expose task state without session or API ownership."""
response = client.get('/api/tasks/unknown-task/status')
assert response.status_code == 404

View File

@@ -12,12 +12,16 @@
<meta property="og:title" content="Dociva — Free Online File Tools" /> <meta property="og:title" content="Dociva — Free Online File Tools" />
<meta property="og:description" content="30+ free tools: merge, split, compress, convert PDFs, images, videos & text. No signup required." /> <meta property="og:description" content="30+ free tools: merge, split, compress, convert PDFs, images, videos & text. No signup required." />
<meta property="og:site_name" content="Dociva" /> <meta property="og:site_name" content="Dociva" />
<meta property="og:image" content="/social-preview.svg" />
<meta property="og:image:alt" content="Dociva social preview" />
<meta property="og:locale" content="en_US" /> <meta property="og:locale" content="en_US" />
<meta property="og:locale:alternate" content="ar_SA" /> <meta property="og:locale:alternate" content="ar_SA" />
<meta property="og:locale:alternate" content="fr_FR" /> <meta property="og:locale:alternate" content="fr_FR" />
<meta name="twitter:card" content="summary_large_image" /> <meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="Dociva — Free Online File Tools" /> <meta name="twitter:title" content="Dociva — Free Online File Tools" />
<meta name="twitter:description" content="30+ free tools: merge, split, compress, convert PDFs, images, videos & text. No signup required." /> <meta name="twitter:description" content="30+ free tools: merge, split, compress, convert PDFs, images, videos & text. No signup required." />
<meta name="twitter:image" content="/social-preview.svg" />
<meta name="twitter:image:alt" content="Dociva social preview" />
<link rel="dns-prefetch" href="https://fonts.googleapis.com" /> <link rel="dns-prefetch" href="https://fonts.googleapis.com" />
<link rel="dns-prefetch" href="https://fonts.gstatic.com" /> <link rel="dns-prefetch" href="https://fonts.gstatic.com" />
<link rel="preconnect" href="https://fonts.googleapis.com" /> <link rel="preconnect" href="https://fonts.googleapis.com" />

View File

@@ -1,212 +1,286 @@
<?xml version="1.0" encoding="UTF-8"?> <?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">
<url> <url>
<loc>https://dociva.io/</loc> <loc>https://dociva.io/</loc>
<lastmod>2026-03-14</lastmod> <lastmod>2026-03-17</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-14</lastmod> <lastmod>2026-03-17</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-14</lastmod> <lastmod>2026-03-17</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-14</lastmod> <lastmod>2026-03-17</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-14</lastmod> <lastmod>2026-03-17</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-14</lastmod> <lastmod>2026-03-17</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-14</lastmod> <lastmod>2026-03-17</lastmod>
<changefreq>weekly</changefreq> <changefreq>weekly</changefreq>
<priority>0.6</priority> <priority>0.6</priority>
</url> </url>
<!-- Blog Posts -->
<url>
<loc>https://dociva.io/blog/how-to-compress-pdf-online</loc>
<lastmod>2026-03-17</lastmod>
<changefreq>monthly</changefreq>
<priority>0.6</priority>
</url>
<url>
<loc>https://dociva.io/blog/convert-images-without-losing-quality</loc>
<lastmod>2026-03-17</lastmod>
<changefreq>monthly</changefreq>
<priority>0.6</priority>
</url>
<url>
<loc>https://dociva.io/blog/ocr-extract-text-from-images</loc>
<lastmod>2026-03-17</lastmod>
<changefreq>monthly</changefreq>
<priority>0.6</priority>
</url>
<url>
<loc>https://dociva.io/blog/merge-split-pdf-files</loc>
<lastmod>2026-03-17</lastmod>
<changefreq>monthly</changefreq>
<priority>0.6</priority>
</url>
<url>
<loc>https://dociva.io/blog/ai-chat-with-pdf-documents</loc>
<lastmod>2026-03-17</lastmod>
<changefreq>monthly</changefreq>
<priority>0.6</priority>
</url>
<!-- PDF Tools --> <!-- 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-14</lastmod> <lastmod>2026-03-17</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-14</lastmod> <lastmod>2026-03-17</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-14</lastmod> <lastmod>2026-03-17</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-14</lastmod> <lastmod>2026-03-17</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-14</lastmod> <lastmod>2026-03-17</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-14</lastmod> <lastmod>2026-03-17</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-14</lastmod> <lastmod>2026-03-17</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-14</lastmod> <lastmod>2026-03-17</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-14</lastmod> <lastmod>2026-03-17</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-14</lastmod> <lastmod>2026-03-17</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-14</lastmod> <lastmod>2026-03-17</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-14</lastmod> <lastmod>2026-03-17</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-14</lastmod> <lastmod>2026-03-17</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-14</lastmod> <lastmod>2026-03-17</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-14</lastmod> <lastmod>2026-03-17</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-14</lastmod> <lastmod>2026-03-17</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-14</lastmod> <lastmod>2026-03-17</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-14</lastmod> <lastmod>2026-03-17</lastmod>
<changefreq>weekly</changefreq> <changefreq>weekly</changefreq>
<priority>0.8</priority> <priority>0.8</priority>
</url> </url>
<url>
<loc>https://dociva.io/tools/sign-pdf</loc>
<lastmod>2026-03-17</lastmod>
<changefreq>weekly</changefreq>
<priority>0.8</priority>
</url>
<url>
<loc>https://dociva.io/tools/crop-pdf</loc>
<lastmod>2026-03-17</lastmod>
<changefreq>weekly</changefreq>
<priority>0.7</priority>
</url>
<url>
<loc>https://dociva.io/tools/flatten-pdf</loc>
<lastmod>2026-03-17</lastmod>
<changefreq>weekly</changefreq>
<priority>0.7</priority>
</url>
<url>
<loc>https://dociva.io/tools/repair-pdf</loc>
<lastmod>2026-03-17</lastmod>
<changefreq>weekly</changefreq>
<priority>0.7</priority>
</url>
<url>
<loc>https://dociva.io/tools/pdf-metadata</loc>
<lastmod>2026-03-17</lastmod>
<changefreq>weekly</changefreq>
<priority>0.6</priority>
</url>
<!-- Image Tools --> <!-- Image Tools -->
<url> <url>
<loc>https://dociva.io/tools/image-converter</loc> <loc>https://dociva.io/tools/image-converter</loc>
<lastmod>2026-03-14</lastmod> <lastmod>2026-03-17</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-14</lastmod> <lastmod>2026-03-17</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-14</lastmod> <lastmod>2026-03-17</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-14</lastmod> <lastmod>2026-03-17</lastmod>
<changefreq>weekly</changefreq> <changefreq>weekly</changefreq>
<priority>0.8</priority> <priority>0.8</priority>
</url> </url>
<url>
<loc>https://dociva.io/tools/image-crop</loc>
<lastmod>2026-03-17</lastmod>
<changefreq>weekly</changefreq>
<priority>0.7</priority>
</url>
<url>
<loc>https://dociva.io/tools/image-rotate-flip</loc>
<lastmod>2026-03-17</lastmod>
<changefreq>weekly</changefreq>
<priority>0.7</priority>
</url>
<!-- AI Tools --> <!-- AI Tools -->
<url> <url>
<loc>https://dociva.io/tools/ocr</loc> <loc>https://dociva.io/tools/ocr</loc>
<lastmod>2026-03-14</lastmod> <lastmod>2026-03-17</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-14</lastmod> <lastmod>2026-03-17</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-14</lastmod> <lastmod>2026-03-17</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-14</lastmod> <lastmod>2026-03-17</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-14</lastmod> <lastmod>2026-03-17</lastmod>
<changefreq>weekly</changefreq> <changefreq>weekly</changefreq>
<priority>0.8</priority> <priority>0.8</priority>
</url> </url>
@@ -214,32 +288,56 @@
<!-- Utility Tools --> <!-- 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-14</lastmod> <lastmod>2026-03-17</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-14</lastmod> <lastmod>2026-03-17</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-14</lastmod> <lastmod>2026-03-17</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-14</lastmod> <lastmod>2026-03-17</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-14</lastmod> <lastmod>2026-03-17</lastmod>
<changefreq>weekly</changefreq> <changefreq>weekly</changefreq>
<priority>0.6</priority> <priority>0.6</priority>
</url> </url>
<url>
<loc>https://dociva.io/tools/pdf-to-pptx</loc>
<lastmod>2026-03-17</lastmod>
<changefreq>weekly</changefreq>
<priority>0.8</priority>
</url>
<url>
<loc>https://dociva.io/tools/excel-to-pdf</loc>
<lastmod>2026-03-17</lastmod>
<changefreq>weekly</changefreq>
<priority>0.8</priority>
</url>
<url>
<loc>https://dociva.io/tools/pptx-to-pdf</loc>
<lastmod>2026-03-17</lastmod>
<changefreq>weekly</changefreq>
<priority>0.8</priority>
</url>
<url>
<loc>https://dociva.io/tools/barcode-generator</loc>
<lastmod>2026-03-17</lastmod>
<changefreq>weekly</changefreq>
<priority>0.7</priority>
</url>
</urlset> </urlset>

View File

@@ -0,0 +1,35 @@
<svg width="1200" height="630" viewBox="0 0 1200 630" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="1200" height="630" fill="#F8FAFC"/>
<rect x="40" y="40" width="1120" height="550" rx="36" fill="url(#bg)"/>
<circle cx="964" cy="138" r="132" fill="#DBEAFE" fill-opacity="0.9"/>
<circle cx="1040" cy="506" r="96" fill="#D1FAE5" fill-opacity="0.85"/>
<circle cx="182" cy="518" r="122" fill="#FCE7F3" fill-opacity="0.9"/>
<rect x="118" y="150" width="114" height="114" rx="28" fill="#0F172A"/>
<path d="M154 206C154 182.804 172.804 164 196 164H208C231.196 164 250 182.804 250 206V208C250 231.196 231.196 250 208 250H196C172.804 250 154 231.196 154 208V206Z" fill="#38BDF8"/>
<path d="M172 196C172 186.059 180.059 178 190 178H202C211.941 178 220 186.059 220 196V218C220 227.941 211.941 236 202 236H190C180.059 236 172 227.941 172 218V196Z" fill="#E0F2FE"/>
<text x="278" y="205" fill="#E2E8F0" font-family="Inter, Arial, sans-serif" font-size="28" font-weight="700" letter-spacing="6">DOCIVA</text>
<text x="118" y="304" fill="white" font-family="Inter, Arial, sans-serif" font-size="64" font-weight="800">Online PDF, Image, and AI Tools</text>
<text x="118" y="360" fill="#CBD5E1" font-family="Inter, Arial, sans-serif" font-size="28" font-weight="500">Convert, compress, edit, OCR, and automate document workflows from one workspace.</text>
<g>
<rect x="118" y="420" width="256" height="54" rx="27" fill="#0EA5E9"/>
<text x="160" y="454" fill="white" font-family="Inter, Arial, sans-serif" font-size="24" font-weight="700">dociva.io</text>
</g>
<g>
<rect x="118" y="500" width="220" height="40" rx="20" fill="#FFFFFF" fill-opacity="0.12"/>
<text x="143" y="526" fill="#E2E8F0" font-family="Inter, Arial, sans-serif" font-size="20" font-weight="600">No signup required</text>
</g>
<g>
<rect x="770" y="210" width="270" height="186" rx="28" fill="#FFFFFF" fill-opacity="0.12" stroke="#E2E8F0" stroke-opacity="0.16"/>
<rect x="800" y="246" width="150" height="16" rx="8" fill="#E0F2FE"/>
<rect x="800" y="282" width="204" height="16" rx="8" fill="#C7D2FE"/>
<rect x="800" y="318" width="126" height="16" rx="8" fill="#A7F3D0"/>
<rect x="800" y="354" width="174" height="16" rx="8" fill="#FDE68A"/>
</g>
<defs>
<linearGradient id="bg" x1="40" y1="40" x2="1160" y2="590" gradientUnits="userSpaceOnUse">
<stop stop-color="#0F172A"/>
<stop offset="0.55" stop-color="#111827"/>
<stop offset="1" stop-color="#0F766E"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

View File

@@ -1,6 +1,6 @@
import { Helmet } from 'react-helmet-async'; import { Helmet } from 'react-helmet-async';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { buildLanguageAlternates, getOgLocale } from '@/utils/seo'; import { buildLanguageAlternates, buildSocialImageUrl, getOgLocale } from '@/utils/seo';
const SITE_NAME = 'Dociva'; const SITE_NAME = 'Dociva';
@@ -28,6 +28,7 @@ export default function SEOHead({ title, description, path, type = 'website', js
const { i18n } = useTranslation(); const { i18n } = useTranslation();
const origin = typeof window !== 'undefined' ? window.location.origin : ''; const origin = typeof window !== 'undefined' ? window.location.origin : '';
const canonicalUrl = `${origin}${path}`; const canonicalUrl = `${origin}${path}`;
const socialImageUrl = buildSocialImageUrl(origin);
const fullTitle = `${title}${SITE_NAME}`; const fullTitle = `${title}${SITE_NAME}`;
const languageAlternates = buildLanguageAlternates(origin, path); const languageAlternates = buildLanguageAlternates(origin, path);
const currentOgLocale = getOgLocale(i18n.language); const currentOgLocale = getOgLocale(i18n.language);
@@ -55,6 +56,8 @@ export default function SEOHead({ title, description, path, type = 'website', js
<meta property="og:url" content={canonicalUrl} /> <meta property="og:url" content={canonicalUrl} />
<meta property="og:type" content={type} /> <meta property="og:type" content={type} />
<meta property="og:site_name" content={SITE_NAME} /> <meta property="og:site_name" content={SITE_NAME} />
<meta property="og:image" content={socialImageUrl} />
<meta property="og:image:alt" content={`${fullTitle} social preview`} />
<meta property="og:locale" content={currentOgLocale} /> <meta property="og:locale" content={currentOgLocale} />
{languageAlternates {languageAlternates
.filter((alternate) => alternate.ogLocale !== currentOgLocale) .filter((alternate) => alternate.ogLocale !== currentOgLocale)
@@ -63,9 +66,11 @@ export default function SEOHead({ title, description, path, type = 'website', js
))} ))}
{/* Twitter */} {/* Twitter */}
<meta name="twitter:card" content="summary" /> <meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content={fullTitle} /> <meta name="twitter:title" content={fullTitle} />
<meta name="twitter:description" content={description} /> <meta name="twitter:description" content={description} />
<meta name="twitter:image" content={socialImageUrl} />
<meta name="twitter:image:alt" content={`${fullTitle} social preview`} />
{/* JSON-LD Structured Data */} {/* JSON-LD Structured Data */}
{schemas.map((schema, i) => ( {schemas.map((schema, i) => (

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, generateToolSchema, generateBreadcrumbs, generateFAQ, getOgLocale } from '@/utils/seo'; import { buildLanguageAlternates, buildSocialImageUrl, generateToolSchema, generateBreadcrumbs, generateFAQ, getOgLocale } 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';
@@ -40,6 +40,7 @@ export default function ToolLandingPage({ slug, children }: ToolLandingPageProps
const origin = typeof window !== 'undefined' ? window.location.origin : ''; const origin = typeof window !== 'undefined' ? window.location.origin : '';
const path = `/tools/${slug}`; const path = `/tools/${slug}`;
const canonicalUrl = `${origin}${path}`; const canonicalUrl = `${origin}${path}`;
const socialImageUrl = buildSocialImageUrl(origin);
const languageAlternates = buildLanguageAlternates(origin, path); const languageAlternates = buildLanguageAlternates(origin, path);
const currentOgLocale = getOgLocale(i18n.language); const currentOgLocale = getOgLocale(i18n.language);
@@ -82,6 +83,8 @@ export default function ToolLandingPage({ slug, children }: ToolLandingPageProps
<meta property="og:description" content={seo.metaDescription} /> <meta property="og:description" content={seo.metaDescription} />
<meta property="og:url" content={canonicalUrl} /> <meta property="og:url" content={canonicalUrl} />
<meta property="og:type" content="website" /> <meta property="og:type" content="website" />
<meta property="og:image" content={socialImageUrl} />
<meta property="og:image:alt" content={`${toolTitle} social preview`} />
<meta property="og:locale" content={currentOgLocale} /> <meta property="og:locale" content={currentOgLocale} />
{languageAlternates {languageAlternates
.filter((alternate) => alternate.ogLocale !== currentOgLocale) .filter((alternate) => alternate.ogLocale !== currentOgLocale)
@@ -90,9 +93,11 @@ export default function ToolLandingPage({ slug, children }: ToolLandingPageProps
))} ))}
{/* Twitter */} {/* Twitter */}
<meta name="twitter:card" content="summary" /> <meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content={`${toolTitle}${seo.titleSuffix}`} /> <meta name="twitter:title" content={`${toolTitle}${seo.titleSuffix}`} />
<meta name="twitter:description" content={seo.metaDescription} /> <meta name="twitter:description" content={seo.metaDescription} />
<meta name="twitter:image" content={socialImageUrl} />
<meta name="twitter:image:alt" content={`${toolTitle} social preview`} />
{/* Structured Data */} {/* Structured Data */}
<script type="application/ld+json">{JSON.stringify(toolSchema)}</script> <script type="application/ld+json">{JSON.stringify(toolSchema)}</script>

View File

@@ -37,17 +37,28 @@ export default function SocialProofStrip({ className = '' }: SocialProofStripPro
return null; return null;
} }
const hasReliableUsageStats = stats.total_files_processed >= 25;
const hasReliableRating = stats.rating_count >= 3;
const topTools = stats.top_tools.slice(0, 3).map((tool) => { const topTools = stats.top_tools.slice(0, 3).map((tool) => {
const seo = getToolSEO(tool.tool); const seo = getToolSEO(tool.tool);
return seo ? t(`tools.${seo.i18nKey}.title`) : tool.tool; return seo ? t(`tools.${seo.i18nKey}.title`) : tool.tool;
}); });
const cards = [ const cards = [
{ label: t('socialProof.processedFiles'), value: stats.total_files_processed.toLocaleString() }, hasReliableUsageStats
{ label: t('socialProof.successRate'), value: `${stats.success_rate}%` }, ? { label: t('socialProof.processedFiles'), value: stats.total_files_processed.toLocaleString() }
{ label: t('socialProof.last24h'), value: stats.files_last_24h.toLocaleString() }, : null,
{ label: t('socialProof.averageRating'), value: `${stats.average_rating.toFixed(1)} / 5` }, hasReliableUsageStats
]; ? { label: t('socialProof.successRate'), value: `${stats.success_rate}%` }
: null,
hasReliableUsageStats
? { label: t('socialProof.last24h'), value: stats.files_last_24h.toLocaleString() }
: null,
hasReliableRating
? { label: t('socialProof.averageRating'), value: `${stats.average_rating.toFixed(1)} / 5` }
: null,
].filter((card): card is { label: string; value: string } => Boolean(card));
return ( return (
<section className={`rounded-[2rem] border border-slate-200 bg-white p-6 shadow-sm dark:border-slate-700 dark:bg-slate-900/70 ${className}`.trim()}> <section className={`rounded-[2rem] border border-slate-200 bg-white p-6 shadow-sm dark:border-slate-700 dark:bg-slate-900/70 ${className}`.trim()}>
@@ -73,20 +84,31 @@ export default function SocialProofStrip({ className = '' }: SocialProofStripPro
)} )}
</div> </div>
<div className="grid gap-3 sm:grid-cols-2 lg:min-w-[420px]"> {cards.length > 0 ? (
{cards.map((card) => ( <div className="grid gap-3 sm:grid-cols-2 lg:min-w-[420px]">
<div key={card.label} className="rounded-2xl bg-slate-50 p-4 dark:bg-slate-800/70"> {cards.map((card) => (
<p className="text-xs font-semibold uppercase tracking-widest text-slate-400 dark:text-slate-500">{card.label}</p> <div key={card.label} className="rounded-2xl bg-slate-50 p-4 dark:bg-slate-800/70">
<p className="mt-2 text-2xl font-bold text-slate-900 dark:text-white">{card.value}</p> <p className="text-xs font-semibold uppercase tracking-widest text-slate-400 dark:text-slate-500">{card.label}</p>
</div> <p className="mt-2 text-2xl font-bold text-slate-900 dark:text-white">{card.value}</p>
))} </div>
</div> ))}
</div>
) : (
<div className="rounded-2xl bg-slate-50 p-5 text-sm leading-7 text-slate-600 dark:bg-slate-800/70 dark:text-slate-300 lg:max-w-md">
{t(
'socialProof.pendingSummary',
'Public activity metrics appear here after we collect enough completed jobs and verified ratings.'
)}
</div>
)}
</div> </div>
<div className="mt-5 flex flex-col gap-3 border-t border-slate-200 pt-4 sm:flex-row sm:items-center sm:justify-between dark:border-slate-700"> <div className="mt-5 flex flex-col gap-3 border-t border-slate-200 pt-4 sm:flex-row sm:items-center sm:justify-between dark:border-slate-700">
<p className="inline-flex items-center gap-2 text-sm text-slate-600 dark:text-slate-400"> <p className="inline-flex items-center gap-2 text-sm text-slate-600 dark:text-slate-400">
<Star className="h-4 w-4 text-amber-500" /> <Star className="h-4 w-4 text-amber-500" />
{t('socialProof.basedOnRatings', { count: stats.rating_count })} {hasReliableRating
? t('socialProof.basedOnRatings', { count: stats.rating_count })
: t('socialProof.pendingRatings', 'Ratings summary will unlock after enough verified feedback.')}
</p> </p>
<Link to="/developers" className="text-sm font-semibold text-primary-600 hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300"> <Link to="/developers" className="text-sm font-semibold text-primary-600 hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300">
{t('socialProof.viewDevelopers')} {t('socialProof.viewDevelopers')}

View File

@@ -22,15 +22,14 @@ const ENDPOINT_GROUPS = [
}, },
]; ];
const CURL_UPLOAD = `curl -X POST https://your-domain.example/api/v1/convert/pdf-to-word \\
-H "X-API-Key: spdf_your_api_key" \\
-F "file=@./sample.pdf"`;
const CURL_POLL = `curl https://your-domain.example/api/v1/tasks/<task_id>/status \\
-H "X-API-Key: spdf_your_api_key"`;
export default function DevelopersPage() { export default function DevelopersPage() {
const { t } = useTranslation(); const { t } = useTranslation();
const origin = typeof window !== 'undefined' ? window.location.origin : 'https://dociva.io';
const curlUpload = `curl -X POST ${origin}/api/v1/convert/pdf-to-word \\
-H "X-API-Key: spdf_your_api_key" \\
-F "file=@./sample.pdf"`;
const curlPoll = `curl ${origin}/api/tasks/<task_id>/status \\
-H "X-API-Key: spdf_your_api_key"`;
return ( return (
<> <>
@@ -91,12 +90,12 @@ export default function DevelopersPage() {
<article className="rounded-[2rem] border border-slate-200 bg-white p-6 shadow-sm dark:border-slate-700 dark:bg-slate-900/70"> <article className="rounded-[2rem] border border-slate-200 bg-white p-6 shadow-sm dark:border-slate-700 dark:bg-slate-900/70">
<h2 className="text-xl font-semibold text-slate-900 dark:text-white">{t('pages.developers.authExampleTitle')}</h2> <h2 className="text-xl font-semibold text-slate-900 dark:text-white">{t('pages.developers.authExampleTitle')}</h2>
<p className="mt-2 text-sm leading-7 text-slate-600 dark:text-slate-400">{t('pages.developers.authExampleSubtitle')}</p> <p className="mt-2 text-sm leading-7 text-slate-600 dark:text-slate-400">{t('pages.developers.authExampleSubtitle')}</p>
<pre className="mt-4 overflow-x-auto rounded-2xl bg-slate-950 p-4 text-sm text-sky-100"><code>{CURL_UPLOAD}</code></pre> <pre className="mt-4 overflow-x-auto rounded-2xl bg-slate-950 p-4 text-sm text-sky-100"><code>{curlUpload}</code></pre>
</article> </article>
<article className="rounded-[2rem] border border-slate-200 bg-white p-6 shadow-sm dark:border-slate-700 dark:bg-slate-900/70"> <article className="rounded-[2rem] border border-slate-200 bg-white p-6 shadow-sm dark:border-slate-700 dark:bg-slate-900/70">
<h2 className="text-xl font-semibold text-slate-900 dark:text-white">{t('pages.developers.pollExampleTitle')}</h2> <h2 className="text-xl font-semibold text-slate-900 dark:text-white">{t('pages.developers.pollExampleTitle')}</h2>
<p className="mt-2 text-sm leading-7 text-slate-600 dark:text-slate-400">{t('pages.developers.pollExampleSubtitle')}</p> <p className="mt-2 text-sm leading-7 text-slate-600 dark:text-slate-400">{t('pages.developers.pollExampleSubtitle')}</p>
<pre className="mt-4 overflow-x-auto rounded-2xl bg-slate-950 p-4 text-sm text-emerald-100"><code>{CURL_POLL}</code></pre> <pre className="mt-4 overflow-x-auto rounded-2xl bg-slate-950 p-4 text-sm text-emerald-100"><code>{curlPoll}</code></pre>
</article> </article>
</section> </section>

View File

@@ -17,6 +17,8 @@ export interface LanguageAlternate {
ogLocale: string; ogLocale: string;
} }
const DEFAULT_SOCIAL_IMAGE_PATH = '/social-preview.svg';
const LANGUAGE_CONFIG: Record<'en' | 'ar' | 'fr', { hrefLang: string; ogLocale: string }> = { const LANGUAGE_CONFIG: Record<'en' | 'ar' | 'fr', { hrefLang: string; ogLocale: string }> = {
en: { hrefLang: 'en', ogLocale: 'en_US' }, en: { hrefLang: 'en', ogLocale: 'en_US' },
ar: { hrefLang: 'ar', ogLocale: 'ar_SA' }, ar: { hrefLang: 'ar', ogLocale: 'ar_SA' },
@@ -42,6 +44,10 @@ export function buildLanguageAlternates(origin: string, path: string): LanguageA
})); }));
} }
export function buildSocialImageUrl(origin: string): string {
return `${origin}${DEFAULT_SOCIAL_IMAGE_PATH}`;
}
/** /**
* Generate WebApplication JSON-LD structured data for a tool page. * Generate WebApplication JSON-LD structured data for a tool page.
*/ */

View File

@@ -11,7 +11,9 @@ Usage:
import argparse import argparse
import os import os
import re
from datetime import datetime from datetime import datetime
from pathlib import Path
# ─── Route definitions with priority and changefreq ────────────────────────── # ─── Route definitions with priority and changefreq ──────────────────────────
@@ -95,9 +97,21 @@ TOOL_GROUPS = [
] ]
def get_blog_slugs() -> list[str]:
repo_root = Path(__file__).resolve().parents[1]
blog_articles_path = repo_root / 'frontend' / 'src' / 'content' / 'blogArticles.ts'
if not blog_articles_path.exists():
return []
content = blog_articles_path.read_text(encoding='utf-8')
return list(dict.fromkeys(re.findall(r"slug:\s*'([^']+)'", content)))
def generate_sitemap(domain: str) -> str: 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()
# Static pages # Static pages
for page in PAGES: for page in PAGES:
@@ -108,6 +122,16 @@ def generate_sitemap(domain: str) -> str:
<priority>{page["priority"]}</priority> <priority>{page["priority"]}</priority>
</url>''') </url>''')
if blog_slugs:
urls.append('\n <!-- Blog Posts -->')
for slug in blog_slugs:
urls.append(f''' <url>
<loc>{domain}/blog/{slug}</loc>
<lastmod>{today}</lastmod>
<changefreq>monthly</changefreq>
<priority>0.6</priority>
</url>''')
# Tool pages by category # Tool pages by category
for label, routes in TOOL_GROUPS: for label, routes in TOOL_GROUPS:
urls.append(f'\n <!-- {label} -->') urls.append(f'\n <!-- {label} -->')
@@ -143,7 +167,7 @@ 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) + sum(len(routes) for _, routes in TOOL_GROUPS) total = len(PAGES) + len(get_blog_slugs()) + sum(len(routes) for _, routes in TOOL_GROUPS)
print(f"Sitemap generated: {args.output}") print(f"Sitemap generated: {args.output}")
print(f"Total URLs: {total}") print(f"Total URLs: {total}")