themes-noir/register.go
Alex Dunmow 1bebbea5ad initial: theme plugin noir
Bootstrapped during the 2026-06-06 BlockNinja consolidation. Was previously
an unversioned directory inside ~/src/blockninja-themes/noir.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-06 14:11:40 +08:00

177 lines
5.7 KiB
Go

package main
import (
"context"
"github.com/a-h/templ"
"git.dev.alexdunmow.com/block/core/blocks"
"git.dev.alexdunmow.com/block/core/plugin"
"git.dev.alexdunmow.com/block/core/templates"
)
// wrap adapts a templ-returning render function to templates.TemplateFunc.
// templ.Component already implements templates.HTMLComponent via Render.
func wrap(f func(ctx context.Context, doc map[string]any) templ.Component) templates.TemplateFunc {
return func(ctx context.Context, doc map[string]any) templates.HTMLComponent {
return f(ctx, doc)
}
}
// Register wires the Noir system template, page templates, blocks, overrides,
// and email wrapper into the host registries.
//
// Order matters: br.LoadSchemasFromFS(Schemas()) is called BEFORE any
// br.Register call so the schema metadata binds correctly.
func Register(tr templates.TemplateRegistry, br blocks.BlockRegistry) error {
// System template.
tr.RegisterSystemTemplate(templates.SystemTemplateMeta{
Key: "noir",
Title: "Noir",
Description: "Silver-on-black photography theme with full-bleed imagery, micro mono labels, lightbox galleries and contact-sheet layouts.",
})
// Page templates (4).
if err := tr.RegisterPageTemplate("noir", templates.PageTemplateMeta{
Key: "default",
Title: "Default",
Description: "Centred gallery page with a thin masthead and dissolved footer.",
Slots: []string{"header", "main", "footer"},
}, wrap(RenderNoirDefault)); err != nil {
return err
}
if err := tr.RegisterPageTemplate("noir", templates.PageTemplateMeta{
Key: "landing",
Title: "Landing",
Description: "Edge-to-edge hero image, micro caption strip, minimal CTA.",
Slots: []string{"hero", "main", "cta", "footer"},
}, wrap(RenderNoirLanding)); err != nil {
return err
}
if err := tr.RegisterPageTemplate("noir", templates.PageTemplateMeta{
Key: "article",
Title: "Article / Project",
Description: "Long-form case study with sticky caption rail and image-led prose.",
Slots: []string{"header", "main", "aside", "footer"},
}, wrap(RenderNoirArticle)); err != nil {
return err
}
if err := tr.RegisterPageTemplate("noir", templates.PageTemplateMeta{
Key: "full-width",
Title: "Full Width",
Description: "Contact-sheet or lightbox grid, no horizontal padding.",
Slots: []string{"header", "main", "footer"},
}, wrap(RenderNoirFullWidth)); err != nil {
return err
}
// Schemas must load BEFORE block registration so metadata binds.
if err := br.LoadSchemasFromFS(Schemas()); err != nil {
return err
}
// Theme-specific blocks (6). Registered with unqualified keys —
// addressed as "noir:<key>" at runtime.
br.Register(LightboxGalleryBlockMeta, LightboxGalleryBlock)
br.Register(ContactSheetBlockMeta, ContactSheetBlock)
br.Register(CaseStudyBlockMeta, CaseStudyBlock)
br.Register(CaptionStripBlockMeta, CaptionStripBlock)
br.Register(ImagePairBlockMeta, ImagePairBlock)
br.Register(FooterBlockMeta, FooterBlock)
// Template overrides (5) — active only when this theme is selected.
br.RegisterTemplateOverride("noir", "heading", NoirHeadingBlock)
br.RegisterTemplateOverride("noir", "text", NoirTextBlock)
br.RegisterTemplateOverride("noir", "image", NoirImageBlock)
br.RegisterTemplateOverride("noir", "button", NoirButtonBlock)
br.RegisterTemplateOverride("noir", "card", NoirCardBlock)
// Branded email wrapper.
tr.RegisterEmailWrapper("noir", NoirEmailWrapper)
return nil
}
// DefaultMasterPages returns the two master pages Noir provisions on first load.
//
// noir:default-master covers the `default` and `article` page templates.
// noir:gallery-master covers `landing` and `full-width`, swapping the
// caption strip for a contact-sheet-style footer and using the dissolved navbar.
func DefaultMasterPages() []plugin.MasterPageDefinition {
return []plugin.MasterPageDefinition{
{
Key: "noir:default-master",
Title: "Noir Default Master",
PageTemplates: []string{"default", "article"},
Blocks: []plugin.MasterPageBlock{
{
BlockKey: "navbar",
Title: "Masthead",
Content: map[string]any{"menuName": "main", "variant": "minimal"},
Slot: "header",
SortOrder: 0,
},
{
BlockKey: "slot",
Title: "Main Slot",
Content: map[string]any{"slotName": "main", "placeholder": "Photographs go here"},
Slot: "main",
SortOrder: 0,
},
{
BlockKey: "noir:caption_strip",
Title: "Caption Strip",
Content: map[string]any{"label": "INDEX", "right": "© studio"},
Slot: "footer",
SortOrder: 0,
},
{
BlockKey: "noir:footer",
Title: "Footer",
Content: map[string]any{"showColophon": "true"},
Slot: "footer",
SortOrder: 1,
},
},
},
{
Key: "noir:gallery-master",
Title: "Noir Gallery Master",
PageTemplates: []string{"landing", "full-width"},
Blocks: []plugin.MasterPageBlock{
{
BlockKey: "navbar",
Title: "Dissolved Masthead",
Content: map[string]any{"menuName": "main", "variant": "dissolved"},
Slot: "header",
SortOrder: 0,
},
{
BlockKey: "slot",
Title: "Main Slot",
Content: map[string]any{"slotName": "main", "placeholder": "Photographs go here"},
Slot: "main",
SortOrder: 0,
},
{
BlockKey: "noir:contact_sheet_footer",
Title: "Contact Sheet Footer",
Content: map[string]any{"items": []any{}},
Slot: "footer",
SortOrder: 0,
},
{
BlockKey: "noir:footer",
Title: "Footer",
Content: map[string]any{"showColophon": "true"},
Slot: "footer",
SortOrder: 1,
},
},
},
}
}