Compare commits

..

No commits in common. "main" and "v0.20.2" have entirely different histories.

17 changed files with 730 additions and 1513 deletions

View File

@ -1,44 +1,9 @@
# Core SDK # Core SDK
Go module `git.dev.alexdunmow.com/block/core`. Go module `git.dev.alexdunmow.com/block/core`. Shared Go code between the **CMS and the orchestrator** — template engine, block registry, shared types, proto definitions.
**Purpose: core exists ONLY for code that must be shared between first-party
BlockNinja entities** — the **CMS**, the **orchestrator**, the **ninja CLI**,
and any future entity that genuinely needs to share code with the others.
## Admission test
Before adding anything here, ask: **do two or more first-party entities need
this code?**
- Needed by only one entity → it belongs in that entity's repo, not core.
- Needed by plugins → it does NOT belong here (see below); plugins build
against the wasm ABI, whose contract the CMS owns (`cms/backend/abi/`,
`cms/docs/abi/`).
The standing direction (Captain, 2026-07-07) is to **shrink core**: prefer
moving code out to its single consumer over adding more in. When a package's
consumer count drops to one, migrate it out.
## Critical Rules ## Critical Rules
- **Core is NOT for plugins.** Plugins are standalone wasm artifacts built - **Core is NOT for plugins.** Its only consumers are the CMS and the orchestrator; it exists purely to share code between those two. Plugins must not import `block/core` — in the wasm-plugin era they are standalone artifacts built against the wasm ABI, not this module. (Core was previously the plugin SDK; that role is gone.)
against the wasm ABI, not this module. (Core was previously the plugin SDK; - **NEVER use `replace` directives in go.mod** — not in this repo, not in any consumer. All module resolution goes through the Gitea module proxy. If you need to test local changes, tag and push a version.
that role is gone, and remaining plugin-era surface is legacy to be worked - All consumers are in-house — no backwards compatibility shims needed. Just change the API and update consumers.
off, not a precedent.)
- **NEVER use `replace` directives in go.mod** — not in this repo, not in any
consumer. All module resolution goes through the Gitea module proxy. If you
need to test local changes, tag and push a version.
- All consumers are in-house — no backwards compatibility shims needed. Just
change the API and update consumers.
## Special case: `templates/bn/` is a synced copy, do not author here
The bn page chrome (head / toolbar / engagement / asset_hooks) is **authored
in `cms/backend/templates/bn`** and mechanically copied here (`make
sync-templates` in cms) solely so guest-side plugin templates can compile it
into their wasm. check-safety check 31 fails cms commits while the copies
drift. Never edit these files here directly — change them in cms and sync.
`templates/bn/validation.go` is intentionally divergent (cms's version bridges
the SDK ValidationTracker under both context keys) and is NOT part of the sync
set. Spec: cms `docs/superpowers/specs/2026-07-07-bn-chrome-single-source-design.md`.

View File

@ -1,19 +1,8 @@
# BlockNinja Core # BlockNinja Core
Go code shared between first-party BlockNinja entities: the **CMS**, the Shared Go code between the BlockNinja CMS and the orchestrator: types, interfaces, and utilities both need.
**orchestrator**, the **ninja CLI**, and any future entity that genuinely
needs to share code with the others.
> **Scope rule:** core is ONLY for code needed by **two or more** of those > **Not a plugin SDK.** Core is purely for sharing code between the CMS and the orchestrator. Plugins must **not** import `block/core` — in the wasm-plugin era they are standalone artifacts built against the wasm ABI. (Core previously served as the plugin SDK; that role is gone.)
> entities. Code with a single consumer belongs in that consumer's repo. The
> standing direction is to shrink core — when a package's consumer count drops
> to one, it gets migrated out.
> **Not a plugin SDK.** Plugins must **not** treat `block/core` as their SDK —
> in the wasm-plugin era they are standalone artifacts built against the wasm
> ABI (owned by the CMS: `cms/backend/abi/`, `cms/docs/abi/`). Core previously
> served as the plugin SDK; that role is gone, and remaining plugin-era
> surface here is legacy to be worked off, not a precedent.
## Package Structure ## Package Structure
@ -23,7 +12,7 @@ needs to share code with the others.
| `blocks/` | BlockMeta, BlockFunc, BlockRegistry interface, BlockContext | | `blocks/` | BlockMeta, BlockFunc, BlockRegistry interface, BlockContext |
| `blocks/builtin/` | Reusable block implementations (HTMLBlock) | | `blocks/builtin/` | Reusable block implementations (HTMLBlock) |
| `templates/` | TemplateRegistry interface | | `templates/` | TemplateRegistry interface |
| `templates/bn/` | Synced copy of the cms-authored bn chrome (see below) | | `templates/bn/` | Shared templ components (head, engagement, toolbar) |
| `auth/` | Claims types, context extractors | | `auth/` | Claims types, context extractors |
| `content/` | Content access interface | | `content/` | Content access interface |
| `settings/` | Settings access interface | | `settings/` | Settings access interface |
@ -34,20 +23,10 @@ needs to share code with the others.
| `ai/` | AI tool registry interface and types | | `ai/` | AI tool registry interface and types |
| `rbac/` | Role type definition | | `rbac/` | Role type definition |
### `templates/bn/` is a synced copy — do not edit here
The bn page chrome (head / toolbar / engagement / asset_hooks) is authored in
`cms/backend/templates/bn` and copied here via the cms `make sync-templates`
target, solely so guest-side plugin templates can compile it into their wasm.
check-safety (check 31) fails cms commits while the copies drift. Change the
chrome in cms, sync, then commit + tag + push here.
## Usage ## Usage
```go Consumers are the CMS and the orchestrator only.
import "git.dev.alexdunmow.com/block/core/blocks"
```
Versioned via git tags through the Gitea module proxy — never `replace` ```go
directives. All consumers are in-house; no backwards-compatibility shims — import "git.dev.alexdunmow.com/block/core/plugin"
change the API and update the consumers. ```

View File

@ -25,7 +25,7 @@ func readZipEntry(t *testing.T, data []byte, name string) []byte {
if err != nil { if err != nil {
t.Fatalf("open %s: %v", name, err) t.Fatalf("open %s: %v", name, err)
} }
defer func() { _ = rc.Close() }() defer rc.Close()
b, err := io.ReadAll(rc) b, err := io.ReadAll(rc)
if err != nil { if err != nil {
t.Fatalf("read %s: %v", name, err) t.Fatalf("read %s: %v", name, err)

View File

@ -56,7 +56,7 @@ func ReadManifest(r io.ReaderAt, size int64) (*Manifest, error) {
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to open manifest.json: %w", err) return nil, fmt.Errorf("failed to open manifest.json: %w", err)
} }
defer func() { _ = rc.Close() }() defer rc.Close()
data, err := io.ReadAll(rc) data, err := io.ReadAll(rc)
if err != nil { if err != nil {

View File

@ -1,6 +1,6 @@
// Code generated by templ - DO NOT EDIT. // Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.1020 // templ: version: v0.3.1001
package shared package shared
//lint:file-ignore SA4006 This context is only used if a nested component is present. //lint:file-ignore SA4006 This context is only used if a nested component is present.
@ -44,7 +44,7 @@ func RenderButton(btn ButtonConfig) templ.Component {
var templ_7745c5c3_Var3 templ.SafeURL var templ_7745c5c3_Var3 templ.SafeURL
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(btn.URL)) templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(btn.URL))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `blocks/shared/button.templ`, Line: 8, Col: 33} return templ.Error{Err: templ_7745c5c3_Err, FileName: `button.templ`, Line: 8, Col: 33}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@ -55,11 +55,11 @@ func RenderButton(btn ButtonConfig) templ.Component {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var4 string var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var2).String()) templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var2).String())
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `blocks/shared/button.templ`, Line: 1, Col: 0} return templ.Error{Err: templ_7745c5c3_Err, FileName: `button.templ`, Line: 1, Col: 0}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var4) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@ -80,7 +80,7 @@ func RenderButton(btn ButtonConfig) templ.Component {
var templ_7745c5c3_Var5 string var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues(btn.InlineStyle()) templ_7745c5c3_Var5, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues(btn.InlineStyle())
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `blocks/shared/button.templ`, Line: 14, Col: 29} return templ.Error{Err: templ_7745c5c3_Err, FileName: `button.templ`, Line: 14, Col: 29}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@ -93,7 +93,7 @@ func RenderButton(btn ButtonConfig) templ.Component {
var templ_7745c5c3_Var6 string var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(btn.Text) templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(btn.Text)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `blocks/shared/button.templ`, Line: 16, Col: 14} return templ.Error{Err: templ_7745c5c3_Err, FileName: `button.templ`, Line: 16, Col: 14}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@ -116,7 +116,7 @@ func RenderButton(btn ButtonConfig) templ.Component {
var templ_7745c5c3_Var8 templ.SafeURL var templ_7745c5c3_Var8 templ.SafeURL
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(btn.URL)) templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(btn.URL))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `blocks/shared/button.templ`, Line: 20, Col: 33} return templ.Error{Err: templ_7745c5c3_Err, FileName: `button.templ`, Line: 20, Col: 33}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@ -127,11 +127,11 @@ func RenderButton(btn ButtonConfig) templ.Component {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var9 string var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var7).String()) templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var7).String())
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `blocks/shared/button.templ`, Line: 1, Col: 0} return templ.Error{Err: templ_7745c5c3_Err, FileName: `button.templ`, Line: 1, Col: 0}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var9) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@ -152,7 +152,7 @@ func RenderButton(btn ButtonConfig) templ.Component {
var templ_7745c5c3_Var10 string var templ_7745c5c3_Var10 string
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(btn.Text) templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(btn.Text)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `blocks/shared/button.templ`, Line: 27, Col: 14} return templ.Error{Err: templ_7745c5c3_Err, FileName: `button.templ`, Line: 27, Col: 14}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@ -175,11 +175,11 @@ func RenderButton(btn ButtonConfig) templ.Component {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var12 string var templ_7745c5c3_Var12 string
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var11).String()) templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var11).String())
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `blocks/shared/button.templ`, Line: 1, Col: 0} return templ.Error{Err: templ_7745c5c3_Err, FileName: `button.templ`, Line: 1, Col: 0}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var12) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@ -190,7 +190,7 @@ func RenderButton(btn ButtonConfig) templ.Component {
var templ_7745c5c3_Var13 string var templ_7745c5c3_Var13 string
templ_7745c5c3_Var13, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues(btn.InlineStyle()) templ_7745c5c3_Var13, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues(btn.InlineStyle())
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `blocks/shared/button.templ`, Line: 32, Col: 74} return templ.Error{Err: templ_7745c5c3_Err, FileName: `button.templ`, Line: 32, Col: 74}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@ -203,7 +203,7 @@ func RenderButton(btn ButtonConfig) templ.Component {
var templ_7745c5c3_Var14 string var templ_7745c5c3_Var14 string
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(btn.Text) templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(btn.Text)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `blocks/shared/button.templ`, Line: 33, Col: 14} return templ.Error{Err: templ_7745c5c3_Err, FileName: `button.templ`, Line: 33, Col: 14}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@ -224,11 +224,11 @@ func RenderButton(btn ButtonConfig) templ.Component {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var16 string var templ_7745c5c3_Var16 string
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var15).String()) templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var15).String())
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `blocks/shared/button.templ`, Line: 1, Col: 0} return templ.Error{Err: templ_7745c5c3_Err, FileName: `button.templ`, Line: 1, Col: 0}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var16) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@ -239,7 +239,7 @@ func RenderButton(btn ButtonConfig) templ.Component {
var templ_7745c5c3_Var17 string var templ_7745c5c3_Var17 string
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(btn.Text) templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(btn.Text)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `blocks/shared/button.templ`, Line: 37, Col: 14} return templ.Error{Err: templ_7745c5c3_Err, FileName: `button.templ`, Line: 37, Col: 14}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@ -288,11 +288,11 @@ func RenderSubmitButton(btn ButtonConfig) templ.Component {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var20 string var templ_7745c5c3_Var20 string
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var19).String()) templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var19).String())
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `blocks/shared/button.templ`, Line: 1, Col: 0} return templ.Error{Err: templ_7745c5c3_Err, FileName: `button.templ`, Line: 1, Col: 0}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var20) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@ -303,7 +303,7 @@ func RenderSubmitButton(btn ButtonConfig) templ.Component {
var templ_7745c5c3_Var21 string var templ_7745c5c3_Var21 string
templ_7745c5c3_Var21, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues(btn.InlineStyle()) templ_7745c5c3_Var21, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues(btn.InlineStyle())
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `blocks/shared/button.templ`, Line: 46, Col: 73} return templ.Error{Err: templ_7745c5c3_Err, FileName: `button.templ`, Line: 46, Col: 73}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@ -316,7 +316,7 @@ func RenderSubmitButton(btn ButtonConfig) templ.Component {
var templ_7745c5c3_Var22 string var templ_7745c5c3_Var22 string
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(btn.Text) templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(btn.Text)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `blocks/shared/button.templ`, Line: 47, Col: 13} return templ.Error{Err: templ_7745c5c3_Err, FileName: `button.templ`, Line: 47, Col: 13}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@ -337,11 +337,11 @@ func RenderSubmitButton(btn ButtonConfig) templ.Component {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var24 string var templ_7745c5c3_Var24 string
templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var23).String()) templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var23).String())
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `blocks/shared/button.templ`, Line: 1, Col: 0} return templ.Error{Err: templ_7745c5c3_Err, FileName: `button.templ`, Line: 1, Col: 0}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var24) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@ -352,7 +352,7 @@ func RenderSubmitButton(btn ButtonConfig) templ.Component {
var templ_7745c5c3_Var25 string var templ_7745c5c3_Var25 string
templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinStringErrs(btn.Text) templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinStringErrs(btn.Text)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `blocks/shared/button.templ`, Line: 51, Col: 13} return templ.Error{Err: templ_7745c5c3_Err, FileName: `button.templ`, Line: 51, Col: 13}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {

View File

@ -10,7 +10,6 @@ import (
"encoding/hex" "encoding/hex"
"net/http" "net/http"
"strconv" "strconv"
"sync/atomic"
"time" "time"
) )
@ -35,7 +34,7 @@ type RedeemResponse struct {
} }
type Server struct { type Server struct {
secret atomic.Pointer[[]byte] secret []byte
count, size, d int count, size, d int
challengeTTL time.Duration challengeTTL time.Duration
tokenTTL time.Duration tokenTTL time.Duration
@ -60,6 +59,7 @@ func WithUsedTokenStore(ns NonceStore) Option { return func(sv *Server) { sv.use
// 32-char salts, difficulty 4, 10-min challenge / 5-min token expiry. // 32-char salts, difficulty 4, 10-min challenge / 5-min token expiry.
func New(secret []byte, opts ...Option) *Server { func New(secret []byte, opts ...Option) *Server {
s := &Server{ s := &Server{
secret: secret,
count: 50, count: 50,
size: 32, size: 32,
d: 4, d: 4,
@ -68,20 +68,12 @@ func New(secret []byte, opts ...Option) *Server {
nonces: NewMemoryNonceStore(), nonces: NewMemoryNonceStore(),
usedTokens: NewMemoryNonceStore(), usedTokens: NewMemoryNonceStore(),
} }
s.secret.Store(&secret)
for _, o := range opts { for _, o := range opts {
o(s) o(s)
} }
return s return s
} }
func (s *Server) secretBytes() []byte { return *s.secret.Load() }
// SetSecret swaps the HMAC secret at runtime. Outstanding challenge and
// verification tokens signed with the old secret immediately fail
// verification, so callers can use this to invalidate all issued tokens.
func (s *Server) SetSecret(secret []byte) { s.secret.Store(&secret) }
func (s *Server) CreateChallenge() (ChallengeResponse, error) { func (s *Server) CreateChallenge() (ChallengeResponse, error) {
buf := make([]byte, 25) buf := make([]byte, 25)
if _, err := rand.Read(buf); err != nil { if _, err := rand.Read(buf); err != nil {
@ -89,7 +81,7 @@ func (s *Server) CreateChallenge() (ChallengeResponse, error) {
} }
nonce := hex.EncodeToString(buf) nonce := hex.EncodeToString(buf)
expires := nowMs() + s.challengeTTL.Milliseconds() expires := nowMs() + s.challengeTTL.Milliseconds()
token := makeChallengeToken(s.secretBytes(), nonce, expires, s.count, s.size, s.d) token := makeChallengeToken(s.secret, nonce, expires, s.count, s.size, s.d)
return ChallengeResponse{ return ChallengeResponse{
Challenge: Challenge{C: s.count, S: s.size, D: s.d}, Challenge: Challenge{C: s.count, S: s.size, D: s.d},
Token: token, Token: token,
@ -98,7 +90,7 @@ func (s *Server) CreateChallenge() (ChallengeResponse, error) {
} }
func (s *Server) Redeem(token string, solutions []string) RedeemResponse { func (s *Server) Redeem(token string, solutions []string) RedeemResponse {
claims := verifyChallengeToken(s.secretBytes(), token) claims := verifyChallengeToken(s.secret, token)
if claims == nil { if claims == nil {
return RedeemResponse{Success: false} return RedeemResponse{Success: false}
} }
@ -119,7 +111,7 @@ func (s *Server) Redeem(token string, solutions []string) RedeemResponse {
} }
} }
expires := nowMs() + s.tokenTTL.Milliseconds() expires := nowMs() + s.tokenTTL.Milliseconds()
vt, err := makeVerificationToken(s.secretBytes(), expires) vt, err := makeVerificationToken(s.secret, expires)
if err != nil { if err != nil {
return RedeemResponse{Success: false} return RedeemResponse{Success: false}
} }
@ -140,7 +132,7 @@ func (s *Server) Redeem(token string, solutions []string) RedeemResponse {
// fragment that still carries the widget (with its reset flow) lets the visitor // fragment that still carries the widget (with its reset flow) lets the visitor
// re-solve and retry — callers MUST keep the widget present on rejection paths. // re-solve and retry — callers MUST keep the widget present on rejection paths.
func (s *Server) VerifyToken(token string) bool { func (s *Server) VerifyToken(token string) bool {
claims := parseVerificationToken(s.secretBytes(), token) claims := parseVerificationToken(s.secret, token)
if claims == nil { if claims == nil {
return false return false
} }

View File

@ -35,7 +35,7 @@ uses; commands assume access via `podman exec blockninja-db psql -U orchestrator
rm -rf /tmp/uat-a1 && mkdir /tmp/uat-a1 && cd /tmp/uat-a1 rm -rf /tmp/uat-a1 && mkdir /tmp/uat-a1 && cd /tmp/uat-a1
git init -q && git commit --allow-empty -qm "initial" git init -q && git commit --allow-empty -qm "initial"
``` ```
- [x] Run: `ninja plugin init --host https://my.blockninja.dev --scope @themes --name uat-a1` (answer: kind=plugin, categories=1,2) - [x] Run: `ninja plugin init --host https://my.localdev.blockninjacms.com --scope @themes --name uat-a1` (answer: kind=plugin, categories=1,2)
- [x] Observe `cat plugin.mod` includes: - [x] Observe `cat plugin.mod` includes:
- `name = "uat-a1"` - `name = "uat-a1"`
- `scope = "@themes"` - `scope = "@themes"`
@ -85,7 +85,7 @@ uses; commands assume access via `podman exec blockninja-db psql -U orchestrator
git tag --list # capture before git tag --list # capture before
git rev-parse HEAD # capture HEAD before git rev-parse HEAD # capture HEAD before
``` ```
- [x] Run `ninja plugin publish --host https://my.blockninja.dev`. - [x] Run `ninja plugin publish --host https://my.localdev.blockninjacms.com`.
- [x] STDOUT contains `Published @themes/uat-a1@0.1.0 (NNN bytes)`. - [x] STDOUT contains `Published @themes/uat-a1@0.1.0 (NNN bytes)`.
- [x] After publish, `git tag --list` output is byte-identical to before. (NO `v0.1.0` tag was created.) - [x] After publish, `git tag --list` output is byte-identical to before. (NO `v0.1.0` tag was created.)
- [x] `git rev-parse HEAD` is unchanged. - [x] `git rev-parse HEAD` is unchanged.
@ -162,7 +162,7 @@ uses; commands assume access via `podman exec blockninja-db psql -U orchestrator
- [x] Run: - [x] Run:
```bash ```bash
curl -sS -X POST \ curl -sS -X POST \
https://my.blockninja.dev/orchestrator.v1.PluginRegistryService/ListCategories \ https://my.localdev.blockninjacms.com/orchestrator.v1.PluginRegistryService/ListCategories \
-H 'Content-Type: application/json' -d '{}' -H 'Content-Type: application/json' -d '{}'
``` ```
- [x] Response includes ALL of: `analytics`, `seo`, `social`, `commerce`, `forms`, `import-export`, `media`, `developer`. - [x] Response includes ALL of: `analytics`, `seo`, `social`, `commerce`, `forms`, `import-export`, `media`, `developer`.
@ -171,9 +171,9 @@ uses; commands assume access via `podman exec blockninja-db psql -U orchestrator
- [x] Setup a fresh repo and start init manually entering a bogus category number — NOT possible via the CLI menu. Instead, hit the RPC directly: - [x] Setup a fresh repo and start init manually entering a bogus category number — NOT possible via the CLI menu. Instead, hit the RPC directly:
```bash ```bash
TOKEN=$(jq -r '.hosts["https://my.blockninja.dev"].token' ~/.config/ninja/creds.json) TOKEN=$(jq -r '.hosts["https://my.localdev.blockninjacms.com"].token' ~/.config/ninja/creds.json)
curl -sS -X POST \ curl -sS -X POST \
https://my.blockninja.dev/orchestrator.v1.PluginRegistryService/CreatePlugin \ https://my.localdev.blockninjacms.com/orchestrator.v1.PluginRegistryService/CreatePlugin \
-H "Authorization: Bearer $TOKEN" \ -H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \ -H 'Content-Type: application/json' \
-d '{"scopeSlug":"themes","name":"uat-c2","kind":"plugin","categories":["bogus"]}' -d '{"scopeSlug":"themes","name":"uat-c2","kind":"plugin","categories":["bogus"]}'
@ -185,7 +185,7 @@ uses; commands assume access via `podman exec blockninja-db psql -U orchestrator
- [x] Same setup as C2 but kind=theme with categories=["analytics"]: - [x] Same setup as C2 but kind=theme with categories=["analytics"]:
```bash ```bash
curl -sS -X POST \ curl -sS -X POST \
https://my.blockninja.dev/orchestrator.v1.PluginRegistryService/CreatePlugin \ https://my.localdev.blockninjacms.com/orchestrator.v1.PluginRegistryService/CreatePlugin \
-H "Authorization: Bearer $TOKEN" \ -H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \ -H 'Content-Type: application/json' \
-d '{"scopeSlug":"themes","name":"uat-c3","kind":"theme","categories":["analytics"]}' -d '{"scopeSlug":"themes","name":"uat-c3","kind":"theme","categories":["analytics"]}'
@ -213,7 +213,7 @@ uses; commands assume access via `podman exec blockninja-db psql -U orchestrator
- [x] Hit ListPlugins with `kind=plugin`: - [x] Hit ListPlugins with `kind=plugin`:
```bash ```bash
curl -sS -X POST \ curl -sS -X POST \
https://my.blockninja.dev/orchestrator.v1.PluginRegistryService/ListPlugins \ https://my.localdev.blockninjacms.com/orchestrator.v1.PluginRegistryService/ListPlugins \
-H "Authorization: Bearer $TOKEN" \ -H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' -d '{"kind":"plugin"}' -H 'Content-Type: application/json' -d '{"kind":"plugin"}'
``` ```
@ -236,7 +236,7 @@ uses; commands assume access via `podman exec blockninja-db psql -U orchestrator
- [x] Run: - [x] Run:
```bash ```bash
curl -sS -o /dev/null -w "%{http_code}\n" \ curl -sS -o /dev/null -w "%{http_code}\n" \
https://my.blockninja.dev/git/themes/lcars.git/info/refs?service=git-upload-pack https://my.localdev.blockninjacms.com/git/themes/lcars.git/info/refs?service=git-upload-pack
``` ```
- [x] HTTP code is `404` (not 200, not 403). The route does not exist. - [x] HTTP code is `404` (not 200, not 403). The route does not exist.
@ -283,7 +283,7 @@ uses; commands assume access via `podman exec blockninja-db psql -U orchestrator
- [x] For any published plugin/version (e.g. uat-a1@0.1.0), call ResolveInstall: - [x] For any published plugin/version (e.g. uat-a1@0.1.0), call ResolveInstall:
```bash ```bash
curl -sS -X POST \ curl -sS -X POST \
https://my.blockninja.dev/orchestrator.v1.PluginRegistryService/ResolveInstall \ https://my.localdev.blockninjacms.com/orchestrator.v1.PluginRegistryService/ResolveInstall \
-H 'Content-Type: application/json' \ -H 'Content-Type: application/json' \
-d '{"scopeSlug":"themes","pluginName":"uat-a1","versionOrChannel":"0.1.0"}' -d '{"scopeSlug":"themes","pluginName":"uat-a1","versionOrChannel":"0.1.0"}'
``` ```

View File

@ -1371,8 +1371,8 @@ podman compose up -d orchestrator-backend
mkdir -p /tmp/smokeplugin && cd /tmp/smokeplugin mkdir -p /tmp/smokeplugin && cd /tmp/smokeplugin
git init -q git init -q
git commit --allow-empty -qm "initial" git commit --allow-empty -qm "initial"
ninja login --host https://my.blockninja.dev ninja login --host https://my.localdev.blockninjacms.com
ninja plugin init --host https://my.blockninja.dev --scope @themes --name smoke ninja plugin init --host https://my.localdev.blockninjacms.com --scope @themes --name smoke
git log --oneline git log --oneline
``` ```
Expected: a commit "Add plugin.mod" exists on HEAD; no `ninja` remote (`git remote -v` is empty). Expected: a commit "Add plugin.mod" exists on HEAD; no `ninja` remote (`git remote -v` is empty).
@ -1380,7 +1380,7 @@ Expected: a commit "Add plugin.mod" exists on HEAD; no `ninja` remote (`git remo
- [ ] **Step 3: Publish** - [ ] **Step 3: Publish**
```bash ```bash
ninja plugin publish --host https://my.blockninja.dev ninja plugin publish --host https://my.localdev.blockninjacms.com
``` ```
Expected: `Published @themes/smoke@0.1.0 (NNN bytes)` with no error. Expected: `Published @themes/smoke@0.1.0 (NNN bytes)` with no error.
@ -1399,7 +1399,7 @@ Expected: a row whose `source_archive_key` ends in `source.tar.zst`.
- [ ] **Step 5: Try publishing again — version collision** - [ ] **Step 5: Try publishing again — version collision**
```bash ```bash
ninja plugin publish --host https://my.blockninja.dev ninja plugin publish --host https://my.localdev.blockninjacms.com
``` ```
Expected: error mentioning "version already published". Expected: error mentioning "version already published".
@ -2226,7 +2226,7 @@ podman compose up -d orchestrator-backend
- [ ] **Step 2: Verify ListCategories** - [ ] **Step 2: Verify ListCategories**
```bash ```bash
curl -sS https://my.blockninja.dev/orchestrator.v1.PluginRegistryService/ListCategories \ curl -sS https://my.localdev.blockninjacms.com/orchestrator.v1.PluginRegistryService/ListCategories \
-H 'Content-Type: application/json' -d '{}' -H 'Content-Type: application/json' -d '{}'
``` ```
Expected: JSON listing the 8 seeded categories. Expected: JSON listing the 8 seeded categories.
@ -2236,7 +2236,7 @@ Expected: JSON listing the 8 seeded categories.
```bash ```bash
mkdir -p /tmp/themeplugin && cd /tmp/themeplugin mkdir -p /tmp/themeplugin && cd /tmp/themeplugin
git init -q && git commit --allow-empty -qm "initial" git init -q && git commit --allow-empty -qm "initial"
ninja plugin init --host https://my.blockninja.dev --scope @themes --name aurora ninja plugin init --host https://my.localdev.blockninjacms.com --scope @themes --name aurora
# At the kind prompt, choose 2 (theme). # At the kind prompt, choose 2 (theme).
cat plugin.mod cat plugin.mod
``` ```
@ -2247,7 +2247,7 @@ Expected: `kind = "theme"` set, no categories line.
```bash ```bash
mkdir -p /tmp/normalplugin && cd /tmp/normalplugin mkdir -p /tmp/normalplugin && cd /tmp/normalplugin
git init -q && git commit --allow-empty -qm "initial" git init -q && git commit --allow-empty -qm "initial"
ninja plugin init --host https://my.blockninja.dev --scope @themes --name foometrics ninja plugin init --host https://my.localdev.blockninjacms.com --scope @themes --name foometrics
# kind: 1 (plugin). categories: pick a couple, e.g. "1,2" # kind: 1 (plugin). categories: pick a couple, e.g. "1,2"
cat plugin.mod cat plugin.mod
``` ```
@ -2257,7 +2257,7 @@ Expected: `kind = "plugin"` and `categories = ["analytics", "seo"]`.
```bash ```bash
cd /tmp/normalplugin cd /tmp/normalplugin
ninja plugin publish --host https://my.blockninja.dev ninja plugin publish --host https://my.localdev.blockninjacms.com
``` ```
Expected: success. Expected: success.
@ -2277,7 +2277,7 @@ cd /tmp/normalplugin
sed -i 's/\["analytics", "seo"\]/["analytics", "seo", "bogus"]/' plugin.mod sed -i 's/\["analytics", "seo"\]/["analytics", "seo", "bogus"]/' plugin.mod
git add plugin.mod && git commit -qm "tweak" git add plugin.mod && git commit -qm "tweak"
ninja plugin bump ninja plugin bump
ninja plugin publish --host https://my.blockninja.dev ninja plugin publish --host https://my.localdev.blockninjacms.com
``` ```
Expected: error: "plugin.mod categories do not match registered categories" Expected: error: "plugin.mod categories do not match registered categories"
(the plugin row's categories don't include "bogus"). The mismatch check (the plugin row's categories don't include "bogus"). The mismatch check

2
go.mod
View File

@ -4,7 +4,7 @@ go 1.26.4
require ( require (
connectrpc.com/connect v1.20.0 connectrpc.com/connect v1.20.0
git.dev.alexdunmow.com/block/ninjatpl v1.0.2 git.dev.alexdunmow.com/block/ninjatpl v1.0.1
github.com/BurntSushi/toml v1.6.0 github.com/BurntSushi/toml v1.6.0
github.com/a-h/templ v0.3.1020 github.com/a-h/templ v0.3.1020
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0

4
go.sum
View File

@ -1,7 +1,7 @@
connectrpc.com/connect v1.20.0 h1:6TNDAB+WeNd2uolWNlYczB5E0KNNaVMNUEx8JEUsPmQ= connectrpc.com/connect v1.20.0 h1:6TNDAB+WeNd2uolWNlYczB5E0KNNaVMNUEx8JEUsPmQ=
connectrpc.com/connect v1.20.0/go.mod h1:A2ygJrukXwWy32vkCAAHNVguZrqZ+jeZ9rGRnGR4dN4= connectrpc.com/connect v1.20.0/go.mod h1:A2ygJrukXwWy32vkCAAHNVguZrqZ+jeZ9rGRnGR4dN4=
git.dev.alexdunmow.com/block/ninjatpl v1.0.2 h1:KQS90HNW35PSzvwDZ3Z9OOwX3OSjX0x62w0AtOYps8s= git.dev.alexdunmow.com/block/ninjatpl v1.0.1 h1:LqMnvSOpQQ9uQ8Wez5U3epE7z5SGN4VdEtWL0cpf0Qw=
git.dev.alexdunmow.com/block/ninjatpl v1.0.2/go.mod h1:WrLz0KysnP5EzJlRCkU2VC1uvazyAXZR99Tn+3Mx1pw= git.dev.alexdunmow.com/block/ninjatpl v1.0.1/go.mod h1:WrLz0KysnP5EzJlRCkU2VC1uvazyAXZR99Tn+3Mx1pw=
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/a-h/templ v0.3.1020 h1:ypAT/L5ySWEnZ6Zft/5yfoWXYYkhFNvEFOeeqecg4tw= github.com/a-h/templ v0.3.1020 h1:ypAT/L5ySWEnZ6Zft/5yfoWXYYkhFNvEFOeeqecg4tw=

2
proto

@ -1 +1 @@
Subproject commit 1ca85e3bb64e83a5764637339ab90a86da96ca09 Subproject commit 06dcc6a5fa996f0527862acf7b7f04c4b893a06e

View File

@ -1,11 +0,0 @@
package bn
// VersionedAssetURL resolves an asset URL to a cache-versioned form. The
// default is the identity function; the CMS host wires it to
// internal/assets.VersionedURL at startup so plugin stylesheet links get
// content-hash ?v= params. Guest-side (wasm) renders keep the identity
// default — asset hashing is a host concern.
//
// This file is part of the cms→core template sync set (make sync-templates);
// it must stay SDK-clean (no cms imports).
var VersionedAssetURL = func(url string) string { return url }

View File

@ -1,6 +1,6 @@
// Code generated by templ - DO NOT EDIT. // Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.1020 // templ: version: v0.3.1001
package bn package bn
//lint:file-ignore SA4006 This context is only used if a nested component is present. //lint:file-ignore SA4006 This context is only used if a nested component is present.
@ -60,11 +60,11 @@ func EngagementScript(config EngagementConfig) templ.Component {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var2 string var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue(engagementConfigJSON(config)) templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(engagementConfigJSON(config))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `engagement.templ`, Line: 30, Col: 63} return templ.Error{Err: templ_7745c5c3_Err, FileName: `engagement.templ`, Line: 30, Col: 63}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }

View File

@ -16,56 +16,22 @@ func resolveMediaURL(url string) string {
return url return url
} }
// canonicalURL resolves the canonical link. An admin-set value wins (made absolute
// against the page's own origin when it is root-relative); otherwise the page's own
// absolute URL is used as a self-referencing canonical. Returns "" only when neither
// is available.
func canonicalURL(canonical, pageURL string) string {
if canonical == "" {
return pageURL
}
if strings.HasPrefix(canonical, "http://") || strings.HasPrefix(canonical, "https://") {
return canonical
}
if strings.HasPrefix(canonical, "/") && pageURL != "" {
if i := strings.Index(pageURL, "://"); i >= 0 {
if j := strings.IndexByte(pageURL[i+3:], '/'); j >= 0 {
return pageURL[:i+3+j] + canonical
}
return pageURL + canonical
}
}
return canonical
}
// BrandingData contains generated favicon/icon URLs // BrandingData contains generated favicon/icon URLs
type BrandingData struct { type BrandingData struct {
FaviconICO string // /.brand/{id}/favicon.ico FaviconICO string // /brand/{id}/favicon.ico
Favicon16 string // /.brand/{id}/favicon-16x16.png Favicon16 string // /brand/{id}/favicon-16x16.png
Favicon32 string // /.brand/{id}/favicon-32x32.png Favicon32 string // /brand/{id}/favicon-32x32.png
Favicon96 string // /.brand/{id}/favicon-96x96.png AppleTouchIcon string // /brand/{id}/apple-touch-icon.png (180x180)
AppleTouchIcon string // /.brand/{id}/apple-touch-icon.png (180x180) Android192 string // /brand/{id}/android-chrome-192x192.png
Android192 string // /.brand/{id}/android-chrome-192x192.png Android512 string // /brand/{id}/android-chrome-512x512.png
Android512 string // /.brand/{id}/android-chrome-512x512.png Maskable512 string // /brand/{id}/maskable-512x512.png
Maskable512 string // /.brand/{id}/maskable-512x512.png Master1024 string // /brand/{id}/icon-1024x1024.png
Master1024 string // /.brand/{id}/icon-1024x1024.png ManifestURL string // /brand/{id}/site.webmanifest
ManifestURL string // /.brand/{id}/site.webmanifest
MaskIcon string // /.brand/{id}/mask-icon.svg
MSTile150 string // /.brand/{id}/mstile-150x150.png
BrowserConfig string // /.brand/{id}/browserconfig.xml
ThemeColor string ThemeColor string
SVG string // Optional SVG pass-through SVG string // Optional SVG pass-through
IsGenerated bool IsGenerated bool
} }
// CustomScriptEntry represents a single custom script with placement control
type CustomScriptEntry struct {
Name string
Placement string // "head" or "body"
Code string
Enabled bool
}
// SiteSettingsData contains site-wide settings for head injection // SiteSettingsData contains site-wide settings for head injection
type SiteSettingsData struct { type SiteSettingsData struct {
Title string Title string
@ -75,9 +41,8 @@ type SiteSettingsData struct {
LogoAlt string LogoAlt string
AppleTouchIcon string AppleTouchIcon string
GoogleAnalyticsID string GoogleAnalyticsID string
CustomScripts []CustomScriptEntry CustomHeadScripts string
GoogleAnalyticsEnabled bool CustomBodyScripts string
GoogleAnalyticsExplicitlySet bool // true if the enabled flag was explicitly set in JSON (not nil)
MetaDescription string MetaDescription string
DefaultOGImage string DefaultOGImage string
TwitterHandle string TwitterHandle string
@ -102,10 +67,6 @@ type PageMeta struct {
TwitterImage string // Twitter card image URL TwitterImage string // Twitter card image URL
CanonicalURL string // Canonical URL for this page CanonicalURL string // Canonical URL for this page
RobotsDirective string // Robots directive (e.g., "noindex, nofollow") RobotsDirective string // Robots directive (e.g., "noindex, nofollow")
OGType string // Open Graph type ("article" for posts, else "website")
PageURL string // Absolute URL of this page (og:url + auto-canonical)
ArticlePublishedTime string // ISO 8601 published time (articles only)
ArticleAuthor string // Author name (articles only)
} }
// HeadData contains all data needed to render the <head> element // HeadData contains all data needed to render the <head> element
@ -198,75 +159,15 @@ func ParseSiteSettings(doc map[string]any) SiteSettingsData {
} }
// Analytics // Analytics
var legacyHeadScripts, legacyBodyScripts string
if analyticsData, ok := siteData["analytics"].(map[string]any); ok { if analyticsData, ok := siteData["analytics"].(map[string]any); ok {
if v, ok := analyticsData["google_analytics_id"].(string); ok { if v, ok := analyticsData["google_analytics_id"].(string); ok {
settings.GoogleAnalyticsID = v settings.GoogleAnalyticsID = v
} }
// Read GA enabled flag — if present, use it; if absent but ID is set, default to enabled (backward compat)
if v, ok := analyticsData["google_analytics_enabled"].(bool); ok {
settings.GoogleAnalyticsEnabled = v
settings.GoogleAnalyticsExplicitlySet = true
} else if settings.GoogleAnalyticsID != "" {
settings.GoogleAnalyticsEnabled = true
}
// Preserve legacy flat fields for fallback
if v, ok := analyticsData["custom_head_scripts"].(string); ok { if v, ok := analyticsData["custom_head_scripts"].(string); ok {
legacyHeadScripts = v settings.CustomHeadScripts = v
} }
if v, ok := analyticsData["custom_body_scripts"].(string); ok { if v, ok := analyticsData["custom_body_scripts"].(string); ok {
legacyBodyScripts = v settings.CustomBodyScripts = v
}
}
// Structured custom scripts
if scriptsArr, ok := siteData["custom_scripts"].([]any); ok && len(scriptsArr) > 0 {
for _, item := range scriptsArr {
entry, ok := item.(map[string]any)
if !ok {
continue
}
cs := CustomScriptEntry{}
if v, ok := entry["name"].(string); ok {
cs.Name = v
}
// Placement: proto enum stored as float64 (1=head, 2=body) or string
switch p := entry["placement"].(type) {
case float64:
if p == 1 {
cs.Placement = "head"
} else if p == 2 {
cs.Placement = "body"
}
case string:
cs.Placement = p
}
if v, ok := entry["code"].(string); ok {
cs.Code = v
}
if v, ok := entry["enabled"].(bool); ok {
cs.Enabled = v
}
settings.CustomScripts = append(settings.CustomScripts, cs)
}
}
// Fallback: if no structured scripts, migrate old flat fields
if len(settings.CustomScripts) == 0 {
if legacyHeadScripts != "" {
settings.CustomScripts = append(settings.CustomScripts, CustomScriptEntry{
Name: "Legacy Head Scripts",
Placement: "head",
Code: legacyHeadScripts,
Enabled: true,
})
}
if legacyBodyScripts != "" {
settings.CustomScripts = append(settings.CustomScripts, CustomScriptEntry{
Name: "Legacy Body Scripts",
Placement: "body",
Code: legacyBodyScripts,
Enabled: true,
})
} }
} }
@ -308,18 +209,6 @@ func ParseSiteSettings(doc map[string]any) SiteSettingsData {
if v, ok := brandingData["svg"].(string); ok { if v, ok := brandingData["svg"].(string); ok {
settings.Branding.SVG = v settings.Branding.SVG = v
} }
if v, ok := brandingData["favicon_96"].(string); ok {
settings.Branding.Favicon96 = v
}
if v, ok := brandingData["mask_icon"].(string); ok {
settings.Branding.MaskIcon = v
}
if v, ok := brandingData["mstile_150"].(string); ok {
settings.Branding.MSTile150 = v
}
if v, ok := brandingData["browserconfig"].(string); ok {
settings.Branding.BrowserConfig = v
}
} }
// Admin bypass mode (for showing banner when admin bypasses maintenance/coming_soon) // Admin bypass mode (for showing banner when admin bypasses maintenance/coming_soon)
@ -387,18 +276,6 @@ func ParsePageMeta(doc map[string]any) PageMeta {
if v, ok := doc["robotsDirective"].(string); ok { if v, ok := doc["robotsDirective"].(string); ok {
meta.RobotsDirective = v meta.RobotsDirective = v
} }
if v, ok := doc["ogType"].(string); ok {
meta.OGType = v
}
if v, ok := doc["pageUrl"].(string); ok {
meta.PageURL = v
}
if v, ok := doc["articlePublishedTime"].(string); ok {
meta.ArticlePublishedTime = v
}
if v, ok := doc["articleAuthor"].(string); ok {
meta.ArticleAuthor = v
}
return meta return meta
} }
@ -433,12 +310,6 @@ func ParseToolbarData(data map[string]any) ToolbarData {
if v, ok := data["preview_mode"].(string); ok { if v, ok := data["preview_mode"].(string); ok {
toolbar.PreviewMode = v toolbar.PreviewMode = v
} }
if v, ok := data["hide_preview_toggle"].(bool); ok {
toolbar.HidePreviewToggle = v
}
if v, ok := data["animate"].(bool); ok {
toolbar.Animate = v
}
if v, ok := data["position"].(string); ok { if v, ok := data["position"].(string); ok {
toolbar.Position = v toolbar.Position = v
} }
@ -519,30 +390,15 @@ templ Head(data HeadData) {
@themeInitScript(data.ThemeMode) @themeInitScript(data.ThemeMode)
<script src="https://unpkg.com/htmx.org@2.0.4"></script> <script src="https://unpkg.com/htmx.org@2.0.4"></script>
if data.Settings.Branding.IsGenerated { if data.Settings.Branding.IsGenerated {
// Use generated brand assets (modern RFG-parity set; SVG first) // Use generated brand assets
if data.Settings.Branding.SVG != "" {
<link rel="icon" type="image/svg+xml" href={ data.Settings.Branding.SVG }/>
}
if data.Settings.Branding.Favicon96 != "" {
<link rel="icon" type="image/png" sizes="96x96" href={ data.Settings.Branding.Favicon96 }/>
}
<link rel="icon" type="image/png" sizes="32x32" href={ data.Settings.Branding.Favicon32 }/>
<link rel="icon" type="image/png" sizes="16x16" href={ data.Settings.Branding.Favicon16 }/>
<link rel="icon" type="image/x-icon" href={ data.Settings.Branding.FaviconICO }/> <link rel="icon" type="image/x-icon" href={ data.Settings.Branding.FaviconICO }/>
<link rel="icon" type="image/png" sizes="16x16" href={ data.Settings.Branding.Favicon16 }/>
<link rel="icon" type="image/png" sizes="32x32" href={ data.Settings.Branding.Favicon32 }/>
<link rel="apple-touch-icon" sizes="180x180" href={ data.Settings.Branding.AppleTouchIcon }/> <link rel="apple-touch-icon" sizes="180x180" href={ data.Settings.Branding.AppleTouchIcon }/>
if data.Settings.Branding.MaskIcon != "" {
<link rel="mask-icon" href={ data.Settings.Branding.MaskIcon } color={ data.Settings.Branding.ThemeColor }/>
}
<link rel="manifest" href={ data.Settings.Branding.ManifestURL }/> <link rel="manifest" href={ data.Settings.Branding.ManifestURL }/>
if data.Settings.Branding.ThemeColor != "" { if data.Settings.Branding.ThemeColor != "" {
<meta name="theme-color" content={ data.Settings.Branding.ThemeColor }/> <meta name="theme-color" content={ data.Settings.Branding.ThemeColor }/>
} }
if data.Settings.Branding.BrowserConfig != "" {
<meta name="msapplication-config" content={ data.Settings.Branding.BrowserConfig }/>
}
if data.Title != "" {
<meta name="apple-mobile-web-app-title" content={ data.Title }/>
}
} else { } else {
// Legacy fallback - manual favicon uploads // Legacy fallback - manual favicon uploads
if data.Settings.Favicon != "" { if data.Settings.Favicon != "" {
@ -562,9 +418,9 @@ templ Head(data HeadData) {
<meta name="description" content={ data.Settings.MetaDescription }/> <meta name="description" content={ data.Settings.MetaDescription }/>
} }
// Canonical URL: admin override (resolved to absolute) → auto self-canonical (M8) // Canonical URL (page-level only, no site default)
if canonical := canonicalURL(data.PageMeta.CanonicalURL, data.PageMeta.PageURL); canonical != "" { if data.PageMeta.CanonicalURL != "" {
<link rel="canonical" href={ canonical }/> <link rel="canonical" href={ data.PageMeta.CanonicalURL }/>
} }
// Robots directive (page-level only - defaults to index,follow if not set) // Robots directive (page-level only - defaults to index,follow if not set)
@ -603,25 +459,8 @@ templ Head(data HeadData) {
<meta property="og:image" content={ resolveMediaURL(data.Settings.DefaultOGImage) }/> <meta property="og:image" content={ resolveMediaURL(data.Settings.DefaultOGImage) }/>
} }
// og:type: article for posts, website otherwise (M5) // og:type - default to website
if data.PageMeta.OGType != "" {
<meta property="og:type" content={ data.PageMeta.OGType }/>
} else {
<meta property="og:type" content="website"/> <meta property="og:type" content="website"/>
}
// og:url: absolute URL of this page (M6)
if data.PageMeta.PageURL != "" {
<meta property="og:url" content={ data.PageMeta.PageURL }/>
}
// article:* metadata for posts (M5)
if data.PageMeta.OGType == "article" {
if data.PageMeta.ArticlePublishedTime != "" {
<meta property="article:published_time" content={ data.PageMeta.ArticlePublishedTime }/>
}
if data.PageMeta.ArticleAuthor != "" {
<meta property="article:author" content={ data.PageMeta.ArticleAuthor }/>
}
}
// === TWITTER CARD META TAGS === // === TWITTER CARD META TAGS ===
if data.Settings.TwitterHandle != "" { if data.Settings.TwitterHandle != "" {
@ -673,7 +512,7 @@ templ Head(data HeadData) {
<link rel="alternate" type="application/atom+xml" title={ data.Settings.RSSFeedTitle + " (Atom)" } href={ data.Settings.RSSFeedURL + "/atom" }/> <link rel="alternate" type="application/atom+xml" title={ data.Settings.RSSFeedTitle + " (Atom)" } href={ data.Settings.RSSFeedURL + "/atom" }/>
} }
if data.Settings.GoogleAnalyticsID != "" && (data.Settings.GoogleAnalyticsEnabled || !data.Settings.GoogleAnalyticsExplicitlySet) { if data.Settings.GoogleAnalyticsID != "" {
<script async src={ "https://www.googletagmanager.com/gtag/js?id=" + data.Settings.GoogleAnalyticsID }></script> <script async src={ "https://www.googletagmanager.com/gtag/js?id=" + data.Settings.GoogleAnalyticsID }></script>
@googleAnalyticsScript(data.Settings.GoogleAnalyticsID) @googleAnalyticsScript(data.Settings.GoogleAnalyticsID)
} }
@ -682,16 +521,14 @@ templ Head(data HeadData) {
// Blog post engagement tracking (only rendered for posts with engagement config) // Blog post engagement tracking (only rendered for posts with engagement config)
@EngagementScript(data.EngagementConfig) @EngagementScript(data.EngagementConfig)
for _, style := range data.PluginStyles { for _, style := range data.PluginStyles {
<link rel="stylesheet" href={ VersionedAssetURL(style) }/> <link rel="stylesheet" href={ style }/>
} }
// Theme CSS injected AFTER plugin styles to take precedence // Theme CSS injected AFTER plugin styles to take precedence
if data.ThemeCSS != "" { if data.ThemeCSS != "" {
@themeStyle(data.ThemeCSS) @themeStyle(data.ThemeCSS)
} }
for _, script := range data.Settings.CustomScripts { if data.Settings.CustomHeadScripts != "" {
if script.Enabled && script.Placement == "head" { @templ.Raw(data.Settings.CustomHeadScripts)
@templ.Raw(script.Code)
}
} }
if data.StructuredData != "" { if data.StructuredData != "" {
@structuredDataScript(data.StructuredData) @structuredDataScript(data.StructuredData)
@ -733,10 +570,8 @@ templ AdminBypassBanner(settings SiteSettingsData) {
// BodyEnd renders custom scripts and admin toolbar before </body> // BodyEnd renders custom scripts and admin toolbar before </body>
templ BodyEnd(settings SiteSettingsData) { templ BodyEnd(settings SiteSettingsData) {
@recordValidationCall(ctx, "BodyEnd") @recordValidationCall(ctx, "BodyEnd")
for _, script := range settings.CustomScripts { if settings.CustomBodyScripts != "" {
if script.Enabled && script.Placement == "body" { @templ.Raw(settings.CustomBodyScripts)
@templ.Raw(script.Code)
}
} }
// Render admin editor toolbar if enabled (auto-injects for all templates) // Render admin editor toolbar if enabled (auto-injects for all templates)
@AdminEditorToolbar(settings.Toolbar) @AdminEditorToolbar(settings.Toolbar)
@ -940,22 +775,15 @@ func themeInitScript(themeMode string) templ.Component {
default: default:
themeMode = "light" themeMode = "light"
} }
// Precedence: admin toolbar override (per-tab, sessionStorage) → visitor
// preference (bn-theme cookie/localStorage) → site default → system.
// Exposed as window.bnApplyTheme so the toolbar theme tester can re-apply
// after changing the override without duplicating this resolution.
return templ.Raw(`<script> return templ.Raw(`<script>
window.bnApplyTheme=function(){ (function(){
var t=null;try{t=sessionStorage.getItem('bn-theme-override')}catch(e){}
if(!t){
var c=document.cookie.match(/(?:^|; )bn-theme=([^;]*)/); var c=document.cookie.match(/(?:^|; )bn-theme=([^;]*)/);
t=c?c[1]:localStorage.getItem('bn-theme'); var t=c?c[1]:localStorage.getItem('bn-theme');
}
if(!t)t='` + themeMode + `'; if(!t)t='` + themeMode + `';
var h=document.documentElement;
if(t==='system')t=window.matchMedia('(prefers-color-scheme:dark)').matches?'dark':'light'; if(t==='system')t=window.matchMedia('(prefers-color-scheme:dark)').matches?'dark':'light';
document.documentElement.classList.toggle('dark',t==='dark'); if(t==='dark')h.classList.add('dark');
return t; else h.classList.remove('dark');
}; })();
window.bnApplyTheme();
</script>`) </script>`)
} }

File diff suppressed because one or more lines are too long

View File

@ -17,8 +17,6 @@ type ToolbarData struct {
Status string // "published", "draft", "scheduled" Status string // "published", "draft", "scheduled"
HasUnpublishedChanges bool HasUnpublishedChanges bool
PreviewMode string // "published" or "draft" PreviewMode string // "published" or "draft"
HidePreviewToggle bool
Animate bool // true only on initial full-page render (entrance animation); false on HTMX re-renders
Position string // "tl", "tc", "tr", "bl", "bc", "br" (default: "tr") Position string // "tl", "tc", "tr", "bl", "bc", "br" (default: "tr")
ScheduledAt *time.Time ScheduledAt *time.Time
TemplateName string TemplateName string
@ -137,20 +135,6 @@ func (t ToolbarData) DropdownAlign() string {
return "right" return "right"
} }
// EnterAnimationClass returns the entrance-animation classes for the initial
// full-page render. It slides the pill in from the edge it rests against (up for
// bottom positions, down for top) then gives a brief pulse. Empty on HTMX
// re-renders (Animate=false) so publish/discard/reposition swaps appear instantly.
func (t ToolbarData) EnterAnimationClass() string {
if !t.Animate {
return ""
}
if t.IsBottomPosition() {
return "bn-toolbar-enter bn-toolbar-enter-up"
}
return "bn-toolbar-enter bn-toolbar-enter-down"
}
// PositionClasses returns the positioning classes for the floating pill // PositionClasses returns the positioning classes for the floating pill
func (t ToolbarData) PositionClasses() string { func (t ToolbarData) PositionClasses() string {
switch t.Position { switch t.Position {
@ -204,7 +188,7 @@ templ AdminEditorToolbar(data ToolbarData) {
if data.Enabled { if data.Enabled {
<div <div
id="bn-admin-toolbar" id="bn-admin-toolbar"
class={ "bn-toolbar fixed z-[9999] flex items-center gap-2 px-3 py-2 text-sm font-medium rounded-full backdrop-blur-md transition-all duration-300", data.PositionClasses(), data.EnterAnimationClass() } class={ "bn-toolbar fixed z-[9999] flex items-center gap-2 px-3 py-2 text-sm font-medium rounded-full backdrop-blur-md transition-all duration-300", data.PositionClasses() }
> >
<style> <style>
/* Toolbar: dark by default (for light pages), light when page has .dark class */ /* Toolbar: dark by default (for light pages), light when page has .dark class */
@ -221,33 +205,6 @@ templ AdminEditorToolbar(data ToolbarData) {
border: 1px solid var(--bn-toolbar-border); border: 1px solid var(--bn-toolbar-border);
box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.25), 0 8px 10px -6px rgba(0, 0, 0, 0.2); box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.25), 0 8px 10px -6px rgba(0, 0, 0, 0.2);
} }
/* Entrance: slide in from the resting edge, then a brief pulse.
Animates `transform` only; Tailwind v4 centres via the separate
`translate` property, so `-translate-x-1/2` is preserved. Runs
~1s after render, 500ms total, initial page load only. */
@keyframes bn-toolbar-slide-up {
0% { opacity: 0; transform: translateY(1.25rem); }
60% { opacity: 1; transform: translateY(0) scale(1); }
80% { transform: translateY(0) scale(1.05); }
100% { opacity: 1; transform: translateY(0) scale(1); }
}
@keyframes bn-toolbar-slide-down {
0% { opacity: 0; transform: translateY(-1.25rem); }
60% { opacity: 1; transform: translateY(0) scale(1); }
80% { transform: translateY(0) scale(1.05); }
100% { opacity: 1; transform: translateY(0) scale(1); }
}
.bn-toolbar-enter {
animation-duration: 500ms;
animation-delay: 900ms;
animation-timing-function: cubic-bezier(0.22, 1, 0.36, 1);
animation-fill-mode: both;
}
.bn-toolbar-enter-up { animation-name: bn-toolbar-slide-up; }
.bn-toolbar-enter-down { animation-name: bn-toolbar-slide-down; }
@media (prefers-reduced-motion: reduce) {
.bn-toolbar-enter { animation: none; }
}
/* Light toolbar when page theme is dark (.dark class on html or body) */ /* Light toolbar when page theme is dark (.dark class on html or body) */
.dark .bn-toolbar, html.dark .bn-toolbar, body.dark .bn-toolbar { .dark .bn-toolbar, html.dark .bn-toolbar, body.dark .bn-toolbar {
--bn-toolbar-bg: rgba(250, 250, 250, 0.95); --bn-toolbar-bg: rgba(250, 250, 250, 0.95);
@ -268,13 +225,6 @@ templ AdminEditorToolbar(data ToolbarData) {
.bn-toolbar .bn-toolbar-primary:hover { .bn-toolbar .bn-toolbar-primary:hover {
background: color-mix(in srgb, var(--bn-toolbar-primary-bg) 90%, black); background: color-mix(in srgb, var(--bn-toolbar-primary-bg) 90%, black);
} }
/* Theme tester: highlighted while a light/dark override is forced.
Uses toolbar vars (not site theme tokens) so it renders on any site. */
.bn-toolbar .bn-theme-tester-active,
.bn-toolbar .bn-theme-tester-active:hover {
background: color-mix(in srgb, var(--bn-toolbar-primary-bg) 20%, transparent);
color: var(--bn-toolbar-primary-bg);
}
/* Dropdown styling - inherits toolbar vars */ /* Dropdown styling - inherits toolbar vars */
.bn-toolbar-dropdown { .bn-toolbar-dropdown {
background: var(--bn-toolbar-bg); background: var(--bn-toolbar-bg);
@ -312,7 +262,6 @@ templ AdminEditorToolbar(data ToolbarData) {
<span class={ "w-2 h-2 rounded-full", templ.KV("bg-success", data.Status == "published"), templ.KV("bg-warning", data.Status == "draft"), templ.KV("bg-info", data.Status == "scheduled"), templ.KV("animate-pulse ring-2 ring-warning/50", data.HasUnpublishedChanges) }></span> <span class={ "w-2 h-2 rounded-full", templ.KV("bg-success", data.Status == "published"), templ.KV("bg-warning", data.Status == "draft"), templ.KV("bg-info", data.Status == "scheduled"), templ.KV("animate-pulse ring-2 ring-warning/50", data.HasUnpublishedChanges) }></span>
<span class="bn-toolbar-muted text-xs hidden sm:inline">{ data.StatusLabel() }</span> <span class="bn-toolbar-muted text-xs hidden sm:inline">{ data.StatusLabel() }</span>
</div> </div>
if !data.HidePreviewToggle {
<!-- Divider --> <!-- Divider -->
<div class="bn-toolbar-divider w-px h-5"></div> <div class="bn-toolbar-divider w-px h-5"></div>
<!-- Preview toggle - compact --> <!-- Preview toggle - compact -->
@ -335,97 +284,6 @@ templ AdminEditorToolbar(data ToolbarData) {
<span class="hidden sm:inline">Live</span> <span class="hidden sm:inline">Live</span>
} }
</button> </button>
}
<!-- Divider -->
<div class="bn-toolbar-divider w-px h-5"></div>
<!-- Theme tester: cycles site default → forced light → forced dark.
Per-tab override (sessionStorage), applied by bnApplyTheme in head.templ.
Never touches the visitor bn-theme preference. -->
<button
type="button"
id="bn-theme-tester"
class="bn-toolbar-hover bn-toolbar-muted inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs transition-colors"
onclick="bnCycleThemeTester()"
title="Theme: site default — click to force light"
>
<span data-tt="auto">
<svg xmlns="http://www.w3.org/2000/svg" class="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="2" y="3" width="20" height="14" rx="2" ry="2"></rect>
<line x1="8" y1="21" x2="16" y2="21"></line>
<line x1="12" y1="17" x2="12" y2="21"></line>
</svg>
</span>
<span data-tt="light" style="display:none">
<svg xmlns="http://www.w3.org/2000/svg" class="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="5"></circle>
<line x1="12" y1="1" x2="12" y2="3"></line>
<line x1="12" y1="21" x2="12" y2="23"></line>
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64"></line>
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78"></line>
<line x1="1" y1="12" x2="3" y2="12"></line>
<line x1="21" y1="12" x2="23" y2="12"></line>
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36"></line>
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22"></line>
</svg>
</span>
<span data-tt="dark" style="display:none">
<svg xmlns="http://www.w3.org/2000/svg" class="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path>
</svg>
</span>
<span data-tt-label class="hidden sm:inline" style="display:none"></span>
</button>
<script>
// Theme tester: per-tab light/dark override for admins. Lives inside
// #bn-admin-toolbar so HTMX outerHTML swaps re-run the sync call.
window.bnCycleThemeTester = function() {
var cur = null;
try { cur = sessionStorage.getItem('bn-theme-override'); } catch (e) {}
var next = cur === 'light' ? 'dark' : (cur === 'dark' ? null : 'light');
try {
if (next) sessionStorage.setItem('bn-theme-override', next);
else sessionStorage.removeItem('bn-theme-override');
} catch (e) {}
if (window.bnApplyTheme) {
window.bnApplyTheme();
} else if (next) {
// Fallback for templates without the bn head script
document.documentElement.classList.toggle('dark', next === 'dark');
}
window.bnSyncThemeTester();
// Let darkmode-switcher blocks refresh their icons
var mode = document.documentElement.classList.contains('dark') ? 'dark' : 'light';
document.querySelectorAll('[data-bn-theme-toggle]').forEach(function(el) {
el.dispatchEvent(new CustomEvent('bn:theme-changed', { detail: { mode: mode } }));
});
};
window.bnSyncThemeTester = function() {
var btn = document.getElementById('bn-theme-tester');
if (!btn) return;
var cur = null;
try { cur = sessionStorage.getItem('bn-theme-override'); } catch (e) {}
var state = (cur === 'light' || cur === 'dark') ? cur : 'auto';
btn.querySelectorAll('[data-tt]').forEach(function(s) {
s.style.display = s.getAttribute('data-tt') === state ? '' : 'none';
});
var lbl = btn.querySelector('[data-tt-label]');
if (state === 'auto') {
lbl.style.display = 'none';
lbl.textContent = '';
} else {
lbl.style.display = '';
lbl.textContent = state === 'light' ? 'Light' : 'Dark';
}
btn.classList.toggle('bn-theme-tester-active', state !== 'auto');
btn.classList.toggle('bn-toolbar-muted', state === 'auto');
btn.title = state === 'auto'
? 'Theme: site default — click to force light'
: (state === 'light'
? 'Theme: forced light — click to force dark'
: 'Theme: forced dark — click to reset to site default');
};
window.bnSyncThemeTester();
</script>
<!-- Blog-specific: Reading time --> <!-- Blog-specific: Reading time -->
if data.IsBlogPost() && data.ReadingTime > 0 { if data.IsBlogPost() && data.ReadingTime > 0 {
<div class="bn-toolbar-muted hidden md:flex items-center gap-1 px-2 text-xs" title="Reading time"> <div class="bn-toolbar-muted hidden md:flex items-center gap-1 px-2 text-xs" title="Reading time">
@ -489,21 +347,6 @@ templ AdminEditorToolbar(data ToolbarData) {
// PageInfoDropdown renders the page info and quick actions dropdown // PageInfoDropdown renders the page info and quick actions dropdown
templ PageInfoDropdown(data ToolbarData) { templ PageInfoDropdown(data ToolbarData) {
<div class="bn-toolbar-dropdown rounded-lg py-2 min-w-[260px] max-w-[320px]"> <div class="bn-toolbar-dropdown rounded-lg py-2 min-w-[260px] max-w-[320px]">
<style>
/* Position-picker chips - shipped with the fragment so styling never
desyncs from the separately-rendered toolbar shell. Uses toolbar
vars (resolve via the enclosing .bn-toolbar) to stay visible in
both inverted themes. */
.bn-toolbar-dropdown .bn-dd-poschip { border-color: var(--bn-toolbar-border); }
.bn-toolbar-dropdown .bn-dd-poschip:hover { background: var(--bn-toolbar-hover); }
.bn-toolbar-dropdown .bn-dd-poschip-active,
.bn-toolbar-dropdown .bn-dd-poschip-active:hover {
background: var(--bn-toolbar-primary-bg);
border-color: var(--bn-toolbar-primary-bg);
}
.bn-toolbar-dropdown .bn-dd-dot { background: var(--bn-toolbar-muted); }
.bn-toolbar-dropdown .bn-dd-dot-active { background: var(--bn-toolbar-primary-fg); }
</style>
<!-- Analytics snapshot --> <!-- Analytics snapshot -->
if data.TodayPageviews > 0 || data.PageviewsTrend != "" { if data.TodayPageviews > 0 || data.PageviewsTrend != "" {
<div class="bn-dd-border px-3 py-2 border-b"> <div class="bn-dd-border px-3 py-2 border-b">
@ -639,16 +482,15 @@ templ PageInfoDropdown(data ToolbarData) {
// positionButton renders a position selector button // positionButton renders a position selector button
templ positionButton(pageID uuid.UUID, pos string, currentPos string, label string) { templ positionButton(pageID uuid.UUID, pos string, currentPos string, label string) {
{{ active := pos == currentPos || (currentPos == "" && pos == "tr") }}
<button <button
type="button" type="button"
class={ "bn-dd-poschip w-8 h-6 rounded border transition-colors flex items-center justify-center", templ.KV("bn-dd-poschip-active", active) } class={ "w-8 h-6 rounded border transition-colors flex items-center justify-center", templ.KV("bg-info border-info", pos == currentPos || (currentPos == "" && pos == "tr")), templ.KV("bn-dd-border bn-dd-hover", pos != currentPos && !(currentPos == "" && pos == "tr")) }
hx-post={ fmt.Sprintf("/toolbar/set-position/%s?pos=%s", pageID, pos) } hx-post={ fmt.Sprintf("/toolbar/set-position/%s?pos=%s", pageID, pos) }
hx-target="#bn-admin-toolbar" hx-target="#bn-admin-toolbar"
hx-swap="outerHTML" hx-swap="outerHTML"
title={ label } title={ label }
> >
<span class={ "w-1.5 h-1.5 rounded-full", templ.KV("bn-dd-dot-active", active), templ.KV("bn-dd-dot", !active) }></span> <span class={ "w-1.5 h-1.5 rounded-full", templ.KV("bg-info-foreground", pos == currentPos || (currentPos == "" && pos == "tr")), templ.KV("bn-dd-muted", pos != currentPos && !(currentPos == "" && pos == "tr")) }></span>
</button> </button>
} }

File diff suppressed because one or more lines are too long