Merge branch 'main' into copilot/vscode-mnbk5p20-roym
This commit is contained in:
@@ -2,7 +2,7 @@
|
|||||||
FLASK_ENV=production
|
FLASK_ENV=production
|
||||||
FLASK_DEBUG=0
|
FLASK_DEBUG=0
|
||||||
SECRET_KEY=replace-with-a-long-random-secret-key
|
SECRET_KEY=replace-with-a-long-random-secret-key
|
||||||
INTERNAL_ADMIN_EMAILS=admin@dociva.io
|
INTERNAL_ADMIN_EMAILS=support@dociva.io
|
||||||
|
|
||||||
# Site Domain (used in sitemap, robots.txt, emails)
|
# Site Domain (used in sitemap, robots.txt, emails)
|
||||||
SITE_DOMAIN=https://dociva.io
|
SITE_DOMAIN=https://dociva.io
|
||||||
|
|||||||
71
.github/copilot-instructions.md
vendored
Normal file
71
.github/copilot-instructions.md
vendored
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
# Copilot Workspace Instructions
|
||||||
|
|
||||||
|
Purpose
|
||||||
|
- Help Copilot-style agents and contributors be productive and safe in this repository.
|
||||||
|
- Surface where to find authoritative docs and which conventions to follow.
|
||||||
|
|
||||||
|
Principles
|
||||||
|
- Link, don't embed: prefer linking to existing docs in `docs/`, `CONTRIBUTING.md`, or `README.md` rather than duplicating content.
|
||||||
|
- Minimize blast radius: make minimal, focused changes and explain rationale in PRs.
|
||||||
|
- Ask clarifying questions before large or ambiguous changes.
|
||||||
|
|
||||||
|
What the agent is allowed to do
|
||||||
|
- Suggest edits, create focused patches, and propose new files following repo style.
|
||||||
|
- Use `apply_patch` for file edits; create new files only when necessary.
|
||||||
|
- Run or suggest commands to run tests locally, but do not push or merge without human approval.
|
||||||
|
|
||||||
|
Conventions & expectations
|
||||||
|
- Follow existing code style and directory boundaries (`backend/` for Flask/Python, `frontend/` for Vite/TypeScript).
|
||||||
|
- When changing behavior, run tests and list the commands to reproduce the failure/fix.
|
||||||
|
- Keep PRs small and target a single logical change.
|
||||||
|
|
||||||
|
Key files & links (authoritative sources)
|
||||||
|
- README: [README.md](README.md#L1)
|
||||||
|
- Contribution & tests: [CONTRIBUTING.md](CONTRIBUTING.md#L1)
|
||||||
|
- Docker & run commands: [docs/Docker-Commands-Guide.md](docs/Docker-Commands-Guide.md#L1)
|
||||||
|
- Backend entry & requirements: [backend/requirements.txt](backend/requirements.txt#L1), [backend/Dockerfile](backend/Dockerfile#L1)
|
||||||
|
- Frontend scripts: [frontend/package.json](frontend/package.json#L1), [frontend/Dockerfile](frontend/Dockerfile#L1)
|
||||||
|
- Compose files: [docker-compose.yml](docker-compose.yml#L1), [docker-compose.prod.yml](docker-compose.prod.yml#L1)
|
||||||
|
- Deployment scripts: [scripts/deploy.sh](scripts/deploy.sh#L1)
|
||||||
|
|
||||||
|
Common build & test commands
|
||||||
|
- Backend tests (project root):
|
||||||
|
```
|
||||||
|
cd backend && python -m pytest tests/ -q
|
||||||
|
```
|
||||||
|
- Frontend dev & tests:
|
||||||
|
```
|
||||||
|
cd frontend && npm install
|
||||||
|
cd frontend && npm run dev
|
||||||
|
cd frontend && npx vitest run
|
||||||
|
```
|
||||||
|
- Dev compose (full stack):
|
||||||
|
```
|
||||||
|
docker compose up --build
|
||||||
|
```
|
||||||
|
- Prod deploy (refer to `scripts/deploy.sh`):
|
||||||
|
```
|
||||||
|
./scripts/deploy.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Anti-patterns (avoid)
|
||||||
|
- Don't invent architectural decisions or rewrite large areas without explicit approval.
|
||||||
|
- Don't add secrets, large binary files, or unrelated formatting changes.
|
||||||
|
- Don't run destructive commands or modify CI/CD configuration without coordination.
|
||||||
|
|
||||||
|
Agent prompts & examples
|
||||||
|
- "Create a small Flask route in `backend/app/routes` that returns health JSON and add a unit test."
|
||||||
|
- "Refactor the image compression service to extract a helper; update callers and tests."
|
||||||
|
- "List the exact commands I should run to reproduce the failing tests for `backend/tests/test_pdf_service.py`."
|
||||||
|
|
||||||
|
Suggested follow-ups (agent customizations)
|
||||||
|
- `create-agent:backend` — focused on Python/Flask edits, runs `pytest`, and knows `backend/` structure.
|
||||||
|
- `create-agent:frontend` — focused on Vite/TypeScript, runs `vitest`, and uses `npm` scripts.
|
||||||
|
- `create-agent:ci` — analyzes `docker-compose.yml` and `scripts/deploy.sh`, suggests CI checks and smoke tests.
|
||||||
|
|
||||||
|
If you want, I can:
|
||||||
|
- Open a draft PR with this file, or
|
||||||
|
- Expand the file with more precise command snippets and per-service README links.
|
||||||
|
|
||||||
|
---
|
||||||
|
Generated by a workspace bootstrap; iterate as needed.
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
"""Flask Application Factory."""
|
"""Flask Application Factory."""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
|
||||||
from flask import Flask, jsonify
|
from flask import Flask, jsonify
|
||||||
@@ -11,7 +12,12 @@ from app.services.ai_cost_service import init_ai_cost_db
|
|||||||
from app.services.site_assistant_service import init_site_assistant_db
|
from app.services.site_assistant_service import init_site_assistant_db
|
||||||
from app.services.contact_service import init_contact_db
|
from app.services.contact_service import init_contact_db
|
||||||
from app.services.stripe_service import init_stripe_db
|
from app.services.stripe_service import init_stripe_db
|
||||||
from app.utils.csrf import CSRFError, apply_csrf_cookie, should_enforce_csrf, validate_csrf_request
|
from app.utils.csrf import (
|
||||||
|
CSRFError,
|
||||||
|
apply_csrf_cookie,
|
||||||
|
should_enforce_csrf,
|
||||||
|
validate_csrf_request,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _init_sentry(app):
|
def _init_sentry(app):
|
||||||
@@ -35,13 +41,15 @@ def _init_sentry(app):
|
|||||||
app.logger.warning("sentry-sdk not installed — monitoring disabled.")
|
app.logger.warning("sentry-sdk not installed — monitoring disabled.")
|
||||||
|
|
||||||
|
|
||||||
def create_app(config_name=None):
|
def create_app(config_name=None, config_overrides=None):
|
||||||
"""Create and configure the Flask application."""
|
"""Create and configure the Flask application."""
|
||||||
if config_name is None:
|
if config_name is None:
|
||||||
config_name = os.getenv("FLASK_ENV", "development")
|
config_name = os.getenv("FLASK_ENV", "development")
|
||||||
|
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
app.config.from_object(config[config_name])
|
app.config.from_object(config[config_name])
|
||||||
|
if config_overrides:
|
||||||
|
app.config.update(config_overrides)
|
||||||
|
|
||||||
# Initialize Sentry early
|
# Initialize Sentry early
|
||||||
_init_sentry(app)
|
_init_sentry(app)
|
||||||
|
|||||||
@@ -1,11 +1,6 @@
|
|||||||
"""File validation utilities — multi-layer security checks."""
|
"""File validation utilities — multi-layer security checks."""
|
||||||
import os
|
|
||||||
|
|
||||||
try:
|
import os
|
||||||
import magic
|
|
||||||
HAS_MAGIC = True
|
|
||||||
except (ImportError, OSError):
|
|
||||||
HAS_MAGIC = False
|
|
||||||
|
|
||||||
from flask import current_app
|
from flask import current_app
|
||||||
from werkzeug.utils import secure_filename
|
from werkzeug.utils import secure_filename
|
||||||
@@ -45,30 +40,60 @@ def validate_file(
|
|||||||
if not file_storage or file_storage.filename == "":
|
if not file_storage or file_storage.filename == "":
|
||||||
raise FileValidationError("No file provided.")
|
raise FileValidationError("No file provided.")
|
||||||
|
|
||||||
filename = secure_filename(file_storage.filename)
|
raw_filename = str(file_storage.filename).strip()
|
||||||
if not filename:
|
if not raw_filename:
|
||||||
raise FileValidationError("Invalid filename.")
|
raise FileValidationError("No file provided.")
|
||||||
|
|
||||||
# Layer 2: Check file extension against whitelist
|
filename = secure_filename(raw_filename)
|
||||||
ext = _get_extension(filename)
|
|
||||||
allowed_extensions = config.get("ALLOWED_EXTENSIONS", {})
|
allowed_extensions = config.get("ALLOWED_EXTENSIONS", {})
|
||||||
|
|
||||||
if allowed_types:
|
if allowed_types:
|
||||||
valid_extensions = {k: v for k, v in allowed_extensions.items() if k in allowed_types}
|
valid_extensions = {
|
||||||
|
k: v for k, v in allowed_extensions.items() if k in allowed_types
|
||||||
|
}
|
||||||
else:
|
else:
|
||||||
valid_extensions = allowed_extensions
|
valid_extensions = allowed_extensions
|
||||||
|
|
||||||
|
# Layer 2: Reject clearly invalid extensions before touching file streams.
|
||||||
|
ext = _get_extension(raw_filename) or _get_extension(filename)
|
||||||
|
if ext and ext not in valid_extensions:
|
||||||
|
raise FileValidationError(
|
||||||
|
f"File type '.{ext}' is not allowed. "
|
||||||
|
f"Allowed types: {', '.join(valid_extensions.keys())}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Layer 3: Check basic file size and header first so we can recover
|
||||||
|
# from malformed filenames like ".pdf" or "." using content sniffing.
|
||||||
|
file_storage.seek(0, os.SEEK_END)
|
||||||
|
file_size = file_storage.tell()
|
||||||
|
file_storage.seek(0)
|
||||||
|
|
||||||
|
if file_size == 0:
|
||||||
|
raise FileValidationError("File is empty.")
|
||||||
|
|
||||||
|
file_header = file_storage.read(8192)
|
||||||
|
file_storage.seek(0)
|
||||||
|
|
||||||
|
detected_mime = _detect_mime(file_header)
|
||||||
|
|
||||||
|
if not ext:
|
||||||
|
ext = _infer_extension_from_content(
|
||||||
|
file_header, detected_mime, valid_extensions
|
||||||
|
)
|
||||||
|
|
||||||
|
if raw_filename.startswith(".") and not _get_extension(filename):
|
||||||
|
filename = ""
|
||||||
|
|
||||||
|
if not filename:
|
||||||
|
filename = f"upload.{ext}" if ext else "upload"
|
||||||
|
|
||||||
if ext not in valid_extensions:
|
if ext not in valid_extensions:
|
||||||
raise FileValidationError(
|
raise FileValidationError(
|
||||||
f"File type '.{ext}' is not allowed. "
|
f"File type '.{ext}' is not allowed. "
|
||||||
f"Allowed types: {', '.join(valid_extensions.keys())}"
|
f"Allowed types: {', '.join(valid_extensions.keys())}"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Layer 3: Check file size against type-specific limits
|
# Layer 4: Check file size against type-specific limits
|
||||||
file_storage.seek(0, os.SEEK_END)
|
|
||||||
file_size = file_storage.tell()
|
|
||||||
file_storage.seek(0)
|
|
||||||
|
|
||||||
size_limits = size_limit_overrides or config.get("FILE_SIZE_LIMITS", {})
|
size_limits = size_limit_overrides or config.get("FILE_SIZE_LIMITS", {})
|
||||||
max_size = size_limits.get(ext, 20 * 1024 * 1024) # Default 20MB
|
max_size = size_limits.get(ext, 20 * 1024 * 1024) # Default 20MB
|
||||||
|
|
||||||
@@ -78,15 +103,8 @@ def validate_file(
|
|||||||
f"File too large. Maximum size for .{ext} files is {max_mb:.0f}MB."
|
f"File too large. Maximum size for .{ext} files is {max_mb:.0f}MB."
|
||||||
)
|
)
|
||||||
|
|
||||||
if file_size == 0:
|
# Layer 5: Check MIME type using magic bytes (if libmagic is available)
|
||||||
raise FileValidationError("File is empty.")
|
if detected_mime:
|
||||||
|
|
||||||
# Layer 4: Check MIME type using magic bytes (if libmagic is available)
|
|
||||||
file_header = file_storage.read(8192)
|
|
||||||
file_storage.seek(0)
|
|
||||||
|
|
||||||
if HAS_MAGIC:
|
|
||||||
detected_mime = magic.from_buffer(file_header, mime=True)
|
|
||||||
expected_mimes = valid_extensions.get(ext, [])
|
expected_mimes = valid_extensions.get(ext, [])
|
||||||
|
|
||||||
if detected_mime not in expected_mimes:
|
if detected_mime not in expected_mimes:
|
||||||
@@ -95,7 +113,7 @@ def validate_file(
|
|||||||
f"Detected type: {detected_mime}"
|
f"Detected type: {detected_mime}"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Layer 5: Additional content checks for specific types
|
# Layer 6: Additional content checks for specific types
|
||||||
if ext == "pdf":
|
if ext == "pdf":
|
||||||
_check_pdf_safety(file_header)
|
_check_pdf_safety(file_header)
|
||||||
|
|
||||||
@@ -104,9 +122,52 @@ def validate_file(
|
|||||||
|
|
||||||
def _get_extension(filename: str) -> str:
|
def _get_extension(filename: str) -> str:
|
||||||
"""Extract and normalize file extension."""
|
"""Extract and normalize file extension."""
|
||||||
if "." not in filename:
|
filename = str(filename or "").strip()
|
||||||
|
if not filename or "." not in filename:
|
||||||
|
return ""
|
||||||
|
stem, ext = filename.rsplit(".", 1)
|
||||||
|
if not ext:
|
||||||
|
return ""
|
||||||
|
if not stem and filename.startswith("."):
|
||||||
|
return ext.lower()
|
||||||
|
return ext.lower()
|
||||||
|
|
||||||
|
|
||||||
|
def _detect_mime(file_header: bytes) -> str | None:
|
||||||
|
"""Detect MIME type lazily so environments without libmagic stay usable."""
|
||||||
|
try:
|
||||||
|
import magic as magic_module
|
||||||
|
except (ImportError, OSError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
return magic_module.from_buffer(file_header, mime=True)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _infer_extension_from_content(
|
||||||
|
file_header: bytes,
|
||||||
|
detected_mime: str | None,
|
||||||
|
valid_extensions: dict[str, list[str]],
|
||||||
|
) -> str:
|
||||||
|
"""Infer a safe extension from MIME type or common signatures."""
|
||||||
|
if detected_mime:
|
||||||
|
for ext, mimes in valid_extensions.items():
|
||||||
|
if detected_mime in mimes:
|
||||||
|
return ext
|
||||||
|
|
||||||
|
signature_map = {
|
||||||
|
b"%PDF": "pdf",
|
||||||
|
b"\x89PNG\r\n\x1a\n": "png",
|
||||||
|
b"\xff\xd8\xff": "jpg",
|
||||||
|
b"RIFF": "webp",
|
||||||
|
}
|
||||||
|
for signature, ext in signature_map.items():
|
||||||
|
if file_header.startswith(signature) and ext in valid_extensions:
|
||||||
|
return ext
|
||||||
|
|
||||||
return ""
|
return ""
|
||||||
return filename.rsplit(".", 1)[1].lower()
|
|
||||||
|
|
||||||
|
|
||||||
def _check_pdf_safety(file_header: bytes):
|
def _check_pdf_safety(file_header: bytes):
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"""Tests for file validation utility."""
|
"""Tests for file validation utility."""
|
||||||
|
|
||||||
import io
|
import io
|
||||||
from unittest.mock import patch, MagicMock
|
from unittest.mock import MagicMock
|
||||||
from app.utils.file_validator import validate_file, FileValidationError
|
from app.utils.file_validator import validate_file, FileValidationError
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
@@ -16,7 +17,7 @@ class TestFileValidator:
|
|||||||
"""Should raise when filename is empty."""
|
"""Should raise when filename is empty."""
|
||||||
with app.app_context():
|
with app.app_context():
|
||||||
mock_file = MagicMock()
|
mock_file = MagicMock()
|
||||||
mock_file.filename = ''
|
mock_file.filename = ""
|
||||||
with pytest.raises(FileValidationError, match="No file provided"):
|
with pytest.raises(FileValidationError, match="No file provided"):
|
||||||
validate_file(mock_file, allowed_types=["pdf"])
|
validate_file(mock_file, allowed_types=["pdf"])
|
||||||
|
|
||||||
@@ -24,16 +25,16 @@ class TestFileValidator:
|
|||||||
"""Should raise when file extension is not allowed."""
|
"""Should raise when file extension is not allowed."""
|
||||||
with app.app_context():
|
with app.app_context():
|
||||||
mock_file = MagicMock()
|
mock_file = MagicMock()
|
||||||
mock_file.filename = 'test.exe'
|
mock_file.filename = "test.exe"
|
||||||
with pytest.raises(FileValidationError, match="not allowed"):
|
with pytest.raises(FileValidationError, match="not allowed"):
|
||||||
validate_file(mock_file, allowed_types=["pdf"])
|
validate_file(mock_file, allowed_types=["pdf"])
|
||||||
|
|
||||||
def test_empty_file_raises(self, app):
|
def test_empty_file_raises(self, app):
|
||||||
"""Should raise when file is empty (0 bytes)."""
|
"""Should raise when file is empty (0 bytes)."""
|
||||||
with app.app_context():
|
with app.app_context():
|
||||||
content = io.BytesIO(b'')
|
content = io.BytesIO(b"")
|
||||||
mock_file = MagicMock()
|
mock_file = MagicMock()
|
||||||
mock_file.filename = 'test.pdf'
|
mock_file.filename = "test.pdf"
|
||||||
mock_file.seek = content.seek
|
mock_file.seek = content.seek
|
||||||
mock_file.tell = content.tell
|
mock_file.tell = content.tell
|
||||||
mock_file.read = content.read
|
mock_file.read = content.read
|
||||||
@@ -43,93 +44,150 @@ class TestFileValidator:
|
|||||||
def test_valid_pdf_passes(self, app):
|
def test_valid_pdf_passes(self, app):
|
||||||
"""Should accept valid PDF file with correct magic bytes."""
|
"""Should accept valid PDF file with correct magic bytes."""
|
||||||
with app.app_context():
|
with app.app_context():
|
||||||
pdf_bytes = b'%PDF-1.4 test content' + b'\x00' * 8192
|
pdf_bytes = b"%PDF-1.4 test content" + b"\x00" * 8192
|
||||||
content = io.BytesIO(pdf_bytes)
|
content = io.BytesIO(pdf_bytes)
|
||||||
|
|
||||||
mock_file = MagicMock()
|
mock_file = MagicMock()
|
||||||
mock_file.filename = 'document.pdf'
|
mock_file.filename = "document.pdf"
|
||||||
mock_file.seek = content.seek
|
mock_file.seek = content.seek
|
||||||
mock_file.tell = content.tell
|
mock_file.tell = content.tell
|
||||||
mock_file.read = content.read
|
mock_file.read = content.read
|
||||||
|
|
||||||
with patch('app.utils.file_validator.HAS_MAGIC', True), patch(
|
with pytest.MonkeyPatch.context() as monkeypatch:
|
||||||
'app.utils.file_validator.magic', create=True
|
monkeypatch.setattr(
|
||||||
) as mock_magic:
|
"app.utils.file_validator._detect_mime",
|
||||||
mock_magic.from_buffer.return_value = 'application/pdf'
|
lambda _header: "application/pdf",
|
||||||
|
)
|
||||||
filename, ext = validate_file(mock_file, allowed_types=["pdf"])
|
filename, ext = validate_file(mock_file, allowed_types=["pdf"])
|
||||||
|
|
||||||
assert filename == 'document.pdf'
|
assert filename == "document.pdf"
|
||||||
assert ext == 'pdf'
|
assert ext == "pdf"
|
||||||
|
|
||||||
def test_valid_html_passes(self, app):
|
def test_valid_html_passes(self, app):
|
||||||
"""Should accept valid HTML file with correct MIME type."""
|
"""Should accept valid HTML file with correct MIME type."""
|
||||||
with app.app_context():
|
with app.app_context():
|
||||||
html_bytes = b'<!doctype html><html><body>Hello</body></html>'
|
html_bytes = b"<!doctype html><html><body>Hello</body></html>"
|
||||||
content = io.BytesIO(html_bytes)
|
content = io.BytesIO(html_bytes)
|
||||||
|
|
||||||
mock_file = MagicMock()
|
mock_file = MagicMock()
|
||||||
mock_file.filename = 'page.html'
|
mock_file.filename = "page.html"
|
||||||
mock_file.seek = content.seek
|
mock_file.seek = content.seek
|
||||||
mock_file.tell = content.tell
|
mock_file.tell = content.tell
|
||||||
mock_file.read = content.read
|
mock_file.read = content.read
|
||||||
|
|
||||||
with patch('app.utils.file_validator.HAS_MAGIC', True), patch(
|
with pytest.MonkeyPatch.context() as monkeypatch:
|
||||||
'app.utils.file_validator.magic', create=True
|
monkeypatch.setattr(
|
||||||
) as mock_magic:
|
"app.utils.file_validator._detect_mime",
|
||||||
mock_magic.from_buffer.return_value = 'text/html'
|
lambda _header: "text/html",
|
||||||
|
)
|
||||||
filename, ext = validate_file(mock_file, allowed_types=["html", "htm"])
|
filename, ext = validate_file(mock_file, allowed_types=["html", "htm"])
|
||||||
|
|
||||||
assert filename == 'page.html'
|
assert filename == "page.html"
|
||||||
assert ext == 'html'
|
assert ext == "html"
|
||||||
|
|
||||||
def test_mime_mismatch_raises(self, app):
|
def test_mime_mismatch_raises(self, app):
|
||||||
"""Should raise when MIME type doesn't match extension."""
|
"""Should raise when MIME type doesn't match extension."""
|
||||||
with app.app_context():
|
with app.app_context():
|
||||||
content = io.BytesIO(b'not a real pdf' + b'\x00' * 8192)
|
content = io.BytesIO(b"not a real pdf" + b"\x00" * 8192)
|
||||||
|
|
||||||
mock_file = MagicMock()
|
mock_file = MagicMock()
|
||||||
mock_file.filename = 'fake.pdf'
|
mock_file.filename = "fake.pdf"
|
||||||
mock_file.seek = content.seek
|
mock_file.seek = content.seek
|
||||||
mock_file.tell = content.tell
|
mock_file.tell = content.tell
|
||||||
mock_file.read = content.read
|
mock_file.read = content.read
|
||||||
|
|
||||||
with patch('app.utils.file_validator.HAS_MAGIC', True), patch(
|
with pytest.MonkeyPatch.context() as monkeypatch:
|
||||||
'app.utils.file_validator.magic', create=True
|
monkeypatch.setattr(
|
||||||
) as mock_magic:
|
"app.utils.file_validator._detect_mime",
|
||||||
mock_magic.from_buffer.return_value = 'text/plain'
|
lambda _header: "text/plain",
|
||||||
|
)
|
||||||
with pytest.raises(FileValidationError, match="does not match"):
|
with pytest.raises(FileValidationError, match="does not match"):
|
||||||
validate_file(mock_file, allowed_types=["pdf"])
|
validate_file(mock_file, allowed_types=["pdf"])
|
||||||
|
|
||||||
def test_file_too_large_raises(self, app):
|
def test_file_too_large_raises(self, app):
|
||||||
"""Should raise when file exceeds size limit."""
|
"""Should raise when file exceeds size limit."""
|
||||||
with app.app_context():
|
with app.app_context():
|
||||||
# Create a file larger than the PDF size limit (20MB)
|
# Use a small override to keep the test stable on Windows/Python 3.13.
|
||||||
large_content = io.BytesIO(b'%PDF-1.4' + b'\x00' * (21 * 1024 * 1024))
|
large_content = io.BytesIO(b"%PDF-1.4" + b"\x00" * 2048)
|
||||||
|
|
||||||
mock_file = MagicMock()
|
mock_file = MagicMock()
|
||||||
mock_file.filename = 'large.pdf'
|
mock_file.filename = "large.pdf"
|
||||||
mock_file.seek = large_content.seek
|
mock_file.seek = large_content.seek
|
||||||
mock_file.tell = large_content.tell
|
mock_file.tell = large_content.tell
|
||||||
mock_file.read = large_content.read
|
mock_file.read = large_content.read
|
||||||
|
|
||||||
|
with pytest.MonkeyPatch.context() as monkeypatch:
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"app.utils.file_validator._detect_mime",
|
||||||
|
lambda _header: "application/pdf",
|
||||||
|
)
|
||||||
with pytest.raises(FileValidationError, match="too large"):
|
with pytest.raises(FileValidationError, match="too large"):
|
||||||
validate_file(mock_file, allowed_types=["pdf"])
|
validate_file(
|
||||||
|
mock_file,
|
||||||
|
allowed_types=["pdf"],
|
||||||
|
size_limit_overrides={"pdf": 1024},
|
||||||
|
)
|
||||||
|
|
||||||
def test_dangerous_pdf_raises(self, app):
|
def test_dangerous_pdf_raises(self, app):
|
||||||
"""Should raise when PDF contains dangerous patterns."""
|
"""Should raise when PDF contains dangerous patterns."""
|
||||||
with app.app_context():
|
with app.app_context():
|
||||||
pdf_bytes = b'%PDF-1.4 /JavaScript evil_code' + b'\x00' * 8192
|
pdf_bytes = b"%PDF-1.4 /JavaScript evil_code" + b"\x00" * 8192
|
||||||
content = io.BytesIO(pdf_bytes)
|
content = io.BytesIO(pdf_bytes)
|
||||||
|
|
||||||
mock_file = MagicMock()
|
mock_file = MagicMock()
|
||||||
mock_file.filename = 'evil.pdf'
|
mock_file.filename = "evil.pdf"
|
||||||
mock_file.seek = content.seek
|
mock_file.seek = content.seek
|
||||||
mock_file.tell = content.tell
|
mock_file.tell = content.tell
|
||||||
mock_file.read = content.read
|
mock_file.read = content.read
|
||||||
|
|
||||||
with patch('app.utils.file_validator.HAS_MAGIC', True), patch(
|
with pytest.MonkeyPatch.context() as monkeypatch:
|
||||||
'app.utils.file_validator.magic', create=True
|
monkeypatch.setattr(
|
||||||
) as mock_magic:
|
"app.utils.file_validator._detect_mime",
|
||||||
mock_magic.from_buffer.return_value = 'application/pdf'
|
lambda _header: "application/pdf",
|
||||||
|
)
|
||||||
with pytest.raises(FileValidationError, match="unsafe"):
|
with pytest.raises(FileValidationError, match="unsafe"):
|
||||||
validate_file(mock_file, allowed_types=["pdf"])
|
validate_file(mock_file, allowed_types=["pdf"])
|
||||||
|
|
||||||
|
def test_pdf_with_missing_extension_name_is_inferred(self, app):
|
||||||
|
"""Should infer PDF extension from content when filename lacks one."""
|
||||||
|
with app.app_context():
|
||||||
|
pdf_bytes = b"%PDF-1.4 test content" + b"\x00" * 8192
|
||||||
|
content = io.BytesIO(pdf_bytes)
|
||||||
|
|
||||||
|
mock_file = MagicMock()
|
||||||
|
mock_file.filename = "."
|
||||||
|
mock_file.seek = content.seek
|
||||||
|
mock_file.tell = content.tell
|
||||||
|
mock_file.read = content.read
|
||||||
|
|
||||||
|
with pytest.MonkeyPatch.context() as monkeypatch:
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"app.utils.file_validator._detect_mime",
|
||||||
|
lambda _header: "application/pdf",
|
||||||
|
)
|
||||||
|
filename, ext = validate_file(mock_file, allowed_types=["pdf"])
|
||||||
|
|
||||||
|
assert filename == "upload.pdf"
|
||||||
|
assert ext == "pdf"
|
||||||
|
|
||||||
|
def test_pdf_hidden_filename_keeps_pdf_extension(self, app):
|
||||||
|
"""Should preserve .pdf from hidden-style filenames like .pdf."""
|
||||||
|
with app.app_context():
|
||||||
|
pdf_bytes = b"%PDF-1.4 test content" + b"\x00" * 8192
|
||||||
|
content = io.BytesIO(pdf_bytes)
|
||||||
|
|
||||||
|
mock_file = MagicMock()
|
||||||
|
mock_file.filename = ".pdf"
|
||||||
|
mock_file.seek = content.seek
|
||||||
|
mock_file.tell = content.tell
|
||||||
|
mock_file.read = content.read
|
||||||
|
|
||||||
|
with pytest.MonkeyPatch.context() as monkeypatch:
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"app.utils.file_validator._detect_mime",
|
||||||
|
lambda _header: "application/pdf",
|
||||||
|
)
|
||||||
|
filename, ext = validate_file(mock_file, allowed_types=["pdf"])
|
||||||
|
|
||||||
|
assert filename == "upload.pdf"
|
||||||
|
assert ext == "pdf"
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
"""Tests for rate limiting middleware."""
|
"""Tests for rate limiting middleware."""
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from app import create_app
|
from app import create_app
|
||||||
|
from tests.conftest import CSRFTestClient
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
@@ -11,33 +13,24 @@ def rate_limited_app(tmp_path):
|
|||||||
never throttled. Here we force the extension's internal flag back to
|
never throttled. Here we force the extension's internal flag back to
|
||||||
True *after* init_app so the decorator limits are enforced.
|
True *after* init_app so the decorator limits are enforced.
|
||||||
"""
|
"""
|
||||||
app = create_app('testing')
|
app = create_app(
|
||||||
app.config.update({
|
"testing",
|
||||||
'TESTING': True,
|
{
|
||||||
'RATELIMIT_STORAGE_URI': 'memory://',
|
"TESTING": True,
|
||||||
'UPLOAD_FOLDER': str(tmp_path / 'uploads'),
|
"RATELIMIT_ENABLED": True,
|
||||||
'OUTPUT_FOLDER': str(tmp_path / 'outputs'),
|
"RATELIMIT_STORAGE_URI": "memory://",
|
||||||
})
|
"UPLOAD_FOLDER": str(tmp_path / "uploads"),
|
||||||
|
"OUTPUT_FOLDER": str(tmp_path / "outputs"),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
app.test_client_class = CSRFTestClient
|
||||||
import os
|
import os
|
||||||
os.makedirs(app.config['UPLOAD_FOLDER'], exist_ok=True)
|
|
||||||
os.makedirs(app.config['OUTPUT_FOLDER'], exist_ok=True)
|
|
||||||
|
|
||||||
# flask-limiter 3.x returns from init_app immediately when
|
os.makedirs(app.config["UPLOAD_FOLDER"], exist_ok=True)
|
||||||
# RATELIMIT_ENABLED=False (TestingConfig default), so `initialized`
|
os.makedirs(app.config["OUTPUT_FOLDER"], exist_ok=True)
|
||||||
# stays False and no limits are enforced. We override the config key
|
|
||||||
# and call init_app a SECOND time so the extension fully initialises.
|
|
||||||
# It is safe to call twice — flask-limiter guards against duplicate
|
|
||||||
# before_request hook registration via app.extensions["limiter"].
|
|
||||||
from app.extensions import limiter as _limiter
|
|
||||||
app.config['RATELIMIT_ENABLED'] = True
|
|
||||||
_limiter.init_app(app) # second call — now RATELIMIT_ENABLED=True
|
|
||||||
|
|
||||||
yield app
|
yield app
|
||||||
|
|
||||||
# Restore so other tests are unaffected
|
|
||||||
_limiter.enabled = False
|
|
||||||
_limiter.initialized = False
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def rate_limited_client(rate_limited_app):
|
def rate_limited_client(rate_limited_app):
|
||||||
@@ -48,12 +41,12 @@ class TestRateLimiter:
|
|||||||
def test_health_endpoint_not_rate_limited(self, client):
|
def test_health_endpoint_not_rate_limited(self, client):
|
||||||
"""Health endpoint should handle many rapid requests."""
|
"""Health endpoint should handle many rapid requests."""
|
||||||
for _ in range(20):
|
for _ in range(20):
|
||||||
response = client.get('/api/health')
|
response = client.get("/api/health")
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
|
||||||
def test_rate_limit_header_present(self, client):
|
def test_rate_limit_header_present(self, client):
|
||||||
"""Response should include a valid HTTP status code."""
|
"""Response should include a valid HTTP status code."""
|
||||||
response = client.get('/api/health')
|
response = client.get("/api/health")
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
@@ -68,7 +61,7 @@ class TestRateLimitEnforcement:
|
|||||||
"""
|
"""
|
||||||
blocked = False
|
blocked = False
|
||||||
for i in range(15):
|
for i in range(15):
|
||||||
r = rate_limited_client.post('/api/compress/pdf')
|
r = rate_limited_client.post("/api/compress/pdf")
|
||||||
if r.status_code == 429:
|
if r.status_code == 429:
|
||||||
blocked = True
|
blocked = True
|
||||||
break
|
break
|
||||||
@@ -81,7 +74,7 @@ class TestRateLimitEnforcement:
|
|||||||
"""POST /api/convert/pdf-to-word is also rate-limited."""
|
"""POST /api/convert/pdf-to-word is also rate-limited."""
|
||||||
blocked = False
|
blocked = False
|
||||||
for _ in range(15):
|
for _ in range(15):
|
||||||
r = rate_limited_client.post('/api/convert/pdf-to-word')
|
r = rate_limited_client.post("/api/convert/pdf-to-word")
|
||||||
if r.status_code == 429:
|
if r.status_code == 429:
|
||||||
blocked = True
|
blocked = True
|
||||||
break
|
break
|
||||||
@@ -94,8 +87,8 @@ class TestRateLimitEnforcement:
|
|||||||
"""
|
"""
|
||||||
# Exhaust compress limit
|
# Exhaust compress limit
|
||||||
for _ in range(15):
|
for _ in range(15):
|
||||||
rate_limited_client.post('/api/compress/pdf')
|
rate_limited_client.post("/api/compress/pdf")
|
||||||
|
|
||||||
# Health should still respond normally
|
# Health should still respond normally
|
||||||
r = rate_limited_client.get('/api/health')
|
r = rate_limited_client.get("/api/health")
|
||||||
assert r.status_code == 200
|
assert r.status_code == 200
|
||||||
@@ -7,12 +7,13 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<meta name="description"
|
<meta name="description"
|
||||||
content="Free online tools for PDF, image, video, and text processing. Merge, split, compress, convert, watermark, protect & more — instantly." />
|
content="Free online tools for PDF, image, video, and text processing. Merge, split, compress, convert, watermark, protect & more — instantly." />
|
||||||
|
<meta name="application-name" content="Dociva" />
|
||||||
|
<meta name="apple-mobile-web-app-title" content="Dociva" />
|
||||||
|
<meta name="theme-color" content="#2563eb" />
|
||||||
<meta name="google-site-verification" content="tx9YptvPfrvb115PeFBWpYpRhw_4CYHQXzpLKNXXV20" />
|
<meta name="google-site-verification" content="tx9YptvPfrvb115PeFBWpYpRhw_4CYHQXzpLKNXXV20" />
|
||||||
<meta name="msvalidate.01" content="65E1161EF971CA2810FE8EABB5F229B4" />
|
<meta name="msvalidate.01" content="65E1161EF971CA2810FE8EABB5F229B4" />
|
||||||
<meta name="keywords"
|
|
||||||
content="PDF tools, merge PDF, split PDF, compress PDF, PDF to Word, image converter, free online tools, Arabic PDF tools" />
|
|
||||||
<meta name="author" content="Dociva" />
|
<meta name="author" content="Dociva" />
|
||||||
<meta name="robots" content="index, follow" />
|
<meta name="robots" content="index,follow,max-image-preview:large,max-snippet:-1,max-video-preview:-1" />
|
||||||
<meta property="og:type" content="website" />
|
<meta property="og:type" content="website" />
|
||||||
<meta property="og:title" content="Dociva — Free Online File Tools" />
|
<meta property="og:title" content="Dociva — Free Online File Tools" />
|
||||||
<meta property="og:description"
|
<meta property="og:description"
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"prebuild": "node scripts/merge-keywords.mjs && node scripts/generate-seo-assets.mjs",
|
"prebuild": "node scripts/merge-keywords.mjs && node scripts/generate-seo-assets.mjs",
|
||||||
"build": "tsc --noEmit && vite build",
|
"build": "tsc --noEmit && vite build && node scripts/render-seo-shells.mjs",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
|
|||||||
33
frontend/public/sitemaps/blog.xml
Normal file
33
frontend/public/sitemaps/blog.xml
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||||
|
<url>
|
||||||
|
<loc>https://dociva.io/blog/how-to-compress-pdf-online</loc>
|
||||||
|
<lastmod>2026-03-29</lastmod>
|
||||||
|
<changefreq>monthly</changefreq>
|
||||||
|
<priority>0.6</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://dociva.io/blog/convert-images-without-losing-quality</loc>
|
||||||
|
<lastmod>2026-03-29</lastmod>
|
||||||
|
<changefreq>monthly</changefreq>
|
||||||
|
<priority>0.6</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://dociva.io/blog/ocr-extract-text-from-images</loc>
|
||||||
|
<lastmod>2026-03-29</lastmod>
|
||||||
|
<changefreq>monthly</changefreq>
|
||||||
|
<priority>0.6</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://dociva.io/blog/merge-split-pdf-files</loc>
|
||||||
|
<lastmod>2026-03-29</lastmod>
|
||||||
|
<changefreq>monthly</changefreq>
|
||||||
|
<priority>0.6</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://dociva.io/blog/ai-chat-with-pdf-documents</loc>
|
||||||
|
<lastmod>2026-03-29</lastmod>
|
||||||
|
<changefreq>monthly</changefreq>
|
||||||
|
<priority>0.6</priority>
|
||||||
|
</url>
|
||||||
|
</urlset>
|
||||||
1131
frontend/public/sitemaps/seo.xml
Normal file
1131
frontend/public/sitemaps/seo.xml
Normal file
File diff suppressed because it is too large
Load Diff
57
frontend/public/sitemaps/static.xml
Normal file
57
frontend/public/sitemaps/static.xml
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||||
|
<url>
|
||||||
|
<loc>https://dociva.io/</loc>
|
||||||
|
<lastmod>2026-03-29</lastmod>
|
||||||
|
<changefreq>daily</changefreq>
|
||||||
|
<priority>1.0</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://dociva.io/tools</loc>
|
||||||
|
<lastmod>2026-03-29</lastmod>
|
||||||
|
<changefreq>weekly</changefreq>
|
||||||
|
<priority>0.8</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://dociva.io/about</loc>
|
||||||
|
<lastmod>2026-03-29</lastmod>
|
||||||
|
<changefreq>monthly</changefreq>
|
||||||
|
<priority>0.4</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://dociva.io/contact</loc>
|
||||||
|
<lastmod>2026-03-29</lastmod>
|
||||||
|
<changefreq>monthly</changefreq>
|
||||||
|
<priority>0.4</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://dociva.io/privacy</loc>
|
||||||
|
<lastmod>2026-03-29</lastmod>
|
||||||
|
<changefreq>yearly</changefreq>
|
||||||
|
<priority>0.3</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://dociva.io/terms</loc>
|
||||||
|
<lastmod>2026-03-29</lastmod>
|
||||||
|
<changefreq>yearly</changefreq>
|
||||||
|
<priority>0.3</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://dociva.io/pricing</loc>
|
||||||
|
<lastmod>2026-03-29</lastmod>
|
||||||
|
<changefreq>monthly</changefreq>
|
||||||
|
<priority>0.7</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://dociva.io/blog</loc>
|
||||||
|
<lastmod>2026-03-29</lastmod>
|
||||||
|
<changefreq>weekly</changefreq>
|
||||||
|
<priority>0.6</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://dociva.io/developers</loc>
|
||||||
|
<lastmod>2026-03-29</lastmod>
|
||||||
|
<changefreq>monthly</changefreq>
|
||||||
|
<priority>0.5</priority>
|
||||||
|
</url>
|
||||||
|
</urlset>
|
||||||
267
frontend/public/sitemaps/tools.xml
Normal file
267
frontend/public/sitemaps/tools.xml
Normal file
@@ -0,0 +1,267 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||||
|
<url>
|
||||||
|
<loc>https://dociva.io/tools/pdf-to-word</loc>
|
||||||
|
<lastmod>2026-03-29</lastmod>
|
||||||
|
<changefreq>weekly</changefreq>
|
||||||
|
<priority>0.9</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://dociva.io/tools/word-to-pdf</loc>
|
||||||
|
<lastmod>2026-03-29</lastmod>
|
||||||
|
<changefreq>weekly</changefreq>
|
||||||
|
<priority>0.9</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://dociva.io/tools/compress-pdf</loc>
|
||||||
|
<lastmod>2026-03-29</lastmod>
|
||||||
|
<changefreq>weekly</changefreq>
|
||||||
|
<priority>0.9</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://dociva.io/tools/merge-pdf</loc>
|
||||||
|
<lastmod>2026-03-29</lastmod>
|
||||||
|
<changefreq>weekly</changefreq>
|
||||||
|
<priority>0.9</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://dociva.io/tools/split-pdf</loc>
|
||||||
|
<lastmod>2026-03-29</lastmod>
|
||||||
|
<changefreq>weekly</changefreq>
|
||||||
|
<priority>0.8</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://dociva.io/tools/rotate-pdf</loc>
|
||||||
|
<lastmod>2026-03-29</lastmod>
|
||||||
|
<changefreq>weekly</changefreq>
|
||||||
|
<priority>0.7</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://dociva.io/tools/pdf-to-images</loc>
|
||||||
|
<lastmod>2026-03-29</lastmod>
|
||||||
|
<changefreq>weekly</changefreq>
|
||||||
|
<priority>0.8</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://dociva.io/tools/images-to-pdf</loc>
|
||||||
|
<lastmod>2026-03-29</lastmod>
|
||||||
|
<changefreq>weekly</changefreq>
|
||||||
|
<priority>0.8</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://dociva.io/tools/watermark-pdf</loc>
|
||||||
|
<lastmod>2026-03-29</lastmod>
|
||||||
|
<changefreq>weekly</changefreq>
|
||||||
|
<priority>0.7</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://dociva.io/tools/protect-pdf</loc>
|
||||||
|
<lastmod>2026-03-29</lastmod>
|
||||||
|
<changefreq>weekly</changefreq>
|
||||||
|
<priority>0.8</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://dociva.io/tools/unlock-pdf</loc>
|
||||||
|
<lastmod>2026-03-29</lastmod>
|
||||||
|
<changefreq>weekly</changefreq>
|
||||||
|
<priority>0.8</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://dociva.io/tools/page-numbers</loc>
|
||||||
|
<lastmod>2026-03-29</lastmod>
|
||||||
|
<changefreq>weekly</changefreq>
|
||||||
|
<priority>0.7</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://dociva.io/tools/pdf-editor</loc>
|
||||||
|
<lastmod>2026-03-29</lastmod>
|
||||||
|
<changefreq>weekly</changefreq>
|
||||||
|
<priority>0.8</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://dociva.io/tools/pdf-flowchart</loc>
|
||||||
|
<lastmod>2026-03-29</lastmod>
|
||||||
|
<changefreq>weekly</changefreq>
|
||||||
|
<priority>0.7</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://dociva.io/tools/pdf-to-excel</loc>
|
||||||
|
<lastmod>2026-03-29</lastmod>
|
||||||
|
<changefreq>weekly</changefreq>
|
||||||
|
<priority>0.8</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://dociva.io/tools/remove-watermark-pdf</loc>
|
||||||
|
<lastmod>2026-03-29</lastmod>
|
||||||
|
<changefreq>weekly</changefreq>
|
||||||
|
<priority>0.7</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://dociva.io/tools/reorder-pdf</loc>
|
||||||
|
<lastmod>2026-03-29</lastmod>
|
||||||
|
<changefreq>weekly</changefreq>
|
||||||
|
<priority>0.7</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://dociva.io/tools/extract-pages</loc>
|
||||||
|
<lastmod>2026-03-29</lastmod>
|
||||||
|
<changefreq>weekly</changefreq>
|
||||||
|
<priority>0.7</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://dociva.io/tools/image-converter</loc>
|
||||||
|
<lastmod>2026-03-29</lastmod>
|
||||||
|
<changefreq>weekly</changefreq>
|
||||||
|
<priority>0.8</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://dociva.io/tools/image-resize</loc>
|
||||||
|
<lastmod>2026-03-29</lastmod>
|
||||||
|
<changefreq>weekly</changefreq>
|
||||||
|
<priority>0.8</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://dociva.io/tools/compress-image</loc>
|
||||||
|
<lastmod>2026-03-29</lastmod>
|
||||||
|
<changefreq>weekly</changefreq>
|
||||||
|
<priority>0.8</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://dociva.io/tools/ocr</loc>
|
||||||
|
<lastmod>2026-03-29</lastmod>
|
||||||
|
<changefreq>weekly</changefreq>
|
||||||
|
<priority>0.8</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://dociva.io/tools/remove-background</loc>
|
||||||
|
<lastmod>2026-03-29</lastmod>
|
||||||
|
<changefreq>weekly</changefreq>
|
||||||
|
<priority>0.8</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://dociva.io/tools/image-to-svg</loc>
|
||||||
|
<lastmod>2026-03-29</lastmod>
|
||||||
|
<changefreq>weekly</changefreq>
|
||||||
|
<priority>0.8</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://dociva.io/tools/html-to-pdf</loc>
|
||||||
|
<lastmod>2026-03-29</lastmod>
|
||||||
|
<changefreq>weekly</changefreq>
|
||||||
|
<priority>0.7</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://dociva.io/tools/chat-pdf</loc>
|
||||||
|
<lastmod>2026-03-29</lastmod>
|
||||||
|
<changefreq>weekly</changefreq>
|
||||||
|
<priority>0.8</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://dociva.io/tools/summarize-pdf</loc>
|
||||||
|
<lastmod>2026-03-29</lastmod>
|
||||||
|
<changefreq>weekly</changefreq>
|
||||||
|
<priority>0.8</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://dociva.io/tools/translate-pdf</loc>
|
||||||
|
<lastmod>2026-03-29</lastmod>
|
||||||
|
<changefreq>weekly</changefreq>
|
||||||
|
<priority>0.8</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://dociva.io/tools/extract-tables</loc>
|
||||||
|
<lastmod>2026-03-29</lastmod>
|
||||||
|
<changefreq>weekly</changefreq>
|
||||||
|
<priority>0.8</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://dociva.io/tools/qr-code</loc>
|
||||||
|
<lastmod>2026-03-29</lastmod>
|
||||||
|
<changefreq>weekly</changefreq>
|
||||||
|
<priority>0.7</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://dociva.io/tools/video-to-gif</loc>
|
||||||
|
<lastmod>2026-03-29</lastmod>
|
||||||
|
<changefreq>weekly</changefreq>
|
||||||
|
<priority>0.7</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://dociva.io/tools/word-counter</loc>
|
||||||
|
<lastmod>2026-03-29</lastmod>
|
||||||
|
<changefreq>weekly</changefreq>
|
||||||
|
<priority>0.6</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://dociva.io/tools/text-cleaner</loc>
|
||||||
|
<lastmod>2026-03-29</lastmod>
|
||||||
|
<changefreq>weekly</changefreq>
|
||||||
|
<priority>0.6</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://dociva.io/tools/pdf-to-pptx</loc>
|
||||||
|
<lastmod>2026-03-29</lastmod>
|
||||||
|
<changefreq>weekly</changefreq>
|
||||||
|
<priority>0.8</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://dociva.io/tools/excel-to-pdf</loc>
|
||||||
|
<lastmod>2026-03-29</lastmod>
|
||||||
|
<changefreq>weekly</changefreq>
|
||||||
|
<priority>0.8</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://dociva.io/tools/pptx-to-pdf</loc>
|
||||||
|
<lastmod>2026-03-29</lastmod>
|
||||||
|
<changefreq>weekly</changefreq>
|
||||||
|
<priority>0.8</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://dociva.io/tools/sign-pdf</loc>
|
||||||
|
<lastmod>2026-03-29</lastmod>
|
||||||
|
<changefreq>weekly</changefreq>
|
||||||
|
<priority>0.8</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://dociva.io/tools/crop-pdf</loc>
|
||||||
|
<lastmod>2026-03-29</lastmod>
|
||||||
|
<changefreq>weekly</changefreq>
|
||||||
|
<priority>0.7</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://dociva.io/tools/flatten-pdf</loc>
|
||||||
|
<lastmod>2026-03-29</lastmod>
|
||||||
|
<changefreq>weekly</changefreq>
|
||||||
|
<priority>0.7</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://dociva.io/tools/repair-pdf</loc>
|
||||||
|
<lastmod>2026-03-29</lastmod>
|
||||||
|
<changefreq>weekly</changefreq>
|
||||||
|
<priority>0.7</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://dociva.io/tools/pdf-metadata</loc>
|
||||||
|
<lastmod>2026-03-29</lastmod>
|
||||||
|
<changefreq>weekly</changefreq>
|
||||||
|
<priority>0.6</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://dociva.io/tools/image-crop</loc>
|
||||||
|
<lastmod>2026-03-29</lastmod>
|
||||||
|
<changefreq>weekly</changefreq>
|
||||||
|
<priority>0.7</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://dociva.io/tools/image-rotate-flip</loc>
|
||||||
|
<lastmod>2026-03-29</lastmod>
|
||||||
|
<changefreq>weekly</changefreq>
|
||||||
|
<priority>0.7</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://dociva.io/tools/barcode-generator</loc>
|
||||||
|
<lastmod>2026-03-29</lastmod>
|
||||||
|
<changefreq>weekly</changefreq>
|
||||||
|
<priority>0.7</priority>
|
||||||
|
</url>
|
||||||
|
</urlset>
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { readFile, writeFile } from 'node:fs/promises';
|
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
@@ -6,6 +6,7 @@ const __filename = fileURLToPath(import.meta.url);
|
|||||||
const __dirname = path.dirname(__filename);
|
const __dirname = path.dirname(__filename);
|
||||||
const frontendRoot = path.resolve(__dirname, '..');
|
const frontendRoot = path.resolve(__dirname, '..');
|
||||||
const publicDir = path.join(frontendRoot, 'public');
|
const publicDir = path.join(frontendRoot, 'public');
|
||||||
|
const sitemapDir = path.join(publicDir, 'sitemaps');
|
||||||
const siteOrigin = String(process.env.VITE_SITE_DOMAIN || 'https://dociva.io').trim().replace(/\/$/, '');
|
const siteOrigin = String(process.env.VITE_SITE_DOMAIN || 'https://dociva.io').trim().replace(/\/$/, '');
|
||||||
const today = new Date().toISOString().slice(0, 10);
|
const today = new Date().toISOString().slice(0, 10);
|
||||||
|
|
||||||
@@ -26,6 +27,7 @@ const routeRegistrySource = await readFile(path.join(frontendRoot, 'src', 'confi
|
|||||||
|
|
||||||
const staticPages = [
|
const staticPages = [
|
||||||
{ path: '/', changefreq: 'daily', priority: '1.0' },
|
{ path: '/', changefreq: 'daily', priority: '1.0' },
|
||||||
|
{ path: '/tools', changefreq: 'weekly', priority: '0.8' },
|
||||||
{ path: '/about', changefreq: 'monthly', priority: '0.4' },
|
{ path: '/about', changefreq: 'monthly', priority: '0.4' },
|
||||||
{ path: '/contact', changefreq: 'monthly', priority: '0.4' },
|
{ path: '/contact', changefreq: 'monthly', priority: '0.4' },
|
||||||
{ path: '/privacy', changefreq: 'yearly', priority: '0.3' },
|
{ path: '/privacy', changefreq: 'yearly', priority: '0.3' },
|
||||||
@@ -94,6 +96,18 @@ function makeUrlTag({ loc, changefreq, priority }) {
|
|||||||
return ` <url>\n <loc>${loc}</loc>\n <lastmod>${today}</lastmod>\n <changefreq>${changefreq}</changefreq>\n <priority>${priority}</priority>\n </url>`;
|
return ` <url>\n <loc>${loc}</loc>\n <lastmod>${today}</lastmod>\n <changefreq>${changefreq}</changefreq>\n <priority>${priority}</priority>\n </url>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function makeSitemapUrlSet(entries) {
|
||||||
|
return `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${entries.join('\n')}\n</urlset>\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeSitemapIndex(entries) {
|
||||||
|
const items = entries
|
||||||
|
.map((entry) => ` <sitemap>\n <loc>${entry.loc}</loc>\n <lastmod>${today}</lastmod>\n </sitemap>`)
|
||||||
|
.join('\n');
|
||||||
|
|
||||||
|
return `<?xml version="1.0" encoding="UTF-8"?>\n<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${items}\n</sitemapindex>\n`;
|
||||||
|
}
|
||||||
|
|
||||||
function dedupeEntries(entries) {
|
function dedupeEntries(entries) {
|
||||||
const seen = new Set();
|
const seen = new Set();
|
||||||
return entries.filter((entry) => {
|
return entries.filter((entry) => {
|
||||||
@@ -110,22 +124,33 @@ const blogSource = await readFile(path.join(frontendRoot, 'src', 'content', 'blo
|
|||||||
const blogSlugs = extractBlogSlugs(blogSource);
|
const blogSlugs = extractBlogSlugs(blogSource);
|
||||||
const toolSlugs = extractToolSlugs(routeRegistrySource);
|
const toolSlugs = extractToolSlugs(routeRegistrySource);
|
||||||
|
|
||||||
const sitemapEntries = dedupeEntries([
|
await mkdir(sitemapDir, { recursive: true });
|
||||||
...staticPages.map((page) => ({
|
|
||||||
|
const staticEntries = dedupeEntries(
|
||||||
|
staticPages.map((page) => ({
|
||||||
loc: `${siteOrigin}${page.path}`,
|
loc: `${siteOrigin}${page.path}`,
|
||||||
changefreq: page.changefreq,
|
changefreq: page.changefreq,
|
||||||
priority: page.priority,
|
priority: page.priority,
|
||||||
})),
|
})),
|
||||||
...blogSlugs.map((slug) => ({
|
).map((entry) => makeUrlTag(entry));
|
||||||
|
|
||||||
|
const blogEntries = dedupeEntries(
|
||||||
|
blogSlugs.map((slug) => ({
|
||||||
loc: `${siteOrigin}/blog/${slug}`,
|
loc: `${siteOrigin}/blog/${slug}`,
|
||||||
changefreq: 'monthly',
|
changefreq: 'monthly',
|
||||||
priority: '0.6',
|
priority: '0.6',
|
||||||
})),
|
})),
|
||||||
...toolSlugs.map((slug) => ({
|
).map((entry) => makeUrlTag(entry));
|
||||||
|
|
||||||
|
const toolEntries = dedupeEntries(
|
||||||
|
toolSlugs.map((slug) => ({
|
||||||
loc: `${siteOrigin}/tools/${slug}`,
|
loc: `${siteOrigin}/tools/${slug}`,
|
||||||
changefreq: 'weekly',
|
changefreq: 'weekly',
|
||||||
priority: toolRoutePriorities.get(slug) || '0.6',
|
priority: toolRoutePriorities.get(slug) || '0.6',
|
||||||
})),
|
})),
|
||||||
|
).map((entry) => makeUrlTag(entry));
|
||||||
|
|
||||||
|
const seoEntries = dedupeEntries([
|
||||||
...seoConfig.toolPageSeeds.flatMap((page) => ([
|
...seoConfig.toolPageSeeds.flatMap((page) => ([
|
||||||
{ loc: `${siteOrigin}/${page.slug}`, changefreq: 'weekly', priority: '0.88' },
|
{ loc: `${siteOrigin}/${page.slug}`, changefreq: 'weekly', priority: '0.88' },
|
||||||
{ loc: `${siteOrigin}/ar/${page.slug}`, changefreq: 'weekly', priority: '0.8' },
|
{ loc: `${siteOrigin}/ar/${page.slug}`, changefreq: 'weekly', priority: '0.8' },
|
||||||
@@ -136,7 +161,12 @@ const sitemapEntries = dedupeEntries([
|
|||||||
])),
|
])),
|
||||||
]).map((entry) => makeUrlTag(entry));
|
]).map((entry) => makeUrlTag(entry));
|
||||||
|
|
||||||
const sitemap = `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${sitemapEntries.join('\n')}\n</urlset>\n`;
|
const sitemapIndex = makeSitemapIndex([
|
||||||
|
{ loc: `${siteOrigin}/sitemaps/static.xml` },
|
||||||
|
{ loc: `${siteOrigin}/sitemaps/blog.xml` },
|
||||||
|
{ loc: `${siteOrigin}/sitemaps/tools.xml` },
|
||||||
|
{ loc: `${siteOrigin}/sitemaps/seo.xml` },
|
||||||
|
]);
|
||||||
|
|
||||||
const robots = [
|
const robots = [
|
||||||
'# robots.txt — Dociva',
|
'# robots.txt — Dociva',
|
||||||
@@ -156,7 +186,11 @@ const robots = [
|
|||||||
'',
|
'',
|
||||||
].join('\n');
|
].join('\n');
|
||||||
|
|
||||||
await writeFile(path.join(publicDir, 'sitemap.xml'), sitemap, 'utf8');
|
await writeFile(path.join(publicDir, 'sitemap.xml'), sitemapIndex, 'utf8');
|
||||||
|
await writeFile(path.join(sitemapDir, 'static.xml'), makeSitemapUrlSet(staticEntries), 'utf8');
|
||||||
|
await writeFile(path.join(sitemapDir, 'blog.xml'), makeSitemapUrlSet(blogEntries), 'utf8');
|
||||||
|
await writeFile(path.join(sitemapDir, 'tools.xml'), makeSitemapUrlSet(toolEntries), 'utf8');
|
||||||
|
await writeFile(path.join(sitemapDir, 'seo.xml'), makeSitemapUrlSet(seoEntries), 'utf8');
|
||||||
await writeFile(path.join(publicDir, 'robots.txt'), robots, 'utf8');
|
await writeFile(path.join(publicDir, 'robots.txt'), robots, 'utf8');
|
||||||
|
|
||||||
console.log(`Generated SEO assets for ${siteOrigin}`);
|
console.log(`Generated SEO assets for ${siteOrigin}`);
|
||||||
166
frontend/scripts/render-seo-shells.mjs
Normal file
166
frontend/scripts/render-seo-shells.mjs
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
import { mkdir, 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 distRoot = path.join(frontendRoot, 'dist');
|
||||||
|
const siteOrigin = String(process.env.VITE_SITE_DOMAIN || 'https://dociva.io').trim().replace(/\/$/, '');
|
||||||
|
const seoPagesPath = path.join(frontendRoot, 'src', 'config', 'seo-tools.json');
|
||||||
|
const seoToolsPath = path.join(frontendRoot, 'src', 'config', 'seoData.ts');
|
||||||
|
const blogPath = path.join(frontendRoot, 'src', 'content', 'blogArticles.ts');
|
||||||
|
|
||||||
|
const baseHtml = await readFile(path.join(distRoot, 'index.html'), 'utf8');
|
||||||
|
const seoPages = JSON.parse(await readFile(seoPagesPath, 'utf8'));
|
||||||
|
const seoToolsSource = await readFile(seoToolsPath, 'utf8');
|
||||||
|
const blogSource = await readFile(blogPath, 'utf8');
|
||||||
|
|
||||||
|
const defaultTitle = 'Dociva — Free Online File Tools';
|
||||||
|
const defaultDescription = 'Free online tools for PDF, image, video, and text processing. Merge, split, compress, convert, watermark, protect & more — instantly.';
|
||||||
|
|
||||||
|
function escapeHtml(value) {
|
||||||
|
return value
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"');
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractToolMetadata(source) {
|
||||||
|
const entries = new Map();
|
||||||
|
const pattern = /i18nKey:\s*'([^']+)'[\s\S]*?slug:\s*'([^']+)'[\s\S]*?titleSuffix:\s*'([^']+)'[\s\S]*?metaDescription:\s*'([^']+)'/g;
|
||||||
|
|
||||||
|
for (const match of source.matchAll(pattern)) {
|
||||||
|
const [, i18nKey, slug, titleSuffix, metaDescription] = match;
|
||||||
|
entries.set(slug, {
|
||||||
|
i18nKey,
|
||||||
|
title: `${titleSuffix} | Dociva`,
|
||||||
|
description: metaDescription,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractBlogEntries(source) {
|
||||||
|
const entries = [];
|
||||||
|
const pattern = /slug:\s*'([^']+)'[\s\S]*?title:\s*\{[\s\S]*?en:\s*'([^']+)'[\s\S]*?seoDescription:\s*\{[\s\S]*?en:\s*'([^']+)'/g;
|
||||||
|
|
||||||
|
for (const match of source.matchAll(pattern)) {
|
||||||
|
const [, slug, title, description] = match;
|
||||||
|
entries.push({ slug, title: `${title} — Dociva`, description });
|
||||||
|
}
|
||||||
|
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
function interpolate(template, values) {
|
||||||
|
return template.replace(/{{\s*([a-zA-Z0-9_]+)\s*}}/g, (_, key) => values[key] ?? '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function withMeta(html, { title, description, url }) {
|
||||||
|
const safeTitle = escapeHtml(title);
|
||||||
|
const safeDescription = escapeHtml(description);
|
||||||
|
const safeUrl = escapeHtml(url);
|
||||||
|
|
||||||
|
let result = html
|
||||||
|
.replace(/<title>.*?<\/title>/, `<title>${safeTitle}</title>`)
|
||||||
|
.replace(/<meta name="description"[\s\S]*?\/>/, `<meta name="description" content="${safeDescription}" />`)
|
||||||
|
.replace(/<meta property="og:title"[\s\S]*?\/>/, `<meta property="og:title" content="${safeTitle}" />`)
|
||||||
|
.replace(/<meta property="og:description"[\s\S]*?\/>/, `<meta property="og:description" content="${safeDescription}" />`)
|
||||||
|
.replace(/<meta name="twitter:title"[\s\S]*?\/>/, `<meta name="twitter:title" content="${safeTitle}" />`)
|
||||||
|
.replace(/<meta name="twitter:description"[\s\S]*?\/>/, `<meta name="twitter:description" content="${safeDescription}" />`);
|
||||||
|
|
||||||
|
result = result.replace(
|
||||||
|
'</head>',
|
||||||
|
` <link rel="canonical" href="${safeUrl}" />\n <meta property="og:url" content="${safeUrl}" />\n</head>`,
|
||||||
|
);
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeRouteShell(routePath, title, description) {
|
||||||
|
const normalizedPath = routePath === '/' ? '' : routePath.replace(/^\//, '');
|
||||||
|
const targetDir = normalizedPath ? path.join(distRoot, normalizedPath) : distRoot;
|
||||||
|
const html = withMeta(baseHtml, {
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
url: `${siteOrigin}${routePath}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
await mkdir(targetDir, { recursive: true });
|
||||||
|
await writeFile(path.join(targetDir, 'index.html'), html, 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
const staticPages = [
|
||||||
|
{ path: '/', title: defaultTitle, description: defaultDescription },
|
||||||
|
{ path: '/tools', title: 'All Tools — Dociva', description: 'Browse every Dociva tool in one place. Explore PDF, image, AI, conversion, and utility workflows from a single search-friendly directory.' },
|
||||||
|
{ path: '/about', title: 'About Dociva', description: 'Learn about Dociva — free, fast, and secure online file tools for PDFs, images, video, and text. No registration required.' },
|
||||||
|
{ path: '/contact', title: 'Contact Dociva', description: 'Contact the Dociva team. Report bugs, request features, or send us a message.' },
|
||||||
|
{ path: '/privacy', title: 'Privacy Policy — Dociva', description: 'Privacy policy for Dociva. Learn how we handle your files and data with full transparency.' },
|
||||||
|
{ path: '/terms', title: 'Terms of Service — Dociva', description: 'Terms of service for Dociva. Understand the rules and guidelines for using our free online tools.' },
|
||||||
|
{ path: '/pricing', title: 'Pricing — Dociva', description: 'Compare free and pro plans for Dociva. Access 30+ tools for free, or upgrade for unlimited processing.' },
|
||||||
|
{ path: '/blog', title: 'Blog — Tips, Tutorials & Updates', description: 'Learn how to compress, convert, edit, and manage PDF files with our expert guides and tutorials.' },
|
||||||
|
{ path: '/developers', title: 'Developers — Dociva', description: 'Explore the Dociva developer portal, async API flow, and production-ready endpoints for document automation.' },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const page of staticPages) {
|
||||||
|
await writeRouteShell(page.path, page.title, page.description);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const blog of extractBlogEntries(blogSource)) {
|
||||||
|
await writeRouteShell(`/blog/${blog.slug}`, blog.title, blog.description);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [slug, tool] of extractToolMetadata(seoToolsSource)) {
|
||||||
|
await writeRouteShell(`/tools/${slug}`, tool.title, tool.description);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const page of seoPages.toolPages) {
|
||||||
|
const englishTitle = interpolate(page.titleTemplate.en, {
|
||||||
|
brand: 'Dociva',
|
||||||
|
focusKeyword: page.focusKeyword.en,
|
||||||
|
});
|
||||||
|
const arabicTitle = interpolate(page.titleTemplate.ar, {
|
||||||
|
brand: 'Dociva',
|
||||||
|
focusKeyword: page.focusKeyword.ar,
|
||||||
|
});
|
||||||
|
|
||||||
|
const englishDescription = interpolate(page.descriptionTemplate.en, {
|
||||||
|
brand: 'Dociva',
|
||||||
|
focusKeyword: page.focusKeyword.en,
|
||||||
|
});
|
||||||
|
const arabicDescription = interpolate(page.descriptionTemplate.ar, {
|
||||||
|
brand: 'Dociva',
|
||||||
|
focusKeyword: page.focusKeyword.ar,
|
||||||
|
});
|
||||||
|
|
||||||
|
await writeRouteShell(`/${page.slug}`, `${englishTitle} — Dociva`, englishDescription);
|
||||||
|
await writeRouteShell(`/ar/${page.slug}`, `${arabicTitle} — Dociva`, arabicDescription);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const page of seoPages.collectionPages) {
|
||||||
|
const englishTitle = interpolate(page.titleTemplate.en, {
|
||||||
|
brand: 'Dociva',
|
||||||
|
focusKeyword: page.focusKeyword.en,
|
||||||
|
});
|
||||||
|
const arabicTitle = interpolate(page.titleTemplate.ar, {
|
||||||
|
brand: 'Dociva',
|
||||||
|
focusKeyword: page.focusKeyword.ar,
|
||||||
|
});
|
||||||
|
|
||||||
|
const englishDescription = interpolate(page.descriptionTemplate.en, {
|
||||||
|
brand: 'Dociva',
|
||||||
|
focusKeyword: page.focusKeyword.en,
|
||||||
|
});
|
||||||
|
const arabicDescription = interpolate(page.descriptionTemplate.ar, {
|
||||||
|
brand: 'Dociva',
|
||||||
|
focusKeyword: page.focusKeyword.ar,
|
||||||
|
});
|
||||||
|
|
||||||
|
await writeRouteShell(`/${page.slug}`, `${englishTitle} — Dociva`, englishDescription);
|
||||||
|
await writeRouteShell(`/ar/${page.slug}`, `${arabicTitle} — Dociva`, arabicDescription);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Rendered route-specific SEO shells.');
|
||||||
@@ -24,6 +24,7 @@ const PricingPage = lazy(() => import('@/pages/PricingPage'));
|
|||||||
const BlogPage = lazy(() => import('@/pages/BlogPage'));
|
const BlogPage = lazy(() => import('@/pages/BlogPage'));
|
||||||
const BlogPostPage = lazy(() => import('@/pages/BlogPostPage'));
|
const BlogPostPage = lazy(() => import('@/pages/BlogPostPage'));
|
||||||
const DevelopersPage = lazy(() => import('@/pages/DevelopersPage'));
|
const DevelopersPage = lazy(() => import('@/pages/DevelopersPage'));
|
||||||
|
const AllToolsPage = lazy(() => import('@/pages/AllToolsPage'));
|
||||||
const InternalAdminPage = lazy(() => import('@/pages/InternalAdminPage'));
|
const InternalAdminPage = lazy(() => import('@/pages/InternalAdminPage'));
|
||||||
const SeoRoutePage = lazy(() => import('@/pages/SeoRoutePage'));
|
const SeoRoutePage = lazy(() => import('@/pages/SeoRoutePage'));
|
||||||
const CookieConsent = lazy(() => import('@/components/layout/CookieConsent'));
|
const CookieConsent = lazy(() => import('@/components/layout/CookieConsent'));
|
||||||
@@ -129,6 +130,7 @@ export default function App() {
|
|||||||
<Route path="/blog" element={<BlogPage />} />
|
<Route path="/blog" element={<BlogPage />} />
|
||||||
<Route path="/blog/:slug" element={<BlogPostPage />} />
|
<Route path="/blog/:slug" element={<BlogPostPage />} />
|
||||||
<Route path="/developers" element={<DevelopersPage />} />
|
<Route path="/developers" element={<DevelopersPage />} />
|
||||||
|
<Route path="/tools" element={<AllToolsPage />} />
|
||||||
<Route path="/internal/admin" element={<InternalAdminPage />} />
|
<Route path="/internal/admin" element={<InternalAdminPage />} />
|
||||||
<Route path="/ar/:slug" element={<SeoRoutePage />} />
|
<Route path="/ar/:slug" element={<SeoRoutePage />} />
|
||||||
<Route path="/:slug" element={<SeoRoutePage />} />
|
<Route path="/:slug" element={<SeoRoutePage />} />
|
||||||
|
|||||||
@@ -92,6 +92,12 @@ export default function Footer() {
|
|||||||
>
|
>
|
||||||
{t('common.terms')}
|
{t('common.terms')}
|
||||||
</Link>
|
</Link>
|
||||||
|
<Link
|
||||||
|
to="/tools"
|
||||||
|
className="text-sm text-slate-500 transition-colors hover:text-primary-600 dark:text-slate-400 dark:hover:text-primary-400"
|
||||||
|
>
|
||||||
|
{t('common.allTools')}
|
||||||
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
to="/about"
|
to="/about"
|
||||||
className="text-sm text-slate-500 transition-colors hover:text-primary-600 dark:text-slate-400 dark:hover:text-primary-400"
|
className="text-sm text-slate-500 transition-colors hover:text-primary-600 dark:text-slate-400 dark:hover:text-primary-400"
|
||||||
|
|||||||
@@ -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, buildSocialImageUrl, getOgLocale, getSiteOrigin } from '@/utils/seo';
|
import { buildSocialImageUrl, getOgLocale, getSiteOrigin } from '@/utils/seo';
|
||||||
|
|
||||||
const SITE_NAME = 'Dociva';
|
const SITE_NAME = 'Dociva';
|
||||||
|
|
||||||
@@ -9,8 +9,6 @@ interface SEOHeadProps {
|
|||||||
title: string;
|
title: string;
|
||||||
/** Meta description */
|
/** Meta description */
|
||||||
description: string;
|
description: string;
|
||||||
/** Optional keywords meta tag */
|
|
||||||
keywords?: string;
|
|
||||||
/** Canonical URL path (e.g. "/about") — origin is auto-prefixed */
|
/** Canonical URL path (e.g. "/about") — origin is auto-prefixed */
|
||||||
path: string;
|
path: string;
|
||||||
/** OG type — defaults to "website" */
|
/** OG type — defaults to "website" */
|
||||||
@@ -24,19 +22,19 @@ interface SEOHeadProps {
|
|||||||
/**
|
/**
|
||||||
* Reusable SEO head component that injects:
|
* Reusable SEO head component that injects:
|
||||||
* - title, description, canonical URL
|
* - title, description, canonical URL
|
||||||
* - optional keywords meta tag
|
|
||||||
* - OpenGraph meta tags (title, description, url, type, site_name, locale)
|
* - OpenGraph meta tags (title, description, url, type, site_name, locale)
|
||||||
* - Twitter card meta tags
|
* - Twitter card meta tags
|
||||||
* - Optional JSON-LD structured data
|
* - Optional JSON-LD structured data
|
||||||
*/
|
*/
|
||||||
export default function SEOHead({ title, description, keywords, path, type = 'website', jsonLd, alternates }: SEOHeadProps) {
|
export default function SEOHead({ title, description, path, type = 'website', jsonLd, alternates }: SEOHeadProps) {
|
||||||
const { i18n } = useTranslation();
|
const { i18n } = useTranslation();
|
||||||
const origin = getSiteOrigin(typeof window !== 'undefined' ? window.location.origin : '');
|
const origin = getSiteOrigin(typeof window !== 'undefined' ? window.location.origin : '');
|
||||||
const canonicalUrl = `${origin}${path}`;
|
const canonicalUrl = `${origin}${path}`;
|
||||||
const socialImageUrl = buildSocialImageUrl(origin);
|
const socialImageUrl = buildSocialImageUrl(origin);
|
||||||
const fullTitle = `${title} — ${SITE_NAME}`;
|
const fullTitle = `${title} — ${SITE_NAME}`;
|
||||||
const languageAlternates = alternates ?? buildLanguageAlternates(origin, path);
|
const languageAlternates = alternates ?? [];
|
||||||
const currentOgLocale = getOgLocale(i18n.language);
|
const currentOgLocale = getOgLocale(i18n.language);
|
||||||
|
const xDefaultHref = languageAlternates.find((alternate) => alternate.hrefLang === 'en')?.href ?? canonicalUrl;
|
||||||
|
|
||||||
const schemas = jsonLd ? (Array.isArray(jsonLd) ? jsonLd : [jsonLd]) : [];
|
const schemas = jsonLd ? (Array.isArray(jsonLd) ? jsonLd : [jsonLd]) : [];
|
||||||
|
|
||||||
@@ -45,7 +43,8 @@ export default function SEOHead({ title, description, keywords, path, type = 'we
|
|||||||
<title>{fullTitle}</title>
|
<title>{fullTitle}</title>
|
||||||
<meta name="description" content={description} />
|
<meta name="description" content={description} />
|
||||||
<meta name="robots" content="index,follow,max-image-preview:large,max-snippet:-1,max-video-preview:-1" />
|
<meta name="robots" content="index,follow,max-image-preview:large,max-snippet:-1,max-video-preview:-1" />
|
||||||
{keywords ? <meta name="keywords" content={keywords} /> : null}
|
<meta name="application-name" content={SITE_NAME} />
|
||||||
|
<meta name="apple-mobile-web-app-title" content={SITE_NAME} />
|
||||||
<link rel="canonical" href={canonicalUrl} />
|
<link rel="canonical" href={canonicalUrl} />
|
||||||
{languageAlternates.map((alternate) => (
|
{languageAlternates.map((alternate) => (
|
||||||
<link
|
<link
|
||||||
@@ -55,7 +54,7 @@ export default function SEOHead({ title, description, keywords, path, type = 'we
|
|||||||
href={alternate.href}
|
href={alternate.href}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
<link rel="alternate" hrefLang="x-default" href={canonicalUrl} />
|
<link rel="alternate" hrefLang="x-default" href={xDefaultHref} />
|
||||||
|
|
||||||
{/* OpenGraph */}
|
{/* OpenGraph */}
|
||||||
<meta property="og:title" content={fullTitle} />
|
<meta property="og:title" content={fullTitle} />
|
||||||
@@ -64,6 +63,7 @@ export default function SEOHead({ title, description, keywords, path, type = 'we
|
|||||||
<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" content={socialImageUrl} />
|
||||||
|
<meta property="og:image:type" content="image/svg+xml" />
|
||||||
<meta property="og:image:alt" content={`${fullTitle} social preview`} />
|
<meta property="og:image:alt" content={`${fullTitle} social preview`} />
|
||||||
<meta property="og:locale" content={currentOgLocale} />
|
<meta property="og:locale" content={currentOgLocale} />
|
||||||
{languageAlternates
|
{languageAlternates
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { Helmet } from 'react-helmet-async';
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { CheckCircle } from 'lucide-react';
|
import { CheckCircle } from 'lucide-react';
|
||||||
import { getToolSEO } from '@/config/seoData';
|
import { getToolSEO } from '@/config/seoData';
|
||||||
import { buildLanguageAlternates, buildSocialImageUrl, generateToolSchema, generateBreadcrumbs, generateFAQ, generateHowTo, getOgLocale, getSiteOrigin } from '@/utils/seo';
|
import { buildSocialImageUrl, generateToolSchema, generateBreadcrumbs, generateFAQ, generateHowTo, getOgLocale, getSiteOrigin } from '@/utils/seo';
|
||||||
import BreadcrumbNav from './BreadcrumbNav';
|
import BreadcrumbNav from './BreadcrumbNav';
|
||||||
import FAQSection from './FAQSection';
|
import FAQSection from './FAQSection';
|
||||||
import RelatedTools from './RelatedTools';
|
import RelatedTools from './RelatedTools';
|
||||||
@@ -43,7 +43,6 @@ export default function ToolLandingPage({ slug, children }: ToolLandingPageProps
|
|||||||
const path = `/tools/${slug}`;
|
const path = `/tools/${slug}`;
|
||||||
const canonicalUrl = `${origin}${path}`;
|
const canonicalUrl = `${origin}${path}`;
|
||||||
const socialImageUrl = buildSocialImageUrl(origin);
|
const socialImageUrl = buildSocialImageUrl(origin);
|
||||||
const languageAlternates = buildLanguageAlternates(origin, path);
|
|
||||||
const currentOgLocale = getOgLocale(i18n.language);
|
const currentOgLocale = getOgLocale(i18n.language);
|
||||||
|
|
||||||
const toolSchema = generateToolSchema({
|
const toolSchema = generateToolSchema({
|
||||||
@@ -77,18 +76,8 @@ export default function ToolLandingPage({ slug, children }: ToolLandingPageProps
|
|||||||
<Helmet>
|
<Helmet>
|
||||||
<title>{toolTitle} — {seo.titleSuffix} | {t('common.appName')}</title>
|
<title>{toolTitle} — {seo.titleSuffix} | {t('common.appName')}</title>
|
||||||
<meta name="description" content={seo.metaDescription} />
|
<meta name="description" content={seo.metaDescription} />
|
||||||
<meta name="keywords" content={seo.keywords} />
|
|
||||||
<meta name="robots" content="index,follow,max-image-preview:large,max-snippet:-1,max-video-preview:-1" />
|
<meta name="robots" content="index,follow,max-image-preview:large,max-snippet:-1,max-video-preview:-1" />
|
||||||
<link rel="canonical" href={canonicalUrl} />
|
<link rel="canonical" href={canonicalUrl} />
|
||||||
{languageAlternates.map((alternate) => (
|
|
||||||
<link
|
|
||||||
key={alternate.hrefLang}
|
|
||||||
rel="alternate"
|
|
||||||
hrefLang={alternate.hrefLang}
|
|
||||||
href={alternate.href}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
<link rel="alternate" hrefLang="x-default" href={canonicalUrl} />
|
|
||||||
|
|
||||||
{/* Open Graph */}
|
{/* Open Graph */}
|
||||||
<meta property="og:title" content={`${toolTitle} — ${seo.titleSuffix}`} />
|
<meta property="og:title" content={`${toolTitle} — ${seo.titleSuffix}`} />
|
||||||
@@ -98,11 +87,6 @@ export default function ToolLandingPage({ slug, children }: ToolLandingPageProps
|
|||||||
<meta property="og:image" content={socialImageUrl} />
|
<meta property="og:image" content={socialImageUrl} />
|
||||||
<meta property="og:image:alt" content={`${toolTitle} social preview`} />
|
<meta property="og:image:alt" content={`${toolTitle} social preview`} />
|
||||||
<meta property="og:locale" content={currentOgLocale} />
|
<meta property="og:locale" content={currentOgLocale} />
|
||||||
{languageAlternates
|
|
||||||
.filter((alternate) => alternate.ogLocale !== currentOgLocale)
|
|
||||||
.map((alternate) => (
|
|
||||||
<meta key={alternate.ogLocale} property="og:locale:alternate" content={alternate.ogLocale} />
|
|
||||||
))}
|
|
||||||
|
|
||||||
{/* Twitter */}
|
{/* Twitter */}
|
||||||
<meta name="twitter:card" content="summary_large_image" />
|
<meta name="twitter:card" content="summary_large_image" />
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect, useRef } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Helmet } from 'react-helmet-async';
|
import { Helmet } from 'react-helmet-async';
|
||||||
import { FileImage } from 'lucide-react';
|
import { FileImage } from 'lucide-react';
|
||||||
@@ -18,6 +18,8 @@ export default function ImagesToPdf() {
|
|||||||
const [isUploading, setIsUploading] = useState(false);
|
const [isUploading, setIsUploading] = useState(false);
|
||||||
const [taskId, setTaskId] = useState<string | null>(null);
|
const [taskId, setTaskId] = useState<string | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [useSinglePickerFlow, setUseSinglePickerFlow] = useState(false);
|
||||||
|
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
const { status, result, error: taskError } = useTaskPolling({
|
const { status, result, error: taskError } = useTaskPolling({
|
||||||
taskId,
|
taskId,
|
||||||
@@ -35,7 +37,22 @@ export default function ImagesToPdf() {
|
|||||||
}
|
}
|
||||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const coarsePointer = window.matchMedia?.('(pointer: coarse)').matches ?? false;
|
||||||
|
const mobileUserAgent = /android|iphone|ipad|ipod|mobile/i.test(navigator.userAgent);
|
||||||
|
setUseSinglePickerFlow(coarsePointer || mobileUserAgent);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const acceptedTypes = ['image/png', 'image/jpeg', 'image/webp', 'image/bmp'];
|
const acceptedTypes = ['image/png', 'image/jpeg', 'image/webp', 'image/bmp'];
|
||||||
|
const acceptValue = acceptedTypes.join(',');
|
||||||
|
|
||||||
|
const openPicker = () => {
|
||||||
|
inputRef.current?.click();
|
||||||
|
};
|
||||||
|
|
||||||
const handleFilesSelect = (newFiles: FileList | File[]) => {
|
const handleFilesSelect = (newFiles: FileList | File[]) => {
|
||||||
const fileArray = Array.from(newFiles).filter((f) =>
|
const fileArray = Array.from(newFiles).filter((f) =>
|
||||||
@@ -45,7 +62,19 @@ export default function ImagesToPdf() {
|
|||||||
setError(t('tools.imagesToPdf.invalidFiles'));
|
setError(t('tools.imagesToPdf.invalidFiles'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setFiles((prev) => [...prev, ...fileArray]);
|
setFiles((prev) => {
|
||||||
|
const seen = new Set(prev.map((file) => `${file.name}:${file.size}:${file.lastModified}`));
|
||||||
|
const uniqueNewFiles = fileArray.filter((file) => {
|
||||||
|
const key = `${file.name}:${file.size}:${file.lastModified}`;
|
||||||
|
if (seen.has(key)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
seen.add(key);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
return [...prev, ...uniqueNewFiles];
|
||||||
|
});
|
||||||
setError(null);
|
setError(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -112,8 +141,7 @@ export default function ImagesToPdf() {
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* Drop zone */}
|
{/* Drop zone */}
|
||||||
<div
|
<div
|
||||||
className="upload-zone cursor-pointer"
|
className="upload-zone"
|
||||||
onClick={() => document.getElementById('images-file-input')?.click()}
|
|
||||||
onDragOver={(e) => e.preventDefault()}
|
onDragOver={(e) => e.preventDefault()}
|
||||||
onDrop={(e) => {
|
onDrop={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -122,9 +150,10 @@ export default function ImagesToPdf() {
|
|||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
id="images-file-input"
|
id="images-file-input"
|
||||||
|
ref={inputRef}
|
||||||
type="file"
|
type="file"
|
||||||
accept=".png,.jpg,.jpeg,.webp,.bmp"
|
accept={acceptValue}
|
||||||
multiple
|
multiple={!useSinglePickerFlow}
|
||||||
className="hidden"
|
className="hidden"
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
if (e.target.files) handleFilesSelect(e.target.files);
|
if (e.target.files) handleFilesSelect(e.target.files);
|
||||||
@@ -133,12 +162,24 @@ export default function ImagesToPdf() {
|
|||||||
/>
|
/>
|
||||||
<FileImage className="mb-4 h-12 w-12 text-slate-400" />
|
<FileImage className="mb-4 h-12 w-12 text-slate-400" />
|
||||||
<p className="mb-2 text-base font-medium text-slate-700">
|
<p className="mb-2 text-base font-medium text-slate-700">
|
||||||
{t('common.dragDrop')}
|
{files.length > 0 ? t('tools.imagesToPdf.addMore') : t('tools.imagesToPdf.selectImages')}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm text-slate-500">PNG, JPG, WebP, BMP</p>
|
<p className="text-sm text-slate-500">PNG, JPG, WebP, BMP</p>
|
||||||
|
{useSinglePickerFlow && (
|
||||||
|
<p className="mt-2 text-xs text-slate-500">
|
||||||
|
{t('tools.imagesToPdf.mobilePickerHint')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
<p className="mt-1 text-xs text-slate-400">
|
<p className="mt-1 text-xs text-slate-400">
|
||||||
{t('common.maxSize', { size: 10 })}
|
{t('common.maxSize', { size: 10 })}
|
||||||
</p>
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={openPicker}
|
||||||
|
className="btn-secondary mt-4"
|
||||||
|
>
|
||||||
|
{files.length > 0 ? t('tools.imagesToPdf.addMore') : t('tools.imagesToPdf.selectImages')}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* File list */}
|
{/* File list */}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ const STATIC_PAGE_ROUTES = [
|
|||||||
'/blog',
|
'/blog',
|
||||||
'/blog/:slug',
|
'/blog/:slug',
|
||||||
'/developers',
|
'/developers',
|
||||||
|
'/tools',
|
||||||
'/internal/admin',
|
'/internal/admin',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
|||||||
@@ -256,6 +256,19 @@
|
|||||||
"contactTitle": "8. الاتصال",
|
"contactTitle": "8. الاتصال",
|
||||||
"contactText": "أسئلة حول هذه الشروط؟ تواصل معنا على"
|
"contactText": "أسئلة حول هذه الشروط؟ تواصل معنا على"
|
||||||
},
|
},
|
||||||
|
"toolsHub": {
|
||||||
|
"metaTitle": "كل الأدوات",
|
||||||
|
"metaDescription": "تصفح جميع أدوات Dociva في مكان واحد. استكشف مسارات PDF والصور والذكاء الاصطناعي والتحويل والأدوات المساعدة من دليل واحد سهل للأرشفة والزحف.",
|
||||||
|
"title": "جميع أدوات Dociva",
|
||||||
|
"description": "استخدم هذا الدليل لاستكشاف كل مسارات Dociva حسب الفئة ثم انتقل مباشرة إلى الأداة التي تحتاجها.",
|
||||||
|
"categories": {
|
||||||
|
"PDF": "أدوات PDF",
|
||||||
|
"Convert": "أدوات التحويل",
|
||||||
|
"Image": "أدوات الصور",
|
||||||
|
"AI": "أدوات الذكاء الاصطناعي",
|
||||||
|
"Utility": "أدوات مساعدة"
|
||||||
|
}
|
||||||
|
},
|
||||||
"cookie": {
|
"cookie": {
|
||||||
"title": "إعدادات ملفات الارتباط",
|
"title": "إعدادات ملفات الارتباط",
|
||||||
"message": "نستخدم ملفات الارتباط لتحسين تجربتك وتحليل حركة الموقع. بالموافقة، فإنك توافق على ملفات الارتباط التحليلية.",
|
"message": "نستخدم ملفات الارتباط لتحسين تجربتك وتحليل حركة الموقع. بالموافقة، فإنك توافق على ملفات الارتباط التحليلية.",
|
||||||
@@ -564,7 +577,8 @@
|
|||||||
"addMore": "أضف صور أخرى",
|
"addMore": "أضف صور أخرى",
|
||||||
"imagesSelected": "{{count}} صور مختارة",
|
"imagesSelected": "{{count}} صور مختارة",
|
||||||
"invalidFiles": "يرجى اختيار ملفات صور صالحة (JPG أو PNG أو WebP).",
|
"invalidFiles": "يرجى اختيار ملفات صور صالحة (JPG أو PNG أو WebP).",
|
||||||
"minFiles": "يرجى اختيار صورة واحدة على الأقل."
|
"minFiles": "يرجى اختيار صورة واحدة على الأقل.",
|
||||||
|
"mobilePickerHint": "في بعض الهواتف يفضَّل اختيار صورة واحدة كل مرة ثم الضغط على حفظ لتأكيدها قبل إضافة الصورة التالية."
|
||||||
},
|
},
|
||||||
"watermarkPdf": {
|
"watermarkPdf": {
|
||||||
"title": "علامة مائية PDF",
|
"title": "علامة مائية PDF",
|
||||||
|
|||||||
@@ -256,6 +256,19 @@
|
|||||||
"contactTitle": "8. Contact",
|
"contactTitle": "8. Contact",
|
||||||
"contactText": "Questions about these terms? Contact us at"
|
"contactText": "Questions about these terms? Contact us at"
|
||||||
},
|
},
|
||||||
|
"toolsHub": {
|
||||||
|
"metaTitle": "All Tools",
|
||||||
|
"metaDescription": "Browse every Dociva tool in one place. Explore PDF, image, AI, conversion, and utility workflows from a single search-friendly directory.",
|
||||||
|
"title": "All Dociva Tools",
|
||||||
|
"description": "Use this directory to explore every Dociva workflow by category and jump directly to the tool you need.",
|
||||||
|
"categories": {
|
||||||
|
"PDF": "PDF Tools",
|
||||||
|
"Convert": "Convert Tools",
|
||||||
|
"Image": "Image Tools",
|
||||||
|
"AI": "AI Tools",
|
||||||
|
"Utility": "Utility Tools"
|
||||||
|
}
|
||||||
|
},
|
||||||
"cookie": {
|
"cookie": {
|
||||||
"title": "Cookie Settings",
|
"title": "Cookie Settings",
|
||||||
"message": "We use cookies to improve your experience and analyze site traffic. By accepting, you consent to analytics cookies.",
|
"message": "We use cookies to improve your experience and analyze site traffic. By accepting, you consent to analytics cookies.",
|
||||||
@@ -564,7 +577,8 @@
|
|||||||
"addMore": "Add More Images",
|
"addMore": "Add More Images",
|
||||||
"imagesSelected": "{{count}} images selected",
|
"imagesSelected": "{{count}} images selected",
|
||||||
"invalidFiles": "Please select valid image files (JPG, PNG, WebP).",
|
"invalidFiles": "Please select valid image files (JPG, PNG, WebP).",
|
||||||
"minFiles": "Please select at least one image."
|
"minFiles": "Please select at least one image.",
|
||||||
|
"mobilePickerHint": "On some phones, select one image at a time and tap Save to confirm it before adding the next image."
|
||||||
},
|
},
|
||||||
"watermarkPdf": {
|
"watermarkPdf": {
|
||||||
"title": "Watermark PDF",
|
"title": "Watermark PDF",
|
||||||
|
|||||||
@@ -256,6 +256,19 @@
|
|||||||
"contactTitle": "8. Contact",
|
"contactTitle": "8. Contact",
|
||||||
"contactText": "Des questions sur ces conditions ? Contactez-nous à"
|
"contactText": "Des questions sur ces conditions ? Contactez-nous à"
|
||||||
},
|
},
|
||||||
|
"toolsHub": {
|
||||||
|
"metaTitle": "Tous les outils",
|
||||||
|
"metaDescription": "Parcourez tous les outils Dociva depuis une seule page. Explorez les workflows PDF, image, IA, conversion et utilitaires dans un répertoire clair et optimisé pour la découverte.",
|
||||||
|
"title": "Tous les outils Dociva",
|
||||||
|
"description": "Utilisez ce répertoire pour parcourir chaque workflow Dociva par catégorie et ouvrir directement l'outil dont vous avez besoin.",
|
||||||
|
"categories": {
|
||||||
|
"PDF": "Outils PDF",
|
||||||
|
"Convert": "Outils de conversion",
|
||||||
|
"Image": "Outils d'image",
|
||||||
|
"AI": "Outils IA",
|
||||||
|
"Utility": "Outils utilitaires"
|
||||||
|
}
|
||||||
|
},
|
||||||
"cookie": {
|
"cookie": {
|
||||||
"title": "Paramètres des cookies",
|
"title": "Paramètres des cookies",
|
||||||
"message": "Nous utilisons des cookies pour améliorer votre expérience et analyser le trafic du site. En acceptant, vous consentez aux cookies analytiques.",
|
"message": "Nous utilisons des cookies pour améliorer votre expérience et analyser le trafic du site. En acceptant, vous consentez aux cookies analytiques.",
|
||||||
@@ -564,7 +577,8 @@
|
|||||||
"addMore": "Ajouter plus d'images",
|
"addMore": "Ajouter plus d'images",
|
||||||
"imagesSelected": "{{count}} images sélectionnées",
|
"imagesSelected": "{{count}} images sélectionnées",
|
||||||
"invalidFiles": "Veuillez sélectionner des fichiers images valides (JPG, PNG, WebP).",
|
"invalidFiles": "Veuillez sélectionner des fichiers images valides (JPG, PNG, WebP).",
|
||||||
"minFiles": "Veuillez sélectionner au moins une image."
|
"minFiles": "Veuillez sélectionner au moins une image.",
|
||||||
|
"mobilePickerHint": "Sur certains téléphones, sélectionnez une image à la fois puis appuyez sur Enregistrer avant d'ajouter la suivante."
|
||||||
},
|
},
|
||||||
"watermarkPdf": {
|
"watermarkPdf": {
|
||||||
"title": "Filigrane PDF",
|
"title": "Filigrane PDF",
|
||||||
|
|||||||
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 { Link } from 'react-router-dom';
|
||||||
import { useSearchParams } from 'react-router-dom';
|
import { useSearchParams } from 'react-router-dom';
|
||||||
import SEOHead from '@/components/seo/SEOHead';
|
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 { BookOpen, Calendar, ArrowRight, Search, X } from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
BLOG_ARTICLES,
|
BLOG_ARTICLES,
|
||||||
@@ -44,11 +44,17 @@ export default function BlogPage() {
|
|||||||
title={t('pages.blog.metaTitle')}
|
title={t('pages.blog.metaTitle')}
|
||||||
description={t('pages.blog.metaDescription')}
|
description={t('pages.blog.metaDescription')}
|
||||||
path="/blog"
|
path="/blog"
|
||||||
jsonLd={generateWebPage({
|
jsonLd={[
|
||||||
|
generateCollectionPage({
|
||||||
name: t('pages.blog.metaTitle'),
|
name: t('pages.blog.metaTitle'),
|
||||||
description: t('pages.blog.metaDescription'),
|
description: t('pages.blog.metaDescription'),
|
||||||
url: `${siteOrigin}/blog`,
|
url: `${siteOrigin}/blog`,
|
||||||
})}
|
}),
|
||||||
|
generateItemList(posts.map((post) => ({
|
||||||
|
name: post.title,
|
||||||
|
url: `${siteOrigin}/blog/${post.slug}`,
|
||||||
|
}))),
|
||||||
|
]}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="mx-auto max-w-4xl">
|
<div className="mx-auto max-w-4xl">
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useDeferredValue } from 'react';
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useSearchParams } from 'react-router-dom';
|
import { useSearchParams } from 'react-router-dom';
|
||||||
import SEOHead from '@/components/seo/SEOHead';
|
import SEOHead from '@/components/seo/SEOHead';
|
||||||
import { generateOrganization, getSiteOrigin } from '@/utils/seo';
|
import { generateOrganization, generateWebSite, getSiteOrigin } from '@/utils/seo';
|
||||||
import {
|
import {
|
||||||
FileText,
|
FileText,
|
||||||
FileOutput,
|
FileOutput,
|
||||||
@@ -121,18 +121,10 @@ export default function HomePage() {
|
|||||||
description={t('home.heroSub')}
|
description={t('home.heroSub')}
|
||||||
path="/"
|
path="/"
|
||||||
jsonLd={[
|
jsonLd={[
|
||||||
{
|
generateWebSite({
|
||||||
'@context': 'https://schema.org',
|
origin: siteOrigin,
|
||||||
'@type': 'WebSite',
|
|
||||||
name: t('common.appName'),
|
|
||||||
url: siteOrigin,
|
|
||||||
description: t('home.heroSub'),
|
description: t('home.heroSub'),
|
||||||
potentialAction: {
|
}),
|
||||||
'@type': 'SearchAction',
|
|
||||||
target: `${siteOrigin}/?q={search_term_string}`,
|
|
||||||
'query-input': 'required name=search_term_string',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
generateOrganization(siteOrigin),
|
generateOrganization(siteOrigin),
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -6,13 +6,19 @@ import SEOHead from '@/components/seo/SEOHead';
|
|||||||
import FAQSection from '@/components/seo/FAQSection';
|
import FAQSection from '@/components/seo/FAQSection';
|
||||||
import {
|
import {
|
||||||
getLocalizedText,
|
getLocalizedText,
|
||||||
getLocalizedTextList,
|
|
||||||
getSeoCollectionPage,
|
getSeoCollectionPage,
|
||||||
interpolateTemplate,
|
interpolateTemplate,
|
||||||
normalizeSeoLocale,
|
normalizeSeoLocale,
|
||||||
} from '@/config/seoPages';
|
} from '@/config/seoPages';
|
||||||
import { getToolSEO } from '@/config/seoData';
|
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';
|
import NotFoundPage from '@/pages/NotFoundPage';
|
||||||
|
|
||||||
interface SeoCollectionPageProps {
|
interface SeoCollectionPageProps {
|
||||||
@@ -64,7 +70,6 @@ export default function SeoCollectionPage({ slug }: SeoCollectionPageProps) {
|
|||||||
const title = interpolateTemplate(getLocalizedText(page.titleTemplate, locale), tokens);
|
const title = interpolateTemplate(getLocalizedText(page.titleTemplate, locale), tokens);
|
||||||
const description = interpolateTemplate(getLocalizedText(page.descriptionTemplate, locale), tokens);
|
const description = interpolateTemplate(getLocalizedText(page.descriptionTemplate, locale), tokens);
|
||||||
const intro = interpolateTemplate(getLocalizedText(page.introTemplate, 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 siteOrigin = getSiteOrigin(typeof window !== 'undefined' ? window.location.origin : '');
|
||||||
const faqItems = page.faqTemplates.map((item) => ({
|
const faqItems = page.faqTemplates.map((item) => ({
|
||||||
question: getLocalizedText(item.question, locale),
|
question: getLocalizedText(item.question, locale),
|
||||||
@@ -82,6 +87,11 @@ export default function SeoCollectionPage({ slug }: SeoCollectionPageProps) {
|
|||||||
];
|
];
|
||||||
|
|
||||||
const jsonLd = [
|
const jsonLd = [
|
||||||
|
generateCollectionPage({
|
||||||
|
name: title,
|
||||||
|
description,
|
||||||
|
url,
|
||||||
|
}),
|
||||||
generateWebPage({
|
generateWebPage({
|
||||||
name: title,
|
name: title,
|
||||||
description,
|
description,
|
||||||
@@ -93,11 +103,18 @@ export default function SeoCollectionPage({ slug }: SeoCollectionPageProps) {
|
|||||||
{ name: title, url },
|
{ name: title, url },
|
||||||
]),
|
]),
|
||||||
generateFAQ(faqItems),
|
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 (
|
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">
|
<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">
|
<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">
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import RelatedTools from '@/components/seo/RelatedTools';
|
|||||||
import SuggestedTools from '@/components/seo/SuggestedTools';
|
import SuggestedTools from '@/components/seo/SuggestedTools';
|
||||||
import {
|
import {
|
||||||
getLocalizedText,
|
getLocalizedText,
|
||||||
getLocalizedTextList,
|
|
||||||
getProgrammaticToolPage,
|
getProgrammaticToolPage,
|
||||||
getSeoCollectionPage,
|
getSeoCollectionPage,
|
||||||
interpolateTemplate,
|
interpolateTemplate,
|
||||||
@@ -19,6 +18,7 @@ import {
|
|||||||
generateBreadcrumbs,
|
generateBreadcrumbs,
|
||||||
generateFAQ,
|
generateFAQ,
|
||||||
generateHowTo,
|
generateHowTo,
|
||||||
|
generateItemList,
|
||||||
generateToolSchema,
|
generateToolSchema,
|
||||||
generateWebPage,
|
generateWebPage,
|
||||||
getSiteOrigin,
|
getSiteOrigin,
|
||||||
@@ -82,7 +82,6 @@ export default function SeoPage({ slug }: SeoPageProps) {
|
|||||||
const useCases = t(`seo.${tool.i18nKey}.useCases`, { returnObjects: true }) as string[];
|
const useCases = t(`seo.${tool.i18nKey}.useCases`, { returnObjects: true }) as string[];
|
||||||
|
|
||||||
const focusKeyword = getLocalizedText(page.focusKeyword, locale);
|
const focusKeyword = getLocalizedText(page.focusKeyword, locale);
|
||||||
const keywords = [focusKeyword, ...getLocalizedTextList(page.supportingKeywords, locale)].join(', ');
|
|
||||||
const tokens = {
|
const tokens = {
|
||||||
brand: 'Dociva',
|
brand: 'Dociva',
|
||||||
focusKeyword,
|
focusKeyword,
|
||||||
@@ -139,11 +138,25 @@ export default function SeoPage({ slug }: SeoPageProps) {
|
|||||||
url,
|
url,
|
||||||
}),
|
}),
|
||||||
generateFAQ(faqItems),
|
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 (
|
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">
|
<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">
|
<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">
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export interface ToolSeoData {
|
|||||||
category?: string;
|
category?: string;
|
||||||
ratingValue?: number;
|
ratingValue?: number;
|
||||||
ratingCount?: number;
|
ratingCount?: number;
|
||||||
|
features?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LanguageAlternate {
|
export interface LanguageAlternate {
|
||||||
@@ -19,6 +20,7 @@ export interface LanguageAlternate {
|
|||||||
|
|
||||||
const DEFAULT_SOCIAL_IMAGE_PATH = '/social-preview.svg';
|
const DEFAULT_SOCIAL_IMAGE_PATH = '/social-preview.svg';
|
||||||
const DEFAULT_SITE_ORIGIN = 'https://dociva.io';
|
const DEFAULT_SITE_ORIGIN = 'https://dociva.io';
|
||||||
|
const DEFAULT_SITE_NAME = 'Dociva';
|
||||||
|
|
||||||
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' },
|
||||||
@@ -35,13 +37,16 @@ export function getOgLocale(language: string): string {
|
|||||||
return LANGUAGE_CONFIG[normalizeSiteLanguage(language)].ogLocale;
|
return LANGUAGE_CONFIG[normalizeSiteLanguage(language)].ogLocale;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildLanguageAlternates(origin: string, path: string): LanguageAlternate[] {
|
export function buildLanguageAlternates(
|
||||||
const separator = path.includes('?') ? '&' : '?';
|
origin: string,
|
||||||
return (Object.entries(LANGUAGE_CONFIG) as Array<[keyof typeof LANGUAGE_CONFIG, (typeof LANGUAGE_CONFIG)[keyof typeof LANGUAGE_CONFIG]]>)
|
localizedPaths: Partial<Record<'en' | 'ar' | 'fr', string>>,
|
||||||
.map(([language, config]) => ({
|
): LanguageAlternate[] {
|
||||||
hrefLang: config.hrefLang,
|
return (Object.entries(localizedPaths) as Array<[keyof typeof LANGUAGE_CONFIG, string | undefined]>)
|
||||||
href: `${origin}${path}${separator}lng=${language}`,
|
.filter(([, path]) => Boolean(path))
|
||||||
ogLocale: config.ogLocale,
|
.map(([language, path]) => ({
|
||||||
|
hrefLang: LANGUAGE_CONFIG[language].hrefLang,
|
||||||
|
href: `${origin}${path}`,
|
||||||
|
ogLocale: LANGUAGE_CONFIG[language].ogLocale,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,20 +73,33 @@ export function buildSocialImageUrl(origin: string): string {
|
|||||||
export function generateToolSchema(tool: ToolSeoData): object {
|
export function generateToolSchema(tool: ToolSeoData): object {
|
||||||
const schema: Record<string, unknown> = {
|
const schema: Record<string, unknown> = {
|
||||||
'@context': 'https://schema.org',
|
'@context': 'https://schema.org',
|
||||||
'@type': 'WebApplication',
|
'@type': 'SoftwareApplication',
|
||||||
name: tool.name,
|
name: tool.name,
|
||||||
url: tool.url,
|
url: tool.url,
|
||||||
applicationCategory: tool.category || 'UtilitiesApplication',
|
applicationCategory: tool.category || 'UtilitiesApplication',
|
||||||
|
applicationSubCategory: tool.category || 'UtilitiesApplication',
|
||||||
operatingSystem: 'Any',
|
operatingSystem: 'Any',
|
||||||
|
browserRequirements: 'Requires JavaScript. Works in modern browsers.',
|
||||||
|
isAccessibleForFree: true,
|
||||||
offers: {
|
offers: {
|
||||||
'@type': 'Offer',
|
'@type': 'Offer',
|
||||||
price: '0',
|
price: '0',
|
||||||
priceCurrency: 'USD',
|
priceCurrency: 'USD',
|
||||||
|
availability: 'https://schema.org/InStock',
|
||||||
},
|
},
|
||||||
description: tool.description,
|
description: tool.description,
|
||||||
inLanguage: ['en', 'ar', 'fr'],
|
inLanguage: ['en', 'ar', 'fr'],
|
||||||
|
provider: {
|
||||||
|
'@type': 'Organization',
|
||||||
|
name: DEFAULT_SITE_NAME,
|
||||||
|
url: getSiteOrigin(),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (tool.features && tool.features.length > 0) {
|
||||||
|
schema.featureList = tool.features;
|
||||||
|
}
|
||||||
|
|
||||||
if (tool.ratingValue && tool.ratingCount && tool.ratingCount > 0) {
|
if (tool.ratingValue && tool.ratingCount && tool.ratingCount > 0) {
|
||||||
schema.aggregateRating = {
|
schema.aggregateRating = {
|
||||||
'@type': 'AggregateRating',
|
'@type': 'AggregateRating',
|
||||||
@@ -161,10 +179,14 @@ export function generateOrganization(origin: string): object {
|
|||||||
return {
|
return {
|
||||||
'@context': 'https://schema.org',
|
'@context': 'https://schema.org',
|
||||||
'@type': 'Organization',
|
'@type': 'Organization',
|
||||||
name: 'Dociva',
|
'@id': `${origin}/#organization`,
|
||||||
|
name: DEFAULT_SITE_NAME,
|
||||||
|
alternateName: 'Dociva File Tools',
|
||||||
url: origin,
|
url: origin,
|
||||||
logo: `${origin}/favicon.svg`,
|
logo: {
|
||||||
sameAs: [],
|
'@type': 'ImageObject',
|
||||||
|
url: `${origin}/logo.svg`,
|
||||||
|
},
|
||||||
contactPoint: {
|
contactPoint: {
|
||||||
'@type': 'ContactPoint',
|
'@type': 'ContactPoint',
|
||||||
email: 'support@dociva.io',
|
email: 'support@dociva.io',
|
||||||
@@ -188,13 +210,68 @@ export function generateWebPage(page: {
|
|||||||
name: page.name,
|
name: page.name,
|
||||||
description: page.description,
|
description: page.description,
|
||||||
url: page.url,
|
url: page.url,
|
||||||
|
inLanguage: ['en', 'ar', 'fr'],
|
||||||
isPartOf: {
|
isPartOf: {
|
||||||
'@type': 'WebSite',
|
'@type': 'WebSite',
|
||||||
name: 'Dociva',
|
'@id': `${getSiteOrigin()}/#website`,
|
||||||
|
name: DEFAULT_SITE_NAME,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function generateWebSite(data: {
|
||||||
|
origin: string;
|
||||||
|
description: string;
|
||||||
|
}): object {
|
||||||
|
return {
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@type': 'WebSite',
|
||||||
|
'@id': `${data.origin}/#website`,
|
||||||
|
name: DEFAULT_SITE_NAME,
|
||||||
|
url: data.origin,
|
||||||
|
description: data.description,
|
||||||
|
publisher: {
|
||||||
|
'@id': `${data.origin}/#organization`,
|
||||||
|
},
|
||||||
|
inLanguage: ['en', 'ar', 'fr'],
|
||||||
|
potentialAction: {
|
||||||
|
'@type': 'SearchAction',
|
||||||
|
target: `${data.origin}/?q={search_term_string}`,
|
||||||
|
'query-input': 'required name=search_term_string',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateCollectionPage(data: {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
url: string;
|
||||||
|
}): object {
|
||||||
|
return {
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@type': 'CollectionPage',
|
||||||
|
name: data.name,
|
||||||
|
description: data.description,
|
||||||
|
url: data.url,
|
||||||
|
isPartOf: {
|
||||||
|
'@id': `${getSiteOrigin()}/#website`,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateItemList(items: { name: string; url: string }[]): object {
|
||||||
|
return {
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@type': 'ItemList',
|
||||||
|
itemListElement: items.map((item, index) => ({
|
||||||
|
'@type': 'ListItem',
|
||||||
|
position: index + 1,
|
||||||
|
name: item.name,
|
||||||
|
url: item.url,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function generateBlogPosting(post: {
|
export function generateBlogPosting(post: {
|
||||||
headline: string;
|
headline: string;
|
||||||
description: string;
|
description: string;
|
||||||
@@ -202,6 +279,7 @@ export function generateBlogPosting(post: {
|
|||||||
datePublished: string;
|
datePublished: string;
|
||||||
inLanguage: string;
|
inLanguage: string;
|
||||||
}): object {
|
}): object {
|
||||||
|
const origin = getSiteOrigin();
|
||||||
return {
|
return {
|
||||||
'@context': 'https://schema.org',
|
'@context': 'https://schema.org',
|
||||||
'@type': 'BlogPosting',
|
'@type': 'BlogPosting',
|
||||||
@@ -211,14 +289,23 @@ export function generateBlogPosting(post: {
|
|||||||
datePublished: post.datePublished,
|
datePublished: post.datePublished,
|
||||||
dateModified: post.datePublished,
|
dateModified: post.datePublished,
|
||||||
inLanguage: post.inLanguage,
|
inLanguage: post.inLanguage,
|
||||||
|
isAccessibleForFree: true,
|
||||||
author: {
|
author: {
|
||||||
'@type': 'Organization',
|
'@type': 'Organization',
|
||||||
name: 'Dociva',
|
name: DEFAULT_SITE_NAME,
|
||||||
},
|
},
|
||||||
publisher: {
|
publisher: {
|
||||||
'@type': 'Organization',
|
'@type': 'Organization',
|
||||||
name: 'Dociva',
|
'@id': `${origin}/#organization`,
|
||||||
|
name: DEFAULT_SITE_NAME,
|
||||||
|
logo: {
|
||||||
|
'@type': 'ImageObject',
|
||||||
|
url: `${origin}/logo.svg`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
mainEntityOfPage: {
|
||||||
|
'@type': 'WebPage',
|
||||||
|
'@id': post.url,
|
||||||
},
|
},
|
||||||
mainEntityOfPage: post.url,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user