fix: serve canonical, hreflang and sitemap URLs and restore Export in the PDF editor

This commit is contained in:
alam00000
2026-10-23 15:01:31 +05:30
parent 7eec9a0d64
commit 6e8e012d3d
20 changed files with 209 additions and 53 deletions
+7 -7
View File
@@ -71,9 +71,9 @@ Have questions, feature requests, or want to chat with the community? Join our D
## 📚 Documentation
[![Documentation](https://img.shields.io/badge/Docs-VitePress-646cff?style=for-the-badge&logo=vite&logoColor=white)](https://bentopdf.com/docs/)
[![Documentation](https://img.shields.io/badge/Docs-VitePress-646cff?style=for-the-badge&logo=vite&logoColor=white)](https://www.bentopdf.com/docs)
Visit our [Documentation](https://bentopdf.com/docs/) for:
Visit our [Documentation](https://www.bentopdf.com/docs) for:
- **Getting Started** guide
- **Tools Reference** (50+ tools)
@@ -100,7 +100,7 @@ BentoPDF is **dual-licensed** to fit your needs:
> **One-time purchase** · **Unlimited devices & users** · **Lifetime updates** · **No AGPL obligations**
📖 For more details, see our [Licensing Page](https://bentopdf.com/licensing.html)
📖 For more details, see our [Licensing Page](https://www.bentopdf.com/licensing)
### AGPL Components (Pre-configured via CDN)
@@ -810,7 +810,7 @@ systemctl --user daemon-reload
systemctl --user enable --now bentopdf
```
For detailed Quadlet configuration, see [Self-Hosting Docker Guide](https://bentopdf.com/docs/self-hosting/docker).
For detailed Quadlet configuration, see [Self-Hosting Docker Guide](https://www.bentopdf.com/docs/self-hosting/docker).
### 🏢 Self-Hosted build (Simple Mode)
@@ -866,7 +866,7 @@ Or set the same variables when building from source — see [Custom Branding](#-
> - **AGPL-3.0** (free): allowed if your deployment publishes its full source code under AGPL — this includes any branding modifications, custom configuration, and any code you build on top of it.
> - **Commercial license** ($79 lifetime): required for closed-source / proprietary deployments — e.g., a private SaaS where you don't open-source your branding fork or surrounding business logic.
>
> See the [Licensing page](https://bentopdf.com/licensing.html) for the full comparison.
> See the [Licensing page](https://www.bentopdf.com/licensing) for the full comparison.
### 🎨 Custom Branding
@@ -930,7 +930,7 @@ docker run -d -p 3000:8080 \
ghcr.io/alam00000/bentopdf-simple:latest
```
Both methods can be combined — the lists are merged. For the full list of tool IDs, see the [self-hosting docs](https://bentopdf.com/docs/self-hosting/docker#disabling-specific-tools).
Both methods can be combined — the lists are merged. For the full list of tool IDs, see the [self-hosting docs](https://www.bentopdf.com/docs/self-hosting/docker#disabling-specific-tools).
You can also disable specific features inside the PDF Editor (e.g., redaction, forms) without disabling the entire editor. Add `editorDisabledCategories` to your `config.json`:
@@ -940,7 +940,7 @@ You can also disable specific features inside the PDF Editor (e.g., redaction, f
}
```
For the full list of editor categories, see the [self-hosting docs](https://bentopdf.com/docs/self-hosting/docker#disabling-editor-features).
For the full list of editor categories, see the [self-hosting docs](https://www.bentopdf.com/docs/self-hosting/docker#disabling-editor-features).
### 🔕 Disabling the GitHub Star Counter
+4 -1
View File
@@ -332,7 +332,10 @@
<i data-lucide="arrow-right"></i>
</a>
</span>
<a href="https://bentopdf.com/docs/" class="lp-btn lp-btn--ghost">
<a
href="https://www.bentopdf.com/docs"
class="lp-btn lp-btn--ghost"
>
<i data-lucide="book-open"></i>
<span data-i18n="nav.docs">Docs</span>
</a>
+2 -2
View File
@@ -11,8 +11,8 @@ export default defineConfig({
transformPageData(pageData) {
const relPath = pageData.relativePath.replace(/\.md$/, '')
const slug = relPath === 'index' ? '' : relPath.replace(/\/index$/, '/')
const canonicalUrl = slug ? `${SITE_URL}/docs/${slug}` : `${SITE_URL}/docs/`
const slug = relPath === 'index' ? '' : relPath.replace(/\/index$/, '')
const canonicalUrl = slug ? `${SITE_URL}/docs/${slug}` : `${SITE_URL}/docs`
pageData.frontmatter.head ??= []
pageData.frontmatter.head.push(['link', { rel: 'canonical', href: canonicalUrl }])
},
+1 -1
View File
@@ -10,7 +10,7 @@ BentoPDF is a free, open-source, privacy-first PDF toolkit that runs **entirely
### Option 1: Use the Hosted Version
Visit [bentopdf.com](https://bentopdf.com) to use BentoPDF instantly—no installation required.
Visit [bentopdf.com](https://www.bentopdf.com) to use BentoPDF instantly—no installation required.
### Option 2: Self-Host with Docker
+1 -1
View File
@@ -3,7 +3,7 @@
BentoPDF is dual-licensed under **AGPL-3.0** (for open-source projects) and a **Commercial License** (for proprietary/closed-source applications).
::: tip Full Details
For complete licensing information, delivery details, AGPL component notices, and invoicing, visit our [Licensing Page](https://bentopdf.com/licensing.html).
For complete licensing information, delivery details, AGPL component notices, and invoicing, visit our [Licensing Page](https://www.bentopdf.com/licensing).
:::
## Get a Commercial License
+1 -1
View File
@@ -148,7 +148,7 @@ Running the Commercial build is allowed under either of BentoPDF's two license o
- **AGPL-3.0** (free) — allowed if your deployment publishes its full source code under AGPL, including any branding modifications and surrounding business logic.
- **Commercial license** ($79 lifetime) — required for closed-source / proprietary deployments where you don't open-source your branding fork or business code.
See the [Licensing page](https://bentopdf.com/licensing.html) for the full comparison. AGPL-licensed WASM modules (PyMuPDF, Ghostscript, CoherentPDF) load from a CDN at runtime, so they don't enter your image and don't change your licensing posture.
See the [Licensing page](https://www.bentopdf.com/licensing) for the full comparison. AGPL-licensed WASM modules (PyMuPDF, Ghostscript, CoherentPDF) load from a CDN at runtime, so they don't enter your image and don't change your licensing posture.
:::
### Base URL
+3 -3
View File
@@ -559,7 +559,7 @@
class="w-20 h-20 md:w-24 md:h-24 rounded-full bg-blue-600 flex items-center justify-center mb-4"
>
<img
src="images/gdpr.svg"
src="/images/gdpr.svg"
alt="GDPR compliance"
class="w-full h-full"
/>
@@ -584,7 +584,7 @@
class="w-20 h-20 md:w-24 md:h-24 rounded-full bg-blue-600 flex items-center justify-center mb-4"
>
<img
src="images/ccpa.svg"
src="/images/ccpa.svg"
alt="CCPA compliance"
class="w-full h-full"
/>
@@ -609,7 +609,7 @@
class="w-20 h-20 md:w-24 md:h-24 rounded-full bg-blue-600 flex items-center justify-center mb-4"
>
<img
src="images/hipaa.svg"
src="/images/hipaa.svg"
alt="HIPAA compliance"
class="w-full h-full"
/>
+11 -8
View File
@@ -110,11 +110,8 @@ http {
return 301 $1/$2$3;
}
if ($request_uri ~ ^(/(?:en|ar|be|da|de|es|fr|id|it|ko|nl|pl|pt|ru|sk|sv|tr|vi|zh|zh-TW|uk|ja|ca))/([a-z0-9][a-z0-9-]*)/(\?.*)?$) {
return 301 $1/$2$3;
}
if ($request_uri ~ ^/((?!(?:en|ar|be|da|de|es|fr|id|it|ko|nl|pl|pt|ru|sk|sv|tr|vi|zh|zh-TW|uk|ja|ca|docs|blog)/)[a-z0-9][a-z0-9-]*)/(\?.*)?$) {
return 301 /$1$2;
if ($request_uri ~ ^(/[^?]*[^/?])/+(\?.*)?$) {
return 301 $1$2;
}
location ~ /src/pages/ {
@@ -122,7 +119,7 @@ http {
}
location ~ ^/(en|ar|be|da|de|es|fr|id|it|ko|nl|pl|pt|ru|sk|sv|tr|vi|zh|zh-TW|uk|ja|ca)(/.*)?$ {
try_files $uri $uri/ $uri.html =404;
try_files $uri $uri.html $uri/index.html =404;
expires 5m;
}
@@ -174,14 +171,20 @@ http {
error_page 404 /404.html;
location = /docs {
include /etc/nginx/security-headers-docs.conf;
try_files /docs/index.html =404;
expires 5m;
}
location ^~ /docs/ {
include /etc/nginx/security-headers-docs.conf;
try_files $uri $uri/ $uri.html /docs/index.html =404;
try_files $uri $uri.html $uri/index.html /docs/index.html =404;
expires 5m;
}
location / {
try_files $uri $uri/ $uri.html =404;
try_files $uri $uri.html $uri/index.html =404;
expires 5m;
}
}
+3 -1
View File
@@ -8,6 +8,7 @@ const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const DIST_DIR = path.resolve(__dirname, '../dist');
const BASE_PATH = (process.env.BASE_URL || '/').replace(/\/$/, '');
const SITE_URL = (process.env.SITE_URL || 'https://www.bentopdf.com').replace(
/\/+$/,
''
@@ -125,7 +126,8 @@ function rewriteInternalLinks(document, knownSlugs) {
);
if (!match) continue;
if (!knownSlugs.has(match[1])) continue;
const target = match[1] === 'index' ? '/' : `/${match[1]}`;
const target =
match[1] === 'index' ? BASE_PATH || '/' : `${BASE_PATH}/${match[1]}`;
link.setAttribute('href', `${target}${match[2] || ''}`);
}
}
+4 -4
View File
@@ -274,7 +274,7 @@ ${renderFaqs(meta.faqs)}
'@type': 'ListItem',
position: 1,
name: 'Blog',
item: `${SITE_URL}/blog/`,
item: `${SITE_URL}/blog`,
},
{
'@type': 'ListItem',
@@ -319,9 +319,9 @@ function renderIndex(posts) {
content="Guides and honest comparisons from the maintainer of BentoPDF: how PDF tools handle your files, and how to get things done without uploading them."
/>
<meta name="robots" content="index, follow, max-image-preview:large" />
<link rel="canonical" href="${SITE_URL}/blog/" />
<link rel="canonical" href="${SITE_URL}/blog" />
<meta property="og:type" content="website" />
<meta property="og:url" content="${SITE_URL}/blog/" />
<meta property="og:url" content="${SITE_URL}/blog" />
<meta property="og:title" content="The BentoPDF Blog" />
<meta
property="og:description"
@@ -363,7 +363,7 @@ ${cards}
'@context': 'https://schema.org',
'@type': 'Blog',
name: 'The BentoPDF Blog',
url: `${SITE_URL}/blog/`,
url: `${SITE_URL}/blog`,
description:
'Guides and honest comparisons from the maintainer of BentoPDF.',
publisher: {
+6 -4
View File
@@ -60,8 +60,7 @@ function buildUrl(langPrefix, pagePath) {
if (BASE_PATH && BASE_PATH !== '') parts.push(BASE_PATH.replace(/^\//, ''));
if (langPrefix) parts.push(langPrefix);
if (pagePath) parts.push(pagePath.replace(/^\//, ''));
const url = parts.filter(Boolean).join('/').replace(/\/+$/, '') || SITE_URL;
return langPrefix && !pagePath ? `${url}/` : url;
return parts.filter(Boolean).join('/').replace(/\/+$/, '') || SITE_URL;
}
function resolveTranslation(langResources, key) {
@@ -142,7 +141,7 @@ const BREADCRUMB_MARKER = 'data-bentopdf-breadcrumb';
const BRAND_NAME = process.env.VITE_BRAND_NAME || 'BentoPDF';
function buildLocalHomeHref(lang) {
const langSegment = lang === 'en' ? '/' : `/${lang}/`;
const langSegment = lang === 'en' ? '/' : `/${lang}`;
return `${BASE_PATH}${langSegment}`.replace(/\/+/g, '/');
}
@@ -378,7 +377,10 @@ function processFileForLanguage(
newHref = `${BASE_PATH}/${lang}/${href}`;
}
link.setAttribute('href', newHref);
link.setAttribute(
'href',
newHref.replace(/^([^?#]*[^/?#])\/(?=$|[?#])/, '$1')
);
});
const result = dom.serialize();
+2 -2
View File
@@ -81,7 +81,7 @@ function buildUrl(lang, pageName) {
if (lang === 'en') {
return pagePath ? `${SITE_URL}/${pagePath}` : SITE_URL;
}
return pagePath ? `${SITE_URL}/${lang}/${pagePath}` : `${SITE_URL}/${lang}/`;
return pagePath ? `${SITE_URL}/${lang}/${pagePath}` : `${SITE_URL}/${lang}`;
}
function generateSitemap() {
@@ -177,7 +177,7 @@ function generateSitemap() {
.map((file) => file.replace('.html', ''));
for (const name of blogFiles) {
const url =
name === 'index' ? `${SITE_URL}/blog/` : `${SITE_URL}/blog/${name}`;
name === 'index' ? `${SITE_URL}/blog` : `${SITE_URL}/blog/${name}`;
const sourcePath = path.join(projectRoot, 'blog', `${name}.html`);
let lastmod;
try {
+2 -2
View File
@@ -111,7 +111,7 @@ function listDocsHtml(dir, prefix) {
);
} else if (entry.name.endsWith('.html') && entry.name !== '404.html') {
const slug = entry.name.replace(/\.html$/, '');
urls.push(slug === 'index' ? `${prefix}/` : `${prefix}/${slug}`);
urls.push(slug === 'index' ? prefix : `${prefix}/${slug}`);
}
}
return urls;
@@ -130,7 +130,7 @@ function addDocsToSitemap() {
return;
}
let xml = fs.readFileSync(sitemapPath, 'utf-8');
if (xml.includes(`<loc>${siteUrl}/docs/</loc>`)) return;
if (xml.includes(`<loc>${siteUrl}/docs</loc>`)) return;
const urls = listDocsHtml(targetDocsPath, '/docs');
const entries = urls
+24 -4
View File
@@ -85,6 +85,11 @@ for (const lang of LOCALE_DIRS) {
: {};
}
function hasTrailingSlash(href) {
const { pathname } = new URL(href);
return pathname !== '/' && pathname.endsWith('/');
}
function expectedCanonicalForFile(rel) {
const parts = rel.split('/');
const fileName = parts.pop();
@@ -121,7 +126,11 @@ function auditHtml(file, knownSlugs) {
if (!isLocalized) {
const internal = findAll(html, /<a[^>]+href=["'](\/[^"'#?]*)["']/g);
for (const href of internal) {
const clean = href.replace(/\/$/, '');
const clean = (
BASE_PATH && (href === BASE_PATH || href.startsWith(`${BASE_PATH}/`))
? href.slice(BASE_PATH.length)
: href
).replace(/\/$/, '');
if (clean === '') continue;
const seg = clean.slice(1);
if (seg.includes('/')) continue;
@@ -162,6 +171,12 @@ function auditHtml(file, knownSlugs) {
}
try {
const url = new URL(actual);
if (hasTrailingSlash(actual)) {
fail(
'canonical',
`${file.rel}: canonical "${actual}" ends with "/" (the server redirects it to the slashless form)`
);
}
if (url.hostname !== HOST) {
warn(
'canonical',
@@ -230,11 +245,10 @@ function auditHtml(file, knownSlugs) {
`${file.rel}: hreflang href host "${url.hostname}" != expected "${HOST}"`
);
}
const trimmed = url.pathname.replace(/^\/|\/$/g, '');
if (LOCALE_DIRS.has(trimmed) && !url.pathname.endsWith('/')) {
if (hasTrailingSlash(m[1])) {
fail(
'hreflang',
`${file.rel}: language-home hreflang "${m[1]}" must end with "/" (the server 301s the slashless form)`
`${file.rel}: hreflang "${m[1]}" ends with "/" (the server redirects it to the slashless form)`
);
}
} catch {
@@ -334,6 +348,12 @@ function auditSitemap() {
try {
const url = new URL(loc);
if (hasTrailingSlash(loc)) {
fail(
'sitemap',
`sitemap URL ends with "/" (the server redirects it to the slashless form): ${loc}`
);
}
if (url.hostname !== HOST) {
fail(
'sitemap',
+7 -2
View File
@@ -184,7 +184,10 @@ export const changeLanguage = (lang: SupportedLanguage): void => {
if (lang === 'en') {
newRelativePath = pagePathWithoutLang;
} else {
newRelativePath = `/${lang}${pagePathWithoutLang}`;
newRelativePath =
pagePathWithoutLang === '/'
? `/${lang}`
: `/${lang}${pagePathWithoutLang}`;
}
let newPath: string;
@@ -293,7 +296,9 @@ export const rewriteLinks = (): void => {
newHref = `/${currentLang}/${href}`;
}
newHref = newHref.replace(/([^:])\/+/g, '$1/');
newHref = newHref
.replace(/([^:])\/+/g, '$1/')
.replace(/^([^?#]*[^/?#])\/(?=$|[?#])/, '$1');
link.setAttribute('href', newHref);
});
+16 -6
View File
@@ -20,6 +20,7 @@ import type {
AppliedRedactionTracker,
DocManagerPlugin,
FreeTextSystemFontAnnotation,
ViewerCommandsPlugin,
RedactionPluginLite,
} from '@/types';
@@ -328,12 +329,8 @@ async function handleFiles(files: FileList) {
pdfWrapper.classList.remove('hidden');
const { default: EmbedPDF } = await import('bentopdf-viewer');
const disabledCategories = [
...getEditorDisabledCategories(),
'document-export',
];
viewerInstance = EmbedPDF.init({
disabledCategories,
disabledCategories: getEditorDisabledCategories(),
type: 'container',
target: pdfContainer,
worker: true,
@@ -425,7 +422,7 @@ async function handleFiles(files: FileList) {
}
downloadBtn.classList.remove('hidden');
downloadBtn.onclick = async () => {
const downloadEditedPdf = async () => {
try {
const documentId = activeDocuments.getActiveDocumentId();
const pendingCount = redactionPlugin?.getState().pendingCount ?? 0;
@@ -541,6 +538,19 @@ async function handleFiles(files: FileList) {
showAlert('Error', 'Failed to download the edited PDF.');
}
};
downloadBtn.onclick = downloadEditedPdf;
const commands = registry.getPlugin('commands')?.provides() as unknown as
| ViewerCommandsPlugin
| undefined;
commands?.unregisterCommand('document:export');
commands?.registerCommand({
id: 'document:export',
labelKey: 'document.export',
icon: 'download',
categories: ['document', 'document-export'],
action: () => void downloadEditedPdf(),
});
const backBtn = document.getElementById('back-to-tools');
if (backBtn) {
+13
View File
@@ -14,6 +14,19 @@ export interface DocManagerPlugin {
saveAsCopy: (id: string) => Promise<Uint8Array>;
}
export interface ViewerCommand {
id: string;
labelKey: string;
icon: string;
categories: string[];
action: () => void;
}
export interface ViewerCommandsPlugin {
registerCommand: (command: ViewerCommand) => void;
unregisterCommand: (id: string) => void;
}
export interface EditorAnnotationRect {
origin: { x: number; y: number };
size: { width: number; height: number };
+2 -2
View File
@@ -55,7 +55,7 @@
</div>
</div>
<a
href="https://www.bentopdf.com/docs/"
href="https://www.bentopdf.com/docs"
class="nav-link"
data-i18n="nav.docs"
>Docs</a
@@ -186,7 +186,7 @@
<a href="/hyper-compress" class="mobile-nav-link">Hyper Compress</a>
</div>
<a
href="https://www.bentopdf.com/docs/"
href="https://www.bentopdf.com/docs"
class="mobile-nav-link"
data-i18n="nav.docs"
>Docs</a
+91
View File
@@ -7,6 +7,97 @@
"cleanUrls": true,
"trailingSlash": false,
"redirects": [
{
"source": "/ua/change-text-color",
"destination": "/uk/text-color",
"permanent": true
},
{
"source": "/ua/src/pages/change-text-color",
"destination": "/uk/text-color",
"permanent": true
},
{
"source": "/:locale(ar|be|da|de|es|fr|id|it|ja|ko|nl|pl|pt|ru|sk|sv|tr|uk|vi|zh|zh-TW)/src/pages/change-text-color",
"destination": "/:locale/text-color",
"permanent": true
},
{
"source": "/(en|tools|src/pages|en/src/pages)/change-text-color",
"destination": "/text-color",
"permanent": true
},
{
"source": "/ua/pdf-to-docx",
"destination": "/uk/pdf-to-word",
"permanent": true
},
{
"source": "/ua/src/pages/pdf-to-docx",
"destination": "/uk/pdf-to-word",
"permanent": true
},
{
"source": "/:locale(ar|be|da|de|es|fr|id|it|ja|ko|nl|pl|pt|ru|sk|sv|tr|uk|vi|zh|zh-TW)/src/pages/pdf-to-docx",
"destination": "/:locale/pdf-to-word",
"permanent": true
},
{
"source": "/(en|tools|src/pages|en/src/pages)/pdf-to-docx",
"destination": "/pdf-to-word",
"permanent": true
},
{
"source": "/ua/decrypt-pdf",
"destination": "/uk/unlock-pdf",
"permanent": true
},
{
"source": "/ua/src/pages/decrypt-pdf",
"destination": "/uk/unlock-pdf",
"permanent": true
},
{
"source": "/:locale(ar|be|da|de|es|fr|id|it|ja|ko|nl|pl|pt|ru|sk|sv|tr|uk|vi|zh|zh-TW)/src/pages/decrypt-pdf",
"destination": "/:locale/unlock-pdf",
"permanent": true
},
{
"source": "/(en|tools|src/pages|en/src/pages)/decrypt-pdf",
"destination": "/unlock-pdf",
"permanent": true
},
{
"source": "/ua/encrypt-pdf",
"destination": "/uk/protect-pdf",
"permanent": true
},
{
"source": "/ua/src/pages/encrypt-pdf",
"destination": "/uk/protect-pdf",
"permanent": true
},
{
"source": "/:locale(ar|be|da|de|es|fr|id|it|ja|ko|nl|pl|pt|ru|sk|sv|tr|uk|vi|zh|zh-TW)/src/pages/encrypt-pdf",
"destination": "/:locale/protect-pdf",
"permanent": true
},
{
"source": "/(en|tools|src/pages|en/src/pages)/encrypt-pdf",
"destination": "/protect-pdf",
"permanent": true
},
{ "source": "/ua", "destination": "/uk", "permanent": true },
{
"source": "/ua/src/pages/:page",
"destination": "/uk/:page",
"permanent": true
},
{
"source": "/en/src/pages/:page",
"destination": "/:page",
"permanent": true
},
{ "source": "/ua/:path*", "destination": "/uk/:path*", "permanent": true },
{ "source": "/en/:path*", "destination": "/:path*", "permanent": true },
{
+9 -2
View File
@@ -515,11 +515,18 @@ function rewriteHtmlPathsPlugin(): Plugin {
for (const fileName of Object.keys(bundle)) {
if (!fileName.endsWith('.html')) continue;
const diskPath = resolve(outDir, fileName);
if (!fs.existsSync(diskPath)) continue;
const diskPath = [
resolve(outDir, fileName),
resolve(outDir, fileName.replace(/^src\/pages\//, '')),
].find((candidate) => fs.existsSync(candidate));
if (!diskPath) continue;
const source = fs.readFileSync(diskPath, 'utf8');
const updated = source
.replace(hrefRegex, `href="${normalizedBase}`)
.replaceAll(
`href="${normalizedBase}"`,
`href="${normalizedBase.slice(0, -1)}"`
)
.replace(srcRegex, `src="${normalizedBase}`)
.replace(contentRegex, `content="${normalizedBase}`);
if (updated !== source) {