@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,90 @@
1
+ ---
2
+ /**
3
+ * Site chrome: brand, navigation columns, legal column, copyright, to-top.
4
+ *
5
+ * Each column disappears when its link list is empty, and the bottom bar
6
+ * disappears when there is neither a copyright nor a to-top label. The old
7
+ * version crashed on `footer.navLinks.map` when either list was absent.
8
+ */
9
+ import type { ImageMetadata } from "astro";
10
+ import Logo from "./Logo.astro";
11
+ import { cmsKey, hasAny, hasItems, hasText, list } from "../lib/content";
12
+
13
+ interface Props {
14
+ footer?: Record<string, any>;
15
+ header?: Record<string, any>;
16
+ logo?: ImageMetadata;
17
+ }
18
+
19
+ const { footer = {}, header = {}, logo } = Astro.props;
20
+
21
+ const usable = (links: unknown) =>
22
+ list<any>(links).filter((link) => hasText(link?.label) && hasText(link?.href));
23
+
24
+ const columns = [
25
+ { key: "navLinks", title: footer.navTitle || "Navigation", links: usable(footer.navLinks) },
26
+ { key: "legalLinks", title: footer.legalTitle || "Rechtliches", links: usable(footer.legalLinks) },
27
+ { key: "socialLinks", title: footer.socialTitle, links: usable(footer.socialLinks) },
28
+ ].filter((column) => hasItems(column.links));
29
+
30
+ const copyright = [footer.copyrightPrefix, footer.copyrightSuffix].filter(hasText).join(" ");
31
+ const hasBrand = hasAny(logo, footer.tagline, header.brandName);
32
+ ---
33
+
34
+ <footer class="sq-footer">
35
+ {
36
+ (hasBrand || columns.length > 0) && (
37
+ <div class="sq-container sq-footer__inner">
38
+ {hasBrand && (
39
+ <div class="sq-footer__brand">
40
+ <Logo
41
+ image={logo}
42
+ name={header.brandName}
43
+ href={header.homeHref || "/"}
44
+ label={header.homeLabel || header.brandName}
45
+ variant="footer"
46
+ />
47
+ {hasText(footer.tagline) && (
48
+ <p class="sq-footer__tagline" data-cms="footer.tagline">
49
+ {footer.tagline}
50
+ </p>
51
+ )}
52
+ </div>
53
+ )}
54
+
55
+ {columns.map((column) => (
56
+ <nav class="sq-footer__nav" aria-label={column.title}>
57
+ {hasText(column.title) && <p class="sq-footer__nav-title">{column.title}</p>}
58
+ <ul>
59
+ {column.links.map((link: any, i: number) => (
60
+ <li>
61
+ <a
62
+ href={link.href}
63
+ target={link.external ? "_blank" : undefined}
64
+ rel={link.external ? "noopener" : undefined}
65
+ data-cms={cmsKey("footer", column.key, i, "label")}
66
+ >
67
+ {link.label}
68
+ </a>
69
+ </li>
70
+ ))}
71
+ </ul>
72
+ </nav>
73
+ ))}
74
+ </div>
75
+ )
76
+ }
77
+
78
+ {
79
+ hasAny(copyright, footer.toTopLabel) && (
80
+ <div class="sq-container sq-footer__bottom">
81
+ {hasText(copyright) && <p data-cms="footer.copyrightPrefix">{copyright}</p>}
82
+ {hasText(footer.toTopLabel) && (
83
+ <a class="sq-to-top" href="#top" data-cms="footer.toTopLabel">
84
+ {footer.toTopLabel}
85
+ </a>
86
+ )}
87
+ </div>
88
+ )
89
+ }
90
+ </footer>
@@ -0,0 +1,97 @@
1
+ ---
2
+ /**
3
+ * Site chrome: brand + primary navigation + mobile toggle.
4
+ *
5
+ * A site with no nav links renders no `<nav>` and no burger button — the
6
+ * toggle would otherwise open an empty drawer.
7
+ */
8
+ import type { ImageMetadata } from "astro";
9
+ import Logo from "./Logo.astro";
10
+ import { hasItems, hasText, list } from "../lib/content";
11
+
12
+ interface Props {
13
+ header?: Record<string, any>;
14
+ logo?: ImageMetadata;
15
+ currentPath?: string;
16
+ navLinks?: any[];
17
+ }
18
+
19
+ const { header = {}, logo, currentPath = "/", navLinks } = Astro.props;
20
+ const links = list<any>(navLinks ?? header.navLinks).filter(
21
+ (link) => hasText(link?.label) && hasText(link?.href),
22
+ );
23
+ const pathOnly = (href: string) => href.split("#")[0] || "/";
24
+
25
+ const bestMatchPath = links.reduce((best: string | null, link: any) => {
26
+ if (link.href.includes("#")) return best;
27
+ const path = pathOnly(link.href);
28
+ const matches =
29
+ path === "/" ? currentPath === "/" : currentPath === path || currentPath.startsWith(`${path}/`);
30
+ if (!matches) return best;
31
+ return !best || path.length > best.length ? path : best;
32
+ }, null as string | null);
33
+
34
+ const isCurrent = (href: string) => !href.includes("#") && pathOnly(href) === bestMatchPath;
35
+
36
+ /**
37
+ * `emergency: true` in the nav link is the supported way to flag the crisis
38
+ * link. The label test is the pre-0.2 behaviour kept so sites that have not
39
+ * added the field yet keep their styling; drop it once content is migrated.
40
+ */
41
+ const isEmergency = (link: any) => link.emergency === true || /notfall/i.test(link.label);
42
+
43
+ const hasNav = hasItems(links);
44
+ ---
45
+
46
+ <header class="sq-header">
47
+ <div class="sq-container sq-header__inner">
48
+ <div class="sq-header__bar">
49
+ <Logo
50
+ image={logo}
51
+ name={header.brandName}
52
+ href={header.homeHref || "/"}
53
+ label={header.homeLabel || header.brandName}
54
+ eager
55
+ />
56
+ {
57
+ hasNav && (
58
+ <div class="sq-header__actions">
59
+ <button
60
+ class="sq-nav__toggle"
61
+ type="button"
62
+ aria-expanded="false"
63
+ aria-controls="site-nav"
64
+ aria-label={header.menuOpenLabel || "Menü öffnen"}
65
+ >
66
+ <span class="sq-nav__toggle-bar" aria-hidden="true" />
67
+ <span class="sq-nav__toggle-bar" aria-hidden="true" />
68
+ <span class="sq-nav__toggle-bar" aria-hidden="true" />
69
+ </button>
70
+ </div>
71
+ )
72
+ }
73
+ </div>
74
+ {
75
+ hasNav && (
76
+ <nav class="sq-nav" id="site-nav" aria-label={header.navLabel || "Hauptnavigation"}>
77
+ <ul class="sq-nav__list">
78
+ {links.map((link: any, i: number) => (
79
+ <li>
80
+ <a
81
+ class={isEmergency(link) ? "sq-nav__emergency" : undefined}
82
+ href={link.href}
83
+ target={link.external ? "_blank" : undefined}
84
+ rel={link.external ? "noopener" : undefined}
85
+ aria-current={isCurrent(link.href) ? "page" : undefined}
86
+ data-cms={`header.navLinks[${i}].label`}
87
+ >
88
+ {link.label}
89
+ </a>
90
+ </li>
91
+ ))}
92
+ </ul>
93
+ </nav>
94
+ )
95
+ }
96
+ </div>
97
+ </header>
package/src/config.ts ADDED
@@ -0,0 +1,59 @@
1
+ import { defineConfig } from "astro/config";
2
+ import type { AstroUserConfig } from "astro";
3
+ import sitemap from "@astrojs/sitemap";
4
+ import { validateSiteContent } from "@sumaq/cms-schema/build";
5
+
6
+ export type SumaqSiteOptions = {
7
+ site: string;
8
+ integrations?: unknown[];
9
+ image?: {
10
+ domains?: string[];
11
+ responsiveStyles?: boolean;
12
+ };
13
+ /** Passed through to Astro (e.g. Google fonts via `fontProviders`). */
14
+ fonts?: AstroUserConfig["fonts"];
15
+ /** Passed through to Astro. Dev-only plugins (QR, devtools) live here. */
16
+ vite?: AstroUserConfig["vite"];
17
+ /** Skip content↔schema validation (escape hatch for experiments). */
18
+ skipContentValidation?: boolean;
19
+
20
+ // Sitios multiidioma: cuando un cliente lo pida, aquí entra
21
+ // i18n?: AstroUserConfig["i18n"];
22
+ // y se reenvía a `defineConfig` igual que `fonts` y `vite`. Hoy ningún sitio
23
+ // lo usa, así que no se añade en vacío: el día que haga falta, el cambio son
24
+ // dos líneas y hay que decidir a la vez cómo se traducen `cms/` y `content/`,
25
+ // que es la parte cara.
26
+ };
27
+
28
+ /**
29
+ * Shared Astro config for Sumaq client sites.
30
+ * Validates content/*.json against cms/*.yaml before the build emits pages.
31
+ */
32
+ export function defineSumaqSite(opts: SumaqSiteOptions) {
33
+ return defineConfig({
34
+ site: opts.site,
35
+ fonts: opts.fonts,
36
+ vite: opts.vite,
37
+ integrations: [
38
+ sitemap(),
39
+ {
40
+ name: "@sumaq/validate-content",
41
+ hooks: {
42
+ "astro:build:start": async () => {
43
+ if (opts.skipContentValidation) return;
44
+ await validateSiteContent({
45
+ cmsDir: "./cms",
46
+ contentDir: "./content",
47
+ });
48
+ },
49
+ },
50
+ },
51
+ ...((opts.integrations as never[]) ?? []),
52
+ ],
53
+ image: {
54
+ responsiveStyles: true,
55
+ domains: opts.image?.domains ?? [],
56
+ ...opts.image,
57
+ },
58
+ });
59
+ }
package/src/index.ts ADDED
@@ -0,0 +1,75 @@
1
+ // Layouts
2
+ export { default as Base } from "./layouts/Base.astro";
3
+
4
+ // Chrome
5
+ export { default as Seo } from "./components/Seo.astro";
6
+ export { default as SiteHeader } from "./components/SiteHeader.astro";
7
+ export { default as SiteFooter } from "./components/SiteFooter.astro";
8
+
9
+ // Primitives — the pieces every block is assembled from (block-contract §3, layer A)
10
+ export { default as Section } from "./components/Section.astro";
11
+ export { default as SectionHeader } from "./components/SectionHeader.astro";
12
+ export { default as SectionHeading } from "./components/SectionHeading.astro";
13
+ export { default as Actions } from "./components/Actions.astro";
14
+ export { default as Card } from "./components/Card.astro";
15
+ export { default as Logo } from "./components/Logo.astro";
16
+ export { default as Media } from "./components/Media.astro";
17
+ export { default as Prose } from "./components/Prose.astro";
18
+ export { default as ScrollToTop } from "./components/ScrollToTop.astro";
19
+
20
+ // Blocks — page openers
21
+ export { default as Hero } from "./blocks/Hero.astro";
22
+ export { default as PageBanner } from "./blocks/PageBanner.astro";
23
+
24
+ // Blocks — content
25
+ export { default as About } from "./blocks/About.astro";
26
+ export { default as Audience } from "./blocks/Audience.astro";
27
+ export { default as Faq } from "./blocks/Faq.astro";
28
+ export { default as FeatureList } from "./blocks/FeatureList.astro";
29
+ export { default as Gallery } from "./blocks/Gallery.astro";
30
+ export { default as Generic } from "./blocks/Generic.astro";
31
+ export { default as Notice } from "./blocks/Notice.astro";
32
+ export { default as Pricing } from "./blocks/Pricing.astro";
33
+ export { default as Projects } from "./blocks/Projects.astro";
34
+ export { default as Quote } from "./blocks/Quote.astro";
35
+ export { default as Services } from "./blocks/Services.astro";
36
+ export { default as Skills } from "./blocks/Skills.astro";
37
+ export { default as Stats } from "./blocks/Stats.astro";
38
+ export { default as Steps } from "./blocks/Steps.astro";
39
+ export { default as Testimonials } from "./blocks/Testimonials.astro";
40
+ export { default as Timeline } from "./blocks/Timeline.astro";
41
+
42
+ // Blocks — conversion
43
+ export { default as Contact } from "./blocks/Contact.astro";
44
+ export { default as ContactDetails } from "./blocks/ContactDetails.astro";
45
+ export { default as CtaBand } from "./blocks/CtaBand.astro";
46
+ export { default as IntroCta } from "./blocks/IntroCta.astro";
47
+
48
+ // Blocks — collections
49
+ export { default as LocationsOverview } from "./blocks/LocationsOverview.astro";
50
+ export { default as ProfileDetail } from "./blocks/ProfileDetail.astro";
51
+ export { default as TeamDirectory } from "./blocks/TeamDirectory.astro";
52
+ export { default as TeamTeaser } from "./blocks/TeamTeaser.astro";
53
+
54
+ // Blocks — standalone pages
55
+ export { default as LegalDocument } from "./blocks/LegalDocument.astro";
56
+ export { default as NotFound } from "./blocks/NotFound.astro";
57
+
58
+ // Blocks — emergency (profile: therapist)
59
+ export { default as EmergencyGrid } from "./blocks/EmergencyGrid.astro";
60
+ export { default as EmergencyHelplines } from "./blocks/EmergencyHelplines.astro";
61
+ export { default as EmergencyPrimary } from "./blocks/EmergencyPrimary.astro";
62
+
63
+ export { default as BlockRenderer } from "./blocks/BlockRenderer.astro";
64
+
65
+ // Lib re-exports for convenience
66
+ export {
67
+ loadEntries,
68
+ applyOrder,
69
+ getTeamMembers,
70
+ getLocationCards,
71
+ getCollectionCards,
72
+ } from "./lib/collections";
73
+ export { telHref } from "./lib/format";
74
+ export { md } from "./lib/markdown";
75
+ export { cmsKey, hasAny, hasImage, hasItems, hasText, list } from "./lib/content";
@@ -0,0 +1,57 @@
1
+ ---
2
+ import { ClientRouter } from "astro:transitions";
3
+ import type { ImageMetadata } from "astro";
4
+ import Seo from "../components/Seo.astro";
5
+ import SiteHeader from "../components/SiteHeader.astro";
6
+ import SiteFooter from "../components/SiteFooter.astro";
7
+ import ScrollToTop from "../components/ScrollToTop.astro";
8
+
9
+ interface Props {
10
+ seo?: Record<string, any>;
11
+ header?: Record<string, any>;
12
+ footer?: Record<string, any>;
13
+ /** Site logo (lives in the client repo, not the kit). */
14
+ logo?: ImageMetadata;
15
+ /** Label of the skip link; the site's language decides the wording. */
16
+ skipLabel?: string;
17
+ /** Floating back-to-top control. Off by default: the footer link covers it. */
18
+ scrollToTop?: boolean | { label?: string; threshold?: number };
19
+ }
20
+
21
+ const {
22
+ seo = {},
23
+ header,
24
+ footer,
25
+ logo,
26
+ skipLabel = "Zum Inhalt springen",
27
+ scrollToTop = false,
28
+ } = Astro.props;
29
+ const lang = seo.lang || "de";
30
+ const scrollOptions = typeof scrollToTop === "object" ? scrollToTop : {};
31
+ ---
32
+
33
+ <!doctype html>
34
+ <html lang={lang}>
35
+ <head>
36
+ <meta charset="UTF-8" />
37
+ <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
38
+ <link rel="sitemap" href="/sitemap-index.xml" />
39
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
40
+ <meta name="generator" content={Astro.generator} />
41
+ <Seo seo={seo} />
42
+ <ClientRouter />
43
+ <slot name="head" />
44
+ </head>
45
+ <body id="top">
46
+ {skipLabel && <a class="sq-skip-link" href="#main">{skipLabel}</a>}
47
+ {header && <SiteHeader header={header} logo={logo} currentPath={Astro.url.pathname} />}
48
+ <main id="main"><slot /></main>
49
+ {footer && <SiteFooter footer={footer} header={header ?? {}} logo={logo} />}
50
+ {
51
+ scrollToTop && (
52
+ <ScrollToTop label={scrollOptions.label} threshold={scrollOptions.threshold} />
53
+ )
54
+ }
55
+ <script src="../scripts/enhancements.js"></script>
56
+ </body>
57
+ </html>
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Collection access helpers.
3
+ *
4
+ * Globs must live in the consuming site (Vite resolves import.meta.glob relative
5
+ * to the importing module). Pass the eager-glob result into loadEntries().
6
+ *
7
+ * The entry slug is the JSON file name — the same rule the Sumaq CMS uses.
8
+ * Never read a `slug` field from the JSON.
9
+ */
10
+
11
+ type Entry = Record<string, any>;
12
+
13
+ function slugOf(path: string): string {
14
+ return path.split("/").pop()!.replace(/\.json$/, "");
15
+ }
16
+
17
+ /** Turn an eager import.meta.glob result into entries with slug from filename. */
18
+ export function loadEntries(modules: Record<string, unknown>): Entry[] {
19
+ return Object.entries(modules)
20
+ .map(([path, entry]) => ({ slug: slugOf(path), ...(entry as Entry) }))
21
+ .sort((a, b) => a.slug.localeCompare(b.slug));
22
+ }
23
+
24
+ /** Applies the display order declared on the index page; unlisted entries go last. */
25
+ export function applyOrder(entries: Entry[], order: string[] = []): Entry[] {
26
+ if (!order.length) return entries;
27
+ const rank = new Map(order.map((slug, index) => [slug, index]));
28
+ return [...entries].sort(
29
+ (a, b) => (rank.get(a.slug) ?? order.length) - (rank.get(b.slug) ?? order.length),
30
+ );
31
+ }
32
+
33
+ /** Slugs always shown first, in this exact order, ahead of the completeness-based sort. */
34
+ const TEAM_PRIORITY_SLUGS = ["stefan-just", "barbara-bischoff"];
35
+
36
+ function teamDataCompleteness(profile: Entry): number {
37
+ return [profile.titleSuffix, profile.role, profile.card?.description].filter(Boolean).length;
38
+ }
39
+
40
+ function sortTeamEntries(entries: Entry[]): Entry[] {
41
+ const bySlug = new Map(entries.map((entry) => [entry.slug, entry]));
42
+ const pinned = TEAM_PRIORITY_SLUGS.map((slug) => bySlug.get(slug)).filter(Boolean) as Entry[];
43
+ const rest = entries
44
+ .filter((entry) => !TEAM_PRIORITY_SLUGS.includes(entry.slug))
45
+ .sort(
46
+ (a, b) =>
47
+ teamDataCompleteness(b) - teamDataCompleteness(a) || a.name.localeCompare(b.name, "de"),
48
+ );
49
+ return [...pinned, ...rest];
50
+ }
51
+
52
+ /** Team members as consumed by the `teamDirectory` block. */
53
+ export function getTeamMembers(entries: Entry[], order?: string[]) {
54
+ const ordered = order?.length ? applyOrder(entries, order) : sortTeamEntries(entries);
55
+ return ordered.map((profile) => ({
56
+ slug: profile.slug,
57
+ name: profile.name,
58
+ titleSuffix: profile.titleSuffix,
59
+ role: profile.role,
60
+ description:
61
+ profile.card?.description ||
62
+ profile.activityAreas?.[0]?.text ||
63
+ profile.diagnoses?.split(",")[0] ||
64
+ profile.role ||
65
+ "",
66
+ photo: profile.photo,
67
+ searchKeywords: profile.searchKeywords || "",
68
+ categories: profile.categories || "",
69
+ href: `/team/${profile.slug}`,
70
+ }));
71
+ }
72
+
73
+ /** Locations as consumed by the `locationsOverview` block. */
74
+ export function getLocationCards(entries: Entry[], order?: string[]) {
75
+ return applyOrder(entries, order).map((entry, index) => {
76
+ const card = entry.card ?? {};
77
+ return {
78
+ id: entry.slug,
79
+ name: [entry.name, entry.nameAccent].filter(Boolean).join(" "),
80
+ address: card.address ?? "",
81
+ description: card.description ?? "",
82
+ priceLabel: card.priceLabel ?? "",
83
+ priceValue: card.priceValue ?? "",
84
+ priceSuffix: card.priceSuffix ?? "",
85
+ href: card.href || (entry.hasDetailPage ? `/standorte/${entry.slug}` : `#${entry.slug}`),
86
+ reverse: index % 2 === 1,
87
+ images: card.images ?? [],
88
+ features: card.features ?? [],
89
+ };
90
+ });
91
+ }
92
+
93
+ /** Alias matching the migration docs: cards for a named collection. */
94
+ export function getCollectionCards(
95
+ name: "locations" | "team",
96
+ entries: Entry[],
97
+ order?: string[],
98
+ ) {
99
+ if (name === "team") return getTeamMembers(entries, order);
100
+ return getLocationCards(entries, order);
101
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Content presence helpers.
3
+ *
4
+ * The kit's rendering rule is: **if there is content, the element renders;
5
+ * if there is not, nothing is emitted.** No empty `<p>`, no empty `<ul>`, no
6
+ * `<header>` with three blank lines inside. These helpers keep that decision
7
+ * identical in every block instead of each component inventing its own truthy
8
+ * check — `""`, `" "`, `[]` and `{ src: "" }` all mean "no content".
9
+ */
10
+
11
+ /** A non-blank string, or a finite number (prices, counters, badges). */
12
+ export function hasText(value: unknown): boolean {
13
+ if (typeof value === "number") return Number.isFinite(value);
14
+ return typeof value === "string" && value.trim() !== "";
15
+ }
16
+
17
+ /** Always an array, with null/undefined entries dropped. Never throws on `undefined`. */
18
+ export function list<T = any>(value: unknown): T[] {
19
+ if (!Array.isArray(value)) return [];
20
+ return value.filter((item) => item !== null && item !== undefined) as T[];
21
+ }
22
+
23
+ /** True when the array holds at least one usable entry. */
24
+ export function hasItems(value: unknown): boolean {
25
+ return list(value).length > 0;
26
+ }
27
+
28
+ /** An image is present when it carries a `src` — either as a string or as `{ src }`. */
29
+ export function hasImage(value: unknown): boolean {
30
+ if (!value) return false;
31
+ if (typeof value === "string") return value.trim() !== "";
32
+ if (typeof value !== "object") return false;
33
+ return hasText((value as { src?: unknown }).src);
34
+ }
35
+
36
+ /** True when at least one of the values would render something. */
37
+ export function hasAny(...values: unknown[]): boolean {
38
+ return values.some((value) =>
39
+ Array.isArray(value) ? hasItems(value) : hasText(value) || hasImage(value),
40
+ );
41
+ }
42
+
43
+ /**
44
+ * Builds a `data-cms` path, or `undefined` when there is no base key.
45
+ * Astro drops attributes whose value is `undefined`, so an unkeyed block
46
+ * simply emits no `data-cms` instead of `data-cms="undefined.title"`.
47
+ */
48
+ export function cmsKey(base: string | undefined, ...parts: (string | number)[]): string | undefined {
49
+ if (!hasText(base)) return undefined;
50
+ return [base, ...parts].join(".").replace(/\.(\d+)/g, "[$1]");
51
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Display helpers shared by the collection detail pages.
3
+ */
4
+
5
+ /**
6
+ * Builds a dialable `tel:` href. Numbers are entered in Austrian national
7
+ * notation ("0660 / 721 7711") or already in E.164 ("+43 677 616519"), so a
8
+ * leading national 0 is replaced by the Austrian country code.
9
+ */
10
+ export function telHref(phone: string): string {
11
+ const digits = String(phone ?? "").replace(/[^\d+]/g, "");
12
+ if (!digits) return "";
13
+ if (digits.startsWith("+")) return `tel:${digits}`;
14
+ if (digits.startsWith("00")) return `tel:+${digits.slice(2)}`;
15
+ if (digits.startsWith("0")) return `tel:+43${digits.slice(1)}`;
16
+ return `tel:${digits}`;
17
+ }
@@ -0,0 +1,5 @@
1
+ import { marked } from "marked";
2
+
3
+ export function md(src?: string) {
4
+ return marked.parse(src || "", { async: false }) as string;
5
+ }
@@ -0,0 +1,35 @@
1
+ # Perfil: práctica con varias sedes y equipo, respaldada por collections.
2
+ # Extraído de www-therapieraum-graz-at.
3
+ #
4
+ # `blocks` es el punto de partida, no el límite: un sitio puede declarar shapes
5
+ # propias en su `cms/page.*.yaml` y pasar sus componentes a `<BlockRenderer
6
+ # components={{ … }} />`. Ver block-contract.md §7.
7
+
8
+ label: Práctica con sedes y equipo
9
+
10
+ blocks:
11
+ - hero
12
+ - pageBanner
13
+ - introCta
14
+ - teamTeaser
15
+ - teamDirectory
16
+ - profileDetail
17
+ - locationsOverview
18
+ - services
19
+ - audience
20
+ - pricing
21
+ - contact
22
+ - cta
23
+ - emergencyPrimary
24
+ - emergencyGrid
25
+ - emergencyHelplines
26
+ - generic
27
+
28
+ collections: [team, locations]
29
+
30
+ pages:
31
+ required: [index, impressum, datenschutz, 404]
32
+ optional: [team, standorte, notfall, agb]
33
+
34
+ composition:
35
+ index: [hero, teamTeaser, introCta, locationsOverview, services, audience, pricing, cta, contact]
@@ -0,0 +1,27 @@
1
+ # Perfil: portfolio profesional / freelance.
2
+ # Extraído de www-elingan-dev.
3
+ #
4
+ # `blocks` es el punto de partida, no el límite: un sitio puede declarar shapes
5
+ # propias en su `cms/page.*.yaml` y pasar sus componentes a `<BlockRenderer
6
+ # components={{ … }} />`. Ver block-contract.md §7.
7
+
8
+ label: Portfolio profesional
9
+
10
+ blocks:
11
+ - hero
12
+ - projects
13
+ - skills
14
+ - about
15
+ - timeline
16
+ - testimonials
17
+ - stats
18
+ - contactDetails
19
+ - cta
20
+ - generic
21
+
22
+ pages:
23
+ required: [index, 404]
24
+ optional: [privacy, terms]
25
+
26
+ composition:
27
+ index: [hero, projects, skills, about, cta]
@@ -0,0 +1,31 @@
1
+ # Perfil: consulta individual de psicoterapia.
2
+ # Extraído de www-utehofer-at, www-bettinageidl-at y www-juliaschaffer-at.
3
+ #
4
+ # `blocks` es el punto de partida, no el límite: un sitio puede declarar shapes
5
+ # propias en su `cms/page.*.yaml` y pasar sus componentes a `<BlockRenderer
6
+ # components={{ … }} />`. Ver block-contract.md §7.
7
+
8
+ label: Consulta de psicoterapia
9
+
10
+ blocks:
11
+ - hero
12
+ - about
13
+ - services
14
+ - featureList
15
+ - steps
16
+ - quote
17
+ - gallery
18
+ - timeline
19
+ - pricing
20
+ - faq
21
+ - notice
22
+ - contactDetails
23
+ - cta
24
+ - generic
25
+
26
+ pages:
27
+ required: [index, impressum, datenschutz, 404]
28
+ optional: [agb, notfall]
29
+
30
+ composition:
31
+ index: [hero, about, services, featureList, steps, quote, pricing, contactDetails, cta]