feat(seo): facts in src/data/business.ts, SEO + JSON-LD components in layout
- business.ts: single source of truth; unknown facts (city, area, prices, packages, phone, delivery time, experience, testimonials) are null and are skipped everywhere. faq.ts builds answers only from known facts. - portfolio-meta.ts: data hook for real per-image alt texts. - SEO.astro: title, description, canonical, Open Graph, Twitter, og:image (EXIF-free JPG via getImage, absolute URL). - SchemaOrg.astro: @graph with WebSite, Person, ProfessionalService (sameAs, ReserveAction to the customer portal, offer catalog without prices), WebPage, optional FAQPage and BreadcrumbList. - MainLayout: lang=sv, Swedish nav (adds Tjänster), consent banner and footer in Swedish, lyxlimo/karleksguiden line removed, info@ address. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WvE3C43Uwtv2vtnoFBLDw7
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
ce5676f552
commit
30e385ec7f
@@ -0,0 +1,155 @@
|
||||
---
|
||||
// src/components/SchemaOrg.astro
|
||||
// JSON-LD (@graph): WebSite + Person (Linus) + ProfessionalService (Images by Olofsson)
|
||||
// + WebPage för aktuell sida (+ FAQPage och BreadcrumbList när det är relevant).
|
||||
// Allt hämtas från src/data/business.ts. null-värden utelämnas helt.
|
||||
import { business, absoluteUrl, genreList } from '../data/business';
|
||||
import type { FaqItem } from '../data/faq';
|
||||
import { normalizePath } from '../lib/url';
|
||||
|
||||
export interface Props {
|
||||
title: string;
|
||||
description: string;
|
||||
path?: string;
|
||||
pageType?: 'WebPage' | 'AboutPage' | 'ContactPage' | 'CollectionPage';
|
||||
/** Namn i brödsmulan för undersidor, t.ex. "Tjänster". */
|
||||
breadcrumb?: string;
|
||||
faq?: FaqItem[];
|
||||
/** Absolut URL till sidans huvudbild (og:image). */
|
||||
imageUrl?: string;
|
||||
}
|
||||
|
||||
const { title, description, path, pageType = 'WebPage', breadcrumb, faq, imageUrl } = Astro.props;
|
||||
|
||||
const site = (Astro.site ?? new URL(business.url)).toString().replace(/\/$/, '');
|
||||
const pagePath = normalizePath(path ?? Astro.url.pathname);
|
||||
const pageUrl = absoluteUrl(pagePath);
|
||||
|
||||
const ids = {
|
||||
website: `${site}/#website`,
|
||||
person: `${site}/#linus-olofsson`,
|
||||
business: `${site}/#images-by-olofsson`,
|
||||
webpage: `${pageUrl}#webpage`,
|
||||
breadcrumb: `${pageUrl}#breadcrumb`,
|
||||
};
|
||||
|
||||
// Tar bort null/undefined/tomma listor så att okända fakta aldrig hamnar i JSON-LD.
|
||||
function clean<T>(value: T): T {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map(clean).filter((v) => v !== null && v !== undefined) as unknown as T;
|
||||
}
|
||||
if (value && typeof value === 'object') {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
|
||||
if (v === null || v === undefined) continue;
|
||||
const c = clean(v);
|
||||
if (Array.isArray(c) && c.length === 0) continue;
|
||||
out[k] = c;
|
||||
}
|
||||
return out as T;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
// Landet är känt (Sverige); ort och region läggs till när de fylls i business.ts.
|
||||
const address = {
|
||||
'@type': 'PostalAddress',
|
||||
addressLocality: business.city,
|
||||
addressRegion: business.region,
|
||||
addressCountry: business.country.code,
|
||||
};
|
||||
|
||||
const graph: Record<string, unknown>[] = [
|
||||
{
|
||||
'@type': 'WebSite',
|
||||
'@id': ids.website,
|
||||
url: `${site}/`,
|
||||
name: business.brand,
|
||||
inLanguage: 'sv-SE',
|
||||
publisher: { '@id': ids.business },
|
||||
},
|
||||
{
|
||||
'@type': 'Person',
|
||||
'@id': ids.person,
|
||||
name: business.person.name,
|
||||
jobTitle: business.person.jobTitle,
|
||||
url: absoluteUrl('/about'),
|
||||
description: `${business.person.jobTitle} baserad i ${business.country.name}. ${business.recognitionThirdPerson}`,
|
||||
knowsAbout: business.genres.map((g) => g.name),
|
||||
worksFor: { '@id': ids.business },
|
||||
sameAs: [business.social.linkedin, business.social.lightroom],
|
||||
},
|
||||
{
|
||||
'@type': 'ProfessionalService',
|
||||
'@id': ids.business,
|
||||
name: business.brand,
|
||||
url: `${site}/`,
|
||||
description: `${business.person.name} fotograferar ${genreList()}.`,
|
||||
email: business.email,
|
||||
telephone: business.phone,
|
||||
image: imageUrl,
|
||||
address,
|
||||
areaServed: business.serviceArea,
|
||||
priceRange: business.priceRange,
|
||||
founder: { '@id': ids.person },
|
||||
employee: { '@id': ids.person },
|
||||
sameAs: [business.social.instagram.url, business.social.flickr, business.social.lightroom],
|
||||
potentialAction: {
|
||||
'@type': 'ReserveAction',
|
||||
name: 'Skicka en bokningsförfrågan',
|
||||
target: business.portal.bookingUrl,
|
||||
},
|
||||
hasOfferCatalog: {
|
||||
'@type': 'OfferCatalog',
|
||||
name: 'Fotografering',
|
||||
itemListElement: business.genres.map((g) => ({
|
||||
'@type': 'Offer',
|
||||
priceCurrency: business.priceFromSek != null ? 'SEK' : null,
|
||||
price: business.priceFromSek,
|
||||
itemOffered: {
|
||||
'@type': 'Service',
|
||||
name: g.name,
|
||||
description: g.description,
|
||||
provider: { '@id': ids.business },
|
||||
},
|
||||
})),
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const webpage: Record<string, unknown> = {
|
||||
'@type': faq?.length ? [pageType, 'FAQPage'] : pageType,
|
||||
'@id': ids.webpage,
|
||||
url: pageUrl,
|
||||
name: title,
|
||||
description,
|
||||
inLanguage: 'sv-SE',
|
||||
isPartOf: { '@id': ids.website },
|
||||
about: { '@id': ids.business },
|
||||
primaryImageOfPage: imageUrl ? { '@type': 'ImageObject', url: imageUrl } : null,
|
||||
breadcrumb: breadcrumb ? { '@id': ids.breadcrumb } : null,
|
||||
mainEntity: faq?.length
|
||||
? faq.map((item) => ({
|
||||
'@type': 'Question',
|
||||
name: item.question,
|
||||
acceptedAnswer: { '@type': 'Answer', text: item.answer },
|
||||
}))
|
||||
: null,
|
||||
};
|
||||
graph.push(webpage);
|
||||
|
||||
if (breadcrumb) {
|
||||
graph.push({
|
||||
'@type': 'BreadcrumbList',
|
||||
'@id': ids.breadcrumb,
|
||||
itemListElement: [
|
||||
{ '@type': 'ListItem', position: 1, name: 'Hem', item: `${site}/` },
|
||||
{ '@type': 'ListItem', position: 2, name: breadcrumb, item: pageUrl },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
const json = JSON.stringify(clean({ '@context': 'https://schema.org', '@graph': graph })).replace(/</g, '\\u003c');
|
||||
---
|
||||
|
||||
<script type="application/ld+json" is:inline set:html={json} />
|
||||
Reference in New Issue
Block a user