mirror of
https://github.com/alam00000/bentopdf.git
synced 2026-11-11 09:23:36 +01:00
seo fixes
This commit is contained in:
1 parent
83ff47db9b
commit
9e980cdea2
189 files changed
+17781
-2238
No files matched your search
@@ -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();
|
||||
@@ -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, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
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();
|
||||
@@ -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('|')})(/|$)`
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
`;
|
||||
|
||||
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
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();
|
||||
@@ -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
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user