@sumaq/site-kit 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/README.md +93 -0
  2. package/package.json +57 -0
  3. package/src/astro-modules.d.ts +11 -0
  4. package/src/blocks/About.astro +94 -0
  5. package/src/blocks/Audience.astro +66 -0
  6. package/src/blocks/BlockRenderer.astro +105 -0
  7. package/src/blocks/Contact.astro +58 -0
  8. package/src/blocks/ContactDetails.astro +91 -0
  9. package/src/blocks/CtaBand.astro +53 -0
  10. package/src/blocks/EmergencyGrid.astro +47 -0
  11. package/src/blocks/EmergencyHelplines.astro +109 -0
  12. package/src/blocks/EmergencyPrimary.astro +51 -0
  13. package/src/blocks/Faq.astro +56 -0
  14. package/src/blocks/FeatureList.astro +73 -0
  15. package/src/blocks/Gallery.astro +95 -0
  16. package/src/blocks/Generic.astro +30 -0
  17. package/src/blocks/Hero.astro +82 -0
  18. package/src/blocks/IntroCta.astro +41 -0
  19. package/src/blocks/LegalDocument.astro +83 -0
  20. package/src/blocks/LocationsOverview.astro +161 -0
  21. package/src/blocks/NotFound.astro +33 -0
  22. package/src/blocks/Notice.astro +49 -0
  23. package/src/blocks/PageBanner.astro +57 -0
  24. package/src/blocks/Pricing.astro +97 -0
  25. package/src/blocks/ProfileDetail.astro +202 -0
  26. package/src/blocks/Projects.astro +91 -0
  27. package/src/blocks/Quote.astro +53 -0
  28. package/src/blocks/Services.astro +46 -0
  29. package/src/blocks/Skills.astro +65 -0
  30. package/src/blocks/Stats.astro +54 -0
  31. package/src/blocks/Steps.astro +76 -0
  32. package/src/blocks/TeamDirectory.astro +164 -0
  33. package/src/blocks/TeamTeaser.astro +84 -0
  34. package/src/blocks/Testimonials.astro +75 -0
  35. package/src/blocks/Timeline.astro +65 -0
  36. package/src/components/Actions.astro +67 -0
  37. package/src/components/Card.astro +104 -0
  38. package/src/components/Logo.astro +57 -0
  39. package/src/components/Media.astro +83 -0
  40. package/src/components/Prose.astro +29 -0
  41. package/src/components/ScrollToTop.astro +49 -0
  42. package/src/components/Section.astro +75 -0
  43. package/src/components/SectionHeader.astro +58 -0
  44. package/src/components/SectionHeading.astro +76 -0
  45. package/src/components/Seo.astro +55 -0
  46. package/src/components/SiteFooter.astro +90 -0
  47. package/src/components/SiteHeader.astro +97 -0
  48. package/src/config.ts +59 -0
  49. package/src/index.ts +75 -0
  50. package/src/layouts/Base.astro +57 -0
  51. package/src/lib/collections.ts +101 -0
  52. package/src/lib/content.ts +51 -0
  53. package/src/lib/format.ts +17 -0
  54. package/src/lib/markdown.ts +5 -0
  55. package/src/profiles/clinic.yaml +35 -0
  56. package/src/profiles/portfolio.yaml +27 -0
  57. package/src/profiles/therapist.yaml +31 -0
  58. package/src/scripts/enhancements.js +459 -0
  59. package/src/tokens/class-inventory.json +284 -0
  60. package/src/tokens/index.css +83 -0
@@ -0,0 +1,67 @@
1
+ ---
2
+ /**
3
+ * A row of CTAs. Renders nothing when the list is empty or every entry lacks
4
+ * a label — an empty actions wrapper still takes vertical space in most CSS,
5
+ * so it must not be emitted.
6
+ *
7
+ * Honours the `external` flag the page schemas already declare
8
+ * (`target="_blank"` + `rel="noopener"`).
9
+ */
10
+ import { cmsKey, hasText, list } from "../lib/content";
11
+
12
+ interface Cta {
13
+ label?: string;
14
+ href?: string;
15
+ variant?: string;
16
+ external?: boolean;
17
+ }
18
+
19
+ interface Props {
20
+ ctas?: Cta[];
21
+ /** Wrapper classes, e.g. `sq-hero__actions` or `sq-section__actions`. */
22
+ class?: string;
23
+ /** `data-cms` base key of the list, e.g. `hero.ctas`. */
24
+ cms?: string;
25
+ /** Class used when a CTA is not `primary`. */
26
+ secondaryClass?: string;
27
+ /** Treat the first CTA as primary regardless of its `variant` (CtaBand). */
28
+ primaryFirst?: boolean;
29
+ /** Extra classes on every button, e.g. `sq-btn--block`. */
30
+ buttonClass?: string;
31
+ }
32
+
33
+ const {
34
+ ctas,
35
+ class: className = "sq-section__actions",
36
+ cms,
37
+ secondaryClass = "sq-btn--ghost",
38
+ primaryFirst = false,
39
+ buttonClass,
40
+ } = Astro.props;
41
+
42
+ const items = list<Cta>(ctas).filter((cta) => hasText(cta.label) && hasText(cta.href));
43
+ ---
44
+
45
+ {
46
+ items.length > 0 && (
47
+ <div class={className}>
48
+ {items.map((cta, i) => (
49
+ <a
50
+ class:list={[
51
+ "sq-btn",
52
+ (primaryFirst && i === 0) || cta.variant === "primary"
53
+ ? "sq-btn--primary"
54
+ : secondaryClass,
55
+ buttonClass,
56
+ ]}
57
+ href={cta.href}
58
+ target={cta.external ? "_blank" : undefined}
59
+ rel={cta.external ? "noopener" : undefined}
60
+ data-cms={cmsKey(cms, i, "label")}
61
+ >
62
+ {cta.label}
63
+ </a>
64
+ ))}
65
+ </div>
66
+ )
67
+ }
@@ -0,0 +1,104 @@
1
+ ---
2
+ /**
3
+ * The card primitive shared by the grid blocks — the shape that appears as
4
+ * `ItemCard` in www-utehofer-at and www-bettinageidl-at and as `FeatureCard`
5
+ * in www-juliaschaffer-at.
6
+ *
7
+ * Every part is optional: no icon → no icon slot, no bullets → no `<ul>`,
8
+ * no link → the card is a plain `<article>` instead of an anchor.
9
+ */
10
+ import Media from "./Media.astro";
11
+ import { cmsKey, hasImage, hasText, list } from "../lib/content";
12
+
13
+ interface Bullet {
14
+ text?: string;
15
+ }
16
+
17
+ interface Props {
18
+ title?: string;
19
+ body?: string;
20
+ icon?: string;
21
+ image?: any;
22
+ imageAlt?: string;
23
+ href?: string;
24
+ external?: boolean;
25
+ badge?: string;
26
+ bullets?: Bullet[] | string[];
27
+ /** `data-cms` base key of this card, e.g. `services.items[0]`. */
28
+ cms?: string;
29
+ class?: string;
30
+ /** Heading tag inside the card. */
31
+ as?: "h2" | "h3" | "h4";
32
+ }
33
+
34
+ const {
35
+ title,
36
+ body,
37
+ icon,
38
+ image,
39
+ imageAlt,
40
+ href,
41
+ external = false,
42
+ badge,
43
+ bullets,
44
+ cms,
45
+ class: className,
46
+ as: Heading = "h3",
47
+ } = Astro.props;
48
+
49
+ const items = list<Bullet | string>(bullets)
50
+ .map((item) => (typeof item === "string" ? item : item.text))
51
+ .filter(hasText);
52
+ const isLink = hasText(href);
53
+ const Root = isLink ? "a" : "article";
54
+ ---
55
+
56
+ <Root
57
+ class:list={["sq-card", className]}
58
+ href={isLink ? href : undefined}
59
+ target={isLink && external ? "_blank" : undefined}
60
+ rel={isLink && external ? "noopener" : undefined}
61
+ data-cms={cms}
62
+ >
63
+ {
64
+ hasImage(image) && (
65
+ <div class="sq-card__media">
66
+ <Media
67
+ src={image}
68
+ alt={imageAlt}
69
+ width={800}
70
+ height={500}
71
+ cms={cmsKey(cms, "image", "src")}
72
+ />
73
+ </div>
74
+ )
75
+ }
76
+ {
77
+ hasImage(icon) && (
78
+ <div class="sq-card__icon" aria-hidden="true">
79
+ <Media src={icon} alt="" cms={cmsKey(cms, "icon")} />
80
+ </div>
81
+ )
82
+ }
83
+ <div class="sq-card__body">
84
+ {hasText(badge) && <p class="sq-card__badge" data-cms={cmsKey(cms, "badge")}>{badge}</p>}
85
+ {
86
+ hasText(title) && (
87
+ <Heading class="sq-card__title" data-cms={cmsKey(cms, "title")}>
88
+ {title}
89
+ </Heading>
90
+ )
91
+ }
92
+ {hasText(body) && <p class="sq-card__text" data-cms={cmsKey(cms, "body")}>{body}</p>}
93
+ {
94
+ items.length > 0 && (
95
+ <ul class="sq-card__list">
96
+ {items.map((item, i) => (
97
+ <li data-cms={cmsKey(cms, "bullets", i, "text")}>{item}</li>
98
+ ))}
99
+ </ul>
100
+ )
101
+ }
102
+ <slot />
103
+ </div>
104
+ </Root>
@@ -0,0 +1,57 @@
1
+ ---
2
+ /**
3
+ * Brand link used by `SiteHeader` and `SiteFooter`.
4
+ *
5
+ * Falls back to the brand name when no image is supplied, and renders nothing
6
+ * at all when neither exists — a site without a logo gets no empty `<a>`.
7
+ */
8
+ import type { ImageMetadata } from "astro";
9
+ import Media from "./Media.astro";
10
+ import { hasImage, hasText } from "../lib/content";
11
+
12
+ interface Props {
13
+ image?: ImageMetadata | string | null;
14
+ name?: string;
15
+ href?: string;
16
+ label?: string;
17
+ /** `footer` adds `sq-logo--footer`. */
18
+ variant?: "header" | "footer";
19
+ eager?: boolean;
20
+ width?: number;
21
+ cms?: string;
22
+ }
23
+
24
+ const {
25
+ image,
26
+ name,
27
+ href = "/",
28
+ label,
29
+ variant = "header",
30
+ eager = false,
31
+ width = 600,
32
+ cms,
33
+ } = Astro.props;
34
+
35
+ const showImage = hasImage(image);
36
+ const showName = !showImage && hasText(name);
37
+ ---
38
+
39
+ {
40
+ (showImage || showName) && (
41
+ <a
42
+ class:list={["sq-logo", variant === "footer" && "sq-logo--footer"]}
43
+ href={href}
44
+ aria-label={label || name || undefined}
45
+ >
46
+ <Media
47
+ src={image}
48
+ alt=""
49
+ class="sq-logo__image"
50
+ width={width}
51
+ loading={eager ? "eager" : "lazy"}
52
+ cms={cms}
53
+ />
54
+ {showName && <span class="sq-logo__name">{name}</span>}
55
+ </a>
56
+ )
57
+ }
@@ -0,0 +1,83 @@
1
+ ---
2
+ /**
3
+ * Image primitive. Renders nothing when there is no `src`.
4
+ *
5
+ * Accepts both shapes the sites use:
6
+ * - a string path coming from `content/*.json` → plain `<img>`
7
+ * - an `ImageMetadata` import (logos, local assets) → `astro:assets` `<Image>`
8
+ */
9
+ import { Image } from "astro:assets";
10
+ import type { ImageMetadata } from "astro";
11
+ import { hasImage, hasText } from "../lib/content";
12
+
13
+ interface Props {
14
+ src?: string | ImageMetadata | { src?: string; alt?: string } | null;
15
+ alt?: string;
16
+ id?: string;
17
+ class?: string;
18
+ width?: number;
19
+ height?: number;
20
+ sizes?: string;
21
+ widths?: number[];
22
+ loading?: "lazy" | "eager";
23
+ decoding?: "async" | "sync" | "auto";
24
+ fetchpriority?: "high" | "low" | "auto";
25
+ /** `data-cms-src` path so the live preview can swap the file. */
26
+ cms?: string;
27
+ }
28
+
29
+ const {
30
+ src,
31
+ alt,
32
+ id,
33
+ class: className,
34
+ width,
35
+ height,
36
+ sizes,
37
+ widths,
38
+ loading = "lazy",
39
+ decoding = "async",
40
+ fetchpriority,
41
+ cms,
42
+ } = Astro.props;
43
+
44
+ /** `{ src, alt }` objects from the CMS unwrap to their own src/alt. */
45
+ const isPlainImageField =
46
+ src !== null && typeof src === "object" && !("width" in src) && "src" in src;
47
+ const resolved = isPlainImageField ? (src as { src?: string }).src : src;
48
+ const resolvedAlt = alt ?? (isPlainImageField ? ((src as { alt?: string }).alt ?? "") : "");
49
+ const isAsset = typeof resolved === "object" && resolved !== null;
50
+ ---
51
+
52
+ {
53
+ hasImage(resolved) &&
54
+ (isAsset ? (
55
+ <Image
56
+ src={resolved as ImageMetadata}
57
+ alt={resolvedAlt}
58
+ id={id}
59
+ class={className}
60
+ width={width}
61
+ height={height}
62
+ widths={widths}
63
+ sizes={sizes}
64
+ loading={loading}
65
+ decoding={decoding}
66
+ data-cms-src={cms}
67
+ />
68
+ ) : (
69
+ <img
70
+ src={resolved as string}
71
+ alt={resolvedAlt}
72
+ id={id}
73
+ class={className}
74
+ width={width}
75
+ height={height}
76
+ sizes={hasText(sizes) ? sizes : undefined}
77
+ loading={loading}
78
+ decoding={decoding}
79
+ fetchpriority={fetchpriority}
80
+ data-cms-src={cms}
81
+ />
82
+ ))
83
+ }
@@ -0,0 +1,29 @@
1
+ ---
2
+ /**
3
+ * Long-form rich text (the output of a markdown field). Renders nothing when
4
+ * the source is empty, so a page with no body does not ship an empty
5
+ * `sq-prose` box.
6
+ */
7
+ import { md } from "../lib/markdown";
8
+ import { hasText } from "../lib/content";
9
+
10
+ interface Props {
11
+ body?: string;
12
+ /** Set when the value is already HTML and must not go through markdown. */
13
+ html?: boolean;
14
+ class?: string;
15
+ id?: string;
16
+ cms?: string;
17
+ /** Root tag — `article` for legal documents, `div` inside blocks. */
18
+ as?: "div" | "article" | "section";
19
+ }
20
+
21
+ const { body, html = false, class: className, id, cms, as: Tag = "div" } = Astro.props;
22
+ const content = hasText(body) ? (html ? body! : md(body)) : "";
23
+ ---
24
+
25
+ {
26
+ hasText(content) && (
27
+ <Tag id={id} class:list={["sq-prose", className]} set:html={content} data-cms={cms} />
28
+ )
29
+ }
@@ -0,0 +1,49 @@
1
+ ---
2
+ /**
3
+ * Floating "back to top" control — the `ScrollToTop.astro` that
4
+ * www-utehofer-at, www-bettinageidl-at and www-juliaschaffer-at each keep
5
+ * their own copy of.
6
+ *
7
+ * Behaviour (show past a scroll threshold, smooth scroll, focus restore) lives
8
+ * in `scripts/enhancements.js`; the markup carries no styles, so each site
9
+ * decides how `sq-scroll-top` and `is-visible` look.
10
+ */
11
+ import { hasText } from "../lib/content";
12
+
13
+ interface Props {
14
+ label?: string;
15
+ /** Pixels of scroll before the control appears. */
16
+ threshold?: number;
17
+ /** Skip-target the control returns focus to. */
18
+ target?: string;
19
+ }
20
+
21
+ const { label = "Back to top", threshold = 300, target = "top" } = Astro.props;
22
+ ---
23
+
24
+ <button
25
+ type="button"
26
+ class="sq-scroll-top"
27
+ data-scroll-top
28
+ data-scroll-threshold={String(threshold)}
29
+ data-scroll-target={target}
30
+ aria-label={label}
31
+ hidden
32
+ >
33
+ <span class="sq-scroll-top__icon" aria-hidden="true">
34
+ <svg
35
+ width="20"
36
+ height="20"
37
+ viewBox="0 0 24 24"
38
+ fill="none"
39
+ stroke="currentColor"
40
+ stroke-width="2"
41
+ stroke-linecap="round"
42
+ stroke-linejoin="round"
43
+ >
44
+ <path d="M12 19V5"></path>
45
+ <path d="m5 12 7-7 7 7"></path>
46
+ </svg>
47
+ </span>
48
+ {hasText(label) && <span class="sq-scroll-top__label">{label}</span>}
49
+ </button>
@@ -0,0 +1,75 @@
1
+ ---
2
+ /**
3
+ * The regular section skeleton every block shares (block-contract §2).
4
+ *
5
+ * Root is always a `<section>` carrying `sq-section` plus the block's own
6
+ * `sq-{name}`; the container always exists so the width stays a CSS decision.
7
+ *
8
+ * The section renders **only when its children render something**. `sq-section`
9
+ * carries vertical rhythm in every site's CSS, so an empty shell would leave a
10
+ * blank band on the page. Children are rendered once, up front, and the result
11
+ * is inlined — which is why kit blocks must stay server-only (no `client:*`).
12
+ */
13
+ import { hasText } from "../lib/content";
14
+
15
+ interface Props {
16
+ /** Block name → `sq-{name}` on the root, kebab-cased. */
17
+ name?: string;
18
+ /** `sq-section--{variant}`; `light` and `muted` are the two in use today. */
19
+ variant?: "light" | "muted" | "tight" | "loose" | string;
20
+ id?: string;
21
+ /** id of the heading that labels the section. */
22
+ labelledby?: string;
23
+ /** `data-cms` base key of the block. */
24
+ cms?: string;
25
+ container?: "default" | "narrow" | "none";
26
+ class?: string;
27
+ containerClass?: string;
28
+ }
29
+
30
+ /** Block names arrive camelCase from the schema; BEM classes are kebab-case. */
31
+ const kebab = (value: string) => value.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase();
32
+
33
+ const {
34
+ name,
35
+ variant,
36
+ id,
37
+ labelledby,
38
+ cms,
39
+ container = "default",
40
+ class: className,
41
+ containerClass,
42
+ } = Astro.props;
43
+
44
+ const body = Astro.slots.has("default") ? await Astro.slots.render("default") : "";
45
+ const hasBody = body.trim() !== "";
46
+ ---
47
+
48
+ {
49
+ hasBody && (
50
+ <section
51
+ class:list={[
52
+ "sq-section",
53
+ hasText(name) && `sq-${kebab(name!)}`,
54
+ hasText(variant) && `sq-section--${variant}`,
55
+ className,
56
+ ]}
57
+ id={id}
58
+ aria-labelledby={labelledby}
59
+ data-cms={cms}
60
+ >
61
+ {container === "none" ? (
62
+ <Fragment set:html={body} />
63
+ ) : (
64
+ <div
65
+ class:list={[
66
+ "sq-container",
67
+ container === "narrow" && "sq-container--narrow",
68
+ containerClass,
69
+ ]}
70
+ set:html={body}
71
+ />
72
+ )}
73
+ </section>
74
+ )
75
+ }
@@ -0,0 +1,58 @@
1
+ ---
2
+ /**
3
+ * `SectionHeading` wrapped in `<header class="sq-section__head">`.
4
+ * Emits nothing — not even the wrapper — when no field carries content.
5
+ */
6
+ import SectionHeading from "./SectionHeading.astro";
7
+ import { hasAny } from "../lib/content";
8
+
9
+ interface Props {
10
+ label?: string;
11
+ title?: string;
12
+ titleAccent?: string;
13
+ lede?: string;
14
+ note?: string;
15
+ cms?: string;
16
+ as?: "h1" | "h2" | "h3";
17
+ titleId?: string;
18
+ titleClass?: string;
19
+ ledeClass?: string;
20
+ /** `sq-center` on the wrapper. */
21
+ align?: "center" | "start";
22
+ class?: string;
23
+ }
24
+
25
+ const {
26
+ label,
27
+ title,
28
+ titleAccent,
29
+ lede,
30
+ note,
31
+ cms,
32
+ as,
33
+ titleId,
34
+ titleClass,
35
+ ledeClass,
36
+ align = "center",
37
+ class: className,
38
+ } = Astro.props;
39
+ ---
40
+
41
+ {
42
+ hasAny(label, title, titleAccent, lede, note) && (
43
+ <header class:list={["sq-section__head", align === "center" && "sq-center", className]}>
44
+ <SectionHeading
45
+ label={label}
46
+ title={title}
47
+ titleAccent={titleAccent}
48
+ lede={lede}
49
+ note={note}
50
+ cms={cms}
51
+ as={as}
52
+ titleId={titleId}
53
+ titleClass={titleClass}
54
+ ledeClass={ledeClass}
55
+ />
56
+ </header>
57
+ )
58
+ }
@@ -0,0 +1,76 @@
1
+ ---
2
+ /**
3
+ * Label / title / lede / note — the four fields every block header repeats,
4
+ * without the `<header>` wrapper. Blocks that need the wrapper use
5
+ * `SectionHeader`; blocks that place these fields straight into the container
6
+ * (`Generic`, `IntroCta`, `TeamTeaser`) use this one.
7
+ *
8
+ * Each field renders only when it carries content, and the whole group
9
+ * disappears when none of them do.
10
+ */
11
+ import { cmsKey, hasAny, hasText } from "../lib/content";
12
+
13
+ interface Props {
14
+ label?: string;
15
+ title?: string;
16
+ titleAccent?: string;
17
+ lede?: string;
18
+ note?: string;
19
+ /** `data-cms` base key; fields resolve to `{cms}.sectionTitle` etc. */
20
+ cms?: string;
21
+ /** Heading tag — `h1` on banners, `h2` everywhere else. */
22
+ as?: "h1" | "h2" | "h3";
23
+ titleId?: string;
24
+ titleClass?: string;
25
+ ledeClass?: string;
26
+ }
27
+
28
+ const {
29
+ label,
30
+ title,
31
+ titleAccent,
32
+ lede,
33
+ note,
34
+ cms,
35
+ as: Heading = "h2",
36
+ titleId,
37
+ titleClass,
38
+ ledeClass,
39
+ } = Astro.props;
40
+ ---
41
+
42
+ {
43
+ hasAny(label, title, titleAccent, lede, note) && (
44
+ <>
45
+ {hasText(label) && (
46
+ <p class="sq-section__label" data-cms={cmsKey(cms, "sectionLabel")}>
47
+ {label}
48
+ </p>
49
+ )}
50
+ {(hasText(title) || hasText(titleAccent)) && (
51
+ <Heading
52
+ id={titleId}
53
+ class:list={["sq-section__title", titleClass]}
54
+ data-cms={cmsKey(cms, "sectionTitle")}
55
+ >
56
+ {title}
57
+ {hasText(titleAccent) && (
58
+ <span class="sq-accent" data-cms={cmsKey(cms, "sectionTitleAccent")}>
59
+ {titleAccent}
60
+ </span>
61
+ )}
62
+ </Heading>
63
+ )}
64
+ {hasText(lede) && (
65
+ <p class:list={["sq-section__lede", ledeClass]} data-cms={cmsKey(cms, "sectionSubtitle")}>
66
+ {lede}
67
+ </p>
68
+ )}
69
+ {hasText(note) && (
70
+ <p class="sq-section__note" data-cms={cmsKey(cms, "sectionNote")}>
71
+ {note}
72
+ </p>
73
+ )}
74
+ </>
75
+ )
76
+ }
@@ -0,0 +1,55 @@
1
+ ---
2
+ /**
3
+ * `<head>` metadata.
4
+ *
5
+ * Covers what the five sites were each solving on their own: the canonical
6
+ * link and JSON-LD that www-juliaschaffer-at kept in `integrations/seo/`, and
7
+ * the keywords/author/robots trio that www-utehofer-at and www-bettinageidl-at
8
+ * inlined in their layouts. Every tag is conditional — a field the client did
9
+ * not fill emits no tag at all.
10
+ */
11
+ import { hasText } from "../lib/content";
12
+
13
+ interface Props {
14
+ seo?: Record<string, any>;
15
+ }
16
+
17
+ const { seo = {} } = Astro.props;
18
+
19
+ const base = Astro.site ?? Astro.url;
20
+ const absolute = (value?: string) => (hasText(value) ? new URL(value!, base).href : undefined);
21
+
22
+ const image = absolute(seo.ogImage);
23
+ const canonical = hasText(seo.canonical)
24
+ ? absolute(seo.canonical)
25
+ : new URL(Astro.url.pathname, base).href;
26
+ const ogTitle = seo.ogTitle || seo.title;
27
+ const ogDescription = seo.ogDescription || seo.description;
28
+ const schema = seo.schema && typeof seo.schema === "object" ? seo.schema : undefined;
29
+ ---
30
+
31
+ {hasText(seo.title) && <title>{seo.title}</title>}
32
+ {hasText(seo.description) && <meta name="description" content={seo.description} />}
33
+ {hasText(seo.keywords) && <meta name="keywords" content={seo.keywords} />}
34
+ {hasText(seo.author) && <meta name="author" content={seo.author} />}
35
+ {hasText(seo.robots) && <meta name="robots" content={seo.robots} />}
36
+ <meta name="theme-color" content={seo.themeColor || "#131313"} />
37
+ <link rel="canonical" href={canonical} />
38
+
39
+ <meta property="og:type" content={seo.ogType || "website"} />
40
+ <meta property="og:site_name" content={seo.siteName || seo.title || ""} />
41
+ <meta property="og:locale" content={seo.ogLocale || "de_AT"} />
42
+ <meta property="og:url" content={canonical} />
43
+ <meta property="og:title" content={ogTitle || ""} />
44
+ <meta property="og:description" content={ogDescription || ""} />
45
+ {image && <meta property="og:image" content={image} />}
46
+ {hasText(seo.ogImageAlt) && <meta property="og:image:alt" content={seo.ogImageAlt} />}
47
+
48
+ <meta name="twitter:card" content={seo.twitterCard || "summary_large_image"} />
49
+ <meta name="twitter:title" content={ogTitle || ""} />
50
+ <meta name="twitter:description" content={ogDescription || ""} />
51
+ {image && <meta name="twitter:image" content={image} />}
52
+ {hasText(seo.twitterSite) && <meta name="twitter:site" content={seo.twitterSite} />}
53
+ {hasText(seo.twitterCreator) && <meta name="twitter:creator" content={seo.twitterCreator} />}
54
+
55
+ {schema && <script type="application/ld+json" is:inline set:html={JSON.stringify(schema)} />}