✨ 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:
+10
-1
@@ -10,6 +10,13 @@ Svelte 5 SPA bundled with esbuild and styled with Tailwind CSS 4.
|
||||
- `src/css/` — global styles and Tailwind imports.
|
||||
- `src/routes/` — page-level components (e.g. `NotFound.svelte` for the 404 page). Not file-based routing — these are manually imported by `App.svelte`.
|
||||
|
||||
## Related skills
|
||||
|
||||
- `frontend-architecture` for routing, stores, API behavior, and i18n flow.
|
||||
- `content-authoring` when frontend changes are driven by new collections, blocks, lookup paths, or `types/global.d.ts` changes.
|
||||
- `nova-pagebuilder-modeling` when block components, `BlockRenderer.svelte`, and admin preview contracts must stay aligned.
|
||||
- `media-seo-publishing` when rendering medialib or file fields; prefer `src/widgets/MedialibImage.svelte` as the shared image boundary.
|
||||
|
||||
## Routing
|
||||
|
||||
This project uses a **custom SPA router** (NOT SvelteKit, NOT file-based routing). Pages are CMS content entries loaded dynamically by URL path.
|
||||
@@ -27,10 +34,12 @@ This project uses a **custom SPA router** (NOT SvelteKit, NOT file-based routing
|
||||
- Keep code and comments in English.
|
||||
- SSR safety: guard browser-only code with `typeof window !== "undefined"`.
|
||||
- API behavior: PUT responses return only changed fields; filter by id uses `_id`; API requests reject non-2xx with `{ response, data }` and error payload in `error.data.error`.
|
||||
- Treat public rendering and admin-preview rendering as the same block contract whenever possible.
|
||||
- If blocks or widgets render foreign media or entities, make the required `lookup` paths explicit instead of assuming `_lookup` data is always present.
|
||||
|
||||
## Tailwind CSS
|
||||
|
||||
- Always use canonical Tailwind utility classes instead of arbitrary values when a standard equivalent exists (e.g. `h-16.5` not `h-[66px]`, `min-h-3` not `min-h-[12px]`).
|
||||
- Always use canonical Tailwind utility classes instead of arbitrary values when a standard equivalent exists.
|
||||
- Only use arbitrary values (`[...]`) when no standard utility covers the needed value.
|
||||
|
||||
## i18n
|
||||
|
||||
@@ -165,7 +165,8 @@
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
{ lookup: NAVIGATION_CONTENT_LOOKUP }
|
||||
undefined,
|
||||
NAVIGATION_CONTENT_LOOKUP
|
||||
),
|
||||
getCachedEntries<"navigation">(
|
||||
"navigation",
|
||||
@@ -174,13 +175,14 @@
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
{ lookup: NAVIGATION_CONTENT_LOOKUP }
|
||||
undefined,
|
||||
NAVIGATION_CONTENT_LOOKUP
|
||||
),
|
||||
])
|
||||
headerNav = headerEntries[0] || null
|
||||
footerNav = footerEntries[0] || null
|
||||
|
||||
// Load content for current path
|
||||
// Load content for current path. Limit 1 so SSR tracks content:<id> instead of content:*.
|
||||
const contentEntries = await getCachedEntries<"content">(
|
||||
"content",
|
||||
{
|
||||
@@ -189,10 +191,11 @@
|
||||
active: true,
|
||||
},
|
||||
"sort",
|
||||
1,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
{ lookup: CONTENT_MEDIA_LOOKUP }
|
||||
CONTENT_MEDIA_LOOKUP
|
||||
)
|
||||
|
||||
if (contentEntries.length > 0) {
|
||||
|
||||
+12
-1
@@ -1,4 +1,5 @@
|
||||
import { mount, unmount, type Component, type SvelteComponent } from "svelte"
|
||||
import { apiBaseOverride } from "./lib/store"
|
||||
import BlockRenderer from "./blocks/BlockRenderer.svelte"
|
||||
|
||||
const previewCssUrl = new URL("./index.css", import.meta.url).toString()
|
||||
@@ -86,6 +87,11 @@ function createContentBlockDefinition(presentation: BlockPresentation): BlockDef
|
||||
position: "relative",
|
||||
},
|
||||
render(container, row, context) {
|
||||
const previewApiBase = context?.projectBase || context?.apiBase
|
||||
if (previewApiBase) {
|
||||
apiBaseOverride.set(String(previewApiBase))
|
||||
}
|
||||
|
||||
const target = document.createElement("div")
|
||||
target.dataset.adminPreview = "true"
|
||||
container.appendChild(target)
|
||||
@@ -99,7 +105,12 @@ function createContentBlockDefinition(presentation: BlockPresentation): BlockDef
|
||||
})
|
||||
|
||||
return {
|
||||
update(nextRow) {
|
||||
update(nextRow, nextContext) {
|
||||
const nextPreviewApiBase = nextContext?.projectBase || nextContext?.apiBase
|
||||
if (nextPreviewApiBase) {
|
||||
apiBaseOverride.set(String(nextPreviewApiBase))
|
||||
}
|
||||
|
||||
unmount(mountedComponent)
|
||||
target.innerHTML = ""
|
||||
mountedComponent = mount(BlockRenderer as Component<any>, {
|
||||
|
||||
+17
-10
@@ -137,13 +137,15 @@ const CACHE_TTL = 1000 * 60 * 60 // 1 hour
|
||||
// Generic collection helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type CollectionNameT = "medialib" | "content" | string
|
||||
type CollectionNameT = "medialib" | "content" | "navigation" | string
|
||||
|
||||
type EntryTypeSwitch<T extends string> = T extends "medialib"
|
||||
? MedialibEntry
|
||||
: T extends "content"
|
||||
? ContentEntry
|
||||
: Record<string, unknown>
|
||||
: T extends "navigation"
|
||||
? NavigationEntry
|
||||
: Record<string, unknown>
|
||||
|
||||
export async function getDBEntries<T extends CollectionNameT>(
|
||||
collectionName: T,
|
||||
@@ -152,7 +154,8 @@ export async function getDBEntries<T extends CollectionNameT>(
|
||||
limit?: number,
|
||||
offset?: number,
|
||||
projection?: string,
|
||||
params?: Record<string, string>
|
||||
params?: Record<string, string>,
|
||||
lookup?: string
|
||||
): Promise<EntryTypeSwitch<T>[]> {
|
||||
const c = await api<EntryTypeSwitch<T>[]>(collectionName, {
|
||||
filter,
|
||||
@@ -161,6 +164,7 @@ export async function getDBEntries<T extends CollectionNameT>(
|
||||
offset,
|
||||
projection,
|
||||
params,
|
||||
lookup,
|
||||
})
|
||||
return c.data
|
||||
}
|
||||
@@ -172,13 +176,14 @@ export async function getCachedEntries<T extends CollectionNameT>(
|
||||
limit?: number,
|
||||
offset?: number,
|
||||
projection?: string,
|
||||
params?: Record<string, string>
|
||||
params?: Record<string, string>,
|
||||
lookup?: string
|
||||
): Promise<EntryTypeSwitch<T>[]> {
|
||||
const filterStr = obj2str({ collectionName, filter, sort, limit, offset, projection, params })
|
||||
const filterStr = obj2str({ collectionName, filter, sort, limit, offset, projection, params, lookup })
|
||||
if (cache[filterStr] && cache[filterStr].expire >= Date.now()) {
|
||||
return cache[filterStr].data as EntryTypeSwitch<T>[]
|
||||
}
|
||||
const entries = await getDBEntries<T>(collectionName, filter, sort, limit, offset, projection, params)
|
||||
const entries = await getDBEntries<T>(collectionName, filter, sort, limit, offset, projection, params, lookup)
|
||||
cache[filterStr] = { expire: Date.now() + CACHE_TTL, data: entries }
|
||||
return entries
|
||||
}
|
||||
@@ -187,18 +192,20 @@ export async function getDBEntry<T extends CollectionNameT>(
|
||||
collectionName: T,
|
||||
filter: MongoFilter,
|
||||
projection?: string,
|
||||
params?: Record<string, string>
|
||||
params?: Record<string, string>,
|
||||
lookup?: string
|
||||
): Promise<EntryTypeSwitch<T> | undefined> {
|
||||
return (await getDBEntries<T>(collectionName, filter, "_id", 1, undefined, projection, params))?.[0]
|
||||
return (await getDBEntries<T>(collectionName, filter, "_id", 1, undefined, projection, params, lookup))?.[0]
|
||||
}
|
||||
|
||||
export async function getCachedEntry<T extends CollectionNameT>(
|
||||
collectionName: T,
|
||||
filter: MongoFilter,
|
||||
projection?: string,
|
||||
params?: Record<string, string>
|
||||
params?: Record<string, string>,
|
||||
lookup?: string
|
||||
): Promise<EntryTypeSwitch<T> | undefined> {
|
||||
return (await getCachedEntries<T>(collectionName, filter, "_id", 1, undefined, projection, params))?.[0]
|
||||
return (await getCachedEntries<T>(collectionName, filter, "_id", 1, undefined, projection, params, lookup))?.[0]
|
||||
}
|
||||
|
||||
export async function postDBEntry<T extends CollectionNameT>(
|
||||
|
||||
@@ -1,3 +1,32 @@
|
||||
import { get } from "svelte/store"
|
||||
import { apiBaseURL } from "../config"
|
||||
import { apiBaseOverride } from "./store"
|
||||
|
||||
function isAbsoluteUrl(url: string | undefined): boolean {
|
||||
return !!url && (/^(?:https?:)?\/\//.test(url) || url.startsWith("data:") || url.startsWith("blob:"))
|
||||
}
|
||||
|
||||
function normalizeBase(base: string | null | undefined): string | null {
|
||||
return base ? base.replace(/\/+$/, "") + "/" : null
|
||||
}
|
||||
|
||||
export function resolveApiAssetUrl(
|
||||
url: string | null | undefined,
|
||||
apiBase: string | null | undefined = get(apiBaseOverride) || apiBaseURL
|
||||
): string | null | undefined {
|
||||
if (!url || isAbsoluteUrl(url) || !url.startsWith("/assets/")) {
|
||||
return url
|
||||
}
|
||||
|
||||
const normalizedApiBase = normalizeBase(apiBase)
|
||||
|
||||
if (!normalizedApiBase) {
|
||||
return url
|
||||
}
|
||||
|
||||
return normalizedApiBase + "_/assets/" + url.replace(/^\/+/, "")
|
||||
}
|
||||
|
||||
export function debounce<T extends (...args: never[]) => void>(
|
||||
func: T,
|
||||
wait: number
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user