feat(render): image block layout, link, and live media attribution

MediaValue gains an Attribution credit resolved through the existing
MediaResolver context hook. The BlockNote image case renders width and
align presets, alt text, an optional link wrap, and the attribution chip
in chip, chipHover, and below modes with the same themed markup as the
cms page-builder image block. Legacy url-and-caption blocks render
byte-identical to the previous output.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Alex Dunmow 2026-07-10 10:51:18 +08:00
parent 39427c40e7
commit 25bcbad081
3 changed files with 234 additions and 2 deletions

View File

@ -13,6 +13,17 @@ type MediaValue struct {
Filename string // Original filename
Width int // Image width in pixels
Height int // Image height in pixels
// Attribution is the stock-source credit (Pexels import), nil for
// ordinary uploads. JSON tags mirror the CMS media row's attribution JSONB.
Attribution *MediaAttribution
}
// MediaAttribution is the stock-source credit for imported media.
type MediaAttribution struct {
Source string `json:"source"`
SourceURL string `json:"source_url"`
Photographer string `json:"photographer"`
PhotographerURL string `json:"photographer_url"`
}
// String renders the default <img> tag representation.

View File

@ -292,11 +292,87 @@ func renderBlock(ctx context.Context, block map[string]any) string {
if alt == "" {
alt = caption
}
sb.WriteString(`<figure class="my-6">`)
fmt.Fprintf(&sb, `<img src="%s" alt="%s" />`, html.EscapeString(url), html.EscapeString(alt))
link := ""
if l, ok := props["link"].(string); ok {
link = l
}
width := ""
if w, ok := props["width"].(string); ok {
width = w
}
align := ""
if a, ok := props["align"].(string); ok {
align = a
}
attMode := ""
if m, ok := props["attributionMode"].(string); ok {
attMode = m
}
var att *blocks.MediaAttribution
if attMode == "chip" || attMode == "chipHover" || attMode == "below" {
if idStr, ok := props["mediaId"].(string); ok && idStr != "" {
if id, err := uuid.Parse(idStr); err == nil {
if resolver := blocks.GetMediaResolver(ctx); resolver != nil {
if mv := resolver.ResolveMedia(ctx, id); mv != nil {
att = mv.Attribution
}
}
}
}
}
chip := ""
switch attMode {
case "chip":
chip = buildAttributionHTML(att, imageChipClass)
case "chipHover":
chip = buildAttributionHTML(att, imageChipClass+" opacity-0 group-hover:opacity-100 focus-within:opacity-100 transition-opacity")
}
figureClass := "my-6"
switch width {
case "small":
figureClass += " max-w-[400px]"
case "medium":
figureClass += " max-w-[600px]"
case "wide":
figureClass += " max-w-[800px]"
}
if width == "small" || width == "medium" || width == "wide" {
switch align {
case "left":
figureClass += " float-left mr-6 mb-4"
case "right":
figureClass += " float-right ml-6 mb-4"
default:
figureClass += " mx-auto"
}
}
img := fmt.Sprintf(`<img src="%s" alt="%s" />`, html.EscapeString(url), html.EscapeString(alt))
if link != "" {
img = fmt.Sprintf(`<a href="%s">%s</a>`, html.EscapeString(link), img)
}
// The positioning wrapper only exists when a chip is rendered, so
// legacy and chip-less blocks keep today's exact markup.
if chip != "" {
wrapClass := "relative overflow-hidden"
if attMode == "chipHover" {
wrapClass = "group " + wrapClass
}
img = fmt.Sprintf(`<div class="%s">%s%s</div>`, wrapClass, img, chip)
}
fmt.Fprintf(&sb, `<figure class="%s">`, figureClass)
sb.WriteString(img)
if caption != "" {
fmt.Fprintf(&sb, "<figcaption>%s</figcaption>", html.EscapeString(caption))
}
if attMode == "below" {
if credit := buildAttributionHTML(att, ""); credit != "" {
fmt.Fprintf(&sb, `<div class="mt-1 text-xs text-muted-foreground text-center">%s</div>`, credit)
}
}
sb.WriteString("</figure>\n")
case "video":
@ -580,6 +656,43 @@ func renderInlineContent(content []map[string]any, insideLink bool) string {
return sb.String()
}
// imageChipClass matches the page-builder image block's attribution chip
// (backend/blocks/tags via image.ninjatpl in the cms repo) so themes style
// blog and page credits identically.
const imageChipClass = "absolute bottom-2 right-2 z-10 px-2 py-1 rounded-md bg-background/80 backdrop-blur-sm text-xs text-muted-foreground"
// buildAttributionHTML renders the escaped "Photo by NAME on SOURCE" credit,
// or "" when there is no photographer to credit. The "on SOURCE" suffix is
// omitted when no source is set.
func buildAttributionHTML(att *blocks.MediaAttribution, class string) string {
if att == nil || att.Photographer == "" {
return ""
}
var b strings.Builder
b.WriteString(`<span`)
if class != "" {
fmt.Fprintf(&b, ` class="%s"`, html.EscapeString(class))
}
b.WriteString(`>Photo by `)
writeAttributionLink(&b, att.PhotographerURL, att.Photographer)
if att.Source != "" {
sourceLabel := strings.ToUpper(att.Source[:1]) + att.Source[1:]
b.WriteString(` on `)
writeAttributionLink(&b, att.SourceURL, sourceLabel)
}
b.WriteString(`</span>`)
return b.String()
}
func writeAttributionLink(b *strings.Builder, href, label string) {
if href != "" && (strings.HasPrefix(href, "https://") || strings.HasPrefix(href, "http://")) {
fmt.Fprintf(b, `<a href="%s" target="_blank" rel="noopener noreferrer" class="underline">%s</a>`,
html.EscapeString(href), html.EscapeString(label))
return
}
b.WriteString(html.EscapeString(label))
}
// autolinkText escapes plain text for HTML output and wraps any https:// URL
// substring in <a href="..." rel="noopener">URL</a>. Bare domains, www. and
// http:// URLs are intentionally not auto-linked.

View File

@ -672,3 +672,111 @@ func TestUnknownBlockType(t *testing.T) {
t.Errorf("expected fallback rendering of unknown block: %s", html)
}
}
type testMediaResolver struct {
att *blocks.MediaAttribution
}
func (r testMediaResolver) ResolveMedia(_ context.Context, mediaID uuid.UUID) *blocks.MediaValue {
return &blocks.MediaValue{Src: "/media/" + mediaID.String(), Attribution: r.att}
}
func imageBlock(props map[string]any) map[string]any {
return map[string]any{"blocks": []any{map[string]any{"type": "image", "props": props}}}
}
// Legacy blocks (url/caption only) must render byte-identical to the old output.
func TestImageBlockLegacyUnchanged(t *testing.T) {
html := BlockNoteToHTML(context.Background(), imageBlock(map[string]any{
"url": "https://example.com/pic.jpg", "caption": "A pic",
}))
want := "<figure class=\"my-6\"><img src=\"https://example.com/pic.jpg\" alt=\"A pic\" /><figcaption>A pic</figcaption></figure>\n"
if html != want {
t.Fatalf("legacy image output changed:\n got: %s\nwant: %s", html, want)
}
}
func TestImageBlockAltAndLink(t *testing.T) {
html := BlockNoteToHTML(context.Background(), imageBlock(map[string]any{
"url": "/media/x.webp", "alt": "Sunset", "link": "https://example.com/story",
}))
if !strings.Contains(html, `alt="Sunset"`) {
t.Fatalf("alt not rendered: %s", html)
}
if !strings.Contains(html, `<a href="https://example.com/story"><img `) {
t.Fatalf("link wrap not rendered: %s", html)
}
}
func TestImageBlockWidthAndAlign(t *testing.T) {
html := BlockNoteToHTML(context.Background(), imageBlock(map[string]any{
"url": "/media/x.webp", "width": "medium", "align": "right",
}))
if !strings.Contains(html, `max-w-[600px]`) || !strings.Contains(html, `float-right ml-6 mb-4`) {
t.Fatalf("width/align classes missing: %s", html)
}
centered := BlockNoteToHTML(context.Background(), imageBlock(map[string]any{
"url": "/media/x.webp", "width": "small", "align": "center",
}))
if !strings.Contains(centered, `max-w-[400px]`) || !strings.Contains(centered, `mx-auto`) {
t.Fatalf("centered width classes missing: %s", centered)
}
}
func TestImageBlockAttributionModes(t *testing.T) {
mediaID := uuid.MustParse("33333333-3333-3333-3333-333333333333")
att := &blocks.MediaAttribution{
Photographer: "Jane Doe",
PhotographerURL: "https://www.pexels.com/@janedoe",
Source: "pexels",
SourceURL: "https://www.pexels.com/photo/123",
}
ctx := blocks.WithMediaResolver(context.Background(), testMediaResolver{att: att})
props := func(mode string) map[string]any {
return map[string]any{"url": "/media/x.webp", "mediaId": mediaID.String(), "attributionMode": mode}
}
chip := BlockNoteToHTML(ctx, imageBlock(props("chip")))
if !strings.Contains(chip, "Photo by ") || !strings.Contains(chip, "Jane Doe") ||
!strings.Contains(chip, "on ") || !strings.Contains(chip, "Pexels") ||
!strings.Contains(chip, `absolute bottom-2 right-2`) {
t.Fatalf("chip mode wrong: %s", chip)
}
hover := BlockNoteToHTML(ctx, imageBlock(props("chipHover")))
if !strings.Contains(hover, `group-hover:opacity-100`) || !strings.Contains(hover, `class="group relative overflow-hidden"`) {
t.Fatalf("chipHover mode wrong: %s", hover)
}
below := BlockNoteToHTML(ctx, imageBlock(props("below")))
if !strings.Contains(below, `mt-1 text-xs text-muted-foreground text-center`) || !strings.Contains(below, "Jane Doe") {
t.Fatalf("below mode wrong: %s", below)
}
off := BlockNoteToHTML(ctx, imageBlock(map[string]any{"url": "/media/x.webp", "mediaId": mediaID.String()}))
if strings.Contains(off, "Photo by") {
t.Fatalf("default mode must render no credit: %s", off)
}
}
func TestImageBlockAttributionDegradesGracefully(t *testing.T) {
mediaID := uuid.MustParse("44444444-4444-4444-4444-444444444444")
props := imageBlock(map[string]any{"url": "/media/x.webp", "mediaId": mediaID.String(), "attributionMode": "chip"})
// No resolver in ctx.
if html := BlockNoteToHTML(context.Background(), props); strings.Contains(html, "Photo by") {
t.Fatalf("no-resolver ctx must render no credit: %s", html)
}
// Resolver returns media without attribution.
ctx := blocks.WithMediaResolver(context.Background(), testMediaResolver{att: nil})
if html := BlockNoteToHTML(ctx, props); strings.Contains(html, "Photo by") {
t.Fatalf("nil attribution must render no credit: %s", html)
}
// Photographer-only credit omits the "on SOURCE" suffix.
ctx = blocks.WithMediaResolver(context.Background(), testMediaResolver{att: &blocks.MediaAttribution{Photographer: "Jane"}})
html := BlockNoteToHTML(ctx, props)
if !strings.Contains(html, "Photo by ") || strings.Contains(html, " on ") {
t.Fatalf("photographer-only credit wrong: %s", html)
}
}