seo fixes

This commit is contained in:
alam00000 committed 2026-09-04 16:26:08 +05:30
1 parent 83ff47db9b
commit 9e980cdea2
189 files changed
+17781 -2238

No files matched your search

+174
View File
@@ -0,0 +1,174 @@
import fs from 'fs';
import path from 'path';
import { JSDOM } from 'jsdom';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const DIST_DIR = path.resolve(__dirname, '../dist');
const SITE_URL = (process.env.SITE_URL || 'https://www.bentopdf.com').replace(
/\/+$/,
''
);
const FALLBACK_SOCIAL_IMAGE = '/images/og-tools.png';
function listTopLevelHtml() {
return fs
.readdirSync(DIST_DIR)
.filter((f) => f.endsWith('.html'))
.map((f) => path.join(DIST_DIR, f));
}
function imageExists(url) {
if (!url) return true;
let pathname;
try {
pathname = url.startsWith('http') ? new URL(url).pathname : url;
} catch {
return true;
}
if (!pathname.startsWith('/images/')) return true;
return fs.existsSync(path.join(DIST_DIR, pathname.replace(/^\//, '')));
}
function fixSocialImages(document) {
const selectors = ['meta[property="og:image"]', 'meta[name="twitter:image"]'];
for (const selector of selectors) {
for (const meta of document.querySelectorAll(selector)) {
const value = meta.getAttribute('content');
if (!imageExists(value)) {
meta.setAttribute('content', `${SITE_URL}${FALLBACK_SOCIAL_IMAGE}`);
}
}
}
}
function fixApplicationCategory(document) {
for (const script of document.querySelectorAll(
'script[type="application/ld+json"]'
)) {
let data;
try {
data = JSON.parse(script.textContent || '');
} catch {
continue;
}
if (
data &&
(data['@type'] === 'SoftwareApplication' ||
data['@type'] === 'WebApplication') &&
data.applicationCategory !== 'UtilitiesApplication'
) {
data.applicationCategory = 'UtilitiesApplication';
script.textContent = JSON.stringify(data, null, 2);
}
}
}
function hasLdType(document, typeName) {
return [
...document.querySelectorAll('script[type="application/ld+json"]'),
].some((s) => (s.textContent || '').includes(`"${typeName}"`));
}
function injectFaqSchema(document) {
if (hasLdType(document, 'FAQPage')) return false;
const items = [];
for (const details of document.querySelectorAll('details.faq-d')) {
const summary = details.querySelector('summary');
const answer = details.querySelector('p');
if (!summary || !answer) continue;
const question = summary.textContent.replace(/\s+/g, ' ').trim();
const answerText = answer.textContent.replace(/\s+/g, ' ').trim();
if (!question || !answerText) continue;
items.push({
'@type': 'Question',
name: question,
acceptedAnswer: { '@type': 'Answer', text: answerText },
});
}
if (items.length === 0) return false;
const script = document.createElement('script');
script.setAttribute('type', 'application/ld+json');
script.textContent = JSON.stringify(
{
'@context': 'https://schema.org',
'@type': 'FAQPage',
mainEntity: items,
},
null,
2
);
document.body.appendChild(script);
return true;
}
function injectWebsiteSchema(document) {
if (hasLdType(document, 'WebSite')) return;
const script = document.createElement('script');
script.setAttribute('type', 'application/ld+json');
script.textContent = JSON.stringify(
{
'@context': 'https://schema.org',
'@type': 'WebSite',
name: 'BentoPDF',
alternateName: ['Bento PDF', 'BentoPDF.com'],
url: `${SITE_URL}/`,
},
null,
2
);
document.body.appendChild(script);
}
function rewriteInternalLinks(document, knownSlugs) {
for (const link of document.querySelectorAll('a[href]')) {
const href = link.getAttribute('href');
if (!href || /^(https?:|\/\/|#|mailto:|tel:|data:)/.test(href)) continue;
const match = href.match(
/^(?:\.\/|\/)?([a-z0-9][a-z0-9-]{0,64})\.html(#[^#]{0,128})?$/
);
if (!match) continue;
if (!knownSlugs.has(match[1])) continue;
const target = match[1] === 'index' ? '/' : `/${match[1]}`;
link.setAttribute('href', `${target}${match[2] || ''}`);
}
}
function enhance() {
if (!fs.existsSync(DIST_DIR)) {
console.error('enhance-seo-pages: dist not found, run vite build first');
process.exit(1);
}
const files = listTopLevelHtml();
const knownSlugs = new Set(files.map((f) => path.basename(f, '.html')));
let faqSchemaCount = 0;
for (const file of files) {
const slug = path.basename(file, '.html');
const dom = new JSDOM(fs.readFileSync(file, 'utf-8'));
const document = dom.window.document;
fixSocialImages(document);
fixApplicationCategory(document);
rewriteInternalLinks(document, knownSlugs);
if (slug === 'index') {
injectWebsiteSchema(document);
}
if (slug !== '404') {
if (injectFaqSchema(document)) faqSchemaCount++;
}
fs.writeFileSync(file, dom.serialize());
dom.window.close();
}
console.log(
`SEO enhance: ${files.length} pages processed, ${faqSchemaCount} FAQPage schemas added.`
);
}
enhance();
+429
View File
@@ -0,0 +1,429 @@
import fs from 'fs';
import path from 'path';
import { marked } from 'marked';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const POSTS_DIR = path.resolve(__dirname, '../blog/posts');
const BLOG_DIR = path.resolve(__dirname, '../blog');
const SITE_URL = 'https://www.bentopdf.com';
const AUTHOR = {
name: 'Alam',
url: `${SITE_URL}/blog/author-alam`,
image: `${SITE_URL}/images/author-alam.jpg`,
profiles: [
'https://github.com/alam00000',
'https://www.linkedin.com/in/abdullah-alam01/',
],
};
const LINK_CLASS = 'text-indigo-300 hover:text-indigo-400';
function escapeHtml(value) {
return value
.replace(/&/g, '&')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function styleBody(html) {
return html
.replace(/<h2>/g, '<h2 class="text-2xl font-bold text-white mt-10 mb-4">')
.replace(/<h3>/g, '<h3 class="text-xl font-semibold text-white mt-6 mb-2">')
.replace(/<p>/g, '<p class="text-gray-300 leading-relaxed mb-5">')
.replace(
/<ol>/g,
'<ol class="list-decimal list-inside text-gray-300 leading-relaxed mb-5 space-y-2">'
)
.replace(
/<ul>/g,
'<ul class="list-disc list-inside text-gray-300 leading-relaxed mb-5 space-y-2">'
)
.replace(
/<a href="http/g,
`<a rel="noopener" class="${LINK_CLASS}" href="http`
)
.replace(/<a href="\//g, `<a class="${LINK_CLASS}" href="/`);
}
function renderInline(markdown) {
return styleBody(marked.parseInline(markdown));
}
function formatDate(iso) {
return new Date(`${iso}T12:00:00Z`).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
timeZone: 'UTC',
});
}
function parsePost(file) {
const raw = fs.readFileSync(path.join(POSTS_DIR, file), 'utf-8');
if (/[—–]/.test(raw)) {
throw new Error(
`${file}: contains an em or en dash, which the house style forbids`
);
}
const match = raw.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
if (!match) {
throw new Error(`${file}: missing JSON frontmatter between --- markers`);
}
let meta;
try {
meta = JSON.parse(match[1]);
} catch (err) {
throw new Error(`${file}: frontmatter is not valid JSON: ${err.message}`, {
cause: err,
});
}
for (const key of [
'title',
'h1',
'description',
'date',
'breadcrumb',
'card',
]) {
if (!meta[key]) throw new Error(`${file}: frontmatter missing "${key}"`);
}
return { slug: file.replace(/\.md$/, ''), meta, body: match[2].trim() };
}
function plainText(markdown) {
return markdown
.replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
.replace(/[*_`>#]/g, '')
.replace(/^\s*\d+\.\s+/gm, '')
.replace(/^\s*[-+]\s+/gm, '')
.replace(/\s+/g, ' ')
.trim();
}
function questionSections(body) {
const sections = [];
const parts = body.split(/^##\s+/m).slice(1);
for (const part of parts) {
const newline = part.indexOf('\n');
const heading = (newline === -1 ? part : part.slice(0, newline)).trim();
if (!heading.endsWith('?')) continue;
const answer = plainText(newline === -1 ? '' : part.slice(newline + 1));
if (!answer) continue;
sections.push({ q: heading, a: answer.slice(0, 600) });
}
return sections;
}
function faqSchema(entries) {
if (entries.length === 0) return '';
return `
<script type="application/ld+json">
${JSON.stringify(
{
'@context': 'https://schema.org',
'@type': 'FAQPage',
mainEntity: entries.map((f) => ({
'@type': 'Question',
name: f.q,
acceptedAnswer: { '@type': 'Answer', text: plainText(f.a) },
})),
},
null,
2
)}
</script>`;
}
function renderFaqs(faqs) {
if (!faqs || faqs.length === 0) return '';
const items = faqs
.map(
(f) => ` <details class="faq-d">
<summary class="faq-d-q">${escapeHtml(f.q)}<i data-lucide="plus" class="faq-d-icon"></i></summary>
<p class="faq-d-a">
${renderInline(f.a)}
</p>
</details>`
)
.join('\n');
return `
<h2 class="text-2xl font-bold text-white mt-10 mb-4">FAQ</h2>
<div class="faq-list">
${items}
</div>`;
}
function renderCta(cta) {
if (!cta) return '';
return `
<div class="bg-gray-800 border border-gray-700 rounded-xl p-6 my-10">
<p class="text-white font-semibold mb-2">${escapeHtml(cta.heading)}</p>
<p class="text-gray-400 mb-4">${escapeHtml(cta.text)}</p>
<a
href="${cta.href}"
class="inline-block bg-indigo-600 hover:bg-indigo-700 text-white font-semibold px-5 py-2.5 rounded-lg"
>${escapeHtml(cta.label)}</a
>
</div>`;
}
function renderPost({ slug, meta, body }) {
const url = `${SITE_URL}/blog/${slug}`;
const updated = meta.updated || meta.date;
const bodyHtml = styleBody(marked.parse(body));
const faqEntries =
meta.faqs && meta.faqs.length > 0 ? meta.faqs : questionSections(body);
return `<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>${escapeHtml(meta.title)}</title>
<meta name="description" content="${escapeHtml(meta.description)}" />
<meta name="robots" content="index, follow, max-image-preview:large" />
<link rel="canonical" href="${url}" />
<meta property="og:type" content="article" />
<meta property="og:url" content="${url}" />
<meta property="og:title" content="${escapeHtml(meta.ogTitle || meta.h1)}" />
<meta property="og:description" content="${escapeHtml(meta.description)}" />
<meta property="og:image" content="${SITE_URL}/images/og-tools.png" />
<meta property="og:site_name" content="BentoPDF" />
<meta name="twitter:card" content="summary_large_image" />
<link rel="manifest" href="/site.webmanifest" />
<link rel="icon" type="image/svg+xml" href="/images/favicon.svg" />
<link rel="icon" href="/favicon.ico" sizes="32x32" />
<link href="/src/css/styles.css" rel="stylesheet" />
</head>
<body class="antialiased bg-gray-900">
{{> navbar }}
<article class="max-w-3xl mx-auto px-4 py-12">
<nav
class="flex items-center gap-2 text-sm text-gray-400 mb-6"
aria-label="Breadcrumb"
>
<a href="/blog" class="hover:text-indigo-300">Blog</a>
<i class="ph ph-caret-right text-xs" aria-hidden="true"></i>
<span class="text-gray-300">${escapeHtml(meta.breadcrumb)}</span>
</nav>
<h1 class="text-3xl md:text-4xl font-bold text-white mb-4 leading-tight">
${escapeHtml(meta.h1)}
</h1>
<div class="flex items-center gap-3 mb-10 text-sm text-gray-400">
<img
src="/images/author-alam.jpg"
alt="${AUTHOR.name}"
width="40"
height="40"
class="rounded-full"
/>
<div>
<a href="/blog/author-alam" class="text-gray-200 font-semibold hover:text-indigo-300">${AUTHOR.name}</a>,
builds BentoPDF ·
<time datetime="${meta.date}">${formatDate(meta.date)}</time>
</div>
</div>
${bodyHtml}
${renderCta(meta.cta)}
${renderFaqs(meta.faqs)}
</article>
{{> footer }}
<script type="module" src="/src/js/utils/lucide-init.ts"></script>
<script type="module" src="/src/js/mobileMenu.ts"></script>
<script type="module">
import '@phosphor-icons/web/regular';
</script>
<script type="application/ld+json">
${JSON.stringify(
{
'@context': 'https://schema.org',
'@type': 'BlogPosting',
headline: meta.h1,
description: meta.description,
url,
datePublished: meta.date,
dateModified: updated,
image: `${SITE_URL}/images/og-tools.png`,
author: {
'@type': 'Person',
name: AUTHOR.name,
url: AUTHOR.url,
sameAs: AUTHOR.profiles,
},
publisher: {
'@type': 'Organization',
name: 'BentoPDF',
url: SITE_URL,
},
},
null,
2
)}
</script>
<script type="application/ld+json">
${JSON.stringify(
{
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
itemListElement: [
{
'@type': 'ListItem',
position: 1,
name: 'Blog',
item: `${SITE_URL}/blog/`,
},
{
'@type': 'ListItem',
position: 2,
name: meta.breadcrumb,
item: url,
},
],
},
null,
2
)}
</script>${faqSchema(faqEntries)}
</body>
</html>
`;
}
function renderIndex(posts) {
const cards = posts
.map(
({ slug, meta }) => ` <a
href="/blog/${slug}"
class="block bg-gray-800 border border-gray-700 rounded-xl p-6 hover:border-indigo-500 transition-colors"
>
<h2 class="text-xl font-bold text-white mb-2">
${escapeHtml(meta.h1)}
</h2>
<p class="text-gray-400 mb-3">
${escapeHtml(meta.card)}
</p>
<span class="text-sm text-gray-500">${formatDate(meta.date)}</span>
</a>`
)
.join('\n\n');
return `<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>The BentoPDF Blog | Privacy-First PDF Guides</title>
<meta
name="description"
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/" />
<meta property="og:type" content="website" />
<meta property="og:url" content="${SITE_URL}/blog/" />
<meta property="og:title" content="The BentoPDF Blog" />
<meta
property="og:description"
content="Guides and honest comparisons from the maintainer of BentoPDF."
/>
<meta property="og:image" content="${SITE_URL}/images/og-tools.png" />
<meta property="og:site_name" content="BentoPDF" />
<meta name="twitter:card" content="summary_large_image" />
<link rel="manifest" href="/site.webmanifest" />
<link rel="icon" type="image/svg+xml" href="/images/favicon.svg" />
<link rel="icon" href="/favicon.ico" sizes="32x32" />
<link href="/src/css/styles.css" rel="stylesheet" />
</head>
<body class="antialiased bg-gray-900">
{{> navbar }}
<section class="max-w-3xl mx-auto px-4 py-12">
<h1 class="text-3xl md:text-4xl font-bold text-white mb-4">The BentoPDF Blog</h1>
<p class="text-gray-300 leading-relaxed mb-2">
I'm <a href="/blog/author-alam" class="${LINK_CLASS}">Alam</a>. I build
BentoPDF and write about PDFs, how they work, and the weird things I
run into along the way.
</p>
</section>
<section class="max-w-3xl mx-auto px-4 pb-16">
<div class="space-y-6">
${cards}
</div>
</section>
{{> footer }}
<script type="module" src="/src/js/utils/lucide-init.ts"></script>
<script type="module" src="/src/js/mobileMenu.ts"></script>
<script type="application/ld+json">
${JSON.stringify(
{
'@context': 'https://schema.org',
'@type': 'Blog',
name: 'The BentoPDF Blog',
url: `${SITE_URL}/blog/`,
description:
'Guides and honest comparisons from the maintainer of BentoPDF.',
publisher: {
'@type': 'Organization',
name: 'BentoPDF',
url: SITE_URL,
},
},
null,
2
)}
</script>
</body>
</html>
`;
}
function generate() {
if (!fs.existsSync(POSTS_DIR)) {
console.log('generate-blog: no blog/posts directory, skipping');
return;
}
const files = fs
.readdirSync(POSTS_DIR)
.filter((f) => f.endsWith('.md'))
.sort();
const posts = files.map(parsePost);
posts.sort((a, b) => (a.meta.date < b.meta.date ? 1 : -1));
const keep = new Set(['index.html', 'author-alam.html']);
for (const post of posts) {
fs.writeFileSync(
path.join(BLOG_DIR, `${post.slug}.html`),
renderPost(post)
);
keep.add(`${post.slug}.html`);
}
fs.writeFileSync(path.join(BLOG_DIR, 'index.html'), renderIndex(posts));
for (const entry of fs.readdirSync(BLOG_DIR)) {
if (entry.endsWith('.html') && !keep.has(entry)) {
fs.rmSync(path.join(BLOG_DIR, entry));
console.log(`generate-blog: removed stale ${entry}`);
}
}
console.log(
`generate-blog: ${posts.length} posts + index generated from blog/posts/`
);
}
generate();
+60 -3
View File
@@ -59,7 +59,47 @@ 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(/^\//, ''));
return parts.filter(Boolean).join('/').replace(/\/+$/, '') || SITE_URL;
const url = parts.filter(Boolean).join('/').replace(/\/+$/, '') || SITE_URL;
return langPrefix && !pagePath ? `${url}/` : url;
}
function resolveTranslation(langResources, key) {
let namespace = 'common';
let keyPath = key;
const colonIndex = key.indexOf(':');
if (colonIndex !== -1) {
namespace = key.slice(0, colonIndex);
keyPath = key.slice(colonIndex + 1);
}
let node = langResources[namespace];
for (const segment of keyPath.split('.')) {
if (node == null || typeof node !== 'object') return null;
node = node[segment];
}
return typeof node === 'string' && node ? node : null;
}
function applyServerTranslations(document, langResources) {
document.querySelectorAll('[data-i18n]').forEach((element) => {
const key = element.getAttribute('data-i18n');
if (!key) return;
const translation = resolveTranslation(langResources, key);
if (translation) element.textContent = translation;
});
document.querySelectorAll('[data-i18n-placeholder]').forEach((element) => {
const key = element.getAttribute('data-i18n-placeholder');
if (!key) return;
const translation = resolveTranslation(langResources, key);
if (translation) element.setAttribute('placeholder', translation);
});
document.querySelectorAll('[data-i18n-title]').forEach((element) => {
const key = element.getAttribute('data-i18n-title');
if (!key) return;
const translation = resolveTranslation(langResources, key);
if (translation) element.setAttribute('title', translation);
});
}
const ORGANIZATION_LD_MARKER = 'data-bentopdf-organization';
@@ -105,10 +145,24 @@ function buildLocalHomeHref(lang) {
return `${BASE_PATH}${langSegment}`.replace(/\/+/g, '/');
}
function removeStaticBreadcrumbLd(document) {
for (const script of document.querySelectorAll(
`script[type="application/ld+json"]:not([${BREADCRUMB_MARKER}])`
)) {
try {
const parsed = JSON.parse(script.textContent || '');
if (parsed && parsed['@type'] === 'BreadcrumbList') script.remove();
} catch {
continue;
}
}
}
function injectToolBreadcrumb(document, lang, toolName, toolUrl) {
const h1 = document.querySelector('h1[data-i18n^="tools:"]');
if (!h1) return;
if (document.querySelector(`[${BREADCRUMB_MARKER}]`)) return;
removeStaticBreadcrumbLd(document);
const homeUrl = buildUrl(lang === 'en' ? '' : lang, '');
@@ -191,6 +245,8 @@ function processFileForLanguage(
document.documentElement.lang = lang;
document.documentElement.dir = lang === 'ar' ? 'rtl' : 'ltr';
applyServerTranslations(document, translations[lang]);
let title = null;
let description = null;
@@ -247,14 +303,13 @@ function processFileForLanguage(
document.head.appendChild(defaultLink);
const localizedUrl = buildUrl(lang, pagePath);
const canonicalUrl = buildUrl('', pagePath);
let canonical = document.querySelector('link[rel="canonical"]');
if (!canonical) {
canonical = document.createElement('link');
canonical.rel = 'canonical';
document.head.appendChild(canonical);
}
canonical.href = canonicalUrl;
canonical.href = localizedUrl;
const ogUrl = document.querySelector('meta[property="og:url"]');
if (ogUrl) ogUrl.content = localizedUrl;
@@ -288,6 +343,8 @@ function processFileForLanguage(
if (href.startsWith('/assets/') || href.includes('/assets/')) return;
if (href === '/blog' || href.startsWith('/blog/')) return;
const langPrefixRegex = new RegExp(
`^(${BASE_PATH})?/(${languages.join('|')})(/|$)`
);
+93 -17
View File
@@ -1,5 +1,6 @@
import fs from 'fs';
import path from 'path';
import { execFileSync } from 'child_process';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
@@ -32,9 +33,17 @@ const PRIORITY_MAP = {
'excel-to-pdf': 0.9,
'powerpoint-to-pdf': 0.9,
'jpg-to-pdf': 0.9,
'pdf-to-docx': 0.9,
'pdf-to-word': 0.9,
'unlock-pdf': 0.9,
'protect-pdf': 0.9,
'pdf-to-excel': 0.9,
'pdf-to-jpg': 0.9,
'compress-pdf-to-100kb': 0.8,
'compress-pdf-to-200kb': 0.8,
'compress-pdf-to-500kb': 0.8,
'compress-pdf-to-1mb': 0.8,
'compress-pdf-to-2mb': 0.8,
'compress-pdf-for-email': 0.8,
about: 0.8,
faq: 0.8,
contact: 0.7,
@@ -52,7 +61,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() {
@@ -66,22 +75,48 @@ function generateSitemap() {
.map((file) => file.replace('.html', ''))
.filter((name) => !EXCLUDED_PAGES.has(name));
const projectRoot = path.resolve(__dirname, '..');
const lastModCache = new Map();
const getLastMod = (lang, pageName) => {
const cacheKey = `${lang}::${pageName}`;
if (lastModCache.has(cacheKey)) return lastModCache.get(cacheKey);
const getLastMod = (pageName) => {
if (lastModCache.has(pageName)) return lastModCache.get(pageName);
const fileName = `${pageName}.html`;
const filePath =
lang === 'en'
? path.join(DIST_DIR, fileName)
: path.join(DIST_DIR, lang, fileName);
let iso;
try {
iso = fs.statSync(filePath).mtime.toISOString().slice(0, 10);
} catch {
iso = new Date().toISOString().slice(0, 10);
const candidates = [
path.join(projectRoot, 'src', 'pages', fileName),
path.join(projectRoot, fileName),
];
let iso = null;
for (const sourcePath of candidates) {
if (!fs.existsSync(sourcePath)) continue;
try {
const out = execFileSync(
'git',
['log', '-1', '--format=%cI', '--', sourcePath],
{ cwd: projectRoot, encoding: 'utf-8' }
).trim();
if (out) iso = out.slice(0, 10);
} catch {
iso = null;
}
if (!iso) {
try {
iso = fs.statSync(sourcePath).mtime.toISOString().slice(0, 10);
} catch {
iso = null;
}
}
break;
}
lastModCache.set(cacheKey, iso);
if (!iso) {
try {
iso = fs
.statSync(path.join(DIST_DIR, fileName))
.mtime.toISOString()
.slice(0, 10);
} catch {
iso = new Date().toISOString().slice(0, 10);
}
}
lastModCache.set(pageName, iso);
return iso;
};
@@ -93,12 +128,11 @@ function generateSitemap() {
for (const pageName of htmlFiles) {
const priority = getPriority(pageName);
const url = buildUrl('en', pageName);
const lastmod = getLastMod('en', pageName);
const lastmod = getLastMod(pageName);
sitemap += ` <url>
<loc>${url}</loc>
<lastmod>${lastmod}</lastmod>
<changefreq>weekly</changefreq>
<priority>${priority}</priority>
`;
@@ -114,6 +148,48 @@ function generateSitemap() {
`;
}
const blogDir = path.join(DIST_DIR, 'blog');
let blogCount = 0;
if (fs.existsSync(blogDir)) {
const blogFiles = fs
.readdirSync(blogDir)
.filter((file) => file.endsWith('.html'))
.map((file) => file.replace('.html', ''));
for (const name of blogFiles) {
const url =
name === 'index' ? `${SITE_URL}/blog/` : `${SITE_URL}/blog/${name}`;
const sourcePath = path.join(projectRoot, 'blog', `${name}.html`);
let lastmod;
try {
const out = execFileSync(
'git',
['log', '-1', '--format=%cI', '--', sourcePath],
{ cwd: projectRoot, encoding: 'utf-8' }
).trim();
lastmod = out ? out.slice(0, 10) : null;
} catch {
lastmod = null;
}
if (!lastmod) {
try {
lastmod = fs.statSync(sourcePath).mtime.toISOString().slice(0, 10);
} catch {
lastmod = new Date().toISOString().slice(0, 10);
}
}
sitemap += ` <url>
<loc>${url}</loc>
<lastmod>${lastmod}</lastmod>
<priority>0.7</priority>
</url>
`;
blogCount++;
}
}
if (blogCount > 0) {
console.log(` Blog: ${blogCount} URLs added (English only)`);
}
sitemap += `</urlset>
`;
+106
View File
@@ -0,0 +1,106 @@
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const TOOLS_CONFIG = path.resolve(__dirname, '../src/js/config/tools.ts');
const PAGES_DIR = path.resolve(__dirname, '../src/pages');
const PARTIAL_PATH = path.resolve(
__dirname,
'../src/partials/tool-links-static.html'
);
function escapeHtml(value) {
return value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function unescapeTs(value) {
return value.replace(/\\'/g, "'").replace(/\\\\/g, '\\');
}
function parseCategories(source) {
const headers = [
...source.matchAll(/name:\s*'((?:[^'\\]|\\.)*)',\s*\n\s*tools:\s*\[/g),
].map((m) => ({ name: unescapeTs(m[1]), index: m.index }));
const toolMatches = [
...source.matchAll(
/\{[^{}]*?href:\s*import\.meta\.env\.BASE_URL\s*\+\s*'((?:[^'\\]|\\.)*)'[^{}]*?\}/gs
),
];
const categories = headers.map((h) => ({ name: h.name, tools: [] }));
for (const match of toolMatches) {
const block = match[0];
const hrefFile = unescapeTs(match[1]);
const nameMatch = block.match(/name:\s*'((?:[^'\\]|\\.)*)'/s);
const subtitleMatch = block.match(/subtitle:\s*\n?\s*'((?:[^'\\]|\\.)*)'/s);
if (!nameMatch) continue;
let categoryIndex = -1;
for (let i = 0; i < headers.length; i++) {
if (headers[i].index < match.index) categoryIndex = i;
}
if (categoryIndex === -1) continue;
categories[categoryIndex].tools.push({
slug: hrefFile.replace(/\.html$/, ''),
name: unescapeTs(nameMatch[1]),
subtitle: subtitleMatch ? unescapeTs(subtitleMatch[1]) : '',
});
}
return categories.filter((c) => c.tools.length > 0);
}
function generate() {
const source = fs.readFileSync(TOOLS_CONFIG, 'utf-8');
const categories = parseCategories(source);
const existingPages = new Set(
fs
.readdirSync(PAGES_DIR)
.filter((f) => f.endsWith('.html'))
.map((f) => f.replace(/\.html$/, ''))
);
const seen = new Set();
const lines = [];
let toolCount = 0;
for (const category of categories) {
const tools = category.tools.filter(
(t) => existingPages.has(t.slug) && !seen.has(t.slug)
);
if (tools.length === 0) continue;
for (const t of tools) seen.add(t.slug);
lines.push(
`<div class="col-span-full mt-6 first:mt-0"><h2 class="text-lg font-semibold text-white">${escapeHtml(category.name)}</h2></div>`
);
for (const tool of tools) {
toolCount++;
lines.push(
`<a href="/${tool.slug}" class="block bg-gray-800 p-4 rounded-lg border border-gray-700 hover:bg-gray-700 transition-colors">` +
`<h3 class="text-white font-semibold mb-1">${escapeHtml(tool.name)}</h3>` +
`<p class="text-gray-400 text-sm">${escapeHtml(tool.subtitle)}</p>` +
`</a>`
);
}
}
if (toolCount < 50) {
console.error(
`generate-static-tool-links: only ${toolCount} tools parsed from tools.ts — parser is likely out of sync with the config format`
);
process.exit(1);
}
fs.writeFileSync(PARTIAL_PATH, lines.join('\n') + '\n');
console.log(
`Static tool links: ${toolCount} tools in ${categories.length} categories -> src/partials/tool-links-static.html`
);
}
generate();
+73 -24
View File
@@ -11,46 +11,95 @@ const mainDistPath = path.join(projectRoot, 'dist');
const targetDocsPath = path.join(mainDistPath, 'docs');
function copyDir(src, dest) {
if (!fs.existsSync(dest)) {
fs.mkdirSync(dest, { recursive: true });
}
if (!fs.existsSync(dest)) {
fs.mkdirSync(dest, { recursive: true });
}
const entries = fs.readdirSync(src, { withFileTypes: true });
const entries = fs.readdirSync(src, { withFileTypes: true });
for (const entry of entries) {
const srcPath = path.join(src, entry.name);
const destPath = path.join(dest, entry.name);
for (const entry of entries) {
const srcPath = path.join(src, entry.name);
const destPath = path.join(dest, entry.name);
if (entry.isDirectory()) {
copyDir(srcPath, destPath);
} else {
fs.copyFileSync(srcPath, destPath);
}
if (entry.isDirectory()) {
copyDir(srcPath, destPath);
} else {
fs.copyFileSync(srcPath, destPath);
}
}
}
console.log('Including docs in dist...');
if (!fs.existsSync(docsDistPath)) {
console.error('Error: Docs build not found. Please run "npm run docs:build" first.');
process.exit(1);
console.error(
'Error: Docs build not found. Please run "npm run docs:build" first.'
);
process.exit(1);
}
if (!fs.existsSync(mainDistPath)) {
console.error('Error: Main dist folder not found. Please run "npm run build" first.');
process.exit(1);
console.error(
'Error: Main dist folder not found. Please run "npm run build" first.'
);
process.exit(1);
}
try {
console.log(`Copying from: ${docsDistPath}`);
console.log(`Copying to: ${targetDocsPath}`);
console.log(`Copying from: ${docsDistPath}`);
console.log(`Copying to: ${targetDocsPath}`);
copyDir(docsDistPath, targetDocsPath);
copyDir(docsDistPath, targetDocsPath);
console.log('Docs successfully included in dist/docs!');
console.log('Docs successfully included in dist/docs!');
const files = fs.readdirSync(targetDocsPath);
console.log(`Copied ${files.length} items to dist/docs`);
const files = fs.readdirSync(targetDocsPath);
console.log(`Copied ${files.length} items to dist/docs`);
addDocsToSitemap();
} catch (error) {
console.error('Error copying docs:', error.message);
process.exit(1);
console.error('Error copying docs:', error.message);
process.exit(1);
}
function listDocsHtml(dir, prefix) {
const urls = [];
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
if (entry.isDirectory()) {
if (entry.name === 'assets' || entry.name.startsWith('.')) continue;
urls.push(
...listDocsHtml(path.join(dir, entry.name), `${prefix}/${entry.name}`)
);
} else if (entry.name.endsWith('.html') && entry.name !== '404.html') {
const slug = entry.name.replace(/\.html$/, '');
urls.push(slug === 'index' ? `${prefix}/` : `${prefix}/${slug}`);
}
}
return urls;
}
function addDocsToSitemap() {
const siteUrl = (process.env.SITE_URL || 'https://www.bentopdf.com').replace(
/\/+$/,
''
);
const sitemapPath = path.join(mainDistPath, 'sitemap.xml');
if (!fs.existsSync(sitemapPath)) {
console.warn(
'sitemap.xml not found in dist; skipping docs sitemap entries'
);
return;
}
let xml = fs.readFileSync(sitemapPath, 'utf-8');
if (xml.includes(`<loc>${siteUrl}/docs/</loc>`)) return;
const urls = listDocsHtml(targetDocsPath, '/docs');
const entries = urls
.map(
(url) =>
` <url>\n <loc>${siteUrl}${url}</loc>\n <priority>0.6</priority>\n </url>\n`
)
.join('');
xml = xml.replace('</urlset>', `${entries}</urlset>`);
fs.writeFileSync(sitemapPath, xml);
console.log(`Added ${urls.length} docs URLs to sitemap.xml`);
}
+108 -3
View File
@@ -67,6 +67,12 @@ function findAll(html, regex) {
return out;
}
const LOCALE_DIRS = new Set(
fs
.readdirSync(LOCALES_DIR)
.filter((d) => fs.statSync(path.join(LOCALES_DIR, d)).isDirectory())
);
function expectedCanonicalForFile(rel) {
const parts = rel.split('/');
const fileName = parts.pop();
@@ -74,12 +80,40 @@ function expectedCanonicalForFile(rel) {
const slug = baseName === 'index' ? '' : baseName;
const segments = [SITE_URL];
if (BASE_PATH) segments.push(BASE_PATH.replace(/^\//, ''));
for (const dir of parts) {
if (LOCALE_DIRS.has(dir) || dir === 'blog') segments.push(dir);
}
if (slug) segments.push(slug);
return segments.join('/').replace(/\/+$/, '') || SITE_URL;
}
function auditHtml(file) {
function auditHtml(file, knownSlugs) {
const html = fs.readFileSync(file.full, 'utf-8');
const isLocalized = file.rel.split('/').some((seg) => LOCALE_DIRS.has(seg));
if (!isLocalized && /[—–]/.test(html)) {
fail(
'em-dash',
`${file.rel}: contains an em or en dash (house style forbids them)`
);
}
if (!isLocalized) {
const internal = findAll(html, /<a[^>]+href=["'](\/[^"'#?]*)["']/g);
for (const href of internal) {
const clean = href.replace(/\/$/, '');
if (clean === '') continue;
const seg = clean.slice(1);
if (seg.includes('/')) continue;
if (LOCALE_DIRS.has(seg) || seg === 'blog' || seg === 'docs') continue;
if (!knownSlugs.has(seg)) {
fail(
'dead-link',
`${file.rel}: links to "${href}" but no such page exists in dist`
);
}
}
}
const titles = findAll(html, /<title[^>]*>[\s\S]*?<\/title>/g);
if (titles.length === 0) {
fail('title', `${file.rel}: no <title>`);
@@ -147,7 +181,7 @@ function auditHtml(file) {
);
}
const emptyI18n = html.match(/<span[^>]*data-i18n="[^"]+"[^>]*>\s*<\/span>/g);
const emptyI18n = html.match(/<span[^>]*data-i18n="[^"]+"[^>]*><\/span>/g);
if (emptyI18n && emptyI18n.length > 0) {
fail(
'data-i18n',
@@ -176,10 +210,76 @@ function auditHtml(file) {
`${file.rel}: hreflang href host "${url.hostname}" != expected "${HOST}"`
);
}
const trimmed = url.pathname.replace(/^\/|\/$/g, '');
if (LOCALE_DIRS.has(trimmed) && !url.pathname.endsWith('/')) {
fail(
'hreflang',
`${file.rel}: language-home hreflang "${m[1]}" must end with "/" (the server 301s the slashless form)`
);
}
} catch {
fail('hreflang', `${file.rel}: hreflang href "${m[1]}" not a valid URL`);
}
}
const descriptions = findAll(
html,
/<meta[^>]+name=["']description["'][^>]*content=["']([^"']*)["']/g
);
if (descriptions.length > 1) {
fail(
'description',
`${file.rel}: ${descriptions.length} meta description tags (expected 1)`
);
}
for (const desc of descriptions) {
if (desc.includes('★')) {
fail('description', `${file.rel}: meta description contains "★"`);
}
}
const isTopLevel = !file.rel.includes('/');
if (isTopLevel) {
const socialImages = findAll(
html,
/<meta[^>]+(?:property=["']og:image["']|name=["']twitter:image["'])[^>]*content=["']([^"']+)["']/g
);
for (const image of socialImages) {
let pathname;
try {
pathname = image.startsWith('http') ? new URL(image).pathname : image;
} catch {
continue;
}
if (
pathname.startsWith('/images/') &&
!fs.existsSync(path.join(DIST_DIR, pathname.replace(/^\//, '')))
) {
fail(
'social-image',
`${file.rel}: og/twitter image "${pathname}" does not exist in dist`
);
}
}
if (html.includes('faq-d') && !html.includes('"FAQPage"')) {
fail(
'faq-schema',
`${file.rel}: visible FAQ section without FAQPage JSON-LD`
);
}
const internalHtmlLinks = findAll(
html,
/<a[^>]+href=["'](\/?[a-z0-9][a-z0-9-]*\.html)["']/g
);
for (const href of internalHtmlLinks) {
fail(
'internal-link',
`${file.rel}: internal link "${href}" should be extensionless`
);
}
}
}
function auditSitemap() {
@@ -248,7 +348,12 @@ function main() {
fail('audit', 'no HTML files found in dist/');
}
for (const file of files) auditHtml(file);
const knownSlugs = new Set(
files
.filter((f) => !f.rel.includes('/'))
.map((f) => f.rel.replace(/\.html$/, ''))
);
for (const file of files) auditHtml(file, knownSlugs);
auditSitemap();
const total = failures.length + warnings.length;