refactor: simplify pongo template context and engine, add private plugins spec
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
26b262ce73
commit
f4e579ad7a
340
docs/superpowers/specs/2026-06-07-private-plugins-design.md
Normal file
340
docs/superpowers/specs/2026-06-07-private-plugins-design.md
Normal file
@ -0,0 +1,340 @@
|
|||||||
|
# Private plugins — design
|
||||||
|
|
||||||
|
Date: 2026-06-07
|
||||||
|
Status: Draft (core + orchestrator backend landed; CMS UI + publisher dashboard TODO)
|
||||||
|
|
||||||
|
## Motivation
|
||||||
|
|
||||||
|
`ninja plugin publish` currently lands every plugin in the shared public
|
||||||
|
registry under a developer-chosen scope (`@themes/foo`, `@myorg/bar`). There
|
||||||
|
is no way to publish a plugin that is restricted to a single account — useful
|
||||||
|
for in-house themes, internal blocks, or per-customer customisations that
|
||||||
|
should never appear in public listings.
|
||||||
|
|
||||||
|
We want a "private plugin" lane that:
|
||||||
|
|
||||||
|
1. The developer opts into with `ninja plugin publish --private`.
|
||||||
|
2. Is scoped to the publisher's **active account** (not a global namespace) so
|
||||||
|
anyone in that account can install it, and no one outside can see it.
|
||||||
|
3. Has CLI ergonomics, a publisher dashboard ("manage what I've published"),
|
||||||
|
and an "easy installer" in the CMS admin so site operators can browse and
|
||||||
|
install their account's private plugins in two clicks.
|
||||||
|
|
||||||
|
Greenfield — the registry currently has no live private plugins. We can change
|
||||||
|
shapes freely and the only existing rows are public.
|
||||||
|
|
||||||
|
## Design decisions
|
||||||
|
|
||||||
|
These were locked in during brainstorming, in order of how foundational they
|
||||||
|
are:
|
||||||
|
|
||||||
|
1. **Scope slug = sentinel `@private`.** Every private plugin lives under one
|
||||||
|
global `@private` namespace. Identity is `(owner_account_id, name)`, not
|
||||||
|
the scope slug, so two accounts can each have an `@private/myplugin`
|
||||||
|
without colliding. Coords are only meaningful inside an account.
|
||||||
|
2. **Account membership is the access boundary.** Any user in
|
||||||
|
`account_users WHERE account_id = plugin.owner_account_id` can publish,
|
||||||
|
install, list, and delete that account's privates. No roles, no per-site
|
||||||
|
grants in v1.
|
||||||
|
3. **`scope =` in `plugin.mod` is ignored when `private = true`.** Leave the
|
||||||
|
value alone in the file, emit a warning at publish.
|
||||||
|
4. **`private = true` lives in `plugin.mod`.** First `--private` rewrites the
|
||||||
|
mod file; subsequent `ninja plugin publish` (no flag) continues to publish
|
||||||
|
private. Re-publishing without `private = true` against an existing private
|
||||||
|
plugin is blocked in v1 (no graduation).
|
||||||
|
5. **Active account, gcloud-style.** After `ninja login`, the server returns
|
||||||
|
the user's accounts. The CLI forces a selection (auto-picks if only one),
|
||||||
|
persists it in `creds.json`, and `ninja account set <slug>` changes it
|
||||||
|
later.
|
||||||
|
6. **CMS installer = a new "Private" tab on `/admin/plugins`** listing the
|
||||||
|
plugins owned by the CMS site's account.
|
||||||
|
7. **Publisher dashboard = my.blockninjacms.com** (account portal), not the
|
||||||
|
CMS admin. Lists plugins + versions, shows "installed on N sites" with a
|
||||||
|
drilldown, supports deleting a version or the whole plugin (delete-plugin
|
||||||
|
is disabled while any site has it installed).
|
||||||
|
8. **CLI parity for delete.** `ninja plugin delete @private/<name>` and
|
||||||
|
`ninja plugin delete-version <name> --version=<v>`.
|
||||||
|
9. **Same channel model** as public (`--channel=latest|beta|...`).
|
||||||
|
10. **No tier gating, no quotas, no graduation in v1.**
|
||||||
|
11. **`ninja plugin init --private`** skips the scope prompt and writes
|
||||||
|
`private = true`.
|
||||||
|
12. **`ninja plugin list`** shows a `Public` section then a
|
||||||
|
`Private — account: <name>` section.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
Four layers, four deliverables. Two are landed, two are still TODO.
|
||||||
|
|
||||||
|
| Layer | Repo | Status | Deliverable |
|
||||||
|
|------------------|-------------------------------------|----------|----------------------------------------------------------|
|
||||||
|
| Shared proto | `block/proto` | ✅ | `plugin_registry.proto` carries the union of all surfaces |
|
||||||
|
| Registry core | `block/core` + `orchestrator/backend` | ✅ | Migration, RPC handlers, auth, sqlc |
|
||||||
|
| Developer CLI | `core/cmd/ninja` | ✅ | `--private`, `account` subcommand, list/delete |
|
||||||
|
| CMS site admin | `cms/web` (`/admin/plugins`) | ⏳ TODO | New "Private" tab + backend route |
|
||||||
|
| Publisher portal | `my.blockninjacms.com` | ⏳ TODO | `/private-plugins` dashboard |
|
||||||
|
|
||||||
|
Data flow for the two operations that exist today:
|
||||||
|
|
||||||
|
```
|
||||||
|
publish --private
|
||||||
|
ninja --(gRPC, bearer user token)--> orchestrator.PluginRegistryService.CreatePlugin
|
||||||
|
{ scope_slug:"private", visibility:PRIVATE,
|
||||||
|
active_account_id }
|
||||||
|
authz: requireAccountMembership
|
||||||
|
row: registry_plugins
|
||||||
|
scope_id = @private sentinel
|
||||||
|
owner_account_id = X
|
||||||
|
ninja --(gRPC)--> orchestrator.PluginPublishService.PublishVersion(plugin_id, …)
|
||||||
|
|
||||||
|
CMS install (Private tab) (TODO)
|
||||||
|
cms/web --(cms API)--> cms/backend
|
||||||
|
cms/backend --(X-CMS-Secret, instance_id)--> orchestrator.PluginRegistryService
|
||||||
|
.ResolveInstall(scope_slug="private",
|
||||||
|
plugin_name,
|
||||||
|
version_or_channel,
|
||||||
|
active_account_id)
|
||||||
|
authz: requireAccountMembership
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cross-repo proto consolidation (incidental but load-bearing)
|
||||||
|
|
||||||
|
Building this revealed that `core` and `orchestrator` were independently
|
||||||
|
forking `plugin_registry.proto` and drifting. As part of this work both repos
|
||||||
|
now consume the canonical `block/proto` repo as a git submodule:
|
||||||
|
|
||||||
|
- `core/proto/` is now a submodule of `block/proto`.
|
||||||
|
- `core` only generates `plugin_registry.proto` via
|
||||||
|
`buf generate --path proto/orchestrator/v1/plugin_registry.proto`. Generating
|
||||||
|
the rest of `orchestrator/v1` (accounts, instances, …) would register proto
|
||||||
|
descriptors that the orchestrator's own bindings also register, and panic at
|
||||||
|
startup with "file ... is already registered".
|
||||||
|
- `core/plugin/mod.go` no longer imports the generated proto package. The
|
||||||
|
`VisibilityLabel` helper that previously lived there was the load-bearing
|
||||||
|
leak (every consumer of `core/plugin` transitively pulled in core's
|
||||||
|
bindings). It now lives in `core/cmd/ninja/cmd/plugin.go` as `visibilityLabel`.
|
||||||
|
- The CLI's `ListMyAccountsForCLI` RPC + `MyAccount` message intentionally
|
||||||
|
duplicate `AccountService.ListMyAccounts` in `accounts.proto`. They look
|
||||||
|
like a duplicate but they're not: this one stays in `plugin_registry.proto`
|
||||||
|
so core can resolve account membership without needing `accounts.proto`
|
||||||
|
generated. They carry the minimum the CLI cares about (`id`, `slug`, `name`).
|
||||||
|
Naming is `*ForCLI` to avoid the message-name collision with `AccountService`.
|
||||||
|
|
||||||
|
## Data model
|
||||||
|
|
||||||
|
Migration `backend/sql/migrations/00059_private_plugins.sql`:
|
||||||
|
|
||||||
|
1. `ALTER TABLE registry_scopes ALTER COLUMN owner_user_id DROP NOT NULL` so
|
||||||
|
the `@private` sentinel scope can exist without a user owner.
|
||||||
|
2. Seed the sentinel scope row:
|
||||||
|
`INSERT INTO registry_scopes (id, slug, display_name, owner_user_id)
|
||||||
|
VALUES ('00000000-0000-0000-0000-000000000001', 'private', 'Private', NULL)`.
|
||||||
|
Fixed UUID lets handlers reference it without a lookup.
|
||||||
|
3. `ALTER TABLE registry_plugins ADD COLUMN owner_account_id UUID REFERENCES accounts(id) ON DELETE RESTRICT`.
|
||||||
|
NULL for public plugins.
|
||||||
|
4. Drop the table-wide `UNIQUE (scope_id, name)`. Replace with two partial
|
||||||
|
uniques: `(scope_id, name) WHERE owner_account_id IS NULL` for publics, and
|
||||||
|
`(owner_account_id, name) WHERE owner_account_id IS NOT NULL` for privates.
|
||||||
|
This is what lets two different accounts each have an `@private/myplugin`.
|
||||||
|
5. CHECK constraint `registry_plugins_visibility_consistency`: a row is either
|
||||||
|
public-scope-owned or account-owned, never both.
|
||||||
|
|
||||||
|
Knock-on effect: `OwnerUserID` in sqlc-generated params is now
|
||||||
|
`uuid.NullUUID`. The scope-create code wraps `claims.UserID` accordingly.
|
||||||
|
|
||||||
|
## Proto
|
||||||
|
|
||||||
|
In `proto/orchestrator/v1/plugin_registry.proto` (in the shared repo, all
|
||||||
|
changes landed except the last one which is still local — see Open issues):
|
||||||
|
|
||||||
|
- `enum PluginVisibility` — `UNSPECIFIED`, `PRIVATE`, `UNDER_REVIEW`, `PUBLIC`,
|
||||||
|
`REJECTED`, `TAKEN_DOWN`.
|
||||||
|
- `Plugin.visibility` converted from `string` to `PluginVisibility` (breaking
|
||||||
|
wire change; greenfield so we accepted it).
|
||||||
|
- `Plugin.owner_account_id` (`string`, field 12) — empty for publics.
|
||||||
|
- `CreatePluginRequest` gains `PluginVisibility visibility` and
|
||||||
|
`string active_account_id`.
|
||||||
|
- `GetPluginRequest` gains `string active_account_id` (resolved when
|
||||||
|
`scope_slug = "private"`).
|
||||||
|
- `ResolveInstallRequest` gains `string active_account_id` (same).
|
||||||
|
- New on `PluginRegistryService`:
|
||||||
|
- `ListPrivatePlugins(account_id) -> [PrivatePluginSummary]`
|
||||||
|
- `DeletePrivatePlugin(account_id, plugin_name)`
|
||||||
|
- `DeletePrivatePluginVersion(account_id, plugin_name, version)`
|
||||||
|
- `ListPrivatePluginInstallSites(account_id, plugin_name) -> [PrivatePluginInstallSite]`
|
||||||
|
- New on `PluginAuthService`:
|
||||||
|
- `ListMyAccountsForCLI() -> [MyAccount]` (see the proto-consolidation
|
||||||
|
section above for why this is intentionally not in `accounts.proto`).
|
||||||
|
- New message `MyAccount { id, slug, name }`.
|
||||||
|
- New messages `PrivatePluginSummary { plugin, channel_versions, installed_site_count }`
|
||||||
|
and `PrivatePluginInstallSite { instance_id, site_display_name, installed_version, pinned_channel }`.
|
||||||
|
|
||||||
|
Also folded into the shared proto from the orchestrator's old local-only fork:
|
||||||
|
`PluginScopeService.ListMyPlugins`, `PluginRegistryService.SubmitForReview`,
|
||||||
|
and the full `PluginModerationService` (`ListPendingReviews`,
|
||||||
|
`ApproveSubmission`, `RejectSubmission`, `RequestChanges`).
|
||||||
|
|
||||||
|
## ninja CLI (`core/cmd/ninja`)
|
||||||
|
|
||||||
|
- **`cmd/account.go`** (new) — `ninja account list / set <slug> / show`.
|
||||||
|
- **`cmd/login.go`** — after device-flow approval, calls `ListMyAccountsForCLI`:
|
||||||
|
0 errors, 1 auto-selects, ≥2 prompts via `pickAccountInteractive`. Persists
|
||||||
|
to `creds.json`.
|
||||||
|
- **`internal/creds/creds.go`** — `HostCreds` gains `ActiveAccountID` and
|
||||||
|
`ActiveAccountSlug`. Legacy files load unchanged (`creds_test.go` covers
|
||||||
|
both round-trip and legacy).
|
||||||
|
- **`plugin/mod.go`** — `ModPlugin.Private bool` field; `Coords()` returns
|
||||||
|
`@private/<name>@<version>` when `Private=true` regardless of `Scope`.
|
||||||
|
Exported `PrivateScopeSlug` constant. Tests in `plugin/mod_test.go`.
|
||||||
|
- **`cmd/plugin.go`**:
|
||||||
|
- `newPluginInitCmd`: `--private` flag; skips scope prompt, calls
|
||||||
|
`CreatePlugin(visibility=PRIVATE, active_account_id=…)`, writes
|
||||||
|
`private = true` to `plugin.mod`, omits the `scope` line. Inherits
|
||||||
|
`private = true` from existing mod so re-running `init` can't silently
|
||||||
|
downgrade.
|
||||||
|
- `newPluginPublishCmd`: `--private` flag is sticky — first use rewrites the
|
||||||
|
mod file. Warns when both `scope` and `private = true` are present.
|
||||||
|
Calls `GetPlugin(scope_slug="private", active_account_id=…)` then
|
||||||
|
`PublishVersion`.
|
||||||
|
- `newPluginListCmd`: sectioned output (`Public` via
|
||||||
|
`ListMyScopes`+`GetScope`, `Private — account: <slug>` via
|
||||||
|
`ListPrivatePlugins`). `--public-only` / `--private-only` filters.
|
||||||
|
- `newPluginDeleteCmd` / `newPluginDeleteVersionCmd`: `--yes` confirm.
|
||||||
|
`parsePrivateCoord` accepts either bare name or `@private/<name>` and
|
||||||
|
rejects any other scope so a slip can't delete a public plugin.
|
||||||
|
|
||||||
|
## Orchestrator backend
|
||||||
|
|
||||||
|
`backend/internal/registry/api/private_plugins.go` (new) implements:
|
||||||
|
- `requireAccountMembership(ctx, accountID)` — single authorisation
|
||||||
|
chokepoint. Returns 401 on unauthenticated, 400 on unparseable UUID, 404
|
||||||
|
(deliberately, hides existence) when caller isn't a member.
|
||||||
|
- `ListPrivatePlugins` — fans out to `ListPrivatePluginChannelVersions` and
|
||||||
|
per-plugin `CountPrivatePluginInstalledSites` to compose summaries.
|
||||||
|
- `DeletePrivatePlugin` — blocks while install count > 0
|
||||||
|
(`CountPrivatePluginInstalledSites`).
|
||||||
|
- `DeletePrivatePluginVersion`.
|
||||||
|
- `ListPrivatePluginInstallSites` — drilldown via the
|
||||||
|
`registry_install_events` ⨝ `instances` join. (Caveat: install events are
|
||||||
|
append-only and there's no uninstall event, so "installed on N sites" is
|
||||||
|
the conservative "ever installed" approximation. Documented in the SQL
|
||||||
|
query.)
|
||||||
|
|
||||||
|
`backend/internal/registry/api/plugin.go` gains branches in:
|
||||||
|
- `CreatePlugin` — `slug=="private"` or `visibility=PRIVATE` →
|
||||||
|
`CreatePrivateRegistryPlugin` under the sentinel scope.
|
||||||
|
- `GetPlugin` — `slug=="private"` → `GetPrivateRegistryPluginByAccountAndName`.
|
||||||
|
- `ResolveInstall` — same.
|
||||||
|
|
||||||
|
`backend/internal/registry/api/converters.go` — `visibilityToProto(string)`
|
||||||
|
maps the DB text column to the new `PluginVisibility` enum; unknown values
|
||||||
|
fall through to `UNSPECIFIED`.
|
||||||
|
|
||||||
|
`backend/internal/registry/auth/device.go` — `ListMyAccountsForCLI` returns
|
||||||
|
`MyAccount` rows from a new sqlc query `ListAccountsForUser`.
|
||||||
|
|
||||||
|
`backend/internal/rbac/interceptor.go` — RBAC entries added: every new private
|
||||||
|
RPC is `RoleUser` (handlers do the per-account membership check on top).
|
||||||
|
`ListMyAccountsForCLI` is also `RoleUser`.
|
||||||
|
|
||||||
|
sqlc queries added to `backend/sql/queries/registry_plugins.sql`:
|
||||||
|
- `CreatePrivateRegistryPlugin`, `GetPrivateRegistryPluginByAccountAndName`,
|
||||||
|
`ListPrivateRegistryPluginsForAccount`,
|
||||||
|
- `DeletePrivateRegistryPlugin`, `DeletePrivateRegistryPluginVersion`,
|
||||||
|
- `ListPrivatePluginChannelVersions`,
|
||||||
|
- `CountPrivatePluginInstalledSites`,
|
||||||
|
- `ListPrivatePluginInstalledSites`.
|
||||||
|
|
||||||
|
And to `backend/sql/queries/account_users.sql`:
|
||||||
|
- `ListAccountsForUser` — accounts the user is an accepted member of, with
|
||||||
|
role.
|
||||||
|
|
||||||
|
## CMS site admin UI (TODO)
|
||||||
|
|
||||||
|
`cms/web/routes/admin/plugins.tsx` — add a "Private" tab. The tab fetches via
|
||||||
|
a new `cms/backend` route that calls `orchestrator.PluginRegistryService
|
||||||
|
.ListPrivatePlugins(account_id=<site's account>)` with `X-CMS-Secret`. Same
|
||||||
|
card layout as Browse, with an Install button that calls
|
||||||
|
`installFromRegistry` with `scope_slug:"private"` and the site's
|
||||||
|
`active_account_id`.
|
||||||
|
|
||||||
|
Note: `ResolveInstall` already supports the private branch; the CMS-side
|
||||||
|
plumbing is what's missing.
|
||||||
|
|
||||||
|
## Publisher dashboard (TODO)
|
||||||
|
|
||||||
|
`my.blockninjacms.com/account/<slug>/private-plugins`. Auth: existing user
|
||||||
|
JWT; gate on `IsAccountMember`. Sections:
|
||||||
|
- List of plugins (display name, version count, "installed on N sites").
|
||||||
|
- Per-plugin drawer with versions per channel, "installed on" drilldown
|
||||||
|
(`ListPrivatePluginInstallSites`), delete-version buttons.
|
||||||
|
- Plugin-level delete button, disabled with tooltip when installs > 0.
|
||||||
|
|
||||||
|
## Out of scope for v1
|
||||||
|
|
||||||
|
- Graduating a private plugin to public (or vice versa). Hard error if
|
||||||
|
attempted.
|
||||||
|
- Per-site grants inside an account (every site in the account sees every
|
||||||
|
private plugin owned by the account).
|
||||||
|
- Subscription/tier gating; quotas. A `private_plugin_limit` column on plans
|
||||||
|
is left as a placeholder for later.
|
||||||
|
- Editing display_name / description / categories without a version bump.
|
||||||
|
- Audit log of who published / installed / deleted.
|
||||||
|
- Private plugin "homepage_url" UI.
|
||||||
|
- Server-rendered registry browse page for privates (CLI + CMS + publisher
|
||||||
|
dashboard cover all consumption surfaces).
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
Build/test gates (all green):
|
||||||
|
- `core`: `go build ./... && go test ./...` clean. Plugin mod and CLI tests:
|
||||||
|
`TestParseModFull_PrivateField`, `TestParseModFull_PrivateDefaultsFalse`,
|
||||||
|
`TestCoords_PrivateOverridesScope`, `TestCoords_PrivateNoScope`,
|
||||||
|
`TestWriteMod_PrivateTrueSerializes`, `TestWriteMod_PrivateFalseOmitted`,
|
||||||
|
`TestParsePrivateCoord`, `TestHostCreds_ActiveAccountRoundTrip`,
|
||||||
|
`TestHostCreds_LegacyFileLoadsWithoutAccount`.
|
||||||
|
- `orchestrator/backend`: `go build ./... && go test ./...` clean — 22/22
|
||||||
|
packages including `internal/registry/api`, `internal/registry/auth`,
|
||||||
|
`internal/server`, `internal/rbac`.
|
||||||
|
|
||||||
|
End-to-end manual flow once CMS + dashboard land:
|
||||||
|
|
||||||
|
1. **Account selection.** `ninja login` against a user with two accounts →
|
||||||
|
picker fires, choice persists. `ninja account show` confirms.
|
||||||
|
`ninja account set <slug>` switches.
|
||||||
|
2. **Publish.** `ninja plugin init --private` in a fresh dir → scope prompt
|
||||||
|
skipped, `plugin.mod` has `private = true`. `ninja plugin publish --private` →
|
||||||
|
coord `@private/myplugin@0.1.0`. Same name re-published from a different
|
||||||
|
account succeeds (unique key is `(account_id, name)`).
|
||||||
|
3. **CMS install.** Private tab shows the plugin on the publisher's account's
|
||||||
|
site; empty on a site in a different account. Direct API attempt with
|
||||||
|
another account's `X-CMS-Secret` → 403.
|
||||||
|
4. **Dashboard.** Lists plugin, shows "installed on 1 site", drilldown lists
|
||||||
|
that site + pinned version. Delete-plugin disabled until site uninstalls.
|
||||||
|
Delete-version on a non-installed version succeeds; CLI `plugins list`
|
||||||
|
reflects it.
|
||||||
|
5. **CLI parity.** `ninja plugin list` shows sections; `delete-version` and
|
||||||
|
`delete` work with `--yes`; server returns `FAILED_PRECONDITION` when
|
||||||
|
install count > 0.
|
||||||
|
|
||||||
|
## Open issues / followups
|
||||||
|
|
||||||
|
- **`ResolveInstallRequest.active_account_id` is uncommitted on the proto
|
||||||
|
side.** The shared `block/proto` working tree currently sits on
|
||||||
|
`feat/plugin-latest-version` with a separate WIP commit, plus many
|
||||||
|
`go_package` renames (`block/ninja` → `block/cms`) across the whole tree.
|
||||||
|
Once that branch lands, we cycle: push the proto change → tag `core`
|
||||||
|
v0.12.x → bump `orchestrator`'s submodule and core pin → push.
|
||||||
|
- **Submodule pointer drift.** Both `core` and `orchestrator` have the shared
|
||||||
|
proto as a submodule; pinning is independent. Distribution via
|
||||||
|
`make release` + `make distribute-sdk` in core already covers go.mod bumps;
|
||||||
|
submodule pointer updates are still manual.
|
||||||
|
- **`InstallFromRegistry` (cms/backend → orchestrator).** This is the CMS-side
|
||||||
|
entry point that delegates to `ResolveInstall`. It already exists for the
|
||||||
|
public path; needs the private branch (pass `scope_slug="private"` +
|
||||||
|
`active_account_id` from the instance's account) when the CMS Private tab
|
||||||
|
ships.
|
||||||
|
- **Install-tracking is approximate.** `registry_install_events` is append-
|
||||||
|
only, so the "N sites" count and dashboard drilldown count
|
||||||
|
ever-installed-and-still-existing sites, not currently-installed sites. A
|
||||||
|
v2 could add an `instance_plugins.registry_version_id` FK so we have a
|
||||||
|
precise current-install signal.
|
||||||
@ -2,6 +2,7 @@ package pongo
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"maps"
|
||||||
|
|
||||||
"github.com/flosch/pongo2/v6"
|
"github.com/flosch/pongo2/v6"
|
||||||
|
|
||||||
@ -91,9 +92,7 @@ func (e *Engine) buildPageContext(ctx context.Context, doc map[string]any) pongo
|
|||||||
// Content fields are available directly; request context is under "ctx".
|
// Content fields are available directly; request context is under "ctx".
|
||||||
func buildBlockContext(ctx context.Context, content map[string]any) pongo2.Context {
|
func buildBlockContext(ctx context.Context, content map[string]any) pongo2.Context {
|
||||||
pongoCtx := make(pongo2.Context, len(content)+1)
|
pongoCtx := make(pongo2.Context, len(content)+1)
|
||||||
for k, v := range content {
|
maps.Copy(pongoCtx, content)
|
||||||
pongoCtx[k] = v
|
|
||||||
}
|
|
||||||
if bc := blocks.GetBlockContext(ctx); bc != nil {
|
if bc := blocks.GetBlockContext(ctx); bc != nil {
|
||||||
pongoCtx["ctx"] = bc.ToMap()
|
pongoCtx["ctx"] = bc.ToMap()
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"io"
|
"io"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
|
"maps"
|
||||||
|
|
||||||
"github.com/flosch/pongo2/v6"
|
"github.com/flosch/pongo2/v6"
|
||||||
|
|
||||||
@ -91,12 +92,8 @@ func (e *Engine) MustBlockTemplateWithDefaults(name string, defaults map[string]
|
|||||||
tpl := pongo2.Must(e.set.FromFile(name))
|
tpl := pongo2.Must(e.set.FromFile(name))
|
||||||
return func(ctx context.Context, content map[string]any) string {
|
return func(ctx context.Context, content map[string]any) string {
|
||||||
merged := make(map[string]any, len(defaults)+len(content))
|
merged := make(map[string]any, len(defaults)+len(content))
|
||||||
for k, v := range defaults {
|
maps.Copy(merged, defaults)
|
||||||
merged[k] = v
|
maps.Copy(merged, content)
|
||||||
}
|
|
||||||
for k, v := range content {
|
|
||||||
merged[k] = v
|
|
||||||
}
|
|
||||||
pongoCtx := buildBlockContext(ctx, merged)
|
pongoCtx := buildBlockContext(ctx, merged)
|
||||||
out, err := tpl.Execute(pongoCtx)
|
out, err := tpl.Execute(pongoCtx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user