@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 +41 -3
- package/package.json +3 -2
- package/src/assets/placeholder.webp +0 -0
- package/src/blocks/Gallery.astro +2 -1
- package/src/blocks/LocationsOverview.astro +2 -1
- package/src/components/Media.astro +116 -15
- package/src/components/Seo.astro +4 -1
- package/src/config.ts +5 -6
- package/src/index.ts +11 -0
- package/src/lib/media.ts +101 -0
- package/src/profiles/clinic.yaml +1 -1
- package/src/profiles/portfolio.yaml +1 -1
- package/src/profiles/therapist.yaml +1 -1
- package/src/scripts/enhancements.js +0 -9
- package/src/tokens/class-inventory.json +1 -1
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.
|
|
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` |
|
|
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 `
|
|
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.
|
|
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.
|
|
36
|
+
"@sumaq/cms-schema": "0.3.0"
|
|
36
37
|
},
|
|
37
38
|
"devDependencies": {
|
|
38
39
|
"astro": "7.1.4",
|
|
Binary file
|
package/src/blocks/Gallery.astro
CHANGED
|
@@ -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.
|
|
3
|
+
* Image primitive.
|
|
4
4
|
*
|
|
5
|
-
* Accepts
|
|
6
|
-
* - a
|
|
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
|
|
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
|
-
|
|
54
|
-
(
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
<
|
|
70
|
-
src={
|
|
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
|
-
|
|
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
|
+
}
|
package/src/components/Seo.astro
CHANGED
|
@@ -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
|
-
|
|
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 `
|
|
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
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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";
|
package/src/lib/media.ts
ADDED
|
@@ -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");
|
package/src/profiles/clinic.yaml
CHANGED
|
@@ -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 `
|
|
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 `
|
|
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 `
|
|
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) => {
|