Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
742f4434b1 | ||
|
|
accb305fa3 | ||
|
|
20c67b80ee | ||
|
|
1ee9c280e2 | ||
|
|
1a75edeae9 | ||
|
|
bc156f2fcb | ||
|
|
d7c1abbfb6 | ||
|
|
81acfac025 | ||
|
|
d94c979731 | ||
|
|
7089964f6b |
43
AGENTS.md
43
AGENTS.md
@ -1,9 +1,44 @@
|
||||
# Core SDK
|
||||
|
||||
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.
|
||||
Go module `git.dev.alexdunmow.com/block/core`.
|
||||
|
||||
**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
|
||||
|
||||
- **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.)
|
||||
- **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.
|
||||
- **Core is NOT for plugins.** Plugins are standalone wasm artifacts built
|
||||
against the wasm ABI, not this module. (Core was previously the plugin SDK;
|
||||
that role is gone, and remaining plugin-era surface is legacy to be worked
|
||||
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`.
|
||||
|
||||
33
README.md
33
README.md
@ -1,8 +1,19 @@
|
||||
# BlockNinja Core
|
||||
|
||||
Shared Go code between the BlockNinja CMS and the orchestrator: types, interfaces, and utilities both need.
|
||||
Go code 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.
|
||||
|
||||
> **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.)
|
||||
> **Scope rule:** core is ONLY for code needed by **two or more** of those
|
||||
> 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
|
||||
|
||||
@ -12,7 +23,7 @@ Shared Go code between the BlockNinja CMS and the orchestrator: types, interface
|
||||
| `blocks/` | BlockMeta, BlockFunc, BlockRegistry interface, BlockContext |
|
||||
| `blocks/builtin/` | Reusable block implementations (HTMLBlock) |
|
||||
| `templates/` | TemplateRegistry interface |
|
||||
| `templates/bn/` | Shared templ components (head, engagement, toolbar) |
|
||||
| `templates/bn/` | Synced copy of the cms-authored bn chrome (see below) |
|
||||
| `auth/` | Claims types, context extractors |
|
||||
| `content/` | Content access interface |
|
||||
| `settings/` | Settings access interface |
|
||||
@ -23,10 +34,20 @@ Shared Go code between the BlockNinja CMS and the orchestrator: types, interface
|
||||
| `ai/` | AI tool registry interface and types |
|
||||
| `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
|
||||
|
||||
Consumers are the CMS and the orchestrator only.
|
||||
|
||||
```go
|
||||
import "git.dev.alexdunmow.com/block/core/plugin"
|
||||
import "git.dev.alexdunmow.com/block/core/blocks"
|
||||
```
|
||||
|
||||
Versioned via git tags through the Gitea module proxy — never `replace`
|
||||
directives. All consumers are in-house; no backwards-compatibility shims —
|
||||
change the API and update the consumers.
|
||||
|
||||
@ -163,6 +163,10 @@ func BuildArchive(ctx context.Context, w io.Writer, in BuildInput) (*Manifest, [
|
||||
}
|
||||
|
||||
// Extra fixed entries, in a stable (sorted) order for reproducibility.
|
||||
// Failure contract (cms parity — the cssManifestsFile block in
|
||||
// buildBackupArchive): a failed Create skips the entry silently, a failed
|
||||
// Write appends a warning. Extra entries are regenerable extras; a problem
|
||||
// with one must never abort the archive.
|
||||
if len(in.Extra) > 0 {
|
||||
names := make([]string, 0, len(in.Extra))
|
||||
for name := range in.Extra {
|
||||
@ -172,10 +176,10 @@ func BuildArchive(ctx context.Context, w io.Writer, in BuildInput) (*Manifest, [
|
||||
for _, name := range names {
|
||||
entry, err := zipWriter.Create(name)
|
||||
if err != nil {
|
||||
return nil, warnings, fmt.Errorf("failed to create %s in zip: %w", name, err)
|
||||
continue
|
||||
}
|
||||
if _, err := entry.Write(in.Extra[name]); err != nil {
|
||||
return nil, warnings, fmt.Errorf("failed to write %s: %w", name, err)
|
||||
warnings = append(warnings, fmt.Sprintf("Failed to write %s: %v", name, err))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -9,6 +9,7 @@ import (
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@ -24,7 +25,7 @@ func readZipEntry(t *testing.T, data []byte, name string) []byte {
|
||||
if err != nil {
|
||||
t.Fatalf("open %s: %v", name, err)
|
||||
}
|
||||
defer rc.Close()
|
||||
defer func() { _ = rc.Close() }()
|
||||
b, err := io.ReadAll(rc)
|
||||
if err != nil {
|
||||
t.Fatalf("read %s: %v", name, err)
|
||||
@ -95,3 +96,76 @@ func TestBuildArchiveRoundTrip(t *testing.T) {
|
||||
t.Fatal("media file missing or corrupted in archive")
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildArchiveExtraEntries locks the happy path of the Extra contract: extra
|
||||
// entries land in the archive with their exact contents. The failure half of the
|
||||
// contract (Create failure → skip silently, Write failure → warning, never a
|
||||
// fatal error — cms cssManifestsFile parity) is documented at the Extra block in
|
||||
// BuildArchive; zip.Writer failures cannot be injected deterministically without
|
||||
// error-injection infrastructure, so the failure path is covered for section
|
||||
// files below where the failure is controllable.
|
||||
func TestBuildArchiveExtraEntries(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
_, warnings, err := BuildArchive(context.Background(), &buf, BuildInput{
|
||||
Source: Source{SiteTitle: "Test Site"},
|
||||
Dump: []byte("dump"),
|
||||
Password: "correct horse",
|
||||
Extra: map[string][]byte{
|
||||
"state/css-manifests.json": []byte(`{"plugins":{}}`),
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildArchive: %v", err)
|
||||
}
|
||||
if len(warnings) != 0 {
|
||||
t.Fatalf("unexpected warnings: %v", warnings)
|
||||
}
|
||||
if got := string(readZipEntry(t, buf.Bytes(), "state/css-manifests.json")); got != `{"plugins":{}}` {
|
||||
t.Fatalf("extra entry content = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildArchiveUnreadableFileWarnsAndContinues proves the warning-not-error
|
||||
// contract: a file that cannot be opened inside an existing section dir appends
|
||||
// a warning and the archive still completes with every readable file present.
|
||||
func TestBuildArchiveUnreadableFileWarnsAndContinues(t *testing.T) {
|
||||
if os.Getuid() == 0 {
|
||||
t.Skip("running as root: chmod 0 files remain readable")
|
||||
}
|
||||
dir := t.TempDir()
|
||||
mediaDir := filepath.Join(dir, "media")
|
||||
if err := os.MkdirAll(mediaDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(mediaDir, "good.txt"), []byte("ok"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
badPath := filepath.Join(mediaDir, "bad.txt")
|
||||
if err := os.WriteFile(badPath, []byte("nope"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Chmod(badPath, 0o000); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
manifest, warnings, err := BuildArchive(context.Background(), &buf, BuildInput{
|
||||
Source: Source{SiteTitle: "Test Site"},
|
||||
Dump: []byte("dump"),
|
||||
Password: "correct horse",
|
||||
Sections: []Section{{Prefix: "media/", Dir: mediaDir}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unreadable file must not abort the archive, got error: %v", err)
|
||||
}
|
||||
if len(warnings) != 1 || !strings.Contains(warnings[0], "bad.txt") {
|
||||
t.Fatalf("want exactly one warning mentioning bad.txt, got: %v", warnings)
|
||||
}
|
||||
// Both files were collected (open fails later, at write time) — cms parity.
|
||||
if manifest.MediaCount != 2 {
|
||||
t.Fatalf("media count = %d, want 2", manifest.MediaCount)
|
||||
}
|
||||
if got := string(readZipEntry(t, buf.Bytes(), "media/good.txt")); got != "ok" {
|
||||
t.Fatalf("good.txt content = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
@ -56,7 +56,7 @@ func ReadManifest(r io.ReaderAt, size int64) (*Manifest, error) {
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to open manifest.json: %w", err)
|
||||
}
|
||||
defer rc.Close()
|
||||
defer func() { _ = rc.Close() }()
|
||||
|
||||
data, err := io.ReadAll(rc)
|
||||
if err != nil {
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
// Code generated by templ - DO NOT EDIT.
|
||||
|
||||
// templ: version: v0.3.1001
|
||||
// templ: version: v0.3.1020
|
||||
package shared
|
||||
|
||||
//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
|
||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(btn.URL))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `button.templ`, Line: 8, Col: 33}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `blocks/shared/button.templ`, Line: 8, Col: 33}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@ -55,11 +55,11 @@ func RenderButton(btn ButtonConfig) templ.Component {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var4 string
|
||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var2).String())
|
||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var2).String())
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `button.templ`, Line: 1, Col: 0}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `blocks/shared/button.templ`, Line: 1, Col: 0}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var4)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@ -80,7 +80,7 @@ func RenderButton(btn ButtonConfig) templ.Component {
|
||||
var templ_7745c5c3_Var5 string
|
||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues(btn.InlineStyle())
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `button.templ`, Line: 14, Col: 29}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `blocks/shared/button.templ`, Line: 14, Col: 29}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@ -93,7 +93,7 @@ func RenderButton(btn ButtonConfig) templ.Component {
|
||||
var templ_7745c5c3_Var6 string
|
||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(btn.Text)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `button.templ`, Line: 16, Col: 14}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `blocks/shared/button.templ`, Line: 16, Col: 14}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@ -116,7 +116,7 @@ func RenderButton(btn ButtonConfig) templ.Component {
|
||||
var templ_7745c5c3_Var8 templ.SafeURL
|
||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(btn.URL))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `button.templ`, Line: 20, Col: 33}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `blocks/shared/button.templ`, Line: 20, Col: 33}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@ -127,11 +127,11 @@ func RenderButton(btn ButtonConfig) templ.Component {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var9 string
|
||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var7).String())
|
||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var7).String())
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `button.templ`, Line: 1, Col: 0}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `blocks/shared/button.templ`, Line: 1, Col: 0}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var9)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@ -152,7 +152,7 @@ func RenderButton(btn ButtonConfig) templ.Component {
|
||||
var templ_7745c5c3_Var10 string
|
||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(btn.Text)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `button.templ`, Line: 27, Col: 14}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `blocks/shared/button.templ`, Line: 27, Col: 14}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@ -175,11 +175,11 @@ func RenderButton(btn ButtonConfig) templ.Component {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var12 string
|
||||
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var11).String())
|
||||
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var11).String())
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `button.templ`, Line: 1, Col: 0}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `blocks/shared/button.templ`, Line: 1, Col: 0}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var12)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@ -190,7 +190,7 @@ func RenderButton(btn ButtonConfig) templ.Component {
|
||||
var templ_7745c5c3_Var13 string
|
||||
templ_7745c5c3_Var13, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues(btn.InlineStyle())
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `button.templ`, Line: 32, Col: 74}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `blocks/shared/button.templ`, Line: 32, Col: 74}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@ -203,7 +203,7 @@ func RenderButton(btn ButtonConfig) templ.Component {
|
||||
var templ_7745c5c3_Var14 string
|
||||
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(btn.Text)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `button.templ`, Line: 33, Col: 14}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `blocks/shared/button.templ`, Line: 33, Col: 14}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@ -224,11 +224,11 @@ func RenderButton(btn ButtonConfig) templ.Component {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var16 string
|
||||
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var15).String())
|
||||
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var15).String())
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `button.templ`, Line: 1, Col: 0}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `blocks/shared/button.templ`, Line: 1, Col: 0}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var16)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@ -239,7 +239,7 @@ func RenderButton(btn ButtonConfig) templ.Component {
|
||||
var templ_7745c5c3_Var17 string
|
||||
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(btn.Text)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `button.templ`, Line: 37, Col: 14}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `blocks/shared/button.templ`, Line: 37, Col: 14}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@ -288,11 +288,11 @@ func RenderSubmitButton(btn ButtonConfig) templ.Component {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var20 string
|
||||
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var19).String())
|
||||
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var19).String())
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `button.templ`, Line: 1, Col: 0}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `blocks/shared/button.templ`, Line: 1, Col: 0}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20))
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var20)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@ -303,7 +303,7 @@ func RenderSubmitButton(btn ButtonConfig) templ.Component {
|
||||
var templ_7745c5c3_Var21 string
|
||||
templ_7745c5c3_Var21, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues(btn.InlineStyle())
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `button.templ`, Line: 46, Col: 73}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `blocks/shared/button.templ`, Line: 46, Col: 73}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@ -316,7 +316,7 @@ func RenderSubmitButton(btn ButtonConfig) templ.Component {
|
||||
var templ_7745c5c3_Var22 string
|
||||
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(btn.Text)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `button.templ`, Line: 47, Col: 13}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `blocks/shared/button.templ`, Line: 47, Col: 13}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@ -337,11 +337,11 @@ func RenderSubmitButton(btn ButtonConfig) templ.Component {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var24 string
|
||||
templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var23).String())
|
||||
templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var23).String())
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `button.templ`, Line: 1, Col: 0}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `blocks/shared/button.templ`, Line: 1, Col: 0}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24))
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var24)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@ -352,7 +352,7 @@ func RenderSubmitButton(btn ButtonConfig) templ.Component {
|
||||
var templ_7745c5c3_Var25 string
|
||||
templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinStringErrs(btn.Text)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `button.templ`, Line: 51, Col: 13}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `blocks/shared/button.templ`, Line: 51, Col: 13}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
|
||||
@ -10,6 +10,7 @@ import (
|
||||
"encoding/hex"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
@ -34,7 +35,7 @@ type RedeemResponse struct {
|
||||
}
|
||||
|
||||
type Server struct {
|
||||
secret []byte
|
||||
secret atomic.Pointer[[]byte]
|
||||
count, size, d int
|
||||
challengeTTL time.Duration
|
||||
tokenTTL time.Duration
|
||||
@ -59,7 +60,6 @@ func WithUsedTokenStore(ns NonceStore) Option { return func(sv *Server) { sv.use
|
||||
// 32-char salts, difficulty 4, 10-min challenge / 5-min token expiry.
|
||||
func New(secret []byte, opts ...Option) *Server {
|
||||
s := &Server{
|
||||
secret: secret,
|
||||
count: 50,
|
||||
size: 32,
|
||||
d: 4,
|
||||
@ -68,12 +68,20 @@ func New(secret []byte, opts ...Option) *Server {
|
||||
nonces: NewMemoryNonceStore(),
|
||||
usedTokens: NewMemoryNonceStore(),
|
||||
}
|
||||
s.secret.Store(&secret)
|
||||
for _, o := range opts {
|
||||
o(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) {
|
||||
buf := make([]byte, 25)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
@ -81,7 +89,7 @@ func (s *Server) CreateChallenge() (ChallengeResponse, error) {
|
||||
}
|
||||
nonce := hex.EncodeToString(buf)
|
||||
expires := nowMs() + s.challengeTTL.Milliseconds()
|
||||
token := makeChallengeToken(s.secret, nonce, expires, s.count, s.size, s.d)
|
||||
token := makeChallengeToken(s.secretBytes(), nonce, expires, s.count, s.size, s.d)
|
||||
return ChallengeResponse{
|
||||
Challenge: Challenge{C: s.count, S: s.size, D: s.d},
|
||||
Token: token,
|
||||
@ -90,7 +98,7 @@ func (s *Server) CreateChallenge() (ChallengeResponse, error) {
|
||||
}
|
||||
|
||||
func (s *Server) Redeem(token string, solutions []string) RedeemResponse {
|
||||
claims := verifyChallengeToken(s.secret, token)
|
||||
claims := verifyChallengeToken(s.secretBytes(), token)
|
||||
if claims == nil {
|
||||
return RedeemResponse{Success: false}
|
||||
}
|
||||
@ -111,7 +119,7 @@ func (s *Server) Redeem(token string, solutions []string) RedeemResponse {
|
||||
}
|
||||
}
|
||||
expires := nowMs() + s.tokenTTL.Milliseconds()
|
||||
vt, err := makeVerificationToken(s.secret, expires)
|
||||
vt, err := makeVerificationToken(s.secretBytes(), expires)
|
||||
if err != nil {
|
||||
return RedeemResponse{Success: false}
|
||||
}
|
||||
@ -132,7 +140,7 @@ func (s *Server) Redeem(token string, solutions []string) RedeemResponse {
|
||||
// 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.
|
||||
func (s *Server) VerifyToken(token string) bool {
|
||||
claims := parseVerificationToken(s.secret, token)
|
||||
claims := parseVerificationToken(s.secretBytes(), token)
|
||||
if claims == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
@ -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
|
||||
git init -q && git commit --allow-empty -qm "initial"
|
||||
```
|
||||
- [x] Run: `ninja plugin init --host https://my.localdev.blockninjacms.com --scope @themes --name uat-a1` (answer: kind=plugin, categories=1,2)
|
||||
- [x] Run: `ninja plugin init --host https://my.blockninja.dev --scope @themes --name uat-a1` (answer: kind=plugin, categories=1,2)
|
||||
- [x] Observe `cat plugin.mod` includes:
|
||||
- `name = "uat-a1"`
|
||||
- `scope = "@themes"`
|
||||
@ -85,7 +85,7 @@ uses; commands assume access via `podman exec blockninja-db psql -U orchestrator
|
||||
git tag --list # capture before
|
||||
git rev-parse HEAD # capture HEAD before
|
||||
```
|
||||
- [x] Run `ninja plugin publish --host https://my.localdev.blockninjacms.com`.
|
||||
- [x] Run `ninja plugin publish --host https://my.blockninja.dev`.
|
||||
- [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] `git rev-parse HEAD` is unchanged.
|
||||
@ -162,7 +162,7 @@ uses; commands assume access via `podman exec blockninja-db psql -U orchestrator
|
||||
- [x] Run:
|
||||
```bash
|
||||
curl -sS -X POST \
|
||||
https://my.localdev.blockninjacms.com/orchestrator.v1.PluginRegistryService/ListCategories \
|
||||
https://my.blockninja.dev/orchestrator.v1.PluginRegistryService/ListCategories \
|
||||
-H 'Content-Type: application/json' -d '{}'
|
||||
```
|
||||
- [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:
|
||||
```bash
|
||||
TOKEN=$(jq -r '.hosts["https://my.localdev.blockninjacms.com"].token' ~/.config/ninja/creds.json)
|
||||
TOKEN=$(jq -r '.hosts["https://my.blockninja.dev"].token' ~/.config/ninja/creds.json)
|
||||
curl -sS -X POST \
|
||||
https://my.localdev.blockninjacms.com/orchestrator.v1.PluginRegistryService/CreatePlugin \
|
||||
https://my.blockninja.dev/orchestrator.v1.PluginRegistryService/CreatePlugin \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-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"]:
|
||||
```bash
|
||||
curl -sS -X POST \
|
||||
https://my.localdev.blockninjacms.com/orchestrator.v1.PluginRegistryService/CreatePlugin \
|
||||
https://my.blockninja.dev/orchestrator.v1.PluginRegistryService/CreatePlugin \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-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`:
|
||||
```bash
|
||||
curl -sS -X POST \
|
||||
https://my.localdev.blockninjacms.com/orchestrator.v1.PluginRegistryService/ListPlugins \
|
||||
https://my.blockninja.dev/orchestrator.v1.PluginRegistryService/ListPlugins \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-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:
|
||||
```bash
|
||||
curl -sS -o /dev/null -w "%{http_code}\n" \
|
||||
https://my.localdev.blockninjacms.com/git/themes/lcars.git/info/refs?service=git-upload-pack
|
||||
https://my.blockninja.dev/git/themes/lcars.git/info/refs?service=git-upload-pack
|
||||
```
|
||||
- [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:
|
||||
```bash
|
||||
curl -sS -X POST \
|
||||
https://my.localdev.blockninjacms.com/orchestrator.v1.PluginRegistryService/ResolveInstall \
|
||||
https://my.blockninja.dev/orchestrator.v1.PluginRegistryService/ResolveInstall \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"scopeSlug":"themes","pluginName":"uat-a1","versionOrChannel":"0.1.0"}'
|
||||
```
|
||||
|
||||
@ -1371,8 +1371,8 @@ podman compose up -d orchestrator-backend
|
||||
mkdir -p /tmp/smokeplugin && cd /tmp/smokeplugin
|
||||
git init -q
|
||||
git commit --allow-empty -qm "initial"
|
||||
ninja login --host https://my.localdev.blockninjacms.com
|
||||
ninja plugin init --host https://my.localdev.blockninjacms.com --scope @themes --name smoke
|
||||
ninja login --host https://my.blockninja.dev
|
||||
ninja plugin init --host https://my.blockninja.dev --scope @themes --name smoke
|
||||
git log --oneline
|
||||
```
|
||||
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**
|
||||
|
||||
```bash
|
||||
ninja plugin publish --host https://my.localdev.blockninjacms.com
|
||||
ninja plugin publish --host https://my.blockninja.dev
|
||||
```
|
||||
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**
|
||||
|
||||
```bash
|
||||
ninja plugin publish --host https://my.localdev.blockninjacms.com
|
||||
ninja plugin publish --host https://my.blockninja.dev
|
||||
```
|
||||
Expected: error mentioning "version already published".
|
||||
|
||||
@ -2226,7 +2226,7 @@ podman compose up -d orchestrator-backend
|
||||
- [ ] **Step 2: Verify ListCategories**
|
||||
|
||||
```bash
|
||||
curl -sS https://my.localdev.blockninjacms.com/orchestrator.v1.PluginRegistryService/ListCategories \
|
||||
curl -sS https://my.blockninja.dev/orchestrator.v1.PluginRegistryService/ListCategories \
|
||||
-H 'Content-Type: application/json' -d '{}'
|
||||
```
|
||||
Expected: JSON listing the 8 seeded categories.
|
||||
@ -2236,7 +2236,7 @@ Expected: JSON listing the 8 seeded categories.
|
||||
```bash
|
||||
mkdir -p /tmp/themeplugin && cd /tmp/themeplugin
|
||||
git init -q && git commit --allow-empty -qm "initial"
|
||||
ninja plugin init --host https://my.localdev.blockninjacms.com --scope @themes --name aurora
|
||||
ninja plugin init --host https://my.blockninja.dev --scope @themes --name aurora
|
||||
# At the kind prompt, choose 2 (theme).
|
||||
cat plugin.mod
|
||||
```
|
||||
@ -2247,7 +2247,7 @@ Expected: `kind = "theme"` set, no categories line.
|
||||
```bash
|
||||
mkdir -p /tmp/normalplugin && cd /tmp/normalplugin
|
||||
git init -q && git commit --allow-empty -qm "initial"
|
||||
ninja plugin init --host https://my.localdev.blockninjacms.com --scope @themes --name foometrics
|
||||
ninja plugin init --host https://my.blockninja.dev --scope @themes --name foometrics
|
||||
# kind: 1 (plugin). categories: pick a couple, e.g. "1,2"
|
||||
cat plugin.mod
|
||||
```
|
||||
@ -2257,7 +2257,7 @@ Expected: `kind = "plugin"` and `categories = ["analytics", "seo"]`.
|
||||
|
||||
```bash
|
||||
cd /tmp/normalplugin
|
||||
ninja plugin publish --host https://my.localdev.blockninjacms.com
|
||||
ninja plugin publish --host https://my.blockninja.dev
|
||||
```
|
||||
Expected: success.
|
||||
|
||||
@ -2277,7 +2277,7 @@ cd /tmp/normalplugin
|
||||
sed -i 's/\["analytics", "seo"\]/["analytics", "seo", "bogus"]/' plugin.mod
|
||||
git add plugin.mod && git commit -qm "tweak"
|
||||
ninja plugin bump
|
||||
ninja plugin publish --host https://my.localdev.blockninjacms.com
|
||||
ninja plugin publish --host https://my.blockninja.dev
|
||||
```
|
||||
Expected: error: "plugin.mod categories do not match registered categories"
|
||||
(the plugin row's categories don't include "bogus"). The mismatch check
|
||||
|
||||
3
go.mod
3
go.mod
@ -4,9 +4,9 @@ go 1.26.4
|
||||
|
||||
require (
|
||||
connectrpc.com/connect v1.20.0
|
||||
git.dev.alexdunmow.com/block/ninjatpl v1.0.2
|
||||
github.com/BurntSushi/toml v1.6.0
|
||||
github.com/a-h/templ v0.3.1020
|
||||
github.com/flosch/pongo2/v6 v6.1.0
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/jackc/pgx/v5 v5.9.2
|
||||
github.com/tetratelabs/wazero v1.12.0
|
||||
@ -18,7 +18,6 @@ require (
|
||||
require (
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/rogpeppe/go-internal v1.15.0 // indirect
|
||||
golang.org/x/sys v0.44.0 // indirect
|
||||
golang.org/x/text v0.36.0 // indirect
|
||||
)
|
||||
|
||||
12
go.sum
12
go.sum
@ -1,5 +1,7 @@
|
||||
connectrpc.com/connect v1.20.0 h1:6TNDAB+WeNd2uolWNlYczB5E0KNNaVMNUEx8JEUsPmQ=
|
||||
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.2/go.mod h1:WrLz0KysnP5EzJlRCkU2VC1uvazyAXZR99Tn+3Mx1pw=
|
||||
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
|
||||
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
||||
github.com/a-h/templ v0.3.1020 h1:ypAT/L5ySWEnZ6Zft/5yfoWXYYkhFNvEFOeeqecg4tw=
|
||||
@ -7,8 +9,6 @@ github.com/a-h/templ v0.3.1020/go.mod h1:A2DlK61v+K+NRoGnhmYbNYVmtYHcFO5/AisMvBd
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/flosch/pongo2/v6 v6.1.0 h1:A/NJbrQJJD2B2mbpw3DRFwBYG0xpCr3vwFlEr46y1HQ=
|
||||
github.com/flosch/pongo2/v6 v6.1.0/go.mod h1:CuDpFm47R0uGGE7z13/tTlt1Y6zdxvr2RLT5LJhsHEU=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
@ -21,14 +21,8 @@ github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw=
|
||||
github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
|
||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
|
||||
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
|
||||
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/rogpeppe/go-internal v1.15.0 h1:D0RCU5rMAp+SpgkiNdrjfJ+LX4J1M32V2NeCY7EJ6hc=
|
||||
github.com/rogpeppe/go-internal v1.15.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
@ -49,8 +43,6 @@ golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
||||
2
proto
2
proto
@ -1 +1 @@
|
||||
Subproject commit 06dcc6a5fa996f0527862acf7b7f04c4b893a06e
|
||||
Subproject commit 1ca85e3bb64e83a5764637339ab90a86da96ca09
|
||||
11
templates/bn/asset_hooks.go
Normal file
11
templates/bn/asset_hooks.go
Normal file
@ -0,0 +1,11 @@
|
||||
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 }
|
||||
@ -1,6 +1,6 @@
|
||||
// Code generated by templ - DO NOT EDIT.
|
||||
|
||||
// templ: version: v0.3.1001
|
||||
// templ: version: v0.3.1020
|
||||
package bn
|
||||
|
||||
//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
|
||||
}
|
||||
var templ_7745c5c3_Var2 string
|
||||
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(engagementConfigJSON(config))
|
||||
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue(engagementConfigJSON(config))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `engagement.templ`, Line: 30, Col: 63}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
|
||||
@ -16,34 +16,69 @@ func resolveMediaURL(url string) string {
|
||||
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
|
||||
type BrandingData struct {
|
||||
FaviconICO string // /brand/{id}/favicon.ico
|
||||
Favicon16 string // /brand/{id}/favicon-16x16.png
|
||||
Favicon32 string // /brand/{id}/favicon-32x32.png
|
||||
AppleTouchIcon string // /brand/{id}/apple-touch-icon.png (180x180)
|
||||
Android192 string // /brand/{id}/android-chrome-192x192.png
|
||||
Android512 string // /brand/{id}/android-chrome-512x512.png
|
||||
Maskable512 string // /brand/{id}/maskable-512x512.png
|
||||
Master1024 string // /brand/{id}/icon-1024x1024.png
|
||||
ManifestURL string // /brand/{id}/site.webmanifest
|
||||
FaviconICO string // /.brand/{id}/favicon.ico
|
||||
Favicon16 string // /.brand/{id}/favicon-16x16.png
|
||||
Favicon32 string // /.brand/{id}/favicon-32x32.png
|
||||
Favicon96 string // /.brand/{id}/favicon-96x96.png
|
||||
AppleTouchIcon string // /.brand/{id}/apple-touch-icon.png (180x180)
|
||||
Android192 string // /.brand/{id}/android-chrome-192x192.png
|
||||
Android512 string // /.brand/{id}/android-chrome-512x512.png
|
||||
Maskable512 string // /.brand/{id}/maskable-512x512.png
|
||||
Master1024 string // /.brand/{id}/icon-1024x1024.png
|
||||
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
|
||||
SVG string // Optional SVG pass-through
|
||||
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
|
||||
type SiteSettingsData struct {
|
||||
Title string
|
||||
Description string
|
||||
Favicon string
|
||||
Logo string
|
||||
LogoAlt string
|
||||
AppleTouchIcon string
|
||||
GoogleAnalyticsID string
|
||||
CustomHeadScripts string
|
||||
CustomBodyScripts string
|
||||
MetaDescription string
|
||||
Title string
|
||||
Description string
|
||||
Favicon string
|
||||
Logo string
|
||||
LogoAlt string
|
||||
AppleTouchIcon string
|
||||
GoogleAnalyticsID string
|
||||
CustomScripts []CustomScriptEntry
|
||||
GoogleAnalyticsEnabled bool
|
||||
GoogleAnalyticsExplicitlySet bool // true if the enabled flag was explicitly set in JSON (not nil)
|
||||
MetaDescription string
|
||||
DefaultOGImage string
|
||||
TwitterHandle string
|
||||
OGSiteName string
|
||||
@ -67,6 +102,10 @@ type PageMeta struct {
|
||||
TwitterImage string // Twitter card image URL
|
||||
CanonicalURL string // Canonical URL for this page
|
||||
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
|
||||
@ -159,15 +198,75 @@ func ParseSiteSettings(doc map[string]any) SiteSettingsData {
|
||||
}
|
||||
|
||||
// Analytics
|
||||
var legacyHeadScripts, legacyBodyScripts string
|
||||
if analyticsData, ok := siteData["analytics"].(map[string]any); ok {
|
||||
if v, ok := analyticsData["google_analytics_id"].(string); ok {
|
||||
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 {
|
||||
settings.CustomHeadScripts = v
|
||||
legacyHeadScripts = v
|
||||
}
|
||||
if v, ok := analyticsData["custom_body_scripts"].(string); ok {
|
||||
settings.CustomBodyScripts = v
|
||||
legacyBodyScripts = 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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@ -209,6 +308,18 @@ func ParseSiteSettings(doc map[string]any) SiteSettingsData {
|
||||
if v, ok := brandingData["svg"].(string); ok {
|
||||
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)
|
||||
@ -276,6 +387,18 @@ func ParsePageMeta(doc map[string]any) PageMeta {
|
||||
if v, ok := doc["robotsDirective"].(string); ok {
|
||||
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
|
||||
}
|
||||
@ -310,6 +433,12 @@ func ParseToolbarData(data map[string]any) ToolbarData {
|
||||
if v, ok := data["preview_mode"].(string); ok {
|
||||
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 {
|
||||
toolbar.Position = v
|
||||
}
|
||||
@ -390,15 +519,30 @@ templ Head(data HeadData) {
|
||||
@themeInitScript(data.ThemeMode)
|
||||
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
|
||||
if data.Settings.Branding.IsGenerated {
|
||||
// Use generated brand assets
|
||||
<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 }/>
|
||||
// Use generated brand assets (modern RFG-parity set; SVG first)
|
||||
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="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 }/>
|
||||
if 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 {
|
||||
// Legacy fallback - manual favicon uploads
|
||||
if data.Settings.Favicon != "" {
|
||||
@ -418,9 +562,9 @@ templ Head(data HeadData) {
|
||||
<meta name="description" content={ data.Settings.MetaDescription }/>
|
||||
}
|
||||
|
||||
// Canonical URL (page-level only, no site default)
|
||||
if data.PageMeta.CanonicalURL != "" {
|
||||
<link rel="canonical" href={ data.PageMeta.CanonicalURL }/>
|
||||
// Canonical URL: admin override (resolved to absolute) → auto self-canonical (M8)
|
||||
if canonical := canonicalURL(data.PageMeta.CanonicalURL, data.PageMeta.PageURL); canonical != "" {
|
||||
<link rel="canonical" href={ canonical }/>
|
||||
}
|
||||
|
||||
// Robots directive (page-level only - defaults to index,follow if not set)
|
||||
@ -459,8 +603,25 @@ templ Head(data HeadData) {
|
||||
<meta property="og:image" content={ resolveMediaURL(data.Settings.DefaultOGImage) }/>
|
||||
}
|
||||
|
||||
// og:type - default to website
|
||||
<meta property="og:type" content="website"/>
|
||||
// og:type: article for posts, website otherwise (M5)
|
||||
if data.PageMeta.OGType != "" {
|
||||
<meta property="og:type" content={ data.PageMeta.OGType }/>
|
||||
} else {
|
||||
<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 ===
|
||||
if data.Settings.TwitterHandle != "" {
|
||||
@ -512,7 +673,7 @@ templ Head(data HeadData) {
|
||||
<link rel="alternate" type="application/atom+xml" title={ data.Settings.RSSFeedTitle + " (Atom)" } href={ data.Settings.RSSFeedURL + "/atom" }/>
|
||||
}
|
||||
|
||||
if data.Settings.GoogleAnalyticsID != "" {
|
||||
if data.Settings.GoogleAnalyticsID != "" && (data.Settings.GoogleAnalyticsEnabled || !data.Settings.GoogleAnalyticsExplicitlySet) {
|
||||
<script async src={ "https://www.googletagmanager.com/gtag/js?id=" + data.Settings.GoogleAnalyticsID }></script>
|
||||
@googleAnalyticsScript(data.Settings.GoogleAnalyticsID)
|
||||
}
|
||||
@ -521,14 +682,16 @@ templ Head(data HeadData) {
|
||||
// Blog post engagement tracking (only rendered for posts with engagement config)
|
||||
@EngagementScript(data.EngagementConfig)
|
||||
for _, style := range data.PluginStyles {
|
||||
<link rel="stylesheet" href={ style }/>
|
||||
<link rel="stylesheet" href={ VersionedAssetURL(style) }/>
|
||||
}
|
||||
// Theme CSS injected AFTER plugin styles to take precedence
|
||||
if data.ThemeCSS != "" {
|
||||
@themeStyle(data.ThemeCSS)
|
||||
}
|
||||
if data.Settings.CustomHeadScripts != "" {
|
||||
@templ.Raw(data.Settings.CustomHeadScripts)
|
||||
for _, script := range data.Settings.CustomScripts {
|
||||
if script.Enabled && script.Placement == "head" {
|
||||
@templ.Raw(script.Code)
|
||||
}
|
||||
}
|
||||
if data.StructuredData != "" {
|
||||
@structuredDataScript(data.StructuredData)
|
||||
@ -570,8 +733,10 @@ templ AdminBypassBanner(settings SiteSettingsData) {
|
||||
// BodyEnd renders custom scripts and admin toolbar before </body>
|
||||
templ BodyEnd(settings SiteSettingsData) {
|
||||
@recordValidationCall(ctx, "BodyEnd")
|
||||
if settings.CustomBodyScripts != "" {
|
||||
@templ.Raw(settings.CustomBodyScripts)
|
||||
for _, script := range settings.CustomScripts {
|
||||
if script.Enabled && script.Placement == "body" {
|
||||
@templ.Raw(script.Code)
|
||||
}
|
||||
}
|
||||
// Render admin editor toolbar if enabled (auto-injects for all templates)
|
||||
@AdminEditorToolbar(settings.Toolbar)
|
||||
@ -775,15 +940,22 @@ func themeInitScript(themeMode string) templ.Component {
|
||||
default:
|
||||
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>
|
||||
(function(){
|
||||
var c=document.cookie.match(/(?:^|; )bn-theme=([^;]*)/);
|
||||
var t=c?c[1]:localStorage.getItem('bn-theme');
|
||||
window.bnApplyTheme=function(){
|
||||
var t=null;try{t=sessionStorage.getItem('bn-theme-override')}catch(e){}
|
||||
if(!t){
|
||||
var c=document.cookie.match(/(?:^|; )bn-theme=([^;]*)/);
|
||||
t=c?c[1]:localStorage.getItem('bn-theme');
|
||||
}
|
||||
if(!t)t='` + themeMode + `';
|
||||
var h=document.documentElement;
|
||||
if(t==='system')t=window.matchMedia('(prefers-color-scheme:dark)').matches?'dark':'light';
|
||||
if(t==='dark')h.classList.add('dark');
|
||||
else h.classList.remove('dark');
|
||||
})();
|
||||
document.documentElement.classList.toggle('dark',t==='dark');
|
||||
return t;
|
||||
};
|
||||
window.bnApplyTheme();
|
||||
</script>`)
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
@ -17,6 +17,8 @@ type ToolbarData struct {
|
||||
Status string // "published", "draft", "scheduled"
|
||||
HasUnpublishedChanges bool
|
||||
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")
|
||||
ScheduledAt *time.Time
|
||||
TemplateName string
|
||||
@ -135,6 +137,20 @@ func (t ToolbarData) DropdownAlign() string {
|
||||
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
|
||||
func (t ToolbarData) PositionClasses() string {
|
||||
switch t.Position {
|
||||
@ -188,7 +204,7 @@ templ AdminEditorToolbar(data ToolbarData) {
|
||||
if data.Enabled {
|
||||
<div
|
||||
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() }
|
||||
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() }
|
||||
>
|
||||
<style>
|
||||
/* Toolbar: dark by default (for light pages), light when page has .dark class */
|
||||
@ -205,6 +221,33 @@ templ AdminEditorToolbar(data ToolbarData) {
|
||||
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);
|
||||
}
|
||||
/* 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) */
|
||||
.dark .bn-toolbar, html.dark .bn-toolbar, body.dark .bn-toolbar {
|
||||
--bn-toolbar-bg: rgba(250, 250, 250, 0.95);
|
||||
@ -225,6 +268,13 @@ templ AdminEditorToolbar(data ToolbarData) {
|
||||
.bn-toolbar .bn-toolbar-primary:hover {
|
||||
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 */
|
||||
.bn-toolbar-dropdown {
|
||||
background: var(--bn-toolbar-bg);
|
||||
@ -262,28 +312,120 @@ 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="bn-toolbar-muted text-xs hidden sm:inline">{ data.StatusLabel() }</span>
|
||||
</div>
|
||||
if !data.HidePreviewToggle {
|
||||
<!-- Divider -->
|
||||
<div class="bn-toolbar-divider w-px h-5"></div>
|
||||
<!-- Preview toggle - compact -->
|
||||
<button
|
||||
type="button"
|
||||
class={ "bn-toolbar-hover inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs transition-colors", templ.KV("!bg-info/20 text-info", data.IsPreviewMode()), templ.KV("bn-toolbar-muted", !data.IsPreviewMode()) }
|
||||
onclick={ togglePreviewMode(data.IsPreviewMode()) }
|
||||
title={ func() string { if data.IsPreviewMode() { return "Viewing draft - click to view published" } else { return "Viewing published - click to preview draft" } }() }
|
||||
>
|
||||
if data.IsPreviewMode() {
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-3.5 w-3.5" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path d="M10 12a2 2 0 100-4 2 2 0 000 4z"></path>
|
||||
<path fill-rule="evenodd" d="M.458 10C1.732 5.943 5.522 3 10 3s8.268 2.943 9.542 7c-1.274 4.057-5.064 7-9.542 7S1.732 14.057.458 10zM14 10a4 4 0 11-8 0 4 4 0 018 0z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
<span class="hidden sm:inline">Draft</span>
|
||||
} else {
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-3.5 w-3.5" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
<span class="hidden sm:inline">Live</span>
|
||||
}
|
||||
</button>
|
||||
}
|
||||
<!-- Divider -->
|
||||
<div class="bn-toolbar-divider w-px h-5"></div>
|
||||
<!-- Preview toggle - compact -->
|
||||
<!-- 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"
|
||||
class={ "bn-toolbar-hover inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs transition-colors", templ.KV("!bg-info/20 text-info", data.IsPreviewMode()), templ.KV("bn-toolbar-muted", !data.IsPreviewMode()) }
|
||||
onclick={ togglePreviewMode(data.IsPreviewMode()) }
|
||||
title={ func() string { if data.IsPreviewMode() { return "Viewing draft - click to view published" } else { return "Viewing published - click to preview draft" } }() }
|
||||
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"
|
||||
>
|
||||
if data.IsPreviewMode() {
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-3.5 w-3.5" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path d="M10 12a2 2 0 100-4 2 2 0 000 4z"></path>
|
||||
<path fill-rule="evenodd" d="M.458 10C1.732 5.943 5.522 3 10 3s8.268 2.943 9.542 7c-1.274 4.057-5.064 7-9.542 7S1.732 14.057.458 10zM14 10a4 4 0 11-8 0 4 4 0 018 0z" clip-rule="evenodd"></path>
|
||||
<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 class="hidden sm:inline">Draft</span>
|
||||
} else {
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-3.5 w-3.5" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"></path>
|
||||
</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 class="hidden sm:inline">Live</span>
|
||||
}
|
||||
</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 -->
|
||||
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">
|
||||
@ -347,6 +489,21 @@ templ AdminEditorToolbar(data ToolbarData) {
|
||||
// PageInfoDropdown renders the page info and quick actions dropdown
|
||||
templ PageInfoDropdown(data ToolbarData) {
|
||||
<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 -->
|
||||
if data.TodayPageviews > 0 || data.PageviewsTrend != "" {
|
||||
<div class="bn-dd-border px-3 py-2 border-b">
|
||||
@ -482,15 +639,16 @@ templ PageInfoDropdown(data ToolbarData) {
|
||||
|
||||
// positionButton renders a position selector button
|
||||
templ positionButton(pageID uuid.UUID, pos string, currentPos string, label string) {
|
||||
{{ active := pos == currentPos || (currentPos == "" && pos == "tr") }}
|
||||
<button
|
||||
type="button"
|
||||
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")) }
|
||||
class={ "bn-dd-poschip w-8 h-6 rounded border transition-colors flex items-center justify-center", templ.KV("bn-dd-poschip-active", active) }
|
||||
hx-post={ fmt.Sprintf("/toolbar/set-position/%s?pos=%s", pageID, pos) }
|
||||
hx-target="#bn-admin-toolbar"
|
||||
hx-swap="outerHTML"
|
||||
title={ label }
|
||||
>
|
||||
<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>
|
||||
<span class={ "w-1.5 h-1.5 rounded-full", templ.KV("bn-dd-dot-active", active), templ.KV("bn-dd-dot", !active) }></span>
|
||||
</button>
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
@ -4,15 +4,15 @@ import (
|
||||
"context"
|
||||
"maps"
|
||||
|
||||
"github.com/flosch/pongo2/v6"
|
||||
"git.dev.alexdunmow.com/block/ninjatpl"
|
||||
|
||||
"git.dev.alexdunmow.com/block/core/blocks"
|
||||
"git.dev.alexdunmow.com/block/core/templates/bn"
|
||||
)
|
||||
|
||||
// buildPageContext builds a pongo2.Context with pre-rendered head/body HTML
|
||||
// buildPageContext builds a ninjatpl.Context with pre-rendered head/body HTML
|
||||
// and all page-level variables from the standard doc map.
|
||||
func (e *Engine) buildPageContext(ctx context.Context, doc map[string]any) pongo2.Context {
|
||||
func (e *Engine) buildPageContext(ctx context.Context, doc map[string]any) ninjatpl.Context {
|
||||
title := "Untitled"
|
||||
if t, ok := doc["title"].(string); ok && t != "" {
|
||||
title = t
|
||||
@ -72,7 +72,7 @@ func (e *Engine) buildPageContext(ctx context.Context, doc map[string]any) pongo
|
||||
slotsAny[k] = v
|
||||
}
|
||||
|
||||
return pongo2.Context{
|
||||
return ninjatpl.Context{
|
||||
"head_html": headHTML,
|
||||
"body_end_html": bodyEndHTML,
|
||||
"admin_banner_html": bannerHTML,
|
||||
@ -88,10 +88,10 @@ func (e *Engine) buildPageContext(ctx context.Context, doc map[string]any) pongo
|
||||
}
|
||||
}
|
||||
|
||||
// buildBlockContext builds a pongo2.Context for block rendering.
|
||||
// buildBlockContext builds a ninjatpl.Context for block rendering.
|
||||
// Content fields are available directly; request context is under "ctx".
|
||||
func buildBlockContext(ctx context.Context, content map[string]any) pongo2.Context {
|
||||
pongoCtx := make(pongo2.Context, len(content)+1)
|
||||
func buildBlockContext(ctx context.Context, content map[string]any) ninjatpl.Context {
|
||||
pongoCtx := make(ninjatpl.Context, len(content)+1)
|
||||
maps.Copy(pongoCtx, content)
|
||||
if bc := blocks.GetBlockContext(ctx); bc != nil {
|
||||
pongoCtx["ctx"] = bc.ToMap()
|
||||
|
||||
@ -7,7 +7,7 @@ import (
|
||||
"io/fs"
|
||||
"maps"
|
||||
|
||||
"github.com/flosch/pongo2/v6"
|
||||
"git.dev.alexdunmow.com/block/ninjatpl"
|
||||
|
||||
"git.dev.alexdunmow.com/block/core/blocks"
|
||||
"git.dev.alexdunmow.com/block/core/templates"
|
||||
@ -18,7 +18,7 @@ import (
|
||||
// MustPageTemplate / MustBlockTemplate to get functions compatible with
|
||||
// the template and block registries.
|
||||
type Engine struct {
|
||||
set *pongo2.TemplateSet
|
||||
set *ninjatpl.TemplateSet
|
||||
stylePaths []string
|
||||
}
|
||||
|
||||
@ -27,19 +27,19 @@ type Engine struct {
|
||||
// stylePaths are CSS URLs included in the page <head> via bn.Head.
|
||||
func NewEngine(pluginFS fs.FS, stylePaths ...string) *Engine {
|
||||
loader := &multiLoader{
|
||||
loaders: []pongo2.TemplateLoader{
|
||||
loaders: []ninjatpl.TemplateLoader{
|
||||
&fsLoader{fsys: pluginFS},
|
||||
&fsLoader{fsys: baseFS},
|
||||
},
|
||||
}
|
||||
set := pongo2.NewSet("plugin", loader)
|
||||
set := ninjatpl.NewSet("plugin", loader)
|
||||
return &Engine{set: set, stylePaths: stylePaths}
|
||||
}
|
||||
|
||||
// pongoComponent wraps a pongo2 template execution as an HTMLComponent.
|
||||
type pongoComponent struct {
|
||||
tpl *pongo2.Template
|
||||
ctx pongo2.Context
|
||||
tpl *ninjatpl.Template
|
||||
ctx ninjatpl.Context
|
||||
}
|
||||
|
||||
func (c *pongoComponent) Render(ctx context.Context, w io.Writer) error {
|
||||
@ -62,7 +62,7 @@ func (c *pongoComponent) Render(ctx context.Context, w io.Writer) error {
|
||||
// {{ site_settings }} — bn.SiteSettingsData struct
|
||||
// {{ page_meta }} — bn.PageMeta struct
|
||||
func (e *Engine) MustPageTemplate(name string) templates.TemplateFunc {
|
||||
tpl := pongo2.Must(e.set.FromFile(name))
|
||||
tpl := ninjatpl.Must(e.set.FromFile(name))
|
||||
return func(ctx context.Context, doc map[string]any) templates.HTMLComponent {
|
||||
pongoCtx := e.buildPageContext(ctx, doc)
|
||||
return &pongoComponent{tpl: tpl, ctx: pongoCtx}
|
||||
@ -75,7 +75,7 @@ func (e *Engine) MustPageTemplate(name string) templates.TemplateFunc {
|
||||
// The content map fields are available directly as template variables.
|
||||
// Request context is available under {{ ctx.url }}, {{ ctx.isEditor }}, etc.
|
||||
func (e *Engine) MustBlockTemplate(name string) blocks.BlockFunc {
|
||||
tpl := pongo2.Must(e.set.FromFile(name))
|
||||
tpl := ninjatpl.Must(e.set.FromFile(name))
|
||||
return func(ctx context.Context, content map[string]any) string {
|
||||
pongoCtx := buildBlockContext(ctx, content)
|
||||
out, err := tpl.Execute(pongoCtx)
|
||||
@ -89,7 +89,7 @@ func (e *Engine) MustBlockTemplate(name string) blocks.BlockFunc {
|
||||
// MustBlockTemplateWithDefaults is like MustBlockTemplate but merges default
|
||||
// values before rendering. Content keys override defaults.
|
||||
func (e *Engine) MustBlockTemplateWithDefaults(name string, defaults map[string]any) blocks.BlockFunc {
|
||||
tpl := pongo2.Must(e.set.FromFile(name))
|
||||
tpl := ninjatpl.Must(e.set.FromFile(name))
|
||||
return func(ctx context.Context, content map[string]any) string {
|
||||
merged := make(map[string]any, len(defaults)+len(content))
|
||||
maps.Copy(merged, defaults)
|
||||
@ -129,9 +129,9 @@ func (e *Engine) MustTemplateOverride(name string) blocks.BlockFunc {
|
||||
// {{ colors.muted }} — muted hex color
|
||||
// (and all other EmailColors fields as lowercase keys)
|
||||
func (e *Engine) MustEmailWrapper(name string) templates.EmailWrapperFunc {
|
||||
tpl := pongo2.Must(e.set.FromFile(name))
|
||||
tpl := ninjatpl.Must(e.set.FromFile(name))
|
||||
return func(body string, ctx templates.EmailContext) string {
|
||||
pongoCtx := pongo2.Context{
|
||||
pongoCtx := ninjatpl.Context{
|
||||
"body": body,
|
||||
"site_name": ctx.SiteSettings.SiteName,
|
||||
"site_url": ctx.SiteSettings.SiteURL,
|
||||
|
||||
@ -5,7 +5,7 @@ import (
|
||||
"io"
|
||||
"io/fs"
|
||||
|
||||
"github.com/flosch/pongo2/v6"
|
||||
"git.dev.alexdunmow.com/block/ninjatpl"
|
||||
)
|
||||
|
||||
// fsLoader adapts an fs.FS to pongo2's TemplateLoader interface.
|
||||
@ -25,7 +25,7 @@ func (l *fsLoader) Get(path string) (io.Reader, error) {
|
||||
// Plugin templates can {% extends "base.html" %} where base.html
|
||||
// lives in the core embedded FS rather than the plugin FS.
|
||||
type multiLoader struct {
|
||||
loaders []pongo2.TemplateLoader
|
||||
loaders []ninjatpl.TemplateLoader
|
||||
}
|
||||
|
||||
func (l *multiLoader) Abs(base, name string) string {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user