Files
George Cushen 26c336eb6d feat(publications): improve structure + awards/funding/license metadata
Restructure `publication` frontmatter from a flat Markdown string into a
structured object (name, short_name, volume, issue, pages, publisher),
resolved through a new `functions/resolve_publication.html` partial that
also emits a per-page warning pointing legacy sites at `hugoblox migrate
publications`. APA/MLA citation rendering and the publication metadata
row both consume the resolver - no parsing of conventional Markdown
strings at render time.

Add work-level metadata fields surfaced via a new reusable
`components/award_badge.html` partial (variants: pill, card-corner,
inline, row; level-driven icon + colour tokens; pagefind filter):

- `awards[]` - name, level (winner/runner_up/honorable_mention/nominee/
  selected/featured), issuer, date, note, url; skim-row pills above the
  featured image, full row in the metadata grid, corner badges on card
  and slides-gallery views, inline marker on citation views, pill on
  date-title-summary
- `funding[]` - funder + grant + url, rendered as a metadata grid row
- `license` - SPDX-friendly string, metadata grid row
- `peer_reviewed`, `open_access` - status pills, decoupled from
  the publication block so preprints can surface them

Demo content updated in academic-cv (and the same shape in research-lab).
2026-05-27 03:53:21 +01:00

353 lines
15 KiB
HTML

{{ define "main" }}
{{ $pub_type_csl := "" }}
{{ $pub_type_display := "" }}
{{ if .Params.publication_types }}
{{ if reflect.IsSlice .Params.publication_types }}
{{ $pub_type_csl = index .Params.publication_types 0 }}
{{ $pub_type_display = i18n (printf "pub_%s" (strings.Replace $pub_type_csl "-" "_")) | default (strings.Title $pub_type_csl) }}
{{ end }}
{{ end }}
{{/* Page Cover */}}
{{ partial "components/cover.html" . }}
<div class="mx-auto flex max-w-screen-xl">
{{ partial "components/sidebar.html" (dict "context" . "no_sidebar" true) }}
{{ partial "components/toc.html" . }}
<article class="w-full break-words flex min-h-[calc(100vh-var(--navbar-height))] min-w-0 justify-center pb-8 pr-[calc(env(safe-area-inset-right)-1.5rem)]">
<main class="w-full min-w-0 max-w-6xl px-6 pt-4 md:px-12">
{{ if .Params.show_breadcrumb }}
<div class="mb-4">
{{ partial "components/breadcrumb.html" . }}
</div>
{{ end }}
{{/* Add spacing if cover has overlapping icon */}}
{{ $cover := partial "functions/get_cover_image.html" . }}
{{ $has_overlap_icon := false }}
{{ if $cover }}
{{ $icon_config := .Params.cover.icon | default (dict) }}
{{ if or $icon_config.name $icon_config.emoji }}
{{ $icon_position := $icon_config.position | default "overlap" }}
{{ if eq $icon_position "overlap" }}
{{ $has_overlap_icon = true }}
{{ end }}
{{ end }}
{{ end }}
<h1 class="{{ if $has_overlap_icon }}mt-12{{ else }}mt-2{{ end }} text-4xl font-bold tracking-tight text-slate-900 dark:text-slate-100" data-pagefind-meta="title" data-pagefind-body>
{{/* Inline icon before title if specified */}}
{{ if $cover }}
{{ $icon_config := .Params.cover.icon | default (dict) }}
{{ if or $icon_config.name $icon_config.emoji }}
{{ $icon_position := $icon_config.position | default "overlap" }}
{{ if eq $icon_position "inline" }}
<span class="inline-flex items-center gap-3">
{{ if $icon_config.emoji }}
<span class="text-4xl">{{ $icon_config.emoji }}</span>
{{ else if $icon_config.name }}
<span class="w-10 h-10">
{{ partial "components/icon.html" (dict "name" $icon_config.name "attributes" "class=\"w-full h-full\"") }}
</span>
{{ end }}
<span>{{- .Title -}}</span>
</span>
{{ else }}
{{- .Title -}}
{{ end }}
{{ else }}
{{- .Title -}}
{{ end }}
{{ else }}
{{- .Title -}}
{{ end }}
</h1>
{{ $display_date := .Params.event_start | default .Date }}
<div class="mt-4 mb-16">
<div class="text-gray-500 dark:text-gray-300 text-sm flex items-center flex-wrap gap-y-2">
{{- if $display_date | and (.Params.show_date | default true) -}}
<span class="mr-1">{{- (time $display_date) | time.Format (site.Params.hugoblox.locale.date_format | default ":date_long") -}}</span>
{{- if .Params.authors }}<span class="mx-1">·</span>{{ end -}}
{{- end -}}
{{/* Set Alpine.js flag if author notes exist */}}
{{ if isset .Params "author_notes" }}
{{ .Store.Set "has_alpine" true }}
{{ end }}
{{ range $i, $slug := .Params.authors }}
{{ $profile := partial "functions/get_author_profile" $slug }}
{{ $avatar := $profile.avatar }}
{{ if and $i (not $avatar) }}<span class="mr-1">,</span>{{ end }}
<div class="group inline-flex items-center text-current gap-x-1.5 mx-1">
{{ with $avatar }}
{{ $avatar_32 := .Process "Fill 32x32 Center webp" }}
<img src="{{ $avatar_32.RelPermalink }}"
width="16"
height="16"
alt="{{ $profile.title }}"
class="inline-block h-4 w-4 rounded-full border border-current"
loading="lazy" />
{{ end }}
<div>{{ $profile.title }}</div>
</div>
{{/* Add author note tooltip if available */}}
{{- if isset $.Params "author_notes" -}}
{{- with (index $.Params.author_notes $i) -}}
<span class="relative inline-block ml-1" x-data="{ tooltip: false }">
<button
@mouseenter="tooltip = true"
@mouseleave="tooltip = false"
@click="tooltip = !tooltip"
class="author-notes text-primary-600 dark:text-primary-400 hover:text-primary-800 dark:hover:text-primary-200 transition-colors cursor-help"
data-tooltip="{{.}}"
aria-label="{{.}}"
type="button"
>
<svg class="inline-block w-4 h-4" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd"></path>
</svg>
</button>
<div
x-show="tooltip"
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0 transform scale-95"
x-transition:enter-end="opacity-100 transform scale-100"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100 transform scale-100"
x-transition:leave-end="opacity-0 transform scale-95"
@click.away="tooltip = false"
class="absolute z-50 bottom-full left-1/2 transform -translate-x-1/2 mb-2 px-3 py-2 text-sm text-white bg-gray-900 dark:bg-gray-700 rounded-lg shadow-lg whitespace-nowrap"
x-cloak
>
{{.}}
<div class="absolute top-full left-1/2 transform -translate-x-1/2 -mt-1 w-0 h-0 border-l-4 border-r-4 border-t-4 border-transparent border-t-gray-900 dark:border-t-gray-700"></div>
</div>
</span>
{{- end -}}
{{- end -}}
{{ end }}
{{ if ne .Params.reading_time false }}
<span class="mx-1">·</span>
<span class="mx-1">
{{ .ReadingTime }} {{ i18n "minute_read" }}
</span>
{{ end }}
</div>
<div class="mt-3">
{{ partial "page_links_div.html" . }}
</div>
{{/* Awards skim row — visible badges above any featured image */}}
{{ with .Params.awards }}
<div class="mt-4 flex flex-wrap gap-2">
{{ range . }}
{{ partial "components/award_badge.html" (dict "award" . "page" $ "variant" "pill") }}
{{ end }}
</div>
{{ end }}
</div>
{{ $featured := partial "functions/get_featured_image.html" . }}
{{ $image_params := partial "functions/get_page_image_params.html" . }}
{{/* Featured image layout */}}
{{ if and $featured (not $image_params.preview_only) }}
{{/* Fit image within max size. */}}
{{ $image := $featured }}
{{/* Determine image placement. */}}
{{ $placement := $image_params.placement | default 1 }}{{/* Default to full column width. */}}
{{/* Configure responsive processing based on placement */}}
{{ $image_container := "" }}
{{ $responsive_sizes := slice }}
{{ $sizes_attr := "" }}
{{ if eq $placement 2}}
{{ $image_container = "container" }}
{{ $responsive_sizes = slice 480 768 1024 1200 1600 }}
{{ $sizes_attr = "(max-width: 480px) 100vw, (max-width: 768px) 90vw, (max-width: 1200px) 80vw, 1200px" }}
{{else if eq $placement 3}}
{{ $image_container = "container-fluid" }}
{{ $responsive_sizes = slice 768 1024 1366 1920 2560 }}
{{ $sizes_attr = "(max-width: 768px) 100vw, (max-width: 1366px) 90vw, (max-width: 1920px) 80vw, 2560px" }}
{{else}}
{{ $image_container = "article-container" }}
{{ $responsive_sizes = slice 320 480 640 720 960 }}
{{ $sizes_attr = "(max-width: 480px) 100vw, (max-width: 640px) 90vw, (max-width: 720px) 80vw, 720px" }}
{{end}}
{{/*
Image processing gate:
- reflect.IsImageResourceProcessable → false for SVG (not processable by Hugo).
- GIF: processable by Hugo but we skip to preserve animation frames.
*/}}
{{ $canProcess := and (reflect.IsImageResourceProcessable $featured) (ne $featured.MediaType.SubType "gif") }}
{{ if not $canProcess }}
{{/* SVG or GIF: render directly without responsive processing */}}
<div class="article-header {{$image_container}} featured-image-wrapper mt-4 mb-16">
<div style="position: relative">
<img src="{{ $featured.RelPermalink }}"
data-pagefind-meta="image[src]"
alt="{{ with $image_params.alt_text }}{{.}}{{ end }}"
class="featured-image"
fetchpriority="high">
{{ with $image_params.caption }}<span class="article-header-caption">{{ . | markdownify | emojify }}</span>{{ end }}
</div>
</div>
{{ else }}
{{/* Pre-fit image to placement constraints, then apply responsive processing */}}
{{ $fitted_image := $featured }}
{{ if eq $placement 2}}
{{ $fitted_image = $featured.Fit "1200x2500" }}
{{else if eq $placement 3}}
{{ $fitted_image = $featured.Fit "2560x2560" }}
{{else}}
{{ $fitted_image = $featured.Fit "720x2500" }}
{{end}}
{{ $responsive := partial "functions/process_responsive_image.html" (dict
"image" $fitted_image
"mode" "responsive"
"sizes" $responsive_sizes
) }}
{{/* Featured image - use fitted image dimensions for container, responsive for srcset */}}
<div class="article-header {{$image_container}} featured-image-wrapper mt-4 mb-16" style="max-width: {{$fitted_image.Width}}px; max-height: {{$fitted_image.Height}}px;">
<div style="position: relative">
<img srcset="{{ $responsive.srcset }}"
sizes="{{ $sizes_attr }}"
src="{{ $responsive.fallback.RelPermalink }}"
data-pagefind-meta="image[src]"
width="{{ $fitted_image.Width }}"
height="{{ $fitted_image.Height }}"
alt="{{ with $image_params.alt_text }}{{.}}{{ end }}"
class="featured-image"
fetchpriority="high">
{{ with $image_params.caption }}<span class="article-header-caption">{{ . | markdownify | emojify }}</span>{{ end }}
</div>
</div>
{{ end }}
{{end}}
{{/* EVENT / PUBLICATION METADATA */}}
{{ $pub := partial "functions/resolve_publication.html" . }}
{{ if .Params.abstract | or (eq .Type "publications") | or (eq .Type "events") }}
<div class="max-w-prose grid grid-cols-1 md:grid-cols-[200px_auto] gap-4 my-6">
{{ if .Params.abstract }}
<div class="font-bold text-2xl">{{ i18n "abstract" }}</div>
<div>{{ .Params.abstract | markdownify }}</div>
{{ end }}
{{/* If the type is Uncategorized, hide the type. */}}
{{ if $pub_type_display }}
<div class="font-bold text-2xl">{{ i18n "publication_type" }}</div>
<div>
{{ if and .Params.publication_types (gt (len .Params.publication_types) 0) }}
<a href="{{ range first 1 (.GetTerms "publication_types") }}{{.RelPermalink}}{{end}}">
{{ $pub_type_display }}
</a>
{{ end }}
</div>
{{ end }}
{{ if not $pub.is_empty }}
<div class="font-bold text-2xl">{{ i18n "publication" }}</div>
<div>{{ $pub.display | markdownify }}{{ with $pub.publisher }}. {{ . | markdownify }}{{ end }}</div>
{{ end }}
{{/* Work-level flags — independent of whether a venue is set, so preprints can still surface OA/peer-review status. */}}
{{ $flags := slice }}
{{ if eq .Params.peer_reviewed true }}{{ $flags = $flags | append (i18n "peer_reviewed") }}{{ end }}
{{ if eq .Params.open_access true }}{{ $flags = $flags | append (i18n "open_access") }}{{ end }}
{{ with $flags }}
<div class="font-bold text-2xl">{{ i18n "status" | default "Status" }}</div>
<div class="flex flex-wrap gap-2">
{{ range . }}
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-[11px] font-medium bg-emerald-100 text-emerald-900 dark:bg-emerald-900/40 dark:text-emerald-200 ring-1 ring-emerald-500/30">{{ . }}</span>
{{ end }}
</div>
{{ end }}
{{ with .Params.awards }}
<div class="font-bold text-2xl">{{ i18n "awards" }}</div>
<div class="space-y-3">
{{ range . }}
{{ partial "components/award_badge.html" (dict "award" . "page" $ "variant" "row") }}
{{ end }}
</div>
{{ end }}
{{ with .Params.funding }}
<div class="font-bold text-2xl">{{ i18n "funding" }}</div>
<div class="space-y-1">
{{ range . }}
<div>
{{ if .url }}<a href="{{ .url }}" target="_blank" rel="noopener" class="hover:underline">{{ .funder }}</a>{{ else }}{{ .funder }}{{ end }}{{ with .grant }} <span class="text-gray-500 dark:text-gray-400">({{ . }})</span>{{ end }}
</div>
{{ end }}
</div>
{{ end }}
{{ with .Params.license }}
<div class="font-bold text-2xl">{{ i18n "license" }}</div>
<div>{{ . }}</div>
{{ end }}
{{ if eq .Type "events" }}
<div class="font-bold text-2xl">{{ i18n "date" }}</div>
<div>
{{ partial "functions/get_event_dates" . }}
</div>
{{ end }}
{{ if .Params.event_name }}
<div class="font-bold text-2xl">{{ i18n "event" }}</div>
<div>
{{ with .Params.event_url }}<a href="{{ . }}" target="_blank" rel="noopener">{{ end }}
{{ .Params.event_name | markdownify }}
{{ if .Params.event_url }}</a>{{ end }}
</div>
{{ end }}
{{ if .Params.location }}
<div class="font-bold text-2xl">{{ i18n "location" }}</div>
<div>
<p>{{ .Params.location | markdownify }}</p>
{{ if .Params.address }}
<p>{{partial "functions/get_address" (dict "root" . "address" .Params.address) }}</p>
{{end}}
</div>
{{ end }}
</div>
{{ end }}
<div class="prose prose-slate lg:prose-xl dark:prose-invert" data-pagefind-body>
{{ with .Section }}
<span data-pagefind-filter="type" style="display:none;">{{ . }}</span>
{{ end }}
{{ with .Params.categories }}
<span data-pagefind-filter="category" style="display:none;">{{ index . 0 }}</span>
{{ end }}
{{ .Content }}
</div>
{{ partial "components/last-edited.html" . }}
<div class="container mx-auto prose prose-slate lg:prose-xl dark:prose-invert mt-5">
{{ .Scratch.Set "invert_pager" true }}
{{ partial "page_footer" . }}
</div>
</main>
</article>
</div>
{{ end }}