feat: add Dev HUD & 15 Obsidian callouts; more collection filters

Dev tools
- Add HBX Dev HUD (floating button + panel) with live log viewer
- Add get-build-id helper to dedupe logs per build (during `hugo server` refreshes)

Callouts
- Implement Obsidian-compatible callouts (15+ types) with shared renderer
- Add i18n title translations (en, es, fr) for all callout types
- Deprecate `callout` shortcode with suppressible warning; keep working
- Migrate starters to Markdown callouts (>[!TYPE]) and update docs

Logging
- Centralize logging in partial `functions/logger` with info/warn/error levels
- Use fmt.Warnidf for suppressible warnings (consistent IDs)
- Only show info in Dev HUD/JSON (no longer use warnf for info logging); warn/error still printed in CLI
- Include source metadata and build ID on each entry

Blocks – Collection
- Add support for `content.filters.kinds`
- Add `content.filters.tags` (array) alongside existing `filters.tag` (string)

Build/Tailwind/JS
- Fix missing Tailwind v4 styles in Hugo render: include preact-built assets, extract classes from JSX/TSX to `@sources`
- Load Preact client per block, publish source maps in dev; on-demand Alpine

CI
- Re-implement fast fails with panicOnWarning now that info logging as warnf removed
- Fix package splitter change detection: strip trailing slashes; collapse paths to starter roots

Starters/Docs
- Restructure courses under `courses/hugo-blox/`
- Update menus and landing pages
- Replace deprecated callout usage in content and docs
- Replace missing hero icon `download` with `arrow-down-tray` in academic-cv guide

Icons
- Improve icon resolver: default pack inference, better fallback, and precise missing-icon warnings (with page ref)
This commit is contained in:
George Cushen committed 2025-09-25 01:58:32 +01:00
1 parent 96f96636fc
commit 787ac94b5d
55 files changed
+1617 -543

No files matched your search

@@ -16,10 +16,13 @@
{{ end }}
{{/* Query */}}
{{ $query := site.RegularPages }}
{{ $query := site.Pages }}
{{ $archive_page := "" }}
{{/* Filters */}}
{{ $kinds := $block.content.filters.kinds | default (slice "page") }}
{{ $query = where $query "Kind" "in" $kinds }}
{{ if $block.content.page_type }}
{{ $query = where $query "Type" $block.content.page_type }}
{{ $archive_page = site.GetPage "Section" $block.content.page_type }}
@@ -31,6 +34,20 @@
{{ $main_folder := index $folders 0 }}
{{ $archive_page = site.GetPage "Section" $main_folder }}
{{ end }}
{{ if $block.content.filters.tags }}
{{ $tags := $block.content.filters.tags }}
{{ $selected := slice }}
{{ range $tags }}
{{ $tag_page := site.GetPage (printf "tags/%s" (urlize .)) }}
{{ if $tag_page }}
{{ $selected = $selected | union $tag_page.Pages }}
{{ end }}
{{ end }}
{{ if gt (len $selected) 0 }}
{{ $query = $query | intersect $selected }}
{{ $archive_page = site.GetPage (printf "tags/%s" (urlize (index $tags 0))) }}
{{ end }}
{{ end }}
{{ if $block.content.filters.tag }}
{{ $archive_page = site.GetPage (printf "tags/%s" (urlize $block.content.filters.tag)) }}
{{ $query = $query | intersect $archive_page.Pages }}
+36 -3
View File
@@ -26,19 +26,52 @@
- id: related
translation: Related
# Callouts
# Callouts - All Obsidian-compatible types
- id: callout_note
translation: Note
- id: callout_abstract
translation: Abstract
- id: callout_summary
translation: Summary
- id: callout_info
translation: Info
- id: callout_todo
translation: Todo
- id: callout_tip
translation: Tip
- id: callout_important
translation: Important
- id: callout_success
translation: Success
- id: callout_question
translation: Question
- id: callout_warning
translation: Warning
- id: callout_failure
translation: Failure
- id: callout_danger
translation: Danger
- id: callout_bug
translation: Bug
- id: callout_example
translation: Example
- id: callout_quote
translation: Quote
- id: callout_important
translation: Important
- id: callout_caution
translation: Caution
+36 -3
View File
@@ -26,19 +26,52 @@
- id: related
translation: Relacionado
# Callouts
# Callouts - All Obsidian-compatible types
- id: callout_note
translation: Nota
- id: callout_abstract
translation: Resumen
- id: callout_summary
translation: Resumen
- id: callout_info
translation: Info
- id: callout_todo
translation: Pendiente
- id: callout_tip
translation: Consejo
- id: callout_important
translation: Importante
- id: callout_success
translation: Éxito
- id: callout_question
translation: Pregunta
- id: callout_warning
translation: Advertencia
- id: callout_failure
translation: Fallo
- id: callout_danger
translation: Peligro
- id: callout_bug
translation: Error
- id: callout_example
translation: Ejemplo
- id: callout_quote
translation: Cita
- id: callout_important
translation: Importante
- id: callout_caution
translation: Precaución
+36 -3
View File
@@ -26,19 +26,52 @@
- id: related
translation: Sur le même sujet
# Callouts
# Callouts - All Obsidian-compatible types
- id: callout_note
translation: Note
- id: callout_abstract
translation: Résumé
- id: callout_summary
translation: Résumé
- id: callout_info
translation: Info
- id: callout_todo
translation: À faire
- id: callout_tip
translation: Conseil
- id: callout_important
translation: Important
- id: callout_success
translation: Succès
- id: callout_question
translation: Question
- id: callout_warning
translation: Attention
- id: callout_failure
translation: Échec
- id: callout_danger
translation: Danger
- id: callout_bug
translation: Bug
- id: callout_example
translation: Exemple
- id: callout_quote
translation: Citation
- id: callout_important
translation: Important
- id: callout_caution
translation: Prudence
@@ -0,0 +1,5 @@
{{/*
Get Build ID - Returns a consistent build identifier for the current build cycle.
This is cached across the entire build to prevent log duplication during dev rebuilds.
*/}}
{{- printf "%d" now.Unix -}}
@@ -20,10 +20,33 @@
{{ with index (index site.Data.icons.hb "icons") $name -}}
{{ $icon = . }}
{{ else }}
{{ with resources.GetMatch (path.Join "media" "icons" $pack (printf "%s.svg" $name)) }}
{{- $prefix := printf "media/icons/%s/" $pack -}}
{{- $icon_path := printf "%s%s.svg" $prefix $name -}}
{{- if not (fileExists (printf "assets/%s" $icon_path)) -}}
{{ $pageRef := "unknown location" }}
{{ if .Page }}
{{ if .Page.File }}
{{ if .Page.File.Path }}
{{ $pageRef = .Page.File.Path }}
{{ else if .Page.RelPermalink }}
{{ $pageRef = .Page.RelPermalink }}
{{ end }}
{{ else if .Page.RelPermalink }}
{{ $pageRef = .Page.RelPermalink }}
{{ end }}
{{ end }}
{{ partial "functions/logger" (dict
"page" .Page
"level" "warn"
"id" (printf "missing-icon-%s-%s" $pack $name)
"message" (printf "The icon `%s` was not found in `assets/media/icons/%s/`. Referenced in: %s" $name $pack $pageRef)
)
}}
{{- end -}}
{{- $icon := resources.Get $icon_path -}}
{{ if $icon }}
{{ $icon = .Content }}
{{else}}
{{ partial "functions/logger" (dict "page" $.Page "level" "warn" "message" (printf "The icon `%s.svg` was not found in your `assets/media/icons/%s/` folder" $name $pack)) }}
{{ else }}
{{ $icon = index (index site.Data.icons.brands "icons") "hugo" }}
{{ end }}
{{ end }}
@@ -1,51 +1,51 @@
{{/* Hugo Blox Logger: centralized logging helper
Usage: partial "functions/logger" (dict "page" . "level" "warn" "message" (printf "..." args...))
- level: "warn" (default) or "error"
- page: optional; current page. Defaults to dot. Used to detect adapter pages
- adapter: optional bool to force adapter-safe mode (e.g., from _content.gotmpl)
*/}}
{{- $level := lower (.level | default "warn") -}}
{{- $msg := .message | default "" -}}
{{- $page := .page | default . -}}
{{- $forceAdapter := .adapter | default false -}}
{{- $isDev := or hugo.IsServer (eq hugo.Environment "development") -}}
{{/*
Hugo Blox Logger: A pragmatic, centralized logging helper for Hugo.
{{/* Detect adapter-generated pages via explicit flag or page param */}}
{{- $isAdapter := $forceAdapter -}}
{{- if not $isAdapter -}}
{{- with $page -}}
{{- with .Params -}}
{{- if or (index . "hb_adapter") (eq (printf "%T" .) "map[string]interface {}" | and (index . "hb_adapter")) -}}
{{- $isAdapter = true -}}
{{- end -}}
This partial is the single source of truth for all build-time logging.
Hugo's template logging is limited to WARN and ERROR. INFO is only shown in the Dev HUD/JSON, not the CLI.
Usage:
{{ partial "functions/logger" (dict
"page" .
"level" "info" | "warn" | "error" (default: "info")
"message" "Your log message here."
"id" "a-unique-id" (optional, for suppressible warnings)
"source" (dict "type" "shortcode|partial|template" "name" "callout|sections" ) (optional)
) }}
*/}}
{{- $level := lower (.level | default "info") -}}
{{- $message := .message | default "" -}}
{{- $id := .id | default "" -}}
{{- $source := .source | default dict -}}
{{- $page := .page | default . -}}
{{- $isDev := hugo.IsServer -}}
{{/* Store log entry for future debug widget/JSON, regardless of environment. */}}
{{- with $page -}}
{{- with .Store -}}
{{- $buildId := partialCached "functions/get-build-id.html" "hbx-build-id" -}}
{{- $log_entry := dict "level" $level "message" $message "page" ($.RelPermalink | default "/") "timestamp" now.Unix "id" $id "source" $source "buildId" $buildId -}}
{{- $current_logs := .Get "hbx_logs" | default slice -}}
{{- $currentBuildId := .Get "hbx_build_id" | default "" -}}
{{/* Clear logs if this is a new build cycle */}}
{{- if ne $currentBuildId $buildId -}}
{{- .Set "hbx_build_id" $buildId -}}
{{- $current_logs = slice -}}
{{- end -}}
{{- .Set "hbx_logs" ($current_logs | append $log_entry) -}}
{{- end -}}
{{- end -}}
{{/* Store log entry for future debug widget */}}
{{- with $page -}}
{{- $log_entry := dict "level" $level "message" $msg "page" ($.RelPermalink | default "/") "timestamp" now.Unix -}}
{{- $current_logs := .Store.Get "hbx_logs" | default slice -}}
{{- .Store.Set "hbx_logs" ($current_logs | append $log_entry) -}}
{{- end -}}
{{/* Multi-tier logging output */}}
{{- if $isDev -}}
{{/* Console logging for immediate dev feedback */}}
<script>console.log('[HBX:{{ $level }}]', {{ $msg | jsonify }});</script>
{{- end -}}
{{/* Emit to CLI using fmt with proper severity. Do not print INFO to CLI. */}}
{{- if eq $level "error" -}}
{{- if not $isAdapter -}}
{{- errorf "%s" $msg -}}
{{- fmt.Errorf "HBX ERROR: %s" $message -}}
{{- else if eq $level "warn" -}}
{{- if $id -}}
{{- fmt.Warnidf $id "HBX WARN: %s" $message -}}
{{- else -}}
{{- if $isDev -}}<!-- HBX log:error: {{ $msg }} -->{{- end -}}
{{- fmt.Warnf "HBX WARN: %s" $message -}}
{{- end -}}
{{- else -}}
{{- if not $isAdapter -}}
{{- warnf "%s" $msg -}}
{{- else -}}
{{- if $isDev -}}<!-- HBX log:warn: {{ $msg }} -->{{- end -}}
{{- end -}}
{{/* info: no CLI output; visible via Dev HUD/JSON only */}}
{{- end -}}
{{- "" -}}
@@ -0,0 +1,155 @@
{{/*
Hugo Blox Builder Shared Callout Renderer
Renders callouts with consistent styling for both shortcodes and render hooks.
Parameters:
- type: The callout type (note, tip, important, warning, caution, or custom icon name)
- content: The callout content (HTML)
- title: Optional title for the callout
- source: "shortcode" or "renderhook" for context
*/}}
{{ $type := (lower .type) | default "note" }}
{{ $content := .content }}
{{ $title := .title }}
{{ if or (not $title) (eq $title "") }}
{{/* Try i18n translation with "callout_" prefix, fall back to title case */}}
{{ $i18n_key := printf "callout_%s" $type }}
{{ $title = or (i18n $i18n_key) (title $type) }}
{{ end }}
{{ $source := .source | default "shortcode" }}
{{/* Define icon and styling mappings for all Obsidian callout types */}}
{{ $alertConfigs := dict
"note" (dict
"icon" "pencil"
"class" "bg-blue-100 dark:bg-blue-900"
"textClass" "text-blue-600 dark:text-blue-300"
"borderClass" "border-blue-500"
)
"abstract" (dict
"icon" "clipboard-document-list"
"class" "bg-cyan-100 dark:bg-cyan-900"
"textClass" "text-cyan-600 dark:text-cyan-300"
"borderClass" "border-cyan-500"
)
"summary" (dict
"icon" "clipboard-document-list"
"class" "bg-cyan-100 dark:bg-cyan-900"
"textClass" "text-cyan-600 dark:text-cyan-300"
"borderClass" "border-cyan-500"
)
"info" (dict
"icon" "information-circle"
"class" "bg-blue-100 dark:bg-blue-900"
"textClass" "text-blue-600 dark:text-blue-300"
"borderClass" "border-blue-500"
)
"todo" (dict
"icon" "check-circle"
"class" "bg-blue-100 dark:bg-blue-900"
"textClass" "text-blue-600 dark:text-blue-300"
"borderClass" "border-blue-500"
)
"tip" (dict
"icon" "light-bulb"
"class" "bg-emerald-100 dark:bg-emerald-900"
"textClass" "text-emerald-600 dark:text-emerald-300"
"borderClass" "border-emerald-500"
)
"success" (dict
"icon" "check-circle"
"class" "bg-green-100 dark:bg-green-900"
"textClass" "text-green-600 dark:text-green-300"
"borderClass" "border-green-500"
)
"question" (dict
"icon" "question-mark-circle"
"class" "bg-yellow-100 dark:bg-yellow-900"
"textClass" "text-yellow-700 dark:text-yellow-300"
"borderClass" "border-yellow-500"
)
"warning" (dict
"icon" "exclamation-triangle"
"class" "bg-orange-100 dark:bg-orange-900"
"textClass" "text-orange-600 dark:text-orange-300"
"borderClass" "border-orange-500"
)
"failure" (dict
"icon" "x-circle"
"class" "bg-red-100 dark:bg-red-900"
"textClass" "text-red-600 dark:text-red-300"
"borderClass" "border-red-500"
)
"danger" (dict
"icon" "exclamation-triangle"
"class" "bg-red-100 dark:bg-red-900"
"textClass" "text-red-600 dark:text-red-300"
"borderClass" "border-red-500"
)
"bug" (dict
"icon" "bug-ant"
"class" "bg-red-100 dark:bg-red-900"
"textClass" "text-red-600 dark:text-red-300"
"borderClass" "border-red-500"
)
"example" (dict
"icon" "beaker"
"class" "bg-purple-100 dark:bg-purple-900"
"textClass" "text-purple-600 dark:text-purple-300"
"borderClass" "border-purple-500"
)
"quote" (dict
"icon" "chat-bubble-left-right"
"class" "bg-gray-100 dark:bg-gray-800"
"textClass" "text-gray-600 dark:text-gray-300"
"borderClass" "border-gray-500"
)
"important" (dict
"icon" "exclamation-circle"
"class" "bg-purple-100 dark:bg-purple-900"
"textClass" "text-purple-600 dark:text-purple-300"
"borderClass" "border-purple-500"
)
"caution" (dict
"icon" "exclamation-triangle"
"class" "bg-red-100 dark:bg-red-900"
"textClass" "text-red-600 dark:text-red-300"
"borderClass" "border-red-500"
)
}}
{{/* Get configuration for this callout type, or use fallback */}}
{{ $config := index $alertConfigs $type }}
{{ $icon := $type }}
{{ $class := "bg-blue-100 dark:bg-blue-900" }}
{{ $textClass := "text-blue-600 dark:text-blue-300" }}
{{ $borderClass := "border-blue-500" }}
{{ if $config }}
{{ $icon = $config.icon }}
{{ $class = $config.class }}
{{ $textClass = $config.textClass }}
{{ $borderClass = $config.borderClass }}
{{ end }}
<div class="callout flex px-4 py-3 mb-6 rounded-md border-l-4 {{ $class }} {{ $borderClass }}"
data-callout="{{ $type }}"
data-callout-metadata="">
<span class="callout-icon pr-3 pt-1 {{ $textClass }}">
{{ partial "functions/get_icon" (dict "name" $icon "attributes" "height=\"24\"") }}
</span>
<div class="callout-content dark:text-neutral-300">
{{/* Display title if provided */}}
{{- with $title -}}
<div class="callout-title font-semibold mb-1">
{{- . -}}
</div>
{{- end -}}
{{/* Display content */}}
<div class="callout-body">
{{- $content -}}
</div>
</div>
</div>
@@ -31,7 +31,7 @@
{{- end -}}
{{- $source = printf "data/pages/%s.yaml (sections_source)" $key -}}
{{- else -}}
{{- partial "functions/logger" (dict "page" . "level" "warn" "message" (printf "HBX: sections_source '%s' not found under data/pages/" $key)) -}}
{{- partial "functions/logger" (dict "page" . "level" "warn" "id" (printf "sections-source-missing-%s" $key) "message" (printf "sections_source '%s' not found under data/pages/" $key) "source" (dict "type" "partial" "name" "hbx/sections")) -}}
{{- end -}}
{{- end -}}
@@ -89,7 +89,7 @@
{{- end -}}
{{- $sec = $merged -}}
{{- else -}}
{{- partial "functions/logger" (dict "page" $ "level" "warn" "message" (printf "HBX: section ref '%s' not found under data/blocks/" $ref)) -}}
{{- partial "functions/logger" (dict "page" $ "level" "warn" "id" (printf "section-ref-missing-%s" $ref) "message" (printf "HBX: section ref '%s' not found under data/blocks/" $ref) "source" (dict "type" "partial" "name" "hbx/sections")) -}}
{{- end -}}
{{- end -}}
{{- $resolved = $resolved | append $sec -}}
@@ -98,8 +98,8 @@
{{- end -}}
{{- if $sections -}}
{{- partial "functions/logger" (dict "page" . "level" "warn" "message" (printf "HBX: Using sections from %s for page %s (found %d sections)" $source $.RelPermalink (len $sections))) -}}
{{- partial "functions/logger" (dict "page" . "level" "info" "message" (printf "✅ Using sections from %s for page %s (found %d sections)" $source $.RelPermalink (len $sections)) "source" (dict "type" "partial" "name" "hbx/sections")) -}}
{{- else -}}
{{- partial "functions/logger" (dict "page" . "level" "warn" "message" (printf "HBX: No sections found for page %s (checked %s)" $.RelPermalink $source)) -}}
{{- partial "functions/logger" (dict "page" . "level" "warn" "id" (printf "sections-none-%s" $.RelPermalink) "message" (printf "HBX: No sections found for page %s (checked %s)" $.RelPermalink $source) "source" (dict "type" "partial" "name" "hbx/sections")) -}}
{{- end -}}
{{- return $sections -}}
@@ -1,42 +1,67 @@
{{/* Hugo Blox: Export Debug Logs for Future Debug Widget */}}
{{/* This hook collects all HBX logs and exports them to a JSON file for tooling */}}
{{- if hugo.IsServer -}}
{{/* Collect all logs from all pages */}}
{{- $all_logs := slice -}}
{{/* Hugo Blox: Export Debug Logs for Dev HUD (window var) */}}
{{- if and hugo.IsServer (site.Params.hbx.log.export | default true) -}}
{{- /* Collect all logs from all pages (site-wide) */ -}}
{{- $entries := slice -}}
{{- range .Site.Pages -}}
{{- $page_logs := .Store.Get "hbx_logs" | default slice -}}
{{- $all_logs = $all_logs | append $page_logs -}}
{{- with .Store -}}
{{- with .Get "hbx_logs" -}}
{{- $entries = $entries | append . -}}
{{- end -}}
{{- end -}}
{{- end -}}
{{- if $all_logs -}}
{{/* Create debug export as a resource for tooling */}}
{{- $debug_data := dict "logs" $all_logs "build_time" now.Unix "hugo_version" hugo.Version -}}
{{- $debug_json := $debug_data | jsonify -}}
{{- $debug_resource := resources.FromString "hbx-debug.json" $debug_json -}}
{{- $debug_resource.Publish -}}
{{/* Also emit to console for immediate visibility */}}
<script>
if (window.console && console.groupCollapsed) {
console.groupCollapsed('[HBX] Build completed with {{ len $all_logs }} log entries');
console.log('Debug data available at:', '/hbx-debug.json');
{{ range $all_logs }}
{{ if . }}
{{ if reflect.IsMap . }}
console.log(
'[HBX:{{ if .level }}{{ .level }}{{ else }}info{{ end }}]',
{{ if .message }}{{ .message | jsonify }}{{ else }}"No message"{{ end }},
'page:',
{{ if .page }}{{ .page | jsonify }}{{ else }}"unknown"{{ end }}
);
{{ else }}
console.log('[HBX:info]', "Log entry with unexpected format", {{ . | jsonify }});
{{ end }}
{{ end }}
{{ end }}
console.groupEnd();
}
</script>
{{- /* Summarize dependencies */ -}}
{{- $deps := slice -}}
{{- range $i, $d := hugo.Deps -}}
{{- $dep := dict "path" $d.Path "version" $d.Version "vendor" $d.Vendor -}}
{{- with $d.Owner -}}
{{- $dep = merge $dep (dict "owner_path" .Path) -}}
{{- end -}}
{{- with $d.Replace -}}
{{- $dep = merge $dep (dict "replace_path" .Path) -}}
{{- end -}}
{{- with $d.Time -}}
{{- $dep = merge $dep (dict "time" (printf "%v" .)) -}}
{{- end -}}
{{- $deps = $deps | append $dep -}}
{{- end -}}
{{- /* Detect blox-tailwind module version if present */ -}}
{{- $bloxTW := dict -}}
{{- range $deps -}}
{{- $p := .path -}}
{{- if and $p (in $p "blox-tailwind") -}}
{{- $bloxTW = dict "path" .path "version" .version -}}
{{- end -}}
{{- end -}}
{{- /* Current page information */ -}}
{{- $filePath := "" -}}
{{- with .File -}}
{{- $filePath = .Path -}}
{{- end -}}
{{- $buildId := partialCached "functions/get-build-id.html" "hbx-build-id" -}}
{{- $currentPage := dict
"path" .RelPermalink
"type" .Type
"layout" (.Layout | default "")
"kind" .Kind
"section" .Section
"title" .Title
"file" $filePath
"buildId" $buildId
-}}
{{- $payload := dict
"version" 1
"generatedAt" (now.UTC.Format "2006-01-02T15:04:05Z07:00")
"hugo" (dict "version" hugo.Version "goVersion" hugo.GoVersion)
"deps" $deps
"modules" (dict "bloxTailwind" $bloxTW)
"entries" $entries
"currentPage" $currentPage
-}}
<script>
window.__HBX_LOGS__ = {{ $payload | jsonify | safeJS }};
</script>
{{- end -}}
@@ -0,0 +1,298 @@
{{/* Hugo Blox: Dev HUD (floating button + panel). Dev only. */}}
{{- if hugo.IsServer -}}
<script>
(function(){
try {
const PLACEMENT = {{ (site.Params.hbx.log.placement | default "bottom-left") | jsonify }};
const OPACITY = {{ (site.Params.hbx.log.opacity | default 1.0) | jsonify }};
const root = document.createElement('div');
root.id = 'hbx-dev-hud-root';
root.style.all = 'initial';
root.style.position = 'fixed';
root.style.zIndex = '2147483647';
root.style.pointerEvents = 'none';
root.style.bottom = '16px';
if (PLACEMENT === 'bottom-right') root.style.right = '16px';
else if (PLACEMENT === 'bottom-center') { root.style.left = '50%'; root.style.transform = 'translateX(-50%)'; }
else root.style.left = '16px';
const shadow = root.attachShadow({ mode: 'open' });
const style = document.createElement('style');
style.textContent = `
:host { all: initial; }
.btn { pointer-events: auto; position:relative; display:inline-flex; align-items:center; justify-content:center; width:44px; height:44px; border-radius:9999px; background:#111418; color:#e6eaf0; border:1px solid #2a2f38; box-shadow: 0 10px 20px rgba(0,0,0,.25); cursor:pointer; }
.btn:hover { filter: brightness(1.1); }
.badge { position:absolute; top:-4px; right:-4px; min-width:18px; height:18px; padding:0 4px; border-radius:9999px; background:#ef4444; color:white; font: 600 11px/18px system-ui, sans-serif; text-align:center; }
.panel { pointer-events:auto; position:fixed; inset:auto auto 72px 16px; width:min(92vw, 1200px); max-height:min(80vh, 800px); background:#0d1117; color:#e6eaf0; border:1px solid #21262d; border-radius:12px; box-shadow: 0 20px 40px rgba(0,0,0,.4); overflow:hidden; display:none; }
.panel.open { display:flex; flex-direction:column; }
.panel-header { display:flex; align-items:center; gap:12px; padding:12px 16px; border-bottom:1px solid #21262d; background:#161b22; }
.panel-header h2 { margin:0; font: 700 16px system-ui, sans-serif; letter-spacing:-0.01em; color:#f0f6fc; }
.panel-header .close-btn { margin-left:auto; width:32px; height:32px; border:none; background:#21262d; color:#8b949e; border-radius:6px; cursor:pointer; font-size:16px; display:flex; align-items:center; justify-content:center; }
.panel-header .close-btn:hover { background:#30363d; color:#f0f6fc; }
.page-info { padding:16px; background:#0d1117; border-bottom:1px solid #21262d; }
.page-info h3 { margin:0 0 12px 0; font: 600 14px system-ui, sans-serif; color:#f0f6fc; display:flex; align-items:center; gap:8px; }
.page-info h3::before { content:'🔍'; font-size:16px; }
.page-meta { display:grid; grid-template-columns:repeat(auto-fit, minmax(200px, 1fr)); gap:12px; }
.meta-item { display:flex; flex-direction:column; gap:4px; }
.meta-label { font: 500 11px system-ui, sans-serif; color:#8b949e; text-transform:uppercase; letter-spacing:0.5px; }
.meta-value { font: 600 13px "SF Mono", Monaco, "Cascadia Code", "Roboto Mono", monospace; color:#79c0ff; background:#161b22; padding:6px 8px; border-radius:6px; border:1px solid #21262d; word-break:break-all; }
.meta-value.type { color:#a5f3fc; }
.meta-value.layout { color:#fbbf24; }
.tabs { display:flex; background:#161b22; border-bottom:1px solid #21262d; }
.tab { padding:12px 16px; font: 500 14px system-ui, sans-serif; color:#8b949e; cursor:pointer; border-bottom:2px solid transparent; }
.tab:hover { color:#f0f6fc; }
.tab.active { color:#58a6ff; border-bottom-color:#58a6ff; }
.tab-content { flex:1; overflow:auto; }
.env-info { padding:12px 16px; font: 500 11px system-ui, sans-serif; color:#8b949e; background:#0d1117; border-bottom:1px solid #21262d; display:flex; align-items:center; gap:16px; }
.filters { display:flex; gap:12px; align-items:center; margin-left:auto; }
.filters label { display:inline-flex; gap:6px; align-items:center; font: 500 12px system-ui, sans-serif; color:#c9d2e1; cursor:pointer; }
.logs-content { overflow:auto; }
.entry { display:grid; grid-template-columns:auto 1fr auto; gap:12px; padding:12px 16px; border-bottom:1px solid #21262d; }
.entry:hover { background:#161b22; }
.entry .lvl { font:700 11px system-ui, sans-serif; text-transform:uppercase; padding:4px 6px; border-radius:4px; }
.entry.error .lvl { color:#ffffff; background:#da3633; }
.entry.warn .lvl { color:#1f2937; background:#f59e0b; }
.entry.info .lvl { color:#ffffff; background:#1f6feb; }
.entry .msg { white-space:pre-wrap; word-break:break-word; font: 400 13px system-ui, sans-serif; line-height:1.5; }
.entry .meta { color:#8b949e; font: 500 11px "SF Mono", Monaco, monospace; }
.page-group-header { padding:12px 16px; font: 600 12px system-ui, sans-serif; color:#58a6ff; border-bottom:1px solid #21262d; background:#0d1117; }
@media (prefers-reduced-motion:no-preference) {
.panel { transition: all .25s cubic-bezier(0.4, 0, 0.2, 1); transform: translateY(8px) scale(0.95); opacity:0; }
.panel.open { transform: translateY(0) scale(1); opacity:1; }
}
`;
shadow.appendChild(style);
const btnWrap = document.createElement('div');
btnWrap.style.position = 'relative';
const btn = document.createElement('button');
btn.className = 'btn';
btn.style.opacity = String(OPACITY);
btn.setAttribute('aria-label', 'Hugo Blox Dev HUD');
btn.textContent = 'HB';
const badge = document.createElement('span');
badge.className = 'badge';
badge.hidden = true;
btnWrap.append(btn, badge);
const panel = document.createElement('div');
panel.className = 'panel';
// Header
const header = document.createElement('div');
header.className = 'panel-header';
const title = document.createElement('h2');
title.textContent = 'Hugo Blox – Dev Tools';
const close = document.createElement('button');
close.className = 'close-btn';
close.textContent = '×';
close.title = 'Close';
header.append(title, close);
// Page Info Section
const pageInfo = document.createElement('div');
pageInfo.className = 'page-info';
const pageTitle = document.createElement('h3');
pageTitle.textContent = 'Current Page';
const pageMeta = document.createElement('div');
pageMeta.className = 'page-meta';
pageInfo.append(pageTitle, pageMeta);
// Environment Info
const envInfo = document.createElement('div');
envInfo.className = 'env-info';
const env = document.createElement('div');
env.textContent = '';
const filters = document.createElement('div');
filters.className = 'filters';
const chkInfo = document.createElement('input');
chkInfo.type='checkbox';
chkInfo.id='hbx-info';
chkInfo.checked = !!(localStorage.getItem('hbx-show-info')||'');
const lblInfo = document.createElement('label');
lblInfo.htmlFor='hbx-info';
lblInfo.textContent='Show info';
const chkAuto = document.createElement('input');
chkAuto.type='checkbox';
chkAuto.id='hbx-auto-open';
chkAuto.checked = !!(localStorage.getItem('hbx-auto-open')||'');
const lblAuto = document.createElement('label');
lblAuto.htmlFor='hbx-auto-open';
lblAuto.textContent='Auto open';
filters.append(lblInfo, chkInfo, lblAuto, chkAuto);
envInfo.append(env, filters);
// Tabs
const tabs = document.createElement('div');
tabs.className = 'tabs';
const logsTab = document.createElement('div');
logsTab.className = 'tab active';
logsTab.textContent = 'Logs';
logsTab.dataset.tab = 'logs';
tabs.append(logsTab);
// Tab Content
const tabContent = document.createElement('div');
tabContent.className = 'tab-content';
const logsContent = document.createElement('div');
logsContent.className = 'logs-content';
tabContent.append(logsContent);
panel.append(header, pageInfo, envInfo, tabs, tabContent);
shadow.append(btnWrap, panel);
const attach = () => { if (!document.body.contains(root)) document.body.appendChild(root); };
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', attach);
else attach();
let logs = [];
let meta = null;
const setLogs = (payload) => {
if (payload) {
logs = Array.isArray(payload.entries) ? payload.entries : [];
meta = payload;
} else {
logs = [];
meta = null;
}
};
const shouldShowInfo = () => chkInfo.checked;
const createMetaItem = (label, value, className = '') => {
const item = document.createElement('div');
item.className = 'meta-item';
const labelEl = document.createElement('div');
labelEl.className = 'meta-label';
labelEl.textContent = label;
const valueEl = document.createElement('div');
valueEl.className = `meta-value ${className}`;
valueEl.textContent = value || '(not set)';
item.append(labelEl, valueEl);
return item;
};
const render = () => {
// Update environment info
if (meta && meta.hugo) {
const hv = meta.hugo.version || '';
const gvRaw = meta.hugo.goVersion || '';
const gv = gvRaw.replace(/^go/i, '');
const tw = meta.modules && meta.modules.bloxTailwind ? meta.modules.bloxTailwind.version : '';
env.textContent = `Hugo ${hv} · Go ${gv}${tw ? ` · blox-tailwind ${tw}` : ''}`;
}
// Update page information
pageMeta.replaceChildren();
if (meta && meta.currentPage) {
const page = meta.currentPage;
pageMeta.append(
createMetaItem('Type', page.type || 'unknown', 'type'),
createMetaItem('Layout', page.layout || '(none)', 'layout'),
createMetaItem('Kind', page.kind || 'unknown'),
createMetaItem('Section', page.section || '(none)'),
createMetaItem('Path', page.path || window.location.pathname),
createMetaItem('File', page.file || '(virtual page)'),
createMetaItem('Build ID', page.buildId || 'unknown')
);
} else {
// Show placeholder data when meta isn't loaded yet
pageMeta.append(
createMetaItem('Type', 'loading...', 'type'),
createMetaItem('Layout', 'loading...', 'layout'),
createMetaItem('Kind', 'loading...'),
createMetaItem('Section', 'loading...'),
createMetaItem('Path', window.location.pathname),
createMetaItem('File', 'loading...'),
createMetaItem('Build ID', 'loading...')
);
}
// Update logs
const visible = logs.filter(e => e.level==='error' || e.level==='warn' || (e.level==='info' && shouldShowInfo()));
const errs = visible.filter(e => e.level==='error').length;
const warns = visible.filter(e => e.level==='warn').length;
const total = errs + warns + (shouldShowInfo()? visible.filter(e=>e.level==='info').length : 0);
badge.textContent = String(total);
badge.hidden = total === 0;
// Update tab badge
logsTab.textContent = `Logs${total > 0 ? ` (${total})` : ''}`;
logsContent.replaceChildren();
const groupByPage = new Map();
for (const e of visible) {
const key = e.page || '/';
if (!groupByPage.has(key)) groupByPage.set(key, []);
groupByPage.get(key).push(e);
}
for (const [page, entries] of groupByPage) {
const h = document.createElement('div');
h.className = 'page-group-header';
h.textContent = page;
logsContent.appendChild(h);
for (const e of entries.sort((a,b)=> (a.level>b.level?-1:1) || (a.timestamp-b.timestamp))) {
const row = document.createElement('div');
row.className = `entry ${e.level}`;
const lvl = document.createElement('div');
lvl.className='lvl';
lvl.textContent=e.level;
const msg = document.createElement('div');
msg.className='msg';
msg.textContent = e.message || '';
const metaEl = document.createElement('div');
metaEl.className='meta';
metaEl.textContent = new Date(e.timestamp*1000).toLocaleTimeString();
row.append(lvl, msg, metaEl);
logsContent.appendChild(row);
}
}
};
const refreshFromWindow = () => {
if (window.__HBX_LOGS__) {
setLogs(window.__HBX_LOGS__);
}
};
const fetchLogs = async () => {
try {
const res = await fetch('/hbx-debug.json?cb=' + Date.now());
if (res.ok) { const data = await res.json(); setLogs(data); render(); }
} catch (e) { /* silent */ }
};
btn.addEventListener('click', () => {
panel.classList.toggle('open');
if (panel.classList.contains('open')) {
refreshFromWindow();
render(); // Immediately render current data
fetchLogs();
}
});
close.addEventListener('click', () => panel.classList.remove('open'));
chkInfo.addEventListener('change', () => {
localStorage.setItem('hbx-show-info', chkInfo.checked ? '1' : '');
render();
});
chkAuto.addEventListener('change', () => {
localStorage.setItem('hbx-auto-open', chkAuto.checked ? '1' : '');
});
// Initial render immediately on load
refreshFromWindow();
render(); // Force initial render even without data
// Auto-open panel if user enabled it
if (chkAuto.checked) {
panel.classList.add('open');
render();
fetchLogs();
}
// Auto-refresh badge every few seconds in dev
setInterval(() => { refreshFromWindow(); }, 3000);
} catch(e) { /* ignore */ }
})();
</script>
{{- end -}}
@@ -25,7 +25,7 @@
{{ if $client_jsx }}
{{/* Build the client-side JSX using Hugo's official integration */}}
{{ $jsx_opts := dict
"targetPath" (printf "js/%s-preact.js" $block_type)
"targetPath" (printf "assets/js/preact-built/%s.js" $block_type)
"JSX" "automatic"
"JSXImportSource" "preact"
"format" "iife"
@@ -2,50 +2,64 @@
{{/* Documentation: https://hugoblox.com/docs/ */}}
{{/* License: https://github.com/HugoBlox/hugo-blox-builder/blob/main/LICENSE.md */}}
{{/*
{{/*
This partial generates Tailwind CSS @source directives that tell Tailwind where to scan for CSS classes.
CRITICAL: Tailwind scans the actual filesystem (relative to main.css), while Hugo operates on a virtual
filesystem with module mounts. This file bridges that gap by translating Hugo's virtual paths to actual
filesystem paths that Tailwind can find.
Environments handled:
1. Test site (HUGO_BLOX_TEST_SITE=true): runs from test/, so ../modules/blox-tailwind/ for theme files
2. Starter sites (HUGO_BLOX_MONOREPO=true): run from starters/[name]/, so ../../../modules/blox-tailwind/
3. End-user sites: use virtual mounted paths from Hugo modules (standard content/, layouts/, assets/)
Without this, Tailwind would miss classes from:
* Hugo Blox theme templates (layouts/, blox/)
* Preact components (blox/** /*.jsx) - intentional space due to Hugo bug thinking '* /' is commend end
* Site content (content/** /*.md) - intentional space due to Hugo bug thinking '* /' is commend end
* Generated HTML classes (hugo_stats.json)
It provides a robust bridge between Hugo's virtual filesystem (which includes module mounts)
and Tailwind's real filesystem scanner.
*/}}
{{ $is_monorepo := eq (os.Getenv "HUGO_BLOX_MONOREPO") "true" }}
{{ $is_test_site := eq (os.Getenv "HUGO_BLOX_TEST_SITE") "true" }}
{{ $paths := slice }}
{{/* 1. Always add paths relative to the current site's root (content, layouts, etc.) */}}
{{/* 1. Always scan the site's source files. */}}
{{ $paths = $paths | append `content/**/*.md` }}
{{ $paths = $paths | append `layouts/**/*.html` }}
{{ $paths = $paths | append `assets/js/**/*.js` }}
{{ $paths = $paths | append `assets/js/**/*.jsx` }}
{{ $paths = $paths | append `assets/js/**/*.tsx` }}
{{ $paths = $paths | append `hugo_stats.json` }}
{{/* 2. If in monorepo, add relative paths to the blox-tailwind module source */}}
{{ if $is_monorepo }}
{{ $module_path_prefix := "" }}
{{ if $is_test_site }}
{{/* Test site is at `test/`, so one level up to repo root */}}
{{ $module_path_prefix = "../" }}
{{ else }}
{{/* Starter sites are at `starters/[name]/`, so three levels up */}}
{{ $module_path_prefix = "../../../" }}
{{/*
2. Crucially, scan Hugo's generated JS output from Preact components.
These files are built by Hugo's `js.Build` into a predictable location within the
project's `assets` directory, making them a reliable source for Tailwind to scan.
This step happens before Tailwind runs, thanks to `templates.Defer` in `site_head.html`.
*/}}
{{ $paths = $paths | append `assets/js/preact-built/**/*.js` }}
{{/*
3. Inline sources: extract class attributes from Preact JSX/TSX and embed them
directly so Tailwind can always see them regardless of FS layout.
*/}}
{{ $jsx_files := resources.Match "js/hbx/**/*.jsx" }}
{{ $tsx_files := resources.Match "js/hbx/**/*.tsx" }}
{{ $extracted_classes := slice }}
{{ range $f := $jsx_files }}
{{ $code := $f.Content }}
{{ $matches := findRE `class(?:Name)?\s*=\s*"([^"]+)"` $code }}
{{ range $m := $matches }}
{{ $cls := replaceRE `^class(?:Name)?\s*=\s*"([^"]+)"$` `$1` $m }}
{{ $classes := split $cls " " }}
{{ range $class := $classes }}
{{ $extracted_classes = $extracted_classes | append $class }}
{{ end }}
{{ end }}
{{ $module_path := printf "%smodules/blox-tailwind" $module_path_prefix }}
{{ $paths = $paths | append (printf `%s/layouts/**/*.html` $module_path) }}
{{ $paths = $paths | append (printf `%s/assets/js/**/*.js` $module_path) }}
{{ $paths = $paths | append (printf `%s/blox/**/*.html` $module_path) }}
{{ $paths = $paths | append (printf `%s/blox/**/*.jsx` $module_path) }}
{{ end }}
{{ range $f := $tsx_files }}
{{ $code := $f.Content }}
{{ $matches := findRE `class(?:Name)?\s*=\s*"([^"]+)"` $code }}
{{ range $m := $matches }}
{{ $cls := replaceRE `^class(?:Name)?\s*=\s*"([^"]+)"$` `$1` $m }}
{{ $classes := split $cls " " }}
{{ range $class := $classes }}
{{ $extracted_classes = $extracted_classes | append $class }}
{{ end }}
{{ end }}
{{ end }}
{{ $extracted_classes = $extracted_classes | uniq }}
{{ range $class := $extracted_classes }}
@source inline("{{ $class }}");
{{ end }}
{{/* Generate the @source rules */}}
@@ -23,7 +23,19 @@
*/}}
{{/* Log deprecation warning */}}
{{ partial "functions/logger" (dict "page" .Page "level" "warn" "message" "The callout shortcode is deprecated. Use standard Markdown alert syntax instead: > [!NOTE]") }}
{{ $src := "/" }}
{{ with .Page }}
{{ with .File }}
{{ $src = .Path }}
{{ end }}
{{ end }}
{{ partial "functions/logger" (dict
"page" .Page
"level" "warn"
"id" "callout-deprecated"
"message" (printf "The `callout` shortcode is deprecated in %s. Use standard Markdown alert syntax instead, such as `> [!NOTE]`." $src)
"source" (dict "type" "shortcode" "name" "callout" "docs" "https://docs.hugoblox.com/reference/markdown/#callouts")
) }}
{{/* Get callout type from parameter */}}
{{ $type := (.Get 0) | default "note" }}
@@ -33,5 +33,9 @@
{{/* EXTENSIBILITY HOOK: BODY-END */}}
{{ partial "functions/get_hook" (dict "hook" "body-end" "context" .) }}
{{/* HBX Dev: ensure debug export and HUD are injected in dev */}}
{{ partial "hooks/body-end/hbx-debug-export.html" . }}
{{ partial "hooks/body-end/hbx-debug-hud.html" . }}
</body>
</html>
@@ -1,80 +0,0 @@
{{/*
Hugo Blox Builder Shared Callout Renderer
Renders callouts with consistent styling for both shortcodes and render hooks.
Parameters:
- type: The callout type (note, tip, important, warning, caution, or custom icon name)
- content: The callout content (HTML)
- title: Optional title for the callout
- source: "shortcode" or "renderhook" for context
*/}}
{{ $type := (lower .type) | default "note" }}
{{ $content := .content }}
{{ $title := .title }}
{{ if not $title }}
{{/* Try i18n translation with "callout_" prefix, fall back to title case */}}
{{ $i18n_key := printf "callout_%s" $type }}
{{ $title = or (i18n $i18n_key) (title $type) }}
{{ end }}
{{ $source := .source | default "shortcode" }}
{{/* Define icon and styling mappings for different alert types */}}
{{ $alertConfigs := dict
"note" (dict
"icon" "information-circle"
"class" "bg-primary-100 dark:bg-primary-900"
"textClass" "text-primary-600 dark:text-primary-300"
)
"tip" (dict
"icon" "light-bulb"
"class" "bg-green-100 dark:bg-green-900"
"textClass" "text-green-600 dark:text-green-300"
)
"important" (dict
"icon" "exclamation-circle"
"class" "bg-purple-100 dark:bg-purple-900"
"textClass" "text-purple-600 dark:text-purple-300"
)
"warning" (dict
"icon" "exclamation-triangle"
"class" "bg-yellow-100 dark:bg-yellow-900"
"textClass" "text-red-400"
)
"caution" (dict
"icon" "exclamation-triangle"
"class" "bg-red-100 dark:bg-red-900"
"textClass" "text-red-600 dark:text-red-300"
)
}}
{{/* Get configuration for this callout type, or use custom icon */}}
{{ $config := index $alertConfigs $type }}
{{ $icon := $type }}
{{ $class := "bg-primary-100 dark:bg-primary-900" }}
{{ $textClass := "text-primary-600 dark:text-primary-300" }}
{{ if $config }}
{{ $icon = $config.icon }}
{{ $class = $config.class }}
{{ $textClass = $config.textClass }}
{{ end }}
<div class="flex px-4 py-3 mb-6 rounded-md {{ $class }}">
<span class="pr-3 pt-1 {{ $textClass }}">
{{ partial "functions/get_icon" (dict "name" $icon "attributes" "height=\"24\"") }}
</span>
<div class="dark:text-neutral-300">
{{/* Display title if provided */}}
{{- with $title -}}
<div class="font-semibold mb-1">
{{- . -}}
</div>
{{- end -}}
{{/* Display content */}}
<div>
{{- $content -}}
</div>
</div>
</div>