feat(seo): canonicalize URLs, clean structured data, add audit gate

- Canonicals/hreflang/og point to www.bentopdf.com everywhere
- Drop fake aggregateRating; add Organization + breadcrumb JSON-LD
- Sitemap: filter 404/wasm-settings, mtime-based lastmod
- Rename locale ua → uk (correct ISO 639-1)
- Self-hoster: SITE_URL build arg, ROBOTS_NOINDEX runtime env
- Simple Mode indexable by default
- nginx: collapse .html and /index.html via 301
- SEO audit script + PR gate; runtime config noise fixed
- a11y contrast fix; language switcher gets search + scroll
- robots.txt: drop Crawl-delay
This commit is contained in:
alam00000 committed 2026-04-29 12:04:34 +05:30
1 parent eff1548ebe
commit 0040317e48
154 files changed
+950 -1010

No files matched your search

+5 -5
View File
@@ -22,7 +22,7 @@ export const supportedLanguages = [
'sv',
'ko',
'ja',
'ua',
'uk',
] as const;
export type SupportedLanguage = (typeof supportedLanguages)[number];
@@ -46,7 +46,7 @@ export const languageNames: Record<SupportedLanguage, string> = {
sv: 'Svenska',
ko: '한국어',
ja: '日本語',
ua: 'Українська',
uk: 'Українська',
};
export const getLanguageFromUrl = (): SupportedLanguage => {
@@ -62,7 +62,7 @@ export const getLanguageFromUrl = (): SupportedLanguage => {
}
const langMatch = path.match(
/^\/(en|ar|fr|es|de|zh|zh-TW|vi|tr|id|it|pt|nl|be|da|ko|sv|ru|ja|ua)(?:\/|$)/
/^\/(en|ar|fr|es|de|zh|zh-TW|vi|tr|id|it|pt|nl|be|da|ko|sv|ru|ja|uk)(?:\/|$)/
);
if (
langMatch &&
@@ -152,7 +152,7 @@ export const changeLanguage = (lang: SupportedLanguage): void => {
let pagePathWithoutLang = relativePath;
const langPrefixMatch = relativePath.match(
/^\/(en|ar|fr|es|de|zh|zh-TW|vi|tr|id|it|pt|nl|be|da|ko|sv|ru|ja|ua)(\/.*)?$/
/^\/(en|ar|fr|es|de|zh|zh-TW|vi|tr|id|it|pt|nl|be|da|ko|sv|ru|ja|uk)(\/.*)?$/
);
if (langPrefixMatch) {
pagePathWithoutLang = langPrefixMatch[2] || '/';
@@ -247,7 +247,7 @@ export const rewriteLinks = (): void => {
}
const langPrefixRegex = new RegExp(
`^(${basePath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})?/?(en|ar|fr|es|de|zh|zh-TW|vi|tr|id|it|pt|nl|be|da|ko|sv|ru|ja|ua)(/|$)`
`^(${basePath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})?/?(en|ar|fr|es|de|zh|zh-TW|vi|tr|id|it|pt|nl|be|da|ko|sv|ru|ja|uk)(/|$)`
);
if (langPrefixRegex.test(href)) {
return;
+90 -11
View File
@@ -3,6 +3,7 @@ import {
languageNames,
getLanguageFromUrl,
changeLanguage,
t,
} from './i18n';
export const createLanguageSwitcher = (): HTMLElement => {
@@ -39,24 +40,63 @@ export const createLanguageSwitcher = (): HTMLElement => {
const dropdown = document.createElement('div');
dropdown.className = `
hidden absolute right-0 mt-2 w-40 rounded-lg
bg-gray-800 border border-gray-700 shadow-xl
py-1 z-50
hidden absolute right-0 mt-2 z-50
w-64 max-w-[calc(100vw-2rem)]
rounded-lg bg-gray-800 border border-gray-700 shadow-xl
flex flex-col overflow-hidden
`.trim();
dropdown.setAttribute('role', 'menu');
const searchWrapper = document.createElement('div');
searchWrapper.className =
'p-2 border-b border-gray-700 bg-gray-800 flex-shrink-0';
const searchPlaceholder =
t('nav.searchLanguage') !== 'nav.searchLanguage'
? t('nav.searchLanguage')
: 'Search language…';
const searchInput = document.createElement('input');
searchInput.type = 'search';
searchInput.placeholder = searchPlaceholder;
searchInput.className = `
w-full px-3 py-1.5 text-sm
bg-gray-900 text-gray-200
border border-gray-700 rounded-md
focus:outline-none focus:border-indigo-500
placeholder-gray-500
`.trim();
searchInput.setAttribute('aria-label', searchPlaceholder);
searchWrapper.appendChild(searchInput);
dropdown.appendChild(searchWrapper);
const list = document.createElement('div');
list.className = 'max-h-64 overflow-y-auto py-1';
list.setAttribute('role', 'none');
const emptyState = document.createElement('p');
emptyState.className = 'hidden px-4 py-3 text-sm text-gray-400 text-center';
const emptyText =
t('nav.noLanguagesFound') !== 'nav.noLanguagesFound'
? t('nav.noLanguagesFound')
: 'No languages found';
emptyState.textContent = emptyText;
const options: HTMLButtonElement[] = [];
supportedLanguages.forEach((lang) => {
const option = document.createElement('button');
option.type = 'button';
option.className = `
w-full px-4 py-2 text-left text-sm text-gray-200
hover:bg-gray-700 flex items-center gap-2
hover:bg-gray-700 flex items-center gap-2 transition-colors
${lang === currentLang ? 'bg-gray-700' : ''}
`.trim();
option.setAttribute('role', 'menuitem');
option.dataset.lang = lang;
option.dataset.searchKey = `${languageNames[lang]} ${lang}`.toLowerCase();
const name = document.createElement('span');
name.textContent = languageNames[lang];
option.appendChild(name);
option.addEventListener('click', () => {
@@ -65,7 +105,37 @@ export const createLanguageSwitcher = (): HTMLElement => {
}
});
dropdown.appendChild(option);
options.push(option);
list.appendChild(option);
});
list.appendChild(emptyState);
dropdown.appendChild(list);
const filterOptions = () => {
const query = searchInput.value.trim().toLowerCase();
let visible = 0;
options.forEach((option) => {
const key = option.dataset.searchKey || '';
const match = !query || key.includes(query);
option.classList.toggle('hidden', !match);
if (match) visible++;
});
emptyState.classList.toggle('hidden', visible > 0);
};
searchInput.addEventListener('input', filterOptions);
dropdown.addEventListener('click', (e) => {
if (e.target instanceof HTMLButtonElement && e.target.dataset.lang) return;
e.stopPropagation();
});
searchInput.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
e.preventDefault();
dropdown.classList.add('hidden');
button.setAttribute('aria-expanded', 'false');
button.focus();
}
});
container.appendChild(button);
@@ -74,8 +144,15 @@ export const createLanguageSwitcher = (): HTMLElement => {
button.addEventListener('click', (e) => {
e.stopPropagation();
const isExpanded = button.getAttribute('aria-expanded') === 'true';
button.setAttribute('aria-expanded', (!isExpanded).toString());
dropdown.classList.toggle('hidden');
const nextOpen = !isExpanded;
button.setAttribute('aria-expanded', nextOpen.toString());
dropdown.classList.toggle('hidden', !nextOpen);
if (nextOpen) {
searchInput.value = '';
filterOptions();
list.scrollTop = 0;
requestAnimationFrame(() => searchInput.focus());
}
});
document.addEventListener('click', () => {
@@ -117,7 +194,7 @@ export const injectLanguageSwitcher = (): void => {
if (socialIconsContainer) {
const wrapper = document.createElement('div');
wrapper.className = 'inline-flex flex-col gap-4'; // gap-4 adds space between icons and switcher
wrapper.className = 'inline-flex flex-col gap-4';
socialIconsContainer.parentNode?.insertBefore(
wrapper,
@@ -139,9 +216,11 @@ export const injectLanguageSwitcher = (): void => {
`.trim();
}
const dropdown = switcher.querySelector('div[role="menu"]');
const dropdown = switcher.querySelector(
'div[role="menu"]'
) as HTMLElement | null;
if (dropdown) {
dropdown.classList.remove('mt-2', 'w-40');
dropdown.classList.remove('mt-2', 'w-64');
dropdown.classList.add('bottom-full', 'mb-2', 'w-full');
}
+7 -2
View File
@@ -14,6 +14,11 @@ export async function loadRuntimeConfig(): Promise<void> {
});
if (!response.ok) return;
const contentType = response.headers.get('content-type') || '';
if (!contentType.toLowerCase().includes('application/json')) {
return;
}
const config: AppConfig = await response.json();
if (Array.isArray(config.disabledTools)) {
for (const toolId of config.disabledTools) {
@@ -27,8 +32,8 @@ export async function loadRuntimeConfig(): Promise<void> {
(c): c is string => typeof c === 'string'
);
}
} catch {
console.error('[LOAD_RUNTIME_CONFIG] Failed to load runtime configuration');
} catch (err) {
console.warn('[LOAD_RUNTIME_CONFIG] Skipped runtime config:', err);
}
}