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 -[![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 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" > GDPR compliance @@ -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" > CCPA compliance @@ -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" > HIPAA compliance 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) {