feat: enhance medialib image handling and add asset URL resolution

- Implemented `resolveApiAssetUrl` function to normalize asset URLs based on API base.
- Updated `MedialibImage` component to utilize new asset URL resolution and added support for alt text and class properties.
- Enhanced image loading behavior with improved width measurement and focal point handling.
- Added placeholder image handling and improved accessibility with alt text.
- Introduced new test script for auditing broken links in skill documentation.
- Expanded seeded test content to include medialib entries and updated related tests for pagebuilder previews.
- Improved global setup and teardown logging for clarity on seeded content management.
This commit is contained in:
2026-05-17 00:52:41 +00:00
parent 958b45272d
commit 4020ad62c5
44 changed files with 4276 additions and 867 deletions
+125 -42
View File
@@ -1,30 +1,36 @@
<script lang="ts">
import { apiBaseURL } from "../config"
import { currentLanguage, DEFAULT_LANGUAGE } from "../lib/i18n"
import { apiBaseOverride } from "../lib/store"
import { resolveApiAssetUrl } from "../lib/utils"
interface Props {
id: string
id?: string
entry?: MedialibEntry | null
filter?: string | null
noPlaceholder?: boolean
alt?: string
caption?: string
showCaption?: boolean
minWidth?: number
widthMultiplier?: number
lazy?: boolean
class?: string
style?: string
}
let {
id,
id = "",
entry = null,
filter = null,
noPlaceholder = false,
alt = "",
caption = "",
showCaption = false,
minWidth = 0,
widthMultiplier = 1,
lazy = false,
class: className = "",
style = "",
}: Props = $props()
@@ -32,15 +38,38 @@
let currentFilter = $state<string>("l-webp")
const effectiveId = $derived(entry?.id || entry?._id || id || "")
const fileSrc = $derived(resolveFileSrc(entry?.file?.src, entry?.id || entry?._id || effectiveId))
const placeholderSrc = $derived(
resolveApiAssetUrl("/assets/img/placeholder-image.svg") || "/assets/img/placeholder-image.svg"
)
// Sync explicit filter prop reactively
$effect(() => {
if (filter) currentFilter = filter
if (filter) {
currentFilter = filter
return
}
if (minWidth) {
currentFilter = getFilterForWidth(minWidth * (widthMultiplier || 1))
}
})
function getAutoFilter(imgWidth: number): string {
const width = minWidth ? Math.max(imgWidth, minWidth) : imgWidth
const effectiveWidth = width * (widthMultiplier || 1)
function getMeasuredWidth(node: HTMLImageElement): number {
const pictureElement = node.closest("picture") as HTMLElement | null
const figureElement = node.closest("figure") as HTMLElement | null
const widthCandidates = [
node.getBoundingClientRect().width,
node.width,
node.parentElement?.getBoundingClientRect().width || 0,
pictureElement?.getBoundingClientRect().width || 0,
figureElement?.getBoundingClientRect().width || 0,
]
const measuredWidth = Math.max(...widthCandidates)
const width = minWidth ? Math.max(measuredWidth, minWidth) : measuredWidth
return width * (widthMultiplier || 1)
}
function getFilterForWidth(imgWidth: number): string {
const effectiveWidth = imgWidth
if (effectiveWidth <= 90) return "xs-webp"
if (effectiveWidth <= 300) return "s-webp"
@@ -50,6 +79,31 @@
return "xxl-webp"
}
function clampFocalPoint(value?: number): number | null {
if (typeof value !== "number" || Number.isNaN(value)) {
return null
}
return Math.min(1, Math.max(0, value))
}
function getFocalPoint(entry: MedialibEntry | null): { x?: number; y?: number } | null {
const fileWithFocalPoint = entry?.file as { focalPoint?: { x?: number; y?: number } } | undefined
return fileWithFocalPoint?.focalPoint || null
}
function getObjectPosition(entry: MedialibEntry | null): string | null {
const focalPoint = getFocalPoint(entry)
const x = clampFocalPoint(focalPoint?.x)
const y = clampFocalPoint(focalPoint?.y)
if (x === null || y === null) {
return "50% 50%"
}
return `${x * 100}% ${y * 100}%`
}
function isRasterImage(entry: MedialibEntry | null): boolean {
if (entry?.file?.type?.match(/^image\/(png|jpe?g|webp)/)) return true
// Fallback: check file extension when MIME type is missing (e.g. public projection)
@@ -59,33 +113,56 @@
function resolveFileSrc(src: string | undefined, entryId: string | undefined): string | null {
if (!src) return null
if (/^(https?:)?\/\//.test(src) || src.startsWith("/")) return src
if (
/^(?:https?:)?\/\//.test(src) ||
src.startsWith("/") ||
src.startsWith("data:") ||
src.startsWith("blob:")
) {
return src
}
if (!entryId) return null
const normalizedApiBase = apiBaseURL.replace(/\/+$/, "")
const normalizedApiBase = ($apiBaseOverride || apiBaseURL).replace(/\/+$/, "")
return `${normalizedApiBase}/medialib/${entryId}/${src.replace(/^\/+/, "")}`
}
// ResizeObserver: only when no explicit filter and raster image
$effect(() => {
const el = imgEl
if (!el || filter || !entry || !isRasterImage(entry)) return
if (typeof ResizeObserver === "undefined") return
let maxObservedWidth = 0
let deferredUpdateFrame: number | null = null
const observer = new ResizeObserver(() => {
const newWidth = el.clientWidth
if (newWidth <= maxObservedWidth) return // only scale up
maxObservedWidth = newWidth
currentFilter = getAutoFilter(newWidth)
const updateFilter = () => {
const width = getMeasuredWidth(el)
if (width <= maxObservedWidth) return
maxObservedWidth = width
currentFilter = getFilterForWidth(width)
}
const observer = new ResizeObserver(updateFilter)
const observedElements = [el, el.parentElement, el.closest("picture"), el.closest("figure")].filter(
(element, index, all) => element && all.indexOf(element) === index
) as Element[]
observedElements.forEach((element) => observer.observe(element))
updateFilter()
deferredUpdateFrame = requestAnimationFrame(() => {
deferredUpdateFrame = null
updateFilter()
})
observer.observe(el)
return () => observer.disconnect()
return () => {
observer.disconnect()
if (deferredUpdateFrame !== null) {
cancelAnimationFrame(deferredUpdateFrame)
}
}
})
function getSrc(src: string | null, entry: MedialibEntry | null): string {
if (!src) return "/assets/img/placeholder-image.svg"
if (!src) return placeholderSrc
if (!isRasterImage(entry)) return src
if (filter) return src + `?filter=${filter}`
return src + `?filter=${currentFilter}`
@@ -97,41 +174,47 @@
return value[lang] || value[DEFAULT_LANGUAGE] || Object.values(value).find((entry) => !!entry) || ""
}
function getAltText(entry: MedialibEntry | null): string {
return alt || resolveLocalizedText(entry?.alt, $currentLanguage)
}
</script>
{#if effectiveId}
{#if entry && fileSrc}
{#if showCaption && caption}
<figure>
<picture>
<img
bind:this={imgEl}
src={getSrc(fileSrc, entry)}
alt={resolveLocalizedText(entry.alt, $currentLanguage)}
data-entry-id={id}
loading={lazy ? "lazy" : undefined}
{style}
/>
</picture>
<figcaption class="mt-2 text-sm text-gray-500 text-center italic">
{@html caption}
</figcaption>
</figure>
{:else}
{#if entry && fileSrc}
{#if showCaption && caption}
<figure>
<picture>
<img
bind:this={imgEl}
src={getSrc(fileSrc, entry)}
alt={resolveLocalizedText(entry.alt, $currentLanguage)}
data-entry-id={id}
alt={getAltText(entry)}
data-entry-id={effectiveId}
class={className}
style:object-position={getObjectPosition(entry)}
loading={lazy ? "lazy" : undefined}
{style}
/>
</picture>
{/if}
{:else if !noPlaceholder}
<figcaption class="mt-2 text-sm text-gray-500 text-center italic">
{@html caption}
</figcaption>
</figure>
{:else}
<picture>
<img src="/assets/img/placeholder-image.svg" alt="not found" data-entry-id={effectiveId} />
<img
bind:this={imgEl}
src={getSrc(fileSrc, entry)}
alt={getAltText(entry)}
data-entry-id={effectiveId}
class={className}
style:object-position={getObjectPosition(entry)}
loading={lazy ? "lazy" : undefined}
{style}
/>
</picture>
{/if}
{:else if !noPlaceholder && (effectiveId || entry)}
<picture>
<img src={placeholderSrc} alt="not found" data-entry-id={effectiveId} class={className} />
</picture>
{/if}