myResume2/utils/seo.js
Blade34242 45a3cddf06
All checks were successful
Node CI / lint (push) Successful in 1m29s
Refresh resume design, Node lifecycle, PDF export and SEO
2026-11-07 07:38:03 +07:00

65 lines
3.3 KiB
JavaScript

function siteOrigin(value) {
if (!value) return null;
const url = new URL(value);
if (!['https:', 'http:'].includes(url.protocol) || url.username || url.password || url.pathname !== '/' || url.search || url.hash) {
throw new Error('SITE_URL must be an absolute HTTP(S) origin, e.g. https://resume.example.com');
}
return url.origin + '/';
}
function buildSeo(data, siteUrl) {
const canonical = siteOrigin(siteUrl);
const title = `${data.name} — ${data.header}`;
const description = data.about.replace(/\s+/g, ' ').trim().slice(0, 160);
const language = /^[a-z]{2,3}(?:-[a-zA-Z0-9]{2,8})*$/.test(data.language || '') ? data.language : 'en';
const image = canonical ? new URL(data.picture, canonical).href : null;
const skills = [...new Set(data.menus.flatMap((menu) => (menu.subentries || []).map((skill) => typeof skill === 'string' ? skill : skill.name || skill.title || skill.skill)).filter(Boolean))];
const person = {
'@type': 'Person',
...(canonical ? { '@id': canonical + '#person', url: canonical } : {}),
name: data.name,
jobTitle: data.header,
description: data.about,
...(image ? { image } : {}),
knowsAbout: skills
};
// Only explicitly identified profile links represent the same person.
const sameAs = data.links.filter((link) => link.sameAs === true && /^https?:\/\//.test(link.link)).map((link) => link.link);
if (sameAs.length) person.sameAs = sameAs;
const schema = {
'@context': 'https://schema.org',
'@type': 'ProfilePage',
...(canonical ? { '@id': canonical + '#profile', url: canonical } : {}),
name: title,
description,
inLanguage: language,
mainEntity: person
};
return { canonical, title, description, language, image, schema: JSON.stringify(schema).replace(/</g, '\\u003c') };
}
function escapeXml(text) {
return text.replace(/[<>&'\"]/g, (char) => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;', "'": '&apos;', '"': '&quot;' })[char]);
}
function buildSummary(data, canonical) {
const lines = [`# ${data.name}`, '', `> ${data.header}`, '', data.about, ''];
if (canonical) lines.push(`Profile: ${canonical}`, '');
lines.push('## Experience', '');
for (const work of data.workexperiences) {
lines.push(`### ${work.desc}`, `${work.title} · ${work.date}`, ...(work.highlights || []).map((point) => `- ${point}`), '');
}
if (data.projects?.length) {
lines.push('## Projects', '');
for (const project of data.projects) lines.push(`### ${project.name}`, project.summary || '', ...(project.stack?.length ? [`Stack: ${project.stack.join(', ')}`] : []), ...(project.link ? [`Project: ${project.link}`] : []), '');
}
lines.push('## Skills', '');
for (const menu of data.menus) lines.push(`### ${menu.title}`, (menu.subentries || []).map((s) => typeof s === 'string' ? s : s.name || s.title || s.skill).filter(Boolean).join(', '), '');
lines.push('## Education', '');
for (const education of data.educations) lines.push(`- ${education.desc1} · ${education.desc2} · ${education.date}`);
lines.push('', '## Interests', '');
for (const interest of data.interests) lines.push(`- ${interest.title}${interest.subentries?.length ? ': ' + interest.subentries.join(', ') : ''}`);
return lines.join('\n') + '\n';
}
module.exports = { buildSeo, siteOrigin, escapeXml, buildSummary };