@sumaq/site-kit 0.2.0 → 0.4.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.
package/README.md CHANGED
@@ -3,7 +3,7 @@
3
3
  Layouts, blocks, helpers and Astro config factory for Sumaq client sites.
4
4
 
5
5
  ```bash
6
- pnpm add @sumaq/site-kit@0.2.0
6
+ pnpm add @sumaq/site-kit@0.4.0
7
7
  ```
8
8
 
9
9
  Public on [npmjs.com](https://www.npmjs.com/package/@sumaq/site-kit). Pin an exact version in each `www-*` site — never a range.
@@ -33,7 +33,7 @@ The checks live in `lib/content.ts` (`hasText`, `list`, `hasItems`, `hasImage`,
33
33
  | `Actions` | a row of CTAs; honours `external` |
34
34
  | `Card` | media, icon, badge, title, text, bullets; becomes an `<a>` when given `href` |
35
35
  | `Logo` | brand link, falls back to the brand name when there is no image |
36
- | `Media` | `<img>` for CMS strings, `astro:assets` `<Image>` for imported assets |
36
+ | `Media` | resolves `/media/…` content paths to optimised assets; passthrough `<img>` for anything else — see [Images](#images) |
37
37
  | `Prose` | markdown or HTML into `sq-prose` |
38
38
  | `ScrollToTop` | floating back-to-top control |
39
39
 
@@ -51,6 +51,44 @@ The checks live in `lib/content.ts` (`hasText`, `list`, `hasItems`, `hasImage`,
51
51
 
52
52
  Every block accepts `section` (the `data-cms` base key), `variant` (`light` / `muted` / …) and `id`, so the same component can appear twice on a page under different CMS keys.
53
53
 
54
+ ## Images
55
+
56
+ A site keeps its images in **`./media/`** at the repo root — a sibling of `schema/` and `content/`, not inside `public/`. The CMS publishes there, and Astro processes what it finds: content hash in the filename, `srcset` at four widths, and a `dist/_astro/…` URL that changes whenever the bytes change.
57
+
58
+ That makes the string in `content/*.json` a **key, not a URL**:
59
+
60
+ ```jsonc
61
+ "image": { "alt": "Retrato", "src": "/media/portrait-a3f9c1d2.webp" }
62
+ ```
63
+
64
+ Nothing serves `/media/portrait-a3f9c1d2.webp`. `Media` looks the key up in a build-time glob and renders the asset Astro emitted for it. **Interpolating that string into a `src=` yourself gives a silent 404** — use `<Media>`, or `mediaUrl()` where only a string fits (an `og:image`, a `data-` attribute a script reads later).
65
+
66
+ ```astro
67
+ <Media src={data.hero.image} sizes="(max-width: 900px) 100vw, 25rem" />
68
+ ```
69
+
70
+ | `src` | What happens |
71
+ |---|---|
72
+ | `/media/x.webp` present in `./media/` | `<Image>` with `srcset` at 640/960/1280/2000 (clamped to the original) plus the manifest's LQIP as a background |
73
+ | `/media/x.svg` | `<img>` with the emitted URL — imported SVGs become components in Astro 7, and this keeps them images |
74
+ | `https://…` or any `public/` path | plain `<img>`, untouched |
75
+ | an `ImageMetadata` import | `<Image>`, as before |
76
+ | empty | nothing — unless `fallback` is set |
77
+
78
+ `media/manifest.json`, written by the CMS, carries `{ alt, width, height, lqip }` per key. A site without one simply renders without LQIP.
79
+
80
+ ### Placeholder
81
+
82
+ `<Media fallback />` shows a placeholder instead of rendering nothing when `src` is empty. It is **opt-in per block** on purpose: a hero with a gap reads better than a grey box, a card grid with a hole does not.
83
+
84
+ The file ships inside this package (`src/assets/placeholder.webp`), so no site has to remember to add one. A site that wants its own branding drops `media/placeholder.webp` in the repo and that wins.
85
+
86
+ In `astro dev`, a `/media/…` key that does not resolve also shows the placeholder plus a `console.warn` with the path. In a production build there is no such branch: `validateSiteContent` (from `@sumaq/cms-schema`, wired by `defineSumaqSite`) has already failed the build. A placeholder in production would hide a broken publication behind a grey rectangle nobody reports.
87
+
88
+ ### Non-image attachments
89
+
90
+ PDFs and the like go to **`public/files/`** and are referenced as `/files/tarifas-a3f9c1d2.pdf`. They are copied verbatim, so their URL stays stable for whoever the client shared it with — which is exactly why they do *not* get a content hash in their served path.
91
+
54
92
  ## Site-local components
55
93
 
56
94
  The kit is where a site **starts**, not where it is confined — a client paying for something of their own gets it in their own repo. Two seams make that work without forking:
@@ -63,7 +101,7 @@ import Rooms from "../components/Rooms.astro"; // only this client
63
101
  <BlockRenderer blocks={blocks} components={{ rooms: Rooms }} />
64
102
  ```
65
103
 
66
- `components` merges into the catalogue **and wins over it**, so a site can add a block the kit does not have or replace one it does. An unmatched `type` still falls back to `Generic`, and warns in dev so a custom block never fails silently. Data-side there is nothing to register: `@sumaq/cms-schema` validates each block against the site's own `cms/*.yaml`, never against a central list.
104
+ `components` merges into the catalogue **and wins over it**, so a site can add a block the kit does not have or replace one it does. An unmatched `type` still falls back to `Generic`, and warns in dev so a custom block never fails silently. Data-side there is nothing to register: `@sumaq/cms-schema` validates each block against the site's own `schema/*.yaml`, never against a central list.
67
105
 
68
106
  **The namespace rule:** `sq-*` belongs to the kit and is inventoried in `class-inventory.json`. Site-local components use their own prefix. That is what keeps the shared markup verifiable without taxing what makes a client's site theirs.
69
107
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sumaq/site-kit",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "description": "Layouts, blocks, helpers and Astro config factory for Sumaq client sites.",
5
5
  "license": "UNLICENSED",
6
6
  "author": "elingan",
@@ -20,6 +20,7 @@
20
20
  "./content": "./src/lib/content.ts",
21
21
  "./format": "./src/lib/format.ts",
22
22
  "./markdown": "./src/lib/markdown.ts",
23
+ "./media": "./src/lib/media.ts",
23
24
  "./tokens.css": "./src/tokens/index.css",
24
25
  "./class-inventory.json": "./src/tokens/class-inventory.json",
25
26
  "./profiles/*": "./src/profiles/*.yaml",
@@ -32,7 +33,7 @@
32
33
  "dependencies": {
33
34
  "@astrojs/sitemap": "3.7.3",
34
35
  "marked": "15.0.7",
35
- "@sumaq/cms-schema": "0.1.0"
36
+ "@sumaq/cms-schema": "0.3.0"
36
37
  },
37
38
  "devDependencies": {
38
39
  "astro": "7.1.4",
Binary file
@@ -11,6 +11,7 @@
11
11
  import Section from "../components/Section.astro";
12
12
  import SectionHeader from "../components/SectionHeader.astro";
13
13
  import Media from "../components/Media.astro";
14
+ import { mediaUrl } from "../lib/media";
14
15
  import { cmsKey, hasText, list } from "../lib/content";
15
16
 
16
17
  const {
@@ -52,7 +53,7 @@ const images = list<any>(items)
52
53
  type="button"
53
54
  class="sq-gallery__trigger"
54
55
  data-gallery-index={i}
55
- data-src={image.src}
56
+ data-src={mediaUrl(image.src)}
56
57
  data-alt={image.alt || ""}
57
58
  aria-label={image.alt || undefined}
58
59
  >
@@ -2,6 +2,7 @@
2
2
  import Section from "../components/Section.astro";
3
3
  import SectionHeader from "../components/SectionHeader.astro";
4
4
  import Media from "../components/Media.astro";
5
+ import { mediaUrl } from "../lib/media";
5
6
  import { cmsKey, hasAny, hasText, list } from "../lib/content";
6
7
 
7
8
  const {
@@ -71,7 +72,7 @@ const hasTitle = hasText(sectionTitle) || hasText(sectionTitleAccent);
71
72
  type="button"
72
73
  class:list={["sq-location__thumb", imageIndex === 0 && "is-active"]}
73
74
  data-target={`${location.id}-main-img`}
74
- data-src={image.src}
75
+ data-src={mediaUrl(image.src)}
75
76
  data-alt={image.alt}
76
77
  aria-label={`${image.alt || location.name} ${imageIndex + 1}`}
77
78
  >
@@ -1,20 +1,36 @@
1
1
  ---
2
2
  /**
3
- * Image primitive. Renders nothing when there is no `src`.
3
+ * Image primitive.
4
4
  *
5
- * Accepts both shapes the sites use:
6
- * - a string path coming from `content/*.json` → plain `<img>`
5
+ * Accepts the three shapes the sites use:
6
+ * - a `/media/…` key from `content/*.json` → resolved to a build asset and
7
+ * optimised by `astro:assets` (srcset + content hash)
8
+ * - any other string (external URL, `public/` path) → plain `<img>`,
9
+ * untouched
7
10
  * - an `ImageMetadata` import (logos, local assets) → `astro:assets` `<Image>`
11
+ *
12
+ * Renders nothing when there is no `src`, unless `fallback` is set — see the
13
+ * placeholder rules below.
8
14
  */
9
15
  import { Image } from "astro:assets";
10
16
  import type { ImageMetadata } from "astro";
11
17
  import { hasImage, hasText } from "../lib/content";
18
+ import {
19
+ DEFAULT_WIDTHS,
20
+ isMediaPath,
21
+ isSvg,
22
+ mediaMeta,
23
+ placeholderOverride,
24
+ resolveMedia,
25
+ } from "../lib/media";
26
+ import kitPlaceholder from "../assets/placeholder.webp";
12
27
 
13
28
  interface Props {
14
29
  src?: string | ImageMetadata | { src?: string; alt?: string } | null;
15
30
  alt?: string;
16
31
  id?: string;
17
32
  class?: string;
33
+ style?: string;
18
34
  width?: number;
19
35
  height?: number;
20
36
  sizes?: string;
@@ -22,6 +38,12 @@ interface Props {
22
38
  loading?: "lazy" | "eager";
23
39
  decoding?: "async" | "sync" | "auto";
24
40
  fetchpriority?: "high" | "low" | "auto";
41
+ /**
42
+ * Show the placeholder instead of rendering nothing when `src` is empty.
43
+ * Opt-in per block: a gap in a hero looks better than a grey box, a hole in
44
+ * a card grid does not.
45
+ */
46
+ fallback?: boolean;
25
47
  /** `data-cms-src` path so the live preview can swap the file. */
26
48
  cms?: string;
27
49
  }
@@ -31,6 +53,7 @@ const {
31
53
  alt,
32
54
  id,
33
55
  class: className,
56
+ style,
34
57
  width,
35
58
  height,
36
59
  sizes,
@@ -38,6 +61,7 @@ const {
38
61
  loading = "lazy",
39
62
  decoding = "async",
40
63
  fetchpriority,
64
+ fallback = false,
41
65
  cms,
42
66
  } = Astro.props;
43
67
 
@@ -45,35 +69,94 @@ const {
45
69
  const isPlainImageField =
46
70
  src !== null && typeof src === "object" && !("width" in src) && "src" in src;
47
71
  const resolved = isPlainImageField ? (src as { src?: string }).src : src;
48
- const resolvedAlt = alt ?? (isPlainImageField ? ((src as { alt?: string }).alt ?? "") : "");
72
+ const authoredAlt = alt ?? (isPlainImageField ? ((src as { alt?: string }).alt ?? "") : "");
49
73
  const isAsset = typeof resolved === "object" && resolved !== null;
74
+
75
+ /** A `/media/…` key resolves to a build asset; anything else stays a string. */
76
+ const localAsset = isAsset ? null : resolveMedia(resolved);
77
+ const meta = isAsset ? null : mediaMeta(resolved);
78
+
79
+ const missing = !hasImage(resolved);
80
+ /** A local key that should have resolved and did not — a broken publication. */
81
+ const broken = !isAsset && isMediaPath(resolved) && localAsset === null;
82
+
83
+ if (broken && import.meta.env.DEV) {
84
+ console.warn(
85
+ `[sumaq] Media: "${resolved}" is not in ./media/. ` +
86
+ "Showing the placeholder — this fails the production build.",
87
+ );
88
+ }
89
+
90
+ /**
91
+ * The placeholder covers two cases: an empty field on a block that opted in,
92
+ * and — in dev only — a key that does not resolve. In production the build has
93
+ * already failed via `validateSiteContent`, so there is nothing to hide.
94
+ */
95
+ const usePlaceholder = (missing && fallback) || (broken && import.meta.env.DEV);
96
+ const placeholder = placeholderOverride ?? kitPlaceholder;
97
+
98
+ /** The author's alt still describes what *should* be there; keep it. */
99
+ const resolvedAlt = usePlaceholder ? (hasText(authoredAlt) ? authoredAlt : "") : authoredAlt;
100
+
101
+ const asset: ImageMetadata | null = usePlaceholder
102
+ ? placeholder
103
+ : (localAsset ?? (isAsset ? (resolved as ImageMetadata) : null));
104
+
105
+ /** Content images get the full srcset; a hand-passed import keeps its own props. */
106
+ const assetWidths = widths ?? (localAsset || usePlaceholder ? DEFAULT_WIDTHS : undefined);
107
+
108
+ /** LQIP from the manifest, shown behind the image while it loads. */
109
+ const lqipStyle = meta?.lqip
110
+ ? `background-image:url(${meta.lqip});background-size:cover;background-position:center`
111
+ : undefined;
112
+ const composedStyle = [style, usePlaceholder ? undefined : lqipStyle]
113
+ .filter(Boolean)
114
+ .join(";") || undefined;
115
+
116
+ /**
117
+ * An SVG gets its dimensions written out by hand (it skips `<Image>`), so a
118
+ * caller that gave only one of them would otherwise pair it with the asset's
119
+ * intrinsic other one and describe a box of the wrong shape.
120
+ */
121
+ const svgWidth = asset && width === undefined && height !== undefined
122
+ ? Math.round((height * asset.width) / asset.height)
123
+ : (width ?? asset?.width);
124
+ const svgHeight = asset && height === undefined && width !== undefined
125
+ ? Math.round((width * asset.height) / asset.width)
126
+ : (height ?? asset?.height);
127
+
128
+ const passthrough = !asset && !missing;
50
129
  ---
51
130
 
52
131
  {
53
- hasImage(resolved) &&
54
- (isAsset ? (
55
- <Image
56
- src={resolved as ImageMetadata}
132
+ asset &&
133
+ (isSvg(asset) ? (
134
+ /* Astro compiles imported SVGs into components; the emitted URL keeps
135
+ them rendering as plain images. */
136
+ <img
137
+ src={asset.src}
57
138
  alt={resolvedAlt}
58
139
  id={id}
59
140
  class={className}
60
- width={width}
61
- height={height}
62
- widths={widths}
63
- sizes={sizes}
141
+ style={composedStyle}
142
+ width={svgWidth}
143
+ height={svgHeight}
64
144
  loading={loading}
65
145
  decoding={decoding}
146
+ fetchpriority={fetchpriority}
66
147
  data-cms-src={cms}
67
148
  />
68
149
  ) : (
69
- <img
70
- src={resolved as string}
150
+ <Image
151
+ src={asset}
71
152
  alt={resolvedAlt}
72
153
  id={id}
73
154
  class={className}
155
+ style={composedStyle}
74
156
  width={width}
75
157
  height={height}
76
- sizes={hasText(sizes) ? sizes : undefined}
158
+ widths={assetWidths}
159
+ sizes={sizes}
77
160
  loading={loading}
78
161
  decoding={decoding}
79
162
  fetchpriority={fetchpriority}
@@ -81,3 +164,21 @@ const isAsset = typeof resolved === "object" && resolved !== null;
81
164
  />
82
165
  ))
83
166
  }
167
+ {
168
+ passthrough && (
169
+ <img
170
+ src={resolved as string}
171
+ alt={resolvedAlt}
172
+ id={id}
173
+ class={className}
174
+ style={composedStyle}
175
+ width={width}
176
+ height={height}
177
+ sizes={hasText(sizes) ? sizes : undefined}
178
+ loading={loading}
179
+ decoding={decoding}
180
+ fetchpriority={fetchpriority}
181
+ data-cms-src={cms}
182
+ />
183
+ )
184
+ }
@@ -9,6 +9,7 @@
9
9
  * not fill emits no tag at all.
10
10
  */
11
11
  import { hasText } from "../lib/content";
12
+ import { mediaUrl } from "../lib/media";
12
13
 
13
14
  interface Props {
14
15
  seo?: Record<string, any>;
@@ -19,7 +20,9 @@ const { seo = {} } = Astro.props;
19
20
  const base = Astro.site ?? Astro.url;
20
21
  const absolute = (value?: string) => (hasText(value) ? new URL(value!, base).href : undefined);
21
22
 
22
- const image = absolute(seo.ogImage);
23
+ // Through the resolver first: a `/media/…` value is a repo path, and the tag a
24
+ // scraper reads has to be the URL Astro actually emitted for it.
25
+ const image = absolute(mediaUrl(seo.ogImage));
23
26
  const canonical = hasText(seo.canonical)
24
27
  ? absolute(seo.canonical)
25
28
  : new URL(Astro.url.pathname, base).href;
package/src/config.ts CHANGED
@@ -21,13 +21,13 @@ export type SumaqSiteOptions = {
21
21
  // i18n?: AstroUserConfig["i18n"];
22
22
  // y se reenvía a `defineConfig` igual que `fonts` y `vite`. Hoy ningún sitio
23
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/`,
24
+ // dos líneas y hay que decidir a la vez cómo se traducen `schema/` y `content/`,
25
25
  // que es la parte cara.
26
26
  };
27
27
 
28
28
  /**
29
29
  * Shared Astro config for Sumaq client sites.
30
- * Validates content/*.json against cms/*.yaml before the build emits pages.
30
+ * Validates content/*.json against schema/*.yaml before the build emits pages.
31
31
  */
32
32
  export function defineSumaqSite(opts: SumaqSiteOptions) {
33
33
  return defineConfig({
@@ -41,10 +41,9 @@ export function defineSumaqSite(opts: SumaqSiteOptions) {
41
41
  hooks: {
42
42
  "astro:build:start": async () => {
43
43
  if (opts.skipContentValidation) return;
44
- await validateSiteContent({
45
- cmsDir: "./cms",
46
- contentDir: "./content",
47
- });
44
+ // No arguments: the directory names are the contract, and
45
+ // @sumaq/cms-schema is the only place that decides them.
46
+ await validateSiteContent();
48
47
  },
49
48
  },
50
49
  },
package/src/index.ts CHANGED
@@ -73,3 +73,14 @@ export {
73
73
  export { telHref } from "./lib/format";
74
74
  export { md } from "./lib/markdown";
75
75
  export { cmsKey, hasAny, hasImage, hasItems, hasText, list } from "./lib/content";
76
+ export {
77
+ DEFAULT_WIDTHS,
78
+ MEDIA_PREFIX,
79
+ isMediaPath,
80
+ isSvg,
81
+ mediaKeys,
82
+ mediaMeta,
83
+ mediaUrl,
84
+ resolveMedia,
85
+ } from "./lib/media";
86
+ export type { MediaManifestEntry } from "./lib/media";
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Resolves the `/media/…` strings that live in `content/*.json` to real build
3
+ * assets.
4
+ *
5
+ * The CMS publishes one master image per upload into the **site's** `./media/`
6
+ * directory and writes its repo path into the content JSON. That string is a
7
+ * *logical key*, not a URL: nothing serves `/media/portrait-a3f9c1d2.webp` at
8
+ * runtime. The glob below turns the key into an `ImageMetadata`, and
9
+ * `astro:assets` emits a content-hashed, resized `dist/_astro/…` file for it.
10
+ *
11
+ * The glob is absolute on purpose. Vite resolves absolute glob patterns against
12
+ * the **project root**, so this module — living inside the published package —
13
+ * still scans the consuming site's `./media/`, never the package's own files.
14
+ *
15
+ * A key that does not resolve is not an error here: external URLs
16
+ * (`https://…`) and `public/` paths are legitimate and fall through to a plain
17
+ * `<img>`. Only a `/media/…` key that fails to resolve is broken, and that is
18
+ * what `isMediaPath` is for — `validateSiteContent` turns it into a build
19
+ * failure, and `Media.astro` shows the placeholder in dev.
20
+ */
21
+ import type { ImageMetadata } from "astro";
22
+
23
+ /** Widths the CMS master (2000px) is resized to. Astro clamps to the original. */
24
+ export const DEFAULT_WIDTHS = [640, 960, 1280, 2000];
25
+
26
+ /** The prefix that marks a string as a local key instead of a URL. */
27
+ export const MEDIA_PREFIX = "/media/";
28
+
29
+ /** What `media/manifest.json` stores per image. Written by the CMS on publish. */
30
+ export interface MediaManifestEntry {
31
+ alt?: string;
32
+ width?: number;
33
+ height?: number;
34
+ /** Base64 data URI shown while the real image loads. */
35
+ lqip?: string;
36
+ }
37
+
38
+ /**
39
+ * The pattern must be a literal: Vite rewrites `import.meta.glob` at transform
40
+ * time and silently yields `{}` for a pattern it cannot read statically.
41
+ * Extensions are the ones `astro:assets` can process.
42
+ */
43
+ const assets = import.meta.glob<{ default: ImageMetadata }>(
44
+ "/media/**/*.{webp,avif,png,jpg,jpeg,svg}",
45
+ { eager: true },
46
+ );
47
+
48
+ const manifests = import.meta.glob<Record<string, MediaManifestEntry>>("/media/manifest.json", {
49
+ eager: true,
50
+ import: "default",
51
+ });
52
+
53
+ /** `{}` when the site has no manifest yet — every lookup simply misses. */
54
+ const manifest: Record<string, MediaManifestEntry> = manifests["/media/manifest.json"] ?? {};
55
+
56
+ /** True for strings the site is expected to resolve locally. */
57
+ export function isMediaPath(value: unknown): value is string {
58
+ return typeof value === "string" && value.startsWith(MEDIA_PREFIX);
59
+ }
60
+
61
+ /** The build asset for a `/media/…` key, or `null` when there is none. */
62
+ export function resolveMedia(value: unknown): ImageMetadata | null {
63
+ if (typeof value !== "string" || value === "") return null;
64
+ return assets[value]?.default ?? null;
65
+ }
66
+
67
+ /**
68
+ * The URL a browser should request for a content ref.
69
+ *
70
+ * `resolveMedia` is for rendering with `<Image>`; this is for the places that
71
+ * can only take a string — an `og:image` meta, a `data-src` a script later
72
+ * assigns. Those are exactly where a `/media/…` key would 404 silently, because
73
+ * nothing serves the key itself: it has to become the emitted asset URL.
74
+ * Anything that does not resolve (external URL, `public/` path) passes through.
75
+ */
76
+ export function mediaUrl(value: unknown): string | undefined {
77
+ if (typeof value !== "string" || value === "") return undefined;
78
+ return resolveMedia(value)?.src ?? value;
79
+ }
80
+
81
+ /** Manifest metadata (lqip, dimensions, authored alt) for a `/media/…` key. */
82
+ export function mediaMeta(value: unknown): MediaManifestEntry | null {
83
+ if (typeof value !== "string") return null;
84
+ return manifest[value] ?? null;
85
+ }
86
+
87
+ /** Every `/media/…` key the site ships. Used by tooling, not by rendering. */
88
+ export function mediaKeys(): string[] {
89
+ return Object.keys(assets);
90
+ }
91
+
92
+ /** SVGs skip `<Image>`: Astro turns imported SVGs into components. */
93
+ export function isSvg(asset: ImageMetadata): boolean {
94
+ return asset.format === "svg";
95
+ }
96
+
97
+ /**
98
+ * A site can override the kit's placeholder with its own branding by dropping
99
+ * `media/placeholder.webp` into the repo.
100
+ */
101
+ export const placeholderOverride = resolveMedia("/media/placeholder.webp");
@@ -2,7 +2,7 @@
2
2
  # Extraído de www-therapieraum-graz-at.
3
3
  #
4
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
5
+ # propias en su `schema/page.*.yaml` y pasar sus componentes a `<BlockRenderer
6
6
  # components={{ … }} />`. Ver block-contract.md §7.
7
7
 
8
8
  label: Práctica con sedes y equipo
@@ -2,7 +2,7 @@
2
2
  # Extraído de www-elingan-dev.
3
3
  #
4
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
5
+ # propias en su `schema/page.*.yaml` y pasar sus componentes a `<BlockRenderer
6
6
  # components={{ … }} />`. Ver block-contract.md §7.
7
7
 
8
8
  label: Portfolio profesional
@@ -2,7 +2,7 @@
2
2
  # Extraído de www-utehofer-at, www-bettinageidl-at y www-juliaschaffer-at.
3
3
  #
4
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
5
+ # propias en su `schema/page.*.yaml` y pasar sus componentes a `<BlockRenderer
6
6
  # components={{ … }} />`. Ver block-contract.md §7.
7
7
 
8
8
  label: Consulta de psicoterapia
@@ -269,15 +269,6 @@ const initEnhancements = () => {
269
269
  });
270
270
  }
271
271
 
272
- // 4b. Broken image fallback (team / profile)
273
- document.querySelectorAll("img[src^='media/team/']").forEach((img) => {
274
- img.addEventListener("error", () => {
275
- if (img.dataset.fallbackApplied) return;
276
- img.dataset.fallbackApplied = "1";
277
- img.src = "media/team/profile_placeholder.CZDfc6EN_1FnOqJ.webp";
278
- });
279
- });
280
-
281
272
  // 4c. Gallery lightbox (<Gallery lightbox />) — one viewer per gallery,
282
273
  // keyboard-navigable, restores focus to the trigger on close.
283
274
  document.querySelectorAll("[data-lightbox]").forEach((viewer) => {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "$comment": "Generated by scripts/build-class-inventory.mjs — do not edit by hand. `sq-*` is the kit's namespace; site-local components must use their own prefix.",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "classes": [
5
5
  "sq-about",
6
6
  "sq-about__body",