mirror of
https://github.com/alam00000/bentopdf.git
synced 2026-11-11 01:14:35 +01:00
Merge main into add-farsi-translation
This commit is contained in:
commit
538e940525
365 files changed
+37167
-45078
No files matched your search
+253
-143
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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, '&')
|
||||
.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">
|
||||
${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
@@ -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!');
|
||||
|
||||
@@ -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
@@ -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>
|
||||
`;
|
||||
|
||||
|
||||
@@ -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();
|
||||
+114
-25
@@ -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`);
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user