From 25bcbad0813eeb248c168c440c530144a026d86e Mon Sep 17 00:00:00 2001 From: Alex Dunmow Date: Fri, 10 Jul 2026 10:51:18 +0800 Subject: [PATCH] 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 --- blocks/template.go | 11 ++++ render/blocknote.go | 117 ++++++++++++++++++++++++++++++++++++++- render/blocknote_test.go | 108 ++++++++++++++++++++++++++++++++++++ 3 files changed, 234 insertions(+), 2 deletions(-) diff --git a/blocks/template.go b/blocks/template.go index 0338201..0a69d2f 100644 --- a/blocks/template.go +++ b/blocks/template.go @@ -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 tag representation. diff --git a/render/blocknote.go b/render/blocknote.go index 793bd98..cb4d5ed 100644 --- a/render/blocknote.go +++ b/render/blocknote.go @@ -292,11 +292,87 @@ func renderBlock(ctx context.Context, block map[string]any) string { if alt == "" { alt = caption } - sb.WriteString(`
`) - fmt.Fprintf(&sb, `%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(`%s`, html.EscapeString(url), html.EscapeString(alt)) + if link != "" { + img = fmt.Sprintf(`%s`, 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(`
%s%s
`, wrapClass, img, chip) + } + + fmt.Fprintf(&sb, `
`, figureClass) + sb.WriteString(img) if caption != "" { fmt.Fprintf(&sb, "
%s
", html.EscapeString(caption)) } + if attMode == "below" { + if credit := buildAttributionHTML(att, ""); credit != "" { + fmt.Fprintf(&sb, `
%s
`, credit) + } + } sb.WriteString("
\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(`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(``) + return b.String() +} + +func writeAttributionLink(b *strings.Builder, href, label string) { + if href != "" && (strings.HasPrefix(href, "https://") || strings.HasPrefix(href, "http://")) { + fmt.Fprintf(b, `%s`, + 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 URL. Bare domains, www. and // http:// URLs are intentionally not auto-linked. diff --git a/render/blocknote_test.go b/render/blocknote_test.go index a3dfb75..688ff49 100644 --- a/render/blocknote_test.go +++ b/render/blocknote_test.go @@ -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 := "
\"A
A pic
\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, `