Merge main into add-farsi-translation

This commit is contained in:
alam00000 committed 2026-10-22 12:47:54 +05:30
commit 538e940525
365 files changed
+37167 -45078

No files matched your search

+253 -143
View File
@@ -2,12 +2,12 @@
/**
* Translation Checker for BentoPDF
*
*
* This script compares translation files across languages and reports:
* - Missing keys (keys present in English but absent in other languages)
* - Extra keys (keys present in other languages but not in English)
* - Untranslated keys (keys with the same value as English)
*
*
* Usage:
* node scripts/check-translations.js
* node scripts/check-translations.js --verbose
@@ -25,212 +25,322 @@ const LOCALES_DIR = path.join(__dirname, '../public/locales');
const REFERENCE_LANG = 'en';
const colors = {
reset: '\x1b[0m',
red: '\x1b[31m',
green: '\x1b[32m',
yellow: '\x1b[33m',
blue: '\x1b[34m',
cyan: '\x1b[36m',
dim: '\x1b[2m',
reset: '\x1b[0m',
red: '\x1b[31m',
green: '\x1b[32m',
yellow: '\x1b[33m',
blue: '\x1b[34m',
cyan: '\x1b[36m',
dim: '\x1b[2m',
};
const args = process.argv.slice(2);
const verbose = args.includes('--verbose');
const specificLang = args.find(arg => arg.startsWith('--lang='))?.split('=')[1];
const specificLang = args
.find((arg) => arg.startsWith('--lang='))
?.split('=')[1];
/**
* Flatten nested JSON object into dot notation
* { a: { b: 'value' } } => { 'a.b': 'value' }
*/
function flattenObject(obj, prefix = '') {
const flattened = {};
const flattened = {};
for (const [key, value] of Object.entries(obj)) {
const newKey = prefix ? `${prefix}.${key}` : key;
for (const [key, value] of Object.entries(obj)) {
const newKey = prefix ? `${prefix}.${key}` : key;
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
Object.assign(flattened, flattenObject(value, newKey));
} else {
flattened[newKey] = value;
}
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
Object.assign(flattened, flattenObject(value, newKey));
} else {
flattened[newKey] = value;
}
}
return flattened;
return flattened;
}
/**
* Load and parse a translation file
*/
function loadTranslation(lang) {
const filePath = path.join(LOCALES_DIR, lang, 'common.json');
const filePath = path.join(LOCALES_DIR, lang, 'common.json');
if (!fs.existsSync(filePath)) {
return null;
}
if (!fs.existsSync(filePath)) {
return null;
}
try {
const content = fs.readFileSync(filePath, 'utf8');
return JSON.parse(content);
} catch (error) {
console.error(`${colors.red}✗ Error parsing ${lang}/common.json:${colors.reset}`, error.message);
return null;
}
try {
const content = fs.readFileSync(filePath, 'utf8');
return JSON.parse(content);
} catch (error) {
console.error(
`${colors.red}✗ Error parsing ${lang}/common.json:${colors.reset}`,
error.message
);
return null;
}
}
/**
* Get all available languages
*/
function getAvailableLanguages() {
if (!fs.existsSync(LOCALES_DIR)) {
console.error(`${colors.red}✗ Locales directory not found: ${LOCALES_DIR}${colors.reset}`);
process.exit(1);
}
if (!fs.existsSync(LOCALES_DIR)) {
console.error(
`${colors.red}✗ Locales directory not found: ${LOCALES_DIR}${colors.reset}`
);
process.exit(1);
}
return fs.readdirSync(LOCALES_DIR)
.filter(item => {
const itemPath = path.join(LOCALES_DIR, item);
return fs.statSync(itemPath).isDirectory();
})
.filter(lang => {
// Only include if common.json exists
return fs.existsSync(path.join(LOCALES_DIR, lang, 'common.json'));
});
return fs
.readdirSync(LOCALES_DIR)
.filter((item) => {
const itemPath = path.join(LOCALES_DIR, item);
return fs.statSync(itemPath).isDirectory();
})
.filter((lang) => {
// Only include if common.json exists
return fs.existsSync(path.join(LOCALES_DIR, lang, 'common.json'));
});
}
/**
* Compare two sets of keys and report differences
*/
function compareKeys(refKeys, targetKeys, refLang, targetLang, refFlat, targetFlat) {
const missing = refKeys.filter(key => !targetKeys.includes(key));
const extra = targetKeys.filter(key => !refKeys.includes(key));
const untranslated = refKeys.filter(key =>
targetKeys.includes(key) && refFlat[key] === targetFlat[key] && typeof refFlat[key] === 'string'
);
function compareKeys(
refKeys,
targetKeys,
refLang,
targetLang,
refFlat,
targetFlat
) {
const missing = refKeys.filter((key) => !targetKeys.includes(key));
const extra = targetKeys.filter((key) => !refKeys.includes(key));
const untranslated = refKeys.filter(
(key) =>
targetKeys.includes(key) &&
refFlat[key] === targetFlat[key] &&
typeof refFlat[key] === 'string'
);
return { missing, extra, untranslated };
return { missing, extra, untranslated };
}
/**
* Print section header
*/
function printHeader(text) {
console.log(`\n${colors.cyan}${'='.repeat(60)}${colors.reset}`);
console.log(`${colors.cyan}${text}${colors.reset}`);
console.log(`${colors.cyan}${'='.repeat(60)}${colors.reset}\n`);
console.log(`\n${colors.cyan}${'='.repeat(60)}${colors.reset}`);
console.log(`${colors.cyan}${text}${colors.reset}`);
console.log(`${colors.cyan}${'='.repeat(60)}${colors.reset}\n`);
}
/**
* Print section
*/
function printSection(title, items, color = colors.yellow) {
if (items.length === 0) return;
if (items.length === 0) return;
console.log(`${color}${title} (${items.length}):${colors.reset}`);
items.forEach(item => {
console.log(` ${colors.dim}•${colors.reset} ${item}`);
});
console.log();
console.log(`${color}${title} (${items.length}):${colors.reset}`);
items.forEach((item) => {
console.log(` ${colors.dim}•${colors.reset} ${item}`);
});
console.log();
}
const ALLOWED_HTML_TAGS = ['br', 'strong', 'em'];
const ALLOWED_TAG = new RegExp(
`^</?(?:${ALLOWED_HTML_TAGS.join('|')})\\s*/?>`,
'i'
);
function findUnsafeHtml(value) {
const problems = [];
if (/<script/i.test(value)) problems.push('<script');
if (/javascript:/i.test(value)) problems.push('javascript:');
if (/\bon[a-z]+\s*=/i.test(value)) problems.push('on* attribute');
for (const match of value.matchAll(/<[a-z/!?]/gi)) {
const markup = value.slice(match.index);
if (!ALLOWED_TAG.test(markup)) {
problems.push(`disallowed markup "${markup.slice(0, 40)}"`);
}
}
return problems;
}
function collectStrings(value, keyPath, out) {
if (typeof value === 'string') {
out.push([keyPath, value]);
} else if (value && typeof value === 'object') {
for (const [key, child] of Object.entries(value)) {
collectStrings(child, keyPath ? `${keyPath}.${key}` : key, out);
}
}
return out;
}
function checkHtmlSafety(langs) {
const findings = [];
for (const lang of langs) {
const dir = path.join(LOCALES_DIR, lang);
for (const file of fs.readdirSync(dir).filter((f) => f.endsWith('.json'))) {
let data;
try {
data = JSON.parse(fs.readFileSync(path.join(dir, file), 'utf8'));
} catch (error) {
findings.push(`${lang}/${file}: invalid JSON (${error.message})`);
continue;
}
for (const [keyPath, value] of collectStrings(data, '', [])) {
for (const problem of findUnsafeHtml(value)) {
findings.push(`${lang}/${file} ${keyPath}: ${problem}`);
}
}
}
}
return findings;
}
/**
* Main function
*/
function main() {
console.log(`${colors.blue}🌍 BentoPDF Translation Checker${colors.reset}\n`);
console.log(`${colors.blue}🌍 BentoPDF Translation Checker${colors.reset}\n`);
const languages = getAvailableLanguages();
const languages = getAvailableLanguages();
if (languages.length === 0) {
console.error(`${colors.red}✗ No translation files found in ${LOCALES_DIR}${colors.reset}`);
process.exit(1);
if (languages.length === 0) {
console.error(
`${colors.red}✗ No translation files found in ${LOCALES_DIR}${colors.reset}`
);
process.exit(1);
}
// Load reference language (English)
const refTranslation = loadTranslation(REFERENCE_LANG);
if (!refTranslation) {
console.error(
`${colors.red}✗ Reference language (${REFERENCE_LANG}) not found${colors.reset}`
);
process.exit(1);
}
const refFlat = flattenObject(refTranslation);
const refKeys = Object.keys(refFlat);
console.log(
`${colors.green}✓ Reference language (${REFERENCE_LANG}): ${refKeys.length} keys${colors.reset}`
);
console.log(
`${colors.dim} Available languages: ${languages.join(', ')}${colors.reset}\n`
);
// Filter languages to check
const langsToCheck = specificLang
? languages.filter((lang) => lang === specificLang)
: languages.filter((lang) => lang !== REFERENCE_LANG);
if (langsToCheck.length === 0) {
console.log(`${colors.yellow}⚠ No languages to check${colors.reset}`);
process.exit(0);
}
let hasIssues = false;
// Check each language
for (const lang of langsToCheck) {
printHeader(`Checking: ${lang.toUpperCase()}`);
const translation = loadTranslation(lang);
if (!translation) {
hasIssues = true;
continue;
}
// Load reference language (English)
const refTranslation = loadTranslation(REFERENCE_LANG);
if (!refTranslation) {
console.error(`${colors.red}✗ Reference language (${REFERENCE_LANG}) not found${colors.reset}`);
process.exit(1);
const targetFlat = flattenObject(translation);
const targetKeys = Object.keys(targetFlat);
const { missing, extra, untranslated } = compareKeys(
refKeys,
targetKeys,
REFERENCE_LANG,
lang,
refFlat,
targetFlat
);
// Summary
console.log(
`${colors.dim}Total keys: ${targetKeys.length} / ${refKeys.length}${colors.reset}\n`
);
// Missing keys
if (missing.length > 0) {
hasIssues = true;
printSection(`Missing Keys`, missing, colors.red);
}
const refFlat = flattenObject(refTranslation);
const refKeys = Object.keys(refFlat);
console.log(`${colors.green}✓ Reference language (${REFERENCE_LANG}): ${refKeys.length} keys${colors.reset}`);
console.log(`${colors.dim} Available languages: ${languages.join(', ')}${colors.reset}\n`);
// Filter languages to check
const langsToCheck = specificLang
? languages.filter(lang => lang === specificLang)
: languages.filter(lang => lang !== REFERENCE_LANG);
if (langsToCheck.length === 0) {
console.log(`${colors.yellow}⚠ No languages to check${colors.reset}`);
process.exit(0);
// Extra keys
if (extra.length > 0) {
hasIssues = true;
printSection(`Extra Keys (not in English)`, extra, colors.yellow);
}
let hasIssues = false;
// Check each language
for (const lang of langsToCheck) {
printHeader(`Checking: ${lang.toUpperCase()}`);
const translation = loadTranslation(lang);
if (!translation) {
hasIssues = true;
continue;
}
const targetFlat = flattenObject(translation);
const targetKeys = Object.keys(targetFlat);
const { missing, extra, untranslated } = compareKeys(
refKeys,
targetKeys,
REFERENCE_LANG,
lang,
refFlat,
targetFlat
);
// Summary
console.log(`${colors.dim}Total keys: ${targetKeys.length} / ${refKeys.length}${colors.reset}\n`);
// Missing keys
if (missing.length > 0) {
hasIssues = true;
printSection(`Missing Keys`, missing, colors.red);
}
// Extra keys
if (extra.length > 0) {
hasIssues = true;
printSection(`Extra Keys (not in English)`, extra, colors.yellow);
}
// Untranslated keys (same as English)
if (verbose && untranslated.length > 0) {
printSection(`Possibly Untranslated (same as English)`, untranslated, colors.cyan);
} else if (untranslated.length > 0) {
console.log(`${colors.cyan}Possibly Untranslated: ${untranslated.length}${colors.reset}`);
console.log(`${colors.dim} (use --verbose to see details)${colors.reset}\n`);
}
// All good
if (missing.length === 0 && extra.length === 0) {
console.log(`${colors.green} No missing or extra keys!${colors.reset}\n`);
}
// Untranslated keys (same as English)
if (verbose && untranslated.length > 0) {
printSection(
`Possibly Untranslated (same as English)`,
untranslated,
colors.cyan
);
} else if (untranslated.length > 0) {
console.log(
`${colors.cyan}Possibly Untranslated: ${untranslated.length}${colors.reset}`
);
console.log(
`${colors.dim} (use --verbose to see details)${colors.reset}\n`
);
}
// Final summary
console.log(`${colors.cyan}${'='.repeat(60)}${colors.reset}\n`);
if (!hasIssues) {
console.log(`${colors.green} All translations are in sync!${colors.reset}\n`);
process.exit(0);
} else {
console.log(`${colors.yellow} Issues found. Please review the output above.${colors.reset}\n`);
process.exit(1);
// All good
if (missing.length === 0 && extra.length === 0) {
console.log(`${colors.green} No missing or extra keys!${colors.reset}\n`);
}
}
const htmlFindings = checkHtmlSafety(
specificLang ? languages.filter((lang) => lang === specificLang) : languages
);
if (htmlFindings.length > 0) {
hasIssues = true;
printHeader('Unsafe HTML in translations');
printSection(
`Allowed tags: ${ALLOWED_HTML_TAGS.join(', ')} (no attributes)`,
htmlFindings,
colors.red
);
} else {
console.log(
`${colors.green} No unsafe HTML in translations (allowed tags: ${ALLOWED_HTML_TAGS.join(', ')})${colors.reset}\n`
);
}
// Final summary
console.log(`${colors.cyan}${'='.repeat(60)}${colors.reset}\n`);
if (!hasIssues) {
console.log(
`${colors.green} All translations are in sync!${colors.reset}\n`
);
process.exit(0);
} else {
console.log(
`${colors.yellow} Issues found. Please review the output above.${colors.reset}\n`
);
process.exit(1);
}
}
main();
+167
View File
@@ -0,0 +1,167 @@
import fs from 'fs';
import path from 'path';
import { JSDOM } from 'jsdom';
import { fileURLToPath } from 'url';
import { serializeJsonLd } from './json-ld.mjs';
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 = serializeJsonLd(data);
}
}
}
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 = serializeJsonLd({
'@context': 'https://schema.org',
'@type': 'FAQPage',
mainEntity: items,
});
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 = serializeJsonLd({
'@context': 'https://schema.org',
'@type': 'WebSite',
name: 'BentoPDF',
alternateName: ['Bento PDF', 'BentoPDF.com'],
url: `${SITE_URL}/`,
});
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();
+415
View File
@@ -0,0 +1,415 @@
import fs from 'fs';
import path from 'path';
import { marked } from 'marked';
import { fileURLToPath } from 'url';
import { serializeJsonLd } from './json-ld.mjs';
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/',
'https://x.com/alamdraws',
],
};
const LINK_CLASS = 'text-indigo-300 hover:text-indigo-400';
function escapeHtml(value) {
return value
.replace(/&/g, '&amp;')
.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">
${serializeJsonLd({
'@context': 'https://schema.org',
'@type': 'FAQPage',
mainEntity: entries.map((f) => ({
'@type': 'Question',
name: f.q,
acceptedAnswer: { '@type': 'Answer', text: plainText(f.a) },
})),
})}
</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">
${serializeJsonLd({
'@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,
},
})}
</script>
<script type="application/ld+json">
${serializeJsonLd({
'@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,
},
],
})}
</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">
${serializeJsonLd({
'@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,
},
})}
</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();
+107 -27
View File
@@ -2,6 +2,7 @@ import fs from 'fs';
import path from 'path';
import { JSDOM } from 'jsdom';
import { fileURLToPath } from 'url';
import { serializeJsonLd } from './json-ld.mjs';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
@@ -59,7 +60,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';
@@ -93,7 +134,7 @@ function injectOrganizationLd(document) {
const script = document.createElement('script');
script.setAttribute('type', 'application/ld+json');
script.setAttribute(ORGANIZATION_LD_MARKER, '');
script.textContent = JSON.stringify(data, null, 2);
script.textContent = serializeJsonLd(data);
document.body.appendChild(script);
}
@@ -105,10 +146,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, '');
@@ -160,7 +215,7 @@ function injectToolBreadcrumb(document, lang, toolName, toolUrl) {
const script = document.createElement('script');
script.setAttribute('type', 'application/ld+json');
script.setAttribute(BREADCRUMB_MARKER, '');
script.textContent = JSON.stringify(ld, null, 2);
script.textContent = serializeJsonLd(ld);
document.body.appendChild(script);
}
@@ -171,18 +226,31 @@ function resolveToolName(translationKey, langTools) {
return enEntry && enEntry.name ? enEntry.name : null;
}
function translationKeyForFile(filenameNoExt) {
return KEY_MAPPING[filenameNoExt] || toCamelCase(filenameNoExt);
}
function translatedLangsForFile(filenameNoExt, translations) {
const translationKey = translationKeyForFile(filenameNoExt);
return languages.filter(
(l) =>
l === 'en' ||
filenameNoExt === 'index' ||
Boolean(translations[l] && translations[l].tools[translationKey])
);
}
function processFileForLanguage(
originalContent,
file,
lang,
translations,
langDir
langDir,
translatedLangs
) {
const filenameNoExt = file.replace('.html', '');
let translationKey = toCamelCase(filenameNoExt);
if (KEY_MAPPING[filenameNoExt]) {
translationKey = KEY_MAPPING[filenameNoExt];
}
const translationKey = translationKeyForFile(filenameNoExt);
const isTranslated = translatedLangs.includes(lang);
const { tools } = translations[lang];
const dom = new JSDOM(originalContent);
@@ -191,6 +259,8 @@ function processFileForLanguage(
document.documentElement.lang = lang;
document.documentElement.dir = ['ar', 'fa'].includes(lang) ? 'rtl' : 'ltr';
applyServerTranslations(document, translations[lang]);
let title = null;
let description = null;
@@ -232,22 +302,25 @@ function processFileForLanguage(
const pagePath = filenameNoExt === 'index' ? '' : filenameNoExt;
languages.forEach((l) => {
const link = document.createElement('link');
link.rel = 'alternate';
link.hreflang = l;
link.href = buildUrl(l === 'en' ? '' : l, pagePath);
document.head.appendChild(link);
});
if (isTranslated) {
translatedLangs.forEach((l) => {
const link = document.createElement('link');
link.rel = 'alternate';
link.hreflang = l;
link.href = buildUrl(l === 'en' ? '' : l, pagePath);
document.head.appendChild(link);
});
const defaultLink = document.createElement('link');
defaultLink.rel = 'alternate';
defaultLink.hreflang = 'x-default';
defaultLink.href = buildUrl('', pagePath);
document.head.appendChild(defaultLink);
const defaultLink = document.createElement('link');
defaultLink.rel = 'alternate';
defaultLink.hreflang = 'x-default';
defaultLink.href = buildUrl('', pagePath);
document.head.appendChild(defaultLink);
}
const englishUrl = buildUrl('', pagePath);
const localizedUrl = buildUrl(lang, pagePath);
const canonicalUrl = buildUrl('', pagePath);
const canonicalUrl = isTranslated ? localizedUrl : englishUrl;
let canonical = document.querySelector('link[rel="canonical"]');
if (!canonical) {
canonical = document.createElement('link');
@@ -257,9 +330,9 @@ function processFileForLanguage(
canonical.href = canonicalUrl;
const ogUrl = document.querySelector('meta[property="og:url"]');
if (ogUrl) ogUrl.content = localizedUrl;
if (ogUrl) ogUrl.content = canonicalUrl;
const twitterUrl = document.querySelector('meta[name="twitter:url"]');
if (twitterUrl) twitterUrl.content = localizedUrl;
if (twitterUrl) twitterUrl.content = canonicalUrl;
injectOrganizationLd(document);
@@ -288,6 +361,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('|')})(/|$)`
);
@@ -313,7 +388,7 @@ function processFileForLanguage(
fs.writeFileSync(path.join(langDir, file), result);
}
function updateEnglishFile(filePath, originalContent) {
function updateEnglishFile(filePath, originalContent, translatedLangs) {
const filenameNoExt = path.basename(filePath, '.html');
const dom = new JSDOM(originalContent);
const document = dom.window.document;
@@ -325,7 +400,7 @@ function updateEnglishFile(filePath, originalContent) {
const pagePath = filenameNoExt === 'index' ? '' : filenameNoExt;
const canonicalUrl = buildUrl('', pagePath);
languages.forEach((l) => {
translatedLangs.forEach((l) => {
const link = document.createElement('link');
link.rel = 'alternate';
link.hreflang = l;
@@ -402,6 +477,10 @@ async function generateI18nPages() {
for (const file of htmlFiles) {
const filePath = path.join(DIST_DIR, file);
const originalContent = fs.readFileSync(filePath, 'utf-8');
const translatedLangs = translatedLangsForFile(
file.replace('.html', ''),
translations
);
for (const lang of languages) {
if (lang === 'en') continue;
@@ -413,7 +492,8 @@ async function generateI18nPages() {
file,
lang,
translations,
langDir
langDir,
translatedLangs
);
processed++;
@@ -425,7 +505,7 @@ async function generateI18nPages() {
await new Promise((resolve) => setImmediate(resolve));
}
updateEnglishFile(filePath, originalContent);
updateEnglishFile(filePath, originalContent, translatedLangs);
}
console.log('✅ i18n pages generated successfully!');
+167 -80
View File
@@ -1,6 +1,7 @@
#!/usr/bin/env node
import { writeFileSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { existsSync, writeFileSync } from 'node:fs';
import { createRequire } from 'node:module';
import { join, dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '..');
@@ -16,103 +17,189 @@ function originOf(urlStr) {
}
}
const JSDELIVR_HOST = 'cdn.jsdelivr.net';
export const DOCS_INLINE_SCRIPT_HASHES = [
'sha256-DQUgNM9X0iH2019NkUxeBvnBoEoRKkHBc/I0iLqPNPA=',
'sha256-La1r0VSk0Po4KFI0duEKhmPu+u0I416JW3oONqtdf4M=',
];
function toScriptSource(url) {
const origin = originOf(url);
if (!origin) return null;
const { host, pathname } = new URL(url);
if (host !== JSDELIVR_HOST) return origin;
const directory = /\.m?js$/.test(pathname)
? pathname.replace(/[^/]*$/, '')
: pathname.replace(/\/?$/, '/');
return `${origin}${directory.replace(/;/g, '%3B').replace(/,/g, '%2C')}`;
}
function uniq(values) {
return Array.from(new Set(values.filter(Boolean)));
}
const DEFAULT_WASM_ORIGINS = {
pymupdf: 'https://cdn.jsdelivr.net',
gs: 'https://cdn.jsdelivr.net',
cpdf: 'https://cdn.jsdelivr.net',
const DEFAULT_WASM_URLS = {
pymupdf: 'https://cdn.jsdelivr.net/npm/@bentopdf/pymupdf-wasm@0.11.16/',
gs: 'https://cdn.jsdelivr.net/npm/@bentopdf/gs-wasm@0.1.1/assets/',
cpdf: 'https://cdn.jsdelivr.net/npm/coherentpdf@2.5.5/dist/',
};
const tesseractPackage = createRequire(import.meta.url)(
'tesseract.js/package.json'
);
const DEFAULT_TESSERACT_WORKER_URL = `https://cdn.jsdelivr.net/npm/tesseract.js@v${tesseractPackage.version}/dist/worker.min.js`;
const DEFAULT_TESSERACT_CORE_URL = `https://cdn.jsdelivr.net/npm/tesseract.js-core@v${tesseractPackage.dependencies['tesseract.js-core'].substring(1)}/`;
const DEFAULT_CORS_PROXY_ORIGIN =
'https://bentopdf-cors-proxy.bentopdf.workers.dev';
const DEFAULT_OCR_FONT_CDN_ORIGIN = 'https://rawcdn.githack.com';
const wasmOrigins = [
originOf(process.env.VITE_WASM_PYMUPDF_URL) || DEFAULT_WASM_ORIGINS.pymupdf,
originOf(process.env.VITE_WASM_GS_URL) || DEFAULT_WASM_ORIGINS.gs,
originOf(process.env.VITE_WASM_CPDF_URL) || DEFAULT_WASM_ORIGINS.cpdf,
const COMMON_HEADERS = [
['X-Frame-Options', 'SAMEORIGIN'],
['X-Content-Type-Options', 'nosniff'],
['X-XSS-Protection', '1; mode=block'],
['Referrer-Policy', 'strict-origin-when-cross-origin'],
[
'Permissions-Policy',
'geolocation=(), camera=(), microphone=(), payment=(), usb=(), interest-cohort=()',
],
['Cross-Origin-Opener-Policy', 'same-origin'],
['Cross-Origin-Embedder-Policy', 'credentialless'],
['Cross-Origin-Resource-Policy', 'cross-origin'],
];
const tesseractOrigins = uniq([
originOf(process.env.VITE_TESSERACT_WORKER_URL),
originOf(process.env.VITE_TESSERACT_CORE_URL),
originOf(process.env.VITE_TESSERACT_LANG_URL),
]);
function urlOrDefault(value, fallback) {
return originOf(value) ? value : fallback;
}
const corsProxyOrigin =
originOf(process.env.VITE_CORS_PROXY_URL) || DEFAULT_CORS_PROXY_ORIGIN;
export function buildSecurityHeaders(env = process.env) {
const wasmUrls = [
urlOrDefault(env.VITE_WASM_PYMUPDF_URL, DEFAULT_WASM_URLS.pymupdf),
urlOrDefault(env.VITE_WASM_GS_URL, DEFAULT_WASM_URLS.gs),
urlOrDefault(env.VITE_WASM_CPDF_URL, DEFAULT_WASM_URLS.cpdf),
];
const wasmOrigins = wasmUrls.map(originOf);
const ocrFontOrigin =
originOf(process.env.VITE_OCR_FONT_BASE_URL) || DEFAULT_OCR_FONT_CDN_ORIGIN;
const tesseractOrigins = uniq([
originOf(env.VITE_TESSERACT_WORKER_URL),
originOf(env.VITE_TESSERACT_CORE_URL),
originOf(env.VITE_TESSERACT_LANG_URL),
]);
const scriptOrigins = uniq([...wasmOrigins, ...tesseractOrigins]);
const connectOrigins = uniq([
...wasmOrigins,
...tesseractOrigins,
corsProxyOrigin,
ocrFontOrigin,
]);
const fontOrigins = uniq([ocrFontOrigin].filter(Boolean));
const corsProxyOrigin =
originOf(env.VITE_CORS_PROXY_URL) || DEFAULT_CORS_PROXY_ORIGIN;
const githubStarsDisabled =
process.env.DISABLE_GITHUB_STARS === 'true' ||
process.env.SIMPLE_MODE === 'true';
const githubApiSource = githubStarsDisabled ? '' : ' https://api.github.com';
const ocrFontOrigin =
originOf(env.VITE_OCR_FONT_BASE_URL) || DEFAULT_OCR_FONT_CDN_ORIGIN;
const directives = [
`default-src 'self'`,
`script-src 'self' 'wasm-unsafe-eval' 'unsafe-eval' blob: ${scriptOrigins.join(' ')}`.trim(),
`worker-src 'self' blob:`,
`style-src 'self' 'unsafe-inline' https://fonts.googleapis.com`,
`img-src 'self' data: blob: https:`,
`font-src 'self' data: https://fonts.gstatic.com ${fontOrigins.join(' ')}`.trim(),
`connect-src 'self' blob:${githubApiSource} https://fonts.gstatic.com ${connectOrigins.join(' ')}`.trim(),
`object-src 'none'`,
`base-uri 'self'`,
`frame-src 'self' blob:`,
`frame-ancestors 'self'`,
`form-action 'self'`,
];
const scriptSources = uniq([
...wasmUrls.map(toScriptSource),
toScriptSource(
urlOrDefault(env.VITE_TESSERACT_WORKER_URL, DEFAULT_TESSERACT_WORKER_URL)
),
toScriptSource(
urlOrDefault(env.VITE_TESSERACT_CORE_URL, DEFAULT_TESSERACT_CORE_URL)
),
]);
const connectOrigins = uniq([
...wasmOrigins,
...tesseractOrigins,
corsProxyOrigin,
ocrFontOrigin,
]);
const fontOrigins = uniq([ocrFontOrigin]);
const docsDirectives = [
`default-src 'self'`,
`script-src 'self' 'unsafe-inline' ${scriptOrigins.join(' ')}`.trim(),
`style-src 'self' 'unsafe-inline' https://fonts.googleapis.com`,
`img-src 'self' data: blob: https:`,
`font-src 'self' data: https://fonts.gstatic.com ${fontOrigins.join(' ')}`.trim(),
`connect-src 'self'${githubApiSource} https://fonts.gstatic.com ${connectOrigins.join(' ')}`.trim(),
`object-src 'none'`,
`base-uri 'self'`,
`frame-ancestors 'self'`,
`form-action 'self'`,
];
const githubStarsDisabled =
env.DISABLE_GITHUB_STARS === 'true' || env.SIMPLE_MODE === 'true';
const githubApiSource = githubStarsDisabled ? '' : ' https://api.github.com';
const csp = directives.join('; ');
const docsCsp = docsDirectives.join('; ');
const directives = [
`default-src 'self'`,
`script-src 'self' 'wasm-unsafe-eval' 'unsafe-eval' blob: ${scriptSources.join(' ')}`.trim(),
`worker-src 'self' blob:`,
`style-src 'self' 'unsafe-inline' https://fonts.googleapis.com`,
`img-src 'self' data: blob: https:`,
`font-src 'self' data: https://fonts.gstatic.com ${fontOrigins.join(' ')}`.trim(),
`connect-src 'self' blob:${githubApiSource} https://fonts.gstatic.com ${connectOrigins.join(' ')}`.trim(),
`object-src 'none'`,
`base-uri 'self'`,
`frame-src 'self' blob:`,
`frame-ancestors 'self'`,
`form-action 'self'`,
];
const commonHeaders = `add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "geolocation=(), camera=(), microphone=(), payment=(), usb=(), interest-cohort=()" always;
add_header Cross-Origin-Opener-Policy "same-origin" always;
add_header Cross-Origin-Embedder-Policy "credentialless" always;
add_header Cross-Origin-Resource-Policy "cross-origin" always;
`;
const docsDirectives = [
`default-src 'self'`,
`script-src 'self' ${DOCS_INLINE_SCRIPT_HASHES.map((hash) => `'${hash}'`).join(' ')}`,
`style-src 'self' 'unsafe-inline' https://fonts.googleapis.com`,
`img-src 'self' data: blob: https:`,
`font-src 'self' data: https://fonts.gstatic.com ${fontOrigins.join(' ')}`.trim(),
`connect-src 'self'${githubApiSource} https://fonts.gstatic.com ${connectOrigins.join(' ')}`.trim(),
`object-src 'none'`,
`base-uri 'self'`,
`frame-ancestors 'self'`,
`form-action 'self'`,
];
const contents = `add_header Content-Security-Policy "${csp}" always;
${commonHeaders}`;
const csp = directives.join('; ');
const docsCsp = docsDirectives.join('; ');
const docsContents = `add_header Content-Security-Policy "${docsCsp}" always;
${commonHeaders}`;
const nginxHeaders = (policy) =>
[['Content-Security-Policy', policy], ...COMMON_HEADERS]
.map(([name, value]) => `add_header ${name} "${value}" always;\n`)
.join('');
const outPath = join(repoRoot, 'security-headers.conf');
const docsOutPath = join(repoRoot, 'security-headers-docs.conf');
writeFileSync(outPath, contents);
writeFileSync(docsOutPath, docsContents);
console.log(
`[security-headers] wrote ${outPath} with ${scriptOrigins.length} script-src / ${connectOrigins.length} connect-src origin(s)`
);
console.log(`[security-headers] wrote ${docsOutPath} (docs CSP)`);
const staticHeaders = [
'/*',
` Content-Security-Policy: ${csp}`,
...COMMON_HEADERS.map(([name, value]) => ` ${name}: ${value}`),
'',
...['/docs', '/docs/*'].flatMap((docsPath) => [
docsPath,
' ! Content-Security-Policy',
` Content-Security-Policy: ${docsCsp}`,
'',
]),
].join('\n');
return {
csp,
docsCsp,
scriptSources,
connectOrigins,
nginx: nginxHeaders(csp),
nginxDocs: nginxHeaders(docsCsp),
staticHeaders,
};
}
function main() {
const headers = buildSecurityHeaders();
const outPath = join(repoRoot, 'security-headers.conf');
const docsOutPath = join(repoRoot, 'security-headers-docs.conf');
writeFileSync(outPath, headers.nginx);
writeFileSync(docsOutPath, headers.nginxDocs);
console.log(
`[security-headers] wrote ${outPath} with ${headers.scriptSources.length} script-src / ${headers.connectOrigins.length} connect-src source(s)`
);
console.log(`[security-headers] wrote ${docsOutPath} (docs CSP)`);
const distDir = join(repoRoot, 'dist');
const customHeaders = join(repoRoot, 'public', '_headers');
if (existsSync(customHeaders)) {
console.log(
`[security-headers] kept your own ${customHeaders} for static hosts`
);
} else if (existsSync(distDir)) {
const distOutPath = join(distDir, '_headers');
writeFileSync(distOutPath, headers.staticHeaders);
console.log(`[security-headers] wrote ${distOutPath}`);
}
}
if (
process.argv[1] &&
resolve(process.argv[1]) === fileURLToPath(import.meta.url)
) {
main();
}
+114 -18
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);
@@ -17,6 +18,26 @@ const languages = fs.readdirSync(LOCALES_DIR).filter((file) => {
return fs.statSync(path.join(LOCALES_DIR, file)).isDirectory();
});
const toCamelCase = (str) => {
return str.replace(/-([a-z])/g, (g) => g[1].toUpperCase());
};
const toolsByLang = {};
for (const lang of languages) {
const toolsPath = path.join(LOCALES_DIR, lang, 'tools.json');
toolsByLang[lang] = fs.existsSync(toolsPath)
? JSON.parse(fs.readFileSync(toolsPath, 'utf-8'))
: {};
}
function alternateLangsFor(pageName) {
if (pageName === 'index') return languages;
const translationKey = toCamelCase(pageName);
return languages.filter(
(l) => l === 'en' || Boolean(toolsByLang[l][translationKey])
);
}
const PRIORITY_MAP = {
index: 1.0,
tools: 0.9,
@@ -32,9 +53,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 +81,7 @@ function buildUrl(lang, pageName) {
if (lang === 'en') {
return pagePath ? `${SITE_URL}/${pagePath}` : SITE_URL;
}
return pagePath ? `${SITE_URL}/${lang}/${pagePath}` : `${SITE_URL}/${lang}`;
return pagePath ? `${SITE_URL}/${lang}/${pagePath}` : `${SITE_URL}/${lang}/`;
}
function generateSitemap() {
@@ -66,22 +95,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,16 +148,15 @@ 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>
`;
for (const altLang of languages) {
for (const altLang of alternateLangsFor(pageName)) {
const altUrl = buildUrl(altLang, pageName);
sitemap += ` <xhtml:link rel="alternate" hreflang="${altLang}" href="${altUrl}"/>
`;
@@ -114,6 +168,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();
+114 -25
View File
@@ -1,6 +1,8 @@
import crypto from 'crypto';
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import { DOCS_INLINE_SCRIPT_HASHES } from './generate-security-headers.mjs';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
@@ -11,46 +13,133 @@ 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 });
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);
}
}
}
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);
if (entry.isDirectory()) {
copyDir(srcPath, destPath);
} else {
fs.copyFileSync(srcPath, destPath);
}
function findUnlistedInlineScripts(dir, unlisted = new Set()) {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const entryPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
findUnlistedInlineScripts(entryPath, unlisted);
continue;
}
if (!entry.name.endsWith('.html')) continue;
const html = fs.readFileSync(entryPath, 'utf8');
const inlineScripts = html.matchAll(
/<script(?![^>]*\bsrc=)([^>]*)>([\s\S]*?)<\/script>/g
);
for (const [, attributes, code] of inlineScripts) {
if (
!code.trim() ||
/type=["']?application\/(ld\+)?json/.test(attributes)
) {
continue;
}
const hash = `sha256-${crypto.createHash('sha256').update(code).digest('base64')}`;
if (!DOCS_INLINE_SCRIPT_HASHES.includes(hash)) unlisted.add(hash);
}
}
return unlisted;
}
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);
}
const unlistedScripts = findUnlistedInlineScripts(docsDistPath);
if (unlistedScripts.size > 0) {
console.error(
'Error: The docs contain inline scripts that the docs Content-Security-Policy does not allow:'
);
for (const hash of unlistedScripts) console.error(` '${hash}'`);
console.error(
'Add these hashes to DOCS_INLINE_SCRIPT_HASHES in scripts/generate-security-headers.mjs and to the /docs script-src in vercel.json and serve.json, then rebuild.'
);
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`);
}
+6
View File
@@ -0,0 +1,6 @@
export function serializeJsonLd(data) {
return JSON.stringify(data, null, 2)
.replace(/</g, '\\u003c')
.replace(/\u2028/g, '\\u2028')
.replace(/\u2029/g, '\\u2029');
}
+128 -3
View File
@@ -67,19 +67,73 @@ 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())
);
const KEY_MAPPING = { index: 'home', 404: 'notFound' };
const toCamelCase = (str) =>
str.replace(/-([a-z])/g, (g) => g[1].toUpperCase());
const toolsByLang = {};
for (const lang of LOCALE_DIRS) {
const toolsPath = path.join(LOCALES_DIR, lang, 'tools.json');
toolsByLang[lang] = fs.existsSync(toolsPath)
? JSON.parse(fs.readFileSync(toolsPath, 'utf-8'))
: {};
}
function expectedCanonicalForFile(rel) {
const parts = rel.split('/');
const fileName = parts.pop();
const baseName = fileName.replace(/\.html$/, '');
const slug = baseName === 'index' ? '' : baseName;
const translationKey = KEY_MAPPING[baseName] || toCamelCase(baseName);
const segments = [SITE_URL];
if (BASE_PATH) segments.push(BASE_PATH.replace(/^\//, ''));
for (const dir of parts) {
if (dir === 'blog') {
segments.push(dir);
} else if (LOCALE_DIRS.has(dir)) {
const translated =
baseName === 'index' ||
Boolean(toolsByLang[dir] && toolsByLang[dir][translationKey]);
if (translated) 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 +201,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 +230,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 +368,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;