diff --git a/README.md b/README.md
index 60796a56..ea008571 100644
--- a/README.md
+++ b/README.md
@@ -71,9 +71,9 @@ Have questions, feature requests, or want to chat with the community? Join our D
## 📚 Documentation
-[](https://bentopdf.com/docs/)
+[](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
diff --git a/about.html b/about.html
index be49594a..d2513b8e 100644
--- a/about.html
+++ b/about.html
@@ -332,7 +332,10 @@
-
+
Docs
diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts
index b1ff8b2d..bad15862 100644
--- a/docs/.vitepress/config.mts
+++ b/docs/.vitepress/config.mts
@@ -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 }])
},
diff --git a/docs/getting-started.md b/docs/getting-started.md
index a6107893..064d620c 100644
--- a/docs/getting-started.md
+++ b/docs/getting-started.md
@@ -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
diff --git a/docs/licensing.md b/docs/licensing.md
index 791e84d5..e9e24f0b 100644
--- a/docs/licensing.md
+++ b/docs/licensing.md
@@ -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
diff --git a/docs/self-hosting/index.md b/docs/self-hosting/index.md
index 35be1b6b..365322cc 100644
--- a/docs/self-hosting/index.md
+++ b/docs/self-hosting/index.md
@@ -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
diff --git a/index.html b/index.html
index 0f33e5a4..c6698d8d 100644
--- a/index.html
+++ b/index.html
@@ -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"
>
@@ -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"
>
@@ -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"
>
diff --git a/nginx.conf b/nginx.conf
index 6ddc157f..2fc61166 100644
--- a/nginx.conf
+++ b/nginx.conf
@@ -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;
}
}
diff --git a/scripts/enhance-seo-pages.mjs b/scripts/enhance-seo-pages.mjs
index 2d3ca4f1..155f77db 100644
--- a/scripts/enhance-seo-pages.mjs
+++ b/scripts/enhance-seo-pages.mjs
@@ -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] || ''}`);
}
}
diff --git a/scripts/generate-blog.mjs b/scripts/generate-blog.mjs
index 2474787e..b4167eb7 100644
--- a/scripts/generate-blog.mjs
+++ b/scripts/generate-blog.mjs
@@ -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."
/>
-
+
-
+
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 {
diff --git a/scripts/include-docs-in-dist.js b/scripts/include-docs-in-dist.js
index c1e97563..b76b270c 100644
--- a/scripts/include-docs-in-dist.js
+++ b/scripts/include-docs-in-dist.js
@@ -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(`${siteUrl}/docs/`)) return;
+ if (xml.includes(`${siteUrl}/docs`)) return;
const urls = listDocsHtml(targetDocsPath, '/docs');
const entries = urls
diff --git a/scripts/seo-audit.mjs b/scripts/seo-audit.mjs
index ce48a327..68ab7212 100644
--- a/scripts/seo-audit.mjs
+++ b/scripts/seo-audit.mjs
@@ -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, /]+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',
diff --git a/src/js/i18n/i18n.ts b/src/js/i18n/i18n.ts
index f5f1aa9a..2b8d21db 100644
--- a/src/js/i18n/i18n.ts
+++ b/src/js/i18n/i18n.ts
@@ -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);
});
diff --git a/src/js/logic/edit-pdf-page.ts b/src/js/logic/edit-pdf-page.ts
index 5d4ba933..23438da6 100644
--- a/src/js/logic/edit-pdf-page.ts
+++ b/src/js/logic/edit-pdf-page.ts
@@ -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) {
diff --git a/src/js/types/edit-pdf-type.ts b/src/js/types/edit-pdf-type.ts
index 90fa11b2..4bc655a8 100644
--- a/src/js/types/edit-pdf-type.ts
+++ b/src/js/types/edit-pdf-type.ts
@@ -14,6 +14,19 @@ export interface DocManagerPlugin {
saveAsCopy: (id: string) => Promise;
}
+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 };
diff --git a/src/partials/navbar.html b/src/partials/navbar.html
index 9604af02..64db4659 100644
--- a/src/partials/navbar.html
+++ b/src/partials/navbar.html
@@ -55,7 +55,7 @@
DocsHyper Compress
Docs 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) {