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, `